@kedge/testing 0.0.0-stage → 0.9.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,438 @@
1
+ // A scenario DSL for workerd tests (K11): products, checkouts, placement,
2
+ // expiry, cancellation, and the relay's release and shortage deliveries,
3
+ // through the same stores, planning and runs the deployment uses, with every
4
+ // D1 request counted. A merchant's agent
5
+ // writes the business case; the DSL keeps the mechanics identical to
6
+ // production, so a passing scenario means the deployed path works.
7
+ import {
8
+ acquireSql,
9
+ BREAK,
10
+ type CartLine,
11
+ type CheckoutPlan,
12
+ type Consent,
13
+ capacityRequest,
14
+ Extensions,
15
+ factGuardSql,
16
+ installSql,
17
+ type JsonRecord,
18
+ type KedgeConfig,
19
+ KedgeError,
20
+ type OrderView,
21
+ planCheckout,
22
+ pluginReadSets,
23
+ priceCartView,
24
+ RELAY_LIMITS,
25
+ statementTexts,
26
+ type TestRuntime,
27
+ type TzTable,
28
+ testRuntime,
29
+ toSnapshot,
30
+ UTC_TZ,
31
+ } from "@kedge/core";
32
+ import {
33
+ D1CapacityStore,
34
+ D1CatalogStore,
35
+ D1CheckoutStore,
36
+ D1OrderStore,
37
+ D1RelayStore,
38
+ markedBatch,
39
+ runMarked,
40
+ runRelease,
41
+ runShortage,
42
+ } from "@kedge/db";
43
+ import { countRequests, type RequestCounter } from "./requests";
44
+
45
+ /** What one pass of the relay's D1-only deliveries did. */
46
+ export interface RelayPass {
47
+ delivered: number;
48
+ /** Failed and due again later. */
49
+ retried: number;
50
+ /** Failed on their last attempt: dead letters for staff. */
51
+ failed: number;
52
+ /** A plugin or handler missing from this config: kept pending, never dropped. */
53
+ orphaned: number;
54
+ /** `stock.shortage` events the pass emitted. */
55
+ shortages: number;
56
+ }
57
+
58
+ export interface ScenarioOptions {
59
+ db: D1Database;
60
+ config?: KedgeConfig;
61
+ runtime?: TestRuntime;
62
+ /** How long a pending order holds its stock; default 15 minutes. */
63
+ holdMs?: number;
64
+ /** The store's compiled timezone rules (P0.4 §4); UTC by default, matching a fresh database. */
65
+ tz?: TzTable;
66
+ }
67
+
68
+ /**
69
+ * A line as a scenario writes it: a plain item by default, or any kind with
70
+ * a parent (by index in the same list), a period and custom fields (P0.3).
71
+ */
72
+ export interface ScenarioLine {
73
+ variantId: string;
74
+ quantity?: number;
75
+ kind?: string;
76
+ /** Index of the parent line in the same list. */
77
+ parent?: number;
78
+ quantityMode?: "own" | "per_parent";
79
+ quantityPerParent?: number;
80
+ periodStart?: number;
81
+ periodEnd?: number;
82
+ custom?: JsonRecord;
83
+ }
84
+
85
+ /** Scenario lines as cart lines, ids `cl_<index>`, per_parent quantities derived. */
86
+ export function cartLines(lines: readonly ScenarioLine[]): CartLine[] {
87
+ return lines.map((l, i) => {
88
+ const parent = l.parent === undefined ? undefined : lines[l.parent];
89
+ const mode = l.quantityMode ?? "own";
90
+ const per = mode === "per_parent" ? (l.quantityPerParent ?? 1) : null;
91
+ return {
92
+ id: `cl_${i}`,
93
+ variantId: l.variantId,
94
+ kind: l.kind ?? "item",
95
+ parentId: l.parent === undefined ? null : `cl_${l.parent}`,
96
+ quantityMode: mode,
97
+ quantity: mode === "per_parent" ? (parent?.quantity ?? 1) * (per ?? 1) : (l.quantity ?? 1),
98
+ quantityPerParent: per,
99
+ periodStart: l.periodStart ?? null,
100
+ periodEnd: l.periodEnd ?? null,
101
+ custom: l.custom ?? {},
102
+ };
103
+ });
104
+ }
105
+
106
+ export interface CheckoutOutcome {
107
+ plan: CheckoutPlan;
108
+ order: OrderView;
109
+ /** D1 requests the checkout attempt made: 2 when nothing failed (claim, commit). */
110
+ requests: number;
111
+ }
112
+
113
+ export interface Scenario {
114
+ readonly db: D1Database;
115
+ readonly requests: RequestCounter;
116
+ readonly runtime: TestRuntime;
117
+ readonly extensions: Extensions;
118
+ /** P0.4's capacity administration, as staff would use it (pools, base changes, conversion). */
119
+ readonly capacity: D1CapacityStore;
120
+ /** Applies each configured plugin's migrations, generated SQL and install row, as `kedge generate` composes them. */
121
+ install(): Promise<void>;
122
+ product(opts?: {
123
+ price?: number;
124
+ stock?: number;
125
+ currency?: string;
126
+ /** Line kinds the variant may be sold as (P0.3 §2.2). */
127
+ kinds?: string[];
128
+ /** False for a component-free variant (a service). */
129
+ inventory?: boolean;
130
+ }): Promise<string>;
131
+ /**
132
+ * A product whose one item is tracked by date: stocked per location, then
133
+ * converted, so each location gets a pool with that base (P0.4 §2.1).
134
+ */
135
+ datedProduct(opts?: {
136
+ price?: number;
137
+ stock?: Record<string, number>;
138
+ kinds?: string[];
139
+ bucketMinutes?: number;
140
+ }): Promise<{ variantId: string; itemId: string; pools: string[] }>;
141
+ /** The cron's capacity maintenance and shortage discovery, until both are idle. */
142
+ maintain(): Promise<{ retired: number; ended: number; shortages: number }>;
143
+ /** The total a cart view would show for these lines: the `expected_total` a shopper sends. */
144
+ quote(email: string | null, lines: ScenarioLine[], currency?: string, customerId?: string | null): Promise<number>;
145
+ /** One checkout attempt: claim, plan, commit. Throws the KedgeError checkout would return. */
146
+ checkout(
147
+ email: string | null,
148
+ lines: ScenarioLine[],
149
+ opts?: {
150
+ expectedTotal?: number;
151
+ currency?: string;
152
+ cartId?: string;
153
+ /** Plays a signed-in customer; checkouts are guests otherwise, as every cart is until auth (P0.5). */
154
+ customerId?: string | null;
155
+ /** Runs between the claim and the commit: the window another shopper, or staff, can act in. */
156
+ beforeCommit?: () => Promise<void>;
157
+ /** The cart's own `order` custom fields, its consents and locale (P0.3). */
158
+ custom?: JsonRecord;
159
+ consents?: Record<string, Consent>;
160
+ locale?: string;
161
+ },
162
+ ): Promise<CheckoutOutcome>;
163
+ place(orderId: string): Promise<{ placed: boolean; order: OrderView }>;
164
+ /** Moves the clock past the order's hold and runs expiry. */
165
+ expire(orderId: string): Promise<void>;
166
+ cancel(orderId: string): Promise<void>;
167
+ /**
168
+ * Runs every due release delivery through the relay, once each, as the
169
+ * clock and the queue consumer would (p0.6a §2.6): a failed one backs off,
170
+ * so a second call runs it again only once it is due.
171
+ */
172
+ drainReleases(): Promise<RelayPass>;
173
+ setPluginState(plugin: string, state: "active" | "disabled"): Promise<void>;
174
+ rows<T = Record<string, unknown>>(sql: string, ...binds: unknown[]): Promise<T[]>;
175
+ }
176
+
177
+ // Each scenario gets its own seed, so ids from two scenarios in one store never collide.
178
+ let seeds = 0;
179
+
180
+ export function scenario(opts: ScenarioOptions): Scenario {
181
+ const runtime = opts.runtime ?? testRuntime(1_700_000_000_000, 1000 + ++seeds);
182
+ const extensions = Extensions.resolve(opts.config ?? {});
183
+ const requests = countRequests(opts.db);
184
+ const db = requests.db;
185
+ const { clock, ids, keys } = runtime;
186
+ const catalog = new D1CatalogStore(db, ids, clock, extensions.errors);
187
+ const checkout = new D1CheckoutStore(db, extensions.errors, 500, { sleep: async () => {} });
188
+ const orders = new D1OrderStore(db, ids, clock, extensions.errors);
189
+ const tz = opts.tz ?? UTC_TZ;
190
+ const capacity = new D1CapacityStore(db, ids, clock, tz, keys);
191
+ let commandKeys = 0;
192
+ const holdMs = opts.holdMs ?? 15 * 60_000;
193
+ let generation = 0;
194
+ const raw = opts.db;
195
+ const store = new D1RelayStore(raw, extensions.errors);
196
+ // Rows the triggers write are due by the database's clock, which a scenario's
197
+ // clock may trail: whichever is later is "now" to the relay.
198
+ const relayNow = () => Math.max(clock.now(), Date.now());
199
+
200
+ /**
201
+ * One pass of the relay's D1-only deliveries, to a stop: the clock's lease
202
+ * steps, then each claimed release and pending `core.shortage` delivery run
203
+ * as runtime-cf's consumer runs it. Subscriber deliveries that need a
204
+ * handler context or the journal (a plugin's subscriber, a finalization)
205
+ * stay leased, as a queue nobody consumes would leave them.
206
+ */
207
+ async function relay(): Promise<RelayPass> {
208
+ const pass: RelayPass = { delivered: 0, retried: 0, failed: 0, orphaned: 0, shortages: 0 };
209
+ const shortages = () =>
210
+ raw
211
+ .prepare("SELECT count(*) AS n FROM outbox_event WHERE type = 'stock.shortage'")
212
+ .first<{ n: number }>()
213
+ .then((r) => r?.n ?? 0);
214
+ const before = await shortages();
215
+ for (;;) {
216
+ const now = relayNow();
217
+ await capacity.handoffPage(now);
218
+ const leased = await store.leasePage(now);
219
+ if (leased.length === 0) break;
220
+ for (const msg of leased) {
221
+ if (msg.k === "sub") {
222
+ const row = await raw
223
+ .prepare("SELECT registration, state FROM subscriber_obligation WHERE seq = ?")
224
+ .bind(msg.seq)
225
+ .first<{ registration: string; state: string }>();
226
+ if (row?.registration !== "core.shortage" || row.state !== "pending") continue;
227
+ } else if (msg.k !== "rel") continue;
228
+ const claimed = await store.claim(msg, relayNow());
229
+ if (!claimed) continue;
230
+ if (msg.k === "sub") {
231
+ await runShortage(store, capacity, claimed.run, claimed.row, relayNow);
232
+ continue;
233
+ }
234
+ const outcome = await runRelease(store, raw, claimed.run, claimed.row, extensions, relayNow);
235
+ if (outcome === "delivered") pass.delivered++;
236
+ else if (outcome === "orphaned") pass.orphaned++;
237
+ else if (outcome === "failed") {
238
+ if (claimed.run.attempts >= RELAY_LIMITS.DELIVERY_ATTEMPTS_MAX) pass.failed++;
239
+ else pass.retried++;
240
+ }
241
+ }
242
+ }
243
+ pass.shortages = (await shortages()) - before;
244
+ return pass;
245
+ }
246
+
247
+ // A migration runs as one batch, as wrangler applies it: its marker (p0.5 §7.1) must clear within it.
248
+ const exec = async (text: string) => {
249
+ const statements = text.split(BREAK).flatMap((s) => statementTexts(s));
250
+ if (statements.length > 0) await raw.batch(statements.map((st) => raw.prepare(st)));
251
+ };
252
+
253
+ return {
254
+ db,
255
+ requests,
256
+ runtime,
257
+ extensions,
258
+ capacity,
259
+ async install() {
260
+ for (const [name, instance] of extensions.plugins) {
261
+ const def = instance.definition;
262
+ for (const m of def.migrations ?? []) await exec(m.sql);
263
+ await exec(
264
+ [...factGuardSql(def), ...acquireSql(def), installSql(def, `inst_${name}`, clock.now())].join(BREAK),
265
+ );
266
+ }
267
+ },
268
+ async product({ price = 1000, stock = 10, currency = "USD", kinds, inventory } = {}) {
269
+ const p = await catalog.createProduct({
270
+ title: "Product",
271
+ handle: `p-${ids.next("product")}`,
272
+ variants: [
273
+ {
274
+ sku: `SKU-${ids.next("variant")}`,
275
+ title: "Variant",
276
+ prices: { [currency]: price },
277
+ stock: inventory === false ? {} : { loc_default: stock },
278
+ allowedKinds: kinds,
279
+ inventory,
280
+ },
281
+ ],
282
+ });
283
+ return p.variantIds[0] as string;
284
+ },
285
+ async datedProduct({ price = 1000, stock = { loc_default: 1 }, kinds, bucketMinutes = 1440 } = {}) {
286
+ const p = await catalog.createProduct({
287
+ title: "Dated",
288
+ handle: `d-${ids.next("product")}`,
289
+ variants: [
290
+ {
291
+ sku: `SKU-${ids.next("variant")}`,
292
+ title: "Dated",
293
+ prices: { USD: price },
294
+ stock,
295
+ allowedKinds: kinds,
296
+ },
297
+ ],
298
+ });
299
+ const variantId = p.variantIds[0] as string;
300
+ const row = await raw
301
+ .prepare("SELECT inventory_item_id FROM variant_inventory_item WHERE variant_id = ?")
302
+ .bind(variantId)
303
+ .first<{ inventory_item_id: string }>();
304
+ const itemId = row?.inventory_item_id as string;
305
+ const converted = await capacity.convert(
306
+ { principal: "stf_scenario", key: `scenario-${++commandKeys}-${variantId}` },
307
+ itemId,
308
+ { to: "dated", bucketMinutes, reason: "scenario" },
309
+ );
310
+ return { variantId, itemId, pools: converted.pools };
311
+ },
312
+ async maintain() {
313
+ const total = { retired: 0, ended: 0, shortages: 0 };
314
+ for (let i = 0; i < 50; i++) {
315
+ const m = await capacity.maintain();
316
+ total.retired += m.retired;
317
+ total.ended += m.ended;
318
+ if (m.retired === 0 && m.ended === 0 && !m.more) break;
319
+ }
320
+ // Shortage scans are core's subscriber now (p0.6a §2.7), delivered by the relay.
321
+ total.shortages = (await relay()).shortages;
322
+ return total;
323
+ },
324
+ async quote(email, lines, currency = "USD", customerId = null) {
325
+ const variantIds = [...new Set(lines.map((l) => l.variantId))];
326
+ const reads = pluginReadSets(extensions, { cartId: "cart_quote", email, customerId, currency, variantIds });
327
+ const r = await catalog.pricing(variantIds, currency, reads);
328
+ const priced = await priceCartView({
329
+ currency,
330
+ email,
331
+ lines: cartLines(lines),
332
+ tax: r.tax,
333
+ variants: new Map(r.variants.map((v) => [v.variantId, v])),
334
+ extensions,
335
+ snapshot: toSnapshot(extensions, r.plugins, { tz, now: clock.now() }),
336
+ actor: "storefront",
337
+ customerId,
338
+ });
339
+ return priced.totals.total;
340
+ },
341
+ async checkout(email, lines, o = {}) {
342
+ const currency = o.currency ?? "USD";
343
+ const customerId = o.customerId ?? null;
344
+ const expectedTotal = o.expectedTotal ?? (await this.quote(email, lines, currency, customerId));
345
+ const cartId = o.cartId ?? ids.next("cart");
346
+ const attemptId = ids.next("checkoutAttempt");
347
+ const variantIds = [...new Set(lines.map((l) => l.variantId))];
348
+ // Counted per call, so a beforeCommit interleaving isn't charged to this
349
+ // attempt. Concurrent scenarios share the counter: assert counts on
350
+ // uncontended checkouts only.
351
+ const claimed = await requests.during(() =>
352
+ checkout.claim({
353
+ cartId,
354
+ generation: ++generation,
355
+ attemptId,
356
+ variantIds,
357
+ currency,
358
+ locale: o.locale,
359
+ now: clock.now(),
360
+ reads: pluginReadSets(extensions, { cartId, email, customerId, currency, variantIds }),
361
+ capacity: capacityRequest(extensions, currency, cartLines(lines)),
362
+ }),
363
+ );
364
+ const claim = claimed.value;
365
+ if (claim.existingOrderId) throw new KedgeError("CONFLICT", "This cart already has an order");
366
+ const plan = await planCheckout(
367
+ {
368
+ cartId,
369
+ attemptId,
370
+ currency,
371
+ email,
372
+ lines: cartLines(lines),
373
+ expectedTotal,
374
+ variants: new Map(claim.variants.map((v) => [v.variantId, v])),
375
+ levels: claim.levels,
376
+ now: clock.now(),
377
+ holdMs,
378
+ extensions,
379
+ snapshot: toSnapshot(extensions, claim.plugins, { tz, now: clock.now() }),
380
+ customerId,
381
+ tax: claim.tax,
382
+ policies: claim.policies,
383
+ consents: o.consents,
384
+ custom: o.custom,
385
+ locale: o.locale,
386
+ capacity: claim.capacity,
387
+ tz,
388
+ },
389
+ ids,
390
+ );
391
+ if (o.beforeCommit) await o.beforeCommit();
392
+ const committed = await requests.during(() => checkout.commit(plan));
393
+ const used = claimed.requests + committed.requests;
394
+ const order = (await orders.get(plan.order.id)) as OrderView;
395
+ return { plan, order, requests: used };
396
+ },
397
+ place: (orderId) => orders.place(orderId, "stf_scenario"),
398
+ async expire(orderId) {
399
+ const row = await raw
400
+ .prepare(`SELECT expires_at FROM "order" WHERE id = ?`)
401
+ .bind(orderId)
402
+ .first<{ expires_at: number }>();
403
+ if (row && clock.now() < row.expires_at) clock.set(row.expires_at + 1);
404
+ await orders.expireDue(clock.now(), 500);
405
+ },
406
+ async cancel(orderId) {
407
+ await markedBatch(raw, "scenario.cancel", [
408
+ raw.prepare(`UPDATE "order" SET status = 'canceled' WHERE id = ?`).bind(orderId),
409
+ ]);
410
+ },
411
+ drainReleases: () => relay(),
412
+ async setPluginState(plugin, state) {
413
+ await runMarked(
414
+ raw,
415
+ raw
416
+ .prepare("UPDATE plugin_state SET state = ?, changed_at = ? WHERE plugin = ?")
417
+ .bind(state, clock.now(), plugin),
418
+ );
419
+ },
420
+ async rows<T>(q: string, ...binds: unknown[]) {
421
+ return (
422
+ await raw
423
+ .prepare(q)
424
+ .bind(...binds)
425
+ .all<T>()
426
+ ).results;
427
+ },
428
+ };
429
+ }
430
+
431
+ /** Runs `n` copies of `fn` at once and sorts the outcomes. */
432
+ export async function concurrently<T>(n: number, fn: (i: number) => Promise<T>) {
433
+ const settled = await Promise.allSettled(Array.from({ length: n }, (_, i) => fn(i)));
434
+ return {
435
+ fulfilled: settled.flatMap((s) => (s.status === "fulfilled" ? [s.value] : [])),
436
+ rejected: settled.flatMap((s) => (s.status === "rejected" ? [s.reason as unknown] : [])),
437
+ };
438
+ }
package/src/vitest.ts ADDED
@@ -0,0 +1,11 @@
1
+ // The one line a merchant's test suite carries (K11): after every test, every
2
+ // invariant holds. Never edited per store; a plugin that needs an exception
3
+ // has found a missing primitive.
4
+ import { afterEach, expect } from "vitest";
5
+ import { checkInvariants } from "./invariants";
6
+
7
+ export function describeKedgeInvariants(db: () => D1Database): void {
8
+ afterEach(async () => {
9
+ expect(await checkInvariants(db())).toEqual([]);
10
+ });
11
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.