@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.
- package/dist/denial-query.d.ts +84 -0
- package/dist/denial-query.d.ts.map +1 -0
- package/dist/denial-query.js +135 -0
- package/dist/denial-query.js.map +1 -0
- package/dist/entity-version.d.ts +115 -0
- package/dist/entity-version.d.ts.map +1 -0
- package/dist/entity-version.js +70 -0
- package/dist/entity-version.js.map +1 -0
- package/dist/idempotency.d.ts +143 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +197 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/scope-host.d.ts +203 -2
- package/dist/scope-host.d.ts.map +1 -1
- package/dist/scope-host.js +7 -0
- package/dist/scope-host.js.map +1 -1
- package/dist/timeline.d.ts +97 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +108 -0
- package/dist/timeline.js.map +1 -0
- package/package.json +2 -2
|
@@ -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"}
|