@happyvertical/smrt-core 0.51.28 → 0.51.30
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 +19 -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/migrations/differ.d.ts +43 -1
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +69 -0
- package/dist/migrations/differ.js.map +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/schema/bounded-concurrency.d.ts +31 -0
- package/dist/schema/bounded-concurrency.d.ts.map +1 -0
- package/dist/schema/bounded-concurrency.js +43 -0
- package/dist/schema/bounded-concurrency.js.map +1 -0
- package/dist/schema/foreign-key-orphan-report.d.ts +21 -0
- package/dist/schema/foreign-key-orphan-report.d.ts.map +1 -1
- package/dist/schema/foreign-key-orphan-report.js +90 -55
- package/dist/schema/foreign-key-orphan-report.js.map +1 -1
- 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 +10 -10
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
import { RUN_ONCE_CLAIMS_TABLE } from './system/schema.js';
|
|
3
|
+
export { RUN_ONCE_CLAIMS_TABLE };
|
|
4
|
+
/** Input identifying one submission for {@link runOnce}. */
|
|
5
|
+
export interface RunOnceParams {
|
|
6
|
+
/** Transaction-capable database handle. May itself already be inside a transaction. */
|
|
7
|
+
db: DatabaseInterface;
|
|
8
|
+
/** Tenant scope. Part of the claim key: claims never cross tenants. */
|
|
9
|
+
tenantId: string;
|
|
10
|
+
/** The submitting principal. Part of the claim key: claims never cross actors. */
|
|
11
|
+
actor: string;
|
|
12
|
+
/** The per-form/per-submission token minted by the caller (e.g. a hidden form field). */
|
|
13
|
+
token: string;
|
|
14
|
+
/**
|
|
15
|
+
* The content being written: plain JSON data, or a `FormData` (digested by
|
|
16
|
+
* its ordered entries; see {@link digestRunOnceContent}). Only its
|
|
17
|
+
* digest is persisted — never the raw value.
|
|
18
|
+
*/
|
|
19
|
+
content: unknown;
|
|
20
|
+
}
|
|
21
|
+
interface RunOnceClaimRow {
|
|
22
|
+
claim_key: string;
|
|
23
|
+
status: string;
|
|
24
|
+
result: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Deterministic content digest: sha256 of sorted-key JSON.
|
|
28
|
+
*
|
|
29
|
+
* Property order never changes the digest, so callers do not need to worry
|
|
30
|
+
* about object construction order producing two different claims for what is
|
|
31
|
+
* semantically the same submission. Content is normalized exactly once (see
|
|
32
|
+
* {@link normalizeRunOnceContent}): `toJSON()` values such as `Date` digest as
|
|
33
|
+
* what they serialize to, a `FormData` digests as its ordered entries in a
|
|
34
|
+
* separate hash domain, and values JSON would reduce to `{}` (`Map`, `Set`,
|
|
35
|
+
* `RegExp`, …) are rejected.
|
|
36
|
+
*
|
|
37
|
+
* @throws {TypeError} when `content` contains a value with no faithful JSON form.
|
|
38
|
+
*/
|
|
39
|
+
export declare function digestRunOnceContent(content: unknown): string;
|
|
40
|
+
/**
|
|
41
|
+
* Derive the {@link runOnce} claim key.
|
|
42
|
+
*
|
|
43
|
+
* NOT the token alone — see the module doc for why that collapses distinct
|
|
44
|
+
* submissions. Folds in `tenantId`, `actor`, the raw `token`, and the
|
|
45
|
+
* content digest, all through sorted-key JSON before hashing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function deriveRunOnceClaimKey(params: {
|
|
48
|
+
tenantId: string;
|
|
49
|
+
actor: string;
|
|
50
|
+
token: string;
|
|
51
|
+
contentDigest: string;
|
|
52
|
+
}): string;
|
|
53
|
+
/**
|
|
54
|
+
* Resolve an existing claim row into either a replayable result or a typed
|
|
55
|
+
* error. Pulled out of {@link runOnce} as a pure function so both branches
|
|
56
|
+
* (`completed` vs. anything else vs. unreadable) are directly unit-testable
|
|
57
|
+
* without forcing a genuine database race.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resolveExistingRunOnceClaim<T>(existing: RunOnceClaimRow | null, claimKey: string): T;
|
|
60
|
+
/**
|
|
61
|
+
* Run `work` exactly once per submission.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* const purchaseOrderId = await runOnce(
|
|
65
|
+
* { db, tenantId, actor, token, content: formData },
|
|
66
|
+
* async (tx) => createPurchaseOrder(tx, formData),
|
|
67
|
+
* );
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* Resolves to `work`'s return value on both a fresh run and a replay of a
|
|
71
|
+
* prior completed run — the caller cannot tell the difference from the
|
|
72
|
+
* return value alone (by design: both are "your submission is durably
|
|
73
|
+
* applied"). Rejects with `RunOnceClaimError` (`./errors.ts`) when the claim
|
|
74
|
+
* cannot be resolved to a definite result right now; see the module doc for
|
|
75
|
+
* the `RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN` distinction. Any
|
|
76
|
+
* other rejection (including one from `work` itself) propagates unchanged,
|
|
77
|
+
* and the claim is rolled back with the transaction so a retry can run again.
|
|
78
|
+
*/
|
|
79
|
+
export declare function runOnce<T>(params: RunOnceParams, work: (tx: DatabaseInterface) => Promise<T>): Promise<T>;
|
|
80
|
+
//# sourceMappingURL=run-once.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-once.d.ts","sourceRoot":"","sources":["../src/run-once.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4GG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,OAAO,EAAE,qBAAqB,EAAE,CAAC;AAEjC,4DAA4D;AAC5D,MAAM,WAAW,aAAa;IAC5B,uFAAuF;IACvF,EAAE,EAAE,iBAAiB,CAAC;IACtB,uEAAuE;IACvE,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE,MAAM,CAAC;IACd,yFAAyF;IACzF,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,UAAU,eAAe;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AA0ID;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAS7D;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;CACvB,GAAG,MAAM,CAQT;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,EAC3C,QAAQ,EAAE,eAAe,GAAG,IAAI,EAChC,QAAQ,EAAE,MAAM,GACf,CAAC,CAQH;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,OAAO,CAAC,CAAC,EAC7B,MAAM,EAAE,aAAa,EACrB,IAAI,EAAE,CAAC,EAAE,EAAE,iBAAiB,KAAK,OAAO,CAAC,CAAC,CAAC,GAC1C,OAAO,CAAC,CAAC,CAAC,CAmDZ"}
|
package/dist/run-once.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { RunOnceClaimError } from "./errors.js";
|
|
2
|
+
import { RUN_ONCE_CLAIMS_TABLE } from "./system/schema.js";
|
|
3
|
+
import { stableStringify } from "./knowledge-graph.js";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
//#region src/run-once.ts
|
|
6
|
+
/**
|
|
7
|
+
* Idempotency seam: run an action exactly once per submission (#3080).
|
|
8
|
+
*
|
|
9
|
+
* Every consumer with a form, a retried request, or a double-tapped button
|
|
10
|
+
* needs the same guard: a claim row, insert-only against a UNIQUE column
|
|
11
|
+
* (`claim_key`) in the SAME transaction as the work it guards, so a retry
|
|
12
|
+
* either runs the work once or replays the first attempt's stored result.
|
|
13
|
+
* This module is that primitive. It is DB/transaction-level — it takes a
|
|
14
|
+
* `DatabaseInterface`, not a `SmrtObject`/`SmrtCollection` — so it works
|
|
15
|
+
* outside SvelteKit and outside the ORM: any caller with a database handle
|
|
16
|
+
* and a JSON-serializable work result can use it. The claim insert itself
|
|
17
|
+
* is raw SQL (`## Concurrent submissions` below explains why), not the
|
|
18
|
+
* ORM's `SmrtObject`-level `_insertOnly` option.
|
|
19
|
+
*
|
|
20
|
+
* ## Key derivation (not the token alone)
|
|
21
|
+
*
|
|
22
|
+
* The claim key is the sha256 hex digest of the sorted-key-JSON encoding of
|
|
23
|
+
* `[tenantId, actor, token, contentDigest]`, where `contentDigest` is itself
|
|
24
|
+
* the sha256 hex digest of the sorted-key-JSON encoding of `content`
|
|
25
|
+
* ({@link digestRunOnceContent}). Sorted-key JSON ({@link stableStringify},
|
|
26
|
+
* `./knowledge-graph.ts`) makes the digest stable regardless of property
|
|
27
|
+
* insertion order.
|
|
28
|
+
*
|
|
29
|
+
* The token alone is NOT the key. A first attempt at this exact problem
|
|
30
|
+
* (reported against #3080) keyed on the token alone and silently collapsed
|
|
31
|
+
* two genuinely different submissions that happened to reuse one form's
|
|
32
|
+
* token. Folding in the content digest means a retry of the SAME submission
|
|
33
|
+
* (same token, same content) replays; a DIFFERENT submission through the
|
|
34
|
+
* same token (edited content before a resubmit) runs as its own claim.
|
|
35
|
+
* Folding in `tenantId` and `actor` means two tenants or two actors can never
|
|
36
|
+
* collide on the same claim even if a token were somehow shared between them.
|
|
37
|
+
*
|
|
38
|
+
* Neither the raw token nor the raw content is ever persisted — only their
|
|
39
|
+
* digests — so the claims table (`_smrt_run_once_claims`,
|
|
40
|
+
* `./system/schema.ts`) cannot leak submitted business data.
|
|
41
|
+
*
|
|
42
|
+
* ## Atomicity
|
|
43
|
+
*
|
|
44
|
+
* `runOnce()` opens its own `db.transaction()` (or nests under the caller's,
|
|
45
|
+
* per `@happyvertical/sql`'s own SAVEPOINT/`NestedTransactionError` rules —
|
|
46
|
+
* see that package's `DatabaseInterface.transaction` doc) and, inside it:
|
|
47
|
+
*
|
|
48
|
+
* 1. Inserts the claim row (`status: 'in_progress'`).
|
|
49
|
+
* 2. Runs `work(tx)`, passing the SAME transaction-bound handle, so the
|
|
50
|
+
* caller's writes and the claim insert commit or roll back together.
|
|
51
|
+
* 3. Updates the claim row to `status: 'completed'` with the JSON-serialized
|
|
52
|
+
* result, still inside the same transaction.
|
|
53
|
+
*
|
|
54
|
+
* If `work()` throws, the callback throws, and `db.transaction()`'s own
|
|
55
|
+
* "commits on success, rolls back on error" contract undoes the claim insert
|
|
56
|
+
* along with whatever `work()` partially wrote — a claim is never stranded
|
|
57
|
+
* `'in_progress'` by a failed attempt, and a retry with the same key can
|
|
58
|
+
* always run again.
|
|
59
|
+
*
|
|
60
|
+
* ## Concurrent submissions
|
|
61
|
+
*
|
|
62
|
+
* The claim insert uses `INSERT ... ON CONFLICT (claim_key) DO NOTHING
|
|
63
|
+
* RETURNING claim_key` (the same portable idiom `_smrt_migrations` already
|
|
64
|
+
* uses in `system/bootstrap.ts`), not a plain `INSERT` caught for a
|
|
65
|
+
* classified `unique_violation`. This matters on PostgreSQL specifically: a
|
|
66
|
+
* statement that raises an error aborts the WHOLE transaction ("current
|
|
67
|
+
* transaction is aborted, commands ignored until end of transaction block"),
|
|
68
|
+
* so a plain `INSERT` failing on the claim key would make every subsequent
|
|
69
|
+
* statement in the same transaction — including the very `SELECT` this
|
|
70
|
+
* function needs to read the winner's stored result back — fail too.
|
|
71
|
+
* `DO NOTHING` never raises: it returns zero rows instead, which this
|
|
72
|
+
* function reads as "lost the race" without ever touching the transaction's
|
|
73
|
+
* error state. SQLite and DuckDB both support the same `ON CONFLICT ... DO
|
|
74
|
+
* NOTHING` / `RETURNING` syntax, so one query works on every adapter.
|
|
75
|
+
*
|
|
76
|
+
* `@happyvertical/sql`'s single-connection adapters (SQLite, DuckDB, JSON)
|
|
77
|
+
* serialize `transaction()` calls on one connection: a second, concurrent
|
|
78
|
+
* `runOnce()` call does not begin its own transaction until the first one
|
|
79
|
+
* ends. PostgreSQL pools, but this `INSERT`'s arbiter still has to check the
|
|
80
|
+
* unique index against another transaction's uncommitted row of the same
|
|
81
|
+
* PRIMARY KEY, and blocks until that transaction resolves, then either
|
|
82
|
+
* no-ops (committed — the row exists) or inserts (rolled back — no row).
|
|
83
|
+
* Either way, two concurrent callers with the same key never both run
|
|
84
|
+
* `work()`: exactly one does, and the loser's `INSERT` returns zero rows —
|
|
85
|
+
* never an error — at which point it reads the now-committed claim row back
|
|
86
|
+
* and replays its stored result.
|
|
87
|
+
*
|
|
88
|
+
* ## Typed answers for a claim that is not (yet) a definite result
|
|
89
|
+
*
|
|
90
|
+
* A `runOnce()` conflict is a *definite* decision — the row exists — but
|
|
91
|
+
* whether it is safe to hand back a result depends on `status`. This mirrors
|
|
92
|
+
* the "refused vs unknown outcome" distinction #2990 drew for AssistantDock
|
|
93
|
+
* actions (`./errors.ts#RunOnceClaimError` carries the two codes):
|
|
94
|
+
*
|
|
95
|
+
* - `status: 'completed'` — replay the stored `result`. No error.
|
|
96
|
+
* - `status` anything else (still `'in_progress'`) — some execution holds
|
|
97
|
+
* this key right now. `RunOnceClaimError.inFlight()`. Under this module's
|
|
98
|
+
* own transaction semantics this is normally unreachable through
|
|
99
|
+
* `runOnce()`-to-`runOnce()` races alone (the losing transaction can only
|
|
100
|
+
* ever observe a COMMITTED row, and this module only ever commits a row
|
|
101
|
+
* once it is `'completed'`) — it is reachable if a claim row was inserted
|
|
102
|
+
* outside this module's own completion discipline, and is kept as a
|
|
103
|
+
* defined answer rather than an assumption.
|
|
104
|
+
* - the row cannot be read back at all after `DO NOTHING` reported zero
|
|
105
|
+
* rows inserted — `RunOnceClaimError.outcomeUnknown()`. Also normally
|
|
106
|
+
* unreachable (the zero-rows case only happens when a committed row
|
|
107
|
+
* already exists); kept for adapters/drivers where that and a subsequent
|
|
108
|
+
* read are not guaranteed to observe the same committed state.
|
|
109
|
+
*
|
|
110
|
+
* Both errors carry `claimKey` and are safe to retry with the SAME token and
|
|
111
|
+
* content; `runOnce()` never retries them itself.
|
|
112
|
+
*
|
|
113
|
+
* @module
|
|
114
|
+
*/
|
|
115
|
+
/** Key tagging a normalized `FormData` inside the FormData hash domain. */
|
|
116
|
+
var FORM_DATA_TAG = "\0FormData";
|
|
117
|
+
/**
|
|
118
|
+
* Prefix of the hash input for content containing a `FormData`. Sorted-key
|
|
119
|
+
* JSON always starts with a JSON token, never `\u0001`, so FormData digests
|
|
120
|
+
* can never equal a plain-JSON digest, and plain-JSON digests stay exactly
|
|
121
|
+
* what #3080 stored.
|
|
122
|
+
*/
|
|
123
|
+
var FORM_DATA_DOMAIN = "run-once-formdata-v1\n";
|
|
124
|
+
/** A captured `FormData`: its ordered entries, already normalized. */
|
|
125
|
+
var NormalizedFormData = class {
|
|
126
|
+
entries;
|
|
127
|
+
constructor(entries) {
|
|
128
|
+
this.entries = entries;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/** Marks a value JSON omits from an object (and writes as `null` in an array). */
|
|
132
|
+
var OMIT = Symbol("run-once-omit");
|
|
133
|
+
/**
|
|
134
|
+
* Reduce `content` to plain JSON data before hashing, following JSON
|
|
135
|
+
* serialization exactly — `toJSON(key)` is called with the property name or
|
|
136
|
+
* array index (so a `Date` digests as its ISO string), and `undefined`,
|
|
137
|
+
* function and symbol values are omitted from objects and written as `null` in
|
|
138
|
+
* arrays — with two differences:
|
|
139
|
+
*
|
|
140
|
+
* - A native `FormData` has no own enumerable properties, so JSON reduces
|
|
141
|
+
* every form to `{}` and would collapse distinct submissions (#3136). It
|
|
142
|
+
* becomes its ordered entry list instead — repeated fields and their order
|
|
143
|
+
* are significant — and a `File`/`Blob` entry digests by name, type and
|
|
144
|
+
* size, never its bytes.
|
|
145
|
+
* - Values JSON would silently misrepresent (`Map`, `Set`, `RegExp`, any other
|
|
146
|
+
* non-plain object without `toJSON()`) or cannot serialize (`bigint`) are
|
|
147
|
+
* rejected, not hashed as `{}`.
|
|
148
|
+
*/
|
|
149
|
+
function normalizeRunOnceContent(input, state, key) {
|
|
150
|
+
let value = input;
|
|
151
|
+
if (value !== null && (typeof value === "object" || typeof value === "bigint") && typeof value.toJSON === "function") value = value.toJSON(key);
|
|
152
|
+
if (value === null) return null;
|
|
153
|
+
switch (typeof value) {
|
|
154
|
+
case "undefined":
|
|
155
|
+
case "function":
|
|
156
|
+
case "symbol": return OMIT;
|
|
157
|
+
case "string":
|
|
158
|
+
case "boolean": return value;
|
|
159
|
+
case "number": return Number.isFinite(value) ? value : null;
|
|
160
|
+
case "bigint": throw new TypeError("runOnce content cannot contain a bigint; pass plain JSON data");
|
|
161
|
+
}
|
|
162
|
+
if (typeof FormData !== "undefined" && value instanceof FormData) {
|
|
163
|
+
state.sawFormData = true;
|
|
164
|
+
const entries = [];
|
|
165
|
+
value.forEach((entry, name) => {
|
|
166
|
+
entries.push([name, normalizeRunOnceContent(entry, state, name)]);
|
|
167
|
+
});
|
|
168
|
+
return new NormalizedFormData(entries);
|
|
169
|
+
}
|
|
170
|
+
if (typeof Blob !== "undefined" && value instanceof Blob) return {
|
|
171
|
+
name: value.name ?? null,
|
|
172
|
+
size: value.size,
|
|
173
|
+
type: value.type
|
|
174
|
+
};
|
|
175
|
+
if (Array.isArray(value)) return value.map((item, index) => {
|
|
176
|
+
const normalized = normalizeRunOnceContent(item, state, String(index));
|
|
177
|
+
return normalized === OMIT ? null : normalized;
|
|
178
|
+
});
|
|
179
|
+
const prototype = Object.getPrototypeOf(value);
|
|
180
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
181
|
+
const name = value.constructor?.name || "non-plain object";
|
|
182
|
+
throw new TypeError(`runOnce content cannot contain a ${name}; convert it to plain JSON data`);
|
|
183
|
+
}
|
|
184
|
+
const result = Object.create(null);
|
|
185
|
+
for (const [member, memberValue] of Object.entries(value)) {
|
|
186
|
+
const normalized = normalizeRunOnceContent(memberValue, state, member);
|
|
187
|
+
if (normalized !== OMIT) result[member] = normalized;
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Map a normalized tree containing {@link NormalizedFormData} into the
|
|
193
|
+
* FormData hash domain: each form becomes `{ [FORM_DATA_TAG]: entries }`, and
|
|
194
|
+
* user keys starting with NUL are escaped (NUL doubled) so no plain object can
|
|
195
|
+
* forge the tag. Works on the captured tree, so no getter or `toJSON()` on the
|
|
196
|
+
* caller's content runs twice.
|
|
197
|
+
*/
|
|
198
|
+
function toFormDataDomain(value) {
|
|
199
|
+
if (value instanceof NormalizedFormData) return { [FORM_DATA_TAG]: value.entries.map(([key, entry]) => [key, toFormDataDomain(entry)]) };
|
|
200
|
+
if (Array.isArray(value)) return value.map(toFormDataDomain);
|
|
201
|
+
if (value !== null && typeof value === "object") {
|
|
202
|
+
const result = Object.create(null);
|
|
203
|
+
for (const [key, member] of Object.entries(value)) result[key.startsWith("\0") ? `\u0000${key}` : key] = toFormDataDomain(member);
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Deterministic content digest: sha256 of sorted-key JSON.
|
|
210
|
+
*
|
|
211
|
+
* Property order never changes the digest, so callers do not need to worry
|
|
212
|
+
* about object construction order producing two different claims for what is
|
|
213
|
+
* semantically the same submission. Content is normalized exactly once (see
|
|
214
|
+
* {@link normalizeRunOnceContent}): `toJSON()` values such as `Date` digest as
|
|
215
|
+
* what they serialize to, a `FormData` digests as its ordered entries in a
|
|
216
|
+
* separate hash domain, and values JSON would reduce to `{}` (`Map`, `Set`,
|
|
217
|
+
* `RegExp`, …) are rejected.
|
|
218
|
+
*
|
|
219
|
+
* @throws {TypeError} when `content` contains a value with no faithful JSON form.
|
|
220
|
+
*/
|
|
221
|
+
function digestRunOnceContent(content) {
|
|
222
|
+
const state = { sawFormData: false };
|
|
223
|
+
const captured = normalizeRunOnceContent(content, state, "");
|
|
224
|
+
const normalized = captured === OMIT ? null : captured;
|
|
225
|
+
const input = state.sawFormData ? FORM_DATA_DOMAIN + stableStringify(toFormDataDomain(normalized)) : stableStringify(normalized);
|
|
226
|
+
return createHash("sha256").update(input).digest("hex");
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Derive the {@link runOnce} claim key.
|
|
230
|
+
*
|
|
231
|
+
* NOT the token alone — see the module doc for why that collapses distinct
|
|
232
|
+
* submissions. Folds in `tenantId`, `actor`, the raw `token`, and the
|
|
233
|
+
* content digest, all through sorted-key JSON before hashing.
|
|
234
|
+
*/
|
|
235
|
+
function deriveRunOnceClaimKey(params) {
|
|
236
|
+
const canonical = stableStringify([
|
|
237
|
+
params.tenantId,
|
|
238
|
+
params.actor,
|
|
239
|
+
params.token,
|
|
240
|
+
params.contentDigest
|
|
241
|
+
]);
|
|
242
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Resolve an existing claim row into either a replayable result or a typed
|
|
246
|
+
* error. Pulled out of {@link runOnce} as a pure function so both branches
|
|
247
|
+
* (`completed` vs. anything else vs. unreadable) are directly unit-testable
|
|
248
|
+
* without forcing a genuine database race.
|
|
249
|
+
*/
|
|
250
|
+
function resolveExistingRunOnceClaim(existing, claimKey) {
|
|
251
|
+
if (!existing) throw RunOnceClaimError.outcomeUnknown(claimKey);
|
|
252
|
+
if (existing.status !== "completed") throw RunOnceClaimError.inFlight(claimKey);
|
|
253
|
+
return JSON.parse(existing.result ?? "null");
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Run `work` exactly once per submission.
|
|
257
|
+
*
|
|
258
|
+
* ```ts
|
|
259
|
+
* const purchaseOrderId = await runOnce(
|
|
260
|
+
* { db, tenantId, actor, token, content: formData },
|
|
261
|
+
* async (tx) => createPurchaseOrder(tx, formData),
|
|
262
|
+
* );
|
|
263
|
+
* ```
|
|
264
|
+
*
|
|
265
|
+
* Resolves to `work`'s return value on both a fresh run and a replay of a
|
|
266
|
+
* prior completed run — the caller cannot tell the difference from the
|
|
267
|
+
* return value alone (by design: both are "your submission is durably
|
|
268
|
+
* applied"). Rejects with `RunOnceClaimError` (`./errors.ts`) when the claim
|
|
269
|
+
* cannot be resolved to a definite result right now; see the module doc for
|
|
270
|
+
* the `RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN` distinction. Any
|
|
271
|
+
* other rejection (including one from `work` itself) propagates unchanged,
|
|
272
|
+
* and the claim is rolled back with the transaction so a retry can run again.
|
|
273
|
+
*/
|
|
274
|
+
async function runOnce(params, work) {
|
|
275
|
+
const { db, tenantId, actor, token, content } = params;
|
|
276
|
+
if (!db.transaction) throw new Error("runOnce: the supplied database does not support transaction()");
|
|
277
|
+
const contentDigest = digestRunOnceContent(content);
|
|
278
|
+
const claimKey = deriveRunOnceClaimKey({
|
|
279
|
+
tenantId,
|
|
280
|
+
actor,
|
|
281
|
+
token,
|
|
282
|
+
contentDigest
|
|
283
|
+
});
|
|
284
|
+
return db.transaction(async (tx) => {
|
|
285
|
+
if (!await tx.single`
|
|
286
|
+
INSERT INTO _smrt_run_once_claims
|
|
287
|
+
(claim_key, tenant_id, actor, content_digest, status, result, created_at)
|
|
288
|
+
VALUES
|
|
289
|
+
(${claimKey}, ${tenantId}, ${actor}, ${contentDigest}, ${"in_progress"}, ${null}, ${/* @__PURE__ */ new Date()})
|
|
290
|
+
ON CONFLICT (claim_key) DO NOTHING
|
|
291
|
+
RETURNING claim_key
|
|
292
|
+
`) return resolveExistingRunOnceClaim(await tx.get(RUN_ONCE_CLAIMS_TABLE, {
|
|
293
|
+
claim_key: claimKey,
|
|
294
|
+
tenant_id: tenantId
|
|
295
|
+
}), claimKey);
|
|
296
|
+
const result = await work(tx);
|
|
297
|
+
await tx.update(RUN_ONCE_CLAIMS_TABLE, {
|
|
298
|
+
claim_key: claimKey,
|
|
299
|
+
tenant_id: tenantId
|
|
300
|
+
}, {
|
|
301
|
+
status: "completed",
|
|
302
|
+
result: JSON.stringify(result ?? null),
|
|
303
|
+
completed_at: /* @__PURE__ */ new Date()
|
|
304
|
+
});
|
|
305
|
+
return result;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
export { RUN_ONCE_CLAIMS_TABLE, deriveRunOnceClaimKey, digestRunOnceContent, resolveExistingRunOnceClaim, runOnce };
|
|
310
|
+
|
|
311
|
+
//# sourceMappingURL=run-once.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-once.js","names":[],"sources":["../src/run-once.ts"],"sourcesContent":["/**\n * Idempotency seam: run an action exactly once per submission (#3080).\n *\n * Every consumer with a form, a retried request, or a double-tapped button\n * needs the same guard: a claim row, insert-only against a UNIQUE column\n * (`claim_key`) in the SAME transaction as the work it guards, so a retry\n * either runs the work once or replays the first attempt's stored result.\n * This module is that primitive. It is DB/transaction-level — it takes a\n * `DatabaseInterface`, not a `SmrtObject`/`SmrtCollection` — so it works\n * outside SvelteKit and outside the ORM: any caller with a database handle\n * and a JSON-serializable work result can use it. The claim insert itself\n * is raw SQL (`## Concurrent submissions` below explains why), not the\n * ORM's `SmrtObject`-level `_insertOnly` option.\n *\n * ## Key derivation (not the token alone)\n *\n * The claim key is the sha256 hex digest of the sorted-key-JSON encoding of\n * `[tenantId, actor, token, contentDigest]`, where `contentDigest` is itself\n * the sha256 hex digest of the sorted-key-JSON encoding of `content`\n * ({@link digestRunOnceContent}). Sorted-key JSON ({@link stableStringify},\n * `./knowledge-graph.ts`) makes the digest stable regardless of property\n * insertion order.\n *\n * The token alone is NOT the key. A first attempt at this exact problem\n * (reported against #3080) keyed on the token alone and silently collapsed\n * two genuinely different submissions that happened to reuse one form's\n * token. Folding in the content digest means a retry of the SAME submission\n * (same token, same content) replays; a DIFFERENT submission through the\n * same token (edited content before a resubmit) runs as its own claim.\n * Folding in `tenantId` and `actor` means two tenants or two actors can never\n * collide on the same claim even if a token were somehow shared between them.\n *\n * Neither the raw token nor the raw content is ever persisted — only their\n * digests — so the claims table (`_smrt_run_once_claims`,\n * `./system/schema.ts`) cannot leak submitted business data.\n *\n * ## Atomicity\n *\n * `runOnce()` opens its own `db.transaction()` (or nests under the caller's,\n * per `@happyvertical/sql`'s own SAVEPOINT/`NestedTransactionError` rules —\n * see that package's `DatabaseInterface.transaction` doc) and, inside it:\n *\n * 1. Inserts the claim row (`status: 'in_progress'`).\n * 2. Runs `work(tx)`, passing the SAME transaction-bound handle, so the\n * caller's writes and the claim insert commit or roll back together.\n * 3. Updates the claim row to `status: 'completed'` with the JSON-serialized\n * result, still inside the same transaction.\n *\n * If `work()` throws, the callback throws, and `db.transaction()`'s own\n * \"commits on success, rolls back on error\" contract undoes the claim insert\n * along with whatever `work()` partially wrote — a claim is never stranded\n * `'in_progress'` by a failed attempt, and a retry with the same key can\n * always run again.\n *\n * ## Concurrent submissions\n *\n * The claim insert uses `INSERT ... ON CONFLICT (claim_key) DO NOTHING\n * RETURNING claim_key` (the same portable idiom `_smrt_migrations` already\n * uses in `system/bootstrap.ts`), not a plain `INSERT` caught for a\n * classified `unique_violation`. This matters on PostgreSQL specifically: a\n * statement that raises an error aborts the WHOLE transaction (\"current\n * transaction is aborted, commands ignored until end of transaction block\"),\n * so a plain `INSERT` failing on the claim key would make every subsequent\n * statement in the same transaction — including the very `SELECT` this\n * function needs to read the winner's stored result back — fail too.\n * `DO NOTHING` never raises: it returns zero rows instead, which this\n * function reads as \"lost the race\" without ever touching the transaction's\n * error state. SQLite and DuckDB both support the same `ON CONFLICT ... DO\n * NOTHING` / `RETURNING` syntax, so one query works on every adapter.\n *\n * `@happyvertical/sql`'s single-connection adapters (SQLite, DuckDB, JSON)\n * serialize `transaction()` calls on one connection: a second, concurrent\n * `runOnce()` call does not begin its own transaction until the first one\n * ends. PostgreSQL pools, but this `INSERT`'s arbiter still has to check the\n * unique index against another transaction's uncommitted row of the same\n * PRIMARY KEY, and blocks until that transaction resolves, then either\n * no-ops (committed — the row exists) or inserts (rolled back — no row).\n * Either way, two concurrent callers with the same key never both run\n * `work()`: exactly one does, and the loser's `INSERT` returns zero rows —\n * never an error — at which point it reads the now-committed claim row back\n * and replays its stored result.\n *\n * ## Typed answers for a claim that is not (yet) a definite result\n *\n * A `runOnce()` conflict is a *definite* decision — the row exists — but\n * whether it is safe to hand back a result depends on `status`. This mirrors\n * the \"refused vs unknown outcome\" distinction #2990 drew for AssistantDock\n * actions (`./errors.ts#RunOnceClaimError` carries the two codes):\n *\n * - `status: 'completed'` — replay the stored `result`. No error.\n * - `status` anything else (still `'in_progress'`) — some execution holds\n * this key right now. `RunOnceClaimError.inFlight()`. Under this module's\n * own transaction semantics this is normally unreachable through\n * `runOnce()`-to-`runOnce()` races alone (the losing transaction can only\n * ever observe a COMMITTED row, and this module only ever commits a row\n * once it is `'completed'`) — it is reachable if a claim row was inserted\n * outside this module's own completion discipline, and is kept as a\n * defined answer rather than an assumption.\n * - the row cannot be read back at all after `DO NOTHING` reported zero\n * rows inserted — `RunOnceClaimError.outcomeUnknown()`. Also normally\n * unreachable (the zero-rows case only happens when a committed row\n * already exists); kept for adapters/drivers where that and a subsequent\n * read are not guaranteed to observe the same committed state.\n *\n * Both errors carry `claimKey` and are safe to retry with the SAME token and\n * content; `runOnce()` never retries them itself.\n *\n * @module\n */\n\nimport { createHash } from 'node:crypto';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { RunOnceClaimError } from './errors.js';\nimport { stableStringify } from './knowledge-graph.js';\nimport { RUN_ONCE_CLAIMS_TABLE } from './system/schema.js';\n\nexport { RUN_ONCE_CLAIMS_TABLE };\n\n/** Input identifying one submission for {@link runOnce}. */\nexport interface RunOnceParams {\n /** Transaction-capable database handle. May itself already be inside a transaction. */\n db: DatabaseInterface;\n /** Tenant scope. Part of the claim key: claims never cross tenants. */\n tenantId: string;\n /** The submitting principal. Part of the claim key: claims never cross actors. */\n actor: string;\n /** The per-form/per-submission token minted by the caller (e.g. a hidden form field). */\n token: string;\n /**\n * The content being written: plain JSON data, or a `FormData` (digested by\n * its ordered entries; see {@link digestRunOnceContent}). Only its\n * digest is persisted — never the raw value.\n */\n content: unknown;\n}\n\ninterface RunOnceClaimRow {\n claim_key: string;\n status: string;\n result: string | null;\n}\n\n/** Key tagging a normalized `FormData` inside the FormData hash domain. */\nconst FORM_DATA_TAG = '\\u0000FormData';\n\n/**\n * Prefix of the hash input for content containing a `FormData`. Sorted-key\n * JSON always starts with a JSON token, never `\\u0001`, so FormData digests\n * can never equal a plain-JSON digest, and plain-JSON digests stay exactly\n * what #3080 stored.\n */\nconst FORM_DATA_DOMAIN = '\\u0001run-once-formdata-v1\\n';\n\n/** A captured `FormData`: its ordered entries, already normalized. */\nclass NormalizedFormData {\n constructor(readonly entries: Array<[string, unknown]>) {}\n}\n\ninterface NormalizeState {\n sawFormData: boolean;\n}\n\n/** Marks a value JSON omits from an object (and writes as `null` in an array). */\nconst OMIT: unique symbol = Symbol('run-once-omit');\n\n/**\n * Reduce `content` to plain JSON data before hashing, following JSON\n * serialization exactly — `toJSON(key)` is called with the property name or\n * array index (so a `Date` digests as its ISO string), and `undefined`,\n * function and symbol values are omitted from objects and written as `null` in\n * arrays — with two differences:\n *\n * - A native `FormData` has no own enumerable properties, so JSON reduces\n * every form to `{}` and would collapse distinct submissions (#3136). It\n * becomes its ordered entry list instead — repeated fields and their order\n * are significant — and a `File`/`Blob` entry digests by name, type and\n * size, never its bytes.\n * - Values JSON would silently misrepresent (`Map`, `Set`, `RegExp`, any other\n * non-plain object without `toJSON()`) or cannot serialize (`bigint`) are\n * rejected, not hashed as `{}`.\n */\nfunction normalizeRunOnceContent(\n input: unknown,\n state: NormalizeState,\n key: string,\n): unknown {\n let value = input;\n if (\n value !== null &&\n (typeof value === 'object' || typeof value === 'bigint') &&\n typeof (value as { toJSON?: unknown }).toJSON === 'function'\n ) {\n value = (value as { toJSON: (key: string) => unknown }).toJSON(key);\n }\n if (value === null) return null;\n switch (typeof value) {\n case 'undefined':\n case 'function':\n case 'symbol':\n return OMIT;\n case 'string':\n case 'boolean':\n return value;\n case 'number':\n return Number.isFinite(value) ? value : null;\n case 'bigint':\n throw new TypeError(\n 'runOnce content cannot contain a bigint; pass plain JSON data',\n );\n }\n if (typeof FormData !== 'undefined' && value instanceof FormData) {\n state.sawFormData = true;\n const entries: Array<[string, unknown]> = [];\n // `forEach`, not `entries()`: consumers type-check core's source against\n // DOM libs without `DOM.Iterable`, where FormData is not iterable.\n value.forEach((entry, name) => {\n entries.push([name, normalizeRunOnceContent(entry, state, name)]);\n });\n return new NormalizedFormData(entries);\n }\n if (typeof Blob !== 'undefined' && value instanceof Blob) {\n return {\n name: (value as Blob & { name?: string }).name ?? null,\n size: value.size,\n type: value.type,\n };\n }\n if (Array.isArray(value)) {\n return value.map((item, index) => {\n const normalized = normalizeRunOnceContent(item, state, String(index));\n return normalized === OMIT ? null : normalized;\n });\n }\n const prototype = Object.getPrototypeOf(value);\n if (prototype !== Object.prototype && prototype !== null) {\n const name =\n (value as { constructor?: { name?: string } }).constructor?.name ||\n 'non-plain object';\n throw new TypeError(\n `runOnce content cannot contain a ${name}; convert it to plain JSON data`,\n );\n }\n const result: Record<string, unknown> = Object.create(null);\n for (const [member, memberValue] of Object.entries(value as object)) {\n const normalized = normalizeRunOnceContent(memberValue, state, member);\n if (normalized !== OMIT) result[member] = normalized;\n }\n return result;\n}\n\n/**\n * Map a normalized tree containing {@link NormalizedFormData} into the\n * FormData hash domain: each form becomes `{ [FORM_DATA_TAG]: entries }`, and\n * user keys starting with NUL are escaped (NUL doubled) so no plain object can\n * forge the tag. Works on the captured tree, so no getter or `toJSON()` on the\n * caller's content runs twice.\n */\nfunction toFormDataDomain(value: unknown): unknown {\n if (value instanceof NormalizedFormData) {\n return {\n [FORM_DATA_TAG]: value.entries.map(([key, entry]) => [\n key,\n toFormDataDomain(entry),\n ]),\n };\n }\n if (Array.isArray(value)) return value.map(toFormDataDomain);\n if (value !== null && typeof value === 'object') {\n const result: Record<string, unknown> = Object.create(null);\n for (const [key, member] of Object.entries(value)) {\n result[key.startsWith('\\u0000') ? `\\u0000${key}` : key] =\n toFormDataDomain(member);\n }\n return result;\n }\n return value;\n}\n\n/**\n * Deterministic content digest: sha256 of sorted-key JSON.\n *\n * Property order never changes the digest, so callers do not need to worry\n * about object construction order producing two different claims for what is\n * semantically the same submission. Content is normalized exactly once (see\n * {@link normalizeRunOnceContent}): `toJSON()` values such as `Date` digest as\n * what they serialize to, a `FormData` digests as its ordered entries in a\n * separate hash domain, and values JSON would reduce to `{}` (`Map`, `Set`,\n * `RegExp`, …) are rejected.\n *\n * @throws {TypeError} when `content` contains a value with no faithful JSON form.\n */\nexport function digestRunOnceContent(content: unknown): string {\n const state: NormalizeState = { sawFormData: false };\n const captured = normalizeRunOnceContent(content, state, '');\n // JSON.stringify of a top-level omitted value is `undefined`; #3080 hashed it as null.\n const normalized = captured === OMIT ? null : captured;\n const input = state.sawFormData\n ? FORM_DATA_DOMAIN + stableStringify(toFormDataDomain(normalized))\n : stableStringify(normalized);\n return createHash('sha256').update(input).digest('hex');\n}\n\n/**\n * Derive the {@link runOnce} claim key.\n *\n * NOT the token alone — see the module doc for why that collapses distinct\n * submissions. Folds in `tenantId`, `actor`, the raw `token`, and the\n * content digest, all through sorted-key JSON before hashing.\n */\nexport function deriveRunOnceClaimKey(params: {\n tenantId: string;\n actor: string;\n token: string;\n contentDigest: string;\n}): string {\n const canonical = stableStringify([\n params.tenantId,\n params.actor,\n params.token,\n params.contentDigest,\n ]);\n return createHash('sha256').update(canonical).digest('hex');\n}\n\n/**\n * Resolve an existing claim row into either a replayable result or a typed\n * error. Pulled out of {@link runOnce} as a pure function so both branches\n * (`completed` vs. anything else vs. unreadable) are directly unit-testable\n * without forcing a genuine database race.\n */\nexport function resolveExistingRunOnceClaim<T>(\n existing: RunOnceClaimRow | null,\n claimKey: string,\n): T {\n if (!existing) {\n throw RunOnceClaimError.outcomeUnknown(claimKey);\n }\n if (existing.status !== 'completed') {\n throw RunOnceClaimError.inFlight(claimKey);\n }\n return JSON.parse(existing.result ?? 'null') as T;\n}\n\n/**\n * Run `work` exactly once per submission.\n *\n * ```ts\n * const purchaseOrderId = await runOnce(\n * { db, tenantId, actor, token, content: formData },\n * async (tx) => createPurchaseOrder(tx, formData),\n * );\n * ```\n *\n * Resolves to `work`'s return value on both a fresh run and a replay of a\n * prior completed run — the caller cannot tell the difference from the\n * return value alone (by design: both are \"your submission is durably\n * applied\"). Rejects with `RunOnceClaimError` (`./errors.ts`) when the claim\n * cannot be resolved to a definite result right now; see the module doc for\n * the `RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN` distinction. Any\n * other rejection (including one from `work` itself) propagates unchanged,\n * and the claim is rolled back with the transaction so a retry can run again.\n */\nexport async function runOnce<T>(\n params: RunOnceParams,\n work: (tx: DatabaseInterface) => Promise<T>,\n): Promise<T> {\n const { db, tenantId, actor, token, content } = params;\n if (!db.transaction) {\n throw new Error(\n 'runOnce: the supplied database does not support transaction()',\n );\n }\n\n const contentDigest = digestRunOnceContent(content);\n const claimKey = deriveRunOnceClaimKey({\n tenantId,\n actor,\n token,\n contentDigest,\n });\n\n return db.transaction(async (tx) => {\n // `ON CONFLICT ... DO NOTHING` never raises — see the module doc for why\n // a plain INSERT-and-catch aborts the whole transaction on PostgreSQL,\n // taking the recovery SELECT below down with it. `RETURNING claim_key`\n // comes back with one row when this call won the claim, zero when it\n // lost it — that is the only signal this function needs.\n const won = await tx.single`\n INSERT INTO _smrt_run_once_claims\n (claim_key, tenant_id, actor, content_digest, status, result, created_at)\n VALUES\n (${claimKey}, ${tenantId}, ${actor}, ${contentDigest}, ${'in_progress'}, ${null}, ${new Date()})\n ON CONFLICT (claim_key) DO NOTHING\n RETURNING claim_key\n `;\n\n if (!won) {\n const existing = (await tx.get(RUN_ONCE_CLAIMS_TABLE, {\n claim_key: claimKey,\n tenant_id: tenantId,\n })) as RunOnceClaimRow | null;\n return resolveExistingRunOnceClaim<T>(existing, claimKey);\n }\n\n const result = await work(tx);\n await tx.update(\n RUN_ONCE_CLAIMS_TABLE,\n { claim_key: claimKey, tenant_id: tenantId },\n {\n status: 'completed',\n result: JSON.stringify(result ?? null),\n completed_at: new Date(),\n },\n );\n return result;\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded-concurrency map for read-only introspection and probe queries.
|
|
3
|
+
*
|
|
4
|
+
* Schema diagnostics (`SchemaComparer.compare()`, the foreign-key orphan
|
|
5
|
+
* report) issue one or more catalog/probe queries per table. Run strictly one
|
|
6
|
+
* after another, their wall time is `queries × network round trip`, which is
|
|
7
|
+
* minutes on a wide schema reached over a high-latency link (a ~280-table
|
|
8
|
+
* `db:status` issued ~5,100 sequential round trips: ~30 s next to the
|
|
9
|
+
* database, 11 min over a ~120 ms RTT link).
|
|
10
|
+
* Running a bounded number in flight on a pooled connection divides that by
|
|
11
|
+
* the concurrency limit without changing what is queried.
|
|
12
|
+
*
|
|
13
|
+
* Results are returned in input order. A limit of 1 is exactly the
|
|
14
|
+
* sequential loop it replaces: items run in order and the first failure stops
|
|
15
|
+
* scheduling further items. With a larger limit, the first failure also stops
|
|
16
|
+
* scheduling; items already in flight are awaited (never left as unhandled
|
|
17
|
+
* rejections) before that first error is rethrown.
|
|
18
|
+
*/
|
|
19
|
+
export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Default number of concurrent read-only catalog introspection queries on
|
|
22
|
+
* PostgreSQL. Well under the `@happyvertical/sql` pool default (20).
|
|
23
|
+
*/
|
|
24
|
+
export declare const POSTGRES_INTROSPECTION_CONCURRENCY = 8;
|
|
25
|
+
/**
|
|
26
|
+
* Default number of concurrent data-scanning probes (e.g. orphan `COUNT(*)`)
|
|
27
|
+
* on PostgreSQL. Lower than introspection: these read table data, and a
|
|
28
|
+
* diagnostic must not load a production primary.
|
|
29
|
+
*/
|
|
30
|
+
export declare const POSTGRES_PROBE_CONCURRENCY = 4;
|
|
31
|
+
//# sourceMappingURL=bounded-concurrency.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bounded-concurrency.d.ts","sourceRoot":"","sources":["../../src/schema/bounded-concurrency.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,kBAAkB,CAAC,CAAC,EAAE,CAAC,EAC3C,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GACzC,OAAO,CAAC,CAAC,EAAE,CAAC,CAyBd;AAED;;;GAGG;AACH,eAAO,MAAM,kCAAkC,IAAI,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,IAAI,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/schema/bounded-concurrency.ts
|
|
2
|
+
/**
|
|
3
|
+
* Bounded-concurrency map for read-only introspection and probe queries.
|
|
4
|
+
*
|
|
5
|
+
* Schema diagnostics (`SchemaComparer.compare()`, the foreign-key orphan
|
|
6
|
+
* report) issue one or more catalog/probe queries per table. Run strictly one
|
|
7
|
+
* after another, their wall time is `queries × network round trip`, which is
|
|
8
|
+
* minutes on a wide schema reached over a high-latency link (a ~280-table
|
|
9
|
+
* `db:status` issued ~5,100 sequential round trips: ~30 s next to the
|
|
10
|
+
* database, 11 min over a ~120 ms RTT link).
|
|
11
|
+
* Running a bounded number in flight on a pooled connection divides that by
|
|
12
|
+
* the concurrency limit without changing what is queried.
|
|
13
|
+
*
|
|
14
|
+
* Results are returned in input order. A limit of 1 is exactly the
|
|
15
|
+
* sequential loop it replaces: items run in order and the first failure stops
|
|
16
|
+
* scheduling further items. With a larger limit, the first failure also stops
|
|
17
|
+
* scheduling; items already in flight are awaited (never left as unhandled
|
|
18
|
+
* rejections) before that first error is rethrown.
|
|
19
|
+
*/
|
|
20
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
21
|
+
const results = new Array(items.length);
|
|
22
|
+
if (items.length === 0) return results;
|
|
23
|
+
const width = Math.max(1, Math.min(Number.isFinite(limit) ? Math.floor(limit) : 1, items.length));
|
|
24
|
+
let next = 0;
|
|
25
|
+
let failure;
|
|
26
|
+
const worker = async () => {
|
|
27
|
+
while (failure === void 0 && next < items.length) {
|
|
28
|
+
const index = next++;
|
|
29
|
+
try {
|
|
30
|
+
results[index] = await fn(items[index], index);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
failure ??= { error };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
await Promise.all(Array.from({ length: width }, () => worker()));
|
|
37
|
+
if (failure) throw failure.error;
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { mapWithConcurrency };
|
|
42
|
+
|
|
43
|
+
//# sourceMappingURL=bounded-concurrency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bounded-concurrency.js","names":[],"sources":["../../src/schema/bounded-concurrency.ts"],"sourcesContent":["/**\n * Bounded-concurrency map for read-only introspection and probe queries.\n *\n * Schema diagnostics (`SchemaComparer.compare()`, the foreign-key orphan\n * report) issue one or more catalog/probe queries per table. Run strictly one\n * after another, their wall time is `queries × network round trip`, which is\n * minutes on a wide schema reached over a high-latency link (a ~280-table\n * `db:status` issued ~5,100 sequential round trips: ~30 s next to the\n * database, 11 min over a ~120 ms RTT link).\n * Running a bounded number in flight on a pooled connection divides that by\n * the concurrency limit without changing what is queried.\n *\n * Results are returned in input order. A limit of 1 is exactly the\n * sequential loop it replaces: items run in order and the first failure stops\n * scheduling further items. With a larger limit, the first failure also stops\n * scheduling; items already in flight are awaited (never left as unhandled\n * rejections) before that first error is rethrown.\n */\nexport async function mapWithConcurrency<T, R>(\n items: readonly T[],\n limit: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) return results;\n\n const width = Math.max(\n 1,\n Math.min(Number.isFinite(limit) ? Math.floor(limit) : 1, items.length),\n );\n let next = 0;\n let failure: { error: unknown } | undefined;\n\n const worker = async (): Promise<void> => {\n while (failure === undefined && next < items.length) {\n const index = next++;\n try {\n results[index] = await fn(items[index] as T, index);\n } catch (error) {\n failure ??= { error };\n }\n }\n };\n\n await Promise.all(Array.from({ length: width }, () => worker()));\n if (failure) throw failure.error;\n return results;\n}\n\n/**\n * Default number of concurrent read-only catalog introspection queries on\n * PostgreSQL. Well under the `@happyvertical/sql` pool default (20).\n */\nexport const POSTGRES_INTROSPECTION_CONCURRENCY = 8;\n\n/**\n * Default number of concurrent data-scanning probes (e.g. orphan `COUNT(*)`)\n * on PostgreSQL. Lower than introspection: these read table data, and a\n * diagnostic must not load a production primary.\n */\nexport const POSTGRES_PROBE_CONCURRENCY = 4;\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAkBA,eAAsB,mBACpB,OACA,OACA,IACc;CACd,MAAM,UAAU,IAAI,MAAS,MAAM,MAAM;CACzC,IAAI,MAAM,WAAW,GAAG,OAAO;CAE/B,MAAM,QAAQ,KAAK,IACjB,GACA,KAAK,IAAI,OAAO,SAAS,KAAK,IAAI,KAAK,MAAM,KAAK,IAAI,GAAG,MAAM,MAAM,CACvE;CACA,IAAI,OAAO;CACX,IAAI;CAEJ,MAAM,SAAS,YAA2B;EACxC,OAAO,YAAY,KAAA,KAAa,OAAO,MAAM,QAAQ;GACnD,MAAM,QAAQ;GACd,IAAI;IACF,QAAQ,SAAS,MAAM,GAAG,MAAM,QAAa,KAAK;GACpD,SAAS,OAAO;IACd,YAAY,EAAE,MAAM;GACtB;EACF;CACF;CAEA,MAAM,QAAQ,IAAI,MAAM,KAAK,EAAE,QAAQ,MAAM,SAAS,OAAO,CAAC,CAAC;CAC/D,IAAI,SAAS,MAAM,QAAQ;CAC3B,OAAO;AACT"}
|
|
@@ -40,9 +40,29 @@ export interface ForeignKeyOrphanCountReport {
|
|
|
40
40
|
/** Foreign keys skipped because a table did not exist live, or the probe failed. */
|
|
41
41
|
skipped: ForeignKeyOrphanSkipped[];
|
|
42
42
|
}
|
|
43
|
+
/** Live table schema as returned by `db.getTableSchema()`. */
|
|
44
|
+
type LiveTableSchema = Awaited<ReturnType<NonNullable<DatabaseInterface['getTableSchema']>>>;
|
|
43
45
|
export interface CollectForeignKeyOrphanCountsOptions {
|
|
44
46
|
/** Explicit engine hint for adapters whose URL is empty or ambiguous. */
|
|
45
47
|
engineHint?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Live table schemas already read in this command over the same
|
|
50
|
+
* connection (e.g. `SchemaComparer.getLiveSchemaSnapshot()` after
|
|
51
|
+
* `compare()`), used instead of re-introspecting those tables. Tables
|
|
52
|
+
* absent from the map are introspected as before.
|
|
53
|
+
*/
|
|
54
|
+
liveSchemas?: ReadonlyMap<string, LiveTableSchema | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* Maximum concurrent live-table introspections. Default:
|
|
57
|
+
* {@link POSTGRES_INTROSPECTION_CONCURRENCY} on PostgreSQL, 1 elsewhere.
|
|
58
|
+
*/
|
|
59
|
+
introspectionConcurrency?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Maximum concurrent orphan `COUNT(*)` probes. These scan table data, so
|
|
62
|
+
* the default is lower: {@link POSTGRES_PROBE_CONCURRENCY} on PostgreSQL,
|
|
63
|
+
* 1 elsewhere.
|
|
64
|
+
*/
|
|
65
|
+
probeConcurrency?: number;
|
|
46
66
|
}
|
|
47
67
|
/**
|
|
48
68
|
* Probe every manifest-declared foreign key for live orphan rows and report
|
|
@@ -54,4 +74,5 @@ export interface CollectForeignKeyOrphanCountsOptions {
|
|
|
54
74
|
* `ObjectRegistry.getAllSchemasAsDefinitions()`.
|
|
55
75
|
*/
|
|
56
76
|
export declare function collectForeignKeyOrphanCounts(db: DatabaseInterface, schemas: Record<string, SchemaDefinition>, options?: CollectForeignKeyOrphanCountsOptions): Promise<ForeignKeyOrphanCountReport>;
|
|
77
|
+
export {};
|
|
57
78
|
//# sourceMappingURL=foreign-key-orphan-report.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"foreign-key-orphan-report.d.ts","sourceRoot":"","sources":["../../src/schema/foreign-key-orphan-report.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"foreign-key-orphan-report.d.ts","sourceRoot":"","sources":["../../src/schema/foreign-key-orphan-report.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAO5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAMrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,oDAAoD;AACpD,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,kEAAkE;IAClE,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,kEAAkE;AAClE,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,IAAI,EAAE,eAAe,GAAG,cAAc,CAAC;CACxC;AAED,gDAAgD;AAChD,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,cAAc,CAAC;IACvB;;;;OAIG;IACH,MAAM,EAAE,qBAAqB,EAAE,CAAC;IAChC,oFAAoF;IACpF,OAAO,EAAE,uBAAuB,EAAE,CAAC;CACpC;AAED,8DAA8D;AAC9D,KAAK,eAAe,GAAG,OAAO,CAC5B,UAAU,CAAC,WAAW,CAAC,iBAAiB,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAC7D,CAAC;AAEF,MAAM,WAAW,oCAAoC;IACnD,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,CAAC,CAAC;IAC/D;;;OAGG;IACH,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;GAQG;AACH,wBAAsB,6BAA6B,CACjD,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EACzC,OAAO,GAAE,oCAAyC,GACjD,OAAO,CAAC,2BAA2B,CAAC,CAsOtC"}
|