@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.
- package/AGENTS.md +20 -0
- package/README.md +47 -0
- package/agents/build-knowledge.md +16 -0
- package/agents/change-feed.md +95 -4
- package/agents/change-signals.md +6 -1
- package/agents/run-once.md +268 -0
- package/agents/schema-paths.md +10 -5
- package/dist/browser.js +4 -3
- package/dist/change-feed-authz.d.ts +141 -0
- package/dist/change-feed-authz.d.ts.map +1 -0
- package/dist/change-feed-authz.js +260 -0
- package/dist/change-feed-authz.js.map +1 -0
- package/dist/change-feed.d.ts +15 -0
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +4 -0
- package/dist/change-feed.js.map +1 -1
- package/dist/errors.d.ts +28 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +36 -1
- package/dist/errors.js.map +1 -1
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +13 -3
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/events-route.d.ts +35 -4
- package/dist/generators/events-route.d.ts.map +1 -1
- package/dist/generators/events-route.js +78 -14
- package/dist/generators/events-route.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/knowledge-graph.d.ts.map +1 -1
- package/dist/knowledge-graph.js +1 -1
- package/dist/knowledge-graph.js.map +1 -1
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/run-once.d.ts +80 -0
- package/dist/run-once.d.ts.map +1 -0
- package/dist/run-once.js +311 -0
- package/dist/run-once.js.map +1 -0
- package/dist/smrt-knowledge.json +20 -14
- package/dist/system/index.d.ts +1 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +3 -2
- package/dist/system/retention.d.ts +53 -4
- package/dist/system/retention.d.ts.map +1 -1
- package/dist/system/retention.js +67 -14
- package/dist/system/retention.js.map +1 -1
- package/dist/system/schema.d.ts +33 -1
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +60 -3
- package/dist/system/schema.js.map +1 -1
- package/dist/vite-plugin/changes-route.d.ts.map +1 -1
- package/dist/vite-plugin/changes-route.js +21 -5
- package/dist/vite-plugin/changes-route.js.map +1 -1
- package/dist/vite-plugin/events-route.d.ts.map +1 -1
- package/dist/vite-plugin/events-route.js +9 -0
- package/dist/vite-plugin/events-route.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +14 -0
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/worker-registration.d.ts +34 -0
- package/dist/vite-plugin/worker-registration.d.ts.map +1 -0
- package/dist/vite-plugin/worker-registration.js +108 -0
- package/dist/vite-plugin/worker-registration.js.map +1 -0
- 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
|
package/agents/change-feed.md
CHANGED
|
@@ -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
|
|
98
|
-
|
|
99
|
-
tenant scoping (`getTenantScopedChangesSince`, fail-closed)
|
|
100
|
-
|
|
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
|
|
package/agents/change-signals.md
CHANGED
|
@@ -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.
|
package/agents/schema-paths.md
CHANGED
|
@@ -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
|
|
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
|
|
748
|
-
|
|
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)`,
|
|
764
|
-
`(status, processed_at)` / `(status, updated_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 };
|