@happyvertical/smrt-core 0.57.31 → 0.57.33

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 (65) hide show
  1. package/README.md +25 -1
  2. package/agents/data-query.md +21 -0
  3. package/dist/browser.d.ts +3 -2
  4. package/dist/browser.d.ts.map +1 -1
  5. package/dist/browser.js +4 -3
  6. package/dist/class.d.ts.map +1 -1
  7. package/dist/class.js +1 -1
  8. package/dist/class.js.map +1 -1
  9. package/dist/consumer-plugin/index.d.ts +2 -2
  10. package/dist/consumer-plugin/index.d.ts.map +1 -1
  11. package/dist/consumer-plugin/index.js.map +1 -1
  12. package/dist/data-query.d.ts +16 -1
  13. package/dist/data-query.d.ts.map +1 -1
  14. package/dist/data-query.js +104 -4
  15. package/dist/data-query.js.map +1 -1
  16. package/dist/db-errors.d.ts +7 -0
  17. package/dist/db-errors.d.ts.map +1 -1
  18. package/dist/db-errors.js +34 -1
  19. package/dist/db-errors.js.map +1 -1
  20. package/dist/dispatch/bus.d.ts +3 -0
  21. package/dist/dispatch/bus.d.ts.map +1 -1
  22. package/dist/dispatch/bus.js +27 -4
  23. package/dist/dispatch/bus.js.map +1 -1
  24. package/dist/dispatch/index.d.ts +1 -1
  25. package/dist/dispatch/index.d.ts.map +1 -1
  26. package/dist/dispatch/types.d.ts +17 -0
  27. package/dist/dispatch/types.d.ts.map +1 -1
  28. package/dist/external-agent-usage.d.ts +90 -0
  29. package/dist/external-agent-usage.d.ts.map +1 -0
  30. package/dist/external-agent-usage.js +272 -0
  31. package/dist/external-agent-usage.js.map +1 -0
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -3
  35. package/dist/manifest/static-manifest.js +1 -1
  36. package/dist/manifest/static-manifest.js.map +1 -1
  37. package/dist/manifest/store.js +1 -1
  38. package/dist/manifest.json +1 -1
  39. package/dist/smrt-knowledge.json +5 -6
  40. package/dist/system/retention.d.ts.map +1 -1
  41. package/dist/system/retention.js +1 -0
  42. package/dist/system/retention.js.map +1 -1
  43. package/dist/testing/database.d.ts.map +1 -1
  44. package/dist/testing/database.js +18 -0
  45. package/dist/testing/database.js.map +1 -1
  46. package/dist/vite-plugin/asset-url-compatibility.d.ts +2 -2
  47. package/dist/vite-plugin/asset-url-compatibility.d.ts.map +1 -1
  48. package/dist/vite-plugin/asset-url-compatibility.js +13 -3
  49. package/dist/vite-plugin/asset-url-compatibility.js.map +1 -1
  50. package/dist/vite-plugin/index.d.ts +5 -2
  51. package/dist/vite-plugin/index.d.ts.map +1 -1
  52. package/dist/vite-plugin/index.js.map +1 -1
  53. package/dist/vite-plugin/plugin-contract.d.ts +164 -0
  54. package/dist/vite-plugin/plugin-contract.d.ts.map +1 -0
  55. package/dist/vite-plugin/sveltekit-route-coordinator.d.ts +5 -5
  56. package/dist/vite-plugin/sveltekit-route-coordinator.d.ts.map +1 -1
  57. package/dist/vite-plugin/sveltekit-route-coordinator.js.map +1 -1
  58. package/dist/vite-plugin/worker-registration.d.ts +4 -4
  59. package/dist/vite-plugin/worker-registration.d.ts.map +1 -1
  60. package/dist/vite-plugin/worker-registration.js.map +1 -1
  61. package/dist/vite-preset/index.d.ts +2 -2
  62. package/dist/vite-preset/index.d.ts.map +1 -1
  63. package/dist/vite-preset/index.js +9 -1
  64. package/dist/vite-preset/index.js.map +1 -1
  65. package/package.json +4 -5
@@ -2,7 +2,7 @@
2
2
  "version": "1.0.0",
3
3
  "timestamp": 0,
4
4
  "packageName": "@happyvertical/smrt-core",
5
- "packageVersion": "0.57.31",
5
+ "packageVersion": "0.57.33",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-core:SmrtClass": {
8
8
  "name": "smrtclass",
@@ -3,18 +3,18 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.57.31",
6
+ "packageVersion": "0.57.33",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "5c8607d87341929e94c70070a3a528a96983bc4fa4b70bfd33db195e86943242",
11
- "packageJson": "9608f1913ca668ef1265f85c3c035d667da44f0d3ac218094f9da27b51b12365",
10
+ "manifest": "0b94da6d4daa3f7336a765a0ee07948c74627c88785b94a01454c77f09fc7159",
11
+ "packageJson": "3f3c55acc5cd39c63a89099e539cb3deb44cf813d299b01b38fae7e057c2fa6c",
12
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
- "moduleDoc:agents/data-query.md": "1b72411d441ce2285bf0c89674e7aa996832a58b552b23a6d43834b907d91355",
17
+ "moduleDoc:agents/data-query.md": "b75c84f716457d0229714ffeec686bf49bc68f25e698938f80c9d28ad8bb0160",
18
18
  "moduleDoc:agents/schema-paths.md": "aef1b6fd687d0dbe444d20bf318e7c62fb9ef063ead933d2fd8168b87fee032e",
19
19
  "moduleDoc:agents/deployment-phases.md": "e1036073f31d54db95de95e498c74cddce5e47f367d56ccc00aad69f4e5a28e1",
20
20
  "moduleDoc:agents/change-feed.md": "c3d6206758dd1d2e0475177e1938fb7d7b6cfe8a6c4d3c3b56ef62bc7bc7739d",
@@ -83,7 +83,6 @@
83
83
  "@noble/hashes": "^2.4.0",
84
84
  "@oxc-project/runtime": "^0.150.0",
85
85
  "cheerio": "^1.2.0",
86
- "fast-glob": "3.3.3",
87
86
  "import-meta-resolve": "4.2.0",
88
87
  "minimatch": "10.2.6",
89
88
  "oxc-transform": "0.150.0",
@@ -1174,7 +1173,7 @@
1174
1173
  {
1175
1174
  "path": "agents/data-query.md",
1176
1175
  "module": "data-query",
1177
- "content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Bounded data queries (#2444)\n\n`normalizeDataQueryRequest()` and `normalizeDataQueryResult()` define the trust\nboundary for the transport-neutral table/report/content query envelope. A\ntrusted adapter supplies `DataQuerySchema`; callers receive only its declared\nprojectable, sortable, filterable, and facetable fields. The helpers validate\nand normalize but never execute SQL or decide tenant/principal access.\n\nUse `createDataQueryFingerprint()` for cache and result correlation. It omits\nrequest id and page position, canonicalizes equivalent filter/projection/facet\nforms, and adds the identity sort tie-break. Keep values scalar and request,\npage, and facet sizes positive and bounded. Results must stay within the schema\nbyte cap with declared field types preserved; datetimes are RFC 3339 instants,\nidentity fields are string/number/datetime-compatible, and JSON values are\nbounded by depth, container count, string size, and bytes before cloning.\n\nOnly normalized `DataQueryResult` envelopes cross REST, MCP, WebMCP, and browser\nboundaries. Adapter-specific report/content context wraps the base envelope; it\ndoes not add unsafe fields or SQL-like controls.\n"
1176
+ "content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Bounded data queries (#2444)\n\n`normalizeDataQueryRequest()` and `normalizeDataQueryResult()` define the trust\nboundary for the transport-neutral table/report/content query envelope. A\ntrusted adapter supplies `DataQuerySchema`; callers receive only its declared\nprojectable, sortable, filterable, and facetable fields. The helpers validate\nand normalize but never execute SQL or decide tenant/principal access.\n\nUse `createDataQueryFingerprint()` for cache and result correlation. It omits\nrequest id and page position, canonicalizes equivalent filter/projection/facet\nforms, and adds the identity sort tie-break. Keep values scalar and request,\npage, and facet sizes positive and bounded. Results must stay within the schema\nbyte cap with declared field types preserved; datetimes are RFC 3339 instants,\nidentity fields are string/number/datetime-compatible, and JSON values are\nbounded by depth, container count, string size, and bytes before cloning.\n\n## Allowed field values (#4381)\n\nA `string`, `number`, or `boolean` field may declare `values`: a closed set of\n`{ value, label?, description? }` entries in declaration order, so an agent can\nmap \"open\" onto `status in [pending, in_progress]`. Values are host-declared\nschema, never inferred from rows. `normalizeDataQuerySchema()` (via the\nexported `normalizeDataQueryFieldValues()`) requires a non-empty, duplicate-free\nlist of at most `MAX_DATA_QUERY_FIELD_VALUES` (100) entries whose values match\nthe field type; value, label, and description text are bounded\n(`MAX_DATA_QUERY_FIELD_VALUE_LENGTH` 256, `..._LABEL_LENGTH` 128,\n`..._DESCRIPTION_LENGTH` 512) and reject Unicode control, format (bidi,\nzero-width), surrogate, and line/paragraph-separator characters. Declared values\nare enforced by default: non-null `eq`/`ne`/`in`/`notIn` filter values must be\nmembers, or the request fails with `DATA_QUERY_VALUE_NOT_ALLOWED` and a message\nlisting the allowed values. The message and `details.allowedValues` both stop\nat the first 20 values; `details.allowedValueCount` gives the full size.\n`ne`/`notIn` are checked too: a misspelled value there would otherwise\nsilently match every row. `valuesEnforced: false` (valid only with `values`) marks the set advisory and\nskips that check; the normalized schema keeps only the `false` form. Range and\n`like` operators, result rows, and facets are never checked against the set.\n\nOnly normalized `DataQueryResult` envelopes cross REST, MCP, WebMCP, and browser\nboundaries. Adapter-specific report/content context wraps the base envelope; it\ndoes not add unsafe fields or SQL-like controls.\n"
1178
1177
  },
1179
1178
  {
1180
1179
  "path": "agents/schema-paths.md",
@@ -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 +1 @@
1
- {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/testing/database.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AA0J5D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;IAEjD;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,EAAE,CAAC,EAAE,iBAAiB,CAAC;IAEvB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAEnB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,OAAO,CAAC;CACrC;AAED,6EAA6E;AAC7E,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,mBAAmB,CAAC,MAAM,CAAC,EACjC,EAAE,EAAE,iBAAiB,EACrB,iBAAiB,UAAQ,GACxB,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAwC3C;AA4DD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAsB,eAAe,CACnC,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,iBAAiB,CAAC,CAgQ5B"}
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/testing/database.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAmL5D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;IAEjD;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,EAAE,CAAC,EAAE,iBAAiB,CAAC;IAEvB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAEnB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,OAAO,CAAC;CACrC;AAED,6EAA6E;AAC7E,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,mBAAmB,CAAC,MAAM,CAAC,EACjC,EAAE,EAAE,iBAAiB,EACrB,iBAAiB,UAAQ,GACxB,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAwC3C;AA4DD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAsB,eAAe,CACnC,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,iBAAiB,CAAC,CAiQ5B"}
@@ -11,6 +11,23 @@ import { SchemaGenerator } from "../schema/generator.js";
11
11
  import { planForeignKeyCreation } from "../schema/foreign-key-planner.js";
12
12
  import { getDatabase } from "@happyvertical/sql";
13
13
  //#region src/testing/database.ts
14
+ /**
15
+ * Strip crash durability from a fresh local file-backed SQLite test database.
16
+ *
17
+ * Opening with `__smrtSkipVitestSchemaPreparation` also bypasses smrt-vitest's
18
+ * speed pragmas (#2221), so every commit here would otherwise fsync: on a
19
+ * loaded host that made a single ingestion fixture take 15 s. Mirrors
20
+ * smrt-vitest's `applySqliteSpeedPragmas()`; best-effort, never fails setup.
21
+ */
22
+ async function applySqliteTestPragmas(db, type, url) {
23
+ if ((type ?? "sqlite") !== "sqlite") return;
24
+ if (/^(:memory:|memory$|file::memory:|https?:\/\/|libsql:\/\/)/.test(url)) return;
25
+ try {
26
+ await db.query("PRAGMA synchronous = OFF");
27
+ await db.query("PRAGMA journal_mode = MEMORY");
28
+ await db.query("PRAGMA temp_store = MEMORY");
29
+ } catch {}
30
+ }
14
31
  function resolveSTIBaseRegistration(className, stiBaseName) {
15
32
  const registered = ObjectRegistry.getClass(className);
16
33
  if (stiBaseName.includes(":")) return ObjectRegistry.getClass(stiBaseName);
@@ -154,6 +171,7 @@ async function getTestDatabase(options = {}) {
154
171
  url,
155
172
  __smrtSkipVitestSchemaPreparation: true
156
173
  });
174
+ if (!existingDb) await applySqliteTestPragmas(db, type, url);
157
175
  if (includeSystemTables) await initializeSystemTables(db);
158
176
  const isImplicitClassList = classes === void 0;
159
177
  const classNames = resolveRequestedSchemaClassNames(classes ?? ObjectRegistry.getQualifiedClassNames());
@@ -1 +1 @@
1
- {"version":3,"file":"database.js","names":[],"sources":["../../src/testing/database.ts"],"sourcesContent":["/**\n * Test database utilities for SMRT framework\n *\n * Provides `getTestDatabase()` which creates an in-memory database with all\n * registered SMRT object schemas. Uses the same schema generation logic as\n * the migration system to ensure consistency between test and production.\n *\n * @example\n * ```typescript\n * import { getTestDatabase } from '@happyvertical/smrt-core/testing';\n *\n * beforeEach(async () => {\n * const db = await getTestDatabase();\n * collection = await MyCollection.create({ db });\n * });\n * ```\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { getDatabase } from '@happyvertical/sql';\nimport { ensurePostgresChangeFeedAppendFunction } from '../change-feed.js';\nimport {\n type CollectionRegistrationLookup,\n isCollectionRegistration,\n resolveCollectionItemClassName,\n resolveRelatedRegistration,\n} from '../registry/collection-resolution.js';\nimport { recordRegistryDiagnostic } from '../registry/diagnostics.js';\nimport { isFrameworkBaseClass } from '../registry/framework-base-classes.js';\nimport { ObjectRegistry } from '../registry.js';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport {\n schemaForeignKeys,\n schemaForeignKeysForEngine,\n} from '../schema/foreign-key-ddl.js';\nimport { planForeignKeyCreation } from '../schema/foreign-key-planner.js';\nimport { SchemaGenerator } from '../schema/generator.js';\nimport type { SchemaDefinition } from '../schema/types.js';\nimport { ensureLegacySystemTableCompatibility } from '../system/compatibility.js';\nimport { getSystemTableDDL } from '../system/schema.js';\n\ntype TestDatabaseConnectionOptions = Parameters<typeof getDatabase>[0] & {\n __smrtSkipVitestSchemaPreparation?: boolean;\n};\n\nfunction resolveSTIBaseRegistration(className: string, stiBaseName: string) {\n const registered = ObjectRegistry.getClass(className);\n\n if (stiBaseName.includes(':')) {\n return ObjectRegistry.getClass(stiBaseName);\n }\n\n if (registered?.packageName) {\n const samePackageBase = ObjectRegistry.getClassInPackage(\n registered.packageName,\n stiBaseName,\n );\n if (samePackageBase) {\n return samePackageBase;\n }\n }\n\n return ObjectRegistry.getClass(stiBaseName);\n}\n\nfunction resolveSTIBaseLookupName(\n className: string,\n stiBaseName: string,\n): string {\n const stiBase = resolveSTIBaseRegistration(className, stiBaseName);\n return stiBase?.qualifiedName || stiBase?.name || stiBaseName;\n}\n\nfunction isSTIChild(className: string): boolean {\n const stiBaseName = ObjectRegistry.getSTIBase(className);\n if (!stiBaseName) {\n return false;\n }\n\n const registered = ObjectRegistry.getClass(className);\n const stiBase = resolveSTIBaseRegistration(className, stiBaseName);\n\n if (registered && stiBase) {\n return registered !== stiBase;\n }\n\n return stiBaseName !== className;\n}\n\ntype RegisteredSchemaClass = NonNullable<\n ReturnType<typeof ObjectRegistry.getClass>\n>;\n\nconst collectionRegistrationLookup: CollectionRegistrationLookup = {\n findClass: (className) => ObjectRegistry.getClass(className),\n findClassInPackage: (packageName, className) =>\n ObjectRegistry.getClassInPackage(packageName, className),\n getInheritanceChain: (className) =>\n ObjectRegistry.getInheritanceChain(className),\n};\n\nfunction resolveCollectionSchemaClassName(\n className: string,\n registered: RegisteredSchemaClass,\n): string {\n const itemClassName = resolveCollectionItemClassName(\n className,\n registered,\n collectionRegistrationLookup,\n );\n if (itemClassName) {\n const itemRegistration = resolveRelatedRegistration(\n itemClassName,\n registered,\n collectionRegistrationLookup,\n );\n const itemLookupName =\n itemRegistration?.qualifiedName ||\n itemRegistration?.name ||\n itemClassName;\n const stiBase = ObjectRegistry.getSTIBase(itemLookupName);\n return stiBase\n ? resolveSTIBaseLookupName(itemLookupName, stiBase)\n : itemLookupName;\n }\n\n const tableName =\n registered.schema?.tableName ||\n registered.config.tableName ||\n ObjectRegistry.getTableName(className);\n if (!tableName) {\n return className;\n }\n\n const collectionPackage = registered.packageName;\n\n for (const candidate of ObjectRegistry.getAllClasses().values()) {\n if (\n candidate === registered ||\n isCollectionRegistration(\n candidate.qualifiedName || candidate.name,\n candidate,\n collectionRegistrationLookup,\n )\n ) {\n continue;\n }\n\n const candidateTableName =\n candidate.schema?.tableName || candidate.config.tableName;\n if (candidateTableName !== tableName) {\n continue;\n }\n\n if (\n collectionPackage &&\n candidate.packageName &&\n candidate.packageName !== collectionPackage\n ) {\n continue;\n }\n\n const candidateLookupName = candidate.qualifiedName || candidate.name;\n const stiBase = ObjectRegistry.getSTIBase(candidateLookupName);\n return stiBase\n ? resolveSTIBaseLookupName(candidateLookupName, stiBase)\n : candidateLookupName;\n }\n\n return className;\n}\n\n/**\n * Options for creating a test database\n */\nexport interface TestDatabaseOptions {\n /**\n * Database type (default: 'sqlite')\n * - 'sqlite': SQLite database\n * - 'json': JSON adapter (stores data as JSON files with DuckDB for querying)\n * - 'duckdb': Native DuckDB database\n * - 'postgres': PostgreSQL database (normally supplied through `db`)\n */\n type?: 'sqlite' | 'json' | 'duckdb' | 'postgres';\n\n /**\n * Database URL (default: ':memory:')\n * Use ':memory:' for in-memory databases (fastest for tests)\n * Or provide a file path for persistent test databases\n */\n url?: string;\n\n /**\n * Pre-existing database to initialize schemas in.\n * If provided, `type` and `url` are ignored.\n */\n db?: DatabaseInterface;\n\n /**\n * Specific classes to setup schemas for.\n * If not provided, sets up schemas for all registered classes.\n */\n classes?: string[];\n\n /**\n * Whether to include system tables (default: true)\n * System tables include _smrt_contexts, _smrt_migrations, etc.\n */\n includeSystemTables?: boolean;\n\n /**\n * Explicitly omit physical FK constraints for adapter/query tests whose\n * subject is unrelated to referential enforcement. Defaults to `false`.\n * This is required (and deliberately noisy at the call site) when a DuckDB\n * fixture uses generated `ON UPDATE CASCADE`, which DuckDB cannot enforce.\n */\n omitForeignKeyConstraints?: boolean;\n}\n\n/** Resolve the DDL dialect used when preparing an existing test database. */\nexport function resolveTestDatabaseDDLEngine(\n type: TestDatabaseOptions['type'],\n db: DatabaseInterface,\n inferFromDatabase = false,\n): 'sqlite' | 'json' | 'duckdb' | 'postgres' {\n // When this helper created the adapter, the requested type is authoritative.\n // Native DuckDB and JSON-on-DuckDB both expose exportTable(), so capability\n // inference must not override an explicit native DuckDB request.\n if (!inferFromDatabase) {\n return type ?? 'sqlite';\n }\n\n const configuredDb = db as DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n exportTable?: unknown;\n inferSchemaFromJSON?: unknown;\n getTableLoadErrors?: unknown;\n };\n\n if (\n typeof configuredDb.inferSchemaFromJSON === 'function' ||\n typeof configuredDb.getTableLoadErrors === 'function'\n ) {\n return 'json';\n }\n\n if (typeof configuredDb.exportTable === 'function') {\n // The native DuckDB adapter exposes schema evolution capabilities that the\n // JSON adapter does not. Keep exportTable-only test doubles compatible with\n // the historical JSON structural marker.\n if (\n typeof configuredDb.getTableSchema === 'function' &&\n typeof configuredDb.alterTable?.addColumn === 'function'\n ) {\n return 'duckdb';\n }\n return 'json';\n }\n\n return detectEngine(\n db.url || configuredDb.config?.url || '',\n configuredDb.type || configuredDb.config?.type,\n );\n}\n\nfunction omitTestForeignKeyConstraints(\n schema: SchemaDefinition,\n): SchemaDefinition {\n const foreignKeyTargets = new Set(\n schemaForeignKeys(schema).map((foreignKey) => foreignKey.referencesTable),\n );\n return {\n ...schema,\n columns: Object.fromEntries(\n Object.entries(schema.columns).map(([name, column]) => [\n name,\n column.foreignKey ? { ...column, foreignKey: undefined } : column,\n ]),\n ),\n foreignKeys: [],\n dependencies: schema.dependencies.filter(\n (dependency) => !foreignKeyTargets.has(dependency),\n ),\n };\n}\n\nfunction resolveRequestedSchemaClassName(className: string): string {\n const registered = ObjectRegistry.getClass(className);\n if (!registered) {\n return className;\n }\n\n if (\n !isCollectionRegistration(\n className,\n registered,\n collectionRegistrationLookup,\n )\n ) {\n const stiBase = ObjectRegistry.getSTIBase(className);\n return stiBase ? resolveSTIBaseLookupName(className, stiBase) : className;\n }\n\n return resolveCollectionSchemaClassName(className, registered);\n}\n\nfunction resolveRequestedSchemaClassNames(classNames: string[]): string[] {\n const resolved: string[] = [];\n const seen = new Set<string>();\n\n for (const className of classNames) {\n const schemaClassName = resolveRequestedSchemaClassName(className);\n if (seen.has(schemaClassName)) {\n continue;\n }\n\n seen.add(schemaClassName);\n resolved.push(schemaClassName);\n }\n\n return resolved;\n}\n\n/**\n * Creates an in-memory test database with schemas pre-created\n *\n * This utility is designed for testing. It creates an in-memory database\n * and initializes all registered SMRT object schemas using the **same\n * schema generation logic** as the production migration system.\n *\n * **Key features:**\n * - Uses `SchemaGenerator.generateSQL()` - the single source of truth for DDL\n * - Handles STI (Single Table Inheritance) correctly\n * - Creates system tables for framework functionality\n * - Safe for parallel test execution: each call creates an isolated\n * in-memory instance with its own embedded write-queue identity, so\n * unrelated `:memory:` databases never serialize writes against each\n * other (#2707). A `cache=shared` URL is the deliberate exception: it\n * asks SQLite/DuckDB to genuinely share the underlying database, so it\n * keeps sharing one write-queue identity too.\n *\n * @param options - Configuration options\n * @returns Promise resolving to configured DatabaseInterface\n *\n * @example\n * ```typescript\n * // Basic usage - all registered schemas\n * const db = await getTestDatabase();\n *\n * // Specific classes only\n * const db = await getTestDatabase({ classes: ['Council', 'Meeting'] });\n *\n * // JSON adapter instead of SQLite\n * const db = await getTestDatabase({ type: 'json' });\n *\n * // Native DuckDB adapter\n * const db = await getTestDatabase({ type: 'duckdb', url: ':memory:' });\n *\n * // File-based database for persistent tests\n * const db = await getTestDatabase({ type: 'sqlite', url: '/tmp/test.db' });\n *\n * // Initialize schemas in an existing database\n * const myDb = await getDatabase({ type: 'sqlite', url: ':memory:' });\n * await getTestDatabase({ db: myDb });\n *\n * // Skip system tables (rare use case)\n * const db = await getTestDatabase({ includeSystemTables: false });\n * ```\n */\nexport async function getTestDatabase(\n options: TestDatabaseOptions = {},\n): Promise<DatabaseInterface> {\n const {\n type,\n url = ':memory:',\n db: existingDb,\n classes,\n includeSystemTables = true,\n omitForeignKeyConstraints = false,\n } = options;\n\n // Use existing database or create new one\n const db =\n existingDb ??\n (await getDatabase({\n type: type ?? 'sqlite',\n url,\n __smrtSkipVitestSchemaPreparation: true,\n } as TestDatabaseConnectionOptions));\n\n // Initialize system tables (same as production)\n if (includeSystemTables) {\n await initializeSystemTables(db);\n }\n\n // Get class names to setup. The framework-base filter below only applies\n // when the caller left `classes` implicit: an explicit list naming a\n // framework base class must still be honored (#2645).\n const isImplicitClassList = classes === undefined;\n const classNames = resolveRequestedSchemaClassNames(\n classes ?? ObjectRegistry.getQualifiedClassNames(),\n );\n\n // Skip if no classes registered. When the caller left `classes` implicit,\n // an empty registry usually means registration never reached this\n // process/module instance of ObjectRegistry (e.g. a Vitest plugin hook\n // registering manifests in a different worker/process than the test code\n // — #2750) rather than a deliberate \"system tables only\" setup. Warn (never\n // throw by default) so that failure surfaces here instead of as a much\n // later, harder-to-diagnose \"no such table\" error. An explicit empty\n // `classes: []` array is a deliberate choice and stays silent.\n if (classNames.length === 0) {\n if (isImplicitClassList) {\n const emptyRegistryMessage =\n 'getTestDatabase() found zero registered classes in ObjectRegistry and ' +\n 'created only system tables. If you expected model tables, the ' +\n 'registry is likely empty in this process — for smrt-vitest ' +\n 'consumers, confirm registration (smrtVitestPlugin() manifest ' +\n 'loading, or your setupFiles) actually runs in the same process ' +\n 'as your test code, or pass an explicit `classes` list to ' +\n 'getTestDatabase() to opt out of this check.';\n // recordRegistryDiagnostic('warn', ...) only buffers -- nothing reads\n // that buffer automatically (only strict-mode 'error' severity throws;\n // printing the buffer requires an explicit flushRegistryDiagnostics()\n // call this package never makes). Pair it with an immediate\n // console.warn so the failure is actually visible at the point of the\n // problem, which is the entire point of this diagnostic (#2750) --\n // the buffered record stays available for programmatic inspection via\n // getRegistryDiagnostics().\n console.warn(`[getTestDatabase] ${emptyRegistryMessage}`);\n recordRegistryDiagnostic(\n 'warn',\n 'TEST_DATABASE_EMPTY_IMPLICIT_REGISTRY',\n emptyRegistryMessage,\n );\n }\n return db;\n }\n\n // Use the same schema generation as production\n const schemaGenerator = new SchemaGenerator();\n const ddlEngine = resolveTestDatabaseDDLEngine(\n type,\n db,\n existingDb !== undefined,\n );\n\n // Collect every table before executing DDL so dependency ordering and cycle\n // handling are identical to production migration/schema paths (#2413).\n const schemas = new Map<\n string,\n { schema: SchemaDefinition; className: string }\n >();\n const authoritativeSchemas = ObjectRegistry.getAllSchemasAsDefinitions();\n\n for (const className of classNames) {\n // R11: the registration carries idType (native uuid vs text); read it\n // below when building runtimeSchemaConfig.\n const registered = ObjectRegistry.getClass(className);\n // Skip STI children - their schema is part of the base class table.\n // main (#1324): isSTIChild() compares RegisteredClass *identity* rather\n // than raw strings, so it stays correct under R5-canon (getSTIBase returns\n // qualified names) while also handling collection/override registrations.\n if (isSTIChild(className)) {\n continue;\n }\n\n // Framework abstract base classes (SmrtObject, SmrtClass, ...) are\n // scaffolding, not resources, and produce phantom smrt_objects/\n // smrt_classes/smrt_collections/smrt_hierarchicals/\n // smrt_polymorphic_associations tables when the default registry\n // snapshot happens to include them (#2645). Mirrors the production\n // `buildMergedTableSchemas()` filter in `registry/schema-builder.ts`, but\n // only for the implicit class list — an explicit `classes: [...]` caller\n // that names a framework base class is still honored.\n if (\n isImplicitClassList &&\n isFrameworkBaseClass(registered?.name, registered?.packageName)\n ) {\n continue;\n }\n\n const tableName = ObjectRegistry.getTableName(className);\n if (!tableName || schemas.has(tableName)) {\n continue;\n }\n\n const fields = await ObjectRegistry.getAllFields(className);\n const strategy = ObjectRegistry.getTableStrategy(className);\n // Mirrors `schema/utils.ts`: this bag is rebuilt by hand, so every `@smrt()`\n // option the generator reads has to be listed or tests silently diverge\n // from the production schema. `indexes` (#2357) was missing from both.\n const runtimeSchemaConfig = {\n conflictColumns: ObjectRegistry.getConflictColumns(className),\n idType: registered?.config.idType,\n indexes: registered?.config.indexes,\n registry: ObjectRegistry,\n };\n\n // Generate schema through the registry paths. Production (`manifest.json`\n // and therefore `smrt db:migrate`) uses the manifest paths\n // (`generateSTISchemaFromManifest` / `generateCTISchemaFromManifest`);\n // the two families are held to the same column and index sets by\n // `src/schema/schema-path-parity.test.ts` (#2359) — do not assume they\n // agree by construction.\n const schema =\n strategy === 'sti'\n ? await schemaGenerator.generateSTISchemaFromRegistry(\n className,\n tableName,\n fields,\n runtimeSchemaConfig,\n )\n : schemaGenerator.generateSchemaFromRegistry(\n className,\n tableName,\n fields,\n runtimeSchemaConfig,\n );\n\n // Manifest registrations can carry explicit FK actions that differ from\n // decorator defaults. The deterministic merged table schema preserves\n // those authoritative actions while still backfilling runtime-only fields.\n const authoritativeSchema = authoritativeSchemas[tableName];\n const effectiveColumns = authoritativeSchema\n ? Object.fromEntries(\n Object.entries(schema.columns).map(([name, column]) => {\n const manifestColumn = authoritativeSchema.columns[name];\n return [\n name,\n manifestColumn\n ? { ...column, foreignKey: manifestColumn.foreignKey }\n : column,\n ];\n }),\n )\n : schema.columns;\n const effectiveForeignKeys = schemaForeignKeys({\n columns: effectiveColumns,\n foreignKeys: [],\n });\n const effectiveSchema: SchemaDefinition = {\n ...schema,\n columns: effectiveColumns,\n foreignKeys: effectiveForeignKeys,\n dependencies: effectiveForeignKeys\n .map((foreignKey) => foreignKey.referencesTable)\n .filter((dependency) => dependency !== tableName),\n };\n\n schemas.set(tableName, {\n schema: omitForeignKeyConstraints\n ? omitTestForeignKeyConstraints(effectiveSchema)\n : effectiveSchema,\n className,\n });\n }\n\n // Explicitly requested test classes still need their registered parents.\n // Add the authoritative dependency closure so a child-only request cannot\n // emit a physical reference to a table this helper never creates.\n const addDependencies = (schema: SchemaDefinition): void => {\n for (const foreignKey of schemaForeignKeysForEngine(schema, ddlEngine)) {\n if (schemas.has(foreignKey.referencesTable)) continue;\n const dependency = authoritativeSchemas[foreignKey.referencesTable];\n if (!dependency) continue;\n const effectiveDependency = omitForeignKeyConstraints\n ? omitTestForeignKeyConstraints(dependency)\n : dependency;\n schemas.set(dependency.tableName, {\n schema: effectiveDependency,\n className: dependency.tableName,\n });\n addDependencies(effectiveDependency);\n }\n };\n for (const { schema } of [...schemas.values()]) addDependencies(schema);\n\n const tablePlan = planForeignKeyCreation(\n [...schemas.values()].map(({ schema }) => schema),\n ddlEngine,\n );\n const ddlStrategy = (await import('../schema/ddl/index.js')).getDDLStrategy(\n ddlEngine,\n );\n\n for (const schema of tablePlan.schemas) {\n const className = schemas.get(schema.tableName)?.className ?? 'unknown';\n const ddl = schemaGenerator.generateSQL(schema, ddlEngine);\n\n try {\n await db.query(ddl);\n\n // Create indexes (use DDL strategy so jsonPath / where / etc. render)\n const indexStatements = ddlStrategy.generateIndexes(schema);\n for (const indexSQL of indexStatements) {\n try {\n await db.query(indexSQL);\n } catch (indexError) {\n // Log but don't fail on index creation errors\n // Some indexes may fail if columns don't exist (STI meta fields)\n console.warn(\n `[getTestDatabase] Warning: Failed to create index: ${indexError instanceof Error ? indexError.message : String(indexError)} (SQL: ${indexSQL})`,\n );\n }\n }\n } catch (error) {\n // Provide helpful error message for table creation failures\n throw new Error(\n `[getTestDatabase] Failed to create table '${schema.tableName}' for class '${className}': ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n }\n\n for (const statement of tablePlan.deferredStatements) {\n try {\n await db.query(statement);\n } catch (error) {\n throw new Error(\n `[getTestDatabase] Failed to add deferred foreign-key constraint: ${error instanceof Error ? error.message : String(error)} (SQL: ${statement})`,\n { cause: error },\n );\n }\n }\n\n return db;\n}\n\n/**\n * Initialize SMRT system tables in a database\n *\n * System tables use _smrt_ prefix and store framework metadata.\n * All statements use `IF NOT EXISTS` for idempotency.\n *\n * @param db - Database interface to initialize\n */\nasync function initializeSystemTables(db: DatabaseInterface): Promise<void> {\n await ensureLegacySystemTableCompatibility(db);\n\n const configuredDb = db as DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n };\n const engine = detectEngine(\n db.url || configuredDb.config?.url || '',\n configuredDb.type || configuredDb.config?.type,\n );\n\n // Split multi-statement SQL into individual statements\n const allStatements: string[] = [];\n for (const multiStatementSQL of getSystemTableDDL(engine)) {\n const statements = multiStatementSQL\n .split(';')\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n allStatements.push(...statements);\n }\n\n // Use db.query() — system tables use CREATE TABLE/INDEX IF NOT EXISTS\n // which databases handle natively without per-column existence checks.\n for (const statement of allStatements) {\n await db.query(statement);\n }\n\n // PostgreSQL keeps the best-effort append boundary in a function that must\n // be executed whole rather than included in semicolon-split portable DDL.\n await ensurePostgresChangeFeedAppendFunction(db);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"database.js","names":[],"sources":["../../src/testing/database.ts"],"sourcesContent":["/**\n * Test database utilities for SMRT framework\n *\n * Provides `getTestDatabase()` which creates an in-memory database with all\n * registered SMRT object schemas. Uses the same schema generation logic as\n * the migration system to ensure consistency between test and production.\n *\n * @example\n * ```typescript\n * import { getTestDatabase } from '@happyvertical/smrt-core/testing';\n *\n * beforeEach(async () => {\n * const db = await getTestDatabase();\n * collection = await MyCollection.create({ db });\n * });\n * ```\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { getDatabase } from '@happyvertical/sql';\nimport { ensurePostgresChangeFeedAppendFunction } from '../change-feed.js';\nimport {\n type CollectionRegistrationLookup,\n isCollectionRegistration,\n resolveCollectionItemClassName,\n resolveRelatedRegistration,\n} from '../registry/collection-resolution.js';\nimport { recordRegistryDiagnostic } from '../registry/diagnostics.js';\nimport { isFrameworkBaseClass } from '../registry/framework-base-classes.js';\nimport { ObjectRegistry } from '../registry.js';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport {\n schemaForeignKeys,\n schemaForeignKeysForEngine,\n} from '../schema/foreign-key-ddl.js';\nimport { planForeignKeyCreation } from '../schema/foreign-key-planner.js';\nimport { SchemaGenerator } from '../schema/generator.js';\nimport type { SchemaDefinition } from '../schema/types.js';\nimport { ensureLegacySystemTableCompatibility } from '../system/compatibility.js';\nimport { getSystemTableDDL } from '../system/schema.js';\n\ntype TestDatabaseConnectionOptions = Parameters<typeof getDatabase>[0] & {\n __smrtSkipVitestSchemaPreparation?: boolean;\n};\n\n/**\n * Strip crash durability from a fresh local file-backed SQLite test database.\n *\n * Opening with `__smrtSkipVitestSchemaPreparation` also bypasses smrt-vitest's\n * speed pragmas (#2221), so every commit here would otherwise fsync: on a\n * loaded host that made a single ingestion fixture take 15 s. Mirrors\n * smrt-vitest's `applySqliteSpeedPragmas()`; best-effort, never fails setup.\n */\nasync function applySqliteTestPragmas(\n db: DatabaseInterface,\n type: string | undefined,\n url: string,\n): Promise<void> {\n if ((type ?? 'sqlite') !== 'sqlite') return;\n if (/^(:memory:|memory$|file::memory:|https?:\\/\\/|libsql:\\/\\/)/.test(url))\n return;\n try {\n await db.query('PRAGMA synchronous = OFF');\n await db.query('PRAGMA journal_mode = MEMORY');\n await db.query('PRAGMA temp_store = MEMORY');\n } catch {\n // An adapter that rejects pragmas keeps its defaults.\n }\n}\n\nfunction resolveSTIBaseRegistration(className: string, stiBaseName: string) {\n const registered = ObjectRegistry.getClass(className);\n\n if (stiBaseName.includes(':')) {\n return ObjectRegistry.getClass(stiBaseName);\n }\n\n if (registered?.packageName) {\n const samePackageBase = ObjectRegistry.getClassInPackage(\n registered.packageName,\n stiBaseName,\n );\n if (samePackageBase) {\n return samePackageBase;\n }\n }\n\n return ObjectRegistry.getClass(stiBaseName);\n}\n\nfunction resolveSTIBaseLookupName(\n className: string,\n stiBaseName: string,\n): string {\n const stiBase = resolveSTIBaseRegistration(className, stiBaseName);\n return stiBase?.qualifiedName || stiBase?.name || stiBaseName;\n}\n\nfunction isSTIChild(className: string): boolean {\n const stiBaseName = ObjectRegistry.getSTIBase(className);\n if (!stiBaseName) {\n return false;\n }\n\n const registered = ObjectRegistry.getClass(className);\n const stiBase = resolveSTIBaseRegistration(className, stiBaseName);\n\n if (registered && stiBase) {\n return registered !== stiBase;\n }\n\n return stiBaseName !== className;\n}\n\ntype RegisteredSchemaClass = NonNullable<\n ReturnType<typeof ObjectRegistry.getClass>\n>;\n\nconst collectionRegistrationLookup: CollectionRegistrationLookup = {\n findClass: (className) => ObjectRegistry.getClass(className),\n findClassInPackage: (packageName, className) =>\n ObjectRegistry.getClassInPackage(packageName, className),\n getInheritanceChain: (className) =>\n ObjectRegistry.getInheritanceChain(className),\n};\n\nfunction resolveCollectionSchemaClassName(\n className: string,\n registered: RegisteredSchemaClass,\n): string {\n const itemClassName = resolveCollectionItemClassName(\n className,\n registered,\n collectionRegistrationLookup,\n );\n if (itemClassName) {\n const itemRegistration = resolveRelatedRegistration(\n itemClassName,\n registered,\n collectionRegistrationLookup,\n );\n const itemLookupName =\n itemRegistration?.qualifiedName ||\n itemRegistration?.name ||\n itemClassName;\n const stiBase = ObjectRegistry.getSTIBase(itemLookupName);\n return stiBase\n ? resolveSTIBaseLookupName(itemLookupName, stiBase)\n : itemLookupName;\n }\n\n const tableName =\n registered.schema?.tableName ||\n registered.config.tableName ||\n ObjectRegistry.getTableName(className);\n if (!tableName) {\n return className;\n }\n\n const collectionPackage = registered.packageName;\n\n for (const candidate of ObjectRegistry.getAllClasses().values()) {\n if (\n candidate === registered ||\n isCollectionRegistration(\n candidate.qualifiedName || candidate.name,\n candidate,\n collectionRegistrationLookup,\n )\n ) {\n continue;\n }\n\n const candidateTableName =\n candidate.schema?.tableName || candidate.config.tableName;\n if (candidateTableName !== tableName) {\n continue;\n }\n\n if (\n collectionPackage &&\n candidate.packageName &&\n candidate.packageName !== collectionPackage\n ) {\n continue;\n }\n\n const candidateLookupName = candidate.qualifiedName || candidate.name;\n const stiBase = ObjectRegistry.getSTIBase(candidateLookupName);\n return stiBase\n ? resolveSTIBaseLookupName(candidateLookupName, stiBase)\n : candidateLookupName;\n }\n\n return className;\n}\n\n/**\n * Options for creating a test database\n */\nexport interface TestDatabaseOptions {\n /**\n * Database type (default: 'sqlite')\n * - 'sqlite': SQLite database\n * - 'json': JSON adapter (stores data as JSON files with DuckDB for querying)\n * - 'duckdb': Native DuckDB database\n * - 'postgres': PostgreSQL database (normally supplied through `db`)\n */\n type?: 'sqlite' | 'json' | 'duckdb' | 'postgres';\n\n /**\n * Database URL (default: ':memory:')\n * Use ':memory:' for in-memory databases (fastest for tests)\n * Or provide a file path for persistent test databases\n */\n url?: string;\n\n /**\n * Pre-existing database to initialize schemas in.\n * If provided, `type` and `url` are ignored.\n */\n db?: DatabaseInterface;\n\n /**\n * Specific classes to setup schemas for.\n * If not provided, sets up schemas for all registered classes.\n */\n classes?: string[];\n\n /**\n * Whether to include system tables (default: true)\n * System tables include _smrt_contexts, _smrt_migrations, etc.\n */\n includeSystemTables?: boolean;\n\n /**\n * Explicitly omit physical FK constraints for adapter/query tests whose\n * subject is unrelated to referential enforcement. Defaults to `false`.\n * This is required (and deliberately noisy at the call site) when a DuckDB\n * fixture uses generated `ON UPDATE CASCADE`, which DuckDB cannot enforce.\n */\n omitForeignKeyConstraints?: boolean;\n}\n\n/** Resolve the DDL dialect used when preparing an existing test database. */\nexport function resolveTestDatabaseDDLEngine(\n type: TestDatabaseOptions['type'],\n db: DatabaseInterface,\n inferFromDatabase = false,\n): 'sqlite' | 'json' | 'duckdb' | 'postgres' {\n // When this helper created the adapter, the requested type is authoritative.\n // Native DuckDB and JSON-on-DuckDB both expose exportTable(), so capability\n // inference must not override an explicit native DuckDB request.\n if (!inferFromDatabase) {\n return type ?? 'sqlite';\n }\n\n const configuredDb = db as DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n exportTable?: unknown;\n inferSchemaFromJSON?: unknown;\n getTableLoadErrors?: unknown;\n };\n\n if (\n typeof configuredDb.inferSchemaFromJSON === 'function' ||\n typeof configuredDb.getTableLoadErrors === 'function'\n ) {\n return 'json';\n }\n\n if (typeof configuredDb.exportTable === 'function') {\n // The native DuckDB adapter exposes schema evolution capabilities that the\n // JSON adapter does not. Keep exportTable-only test doubles compatible with\n // the historical JSON structural marker.\n if (\n typeof configuredDb.getTableSchema === 'function' &&\n typeof configuredDb.alterTable?.addColumn === 'function'\n ) {\n return 'duckdb';\n }\n return 'json';\n }\n\n return detectEngine(\n db.url || configuredDb.config?.url || '',\n configuredDb.type || configuredDb.config?.type,\n );\n}\n\nfunction omitTestForeignKeyConstraints(\n schema: SchemaDefinition,\n): SchemaDefinition {\n const foreignKeyTargets = new Set(\n schemaForeignKeys(schema).map((foreignKey) => foreignKey.referencesTable),\n );\n return {\n ...schema,\n columns: Object.fromEntries(\n Object.entries(schema.columns).map(([name, column]) => [\n name,\n column.foreignKey ? { ...column, foreignKey: undefined } : column,\n ]),\n ),\n foreignKeys: [],\n dependencies: schema.dependencies.filter(\n (dependency) => !foreignKeyTargets.has(dependency),\n ),\n };\n}\n\nfunction resolveRequestedSchemaClassName(className: string): string {\n const registered = ObjectRegistry.getClass(className);\n if (!registered) {\n return className;\n }\n\n if (\n !isCollectionRegistration(\n className,\n registered,\n collectionRegistrationLookup,\n )\n ) {\n const stiBase = ObjectRegistry.getSTIBase(className);\n return stiBase ? resolveSTIBaseLookupName(className, stiBase) : className;\n }\n\n return resolveCollectionSchemaClassName(className, registered);\n}\n\nfunction resolveRequestedSchemaClassNames(classNames: string[]): string[] {\n const resolved: string[] = [];\n const seen = new Set<string>();\n\n for (const className of classNames) {\n const schemaClassName = resolveRequestedSchemaClassName(className);\n if (seen.has(schemaClassName)) {\n continue;\n }\n\n seen.add(schemaClassName);\n resolved.push(schemaClassName);\n }\n\n return resolved;\n}\n\n/**\n * Creates an in-memory test database with schemas pre-created\n *\n * This utility is designed for testing. It creates an in-memory database\n * and initializes all registered SMRT object schemas using the **same\n * schema generation logic** as the production migration system.\n *\n * **Key features:**\n * - Uses `SchemaGenerator.generateSQL()` - the single source of truth for DDL\n * - Handles STI (Single Table Inheritance) correctly\n * - Creates system tables for framework functionality\n * - Safe for parallel test execution: each call creates an isolated\n * in-memory instance with its own embedded write-queue identity, so\n * unrelated `:memory:` databases never serialize writes against each\n * other (#2707). A `cache=shared` URL is the deliberate exception: it\n * asks SQLite/DuckDB to genuinely share the underlying database, so it\n * keeps sharing one write-queue identity too.\n *\n * @param options - Configuration options\n * @returns Promise resolving to configured DatabaseInterface\n *\n * @example\n * ```typescript\n * // Basic usage - all registered schemas\n * const db = await getTestDatabase();\n *\n * // Specific classes only\n * const db = await getTestDatabase({ classes: ['Council', 'Meeting'] });\n *\n * // JSON adapter instead of SQLite\n * const db = await getTestDatabase({ type: 'json' });\n *\n * // Native DuckDB adapter\n * const db = await getTestDatabase({ type: 'duckdb', url: ':memory:' });\n *\n * // File-based database for persistent tests\n * const db = await getTestDatabase({ type: 'sqlite', url: '/tmp/test.db' });\n *\n * // Initialize schemas in an existing database\n * const myDb = await getDatabase({ type: 'sqlite', url: ':memory:' });\n * await getTestDatabase({ db: myDb });\n *\n * // Skip system tables (rare use case)\n * const db = await getTestDatabase({ includeSystemTables: false });\n * ```\n */\nexport async function getTestDatabase(\n options: TestDatabaseOptions = {},\n): Promise<DatabaseInterface> {\n const {\n type,\n url = ':memory:',\n db: existingDb,\n classes,\n includeSystemTables = true,\n omitForeignKeyConstraints = false,\n } = options;\n\n // Use existing database or create new one\n const db =\n existingDb ??\n (await getDatabase({\n type: type ?? 'sqlite',\n url,\n __smrtSkipVitestSchemaPreparation: true,\n } as TestDatabaseConnectionOptions));\n if (!existingDb) await applySqliteTestPragmas(db, type, url);\n\n // Initialize system tables (same as production)\n if (includeSystemTables) {\n await initializeSystemTables(db);\n }\n\n // Get class names to setup. The framework-base filter below only applies\n // when the caller left `classes` implicit: an explicit list naming a\n // framework base class must still be honored (#2645).\n const isImplicitClassList = classes === undefined;\n const classNames = resolveRequestedSchemaClassNames(\n classes ?? ObjectRegistry.getQualifiedClassNames(),\n );\n\n // Skip if no classes registered. When the caller left `classes` implicit,\n // an empty registry usually means registration never reached this\n // process/module instance of ObjectRegistry (e.g. a Vitest plugin hook\n // registering manifests in a different worker/process than the test code\n // — #2750) rather than a deliberate \"system tables only\" setup. Warn (never\n // throw by default) so that failure surfaces here instead of as a much\n // later, harder-to-diagnose \"no such table\" error. An explicit empty\n // `classes: []` array is a deliberate choice and stays silent.\n if (classNames.length === 0) {\n if (isImplicitClassList) {\n const emptyRegistryMessage =\n 'getTestDatabase() found zero registered classes in ObjectRegistry and ' +\n 'created only system tables. If you expected model tables, the ' +\n 'registry is likely empty in this process — for smrt-vitest ' +\n 'consumers, confirm registration (smrtVitestPlugin() manifest ' +\n 'loading, or your setupFiles) actually runs in the same process ' +\n 'as your test code, or pass an explicit `classes` list to ' +\n 'getTestDatabase() to opt out of this check.';\n // recordRegistryDiagnostic('warn', ...) only buffers -- nothing reads\n // that buffer automatically (only strict-mode 'error' severity throws;\n // printing the buffer requires an explicit flushRegistryDiagnostics()\n // call this package never makes). Pair it with an immediate\n // console.warn so the failure is actually visible at the point of the\n // problem, which is the entire point of this diagnostic (#2750) --\n // the buffered record stays available for programmatic inspection via\n // getRegistryDiagnostics().\n console.warn(`[getTestDatabase] ${emptyRegistryMessage}`);\n recordRegistryDiagnostic(\n 'warn',\n 'TEST_DATABASE_EMPTY_IMPLICIT_REGISTRY',\n emptyRegistryMessage,\n );\n }\n return db;\n }\n\n // Use the same schema generation as production\n const schemaGenerator = new SchemaGenerator();\n const ddlEngine = resolveTestDatabaseDDLEngine(\n type,\n db,\n existingDb !== undefined,\n );\n\n // Collect every table before executing DDL so dependency ordering and cycle\n // handling are identical to production migration/schema paths (#2413).\n const schemas = new Map<\n string,\n { schema: SchemaDefinition; className: string }\n >();\n const authoritativeSchemas = ObjectRegistry.getAllSchemasAsDefinitions();\n\n for (const className of classNames) {\n // R11: the registration carries idType (native uuid vs text); read it\n // below when building runtimeSchemaConfig.\n const registered = ObjectRegistry.getClass(className);\n // Skip STI children - their schema is part of the base class table.\n // main (#1324): isSTIChild() compares RegisteredClass *identity* rather\n // than raw strings, so it stays correct under R5-canon (getSTIBase returns\n // qualified names) while also handling collection/override registrations.\n if (isSTIChild(className)) {\n continue;\n }\n\n // Framework abstract base classes (SmrtObject, SmrtClass, ...) are\n // scaffolding, not resources, and produce phantom smrt_objects/\n // smrt_classes/smrt_collections/smrt_hierarchicals/\n // smrt_polymorphic_associations tables when the default registry\n // snapshot happens to include them (#2645). Mirrors the production\n // `buildMergedTableSchemas()` filter in `registry/schema-builder.ts`, but\n // only for the implicit class list — an explicit `classes: [...]` caller\n // that names a framework base class is still honored.\n if (\n isImplicitClassList &&\n isFrameworkBaseClass(registered?.name, registered?.packageName)\n ) {\n continue;\n }\n\n const tableName = ObjectRegistry.getTableName(className);\n if (!tableName || schemas.has(tableName)) {\n continue;\n }\n\n const fields = await ObjectRegistry.getAllFields(className);\n const strategy = ObjectRegistry.getTableStrategy(className);\n // Mirrors `schema/utils.ts`: this bag is rebuilt by hand, so every `@smrt()`\n // option the generator reads has to be listed or tests silently diverge\n // from the production schema. `indexes` (#2357) was missing from both.\n const runtimeSchemaConfig = {\n conflictColumns: ObjectRegistry.getConflictColumns(className),\n idType: registered?.config.idType,\n indexes: registered?.config.indexes,\n registry: ObjectRegistry,\n };\n\n // Generate schema through the registry paths. Production (`manifest.json`\n // and therefore `smrt db:migrate`) uses the manifest paths\n // (`generateSTISchemaFromManifest` / `generateCTISchemaFromManifest`);\n // the two families are held to the same column and index sets by\n // `src/schema/schema-path-parity.test.ts` (#2359) — do not assume they\n // agree by construction.\n const schema =\n strategy === 'sti'\n ? await schemaGenerator.generateSTISchemaFromRegistry(\n className,\n tableName,\n fields,\n runtimeSchemaConfig,\n )\n : schemaGenerator.generateSchemaFromRegistry(\n className,\n tableName,\n fields,\n runtimeSchemaConfig,\n );\n\n // Manifest registrations can carry explicit FK actions that differ from\n // decorator defaults. The deterministic merged table schema preserves\n // those authoritative actions while still backfilling runtime-only fields.\n const authoritativeSchema = authoritativeSchemas[tableName];\n const effectiveColumns = authoritativeSchema\n ? Object.fromEntries(\n Object.entries(schema.columns).map(([name, column]) => {\n const manifestColumn = authoritativeSchema.columns[name];\n return [\n name,\n manifestColumn\n ? { ...column, foreignKey: manifestColumn.foreignKey }\n : column,\n ];\n }),\n )\n : schema.columns;\n const effectiveForeignKeys = schemaForeignKeys({\n columns: effectiveColumns,\n foreignKeys: [],\n });\n const effectiveSchema: SchemaDefinition = {\n ...schema,\n columns: effectiveColumns,\n foreignKeys: effectiveForeignKeys,\n dependencies: effectiveForeignKeys\n .map((foreignKey) => foreignKey.referencesTable)\n .filter((dependency) => dependency !== tableName),\n };\n\n schemas.set(tableName, {\n schema: omitForeignKeyConstraints\n ? omitTestForeignKeyConstraints(effectiveSchema)\n : effectiveSchema,\n className,\n });\n }\n\n // Explicitly requested test classes still need their registered parents.\n // Add the authoritative dependency closure so a child-only request cannot\n // emit a physical reference to a table this helper never creates.\n const addDependencies = (schema: SchemaDefinition): void => {\n for (const foreignKey of schemaForeignKeysForEngine(schema, ddlEngine)) {\n if (schemas.has(foreignKey.referencesTable)) continue;\n const dependency = authoritativeSchemas[foreignKey.referencesTable];\n if (!dependency) continue;\n const effectiveDependency = omitForeignKeyConstraints\n ? omitTestForeignKeyConstraints(dependency)\n : dependency;\n schemas.set(dependency.tableName, {\n schema: effectiveDependency,\n className: dependency.tableName,\n });\n addDependencies(effectiveDependency);\n }\n };\n for (const { schema } of [...schemas.values()]) addDependencies(schema);\n\n const tablePlan = planForeignKeyCreation(\n [...schemas.values()].map(({ schema }) => schema),\n ddlEngine,\n );\n const ddlStrategy = (await import('../schema/ddl/index.js')).getDDLStrategy(\n ddlEngine,\n );\n\n for (const schema of tablePlan.schemas) {\n const className = schemas.get(schema.tableName)?.className ?? 'unknown';\n const ddl = schemaGenerator.generateSQL(schema, ddlEngine);\n\n try {\n await db.query(ddl);\n\n // Create indexes (use DDL strategy so jsonPath / where / etc. render)\n const indexStatements = ddlStrategy.generateIndexes(schema);\n for (const indexSQL of indexStatements) {\n try {\n await db.query(indexSQL);\n } catch (indexError) {\n // Log but don't fail on index creation errors\n // Some indexes may fail if columns don't exist (STI meta fields)\n console.warn(\n `[getTestDatabase] Warning: Failed to create index: ${indexError instanceof Error ? indexError.message : String(indexError)} (SQL: ${indexSQL})`,\n );\n }\n }\n } catch (error) {\n // Provide helpful error message for table creation failures\n throw new Error(\n `[getTestDatabase] Failed to create table '${schema.tableName}' for class '${className}': ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n }\n\n for (const statement of tablePlan.deferredStatements) {\n try {\n await db.query(statement);\n } catch (error) {\n throw new Error(\n `[getTestDatabase] Failed to add deferred foreign-key constraint: ${error instanceof Error ? error.message : String(error)} (SQL: ${statement})`,\n { cause: error },\n );\n }\n }\n\n return db;\n}\n\n/**\n * Initialize SMRT system tables in a database\n *\n * System tables use _smrt_ prefix and store framework metadata.\n * All statements use `IF NOT EXISTS` for idempotency.\n *\n * @param db - Database interface to initialize\n */\nasync function initializeSystemTables(db: DatabaseInterface): Promise<void> {\n await ensureLegacySystemTableCompatibility(db);\n\n const configuredDb = db as DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n };\n const engine = detectEngine(\n db.url || configuredDb.config?.url || '',\n configuredDb.type || configuredDb.config?.type,\n );\n\n // Split multi-statement SQL into individual statements\n const allStatements: string[] = [];\n for (const multiStatementSQL of getSystemTableDDL(engine)) {\n const statements = multiStatementSQL\n .split(';')\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n allStatements.push(...statements);\n }\n\n // Use db.query() — system tables use CREATE TABLE/INDEX IF NOT EXISTS\n // which databases handle natively without per-column existence checks.\n for (const statement of allStatements) {\n await db.query(statement);\n }\n\n // PostgreSQL keeps the best-effort append boundary in a function that must\n // be executed whole rather than included in semicolon-split portable DDL.\n await ensurePostgresChangeFeedAppendFunction(db);\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) {
@@ -1 +1 @@
1
- {"version":3,"file":"asset-url-compatibility.js","names":[],"sources":["../../src/vite-plugin/asset-url-compatibility.ts"],"sourcesContent":["import type { Plugin } from 'vite';\n\n// Returning true on exhaustion is conservative: only a complete walk may prove\n// that comment/string matches cannot require Vite's asset transform.\nfunction hasImportMeta(ast: unknown): boolean {\n if (\n !ast ||\n typeof ast !== 'object' ||\n (ast as { type?: unknown }).type !== 'Program' ||\n !Array.isArray((ast as { body?: unknown }).body)\n )\n return true;\n const pending: unknown[] = [ast];\n let visited = 0;\n while (pending.length) {\n const node = pending.pop();\n if (!node || typeof node !== 'object') continue;\n if (++visited > 1_000_000) return true;\n if (Array.isArray(node)) {\n for (const child of node) {\n if (child && typeof child === 'object') pending.push(child);\n }\n continue;\n }\n const value = node as Record<string, unknown>;\n if (\n value.type === 'MetaProperty' &&\n (value.meta as { name?: string } | undefined)?.name === 'import' &&\n (value.property as { name?: string } | undefined)?.name === 'meta'\n )\n return true;\n for (const child of Object.values(value)) {\n if (child && typeof child === 'object') pending.push(child);\n }\n }\n return false;\n}\n\nconst unsafeAssetFilter = /new\\s+URL.+import\\.meta\\.url/s;\n\n/**\n * Keep Vite 7 asset URL transforms safe for large browser/Planner workers.\n * The known backtracking prefilter and comment-only eligibility are guarded;\n * Vite still owns asset parsing,\n * resolution and rewriting. Vite 8 and unrecognized filter shapes are untouched.\n * Install once in root plugins; existing worker plugin factories are preserved.\n */\nexport function smrtAssetUrlCompatibility(): Plugin {\n let supportedVite = false;\n const createPlugin = (includeWorkers: boolean): Plugin => ({\n name: 'smrt-asset-url-compatibility',\n ...(includeWorkers\n ? ({\n async config(config) {\n const { version } = await import('vite');\n supportedVite = version.startsWith('7.');\n if (!supportedVite) return;\n const existing = config.worker?.plugins;\n return {\n worker: {\n plugins: (...args) => [\n existing?.(...args) ?? [],\n createPlugin(false),\n ],\n },\n };\n },\n } satisfies Partial<Plugin>)\n : {}),\n configResolved(config) {\n if (!supportedVite) return;\n for (const plugin of config.plugins) {\n if (plugin.name !== 'vite:asset-import-meta-url') continue;\n const transform = plugin.transform;\n if (!transform || typeof transform !== 'object') continue;\n const filter = transform.filter;\n const code = filter?.code;\n if (\n filter &&\n code instanceof RegExp &&\n code.source === unsafeAssetFilter.source &&\n code.flags === unsafeAssetFilter.flags\n ) {\n filter.code = 'import.meta.url';\n const handler = transform.handler;\n transform.handler = function (...args) {\n try {\n if (!hasImportMeta(this.parse(args[0]))) return null;\n } catch {\n // Unknown syntax/context must retain Vite's original behavior.\n }\n return handler.apply(this, args);\n };\n }\n }\n },\n });\n return createPlugin(true);\n}\n"],"mappings":";AAIA,SAAS,cAAc,KAAuB;CAC5C,IACE,CAAC,OACD,OAAO,QAAQ,YACd,IAA2B,SAAS,aACrC,CAAC,MAAM,QAAS,IAA2B,IAAI,GAE/C,OAAO;CACT,MAAM,UAAqB,CAAC,GAAG;CAC/B,IAAI,UAAU;CACd,OAAO,QAAQ,QAAQ;EACrB,MAAM,OAAO,QAAQ,IAAI;EACzB,IAAI,CAAC,QAAQ,OAAO,SAAS,UAAU;EACvC,IAAI,EAAE,UAAU,KAAW,OAAO;EAClC,IAAI,MAAM,QAAQ,IAAI,GAAG;GACvB,KAAK,MAAM,SAAS,MAClB,IAAI,SAAS,OAAO,UAAU,UAAU,QAAQ,KAAK,KAAK;GAE5D;EACF;EACA,MAAM,QAAQ;EACd,IACE,MAAM,SAAS,kBACd,MAAM,MAAwC,SAAS,YACvD,MAAM,UAA4C,SAAS,QAE5D,OAAO;EACT,KAAK,MAAM,SAAS,OAAO,OAAO,KAAK,GACrC,IAAI,SAAS,OAAO,UAAU,UAAU,QAAQ,KAAK,KAAK;CAE9D;CACA,OAAO;AACT;AAEA,IAAM,oBAAoB;;;;;;;;AAS1B,SAAgB,4BAAoC;CAClD,IAAI,gBAAgB;CACpB,MAAM,gBAAgB,oBAAqC;EACzD,MAAM;EACN,GAAI,iBACC,EACC,MAAM,OAAO,QAAQ;GACnB,MAAM,EAAE,YAAY,MAAM,OAAO;GACjC,gBAAgB,QAAQ,WAAW,IAAI;GACvC,IAAI,CAAC,eAAe;GACpB,MAAM,WAAW,OAAO,QAAQ;GAChC,OAAO,EACL,QAAQ,EACN,UAAU,GAAG,SAAS,CACpB,WAAW,GAAG,IAAI,KAAK,CAAC,GACxB,aAAa,KAAK,CACpB,EACF,EACF;EACF,EACF,IACA,CAAC;EACL,eAAe,QAAQ;GACrB,IAAI,CAAC,eAAe;GACpB,KAAK,MAAM,UAAU,OAAO,SAAS;IACnC,IAAI,OAAO,SAAS,8BAA8B;IAClD,MAAM,YAAY,OAAO;IACzB,IAAI,CAAC,aAAa,OAAO,cAAc,UAAU;IACjD,MAAM,SAAS,UAAU;IACzB,MAAM,OAAO,QAAQ;IACrB,IACE,UACA,gBAAgB,UAChB,KAAK,WAAW,kBAAkB,UAClC,KAAK,UAAU,kBAAkB,OACjC;KACA,OAAO,OAAO;KACd,MAAM,UAAU,UAAU;KAC1B,UAAU,UAAU,SAAU,GAAG,MAAM;MACrC,IAAI;OACF,IAAI,CAAC,cAAc,KAAK,MAAM,KAAK,EAAE,CAAC,GAAG,OAAO;MAClD,QAAQ,CAER;MACA,OAAO,QAAQ,MAAM,MAAM,IAAI;KACjC;IACF;GACF;EACF;CACF;CACA,OAAO,aAAa,IAAI;AAC1B"}
1
+ {"version":3,"file":"asset-url-compatibility.js","names":[],"sources":["../../src/vite-plugin/asset-url-compatibility.ts"],"sourcesContent":["import { resolve } from 'node:path';\nimport { importProjectVite } from '../utils/import-project-vite.js';\nimport type {\n SmrtAwaitable,\n SmrtHookContext,\n SmrtVitePlugin,\n} from './plugin-contract.js';\n\n/** An external transform is preserved verbatim, including its source map. */\ninterface AssetTransform {\n filter: { code?: string | RegExp };\n handler(\n this: SmrtHookContext,\n code: string,\n id: string,\n options?: { ssr?: boolean },\n ): SmrtAwaitable<\n string | { code?: string; map?: object | string | null } | null | void\n >;\n}\n\nfunction isAssetTransform(value: unknown): value is AssetTransform {\n if (!value || typeof value !== 'object') return false;\n const candidate = value as { filter?: unknown; handler?: unknown };\n return (\n !!candidate.filter &&\n typeof candidate.filter === 'object' &&\n typeof candidate.handler === 'function'\n );\n}\n\n// Returning true on exhaustion is conservative: only a complete walk may prove\n// that comment/string matches cannot require Vite's asset transform.\nfunction hasImportMeta(ast: unknown): boolean {\n if (\n !ast ||\n typeof ast !== 'object' ||\n (ast as { type?: unknown }).type !== 'Program' ||\n !Array.isArray((ast as { body?: unknown }).body)\n )\n return true;\n const pending: unknown[] = [ast];\n let visited = 0;\n while (pending.length) {\n const node = pending.pop();\n if (!node || typeof node !== 'object') continue;\n if (++visited > 1_000_000) return true;\n if (Array.isArray(node)) {\n for (const child of node) {\n if (child && typeof child === 'object') pending.push(child);\n }\n continue;\n }\n const value = node as Record<string, unknown>;\n if (\n value.type === 'MetaProperty' &&\n (value.meta as { name?: string } | undefined)?.name === 'import' &&\n (value.property as { name?: string } | undefined)?.name === 'meta'\n )\n return true;\n for (const child of Object.values(value)) {\n if (child && typeof child === 'object') pending.push(child);\n }\n }\n return false;\n}\n\nconst unsafeAssetFilter = /new\\s+URL.+import\\.meta\\.url/s;\n\n/**\n * Keep Vite 7 asset URL transforms safe for large browser/Planner workers.\n * The known backtracking prefilter and comment-only eligibility are guarded;\n * Vite still owns asset parsing,\n * resolution and rewriting. Vite 8 and unrecognized filter shapes are untouched.\n * Install once in root plugins; existing worker plugin factories are preserved.\n */\nexport function smrtAssetUrlCompatibility(): SmrtVitePlugin {\n let supportedVite = false;\n const createPlugin = (includeWorkers: boolean): SmrtVitePlugin => ({\n name: 'smrt-asset-url-compatibility',\n ...(includeWorkers\n ? ({\n async config(config) {\n const { version } = await importProjectVite(\n resolve(process.cwd(), config.root ?? '.'),\n 'Asset URL compatibility',\n );\n supportedVite = version.startsWith('7.');\n if (!supportedVite) return;\n const existing = config.worker?.plugins;\n // Mutate the config using Vite's supported config-hook contract.\n // Foreign worker plugins remain opaque pass-through values; only\n // the appended SMRT plugin is authored against our hook contract.\n config.worker = {\n ...config.worker,\n plugins: (...args) => [\n existing?.(...args) ?? [],\n createPlugin(false),\n ],\n };\n },\n } satisfies Partial<SmrtVitePlugin>)\n : {}),\n configResolved(config) {\n if (!supportedVite) return;\n for (const plugin of config.plugins) {\n if (plugin.name !== 'vite:asset-import-meta-url') continue;\n const transform = plugin.transform;\n if (!isAssetTransform(transform)) continue;\n const filter = transform.filter;\n const code = filter?.code;\n if (\n filter &&\n code instanceof RegExp &&\n code.source === unsafeAssetFilter.source &&\n code.flags === unsafeAssetFilter.flags\n ) {\n filter.code = 'import.meta.url';\n const handler = transform.handler;\n transform.handler = function (...args) {\n try {\n if (!hasImportMeta(this.parse(args[0]))) return null;\n } catch {\n // Unknown syntax/context must retain Vite's original behavior.\n }\n return handler.apply(this, args);\n };\n }\n }\n },\n });\n return createPlugin(true);\n}\n"],"mappings":";;;AAqBA,SAAS,iBAAiB,OAAyC;CACjE,IAAI,CAAC,SAAS,OAAO,UAAU,UAAU,OAAO;CAChD,MAAM,YAAY;CAClB,OACE,CAAC,CAAC,UAAU,UACZ,OAAO,UAAU,WAAW,YAC5B,OAAO,UAAU,YAAY;AAEjC;AAIA,SAAS,cAAc,KAAuB;CAC5C,IACE,CAAC,OACD,OAAO,QAAQ,YACd,IAA2B,SAAS,aACrC,CAAC,MAAM,QAAS,IAA2B,IAAI,GAE/C,OAAO;CACT,MAAM,UAAqB,CAAC,GAAG;CAC/B,IAAI,UAAU;CACd,OAAO,QAAQ,QAAQ;EACrB,MAAM,OAAO,QAAQ,IAAI;EACzB,IAAI,CAAC,QAAQ,OAAO,SAAS,UAAU;EACvC,IAAI,EAAE,UAAU,KAAW,OAAO;EAClC,IAAI,MAAM,QAAQ,IAAI,GAAG;GACvB,KAAK,MAAM,SAAS,MAClB,IAAI,SAAS,OAAO,UAAU,UAAU,QAAQ,KAAK,KAAK;GAE5D;EACF;EACA,MAAM,QAAQ;EACd,IACE,MAAM,SAAS,kBACd,MAAM,MAAwC,SAAS,YACvD,MAAM,UAA4C,SAAS,QAE5D,OAAO;EACT,KAAK,MAAM,SAAS,OAAO,OAAO,KAAK,GACrC,IAAI,SAAS,OAAO,UAAU,UAAU,QAAQ,KAAK,KAAK;CAE9D;CACA,OAAO;AACT;AAEA,IAAM,oBAAoB;;;;;;;;AAS1B,SAAgB,4BAA4C;CAC1D,IAAI,gBAAgB;CACpB,MAAM,gBAAgB,oBAA6C;EACjE,MAAM;EACN,GAAI,iBACC,EACC,MAAM,OAAO,QAAQ;GACnB,MAAM,EAAE,YAAY,MAAM,kBACxB,QAAQ,QAAQ,IAAI,GAAG,OAAO,QAAQ,GAAG,GACzC,yBACF;GACA,gBAAgB,QAAQ,WAAW,IAAI;GACvC,IAAI,CAAC,eAAe;GACpB,MAAM,WAAW,OAAO,QAAQ;GAIhC,OAAO,SAAS;IACd,GAAG,OAAO;IACV,UAAU,GAAG,SAAS,CACpB,WAAW,GAAG,IAAI,KAAK,CAAC,GACxB,aAAa,KAAK,CACpB;GACF;EACF,EACF,IACA,CAAC;EACL,eAAe,QAAQ;GACrB,IAAI,CAAC,eAAe;GACpB,KAAK,MAAM,UAAU,OAAO,SAAS;IACnC,IAAI,OAAO,SAAS,8BAA8B;IAClD,MAAM,YAAY,OAAO;IACzB,IAAI,CAAC,iBAAiB,SAAS,GAAG;IAClC,MAAM,SAAS,UAAU;IACzB,MAAM,OAAO,QAAQ;IACrB,IACE,UACA,gBAAgB,UAChB,KAAK,WAAW,kBAAkB,UAClC,KAAK,UAAU,kBAAkB,OACjC;KACA,OAAO,OAAO;KACd,MAAM,UAAU,UAAU;KAC1B,UAAU,UAAU,SAAU,GAAG,MAAM;MACrC,IAAI;OACF,IAAI,CAAC,cAAc,KAAK,MAAM,KAAK,EAAE,CAAC,GAAG,OAAO;MAClD,QAAQ,CAER;MACA,OAAO,QAAQ,MAAM,MAAM,IAAI;KACjC;IACF;GACF;EACF;CACF;CACA,OAAO,aAAa,IAAI;AAC1B"}
@@ -4,7 +4,8 @@
4
4
  */
5
5
  export { smrtAssetUrlCompatibility } from './asset-url-compatibility.js';
6
6
  import type { DomainKnowledgeAgentSurface, DomainKnowledgeConfig } from '@happyvertical/smrt-types';
7
- import type { Plugin } from 'vite';
7
+ import type { SmrtVitePlugin } from './plugin-contract.js';
8
+ export type { SmrtVitePlugin } from './plugin-contract.js';
8
9
  import { type SmrtGenerationSnapshotOptions } from '../generation-snapshot.js';
9
10
  import { type SmartObjectManifest } from '../scanner/types.js';
10
11
  import type { ScannerAgentSurface, ScannerModule } from '../utils/scanner-module.js';
@@ -210,7 +211,9 @@ export declare function stampPackageModule(code: string, id: string, manifest: S
210
211
  * faster for large payloads and sidesteps identifier/keyword edge cases.
211
212
  */
212
213
  export declare function generateInlineRegisterModule(manifest: SmartObjectManifest): string;
213
- export declare function smrtPlugin(options?: SmrtPluginOptions): Plugin;
214
+ export declare function smrtPlugin(options?: SmrtPluginOptions): SmrtVitePlugin<SmrtPluginApi> & {
215
+ api: SmrtPluginApi;
216
+ };
214
217
  /**
215
218
  * Generate TypeScript declaration file for virtual modules
216
219
  * This eliminates the need for manual type maintenance