@happyvertical/smrt-core 0.40.70 → 0.42.0

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 (193) hide show
  1. package/AGENTS.md +3 -3
  2. package/agents/change-feed.md +1 -1
  3. package/agents/schema-paths.md +561 -32
  4. package/dist/browser.js +5 -4
  5. package/dist/cascade.d.ts +120 -0
  6. package/dist/cascade.d.ts.map +1 -0
  7. package/dist/cascade.js +430 -0
  8. package/dist/cascade.js.map +1 -0
  9. package/dist/change-feed.d.ts +34 -2
  10. package/dist/change-feed.d.ts.map +1 -1
  11. package/dist/change-feed.js +54 -13
  12. package/dist/change-feed.js.map +1 -1
  13. package/dist/change-signals.d.ts.map +1 -1
  14. package/dist/change-signals.js +8 -1
  15. package/dist/change-signals.js.map +1 -1
  16. package/dist/class.d.ts +36 -3
  17. package/dist/class.d.ts.map +1 -1
  18. package/dist/class.js +69 -14
  19. package/dist/class.js.map +1 -1
  20. package/dist/collection-cache.js +0 -0
  21. package/dist/collection-cache.js.map +1 -1
  22. package/dist/collection.d.ts +29 -0
  23. package/dist/collection.d.ts.map +1 -1
  24. package/dist/collection.js +68 -16
  25. package/dist/collection.js.map +1 -1
  26. package/dist/config.d.ts +10 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/decorators/index.d.ts +63 -5
  30. package/dist/decorators/index.d.ts.map +1 -1
  31. package/dist/decorators/index.js +37 -5
  32. package/dist/decorators/index.js.map +1 -1
  33. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  34. package/dist/dispatch/collections/Dispatches.js +21 -6
  35. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  36. package/dist/dispatch/models/Dispatch.d.ts +1 -1
  37. package/dist/dispatch/models/Dispatch.d.ts.map +1 -1
  38. package/dist/dispatch/models/Dispatch.js +3 -2
  39. package/dist/dispatch/models/Dispatch.js.map +1 -1
  40. package/dist/dispatch/models/DispatchSubscription.d.ts +1 -1
  41. package/dist/dispatch/models/DispatchSubscription.d.ts.map +1 -1
  42. package/dist/dispatch/models/DispatchSubscription.js +2 -1
  43. package/dist/dispatch/models/DispatchSubscription.js.map +1 -1
  44. package/dist/dispatch/types.d.ts +5 -0
  45. package/dist/dispatch/types.d.ts.map +1 -1
  46. package/dist/embedded-write-queue.d.ts +46 -0
  47. package/dist/embedded-write-queue.d.ts.map +1 -0
  48. package/dist/embedded-write-queue.js +66 -0
  49. package/dist/embedded-write-queue.js.map +1 -0
  50. package/dist/embeddings/storage.d.ts +7 -0
  51. package/dist/embeddings/storage.d.ts.map +1 -1
  52. package/dist/embeddings/storage.js +31 -13
  53. package/dist/embeddings/storage.js.map +1 -1
  54. package/dist/errors.d.ts +1 -1
  55. package/dist/hierarchical.js +1 -1
  56. package/dist/index.d.ts +2 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -6
  59. package/dist/interceptors.d.ts +21 -0
  60. package/dist/interceptors.d.ts.map +1 -1
  61. package/dist/interceptors.js +27 -1
  62. package/dist/interceptors.js.map +1 -1
  63. package/dist/learning/memory.d.ts.map +1 -1
  64. package/dist/learning/memory.js +5 -4
  65. package/dist/learning/memory.js.map +1 -1
  66. package/dist/manifest/generator.d.ts.map +1 -1
  67. package/dist/manifest/generator.js +4 -7
  68. package/dist/manifest/generator.js.map +1 -1
  69. package/dist/manifest/static-manifest.js +10 -10
  70. package/dist/manifest/static-manifest.js.map +1 -1
  71. package/dist/manifest/store.js +1 -1
  72. package/dist/manifest/store.js.map +1 -1
  73. package/dist/manifest.json +19 -19
  74. package/dist/migrations/differ.d.ts +9 -1
  75. package/dist/migrations/differ.d.ts.map +1 -1
  76. package/dist/migrations/differ.js +12 -7
  77. package/dist/migrations/differ.js.map +1 -1
  78. package/dist/migrations/index.d.ts +2 -0
  79. package/dist/migrations/index.d.ts.map +1 -1
  80. package/dist/migrations/index.js +3 -1
  81. package/dist/migrations/integer-width.d.ts +99 -0
  82. package/dist/migrations/integer-width.d.ts.map +1 -0
  83. package/dist/migrations/integer-width.js +237 -0
  84. package/dist/migrations/integer-width.js.map +1 -0
  85. package/dist/migrations/minor-units.d.ts +162 -0
  86. package/dist/migrations/minor-units.d.ts.map +1 -0
  87. package/dist/migrations/minor-units.js +383 -0
  88. package/dist/migrations/minor-units.js.map +1 -0
  89. package/dist/migrations/tracker.d.ts.map +1 -1
  90. package/dist/migrations/tracker.js +28 -20
  91. package/dist/migrations/tracker.js.map +1 -1
  92. package/dist/migrations.js +3 -1
  93. package/dist/object.d.ts +98 -5
  94. package/dist/object.d.ts.map +1 -1
  95. package/dist/object.js +138 -30
  96. package/dist/object.js.map +1 -1
  97. package/dist/query-bounds.d.ts +5 -1
  98. package/dist/query-bounds.d.ts.map +1 -1
  99. package/dist/query-bounds.js +5 -1
  100. package/dist/query-bounds.js.map +1 -1
  101. package/dist/registry/class-registration.d.ts.map +1 -1
  102. package/dist/registry/class-registration.js +3 -1
  103. package/dist/registry/class-registration.js.map +1 -1
  104. package/dist/registry/manifest-field-merge.d.ts +12 -0
  105. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  106. package/dist/registry/manifest-field-merge.js +14 -2
  107. package/dist/registry/manifest-field-merge.js.map +1 -1
  108. package/dist/registry/schema-builder.d.ts +22 -1
  109. package/dist/registry/schema-builder.d.ts.map +1 -1
  110. package/dist/registry/schema-builder.js +205 -165
  111. package/dist/registry/schema-builder.js.map +1 -1
  112. package/dist/registry/types.d.ts +3 -2
  113. package/dist/registry/types.d.ts.map +1 -1
  114. package/dist/registry.d.ts +41 -46
  115. package/dist/registry.d.ts.map +1 -1
  116. package/dist/registry.js +61 -83
  117. package/dist/registry.js.map +1 -1
  118. package/dist/scanner/manifest-generator.d.ts +45 -0
  119. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  120. package/dist/scanner/manifest-generator.js +92 -28
  121. package/dist/scanner/manifest-generator.js.map +1 -1
  122. package/dist/schema/conflict-target.d.ts +104 -0
  123. package/dist/schema/conflict-target.d.ts.map +1 -0
  124. package/dist/schema/conflict-target.js +129 -0
  125. package/dist/schema/conflict-target.js.map +1 -0
  126. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  127. package/dist/schema/ddl/duckdb-strategy.js +3 -1
  128. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  129. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  130. package/dist/schema/ddl/postgres-strategy.js +14 -1
  131. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  132. package/dist/schema/generator.d.ts +245 -41
  133. package/dist/schema/generator.d.ts.map +1 -1
  134. package/dist/schema/generator.js +397 -241
  135. package/dist/schema/generator.js.map +1 -1
  136. package/dist/schema/index-utils.d.ts +120 -0
  137. package/dist/schema/index-utils.d.ts.map +1 -1
  138. package/dist/schema/index-utils.js +242 -1
  139. package/dist/schema/index-utils.js.map +1 -1
  140. package/dist/schema/index.d.ts +2 -3
  141. package/dist/schema/index.d.ts.map +1 -1
  142. package/dist/schema/index.js +2 -3
  143. package/dist/schema/live-parity.d.ts +3 -1
  144. package/dist/schema/live-parity.d.ts.map +1 -1
  145. package/dist/schema/live-parity.js +23 -2
  146. package/dist/schema/live-parity.js.map +1 -1
  147. package/dist/schema/system-table-shapes.d.ts +13 -0
  148. package/dist/schema/system-table-shapes.d.ts.map +1 -1
  149. package/dist/schema/system-table-shapes.js +14 -1
  150. package/dist/schema/system-table-shapes.js.map +1 -1
  151. package/dist/schema/types.d.ts +17 -10
  152. package/dist/schema/types.d.ts.map +1 -1
  153. package/dist/schema/utils.d.ts +1 -1
  154. package/dist/schema/utils.d.ts.map +1 -1
  155. package/dist/schema/utils.js +3 -3
  156. package/dist/schema/utils.js.map +1 -1
  157. package/dist/schema.js +2 -3
  158. package/dist/smrt-knowledge.json +9 -9
  159. package/dist/system/compatibility.d.ts +42 -0
  160. package/dist/system/compatibility.d.ts.map +1 -1
  161. package/dist/system/compatibility.js +182 -9
  162. package/dist/system/compatibility.js.map +1 -1
  163. package/dist/system/index.d.ts +1 -0
  164. package/dist/system/index.d.ts.map +1 -1
  165. package/dist/system/index.js +3 -2
  166. package/dist/system/retention.d.ts +237 -0
  167. package/dist/system/retention.d.ts.map +1 -0
  168. package/dist/system/retention.js +497 -0
  169. package/dist/system/retention.js.map +1 -0
  170. package/dist/system/schema.d.ts +102 -16
  171. package/dist/system/schema.d.ts.map +1 -1
  172. package/dist/system/schema.js +85 -47
  173. package/dist/system/schema.js.map +1 -1
  174. package/dist/system/types.d.ts +0 -2
  175. package/dist/system/types.d.ts.map +1 -1
  176. package/dist/testing/database.d.ts.map +1 -1
  177. package/dist/testing/database.js.map +1 -1
  178. package/dist/utils/safe-integer.d.ts +19 -0
  179. package/dist/utils/safe-integer.d.ts.map +1 -0
  180. package/dist/utils/safe-integer.js +31 -0
  181. package/dist/utils/safe-integer.js.map +1 -0
  182. package/dist/utils.d.ts +2 -1
  183. package/dist/utils.d.ts.map +1 -1
  184. package/dist/utils.js +6 -3
  185. package/dist/utils.js.map +1 -1
  186. package/dist/vite-plugin/index.d.ts.map +1 -1
  187. package/dist/vite-plugin/index.js +4 -62
  188. package/dist/vite-plugin/index.js.map +1 -1
  189. package/package.json +4 -4
  190. package/dist/schema/override-system.d.ts +0 -43
  191. package/dist/schema/override-system.d.ts.map +0 -1
  192. package/dist/schema/override-system.js +0 -205
  193. package/dist/schema/override-system.js.map +0 -1
@@ -0,0 +1,497 @@
1
+ import { toSafeInteger } from "../utils/safe-integer.js";
2
+ import { detectEngine } from "../schema/ddl/index.js";
3
+ import { pruneChangeFeed } from "../change-feed.js";
4
+ import { createLogger } from "@happyvertical/logger";
5
+ //#region src/system/retention.ts
6
+ /**
7
+ * System-table retention — bounded growth for framework-owned tables (#2375).
8
+ *
9
+ * Several `_smrt_*` tables are append-only by construction and were, until
10
+ * this module, unbounded by default: `_smrt_changes` grew one row per
11
+ * framework save/delete, `_smrt_ai_usage` one row per AI call (persistence is
12
+ * on by default, see `config.ts`), `_smrt_contexts` accumulated rows whose
13
+ * `expires_at` nothing ever enforced, and `_smrt_dispatch` retained completed
14
+ * work until an operator remembered to run `smrt dispatch:cleanup`.
15
+ *
16
+ * This module supplies the missing half:
17
+ *
18
+ * - {@link pruneAiUsage} — the retention API `_smrt_ai_usage` never had.
19
+ * - {@link pruneExpiredContexts} — enforcement for `_smrt_contexts.expires_at`.
20
+ * - {@link runRetentionSweep} — one entry point that applies a documented,
21
+ * opt-out {@link RetentionPolicy} across every framework-owned table, plus
22
+ * any task other packages contribute via {@link registerRetentionTask}
23
+ * (`@happyvertical/smrt-jobs` registers job/job-event cleanup;
24
+ * `@happyvertical/smrt-users` registers session and token expiry).
25
+ *
26
+ * Scheduling is the caller's: `smrt db:prune` is the cron entry point, and a
27
+ * running `TaskRunner` sweeps on an interval unless configured otherwise.
28
+ *
29
+ * ## Failure policy
30
+ *
31
+ * A sweep is maintenance, not a transaction. One failing task never aborts the
32
+ * rest — the failure is captured on that task's {@link RetentionTaskResult}
33
+ * and the sweep continues. Callers decide what a partial sweep means; the CLI
34
+ * exits non-zero when any task failed.
35
+ *
36
+ * ## Predicate indexes
37
+ *
38
+ * Every predicate below is indexed by the system DDL in `system/schema.ts`
39
+ * (`_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`,
40
+ * `_smrt_dispatch(status, processed_at)` / `(status, updated_at)`) or by the
41
+ * jobs compatibility path in `system/compatibility.ts`
42
+ * (`_smrt_jobs(status, completed_at)`, `_smrt_job_events(created_at)`).
43
+ * A new retention predicate must ship with its index.
44
+ *
45
+ * @see https://github.com/happyvertical/smrt/issues/2375
46
+ * @packageDocumentation
47
+ */
48
+ var logger = createLogger({ level: "info" });
49
+ /** Milliseconds in a day — the unit every retention policy is expressed in. */
50
+ var MS_PER_DAY = 1440 * 60 * 1e3;
51
+ /**
52
+ * Documented retention defaults.
53
+ *
54
+ * Chosen to be generous relative to every in-tree consumer: change-feed
55
+ * cursors resync automatically past the window, AI usage outlives a monthly
56
+ * billing cycle by two months, and dispatch keeps a full quarter of failures
57
+ * for forensics. Applications that need longer history raise these; those that
58
+ * need none set the table to `false`.
59
+ */
60
+ var DEFAULT_RETENTION_POLICY = {
61
+ changes: { maxAgeDays: 30 },
62
+ aiUsage: { maxAgeDays: 90 },
63
+ contexts: {},
64
+ dispatch: {
65
+ completedOlderThanDays: 30,
66
+ failedOlderThanDays: 90
67
+ }
68
+ };
69
+ var RETENTION_TASKS_KEY = Symbol.for("smrt.retention-tasks");
70
+ /**
71
+ * Names {@link builtInTasks} always uses for the four framework-owned tables.
72
+ *
73
+ * Reserved against contributed tasks: `RetentionTaskResult.task` is
74
+ * documented unique within a sweep, and a contributed task sharing one of
75
+ * these names would collide with a built-in result — silently, since nothing
76
+ * else in the sweep loop checks for it — and would also be unreachable
77
+ * through {@link RetentionPolicy.tasks}, which only ever sees the *last*
78
+ * task run under that name, never the built-in one (opting a built-in table
79
+ * out goes through its own dedicated policy field instead).
80
+ */
81
+ var RESERVED_TASK_NAMES = /* @__PURE__ */ new Set([
82
+ "changes",
83
+ "ai-usage",
84
+ "contexts",
85
+ "dispatch"
86
+ ]);
87
+ /**
88
+ * The task registry, held on `globalThis` rather than in module scope.
89
+ *
90
+ * Contributing packages resolve their own copy of `@happyvertical/smrt-core`
91
+ * whenever a consumer's install is not fully deduped, and the CLI resolves a
92
+ * third. A module-local `Map` would give each of them a private, empty
93
+ * registry — the task would register in one and the sweep would run in
94
+ * another. `ObjectRegistry` is on `globalThis` for the same reason.
95
+ */
96
+ function registeredTasks() {
97
+ const root = globalThis;
98
+ root[RETENTION_TASKS_KEY] ??= /* @__PURE__ */ new Map();
99
+ return root[RETENTION_TASKS_KEY];
100
+ }
101
+ /**
102
+ * Register a retention task so {@link runRetentionSweep} includes it.
103
+ *
104
+ * Registering a task does not schedule anything — a task only runs when
105
+ * something calls {@link runRetentionSweep}, and the policy can still turn it
106
+ * off by name. Re-registering the same name replaces the previous task, which
107
+ * keeps module re-evaluation (HMR, repeated test imports) idempotent.
108
+ *
109
+ * @throws If `task.name` is empty, or is one of the four built-in table
110
+ * names (`changes`, `ai-usage`, `contexts`, `dispatch`) — see
111
+ * {@link RESERVED_TASK_NAMES}.
112
+ */
113
+ function registerRetentionTask(task) {
114
+ if (!task.name) throw new Error("registerRetentionTask requires a non-empty task name");
115
+ if (RESERVED_TASK_NAMES.has(task.name)) throw new Error(`registerRetentionTask: "${task.name}" is a built-in task name (reserved: ${[...RESERVED_TASK_NAMES].join(", ")})`);
116
+ registeredTasks().set(task.name, task);
117
+ }
118
+ /** Remove a registered retention task. Returns `true` if one was removed. */
119
+ function unregisterRetentionTask(name) {
120
+ return registeredTasks().delete(name);
121
+ }
122
+ /** Registered retention tasks, ordered by name for deterministic sweeps. */
123
+ function getRetentionTasks() {
124
+ return [...registeredTasks().values()].sort((left, right) => left.name.localeCompare(right.name));
125
+ }
126
+ /** Drop every registered retention task (test helper). */
127
+ function clearRetentionTasks() {
128
+ registeredTasks().clear();
129
+ }
130
+ /** Name of the AI usage telemetry table. */
131
+ var AI_USAGE_TABLE = "_smrt_ai_usage";
132
+ /** Name of the remembered-context table. */
133
+ var CONTEXTS_TABLE = "_smrt_contexts";
134
+ /**
135
+ * Prune AI usage telemetry to bound `_smrt_ai_usage` growth.
136
+ *
137
+ * `_smrt_ai_usage` gains one row per AI call and persistence is on by default,
138
+ * so a busy deployment writes millions of rows a year with nothing to remove
139
+ * them. Both bounds may be combined; each is applied independently and the
140
+ * returned count is the total deleted.
141
+ *
142
+ * Predicates are covered by `idx_smrt_ai_usage_tenant_created`
143
+ * (`tenant_id, created_at`) — the same index the subscriptions billing meter
144
+ * needs for its `tenant_id + created_at` range scan.
145
+ *
146
+ * @param db - Database holding the system tables.
147
+ * @param retention - At least one of `maxAgeMs` / `maxRows`.
148
+ * @returns Number of records deleted (or, under `dryRun`, matched).
149
+ */
150
+ async function pruneAiUsage(db, retention) {
151
+ const { maxAgeMs, maxRows, dryRun = false } = retention;
152
+ if (maxAgeMs == null && maxRows == null) throw new Error("pruneAiUsage requires maxAgeMs and/or maxRows");
153
+ if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) throw new Error(`pruneAiUsage maxAgeMs must be >= 0, got ${maxAgeMs}`);
154
+ if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) throw new Error(`pruneAiUsage maxRows must be >= 0, got ${maxRows}`);
155
+ const now = retention.now ?? /* @__PURE__ */ new Date();
156
+ const scope = tenantScopeClause(retention);
157
+ let pruned = 0;
158
+ const alreadyCounted = {
159
+ conditions: [],
160
+ params: []
161
+ };
162
+ if (maxAgeMs != null) {
163
+ const cutoff = new Date(now.getTime() - maxAgeMs).toISOString();
164
+ pruned += await deleteFrom(db, AI_USAGE_TABLE, [...scope.conditions, "created_at < ?"], [...scope.params, cutoff], dryRun);
165
+ alreadyCounted.conditions.push("created_at >= ?");
166
+ alreadyCounted.params.push(cutoff);
167
+ }
168
+ if (maxRows != null) {
169
+ const keep = Math.floor(maxRows);
170
+ if (keep === 0) pruned += await deleteFrom(db, AI_USAGE_TABLE, [...scope.conditions, ...alreadyCounted.conditions], [...scope.params, ...alreadyCounted.params], dryRun);
171
+ else {
172
+ const cutoff = (await selectOne(db, `SELECT created_at FROM ${AI_USAGE_TABLE}
173
+ ${whereClause(scope.conditions)}
174
+ ORDER BY created_at DESC
175
+ LIMIT 1 OFFSET ${keep - 1}`, scope.params))?.created_at;
176
+ if (cutoff != null) pruned += await deleteFrom(db, AI_USAGE_TABLE, [
177
+ ...scope.conditions,
178
+ "created_at < ?",
179
+ ...alreadyCounted.conditions
180
+ ], [
181
+ ...scope.params,
182
+ cutoff,
183
+ ...alreadyCounted.params
184
+ ], dryRun);
185
+ }
186
+ }
187
+ return { pruned };
188
+ }
189
+ /**
190
+ * Delete context entries whose `expires_at` has passed.
191
+ *
192
+ * `remember({ expiresAt })` has always stored the expiry and never acted on
193
+ * it. Enforcement is deliberately prune-side only: `recall()`/`recallAll()`
194
+ * keep their documented "expiry is not applied at read time" contract, so this
195
+ * bounds storage without changing read semantics for existing callers.
196
+ * (`LearningMemory` filters expired rows itself.)
197
+ *
198
+ * Rows with a `NULL` `expires_at` never expire and are never touched.
199
+ * The predicate is covered by `idx_smrt_contexts_expires_at`.
200
+ *
201
+ * @param db - Database holding the system tables.
202
+ * @param options.now - Clock for the expiry comparison (default: now).
203
+ * @param options.dryRun - Count matching rows without deleting them.
204
+ * @returns Number of expired entries deleted (or, under `dryRun`, matched).
205
+ */
206
+ async function pruneExpiredContexts(db, options = {}) {
207
+ return { pruned: await deleteFrom(db, CONTEXTS_TABLE, ["expires_at IS NOT NULL", "expires_at < ?"], [(options.now ?? /* @__PURE__ */ new Date()).toISOString()], options.dryRun ?? false) };
208
+ }
209
+ /**
210
+ * Apply a {@link RetentionPolicy} across every framework-owned table.
211
+ *
212
+ * Built-in tasks run first, in a fixed order (`changes`, `ai-usage`,
213
+ * `contexts`, `dispatch`), followed by tasks registered via
214
+ * {@link registerRetentionTask} in name order. Tables that do not exist in
215
+ * this database are reported as `skipped: 'unavailable'` rather than failing —
216
+ * a sweep must be safe to run against a partially bootstrapped database.
217
+ *
218
+ * @param db - Database holding the system tables.
219
+ * @param policy - Overrides on {@link DEFAULT_RETENTION_POLICY}.
220
+ * @returns Per-task and aggregate counts; never throws for a task failure.
221
+ */
222
+ async function runRetentionSweep(db, policy = {}) {
223
+ const startedAt = /* @__PURE__ */ new Date();
224
+ const dryRun = policy.dryRun ?? false;
225
+ if (policy.enabled === false) return {
226
+ dryRun,
227
+ startedAt: startedAt.toISOString(),
228
+ durationMs: 0,
229
+ pruned: 0,
230
+ tasks: [],
231
+ failed: false
232
+ };
233
+ const context = {
234
+ dryRun,
235
+ now: startedAt
236
+ };
237
+ const tasks = [];
238
+ for (const builtIn of builtInTasks(policy)) {
239
+ if (!builtIn.task) {
240
+ tasks.push({
241
+ task: builtIn.name,
242
+ pruned: 0,
243
+ skipped: "disabled"
244
+ });
245
+ continue;
246
+ }
247
+ tasks.push(await runTask(db, builtIn.table, builtIn.task, context));
248
+ }
249
+ for (const task of getRetentionTasks()) {
250
+ if (policy.tasks?.[task.name] === false) {
251
+ tasks.push({
252
+ task: task.name,
253
+ pruned: 0,
254
+ skipped: "disabled"
255
+ });
256
+ continue;
257
+ }
258
+ tasks.push(await runTask(db, null, task, context));
259
+ }
260
+ const pruned = tasks.reduce((total, task) => total + task.pruned, 0);
261
+ const failed = tasks.some((task) => task.error !== void 0);
262
+ logger.info("[smrt] retention sweep complete", {
263
+ dryRun,
264
+ pruned,
265
+ failed,
266
+ tasks: tasks.map((task) => `${task.task}=${task.pruned}`).join(" ")
267
+ });
268
+ return {
269
+ dryRun,
270
+ startedAt: startedAt.toISOString(),
271
+ durationMs: Date.now() - startedAt.getTime(),
272
+ pruned,
273
+ tasks,
274
+ failed
275
+ };
276
+ }
277
+ /**
278
+ * Run one task, converting an absent table or a thrown error into a result.
279
+ *
280
+ * `requiredTable` is checked before the task runs so a sweep against a
281
+ * database that never bootstrapped a given system table reports
282
+ * `unavailable` instead of surfacing an engine-specific "no such table".
283
+ */
284
+ async function runTask(db, requiredTable, task, context) {
285
+ try {
286
+ if (requiredTable && !await tableExists(db, requiredTable)) return {
287
+ task: task.name,
288
+ pruned: 0,
289
+ skipped: "unavailable"
290
+ };
291
+ const outcome = await task.run(db, context);
292
+ return typeof outcome === "number" ? {
293
+ task: task.name,
294
+ pruned: outcome
295
+ } : {
296
+ task: task.name,
297
+ pruned: outcome.pruned,
298
+ details: outcome.details
299
+ };
300
+ } catch (error) {
301
+ const message = error instanceof Error ? error.message : String(error);
302
+ logger.warn(`[smrt] retention task "${task.name}" failed: ${message}`);
303
+ return {
304
+ task: task.name,
305
+ pruned: 0,
306
+ error: message
307
+ };
308
+ }
309
+ }
310
+ /** Resolve a per-table policy entry, or `null` when it is opted out. */
311
+ function resolveEntry(entry, defaults) {
312
+ if (entry === false) return null;
313
+ const merged = {
314
+ ...defaults,
315
+ ...entry ?? {}
316
+ };
317
+ return merged.enabled === false ? null : merged;
318
+ }
319
+ /** The framework-owned tasks, in their fixed execution order. */
320
+ function builtInTasks(policy) {
321
+ return [
322
+ {
323
+ name: "changes",
324
+ table: "_smrt_changes",
325
+ task: changeFeedTask(policy)
326
+ },
327
+ {
328
+ name: "ai-usage",
329
+ table: AI_USAGE_TABLE,
330
+ task: aiUsageTask(policy)
331
+ },
332
+ {
333
+ name: "contexts",
334
+ table: CONTEXTS_TABLE,
335
+ task: contextsTask(policy)
336
+ },
337
+ {
338
+ name: "dispatch",
339
+ table: "_smrt_dispatch",
340
+ task: dispatchTask(policy)
341
+ }
342
+ ];
343
+ }
344
+ function changeFeedTask(policy) {
345
+ const config = resolveEntry(policy.changes, DEFAULT_RETENTION_POLICY.changes);
346
+ if (!config) return null;
347
+ return {
348
+ name: "changes",
349
+ description: "Prune the append-only change feed (_smrt_changes)",
350
+ run: async (db, context) => {
351
+ const { pruned } = await pruneChangeFeed(db, {
352
+ maxAgeMs: config.maxAgeDays == null ? void 0 : config.maxAgeDays * MS_PER_DAY,
353
+ maxRows: config.maxRows,
354
+ dryRun: context.dryRun
355
+ });
356
+ return pruned;
357
+ }
358
+ };
359
+ }
360
+ function aiUsageTask(policy) {
361
+ const config = resolveEntry(policy.aiUsage, DEFAULT_RETENTION_POLICY.aiUsage);
362
+ if (!config) return null;
363
+ return {
364
+ name: "ai-usage",
365
+ description: "Prune AI usage telemetry (_smrt_ai_usage)",
366
+ run: async (db, context) => {
367
+ const { pruned } = await pruneAiUsage(db, {
368
+ maxAgeMs: config.maxAgeDays == null ? void 0 : config.maxAgeDays * MS_PER_DAY,
369
+ maxRows: config.maxRows,
370
+ dryRun: context.dryRun,
371
+ now: context.now
372
+ });
373
+ return pruned;
374
+ }
375
+ };
376
+ }
377
+ function contextsTask(policy) {
378
+ if (!resolveEntry(policy.contexts, DEFAULT_RETENTION_POLICY.contexts)) return null;
379
+ return {
380
+ name: "contexts",
381
+ description: "Delete expired context entries (_smrt_contexts)",
382
+ run: async (db, context) => {
383
+ const { pruned } = await pruneExpiredContexts(db, {
384
+ now: context.now,
385
+ dryRun: context.dryRun
386
+ });
387
+ return pruned;
388
+ }
389
+ };
390
+ }
391
+ function dispatchTask(policy) {
392
+ const config = resolveEntry(policy.dispatch, DEFAULT_RETENTION_POLICY.dispatch);
393
+ if (!config) return null;
394
+ return {
395
+ name: "dispatch",
396
+ description: "Prune processed dispatch queue rows (_smrt_dispatch)",
397
+ run: async (db, context) => {
398
+ const { DispatchCollection } = await import("../dispatch/collections/Dispatches.js");
399
+ const result = await DispatchCollection.cleanup(db, {
400
+ completedOlderThanDays: config.completedOlderThanDays,
401
+ failedOlderThanDays: config.failedOlderThanDays,
402
+ dryRun: context.dryRun
403
+ });
404
+ return {
405
+ pruned: result.completedDeleted + result.failedDeleted,
406
+ details: {
407
+ completed: result.completedDeleted,
408
+ failed: result.failedDeleted
409
+ }
410
+ };
411
+ }
412
+ };
413
+ }
414
+ function getQueryRows(result) {
415
+ if (Array.isArray(result)) return result;
416
+ if (result && typeof result === "object" && "rows" in result) {
417
+ const rows = result.rows;
418
+ if (Array.isArray(rows)) return rows;
419
+ }
420
+ return [];
421
+ }
422
+ function getDatabaseUrl(db) {
423
+ return db.url || db.config?.url || "";
424
+ }
425
+ /**
426
+ * Rewrite `?` placeholders to `$n` on PostgreSQL.
427
+ *
428
+ * Retention SQL is written once in the portable `?` form; only the binding
429
+ * syntax differs between engines.
430
+ */
431
+ function bindPlaceholders(db, sql) {
432
+ if (detectEngine(getDatabaseUrl(db), db.type) !== "postgres") return sql;
433
+ let index = 0;
434
+ return sql.replace(/\?/g, () => `$${++index}`);
435
+ }
436
+ function whereClause(conditions) {
437
+ return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
438
+ }
439
+ function tenantScopeClause(retention) {
440
+ if (!("tenantId" in retention)) return {
441
+ conditions: [],
442
+ params: []
443
+ };
444
+ if (retention.tenantId === void 0) return {
445
+ conditions: [],
446
+ params: []
447
+ };
448
+ if (retention.tenantId === null) return {
449
+ conditions: ["tenant_id IS NULL"],
450
+ params: []
451
+ };
452
+ return {
453
+ conditions: ["tenant_id = ?"],
454
+ params: [retention.tenantId]
455
+ };
456
+ }
457
+ async function selectOne(db, sql, params) {
458
+ return getQueryRows(await db.query(bindPlaceholders(db, sql), ...params))[0];
459
+ }
460
+ /**
461
+ * Count matching rows, then delete them unless this is a dry run.
462
+ *
463
+ * Counting first gives a usable number on every adapter — `rowCount` is not
464
+ * reliably populated across the engines SMRT supports — and is what makes
465
+ * `dryRun` a genuine preview of the same predicate rather than an estimate of
466
+ * a different one.
467
+ *
468
+ * The count and the delete are two statements and deliberately not a
469
+ * transaction: a maintenance sweep must not hold a write lock over a large
470
+ * delete. The returned figure is therefore approximate under concurrent
471
+ * writers, exactly as `pruneChangeFeed` documents.
472
+ */
473
+ async function deleteFrom(db, table, conditions, params, dryRun) {
474
+ const where = whereClause(conditions);
475
+ const total = toSafeInteger((await selectOne(db, `SELECT COUNT(*) AS total FROM ${table} ${where}`, params))?.total ?? 0, "Retention count");
476
+ if (total <= 0) return 0;
477
+ if (!dryRun) await db.query(bindPlaceholders(db, `DELETE FROM ${table} ${where}`), ...params);
478
+ return total;
479
+ }
480
+ /** Best-effort table probe used to report `unavailable` rather than throwing. */
481
+ async function tableExists(db, table) {
482
+ try {
483
+ if (typeof db.tableExists === "function") return await db.tableExists(table);
484
+ } catch {
485
+ return false;
486
+ }
487
+ try {
488
+ await db.query(`SELECT 1 FROM ${table} LIMIT 1`);
489
+ return true;
490
+ } catch {
491
+ return false;
492
+ }
493
+ }
494
+ //#endregion
495
+ export { DEFAULT_RETENTION_POLICY, clearRetentionTasks, getRetentionTasks, pruneAiUsage, pruneExpiredContexts, registerRetentionTask, runRetentionSweep, unregisterRetentionTask };
496
+
497
+ //# sourceMappingURL=retention.js.map
@@ -0,0 +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, and `_smrt_dispatch` retained completed\n * work until an operator remembered to run `smrt dispatch:cleanup`.\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)`) or by the\n * jobs compatibility path in `system/compatibility.ts`\n * (`_smrt_jobs(status, completed_at)`, `_smrt_job_events(created_at)`).\n * A new retention predicate must ship with 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';\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 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 /**\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} = {\n changes: { maxAgeDays: 30 },\n aiUsage: { maxAgeDays: 90 },\n contexts: {},\n dispatch: { completedOlderThanDays: 30, failedOlderThanDays: 90 },\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 four 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]);\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 four built-in table\n * names (`changes`, `ai-usage`, `contexts`, `dispatch`) — see\n * {@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// 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`), 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}\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\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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"}