/** * Content-addressed attachment storage for CLEO tasks.db. * * Blobs are stored on disk at: * `.cleo/attachments/sha256//.` * * The SQLite `attachments` + `attachment_refs` tables (tasks.db) act as the * registry and ref-count ledger. Two `put` calls with identical bytes produce * one row and one file — the ref-count increments rather than duplicating. * * This module has NO dependency on the `llmtxt` npm package — it uses Node's * built-in `crypto.createHash('sha256')` for hashing and plain `node:fs` * for storage. * * @epic T760 * @task T796 */ import type { Attachment, AttachmentMetadata, AttachmentRef } from '@cleocode/contracts'; import { getDb } from './sqlite.js'; import { type AttachmentLifecycleStatus } from './tasks-schema.js'; /** * Error thrown when a retrieved blob's SHA-256 hash does not match * the expected value stored in metadata. * * This indicates possible disk corruption or that the wrong file * was stored at the expected path. */ export declare class AttachmentIntegrityError extends Error { readonly expectedSha256: string; readonly actualSha256: string; readonly path: string; /** * @param expectedSha256 - The SHA-256 stored in the attachment metadata * @param actualSha256 - The SHA-256 computed from the retrieved bytes * @param path - The file path where the mismatch was detected */ constructor(expectedSha256: string, actualSha256: string, path: string); } /** * Discriminated union for the result of a `deref` operation. */ export type DerefResult = { status: 'not-found'; } | { status: 'derefd'; refCountAfter: number; } | { status: 'removed'; }; /** * Error thrown by {@link AttachmentStore.put} when an attempt is made to * assign a slug that is already in use elsewhere in the project DB. * * Carries `suggestions` so callers (CLI dispatch) can surface alternative * slugs without re-deriving them. The list is always exactly 3 candidates. * * @task T9636 */ export declare class SlugCollisionError extends Error { readonly slug: string; readonly suggestions: readonly string[]; constructor(slug: string, suggestions: readonly string[]); } /** * Error thrown by {@link AttachmentStore.put} when a writer attempts to * assign a slug that was NOT first reserved through the central * {@link import('../docs/slug-allocator.js').reserveSlug} chokepoint. * * This is a PROGRAMMER ERROR — production CLI verbs must always call * `reserveSlug` before `put({ slug })`. Surfacing the bypass at the * write call protects against dual-writer drift (the bug class * described in T10294 RCA and tracked under Saga T10288 / Epic T10289). * * @task T10392 * @epic T10289 * @saga T10288 */ export declare class SlugNotReservedByAllocatorError extends Error { readonly slug: string; constructor(slug: string); } /** * Side-table extension for {@link AttachmentStore.put} carrying optional * project-scoped metadata (slug, type) that lives directly on the * `attachments` row rather than inside the discriminated-union JSON blob. * * @task T9636 (slug) / T9637 (type) */ export interface PutAttachmentExtras { /** Optional kebab-case slug; uniqueness is enforced at the DB layer. */ slug?: string; /** Optional taxonomy classification; validated upstream. */ type?: string; } /** * Content-addressed attachment store backed by tasks.db + the filesystem. * * All methods accept an optional `cwd` parameter for path resolution — the * same convention used throughout `@cleocode/core`. */ export interface AttachmentStore { /** * Store bytes and register the attachment in tasks.db. * * If an attachment with the same SHA-256 already exists, the existing row is * returned unchanged (content-addressed deduplication). A new * `attachment_refs` row is created for each call so that the caller's owning * entity receives its own ref even when the blob is shared. * * When `extras.slug` is provided it is assigned to the underlying row; * collision with another row's slug throws {@link SlugCollisionError} after * rolling back. When the same SHA-256 is re-put with a slug, the slug is * applied to the existing row (no-op if equal, collision-checked otherwise). * * @param bytes - Content to store (Buffer or UTF-8 string) * @param attachment - Attachment descriptor **without** `sha256` pre-filled * @param ownerType - Entity type for the initial ref (e.g., `"task"`) * @param ownerId - Entity ID for the initial ref (e.g., `"T766"`) * @param attachedBy - Optional agent identity that created the ref * @param cwd - Optional working directory for path resolution * @param extras - Optional per-row metadata (slug, type) — T9636/T9637 * @returns Resolved {@link AttachmentMetadata} (with `sha256` and `id` set) */ put(bytes: Buffer | string, attachment: Omit, ownerType: AttachmentRef['ownerType'], ownerId: string, attachedBy?: string, cwd?: string, extras?: PutAttachmentExtras): Promise; /** * Retrieve a blob's bytes and metadata by SHA-256 hash. * * @param sha256 - 64-character hex SHA-256 digest * @param cwd - Optional working directory for path resolution * @returns `{ bytes, metadata }` or `null` if not found */ get(sha256: string, cwd?: string): Promise<{ bytes: Buffer; metadata: AttachmentMetadata; } | null>; /** * Retrieve attachment metadata by attachment ID. * * @param attachmentId - The `att_<...>` or UUID attachment ID * @param cwd - Optional working directory for path resolution * @returns {@link AttachmentMetadata} or `null` if not found */ getMetadata(attachmentId: string, cwd?: string): Promise; /** * Retrieve attachment metadata + slug + type by slug. * * Returns `null` if no attachment in this project carries the slug. * * `summary` and `lifecycleStatus` (T10158 provenance columns) are * surfaced here so similarity-style callers (T10163) do not have to * round-trip the DB a second time. * * @task T9636 */ findBySlug(slug: string, cwd?: string): Promise<{ metadata: AttachmentMetadata; slug: string; type: string | null; summary: string | null; lifecycleStatus: AttachmentLifecycleStatus; /** Stored display-alias number (T11875), or `null` when unset. */ displayAlias: number | null; } | null>; /** * List ALL attachments in the project DB, optionally filtered by `type`. * * Returns one row per `attachment_refs` entry so callers see how the * attachment was bound to its owner. Use this for `cleo docs list --project`. * * `summary` and `lifecycleStatus` (T10158 provenance columns) are * surfaced as additive fields for callers that need them (T10163). * * @task T9638 */ listAllInProject(cwd?: string, filter?: { type?: string; }): Promise>; /** * Read the slug + type columns for an attachment ID. * * Returns `null` when the row doesn't exist; otherwise returns the raw * column values (each independently nullable). * * @task T9636 / T9637 */ getExtras(attachmentId: string, cwd?: string): Promise<{ slug: string | null; type: string | null; displayAlias: number | null; } | null>; /** * List all attachments associated with a given owner entity. * * @param ownerType - Entity type (e.g., `"task"`) * @param ownerId - Entity ID (e.g., `"T766"`) * @param cwd - Optional working directory for path resolution * @returns Array of {@link AttachmentMetadata} (may be empty) */ listByOwner(ownerType: string, ownerId: string, cwd?: string): Promise; /** * Create an `attachment_refs` row linking an attachment to an owner. * * Also increments `attachments.ref_count`. * * @param attachmentId - ID of the attachment to reference * @param ownerType - Entity type for the ref * @param ownerId - Entity ID for the ref * @param attachedBy - Optional agent identity * @param cwd - Optional working directory for path resolution */ ref(attachmentId: string, ownerType: AttachmentRef['ownerType'], ownerId: string, attachedBy?: string, cwd?: string): Promise; /** * Remove an `attachment_refs` row and decrement `attachments.ref_count`. * * When `ref_count` reaches zero the blob file is deleted from disk and the * `attachments` row is removed. * * @param attachmentId - ID of the attachment to dereference * @param ownerType - Entity type of the ref to remove * @param ownerId - Entity ID of the ref to remove * @param cwd - Optional working directory for path resolution * @returns Discriminated union: * - `{ status: 'not-found' }` if the attachment does not exist * - `{ status: 'derefd', refCountAfter: N }` when refCount decreased but blob remains * - `{ status: 'removed' }` when the blob was purged (refCount → 0) */ deref(attachmentId: string, ownerType: string, ownerId: string, cwd?: string): Promise; } /** * Drizzle DB type used internally — matches the singleton returned by getDb(). * Declared loosely (`Awaited>`) so the helper stays * forward-compatible if the schema-strictness option changes upstream. */ type DrizzleDb = Awaited>; /** * Re-export of {@link deriveSlugSuggestions} for the central slug * allocator (T10392). * * Lives in this module rather than the allocator so the suggestion * algorithm has exactly ONE implementation. Both the late-bound * `SlugCollisionError` path inside `attachmentStore.put` and the * early-bound `reserveSlug` chokepoint use the same helper, so the * suggestion shape is uniform regardless of which layer caught the * conflict. * * Loose typing on `db` matches the internal `DrizzleDb` alias so * cross-package consumers do not have to import drizzle types. * * @task T10392 * @internal */ export declare function deriveSlugSuggestionsForAllocator(db: DrizzleDb, base: string): Promise; /** * Create a concrete {@link AttachmentStore} instance. * * The store is stateless — each method opens the tasks.db singleton via * `getDb(cwd)` for consistency with the rest of `@cleocode/core`. * * @example * ```ts * const store = createAttachmentStore(); * const meta = await store.put( * Buffer.from('# Hello'), * { kind: 'blob', storageKey: '', mime: 'text/markdown', size: 7 }, * 'task', 'T796', * ); * ``` */ export declare function createAttachmentStore(): AttachmentStore; /** * Which backend persisted the attachment. * * As of T11141 only `llmtxt` is supported by the mirror store; the `legacy` * variant is kept because the docs operation contracts still expose it. */ export type AttachmentBackend = 'llmtxt' | 'legacy'; /** * Input descriptor for {@link AttachmentBlobStore.put}. */ export interface AttachmentFileInput { /** User-visible name, for example `design.png`. Must not contain path separators. */ readonly name: string; /** Raw bytes. */ readonly data: Uint8Array; /** Optional IANA MIME type. Defaults to `application/octet-stream`. */ readonly contentType?: string; } /** * Result returned from {@link AttachmentBlobStore.put}. */ export interface AttachmentPutResult { /** Unique attachment id as minted by the active backend. */ readonly attachmentId: string; /** Lowercase hex SHA-256 digest. */ readonly sha256: string; /** Which backend persisted these bytes. Always `llmtxt` as of T11141. */ readonly backend: AttachmentBackend; } /** * Entry returned from {@link AttachmentBlobStore.list}. */ export interface AttachmentListEntry { /** Unique attachment id. */ readonly attachmentId: string; /** User-visible name. */ readonly name: string; /** Lowercase hex SHA-256 digest. */ readonly sha256: string; } /** * Retrieved attachment from {@link AttachmentBlobStore.get}. */ export interface AttachmentGetResult { /** Raw bytes. */ readonly data: Uint8Array; /** User-visible name. */ readonly name: string; /** IANA MIME type when known. */ readonly contentType?: string; } /** * Minimal llmtxt-backed mirror contract used by docs operations. */ export interface AttachmentBlobStore { /** Persist bytes for an owner and return backend metadata. */ put(taskId: string, file: AttachmentFileInput): Promise; /** Retrieve bytes by attachment id, or `null` when the id is unknown. */ get(attachmentId: string): Promise; /** List active attachments for an owner. */ list(taskId: string): Promise; /** Soft-delete an attachment by id. Unknown ids are ignored. */ remove(attachmentId: string, taskId?: string): Promise; } /** * Options for {@link createAttachmentBlobStore}. */ export interface CreateAttachmentBlobStoreOptions { /** Reserved compatibility field; callers should omit options. */ readonly __reserved?: never; } /** * Resolve the attachment backend used for new mirror-store writes. * * The legacy fallback was retired in T11141, so this always returns `llmtxt`. */ export declare function resolveAttachmentBackend(): Promise; /** * Construct the llmtxt-backed attachment mirror store. * * The returned store lazily opens the llmtxt backend on first use. If * `llmtxt/blob` plus Node's SQLite support are unavailable, operations throw * rather than silently falling back to the legacy tasks.db store. * * @param projectRoot Absolute project root used for the llmtxt manifest. */ export declare function createAttachmentBlobStore(projectRoot: string): AttachmentBlobStore; export {}; //# sourceMappingURL=attachment-store.d.ts.map