@happyvertical/smrt-core 0.51.19 → 0.51.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,20 +3,20 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.51.19",
6
+ "packageVersion": "0.51.20",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "3d9e46b370fef6284ed8804c9a57835e9e6e92647b5707a689eccf1f11fd2606",
11
- "packageJson": "d38a731745c8305743983f0dcdd8b6cade9466aa33c3ca0c0797e3f4dcdcc21d",
12
- "agents": "3c6673d5e85bd43f1b445d446f043b6044726df113e73fb17c20a6705aa6503c",
10
+ "manifest": "ea50340153da5679900024dd700ad5f3a75a238c7c558b944fd8815fa0720d48",
11
+ "packageJson": "81307a5008d8372cf3712e3bc0361d88bd1616edd169c64d8dea8f1ecb8cc374",
12
+ "agents": "2e21cb54b0fc615cef8a24324cd297f05d4117d0caac61a7be10cada2b7dcae1",
13
13
  "moduleDoc:agents/object-runtime.md": "34a4e740c109467cd5486c4af0e3bf4b63915697721ab584df0e5506ea9290ca",
14
14
  "moduleDoc:agents/revision-guard.md": "aa6b1ddb5b6b49fa27ebbe575ec7fcd6d5ceb35ee25acc702f877a226eb9a99a",
15
15
  "moduleDoc:agents/collection-reads.md": "e5bb79b59c17e79a85a71580a019e6b0b641e498ac4d850073fd5ad634b57cb7",
16
16
  "moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5",
17
17
  "moduleDoc:agents/data-query.md": "1b72411d441ce2285bf0c89674e7aa996832a58b552b23a6d43834b907d91355",
18
18
  "moduleDoc:agents/schema-paths.md": "8a8edc9b8d38de15bff09bf5dc20ad7389f83923ad21462253bb88bb4880bce0",
19
- "moduleDoc:agents/change-feed.md": "4b71888a93f9dc356196d312149871bf8b6562365cf68cdfd246a52791dffcdc",
19
+ "moduleDoc:agents/change-feed.md": "5fdd12df8793547b3923fee3c6c9d5bd57525b94642c6123df3653610015cbde",
20
20
  "moduleDoc:agents/change-signals.md": "d9cb6a5541728ffea46607a6b1d4fa61d4621849f2b4ea86a0645fbb0af892e9",
21
21
  "moduleDoc:agents/generators.md": "4bc7787947fbcb5c084ce3e7849eff7a240580226e6b64271de7493870683cdf",
22
22
  "moduleDoc:agents/build-knowledge.md": "804fb47dfbdef4fa3ac7b66fafc825917a474d8c91b1675e647fe646df474c6b",
@@ -1047,7 +1047,7 @@
1047
1047
  "polymorphicAssociations": 1,
1048
1048
  "uuidColumns": 3
1049
1049
  },
1050
- "agentDoc": "# @happyvertical/smrt-core\n\nFoundation ORM, registry, schema/code generation, AI integration, and DispatchBus.\nRead the module for the subsystem being edited; root AGENTS covers shared model\nand repository rules.\n\n## Modules\n\n| Source | Scope | Module doc |\n|---|---|---|\n| `src/object.ts`, `src/collection.ts`, `src/child-accessors.ts` | Lifecycle, hydration, operators, STI, child accessors, dispatch | [agents/object-runtime.md](agents/object-runtime.md) |\n| `src/revision-guard.ts` | Guarded writes and PostgreSQL revision precision | [agents/revision-guard.md](agents/revision-guard.md) |\n| `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n| `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |\n| `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |\n| `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |\n| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |\n| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |\n| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |\n| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Per-class registry work on the hydration path (manifest reconciliation in\n `ensureManifestLoaded()`, the simple-name index behind `findClass()`) is\n memoized per registry generation (`src/registry/generation.ts`, #3047). Any\n new writer of registered classes, their field/method metadata, or loaded\n manifests must go through the tracked maps or call\n `bumpRegistryGeneration()`; `issue-3047-hydration-registry-work.test.ts`\n guards that hydration does not repeat this work per row.\n\n- Production DDL uses manifest generators; `getTestDatabase()` uses registry\n generators. Keep columns, indexes, FK actions, and runtime conflict targets in\n parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate\n needs its index or an explicit reason none is needed.\n- Tenant scoping covers every read and every unique/conflict key. Explicit\n conflict columns are not rewritten; their author must include tenant scope.\n- Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.\n Preserve revision compare-and-swap ordering through public `save()`,\n `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete\n `withTransaction()` callbacks share a process-local write queue.\n- Collection model hydration is serial in result order: initialization may query\n the same transaction-bound PostgreSQL client. Use projections for plain rows.\n- Native DuckDB UUIDs must be cast coherently on read before identity reuse;\n custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay\n a mutation to discover result types.\n- `withDatabase(db, callback)` restores only database bindings (including public\n `options.db`); `withTransaction(callback)` also restores identity/revision\n metadata after rollback. Do not use a bound instance concurrently.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses an advisory lock. Application tables still require migrations;\n runtime table verification checks existence only.\n- Manifest generation fails closed on scanner errors, including unresolved\n decorator spreads. Never emit partial, default-open registration.\n- Generated registration repairs bundled class identity using the exact imported\n constructor, explicit package, and isolated one-object manifest. Never infer\n ownership from paths, simple names, or table names; packages can share names.\n Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.\n- API custom-action eligibility has ONE resolver, `resolveApiMethodExposure()`\n in `generators/custom-action.ts`: both SvelteKit route emitters,\n `resolveApiActionSet`, and `knowledge.ts` read it, and a new consumer must too\n — a local mirror is how a method gets reported unavailable while its route\n file is still written. A public method routes by default only when every\n parameter is JSON-shaped; `@method({ expose })` overrides in both directions,\n `expose: true` bypasses the heuristic ALONE, and an explicit `api.include` or\n `api.routes` entry keeps its pre-#2686 route. Fail closed on scanner\n uncertainty: read `parameters[].typeUnresolved`, never the `'any'` it\n substitutes. Accepting `Date` as wire-able and hydrating it\n (`toCustomActionDate`) are one decision — changing either breaks the other.\n The runtime `APIGenerator` transport stays declaration-gated: `rest.ts`\n dispatch and `preflight-route.ts` prediction both read\n `declaresRuntimeRestRoute()`, which must accept every `ApiCustomRouteConfig`\n option so a sweep moving one onto its method cannot delete the endpoint. Its\n twin `declaresRuntimeRestRouteShape()` deliberately ignores `expose: false`:\n the dispatcher must still SEE a withheld declaration to answer 404, because\n `POST /<collection>/<segment>` resolves to `create` when nothing claims the\n segment. Split unions and type arguments with `splitTopLevel()` — a naive\n `split('|')` truncates `Record<string, Asset | null>` into fragments that\n match no rule and are then accepted, widening the gate. `extractTypeName`\n returns `null` for a generic with an unresolvable ARGUMENT or a union with an\n unresolvable BRANCH so those reach that fail-closed path instead of arriving\n as a bare `'Array'`. Runtime transports read\n `ObjectRegistry.resolveRuntimeMethod()`, which tries the item class's manifest\n entry, then the COLLECTION class's (where a collection-hosted action's\n parameters live), then the live `@method()` store — keyed by CONSTRUCTOR,\n never by simple name (`Account` exists in two packages), and recording\n `isStatic` because an unscanned runtime has no manifest to recover the\n receiver from. `isRestActionRoutable` ANDs \"declared\" with that receiver, so\n it never predicts `allow` for an action dispatch refuses. A declared action\n with no receiver is refused (501 collection-scoped, 404 item-scoped), never\n allowed to fall through into `create`. All eight consumers read the resolver,\n including `packages/smrt-workbench/src/discovery.ts`; it imports the\n `./generators/custom-action` LEAF subpath, not `./generators`, because that\n barrel value-re-exports `MCPGenerator` and so drags `@happyvertical/ai` and\n `@happyvertical/sql` into a build-time helper (2 modules vs 109). Keep\n `custom-action.ts` free of value imports outside `tools/tool-generator`.\n\n## Gotchas\n\n- Optional filesystem support stays lazy: use `createFilesystemAdapter()` in\n `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps\n import `@happyvertical/smrt-core/filesystem` at startup. Use\n `importOptionalDependency()` for similarly heavy optional dependencies.\n- Database retries are transient-only, four attempts total for `get`/`upsert`.\n Use `src/db-errors.ts` classifiers through the cause chain, never message\n matching (SDK driver text can live in `context.originalError`). Constraints,\n bad input, missing tables, and aborted PostgreSQL transactions fail immediately.\n Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes\n `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.\n- Property initializers precede option values; options win. Arrays/objects are\n shallow-cloned. Collection creation caches fields; table verification caches\n by DB URL and table. Preserve these scopes when changing initialization.\n- The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild\n core after editing those sources before testing consumer manifest generation.\n Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.\n\n## Validation\n\nRun focused tests first, then applicable package checks:\n\n```bash\npnpm --filter @happyvertical/smrt-core test\npnpm --filter @happyvertical/smrt-core typecheck\npnpm --filter @happyvertical/smrt-core build\npnpm --filter @happyvertical/smrt-core test:postgres\npnpm check:agents-chain\npnpm smrt dev:knowledge-check\n```\n\nThe PostgreSQL lane is required for numeric types, UUID casts, conflict targets,\ntimestamps, and migrations. Tests generate their manifest before Vitest; restart\nwatch mode after adding decorated classes. Documentation-only changes need\ninstruction-chain and knowledge freshness checks, not the runtime test suite.\n",
1050
+ "agentDoc": "# @happyvertical/smrt-core\n\nFoundation ORM, registry, schema/code generation, AI integration, and DispatchBus.\nRead the module for the subsystem being edited; root AGENTS covers shared model\nand repository rules.\n\n## Modules\n\n| Source | Scope | Module doc |\n|---|---|---|\n| `src/object.ts`, `src/collection.ts`, `src/child-accessors.ts` | Lifecycle, hydration, operators, STI, child accessors, dispatch | [agents/object-runtime.md](agents/object-runtime.md) |\n| `src/revision-guard.ts` | Guarded writes and PostgreSQL revision precision | [agents/revision-guard.md](agents/revision-guard.md) |\n| `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n| `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |\n| `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |\n| `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |\n| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |\n| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |\n| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |\n| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Per-class registry work on the hydration path (manifest reconciliation in\n `ensureManifestLoaded()`, the simple-name index behind `findClass()`) is\n memoized per registry generation (`src/registry/generation.ts`, #3047). Any\n new writer of registered classes, their field/method metadata, or loaded\n manifests must go through the tracked maps or call\n `bumpRegistryGeneration()`; `issue-3047-hydration-registry-work.test.ts`\n guards that hydration does not repeat this work per row.\n\n- Production DDL uses manifest generators; `getTestDatabase()` uses registry\n generators. Keep columns, indexes, FK actions, and runtime conflict targets in\n parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate\n needs its index or an explicit reason none is needed.\n- Tenant scoping covers every read and every unique/conflict key. Explicit\n conflict columns are not rewritten; their author must include tenant scope.\n- Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.\n Preserve revision compare-and-swap ordering through public `save()`,\n `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete\n `withTransaction()` callbacks share a process-local write queue.\n- Collection model hydration is serial in result order: initialization may query\n the same transaction-bound PostgreSQL client. Use projections for plain rows.\n- Native DuckDB UUIDs must be cast coherently on read before identity reuse;\n custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay\n a mutation to discover result types.\n- `withDatabase(db, callback)` restores only database bindings (including public\n `options.db`); `withTransaction(callback)` also restores identity/revision\n metadata after rollback. Do not use a bound instance concurrently.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses a transaction-scoped advisory lock, taken only when the\n PostgreSQL schema version is not yet stamped: inside a caller's transaction it\n is held until that transaction ends (#2868). Application tables still require migrations;\n runtime table verification checks existence only.\n- Manifest generation fails closed on scanner errors, including unresolved\n decorator spreads. Never emit partial, default-open registration.\n- Generated registration repairs bundled class identity using the exact imported\n constructor, explicit package, and isolated one-object manifest. Never infer\n ownership from paths, simple names, or table names; packages can share names.\n Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.\n- API custom-action eligibility has ONE resolver, `resolveApiMethodExposure()`\n in `generators/custom-action.ts`: both SvelteKit route emitters,\n `resolveApiActionSet`, and `knowledge.ts` read it, and a new consumer must too\n — a local mirror is how a method gets reported unavailable while its route\n file is still written. A public method routes by default only when every\n parameter is JSON-shaped; `@method({ expose })` overrides in both directions,\n `expose: true` bypasses the heuristic ALONE, and an explicit `api.include` or\n `api.routes` entry keeps its pre-#2686 route. Fail closed on scanner\n uncertainty: read `parameters[].typeUnresolved`, never the `'any'` it\n substitutes. Accepting `Date` as wire-able and hydrating it\n (`toCustomActionDate`) are one decision — changing either breaks the other.\n The runtime `APIGenerator` transport stays declaration-gated: `rest.ts`\n dispatch and `preflight-route.ts` prediction both read\n `declaresRuntimeRestRoute()`, which must accept every `ApiCustomRouteConfig`\n option so a sweep moving one onto its method cannot delete the endpoint. Its\n twin `declaresRuntimeRestRouteShape()` deliberately ignores `expose: false`:\n the dispatcher must still SEE a withheld declaration to answer 404, because\n `POST /<collection>/<segment>` resolves to `create` when nothing claims the\n segment. Split unions and type arguments with `splitTopLevel()` — a naive\n `split('|')` truncates `Record<string, Asset | null>` into fragments that\n match no rule and are then accepted, widening the gate. `extractTypeName`\n returns `null` for a generic with an unresolvable ARGUMENT or a union with an\n unresolvable BRANCH so those reach that fail-closed path instead of arriving\n as a bare `'Array'`. Runtime transports read\n `ObjectRegistry.resolveRuntimeMethod()`, which tries the item class's manifest\n entry, then the COLLECTION class's (where a collection-hosted action's\n parameters live), then the live `@method()` store — keyed by CONSTRUCTOR,\n never by simple name (`Account` exists in two packages), and recording\n `isStatic` because an unscanned runtime has no manifest to recover the\n receiver from. `isRestActionRoutable` ANDs \"declared\" with that receiver, so\n it never predicts `allow` for an action dispatch refuses. A declared action\n with no receiver is refused (501 collection-scoped, 404 item-scoped), never\n allowed to fall through into `create`. All eight consumers read the resolver,\n including `packages/smrt-workbench/src/discovery.ts`; it imports the\n `./generators/custom-action` LEAF subpath, not `./generators`, because that\n barrel value-re-exports `MCPGenerator` and so drags `@happyvertical/ai` and\n `@happyvertical/sql` into a build-time helper (2 modules vs 109). Keep\n `custom-action.ts` free of value imports outside `tools/tool-generator`.\n\n## Gotchas\n\n- Optional filesystem support stays lazy: use `createFilesystemAdapter()` in\n `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps\n import `@happyvertical/smrt-core/filesystem` at startup. Use\n `importOptionalDependency()` for similarly heavy optional dependencies.\n- Database retries are transient-only, four attempts total for `get`/`upsert`.\n Use `src/db-errors.ts` classifiers through the cause chain, never message\n matching (SDK driver text can live in `context.originalError`). Constraints,\n bad input, missing tables, and aborted PostgreSQL transactions fail immediately.\n Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes\n `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.\n- Property initializers precede option values; options win. Arrays/objects are\n shallow-cloned. Collection creation caches fields; table verification caches\n by DB URL and table. Preserve these scopes when changing initialization.\n- The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild\n core after editing those sources before testing consumer manifest generation.\n Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.\n\n## Validation\n\nRun focused tests first, then applicable package checks:\n\n```bash\npnpm --filter @happyvertical/smrt-core test\npnpm --filter @happyvertical/smrt-core typecheck\npnpm --filter @happyvertical/smrt-core build\npnpm --filter @happyvertical/smrt-core test:postgres\npnpm check:agents-chain\npnpm smrt dev:knowledge-check\n```\n\nThe PostgreSQL lane is required for numeric types, UUID casts, conflict targets,\ntimestamps, and migrations. Tests generate their manifest before Vitest; restart\nwatch mode after adding decorated classes. Documentation-only changes need\ninstruction-chain and knowledge freshness checks, not the runtime test suite.\n",
1051
1051
  "moduleDocs": [
1052
1052
  {
1053
1053
  "path": "agents/object-runtime.md",
@@ -1082,7 +1082,7 @@
1082
1082
  {
1083
1083
  "path": "agents/change-feed.md",
1084
1084
  "module": "change-feed",
1085
- "content": "# smrt-core/change feed\n\nModule semantics for `src/change-feed.ts`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- Staged appends (PostgreSQL, #2649): `MAX+1` costs a *wait* — the loser of a primary-key race waits for the winner's whole transaction — so an append inside a caller transaction that already wrote rows would let a long write transaction and an ordinary concurrent request form a real lock cycle (`40P01`, found downstream in willgriffin/willgriffin.dev#457). `_smrt_append_change` therefore checks `pg_current_xact_id_if_assigned()` (PostgreSQL 13+): when the caller already has a transaction id it stages the entry in `_smrt_changes_pending` (identity key, conflicts with nothing, never waits, still rolled back with the caller) and `appendChange()` returns **`null`** instead of a sequence. `_smrt_drain_changes()` moves *committed* staged rows into `_smrt_changes` with contiguous `MAX(seq)+row_number()` sequences under a **try-only** advisory lock, so the drain never waits either. Draining is driven from JavaScript, never inside the append helper: an entry sequenced invisibly server-side would get no live `_events` signal while the append's own signal carried a higher sequence, and a subscriber resuming from that `Last-Event-ID` would skip it permanently. `drainChangeFeed(db)` is called best-effort by `getChangesSince()`, by `pruneChangeFeed()`, and by the append path itself (throttled to one drain per 250 ms per database, bypassed immediately after this process staged an append). The append path issues ONE drain statement (a single bounded pass) and nothing else — the caller may own the surrounding transaction, and a second statement there could fail and abort it behind the feed's own error-swallowing (#2026); the helper's whole body, preflight included, sits inside its exception boundary for the same reason. It still settles that drain's signals in order without spending a statement: a drain that allocated anything assigns a transaction id, so an append that then takes the DIRECT path proves the drain committed, and its signals publish ahead of the append's own; a deferred append proves nothing and queues them. Signals for drained entries publish only after a follow-up probe proves the drain committed, so an uncommitted drain can never advertise a sequence a rollback releases for reuse. `getChangesSince()` additionally holds back everything the handle's own drains allocated while a transaction id is still assigned at read time (a per-handle watermark, so later reads in the same transaction keep holding back), and `bootstrapSystemTables()` refreshes the helpers before its schema-version fast return so an already-stamped database does not keep the deadlocking function. Consequences: the cursor guarantee is unchanged (one `MAX+1` writer at a time, only over committed work), but a staged entry becomes visible one drain after its transaction commits, its log position is drain order rather than statement order, and it publishes its live `_events` signal at drain time (post-commit — the old pre-commit signal could describe a rolled-back write). SQLite/DuckDB keep the direct insert unchanged. Residual: an append issued as a transaction's *first* statement still allocates inline — it holds no row locks then, so it cannot close a cycle, but it can make other direct appenders wait for that commit; issue explicit `bumpChangeFeed()` calls after the write or outside the transaction. A deployment that writes only through transactions and reads the feed from a connection that cannot write must schedule `drainChangeFeed()` on a writable one.\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table **plus that table's staged-but-undrained count**, so a staged write still moves the ETag and cannot false-304 a client; the sum is monotonic because draining `n` staged rows raises the table's `MAX(seq)` by at least `n`, and both terms are read in ONE statement — separate reads let a drain be counted twice, minting a version a later write re-mints; replica-stable, with no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable. The age bound is a **prefix** bound — everything below the oldest entry still inside the window — because `created_at` and `seq` are not co-monotonic (writer clocks skew, and a staged entry carries its stage-time stamp into a later-assigned sequence); deleting by timestamp alone could punch a hole in the middle of the retained run, where `since < floor - 1` cannot see it and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n\n\n## Credential-bearing tables are never disclosed (#2937)\n\n`src/change-feed-sensitivity.ts` is a leaf module (it imports nothing, so\n`change-feed.ts`, `change-signals.ts` and the registry can all consult it\nwithout a cycle) holding the tables whose **row id or payload is a secret**. A\ntable joins it two ways: by name, via the baseline\n`CHANGE_FEED_CREDENTIAL_TABLES` (`sessions`, `users_cli_auth_requests`,\n`users_magic_link_tokens`, `magic_link_tokens`, `api_keys`,\n`nostr_identities`), or because a class declared `@smrt({ sensitive: true })`\nand registration pushed its resolved table name across\n(`declareChangeFeedSensitiveTable`, called from both the decorator and the\nmanifest-stub registration paths). Both are needed: the declaration is the\npackage's own contract but only binds in a process where that package\nregistered, while the name baseline is all the read path has when serving a\ndatabase another process writes. The set is **monotonic** — `sensitive: false`\nis not an opt-out and nothing removes a name — so a consumer whose domain\ntable is named `sessions` loses feed coverage for it and must rename it via\n`@smrt({ tableName })`.\n\n`isChangeFeedObservableTable()` ANDs the sensitivity check with\n`CHANGE_FEED_EXCLUDED_TABLES`, and the refusal is enforced at four points, not\none: the interceptor write path; `appendChange`/`appendChanges`, the lowest\nwrite API, so `bumpChangeFeed()` and any other escape hatch are covered (a\nrefused append returns `null`, indistinguishable from a staged one);\n`deliverLocally` in `change-signals.ts`, which also catches a signal broadcast\nby a peer replica running an older build; and **`getChangesSince()`**. The\nread-side refusal is what makes upgrading sufficient — rows an earlier version\nalready wrote stay in the log but are never served. A caller-named sensitive\ntable is dropped from the `tables` filter rather than erroring, so nothing\nconfirms the table exists, and a request naming *only* sensitive tables gets\nan empty page rather than an unfiltered one. Filtered rows never hold the\ncursor back: an exhaustive page still advances to the served horizon.\n\n`pruneChangeFeed()` additionally deletes sensitive rows below the horizon on\nevery sweep, ahead of either retention bound, so credentials do not sit at rest\nfor the retention window. That deletes from the middle of the retained run,\nwhich the age bound goes to lengths to avoid — permissible only because these\nrows are unservable on every read path, so no page ever contained them and no\ncursor can fall into the gap. It **does** move `floor`, and usually will: a\nsession is created before the first domain write and re-saved on every request,\nso the lowest retained sequences are typically credential rows. `floor` is read\nlive (`MIN(seq)`) on every `getChangesSince` and never cached, so the only\nconsequence is that a cursor below the new floor is answered `resyncRequired`\nand refetches — including a `since=0` client on a never-pruned feed. Extra\nresyncs, never a missed change. The horizon does not move, because the newest\nentry is retained even when it is sensitive; that preserves \"a non-empty feed\nis never emptied\" and means one credential row can linger until the next write\nmoves the horizon past it.\n\nBecause a non-observable table appends nothing, it also has no ETag source:\n`getTableVersion()` would pin at whatever an older build last wrote and then\nfall back to the global horizon, which moves only on unrelated traffic — so a\nconditional GET could answer `304` for a revoked API key or a rotated session.\n`getTableVersion()` therefore returns a deliberately **unrepeatable** value for\nsuch a table — 48 bits of `crypto` randomness per call, not a per-process\ncounter, because two replicas' clock-seeded counters drift through each other\nand the ETag carries no per-process entropy — so no *concrete* `If-None-Match`\ncan match and every such read is a full 200. It is the single point every ETag\npath goes through (the runtime `APIGenerator`, the generated\n`conditionalVersionedRead`, and route files an older generator already\nemitted), so no call site needs a special case. A wildcard `If-None-Match: *`\nstill 304s; the read paths evaluate it only after the payload is built, so it\ndistinguishes nothing an unconditional request would not.\n\nRegistration derives the declared name from config and manifest; the writer uses\n`instance.tableName`, which resolves through the STI base's schema and then the\nclass's own. Where those two derivations disagree the declaration lands on a\nname nothing writes under, and the real table keeps appending. STI is *not* such\na case today — `@smrt()` already resolves an STI child to its base's table, so a\nchild declaring `sensitive` declares `<base>` (verified) — but the manifest-stub\nand manifest-merge paths derive the name differently again, so registration\ndeclares every candidate name. The authoritative check is at the write path:\n`isChangeFeedSensitiveWrite()` asks the registry about the instance's own class\nthrough a resolver hook on the leaf module (so `change-feed.ts` never imports\nthe registry), sees the exact name being recorded with no derivation to keep in\nsync, and declares it — closing the read path and signal bus for that table.\n\nScope note: the generated `_changes`/`_events` routes still gate on an\nauthenticated principal only — there is no per-table permission check, and\ntenant scoping (`getTenantScopedChangesSince`, fail-closed) remains the sole\nrow-level filter for ordinary tables.\n\n## Compatible bulk mutations (#2818)\n\n`appendChanges(db, entries)` validates all inputs before writing and returns one\nsequence (or PostgreSQL staged `null`) per entry in input order. A nonempty batch\nuses one client SQL statement and one set-based insert. The PostgreSQL\n`_smrt_append_changes(jsonb)` helper chooses the staged/direct path once before\nits own writes assign a transaction ID, and retains the same exception isolation\nas the single-row helper. Bootstrap refreshes its body marker and runtime grants\ninclude this exact helper signature.\n\nThe registered feed interceptor explicitly supports grouped lifecycle completion.\n`recordInstanceChanges()` retains observable-table filtering, row/tenant IDs,\nper-row operations, and best-effort failure handling. It publishes each direct\nsequence in order; staged entries publish through the existing committed drain.\nA batch is never replaced with a table-level bump or a single aggregate event.\nThe junction caller bounds its batch; the low-level append API does not split\none logical append into partially committed chunks.\n"
1085
+ "content": "# smrt-core/change feed\n\nModule semantics for `src/change-feed.ts`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry (a jittered pause of at most 40 ms between attempts, so two tight-loop writers cannot starve one another — #3062) — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- Staged appends (PostgreSQL, #2649): `MAX+1` costs a *wait* — the loser of a primary-key race waits for the winner's whole transaction — so an append inside a caller transaction that already wrote rows would let a long write transaction and an ordinary concurrent request form a real lock cycle (`40P01`, found downstream in willgriffin/willgriffin.dev#457). `_smrt_append_change` therefore checks `pg_current_xact_id_if_assigned()` (PostgreSQL 13+): when the caller already has a transaction id it stages the entry in `_smrt_changes_pending` (identity key, conflicts with nothing, never waits, still rolled back with the caller) and `appendChange()` returns **`null`** instead of a sequence. `_smrt_drain_changes()` moves *committed* staged rows into `_smrt_changes` with contiguous `MAX(seq)+row_number()` sequences under a **try-only** advisory lock, so the drain never waits either. Draining is driven from JavaScript, never inside the append helper: an entry sequenced invisibly server-side would get no live `_events` signal while the append's own signal carried a higher sequence, and a subscriber resuming from that `Last-Event-ID` would skip it permanently. `drainChangeFeed(db)` is called best-effort by `getChangesSince()`, by `pruneChangeFeed()`, and by the append path itself (throttled to one drain per 250 ms per database, bypassed immediately after this process staged an append). The append path issues ONE drain statement (a single bounded pass) and nothing else — the caller may own the surrounding transaction, and a second statement there could fail and abort it behind the feed's own error-swallowing (#2026); the helper's whole body, preflight included, sits inside its exception boundary for the same reason. It still settles that drain's signals in order without spending a statement: a drain that allocated anything assigns a transaction id, so an append that then takes the DIRECT path proves the drain committed, and its signals publish ahead of the append's own; a deferred append proves nothing and queues them. Signals for drained entries publish only after a follow-up probe proves the drain committed, so an uncommitted drain can never advertise a sequence a rollback releases for reuse. `getChangesSince()` additionally holds back everything the handle's own drains allocated while a transaction id is still assigned at read time (a per-handle watermark, so later reads in the same transaction keep holding back), and `bootstrapSystemTables()` refreshes the helpers before its schema-version fast return so an already-stamped database does not keep the deadlocking function. Consequences: the cursor guarantee is unchanged (one `MAX+1` writer at a time, only over committed work), but a staged entry becomes visible one drain after its transaction commits, its log position is drain order rather than statement order, and it publishes its live `_events` signal at drain time (post-commit — the old pre-commit signal could describe a rolled-back write). SQLite/DuckDB keep the direct insert unchanged. Residual: an append issued as a transaction's *first* statement still allocates inline — it holds no row locks then, so it cannot close a cycle, but it can make other direct appenders wait for that commit; issue explicit `bumpChangeFeed()` calls after the write or outside the transaction. A deployment that writes only through transactions and reads the feed from a connection that cannot write must schedule `drainChangeFeed()` on a writable one.\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table **plus that table's staged-but-undrained count**, so a staged write still moves the ETag and cannot false-304 a client; the sum is monotonic because draining `n` staged rows raises the table's `MAX(seq)` by at least `n`, and both terms are read in ONE statement — separate reads let a drain be counted twice, minting a version a later write re-mints; replica-stable, with no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable. The age bound is a **prefix** bound — everything below the oldest entry still inside the window — because `created_at` and `seq` are not co-monotonic (writer clocks skew, and a staged entry carries its stage-time stamp into a later-assigned sequence); deleting by timestamp alone could punch a hole in the middle of the retained run, where `since < floor - 1` cannot see it and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n\n\n## Credential-bearing tables are never disclosed (#2937)\n\n`src/change-feed-sensitivity.ts` is a leaf module (it imports nothing, so\n`change-feed.ts`, `change-signals.ts` and the registry can all consult it\nwithout a cycle) holding the tables whose **row id or payload is a secret**. A\ntable joins it two ways: by name, via the baseline\n`CHANGE_FEED_CREDENTIAL_TABLES` (`sessions`, `users_cli_auth_requests`,\n`users_magic_link_tokens`, `magic_link_tokens`, `api_keys`,\n`nostr_identities`), or because a class declared `@smrt({ sensitive: true })`\nand registration pushed its resolved table name across\n(`declareChangeFeedSensitiveTable`, called from both the decorator and the\nmanifest-stub registration paths). Both are needed: the declaration is the\npackage's own contract but only binds in a process where that package\nregistered, while the name baseline is all the read path has when serving a\ndatabase another process writes. The set is **monotonic** — `sensitive: false`\nis not an opt-out and nothing removes a name — so a consumer whose domain\ntable is named `sessions` loses feed coverage for it and must rename it via\n`@smrt({ tableName })`.\n\n`isChangeFeedObservableTable()` ANDs the sensitivity check with\n`CHANGE_FEED_EXCLUDED_TABLES`, and the refusal is enforced at four points, not\none: the interceptor write path; `appendChange`/`appendChanges`, the lowest\nwrite API, so `bumpChangeFeed()` and any other escape hatch are covered (a\nrefused append returns `null`, indistinguishable from a staged one);\n`deliverLocally` in `change-signals.ts`, which also catches a signal broadcast\nby a peer replica running an older build; and **`getChangesSince()`**. The\nread-side refusal is what makes upgrading sufficient — rows an earlier version\nalready wrote stay in the log but are never served. A caller-named sensitive\ntable is dropped from the `tables` filter rather than erroring, so nothing\nconfirms the table exists, and a request naming *only* sensitive tables gets\nan empty page rather than an unfiltered one. Filtered rows never hold the\ncursor back: an exhaustive page still advances to the served horizon.\n\n`pruneChangeFeed()` additionally deletes sensitive rows below the horizon on\nevery sweep, ahead of either retention bound, so credentials do not sit at rest\nfor the retention window. That deletes from the middle of the retained run,\nwhich the age bound goes to lengths to avoid — permissible only because these\nrows are unservable on every read path, so no page ever contained them and no\ncursor can fall into the gap. It **does** move `floor`, and usually will: a\nsession is created before the first domain write and re-saved on every request,\nso the lowest retained sequences are typically credential rows. `floor` is read\nlive (`MIN(seq)`) on every `getChangesSince` and never cached, so the only\nconsequence is that a cursor below the new floor is answered `resyncRequired`\nand refetches — including a `since=0` client on a never-pruned feed. Extra\nresyncs, never a missed change. The horizon does not move, because the newest\nentry is retained even when it is sensitive; that preserves \"a non-empty feed\nis never emptied\" and means one credential row can linger until the next write\nmoves the horizon past it.\n\nBecause a non-observable table appends nothing, it also has no ETag source:\n`getTableVersion()` would pin at whatever an older build last wrote and then\nfall back to the global horizon, which moves only on unrelated traffic — so a\nconditional GET could answer `304` for a revoked API key or a rotated session.\n`getTableVersion()` therefore returns a deliberately **unrepeatable** value for\nsuch a table — 48 bits of `crypto` randomness per call, not a per-process\ncounter, because two replicas' clock-seeded counters drift through each other\nand the ETag carries no per-process entropy — so no *concrete* `If-None-Match`\ncan match and every such read is a full 200. It is the single point every ETag\npath goes through (the runtime `APIGenerator`, the generated\n`conditionalVersionedRead`, and route files an older generator already\nemitted), so no call site needs a special case. A wildcard `If-None-Match: *`\nstill 304s; the read paths evaluate it only after the payload is built, so it\ndistinguishes nothing an unconditional request would not.\n\nRegistration derives the declared name from config and manifest; the writer uses\n`instance.tableName`, which resolves through the STI base's schema and then the\nclass's own. Where those two derivations disagree the declaration lands on a\nname nothing writes under, and the real table keeps appending. STI is *not* such\na case today — `@smrt()` already resolves an STI child to its base's table, so a\nchild declaring `sensitive` declares `<base>` (verified) — but the manifest-stub\nand manifest-merge paths derive the name differently again, so registration\ndeclares every candidate name. The authoritative check is at the write path:\n`isChangeFeedSensitiveWrite()` asks the registry about the instance's own class\nthrough a resolver hook on the leaf module (so `change-feed.ts` never imports\nthe registry), sees the exact name being recorded with no derivation to keep in\nsync, and declares it — closing the read path and signal bus for that table.\n\nScope note: the generated `_changes`/`_events` routes still gate on an\nauthenticated principal only — there is no per-table permission check, and\ntenant scoping (`getTenantScopedChangesSince`, fail-closed) remains the sole\nrow-level filter for ordinary tables.\n\n## Compatible bulk mutations (#2818)\n\n`appendChanges(db, entries)` validates all inputs before writing and returns one\nsequence (or PostgreSQL staged `null`) per entry in input order. A nonempty batch\nuses one client SQL statement and one set-based insert. The PostgreSQL\n`_smrt_append_changes(jsonb)` helper chooses the staged/direct path once before\nits own writes assign a transaction ID, and retains the same exception isolation\nas the single-row helper. Bootstrap refreshes its body marker and runtime grants\ninclude this exact helper signature.\n\nThe registered feed interceptor explicitly supports grouped lifecycle completion.\n`recordInstanceChanges()` retains observable-table filtering, row/tenant IDs,\nper-row operations, and best-effort failure handling. It publishes each direct\nsequence in order; staged entries publish through the existing committed drain.\nA batch is never replaced with a table-level bump or a single aggregate event.\nThe junction caller bounds its batch; the low-level append API does not split\none logical append into partially committed chunks.\n"
1086
1086
  },
1087
1087
  {
1088
1088
  "path": "agents/change-signals.md",
@@ -1 +1 @@
1
- {"version":3,"file":"bootstrap.d.ts","sourceRoot":"","sources":["../../src/system/bootstrap.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAG5D,OAAO,KAAK,EAAE,iBAAiB,EAAqB,MAAM,oBAAoB,CAAC;AA8H/E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,wCAAwC,CAAC,CAAC,EAC9D,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,IAAI,EAAE,CAAC,QAAQ,EAAE,iBAAiB,KAAK,OAAO,CAAC,CAAC,CAAC,GAChD,OAAO,CAAC,CAAC,CAAC,CA2LZ;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAIf"}
1
+ {"version":3,"file":"bootstrap.d.ts","sourceRoot":"","sources":["../../src/system/bootstrap.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAG5D,OAAO,KAAK,EAAE,iBAAiB,EAAqB,MAAM,oBAAoB,CAAC;AA8H/E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,wCAAwC,CAAC,CAAC,EAC9D,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,IAAI,EAAE,CAAC,QAAQ,EAAE,iBAAiB,KAAK,OAAO,CAAC,CAAC,CAAC,GAChD,OAAO,CAAC,CAAC,CAAC,CA2LZ;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAoBf"}
@@ -135,6 +135,10 @@ async function runSerializedAgainstSystemTableBootstrap(db, typeHint, work) {
135
135
  * transaction; other engines use the schema's idempotent DDL directly.
136
136
  */
137
137
  async function ensureSystemTables(db, typeHint) {
138
+ if (getDatabaseEngine(db, typeHint) === "postgres" && await isSystemSchemaVersionApplied(db, typeHint)) {
139
+ await ensurePostgresChangeFeedHelpers(db, typeHint);
140
+ return;
141
+ }
138
142
  await runSerializedAgainstSystemTableBootstrap(db, typeHint, (scopedDb) => bootstrapSystemTables(scopedDb, typeHint));
139
143
  }
140
144
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"bootstrap.js","names":[],"sources":["../../src/system/bootstrap.ts"],"sourcesContent":["/** Canonical, idempotent SMRT system-table provisioning. */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface, TransactionHandle } from '@happyvertical/sql';\nimport {\n ensurePostgresChangeFeedAppendFunction,\n ensurePostgresChangeFeedHelpers,\n} from '../change-feed.js';\nimport {\n assertPostgresSystemTimestampsCurrent,\n ensureBootstrapSystemTableCompatibility,\n getDatabaseEngine,\n tableExists,\n} from './compatibility.js';\nimport { getSystemTableDDL, SMRT_SCHEMA_VERSION } from './schema.js';\n\nconst SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL =\n \"SELECT pg_advisory_xact_lock(hashtext('smrt'), hashtext('system-tables'))\";\n\n/**\n * Timeout budget for the PostgreSQL system-table bootstrap transaction.\n *\n * The runtime pool's session `lock_timeout`/`statement_timeout` (#2377) are\n * sized for request work. This transaction is not request work: it holds the\n * advisory lock across up to 29 sequential DDL round-trips — ~18.85 s on a\n * high-latency link at 650 ms per round trip — and a second replica cold-starting\n * against the same fresh database *waits* on that lock. Both GUCs bound that\n * wait, because `pg_advisory_xact_lock` is an ordinary statement in the lock\n * manager, so at the runtime defaults the second replica would abort with\n * \"canceling statement due to lock timeout\" where it previously waited and\n * succeeded.\n *\n * Five minutes is an order of magnitude above the documented worst case and\n * still bounded — this is a raise, not a disable. `SET LOCAL` scopes it to this\n * transaction, the same lever migrations use for the same reason (#2362).\n */\nconst SYSTEM_TABLE_BOOTSTRAP_TIMEOUT_SQL = [\n \"SET LOCAL lock_timeout = '300000ms'\",\n \"SET LOCAL statement_timeout = '300000ms'\",\n];\nconst logger = createLogger({ level: 'info' });\n\ntype TransactionCapableDatabase = DatabaseInterface & {\n transaction?: <T>(\n this: DatabaseInterface,\n callback: (tx: DatabaseInterface) => Promise<T>,\n ) => Promise<T>;\n};\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) return result as Record<string, unknown>[];\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) return rows as Record<string, unknown>[];\n }\n return [];\n}\n\nasync function isSystemSchemaVersionApplied(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<boolean> {\n const engine = getDatabaseEngine(db, typeHint);\n if (\n engine === 'postgres' &&\n !(await tableExists(db, '_smrt_migrations', typeHint))\n ) {\n return false;\n }\n try {\n const versionParam = engine === 'postgres' ? '$1' : '?';\n const rows = await db.query(\n `SELECT 1 FROM _smrt_migrations WHERE version = ${versionParam} LIMIT 1`,\n SMRT_SCHEMA_VERSION,\n );\n return getQueryRows(rows).length > 0;\n } catch (error) {\n if (engine === 'postgres') throw error;\n return false;\n }\n}\n\nasync function bootstrapSystemTables(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n // #2649: the change-feed helpers changed without a change to the portable\n // system DDL, so a database already stamped with this version would never\n // reach the install below and would keep the deadlocking append function.\n // Ordinary model writes do not call ensureChangeFeedTable(), so nothing else\n // would repair it. One catalog probe when the helpers are already current.\n await ensurePostgresChangeFeedHelpers(db, typeHint);\n\n if (await isSystemSchemaVersionApplied(db, typeHint)) return;\n\n await ensureBootstrapSystemTableCompatibility(db, typeHint);\n const engine = getDatabaseEngine(db, typeHint);\n for (const ddl of getSystemTableDDL(engine)) {\n for (const statement of ddl\n .split(';')\n .map((value) => value.trim())\n .filter(Boolean)) {\n await db.query(statement);\n }\n }\n await ensurePostgresChangeFeedAppendFunction(db, { typeHint });\n await assertPostgresSystemTimestampsCurrent(db, typeHint);\n\n const id = crypto.randomUUID();\n const description = 'Initial SMRT system tables';\n await db.execute`\n INSERT INTO _smrt_migrations (id, version, description)\n VALUES (${id}, ${SMRT_SCHEMA_VERSION}, ${description})\n ON CONFLICT(version) DO NOTHING\n `;\n}\n\nasync function rollbackBootstrap(tx: TransactionHandle): Promise<void> {\n try {\n if (typeof tx.isActive !== 'function' || tx.isActive()) await tx.rollback();\n } catch (error) {\n logger.warn(\n `[smrt] Failed to rollback system table bootstrap transaction: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n }\n}\n\n/**\n * Run `work` serialized against concurrent SMRT system-table bootstrap.\n *\n * `_smrt_dispatch`/`_smrt_dispatch_subscriptions` (and every other\n * framework-owned `_smrt_*` table) are catalog objects shared by every\n * caller against one physical database — including standalone initializers\n * such as `DispatchBus.initialize()` that provision or verify a subset of\n * {@link ALL_SYSTEM_TABLES} outside of a full {@link ensureSystemTables} run\n * (#2861). PostgreSQL DDL against the same object from concurrent sessions is\n * not internally serialized (`CREATE TABLE IF NOT EXISTS` races on\n * `pg_type_typname_nsp_index` under true concurrency; `ALTER TABLE`/`CREATE\n * INDEX` take catalog locks that queue behind each other), so every writer of\n * a system table — full bootstrap or a standalone initializer alike — must\n * take the *same* advisory lock before touching it. Using one shared lock key\n * across all of them is what makes them mutually exclusive rather than each\n * safe only against itself.\n *\n * PostgreSQL: wraps `work` in the same bounded advisory-locked transaction\n * {@link bootstrapSystemTables} uses. Other engines have no comparable\n * catalog race for this codebase's supported deployment shapes, so `work`\n * runs directly against `db`.\n */\nexport async function runSerializedAgainstSystemTableBootstrap<T>(\n db: DatabaseInterface,\n typeHint: string | undefined,\n work: (scopedDb: DatabaseInterface) => Promise<T>,\n): Promise<T> {\n if (getDatabaseEngine(db, typeHint) !== 'postgres') {\n return work(db);\n }\n\n const beginTransaction = db.beginTransaction;\n const transaction = (db as TransactionCapableDatabase).transaction;\n\n if (typeof beginTransaction === 'function') {\n const tx = await beginTransaction.call(db);\n if (!tx) throw new Error('Database transaction could not be started');\n try {\n // Raise the budget before taking the lock — the wait itself is what the\n // runtime session timeouts would otherwise cancel (#2377).\n for (const sql of SYSTEM_TABLE_BOOTSTRAP_TIMEOUT_SQL) await tx.query(sql);\n await tx.query(SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL);\n const result = await work(tx);\n await tx.commit();\n return result;\n } catch (error) {\n await rollbackBootstrap(tx);\n throw error;\n }\n }\n\n if (typeof transaction === 'function') {\n // `db` has `transaction()` but no `beginTransaction()`. Two distinct\n // shapes reach here, and they are NOT the same case:\n //\n // - `beginTransaction()`'s own returned handle additionally carries\n // `commit`/`rollback`/`isActive` (e.g. `createIsolatedTestDb()`'s\n // per-test transaction, #2861 review Finding 3:\n // `packages/vitest/src/__tests__/\n // issue-2429-postgres-system-tables.optional.test.ts` hands one\n // straight to `createDispatchBus`). That handle already *is* inside a\n // transaction.\n // - Everything else that carries `transaction` alone and neither\n // `commit` nor `rollback` is NOT necessarily already inside one.\n // Issue #35's pre-existing contract\n // (`packages/core/src/__tests__/\n // issue-35-system-tables-initialization.test.ts`) requires a\n // transaction-capable-but-idle adapter of exactly this shape to run\n // its DDL through `db.transaction(cb)`, not directly against `db`.\n // The callback argument of `db.transaction(cb)` itself (#2861 review\n // recall: `class.ts`/`agent.ts` can reach this shape via `db: tx`)\n // carries the identical shape while already being inside the\n // enclosing transaction — `@happyvertical/sql` has no public marker\n // for \"this handle is already inside a transaction\" to tell the two\n // apart (upstream tracked: happyvertical/sdk#1249). Filed rather than\n // silently widened further: opening via `db.transaction(cb)` serves\n // both — for a genuinely idle handle it is an ordinary transaction\n // (issue #35's contract), and for the callback-argument shape\n // `@happyvertical/sql` documents that nesting re-enters the enclosing\n // transaction under a `SAVEPOINT` on the same connection, so the DDL\n // still runs against the one real transaction either way.\n const alreadyInTransaction =\n typeof (db as Partial<TransactionHandle>).commit === 'function' &&\n typeof (db as Partial<TransactionHandle>).rollback === 'function';\n\n if (!alreadyInTransaction) {\n // Capture on `db` *before* opening the nested scope: for the\n // callback-argument shape this reads the enclosing transaction's\n // current budget so it can be restored; for a genuinely idle handle\n // it reads the ambient session default, which the restore below then\n // harmlessly reapplies to the (about to end) fresh transaction.\n const priorLockTimeout = getQueryRows(\n await db.query('SHOW lock_timeout'),\n )[0]?.lock_timeout;\n const priorStatementTimeout = getQueryRows(\n await db.query('SHOW statement_timeout'),\n )[0]?.statement_timeout;\n // Called via `.call(db, ...)` with explicit type arguments — the\n // established pattern for this same `TransactionCapable`-style shape\n // elsewhere in this package (`cascade.ts`'s two `transaction.call<...>`\n // sites) — rather than a bare `transaction(...)` call, which would\n // drop the `this: DatabaseInterface` receiver the declared signature\n // requires. No live `@happyvertical/sql` adapter's `transaction`\n // currently reads `this`, but nothing guarantees that of every\n // `DatabaseInterface` implementer.\n return transaction.call<\n DatabaseInterface,\n [(tx: DatabaseInterface) => Promise<T>],\n Promise<T>\n >(db, async (tx) => {\n for (const sql of SYSTEM_TABLE_BOOTSTRAP_TIMEOUT_SQL) {\n await tx.query(sql);\n }\n await tx.query(SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL);\n const result = await work(tx);\n // Restore only on success. On failure — DDL error or a timed-out\n // lock wait — the (sub)transaction is already aborted (PostgreSQL\n // 25P02: no statement but ROLLBACK/ROLLBACK TO SAVEPOINT is\n // accepted), so issuing `SET LOCAL` here would itself error and\n // replace the real failure. That path needs no explicit restore\n // anyway: `db.transaction(cb)` rolls back to the savepoint on a\n // thrown error, and `ROLLBACK TO SAVEPOINT` *does* undo `SET\n // LOCAL` (unlike the `RELEASE SAVEPOINT` success path this\n // restore guards against — confirmed directly against a live\n // PostgreSQL 17 instance).\n if (typeof priorLockTimeout === 'string') {\n await tx.query(`SET LOCAL lock_timeout = '${priorLockTimeout}'`);\n }\n if (typeof priorStatementTimeout === 'string') {\n await tx.query(\n `SET LOCAL statement_timeout = '${priorStatementTimeout}'`,\n );\n }\n return result;\n });\n }\n\n // `pg_advisory_xact_lock` run directly against `db` here (no new\n // transaction, no savepoint) scopes to the transaction `db` is already\n // in and auto-releases only when *that* transaction commits or rolls\n // back — exactly like the branch above, which opens and promptly\n // commits its own. This matters for more than tidiness:\n //\n // - Wrapping `db`'s own `transaction()` (a savepoint) to take the lock\n // was tried and reverted: PostgreSQL does not scope\n // `pg_advisory_xact_lock`/`SET LOCAL` to a savepoint — both live for\n // the rest of the *transaction* — so it leaked the lock hold and a\n // raised timeout budget into the caller's transaction (confirmed:\n // `SHOW lock_timeout` read back the raised `5min` budget instead of\n // the caller's original value after `createDispatchBus()` returned).\n // - A session-scoped `pg_advisory_lock`/`pg_advisory_unlock` pair\n // released right after `work()` was tried next and also reverted:\n // releasing the lock before the caller's own transaction ends does\n // not close the actual race — two callers each holding their own\n // uncommitted transaction still deadlock on PostgreSQL's ordinary\n // catalog lock for the not-yet-visible `CREATE TABLE`, entirely\n // independent of our advisory lock, because that catalog lock is held\n // until the *first* caller's transaction ends, not until it releases\n // our lock (confirmed by direct reproduction).\n //\n // Taking the xact-scoped lock directly on `db` is the only scheme that\n // actually closes this: a second caller's lock acquisition blocks until\n // the first caller's transaction ends, by which point its DDL is either\n // committed (visible) or rolled back (gone) — no catalog-lock stall\n // either way.\n //\n // The wait for this lock still needs the same raised budget the\n // sibling branch above gives itself — every framework-owned connection\n // (`applyPostgresRuntimeTimeouts()`, wired into `SmrtClass`,\n // `createDispatchBus`'s own `getDatabase()` path, and the generators)\n // bakes a request-sized `lock_timeout=10000ms` into the connection by\n // default, so without a raise here a caller-owned transaction that\n // holds this lock for longer than 10s (concurrent callers now correctly\n // serialize on it — see the shapes above) would abort the *next*\n // waiter with \"canceling statement due to lock timeout\", reproducing\n // the #2861 symptom class through the timeout door instead of the\n // catalog-race door. Unlike the sibling branch, this one must not keep\n // the raise for the caller's remaining transaction: capture the\n // caller's current values first and restore them immediately after\n // acquiring the lock, before `work(db)` runs — the raise covers only\n // the wait PostgreSQL's own lock manager enforces, never the caller's\n // subsequent statements.\n const priorLockTimeout = getQueryRows(\n await db.query('SHOW lock_timeout'),\n )[0]?.lock_timeout;\n const priorStatementTimeout = getQueryRows(\n await db.query('SHOW statement_timeout'),\n )[0]?.statement_timeout;\n for (const sql of SYSTEM_TABLE_BOOTSTRAP_TIMEOUT_SQL) await db.query(sql);\n await db.query(SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL);\n // Restore only after the lock is actually acquired. `db` here is the\n // caller's real top-level transaction, not a savepoint: if the wait\n // itself times out, PostgreSQL has already aborted that transaction\n // (25P02 — no statement but ROLLBACK is accepted until it ends), so a\n // `finally`-scoped restore would itself error and replace the real\n // lock-timeout failure with a confusing \"transaction is aborted\"\n // error. There is nothing to restore in that case either: the caller's\n // transaction never reaches `work()` and ends via rollback, which\n // discards the raised values along with everything else.\n if (typeof priorLockTimeout === 'string') {\n await db.query(`SET LOCAL lock_timeout = '${priorLockTimeout}'`);\n }\n if (typeof priorStatementTimeout === 'string') {\n await db.query(\n `SET LOCAL statement_timeout = '${priorStatementTimeout}'`,\n );\n }\n return work(db);\n }\n\n throw new Error(\n 'Postgres system table bootstrap requires a transaction-capable database adapter',\n );\n}\n\n/**\n * Ensure every framework-owned SMRT system table exists before use.\n *\n * PostgreSQL provisioning is serialized in a bounded advisory-locked\n * transaction; other engines use the schema's idempotent DDL directly.\n */\nexport async function ensureSystemTables(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n await runSerializedAgainstSystemTableBootstrap(db, typeHint, (scopedDb) =>\n bootstrapSystemTables(scopedDb, typeHint),\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"bootstrap.js","names":[],"sources":["../../src/system/bootstrap.ts"],"sourcesContent":["/** Canonical, idempotent SMRT system-table provisioning. */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface, TransactionHandle } from '@happyvertical/sql';\nimport {\n ensurePostgresChangeFeedAppendFunction,\n ensurePostgresChangeFeedHelpers,\n} from '../change-feed.js';\nimport {\n assertPostgresSystemTimestampsCurrent,\n ensureBootstrapSystemTableCompatibility,\n getDatabaseEngine,\n tableExists,\n} from './compatibility.js';\nimport { getSystemTableDDL, SMRT_SCHEMA_VERSION } from './schema.js';\n\nconst SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL =\n \"SELECT pg_advisory_xact_lock(hashtext('smrt'), hashtext('system-tables'))\";\n\n/**\n * Timeout budget for the PostgreSQL system-table bootstrap transaction.\n *\n * The runtime pool's session `lock_timeout`/`statement_timeout` (#2377) are\n * sized for request work. This transaction is not request work: it holds the\n * advisory lock across up to 29 sequential DDL round-trips — ~18.85 s on a\n * high-latency link at 650 ms per round trip — and a second replica cold-starting\n * against the same fresh database *waits* on that lock. Both GUCs bound that\n * wait, because `pg_advisory_xact_lock` is an ordinary statement in the lock\n * manager, so at the runtime defaults the second replica would abort with\n * \"canceling statement due to lock timeout\" where it previously waited and\n * succeeded.\n *\n * Five minutes is an order of magnitude above the documented worst case and\n * still bounded — this is a raise, not a disable. `SET LOCAL` scopes it to this\n * transaction, the same lever migrations use for the same reason (#2362).\n */\nconst SYSTEM_TABLE_BOOTSTRAP_TIMEOUT_SQL = [\n \"SET LOCAL lock_timeout = '300000ms'\",\n \"SET LOCAL statement_timeout = '300000ms'\",\n];\nconst logger = createLogger({ level: 'info' });\n\ntype TransactionCapableDatabase = DatabaseInterface & {\n transaction?: <T>(\n this: DatabaseInterface,\n callback: (tx: DatabaseInterface) => Promise<T>,\n ) => Promise<T>;\n};\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) return result as Record<string, unknown>[];\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) return rows as Record<string, unknown>[];\n }\n return [];\n}\n\nasync function isSystemSchemaVersionApplied(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<boolean> {\n const engine = getDatabaseEngine(db, typeHint);\n if (\n engine === 'postgres' &&\n !(await tableExists(db, '_smrt_migrations', typeHint))\n ) {\n return false;\n }\n try {\n const versionParam = engine === 'postgres' ? '$1' : '?';\n const rows = await db.query(\n `SELECT 1 FROM _smrt_migrations WHERE version = ${versionParam} LIMIT 1`,\n SMRT_SCHEMA_VERSION,\n );\n return getQueryRows(rows).length > 0;\n } catch (error) {\n if (engine === 'postgres') throw error;\n return false;\n }\n}\n\nasync function bootstrapSystemTables(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n // #2649: the change-feed helpers changed without a change to the portable\n // system DDL, so a database already stamped with this version would never\n // reach the install below and would keep the deadlocking append function.\n // Ordinary model writes do not call ensureChangeFeedTable(), so nothing else\n // would repair it. One catalog probe when the helpers are already current.\n await ensurePostgresChangeFeedHelpers(db, typeHint);\n\n if (await isSystemSchemaVersionApplied(db, typeHint)) return;\n\n await ensureBootstrapSystemTableCompatibility(db, typeHint);\n const engine = getDatabaseEngine(db, typeHint);\n for (const ddl of getSystemTableDDL(engine)) {\n for (const statement of ddl\n .split(';')\n .map((value) => value.trim())\n .filter(Boolean)) {\n await db.query(statement);\n }\n }\n await ensurePostgresChangeFeedAppendFunction(db, { typeHint });\n await assertPostgresSystemTimestampsCurrent(db, typeHint);\n\n const id = crypto.randomUUID();\n const description = 'Initial SMRT system tables';\n await db.execute`\n INSERT INTO _smrt_migrations (id, version, description)\n VALUES (${id}, ${SMRT_SCHEMA_VERSION}, ${description})\n ON CONFLICT(version) DO NOTHING\n `;\n}\n\nasync function rollbackBootstrap(tx: TransactionHandle): Promise<void> {\n try {\n if (typeof tx.isActive !== 'function' || tx.isActive()) await tx.rollback();\n } catch (error) {\n logger.warn(\n `[smrt] Failed to rollback system table bootstrap transaction: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n }\n}\n\n/**\n * Run `work` serialized against concurrent SMRT system-table bootstrap.\n *\n * `_smrt_dispatch`/`_smrt_dispatch_subscriptions` (and every other\n * framework-owned `_smrt_*` table) are catalog objects shared by every\n * caller against one physical database — including standalone initializers\n * such as `DispatchBus.initialize()` that provision or verify a subset of\n * {@link ALL_SYSTEM_TABLES} outside of a full {@link ensureSystemTables} run\n * (#2861). PostgreSQL DDL against the same object from concurrent sessions is\n * not internally serialized (`CREATE TABLE IF NOT EXISTS` races on\n * `pg_type_typname_nsp_index` under true concurrency; `ALTER TABLE`/`CREATE\n * INDEX` take catalog locks that queue behind each other), so every writer of\n * a system table — full bootstrap or a standalone initializer alike — must\n * take the *same* advisory lock before touching it. Using one shared lock key\n * across all of them is what makes them mutually exclusive rather than each\n * safe only against itself.\n *\n * PostgreSQL: wraps `work` in the same bounded advisory-locked transaction\n * {@link bootstrapSystemTables} uses. Other engines have no comparable\n * catalog race for this codebase's supported deployment shapes, so `work`\n * runs directly against `db`.\n */\nexport async function runSerializedAgainstSystemTableBootstrap<T>(\n db: DatabaseInterface,\n typeHint: string | undefined,\n work: (scopedDb: DatabaseInterface) => Promise<T>,\n): Promise<T> {\n if (getDatabaseEngine(db, typeHint) !== 'postgres') {\n return work(db);\n }\n\n const beginTransaction = db.beginTransaction;\n const transaction = (db as TransactionCapableDatabase).transaction;\n\n if (typeof beginTransaction === 'function') {\n const tx = await beginTransaction.call(db);\n if (!tx) throw new Error('Database transaction could not be started');\n try {\n // Raise the budget before taking the lock — the wait itself is what the\n // runtime session timeouts would otherwise cancel (#2377).\n for (const sql of SYSTEM_TABLE_BOOTSTRAP_TIMEOUT_SQL) await tx.query(sql);\n await tx.query(SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL);\n const result = await work(tx);\n await tx.commit();\n return result;\n } catch (error) {\n await rollbackBootstrap(tx);\n throw error;\n }\n }\n\n if (typeof transaction === 'function') {\n // `db` has `transaction()` but no `beginTransaction()`. Two distinct\n // shapes reach here, and they are NOT the same case:\n //\n // - `beginTransaction()`'s own returned handle additionally carries\n // `commit`/`rollback`/`isActive` (e.g. `createIsolatedTestDb()`'s\n // per-test transaction, #2861 review Finding 3:\n // `packages/vitest/src/__tests__/\n // issue-2429-postgres-system-tables.optional.test.ts` hands one\n // straight to `createDispatchBus`). That handle already *is* inside a\n // transaction.\n // - Everything else that carries `transaction` alone and neither\n // `commit` nor `rollback` is NOT necessarily already inside one.\n // Issue #35's pre-existing contract\n // (`packages/core/src/__tests__/\n // issue-35-system-tables-initialization.test.ts`) requires a\n // transaction-capable-but-idle adapter of exactly this shape to run\n // its DDL through `db.transaction(cb)`, not directly against `db`.\n // The callback argument of `db.transaction(cb)` itself (#2861 review\n // recall: `class.ts`/`agent.ts` can reach this shape via `db: tx`)\n // carries the identical shape while already being inside the\n // enclosing transaction — `@happyvertical/sql` has no public marker\n // for \"this handle is already inside a transaction\" to tell the two\n // apart (upstream tracked: happyvertical/sdk#1249). Filed rather than\n // silently widened further: opening via `db.transaction(cb)` serves\n // both — for a genuinely idle handle it is an ordinary transaction\n // (issue #35's contract), and for the callback-argument shape\n // `@happyvertical/sql` documents that nesting re-enters the enclosing\n // transaction under a `SAVEPOINT` on the same connection, so the DDL\n // still runs against the one real transaction either way.\n const alreadyInTransaction =\n typeof (db as Partial<TransactionHandle>).commit === 'function' &&\n typeof (db as Partial<TransactionHandle>).rollback === 'function';\n\n if (!alreadyInTransaction) {\n // Capture on `db` *before* opening the nested scope: for the\n // callback-argument shape this reads the enclosing transaction's\n // current budget so it can be restored; for a genuinely idle handle\n // it reads the ambient session default, which the restore below then\n // harmlessly reapplies to the (about to end) fresh transaction.\n const priorLockTimeout = getQueryRows(\n await db.query('SHOW lock_timeout'),\n )[0]?.lock_timeout;\n const priorStatementTimeout = getQueryRows(\n await db.query('SHOW statement_timeout'),\n )[0]?.statement_timeout;\n // Called via `.call(db, ...)` with explicit type arguments — the\n // established pattern for this same `TransactionCapable`-style shape\n // elsewhere in this package (`cascade.ts`'s two `transaction.call<...>`\n // sites) — rather than a bare `transaction(...)` call, which would\n // drop the `this: DatabaseInterface` receiver the declared signature\n // requires. No live `@happyvertical/sql` adapter's `transaction`\n // currently reads `this`, but nothing guarantees that of every\n // `DatabaseInterface` implementer.\n return transaction.call<\n DatabaseInterface,\n [(tx: DatabaseInterface) => Promise<T>],\n Promise<T>\n >(db, async (tx) => {\n for (const sql of SYSTEM_TABLE_BOOTSTRAP_TIMEOUT_SQL) {\n await tx.query(sql);\n }\n await tx.query(SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL);\n const result = await work(tx);\n // Restore only on success. On failure — DDL error or a timed-out\n // lock wait — the (sub)transaction is already aborted (PostgreSQL\n // 25P02: no statement but ROLLBACK/ROLLBACK TO SAVEPOINT is\n // accepted), so issuing `SET LOCAL` here would itself error and\n // replace the real failure. That path needs no explicit restore\n // anyway: `db.transaction(cb)` rolls back to the savepoint on a\n // thrown error, and `ROLLBACK TO SAVEPOINT` *does* undo `SET\n // LOCAL` (unlike the `RELEASE SAVEPOINT` success path this\n // restore guards against — confirmed directly against a live\n // PostgreSQL 17 instance).\n if (typeof priorLockTimeout === 'string') {\n await tx.query(`SET LOCAL lock_timeout = '${priorLockTimeout}'`);\n }\n if (typeof priorStatementTimeout === 'string') {\n await tx.query(\n `SET LOCAL statement_timeout = '${priorStatementTimeout}'`,\n );\n }\n return result;\n });\n }\n\n // `pg_advisory_xact_lock` run directly against `db` here (no new\n // transaction, no savepoint) scopes to the transaction `db` is already\n // in and auto-releases only when *that* transaction commits or rolls\n // back — exactly like the branch above, which opens and promptly\n // commits its own. This matters for more than tidiness:\n //\n // - Wrapping `db`'s own `transaction()` (a savepoint) to take the lock\n // was tried and reverted: PostgreSQL does not scope\n // `pg_advisory_xact_lock`/`SET LOCAL` to a savepoint — both live for\n // the rest of the *transaction* — so it leaked the lock hold and a\n // raised timeout budget into the caller's transaction (confirmed:\n // `SHOW lock_timeout` read back the raised `5min` budget instead of\n // the caller's original value after `createDispatchBus()` returned).\n // - A session-scoped `pg_advisory_lock`/`pg_advisory_unlock` pair\n // released right after `work()` was tried next and also reverted:\n // releasing the lock before the caller's own transaction ends does\n // not close the actual race — two callers each holding their own\n // uncommitted transaction still deadlock on PostgreSQL's ordinary\n // catalog lock for the not-yet-visible `CREATE TABLE`, entirely\n // independent of our advisory lock, because that catalog lock is held\n // until the *first* caller's transaction ends, not until it releases\n // our lock (confirmed by direct reproduction).\n //\n // Taking the xact-scoped lock directly on `db` is the only scheme that\n // actually closes this: a second caller's lock acquisition blocks until\n // the first caller's transaction ends, by which point its DDL is either\n // committed (visible) or rolled back (gone) — no catalog-lock stall\n // either way.\n //\n // The wait for this lock still needs the same raised budget the\n // sibling branch above gives itself — every framework-owned connection\n // (`applyPostgresRuntimeTimeouts()`, wired into `SmrtClass`,\n // `createDispatchBus`'s own `getDatabase()` path, and the generators)\n // bakes a request-sized `lock_timeout=10000ms` into the connection by\n // default, so without a raise here a caller-owned transaction that\n // holds this lock for longer than 10s (concurrent callers now correctly\n // serialize on it — see the shapes above) would abort the *next*\n // waiter with \"canceling statement due to lock timeout\", reproducing\n // the #2861 symptom class through the timeout door instead of the\n // catalog-race door. Unlike the sibling branch, this one must not keep\n // the raise for the caller's remaining transaction: capture the\n // caller's current values first and restore them immediately after\n // acquiring the lock, before `work(db)` runs — the raise covers only\n // the wait PostgreSQL's own lock manager enforces, never the caller's\n // subsequent statements.\n const priorLockTimeout = getQueryRows(\n await db.query('SHOW lock_timeout'),\n )[0]?.lock_timeout;\n const priorStatementTimeout = getQueryRows(\n await db.query('SHOW statement_timeout'),\n )[0]?.statement_timeout;\n for (const sql of SYSTEM_TABLE_BOOTSTRAP_TIMEOUT_SQL) await db.query(sql);\n await db.query(SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL);\n // Restore only after the lock is actually acquired. `db` here is the\n // caller's real top-level transaction, not a savepoint: if the wait\n // itself times out, PostgreSQL has already aborted that transaction\n // (25P02 — no statement but ROLLBACK is accepted until it ends), so a\n // `finally`-scoped restore would itself error and replace the real\n // lock-timeout failure with a confusing \"transaction is aborted\"\n // error. There is nothing to restore in that case either: the caller's\n // transaction never reaches `work()` and ends via rollback, which\n // discards the raised values along with everything else.\n if (typeof priorLockTimeout === 'string') {\n await db.query(`SET LOCAL lock_timeout = '${priorLockTimeout}'`);\n }\n if (typeof priorStatementTimeout === 'string') {\n await db.query(\n `SET LOCAL statement_timeout = '${priorStatementTimeout}'`,\n );\n }\n return work(db);\n }\n\n throw new Error(\n 'Postgres system table bootstrap requires a transaction-capable database adapter',\n );\n}\n\n/**\n * Ensure every framework-owned SMRT system table exists before use.\n *\n * PostgreSQL provisioning is serialized in a bounded advisory-locked\n * transaction; other engines use the schema's idempotent DDL directly.\n */\nexport async function ensureSystemTables(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n // Lock-free fast path for an already-provisioned PostgreSQL database. The\n // bootstrap lock is transaction-scoped, and on a handle that is already\n // inside a caller's transaction it is held until *that* transaction ends —\n // even when the version probe below finds nothing to install. First use of\n // a model inside concurrent request transactions therefore serialized every\n // one of them behind the first to commit (#2868: the PostgreSQL OIDC\n // provisioning scenarios wait on exactly that lock). A committed version\n // stamp means every table exists, so only the helper probe remains; it\n // takes the same lock server-side, and only when it has to install.\n if (\n getDatabaseEngine(db, typeHint) === 'postgres' &&\n (await isSystemSchemaVersionApplied(db, typeHint))\n ) {\n await ensurePostgresChangeFeedHelpers(db, typeHint);\n return;\n }\n await runSerializedAgainstSystemTableBootstrap(db, typeHint, (scopedDb) =>\n bootstrapSystemTables(scopedDb, typeHint),\n );\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-core",
3
- "version": "0.51.19",
3
+ "version": "0.51.20",
4
4
  "description": "Core AI agent framework with standardized collections, object-relational mapping, and code generators",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -154,9 +154,9 @@
154
154
  "@happyvertical/files": "^0.89.12",
155
155
  "@happyvertical/json": "^0.89.12",
156
156
  "@happyvertical/logger": "^0.89.12",
157
- "@happyvertical/smrt-config": "0.51.19",
158
- "@happyvertical/smrt-scanner": "0.51.19",
159
- "@happyvertical/smrt-types": "0.51.19",
157
+ "@happyvertical/smrt-config": "0.51.20",
158
+ "@happyvertical/smrt-scanner": "0.51.20",
159
+ "@happyvertical/smrt-types": "0.51.20",
160
160
  "@happyvertical/sql": "^0.89.12",
161
161
  "@happyvertical/utils": "^0.89.12",
162
162
  "@oxc-project/runtime": "^0.150.0",