@cosmicdrift/kumiko-framework 0.305.0 → 0.306.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.
Files changed (60) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  3. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  4. package/src/api/request-context.ts +5 -4
  5. package/src/api/routes.ts +26 -1
  6. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  7. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  8. package/src/bun-db/query.ts +42 -18
  9. package/src/changes.json +66 -0
  10. package/src/db/__tests__/pg-error.test.ts +14 -0
  11. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  12. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  13. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  14. package/src/db/index.ts +1 -1
  15. package/src/db/pg-error.ts +13 -0
  16. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  17. package/src/db/tenant-db.ts +90 -13
  18. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  19. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  20. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  21. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  22. package/src/engine/extension-names.ts +55 -25
  23. package/src/engine/extensions/storage-provider.ts +14 -41
  24. package/src/engine/extensions/tenant-data.ts +4 -0
  25. package/src/engine/extensions/tenant-resource.ts +40 -0
  26. package/src/engine/extensions/user-data.ts +8 -7
  27. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  29. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  30. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  31. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  32. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  33. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  34. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  35. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  36. package/src/engine/feature-ast/index.ts +11 -1
  37. package/src/engine/feature-ast/patch.ts +338 -5
  38. package/src/engine/feature-ast/patcher.ts +2 -2
  39. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  40. package/src/engine/feature-ast/patterns.ts +22 -15
  41. package/src/engine/feature-ast/render.ts +1 -0
  42. package/src/engine/feature-ui-extensions.ts +8 -7
  43. package/src/engine/index.ts +21 -5
  44. package/src/engine/types/extension-options-map.ts +1 -0
  45. package/src/engine/types/index.ts +6 -0
  46. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  47. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  48. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  49. package/src/jobs/job-runner.ts +151 -14
  50. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  51. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  52. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  53. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  54. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  55. package/src/pipeline/dispatch-batch.ts +56 -13
  56. package/src/pipeline/idempotency.ts +16 -0
  57. package/src/pipeline/system-identity-switch.ts +22 -4
  58. package/src/testing/closed-connection-error.ts +62 -0
  59. package/src/testing/index.ts +1 -0
  60. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -177,35 +177,52 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
177
177
  },
178
178
  );
179
179
 
180
- // Records whether ctx.db threw (should, once the request signal is
181
- // aborted) and whether the ctx.dbOutsideTransaction insert still landed
182
- // (should — durability writes must survive a client disconnect).
183
- //
184
- // The throwing side reads via ctx.db.selectMany, not the event-store
185
- // executor's create() — the latter writes through db.raw (bypassing
186
- // TenantDb's withDbSpan/signal check entirely), so it wouldn't exercise
187
- // the signal wiring this test is meant to prove. insertOne isn't an
188
- // option either: bagTable is executor-managed (WritableTable rejects its
189
- // EXECUTOR_ONLY brand) — direct writes would drift it past its event
190
- // stream. selectMany has no such restriction (reads keep the plain
191
- // SchemaTable param) and still goes through the same signal check.
180
+ // Proves runBatch strips the request signal: both inserts land and
181
+ // ctx.signal is undefined even though the request was pre-aborted.
192
182
  r.writeHandler(
193
183
  "bag:create-signal-probe",
194
184
  z.object({ label: z.string() }),
195
185
  async (event, ctx) => {
196
186
  const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
197
- let dbThrewAbortError = false;
198
- try {
199
- await ctx.db?.selectMany(bagTable, {});
200
- } catch (err) {
201
- dbThrewAbortError = err instanceof Error && err.name === "AbortError";
202
- }
187
+ await ctx.db?.selectMany(bagTable, {});
188
+ await crud.create({ label: `${event.payload.label}-inside-tx` }, event.user, ctx.db);
203
189
  const outsideTx = ctx.dbOutsideTransaction;
204
190
  if (!outsideTx) {
205
191
  throw new Error("bag:create-signal-probe requires ctx.dbOutsideTransaction");
206
192
  }
207
193
  await crud.create({ label: `${event.payload.label}-outside-tx` }, event.user, outsideTx);
208
- return { isSuccess: true as const, data: { dbThrewAbortError } };
194
+ return { isSuccess: true as const, data: { signalSeen: ctx.signal !== undefined } };
195
+ },
196
+ { access: { roles: ["Admin"] } },
197
+ );
198
+
199
+ // Query path counterpart — ctx.db.selectMany hits signal.throwIfAborted().
200
+ r.queryHandler(
201
+ "bag:list-signal-probe",
202
+ z.object({}),
203
+ async (_query, ctx) => selectMany(ctx.db, bagTable),
204
+ { access: { roles: ["Admin"] } },
205
+ );
206
+
207
+ // Negative control: never touches ctx.db, so a pre-aborted signal must still 500.
208
+ r.queryHandler(
209
+ "bag:query-boom",
210
+ z.object({}),
211
+ async () => {
212
+ throw new Error("unrelated boom");
213
+ },
214
+ { access: { roles: ["Admin"] } },
215
+ );
216
+
217
+ // Pre-write ctx.db read — crud.create's own insert writes via db.raw and
218
+ // doesn't check the signal, so this is what makes the test discriminating.
219
+ r.writeHandler(
220
+ "bag:create-plain",
221
+ z.object({ label: z.string() }),
222
+ async (event, ctx) => {
223
+ await ctx.db?.selectMany(bagTable, {});
224
+ const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
225
+ return crud.create(event.payload, event.user, ctx.db);
209
226
  },
210
227
  { access: { roles: ["Admin"] } },
211
228
  );
@@ -366,7 +383,7 @@ describe("ctx.dbOutsideTransaction", () => {
366
383
  expect(labels).toEqual(["probe-outside-tx"]);
367
384
  });
368
385
 
369
- test("an already-aborted request signal fails ctx.db but not ctx.dbOutsideTransaction", async () => {
386
+ test("an already-aborted request signal still commits both ctx.db and ctx.dbOutsideTransaction writes", async () => {
370
387
  const controller = new AbortController();
371
388
  controller.abort();
372
389
  const token = await stack.jwt.sign(admin);
@@ -385,16 +402,86 @@ describe("ctx.dbOutsideTransaction", () => {
385
402
 
386
403
  const body = (await res.json()) as {
387
404
  isSuccess: boolean;
388
- data?: { dbThrewAbortError: boolean };
405
+ data?: { signalSeen: boolean };
389
406
  };
390
407
  expect(body.isSuccess).toBe(true);
391
- expect(body.data?.dbThrewAbortError).toBe(true);
408
+ expect(body.data?.signalSeen).toBe(false);
392
409
 
393
- // Only the outside-tx insert landed — ctx.db's insert threw before it
394
- // could write, and there was nothing to roll back for it.
410
+ // Both inserts landed — the client's disconnect doesn't touch the write.
395
411
  const bags = await selectMany(stack.db, bagTable);
396
- const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
397
- expect(labels).toEqual(["signal-probe-outside-tx"]);
412
+ const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]).sort();
413
+ expect(labels).toEqual(["signal-probe-inside-tx", "signal-probe-outside-tx"]);
414
+ });
415
+
416
+ test("an already-aborted signal doesn't stop idempotent retries from committing", async () => {
417
+ const controller = new AbortController();
418
+ controller.abort();
419
+ const token = await stack.jwt.sign(admin);
420
+ const requestId = "retry-under-abort-1";
421
+
422
+ const requestOptions = {
423
+ method: "POST",
424
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
425
+ body: JSON.stringify({
426
+ type: "ctxbridge:write:bag:create-plain",
427
+ payload: { label: "retried" },
428
+ requestId,
429
+ }),
430
+ signal: controller.signal,
431
+ } as const;
432
+
433
+ const first = await stack.app.request(
434
+ new Request("http://test.local/api/write", requestOptions),
435
+ );
436
+ const second = await stack.app.request(
437
+ new Request("http://test.local/api/write", requestOptions),
438
+ );
439
+
440
+ expect((await first.json()).isSuccess).toBe(true);
441
+ expect((await second.json()).isSuccess).toBe(true);
442
+
443
+ const bags = await selectMany(stack.db, bagTable);
444
+ expect(bags).toHaveLength(1);
445
+ });
446
+ });
447
+
448
+ describe("query dispatch surfaces a client abort as 499, not a server fault", () => {
449
+ test("a pre-aborted signal 499s instead of 500ing", async () => {
450
+ const controller = new AbortController();
451
+ controller.abort();
452
+ const token = await stack.jwt.sign(admin);
453
+
454
+ const res = await stack.app.request(
455
+ new Request("http://test.local/api/query", {
456
+ method: "POST",
457
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
458
+ body: JSON.stringify({
459
+ type: "ctxbridge:query:bag:list-signal-probe",
460
+ payload: {},
461
+ }),
462
+ signal: controller.signal,
463
+ }),
464
+ );
465
+ expect(res.status).toBe(499);
466
+ });
467
+
468
+ test("a pre-aborted signal still 500s when the handler fails for an unrelated reason", async () => {
469
+ const controller = new AbortController();
470
+ controller.abort();
471
+ const token = await stack.jwt.sign(admin);
472
+
473
+ const res = await stack.app.request(
474
+ new Request("http://test.local/api/query", {
475
+ method: "POST",
476
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
477
+ body: JSON.stringify({
478
+ type: "ctxbridge:query:bag:query-boom",
479
+ payload: {},
480
+ }),
481
+ signal: controller.signal,
482
+ }),
483
+ );
484
+ expect(res.status).toBe(500);
398
485
  });
399
486
  });
400
487
 
@@ -1082,5 +1082,10 @@ function createMockIdempotencyGuard() {
1082
1082
  pendingTokens.delete(key);
1083
1083
  results.set(key, JSON.stringify(result));
1084
1084
  },
1085
+ async release(tenantId: string, userId: string, requestId: string, token: string) {
1086
+ const key = `${tenantId}:${userId}:${requestId}`;
1087
+ if (pendingTokens.get(key) !== token) return;
1088
+ pendingTokens.delete(key);
1089
+ },
1085
1090
  };
1086
1091
  }
@@ -0,0 +1,246 @@
1
+ // fw#3198 — a postSave hook's OWN `escapeHatch` must gate ctx.systemDb.unsafeRaw
2
+ // inside a r.systemScope() handler's write, not the handler's (absent) grant.
3
+ // Real HTTP calls + setupTestStack — never createTestDispatcher. Modelled on
4
+ // entity-write-crosstenant.integration.test.ts (tier-engine's
5
+ // ctx.systemDb.unsafeRaw + createTenantDb(raw, otherTenantId, "system") pattern)
6
+ // and escape-hatch-audit.integration.test.ts (recordingSink for EscapeHatchUseEvent).
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { defineUnmanagedTable } from "../../db/entity-table-meta";
10
+ import { selectMany } from "../../db/query";
11
+ import { createTenantDb } from "../../db/tenant-db";
12
+ import {
13
+ createEntity,
14
+ createTextField,
15
+ defineEntityCreateHandler,
16
+ defineFeature,
17
+ HookPhases,
18
+ } from "../../engine";
19
+ import type { EscapeHatchUseEvent } from "../../engine/types";
20
+ import {
21
+ setupTestStack,
22
+ type TestStack,
23
+ TestUsers,
24
+ testTenantId,
25
+ unsafeCreateEntityTable,
26
+ } from "../../stack";
27
+
28
+ const OTHER_TENANT_ID = testTenantId(2);
29
+ const HOOK_ESCAPE_HATCH_REASON = "fw#3198 integration test — hook's own cross-tenant audit write";
30
+ const NO_HOOK_ESCAPE_HATCH_REASON = "fw#3198 integration test — hook declared no escapeHatch";
31
+
32
+ const auditTable = defineUnmanagedTable({
33
+ tableName: "store_fw3198_hook_systemdb_audit",
34
+ columns: [
35
+ {
36
+ name: "id",
37
+ pgType: "uuid",
38
+ notNull: true,
39
+ primaryKey: true,
40
+ defaultSql: "gen_random_uuid()",
41
+ },
42
+ { name: "tenant_id", pgType: "uuid", notNull: true },
43
+ { name: "note", pgType: "text", notNull: true },
44
+ ],
45
+ });
46
+
47
+ type ThingMode =
48
+ | "escapeHatch-unsafe-raw"
49
+ | "escapeHatch-ack-unsafe-raw"
50
+ | "no-escapeHatch-unsafe-raw"
51
+ | "no-escapeHatch-ack-unsafe-raw"
52
+ | "no-escapeHatch-ack-crud";
53
+
54
+ const thingEntity = createEntity({
55
+ table: "hooksys3198_things",
56
+ fields: {
57
+ label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
58
+ mode: createTextField({ required: true, personal: false, reason: "test_fixture" }),
59
+ },
60
+ access: { write: { Admin: "all" } },
61
+ });
62
+
63
+ // Feature A: r.systemScope() handler whose postSave fires with ctx.systemDb present —
64
+ // mirrors tenant/feature.ts (r.systemScope() + entity + create handler).
65
+ const featureA = defineFeature("hooksys3198-a", (r) => {
66
+ r.systemScope();
67
+ r.entity("thing", thingEntity);
68
+ r.writeHandler(defineEntityCreateHandler("thing", thingEntity, { access: { roles: ["Admin"] } }));
69
+ });
70
+
71
+ function modeOf(result: unknown): ThingMode | undefined {
72
+ const saveResult = result as { isNew?: unknown; data?: unknown };
73
+ if (saveResult.isNew !== true) return undefined;
74
+ const data = saveResult.data as { mode?: unknown };
75
+ return typeof data.mode === "string" ? (data.mode as ThingMode) : undefined;
76
+ }
77
+
78
+ function labelOf(result: unknown): string {
79
+ const saveResult = result as { data?: unknown };
80
+ const data = saveResult.data as { label?: unknown };
81
+ return typeof data.label === "string" ? data.label : "";
82
+ }
83
+
84
+ // Feature B: NOT systemScope, hooks cross-feature onto A's "thing" entity — same
85
+ // shape as tier-engine's hook on the "tenant" feature's entity.
86
+ const featureB = defineFeature("hooksys3198-b", (r) => {
87
+ r.storeTable(auditTable, {
88
+ reason:
89
+ "fw#3198 integration test — cross-tenant audit row written from a hook's own escapeHatch",
90
+ });
91
+
92
+ r.hook(
93
+ "postSave",
94
+ { allOf: "thing" },
95
+ async (result, ctx) => {
96
+ const mode = modeOf(result);
97
+ if (mode !== "escapeHatch-unsafe-raw" && mode !== "escapeHatch-ack-unsafe-raw") return;
98
+ if (!ctx.systemDb) return;
99
+ const rawDb =
100
+ mode === "escapeHatch-unsafe-raw"
101
+ ? ctx.systemDb.unsafeRaw(HOOK_ESCAPE_HATCH_REASON)
102
+ : ctx.systemDb
103
+ .acknowledgeCrossTenant(HOOK_ESCAPE_HATCH_REASON)
104
+ .unsafeRaw(HOOK_ESCAPE_HATCH_REASON);
105
+ const otherTenantDb = createTenantDb(rawDb, OTHER_TENANT_ID, "system");
106
+ await otherTenantDb.insertOne(auditTable, { note: `${mode}:${labelOf(result)}` });
107
+ },
108
+ { escapeHatch: { reason: HOOK_ESCAPE_HATCH_REASON } },
109
+ // phase defaults to afterCommit
110
+ );
111
+
112
+ r.hook(
113
+ "postSave",
114
+ { allOf: "thing" },
115
+ async (result, ctx) => {
116
+ const mode = modeOf(result);
117
+ if (!ctx.systemDb) return;
118
+ if (mode === "no-escapeHatch-unsafe-raw") {
119
+ const rawDb = ctx.systemDb.unsafeRaw(NO_HOOK_ESCAPE_HATCH_REASON);
120
+ const otherTenantDb = createTenantDb(rawDb, OTHER_TENANT_ID, "system");
121
+ await otherTenantDb.insertOne(auditTable, { note: `${mode}:${labelOf(result)}` });
122
+ } else if (mode === "no-escapeHatch-ack-unsafe-raw") {
123
+ const rawDb = ctx.systemDb
124
+ .acknowledgeCrossTenant(NO_HOOK_ESCAPE_HATCH_REASON)
125
+ .unsafeRaw(NO_HOOK_ESCAPE_HATCH_REASON);
126
+ const otherTenantDb = createTenantDb(rawDb, OTHER_TENANT_ID, "system");
127
+ await otherTenantDb.insertOne(auditTable, { note: `${mode}:${labelOf(result)}` });
128
+ } else if (mode === "no-escapeHatch-ack-crud") {
129
+ const db = ctx.systemDb.acknowledgeCrossTenant(NO_HOOK_ESCAPE_HATCH_REASON);
130
+ await db.insertOne(auditTable, {
131
+ tenantId: OTHER_TENANT_ID,
132
+ note: `${mode}:${labelOf(result)}`,
133
+ });
134
+ }
135
+ },
136
+ { phase: HookPhases.inTransaction },
137
+ );
138
+ });
139
+
140
+ function recordingSink(events: EscapeHatchUseEvent[]) {
141
+ return async (event: EscapeHatchUseEvent) => {
142
+ events.push(event);
143
+ };
144
+ }
145
+
146
+ async function auditNotesFor(
147
+ label: string,
148
+ ): Promise<ReadonlyArray<{ tenantId: string; note: string }>> {
149
+ const rows = await selectMany<{ tenantId: string; note: string }>(stack.db, auditTable, {});
150
+ return rows.filter((r) => r.note.endsWith(`:${label}`));
151
+ }
152
+
153
+ let stack: TestStack;
154
+ const escapeHatchAuditEvents: EscapeHatchUseEvent[] = [];
155
+
156
+ beforeAll(async () => {
157
+ stack = await setupTestStack({
158
+ features: [featureA, featureB],
159
+ extraContext: { _escapeHatchAuditSink: recordingSink(escapeHatchAuditEvents) },
160
+ });
161
+ await unsafeCreateEntityTable(stack.db, thingEntity, "thing");
162
+ });
163
+
164
+ afterAll(async () => {
165
+ await stack.cleanup();
166
+ });
167
+
168
+ describe("a postSave hook's own escapeHatch gates ctx.systemDb.unsafeRaw (fw#3198)", () => {
169
+ test("afterCommit, WITH escapeHatch, ctx.systemDb.unsafeRaw: cross-tenant audit row lands, reported as unsafe-raw", async () => {
170
+ escapeHatchAuditEvents.length = 0;
171
+ const label = `label-${crypto.randomUUID()}`;
172
+ await stack.http.writeOk(
173
+ "hooksys3198-a:write:thing:create",
174
+ { label, mode: "escapeHatch-unsafe-raw" },
175
+ TestUsers.admin,
176
+ );
177
+
178
+ const rows = await auditNotesFor(label);
179
+ expect(rows).toHaveLength(1);
180
+ expect(rows[0]?.tenantId).toBe(OTHER_TENANT_ID);
181
+
182
+ const matches = escapeHatchAuditEvents.filter((e) => e.kind === "unsafe-raw");
183
+ expect(matches).toEqual([
184
+ {
185
+ handler: "hooksys3198-a:write:thing:create",
186
+ kind: "unsafe-raw",
187
+ reason: HOOK_ESCAPE_HATCH_REASON,
188
+ tenantId: TestUsers.admin.tenantId,
189
+ actor: TestUsers.admin.id,
190
+ target: undefined,
191
+ },
192
+ ]);
193
+ });
194
+
195
+ test("afterCommit, WITH escapeHatch, ctx.systemDb.acknowledgeCrossTenant(...).unsafeRaw: cross-tenant audit row lands (the reported issue path)", async () => {
196
+ const label = `label-${crypto.randomUUID()}`;
197
+ await stack.http.writeOk(
198
+ "hooksys3198-a:write:thing:create",
199
+ { label, mode: "escapeHatch-ack-unsafe-raw" },
200
+ TestUsers.admin,
201
+ );
202
+
203
+ const rows = await auditNotesFor(label);
204
+ expect(rows).toHaveLength(1);
205
+ expect(rows[0]?.tenantId).toBe(OTHER_TENANT_ID);
206
+ });
207
+
208
+ test("inTransaction, WITHOUT escapeHatch, ctx.systemDb.unsafeRaw: denied, no audit row", async () => {
209
+ const label = `label-${crypto.randomUUID()}`;
210
+ const err = await stack.http.writeErr(
211
+ "hooksys3198-a:write:thing:create",
212
+ { label, mode: "no-escapeHatch-unsafe-raw" },
213
+ TestUsers.admin,
214
+ );
215
+ expect(err.code).toBe("access_denied");
216
+
217
+ const rows = await auditNotesFor(label);
218
+ expect(rows).toHaveLength(0);
219
+ });
220
+
221
+ test("inTransaction, WITHOUT escapeHatch, ctx.systemDb.acknowledgeCrossTenant(...).unsafeRaw: denied, no audit row", async () => {
222
+ const label = `label-${crypto.randomUUID()}`;
223
+ const err = await stack.http.writeErr(
224
+ "hooksys3198-a:write:thing:create",
225
+ { label, mode: "no-escapeHatch-ack-unsafe-raw" },
226
+ TestUsers.admin,
227
+ );
228
+ expect(err.code).toBe("access_denied");
229
+
230
+ const rows = await auditNotesFor(label);
231
+ expect(rows).toHaveLength(0);
232
+ });
233
+
234
+ test("inTransaction, WITHOUT escapeHatch, ctx.systemDb.acknowledgeCrossTenant(...) typed CRUD: still works (regression)", async () => {
235
+ const label = `label-${crypto.randomUUID()}`;
236
+ await stack.http.writeOk(
237
+ "hooksys3198-a:write:thing:create",
238
+ { label, mode: "no-escapeHatch-ack-crud" },
239
+ TestUsers.admin,
240
+ );
241
+
242
+ const rows = await auditNotesFor(label);
243
+ expect(rows).toHaveLength(1);
244
+ expect(rows[0]?.tenantId).toBe(OTHER_TENANT_ID);
245
+ });
246
+ });
@@ -0,0 +1,198 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
4
+ import { asRawClient, selectMany } from "../../db/query";
5
+ import { buildEntityTable } from "../../db/table-builder";
6
+ import { createEntity, createTextField, defineFeature } from "../../engine";
7
+ import { UnprocessableError, writeFailure } from "../../errors";
8
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
9
+
10
+ const probeEntity = createEntity({
11
+ table: "idempotency_transient_probes",
12
+ fields: {
13
+ label: createTextField({ personal: false, reason: "test_fixture", required: true }),
14
+ },
15
+ });
16
+ const probeTable = buildEntityTable("probe", probeEntity);
17
+
18
+ const batchItemEntity = createEntity({
19
+ table: "idempotency_transient_batch_items",
20
+ fields: {
21
+ label: createTextField({ personal: false, reason: "test_fixture", required: true }),
22
+ },
23
+ });
24
+ const batchItemTable = buildEntityTable("batchItem", batchItemEntity);
25
+
26
+ // Reset per test — counts real handler invocations so a cached replay
27
+ // (no re-invocation) is distinguishable from a genuine retry.
28
+ let transientCallCount = 0;
29
+ let unprocessableCallCount = 0;
30
+ let batchTransientCallCount = 0;
31
+
32
+ const transientFailureFeature = defineFeature("idempotencytransient", (r) => {
33
+ r.entity("probe", probeEntity);
34
+ r.entity("batchItem", batchItemEntity);
35
+
36
+ // Inserts via ctx.db, then throws on its first invocation only — the
37
+ // insert lands inside the (later rolled-back) transaction, so a retry
38
+ // must not see a leftover row from the failed attempt.
39
+ r.writeHandler(
40
+ "probe:create-transient-once",
41
+ z.object({ label: z.string() }),
42
+ async (event, ctx) => {
43
+ transientCallCount++;
44
+ const crud = createEventStoreExecutor(probeTable, probeEntity, { entityName: "probe" });
45
+ const created = await crud.create(event.payload, event.user, ctx.db);
46
+ if (transientCallCount === 1) {
47
+ throw new Error("transient boom");
48
+ }
49
+ return created;
50
+ },
51
+ { access: { roles: ["Admin"] } },
52
+ );
53
+
54
+ r.writeHandler(
55
+ "probe:create-always-rejected",
56
+ z.object({ label: z.string() }),
57
+ async () => {
58
+ unprocessableCallCount++;
59
+ return writeFailure(new UnprocessableError("always_rejected"));
60
+ },
61
+ { access: { roles: ["Admin"] } },
62
+ );
63
+
64
+ r.writeHandler(
65
+ "batch-item:create",
66
+ z.object({ label: z.string() }),
67
+ async (event, ctx) => {
68
+ const crud = createEventStoreExecutor(batchItemTable, batchItemEntity, {
69
+ entityName: "batchItem",
70
+ });
71
+ return crud.create(event.payload, event.user, ctx.db);
72
+ },
73
+ { access: { roles: ["Admin"] } },
74
+ );
75
+
76
+ r.writeHandler(
77
+ "probe:create-batch-transient-once",
78
+ z.object({ label: z.string() }),
79
+ async (event, ctx) => {
80
+ batchTransientCallCount++;
81
+ const crud = createEventStoreExecutor(probeTable, probeEntity, { entityName: "probe" });
82
+ const created = await crud.create(event.payload, event.user, ctx.db);
83
+ if (batchTransientCallCount === 1) {
84
+ throw new Error("transient boom in batch");
85
+ }
86
+ return created;
87
+ },
88
+ { access: { roles: ["Admin"] } },
89
+ );
90
+ });
91
+
92
+ let stack: TestStack;
93
+ const admin = TestUsers.admin;
94
+
95
+ beforeAll(async () => {
96
+ stack = await setupTestStack({ features: [transientFailureFeature] });
97
+ await unsafeCreateEntityTable(stack.db, probeEntity);
98
+ await unsafeCreateEntityTable(stack.db, batchItemEntity);
99
+ });
100
+
101
+ afterAll(async () => {
102
+ await stack.cleanup();
103
+ });
104
+
105
+ beforeEach(async () => {
106
+ transientCallCount = 0;
107
+ unprocessableCallCount = 0;
108
+ batchTransientCallCount = 0;
109
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${probeTable.tableName}"`);
110
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${batchItemTable.tableName}"`);
111
+ await stack.redis.flushNamespace();
112
+ });
113
+
114
+ describe("idempotent retry after a rolled-back transient 5xx", () => {
115
+ test("write: first call fails with internal_error, retry with the same requestId succeeds", async () => {
116
+ const requestId = "req-transient-5xx-1";
117
+
118
+ const first = await stack.http.write(
119
+ "idempotencytransient:write:probe:create-transient-once",
120
+ { label: "attempt-1" },
121
+ admin,
122
+ requestId,
123
+ );
124
+ const firstBody = (await first.json()) as { isSuccess: boolean; error?: { code?: string } };
125
+ expect(firstBody.isSuccess).toBe(false);
126
+ expect(firstBody.error?.code).toBe("internal_error");
127
+
128
+ const second = await stack.http.write(
129
+ "idempotencytransient:write:probe:create-transient-once",
130
+ { label: "attempt-1" },
131
+ admin,
132
+ requestId,
133
+ );
134
+ const secondBody = (await second.json()) as { isSuccess: boolean };
135
+ expect(secondBody.isSuccess).toBe(true);
136
+
137
+ expect(transientCallCount).toBe(2);
138
+ const rows = await selectMany(stack.db, probeTable);
139
+ expect(rows).toHaveLength(1);
140
+ });
141
+
142
+ test("write: a deterministic 4xx rejection stays cached — retry returns the identical body without re-invoking the handler", async () => {
143
+ const requestId = "req-4xx-cached-1";
144
+
145
+ const first = await stack.http.write(
146
+ "idempotencytransient:write:probe:create-always-rejected",
147
+ { label: "rejected" },
148
+ admin,
149
+ requestId,
150
+ );
151
+ const firstBody = (await first.json()) as {
152
+ isSuccess: boolean;
153
+ error?: { code?: string; message?: string; details?: unknown };
154
+ };
155
+
156
+ const second = await stack.http.write(
157
+ "idempotencytransient:write:probe:create-always-rejected",
158
+ { label: "rejected" },
159
+ admin,
160
+ requestId,
161
+ );
162
+ const secondBody = (await second.json()) as typeof firstBody;
163
+
164
+ // Compare the cached payload, not the wire envelope — the response
165
+ // carries a fresh per-HTTP-call requestId/timestamp on every replay,
166
+ // both from the same cached idempotency entry.
167
+ expect(secondBody.isSuccess).toBe(false);
168
+ expect(secondBody.error?.code).toEqual(firstBody.error?.code);
169
+ expect(secondBody.error?.message).toEqual(firstBody.error?.message);
170
+ expect(secondBody.error?.details).toEqual(firstBody.error?.details);
171
+ expect(unprocessableCallCount).toBe(1);
172
+ });
173
+
174
+ test("batch: a rolled-back transient 5xx on one command doesn't block a retry, and the rolled-back first command lands exactly once", async () => {
175
+ const requestId = "req-batch-transient-5xx-1";
176
+ const commands = [
177
+ { type: "idempotencytransient:write:batch-item:create", payload: { label: "item-1" } },
178
+ {
179
+ type: "idempotencytransient:write:probe:create-batch-transient-once",
180
+ payload: { label: "probe-1" },
181
+ },
182
+ ];
183
+
184
+ const first = await stack.http.batch(commands, admin, requestId);
185
+ const firstBody = (await first.json()) as { isSuccess: boolean };
186
+ expect(firstBody.isSuccess).toBe(false);
187
+
188
+ const second = await stack.http.batch(commands, admin, requestId);
189
+ const secondBody = (await second.json()) as { isSuccess: boolean };
190
+ expect(secondBody.isSuccess).toBe(true);
191
+
192
+ expect(batchTransientCallCount).toBe(2);
193
+ const batchItems = await selectMany(stack.db, batchItemTable);
194
+ expect(batchItems).toHaveLength(1);
195
+ const probes = await selectMany(stack.db, probeTable);
196
+ expect(probes).toHaveLength(1);
197
+ });
198
+ });