@happyvertical/smrt-core 0.51.28 → 0.51.29

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 (67) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +47 -0
  3. package/agents/build-knowledge.md +16 -0
  4. package/agents/change-feed.md +95 -4
  5. package/agents/change-signals.md +6 -1
  6. package/agents/run-once.md +268 -0
  7. package/agents/schema-paths.md +10 -5
  8. package/dist/browser.js +4 -3
  9. package/dist/change-feed-authz.d.ts +141 -0
  10. package/dist/change-feed-authz.d.ts.map +1 -0
  11. package/dist/change-feed-authz.js +260 -0
  12. package/dist/change-feed-authz.js.map +1 -0
  13. package/dist/change-feed.d.ts +15 -0
  14. package/dist/change-feed.d.ts.map +1 -1
  15. package/dist/change-feed.js +4 -0
  16. package/dist/change-feed.js.map +1 -1
  17. package/dist/errors.d.ts +28 -0
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +36 -1
  20. package/dist/errors.js.map +1 -1
  21. package/dist/generators/changes-route.d.ts.map +1 -1
  22. package/dist/generators/changes-route.js +13 -3
  23. package/dist/generators/changes-route.js.map +1 -1
  24. package/dist/generators/events-route.d.ts +35 -4
  25. package/dist/generators/events-route.d.ts.map +1 -1
  26. package/dist/generators/events-route.js +78 -14
  27. package/dist/generators/events-route.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +6 -3
  31. package/dist/knowledge-graph.d.ts.map +1 -1
  32. package/dist/knowledge-graph.js +1 -1
  33. package/dist/knowledge-graph.js.map +1 -1
  34. package/dist/manifest/static-manifest.js +1 -1
  35. package/dist/manifest/static-manifest.js.map +1 -1
  36. package/dist/manifest/store.js +1 -1
  37. package/dist/manifest.json +1 -1
  38. package/dist/run-once.d.ts +80 -0
  39. package/dist/run-once.d.ts.map +1 -0
  40. package/dist/run-once.js +311 -0
  41. package/dist/run-once.js.map +1 -0
  42. package/dist/smrt-knowledge.json +20 -14
  43. package/dist/system/index.d.ts +1 -0
  44. package/dist/system/index.d.ts.map +1 -1
  45. package/dist/system/index.js +3 -2
  46. package/dist/system/retention.d.ts +53 -4
  47. package/dist/system/retention.d.ts.map +1 -1
  48. package/dist/system/retention.js +67 -14
  49. package/dist/system/retention.js.map +1 -1
  50. package/dist/system/schema.d.ts +33 -1
  51. package/dist/system/schema.d.ts.map +1 -1
  52. package/dist/system/schema.js +60 -3
  53. package/dist/system/schema.js.map +1 -1
  54. package/dist/vite-plugin/changes-route.d.ts.map +1 -1
  55. package/dist/vite-plugin/changes-route.js +21 -5
  56. package/dist/vite-plugin/changes-route.js.map +1 -1
  57. package/dist/vite-plugin/events-route.d.ts.map +1 -1
  58. package/dist/vite-plugin/events-route.js +9 -0
  59. package/dist/vite-plugin/events-route.js.map +1 -1
  60. package/dist/vite-plugin/index.d.ts.map +1 -1
  61. package/dist/vite-plugin/index.js +14 -0
  62. package/dist/vite-plugin/index.js.map +1 -1
  63. package/dist/vite-plugin/worker-registration.d.ts +34 -0
  64. package/dist/vite-plugin/worker-registration.d.ts.map +1 -0
  65. package/dist/vite-plugin/worker-registration.js +108 -0
  66. package/dist/vite-plugin/worker-registration.js.map +1 -0
  67. package/package.json +4 -4
package/AGENTS.md CHANGED
@@ -16,6 +16,7 @@ and repository rules.
16
16
  | `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |
17
17
  | `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |
18
18
  | `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |
19
+ | `src/change-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [agents/change-feed.md](agents/change-feed.md) |
19
20
  | `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |
20
21
  | `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |
21
22
  | `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |
@@ -23,6 +24,7 @@ and repository rules.
23
24
  | `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |
24
25
  | `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |
25
26
  | `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |
27
+ | `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |
26
28
 
27
29
  ## Cross-module invariants
28
30
 
@@ -53,6 +55,24 @@ and repository rules.
53
55
  - `withDatabase(db, callback)` restores only database bindings (including public
54
56
  `options.db`); `withTransaction(callback)` also restores identity/revision
55
57
  metadata after rollback. Do not use a bound instance concurrently.
58
+ - `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,
59
+ #3080) claims a key derived from all four inputs — never the token alone,
60
+ which collapses distinct submissions — via `INSERT ... ON CONFLICT
61
+ (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL
62
+ `_smrt_run_once_claims` system table, and runs `work` in the SAME
63
+ transaction; a completed claim replays its stored result instead of
64
+ re-running, a failed `work` rolls the claim back with the transaction, and
65
+ an unresolved claim raises typed `RunOnceClaimError`
66
+ (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.
67
+ The claim insert must never raise on conflict: PostgreSQL aborts the whole
68
+ transaction on any raised error, which would take the recovery `SELECT`
69
+ down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`
70
+ caught for `isUniqueViolationError`. Completed claims are bounded by
71
+ `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,
72
+ `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on
73
+ `completed_at`; `in_progress` rows are never matched) — the window is a
74
+ replay deadline, not just disk hygiene: a token replayed after its claim
75
+ is swept runs `work()` again. See `agents/run-once.md`.
56
76
  - `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;
57
77
  call it on a base PostgreSQL connection before caller-owned transactions.
58
78
  Bootstrap uses a transaction-scoped advisory lock, taken only when the
package/README.md CHANGED
@@ -67,6 +67,52 @@ const results = await products.list({
67
67
 
68
68
  ```
69
69
 
70
+ ### Run an action exactly once per submission (`runOnce`)
71
+
72
+ A double-tapped submit, a retried request, or two concurrent requests should
73
+ never write the same record twice. `runOnce()` (#3080) is the shared
74
+ idempotency seam: an insert-only claim, written in the SAME transaction as
75
+ the work it guards, so a retry either runs the work once or replays the first
76
+ attempt's stored result.
77
+
78
+ ```typescript
79
+ import { runOnce, RunOnceClaimError } from '@happyvertical/smrt-core';
80
+
81
+ const purchaseOrderId = await runOnce(
82
+ {
83
+ db,
84
+ tenantId: session.tenantId,
85
+ actor: session.userId,
86
+ token: formData.get('submissionToken') as string, // minted once per rendered form
87
+ content: { supplierId, lines }, // only its digest is persisted, never the raw value
88
+ },
89
+ async (tx) => {
90
+ const po = await createPurchaseOrder(tx, { supplierId, lines });
91
+ return po.id; // must be JSON-serializable — this is what a replay returns
92
+ },
93
+ );
94
+ ```
95
+
96
+ - **Key derivation is `(tenantId, actor, token, contentDigest)` — never the
97
+ token alone.** A token-only key collapses two genuinely different
98
+ submissions that happen to reuse one form's token. `contentDigest` is a
99
+ sha256 of the sorted-key-JSON encoding of `content`, so key order never
100
+ changes it.
101
+ - The claim lives in the hand-migrated `_smrt_run_once_claims` system table
102
+ (never an application table you migrate yourself) and only ever stores
103
+ digests plus the JSON-serialized `work()` result — never the raw token or
104
+ raw content.
105
+ - A claim whose work is still in flight, or whose outcome cannot be resolved,
106
+ rejects with `RunOnceClaimError` (`code: 'RUN_ONCE_IN_FLIGHT'` or
107
+ `'RUN_ONCE_OUTCOME_UNKNOWN'`) instead of silently re-running or guessing —
108
+ mirroring the refused-vs-unknown-outcome distinction from #2990. Retry with
109
+ the SAME token and content; `runOnce()` never retries on its own.
110
+ - If `work()` throws, the transaction that inserted the claim rolls it back
111
+ too — a failed attempt never strands a claim, and a retry with the same key
112
+ runs again.
113
+
114
+ See [`agents/run-once.md`](agents/run-once.md) for the full contract.
115
+
70
116
  ### Bounded multi-collection reads
71
117
 
72
118
  When one request needs several independent collections, use a keyed read plan
@@ -527,6 +573,7 @@ Every `SmrtObject`/`SmrtCollection` can persist learned knowledge via `remember(
527
573
  | `AIError` | AI provider failures |
528
574
  | `ValidationError` | Field/object validation failures |
529
575
  | `RuntimeError` | General runtime failures |
576
+ | `RunOnceClaimError` | `runOnce()` claim in flight or unresolvable (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) |
530
577
  | `ErrorUtils` | Retry policy (`withRetry`, `isRetryable`) plus sanitization helpers |
531
578
 
532
579
  ### Database error classification
@@ -15,6 +15,22 @@ schemas. No schema/persistence/security effect — `sensitive`/`readPermission`
15
15
  stay the security rail, and `sensitive`/`transient` fields never emit to the
16
16
  client at all.
17
17
 
18
+ ## Worker registration artifact (#3117)
19
+
20
+ With `svelteKit.enabled`, a production `vite build` compiles the generated
21
+ `smrt-register.ts` into `.smrt/runtime/register.js` after the server build
22
+ (`src/vite-plugin/worker-registration.ts`, called once from `closeBundle`).
23
+ adapter-node re-bundles the server from its own entries, so the web bundle is
24
+ never importable by a separate Node process; this artifact is how a deployed
25
+ task/schedule worker registers the app's local objects (and, through the
26
+ `.smrt/register.js` it imports, consumed packages' objects) with the same
27
+ qualified identities and isolated manifests as the web server. It is a nested
28
+ SSR build with no consumer plugins: only aliases pointing into the project
29
+ sources (e.g. `$lib`) apply, and every other bare import stays external so the
30
+ worker shares the installed `@happyvertical/smrt-core` registry. SvelteKit
31
+ virtual modules (`$env/*`, `$app/*`) are not available to a worker; an object
32
+ importing one fails at worker startup. A failure fails the build.
33
+
18
34
  ## Lightweight discovery
19
35
 
20
36
  `src/knowledge-discovery.ts`, exported through `smrt-core/knowledge`, enumerates
@@ -94,10 +94,101 @@ through a resolver hook on the leaf module (so `change-feed.ts` never imports
94
94
  the registry), sees the exact name being recorded with no derivation to keep in
95
95
  sync, and declares it — closing the read path and signal bus for that table.
96
96
 
97
- Scope note: the generated `_changes`/`_events` routes still gate on an
98
- authenticated principal only — there is no per-table permission check, and
99
- tenant scoping (`getTenantScopedChangesSince`, fail-closed) remains the sole
100
- row-level filter for ordinary tables.
97
+ Scope note: the generated `_changes`/`_events` routes — both the SvelteKit
98
+ routes and the runtime REST generator's — authorize on an authenticated
99
+ principal plus tenant scoping (`getTenantScopedChangesSince`, fail-closed) by
100
+ default. A consumer-supplied table/row authorization seam beyond that —
101
+ `change-feed-authz.ts` (#3020, below) — is optional and off until registered.
102
+
103
+ ## Consumer table/row authorization seam (#3020)
104
+
105
+ Tenant scoping keeps one tenant from seeing another's rows, but it cannot
106
+ express "table X is readable by some principals of the tenant but not others"
107
+ or "principal P only sees their own rows of table X" (e.g. a bay-tablet
108
+ station versus the back office). `?tables=` on `_changes` was always a
109
+ client-chosen filter, never enforcement — a table the client did not ask for
110
+ was withheld only because it did not ask, not because it could not have.
111
+
112
+ `src/change-feed-authz.ts` closes that gap with two independent, optional
113
+ hooks, dependency-inverted onto `globalThis` exactly like
114
+ `resolveDispatchTenantScope` (`dispatch/tenant-resolver.ts`), so a consumer
115
+ registers them once with no change to the generated "DO NOT EDIT" route files.
116
+ Both hooks are evaluated identically by **every** transport — the generated
117
+ SvelteKit `_changes`/`_events` routes AND the runtime REST generator's
118
+ `_changes`/`_events` (`generators/changes-route.ts`, `generators/events-route.ts`)
119
+ — so a consumer serving both cannot register a hook that protects one and
120
+ leaks through the other:
121
+
122
+ - `setChangeFeedAuthorizer(authorizeChangeFeed)` — table-level.
123
+ `authorizeChangeFeed({ locals, request, tables }) => allowedTables` returns
124
+ the tables the requester may read from the feed at all; the result is
125
+ INTERSECTED with the client's `?tables=` filter (never unioned), so a table
126
+ the hook does not name is never queried, let alone returned.
127
+ - `setChangeFeedEntryVisibility(isChangeFeedEntryVisible)` — row-level, for
128
+ scope narrower than a whole table. Applied per entry, after the table
129
+ filter, before anything is serialized — including a live `_events` signal.
130
+
131
+ `locals` is SvelteKit's `event.locals`; `request` is SvelteKit's
132
+ `event.request` or, on REST, the `authMiddleware`-processed `Request`. REST
133
+ has no `locals` concept, so REST routes pass `locals: undefined` and a
134
+ REST-hosting consumer identifies the principal from `request` instead
135
+ (whatever the `authMiddleware` attached — headers, a decoded token, etc.).
136
+ Every generated route supplies `request`.
137
+
138
+ Both hooks are optional and independent; unregistered, every transport
139
+ behaves exactly as before (tenant-scoped only). A hook that throws or returns
140
+ something other than the documented shape fails closed to "authorizes
141
+ nothing" for that call — same posture as a throwing `resolveDispatchTenantScope`
142
+ resolver, and the same 200-empty-page shape the sensitivity filter above
143
+ already gives a request naming only tables it may not see (never a 5xx, which
144
+ would tell a caller its own hook is broken — indistinguishable from "denied"
145
+ from the caller's side).
146
+
147
+ Every filter narrows the *returned* `changes`; nothing ever touches
148
+ `cursor`/`resyncRequired`/`resyncCursor` — mirrors the sensitivity filter's
149
+ "filters affect what is returned, never how the cursor advances" and for the
150
+ same reason: a client must advance past a denied entry without re-polling it
151
+ forever, and the entry's row id/timing must never be inferable from a stalled
152
+ cursor. Table denial sets `getChangesSince`'s explicit `denyAllTables: true`
153
+ option rather than passing an empty `tables` array, because `getChangesSince`
154
+ treats an omitted/empty `tables` as "no filter" and would otherwise widen the
155
+ read. `denyAllTables` skips the `table_name IN (...)` clause (and the query)
156
+ entirely — an earlier version instead passed a guaranteed-no-match sentinel
157
+ table name through that clause, which broke on PostgreSQL (it rejects a NUL
158
+ byte in a text parameter, turning a denied request into a 500 instead of the
159
+ required 200 empty page, #3020 P1 follow-up). `denyAllTables` still runs every
160
+ resync/pruned-cursor check unfiltered first, so the real horizon computation
161
+ runs and the cursor still advances — it just matches zero rows.
162
+ `change-feed-authz.ts`'s `isChangeFeedDenyAll()` is the one place that decides
163
+ "empty allow-list" (deny all) vs. "no hook registered" (no filter); both
164
+ `readAuthorized()` (pull side) and `buildChangeEventStream()`'s catch-up loop
165
+ (push side) call it rather than re-deriving the distinction. With NO hook
166
+ registered, `resolveAuthorizedChangeFeedTables()` normalizes an explicit empty
167
+ requested `tables: []` to `undefined` before that decision runs: `?tables=`
168
+ with nothing in it is documented as equivalent to an omitted filter, and
169
+ without a hook there is no authorized answer to narrow anything with — feeding
170
+ `[]` through unchanged would let `isChangeFeedDenyAll()` read it as "deny
171
+ every table" and silently break the promised unchanged default (#3020
172
+ follow-up). Once a hook IS registered, its own answer is what decides
173
+ deny-all, exactly as above.
174
+
175
+ A registered hook's returned table list is also validated for USABLE
176
+ strings, not just string-typed ones: any entry that is empty or contains a
177
+ NUL byte fails the whole call closed to `[]` (deny all), the same posture as
178
+ a non-array or non-string result. This closes the same NUL-byte hazard the
179
+ `denyAllTables` sentinel removal above fixed for a *synthesized* value — here
180
+ the NUL byte would come from the hook's OWN answer, which otherwise flows
181
+ straight into the ordinary `table_name IN (...)` clause and 500s on
182
+ PostgreSQL (#3020 Copilot follow-up).
183
+
184
+ `getAuthorizedChangesSince()` / `getAuthorizedTenantScopedChangesSince()` wrap
185
+ `getChangesSince()` / `getTenantScopedChangesSince()` with both hooks applied;
186
+ BOTH `_changes` routes (SvelteKit's `vite-plugin/changes-route.ts` and REST's
187
+ `generators/changes-route.ts` `handleChangesRoute`) call the tenant-scoped
188
+ variant, and `buildChangeEventStream()` (`change-signals.md`) — shared by
189
+ BOTH `_events` routes — applies the same hooks to catch-up replay and live
190
+ signal delivery, resolving the table allow-list ONCE at connection open
191
+ (mirrors captured tenant scope).
101
192
 
102
193
  ## Compatible bulk mutations (#2818)
103
194
 
@@ -10,5 +10,10 @@ The push companion to the change feed (`src/change-signals.ts` + the generated `
10
10
 
11
11
  - **Change-signal bus** (`src/change-signals.ts`): every framework save/delete that appends a durable feed row also publishes a coarse `ChangeSignal` `{ table, operation, rowId, tenantId, seq }` — **never a row payload** (authorization stays on the read path). Structurally mirrors the collection cache's notify/listen path. `subscribeToChangeSignals(db, listener) → unsubscribe`; `publishChangeSignal`/`broadcastChangeSignal`/the listener loop stay internal. `appendChange` now returns the allocated `seq` (was `void`); the signal carries it as a coarse resume cursor (`bumpChangeFeed` ignores the return). The publish runs only after the append SUCCEEDS (no signal without a durable feed row) and in its own log-and-swallow try/catch, so a signal problem never fails the user's write. `_smrt_*` writes never signal (the writer skips them). Delivery is synchronous per-listener with per-listener try/catch (one throwing SSE controller never blocks others); no per-subscriber queue — backpressure rides the platform `ReadableStream`.
12
12
  - **In-process + cross-replica**: locally-published and peer-received signals go through the SAME `deliverLocally` path. Cross-replica fan-out rides the db adapter's optional notification capability (`db.notifications`, a NEW `smrt_change_signals` channel distinct from the cache channel) with echo-avoidance by `PROCESS_ID`. No capability → in-process only, warn-once, **never an error, never blocks the write** (subscribers on other replicas fall back to cursor polling).
13
- - **Generated `_events` SSE route**: REST (`GET {basePath}/_events`, requires `authMiddleware`, otherwise 401 — fail-closed, per-model `api.public` does NOT apply; 405 non-GET; 503 no db or subscriber capacity reached) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`; cap via `sveltekit.eventsRoute.maxSubscribers`, where `0` means unlimited). Tenant scope is captured ONCE at connection open (`resolveDispatchTenantScope`) and filtered server-side per signal via `signalVisibleToTenant` (same rule as `getChangesSince`'s tenant filter) before any byte hits the wire — delivery runs outside any tenant ALS context, so it must use the captured value. The stream lifecycle lives in `buildChangeEventStream(db, { cursor, tenantScope, heartbeatMs?, manifestHash? })` (exported; the SvelteKit route imports it so it stays thin): subscribe-before-catch-up (closes the gap window; overlap is deduped by the SSE `id:`/seq client-side), `retry: 3000`, optional connection-open `event: manifest` carrying `{ manifestHash }` for live contract detection, cursor catch-up via `getChangesSince` filtered by the CAPTURED scope (not re-resolved from ALS, so it matches the live-signal filter exactly and can't replay another tenant's rows) (`Last-Event-ID` header beats `?since=`; default = live-forward only; `resyncRequired` → `event: resync`), heartbeat (`DEFAULT_EVENTS_HEARTBEAT_MS` = 15s), and `cancel()` teardown (clears heartbeat + unsubscribes). SSE change frame: `id: <seq>\nevent: change\ndata: {table,operation,rowId,tenantId}\n\n` — seq is ONLY in the `id:` line, never the data JSON. Subscriber cap default is `DEFAULT_EVENTS_MAX_SUBSCRIBERS` = 1000; over-cap connections return retryable 503 + `Retry-After`, and existing subscribers are unaffected. **Cross-origin is opt-in and fail-closed (#1861)**: same-origin only by default. REST wraps `_events` in its CORS layer — set `enableCors` + an explicit `allowedOrigins` allowlist + `allowCredentials: true` and an allow-listed browser can subscribe with a credentialed `EventSource` (`withCredentials: true`); the response echoes the specific `Origin` (never `*`) plus `Access-Control-Allow-Credentials: true`. SvelteKit mirrors this via `sveltekit.eventsRoute.allowedOrigins` + `allowCredentials` (the generated route bakes the allowlist into a `Set`, echoes only a member origin, and answers a credentialed `OPTIONS` preflight). CORS never authorizes — the fail-closed auth guard and captured tenant scope are unchanged, so the read posture holds identically across origins; it only lets an allow-listed browser's cookies reach the guard. Client disconnect through the Node `createServer` bridge now cancels the response reader (was a teardown leak) so `cancel()` fires and the subscription is released.
13
+ - **Generated `_events` SSE route**: REST (`GET {basePath}/_events`, requires `authMiddleware`, otherwise 401 — fail-closed, per-model `api.public` does NOT apply; 405 non-GET; 503 no db or subscriber capacity reached) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`; cap via `sveltekit.eventsRoute.maxSubscribers`, where `0` means unlimited). Tenant scope is captured ONCE at connection open (`resolveDispatchTenantScope`) and filtered server-side per signal via `signalVisibleToTenant` (same rule as `getChangesSince`'s tenant filter) before any byte hits the wire — delivery runs outside any tenant ALS context, so it must use the captured value. The stream lifecycle lives in `buildChangeEventStream(db, { cursor, tenantScope, heartbeatMs?, manifestHash?, locals? })` (exported; the SvelteKit route imports it so it stays thin): subscribe-before-catch-up (closes the gap window; overlap is deduped by the SSE `id:`/seq client-side), `retry: 3000`, optional connection-open `event: manifest` carrying `{ manifestHash }` for live contract detection, cursor catch-up via `getChangesSince` filtered by the CAPTURED scope (not re-resolved from ALS, so it matches the live-signal filter exactly and can't replay another tenant's rows) (`Last-Event-ID` header beats `?since=`; default = live-forward only; `resyncRequired` → `event: resync`), heartbeat (`DEFAULT_EVENTS_HEARTBEAT_MS` = 15s), and `cancel()` teardown (clears heartbeat + unsubscribes). SSE change frame: `id: <seq>\nevent: change\ndata: {table,operation,rowId,tenantId}\n\n` — seq is ONLY in the `id:` line, never the data JSON. Subscriber cap default is `DEFAULT_EVENTS_MAX_SUBSCRIBERS` = 1000; over-cap connections return retryable 503 + `Retry-After`, and existing subscribers are unaffected. **Cross-origin is opt-in and fail-closed (#1861)**: same-origin only by default. REST wraps `_events` in its CORS layer — set `enableCors` + an explicit `allowedOrigins` allowlist + `allowCredentials: true` and an allow-listed browser can subscribe with a credentialed `EventSource` (`withCredentials: true`); the response echoes the specific `Origin` (never `*`) plus `Access-Control-Allow-Credentials: true`. SvelteKit mirrors this via `sveltekit.eventsRoute.allowedOrigins` + `allowCredentials` (the generated route bakes the allowlist into a `Set`, echoes only a member origin, and answers a credentialed `OPTIONS` preflight). CORS never authorizes — the fail-closed auth guard and captured tenant scope are unchanged, so the read posture holds identically across origins; it only lets an allow-listed browser's cookies reach the guard. Client disconnect through the Node `createServer` bridge now cancels the response reader (was a teardown leak) so `cancel()` fires and the subscription is released.
14
+ - **Table/row authorization** (#3020, `change-feed-authz.ts`), evaluated on BOTH transports: the SvelteKit route forwards `{ locals, request }` and REST's `handleEventsRoute` forwards `{ locals: undefined, request: authResult }` (the `authMiddleware`-processed request — REST has no `locals`, so a hook a REST-hosting consumer registers identifies the principal from `request`) into `buildChangeEventStream`. If the consumer registered `authorizeChangeFeed`/`isChangeFeedEntryVisible`, `start()` resolves the allowed-tables set ONCE at connection open — same reasoning and same code shape as captured `tenantScope` — and applies both hooks to catch-up replay (via `getChangesSince`'s `tables` filter, passing `denyAllTables: true` — never a synthesized sentinel table name — when the resolved allow-list is empty; `isChangeFeedDenyAll()` makes that call the same way `change-feed-authz.ts`'s own `readAuthorized()` does; see `change-feed.md`'s #3020 section) and to every live signal before `controller.enqueue`. Table checks stay synchronous (a `Set.has`); the row-visibility check is async, so live delivery chains it through a per-connection `deliveryQueue` promise to keep order — but that chain is only ever allocated when a row hook is actually registered, so the unconfigured default keeps the pre-#3020 fully-synchronous per-listener delivery ("delivery is synchronous per-listener" above still holds when no row hook is registered). `start()` re-checks `closed` immediately after that authorization `await` and bails out without subscribing when the client disconnected while it was pending (#3020 P2) — `cancel()` may run `teardown()` before any subscription exists, and subscribing after `closed` is already `true` would register a `change-signals` listener no later `teardown()` call could ever remove (it short-circuits on `closed`), permanently inflating `changeSignalSubscriberCount`.
15
+
16
+ **Live-forward gap fill during the authorizer await** (#3020 follow-up): `unsubscribe` above is not set until AFTER the table-authorizer `await` resolves, so a write that commits (and signals) while a hook is pending has no listener to reach it. With an explicit `cursor`, this is harmless — the catch-up phase (below) starts from that pre-await cursor and replays anything committed in the gap. In live-forward-only mode (`cursor === null`) there is no catch-up phase at all, so that write would be lost forever, not merely delayed. When a table hook is registered AND `cursor === null`, `start()` therefore captures the feed's current head BEFORE the authorizer `await` (one `denyAllTables: true` `getChangesSince` bounds query — cheap, no row scan) as a gap-fill cursor; once the allow-list resolves, catch-up runs from that captured cursor exactly as it would for an explicit one, replaying only entries appended during the await (applying the same resolved table/row hooks) before the stream goes fully live. A supplied `cursor` needs no separate capture since its own catch-up already starts before the same await.
17
+
18
+ Subscribe still happens before the gap-fill catch-up query runs, so a write landing in that narrower window (between subscribe attaching and the catch-up read) can again be delivered twice — once live, once replayed — exactly like the ordinary subscribe-then-catch-up overlap above; the client dedupes both cases identically via the SSE `id:`/seq.
14
19
  - **Known gaps** (documented in the module): raw-SQL writes don't signal (same gap as the feed); live signals for caller-managed-transaction writes are best-effort (the append + signal fire pre-commit, so a rolled-back write may emit a signal and its freed seq is later reused) — the autocommit default path is exact, and clients reconcile via full catch-up/resync (inherits the change feed's transaction caveat).
@@ -0,0 +1,268 @@
1
+ # runOnce: shared idempotency seam (#3080)
2
+
3
+ `src/run-once.ts`. A DB/transaction-level primitive — it takes a
4
+ `DatabaseInterface`, not a `SmrtObject`/`SmrtCollection` — so it works outside
5
+ SvelteKit and outside the ORM. Any caller with a database handle and a
6
+ JSON-serializable work result can use it.
7
+
8
+ ## Why this exists, and why it is not the token alone
9
+
10
+ Every consumer with a form, a retried request, or a double-tapped button
11
+ needs the same guard, and a first hand-rolled attempt at it (reported against
12
+ this issue) keyed the claim on the per-form token alone. That silently
13
+ collapsed two genuinely different submissions that happened to reuse one
14
+ form's token — the exact bug this primitive exists to prevent everyone from
15
+ re-introducing.
16
+
17
+ ## Key derivation
18
+
19
+ ```
20
+ normalized = normalizeRunOnceContent(content) // one pass; FormData captured as a node
21
+ contentDigest = containsFormData(normalized)
22
+ ? sha256("\u0001run-once-formdata-v1\n" + stableStringify(toFormDataDomain(normalized)))
23
+ : sha256(stableStringify(normalized)) // plain JSON: unchanged since #3080
24
+ claimKey = sha256(stableStringify([tenantId, actor, token, contentDigest]))
25
+ ```
26
+
27
+ `stableStringify` (`../src/knowledge-graph.ts`) is sorted-key JSON: property
28
+ insertion order never changes the digest. Content is normalized first, following
29
+ JSON serialization exactly: `toJSON(key)` is called with the property name or
30
+ array index (a `Date` digests as its ISO string, not `{}`), and `undefined`,
31
+ function and symbol values are omitted from objects and written as `null` in
32
+ arrays. A `FormData` has no enumerable properties, so
33
+ JSON would reduce every form to `{}`; it digests as its ordered entry list
34
+ instead (repeated fields are significant; a `File` digests by name, type and
35
+ size), hashed in a separate domain (the input is prefixed with a byte sorted-key
36
+ JSON never starts with, and user keys starting with NUL are escaped) so no plain
37
+ object can forge it and plain-JSON digests are unchanged. `Map`, `Set`,
38
+ `RegExp`, any other non-plain object without `toJSON()`, and `bigint` throw a
39
+ `TypeError` rather than silently hashing as `{}` (#3136). Folding in `tenantId`
40
+ and `actor`
41
+ means two tenants or two actors can never collide on the same claim even if a
42
+ token were somehow shared between them; folding in `contentDigest` means a
43
+ retry of the SAME submission (same token, same content) replays, while a
44
+ DIFFERENT submission through the same token (edited content before a
45
+ resubmit) runs as its own claim.
46
+
47
+ Neither the raw token nor the raw content is ever persisted — only their
48
+ digests — so `_smrt_run_once_claims` cannot leak submitted business data.
49
+
50
+ ## Placement: `smrt-core`, not `smrt-svelte`
51
+
52
+ The issue posed this as a judgement call. `runOnce()` lives in `smrt-core`
53
+ beside the other write primitives (`object.ts`, `collection.ts`) because:
54
+
55
+ - It is DB/transaction-level, not SvelteKit-specific — any caller with a
56
+ `DatabaseInterface` can use it, including CLI jobs, MCP tool handlers, and
57
+ non-web callers.
58
+ - The claim table must exist for every `smrt-core` consumer, not only ones
59
+ that also depend on `smrt-svelte`.
60
+
61
+ No SvelteKit form-token helper shipped alongside it. The token contract
62
+ (mint a random, unguessable value once per rendered form; carry it as a
63
+ hidden field or equivalent; pass it straight through to `runOnce()`) is a few
64
+ lines any consumer already owns as part of rendering its own form, and there
65
+ was no existing svelte-facing package this fit into without adding one. If a
66
+ second consumer needs the exact same minting/reading helper, add it to
67
+ `smrt-svelte`'s forms module then — do not add a package for one caller.
68
+
69
+ ## Schema: a hand-DDL system table, not an `@smrt()` model
70
+
71
+ `_smrt_run_once_claims` is declared in `src/system/schema.ts`
72
+ (`CREATE_SMRT_RUN_ONCE_CLAIMS_TABLE`), added to `ALL_SYSTEM_TABLES`, and
73
+ created by `ensureSystemTables()` / `bootstrapSystemTables()`
74
+ (`src/system/bootstrap.ts`) — the same mechanism that creates
75
+ `_smrt_migrations` and `_smrt_contexts`. This is "Mechanism A" in the
76
+ `_smrt_` prefix classification ([schema-paths.md](schema-paths.md)), not
77
+ "Mechanism B" (an `@smrt()`-decorated model migrated via `db:migrate`, like
78
+ `_smrt_jobs`).
79
+
80
+ This was a deliberate choice, not the default: `runOnce()` must work for any
81
+ `smrt-core` consumer without that consumer registering a class and running a
82
+ migration for a table they've never heard of. A Mechanism-B table only exists
83
+ once an app imports and registers its owning class; a Mechanism-A table
84
+ exists for every `smrt-core` database unconditionally, which is what a
85
+ framework-wide primitive needs.
86
+
87
+ Adding the table required:
88
+
89
+ - A new DDL constant in `ALL_SYSTEM_TABLES`.
90
+ - Bumping `SMRT_SCHEMA_VERSION` (1.10.1 → 1.11.0) — `CREATE TABLE IF NOT
91
+ EXISTS` is a no-op on an existing database, so nothing upgrades without the
92
+ bump.
93
+ - A new `SMRT_SCHEMA_DDL_CHECKSUMS` entry — `system-schema-evolution.test.ts`
94
+ recomputes the DDL hash and fails the build if it does not match the
95
+ recorded value for the current version.
96
+
97
+ No new column was added to an existing system table, so no
98
+ `addColumnIfMissing()` entry in `system/compatibility.ts` was needed. Live
99
+ schema parity (`checkLiveSchemaParity`, `smrt doctor --db` /
100
+ `db:status --parity`) picks the table up automatically: system-table shape
101
+ parsing (`schema/system-table-shapes.ts`) derives its expectation from the
102
+ same DDL rather than a hand-maintained list.
103
+
104
+ ## Table shape
105
+
106
+ ```sql
107
+ CREATE TABLE IF NOT EXISTS _smrt_run_once_claims (
108
+ claim_key TEXT PRIMARY KEY, -- the derived key above
109
+ tenant_id TEXT NOT NULL, -- redundant with claim_key; kept for admin queries and a defense-in-depth tenant filter on lookup
110
+ actor TEXT NOT NULL, -- same
111
+ content_digest TEXT NOT NULL, -- same, plus lets an operator distinguish "same token, different content" incidents
112
+ status TEXT NOT NULL DEFAULT 'in_progress', -- 'in_progress' | 'completed'
113
+ result TEXT, -- JSON-serialized work() return value, set only when status = 'completed'
114
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
115
+ completed_at TIMESTAMP
116
+ );
117
+ ```
118
+
119
+ `claim_key` is the PRIMARY KEY — the "insert-only against a UNIQUE column"
120
+ the issue asked for. A second `INSERT` for the same key never adopts the
121
+ existing row.
122
+
123
+ **Why the claim insert is raw SQL (`INSERT ... ON CONFLICT (claim_key) DO
124
+ NOTHING RETURNING claim_key`), not a plain `INSERT` caught for a classified
125
+ `unique_violation`:** on PostgreSQL, a statement that raises an error aborts
126
+ the WHOLE transaction ("current transaction is aborted, commands ignored
127
+ until end of transaction block") — every later statement in that same
128
+ transaction fails too, including the `SELECT` `runOnce()` needs to read the
129
+ winner's stored result back. A first version of this primitive did exactly
130
+ that (plain `INSERT` + catch classified via `isUniqueViolationError`,
131
+ `../src/db-errors.ts`) and every replay/double-submit on PostgreSQL broke:
132
+ review caught it before merge. `DO NOTHING` never raises — it reports zero
133
+ rows via `RETURNING` instead — so the transaction's error state is never
134
+ touched. SQLite and DuckDB support the identical `ON CONFLICT ... DO
135
+ NOTHING` / `RETURNING` syntax (already used elsewhere in this codebase for
136
+ `_smrt_migrations`, `system/bootstrap.ts`), so one query is portable across
137
+ every adapter without engine branching.
138
+
139
+ ## Atomicity and rollback
140
+
141
+ `runOnce()` opens `db.transaction()` (or nests under a caller-supplied
142
+ transaction, per `@happyvertical/sql`'s own SAVEPOINT /
143
+ `NestedTransactionError` rules) and, inside it: inserts the claim
144
+ (`in_progress`), runs `work(tx)` with that SAME transaction-bound handle, then
145
+ updates the claim to `completed` with the JSON-serialized result — all one
146
+ transaction.
147
+
148
+ If `work()` throws, the callback throws, and `db.transaction()`'s own
149
+ commit-on-success/rollback-on-error contract undoes the claim insert along
150
+ with whatever `work()` partially wrote. A claim is never stranded
151
+ `in_progress` by a failed attempt; a retry with the same key can always run
152
+ again. No compensating delete is needed — this falls out of using one
153
+ transaction for the whole claim+work+completion sequence, not a special case
154
+ in `runOnce()`'s own code.
155
+
156
+ ## Concurrency
157
+
158
+ `@happyvertical/sql`'s single-connection adapters (SQLite, DuckDB, JSON)
159
+ serialize `transaction()` calls on one connection: a second, concurrent
160
+ `runOnce()` call does not begin its own transaction until the first ends.
161
+ PostgreSQL pools, but the claim insert's arbiter still has to check the
162
+ unique index against another transaction's uncommitted row of the same
163
+ PRIMARY KEY, and blocks until that transaction resolves, then either no-ops
164
+ (committed — `DO NOTHING` fires, zero rows) or inserts (rolled back). On
165
+ every adapter, two concurrent callers with the same key never both run
166
+ `work()`, and the loser never sees a PostgreSQL error for it — only zero
167
+ rows back, at which point it reads the now-committed, now-`completed` claim
168
+ row and replays its stored result.
169
+
170
+ This is also why the "in flight" and "unknown outcome" branches below are
171
+ normally unreachable through `runOnce()`-to-`runOnce()` races alone on a
172
+ single connection — the losing transaction can only ever observe a
173
+ COMMITTED row, and this module only ever commits a row once it is
174
+ `completed`. They are kept as defined answers (not assumptions) for a claim
175
+ row that reached that state some other way, and are exercised in
176
+ `src/__tests__/issue-3080-run-once.test.ts` by seeding the table directly and
177
+ by unit-testing the pure resolver (`resolveExistingRunOnceClaim`).
178
+
179
+ ## Typed answers: refused vs. unknown outcome (mirrors #2990)
180
+
181
+ A conflict on insert is a *definite* decision — the row exists — but whether
182
+ it is safe to hand back a result depends on `status`:
183
+
184
+ | Row state after conflict | Result |
185
+ |---|---|
186
+ | `status: 'completed'` | Replay the stored `result`. No error. |
187
+ | exists, not completed | `RunOnceClaimError.inFlight()` — `code: 'RUN_ONCE_IN_FLIGHT'`. Some execution holds this key right now; retry later with the SAME token and content. |
188
+ | conflict reported, no row readable | `RunOnceClaimError.outcomeUnknown()` — `code: 'RUN_ONCE_OUTCOME_UNKNOWN'`. Genuinely indeterminate; retrying with the SAME token and content is still safe. |
189
+
190
+ Both error codes live on one `RunOnceClaimError` class (`src/errors.ts`),
191
+ following this codebase's existing error-hierarchy convention (one class,
192
+ multiple codes via static factories — see `TenantIsolationError`,
193
+ `DatabaseError`) rather than a bespoke class hierarchy. Neither is retried
194
+ automatically by `runOnce()` itself: only the caller knows whether retrying
195
+ inline is appropriate for its own request.
196
+
197
+ ## Retention: completed claims are pruned; the window is a replay deadline
198
+
199
+ `_smrt_run_once_claims` is append-only in normal operation — every submission
200
+ adds a row and nothing else ever deletes one — so, like `_smrt_changes`,
201
+ `_smrt_ai_usage`, and `_smrt_dispatch`, it is wired into
202
+ `system/retention.ts`'s `runRetentionSweep()` as a built-in task
203
+ (`'run-once-claims'`), covered by `DEFAULT_RETENTION_POLICY.runOnceClaims`
204
+ (default `{ maxAgeDays: 30 }`, overridable per policy or disabled with
205
+ `runOnceClaims: false`). `pruneRunOnceClaims()` deletes only rows with
206
+ `status = 'completed'` AND `completed_at` older than the window; an
207
+ `'in_progress'` row has no `completed_at` and can never match this predicate,
208
+ so a claim held by a live (or abandoned) transaction is never pruned by age
209
+ alone. The predicate is covered by
210
+ `idx_smrt_run_once_claims_status_completed` (`status, completed_at`), added
211
+ in `SMRT_SCHEMA_VERSION` 1.11.1.
212
+
213
+ **The window is a replay deadline, not just disk hygiene.** `runOnce()`'s
214
+ entire contract — a retry or double-submit with the SAME token and content
215
+ replays the first attempt's stored result instead of running `work()` again
216
+ — depends on the claim row still existing. Once retention sweeps a completed
217
+ claim, a client that replays that exact request (same `tenantId`, `actor`,
218
+ `token`, and content) no longer collides with anything: `runOnce()` sees no
219
+ existing row, wins the insert, and runs `work()` a second time. This is
220
+ silent — there is no error, just a second execution of whatever `work()`
221
+ does. The default 30-day window is chosen to be comfortably longer than any
222
+ realistic client retry window (a browser tab left open, a queued mobile
223
+ request, a webhook redelivery schedule) for the same reason the other
224
+ default windows in `DEFAULT_RETENTION_POLICY` are generous relative to their
225
+ consumers (see `system/retention.ts`'s module doc). A consumer with a
226
+ longer-lived retry/replay window (e.g. an at-least-once delivery queue with a
227
+ multi-week redelivery tail) must raise `runOnceClaims.maxAgeDays` to exceed
228
+ it, not rely on the default.
229
+
230
+ Tests: `src/__tests__/issue-2375-retention.test.ts` covers
231
+ `pruneRunOnceClaims()` end-to-end through `runRetentionSweep()` — an old
232
+ completed claim is removed, a recent completed claim and an old
233
+ `in_progress` claim both survive.
234
+
235
+ ## Tests
236
+
237
+ `src/__tests__/issue-3080-run-once.test.ts` (real in-memory SQLite via
238
+ `getTestDatabase({ classes: [] })` — no `@smrt()` model is needed, only the
239
+ system table):
240
+
241
+ - Runs work once; returns its result.
242
+ - Retry after completion replays the stored result without re-running.
243
+ - Concurrent double submit (`Promise.all`) produces one claim row and the
244
+ same result for both callers.
245
+ - Same token, different content → two separate claims (guards the
246
+ token-alone regression this issue was filed against).
247
+ - Same token/content, different actor → separate claim.
248
+ - Same token/content, different tenant → separate claim.
249
+ - Work throws → claim rolls back; a subsequent retry with the same key runs.
250
+ - A claim seeded directly as `in_progress` → `RunOnceClaimError.inFlight()`,
251
+ `work()` never called.
252
+ - Content-digest stability (key order) and sensitivity (value change) are
253
+ unit-tested directly.
254
+ - `resolveExistingRunOnceClaim` (pure resolver) is unit-tested for all three
255
+ branches, including the otherwise-unreachable `outcomeUnknown` case.
256
+
257
+ `src/__tests__/issue-3080-run-once-postgres.optional.test.ts` (real
258
+ PostgreSQL, `SMRT_TEST_POSTGRES_URL`, `describe.skipIf`, following
259
+ `change-feed-concurrency.optional.test.ts`'s established pattern: distinct
260
+ `dbid`s for two genuinely concurrent connections, `getTestDatabase({ db,
261
+ classes: [] })` to bootstrap system tables): replay after completion, and
262
+ concurrent double submit across the two real connections — both would
263
+ reject with a PostgreSQL `DatabaseError` ("Failed to retrieve record from
264
+ table", the driver's wrapping of 25P02 "current transaction is aborted")
265
+ against the pre-fix plain-`INSERT`-and-catch version; verified locally
266
+ against a disposable `postgres:18-alpine` container (revert via patch file,
267
+ confirmed both tests red, restored, confirmed both green) since the SQLite
268
+ suite cannot reach this PostgreSQL-specific failure mode at all.
@@ -737,15 +737,19 @@ the component is uniformly UUID and the planner emits nothing.
737
737
 
738
738
  ### Retention (`src/system/retention.ts`)
739
739
 
740
- `runRetentionSweep(db, policy)` runs four built-ins in fixed order, then
740
+ `runRetentionSweep(db, policy)` runs five built-ins in fixed order (`changes`,
741
+ `ai-usage`, `contexts`, `dispatch`, `run-once-claims`), then
741
742
  `registerRetentionTask()` contributions. A failed task records its result and
742
743
  continues; a missing table is `unavailable`. The `globalThis` registry avoids
743
744
  split registrations under duplicate core resolution; package tasks exist only
744
745
  after importing the package. CLI prune optionally imports jobs/users.
745
746
 
746
747
  Defaults are opt-out: changes 30 days, AI usage 90 days, completed dispatch 30
747
- days/failed dispatch 90 days, contexts by `expires_at`. `smrt.configure({
748
- retention })` tunes built-ins; contributed tasks own their defaults/options.
748
+ days/failed dispatch 90 days, contexts by `expires_at`, run-once claims 30
749
+ days on `completed_at` (#3080; `in_progress` rows never match — the window is
750
+ a replay deadline for `runOnce()`, not just disk hygiene, see
751
+ `agents/run-once.md`). `smrt.configure({ retention })` tunes built-ins;
752
+ contributed tasks own their defaults/options.
749
753
  Jobs defaults are 7 days terminal, 30 failed, 30 events via
750
754
  `registerJobRetentionTasks()` or runner `retention.jobs`; expired credentials
751
755
  have no extra window. Disable a table/task with `false` or the whole policy with
@@ -760,8 +764,9 @@ approximate under concurrency. Overlapping change/AI-usage bounds exclude rows
760
764
  already counted, including dry runs.
761
765
 
762
766
  Retention indexes belong in system DDL and its versioned replay:
763
- `_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`, and dispatch
764
- `(status, processed_at)` / `(status, updated_at)`. Jobs `(status, completed_at)`
767
+ `_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`, dispatch
768
+ `(status, processed_at)` / `(status, updated_at)`, and
769
+ `_smrt_run_once_claims(status, completed_at)`. Jobs `(status, completed_at)`
765
770
  belongs in `ensureJobsSystemTableCompatibility()` on each collection initialize,
766
771
  since decorated jobs tables do not exist at bootstrap.
767
772
 
package/dist/browser.js CHANGED
@@ -4,7 +4,8 @@ import { MetricsAdapter } from "./adapters/metrics.js";
4
4
  import { PubSubAdapter } from "./adapters/pubsub.js";
5
5
  import "./adapters/index.js";
6
6
  import { classifyDatabaseError, classifyDialectMessage, isAbortedTransactionError, isDeterministicDatabaseError, isNotNullViolationError, isTransientDatabaseError, isUniqueViolationError } from "./db-errors.js";
7
- import { AIError, ConfigurationError, DatabaseError, ErrorUtils, FilesystemError, NetworkError, RuntimeError, SmrtError, TenantIsolationError, ValidationError, ValidationReport, ValidationUtils } from "./errors.js";
7
+ import { AIError, ConfigurationError, DatabaseError, ErrorUtils, FilesystemError, NetworkError, RunOnceClaimError, RuntimeError, SmrtError, TenantIsolationError, ValidationError, ValidationReport, ValidationUtils } from "./errors.js";
8
+ import { SMRT_SCHEMA_VERSION } from "./system/schema.js";
8
9
  import { config } from "./config.js";
9
10
  import { SignalSanitizer } from "./signals/sanitizer.js";
10
11
  import { SignalBus } from "./signals/bus.js";
@@ -24,7 +25,7 @@ import { SmrtPolymorphicAssociation } from "./polymorphic-association.js";
24
25
  import "./signals/index.js";
25
26
  import { DIAGNOSTICS_DEFAULT_LIMIT, SYSTEM_DIAGNOSTICS_TABLES, readDispatchHealth, readJobHealth, readMigrationStatus, readRecentChanges, readRegistryDrift, readScheduleHealth, readSystemDiagnostics } from "./system/diagnostics.js";
26
27
  import { BOOTED_PROVENANCE, assertPlainJson, sanitizeMessagePaths, snapshotRegistry } from "./system/registry-snapshot.js";
27
- import { DEFAULT_RETENTION_POLICY, clearRetentionTasks, getRetentionTasks, pruneAiUsage, pruneExpiredContexts, registerRetentionTask, runRetentionSweep, unregisterRetentionTask } from "./system/retention.js";
28
+ import { DEFAULT_RETENTION_POLICY, clearRetentionTasks, getRetentionTasks, pruneAiUsage, pruneExpiredContexts, pruneRunOnceClaims, registerRetentionTask, runRetentionSweep, unregisterRetentionTask } from "./system/retention.js";
28
29
  import "./system/index.js";
29
30
  import "./tools/index.js";
30
- export { AIError, AiUsageCollector, AiUsagePersistenceHandler, BOOTED_PROVENANCE, ConfigurationError, DEFAULT_AI_COST_RATES, DEFAULT_FACET_LIMIT, DEFAULT_RETENTION_POLICY, DIAGNOSTICS_DEFAULT_LIMIT, DatabaseError, EmbeddingUnavailableError, ErrorUtils, FilesystemError, MAX_FACET_FIELDS, MAX_FACET_LIMIT, MAX_STI_READ_SCOPE_TYPES, MetricsAdapter, NetworkError, ObjectRegistry, PubSubAdapter, RuntimeError, SMRT_COLLECTION_BASE_NAMES, SYSTEM_DIAGNOSTICS_TABLES, SignalBus, SignalSanitizer, SmrtClass, SmrtCollection, SmrtError, SmrtHierarchical, SmrtJunction, SmrtObject, SmrtPolymorphicAssociation, TenantIsolationError, ValidationError, ValidationReport, ValidationUtils, assertPlainJson, assertPostgresSystemTimestampsCurrent, classifyDatabaseError, classifyDialectMessage, clearRetentionTasks, config, convertTypeToJsonSchema, ensureBootstrapSystemTableCompatibility, ensureDeferredSystemTableCompatibility, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, ensureSystemTables, estimateAiUsageCost, executeToolCall, executeToolCalls, formatToolResults, generateToolFromMethod, generateToolManifest, getDatabaseEngine, getRetentionTasks, isAbortedTransactionError, isDeterministicDatabaseError, isFrameworkBaseClass, isNotNullViolationError, isSmrtCollectionExtendsName, isTransientDatabaseError, isUniqueViolationError, migratePostgresSystemTimestamps, planPostgresSystemTimestampMigrations, pruneAiUsage, pruneExpiredContexts, readDispatchHealth, readJobHealth, readMigrationStatus, readRecentChanges, readRegistryDrift, readScheduleHealth, readSystemDiagnostics, registerRetentionTask, runRetentionSweep, sanitizeMessagePaths, shouldIncludeMethod, smrt, smrt as smrtRegistry, snapshotRegistry, tableExists, unregisterRetentionTask, validateToolCall };
31
+ export { AIError, AiUsageCollector, AiUsagePersistenceHandler, BOOTED_PROVENANCE, ConfigurationError, DEFAULT_AI_COST_RATES, DEFAULT_FACET_LIMIT, DEFAULT_RETENTION_POLICY, DIAGNOSTICS_DEFAULT_LIMIT, DatabaseError, EmbeddingUnavailableError, ErrorUtils, FilesystemError, MAX_FACET_FIELDS, MAX_FACET_LIMIT, MAX_STI_READ_SCOPE_TYPES, MetricsAdapter, NetworkError, ObjectRegistry, PubSubAdapter, RunOnceClaimError, RuntimeError, SMRT_COLLECTION_BASE_NAMES, SMRT_SCHEMA_VERSION, SYSTEM_DIAGNOSTICS_TABLES, SignalBus, SignalSanitizer, SmrtClass, SmrtCollection, SmrtError, SmrtHierarchical, SmrtJunction, SmrtObject, SmrtPolymorphicAssociation, TenantIsolationError, ValidationError, ValidationReport, ValidationUtils, assertPlainJson, assertPostgresSystemTimestampsCurrent, classifyDatabaseError, classifyDialectMessage, clearRetentionTasks, config, convertTypeToJsonSchema, ensureBootstrapSystemTableCompatibility, ensureDeferredSystemTableCompatibility, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, ensureSystemTables, estimateAiUsageCost, executeToolCall, executeToolCalls, formatToolResults, generateToolFromMethod, generateToolManifest, getDatabaseEngine, getRetentionTasks, isAbortedTransactionError, isDeterministicDatabaseError, isFrameworkBaseClass, isNotNullViolationError, isSmrtCollectionExtendsName, isTransientDatabaseError, isUniqueViolationError, migratePostgresSystemTimestamps, planPostgresSystemTimestampMigrations, pruneAiUsage, pruneExpiredContexts, pruneRunOnceClaims, readDispatchHealth, readJobHealth, readMigrationStatus, readRecentChanges, readRegistryDrift, readScheduleHealth, readSystemDiagnostics, registerRetentionTask, runRetentionSweep, sanitizeMessagePaths, shouldIncludeMethod, smrt, smrt as smrtRegistry, snapshotRegistry, tableExists, unregisterRetentionTask, validateToolCall };