import type { AdapterTransaction, EngineIdentity, StoreAdapter, TransactionOptions } from '@adhd/sox-store-adapter'; import { rebuildTable } from './rebuild-table.js'; export { rebuildTable }; /** * Connection PRAGMAs applied by applySchema(). BUG-SOXGRAPH-002: `busy_timeout` * is deliberately NOT here — the write-contention contract is adapter-owned. * SqliteAdapter sets `PRAGMA busy_timeout = 3000` at connect (sqlite-adapter.ts); * Turso no-ops unknown PRAGMAs and the driver default applies. A second * graph-store-level busy_timeout would clobber the adapter's chosen value on * every applySchema and split the knob between two owners. */ export declare const PRAGMAS: string[]; /** The canonical graph schema. Carries the BL-430 `json_valid` CHECKs. */ export declare const GRAPH_DDL: string; /** * The schema **as it was before BL-430** — no `json_valid` CHECK on any column. * * This is not dead code and it is not a fallback: it is the shape every store * created before 2026-08-04 still has, and it exists so a test can construct a * genuine legacy store rather than pretend one. `stats-bl343-row-resilience.spec.ts` * has to insert `tags = ''` — the BL-342 shape — to prove `memory_stats` stays * up on a store that already contains it. Under the constrained schema that * INSERT is (correctly) rejected, so the fixture pre-creates `node` from this * DDL and lets the store's own `CREATE TABLE IF NOT EXISTS` no-op over it. * * That is deliberately the *same* mechanism by which a real legacy store keeps * its unconstrained schema, so the fixture cannot drift from the population it * stands in for — and it needs no raw-SQL escape hatch, no test-only pragma, * and no ability to defeat the constraint on a store that has it. */ export declare const GRAPH_DDL_PRE_BL430: string; export declare const FTS_DDL = "\nCREATE VIRTUAL TABLE IF NOT EXISTS fts_node USING fts5(content, name, summary,\n content='node', content_rowid='rowid', tokenize='unicode61');\n"; export declare const FTS_TRIGGERS = "\nCREATE TRIGGER IF NOT EXISTS fts_node_ai AFTER INSERT ON node BEGIN\n INSERT INTO fts_node(rowid, content, name, summary)\n VALUES (new.rowid, new.content, new.name, new.summary);\nEND;\nCREATE TRIGGER IF NOT EXISTS fts_node_ad AFTER DELETE ON node BEGIN\n INSERT INTO fts_node(fts_node, rowid, content, name, summary)\n VALUES ('delete', old.rowid, old.content, old.name, old.summary);\nEND;\nCREATE TRIGGER IF NOT EXISTS fts_node_au AFTER UPDATE ON node BEGIN\n INSERT INTO fts_node(fts_node, rowid, content, name, summary)\n VALUES ('delete', old.rowid, old.content, old.name, old.summary);\n INSERT INTO fts_node(rowid, content, name, summary)\n VALUES (new.rowid, new.content, new.name, new.summary);\nEND;\n"; /** * Drizzle-generated create-if-absent DDL for standalone `GraphStore` consumers. * Carries the same BL-430 `json_valid` CHECKs as {@link GRAPH_DDL}, on the same * new-stores-only terms — `IF NOT EXISTS` no-ops against an existing table. * * `NODE_TABLE_DDL` / `EDGE_TABLE_DDL` below deliberately do **not** carry them: * those feed `rebuildTable`, which copies an existing populated table's rows * into a fresh one. A CHECK there would abort the migration on any store that * already holds the BL-342 shape — turning a schema upgrade into an outage on * exactly the stores that need it most. */ export declare const INLINE_MIGRATION_DDL = "\nCREATE TABLE IF NOT EXISTS \"node\" (\n \"rowid\" integer PRIMARY KEY NOT NULL,\n \"uid\" text NOT NULL,\n \"kind\" text NOT NULL,\n \"content\" text,\n \"name\" text,\n \"summary\" text,\n \"topic\" text,\n \"tags\" text CHECK (\"tags\" IS NULL OR json_valid(\"tags\")),\n \"importance\" real DEFAULT 1.0,\n \"confidence\" real,\n \"content_hash\" text,\n \"namespace\" text DEFAULT 'global',\n \"meta\" text CHECK (\"meta\" IS NULL OR json_valid(\"meta\")),\n \"agent_id\" text,\n \"session_id\" text,\n \"source\" text CHECK (\"source\" IN ('message','tool_output','observation','document','reflection','import')),\n \"project_path\" text,\n \"level\" integer,\n \"resume_state\" text,\n \"t_occurred\" text,\n \"t_expires\" text,\n \"t_created\" text NOT NULL,\n \"t_valid\" text,\n \"t_invalid\" text,\n \"is_superseded\" integer DEFAULT 0,\n \"access_count\" integer DEFAULT 0,\n \"last_access\" text,\n \"t_updated\" text\n);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS \"edge\" (\n \"rowid\" integer PRIMARY KEY NOT NULL,\n \"src\" integer NOT NULL REFERENCES \"node\" ON DELETE CASCADE,\n \"dst\" integer NOT NULL REFERENCES \"node\" ON DELETE CASCADE,\n \"rel\" text NOT NULL,\n \"weight\" real DEFAULT 1.0,\n \"confidence\" real,\n \"origin\" text CHECK (\"origin\" IN ('extracted','inferred','user_asserted')),\n \"meta\" text CHECK (\"meta\" IS NULL OR json_valid(\"meta\")),\n \"t_created\" text NOT NULL,\n \"t_expired\" text,\n \"t_valid\" text,\n \"t_invalid\" text\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX IF NOT EXISTS \"node_uid_unique\" ON \"node\" (\"uid\");\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_kind\" ON \"node\" (\"kind\");\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_hash\" ON \"node\" (\"content_hash\");\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_agent\" ON \"node\" (\"agent_id\");\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_session\" ON \"node\" (\"session_id\");\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_validity\" ON \"node\" (\"t_invalid\") WHERE \"t_invalid\" IS NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_importance\" ON \"node\" (\"importance\");\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_temporal\" ON \"node\" (\"t_invalid\", \"t_created\" DESC) WHERE \"t_invalid\" IS NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_topic\" ON \"node\" (\"topic\") WHERE \"topic\" IS NOT NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_project\" ON \"node\" (\"project_path\") WHERE \"project_path\" IS NOT NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_namespace\" ON \"node\" (\"namespace\");\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_expires\" ON \"node\" (\"t_expires\") WHERE \"t_expires\" IS NOT NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_edge_src\" ON \"edge\" (\"src\", \"rel\") WHERE \"t_expired\" IS NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_edge_dst\" ON \"edge\" (\"dst\", \"rel\") WHERE \"t_expired\" IS NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_edge_live\" ON \"edge\" (\"t_invalid\") WHERE \"t_invalid\" IS NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_edge_src_live\" ON \"edge\" (\"src\", \"rel\") WHERE \"t_invalid\" IS NULL;\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_edge_dst_live\" ON \"edge\" (\"dst\", \"rel\") WHERE \"t_invalid\" IS NULL;\n--> statement-breakpoint\nCREATE UNIQUE INDEX IF NOT EXISTS \"ix_edge_unique\" ON \"edge\" (\"src\", \"dst\", \"rel\");\n--> statement-breakpoint\nCREATE INDEX IF NOT EXISTS \"ix_node_kind_live\" ON \"node\" (\"kind\") WHERE \"t_invalid\" IS NULL;\n"; export declare const DEFAULT_NODE_KINDS: readonly ["episode", "entity", "claim", "community", "session", "generic"]; export declare const NODE_TABLE_DDL = "CREATE TABLE node (\n rowid INTEGER PRIMARY KEY,\n uid TEXT UNIQUE NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('episode','entity','claim','community','session','generic')),\n content TEXT,\n name TEXT,\n summary TEXT,\n topic TEXT,\n tags TEXT,\n importance REAL DEFAULT 1.0,\n confidence REAL,\n content_hash TEXT,\n namespace TEXT DEFAULT 'global',\n meta TEXT,\n agent_id TEXT,\n session_id TEXT,\n source TEXT CHECK (source IN ('message','tool_output','observation','document','reflection','import')),\n project_path TEXT,\n level INTEGER,\n resume_state TEXT,\n t_occurred TEXT,\n t_expires TEXT,\n t_created TEXT NOT NULL,\n t_valid TEXT,\n t_invalid TEXT,\n is_superseded INTEGER DEFAULT 0,\n access_count INTEGER DEFAULT 0,\n last_access TEXT,\n t_updated TEXT\n)"; export declare const EDGE_TABLE_DDL = "CREATE TABLE edge (\n rowid INTEGER PRIMARY KEY,\n src INTEGER NOT NULL REFERENCES node ON DELETE CASCADE,\n dst INTEGER NOT NULL REFERENCES node ON DELETE CASCADE,\n rel TEXT NOT NULL CHECK (rel IN ('MENTIONS','SUPPORTS','RELATES_TO','SUPERSEDES','DERIVED_FROM','MEMBER_OF','PART_OF','SAME_AS','ASSIGNED_TO','DEPENDS_ON')),\n weight REAL DEFAULT 1.0,\n confidence REAL,\n origin TEXT CHECK (origin IN ('extracted','inferred','user_asserted')),\n meta TEXT,\n t_created TEXT NOT NULL,\n t_expired TEXT,\n t_valid TEXT,\n t_invalid TEXT\n)"; /** * PKT-61 (BL-442) — the operator open-schema migration's target DDL for `node`. * * Byte-identical to {@link NODE_TABLE_DDL} with only the `CHECK (kind IN (...))` clause removed * — `kind` becomes plain `TEXT NOT NULL`, matching {@link INLINE_MIGRATION_DDL}'s shape. Copied * verbatim from `ensure-check-constraints.bl447.spec.ts`'s `OPEN_SCHEMA_NODE_DDL` (proven-correct * 28-column fixture; see SPEC-PKT-61.md §2.1) rather than derived by regex-stripping the CHECK out * of `NODE_TABLE_DDL` — do not "simplify" this into a derivation, per that spec's explicit ruling. */ export declare const NODE_TABLE_DDL_OPEN = "CREATE TABLE node (\n rowid INTEGER PRIMARY KEY,\n uid TEXT UNIQUE NOT NULL,\n kind TEXT NOT NULL,\n content TEXT,\n name TEXT,\n summary TEXT,\n topic TEXT,\n tags TEXT,\n importance REAL DEFAULT 1.0,\n confidence REAL,\n content_hash TEXT,\n namespace TEXT DEFAULT 'global',\n meta TEXT,\n agent_id TEXT,\n session_id TEXT,\n source TEXT CHECK (source IN ('message','tool_output','observation','document','reflection','import')),\n project_path TEXT,\n level INTEGER,\n resume_state TEXT,\n is_superseded INTEGER DEFAULT 0,\n t_occurred TEXT,\n t_expires TEXT,\n t_created TEXT NOT NULL,\n t_valid TEXT,\n t_invalid TEXT,\n access_count INTEGER DEFAULT 0,\n last_access TEXT,\n t_updated TEXT\n)"; /** * PKT-61 (BL-442) — the operator open-schema migration's target DDL for `edge`. * * Byte-identical to {@link EDGE_TABLE_DDL} with only the `CHECK (rel IN (...))` clause removed — * `rel` becomes plain `TEXT NOT NULL`. Copied verbatim from * `ensure-check-constraints.bl447.spec.ts`'s `OPEN_SCHEMA_EDGE_DDL`; see {@link NODE_TABLE_DDL_OPEN}. */ export declare const EDGE_TABLE_DDL_OPEN = "CREATE TABLE edge (\n rowid INTEGER PRIMARY KEY,\n src INTEGER NOT NULL REFERENCES node ON DELETE CASCADE,\n dst INTEGER NOT NULL REFERENCES node ON DELETE CASCADE,\n rel TEXT NOT NULL,\n weight REAL DEFAULT 1.0,\n confidence REAL,\n origin TEXT CHECK (origin IN ('extracted','inferred','user_asserted')),\n meta TEXT,\n t_created TEXT NOT NULL,\n t_expired TEXT,\n t_valid TEXT,\n t_invalid TEXT\n)"; export declare const NODE_COLUMNS: string[]; export declare const EDGE_COLUMNS: string[]; export declare const NODE_INDEX_DDLS: string[]; export declare const EDGE_INDEX_DDLS: string[]; /** * Bind-variable budget for ONE batched {@link GraphBackend.getEdges} statement. * Every parameter of the statement — the `srcs` / `dsts` id lists, the `rels` * list and any singular / metadata filters — together stays at or under this * value, which is below SQLite's historical 999-variable ceiling (<3.32). Id * lists longer than the remaining budget are split into ascending chunks; * chunk results are concatenated in chunk order, so the result equals one * unchunked read. */ export declare const EDGE_READ_CHUNK = 500; /** * Maximum number of distinct `rels` accepted by one batched read. `rels` is a * filter, not a key list: it is not chunked (chunking a filter would split the * order contract), so it is bounded and a larger list is a * {@link ConstraintError}. Leaves at least `EDGE_READ_CHUNK - EDGE_READ_MAX_RELS` * bind variables per statement for ids. */ export declare const EDGE_READ_MAX_RELS = 64; /** * Query for {@link GraphBackend.getEdges}. Every form reads only live edges * (`t_invalid IS NULL`). * * The singular `src`/`dst`/`rel` and plural `srcs`/`dsts`/`rels` are mutually * exclusive per axis (passing both throws {@link ConstraintError}). A * present-but-empty plural list returns `[]` without executing SQL. * * Batch ordering is contractual: `srcs` (with `dst`, `dsts` or neither) yields * `(src, rel, rowid)` ascending; `dsts` without `srcs` (with `src` or neither) * yields `(dst, rel, rowid)` ascending. `srcs` + `dsts` is the intersection, * ordered `(src, rel, rowid)`. Ids must be non-negative integers * ({@link ConstraintError} otherwise); `rels` holds at most * {@link EDGE_READ_MAX_RELS} distinct values; no statement binds more than * {@link EDGE_READ_CHUNK} variables. */ export interface IEdgeQuery { src?: number; dst?: number; rel?: EdgeRel; metadata?: Record; /** Batch out-keys: edges whose `src` is in the list (uses `ix_edge_src_live`). */ srcs?: readonly number[]; /** Batch in-keys: edges whose `dst` is in the list (uses `ix_edge_dst_live`). */ dsts?: readonly number[]; /** Relation narrowing for any form (ANDed). */ rels?: readonly EdgeRel[]; } export declare class ConstraintError extends Error { constructor(message: string); } export declare class BitemporalConflictError extends Error { readonly nodeId: number; constructor(message: string, nodeId: number); } export declare class NodeNotFoundError extends Error { readonly nodeId: number; constructor(message: string, nodeId: number); } /** * The branded-string widening pattern (ADR-0010 D4, BL-444, BL-448/PKT-74): the ten known rels * still autocomplete in every parameter position (`writeEdge(src, dst, rel: EdgeRel, ...)`, * `getEdges({ rel })`, etc. — `(string & {})` prevents TypeScript from collapsing the union to * bare `string`), while any other string is still assignable — the SQL `rel` CHECK is gone from * the fresh-store DDL (see `graphDdl()`/`INLINE_MIGRATION_DDL` above) and an injected `TypePolicy` * is the only remaining vocabulary gate (PKT-59/BL-440). * * This is source-breaking in RETURN position, not additive: a consumer that exhaustively * `switch`es on `EdgeRecord.rel` (or otherwise narrows `EdgeRel` to `never` in a default arm) * stops compiling once this widens, because the `default` arm's type is no longer `never` — it is * `string & {}`. No in-repo consumer does this today (confirmed by grep against * `libs/memory-core/src` and this package's own `src`), but `@adhd/sox-graph-store` is published * (`private: false`) and BL-444 records this as a real risk for an external consumer. See * `open-rel-check.bl448.spec.ts`'s AC-Type for a demonstrated (not merely asserted) compile break. */ export type EdgeRel = 'MENTIONS' | 'SUPPORTS' | 'RELATES_TO' | 'DERIVED_FROM' | 'SUPERSEDES' | 'SAME_AS' | 'ASSIGNED_TO' | 'MEMBER_OF' | 'PART_OF' | 'DEPENDS_ON' | 'REFUTES' | (string & {}); export type Confidence = 'confirmed' | 'unverified' | 'disputed' | 'deprecated'; export interface NodeMeta { kind?: string; name?: string; summary?: string; topic?: string; tags?: string[]; importance?: number; confidence?: Confidence; source?: string; agentId?: string; projectPath?: string; sessionId?: string; namespace?: string; tOccurred?: string; tExpires?: string; metadata?: Record; } export interface NodeRecord { id: number; /** The stable, exportable UUID (`node.uid`) — distinct from `id` (rowid, per-store). */ uid: string; kind: string; content: string; name?: string; summary?: string; topic?: string; tags: string[]; importance?: number; confidence?: Confidence; tCreated: string; tValid: string; tInvalid?: string; tExpires?: string; isSuperseded: boolean; isStale: boolean; namespace: string; metadata?: Record; } export interface EdgeMeta { weight?: number; metadata?: Record; } export interface EdgeRecord { src: number; dst: number; rel: EdgeRel; weight?: number; tCreated: string; metadata?: Record; } /** * FEAT-014 — the complete metadata filter operator set. A metadata value is an * operator object iff it is a plain object carrying at least one of these keys; * otherwise it is a scalar equality (back-compat). Bounds are compared via * `json_extract`, so ISO-8601 strings sort chronologically and numbers compare * numerically. All present operators on one key AND together. */ export interface MetadataFilter { eq?: MetadataScalar; neq?: MetadataScalar; in?: MetadataScalar[]; gt?: string | number; gte?: string | number; lt?: string | number; lte?: string | number; between?: [string | number, string | number]; /** `exists: true` → IS NOT NULL; `false` → IS NULL. Presence/absence of the key. */ exists?: boolean; /** Array membership (or scalar equality) via `json_each(meta, '$.key')`. */ contains?: MetadataScalar; } export type MetadataScalar = string | number | boolean | null; export type MetadataFilterValue = MetadataScalar | MetadataFilter; /** A sort target: a named node column, or a metadata key resolved via `json_extract`. */ export type SortField = 'importance' | 'tCreated' | 'tValid' | 'name' | { metadata: string; }; export type SortDirection = 'asc' | 'desc'; export interface NodeFilter { ids?: number[]; kind?: string | string[]; topic?: string | string[]; tags?: string[]; tagsMatchAll?: boolean; importanceMin?: number; confidence?: Confidence | Confidence[]; tCreatedAfter?: string; tCreatedBefore?: string; tUpdatedAfter?: string; tUpdatedBefore?: string; validAt?: string; isStale?: boolean; namespace?: string; projectPath?: string; agentId?: string; /** FEAT-011 — filter on the `name` column (equality, or `IN` when an array). */ name?: string | string[]; /** FEAT-010 — include invalidated (soft-deleted) nodes. Default `true`. */ liveOnly?: boolean; /** * Filter on the `is_superseded` column — `false` selects only current * rows, `true` only superseded ones. **Omit it and no clause is emitted, * so superseded rows are returned**: that is the long-standing default and * the behaviour supersession-chain readers depend on. Opt-in, exactly as * {@link NodeFilter.isStale} is. * * `is_superseded` is an axis INDEPENDENT of `t_invalid`/`liveOnly`. A * content edit that supersedes a row sets `is_superseded = 1` and * deliberately leaves `t_invalid` NULL, so a superseded row is still * "live" by `liveOnly` alone. A consumer listing current records must * therefore ask for BOTH — `{ liveOnly: true, isSuperseded: false }`. * Filtering superseded rows out in application code instead is NOT * equivalent: it cannot correct `countNodes`, and it breaks keyset paging, * which slices a `limit + 1` fetch and so would return short pages. */ isSuperseded?: boolean; metadata?: Record; /** Single sort field, or an array for a multi-key ORDER BY. */ orderBy?: SortField | SortField[]; /** Per-key direction; an array aligns by index with `orderBy`. Defaults per field. */ orderDir?: SortDirection | SortDirection[]; limit?: number; offset?: number; /** * FEAT-024 (C) — keyset cursor. When set, the query compiles to * `WHERE rowid > ? ORDER BY rowid ASC` (ignoring `orderBy`/`offset`) for a * stable, O(1)-per-page scan. `offset` is O(offset) and unstable under * concurrent writes; keyset is the pagination primitive. */ after?: number; } export interface GraphBackendCapabilities { bitemporal: boolean; fullTextSearch: boolean; metadataFilter: boolean; } /** * BUG-040 — write options. `skipDedupe` opts a write out of the global * case-insensitive content-hash dedupe (default false, back-compat). Entity * nodes identified by a business key MUST set this: identity is the DB-generated * `uid`, and uniqueness is consumer-declared via {@link NodeUniquenessPolicy} * (FEAT-023, ADR-0010 D2). FEAT-012's global `(kind, name)` index was * reverted — it is not a uniqueness guard. */ export interface WriteNodeOpts { skipDedupe?: boolean; } /** * G1 (FEAT-SOXGRAPH-001) — the transaction-scoped graph handle a * {@link GraphBackend.transaction} callback receives. * * It is a structural SUPERSET of {@link AdapterTransaction}: the raw * `executeGet` / `executeAll` / `executeRun` / `exec` surface is preserved * verbatim (so every existing callback that hand-composes SQL keeps compiling * and behaving identically), and the typed graph primitives below are ADDED, * bound to the SAME live transaction. * * Why this exists: `transaction()` used to hand out only the raw adapter tx, * so a consumer that wanted `writeNode` / `writeEdge` / `touch` inside a * transaction had to either call the bare backend (which routes through * `this.adapter`) or hand-compose the library's own INSERT column lists — * coupling the consumer to the schema. Every typed method here executes on the * transaction, never on the bare adapter, so a check-then-write composed * through `tx` is atomic in ONE `BEGIN IMMEDIATE` (ADR-0012 §1's CAS * primitive). * * Delegation contract (ADR-0010 D2): the write methods delegate to the same * policy-validating internals the bare backend uses (`writeNodeInTx` → * `typePolicy.validateKind` + `uniquenessPolicy.check`; `writeEdgeInTx` → * `typePolicy.validateEdge`/`validateRel`). They never re-derive raw * INSERT/UPDATE — that would bypass the injected policy and reintroduce the * exact column-list coupling this seam removes. * * Not exposed here (deliberately): `searchNodes` / `countNodesFts` are * FTS-backed and have no per-transaction adapter path, so they stay on * {@link GraphBackend} and always read committed state. */ export interface GraphTransaction extends AdapterTransaction { /** Tx-scoped {@link GraphBackend.writeNode}. */ writeNode(content: string, meta: NodeMeta, opts?: WriteNodeOpts): Promise; /** Tx-scoped {@link GraphBackend.findOrCreateNode} — a race-safe check+INSERT when composed here. */ findOrCreateNode(kind: string, name: string, opts?: { content?: string; meta?: NodeMeta; }): Promise; /** Tx-scoped {@link GraphBackend.supersede}. Does NOT open a nested transaction. */ supersede(oldId: number, newContent: string, meta: NodeMeta): Promise; /** Tx-scoped {@link GraphBackend.invalidate}. */ invalidate(nodeId: number, reason?: string): Promise; /** Tx-scoped {@link GraphBackend.touch}. */ touch(nodeId: number, meta: Partial): Promise; /** Tx-scoped {@link GraphBackend.writeEdge}. */ writeEdge(src: number, dst: number, rel: EdgeRel, meta?: EdgeMeta): Promise; /** Tx-scoped {@link GraphBackend.invalidateEdge}. */ invalidateEdge(src: number, dst: number, rel: EdgeRel, reason?: string): Promise; /** Tx-scoped {@link GraphBackend.writeNodeBatch}. Does NOT open a nested transaction. */ writeNodeBatch(nodes: Array<{ content: string; meta: NodeMeta; }>, opts?: WriteNodeOpts): Promise; /** Tx-scoped {@link GraphBackend.writeGraph}. Does NOT open a nested transaction. */ writeGraph(nodes: Array<{ content: string; meta: NodeMeta; }>, edges: Array<{ srcIdx: number; dstIdx: number; rel: EdgeRel; meta?: EdgeMeta; }>, opts?: WriteNodeOpts): Promise; /** Tx-scoped {@link GraphBackend.writeEdges}. Does NOT open a nested transaction. */ writeEdges(edges: Array<{ src: number; dst: number; rel: EdgeRel; meta?: EdgeMeta; }>): Promise; /** Tx-scoped read — sees this transaction's uncommitted writes. */ getNode(id: number): Promise; /** Tx-scoped read — sees this transaction's uncommitted writes. */ getNodeByUid(uid: string): Promise; /** Tx-scoped read — sees this transaction's uncommitted edge writes/invalidations. */ getEdges(opts: IEdgeQuery): Promise; /** Tx-scoped read — sees this transaction's uncommitted writes. */ getNodesByIds(ids: number[], opts?: { liveOnly?: boolean; }): Promise; } export interface GraphBackend { readonly capabilities: GraphBackendCapabilities; /** * (BL-508) Engine identity of the backing store — the `_sox_engine` marker * row (`engine`, `sox_version`, `driver_version`, `first_opened_at`, * `last_opened_at`), or `null` when the store is in-memory or predates the * marker scheme. The open result of `createGraphBackend` carries it so a * graph-store consumer can surface client/engine version tracking without * re-probing. Accessing it runs the foreign-engine guard once per adapter * instance (fail-closed on a marker mismatch for turso-owned stores). */ readonly engineIdentity?: EngineIdentity | null; applySchema(): Promise; writeNode(content: string, meta: NodeMeta, opts?: WriteNodeOpts): Promise; /** FEAT-011 — idempotent find-or-create by business key (kind, name). */ findOrCreateNode(kind: string, name: string, opts?: { content?: string; meta?: NodeMeta; }): Promise; supersede(oldId: number, newContent: string, meta: NodeMeta): Promise; invalidate(nodeId: number, reason?: string): Promise; touch(nodeId: number, meta: Partial): Promise; writeNodeBatch(nodes: Array<{ content: string; meta: NodeMeta; }>, opts?: WriteNodeOpts): Promise; writeGraph(nodes: Array<{ content: string; meta: NodeMeta; }>, edges: Array<{ srcIdx: number; dstIdx: number; rel: EdgeRel; meta?: EdgeMeta; }>, opts?: WriteNodeOpts): Promise; /** * FEAT-024 (A1) — expose atomic multi-operation composition. The v2 "create * issue" is one logical write over existing nodes (writeNode + edges); this * is the mechanism that makes FEAT-023's uniqueness check + multi-op writes * atomic, and it removes the need for a consumer to reach the raw adapter. * * G1 (FEAT-SOXGRAPH-001) — the callback now receives a * {@link GraphTransaction}: the raw `AdapterTransaction` surface PLUS typed, * tx-bound graph primitives (`tx.writeNode` / `tx.writeEdge` / * `tx.invalidateEdge` / `tx.touch` / `tx.getNodeByUid` / …). Because * `GraphTransaction` is a structural superset of `AdapterTransaction`, * existing callbacks (`(tx) => tx.executeRun(...)`) keep compiling and * behaving identically. * * `opts` passes straight through to `adapter.transaction(fn, opts)` — * `{ mode: 'immediate' }` is `BEGIN IMMEDIATE` (the ADR-0012 §1 CAS * primitive; see `store-adapter`'s `TransactionOptions`). Default remains * `'deferred'`. */ transaction(fn: (tx: GraphTransaction) => Promise, opts?: TransactionOptions): Promise; /** FEAT-024 (A2) — set `edge.t_invalid`. Idempotent; `writeEdge` with the same (src,dst,rel) re-livens it. */ invalidateEdge(src: number, dst: number, rel: EdgeRel, reason?: string): Promise; /** FEAT-024 (A3) — bulk edge write between existing nodes in one transaction. */ writeEdges(edges: Array<{ src: number; dst: number; rel: EdgeRel; meta?: EdgeMeta; }>): Promise; getNode(id: number): Promise; /** * Resolve a node by its stable UUID (`node.uid`). `id` (rowid) is per-store and * changes across an export/rebuild; `uid` is the exportable, cross-store stable * identity a consumer can expose. Returns null when no node carries that uid. */ getNodeByUid(uid: string): Promise; /** FEAT-024 (B4) — ordered batch read, one query, in the requested id order (missing/tombstones omitted per `liveOnly`). */ getNodesByIds(ids: number[], opts?: { liveOnly?: boolean; }): Promise; queryNodes(filter?: NodeFilter): Promise; searchNodes(query: string, opts?: { limit?: number; offset?: number; filter?: NodeFilter; }): Promise>; countNodes(filter?: NodeFilter): Promise; /** FEAT-024 (B5) — single GROUP BY over a node column instead of N `countNodes` calls. */ countBy(field: 'kind' | 'namespace' | 'agentId', filter?: NodeFilter): Promise>; countNodesFts(query: string, filter?: NodeFilter): Promise; getSupersessionChain(nodeId: number): Promise; writeEdge(src: number, dst: number, rel: EdgeRel, meta?: EdgeMeta): Promise; getEdges(opts: IEdgeQuery): Promise; getNeighbors(nodeId: number, opts?: { rel?: EdgeRel; depth?: number; direction?: 'in' | 'out' | 'both'; }): Promise; getNeighborsWithEdges(nodeId: number, opts?: { rel?: EdgeRel; depth?: number; direction?: 'in' | 'out' | 'both'; }): Promise>; isReachable(src: number, dst: number, opts?: { rel?: EdgeRel; direction?: 'out' | 'in'; }): Promise; getSubgraph(rootId: number, opts?: { rel?: EdgeRel; depth?: number; direction?: 'out' | 'in' | 'both'; }): Promise<{ nodes: NodeRecord[]; edges: EdgeRecord[]; }>; } export declare const PUBLIC_EDGE_RELS: readonly EdgeRel[]; /** * The rels baked into every store's CHECK (rel IN (...)) clause today — the full ten-member * EdgeRel vocabulary (index.ts:364-374), NOT PUBLIC_EDGE_RELS (which is memory-core's own * 7-member tool-surface subset, unrelated to this constant and untouched by this packet). * This is DEFAULT_TYPE_POLICY's validateRel() vocabulary — see BL-440. */ export declare const DEFAULT_EDGE_RELS: readonly EdgeRel[]; /** * Injected type-vocabulary policy (ADR-0010 D2). graph-store owns no vocabulary of its own — * a TypePolicy is pure in-process validation with NO reference to DDL, rebuildTable, or the * adapter. It is called at the write boundary (writeNode / writeEdgeInTx) and throws * ConstraintError to reject. See BL-440's "no path to DDL" requirement — a TypePolicy * implementation MUST NOT be able to alter the schema under any input. */ export interface TypePolicy { validateKind(kind: string): void; validateRel(rel: string): void; /** * FEAT-013 — OPTIONAL. Validate an edge with its endpoint kinds resolved. * Called by writeEdgeInTx AFTER resolving src/dst kinds. When absent, * the store falls back to `validateRel(rel)` — preserving the current API * and the DEFAULT_TYPE_POLICY closed-vocabulary behavior byte-for-byte. * Purity contract (BL-440 "no path to DDL") remains in force: this is a * pure predicate over three strings — no adapter, no DDL, no I/O. */ validateEdge?(srcKind: string, rel: string, dstKind: string): void; } /** * The default TypePolicy every StoreGraphBackend gets when no typePolicy is supplied. This is * deliberately the CLOSED six-kind / ten-rel vocabulary the CHECK constraints enforce today — * NOT the "syntactic-only" default ADR-0010 D2 describes as graph-store's eventual built-in * default. That eventual shift only becomes safe once every memory-core call site injects its * own MemoryOntologyPolicy explicitly (PKT-60) — until then, a caller supplying no typePolicy * (all 8 memory-core call sites, today) MUST see byte-identical behaviour to pre-PKT-59. Do not * widen this default in this packet. */ export declare const DEFAULT_TYPE_POLICY: TypePolicy; /** Options accepted by createGraphBackend() / the StoreGraphBackend constructor. */ /** * FEAT-021 — dependency-inversion write observer. graph-store DEFINES this * interface and imports nothing for it; a higher-tier composer (the semantic * facade) IMPLEMENTS it to do embed-on-write / delete-on-invalidate. This is the * same DI idiom as TypePolicy — it keeps graph-store base-tier-pure while * letting the embedding lifecycle be owned by the composition layer. Observers * fire inline, right after the write's INSERT/UPDATE (inside the surrounding * transaction for batched writes — a slow observer therefore holds that * transaction open), and their failures degrade (logged) rather than corrupt the * data write. */ export interface GraphWriteObserver { onNodeWritten?(node: NodeRecord, meta: NodeMeta): void | Promise; onNodeInvalidated?(nodeId: number): void | Promise; onNodeUpdated?(node: NodeRecord, changed: Partial): void | Promise; } /** * FEAT-023 — injectable, transaction-scoped uniqueness seam. Replaces * FEAT-012's global `(kind, name)` unique index, which was wrong on two * counts: (1) too broad — it forced "name is identity" on every kind, and the * analysis package's community nodes collided (BUG-042); (2) wrong layer — * relational uniqueness is edge-scoped (component names unique *within* a * project), inexpressible as a column index. * * The policy runs inside `writeNode` BEFORE the INSERT, with read access to the * store's read path (the `tx` handle). Consumers own the semantics: * - flat catalog -> `SELECT … WHERE kind = ? AND name = ?` * - components -> edge-scoped `SELECT` through the `MEMBER_OF`/`owns_project` edge * * **Parallel-process note (ADR-0012).** The store is parallel-process enabled: * multiple processes may hold concurrent write connections to the same * Turso-backed store. A check-then-INSERT is therefore NOT atomic across * processes — the `check` SELECT and the later INSERT commit under separate * writer-slot acquisitions, so a concurrent writer can commit the same * (kind, name) between them. This seam declares *what* is unique, not *how* the * race is closed: the consumer must wrap check+INSERT in one write transaction * or back it with a DDL constraint (BUG-GRAPHSTORE-STALE-SINGLE-WRITER-001). * * A policy rejects a write by throwing {@link ConstraintError}. This is the * same DI idiom as {@link TypePolicy} and {@link GraphWriteObserver}: graph-store * owns no uniqueness semantics of its own — the consumer declares them. */ export interface NodeUniquenessPolicy { check(meta: NodeMeta, tx: AdapterTransaction): Promise; } export interface GraphBackendOpts { /** Injected type-vocabulary policy. Defaults to DEFAULT_TYPE_POLICY (today's six kinds, ten rels). */ typePolicy?: TypePolicy; /** FEAT-021 — write observers, fired after-commit. Additive; default empty. */ observers?: GraphWriteObserver[]; /** FEAT-023 — injectable uniqueness policy, run inside writeNode before the INSERT. Default undefined = the store enforces nothing. */ uniquenessPolicy?: NodeUniquenessPolicy; } export interface FilterClause { where: string; params: unknown[]; } export declare function buildNodeFilterClause(filter: NodeFilter | undefined, liveOnly: boolean, tableAlias: string): FilterClause; export declare class StoreGraphBackend implements GraphBackend { readonly capabilities: GraphBackendCapabilities; private adapter; private schemaApplied; private typePolicy; private observers; private uniquenessPolicy; /** * Whether the underlying engine accepts `WITH RECURSIVE` at prepare. True on * SQLite and Turso Database Rust >= 0.8.0; FALSE on Turso 0.7.x (probed * once at connect by store-adapter, see `AdapterCapabilities.recursiveCte`). * When false, the five recursive-graph methods below switch to iterative * BFS fallbacks that produce the same results (empirically pinned by the * "recursive-cte fallback parity" tests). External adapters that omit the * capability default to true — only Turso 0.7.x sets it false. * * (BL-580, DEBT-003 lazy-connect) This is a LIVE getter, not a * constructor-snapshotted field, deliberately: `TursoAdapterImpl.connect()` * seeds `capabilities.recursiveCte` with a conservative `false` guess on a * never-opened adapter and only corrects it to the real probed value once * the adapter's first real operation completes (`_reconnect()` reassigns * `capabilities` in place — the getter it exposes, `turso-adapter.ts`'s * `get capabilities()`, is itself live). `createGraphBackend()` constructs * this class eagerly, right after `connect()`, before any operation has * run — a constructor-time snapshot (the pre-BL-580 shape) would freeze the * conservative `false` guess for this instance's entire lifetime, forever * forcing the iterative BFS fallback even on a Turso 0.8.x+ store that had * long since corrected `recursiveCte: true`. Reading `adapter.capabilities` * fresh on every access is a plain in-memory field read (no I/O), so there * is no cost to staying live the way there is for `engineIdentity`. */ private get supportsRecursiveCte(); /** (BL-508) Cached engine identity — see the `engineIdentity` getter. */ private _engineIdentity; constructor(adapter: StoreAdapter, opts?: GraphBackendOpts); /** FEAT-021 — run a write-observer callback across all observers, degrading * (logged) on failure rather than corrupting the data write. */ private notifyObservers; /** FEAT-021 — build a minimal-but-valid NodeRecord for observer callbacks * from the write args, without a re-select. */ private buildNodeRecord; /** * (BL-508) The backing store's engine identity (marker row), computed once * per adapter instance and cached. The foreign-engine guard runs on the * FIRST access: a turso-owned store with a SQLite marker FAILS CLOSED here * (typed `ESqliteNativeStore`) — defense-in-depth under the turso adapter's * own connect-time refusal, for direct-adapter consumers. Access cost is * per-connection, never per-op (adapter handles are long-lived — memory-core * caches them in `getDb`). * * (BL-580, DEBT-003 lazy-connect) `TursoAdapterImpl.connect()` is now lazy: * it opens no driver connection and stamps NO `_sox_engine` marker until the * first real operation (see turso-adapter.ts `connect()`'s doc comment). * * (BUG-026) For a TURSO store this getter never resolves identity through * the sync better-sqlite3 path — that readonly open creates the classic * `-shm` on the turso store on every construction (the 'exp9 poisoner' * cross-engine class, engine-guard.ts), which is exactly how the store * became reachable by a foreign engine in the first place. Turso identity * resolution is async-only via `readEngineIdentityViaAdapter` (wired through * memory-core's `getStoreEngineIdentity`); this sync getter returns the * cached identity or `null` (left uncached, per the BL-580 pattern) so an * async resolver can populate it without a poisoner open here. For a * non-turso adapter the sync `getEngineIdentitySync` read remains — its * marker is stamped synchronously at construction/`init()` time, so a `null` * there means a real unmarked legacy store, not "not yet opened". * * This does not weaken the fail-closed MISMATCH guard below: unlike the * marker row, `assertStoreEngineSync` reads the `application_id` SQLite * header via `readApplicationId` — a pure filesystem read independent of * whether `_sox_engine` has been stamped — and `TursoAdapterImpl.connect()` * already runs that identical eager header check before ever returning the * lazy shell (turso-adapter.ts, `connect()`, BL-508 foreign-engine * refusal). A genuine mismatch still refuses at construction time, before * this getter is even reached; only "identity resolution" (which requires * the marker row) is provisional pre-open, never "mismatch detection". */ get engineIdentity(): EngineIdentity | null; applySchema(): Promise; /** * FTS schema creation — A2 (FEAT-SOXGRAPH-001): delegated to the adapter's * `ensureFtsIndex`, which owns the per-backend mechanics (fts5 virtual * table + triggers + backfill on SQLite, Tantivy `CREATE INDEX … USING fts` * on Turso, BL-461 adoption of a non-canonical existing index, legacy * residue cleanup) and gates on `capabilities.fts` itself. graph-store * supplies the schema data (weights + the FTS_DDL/FTS_TRIGGERS constants * that ARE its FTS5 schema) and stays out of the SQL. */ private applyFtsSchema; /** FTS re-sync after a node-table rebuild — same dialect rules as * {@link applyFtsSchema}, executed against the transaction handle. */ private reapplyFtsInTx; private ensureCheckConstraints; /** * (BL-506) Before a schema rebuild moves a table's `sqlite_master` row, * delete any dead fts5 residue the store carries — turso stores only. * * Legacy stores whose `node`/`edge` schema was authored by a Drizzle * migration that no longer exists in this repo (the 2026-07-11 * `0000_sad_onslaught.sql`; the live backlog.db is one) carry the SQLite-era * FTS5 stack (`fts_node` virtual table + 4 shadow tables + 3 content-sync * triggers) at `sqlite_master` rows after `node`/`edge`. `node`/`edge` and * their FTS objects are LIBRARY-domain schema — Drizzle is a live dependency * in this ecosystem and owns only app tables, so removing the residue and * rebuilding `edge` stays within this package's ownership and touches * nothing Drizzle owns. graph-store's FTS * is dialect-driven — on turso the FTS is Tantivy — so those fts5 rows are * dead weight. But their presence makes the Turso engine's catalog build * abort SILENTLY at the first unparseable row, so every schema object whose * `sqlite_master` row lands after them never registers on open (BL-508). * A rebuild of `edge` (BL-507's explicit-rowid-FK heal) ALTER-RENAMEs a new * `edge` row to a rowid after the residue — turning a working store into * one that opens with `no such table: edge` (BL-506, proven on the live * store 2026-08-11; this class of break is invisible to DDL-normalization * acceptance because the catalog abort is silent). * * Deleting the residue FIRST keeps the rebuilt row in a parseable region. * The deletion goes through the sanctioned better-sqlite3 escape hatch * (store-adapter's {@link deleteSchemaRowsViaBetterSqlite3} — * `unsafeMode` + `PRAGMA writable_schema=ON` + `DELETE FROM sqlite_master`): * the Turso driver hard-refuses `sqlite_master` writes and its DROPs * against fts5 objects silently no-op. better-sqlite3 writing the file * requires the turso connection CLOSED — cross-engine WAL coordination is * exactly what destroys stores (BL-508) — hence close → drop → reopen, * mirroring memory-core's openDb() (db.ts:630-632). The name-based DELETE * also removes any duplicate `fts_node_ai` trigger a `CREATE TRIGGER IF * NOT EXISTS` replay created (Turso does not dedupe it — BL-507). * * Never makes the heal worse: a failed presence probe skips the drop (the * pre-BL-506 behavior), and a failed drop is logged loudly before the * reopen still runs. */ private dropFts5ResidueBeforeRebuild; private addColumnIfMissing; writeNode(content: string, meta: NodeMeta, opts?: WriteNodeOpts): Promise; private writeNodeInTx; /** * FEAT-011 — the business-key primitive. Return the id of the existing * (kind, name) node, or create it. Idempotent. Parallel-process note * (ADR-0012): the store is parallel-process enabled, so a plain * SELECT-then-INSERT is NOT race-free across concurrent processes — two * processes can both miss and both INSERT the same (kind, name). The FEAT-012 * unique index was reverted for other reasons (FEAT-023: too broad per * BUG-042; edge-scoped uniqueness inexpressible as a column index); under * multi-writer the caller must wrap check+INSERT in one transaction or add a * DDL backstop (BUG-GRAPHSTORE-STALE-SINGLE-WRITER-001). * Deliberately does NOT route identity through the content-hash dedupe — two * distinct (kind, name) entities with identical content must remain distinct * (BUG-040). */ findOrCreateNode(kind: string, name: string, opts?: { content?: string; meta?: NodeMeta; }): Promise; /** G1 — tx-scoped {@link findOrCreateNode}: the SELECT and the INSERT share one handle. */ private findOrCreateNodeInTx; supersede(oldId: number, newContent: string, meta: NodeMeta): Promise; /** G1 — tx-scoped {@link supersede}: mints the replacement, flags the old row, * and writes the SUPERSEDES edge — all on `db`, no nested transaction. */ private supersedeInTx; invalidate(nodeId: number, reason?: string): Promise; /** G1 — tx-scoped {@link invalidate}. */ private invalidateInTx; touch(nodeId: number, meta: Partial): Promise; /** G1 — tx-scoped {@link touch}. */ private touchInTx; writeNodeBatch(nodes: Array<{ content: string; meta: NodeMeta; }>, opts?: WriteNodeOpts): Promise; /** G1 — tx-scoped {@link writeNodeBatch}; never opens a nested transaction. */ private writeNodeBatchInTx; writeGraph(nodes: Array<{ content: string; meta: NodeMeta; }>, edges: Array<{ srcIdx: number; dstIdx: number; rel: EdgeRel; meta?: EdgeMeta; }>, opts?: WriteNodeOpts): Promise; /** G1 — tx-scoped {@link writeGraph}; never opens a nested transaction. * Both the node INSERTs and the endpoint-kind lookup now run on `db` (the * transaction), not `this.adapter` — the write and its kind resolution * provably share one handle. */ private writeGraphInTx; getNode(id: number): Promise; /** G1 — tx-scoped {@link getNode}. */ private getNodeInTx; /** Resolve by stable UUID — see GraphBackend.getNodeByUid. */ getNodeByUid(uid: string): Promise; /** G1 — tx-scoped {@link getNodeByUid}. */ private getNodeByUidInTx; /** * FEAT-024 (A1) — expose the adapter's transaction so consumers can compose * atomic multi-operation writes over *existing* nodes (the v2 "create issue" * = writeNode + edges + transition). This is also what makes FEAT-023's * uniqueness check + a multi-op write atomic. * * G1 — the callback receives a {@link GraphTransaction} (raw `AdapterTransaction` * surface PLUS typed, tx-bound graph primitives). Throwing from it rolls the * whole thing back. `opts` passes to `adapter.transaction` — `{ mode: * 'immediate' }` = `BEGIN IMMEDIATE` (ADR-0012 §1). */ transaction(fn: (tx: GraphTransaction) => Promise, opts?: TransactionOptions): Promise; /** * G1 — wrap a live adapter transaction in the tx-scoped graph view. Every * typed method delegates to the same private `*InTx` internals the bare * backend uses, so the injected `typePolicy`/`uniquenessPolicy` run at the * write boundary exactly as they do outside a transaction (ADR-0010 D2). */ private buildTxView; /** * FEAT-024 (A3) — bulk edge write between existing nodes in one transaction. * The migration writes thousands of edges; `writeGraph` bundles nodes+edges * and cannot target existing nodes. Endpoint kinds are resolved in a single * query up front (not one per edge) and threaded through `writeEdgeInTx` * for validateEdge. * * G1 — this opens its OWN transaction. Do not call it from inside a * `transaction()` callback (a nested BEGIN throws / deadlocks); use * `tx.writeEdges(...)` there instead. */ writeEdges(edges: Array<{ src: number; dst: number; rel: EdgeRel; meta?: EdgeMeta; }>): Promise; /** G1 — tx-scoped {@link writeEdges}; never opens a nested transaction. */ private writeEdgesInTx; /** FEAT-024 (B4) — ordered batch read, one query, in the requested id order. */ getNodesByIds(ids: number[], opts?: { liveOnly?: boolean; }): Promise; /** G1 — tx-scoped {@link getNodesByIds}. */ private getNodesByIdsInTx; queryNodes(filter?: NodeFilter): Promise; searchNodes(query: string, opts?: { limit?: number; offset?: number; filter?: NodeFilter; }): Promise>; countNodes(filter?: NodeFilter): Promise; /** * FEAT-024 (B5) — single `GROUP BY` over a node column instead of N * `countNodes` calls. `field` maps to a real node column; `status`/`priority` * counting is deliberately NOT here — in the v2 model those are edge-scoped * (`has_status` / a metadata key), not a node column, and belong to the app * layer. A NULL key (e.g. `agentId` unset) is keyed `'(null)'`. */ countBy(field: 'kind' | 'namespace' | 'agentId', filter?: NodeFilter): Promise>; countNodesFts(query: string, filter?: NodeFilter): Promise; getSupersessionChain(nodeId: number): Promise; /** * Iterative fallback for {@link getSupersessionChain} — the same CTE * phases as the recursive SQL, walked with getEdges: (1) bidirectional * `connected` set from `nodeId`, discovered FIFO so the Set's insertion * order IS the recursive CTE's BFS scan order (seed first, then per * dequeued node the incoming arm before the outgoing arm, mirroring the * CTE's two UNION arms); (2) `head` = FIRST connected node in that BFS * discovery order with no outbound SUPERSEDES (the recursive `head` CTE * is `LIMIT 1` over the connected scan — BFS order from the seed, NOT * rowid order, which is what picks the head on multi-root components); * (3) `chain` BFS from head via dst → src with depth; (4) collect * (rowid, depth), sort (depth, rowid), fetch nodes. * * Produces the SAME oldest-first ordering as the recursive SQL on linear * chains (pinned by the parity tests: [v1, v2, v3]) and the SAME set AND * order on multi-root components (pinned by the two-root parity test: * getSupersessionChain(v9) → [v9, v2, v3] on both paths). Neither path * filters node liveness — the recursive CTEs never join `node`, so * invalidated chain members are returned by both. Edges are walked via * getEdges (live edges only, `t_invalid IS NULL`) — the recursive SQL * also walks only live edges; no public path invalidates SUPERSEDES * edges, so the two cannot diverge in practice. */ private getSupersessionChainIterative; writeEdge(src: number, dst: number, rel: EdgeRel, meta?: EdgeMeta): Promise; /** * FEAT-024 (A2) — set `edge.t_invalid` (soft-delete). Idempotent: an edge * that is already invalidated (or absent) is a no-op. The v2 model re-assigns * status/priority/component by invalidating the old edge + writing the new * one. `writeEdge` with the same (src, dst, rel) re-livens it (`t_invalid = * NULL`) via the existing upsert. `reason`, when present, is merged into the * edge's `meta` alongside `invalidatedAt`. */ invalidateEdge(src: number, dst: number, rel: EdgeRel, reason?: string): Promise; /** G1 — tx-scoped {@link invalidateEdge}. */ private invalidateEdgeInTx; /** * FEAT-013 — resolve endpoint kinds, then validate the edge with endpoint * context when the injected TypePolicy provides `validateEdge`. Falls back to * `validateRel` (byte-identical to the pre-FEAT-013 behavior) when the policy * has no `validateEdge`. `resolvedKinds` is the writeGraph fast-path: kinds * already captured during node insertion, avoiding a per-edge SELECT. * * G1 — `db` is the handle every statement (kind lookup + INSERT) runs on: * the transaction when reached through {@link transaction}, else the bare * adapter. This is the ADR-0010 D2 write boundary — the injected TypePolicy * validates here regardless of which handle is in play. */ private writeEdgeInTx; getEdges(opts: IEdgeQuery): Promise; /** G1 — tx-scoped {@link getEdges}; sees this transaction's uncommitted edge writes/invalidations. */ private getEdgesInTx; getNeighbors(nodeId: number, opts?: { rel?: EdgeRel; depth?: number; direction?: 'in' | 'out' | 'both'; }): Promise; private getNeighborsDepth1; private getNeighborsRecursive; /** * Iterative fallback for {@link getNeighborsRecursive} — BFS with a visited * set (cycle termination), excluding the seed. Mirrors the recursive path * exactly in the two places that matter: * * - **Budget semantics (empirical, real sqlite, 2026-08-10):** the * recursive CTE's `LIMIT depth*100` is a TOTAL row budget across the whole * recursion INCLUDING the seed row — NOT a per-step cap (probe: LIMIT 25 * over a level-1 fanout of 25 returned exactly 25 rows = seed + 24; LIMIT * 30 returned seed + 25 + 4; LIMIT 100000 over a 30-chain returned all 30). * `depth` bounds the BUDGET, not the walk depth — the recursive walk is * depth-unbounded and stops only when the budget binds or the graph is * exhausted. The iterative counter counts total discovered rows (seed * included), so both paths truncate identically. * - **Liveness:** edges walked live-only (getEdges = `t_invalid IS NULL`, * same as the CTE's WHERE); the RESULT filters `node.t_invalid IS NULL` * (same as the outer SELECT) while the walk itself crosses invalidated * nodes (the CTE never joins node). * * direction 'both' runs out and in as two independent budgeted walks, then * union-dedups — byte-for-byte what the recursive 'both' branch does. */ private getNeighborsIterative; getNeighborsWithEdges(nodeId: number, opts?: { rel?: EdgeRel; depth?: number; direction?: 'in' | 'out' | 'both'; }): Promise>; isReachable(src: number, dst: number, opts?: { rel?: EdgeRel; direction?: 'out' | 'in'; }): Promise; getSubgraph(rootId: number, opts?: { rel?: EdgeRel; depth?: number; direction?: 'out' | 'in' | 'both'; }): Promise<{ nodes: NodeRecord[]; edges: EdgeRecord[]; }>; /** * Iterative fallback for {@link getSubgraph} — level-by-level BFS from * rootId (level 0 = the root), visited set for cycle termination (the * recursive `sub` CTE terminates via UNION dedup). `maxDepth >= 0` stops at * depth === maxDepth (mirroring the recursive term's `s.depth < maxDepth`); * `-1` walks unbounded. direction 'both' runs out and in and merges by id * / src:dst:rel key — byte-for-byte the recursive 'both' branch. * * Deliberately NO node-liveness filter: the recursive `sub` CTE never joins * `node`, so an invalidated node behind a live edge IS part of the subgraph * — the parity tests pin this quirk on both paths. Edges use the SAME * non-recursive query as the recursive path, verbatim (src IN + dst IN + * t_invalid IS NULL). */ private getSubgraphIterative; } export declare function createGraphBackend(adapter: StoreAdapter, opts?: GraphBackendOpts): GraphBackend; //# sourceMappingURL=index.d.ts.map