/** * Attachment content search (#1575): the text an attachment's bytes carry, extracted off * the upload by a job and indexed in the scope, keyed by attachment id. * * `ctx.search` answers over an entity's declared columns. An attachment had nothing but * its filename there, because nothing ever read its bytes. This is the reading, and the * place the result lands. It is kernel-owned for the reasons the entity index is (see * `search-index.ts`): no module may write `_substrat_attachments`'s neighbourhood, a * kernel-maintained index cannot be desynchronised by a writer that forgets it, and the * permission gate is already the kernel's. * * ## The flow * * 1. **Upload** writes the attachment row, a `pending` text row and a job run * (`ATTACHMENT_TEXT_MODULE` / `ATTACHMENT_TEXT_JOB`, instance = the attachment id), all * in the upload's own transaction. Nothing is extracted on the upload path, so an * extraction failure cannot fail an upload: the bytes and the record have landed * whatever happens next. * 2. **The job driver** (`runDueJobs`, which the scope sweeper calls when a deployment * opts into `runJobs`) runs `attachmentTextJob`: read the record, read the bytes, run * the host's extractor for its type (K-43), write the outcome. Each adapter supplies * the handler itself for this key, so no deployment has to register it. * 3. **Search** is `ScopeAttachments.search`, authorized before it matches (below). * * ## Reading the bytes needs no grant, and that is deliberate * * Extraction is a derivation the kernel owns, like the entity index's triggers reading a * module's rows. Reading through `getSystemAttachments` instead would need * `system:` to hold every target's read key, and a CP-less vertical has no way to * seat that grant short of declaring a schedule it does not have. The gate that decides * what a PERSON learns is at search time, and it is the same check `list` and `open` make. * * ## The search gate, and what it refuses to leak * * A search AUTHORIZES FIRST, from the scope and the caller alone, and only then matches * (`searchAttachments`). The owners the caller may read — the check `open` makes, the * target's `readPermission` on the owning entity — are decided before the term is looked * at, and handed to the query, so `ORDER BY … LIMIT` runs over readable rows only. A match * the caller cannot open neither appears nor takes a slot, however many there are: the * page a caller gets is the page they would get if those attachments did not exist. And * because nothing is examined and then dropped, there is no scan bound whose cutoff — or * whose running time — depends on hidden rows. Three more choices follow from the same * rule, each because the alternative would leak. * * - **No count and no "more" marker.** Either would count matches the caller cannot see. * - **No score, and newest-first order instead of relevance.** FTS5's `bm25` weighs a term * by how many documents in the WHOLE index contain it, including the ones the caller * cannot open. Exposing the score, or ordering a multi-term result by it, would let a * caller infer how often a term occurs in attachments they may not read. Ordering by * attachment id (a ULID, so upload time) depends only on rows the caller can see. * - **No snippet.** A snippet is text from the attachment, and producing one for a denied * row would be the leak itself. * * The bound that remains is on authorization, not on matches: a caller without scope-level * read on a target type has its owners checked one by one, at most * `ATTACHMENT_SEARCH_OWNER_MAX` of them, and past that the search is refused with a stable * reason (`ATTACHMENT_SEARCH_TOO_MANY_OWNERS`). Whether it refuses depends on the scope's * owner count and the caller's rights — the same answer for every term. What still scales * with every match is the FTS index's own read of its posting lists, which is the same work * whoever asks. * * ## Where the text lives, and where it does not go * * Both tables carry the `_substrat_search_` prefix, which is what keeps them out of every * scope dump (`isSearchIndexTable`). That is the point, not a convenience: a dump carries * an attachment's METADATA row and never its bytes, so the text extracted from those bytes * stays where the bytes are. A restore or a preview carry that brings an attachment row * back re-queues its extraction (`reconcileAttachmentText`), which re-reads the bytes if * they are still there — the same scope — and records a legible failure where they are * not, as in a fork into a new scope. The lake streams the outbox only, so the text never * reaches it either. * * Erasure: `shredSubject` does not reach attachments — they carry no subject and their * bytes are not sealed per subject — so there is no shred path for the text to outlive. * What does make bytes unreadable is deleting the attachment row (`remove`, or any other * delete), and a trigger on `_substrat_attachments` removes the text row with it, which * takes its index entries out through the index's own triggers. */ import { type AttachmentRecord, type Decision, type EntityRef, type ModuleId, type PermissionKey } from '@substrat-run/contracts'; import type { JobHandler } from './job-run.js'; import { type ScopedSql } from './scope-host.js'; import { type AttachmentExtractor, type AttachmentTextBounds, type ExtractionOutcome } from './attachment-extractor.js'; /** The job's module: the kernel itself, which owns the index. Parses as a `ModuleId`. */ export declare const ATTACHMENT_TEXT_MODULE: ModuleId; /** The job's name. With the module, the key every adapter supplies a handler for. */ export declare const ATTACHMENT_TEXT_JOB = "attachment-text"; /** The backfill's job name: queues extraction for attachments that predate it. */ export declare const ATTACHMENT_TEXT_BACKFILL_JOB = "attachment-text-backfill"; /** The kernel's own jobs, each bound by the adapter to the scope it is driving. */ export interface KernelJobHandlers { readonly [ATTACHMENT_TEXT_JOB]: JobHandler; readonly [ATTACHMENT_TEXT_BACKFILL_JOB]: JobHandler; } /** * The kernel's handler for a run, or undefined for a run that is not the kernel's — which the * adapter's own registry answers. The one place a kernel job is dispatched, so a new one is * a kernel change and an entry in each adapter's handler map, never a branch in its drive. */ export declare function kernelJobFor(run: { module_id: string; job: string; }, handlers: KernelJobHandlers): { handler: JobHandler; } | undefined; /** Attachments one backfill pass looks at — the bound on what a pass writes, too. */ export declare const ATTACHMENT_TEXT_BACKFILL_BATCH = 200; /** * Refuse a job registered under the kernel's own module id — every adapter's `registerJob` * calls this first. * * The kernel's jobs are each host's own: it supplies their handlers at dispatch, bound to * the scope it is driving, and dispatch looks there before the registry. A handler * registered under the kernel's id would therefore never run, and say nothing — the one * outcome a registration must not have. The whole id is reserved, not only today's job * name, so a later kernel job cannot be shadowed by a registration that predates it. */ export declare function assertJobRegistrable(moduleId: string, name: string): void; /** * The text rows and their index, as both adapters build them. * * Shared for the reason `JOB_RUN_DDL` is: `lint:spine-ddl` compares what each adapter's * `KERNEL_DDL` executes, and one definition keeps the two the same shape. * * **The double underscore is a reservation.** A module's derived index is named * `_substrat_search__`, and `slug` can never produce a leading * underscore, so no module's index can ever collide with these two names. * * **External content.** The index stores terms and points at the text row's `rid`; the * text is stored once, in the row. The three triggers keep the index in step with the * row however it is written, and the fourth removes the row when its attachment goes. * Interpolated into each adapter's `KERNEL_DDL` after `_substrat_attachments`, which the * fourth trigger names. * * **Only a body reaches the index.** Most rows never carry one — every upload starts * `pending`, and an image or a PDF stays bodiless — so the index triggers are guarded on * it: a NULL body would index no term and still cost the index's own bookkeeping writes, * inside the upload's transaction. An update that leaves the body as it was (a replayed * extraction) re-tokenizes nothing. */ export declare const ATTACHMENT_TEXT_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_search__attachment_text (\n -- The rowid alias the index points at. Never exposed: the attachment id is the key.\n rid INTEGER PRIMARY KEY,\n attachment_id TEXT NOT NULL UNIQUE,\n -- 'pending' | 'indexed' | 'empty' | 'unsupported' | 'failed'. Only 'indexed' has a body.\n status TEXT NOT NULL,\n -- Which extractor ran ('text', 'html', 'docx', 'xlsx', 'pptx'). NULL while pending,\n -- and for a type no extractor reads.\n extractor TEXT,\n -- The extracted text, normalized and capped. NULL unless indexed.\n body TEXT,\n -- UTF-8 bytes of body. NULL unless indexed.\n body_bytes INTEGER,\n -- 1 when body was cut at the per-attachment bound.\n truncated INTEGER NOT NULL DEFAULT 0,\n -- Why there is no body: the unsupported type, the damaged file, the bound exceeded.\n -- Never quotes the file.\n detail TEXT,\n updated_at TEXT NOT NULL\n );\n CREATE VIRTUAL TABLE IF NOT EXISTS _substrat_search__attachments USING fts5(\n body, content='_substrat_search__attachment_text', content_rowid='rid', tokenize='unicode61'\n );\n CREATE TRIGGER IF NOT EXISTS _substrat_search__attachment_text_ai\n AFTER INSERT ON _substrat_search__attachment_text WHEN new.body IS NOT NULL BEGIN\n INSERT INTO _substrat_search__attachments(rowid, body) VALUES (new.rid, new.body);\n END;\n CREATE TRIGGER IF NOT EXISTS _substrat_search__attachment_text_ad\n AFTER DELETE ON _substrat_search__attachment_text WHEN old.body IS NOT NULL BEGIN\n INSERT INTO _substrat_search__attachments(_substrat_search__attachments, rowid, body)\n VALUES ('delete', old.rid, old.body);\n END;\n CREATE TRIGGER IF NOT EXISTS _substrat_search__attachment_text_au\n AFTER UPDATE ON _substrat_search__attachment_text WHEN old.body IS NOT new.body BEGIN\n INSERT INTO _substrat_search__attachments(_substrat_search__attachments, rowid, body)\n SELECT 'delete', old.rid, old.body WHERE old.body IS NOT NULL;\n INSERT INTO _substrat_search__attachments(rowid, body)\n SELECT new.rid, new.body WHERE new.body IS NOT NULL;\n END;\n CREATE TRIGGER IF NOT EXISTS _substrat_attachments_text_ad\n AFTER DELETE ON _substrat_attachments BEGIN\n DELETE FROM _substrat_search__attachment_text WHERE attachment_id = old.id;\n END;\n"; /** Where an attachment's text is. */ export type AttachmentTextStatus = 'pending' | 'indexed' | 'empty' | 'unsupported' | 'failed'; /** An attachment's extraction state, as `readAttachmentText` reports it. */ export interface AttachmentTextState { attachmentId: string; status: AttachmentTextStatus; extractor: string | null; /** UTF-8 bytes indexed; null unless `indexed`. */ bytes: number | null; truncated: boolean; /** Why there is no text — or, for a `failed` derived from the run, the run's last error. */ detail: string | null; updatedAt: string; } /** * Queue one attachment's extraction: a `pending` row and a coalesced run, inside the * caller's transaction. An existing text row is left as it is: re-extracting an indexed * attachment keeps its current text searchable until the new outcome replaces it. */ export declare function enqueueAttachmentText(sql: ScopedSql, attachmentId: string, runId: string, at: string): void; /** * Write one extraction outcome — only while the attachment still exists. * * A run reads the bytes outside any lock, so the attachment can be removed while it * extracts. The remove's trigger has already taken the text row away; an unguarded upsert * would put the text of a deleted attachment straight back. So the existence check and * the write are one statement, and the return says which happened. * * Idempotent: the same outcome written twice leaves one row and one set of index entries, * because an upsert on a present row is an UPDATE, whose trigger replaces the old terms * with the new ones (and touches nothing when the body did not change). */ export declare function recordAttachmentText(sql: ScopedSql, attachmentId: string, outcome: ExtractionOutcome, at: string): boolean; /** * After a restore or a fork: drop text whose attachment the load did not bring back, and * queue extraction for every attachment that has no text row. * * The text tables are not in a dump, so a load leaves them as they were: an in-place * restore keeps the text of attachments that exist on both sides (an attachment id names * one write-once object, so that text is still the text of those bytes), and holds rows * for attachments the restore rewound away, which must go. A fork starts with none. * Every attachment row without text is queued; its run re-reads the bytes, and where they * are not reachable — a fork, whose objects stay under the source scope's key — the * outcome is a `failed` row that says so. */ export declare function reconcileAttachmentText(sql: ScopedSql, mintId: () => string, at: string): { removed: number; queued: number; }; /** * Start the scope's backfill (#1575) — once in its life, and only once it holds attachments. * * An attachment uploaded before extraction existed has no text row, so nothing ever queued * it and nothing would: it stayed unsearchable, and `readAttachmentText` answered null for * it forever. The backfill finds those. It is a kernel job like extraction, so the work rides * the job driver, off every request: this function only writes the run, and the driver's * passes walk the attachments `ATTACHMENT_TEXT_BACKFILL_BATCH` at a time. * * **The run row is the marker.** Runs are retained, so a scope whose backfill ever started * never starts another — done, failed or still running. Every upload since extraction * existed writes its own text row, and a restore or a fork reconciles its own * (`reconcileAttachmentText`), so one walk is all a scope ever needs. A scope with no * attachments yet is not marked: an empty scope has nothing to walk, and the first upload * after it is marked costs one walk over rows that all have text. * * Read before it writes, so the drive that calls it on every pass writes nothing once the * scope is marked: two index probes. Returns whether it started the run. */ export declare function startAttachmentTextBackfill(sql: ScopedSql, runId: string, at: string): boolean; /** What one backfill batch did. `last` is the cursor the next batch resumes after. */ export interface AttachmentTextBackfillBatch { readonly scanned: number; readonly queued: number; readonly last: string | null; /** True when the batch reached the end of the attachments. */ readonly done: boolean; } /** * One backfill batch, inside the caller's transaction: the next `batch` attachments after * `after`, by id, and an extraction queued for each that has no text row. Bounded by what it * LOOKS AT, not by what it queues, so a pass over attachments that all have text costs the * same as one that queues every row. Idempotent: a batch replayed after a crash finds the * rows it queued and queues nothing twice. */ export declare function queueAttachmentTextBackfill(sql: ScopedSql, after: string | null, mintId: () => string, at: string, batch?: number): AttachmentTextBackfillBatch; /** * The backfill job: one batch per pass, the cursor the last attachment id it looked at. The * batch commits on its own and the cursor with the pass, so a pass that dies between the two * replays a batch that queues nothing twice (`queueAttachmentTextBackfill`). */ export declare function attachmentTextBackfillJob(source: { queueBatch(after: string | null): Promise; }): JobHandler; /** * An attachment's extraction state, or null when none was ever recorded — an attachment * uploaded before extraction existed, or one a fork carried without its text. * * Reads the spine through `ctx.sql`, which module code may (it may never write it). Like * `readTimeline`, it checks no permission: the caller does, first, as it would before * `open`. * * A `pending` row whose latest run has FAILED is reported as `failed`, with the run's * error. That run will not be retried, so "pending" would be a promise nobody keeps; the * row itself is left alone so a re-queue picks it up. */ export declare function readAttachmentText(ctx: { readonly sql: Pick; }, attachmentId: string): AttachmentTextState | null; /** * The most owners a search will check one by one, for a caller without scope-level read * on their type. Past it the search is REFUSED (`ATTACHMENT_SEARCH_TOO_MANY_OWNERS`), never * truncated: a truncated owner set would drop readable hits silently, by an order the * caller cannot see. */ export declare const ATTACHMENT_SEARCH_OWNER_MAX = 2000; /** The `forbidden` reason a refused search carries — stable, so a UI can explain it. */ export declare const ATTACHMENT_SEARCH_TOO_MANY_OWNERS = "attachment_search_too_many_owners"; /** The gate a search runs through: the declared targets, and `ctx.check` as the caller. */ export interface AttachmentSearchGate { /** The read key `open` checks, per declared target entity type. */ readonly targets: ReadonlyMap; /** `ctx.check`, as the surface's principal — with no entity, at the scope. */ check(permission: PermissionKey, entity?: EntityRef): Promise; } /** * The owners a narrowed caller is checked over: those of the given types that HAVE text, * one more than the cap so a refusal can tell "at the cap" from "past it". Walks the * `(entity_type, entity_id)` index on `_substrat_attachments`. Params: types (JSON array), * limit. */ export declare const ATTACHMENT_SEARCH_OWNERS_SQL = "SELECT DISTINCT a.entity_type, a.entity_id\n FROM _substrat_attachments a\n JOIN _substrat_search__attachment_text t ON t.attachment_id = a.id\n WHERE t.body IS NOT NULL AND a.entity_type IN (SELECT value FROM json_each(?))\n ORDER BY a.entity_type, a.entity_id\n LIMIT ?"; /** * The match, restricted to readable owners BEFORE the order and the limit. A wide type is * one `IN` over the JSON array of types; a narrowed owner is a row-value `IN` over JSON * pairs, which SQLite answers from an ephemeral index on the subquery rather than per * pair — one bound JSON array, never a `?` per owner, since a Durable Object refuses the * 101st parameter. Params: match, wide types (JSON array), owners (JSON array of * `[entityType, entityId]`), limit. */ export declare const ATTACHMENT_SEARCH_SQL = "SELECT a.id, a.entity_type, a.entity_id, a.filename, a.content_type, a.size,\n a.sha256, a.visibility, a.created_by, a.created_at\n FROM _substrat_search__attachments\n JOIN _substrat_search__attachment_text t ON t.rid = _substrat_search__attachments.rowid\n JOIN _substrat_attachments a ON a.id = t.attachment_id\n WHERE _substrat_search__attachments MATCH ?\n AND (a.entity_type IN (SELECT value FROM json_each(?))\n OR (a.entity_type, a.entity_id) IN\n (SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?)))\n ORDER BY a.id DESC\n LIMIT ?"; /** * Search extracted text: attachments the caller may open, newest first, at most `limit`. * * The readable owners are decided first (`readableOwners`) and handed to the query, so the * `ORDER BY … LIMIT` runs over readable rows only: a match the caller cannot open neither * appears nor takes a slot, however many there are or how new. Nothing is examined and then * dropped, so there is no scan bound whose cutoff could depend on hidden rows. * * The term is judged before anything else, so a too-short term refuses at no cost. The FTS * table is not aliased: its own name is the hidden column `MATCH` reads (`searchQuery`). */ export declare function searchAttachments(sql: Pick, gate: AttachmentSearchGate, term: string, limit: number): Promise; /** One `_substrat_attachments` row, as SELECTed. */ export interface AttachmentRowShape { readonly id: string; readonly entity_type: string; readonly entity_id: string; readonly filename: string; readonly content_type: string; readonly size: number; readonly sha256: string; readonly visibility: string; readonly created_by: string; readonly created_at: string; } /** The metadata fact an attachment row records — parsed, so a malformed row throws here. */ export declare function attachmentRecordOfRow(row: AttachmentRowShape): AttachmentRecord; /** * What the extraction job needs from its adapter: no permission gate on any of it, because * extraction is the kernel's own derivation (this file's header says why). */ export interface AttachmentTextSource { /** The record, or null when the attachment no longer exists. */ record(attachmentId: string): Promise; /** * The bytes, or null when the blob store does not hold them. A THROW is a transient * failure (the store is unreachable) and the run retries; null is a fact about this * attachment and is recorded as such. */ bytes(record: AttachmentRecord): Promise; /** Write an outcome; false when the attachment was removed meanwhile. */ write(attachmentId: string, outcome: ExtractionOutcome): Promise; } /** * The extraction job: one pass per attachment, done when its outcome is written. * * Everything decidable without the bytes is decided first — no extractor for the type, a * recorded size over the input bound — so those files are never fetched. Bytes that are * gone, or that no longer match the recorded hash, are recorded `failed`: retrying cannot * bring them back. Only a throw from the store is retried, by the driver's backoff; what * the EXTRACTOR does, it answers for in its outcome (`runAttachmentExtractor`). * * The extractors are the host's, handed in (K-43): the kernel parses no format itself. * * No `step()`: the pass is one idempotent unit (read, extract, upsert), so a replay after * a crash simply does it again, and a step memo would only park the extracted text in * `_substrat_job_steps`, which a dump carries. */ export declare function attachmentTextJob(source: AttachmentTextSource, extractors: readonly AttachmentExtractor[], bounds?: AttachmentTextBounds): JobHandler; //# sourceMappingURL=attachment-text.d.ts.map