@happyvertical/smrt-core 0.57.30 → 0.57.32

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.
Files changed (55) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +23 -0
  3. package/agents/deployment-phases.md +71 -0
  4. package/dist/browser.d.ts +1 -0
  5. package/dist/browser.d.ts.map +1 -1
  6. package/dist/browser.js +2 -1
  7. package/dist/class.d.ts.map +1 -1
  8. package/dist/class.js +1 -1
  9. package/dist/class.js.map +1 -1
  10. package/dist/consumer-plugin/index.d.ts +2 -2
  11. package/dist/consumer-plugin/index.d.ts.map +1 -1
  12. package/dist/consumer-plugin/index.js.map +1 -1
  13. package/dist/external-agent-usage.d.ts +90 -0
  14. package/dist/external-agent-usage.d.ts.map +1 -0
  15. package/dist/external-agent-usage.js +272 -0
  16. package/dist/external-agent-usage.js.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/manifest/static-manifest.js +1 -1
  21. package/dist/manifest/static-manifest.js.map +1 -1
  22. package/dist/manifest/store.js +1 -1
  23. package/dist/manifest.json +1 -1
  24. package/dist/migrations/deployment-phase-tracker.d.ts +57 -0
  25. package/dist/migrations/deployment-phase-tracker.d.ts.map +1 -0
  26. package/dist/migrations/deployment-phase-tracker.js +179 -0
  27. package/dist/migrations/deployment-phase-tracker.js.map +1 -0
  28. package/dist/migrations/index.d.ts +1 -0
  29. package/dist/migrations/index.d.ts.map +1 -1
  30. package/dist/migrations/index.js +2 -1
  31. package/dist/migrations.js +2 -1
  32. package/dist/smrt-knowledge.json +11 -5
  33. package/dist/system/retention.d.ts.map +1 -1
  34. package/dist/system/retention.js +1 -0
  35. package/dist/system/retention.js.map +1 -1
  36. package/dist/vite-plugin/asset-url-compatibility.d.ts +2 -2
  37. package/dist/vite-plugin/asset-url-compatibility.d.ts.map +1 -1
  38. package/dist/vite-plugin/asset-url-compatibility.js +13 -3
  39. package/dist/vite-plugin/asset-url-compatibility.js.map +1 -1
  40. package/dist/vite-plugin/index.d.ts +5 -2
  41. package/dist/vite-plugin/index.d.ts.map +1 -1
  42. package/dist/vite-plugin/index.js.map +1 -1
  43. package/dist/vite-plugin/plugin-contract.d.ts +164 -0
  44. package/dist/vite-plugin/plugin-contract.d.ts.map +1 -0
  45. package/dist/vite-plugin/sveltekit-route-coordinator.d.ts +5 -5
  46. package/dist/vite-plugin/sveltekit-route-coordinator.d.ts.map +1 -1
  47. package/dist/vite-plugin/sveltekit-route-coordinator.js.map +1 -1
  48. package/dist/vite-plugin/worker-registration.d.ts +4 -4
  49. package/dist/vite-plugin/worker-registration.d.ts.map +1 -1
  50. package/dist/vite-plugin/worker-registration.js.map +1 -1
  51. package/dist/vite-preset/index.d.ts +2 -2
  52. package/dist/vite-preset/index.d.ts.map +1 -1
  53. package/dist/vite-preset/index.js +9 -1
  54. package/dist/vite-preset/index.js.map +1 -1
  55. package/package.json +4 -4
@@ -3,19 +3,20 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.57.30",
6
+ "packageVersion": "0.57.32",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "6ca617588ae07a1d7bde21614cc2b68bdfc1a1f58d96e192aad2e4e98544b677",
11
- "packageJson": "7614147170aae89909a0f2481e4292395aa0b38a206485236758df96eae8b619",
12
- "agents": "3a78a0babd2884ac22e4b376a3c9618037ab818ad4bdd762751f82fa53c21d04",
10
+ "manifest": "7d1109a2963406b400683204d48276f2b9ebdee2d8b11fb6c30d8d1452d5fe99",
11
+ "packageJson": "0bf6bf65d2d7f03263a19caf78db044dd842a36e7e222f9b0baf3e962c973c11",
12
+ "agents": "952dd7df7461bd7657b346f3ea63f1d240a625863b6ceef7b0f6d4960f978068",
13
13
  "moduleDoc:agents/object-runtime.md": "fd2748649e820c2fe5960fa4a44c5f3e2a5afa57da89925e8b4cb3b53d67a07b",
14
14
  "moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
15
15
  "moduleDoc:agents/collection-reads.md": "39acea7faf3505d38bfb999e414f4c4c6f964ec7c3d19cc52993302c29223029",
16
16
  "moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5",
17
17
  "moduleDoc:agents/data-query.md": "1b72411d441ce2285bf0c89674e7aa996832a58b552b23a6d43834b907d91355",
18
18
  "moduleDoc:agents/schema-paths.md": "aef1b6fd687d0dbe444d20bf318e7c62fb9ef063ead933d2fd8168b87fee032e",
19
+ "moduleDoc:agents/deployment-phases.md": "e1036073f31d54db95de95e498c74cddce5e47f367d56ccc00aad69f4e5a28e1",
19
20
  "moduleDoc:agents/change-feed.md": "c3d6206758dd1d2e0475177e1938fb7d7b6cfe8a6c4d3c3b56ef62bc7bc7739d",
20
21
  "moduleDoc:agents/change-signals.md": "a2dbe7077483b5a19bd5ea9589b5a08fe847377c8e05d01d57f813c6d9cbc28b",
21
22
  "moduleDoc:agents/generators.md": "abf359fdc6fa7f0afaf4a096d52b75982084b80d539a4ed021d3d95b47714b14",
@@ -1148,7 +1149,7 @@
1148
1149
  "polymorphicAssociations": 1,
1149
1150
  "uuidColumns": 3
1150
1151
  },
1151
- "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`, `src/unchanged-indexed.ts`, `src/native-duckdb.ts` | Guarded writes, PostgreSQL revision precision, unchanged indexed columns on referenced DuckDB rows (#3737) | [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-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [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| `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |\n| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |\n| `src/recipe.ts`, `../scanner/src/recipes.ts` | `SmrtRecipe`: declared user-facing units (models, nav, `requires`, curation `options`) collected into manifest and knowledge `recipes` (#3590) | [agents/recipes.md](agents/recipes.md) |\n| `src/cookbook/` | Cookbook v1 document: browser-safe validate/migrate, JSON Schema (`./cookbook`, `./cookbook/v1.schema.json`; #3748) | [agents/cookbook.md](agents/cookbook.md) |\n| `src/cookbook/engine/` | Headless cookbook command engine: typed commands, idempotent retries, atomic batches, receipts, tool schemas (`./cookbook/engine`; #3753) | [agents/cookbook-engine.md](agents/cookbook-engine.md) |\n| `src/browser.ts`, `src/host.ts`, `src/host.browser.ts` | Browser entry that builds and evaluates without Node-only modules: host seam, `package.json#browser` swaps, `pnpm test:browser` (#2838) | [agents/browser-entry.md](agents/browser-entry.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Any output that names a database URL (CLI banners, logs, errors) renders it\n with `redactDatabaseUrl` / `redactDatabaseUrlsInText`\n (`src/utils/database-url.ts`, #3527): never the password or query string,\n and never the raw string when it cannot be parsed.\n- `SmrtError` lives in import-free `src/smrt-error.ts`, published as\n `./smrt-error` (#4100) for browser-safe code that must not load the root\n entry; `errors.ts` re-exports that one class. Keep it import-free.\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 A `tenantId` field makes rows tenant-OWNED without any tenancy declaration:\n the default key leads with `tenant_id`. A new object's natural-key save reads\n the row its upsert would hit and refuses a different owner\n (`TENANT_ISOLATION_VIOLATION`) or adopts a same-owner row's id, so\n `DO UPDATE` never rewrites a primary key or owner. Only an explicit natural\n key adopts: a slug `getSlug()` derived from `name`/`title`/`label` moves to\n a free `-2`, `-3`, … slug and INSERTs. See `agents/schema-paths.md`\n (\"Tenant OWNERSHIP\").\n- Owning `CollectionInterceptor.writeGuard` predicates must reach final writes,\n including natural-key adoption and deletes; zero matched rows fail closed.\n See [object runtime](agents/object-runtime.md).\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- `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,\n #3080) claims a key derived from all four inputs — never the token alone,\n which collapses distinct submissions — via `INSERT ... ON CONFLICT\n (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL\n `_smrt_run_once_claims` system table, and runs `work` in the SAME\n transaction; a completed claim replays its stored result instead of\n re-running, a failed `work` rolls the claim back with the transaction, and\n an unresolved claim raises typed `RunOnceClaimError`\n (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.\n The claim insert must never raise on conflict: PostgreSQL aborts the whole\n transaction on any raised error, which would take the recovery `SELECT`\n down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`\n caught for `isUniqueViolationError`. Completed claims are bounded by\n `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,\n `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on\n `completed_at`; `in_progress` rows are never matched) — the window is a\n replay deadline, not just disk hygiene: a token replayed after its claim\n is swept runs `work()` again. See `agents/run-once.md`.\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- Decorator registration trusts a class's stack-derived package only when that\n package declares the class (stub, own manifest entry, or explicit\n `packageName`); it then matches the qualified key and never adopts another\n package's same-named registration. Otherwise a loaded (or the cwd project's)\n manifest entry describing the declaring file supplies the identity — apps\n scan workspace-package sources under the app's name (#3110). A class of\n another known package coexists under its qualified key\n (`decorator-different-packages-qualified-coexist`); bundled output needs\n a resolved table that differs first (#3106). Stack\n attribution skips smrt-core (including source-mapped installed\n `smrt-core/src` frames, #3109), decorator-helper, and module-runner frames,\n and drops a frame's Vite `?v=` query (#3490). Library builds stamp each\n decorated class with `static __smrtPackage__` (own property, read at\n decoration as its explicit package) and publish `stampsConstructors`; an\n unstamped class never takes a stamping package's entry by name and table,\n in either order (#3490, `issue-3490-*.test.ts`, `package-stamp.test.ts`).\n Qualified names are `@scope/pkg:Class`; the scope is a deliberate\n discriminator, so manifest generation (`generateManifest`,\n `applyGenerationPasses`, hence `pnpm build`) throws\n `CONFIG_UNSCOPED_PACKAGE_NAME` for an unscoped `package.json` name (#3408).\n Instance and collection registry lookups pass the qualified name. A parent\n imported from another scoped package is emitted as `extendsQualified`\n (scanner traces named, aliased, and namespace imports; `extends` stays the\n simple name), kept only when that package's manifest declares it\n (`verifyImportedParents`); decorator registration takes the parent from the\n real constructor's registration, which a stale simple manifest parent never\n overwrites (#4145, `issue-4145-*.test.ts`). The schema\n planner rejects a table claimed by two unrelated classes\n (`CONFIG_TABLE_NAME_COLLISION`): only one STI family shares a table (#3098);\n collections (including `SmrtReportCollection`) never claim one. Consumer\n gates: `registry/__tests__/issue-3110-*.test.ts`, `issue-3109-*.test.ts`,\n `packages/bundle-gate/src/__tests__/consumer-same-name.spec.ts`.\n- A collection class never publishes more than its model: unset surfaces\n inherit the model's, and the build rejects a collection that opens a CRUD\n write its model closes. See `agents/generators.md` (Collection exposure).\n- A model subclass (STI child or CTI subclass) never publishes more than its\n parent model: unset surfaces and `sensitive` inherit the parent's, and the\n build rejects a subclass that widens them unless it names each operation in\n `widenParentExposure`. See `agents/generators.md` (Subclass exposure).\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- `src/browser.ts` is the `browser`-condition entry of the package root: the\n Node entry (`index.ts`) minus HTTP servers, manifest/knowledge discovery, run-once,\n the Vite plugin, and the test helper. A new export added to `index.ts` must also\n go to `browser.ts` unless it is Node-only; `src/__tests__/browser-entry-parity.test.ts`\n fails otherwise (allowlist: `NODE_ONLY_GROUPS` in that test, each with a reason; the browser\n entry may not publish a name the Node entry lacks), and also\n fails when a workspace package imports a root value the browser entry lacks.\n- `exports['.'].browser` (`dist/browser.js`) is the browser surface. Model\n package roots are bundled for browsers by the bundle-gate browser fixture\n (#3621): anything a model root imports from core must be exported by the\n browser entry and must not reach `node:` built-ins, `pg`, `cosmiconfig`, or\n `jiti`. Keep Node-only code behind a subpath or a lazy import; when a fix\n makes a package pass, delete its entry in\n `packages/bundle-gate/src/browser-gate/expected-failures.ts`.\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\n`typecheck` checks production source first, then the test project in\n`tsconfig.test.json`. Test coverage and the shared relaxed config\ncontract are documented in [agents/test-types.md](agents/test-types.md).\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",
1152
+ "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`, `src/unchanged-indexed.ts`, `src/native-duckdb.ts` | Guarded writes, PostgreSQL revision precision, unchanged indexed columns on referenced DuckDB rows (#3737) | [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/migrations/deployment-phase-tracker.ts` | Permanent checksummed PostgreSQL deployment steps, atomic markers and explicit external-effect reconciliation | [agents/deployment-phases.md](agents/deployment-phases.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [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| `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |\n| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |\n| `src/recipe.ts`, `../scanner/src/recipes.ts` | `SmrtRecipe`: declared user-facing units (models, nav, `requires`, curation `options`) collected into manifest and knowledge `recipes` (#3590) | [agents/recipes.md](agents/recipes.md) |\n| `src/cookbook/` | Cookbook v1 document: browser-safe validate/migrate, JSON Schema (`./cookbook`, `./cookbook/v1.schema.json`; #3748) | [agents/cookbook.md](agents/cookbook.md) |\n| `src/cookbook/engine/` | Headless cookbook command engine: typed commands, idempotent retries, atomic batches, receipts, tool schemas (`./cookbook/engine`; #3753) | [agents/cookbook-engine.md](agents/cookbook-engine.md) |\n| `src/browser.ts`, `src/host.ts`, `src/host.browser.ts` | Browser entry that builds and evaluates without Node-only modules: host seam, `package.json#browser` swaps, `pnpm test:browser` (#2838) | [agents/browser-entry.md](agents/browser-entry.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Any output that names a database URL (CLI banners, logs, errors) renders it\n with `redactDatabaseUrl` / `redactDatabaseUrlsInText`\n (`src/utils/database-url.ts`, #3527): never the password or query string,\n and never the raw string when it cannot be parsed.\n- `SmrtError` lives in import-free `src/smrt-error.ts`, published as\n `./smrt-error` (#4100) for browser-safe code that must not load the root\n entry; `errors.ts` re-exports that one class. Keep it import-free.\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 A `tenantId` field makes rows tenant-OWNED without any tenancy declaration:\n the default key leads with `tenant_id`. A new object's natural-key save reads\n the row its upsert would hit and refuses a different owner\n (`TENANT_ISOLATION_VIOLATION`) or adopts a same-owner row's id, so\n `DO UPDATE` never rewrites a primary key or owner. Only an explicit natural\n key adopts: a slug `getSlug()` derived from `name`/`title`/`label` moves to\n a free `-2`, `-3`, … slug and INSERTs. See `agents/schema-paths.md`\n (\"Tenant OWNERSHIP\").\n- Owning `CollectionInterceptor.writeGuard` predicates must reach final writes,\n including natural-key adoption and deletes; zero matched rows fail closed.\n See [object runtime](agents/object-runtime.md).\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- `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,\n #3080) claims a key derived from all four inputs — never the token alone,\n which collapses distinct submissions — via `INSERT ... ON CONFLICT\n (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL\n `_smrt_run_once_claims` system table, and runs `work` in the SAME\n transaction; a completed claim replays its stored result instead of\n re-running, a failed `work` rolls the claim back with the transaction, and\n an unresolved claim raises typed `RunOnceClaimError`\n (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.\n The claim insert must never raise on conflict: PostgreSQL aborts the whole\n transaction on any raised error, which would take the recovery `SELECT`\n down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`\n caught for `isUniqueViolationError`. Completed claims are bounded by\n `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,\n `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on\n `completed_at`; `in_progress` rows are never matched) — the window is a\n replay deadline, not just disk hygiene: a token replayed after its claim\n is swept runs `work()` again. See `agents/run-once.md`.\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- Decorator registration trusts a class's stack-derived package only when that\n package declares the class (stub, own manifest entry, or explicit\n `packageName`); it then matches the qualified key and never adopts another\n package's same-named registration. Otherwise a loaded (or the cwd project's)\n manifest entry describing the declaring file supplies the identity — apps\n scan workspace-package sources under the app's name (#3110). A class of\n another known package coexists under its qualified key\n (`decorator-different-packages-qualified-coexist`); bundled output needs\n a resolved table that differs first (#3106). Stack\n attribution skips smrt-core (including source-mapped installed\n `smrt-core/src` frames, #3109), decorator-helper, and module-runner frames,\n and drops a frame's Vite `?v=` query (#3490). Library builds stamp each\n decorated class with `static __smrtPackage__` (own property, read at\n decoration as its explicit package) and publish `stampsConstructors`; an\n unstamped class never takes a stamping package's entry by name and table,\n in either order (#3490, `issue-3490-*.test.ts`, `package-stamp.test.ts`).\n Qualified names are `@scope/pkg:Class`; the scope is a deliberate\n discriminator, so manifest generation (`generateManifest`,\n `applyGenerationPasses`, hence `pnpm build`) throws\n `CONFIG_UNSCOPED_PACKAGE_NAME` for an unscoped `package.json` name (#3408).\n Instance and collection registry lookups pass the qualified name. A parent\n imported from another scoped package is emitted as `extendsQualified`\n (scanner traces named, aliased, and namespace imports; `extends` stays the\n simple name), kept only when that package's manifest declares it\n (`verifyImportedParents`); decorator registration takes the parent from the\n real constructor's registration, which a stale simple manifest parent never\n overwrites (#4145, `issue-4145-*.test.ts`). The schema\n planner rejects a table claimed by two unrelated classes\n (`CONFIG_TABLE_NAME_COLLISION`): only one STI family shares a table (#3098);\n collections (including `SmrtReportCollection`) never claim one. Consumer\n gates: `registry/__tests__/issue-3110-*.test.ts`, `issue-3109-*.test.ts`,\n `packages/bundle-gate/src/__tests__/consumer-same-name.spec.ts`.\n- A collection class never publishes more than its model: unset surfaces\n inherit the model's, and the build rejects a collection that opens a CRUD\n write its model closes. See `agents/generators.md` (Collection exposure).\n- A model subclass (STI child or CTI subclass) never publishes more than its\n parent model: unset surfaces and `sensitive` inherit the parent's, and the\n build rejects a subclass that widens them unless it names each operation in\n `widenParentExposure`. See `agents/generators.md` (Subclass exposure).\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- `src/browser.ts` is the `browser`-condition entry of the package root: the\n Node entry (`index.ts`) minus HTTP servers, manifest/knowledge discovery, run-once,\n the Vite plugin, and the test helper. A new export added to `index.ts` must also\n go to `browser.ts` unless it is Node-only; `src/__tests__/browser-entry-parity.test.ts`\n fails otherwise (allowlist: `NODE_ONLY_GROUPS` in that test, each with a reason; the browser\n entry may not publish a name the Node entry lacks), and also\n fails when a workspace package imports a root value the browser entry lacks.\n- `exports['.'].browser` (`dist/browser.js`) is the browser surface. Model\n package roots are bundled for browsers by the bundle-gate browser fixture\n (#3621): anything a model root imports from core must be exported by the\n browser entry and must not reach `node:` built-ins, `pg`, `cosmiconfig`, or\n `jiti`. Keep Node-only code behind a subpath or a lazy import; when a fix\n makes a package pass, delete its entry in\n `packages/bundle-gate/src/browser-gate/expected-failures.ts`.\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\n`typecheck` checks production source first, then the test project in\n`tsconfig.test.json`. Test coverage and the shared relaxed config\ncontract are documented in [agents/test-types.md](agents/test-types.md).\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",
1152
1153
  "moduleDocs": [
1153
1154
  {
1154
1155
  "path": "agents/object-runtime.md",
@@ -1180,6 +1181,11 @@
1180
1181
  "module": "schema-paths",
1181
1182
  "content": "# smrt-core/schema paths\n\nModule semantics for `src/schema/` — which `SchemaGenerator` entry point reaches\na real database, what each one emits, and the rules that keep them in step.\nPackage orientation, the cross-module invariants, and the traps that apply\nbefore editing anything live in [../AGENTS.md](../AGENTS.md) — read that first;\nit links the relevant runtime and generation contracts.\n\n## Four entry points, two of which ship\n\n`src/schema/generator.ts` exposes four index-emitting entry points. Their columns and indexes must agree for the same class.\n\n| Entry point | Selected by | Status |\n|---|---|---|\n| `generateSTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateCTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |\n| `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |\n\nProduction DDL takes the manifest route:\n\n```\n@smrt() class ─▶ scanner ─▶ manifest.json ─▶ generate{STI,CTI}SchemaFromManifest\n ─▶ registered `schema` ─▶ ObjectRegistry.getAllSchemasAsDefinitions()\n ├─▶ smrt db:migrate | db:diff | db:status\n │ (the CLI drives SchemaComparer + MigrationTracker directly)\n └─▶ migrateSmrtSchemas() / getPendingSchemaStatements()\n (src/migrations/orchestrate.ts — exported for programmatic\n use; no in-repo caller outside its own tests)\n```\n\nSince #2359 the two families share one set of index helpers and\n`src/schema/schema-path-parity.test.ts` runs the same fixture manifest through\nthe manifest paths, through `ObjectRegistry.registerFromManifest()` + the\nregistry paths, and through `getAllSchemasAsDefinitions()`, asserting identical\ncolumn and index sets. Extend that fixture with every generator change; a\ndivergence is a bug in the generator, not an exception to add to the test.\n\n### Index rules (#2359)\n\n- **Reference columns are always indexed.** `ensureReferenceColumnIndexes()`\n runs last on every path and gives each `@foreignKey`, `@crossPackageRef` and\n tenant column `<table>_<column>_idx` unless an UNQUALIFIED index (no `WHERE`,\n no JSON path) already leads with it — the `conflictColumns` unique index or an\n `indexed: true` opt-in, or the column's own inline UNIQUE. A partial\n `WHERE _meta_type = …` index does not count: base-class polymorphic queries\n carry no discriminator predicate. `indexed: true` on a reference column is\n redundant. Roll the index wave out to production with\n `smrt db:migrate --postgres-safe` (concurrent-index mode, #2362): a plain\n atomic batch takes SHARE/ACCESS EXCLUSIVE locks for ~230 index builds. STI FK indexes are plain, one per\n column, not per-class partial.\n- **No index on the primary key.** `<table>_id_idx` is gone from every path,\n and `conflictColumns` equal to the PK column set emit no conflict index\n (`ON CONFLICT (id)` binds to the PK constraint). `SchemaComparer` drops the\n legacy non-unique single-column PK index from existing databases without\n `--drop-indexes` when the live table reports that column as its sole primary\n key (never a UNIQUE one — on PostgreSQL that may back a custom-named PRIMARY\n KEY constraint, and `DROP INDEX` on it would fail the atomic batch).\n- **Slug loading keeps its index.** Custom `conflictColumns` replace the\n `(slug, context)` unique index; `loadFromSlug()`/`getId()`/`getSavedId()`\n still filter on slug/context, so a plain `<table>_slug_context_idx` is kept\n (additive; routing those lookups through the conflict key would change which\n row a slug resolves to). The tenant-led default key below counts as serving\n it (`servesSlugLookup()`): a tenant-scoped slug lookup carries the tenant\n predicate (#2365) and is served by the prefix, so no second index.\n- **Tenant default keys** are `(tenant_id, slug, context)`, plus `_meta_type`\n for STI. `ManifestGenerator.normalizeConflictColumns()` and\n `ObjectRegistry.getConflictColumns()` share `src/schema/conflict-target.ts`:\n resolve tenant fields through the schema owner/STI root, report group/bucket\n columns through the report, and custom PKs through their key. Explicit\n `conflictColumns` remain unchanged. The manifest, schema, knowledge, and\n runtime must carry the same value.\n Names remain `<table>_slug_context_idx` / `_slug_context_meta_type_idx`, so\n migration replaces a same-name global unique with tenant-led columns. That\n prefix serves tenant and tenant-scoped slug reads; a legacy standalone tenant\n index is dropped only with `--drop-indexes`.\n- **Widening an explicit key** (e.g. `['key']` → `['tenant_id', 'key']`,\n #3126) renames its index, so migration adds the new unique and\n `SchemaComparer` drops the superseded one without `--drop-indexes`: a UNIQUE\n index named `conflictIndexName()` for its own columns, with no predicate,\n whose columns are a strict subset of a declared UNIQUE index. It is a pure\n relaxation (every row satisfies the wider key) and otherwise would keep\n rejecting rows the model allows. The maintenance-window rule below applies.\n- **Optional NULL tenants** retain the SDK's NULL-equal upsert identity. Generated\n framework conflict indexes with nullable keys carry `nullsNotDistinct: true`;\n PostgreSQL 15+ creates `UNIQUE NULLS NOT DISTINCT`, enabling warm single-statement\n native upserts. Earlier PostgreSQL, SQLite and DuckDB retain adapter fallback\n semantics. Existing ordinary indexes are upgraded only through the explicit\n [NULL-equal maintenance migration](null-equal-indexes.md). Optional business\n unique fields remain NULLS DISTINCT; this marker is not inferred from them.\n- **Tenant-key rollout requires a maintenance window.** Old code/new indexes\n and new code/old indexes both fail new-object saves because conflict column\n sets must match exactly; persisted ID-based saves still work. Backfill legacy\n NULL tenants first or scoped ingestion creates separate rows and cannot see\n the old global ones. Cross-tenant natural-key dedup now creates one row per\n tenant. Deploy code and migrate together in atomic mode: each table drops\n and recreates its same-name unique index, holding ACCESS EXCLUSIVE locks\n (including against reads) until commit. Size `statementTimeout` for the\n largest table. A valid old subset unique guarantees the superset build;\n missing/nonunique old indexes may contain duplicates and need dedup first.\n Include the reference-index wave in that atomic window. `--postgres-safe` is\n suitable for an additive reference-index-only wave, but a key replacement\n leaves a per-table gap between drop/build and a failed build leaves no arbiter\n until rerun. There is no automatic DOWN; reverting code requires deliberately\n recreating its old indexes.\n\n- **STI `@field({ unique: true })` is enforced through indexes** (the differ can\n add an index to an existing table, never a column constraint): a full\n `<table>_<col>_unique_idx` when the STI base declares it, one\n `<table>_<col>_<class>_unique_idx WHERE _meta_type = '<qualified>'` per class\n when only descendants do — uniqueness per concrete class, not across the\n subtree. DuckDB/JSON have no partial indexes, so the descendant-scoped shape\n (`isStiSubtypeUniqueIndex`) is not emitted there — degrading it to a full\n UNIQUE would constrain every subtype; the DDL strategy and the differ both\n skip it, while other partial indexes keep degrading to full ones as before. Remember the\n framework serializes an unset text field as `''`, so a unique optional text\n field must be `nullable: true` with a `null` initializer or every unset row\n collides.\n- **Every class in an STI hierarchy carries the schema of the one shared\n table**, generated from the root base (`ManifestGenerator.generateSchemas()`\n resolves the root through `findSTIBaseInfo`), so a child never treats its own\n descendant-only unique field as base-declared.\n\n`src/schema/utils.ts` sits in between, and the two exports differ:\n\n- `generateSchema()` (reached from `SmrtCollection.generateSchema()`) always\n rebuilds from the registry and writes the result back into the registry,\n replacing whatever the manifest registered for that class.\n- `ensureSchema()` (reached from the deprecated `smrt db:setup`) is\n manifest-first: it takes `ObjectRegistry.getSchema()` plus the merged\n `getAllSchemasAsDefinitions()` table definition, and only falls back to\n `generateSchema()` when no schema is registered at all.\n\n## Verification\n\nExtend `src/schema/schema-path-parity.test.ts` for every generator change;\nmanifest, registry, and merged migration schemas must agree. Inspect regenerated\n`dist/manifest.json` and schemas across affected packages, not only decorators.\nRuntime `verifyPersistenceTable()` checks table existence only. Database drift\nchecks compare with generated artifacts; they cannot detect an omission shared\nby those artifacts. Use `smrt doctor --db` / `db:status --parity` for live parity.\n\nEvery new query predicate needs its index or an explicit reason none is needed.\nRun `pnpm --filter @happyvertical/smrt-core test:postgres` for numeric types,\nUUID casts, conflict targets, timestamps, or migrations. Schema-affecting options\nmust reach `SchemaGeneratorConfig` and both config rebuild sites:\n`src/schema/utils.ts` and `src/testing/database.ts`.\n\nTenant uniqueness and conflict targets must include the tenant column; explicit\n`conflictColumns` are author-owned and never rewritten. All reads, including\nhydration, slug lookup, vector search, and memory, remain interceptor-aware.\nRetry only transient errors classified through the cause chain; never retry an\naborted PostgreSQL transaction (`25P02`).\n\n### Composite indexes are declared, not inferred (#2357)\n\nThe generated set only covers foreign keys, unique/conflict columns, the STI\ndiscriminator, reference columns (#2359), default list ordering, and single columns opted in with `@field({ indexed: true })`. A list\nworkload's access path is composite, so declare it:\n\n```ts\n@smrt({\n indexes: [\n { name: 'contents_tenant_id_publish_date_idx',\n columns: ['tenantId', 'publish_date'] },\n ],\n})\n```\n\n`columns` takes field names or column names in access-path order — filter\ncolumns first, sort column last. Declare columns, not a direction: PostgreSQL\nscans a btree either way, so an ascending index also serves the matching\n`ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`\n(partial index) are honoured.\n\n`appendDeclaredIndexes()` runs first on all four entry points, ahead of\n`ensureDefaultListOrderingIndex()` (default ordering below) and `ensureReferenceColumnIndexes()`,\nso a declared composite leading with the tenant column (or any reference column)\nreplaces the automatic standalone index rather than duplicating it.\nUnknown columns, malformed entries, and a name collision with a different index\nall fail generation — a silently dropped index only surfaces later as a\nproduction slowdown. Keep both config rebuild sites aligned.\n\n### Relationship targets resolve to a class name on both paths\n\n`@foreignKey`/`@oneToMany`/`@manyToMany` accept a class, a name string, or a\n`() => Target` thunk. The decorator invokes the thunk and throws when the target\ncannot be resolved (never `related: ''`); the scanner unwraps the same thunk\nfrom raw source (never `related: '() => Target'`). An unresolved target silently\ncosts the relationship edge, `loadRelated()`, and the FK-derived index (#2379).\nA thunk resolves at decoration time, so a target declared later in the same\nmodule is still in its temporal dead zone — use the string form there.\nRuntime decorators retain the resolved constructor alongside the display name.\nInverse lookup and cascade planning resolve that constructor when queried, so\nthe target may register after its child without binding to a same-name class\nin another package. String targets prefer the declaring package; ambiguous\nunqualified targets never match an unrelated package's inverse edge.\nRegistry schema generation and the merged migration schema resolve the same\ncanonical target before selecting both its table and identifier type. The\nmanifest generator resolves unqualified targets in the declaring package.\nKeep the merged schema in parity tests: `getTestDatabase()` preserves its\nauthoritative FK metadata over the generated columns, so testing only the\nstandalone generator cannot prove the constraint that reaches the database.\n\nField decorator registration retains constructor ownership separately from the\nsimple-name inspection view. Custom decorators should pass the callback's\nconstructor as `registerFieldDecorator`'s fourth argument. Omit it only for\nintentional legacy string-only metadata; that legacy metadata still composes\nwith constructor-owned options, but mirrored public decorator fields do not.\n\n### A SQLite type change is a table rebuild (#2370)\n\nSQLite has no `ALTER TABLE ... ALTER COLUMN ... TYPE`, so\n`src/migrations/sqlite-rebuild.ts` answers a `type_upgrade` on SQLite with the\nstatement list SQLite's own docs prescribe: stage a new table under\n`_smrt_rebuild_<table>`, copy, drop, rename, replay the indexes and triggers.\n`SchemaComparer.compareTable` swaps that plan in for the differ's\n\"requires table recreation\" placeholder, so `db:migrate` applies it inside the\nnormal atomic batch instead of exiting 1 forever.\n\nFour properties of that module are load-bearing; keep them if you touch it:\n\n- **The target shape comes from the live `sqlite_master` DDL**, retyping only\n the drifted columns. It is not regenerated from the manifest, so the rebuild\n never becomes an implicit `DROP COLUMN`, and it preserves table constraints,\n `CHECK`s, and `WITHOUT ROWID`/`STRICT`.\n- **The rebuild is hoisted ahead of the table's other column changes.** Its\n staging DDL and copy list are captured at diff time, and the differ emits\n changes in manifest field order, so a new field declared above the retyped\n one would otherwise run `ALTER TABLE ... ADD COLUMN` first and have the\n rebuild silently drop it — both statements succeed and the batch commits.\n Rebuild first, then add columns to the rebuilt table.\n- **The copy carries no `CAST`.** SQLite applies the destination column's\n affinity on insert — the same conversion a fresh table performs. An explicit\n cast is worse: non-numeric TEXT cast to REAL/INTEGER silently becomes `0`,\n and an ISO timestamp cast to NUMERIC-affinity `DATETIME` becomes its year.\n- **It refuses when any table has a foreign key onto the target and\n `PRAGMA foreign_keys` is ON** (the SMRT adapter's default). `DROP TABLE`\n performs an implicit `DELETE FROM` that fires `ON DELETE CASCADE` on\n children, and `defer_foreign_keys` defers constraint *checks*, not FK\n *actions* — verified: the child rows go. The target's own self-reference\n counts, because the staging table copies that clause and becomes a child of\n the table being dropped (verified: a two-row self-referencing table finishes\n the rebuild holding one row). Such a column stays manual drift.\n- **`PRAGMA legacy_alter_table` brackets the rename**, because SQLite ≥ 3.25\n re-parses the schema on `ALTER TABLE ... RENAME` and a view still pointing at\n the just-dropped table makes it fail outright. It is restored immediately\n after; a rolled-back batch leaves it set on that connection, which is inert\n here only because nothing else in SMRT renames a table.\n\nAll the drifted columns of one table share a single rebuild: the first change\ncarries the plan and the rest become `no change needed` comments that the CLI\nclassifies as no-ops.\n\n## What the differ compares (#2369)\n\n`SchemaComparer` (`src/migrations/differ.ts`) compares each manifest column's\ntype, then — unless the type itself is drifting — its nullability and default,\nand always reports what it will not touch:\n\n- **Strengthening** (`SET NOT NULL`, `SET DEFAULT`) is executable on\n PostgreSQL/DuckDB. `SET NOT NULL` is preceded by an `UPDATE … WHERE c IS NULL`\n backfill of the manifest default; without a default the live data is probed\n and, if NULLs exist, the change is reported (comment SQL + `advisory`) instead\n of emitting an ALTER that would abort the atomic batch.\n- **Relaxing** (`DROP NOT NULL`, `DROP DEFAULT`) is a report-only advisory until\n the caller passes `relaxColumns` (`db:migrate --relax-columns`). The manifest\n can be under-specified (#2372 registration-order weakness), so a live column\n that is stricter than the manifest is never weakened silently.\n- **Orphans** — DB columns absent from the manifest, DB tables no manifest\n declares (`SchemaDiff.orphan_tables`), and unclaimed `*_key` unique constraint\n indexes — are always reported. A NOT NULL orphan without a default is a\n `warning` advisory (every ORM insert fails on it); `includeDroppedColumns`\n (`--drop-columns`) drops it, `relaxColumns` relaxes it. Advisory-only changes\n carry no SQL, never reach the tracker, and do not fail `db:migrate`.\n- **ADD COLUMN** is planned per engine: DuckDB rejects every inline constraint\n (add with `DEFAULT`, then `SET NOT NULL`, `CREATE UNIQUE INDEX`); SQLite\n rejects inline `UNIQUE` (separate `CREATE UNIQUE INDEX <table>_<col>_key`, the\n PostgreSQL constraint-index name, so the orphan sweep leaves it alone) and\n `NOT NULL` without a default on a populated table; PostgreSQL keeps constraints\n inline. DuckDB has no `ADD CONSTRAINT`, so the separate index is the only\n way to add uniqueness there; the bundled DuckDB 1.4.x resolves\n `ON CONFLICT (col)` through that index (the old #12684 limitation the DuckDB\n strategy's `requiresInlineUnique()` note describes no longer reproduces —\n the #2369 DuckDB test pins the upsert), older DuckDB builds may not. A required column with no default is enforced inline only on an empty\n table. On a populated one it needs a per-row `backfill` (#3008,\n `ColumnDefinition.backfill` from `@field({ backfill })`): PostgreSQL/DuckDB\n run `ADD COLUMN` (nullable) → `UPDATE … SET col = (backfill) WHERE col IS\n NULL` → `SET DEFAULT` (deferred, if declared) → `SET NOT NULL` → the unique\n index; SQLite adds it through the table rebuild (`sqliteBackfillPlaceholderSql`\n → `planSqliteTableRebuilds` appends `\"col\" TYPE NOT NULL` and copies\n `(backfill)`), one rebuild per table for every such column. The differ first\n probes `WHERE (backfill) IS NULL` on the live rows. With no default and no\n backfill, or with a backfill that yields NULL or will not evaluate, the\n column is **not added**: a manual `alter_column` (`mismatch.actual ===\n REQUIRED_COLUMN_NOT_ADDED`) names table and column, and `compareTable`\n withholds `add_index`/`add_foreign_key` changes on it. The same backfill\n repairs nullability drift (an existing nullable column holding NULLs).\n A backfill for a single-column unique target is also probed for duplicate\n values and refused by name. Partial unique indexes are exempt from that\n probe only where `supportsPartialIndexes()` holds (SQLite/PostgreSQL);\n DuckDB/JSON build them as full indexes, so they are probed there (#3015).\n DuckDB refuses `ALTER COLUMN` while any index depends on the table, so its\n `SET NOT NULL` is bracketed by `DROP INDEX`/recreate from `duckdb_indexes().sql`.\n- **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual\n (comment SQL → `db:migrate` exit 1), except a NOT NULL tightening with a\n declared `backfill`, which the rebuild performs (`COALESCE(col, backfill)`\n in the copy). The rebuild otherwise consumes `type_upgrade` placeholders.\n- Defaults compare through `canonicalizeDefault()`, which folds engine\n renderings (`'x'::text`, `CAST('t' AS BOOLEAN)`, `CURRENT_TIMESTAMP` vs\n `now()`) by manifest type; an unclassifiable rendering skips the comparison\n rather than risking a false positive that would churn every run. The\n round-trip test (create from each DDL strategy → compare → zero changes) in\n `src/migrations/__tests__/issue-2369-*.test.ts` guards this.\n\n### `schema.ddl` is a preview, not the table\n\n`SchemaDefinition.ddl` / `manifest.json` `schema.ddl` is the engine-neutral\nCREATE TABLE string from `SchemaGenerator.generateSQL()` with no engine: no\nindexes, no triggers, abstract `REAL`/`JSON`/`UUID`/`TIMESTAMP`. It is kept for\nbackward compatibility only. Everything that needs an executable table renders\n`columns` + `indexes` through `getDDLStrategy(engine)` — `db:migrate`\n(`migrations/orchestrate.ts`), `MigrationGenerator` (default\n`materializeStructuredSchema: true`; `false` is a deprecated opt-out),\n`SchemaAggregator`, and `createIsolatedTestDbFromManifest` in smrt-vitest, the\nlast two via `src/schema/manifest-schema.ts` (`collectManifestTables` /\n`renderCollectedManifestTable`). The cached string is merged in only for a\ntable whose contributors expose no structured columns (hand-authored\nmanifests); table constraints that exist only in the string are dropped with a\nwarning, as `db:migrate` drops them. Do not add a new consumer of the\nstring, and do not write a private CREATE INDEX renderer — the retired ones\ndropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out\n`PRIMARY KEY NOT NULL`: SQLite lets a bare non-INTEGER PRIMARY KEY hold NULL.\n\n### The merged table shape is registration-order independent (#2372)\n\n`getAllSchemas()` and `getAllSchemasAsDefinitions()` fold every class that\nshares a physical table — the whole STI hierarchy — into one shape. Both route\nthrough `buildMergedTableSchemas()`, which groups contributors by table and\nthen merges them in a **deterministic** order: the STI base first, then\nancestors before descendants, then by qualified name.\n\nThe first contributor supplies fallback columns, `idType`, conflict columns,\ncached DDL, and wins column conflicts. Keep base-first ordering even when a\nchild without manifest schema registers first.\n\nTwo invariants keep the two assembly paths agreeing:\n\n- `createBaseColumns()` mirrors what `generateSchemaFromManifest` /\n `generateSTISchemaFromManifest` emit for the same table, so a table built\n from runtime field metadata alone has the same NOT NULL/DEFAULT shape as one\n built from a manifest. Note `_meta_type` is `TEXT NOT NULL` with **no**\n default, matching the generator.\n- `fieldsToColumns()` reads `required`, `default`, and `description` from the\n top level *or* `_meta`. Registry fields normalize them into `_meta`\n (`manifest-field-merge.ts`), so reading only the top level silently dropped\n NOT NULL and DEFAULT for every registry-sourced field.\n\nSTI columns stay nullable regardless of the field's `required` flag\n(`fieldsToColumns(fields, { stiUnionColumns: true })`): the table holds the\nunion of all subtypes' fields, so a column only one subtype declares is never\npopulated on a sibling's row. Declared defaults are still emitted. This matches\n`generateSTISchemaFromManifest`, which sets `notNull: false` on every non-system\nSTI column.\n\nWhen adding a class-level input to the merged shape, take it from the seeding\ncontributor rather than \"whichever class arrives first\", and cover it with a\nchild-first/base-first equality test.\n\n### Default list ordering indexes\n\nAll four generators index `DEFAULT_LIST_ORDER_BY` (`created_at DESC, <pk> ASC`):\n`ensureDefaultListOrderingIndex()` emits `(tenant column, created_at)` when\nscoped, otherwise `(created_at)`. Resolve tenant columns by `referenceKind ===\n'tenantId'`, not spelling. The tenant-leading pair also serves the reference\nindex requirement.\n\nOnly an unqualified, non-JSON-path index with the same leading columns suppresses\nit. Append declared composites first, then default ordering, then reference\nindexes. `(tenant_id, created_at, status)` replaces the default pair;\n`(tenant_id, publish_date)` does not. Emit one plain index per STI table, since\nbase polymorphic reads lack `_meta_type` predicates.\n\nDo not add direction or PK columns by inference: `IndexDefinition` has no\nper-column directions, backward B-tree scans serve descending timestamps, and\nthe mixed-direction PK tie-break still needs incremental sorting within equal\ntimestamps.\n\n### One conflict-target rule, applied on every producer\n\n`save()` upserts on `ObjectRegistry.getConflictColumns()`; the schema must\ncarry exactly one unique index over those columns (or they must be the\nprimary key). Keep the derivation in `src/schema/conflict-target.ts` and let\nevery producer call it — the three manifest pipelines share\n`ManifestGenerator.applyGenerationPasses()` since #2360 because\n`ManifestBuilder` had silently skipped the report passes for months. When you\nadd a way for the key to vary (a new decorator option, a new class kind),\nthread it through `getConflictColumns()`, `normalizeConflictColumns()` and the\ngenerator's `resolveConflictTarget()` together, and extend the parity test's\n\"unique index == conflict target\" assertion; a key the runtime uses and the\nschema does not index is a hard PostgreSQL error (42P10) on the first save,\nand a key the schema indexes without the tenant column is the silent\ncross-tenant overwrite this rule exists for.\n\n### Tenant OWNERSHIP, not tenant scope, leads the default key\n\nA class with a `tenantId` field is tenant-owned even when it declares no\ntenancy — consumers register such classes with the tenancy interceptor at\nruntime (Anytown's Ludis `League`/`Team`, smrt-users `Group`/`Role`/…), which\ncore never reads. `resolveOwnershipTenantColumn()` (declared tenant column,\nelse `tenant_id`) leads the default natural key on every producer:\n`ObjectRegistry.getOwnershipTenantColumn()` / `getConflictColumns()`,\n`normalizeConflictColumns()`, the generator's `resolveConflictTarget()` and the\nregistry schema-builder. The tenancy read policy is still never inferred\n(`getTenantScopedConfig()` stays undefined). Explicit `conflictColumns` are\nnever rewritten. Old code's `ON CONFLICT (slug, context)` and new code's\n`ON CONFLICT (tenant_id, slug, context)` each need their own unique index, so\nthe inferred tenant-led key is named by the custom-key rule\n(`<table>_tenant_id_slug_idx`; only a DECLARED tenant column keeps\n`<table>_slug_context_idx`) and `smrt db:migrate` builds it BESIDE the legacy\nglobal unique (expand). The differ keeps the legacy index\n(`isLegacyNaturalKeyIndex()`, exempt from the #3126 superseded-index sweep and\nfrom `--drop-indexes`) until `--drop-legacy-natural-key` /\n`DiffOptions.dropLegacyNaturalKey` (contract). `db:status` exits 1 while the\ntenant-led unique is missing and warns while the legacy one survives\n(`cli/src/commands/tenant-natural-keys.ts`). Deploy order and locking:\n`docs/content/core.md` (\"Upgrading an existing database\"). The implicit\n`tenant_id` column never makes a foreign key identifying\n(`resolveForeignKeyDeleteAction({ columnName })`): a tenant FK stays\n`NO ACTION`, so deleting a tenant does not cascade through tenant-owned tables.\n\nThe save path backs this up where the key still omits the owner (an explicit\nkey, or a live table not yet migrated) — `SmrtObject.writeNaturalKeyRow()`. The\nrow a NEW object's conflict target matches is read directly, bypassing read\ninterceptors. A different owner on any ownership column\n(`ObjectRegistry.getOwnershipColumns()`: `tenant_id`, the declared tenant\ncolumn, `@tenantId` fields and runtime registrations reported through\n`ObjectRegistry.registerOwnershipColumnSource()` — the tenancy package reports\n`registerTenantScopedClass(…, { field })` columns) raises\n`TenantIsolationError.naturalKeyOwnedElsewhere()` (`TENANT_ISOLATION_VIOLATION`,\nnever retried, never naming the other owner), also under `withSystemContext()`;\nNULL — or an ownership column the save leaves unset — counts as an owner. A\nsame-owner row is adopted by id (keeping its `created_at`, applied to the\ninstance only after the write succeeds), so `DO UPDATE SET` never rewrites a\nprimary key (no `ON UPDATE CASCADE` churn). Adoption needs an explicit natural\nkey: when the match is only through a slug `getSlug()` derived from\n`name`/`title`/`label` (a human value, not an identity), the new object keeps\nits id, moves to the first free `<base>-2` … `<base>-9` (one `slug IN (…)`\nprobe; then `<base>-<8 id chars>`) and INSERTs — whoever owns the taken row, so\nit is never refused. `getOrUpsert()`'s create path opts back into adoption\n(`_adoptNaturalKey`): a miss on a non-key field updates the row the derived\nslug names instead of adding `<slug>-2`. On PostgreSQL with no NULL conflict\nvalue the write is `INSERT … ON CONFLICT (…) DO NOTHING RETURNING id` first, so\na new key costs one statement and no pre-read, and concurrent first creates\nnever collapse: the loser re-reads and adopts (explicit key) or moves (derived\nslug, then straight to the id suffix), bounded retries. Embedded engines\n(serialized by the in-process write queue) and NULL-bearing keys read first.\nThe junction batch path falls back to per-item saves when\nits conflict target omits an ownership column present in the row (`tenant_id`,\nthe declared tenant column, or any `@tenantId`-marked field), and when any new\nitem's natural key already names a row the batch is not removing; its\n`DO UPDATE SET` never lists `id`, `created_at` or an ownership column. Strict\ninserts (`_insertOnly`) check NULL-bearing keys the same way, because NULLs\nare distinct in the unique index on SQLite, DuckDB and PostgreSQL < 15.\n\nA save with no owner (`tenantId: null`, typically under `withSystemContext()`)\nis deliberately NOT refused when a tenant row shares its slug under the\ntenant-led key: it lands as a separate global row, the global-default /\ntenant-override shape #2360 keeps. Refusing it would need a cross-owner read on\nevery global save and would break that pattern. A model that needs a slug\nunique across every owner declares explicit `conflictColumns` without the\ntenant column; then the guard refuses the colliding save instead.\n\n### Every generated index name is length-guarded before it leaves a path (#2374)\n\nPostgreSQL truncates identifiers beyond 63 bytes; two generated names sharing\nthat prefix can make `CREATE INDEX IF NOT EXISTS` silently skip an index.\n\n`schema/index-utils.ts` owns the guard, and it splits by who owns the name:\n\n- **Generated index, trigger and PL/pgSQL function names** →\n `shortenIdentifier()`. Deterministic `<head>_<digest><suffix>`, digest taken\n over the **full** original so a shared prefix still yields distinct names, and\n a recognised suffix (`_idx`, `_unique_idx`, `_key`, `_pkey`) preserved.\n- **Hand-declared `@smrt({ indexes: [{ name }] })`** → `assertIdentifierFits()`,\n a hard error in `validateDeclaredIndex()`. Renaming what a developer wrote is\n worse than refusing it, and `SchemaComparer` matches indexes **by name**\n first, so a 70-byte declaration could never match the 63-byte index\n PostgreSQL stored and `db:migrate` would emit `add_index` forever.\n- **Table and column names** are not guarded: PostgreSQL truncates their\n declarations and references consistently. `smrt-users` tests intentionally\n long table names; collision risk remains with the author.\n\n`enforceIdentifierLimits()` is the single call site per path, placed **after**\n`ensureReferenceColumnIndexes()` — nothing may lengthen a name after it. Doing\nthe shortening at the end rather than at each `indexes.push()` is safe because\nthe digest covers the whole original name, so entries distinct before shortening\nstay distinct after; the helper still throws if two ever collide. The migrate\nleg's `withConflictIndex()` (`registry/schema-builder.ts`) and the PostgreSQL\ntrigger-function name call `shortenIdentifier()` directly, because they compose\na name outside the generator's index list. Note that an over-long *table* name\nstill yields in-limit, distinct *index* names, because the shortening runs over\nthe whole composed name.\n\nThe digest is FNV-1a, not `node:crypto`: `index-utils.ts` is re-exported from\n`schema/utils.ts`, which exists to keep Node built-ins out of browser bundles.\nIt only has to be *stable* — a shortened name that changed between releases\nwould make every deployment drop and recreate the index — so the parity and\nunit tests pin the literal output rather than recomputing it. Unpaired\nsurrogates are folded to U+FFFD before both counting and hashing, so the digest\nis taken over exactly the bytes the driver transmits.\n\nExisting databases migrate **by name swap, without a rebuild**: the live index\nstill carries the name PostgreSQL truncated it to, the manifest now carries the\nshortened one, and the differ claims it by signature (columns + uniqueness +\npredicate), emitting nothing — including under `includeDroppedIndexes`. See\n`migrations/__tests__/index-drift.test.ts` and the PostgreSQL lane test\n`schema/issue-2374-identifier-length-postgres.optional.test.ts`.\n\nOut of scope, deliberately: constraint names PostgreSQL invents for itself. A\nCTI table's inline `UNIQUE` produces an implicit `<table>_<column>_key`, which\ncan exceed 63 bytes even when the table and column each fit. SMRT never names\nit, and PostgreSQL disambiguates its own truncations by appending a counter\nrather than collapsing them, so there is no silent-collision hazard there.\n\n### The `_smrt_` prefix does not mean \"system table\" (#2376)\n\n`SmrtClass` coalesces concurrent system-table initialization through completion\nof bootstrap, deferred compatibility, and vector setup (#3944). In-flight work\nis shared only by the same database handle. Distinct handles at the same URL\nretain database-level coordination: a caller-owned transaction may finish its\ninitialization statements before its tables become visible to another handle.\nAll same-handle waiters observe failure; the pending entry is removed so a fresh\ninstance can retry. Existing completed-success cache identities are unchanged.\nProvision system tables on a base connection before caller-owned transactions;\nconstructor initialization still preserves PostgreSQL's transaction lock lifetime.\n\n`bootstrapSystemTables()` owns nine hand-written tables; ~25 more `_smrt_*`\ntables belong to `@smrt()` models and are created by `db:migrate` (feature\nflags, prompt overrides, subscription plans, report schedules, field policies,\njobs). Never classify by prefix — use `SYSTEM_TABLE_NAMES`\n(`schema/system-table-shapes.ts`, derived from the DDL parse) plus\n`FRAMEWORK_OPERATIONAL_TABLES` / `RETIRED_SYSTEM_TABLES` in `system/schema.ts`.\nThe change-feed writer skipped by prefix, so clients syncing those domain\ntables through `_changes` never saw an update.\n\nEditing `ALL_SYSTEM_TABLES` requires bumping `SMRT_SCHEMA_VERSION` *and*\nappending to `SMRT_SCHEMA_DDL_CHECKSUMS` — the version gates the DDL replay, so\nwithout a bump no existing database ever applies the change. A new **column**\nadditionally needs an `addColumnIfMissing()` entry in `system/compatibility.ts`\n(`CREATE TABLE IF NOT EXISTS` is a no-op on an existing table).\n`system-schema-evolution.test.ts` enforces both, and asserts a legacy database\nupgrades to exactly the shape a fresh install gets.\n\n`_smrt_jobs` / `_smrt_job_events` are dual-owned: `db:migrate` creates them,\nthe compatibility pass reshapes them. On a fresh install bootstrap runs first,\nso their pass is deferred — `ensureDeferredSystemTableCompatibility()` re-runs\nuntil the tables exist, then stamps a `<version>+deferred-compat` marker. It\nruns OUTSIDE the bootstrap lock and swallows its own failures: those statements\ntarget tables the framework does not own, and inside the PostgreSQL transaction\none failure would roll back system-table creation with it. Only\n`ensureBootstrapSystemTableCompatibility()` (the tables the DDL itself creates)\nbelongs inside the lock.\n\nReconciling `_smrt_jobs.task_id` uniqueness reads the live index catalog, which\nis implemented for PostgreSQL and SQLite only; DuckDB and the JSON adapter keep\nthe redundant compat index rather than risk dropping the one that enforces the\nupsert conflict target. When reading a PostgreSQL catalog array, cast it\n(`attname::text`) and parse both shapes — a driver with no parser registered for\nthe array OID returns the raw `{a,b}` literal, and reading that as \"no columns\"\nsilently inverts an index-existence decision.\n\n## Same-package referential integrity uses two matching rails\n\n`@foreignKey(Target)` emits a physical database constraint when the target is\nin the same package and applies the same action through `SmrtObject.delete()`\nin `src/cascade.ts`. A shared delete-action resolver keeps both paths aligned;\nthe established generated `ON UPDATE CASCADE` default remains unchanged:\n\n| Reference | Default when `onDelete` is absent |\n|---|---|\n| Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |\n| Polymorphic `(metaType, metaId)` association row | `CASCADE` |\n| Ordinary same-package reference | `NO ACTION` — deletion is refused while references remain |\n| Every `@tenantId()` field | Excluded from physical constraints and delete cascades |\n\nThe natural-key rule is what cleans junction rows up without any per-package\nannotation: a junction declares\n`@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so the\nrow is *identified* by the content and cannot outlive it. An ordinary child\n(`Order.customerId`) is keyed by `(slug, context)` and therefore defaults to\nimmediate `NO ACTION` unless it opts in explicitly.\n\n**`@tenantId()` is excluded even though it lands in `conflictColumns`.**\n#2360 leads every tenant-scoped class's *default* natural key with the\ntenant column, so without this exclusion, deleting one `Tenant` row would\nrecursively CASCADE through every tenant-scoped table in the schema that has\nnot declared its own `conflictColumns` — the overwhelming majority. The\ntenant column scopes ownership; it does not identify the row the way a\njunction's foreign key does. Detected via the `__tenancy.isTenantIdField`\nmarker on `FieldMeta` (`smrt-core` reads it structurally so it never depends\non `smrt-tenancy`). `@tenantId()` exposes no `onDelete` option today, so\nthis cannot currently be overridden per field.\n\nOn PostgreSQL `db:migrate` converges a live SMRT-owned constraint whose only\ndrift is its action (#3023, `SchemaComparer.findReplaceableForeignKey()`):\nbuild-then-swap via `renderForeignKeyReplaceStatements()` — staged\n`<name>_smrt_new` added `NOT VALID` and validated before the old constraint is\ndropped and the staged one renamed; `--postgres-safe` runs the VALIDATE and the\nswap outside the batch transaction. A change toward `CASCADE` / `SET NULL` /\n`SET DEFAULT` still applies (the manifest declares it) but carries a\n`warning` advisory that `db:diff`, `db:status` and `db:migrate` print as\nDESTRUCTIVE.\n\n`@crossPackageRef()` remains runtime-only: it registers relationship loading\nand indexes but deliberately emits no physical constraint, avoiding circular\npackage DDL. Tenant markers follow the same non-constraint rule because a\ntenant is a scope, not an ownership edge.\n\nSame-package archival identifiers may explicitly use `@foreignKey(Target, {\nconstraint: false })`: preserve relationship loading and indexing, but omit\nphysical constraints, schema dependencies, and application cascade/preflight\nso the identifier survives parent deletion. Document the retention reason at\nthe field; ordinary references remain constrained.\n\nFor a same-package relationship whose semantics are portable but whose physical\nconstraint shape is not, `@foreignKey(Target, { constraint: { engines: [...] } })`\nis the public exception. The allowlist scopes physical DDL and dependency\nplanning only. Relationship metadata, UUID representation, derived indexes, and\nthe application delete rail remain canonical on every engine. Empty or unknown\nengine lists fail closed; unannotated unsupported DuckDB cycles and actions keep\ntheir actionable refusal.\n\nEvery schema creation entry point uses the same deterministic dependency\nplanner. Parents are created before children. SQLite keeps cycle constraints\ninline because it can create them safely. PostgreSQL creates mutually dependent\ntables first and adds their named constraints afterward. DuckDB refuses cycles,\nself-references, `CASCADE`, and `SET NULL` with an actionable error because its\ncurrent ALTER/constraint support cannot enforce those shapes safely.\n\nRollback drops children before parents, removes deferred PostgreSQL cycle\nconstraints first, and defers SQLite checks while dropping populated cycles.\nAggregation that filters a parent also removes a retained child's physical FK.\nPostgreSQL deferred constraint adds are idempotent. Generated `ON UPDATE\nCASCADE` remains the default; DuckDB/JSON must refuse unsupported actions\nrather than silently stripping them.\n\nFor existing tables, PostgreSQL checks the exact child table/column against the\nexact referenced table/column before adding a constraint as `NOT VALID` and\nthen validating it. The probe uses distinct child/parent aliases and, when both\nmanifest columns are UUIDs, bases its guarded casts on both live column types:\nmatching live types compare directly, while a legacy text side is shape-checked\nbefore casting. This keeps a self-reference or malformed legacy value from\ninvalidating the query. An orphan stops migration with detector SQL and an\nexecutable repair suggestion: nullable FKs are cleared, while required FKs\nrequire an explicit operator decision to reassign the reference or deliberately\nremove a child row after preserving its required data. A probe failure is\nsurfaced as a database/framework error, never misreported as orphan data.\nWhen the referenced parent is absent but scheduled for creation by the same\n`compare()` batch (#3240), PostgreSQL first proves the entire existing child\ntable empty instead of querying the absent parent. Nonempty children are blocked\nwithout executable repair SQL: create and populate the parent deliberately,\nthen rerun the ordinary orphan preflight. The exception never applies to a\nstandalone `compareTable()` or an undeclared missing parent, and incompatible\ncolumn types, missing target columns, and existing conflicting constraints stay\nblocked. Planned-parent and newly added child-column types are compared using\nthe PostgreSQL DDL mapping (for example, `TIMESTAMP` becomes `TIMESTAMPTZ`,\n`INTEGER` becomes `BIGINT`); existing columns retain their live physical types. `NOT VALID` plus `VALIDATE CONSTRAINT` remains the authoritative check;\na writer racing the preflight causes the atomic migration to roll back.\nSQLite emits inline `REFERENCES` when adding a new nullable column with no\nnon-NULL default or backfill (#3795): existing rows receive NULL and the database\nenforces subsequent writes. An existing-column constraint or another column\nshape still requires a deliberate table rebuild. DuckDB reports the unsupported ALTER\npath. Neither engine treats an unsupported constraint addition as a successful\nno-op.\n\n**Counting orphans is a separate, read-only concern (#2753).**\n`renderForeignKeyOrphanDetector({ limitOne: true })` makes the probe a gate;\n`schema/foreign-key-orphan-report.ts`'s `collectForeignKeyOrphanCounts()` runs\nthe identical predicate as `COUNT(*)` (via the same function's `countOnly`\noption, so the FROM/JOIN/WHERE clause is never duplicated) for every\nmanifest-declared foreign key, and reports child/parent table and column, the\nlive count, and child-column nullability. A relationship whose child or parent\ntable does not exist live is skipped and reported separately rather than\nfailing the whole run. It never repairs anything — the CLI surface is\n`smrt db:orphans` (`packages/cli/src/commands/db-orphans.ts`), and `db:status`\nprints a compact per-foreign-key summary when any count is nonzero.\n\n**Diagnostics are latency-bound, not query-bound.** Each `getTableSchema()` is\nfive catalog round trips, so `compare()` and the orphan report read each table\nat most once per run, with bounded concurrency on PostgreSQL\n(`schema/bounded-concurrency.ts`: 8 introspections, 4 data-scanning probes; 1\nelsewhere). `compare()` reads partial-index predicates in one `pg_indexes`\nquery, and `db:status` seeds the orphan report with\n`SchemaComparer.getLiveSchemaSnapshot()`. Never add a per-table or\nper-relationship query to these paths without batching or bounding it.\n\n**Partial apply and opt-in orphan disposition (#2748).** The batch an\nunflagged `db:migrate` attempts already excludes every blocked change (a\nFK-with-orphans, a manual `type_upgrade`/`alter_column`) — those never leave\n`SchemaDiff.changes` as executable SQL, so they never enter the atomic\ntracker batch on their own. `--apply-unblocked` adds a second, opt-in\nsafety layer on top of that exclusion: the CLI partitions the remaining\n\"safe\" bucket by *dependency*, not just by whether a change is individually\nblocked. `computeBlockedColumns()` (`packages/cli/src/commands/\ndb-migrate-actions.ts`) reads every manual intervention's blocked\n`table.column` identity; `partitionUnblockedMigrations()` then withholds any\n`add_index`/`add_foreign_key`/`alter_column`/`drop_column` that reads or\nwrites one of those columns (an index on a column whose type upgrade is\nblocked, or a FK whose child *or* parent column is blocked) and applies\neverything else through the normal transaction/tracker path. Off by\ndefault: without the flag, the batch stays exactly what it always was.\n\nThe FK-orphan advisory branch in `compareForeignKeys()` (`migrations/\ndiffer.ts`) tags its `SchemaChange` with `orphanBlocked: true` and\n`orphanNullable` (mirroring `renderForeignKeyOrphanRepair()`'s own\nnullable/not-nullable branch) so a caller can identify this specific\nmanual-intervention reason without parsing `advisory.message`. `db:migrate\n--null-orphans` reads that tag: for a nullable child column it runs the\ndiffer's own suggested `UPDATE ... SET <column> = NULL` (from\n`advisory.suggestedSql[1]`, the exact statement the differ already\nrendered — never re-derived), then rebuilds the `ADD CONSTRAINT NOT VALID` +\n`VALIDATE CONSTRAINT` pair via the shared `renderForeignKeyAddStatements()`\nhelper (`schema/foreign-key-ddl.ts`, also used by the differ's own safe-add\nbranch) and adds it through the normal tracker path. A NOT NULL child column\nkeeps the unconditional \"Manual repair required\" refusal — nulling isn't an\noption and the flag never deletes rows. `planOrphanDispositions()` fails\nclosed (routes to \"not nullable\"/no-op) if an orphan-blocked change is\nmissing its `foreignKey` definition or its `advisory.suggestedSql` pair, on\nthe same \"report and withhold rather than guess\" principle as the rest of\nthis gate.\n\n`getForeignKeyOrphanOptions()`'s `nullable` reads BOTH sides: the manifest\nAND the live column (`dbSchema.columns[...].notNull`), never the manifest\nalone (review, #2748). A manifest relaxed to nullable while the live column\nhas not converged yet — the same drift `--relax-columns` handles for plain\ncolumns — would otherwise report `nullable: true` from the manifest side\nonly, and `--null-orphans` would attempt an `UPDATE` PostgreSQL rejects\noutright (`23502`), failing the whole atomic batch instead of refusing just\nthat one relationship. Nullable only when both sides agree; a real\nNOT NULL on either side keeps the unconditional refusal.\n\n### Pre-R11 `text` ids converge to `uuid` before any FK statement (#2608)\n\nPostgreSQL FK columns must have matching physical types. Legacy text IDs may\nmeet newer native UUID references; neither SQLite (text UUID by design) nor\nDuckDB (no in-place type rewrite) emits this convergence.\n\n**The runtime guard fails closed.** `SchemaManager.ensurePostgresForeignKey()`\nreads both live column types and refuses to emit `ADD CONSTRAINT` when they\ndisagree, naming both columns, both live types, and the repair. It deliberately\nskips the orphan probe in that case: across mismatched types the probe answers\na question about casted values, not about the constraint being refused, and it\nhas to run again after the columns converge anyway.\n\n**The differ converges the columns.** `planUuidConvergence()`\n(`src/schema/uuid-convergence.ts`) groups every manifest relationship that\ndeclares UUID on both sides into connected components and converges a component\nonly when the live database already proves the target shape — at least one\nmember is native `uuid`. A component that is `text` on *every* side is the\ntolerated pre-R11 deployment and is left alone; its foreign keys are\ntype-compatible today, and the R11 uuid/text equivalence in\n`migrations/differ.ts` keeps it out of the column diff.\n\nConverge entire relationship components, including siblings and self-references;\nan unreferenced legacy text ID retains its UUID/text equivalence tolerance.\n\nThe planner never coerces data. Before emitting anything it probes each column\nit would rewrite for values that are not uuid-shaped (the same `~*` canonical\npattern the orphan probe uses) and refuses the whole component — with the count\nand a sample value — if any exist, if the probe cannot run, if a member carries\nsome third physical type, or if a live foreign key still constrains a column\nthat must change. `@happyvertical/sql` introspection does not expose live\nPostgreSQL constraint names, so SMRT cannot drop and re-add those constraints\nfor you: drop them deliberately, rerun the migration to converge, and let SMRT\nre-add the manifest constraints.\n\nRefusals are reported, not silent. Each one becomes a warning advisory with no\nexecutable SQL, so it reaches `unactionableChanges` / `hasManualDrift` and\n`db:status` shows **blocked: incompatible column types** instead of *pending*.\nThe same check runs per relationship in `compareForeignKeys`, so a foreign key\nwhose live types will still disagree after this run's conversions is reported\nblocked rather than emitted as pending DDL that cannot succeed.\n\nThe planner also inspects tables the manifest no longer declares. A live\nforeign key from an orphan table onto a column that must convert still blocks\n`ALTER COLUMN … TYPE`, so the differ introspects every existing table — not\nonly the manifest ones — whenever there is at least one conversion candidate,\nand reports the dependency instead of emitting DDL PostgreSQL would reject. An\nalready-converged database has no candidates and pays nothing.\n\nOrdering is a contract. Conversions carry `SchemaChange.phase =\n'pre_foreign_key'`, and the orchestrator emits them **before every CREATE TABLE\nand every foreign-key statement in the batch**. Both halves matter:\n`planForeignKeyCreation()` only defers the constraints inside a mutual cycle,\nso an acyclic new child table keeps its foreign key *inline in `CREATE TABLE`*\n— a brand-new `uuid` child pointing at a legacy `text` parent fails exactly\nlike an existing one, before the parent could be converted. Conversions only\never rewrite columns that already exist, so leading the batch is always safe. A\nlive `DEFAULT` on a converting column is dropped first (PostgreSQL refuses\n`ALTER COLUMN … TYPE` when the default cannot be cast); the ordinary default\ncomparison re-establishes the manifest default on the next run.\n\nThere are **two** batch builders and both order on that marker:\n`collectStatementsFromDiff()` in `migrations/orchestrate.ts` (used by\n`getPendingSchemaStatements` / `migrateSmrtSchemas`) and the tracker batch\n`db:migrate` assembles by hand in `@happyvertical/smrt-cli`\n(`commands/utilities.ts`). `partitionSchemaChanges()` carries\n`SchemaChange.phase` onto `MigrationAction.phase` so the CLI can partition the\nsame way, in both the applied batch and the `--dry-run` preview. If you add a\nthird consumer, order it the same way.\n\nConvergence entries carry the manifest column definition. Every `type_upgrade`\nconsumer reads `SchemaChange.column` — `partitionSchemaChanges()` in\n`@happyvertical/smrt-cli` skips an entry without one — so a conversion missing\nit would drop out of the `db:migrate` batch while `compareForeignKeys()` still\nassumed the converged type. Refused convergences carry the same column plus an\nadvisory and no SQL, and the CLI routes them to the report-only advisories\nrather than to manual interventions or the tracker.\n\nThe uuid wording is gated on the manifest. Both the runtime guard and the\nstatus planner reach their incompatible-type branch for *any* mismatched pair,\nnot only uuid/text. A `USING …::uuid` repair is suggested only when the\nmanifest declares UUID on both sides **and** a live side is actually `text`;\notherwise the diagnostic names the two live types and asks the operator to\nalign them deliberately.\n\nThe conversion is one-time and idempotent: once the column is native `uuid`,\nthe component is uniformly UUID and the planner emits nothing.\n\n### Application cascade invariants (`src/cascade.ts`)\n\n- Rebuild the registry-derived plan on every delete; manifests register lazily.\n Caching requires invalidation across every registration path.\n- Plan from `getResolvedQualifiedName()`. Every registered polymorphic\n association class participates, since its runtime target can be any class;\n `CascadePlan.isEmpty` requires no such class anywhere and no typed references.\n Only an empty plan skips the transaction.\n- Cascades are set-based: child hooks/interceptors and change-feed tombstones do\n not run. Only the explicitly deleted object runs its lifecycle. RESTRICT\n checks precede mutations; the parent DELETE and cascades share one transaction\n where supported, so deeper refusals roll back.\n- Derived `_smrt_embeddings` / `_smrt_contexts` cleanup matches IDs AND\n `ownerClassCandidates()` (qualified and simple STI member names), never IDs\n alone: unrelated text-ID classes can collide. Cleanup failures are logged,\n not raised, because these tables may not exist in older databases.\n- Never cascade append-only `_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals`,\n or dispatch logs; deleting change tombstones would break sync.\n\n### Retention (`src/system/retention.ts`)\n\n`runRetentionSweep(db, policy)` runs five built-ins in fixed order (`changes`,\n`ai-usage`, `contexts`, `dispatch`, `run-once-claims`), then\n`registerRetentionTask()` contributions. A failed task records its result and\ncontinues; a missing table is `unavailable`. The `globalThis` registry avoids\nsplit registrations under duplicate core resolution; package tasks exist only\nafter importing the package. CLI prune optionally imports jobs/users.\n\nDefaults are opt-out: changes 30 days, AI usage 90 days, completed dispatch 30\ndays/failed dispatch 90 days, contexts by `expires_at`, run-once claims 30\ndays on `completed_at` (#3080; `in_progress` rows never match — the window is\na replay deadline for `runOnce()`, not just disk hygiene, see\n`agents/run-once.md`). `smrt.configure({ retention })` tunes built-ins;\ncontributed tasks own their defaults/options.\nJobs defaults are 7 days terminal, 30 failed, 30 events via\n`registerJobRetentionTasks()` or runner `retention.jobs`; expired credentials\nhave no extra window. Disable a table/task with `false` or the whole policy with\n`enabled: false`; CLI `--skip` and runner configuration expose these controls.\nTask names use package prefixes (`jobs-records`, `users-sessions`).\n\nCore does not schedule sweeps. TaskRunner runs every six hours, first one\ninterval after start; `retention: false` opts out. `smrt db:prune` supports cron.\nEvery prune counts then deletes using the same predicate; `rowCount` is not\nportable. The two statements deliberately are not transactional, so counts are\napproximate under concurrency. Overlapping change/AI-usage bounds exclude rows\nalready counted, including dry runs.\n\nRetention indexes belong in system DDL and its versioned replay:\n`_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`, dispatch\n`(status, processed_at)` / `(status, updated_at)`, and\n`_smrt_run_once_claims(status, completed_at)`. Jobs `(status, completed_at)`\nbelongs in `ensureJobsSystemTableCompatibility()` on each collection initialize,\nsince decorated jobs tables do not exist at bootstrap.\n\nExpiry remains prune-side for object/collection `recall()`/`recallAll()`;\n`LearningMemory` separately filters it at read time.\n\n## Supported generation surfaces\n\nThe four entry points above are the supported generator paths. The unused AST\n`generateSchema(objectDef)`, `smrt:schema` / `@happyvertical/smrt-virt-schema`\nvirtual modules, and project-specific `SchemaOverrideSystem` were removed.\n\nKeep published `SchemaDefinition.triggers`, `TriggerDefinition`, and the DDL\nstrategies' trigger renderers: they support hand-authored new-table schemas.\nGenerated `@smrt()` schemas always emit `triggers: []`; no decorator populates\nit, `save()` maintains `updated_at`, and the differ never retrofits triggers.\nAdding live trigger generation requires a migration rollout design.\n`_smrt_signals` and database-persisted registry APIs are retired; see\n`RETIRED_SYSTEM_TABLES` in `src/system/schema.ts`.\n\n## PostgreSQL migration execution\n\n`MigrationTracker.applyAll({ atomic: true })` sets local lock/statement timeouts\nbefore any DDL. `postgresSafe: true` commits non-index DDL atomically, then runs\nindexes CONCURRENTLY on `db.acquireSession()` so settings and DDL share a\nconnection. This mode is not atomic. Unfinished indexes are `failed`, not\n`running`; `[smrt: concurrent-index phase 1 committed]` in `error_message`\nallows reruns to resume index work without replaying committed DDL. Inspect\n`pg_index.indisvalid` and drop INVALID indexes before rebuild (`pg_indexes`\nalone cannot detect them). Operational commands: `packages/cli/AGENTS.md`.\n\n### Subclass conflict identities (#3456)\n\nAn STI child's explicit `conflictColumns` owns a partial unique index restricted\nto its qualified discriminator. The root conflict index excludes these children;\nregistry and manifest paths emit the same predicates. Natural-key lookups include\nthat scope, PostgreSQL atomic inserts include the index predicate, and adopted\nrows update by id. Bulk writes use per-item guards for these targets. Normal\nmigrations replace the root index by name and add the child index; repair any\nexisting duplicate child natural keys before migrating. Stop old writers for this\nindex migration and load the complete consumer manifest on every shared-table\nwriter before resuming: a provider-only manifest lacks the child predicates.\nDuckDB/JSON reject\nthese schemas because partial unique conflict identities cannot be enforced.\n\n## Consumer physical table bindings (#3660)\n\n`smrt.tableNames` maps exact qualified model identities to deployment-local\nphysical tables. `registry/table-mappings.ts` projects schemas for both runtime\n`getSchema()` reads and merged migration planning, including index names and\nrelationship targets. Never remap by a raw old table name: two unrelated models\nmay own that name in their respective packages. Bind STI roots only; sensitive\nmodels are refused. Well-formed bindings may precede model registration;\npartial registries never need unrelated models merely to satisfy configuration.\nRuntime object/collection getters recheck ownership even for cached table\nnames; validation is memoized by registry generation and binding configuration.\nConfiguration must load before first database use and stay\nfixed for that process. This is not an implicit data migration; see\n[consumer table bindings](../../../docs/content/table-bindings.md).\n"
1182
1183
  },
1184
+ {
1185
+ "path": "agents/deployment-phases.md",
1186
+ "module": "deployment-phases",
1187
+ "content": "# Durable deployment data steps\n\n`DeploymentPhaseTracker`, exported from `@happyvertical/smrt-core/migrations`,\nis a PostgreSQL-only deployment data-step API. SQL schema steps continue to use\n`MigrationTracker`, including its checksums and concurrent-index recovery.\n\nA permanent `_smrt_deployment_steps` ledger keys each step by `application`,\n`environment`, and stable `stepId`. Reusing an ID with a different checksum or\nexecution mode fails before its work or inspection executes. A release identifier\nalone is not a stable step ID: otherwise later releases replay old data changes.\nThere is no retention. Preserve this table when restoring data; deleting markers\npermits replay. Checkpoints must never contain secrets. `MAX_DEPLOYMENT_CHECKPOINT_BYTES` limits\nserialized checkpoint JSON to 16 KiB measured in UTF-8 bytes, including quotes\nand JSON syntax. Oversized or unserializable values fail with\n`INVALID_CHECKPOINT` before their ledger query; the prior durable checkpoint is\npreserved. Values follow `JSON.stringify` conventions: undefined/null checkpoints\nnormalize to null, nonfinite numbers become null, object properties containing\nundefined/functions/symbols are omitted, and those array elements become null.\nCircular references, BigInt, and top-level functions/symbols are rejected.\nA checkpoint rejection does not undo prior external effects: reconciliation\nstill determines whether they completed or may safely resume.\n\nEvery execution requires `checkSafety(connection)`: a read-only callback that\nthrows unless runtime/static schema agreement and writer quiescence have been\nproved. It runs after acquiring the scope lock and before any ledger DDL or data\nwrite, including a replay. The caller owns platform fencing and complete writer\ninventory; an advisory lock excludes cooperating deployment runners, not runtime\nwriters. Guards must not open transactions or change session/lock state.\n\nThe adapter must expose public `acquireSession()`. A session advisory lock\nserializes every step in the same application/environment; the same pinned\nsession executes data writes and markers. Lock acquisition times out after\n`lockTimeoutMs` (default 30 seconds). Other adapters fail closed. DDL provision\nruns outside the data transaction under a separate initialization lock. The\nmigration role needs schema CREATE and ledger read/write permissions.\n\n`runTransactionalStep(options, async tx => { ... })` exposes a query-only handle\non that pinned session. Use **only that handle** for all transactional mutations.\nData and the completed marker commit together. A thrown callback rolls both back;\na lost COMMIT response is never retried internally. On retry, the committed marker\nskips work, or the absent marker permits a fresh transaction. Do not issue\ntransaction control, DDL requiring autocommit, session configuration or advisory\nlock changes inside callbacks. Work outside this handle is not made atomic.\n\n`runReconciledStep({ ...options, inspect, apply })` commits a running marker before\ncalling either callback. `inspect(context)` must independently classify actual\neffects as `complete`, `safe-to-resume`, or `needs-repair`. It runs on fresh and\ninterrupted attempts. Only `safe-to-resume` permits `apply(context)`, and another\ninspection must prove complete afterward. `saveCheckpoint(json)` persists a\nrunning checkpoint before external effects; `context.checkpoint` exposes the\nlatest value. Each effect must tolerate the interval between performing it and\nsaving its checkpoint. No blind shell-command replay is provided.\n\nA failure records needs-repair if the session survives; process/connection death\ncan leave running. Both are unresolved and require inspection on the next attempt.\n`getStepStatus(key)` is read-only and reports the durable status without guessing\nwhether a running owner is alive. A reconciler may prove a needs-repair attempt\nsafe to resume after an operator repairs the underlying problem. A callback that\ncannot determine safety must return needs-repair and refuse further mutation.\n\nExternal effects cannot be atomically fenced by a PostgreSQL connection:\n`assertActive()` checks the pinned session, but it cannot stop an already-running\nshell command or fence an external service after connection loss. Applications\nmust supply service-specific fencing/idempotency and truthful inspection, or\nrequire manual repair. This API makes no exactly-once claim for external effects.\n\nFocused validation: `pnpm --filter @happyvertical/smrt-core exec vitest run\nsrc/migrations/__tests__/deployment-phase-tracker-postgres.optional.test.ts` with\n`SMRT_TEST_POSTGRES_URL` pointing to an isolated PostgreSQL database. Tests use\nseparate pools to exercise serialization, atomic marker/data rollback and commit,\nchecksum refusal, and ambiguous nontransactional recovery.\n"
1188
+ },
1183
1189
  {
1184
1190
  "path": "agents/change-feed.md",
1185
1191
  "module": "change-feed",
@@ -1 +1 @@
1
- {"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../../src/system/retention.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../../src/system/retention.ts"],"names":[],"mappings":"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"}
@@ -160,6 +160,7 @@ async function pruneAiUsage(db, retention) {
160
160
  if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) throw new Error(`pruneAiUsage maxRows must be >= 0, got ${maxRows}`);
161
161
  const now = retention.now ?? /* @__PURE__ */ new Date();
162
162
  const scope = tenantScopeClause(retention);
163
+ scope.conditions.push("NOT (operation = 'external-agent' AND id LIKE 'external-agent:%')");
163
164
  let pruned = 0;
164
165
  const alreadyCounted = {
165
166
  conditions: [],
@@ -1 +1 @@
1
- {"version":3,"file":"retention.js","names":[],"sources":["../../src/system/retention.ts"],"sourcesContent":["/**\n * System-table retention — bounded growth for framework-owned tables (#2375).\n *\n * Several `_smrt_*` tables are append-only by construction and were, until\n * this module, unbounded by default: `_smrt_changes` grew one row per\n * framework save/delete, `_smrt_ai_usage` one row per AI call (persistence is\n * on by default, see `config.ts`), `_smrt_contexts` accumulated rows whose\n * `expires_at` nothing ever enforced, `_smrt_dispatch` retained completed\n * work until an operator remembered to run `smrt dispatch:cleanup`, and\n * `_smrt_run_once_claims` (#3080) gains one row per `runOnce()` submission\n * with nothing pruning completed ones.\n *\n * This module supplies the missing half:\n *\n * - {@link pruneAiUsage} — the retention API `_smrt_ai_usage` never had.\n * - {@link pruneExpiredContexts} — enforcement for `_smrt_contexts.expires_at`.\n * - {@link runRetentionSweep} — one entry point that applies a documented,\n * opt-out {@link RetentionPolicy} across every framework-owned table, plus\n * any task other packages contribute via {@link registerRetentionTask}\n * (`@happyvertical/smrt-jobs` registers job/job-event cleanup;\n * `@happyvertical/smrt-users` registers session and token expiry).\n *\n * Scheduling is the caller's: `smrt db:prune` is the cron entry point, and a\n * running `TaskRunner` sweeps on an interval unless configured otherwise.\n *\n * ## Failure policy\n *\n * A sweep is maintenance, not a transaction. One failing task never aborts the\n * rest — the failure is captured on that task's {@link RetentionTaskResult}\n * and the sweep continues. Callers decide what a partial sweep means; the CLI\n * exits non-zero when any task failed.\n *\n * ## Predicate indexes\n *\n * Every predicate below is indexed by the system DDL in `system/schema.ts`\n * (`_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`,\n * `_smrt_dispatch(status, processed_at)` / `(status, updated_at)`,\n * `_smrt_run_once_claims(status, completed_at)`) or by the jobs compatibility\n * path in `system/compatibility.ts` (`_smrt_jobs(status, completed_at)`,\n * `_smrt_job_events(created_at)`). A new retention predicate must ship with\n * its index.\n *\n * @see https://github.com/happyvertical/smrt/issues/2375\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { pruneChangeFeed } from '../change-feed.js';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport { toSafeInteger } from '../utils/safe-integer.js';\nimport { RUN_ONCE_CLAIMS_TABLE } from './schema.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Milliseconds in a day — the unit every retention policy is expressed in. */\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n// ============================================================================\n// Policy\n// ============================================================================\n\n/** Retention for the append-only change feed (`_smrt_changes`). */\nexport interface ChangeFeedRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n /** Drop entries older than this many days (default 30). */\n maxAgeDays?: number;\n /** Additionally keep at most this many newest entries (default: unbounded). */\n maxRows?: number;\n}\n\n/** Retention for AI usage telemetry (`_smrt_ai_usage`). */\nexport interface AiUsageRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n /** Drop records older than this many days (default 90). */\n maxAgeDays?: number;\n /** Additionally keep at most this many newest records (default: unbounded). */\n maxRows?: number;\n}\n\n/** Retention for remembered context entries (`_smrt_contexts`). */\nexport interface ContextsRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n}\n\n/** Retention for the dispatch queue (`_smrt_dispatch`). */\nexport interface DispatchRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n /** Drop completed dispatches processed more than this many days ago (default 30). */\n completedOlderThanDays?: number;\n /** Drop failed dispatches last touched more than this many days ago (default 90). */\n failedOlderThanDays?: number;\n}\n\n/**\n * Retention for `runOnce()` idempotency claims (`_smrt_run_once_claims`, #3080).\n *\n * Only `status: 'completed'` rows with a `completed_at` older than the window\n * are ever deleted — an `'in_progress'` claim has no `completed_at` and is\n * never touched by this task regardless of age. See\n * {@link pruneRunOnceClaims} and `agents/run-once.md` for the consequence of\n * this window: a caller that replays a `runOnce()` token after its claim has\n * been swept runs `work()` again instead of replaying the stored result, so\n * `maxAgeDays` must stay comfortably longer than any realistic client retry\n * window.\n */\nexport interface RunOnceClaimsRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n /** Drop completed claims older than this many days (default 30). */\n maxAgeDays?: number;\n}\n\n/**\n * Retention policy for a sweep.\n *\n * Every table is pruned by default; set a table's entry to `false` (or\n * `{ enabled: false }`) to opt that table out, or `enabled: false` at the top\n * level to opt the whole sweep out.\n */\nexport interface RetentionPolicy {\n /** Run the sweep at all (default `true`). */\n enabled?: boolean;\n /** Report what would be pruned without deleting anything (default `false`). */\n dryRun?: boolean;\n /** `_smrt_changes` retention, or `false` to skip. */\n changes?: ChangeFeedRetentionPolicy | false;\n /** `_smrt_ai_usage` retention, or `false` to skip. */\n aiUsage?: AiUsageRetentionPolicy | false;\n /** `_smrt_contexts` expiry enforcement, or `false` to skip. */\n contexts?: ContextsRetentionPolicy | false;\n /** `_smrt_dispatch` retention, or `false` to skip. */\n dispatch?: DispatchRetentionPolicy | false;\n /** `_smrt_run_once_claims` retention, or `false` to skip. */\n runOnceClaims?: RunOnceClaimsRetentionPolicy | false;\n /**\n * Enable/disable individual tasks contributed through\n * {@link registerRetentionTask}, keyed by task name. Unlisted tasks run.\n */\n tasks?: Record<string, boolean>;\n}\n\n/**\n * Documented retention defaults.\n *\n * Chosen to be generous relative to every in-tree consumer: change-feed\n * cursors resync automatically past the window, AI usage outlives a monthly\n * billing cycle by two months, and dispatch keeps a full quarter of failures\n * for forensics. Applications that need longer history raise these; those that\n * need none set the table to `false`.\n */\nexport const DEFAULT_RETENTION_POLICY: {\n changes: ChangeFeedRetentionPolicy;\n aiUsage: AiUsageRetentionPolicy;\n contexts: ContextsRetentionPolicy;\n dispatch: DispatchRetentionPolicy;\n runOnceClaims: RunOnceClaimsRetentionPolicy;\n} = {\n changes: { maxAgeDays: 30 },\n aiUsage: { maxAgeDays: 90 },\n contexts: {},\n dispatch: { completedOlderThanDays: 30, failedOlderThanDays: 90 },\n // 30 days is generous relative to any realistic runOnce() client retry —\n // see RunOnceClaimsRetentionPolicy for the replay consequence of a shorter\n // window.\n runOnceClaims: { maxAgeDays: 30 },\n};\n\n// ============================================================================\n// Tasks\n// ============================================================================\n\n/** Per-run inputs handed to every retention task. */\nexport interface RetentionTaskContext {\n /** Count what would be deleted instead of deleting it. */\n dryRun: boolean;\n /** Sweep start time — every task derives its cutoffs from this one clock. */\n now: Date;\n}\n\n/** What one task did (or why it did nothing). */\nexport interface RetentionTaskResult {\n /** Task name, unique within a sweep. */\n task: string;\n /** Rows deleted, or — under `dryRun` — rows that would be deleted. */\n pruned: number;\n /**\n * Why the task did no work: `disabled` (policy opted it out) or\n * `unavailable` (the table does not exist in this database).\n */\n skipped?: 'disabled' | 'unavailable';\n /** Message of the error this task failed with, if any. */\n error?: string;\n /** Optional per-bucket breakdown (e.g. completed vs failed dispatches). */\n details?: Record<string, number>;\n}\n\n/** What a task returns; a bare number is shorthand for `{ pruned }`. */\nexport type RetentionTaskOutcome =\n | number\n | { pruned: number; details?: Record<string, number> };\n\n/**\n * A retention task contributed by a package that owns its own tables.\n *\n * Tasks must honour `context.dryRun` — a task that deletes during a dry run\n * makes the whole preview a lie.\n */\nexport interface RetentionTask {\n /** Unique name; also the {@link RetentionPolicy.tasks} opt-out key. */\n name: string;\n /** One-line description surfaced by `smrt db:prune --json`. */\n description?: string;\n /** Perform (or, under `dryRun`, count) the deletion. */\n run(\n db: DatabaseInterface,\n context: RetentionTaskContext,\n ): Promise<RetentionTaskOutcome>;\n}\n\nconst RETENTION_TASKS_KEY = Symbol.for('smrt.retention-tasks');\n\n/**\n * Names {@link builtInTasks} always uses for the five framework-owned tables.\n *\n * Reserved against contributed tasks: `RetentionTaskResult.task` is\n * documented unique within a sweep, and a contributed task sharing one of\n * these names would collide with a built-in result — silently, since nothing\n * else in the sweep loop checks for it — and would also be unreachable\n * through {@link RetentionPolicy.tasks}, which only ever sees the *last*\n * task run under that name, never the built-in one (opting a built-in table\n * out goes through its own dedicated policy field instead).\n */\nconst RESERVED_TASK_NAMES = new Set([\n 'changes',\n 'ai-usage',\n 'contexts',\n 'dispatch',\n 'run-once-claims',\n]);\n\n/**\n * The task registry, held on `globalThis` rather than in module scope.\n *\n * Contributing packages resolve their own copy of `@happyvertical/smrt-core`\n * whenever a consumer's install is not fully deduped, and the CLI resolves a\n * third. A module-local `Map` would give each of them a private, empty\n * registry — the task would register in one and the sweep would run in\n * another. `ObjectRegistry` is on `globalThis` for the same reason.\n */\nfunction registeredTasks(): Map<string, RetentionTask> {\n const root = globalThis as typeof globalThis & {\n [RETENTION_TASKS_KEY]?: Map<string, RetentionTask>;\n };\n root[RETENTION_TASKS_KEY] ??= new Map<string, RetentionTask>();\n return root[RETENTION_TASKS_KEY];\n}\n\n/**\n * Register a retention task so {@link runRetentionSweep} includes it.\n *\n * Registering a task does not schedule anything — a task only runs when\n * something calls {@link runRetentionSweep}, and the policy can still turn it\n * off by name. Re-registering the same name replaces the previous task, which\n * keeps module re-evaluation (HMR, repeated test imports) idempotent.\n *\n * @throws If `task.name` is empty, or is one of the five built-in table\n * names (`changes`, `ai-usage`, `contexts`, `dispatch`, `run-once-claims`)\n * — see {@link RESERVED_TASK_NAMES}.\n */\nexport function registerRetentionTask(task: RetentionTask): void {\n if (!task.name) {\n throw new Error('registerRetentionTask requires a non-empty task name');\n }\n if (RESERVED_TASK_NAMES.has(task.name)) {\n throw new Error(\n `registerRetentionTask: \"${task.name}\" is a built-in task name (reserved: ${[...RESERVED_TASK_NAMES].join(', ')})`,\n );\n }\n registeredTasks().set(task.name, task);\n}\n\n/** Remove a registered retention task. Returns `true` if one was removed. */\nexport function unregisterRetentionTask(name: string): boolean {\n return registeredTasks().delete(name);\n}\n\n/** Registered retention tasks, ordered by name for deterministic sweeps. */\nexport function getRetentionTasks(): RetentionTask[] {\n return [...registeredTasks().values()].sort((left, right) =>\n left.name.localeCompare(right.name),\n );\n}\n\n/** Drop every registered retention task (test helper). */\nexport function clearRetentionTasks(): void {\n registeredTasks().clear();\n}\n\n// ============================================================================\n// AI usage retention\n// ============================================================================\n\n/** Bounds for {@link pruneAiUsage}. At least one bound is required. */\nexport interface AiUsageRetention {\n /** Delete records older than this many milliseconds. */\n maxAgeMs?: number;\n /**\n * Keep at most this many newest records (by `created_at`).\n *\n * A ceiling, not a floor: combined with `maxAgeMs`, the two bounds union\n * — a row past the age cutoff is deleted regardless of `maxRows`, so\n * `maxRows` never resurrects rows the age bound already excludes. If every\n * row is already older than `maxAgeMs`, a finite `maxRows` keeps none of\n * them; it does not guarantee `maxRows` survivors independent of age. (A\n * `maxRows` that overrode age this way would defeat the reason `maxAgeMs`\n * usually exists in the first place — a compliance/privacy deletion\n * deadline that a row-count floor should not be able to postpone.)\n *\n * Rows sharing the cutoff timestamp all survive, so the retained count can\n * exceed `maxRows` by the size of one timestamp tie.\n */\n maxRows?: number;\n /**\n * Restrict the prune to one tenant: a string matches that tenant, `null`\n * matches the global (tenant-less) records. Omit to prune every tenant.\n */\n tenantId?: string | null;\n /** Count matching records without deleting them. */\n dryRun?: boolean;\n /** Clock for the age cutoff (default: now). */\n now?: Date;\n}\n\n/** Name of the AI usage telemetry table. */\nconst AI_USAGE_TABLE = '_smrt_ai_usage';\n\n/** Name of the remembered-context table. */\nconst CONTEXTS_TABLE = '_smrt_contexts';\n\n/**\n * Prune AI usage telemetry to bound `_smrt_ai_usage` growth.\n *\n * `_smrt_ai_usage` gains one row per AI call and persistence is on by default,\n * so a busy deployment writes millions of rows a year with nothing to remove\n * them. Both bounds may be combined; each is applied independently and the\n * returned count is the total deleted.\n *\n * Predicates are covered by `idx_smrt_ai_usage_tenant_created`\n * (`tenant_id, created_at`) — the same index the subscriptions billing meter\n * needs for its `tenant_id + created_at` range scan.\n *\n * @param db - Database holding the system tables.\n * @param retention - At least one of `maxAgeMs` / `maxRows`.\n * @returns Number of records deleted (or, under `dryRun`, matched).\n */\nexport async function pruneAiUsage(\n db: DatabaseInterface,\n retention: AiUsageRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows, dryRun = false } = retention;\n\n if (maxAgeMs == null && maxRows == null) {\n throw new Error('pruneAiUsage requires maxAgeMs and/or maxRows');\n }\n if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {\n throw new Error(`pruneAiUsage maxAgeMs must be >= 0, got ${maxAgeMs}`);\n }\n if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) {\n throw new Error(`pruneAiUsage maxRows must be >= 0, got ${maxRows}`);\n }\n\n const now = retention.now ?? new Date();\n const scope = tenantScopeClause(retention);\n let pruned = 0;\n\n // Rows the age bound already accounted for. Redundant when they were really\n // deleted, load-bearing under `dryRun`, where they were not — without it the\n // row bound would count the same records a second time.\n const alreadyCounted: { conditions: string[]; params: unknown[] } = {\n conditions: [],\n params: [],\n };\n\n if (maxAgeMs != null) {\n const cutoff = new Date(now.getTime() - maxAgeMs).toISOString();\n pruned += await deleteFrom(\n db,\n AI_USAGE_TABLE,\n [...scope.conditions, 'created_at < ?'],\n [...scope.params, cutoff],\n dryRun,\n );\n alreadyCounted.conditions.push('created_at >= ?');\n alreadyCounted.params.push(cutoff);\n }\n\n if (maxRows != null) {\n const keep = Math.floor(maxRows);\n if (keep === 0) {\n pruned += await deleteFrom(\n db,\n AI_USAGE_TABLE,\n [...scope.conditions, ...alreadyCounted.conditions],\n [...scope.params, ...alreadyCounted.params],\n dryRun,\n );\n } else {\n // The oldest retained row's timestamp is the cutoff. `LIMIT 1 OFFSET n`\n // is portable across every supported engine, unlike the\n // `LIMIT ALL`/`LIMIT -1` spellings of \"all rows past an offset\".\n const boundary = await selectOne(\n db,\n `SELECT created_at FROM ${AI_USAGE_TABLE}\n ${whereClause(scope.conditions)}\n ORDER BY created_at DESC\n LIMIT 1 OFFSET ${keep - 1}`,\n scope.params,\n );\n const cutoff = boundary?.created_at;\n if (cutoff != null) {\n pruned += await deleteFrom(\n db,\n AI_USAGE_TABLE,\n [...scope.conditions, 'created_at < ?', ...alreadyCounted.conditions],\n [...scope.params, cutoff, ...alreadyCounted.params],\n dryRun,\n );\n }\n }\n }\n\n return { pruned };\n}\n\n/**\n * Delete context entries whose `expires_at` has passed.\n *\n * `remember({ expiresAt })` has always stored the expiry and never acted on\n * it. Enforcement is deliberately prune-side only: `recall()`/`recallAll()`\n * keep their documented \"expiry is not applied at read time\" contract, so this\n * bounds storage without changing read semantics for existing callers.\n * (`LearningMemory` filters expired rows itself.)\n *\n * Rows with a `NULL` `expires_at` never expire and are never touched.\n * The predicate is covered by `idx_smrt_contexts_expires_at`.\n *\n * @param db - Database holding the system tables.\n * @param options.now - Clock for the expiry comparison (default: now).\n * @param options.dryRun - Count matching rows without deleting them.\n * @returns Number of expired entries deleted (or, under `dryRun`, matched).\n */\nexport async function pruneExpiredContexts(\n db: DatabaseInterface,\n options: { now?: Date; dryRun?: boolean } = {},\n): Promise<{ pruned: number }> {\n const now = (options.now ?? new Date()).toISOString();\n const pruned = await deleteFrom(\n db,\n CONTEXTS_TABLE,\n ['expires_at IS NOT NULL', 'expires_at < ?'],\n [now],\n options.dryRun ?? false,\n );\n return { pruned };\n}\n\n// ============================================================================\n// runOnce() claim retention\n// ============================================================================\n\n/** Bounds for {@link pruneRunOnceClaims}. */\nexport interface RunOnceClaimsRetention {\n /** Delete completed claims older than this many milliseconds. */\n maxAgeMs: number;\n /** Count matching records without deleting them. */\n dryRun?: boolean;\n /** Clock for the age cutoff (default: now). */\n now?: Date;\n}\n\n/**\n * Prune completed `runOnce()` idempotency claims to bound\n * `_smrt_run_once_claims` growth (#3080).\n *\n * Only rows with `status = 'completed'` AND a `completed_at` older than the\n * cutoff are deleted. An `'in_progress'` claim has no `completed_at`, so this\n * predicate can never match one — a claim held open by an in-flight (or\n * abandoned) transaction is never pruned by age alone.\n *\n * Predicate is covered by `idx_smrt_run_once_claims_status_completed`\n * (`status, completed_at`).\n *\n * @param db - Database holding the system tables.\n * @param retention - `maxAgeMs` is required; there is no row-count bound.\n * @returns Number of claims deleted (or, under `dryRun`, matched).\n */\nexport async function pruneRunOnceClaims(\n db: DatabaseInterface,\n retention: RunOnceClaimsRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, dryRun = false } = retention;\n\n if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) {\n throw new Error(\n `pruneRunOnceClaims maxAgeMs must be >= 0, got ${maxAgeMs}`,\n );\n }\n\n const now = retention.now ?? new Date();\n const cutoff = new Date(now.getTime() - maxAgeMs).toISOString();\n const pruned = await deleteFrom(\n db,\n RUN_ONCE_CLAIMS_TABLE,\n [\"status = 'completed'\", 'completed_at IS NOT NULL', 'completed_at < ?'],\n [cutoff],\n dryRun,\n );\n return { pruned };\n}\n\n// ============================================================================\n// Sweep\n// ============================================================================\n\n/** Aggregate outcome of one {@link runRetentionSweep} call. */\nexport interface RetentionSweepResult {\n /** Whether this run only counted rows. */\n dryRun: boolean;\n /** Sweep start time, ISO-8601. */\n startedAt: string;\n /** Wall-clock duration of the sweep. */\n durationMs: number;\n /** Total rows pruned (or matched) across all tasks. */\n pruned: number;\n /** Per-task outcomes, in execution order. */\n tasks: RetentionTaskResult[];\n /** Whether any task failed — the sweep never throws for a task failure. */\n failed: boolean;\n}\n\n/**\n * Apply a {@link RetentionPolicy} across every framework-owned table.\n *\n * Built-in tasks run first, in a fixed order (`changes`, `ai-usage`,\n * `contexts`, `dispatch`, `run-once-claims`), followed by tasks registered via\n * {@link registerRetentionTask} in name order. Tables that do not exist in\n * this database are reported as `skipped: 'unavailable'` rather than failing —\n * a sweep must be safe to run against a partially bootstrapped database.\n *\n * @param db - Database holding the system tables.\n * @param policy - Overrides on {@link DEFAULT_RETENTION_POLICY}.\n * @returns Per-task and aggregate counts; never throws for a task failure.\n */\nexport async function runRetentionSweep(\n db: DatabaseInterface,\n policy: RetentionPolicy = {},\n): Promise<RetentionSweepResult> {\n const startedAt = new Date();\n const dryRun = policy.dryRun ?? false;\n\n if (policy.enabled === false) {\n return {\n dryRun,\n startedAt: startedAt.toISOString(),\n durationMs: 0,\n pruned: 0,\n tasks: [],\n failed: false,\n };\n }\n\n const context: RetentionTaskContext = { dryRun, now: startedAt };\n const tasks: RetentionTaskResult[] = [];\n\n for (const builtIn of builtInTasks(policy)) {\n if (!builtIn.task) {\n tasks.push({ task: builtIn.name, pruned: 0, skipped: 'disabled' });\n continue;\n }\n tasks.push(await runTask(db, builtIn.table, builtIn.task, context));\n }\n\n for (const task of getRetentionTasks()) {\n if (policy.tasks?.[task.name] === false) {\n tasks.push({ task: task.name, pruned: 0, skipped: 'disabled' });\n continue;\n }\n tasks.push(await runTask(db, null, task, context));\n }\n\n const pruned = tasks.reduce((total, task) => total + task.pruned, 0);\n const failed = tasks.some((task) => task.error !== undefined);\n\n logger.info('[smrt] retention sweep complete', {\n dryRun,\n pruned,\n failed,\n tasks: tasks.map((task) => `${task.task}=${task.pruned}`).join(' '),\n });\n\n return {\n dryRun,\n startedAt: startedAt.toISOString(),\n durationMs: Date.now() - startedAt.getTime(),\n pruned,\n tasks,\n failed,\n };\n}\n\n/**\n * Run one task, converting an absent table or a thrown error into a result.\n *\n * `requiredTable` is checked before the task runs so a sweep against a\n * database that never bootstrapped a given system table reports\n * `unavailable` instead of surfacing an engine-specific \"no such table\".\n */\nasync function runTask(\n db: DatabaseInterface,\n requiredTable: string | null,\n task: RetentionTask,\n context: RetentionTaskContext,\n): Promise<RetentionTaskResult> {\n try {\n if (requiredTable && !(await tableExists(db, requiredTable))) {\n return { task: task.name, pruned: 0, skipped: 'unavailable' };\n }\n\n const outcome = await task.run(db, context);\n return typeof outcome === 'number'\n ? { task: task.name, pruned: outcome }\n : { task: task.name, pruned: outcome.pruned, details: outcome.details };\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n logger.warn(`[smrt] retention task \"${task.name}\" failed: ${message}`);\n return { task: task.name, pruned: 0, error: message };\n }\n}\n\n/** Resolve a per-table policy entry, or `null` when it is opted out. */\nfunction resolveEntry<T extends { enabled?: boolean }>(\n entry: T | false | undefined,\n defaults: T,\n): T | null {\n if (entry === false) return null;\n const merged = { ...defaults, ...(entry ?? {}) };\n return merged.enabled === false ? null : merged;\n}\n\n/** A built-in task plus the table it needs and whether policy enabled it. */\ninterface BuiltInRetentionTask {\n name: string;\n table: string;\n task: RetentionTask | null;\n}\n\n/** The framework-owned tasks, in their fixed execution order. */\nfunction builtInTasks(policy: RetentionPolicy): BuiltInRetentionTask[] {\n return [\n { name: 'changes', table: '_smrt_changes', task: changeFeedTask(policy) },\n { name: 'ai-usage', table: AI_USAGE_TABLE, task: aiUsageTask(policy) },\n { name: 'contexts', table: CONTEXTS_TABLE, task: contextsTask(policy) },\n { name: 'dispatch', table: '_smrt_dispatch', task: dispatchTask(policy) },\n {\n name: 'run-once-claims',\n table: RUN_ONCE_CLAIMS_TABLE,\n task: runOnceClaimsTask(policy),\n },\n ];\n}\n\nfunction changeFeedTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<ChangeFeedRetentionPolicy>(\n policy.changes,\n DEFAULT_RETENTION_POLICY.changes,\n );\n if (!config) return null;\n\n return {\n name: 'changes',\n description: 'Prune the append-only change feed (_smrt_changes)',\n run: async (db, context) => {\n const { pruned } = await pruneChangeFeed(db, {\n maxAgeMs:\n config.maxAgeDays == null\n ? undefined\n : config.maxAgeDays * MS_PER_DAY,\n maxRows: config.maxRows,\n dryRun: context.dryRun,\n });\n return pruned;\n },\n };\n}\n\nfunction aiUsageTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<AiUsageRetentionPolicy>(\n policy.aiUsage,\n DEFAULT_RETENTION_POLICY.aiUsage,\n );\n if (!config) return null;\n\n return {\n name: 'ai-usage',\n description: 'Prune AI usage telemetry (_smrt_ai_usage)',\n run: async (db, context) => {\n const { pruned } = await pruneAiUsage(db, {\n maxAgeMs:\n config.maxAgeDays == null\n ? undefined\n : config.maxAgeDays * MS_PER_DAY,\n maxRows: config.maxRows,\n dryRun: context.dryRun,\n now: context.now,\n });\n return pruned;\n },\n };\n}\n\nfunction contextsTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<ContextsRetentionPolicy>(\n policy.contexts,\n DEFAULT_RETENTION_POLICY.contexts,\n );\n if (!config) return null;\n\n return {\n name: 'contexts',\n description: 'Delete expired context entries (_smrt_contexts)',\n run: async (db, context) => {\n const { pruned } = await pruneExpiredContexts(db, {\n now: context.now,\n dryRun: context.dryRun,\n });\n return pruned;\n },\n };\n}\n\nfunction dispatchTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<DispatchRetentionPolicy>(\n policy.dispatch,\n DEFAULT_RETENTION_POLICY.dispatch,\n );\n if (!config) return null;\n\n return {\n name: 'dispatch',\n description: 'Prune processed dispatch queue rows (_smrt_dispatch)',\n run: async (db, context) => {\n // Imported lazily: the dispatch collection pulls in the whole dispatch\n // module graph, which this module must not force onto callers that only\n // prune system tables.\n const { DispatchCollection } = await import(\n '../dispatch/collections/Dispatches.js'\n );\n const result = await DispatchCollection.cleanup(db, {\n completedOlderThanDays: config.completedOlderThanDays,\n failedOlderThanDays: config.failedOlderThanDays,\n dryRun: context.dryRun,\n });\n return {\n pruned: result.completedDeleted + result.failedDeleted,\n details: {\n completed: result.completedDeleted,\n failed: result.failedDeleted,\n },\n };\n },\n };\n}\n\nfunction runOnceClaimsTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<RunOnceClaimsRetentionPolicy>(\n policy.runOnceClaims,\n DEFAULT_RETENTION_POLICY.runOnceClaims,\n );\n if (!config) return null;\n\n return {\n name: 'run-once-claims',\n description:\n 'Prune completed runOnce() idempotency claims (_smrt_run_once_claims)',\n run: async (db, context) => {\n const { pruned } = await pruneRunOnceClaims(db, {\n maxAgeMs:\n (config.maxAgeDays ??\n DEFAULT_RETENTION_POLICY.runOnceClaims.maxAgeDays ??\n 30) * MS_PER_DAY,\n dryRun: context.dryRun,\n now: context.now,\n });\n return pruned;\n },\n };\n}\n\n// ============================================================================\n// SQL helpers\n// ============================================================================\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction getDatabaseUrl(db: DatabaseInterface): string {\n return db.url || (db as { config?: { url?: string } }).config?.url || '';\n}\n\n/**\n * Rewrite `?` placeholders to `$n` on PostgreSQL.\n *\n * Retention SQL is written once in the portable `?` form; only the binding\n * syntax differs between engines.\n */\nfunction bindPlaceholders(db: DatabaseInterface, sql: string): string {\n const engine = detectEngine(\n getDatabaseUrl(db),\n (db as { type?: string }).type,\n );\n if (engine !== 'postgres') return sql;\n\n let index = 0;\n return sql.replace(/\\?/g, () => `$${++index}`);\n}\n\nfunction whereClause(conditions: string[]): string {\n return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';\n}\n\nfunction tenantScopeClause(retention: AiUsageRetention): {\n conditions: string[];\n params: unknown[];\n} {\n if (!('tenantId' in retention)) return { conditions: [], params: [] };\n if (retention.tenantId === undefined) return { conditions: [], params: [] };\n if (retention.tenantId === null) {\n return { conditions: ['tenant_id IS NULL'], params: [] };\n }\n return { conditions: ['tenant_id = ?'], params: [retention.tenantId] };\n}\n\nasync function selectOne(\n db: DatabaseInterface,\n sql: string,\n params: unknown[],\n): Promise<Record<string, unknown> | undefined> {\n const rows = getQueryRows(\n await db.query(bindPlaceholders(db, sql), ...params),\n );\n return rows[0];\n}\n\n/**\n * Count matching rows, then delete them unless this is a dry run.\n *\n * Counting first gives a usable number on every adapter — `rowCount` is not\n * reliably populated across the engines SMRT supports — and is what makes\n * `dryRun` a genuine preview of the same predicate rather than an estimate of\n * a different one.\n *\n * The count and the delete are two statements and deliberately not a\n * transaction: a maintenance sweep must not hold a write lock over a large\n * delete. The returned figure is therefore approximate under concurrent\n * writers, exactly as `pruneChangeFeed` documents.\n */\nasync function deleteFrom(\n db: DatabaseInterface,\n table: string,\n conditions: string[],\n params: unknown[],\n dryRun: boolean,\n): Promise<number> {\n const where = whereClause(conditions);\n const countRow = await selectOne(\n db,\n `SELECT COUNT(*) AS total FROM ${table} ${where}`,\n params,\n );\n const total = toSafeInteger(countRow?.total ?? 0, 'Retention count');\n if (total <= 0) return 0;\n\n if (!dryRun) {\n await db.query(\n bindPlaceholders(db, `DELETE FROM ${table} ${where}`),\n ...params,\n );\n }\n\n return total;\n}\n\n/** Best-effort table probe used to report `unavailable` rather than throwing. */\nasync function tableExists(\n db: DatabaseInterface,\n table: string,\n): Promise<boolean> {\n try {\n if (typeof db.tableExists === 'function') {\n return await db.tableExists(table);\n }\n } catch {\n return false;\n }\n\n try {\n await db.query(`SELECT 1 FROM ${table} LIMIT 1`);\n return true;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"retention.js","names":[],"sources":["../../src/system/retention.ts"],"sourcesContent":["/**\n * System-table retention — bounded growth for framework-owned tables (#2375).\n *\n * Several `_smrt_*` tables are append-only by construction and were, until\n * this module, unbounded by default: `_smrt_changes` grew one row per\n * framework save/delete, `_smrt_ai_usage` one row per AI call (persistence is\n * on by default, see `config.ts`), `_smrt_contexts` accumulated rows whose\n * `expires_at` nothing ever enforced, `_smrt_dispatch` retained completed\n * work until an operator remembered to run `smrt dispatch:cleanup`, and\n * `_smrt_run_once_claims` (#3080) gains one row per `runOnce()` submission\n * with nothing pruning completed ones.\n *\n * This module supplies the missing half:\n *\n * - {@link pruneAiUsage} — the retention API `_smrt_ai_usage` never had.\n * - {@link pruneExpiredContexts} — enforcement for `_smrt_contexts.expires_at`.\n * - {@link runRetentionSweep} — one entry point that applies a documented,\n * opt-out {@link RetentionPolicy} across every framework-owned table, plus\n * any task other packages contribute via {@link registerRetentionTask}\n * (`@happyvertical/smrt-jobs` registers job/job-event cleanup;\n * `@happyvertical/smrt-users` registers session and token expiry).\n *\n * Scheduling is the caller's: `smrt db:prune` is the cron entry point, and a\n * running `TaskRunner` sweeps on an interval unless configured otherwise.\n *\n * ## Failure policy\n *\n * A sweep is maintenance, not a transaction. One failing task never aborts the\n * rest — the failure is captured on that task's {@link RetentionTaskResult}\n * and the sweep continues. Callers decide what a partial sweep means; the CLI\n * exits non-zero when any task failed.\n *\n * ## Predicate indexes\n *\n * Every predicate below is indexed by the system DDL in `system/schema.ts`\n * (`_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`,\n * `_smrt_dispatch(status, processed_at)` / `(status, updated_at)`,\n * `_smrt_run_once_claims(status, completed_at)`) or by the jobs compatibility\n * path in `system/compatibility.ts` (`_smrt_jobs(status, completed_at)`,\n * `_smrt_job_events(created_at)`). A new retention predicate must ship with\n * its index.\n *\n * @see https://github.com/happyvertical/smrt/issues/2375\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { pruneChangeFeed } from '../change-feed.js';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport { toSafeInteger } from '../utils/safe-integer.js';\nimport { RUN_ONCE_CLAIMS_TABLE } from './schema.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Milliseconds in a day — the unit every retention policy is expressed in. */\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n// ============================================================================\n// Policy\n// ============================================================================\n\n/** Retention for the append-only change feed (`_smrt_changes`). */\nexport interface ChangeFeedRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n /** Drop entries older than this many days (default 30). */\n maxAgeDays?: number;\n /** Additionally keep at most this many newest entries (default: unbounded). */\n maxRows?: number;\n}\n\n/** Retention for AI usage telemetry (`_smrt_ai_usage`). */\nexport interface AiUsageRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n /** Drop records older than this many days (default 90). */\n maxAgeDays?: number;\n /** Additionally keep at most this many newest records (default: unbounded). */\n maxRows?: number;\n}\n\n/** Retention for remembered context entries (`_smrt_contexts`). */\nexport interface ContextsRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n}\n\n/** Retention for the dispatch queue (`_smrt_dispatch`). */\nexport interface DispatchRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n /** Drop completed dispatches processed more than this many days ago (default 30). */\n completedOlderThanDays?: number;\n /** Drop failed dispatches last touched more than this many days ago (default 90). */\n failedOlderThanDays?: number;\n}\n\n/**\n * Retention for `runOnce()` idempotency claims (`_smrt_run_once_claims`, #3080).\n *\n * Only `status: 'completed'` rows with a `completed_at` older than the window\n * are ever deleted — an `'in_progress'` claim has no `completed_at` and is\n * never touched by this task regardless of age. See\n * {@link pruneRunOnceClaims} and `agents/run-once.md` for the consequence of\n * this window: a caller that replays a `runOnce()` token after its claim has\n * been swept runs `work()` again instead of replaying the stored result, so\n * `maxAgeDays` must stay comfortably longer than any realistic client retry\n * window.\n */\nexport interface RunOnceClaimsRetentionPolicy {\n /** Run this task (default `true`). */\n enabled?: boolean;\n /** Drop completed claims older than this many days (default 30). */\n maxAgeDays?: number;\n}\n\n/**\n * Retention policy for a sweep.\n *\n * Every table is pruned by default; set a table's entry to `false` (or\n * `{ enabled: false }`) to opt that table out, or `enabled: false` at the top\n * level to opt the whole sweep out.\n */\nexport interface RetentionPolicy {\n /** Run the sweep at all (default `true`). */\n enabled?: boolean;\n /** Report what would be pruned without deleting anything (default `false`). */\n dryRun?: boolean;\n /** `_smrt_changes` retention, or `false` to skip. */\n changes?: ChangeFeedRetentionPolicy | false;\n /** `_smrt_ai_usage` retention, or `false` to skip. */\n aiUsage?: AiUsageRetentionPolicy | false;\n /** `_smrt_contexts` expiry enforcement, or `false` to skip. */\n contexts?: ContextsRetentionPolicy | false;\n /** `_smrt_dispatch` retention, or `false` to skip. */\n dispatch?: DispatchRetentionPolicy | false;\n /** `_smrt_run_once_claims` retention, or `false` to skip. */\n runOnceClaims?: RunOnceClaimsRetentionPolicy | false;\n /**\n * Enable/disable individual tasks contributed through\n * {@link registerRetentionTask}, keyed by task name. Unlisted tasks run.\n */\n tasks?: Record<string, boolean>;\n}\n\n/**\n * Documented retention defaults.\n *\n * Chosen to be generous relative to every in-tree consumer: change-feed\n * cursors resync automatically past the window, AI usage outlives a monthly\n * billing cycle by two months, and dispatch keeps a full quarter of failures\n * for forensics. Applications that need longer history raise these; those that\n * need none set the table to `false`.\n */\nexport const DEFAULT_RETENTION_POLICY: {\n changes: ChangeFeedRetentionPolicy;\n aiUsage: AiUsageRetentionPolicy;\n contexts: ContextsRetentionPolicy;\n dispatch: DispatchRetentionPolicy;\n runOnceClaims: RunOnceClaimsRetentionPolicy;\n} = {\n changes: { maxAgeDays: 30 },\n aiUsage: { maxAgeDays: 90 },\n contexts: {},\n dispatch: { completedOlderThanDays: 30, failedOlderThanDays: 90 },\n // 30 days is generous relative to any realistic runOnce() client retry —\n // see RunOnceClaimsRetentionPolicy for the replay consequence of a shorter\n // window.\n runOnceClaims: { maxAgeDays: 30 },\n};\n\n// ============================================================================\n// Tasks\n// ============================================================================\n\n/** Per-run inputs handed to every retention task. */\nexport interface RetentionTaskContext {\n /** Count what would be deleted instead of deleting it. */\n dryRun: boolean;\n /** Sweep start time — every task derives its cutoffs from this one clock. */\n now: Date;\n}\n\n/** What one task did (or why it did nothing). */\nexport interface RetentionTaskResult {\n /** Task name, unique within a sweep. */\n task: string;\n /** Rows deleted, or — under `dryRun` — rows that would be deleted. */\n pruned: number;\n /**\n * Why the task did no work: `disabled` (policy opted it out) or\n * `unavailable` (the table does not exist in this database).\n */\n skipped?: 'disabled' | 'unavailable';\n /** Message of the error this task failed with, if any. */\n error?: string;\n /** Optional per-bucket breakdown (e.g. completed vs failed dispatches). */\n details?: Record<string, number>;\n}\n\n/** What a task returns; a bare number is shorthand for `{ pruned }`. */\nexport type RetentionTaskOutcome =\n | number\n | { pruned: number; details?: Record<string, number> };\n\n/**\n * A retention task contributed by a package that owns its own tables.\n *\n * Tasks must honour `context.dryRun` — a task that deletes during a dry run\n * makes the whole preview a lie.\n */\nexport interface RetentionTask {\n /** Unique name; also the {@link RetentionPolicy.tasks} opt-out key. */\n name: string;\n /** One-line description surfaced by `smrt db:prune --json`. */\n description?: string;\n /** Perform (or, under `dryRun`, count) the deletion. */\n run(\n db: DatabaseInterface,\n context: RetentionTaskContext,\n ): Promise<RetentionTaskOutcome>;\n}\n\nconst RETENTION_TASKS_KEY = Symbol.for('smrt.retention-tasks');\n\n/**\n * Names {@link builtInTasks} always uses for the five framework-owned tables.\n *\n * Reserved against contributed tasks: `RetentionTaskResult.task` is\n * documented unique within a sweep, and a contributed task sharing one of\n * these names would collide with a built-in result — silently, since nothing\n * else in the sweep loop checks for it — and would also be unreachable\n * through {@link RetentionPolicy.tasks}, which only ever sees the *last*\n * task run under that name, never the built-in one (opting a built-in table\n * out goes through its own dedicated policy field instead).\n */\nconst RESERVED_TASK_NAMES = new Set([\n 'changes',\n 'ai-usage',\n 'contexts',\n 'dispatch',\n 'run-once-claims',\n]);\n\n/**\n * The task registry, held on `globalThis` rather than in module scope.\n *\n * Contributing packages resolve their own copy of `@happyvertical/smrt-core`\n * whenever a consumer's install is not fully deduped, and the CLI resolves a\n * third. A module-local `Map` would give each of them a private, empty\n * registry — the task would register in one and the sweep would run in\n * another. `ObjectRegistry` is on `globalThis` for the same reason.\n */\nfunction registeredTasks(): Map<string, RetentionTask> {\n const root = globalThis as typeof globalThis & {\n [RETENTION_TASKS_KEY]?: Map<string, RetentionTask>;\n };\n root[RETENTION_TASKS_KEY] ??= new Map<string, RetentionTask>();\n return root[RETENTION_TASKS_KEY];\n}\n\n/**\n * Register a retention task so {@link runRetentionSweep} includes it.\n *\n * Registering a task does not schedule anything — a task only runs when\n * something calls {@link runRetentionSweep}, and the policy can still turn it\n * off by name. Re-registering the same name replaces the previous task, which\n * keeps module re-evaluation (HMR, repeated test imports) idempotent.\n *\n * @throws If `task.name` is empty, or is one of the five built-in table\n * names (`changes`, `ai-usage`, `contexts`, `dispatch`, `run-once-claims`)\n * — see {@link RESERVED_TASK_NAMES}.\n */\nexport function registerRetentionTask(task: RetentionTask): void {\n if (!task.name) {\n throw new Error('registerRetentionTask requires a non-empty task name');\n }\n if (RESERVED_TASK_NAMES.has(task.name)) {\n throw new Error(\n `registerRetentionTask: \"${task.name}\" is a built-in task name (reserved: ${[...RESERVED_TASK_NAMES].join(', ')})`,\n );\n }\n registeredTasks().set(task.name, task);\n}\n\n/** Remove a registered retention task. Returns `true` if one was removed. */\nexport function unregisterRetentionTask(name: string): boolean {\n return registeredTasks().delete(name);\n}\n\n/** Registered retention tasks, ordered by name for deterministic sweeps. */\nexport function getRetentionTasks(): RetentionTask[] {\n return [...registeredTasks().values()].sort((left, right) =>\n left.name.localeCompare(right.name),\n );\n}\n\n/** Drop every registered retention task (test helper). */\nexport function clearRetentionTasks(): void {\n registeredTasks().clear();\n}\n\n// ============================================================================\n// AI usage retention\n// ============================================================================\n\n/** Bounds for {@link pruneAiUsage}. At least one bound is required. */\nexport interface AiUsageRetention {\n /** Delete records older than this many milliseconds. */\n maxAgeMs?: number;\n /**\n * Keep at most this many newest records (by `created_at`).\n *\n * A ceiling, not a floor: combined with `maxAgeMs`, the two bounds union\n * — a row past the age cutoff is deleted regardless of `maxRows`, so\n * `maxRows` never resurrects rows the age bound already excludes. If every\n * row is already older than `maxAgeMs`, a finite `maxRows` keeps none of\n * them; it does not guarantee `maxRows` survivors independent of age. (A\n * `maxRows` that overrode age this way would defeat the reason `maxAgeMs`\n * usually exists in the first place — a compliance/privacy deletion\n * deadline that a row-count floor should not be able to postpone.)\n *\n * Rows sharing the cutoff timestamp all survive, so the retained count can\n * exceed `maxRows` by the size of one timestamp tie.\n */\n maxRows?: number;\n /**\n * Restrict the prune to one tenant: a string matches that tenant, `null`\n * matches the global (tenant-less) records. Omit to prune every tenant.\n */\n tenantId?: string | null;\n /** Count matching records without deleting them. */\n dryRun?: boolean;\n /** Clock for the age cutoff (default: now). */\n now?: Date;\n}\n\n/** Name of the AI usage telemetry table. */\nconst AI_USAGE_TABLE = '_smrt_ai_usage';\n\n/** Name of the remembered-context table. */\nconst CONTEXTS_TABLE = '_smrt_contexts';\n\n/**\n * Prune AI usage telemetry to bound `_smrt_ai_usage` growth.\n *\n * `_smrt_ai_usage` gains one row per AI call and persistence is on by default,\n * so a busy deployment writes millions of rows a year with nothing to remove\n * them. Both bounds may be combined; each is applied independently and the\n * returned count is the total deleted.\n *\n * Predicates are covered by `idx_smrt_ai_usage_tenant_created`\n * (`tenant_id, created_at`) — the same index the subscriptions billing meter\n * needs for its `tenant_id + created_at` range scan.\n *\n * @param db - Database holding the system tables.\n * @param retention - At least one of `maxAgeMs` / `maxRows`.\n * @returns Number of records deleted (or, under `dryRun`, matched).\n */\nexport async function pruneAiUsage(\n db: DatabaseInterface,\n retention: AiUsageRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows, dryRun = false } = retention;\n\n if (maxAgeMs == null && maxRows == null) {\n throw new Error('pruneAiUsage requires maxAgeMs and/or maxRows');\n }\n if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {\n throw new Error(`pruneAiUsage maxAgeMs must be >= 0, got ${maxAgeMs}`);\n }\n if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) {\n throw new Error(`pruneAiUsage maxRows must be >= 0, got ${maxRows}`);\n }\n\n const now = retention.now ?? new Date();\n const scope = tenantScopeClause(retention);\n // External reports are immutable replay evidence, not disposable AI telemetry.\n scope.conditions.push(\n \"NOT (operation = 'external-agent' AND id LIKE 'external-agent:%')\",\n );\n let pruned = 0;\n\n // Rows the age bound already accounted for. Redundant when they were really\n // deleted, load-bearing under `dryRun`, where they were not — without it the\n // row bound would count the same records a second time.\n const alreadyCounted: { conditions: string[]; params: unknown[] } = {\n conditions: [],\n params: [],\n };\n\n if (maxAgeMs != null) {\n const cutoff = new Date(now.getTime() - maxAgeMs).toISOString();\n pruned += await deleteFrom(\n db,\n AI_USAGE_TABLE,\n [...scope.conditions, 'created_at < ?'],\n [...scope.params, cutoff],\n dryRun,\n );\n alreadyCounted.conditions.push('created_at >= ?');\n alreadyCounted.params.push(cutoff);\n }\n\n if (maxRows != null) {\n const keep = Math.floor(maxRows);\n if (keep === 0) {\n pruned += await deleteFrom(\n db,\n AI_USAGE_TABLE,\n [...scope.conditions, ...alreadyCounted.conditions],\n [...scope.params, ...alreadyCounted.params],\n dryRun,\n );\n } else {\n // The oldest retained row's timestamp is the cutoff. `LIMIT 1 OFFSET n`\n // is portable across every supported engine, unlike the\n // `LIMIT ALL`/`LIMIT -1` spellings of \"all rows past an offset\".\n const boundary = await selectOne(\n db,\n `SELECT created_at FROM ${AI_USAGE_TABLE}\n ${whereClause(scope.conditions)}\n ORDER BY created_at DESC\n LIMIT 1 OFFSET ${keep - 1}`,\n scope.params,\n );\n const cutoff = boundary?.created_at;\n if (cutoff != null) {\n pruned += await deleteFrom(\n db,\n AI_USAGE_TABLE,\n [...scope.conditions, 'created_at < ?', ...alreadyCounted.conditions],\n [...scope.params, cutoff, ...alreadyCounted.params],\n dryRun,\n );\n }\n }\n }\n\n return { pruned };\n}\n\n/**\n * Delete context entries whose `expires_at` has passed.\n *\n * `remember({ expiresAt })` has always stored the expiry and never acted on\n * it. Enforcement is deliberately prune-side only: `recall()`/`recallAll()`\n * keep their documented \"expiry is not applied at read time\" contract, so this\n * bounds storage without changing read semantics for existing callers.\n * (`LearningMemory` filters expired rows itself.)\n *\n * Rows with a `NULL` `expires_at` never expire and are never touched.\n * The predicate is covered by `idx_smrt_contexts_expires_at`.\n *\n * @param db - Database holding the system tables.\n * @param options.now - Clock for the expiry comparison (default: now).\n * @param options.dryRun - Count matching rows without deleting them.\n * @returns Number of expired entries deleted (or, under `dryRun`, matched).\n */\nexport async function pruneExpiredContexts(\n db: DatabaseInterface,\n options: { now?: Date; dryRun?: boolean } = {},\n): Promise<{ pruned: number }> {\n const now = (options.now ?? new Date()).toISOString();\n const pruned = await deleteFrom(\n db,\n CONTEXTS_TABLE,\n ['expires_at IS NOT NULL', 'expires_at < ?'],\n [now],\n options.dryRun ?? false,\n );\n return { pruned };\n}\n\n// ============================================================================\n// runOnce() claim retention\n// ============================================================================\n\n/** Bounds for {@link pruneRunOnceClaims}. */\nexport interface RunOnceClaimsRetention {\n /** Delete completed claims older than this many milliseconds. */\n maxAgeMs: number;\n /** Count matching records without deleting them. */\n dryRun?: boolean;\n /** Clock for the age cutoff (default: now). */\n now?: Date;\n}\n\n/**\n * Prune completed `runOnce()` idempotency claims to bound\n * `_smrt_run_once_claims` growth (#3080).\n *\n * Only rows with `status = 'completed'` AND a `completed_at` older than the\n * cutoff are deleted. An `'in_progress'` claim has no `completed_at`, so this\n * predicate can never match one — a claim held open by an in-flight (or\n * abandoned) transaction is never pruned by age alone.\n *\n * Predicate is covered by `idx_smrt_run_once_claims_status_completed`\n * (`status, completed_at`).\n *\n * @param db - Database holding the system tables.\n * @param retention - `maxAgeMs` is required; there is no row-count bound.\n * @returns Number of claims deleted (or, under `dryRun`, matched).\n */\nexport async function pruneRunOnceClaims(\n db: DatabaseInterface,\n retention: RunOnceClaimsRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, dryRun = false } = retention;\n\n if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) {\n throw new Error(\n `pruneRunOnceClaims maxAgeMs must be >= 0, got ${maxAgeMs}`,\n );\n }\n\n const now = retention.now ?? new Date();\n const cutoff = new Date(now.getTime() - maxAgeMs).toISOString();\n const pruned = await deleteFrom(\n db,\n RUN_ONCE_CLAIMS_TABLE,\n [\"status = 'completed'\", 'completed_at IS NOT NULL', 'completed_at < ?'],\n [cutoff],\n dryRun,\n );\n return { pruned };\n}\n\n// ============================================================================\n// Sweep\n// ============================================================================\n\n/** Aggregate outcome of one {@link runRetentionSweep} call. */\nexport interface RetentionSweepResult {\n /** Whether this run only counted rows. */\n dryRun: boolean;\n /** Sweep start time, ISO-8601. */\n startedAt: string;\n /** Wall-clock duration of the sweep. */\n durationMs: number;\n /** Total rows pruned (or matched) across all tasks. */\n pruned: number;\n /** Per-task outcomes, in execution order. */\n tasks: RetentionTaskResult[];\n /** Whether any task failed — the sweep never throws for a task failure. */\n failed: boolean;\n}\n\n/**\n * Apply a {@link RetentionPolicy} across every framework-owned table.\n *\n * Built-in tasks run first, in a fixed order (`changes`, `ai-usage`,\n * `contexts`, `dispatch`, `run-once-claims`), followed by tasks registered via\n * {@link registerRetentionTask} in name order. Tables that do not exist in\n * this database are reported as `skipped: 'unavailable'` rather than failing —\n * a sweep must be safe to run against a partially bootstrapped database.\n *\n * @param db - Database holding the system tables.\n * @param policy - Overrides on {@link DEFAULT_RETENTION_POLICY}.\n * @returns Per-task and aggregate counts; never throws for a task failure.\n */\nexport async function runRetentionSweep(\n db: DatabaseInterface,\n policy: RetentionPolicy = {},\n): Promise<RetentionSweepResult> {\n const startedAt = new Date();\n const dryRun = policy.dryRun ?? false;\n\n if (policy.enabled === false) {\n return {\n dryRun,\n startedAt: startedAt.toISOString(),\n durationMs: 0,\n pruned: 0,\n tasks: [],\n failed: false,\n };\n }\n\n const context: RetentionTaskContext = { dryRun, now: startedAt };\n const tasks: RetentionTaskResult[] = [];\n\n for (const builtIn of builtInTasks(policy)) {\n if (!builtIn.task) {\n tasks.push({ task: builtIn.name, pruned: 0, skipped: 'disabled' });\n continue;\n }\n tasks.push(await runTask(db, builtIn.table, builtIn.task, context));\n }\n\n for (const task of getRetentionTasks()) {\n if (policy.tasks?.[task.name] === false) {\n tasks.push({ task: task.name, pruned: 0, skipped: 'disabled' });\n continue;\n }\n tasks.push(await runTask(db, null, task, context));\n }\n\n const pruned = tasks.reduce((total, task) => total + task.pruned, 0);\n const failed = tasks.some((task) => task.error !== undefined);\n\n logger.info('[smrt] retention sweep complete', {\n dryRun,\n pruned,\n failed,\n tasks: tasks.map((task) => `${task.task}=${task.pruned}`).join(' '),\n });\n\n return {\n dryRun,\n startedAt: startedAt.toISOString(),\n durationMs: Date.now() - startedAt.getTime(),\n pruned,\n tasks,\n failed,\n };\n}\n\n/**\n * Run one task, converting an absent table or a thrown error into a result.\n *\n * `requiredTable` is checked before the task runs so a sweep against a\n * database that never bootstrapped a given system table reports\n * `unavailable` instead of surfacing an engine-specific \"no such table\".\n */\nasync function runTask(\n db: DatabaseInterface,\n requiredTable: string | null,\n task: RetentionTask,\n context: RetentionTaskContext,\n): Promise<RetentionTaskResult> {\n try {\n if (requiredTable && !(await tableExists(db, requiredTable))) {\n return { task: task.name, pruned: 0, skipped: 'unavailable' };\n }\n\n const outcome = await task.run(db, context);\n return typeof outcome === 'number'\n ? { task: task.name, pruned: outcome }\n : { task: task.name, pruned: outcome.pruned, details: outcome.details };\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n logger.warn(`[smrt] retention task \"${task.name}\" failed: ${message}`);\n return { task: task.name, pruned: 0, error: message };\n }\n}\n\n/** Resolve a per-table policy entry, or `null` when it is opted out. */\nfunction resolveEntry<T extends { enabled?: boolean }>(\n entry: T | false | undefined,\n defaults: T,\n): T | null {\n if (entry === false) return null;\n const merged = { ...defaults, ...(entry ?? {}) };\n return merged.enabled === false ? null : merged;\n}\n\n/** A built-in task plus the table it needs and whether policy enabled it. */\ninterface BuiltInRetentionTask {\n name: string;\n table: string;\n task: RetentionTask | null;\n}\n\n/** The framework-owned tasks, in their fixed execution order. */\nfunction builtInTasks(policy: RetentionPolicy): BuiltInRetentionTask[] {\n return [\n { name: 'changes', table: '_smrt_changes', task: changeFeedTask(policy) },\n { name: 'ai-usage', table: AI_USAGE_TABLE, task: aiUsageTask(policy) },\n { name: 'contexts', table: CONTEXTS_TABLE, task: contextsTask(policy) },\n { name: 'dispatch', table: '_smrt_dispatch', task: dispatchTask(policy) },\n {\n name: 'run-once-claims',\n table: RUN_ONCE_CLAIMS_TABLE,\n task: runOnceClaimsTask(policy),\n },\n ];\n}\n\nfunction changeFeedTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<ChangeFeedRetentionPolicy>(\n policy.changes,\n DEFAULT_RETENTION_POLICY.changes,\n );\n if (!config) return null;\n\n return {\n name: 'changes',\n description: 'Prune the append-only change feed (_smrt_changes)',\n run: async (db, context) => {\n const { pruned } = await pruneChangeFeed(db, {\n maxAgeMs:\n config.maxAgeDays == null\n ? undefined\n : config.maxAgeDays * MS_PER_DAY,\n maxRows: config.maxRows,\n dryRun: context.dryRun,\n });\n return pruned;\n },\n };\n}\n\nfunction aiUsageTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<AiUsageRetentionPolicy>(\n policy.aiUsage,\n DEFAULT_RETENTION_POLICY.aiUsage,\n );\n if (!config) return null;\n\n return {\n name: 'ai-usage',\n description: 'Prune AI usage telemetry (_smrt_ai_usage)',\n run: async (db, context) => {\n const { pruned } = await pruneAiUsage(db, {\n maxAgeMs:\n config.maxAgeDays == null\n ? undefined\n : config.maxAgeDays * MS_PER_DAY,\n maxRows: config.maxRows,\n dryRun: context.dryRun,\n now: context.now,\n });\n return pruned;\n },\n };\n}\n\nfunction contextsTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<ContextsRetentionPolicy>(\n policy.contexts,\n DEFAULT_RETENTION_POLICY.contexts,\n );\n if (!config) return null;\n\n return {\n name: 'contexts',\n description: 'Delete expired context entries (_smrt_contexts)',\n run: async (db, context) => {\n const { pruned } = await pruneExpiredContexts(db, {\n now: context.now,\n dryRun: context.dryRun,\n });\n return pruned;\n },\n };\n}\n\nfunction dispatchTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<DispatchRetentionPolicy>(\n policy.dispatch,\n DEFAULT_RETENTION_POLICY.dispatch,\n );\n if (!config) return null;\n\n return {\n name: 'dispatch',\n description: 'Prune processed dispatch queue rows (_smrt_dispatch)',\n run: async (db, context) => {\n // Imported lazily: the dispatch collection pulls in the whole dispatch\n // module graph, which this module must not force onto callers that only\n // prune system tables.\n const { DispatchCollection } = await import(\n '../dispatch/collections/Dispatches.js'\n );\n const result = await DispatchCollection.cleanup(db, {\n completedOlderThanDays: config.completedOlderThanDays,\n failedOlderThanDays: config.failedOlderThanDays,\n dryRun: context.dryRun,\n });\n return {\n pruned: result.completedDeleted + result.failedDeleted,\n details: {\n completed: result.completedDeleted,\n failed: result.failedDeleted,\n },\n };\n },\n };\n}\n\nfunction runOnceClaimsTask(policy: RetentionPolicy): RetentionTask | null {\n const config = resolveEntry<RunOnceClaimsRetentionPolicy>(\n policy.runOnceClaims,\n DEFAULT_RETENTION_POLICY.runOnceClaims,\n );\n if (!config) return null;\n\n return {\n name: 'run-once-claims',\n description:\n 'Prune completed runOnce() idempotency claims (_smrt_run_once_claims)',\n run: async (db, context) => {\n const { pruned } = await pruneRunOnceClaims(db, {\n maxAgeMs:\n (config.maxAgeDays ??\n DEFAULT_RETENTION_POLICY.runOnceClaims.maxAgeDays ??\n 30) * MS_PER_DAY,\n dryRun: context.dryRun,\n now: context.now,\n });\n return pruned;\n },\n };\n}\n\n// ============================================================================\n// SQL helpers\n// ============================================================================\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction getDatabaseUrl(db: DatabaseInterface): string {\n return db.url || (db as { config?: { url?: string } }).config?.url || '';\n}\n\n/**\n * Rewrite `?` placeholders to `$n` on PostgreSQL.\n *\n * Retention SQL is written once in the portable `?` form; only the binding\n * syntax differs between engines.\n */\nfunction bindPlaceholders(db: DatabaseInterface, sql: string): string {\n const engine = detectEngine(\n getDatabaseUrl(db),\n (db as { type?: string }).type,\n );\n if (engine !== 'postgres') return sql;\n\n let index = 0;\n return sql.replace(/\\?/g, () => `$${++index}`);\n}\n\nfunction whereClause(conditions: string[]): string {\n return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';\n}\n\nfunction tenantScopeClause(retention: AiUsageRetention): {\n conditions: string[];\n params: unknown[];\n} {\n if (!('tenantId' in retention)) return { conditions: [], params: [] };\n if (retention.tenantId === undefined) return { conditions: [], params: [] };\n if (retention.tenantId === null) {\n return { conditions: ['tenant_id IS NULL'], params: [] };\n }\n return { conditions: ['tenant_id = ?'], params: [retention.tenantId] };\n}\n\nasync function selectOne(\n db: DatabaseInterface,\n sql: string,\n params: unknown[],\n): Promise<Record<string, unknown> | undefined> {\n const rows = getQueryRows(\n await db.query(bindPlaceholders(db, sql), ...params),\n );\n return rows[0];\n}\n\n/**\n * Count matching rows, then delete them unless this is a dry run.\n *\n * Counting first gives a usable number on every adapter — `rowCount` is not\n * reliably populated across the engines SMRT supports — and is what makes\n * `dryRun` a genuine preview of the same predicate rather than an estimate of\n * a different one.\n *\n * The count and the delete are two statements and deliberately not a\n * transaction: a maintenance sweep must not hold a write lock over a large\n * delete. The returned figure is therefore approximate under concurrent\n * writers, exactly as `pruneChangeFeed` documents.\n */\nasync function deleteFrom(\n db: DatabaseInterface,\n table: string,\n conditions: string[],\n params: unknown[],\n dryRun: boolean,\n): Promise<number> {\n const where = whereClause(conditions);\n const countRow = await selectOne(\n db,\n `SELECT COUNT(*) AS total FROM ${table} ${where}`,\n params,\n );\n const total = toSafeInteger(countRow?.total ?? 0, 'Retention count');\n if (total <= 0) return 0;\n\n if (!dryRun) {\n await db.query(\n bindPlaceholders(db, `DELETE FROM ${table} ${where}`),\n ...params,\n );\n }\n\n return total;\n}\n\n/** Best-effort table probe used to report `unavailable` rather than throwing. */\nasync function tableExists(\n db: DatabaseInterface,\n table: string,\n): Promise<boolean> {\n try {\n if (typeof db.tableExists === 'function') {\n return await db.tableExists(table);\n }\n } catch {\n return false;\n }\n\n try {\n await db.query(`SELECT 1 FROM ${table} LIMIT 1`);\n return true;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import type { Plugin } from 'vite';
1
+ import type { SmrtVitePlugin } from './plugin-contract.js';
2
2
  /**
3
3
  * Keep Vite 7 asset URL transforms safe for large browser/Planner workers.
4
4
  * The known backtracking prefilter and comment-only eligibility are guarded;
@@ -6,5 +6,5 @@ import type { Plugin } from 'vite';
6
6
  * resolution and rewriting. Vite 8 and unrecognized filter shapes are untouched.
7
7
  * Install once in root plugins; existing worker plugin factories are preserved.
8
8
  */
9
- export declare function smrtAssetUrlCompatibility(): Plugin;
9
+ export declare function smrtAssetUrlCompatibility(): SmrtVitePlugin;
10
10
  //# sourceMappingURL=asset-url-compatibility.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"asset-url-compatibility.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/asset-url-compatibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAwCnC;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,CAmDlD"}
1
+ {"version":3,"file":"asset-url-compatibility.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/asset-url-compatibility.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAGV,cAAc,EACf,MAAM,sBAAsB,CAAC;AA+D9B;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,cAAc,CAwD1D"}
@@ -1,4 +1,11 @@
1
+ import { importProjectVite } from "../utils/import-project-vite.js";
2
+ import { resolve } from "node:path";
1
3
  //#region src/vite-plugin/asset-url-compatibility.ts
4
+ function isAssetTransform(value) {
5
+ if (!value || typeof value !== "object") return false;
6
+ const candidate = value;
7
+ return !!candidate.filter && typeof candidate.filter === "object" && typeof candidate.handler === "function";
8
+ }
2
9
  function hasImportMeta(ast) {
3
10
  if (!ast || typeof ast !== "object" || ast.type !== "Program" || !Array.isArray(ast.body)) return true;
4
11
  const pending = [ast];
@@ -30,18 +37,21 @@ function smrtAssetUrlCompatibility() {
30
37
  const createPlugin = (includeWorkers) => ({
31
38
  name: "smrt-asset-url-compatibility",
32
39
  ...includeWorkers ? { async config(config) {
33
- const { version } = await import("vite");
40
+ const { version } = await importProjectVite(resolve(process.cwd(), config.root ?? "."), "Asset URL compatibility");
34
41
  supportedVite = version.startsWith("7.");
35
42
  if (!supportedVite) return;
36
43
  const existing = config.worker?.plugins;
37
- return { worker: { plugins: (...args) => [existing?.(...args) ?? [], createPlugin(false)] } };
44
+ config.worker = {
45
+ ...config.worker,
46
+ plugins: (...args) => [existing?.(...args) ?? [], createPlugin(false)]
47
+ };
38
48
  } } : {},
39
49
  configResolved(config) {
40
50
  if (!supportedVite) return;
41
51
  for (const plugin of config.plugins) {
42
52
  if (plugin.name !== "vite:asset-import-meta-url") continue;
43
53
  const transform = plugin.transform;
44
- if (!transform || typeof transform !== "object") continue;
54
+ if (!isAssetTransform(transform)) continue;
45
55
  const filter = transform.filter;
46
56
  const code = filter?.code;
47
57
  if (filter && code instanceof RegExp && code.source === unsafeAssetFilter.source && code.flags === unsafeAssetFilter.flags) {