@kedge/testing 0.0.0-stage → 0.10.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,308 @@
1
+ // The invariant suite (K11): one line in a merchant's tests,
2
+ // `describeKedgeInvariants(() => env.DB)`, never edited. After every test it
3
+ // re-derives what the triggers keep and compares it with what's stored, so a
4
+ // plugin or local migration that bypasses a guarantee fails the build, not a
5
+ // store. Each check is a query returning the offending rows.
6
+ import { allocCostSql, releaseCostSql } from "@kedge/db";
7
+
8
+ interface Check {
9
+ name: string;
10
+ /** Why this matters, shown with the offending rows. */
11
+ rule: string;
12
+ sql: string;
13
+ }
14
+
15
+ export const INVARIANTS: readonly Check[] = [
16
+ {
17
+ name: "level_reserved_matches_reservations",
18
+ rule: "inventory_level.reserved equals its active reservations (D-027)",
19
+ sql: `SELECT l.inventory_item_id, l.location_id, l.reserved, COALESCE(s.q, 0) AS active
20
+ FROM inventory_level l LEFT JOIN (SELECT inventory_item_id, location_id, SUM(quantity) AS q
21
+ FROM inventory_reservation WHERE released_at IS NULL GROUP BY 1, 2) s
22
+ ON s.inventory_item_id = l.inventory_item_id AND s.location_id = l.location_id
23
+ WHERE l.reserved != COALESCE(s.q, 0)`,
24
+ },
25
+ {
26
+ name: "facts_backed",
27
+ rule: "every contribution identity of a committed order is backed exactly as declared (K2)",
28
+ sql: `SELECT f.id, f.plugin, f.cardinality, f.backed_count FROM contribution_fact f
29
+ JOIN contribution_check c ON c.order_id = f.order_id WHERE f.backed_count != f.cardinality`,
30
+ },
31
+ {
32
+ name: "totals_match_rows",
33
+ rule: "an order's stored totals equal its lines, fees, adjustments and tax lines; only exclusive tax adds to the total (P0.3 §3.2)",
34
+ sql: `SELECT o.id FROM "order" o WHERE
35
+ o.fee_total != (SELECT COALESCE(sum(amount), 0) FROM order_fee WHERE order_id = o.id)
36
+ OR o.discount_total != -(SELECT COALESCE(sum(amount), 0) FROM order_adjustment WHERE order_id = o.id)
37
+ OR o.subtotal != (SELECT COALESCE(sum(unit_price * quantity), 0) FROM order_line WHERE order_id = o.id)
38
+ OR o.tax_total != (SELECT COALESCE(sum(amount), 0) FROM order_tax_line WHERE order_id = o.id)
39
+ OR o.total != o.subtotal - o.discount_total + o.shipping_total + o.fee_total
40
+ + (SELECT COALESCE(sum(amount), 0) FROM order_tax_line WHERE order_id = o.id AND inclusive = 0)`,
41
+ },
42
+ {
43
+ name: "class_totals",
44
+ rule: "revenue_total and deposit_total split the total by charge class (K4)",
45
+ sql: `SELECT o.id FROM "order" o WHERE o.revenue_total IS NULL OR o.deposit_total IS NULL
46
+ OR o.total != o.revenue_total + o.deposit_total
47
+ OR o.deposit_total != (SELECT COALESCE(sum(unit_price * quantity), 0) FROM order_line WHERE order_id = o.id AND charge_class = 'deposit')
48
+ + (SELECT COALESCE(sum(amount), 0) FROM order_fee WHERE order_id = o.id AND charge_class = 'deposit')
49
+ + (SELECT COALESCE(sum(amount), 0) FROM order_tax_line WHERE order_id = o.id AND inclusive = 0 AND charge_class = 'deposit')`,
50
+ },
51
+ {
52
+ name: "discounts_on_discountable_revenue",
53
+ rule: "discounts are allocated to revenue lines whose kind is discountable only (K4)",
54
+ sql: `SELECT x.adjustment_id, x.order_line_id FROM order_line_adjustment x JOIN order_line l ON l.id = x.order_line_id
55
+ WHERE l.discountable = 0 OR l.charge_class != 'revenue'`,
56
+ },
57
+ {
58
+ name: "per_parent_quantities",
59
+ rule: "a per_parent line's quantity is its parent's times its multiplier (P0.3 §1.3)",
60
+ sql: `SELECT c.id FROM order_line c JOIN order_line p ON p.id = c.parent_line_id
61
+ WHERE c.quantity_mode = 'per_parent' AND c.quantity != p.quantity * c.quantity_per_parent
62
+ UNION ALL
63
+ SELECT c.id FROM order_line c LEFT JOIN order_line p ON p.id = c.parent_line_id
64
+ WHERE c.parent_line_id IS NOT NULL AND (p.id IS NULL OR p.order_id != c.order_id OR p.parent_line_id IS NOT NULL)`,
65
+ },
66
+ {
67
+ name: "tax_lines_owed",
68
+ rule: "every taxable line and fee with a positive rate in its order's context has exactly its tax line, with the formula's amount (P0.3 §3.3)",
69
+ sql: `SELECT z.id FROM (
70
+ SELECT l.id, l.taxable, l.charge_class AS cls, o.tax_inclusive AS incl,
71
+ CASE l.charge_class WHEN 'revenue' THEN o.tax_rate_revenue_ppm ELSE o.tax_rate_deposit_ppm END AS rate,
72
+ l.unit_price * l.quantity + (SELECT COALESCE(sum(amount), 0) FROM order_line_adjustment WHERE order_line_id = l.id) AS base,
73
+ t.id AS tid, t.rate_ppm AS trate, t.inclusive AS tincl, t.charge_class AS tcls, t.amount AS tamount
74
+ FROM order_line l JOIN "order" o ON o.id = l.order_id LEFT JOIN order_tax_line t ON t.order_line_id = l.id
75
+ UNION ALL
76
+ SELECT f.id, f.taxable, f.charge_class, o.tax_inclusive,
77
+ CASE f.charge_class WHEN 'revenue' THEN o.tax_rate_revenue_ppm ELSE o.tax_rate_deposit_ppm END,
78
+ f.amount, t.id, t.rate_ppm, t.inclusive, t.charge_class, t.amount
79
+ FROM order_fee f JOIN "order" o ON o.id = f.order_id LEFT JOIN order_tax_line t ON t.order_fee_id = f.id) z
80
+ WHERE CASE WHEN z.taxable = 1 AND z.rate > 0 THEN
81
+ z.tid IS NULL OR z.trate != z.rate OR z.tincl != z.incl OR z.tcls != z.cls
82
+ OR z.tamount != (z.base / (1000000 + z.incl * z.rate)) * z.rate
83
+ + ((z.base % (1000000 + z.incl * z.rate)) * z.rate * 2 + (1000000 + z.incl * z.rate))
84
+ / (2 * (1000000 + z.incl * z.rate))
85
+ ELSE z.tid IS NOT NULL END`,
86
+ },
87
+ {
88
+ name: "orders_sealed",
89
+ rule: "every order has its seal and the obligation that forced it (P0.3 §1.7)",
90
+ sql: `SELECT o.id FROM "order" o WHERE NOT EXISTS (SELECT 1 FROM contribution_check c WHERE c.order_id = o.id)
91
+ OR NOT EXISTS (SELECT 1 FROM order_seal_due d WHERE d.order_id = o.id)`,
92
+ },
93
+ {
94
+ name: "write_once_covers_columns",
95
+ rule: "the order's write-once guard names every column but status, so a column a later migration adds is frozen until its slice says otherwise (P0.3 §1.7)",
96
+ sql: `SELECT c.name FROM pragma_table_info('order') c
97
+ WHERE c.name != 'status'
98
+ AND instr(COALESCE((SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'order_write_once'), ''),
99
+ 'NEW.' || c.name || ' IS NOT OLD.' || c.name) = 0`,
100
+ },
101
+ {
102
+ name: "acceptances_in_order_locale",
103
+ rule: "an acceptance's revision is in its order's locale, one per policy (P0.3 §5.2)",
104
+ sql: `SELECT a.id FROM policy_acceptance a JOIN policy_revision r ON r.id = a.revision_id JOIN "order" o ON o.id = a.order_id
105
+ WHERE r.locale != o.locale
106
+ UNION ALL
107
+ SELECT min(a.id) FROM policy_acceptance a JOIN policy_revision r ON r.id = a.revision_id
108
+ GROUP BY a.order_id, r.policy HAVING count(*) > 1`,
109
+ },
110
+ {
111
+ name: "adjustments_allocated",
112
+ rule: "each adjustment is fully allocated to lines of its own order, and no line goes below zero (D-029)",
113
+ sql: `SELECT a.id FROM order_adjustment a
114
+ WHERE a.amount != (SELECT COALESCE(sum(amount), 0) FROM order_line_adjustment WHERE adjustment_id = a.id)
115
+ UNION ALL
116
+ SELECT l.id FROM order_line l
117
+ WHERE l.unit_price * l.quantity + (SELECT COALESCE(sum(amount), 0) FROM order_line_adjustment WHERE order_line_id = l.id) < 0
118
+ UNION ALL
119
+ SELECT x.adjustment_id FROM order_line_adjustment x
120
+ JOIN order_adjustment a ON a.id = x.adjustment_id JOIN order_line l ON l.id = x.order_line_id
121
+ WHERE a.order_id != l.order_id`,
122
+ },
123
+ {
124
+ name: "status_matches_history",
125
+ rule: "an order's status is the last state its transition rows reached (K1)",
126
+ sql: `SELECT o.id, o.status, t.to_state FROM "order" o
127
+ JOIN transition t ON t.id = (SELECT max(id) FROM transition WHERE aggregate = 'order' AND aggregate_id = o.id)
128
+ WHERE t.to_state != o.status`,
129
+ },
130
+ {
131
+ name: "placed_orders_have_history",
132
+ rule: "an order that left pending_payment has a transition row for it (K1)",
133
+ sql: `SELECT o.id FROM "order" o WHERE o.status != 'pending_payment'
134
+ AND NOT EXISTS (SELECT 1 FROM transition t WHERE t.aggregate = 'order' AND t.aggregate_id = o.id)`,
135
+ },
136
+ {
137
+ name: "releases_reach_participants",
138
+ rule: "every release transition owes one obligation to each participant that had a handler for it (K1)",
139
+ sql: `SELECT t.uid, p.plugin FROM transition t JOIN plugin_participation p ON p.order_id = t.aggregate_id
140
+ WHERE t.aggregate = 'order' AND t.to_state IN ('expired', 'canceled')
141
+ AND (',' || p.releases || ',') LIKE ('%,' || t.to_state || ',%')
142
+ AND NOT EXISTS (SELECT 1 FROM release_obligation r WHERE r.plugin = p.plugin AND r.transition_uid = t.uid)`,
143
+ },
144
+ {
145
+ name: "participants_installed",
146
+ rule: "only installed plugins take part in orders (§4.1)",
147
+ sql: `SELECT p.plugin, p.order_id FROM plugin_participation p
148
+ WHERE NOT EXISTS (SELECT 1 FROM plugin_state s WHERE s.plugin = p.plugin)`,
149
+ },
150
+ {
151
+ name: "fact_guards_installed",
152
+ rule: "every table holding plugin facts still has core's generated guards (K2): a rebuilt table loses its triggers",
153
+ sql: `SELECT DISTINCT f.plugin, f.fact_table FROM contribution_fact f WHERE f.fact_table IS NOT NULL
154
+ AND (SELECT count(*) FROM sqlite_master m WHERE m.type = 'trigger'
155
+ AND m.name IN ('kedge_' || f.plugin || '_' || f.fact_table || '_fact_guard',
156
+ 'kedge_' || f.plugin || '_' || f.fact_table || '_fact_backed',
157
+ 'kedge_' || f.plugin || '_' || f.fact_table || '_fact_immutable',
158
+ 'kedge_' || f.plugin || '_' || f.fact_table || '_fact_no_delete')) != 4`,
159
+ },
160
+ // P0.4 (dated-capacity.md §2.2): each derived state against its one rebuild
161
+ // query, and the recorded facts one-directionally.
162
+ {
163
+ name: "bucket_held_matches_holds",
164
+ rule: "capacity_bucket.held is the sum of active holds covering it, and every active hold's buckets exist (§2.2)",
165
+ sql: `SELECT b.pool_id, b.bucket, b.held FROM capacity_bucket b
166
+ WHERE b.held != (SELECT coalesce(sum(h.qty), 0) FROM capacity_hold h WHERE h.pool_id = b.pool_id
167
+ AND h.released_at IS NULL AND h.first_bucket <= b.bucket AND h.last_bucket >= b.bucket)
168
+ UNION ALL
169
+ SELECT h.pool_id, h.id, NULL FROM capacity_hold h WHERE h.released_at IS NULL
170
+ AND (SELECT count(*) FROM capacity_bucket b WHERE b.pool_id = h.pool_id AND b.bucket BETWEEN h.first_bucket AND h.last_bucket)
171
+ != h.last_bucket - h.first_bucket + 1`,
172
+ },
173
+ {
174
+ name: "capacity_base_canonical",
175
+ rule: "capacity_base is one row at bucket 0 and one per distinct active change at or after the watermark, each the initial base plus the changes up to it (§2.2)",
176
+ sql: `WITH expected(pool_id, from_bucket) AS (
177
+ SELECT id, 0 FROM capacity_pool
178
+ UNION
179
+ SELECT c.pool_id, c.from_bucket FROM capacity_base_change c JOIN capacity_pool p ON p.id = c.pool_id
180
+ WHERE c.state = 'active' AND c.from_bucket >= p.compacted_before),
181
+ want AS (
182
+ SELECT e.pool_id, e.from_bucket, p.initial_base + coalesce((SELECT sum(c.delta) FROM capacity_base_change c
183
+ WHERE c.pool_id = e.pool_id AND c.state = 'active'
184
+ AND c.from_bucket <= max(e.from_bucket, p.compacted_before - 1)), 0) AS base
185
+ FROM expected e JOIN capacity_pool p ON p.id = e.pool_id)
186
+ -- Each direction in its own subquery: compound operators run left to right, so a bare
187
+ -- A EXCEPT B UNION ALL B EXCEPT A would drop A's missing rows again (codex code r14).
188
+ SELECT * FROM (SELECT pool_id, from_bucket, base FROM want EXCEPT SELECT pool_id, from_bucket, base FROM capacity_base)
189
+ UNION ALL
190
+ SELECT * FROM (SELECT pool_id, from_bucket, base FROM capacity_base EXCEPT SELECT pool_id, from_bucket, base FROM want)`,
191
+ },
192
+ {
193
+ name: "pending_expiry_rebuild",
194
+ rule: "pending_expiry is exactly the (resource, expires_at, order) of pending orders' active reservations and holds (§2.2)",
195
+ sql: `WITH want(k, r, e, o) AS (
196
+ SELECT 'item', x.inventory_item_id, o.expires_at, o.id FROM inventory_reservation x JOIN "order" o ON o.id = x.order_id
197
+ WHERE o.status = 'pending_payment' AND x.released_at IS NULL
198
+ UNION
199
+ SELECT 'pool', h.pool_id, o.expires_at, o.id FROM capacity_hold h JOIN "order" o ON o.id = h.order_id
200
+ WHERE o.status = 'pending_payment' AND h.released_at IS NULL)
201
+ SELECT * FROM (SELECT * FROM want EXCEPT SELECT resource_kind, resource_id, expires_at, order_id FROM pending_expiry)
202
+ UNION ALL
203
+ SELECT * FROM (SELECT resource_kind, resource_id, expires_at, order_id FROM pending_expiry EXCEPT SELECT * FROM want)`,
204
+ },
205
+ {
206
+ name: "retirement_queue_rebuild",
207
+ rule: "hold_retirement_due is exactly the active holds that are orderless or of completed orders (§2.2)",
208
+ sql: `WITH want(hold_id, pool_id, retire_after) AS (
209
+ SELECT h.id, h.pool_id, h.retire_after FROM capacity_hold h LEFT JOIN "order" o ON o.id = h.order_id
210
+ WHERE h.released_at IS NULL AND (h.order_id IS NULL OR o.status = 'completed'))
211
+ SELECT * FROM (SELECT * FROM want EXCEPT SELECT hold_id, pool_id, retire_after FROM hold_retirement_due)
212
+ UNION ALL
213
+ SELECT * FROM (SELECT hold_id, pool_id, retire_after FROM hold_retirement_due EXCEPT SELECT * FROM want)`,
214
+ },
215
+ {
216
+ name: "reopenable_pools_rebuild",
217
+ rule: "reopenable_hold_pool is exactly the pools of released holds on expired, reopenable orders (§2.2)",
218
+ sql: `WITH want(pool_id, order_id) AS (
219
+ SELECT DISTINCT h.pool_id, o.id FROM capacity_hold h JOIN "order" o ON o.id = h.order_id
220
+ WHERE o.status = 'expired' AND o.reopenable = 1 AND h.released_at IS NOT NULL)
221
+ SELECT * FROM (SELECT * FROM want EXCEPT SELECT pool_id, order_id FROM reopenable_hold_pool)
222
+ UNION ALL
223
+ SELECT * FROM (SELECT pool_id, order_id FROM reopenable_hold_pool EXCEPT SELECT * FROM want)`,
224
+ },
225
+ {
226
+ name: "order_costs_rebuild",
227
+ rule: "an order's release_cost and current_alloc_cost equal their rebuild queries (p0.4 §2.2)",
228
+ sql: `SELECT o.id, o.release_cost, o.current_alloc_cost FROM "order" o
229
+ WHERE o.release_cost != ${releaseCostSql("o.id")} OR o.current_alloc_cost != ${allocCostSql("o.id")}`,
230
+ },
231
+ {
232
+ name: "allocations_within_counts",
233
+ rule: "an order's active allocations never exceed what it committed: reservation_count and hold_count (§2.2)",
234
+ sql: `SELECT o.id FROM "order" o
235
+ WHERE (SELECT count(*) FROM inventory_reservation x WHERE x.order_id = o.id AND x.released_at IS NULL) > o.reservation_count
236
+ OR (SELECT count(*) FROM capacity_hold h WHERE h.order_id = o.id AND h.released_at IS NULL) > o.hold_count`,
237
+ },
238
+ {
239
+ name: "order_history_marked",
240
+ rule: "no live reservation, hold or component names an item whose order_history is 0 (§2)",
241
+ sql: `SELECT x.inventory_item_id FROM inventory_reservation x JOIN inventory_item i ON i.id = x.inventory_item_id
242
+ WHERE x.released_at IS NULL AND i.order_history = 0
243
+ UNION ALL
244
+ SELECT c.inventory_item_id FROM order_line_component c JOIN inventory_item i ON i.id = c.inventory_item_id
245
+ WHERE i.order_history = 0
246
+ UNION ALL
247
+ SELECT p.inventory_item_id FROM capacity_hold h JOIN capacity_pool p ON p.id = h.pool_id
248
+ JOIN inventory_item i ON i.id = p.inventory_item_id WHERE h.order_id IS NOT NULL AND i.order_history = 0`,
249
+ },
250
+ {
251
+ name: "allocations_match_tracking",
252
+ rule: "live reservations are on counted items and live order holds on dated items' pools (§2.1)",
253
+ sql: `SELECT x.id FROM inventory_reservation x JOIN inventory_item i ON i.id = x.inventory_item_id
254
+ WHERE x.released_at IS NULL AND i.tracking != 'count'
255
+ UNION ALL
256
+ SELECT h.id FROM capacity_hold h JOIN capacity_pool p ON p.id = h.pool_id JOIN inventory_item i ON i.id = p.inventory_item_id
257
+ WHERE h.released_at IS NULL AND p.kind = 'item' AND i.tracking != 'dated'`,
258
+ },
259
+ {
260
+ name: "money_cites_own_contributor",
261
+ rule: "fees and adjustments cite an identity of their own order and plugin, which participates (K2)",
262
+ sql: `SELECT m.id FROM (SELECT id, order_id, plugin FROM order_fee UNION ALL SELECT id, order_id, plugin FROM order_adjustment) m
263
+ WHERE NOT EXISTS (SELECT 1 FROM plugin_participation p WHERE p.order_id = m.order_id AND p.plugin = m.plugin)`,
264
+ },
265
+ ];
266
+
267
+ export interface Violation {
268
+ invariant: string;
269
+ rule: string;
270
+ rows: unknown[];
271
+ }
272
+
273
+ /** Runs every invariant query in one batch; returns the ones with offending rows. */
274
+ export async function checkInvariants(db: D1Database): Promise<Violation[]> {
275
+ const results = await db.batch(INVARIANTS.map((c) => db.prepare(c.sql)));
276
+ const violations = INVARIANTS.flatMap((c, i) => {
277
+ const rows = results[i]?.results ?? [];
278
+ return rows.length > 0 ? [{ invariant: c.name, rule: c.rule, rows }] : [];
279
+ });
280
+ // Counters agreeing isn't proof: each backed identity's fact row must exist
281
+ // in its table (a rebuilt table can lose rows while backed_count stays 1).
282
+ const tables = (
283
+ await db
284
+ .prepare("SELECT DISTINCT fact_table FROM contribution_fact WHERE fact_table IS NOT NULL")
285
+ .all<{ fact_table: string }>()
286
+ ).results.map((r) => r.fact_table);
287
+ for (const table of tables) {
288
+ if (!/^plg_[a-z0-9_]+$/.test(table)) {
289
+ violations.push({ invariant: "facts_exist", rule: "fact tables are plugin tables", rows: [{ table }] });
290
+ continue;
291
+ }
292
+ const missing = await db
293
+ .prepare(
294
+ `SELECT f.id FROM contribution_fact f WHERE f.fact_table = ? AND f.backed_count > 0
295
+ AND NOT EXISTS (SELECT 1 FROM "${table}" t WHERE t.contribution_fact_id = f.id)`,
296
+ )
297
+ .bind(table)
298
+ .all()
299
+ .catch((e: unknown) => ({ results: [{ error: String((e as Error)?.message ?? e) }] }));
300
+ if (missing.results.length > 0)
301
+ violations.push({
302
+ invariant: "facts_exist",
303
+ rule: "every backed contribution identity still has its fact row (K2)",
304
+ rows: missing.results,
305
+ });
306
+ }
307
+ return violations;
308
+ }
package/src/ports.ts ADDED
@@ -0,0 +1,207 @@
1
+ // Test doubles for P0.6a's journal tests (docs/proposals/p0.6a-relay-timers-effects.md
2
+ // §4.2, §10): a provider that deduplicates by key over a horizon, captures
3
+ // every call, and can lose a response, fail, stall, refuse as not executed or
4
+ // switch accounts. `executions` counts what the provider really did, so a test
5
+ // can tell one email from two whatever the journal recorded.
6
+
7
+ import {
8
+ definePort,
9
+ type EffectOperation,
10
+ KedgeProviderError,
11
+ type Observation,
12
+ type OperationCall,
13
+ type PortDefinition,
14
+ } from "@kedge/core";
15
+
16
+ const HOUR = 3_600_000;
17
+
18
+ export interface CapturedCall {
19
+ readonly operation: string;
20
+ readonly providerKey: string;
21
+ readonly input: Readonly<Record<string, unknown>>;
22
+ readonly expiresAt: number | null;
23
+ readonly at: number;
24
+ }
25
+
26
+ /** One fault, applied to the next matching call and then spent. */
27
+ export type Fault =
28
+ /** The provider executes, then the response is lost (a network error). */
29
+ | "lose_response"
30
+ /** The request never arrives (a network error before sending). */
31
+ | "fail_before_send"
32
+ /** The provider refuses without executing, and says so. */
33
+ | "not_executed"
34
+ /** The provider refuses for good. */
35
+ | "final"
36
+ /** The call waits until `release()`, then behaves normally. */
37
+ | "stall";
38
+
39
+ /** A simulated provider. Its clock is its own: a key is remembered for the horizon by it. */
40
+ export class ProviderDouble {
41
+ readonly calls: CapturedCall[] = [];
42
+ /** What the provider executed, by provider key: the count a test checks. */
43
+ readonly executions = new Map<string, number>();
44
+ /** The provider's dedupe store. */
45
+ readonly remembered = new Map<string, { result: Record<string, unknown>; at: number }>();
46
+ /** What an observation reports for a key, overriding what the provider knows. */
47
+ readonly observed = new Map<string, Observation | { kind: "awaiting" }>();
48
+ readonly clock = { now: 1_000_000 };
49
+ /** The account the port reports; change it to switch accounts. */
50
+ account = "acct_test_1";
51
+ /** Whether the provider can be observed at all. */
52
+ observable = true;
53
+ /** An observation that never answers and ignores its signal, as a stuck SDK call would. */
54
+ observeHangs = false;
55
+ /** A provider that can confirm absence: observing a key it never executed answers `not_executed`, not `unknown`. */
56
+ confirmsAbsence = false;
57
+ port!: PortDefinition;
58
+ private readonly faults: { f: Fault; operation: string | undefined }[] = [];
59
+ private releaseStall: (() => void) | null = null;
60
+
61
+ constructor(
62
+ readonly name: string,
63
+ readonly horizonMs: number,
64
+ ) {}
65
+
66
+ fault(f: Fault, operation?: string): void {
67
+ this.faults.push({ f, operation });
68
+ }
69
+ release(): void {
70
+ this.releaseStall?.();
71
+ }
72
+ /** How many times the provider really executed anything. */
73
+ get executed(): number {
74
+ return [...this.executions.values()].reduce((a, b) => a + b, 0);
75
+ }
76
+
77
+ private take(op: string): Fault | null {
78
+ const i = this.faults.findIndex((x) => x.operation === undefined || x.operation === op);
79
+ return i < 0 ? null : (this.faults.splice(i, 1)[0]?.f ?? null);
80
+ }
81
+
82
+ async run(
83
+ op: string,
84
+ call: OperationCall,
85
+ input: Readonly<Record<string, unknown>>,
86
+ make: () => Record<string, unknown>,
87
+ ): Promise<Record<string, unknown>> {
88
+ this.calls.push({
89
+ operation: op,
90
+ providerKey: call.providerKey,
91
+ input,
92
+ expiresAt: call.expiresAt,
93
+ at: this.clock.now,
94
+ });
95
+ let f = this.take(op);
96
+ if (f === "stall") {
97
+ await new Promise<void>((resolve) => {
98
+ this.releaseStall = resolve;
99
+ });
100
+ f = this.take(op);
101
+ }
102
+ if (f === "fail_before_send") throw new Error("network: connection reset before the request was sent");
103
+ if (f === "not_executed") throw new KedgeProviderError("rejected", { notExecuted: true });
104
+ if (f === "final") throw new KedgeProviderError("declined", { final: true });
105
+ // A fenced request past its own expiry is refused by the provider (§4.2).
106
+ if (call.expiresAt !== null && this.clock.now > call.expiresAt)
107
+ throw new KedgeProviderError("expired", { notExecuted: true });
108
+ const known = this.remembered.get(call.providerKey);
109
+ let result: Record<string, unknown>;
110
+ if (known && this.clock.now - known.at < this.horizonMs) result = known.result;
111
+ else {
112
+ result = make();
113
+ this.remembered.set(call.providerKey, { result, at: this.clock.now });
114
+ this.executions.set(call.providerKey, (this.executions.get(call.providerKey) ?? 0) + 1);
115
+ }
116
+ if (f === "lose_response") throw new Error("network: response lost");
117
+ return result;
118
+ }
119
+
120
+ async observe(providerKey: string): Promise<Observation> {
121
+ if (this.observeHangs) await new Promise(() => {});
122
+ const forced = this.observed.get(providerKey);
123
+ if (forced) return forced.kind === "awaiting" ? { kind: "unknown" } : forced;
124
+ if (!this.observable) return { kind: "unknown" };
125
+ const seen = this.remembered.get(providerKey);
126
+ if (seen) return { kind: "succeeded", result: seen.result };
127
+ return this.confirmsAbsence ? { kind: "not_executed" } : { kind: "unknown" };
128
+ }
129
+ }
130
+
131
+ let seq = 0;
132
+ const next = (p: string) => `${p}_${++seq}`;
133
+ /** Every code the doubles throw: a final refusal, a verified non-execution, a fenced request past its expiry. */
134
+ const FAILURES = ["declined", "rejected", "expired"];
135
+
136
+ function finish(d: ProviderDouble, deliveryClass: "effect" | "notification", operations: PortDefinition["operations"]) {
137
+ d.port = definePort({
138
+ name: d.name,
139
+ deliveryClass,
140
+ // In-memory doubles: synthetic, so only a test or preview Worker may run them (p0.6b §4.2).
141
+ synthetic: true,
142
+ credentialMode: async () => "test",
143
+ dedupe: { enforced: true, scope: "account", horizonMs: d.horizonMs },
144
+ binding: async () => ({ provider: d.name, account: d.account, installation: null, scope: "default" }),
145
+ operations,
146
+ });
147
+ return d;
148
+ }
149
+
150
+ /** An email provider: a 24-hour key horizon, `window` resends, observable by key (Resend-like). */
151
+ export function testEmailPort(
152
+ opts: { name?: string; horizonMs?: number; deliveryClass?: "effect" | "notification" } = {},
153
+ ): ProviderDouble {
154
+ const d = new ProviderDouble(opts.name ?? "test-email", opts.horizonMs ?? 24 * HOUR);
155
+ const send: EffectOperation = {
156
+ kind: "effect",
157
+ version: 1,
158
+ category: "message",
159
+ resend: { policy: "window", delayMs: 30_000 },
160
+ presentation: ["placed_at", "order_number"],
161
+ erasure: "optional",
162
+ classified: [{ field: "to", subject: "customer", scope: "email" }],
163
+ result: { message_id: "id" },
164
+ failures: FAILURES,
165
+ call: (input, call) => d.run("send", call, input, () => ({ message_id: next("msg") })),
166
+ observe: ({ providerKey }) => d.observe(providerKey),
167
+ };
168
+ return finish(d, opts.deliveryClass ?? "effect", { send });
169
+ }
170
+
171
+ /**
172
+ * A payment provider: a non-charging `initiate` under `window`, a `refund`
173
+ * under `never`, and an observation that can say awaiting, captured, not
174
+ * executed or nothing (Stripe-like: keys may be pruned after 24 hours).
175
+ */
176
+ export function testPaymentPort(opts: { name?: string; horizonMs?: number } = {}): ProviderDouble {
177
+ const d = new ProviderDouble(opts.name ?? "test-payment", opts.horizonMs ?? 24 * HOUR);
178
+ const initiate: EffectOperation = {
179
+ kind: "effect",
180
+ version: 1,
181
+ category: "other",
182
+ resend: { policy: "window", delayMs: 30_000 },
183
+ erasure: "operational",
184
+ result: { intent_id: "id", status: "status" },
185
+ failures: FAILURES,
186
+ call: (input, call) => d.run("initiate", call, input, () => ({ intent_id: next("pi"), status: "requires_action" })),
187
+ observe: ({ providerKey }) => d.observe(providerKey),
188
+ };
189
+ const refund: EffectOperation = {
190
+ kind: "effect",
191
+ version: 1,
192
+ money: true,
193
+ category: "refund",
194
+ resend: { policy: "never" },
195
+ erasure: "required",
196
+ result: { refund_id: "id", amount: "amount" },
197
+ failures: FAILURES,
198
+ call: (input, call) =>
199
+ d.run("refund", call, input, () => ({ refund_id: next("re"), amount: Number(input.amount ?? 0) })),
200
+ observe: ({ providerKey }) => d.observe(providerKey),
201
+ };
202
+ return finish(d, "effect", {
203
+ initiate,
204
+ refund,
205
+ status: { kind: "observation", call: async (input) => d.observe(String(input.key)) },
206
+ });
207
+ }
@@ -0,0 +1,68 @@
1
+ // Counts D1 requests, the resource D1 runs out of (D-032). A batch is one
2
+ // request; so is each run/all/first/raw of a prepared statement. Scenarios
3
+ // assert exact counts, so a plugin that adds a request to the checkout path
4
+ // fails a test instead of a flash sale (K2).
5
+
6
+ export interface RequestCounter {
7
+ /** Use this binding instead of the real one. */
8
+ readonly db: D1Database;
9
+ count(): number;
10
+ reset(): void;
11
+ /** Requests made while `fn` ran. */
12
+ during<T>(fn: () => Promise<T>): Promise<{ value: T; requests: number }>;
13
+ }
14
+
15
+ export function countRequests(real: D1Database): RequestCounter {
16
+ let n = 0;
17
+ // D1 needs its own statement objects inside a batch, so every wrapper maps back to one.
18
+ const realOf = new WeakMap<object, D1PreparedStatement>();
19
+ const wrapStatement = (s: D1PreparedStatement): D1PreparedStatement => {
20
+ const wrapped = new Proxy(s, {
21
+ get(target, prop, receiver) {
22
+ if (prop === "bind") return (...args: unknown[]) => wrapStatement(target.bind(...args));
23
+ if (prop === "run" || prop === "all" || prop === "first" || prop === "raw") {
24
+ const fn = Reflect.get(target, prop, receiver) as (...a: unknown[]) => unknown;
25
+ return (...args: unknown[]) => {
26
+ n++;
27
+ return fn.apply(target, args);
28
+ };
29
+ }
30
+ const v = Reflect.get(target, prop, receiver);
31
+ return typeof v === "function" ? v.bind(target) : v;
32
+ },
33
+ });
34
+ realOf.set(wrapped, s);
35
+ return wrapped;
36
+ };
37
+ const db = new Proxy(real, {
38
+ get(target, prop, receiver) {
39
+ if (prop === "prepare") return (query: string) => wrapStatement(target.prepare(query));
40
+ if (prop === "batch") {
41
+ return (stmts: D1PreparedStatement[]) => {
42
+ n++;
43
+ return target.batch(stmts.map((s) => realOf.get(s) ?? s));
44
+ };
45
+ }
46
+ if (prop === "exec") {
47
+ return (q: string) => {
48
+ n++;
49
+ return target.exec(q);
50
+ };
51
+ }
52
+ const v = Reflect.get(target, prop, receiver);
53
+ return typeof v === "function" ? v.bind(target) : v;
54
+ },
55
+ });
56
+ return {
57
+ db,
58
+ count: () => n,
59
+ reset: () => {
60
+ n = 0;
61
+ },
62
+ async during(fn) {
63
+ const before = n;
64
+ const value = await fn();
65
+ return { value, requests: n - before };
66
+ },
67
+ };
68
+ }