@substrat-run/kernel 0.87.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import { type DenialBucket, type DenialFilter, type PermissionDenial } from '@substrat-run/contracts';
2
+ /**
3
+ * The SELECTs behind every read of a scope's denial log (#867, K-35's stated tail).
4
+ *
5
+ * Four surfaces answer questions from `_substrat_denials` — the pure adapter's
6
+ * `HostAdmin`, the Durable Object's RPC, the vertical's `/internal/denials` seam and
7
+ * the control-plane route above them — and they must not drift on what "newest"
8
+ * means, what a bucket groups by, or which rows a window bound includes. Ids are
9
+ * ULIDs, so `ORDER BY id DESC` IS newest-first with no second index.
10
+ *
11
+ * Filters are re-parsed here rather than trusted: this builds SQL and every field can
12
+ * arrive from an HTTP query string. Values are bound, never interpolated.
13
+ */
14
+ /** Every column of `_substrat_denials`, in the order `mapDenialRow` expects. */
15
+ export declare const DENIAL_COLUMNS = "id, actor, permission, tenant_id, scope_id, operation, at, drained_at";
16
+ /** The raw row shape, as either adapter hands it back. */
17
+ export interface DenialRow {
18
+ id: string;
19
+ actor: string;
20
+ permission: string;
21
+ tenant_id: string;
22
+ scope_id: string | null;
23
+ operation: string | null;
24
+ at: string;
25
+ drained_at: string | null;
26
+ }
27
+ /**
28
+ * The stored spelling of an actor. The writer persists `JSON.stringify(actor)`, so a
29
+ * principal is stored WITH its quotes (`"01J…"`) while a system or connection actor is
30
+ * stored as an object (`{"system":"invoicing"}`). A caller filtering by actor holds the
31
+ * logical form, not that encoding, so normalize rather than making every call site know:
32
+ * text that already parses as JSON is passed through, anything else is a bare id and is
33
+ * stringified. Round-trips exactly what `recordDenial` wrote in both adapters.
34
+ */
35
+ export declare function storedActor(input: string): string;
36
+ /** Turn a stored row into the contract shape. */
37
+ export declare function mapDenialRow(row: DenialRow): PermissionDenial;
38
+ /** A bounded page of raw denial rows, newest first. */
39
+ export declare function denialListQuery(filter?: DenialFilter): {
40
+ sql: string;
41
+ params: (string | number)[];
42
+ };
43
+ /**
44
+ * K-35's rate-buckets: one row per (actor, permission), busiest first.
45
+ *
46
+ * Busiest-first rather than newest-first on purpose — this view exists BECAUSE the
47
+ * volume is attacker-influenceable, and ordering by recency would let whoever wrote
48
+ * the last hundred rows push everyone else off the page, which is the exact failure
49
+ * the bucketing is there to prevent. Ties break on `MAX(id)` so the order is total.
50
+ */
51
+ export declare function denialSummaryQuery(filter?: DenialFilter): {
52
+ sql: string;
53
+ params: (string | number)[];
54
+ };
55
+ export interface DenialBucketRow {
56
+ actor: string;
57
+ permission: string;
58
+ count: number;
59
+ operations: number;
60
+ first_at: string;
61
+ last_at: string;
62
+ }
63
+ export declare function mapDenialBucketRow(row: DenialBucketRow): DenialBucket;
64
+ /** Totals for the FILTERED set — what the capped bucket list is a page of. */
65
+ export declare function denialTotalsQuery(filter?: DenialFilter): {
66
+ sql: string;
67
+ params: (string | number)[];
68
+ };
69
+ /**
70
+ * Facts about the WINDOW, filter ignored — deliberately.
71
+ *
72
+ * These describe the log, not the query. A caller reading an empty filtered result
73
+ * needs to know whether the log reaches back past the interval it asked about, because
74
+ * rows here drain rather than expire (K-24's split) and until a Tier-2 sink exists the
75
+ * window simply IS the retention. Reporting the floor is what stops absence being read
76
+ * as "this never happened" — K-35 calls that a stated limitation, so the surface states it.
77
+ */
78
+ export declare const DENIAL_WINDOW_QUERY: string;
79
+ export interface DenialWindowRow {
80
+ oldest_at: string | null;
81
+ newest_at: string | null;
82
+ drained: number | null;
83
+ }
84
+ //# sourceMappingURL=denial-query.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"denial-query.d.ts","sourceRoot":"","sources":["../src/denial-query.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EAItB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;GAWG;AAEH,gFAAgF;AAChF,eAAO,MAAM,cAAc,0EAA0E,CAAC;AAEtG,0DAA0D;AAC1D,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOjD;AAED,iDAAiD;AACjD,wBAAgB,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,gBAAgB,CAW7D;AAgCD,uDAAuD;AACvD,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAA;CAAE,CAOnG;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG;IACzD,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;CAC7B,CAYA;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,YAAY,CASrE;AAED,8EAA8E;AAC9E,wBAAgB,iBAAiB,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;CAC7B,CAOA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,QAGL,CAAC;AAE5B,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB"}
@@ -0,0 +1,135 @@
1
+ import { DEFAULT_DENIAL_LIMIT, denialFilter, } from '@substrat-run/contracts';
2
+ /**
3
+ * The SELECTs behind every read of a scope's denial log (#867, K-35's stated tail).
4
+ *
5
+ * Four surfaces answer questions from `_substrat_denials` — the pure adapter's
6
+ * `HostAdmin`, the Durable Object's RPC, the vertical's `/internal/denials` seam and
7
+ * the control-plane route above them — and they must not drift on what "newest"
8
+ * means, what a bucket groups by, or which rows a window bound includes. Ids are
9
+ * ULIDs, so `ORDER BY id DESC` IS newest-first with no second index.
10
+ *
11
+ * Filters are re-parsed here rather than trusted: this builds SQL and every field can
12
+ * arrive from an HTTP query string. Values are bound, never interpolated.
13
+ */
14
+ /** Every column of `_substrat_denials`, in the order `mapDenialRow` expects. */
15
+ export const DENIAL_COLUMNS = 'id, actor, permission, tenant_id, scope_id, operation, at, drained_at';
16
+ /**
17
+ * The stored spelling of an actor. The writer persists `JSON.stringify(actor)`, so a
18
+ * principal is stored WITH its quotes (`"01J…"`) while a system or connection actor is
19
+ * stored as an object (`{"system":"invoicing"}`). A caller filtering by actor holds the
20
+ * logical form, not that encoding, so normalize rather than making every call site know:
21
+ * text that already parses as JSON is passed through, anything else is a bare id and is
22
+ * stringified. Round-trips exactly what `recordDenial` wrote in both adapters.
23
+ */
24
+ export function storedActor(input) {
25
+ try {
26
+ JSON.parse(input);
27
+ return input;
28
+ }
29
+ catch {
30
+ return JSON.stringify(input);
31
+ }
32
+ }
33
+ /** Turn a stored row into the contract shape. */
34
+ export function mapDenialRow(row) {
35
+ return {
36
+ id: row.id,
37
+ actor: JSON.parse(row.actor),
38
+ permission: row.permission,
39
+ tenantId: row.tenant_id,
40
+ scopeId: (row.scope_id ?? null),
41
+ operation: row.operation ?? null,
42
+ at: row.at,
43
+ drainedAt: row.drained_at ?? null,
44
+ };
45
+ }
46
+ /** The WHERE fragment shared by the row read and the summary. */
47
+ function where(f) {
48
+ const parts = [];
49
+ const params = [];
50
+ if (f.actor !== undefined) {
51
+ parts.push('actor = ?');
52
+ params.push(storedActor(f.actor));
53
+ }
54
+ if (f.permission !== undefined) {
55
+ parts.push('permission = ?');
56
+ params.push(f.permission);
57
+ }
58
+ if (f.operation !== undefined) {
59
+ parts.push('operation = ?');
60
+ params.push(f.operation);
61
+ }
62
+ // `at` is ISO 8601 text, which sorts lexicographically — the comparison is the
63
+ // ordering, no date parsing on either adapter. Inclusive lower, exclusive upper, so
64
+ // adjacent windows tile without double-counting a row on the boundary.
65
+ if (f.since !== undefined) {
66
+ parts.push('at >= ?');
67
+ params.push(f.since);
68
+ }
69
+ if (f.until !== undefined) {
70
+ parts.push('at < ?');
71
+ params.push(f.until);
72
+ }
73
+ return { clause: parts.length ? ` WHERE ${parts.join(' AND ')}` : '', params };
74
+ }
75
+ /** A bounded page of raw denial rows, newest first. */
76
+ export function denialListQuery(filter) {
77
+ const f = denialFilter.parse(filter ?? {});
78
+ const w = where(f);
79
+ return {
80
+ sql: `SELECT ${DENIAL_COLUMNS} FROM _substrat_denials${w.clause} ORDER BY id DESC LIMIT ?`,
81
+ params: [...w.params, f.limit ?? DEFAULT_DENIAL_LIMIT],
82
+ };
83
+ }
84
+ /**
85
+ * K-35's rate-buckets: one row per (actor, permission), busiest first.
86
+ *
87
+ * Busiest-first rather than newest-first on purpose — this view exists BECAUSE the
88
+ * volume is attacker-influenceable, and ordering by recency would let whoever wrote
89
+ * the last hundred rows push everyone else off the page, which is the exact failure
90
+ * the bucketing is there to prevent. Ties break on `MAX(id)` so the order is total.
91
+ */
92
+ export function denialSummaryQuery(filter) {
93
+ const f = denialFilter.parse(filter ?? {});
94
+ const w = where(f);
95
+ return {
96
+ sql: `SELECT actor, permission, COUNT(*) AS count,` +
97
+ ` COUNT(DISTINCT operation) AS operations,` +
98
+ ` MIN(at) AS first_at, MAX(at) AS last_at, MAX(id) AS last_id` +
99
+ ` FROM _substrat_denials${w.clause}` +
100
+ ` GROUP BY actor, permission ORDER BY count DESC, last_id DESC LIMIT ?`,
101
+ params: [...w.params, f.limit ?? DEFAULT_DENIAL_LIMIT],
102
+ };
103
+ }
104
+ export function mapDenialBucketRow(row) {
105
+ return {
106
+ actor: JSON.parse(row.actor),
107
+ permission: row.permission,
108
+ count: Number(row.count),
109
+ operations: Number(row.operations),
110
+ firstAt: row.first_at,
111
+ lastAt: row.last_at,
112
+ };
113
+ }
114
+ /** Totals for the FILTERED set — what the capped bucket list is a page of. */
115
+ export function denialTotalsQuery(filter) {
116
+ const f = denialFilter.parse(filter ?? {});
117
+ const w = where(f);
118
+ return {
119
+ sql: `SELECT COUNT(*) AS total, COUNT(DISTINCT actor) AS actors FROM _substrat_denials${w.clause}`,
120
+ params: w.params,
121
+ };
122
+ }
123
+ /**
124
+ * Facts about the WINDOW, filter ignored — deliberately.
125
+ *
126
+ * These describe the log, not the query. A caller reading an empty filtered result
127
+ * needs to know whether the log reaches back past the interval it asked about, because
128
+ * rows here drain rather than expire (K-24's split) and until a Tier-2 sink exists the
129
+ * window simply IS the retention. Reporting the floor is what stops absence being read
130
+ * as "this never happened" — K-35 calls that a stated limitation, so the surface states it.
131
+ */
132
+ export const DENIAL_WINDOW_QUERY = 'SELECT MIN(at) AS oldest_at, MAX(at) AS newest_at,' +
133
+ ' SUM(CASE WHEN drained_at IS NOT NULL THEN 1 ELSE 0 END) AS drained' +
134
+ ' FROM _substrat_denials';
135
+ //# sourceMappingURL=denial-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"denial-query.js","sourceRoot":"","sources":["../src/denial-query.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,115 @@
1
+ import { type EntityRef } from '@substrat-run/contracts';
2
+ /**
3
+ * An entity's version — the ULID of the last event about it (#901).
4
+ *
5
+ * ## There is no version column, and there should not be one
6
+ *
7
+ * This began as a `_version INTEGER` on every entity table, bumped by a trigger
8
+ * the DDL emitter would ship per table. That design works, and it was rejected
9
+ * for two reasons that are not about cost.
10
+ *
11
+ * It would have put a platform guarantee into per-vertical DDL. The only way to
12
+ * make a column unforgettable is a trigger, and a trigger is SQLite — replicated
13
+ * into every table, re-derived for every vertical authored afterwards, and
14
+ * unavailable to any adapter that is not SQLite. The scope-host contract is not
15
+ * a SQL contract; `query`/`exec` are how *these* adapters happen to serve it.
16
+ *
17
+ * And the version already existed. `_substrat_outbox` has recorded `entity_type`
18
+ * and `entity_id` against a monotonic ULID `id` since it was written, on every
19
+ * event, for every module. Nothing needed to be added to a row.
20
+ *
21
+ * ## Why the ULID is a sound version
22
+ *
23
+ * - **Monotonic and exactly comparable.** `ulid()` uses the spec's monotonic
24
+ * factory, and its header names that load-bearing precisely because this table
25
+ * orders by id: two events in one millisecond still sort in creation order.
26
+ * - **Never pruned.** The outbox DRAINS, it does not expire (K-24's split) —
27
+ * `drained_at` marks a shipped row and nothing deletes it.
28
+ * - **Survives erasure.** A shred nulls `payload` and keeps the row, so an
29
+ * erased entity can still refuse a stale write. A version that vanished with
30
+ * the data would fail open at exactly the wrong moment.
31
+ * - **Unforgeable.** Module code cannot write `_substrat_*` (rule 3). A column
32
+ * would have had to earn that property with a trigger clever enough to reset a
33
+ * forged value; the spine has it by construction.
34
+ *
35
+ * ## What it is sensitive to
36
+ *
37
+ * ANY event about the entity moves the version, including one that changed
38
+ * nothing the caller read. A precondition built on this is therefore
39
+ * conservative: it can refuse a write that would in fact have been safe, and it
40
+ * cannot admit one that would not. That is the correct direction to fail, and it
41
+ * is a real difference from a per-row counter — documented here rather than
42
+ * discovered by someone debugging a 412 they think is spurious.
43
+ *
44
+ * The converse is the one hole worth naming: a mutation that emits no event does
45
+ * not move the version. "Every mutation emits a fat event" is a rule that review
46
+ * enforces and `boundary-lint` does not, so the answer is not here — it is that
47
+ * a declared `concurrency` must be compile-checked against the operation's
48
+ * declared `emits` (#129), which is strictly more than a trigger would have
49
+ * given: a trigger guarantees the column moved, never that the operation
50
+ * announced what it did.
51
+ */
52
+ /** The opaque version token. A ULID, but callers compare it — they do not read it. */
53
+ export type EntityVersion = string;
54
+ /**
55
+ * The one SELECT behind every read of an entity's version.
56
+ *
57
+ * Both adapters call this rather than writing the query twice, for the same
58
+ * reason `platformRequestHistoryQuery` exists: two surfaces answering one
59
+ * question from one table must not drift on what the answer means.
60
+ *
61
+ * `MAX(id)` with no `GROUP BY` always returns exactly one row — the value is
62
+ * NULL when the entity has no events. So absence is a null column, never a
63
+ * missing row, and the caller distinguishes "never touched" from "touched" on
64
+ * the value alone.
65
+ */
66
+ export declare function entityVersionQuery(ref: EntityRef): {
67
+ sql: string;
68
+ params: [string, string];
69
+ };
70
+ /**
71
+ * The index that makes the query a seek instead of a scan.
72
+ *
73
+ * Column order matters and is not arbitrary: the two equality predicates come
74
+ * first, and `id` last so SQLite answers `MAX(id)` by walking to the end of the
75
+ * matched range rather than aggregating over it. Emitted into both adapters'
76
+ * spine DDL — kernel-owned, so no vertical carries a migration for it.
77
+ *
78
+ * The outbox had no index at all before this. Every other spine table with a
79
+ * filtered read has one (`_substrat_access_log_actor`, `_substrat_admin_log_scope`,
80
+ * and the rest); the outbox was only ever read by drain order, which is its
81
+ * primary key.
82
+ */
83
+ export declare const OUTBOX_ENTITY_INDEX = "CREATE INDEX IF NOT EXISTS _substrat_outbox_entity ON _substrat_outbox (entity_type, entity_id, id);";
84
+ /** The row `entityVersionQuery` returns — one row, `version` null when there are no events. */
85
+ export interface EntityVersionRow {
86
+ readonly version: string | null;
87
+ }
88
+ /** Normalise the single row into the contract's answer. */
89
+ export declare function entityVersionOf(rows: readonly EntityVersionRow[]): EntityVersion | null;
90
+ /**
91
+ * Refuse a write whose caller is holding a stale version (#129).
92
+ *
93
+ * Both adapters call this rather than comparing twice, for the reason
94
+ * `entityVersionQuery` exists one screen up: two surfaces answering one question
95
+ * must not drift on what the answer means. Here the answer decides whether a
96
+ * write lands, so drifting would mean one host silently admitting what the other
97
+ * refuses.
98
+ *
99
+ * ## This must be called INSIDE the operation's transaction
100
+ *
101
+ * A precondition read outside the write's transaction is a time-of-check /
102
+ * time-of-use bug wearing a safety mechanism's clothes: the version is read, a
103
+ * concurrent writer commits, and then the guarded write commits over it having
104
+ * "passed". Serialising the read with the write is the entire guarantee, so this
105
+ * takes a version the CALLER has already read under `BEGIN`, and cannot do the
106
+ * read itself without inviting the mistake.
107
+ *
108
+ * ## The refusal deliberately carries no version
109
+ *
110
+ * See `PROBLEM_EXTENSIONS.precondition_failed`. Handing the current tag back
111
+ * turns the obvious client fix into a blind retry that overwrites the change
112
+ * which caused the refusal.
113
+ */
114
+ export declare function assertIfMatch(ref: EntityRef, ifMatch: string, version: EntityVersion | null): void;
115
+ //# sourceMappingURL=entity-version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-version.d.ts","sourceRoot":"","sources":["../src/entity-version.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,KAAK,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEvF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,sFAAsF;AACtF,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC;AAEnC;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,SAAS,GAAG;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1B,CAKA;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,yGACwE,CAAC;AAEzG,+FAA+F;AAC/F,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,gBAAgB,EAAE,GAAG,aAAa,GAAG,IAAI,CAEvF;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,GAAG,IAAI,CAUlG"}
@@ -0,0 +1,70 @@
1
+ import { ifMatchAdmits, substratError } from '@substrat-run/contracts';
2
+ /**
3
+ * The one SELECT behind every read of an entity's version.
4
+ *
5
+ * Both adapters call this rather than writing the query twice, for the same
6
+ * reason `platformRequestHistoryQuery` exists: two surfaces answering one
7
+ * question from one table must not drift on what the answer means.
8
+ *
9
+ * `MAX(id)` with no `GROUP BY` always returns exactly one row — the value is
10
+ * NULL when the entity has no events. So absence is a null column, never a
11
+ * missing row, and the caller distinguishes "never touched" from "touched" on
12
+ * the value alone.
13
+ */
14
+ export function entityVersionQuery(ref) {
15
+ return {
16
+ sql: 'SELECT MAX(id) AS version FROM _substrat_outbox WHERE entity_type = ? AND entity_id = ?',
17
+ params: [ref.entityType, ref.entityId],
18
+ };
19
+ }
20
+ /**
21
+ * The index that makes the query a seek instead of a scan.
22
+ *
23
+ * Column order matters and is not arbitrary: the two equality predicates come
24
+ * first, and `id` last so SQLite answers `MAX(id)` by walking to the end of the
25
+ * matched range rather than aggregating over it. Emitted into both adapters'
26
+ * spine DDL — kernel-owned, so no vertical carries a migration for it.
27
+ *
28
+ * The outbox had no index at all before this. Every other spine table with a
29
+ * filtered read has one (`_substrat_access_log_actor`, `_substrat_admin_log_scope`,
30
+ * and the rest); the outbox was only ever read by drain order, which is its
31
+ * primary key.
32
+ */
33
+ export const OUTBOX_ENTITY_INDEX = 'CREATE INDEX IF NOT EXISTS _substrat_outbox_entity ON _substrat_outbox (entity_type, entity_id, id);';
34
+ /** Normalise the single row into the contract's answer. */
35
+ export function entityVersionOf(rows) {
36
+ return rows[0]?.version ?? null;
37
+ }
38
+ /**
39
+ * Refuse a write whose caller is holding a stale version (#129).
40
+ *
41
+ * Both adapters call this rather than comparing twice, for the reason
42
+ * `entityVersionQuery` exists one screen up: two surfaces answering one question
43
+ * must not drift on what the answer means. Here the answer decides whether a
44
+ * write lands, so drifting would mean one host silently admitting what the other
45
+ * refuses.
46
+ *
47
+ * ## This must be called INSIDE the operation's transaction
48
+ *
49
+ * A precondition read outside the write's transaction is a time-of-check /
50
+ * time-of-use bug wearing a safety mechanism's clothes: the version is read, a
51
+ * concurrent writer commits, and then the guarded write commits over it having
52
+ * "passed". Serialising the read with the write is the entire guarantee, so this
53
+ * takes a version the CALLER has already read under `BEGIN`, and cannot do the
54
+ * read itself without inviting the mistake.
55
+ *
56
+ * ## The refusal deliberately carries no version
57
+ *
58
+ * See `PROBLEM_EXTENSIONS.precondition_failed`. Handing the current tag back
59
+ * turns the obvious client fix into a blind retry that overwrites the change
60
+ * which caused the refusal.
61
+ */
62
+ export function assertIfMatch(ref, ifMatch, version) {
63
+ if (ifMatchAdmits(ifMatch, version))
64
+ return;
65
+ throw substratError('precondition_failed', version === null
66
+ ? `${ref.entityType} ${ref.entityId} has no recorded version — it may never have existed, ` +
67
+ 'or the write you are holding a tag for was rolled back'
68
+ : `${ref.entityType} ${ref.entityId} changed since you read it`, { entity: ref });
69
+ }
70
+ //# sourceMappingURL=entity-version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-version.js","sourceRoot":"","sources":["../src/entity-version.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAkB,MAAM,yBAAyB,CAAC;AAwDvF;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAc;IAI/C,OAAO;QACL,GAAG,EAAE,yFAAyF;QAC9F,MAAM,EAAE,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,QAAQ,CAAC;KACvC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAC9B,sGAAsG,CAAC;AAOzG,2DAA2D;AAC3D,MAAM,UAAU,eAAe,CAAC,IAAiC;IAC/D,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,IAAI,IAAI,CAAC;AAClC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,aAAa,CAAC,GAAc,EAAE,OAAe,EAAE,OAA6B;IAC1F,IAAI,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC;QAAE,OAAO;IAC5C,MAAM,aAAa,CACjB,qBAAqB,EACrB,OAAO,KAAK,IAAI;QACd,CAAC,CAAC,GAAG,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC,QAAQ,wDAAwD;YACzF,wDAAwD;QAC1D,CAAC,CAAC,GAAG,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC,QAAQ,4BAA4B,EACjE,EAAE,MAAM,EAAE,GAAG,EAAE,CAChB,CAAC;AACJ,CAAC"}
@@ -0,0 +1,143 @@
1
+ import { type CheckSubject } from '@substrat-run/contracts';
2
+ /**
3
+ * The spine half of request idempotency (#116) — what remembers a key, and what
4
+ * a second request carrying it is answered with.
5
+ *
6
+ * `@substrat-run/contracts` owns the wire (the header names, what makes a key
7
+ * well-formed, what makes two requests the same request); this owns the table
8
+ * and the decisions read off it. Both adapters call these rather than writing
9
+ * the SQL twice, for the reason `entityVersionQuery` gives one file over: two
10
+ * surfaces answering one question must not drift on what the answer means. Here
11
+ * the answer decides whether work happens at all.
12
+ *
13
+ * ## Recorded INSIDE the operation's transaction, which is the whole design
14
+ *
15
+ * The row is written in the same transaction as the work it describes, after the
16
+ * handler and before `COMMIT`. Three properties fall out of that one placement,
17
+ * and none of them needed a mechanism of its own:
18
+ *
19
+ * - **A failed request is retried, not replayed.** The operation threw, the
20
+ * transaction rolled back, and the row went with it. There is nothing to find,
21
+ * so the retry executes — which is correct, because nothing happened the first
22
+ * time. Recording failures would have meant deciding which of them are
23
+ * permanent, and that is a judgement no generic layer can make.
24
+ * - **A replayed response describes work that actually committed.** The row and
25
+ * the rows it is about are the same transaction; there is no window in which
26
+ * one exists without the other.
27
+ * - **The dedupe cannot be defeated by a concurrent retry.** Invokes serialise
28
+ * per scope in both adapters, so the second request takes its turn after the
29
+ * first has committed — no in-flight state, no "still processing" 409.
30
+ *
31
+ * ## What a replay is NOT
32
+ *
33
+ * It is not a fresh authorization. The recorded response is returned without
34
+ * running the handler, and the permission check lives inside the handler — so a
35
+ * caller whose access was revoked in the last 24 hours can still replay their own
36
+ * response. This is bounded by the two things that make it defensible: the row is
37
+ * keyed by the SUBJECT, so a caller can only ever reach responses they themselves
38
+ * received, and the window is a day. It is stated here rather than discovered,
39
+ * because the alternative — re-running the operation to re-check the permission —
40
+ * is the duplicate execution this feature exists to prevent.
41
+ */
42
+ /**
43
+ * The dedupe table, kernel-owned so that no vertical carries a migration for it.
44
+ *
45
+ * Keyed `(subject, key)` rather than `(key)`, and that is a safety property, not
46
+ * a namespacing convenience: a key is a string a client chose, two clients will
47
+ * choose `1`, and a lookup that found the other one's row would replay a response
48
+ * across a principal boundary. With the subject in the primary key that is not a
49
+ * check that could be forgotten — it is a row that cannot be reached.
50
+ *
51
+ * `operation` is stored as well as hashed into `fingerprint`. The hash is what
52
+ * decides a mismatch; the column is what makes the table readable when someone is
53
+ * working out why a client is getting 409s.
54
+ */
55
+ export declare const IDEMPOTENCY_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_idempotency (\n subject TEXT NOT NULL,\n key TEXT NOT NULL,\n operation TEXT NOT NULL,\n -- SHA-256 over (operation, parsed input). A second request under this key\n -- whose fingerprint differs is a REUSE, refused with 409 \u2014 never served the\n -- first request's response, which is a different request's answer.\n fingerprint TEXT NOT NULL,\n -- The operation's return value as JSON. NULL means one of two things, which\n -- `oversized` separates: the operation returned nothing, or the result was\n -- too large to record and a replay must be refused rather than re-executed.\n result TEXT,\n oversized INTEGER NOT NULL DEFAULT 0,\n -- #129's tag, replayed with the body so a retry hands the client the same\n -- ETag the original did. Without it a replayed response has no validator and\n -- the client's next conditional write has nothing to send.\n entity_version TEXT,\n recorded_at TEXT NOT NULL,\n PRIMARY KEY (subject, key)\n );\n CREATE INDEX IF NOT EXISTS _substrat_idempotency_recorded\n ON _substrat_idempotency (recorded_at);\n";
56
+ /** The stored row, as both adapters' SQL returns it. */
57
+ export interface IdempotencyRow {
58
+ readonly operation: string;
59
+ readonly fingerprint: string;
60
+ readonly result: string | null;
61
+ readonly oversized: number;
62
+ readonly entity_version: string | null;
63
+ }
64
+ /** What a replay answers with: the recorded return value and the recorded tag. */
65
+ export interface IdempotentReplay {
66
+ readonly result: unknown;
67
+ readonly entityVersion: string | null;
68
+ }
69
+ /** The subject a key is scoped to, in the form the column stores. */
70
+ export declare function idempotencySubject(subject: CheckSubject): string;
71
+ /**
72
+ * Refuse a malformed key at the door.
73
+ *
74
+ * `validation_failed` rather than `conflict`: nothing is in conflict, the caller
75
+ * sent a header we cannot store. Refused rather than ignored, for the reason the
76
+ * `If-Match` path gives — a caller who believes their retry is safe and whose
77
+ * key was silently dropped is in exactly the position this feature exists to
78
+ * prevent, arrived at through the feature itself.
79
+ */
80
+ export declare function assertIdempotencyKey(key: string): void;
81
+ /** The lookup a retry is answered from. */
82
+ export declare function idempotencyLookupQuery(subject: CheckSubject, key: string): {
83
+ sql: string;
84
+ params: [string, string];
85
+ };
86
+ /**
87
+ * Decide what a second request under this key gets.
88
+ *
89
+ * Two refusals and one replay, both refusals `conflict` (409) with a reason slug
90
+ * this feature owns:
91
+ *
92
+ * - **Reuse.** Same key, different request. The client's assertion that this is
93
+ * the request it sent before is false, and the one thing that must not happen
94
+ * is serving the earlier request's response to it.
95
+ * - **Unavailable.** The original response was too large to record. Refused
96
+ * rather than re-executed, which is the fail-closed direction: an error the
97
+ * caller can act on, instead of the duplicate work the key was sent to avoid.
98
+ */
99
+ export declare function replayFor(key: string, fingerprint: string, row: IdempotencyRow): IdempotentReplay;
100
+ /**
101
+ * The row a completed operation leaves behind.
102
+ *
103
+ * Serialisation happens here rather than at each call site so the size decision
104
+ * has one home: over `IDEMPOTENCY_RESULT_LIMIT` the body is dropped and the key
105
+ * is recorded as oversized, which is what makes a later replay a refusal instead
106
+ * of a silent re-execution.
107
+ *
108
+ * `undefined` and `null` results are both stored as a NULL body with
109
+ * `oversized = 0`; a replay returns `undefined` for either. An operation whose
110
+ * return value a caller distinguishes on that difference has a bigger problem
111
+ * than this table.
112
+ */
113
+ export declare function idempotencyRecordStatement(subject: CheckSubject, key: string, operation: string, fingerprint: string, result: unknown, entityVersion: string | null, at: string): {
114
+ sql: string;
115
+ params: [string, string, string, string, string | null, number, string | null, string];
116
+ };
117
+ /**
118
+ * Age rows out, in the same transaction as the write that added one.
119
+ *
120
+ * Opportunistic rather than swept, deliberately. A sweeper would be a second
121
+ * schedule, a second failure mode and a second thing to deploy, for a table whose
122
+ * only writer is already here holding a transaction open. Bounded work: the
123
+ * `recorded_at` index makes it a range delete, and it runs only on an invocation
124
+ * that carried a key — so a fleet that never uses the feature never pays for it.
125
+ *
126
+ * The consequence worth stating: a scope that stops receiving keyed requests
127
+ * keeps its last rows past the window. They are inert (nothing reads a row
128
+ * without a key that matches it) and the next keyed request clears them.
129
+ */
130
+ export declare function idempotencyPruneStatement(now: string): {
131
+ sql: string;
132
+ params: [string];
133
+ };
134
+ /**
135
+ * The refusal an operation that declared `idempotency: false` answers a key with.
136
+ *
137
+ * Not a `conflict` — nothing conflicts — and not silence, which is the failure
138
+ * mode every branch of this feature is written to avoid. The operation opted out
139
+ * because its response must not be recorded; a caller who sent a key and got a
140
+ * 200 would believe a retry is safe when the second one will execute again.
141
+ */
142
+ export declare function idempotencyOptedOutMessage(operation: string): string;
143
+ //# sourceMappingURL=idempotency.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,YAAY,EAClB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,+nCAuB3B,CAAC;AAEF,wDAAwD;AACxD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC;AAED,kFAAkF;AAClF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AAED,qEAAqE;AACrE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAEhE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAMtD;AAED,2CAA2C;AAC3C,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,GACV;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAO3C;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,cAAc,GAClB,gBAAgB,CAsBlB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,EAAE,EAAE,MAAM,GACT;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,CAAA;CAAE,CAmBzG;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,CAAC,CAAA;CAAE,CAMxF;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAKpE"}