@cosmicdrift/kumiko-framework 0.305.0 → 0.307.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/package.json +4 -4
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +71 -0
- package/src/api/__tests__/sse-broker.test.ts +49 -0
- package/src/api/redis-sse-broker.ts +17 -3
- package/src/api/request-context.ts +29 -4
- package/src/api/routes.ts +26 -1
- package/src/api/sse-broker.ts +29 -11
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +92 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/queries/event-consumer.ts +57 -3
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +133 -18
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +21 -5
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +6 -0
- package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
- package/src/jobs/job-runner.ts +211 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/dispatch-batch.ts +102 -29
- package/src/pipeline/dispatch-stream.ts +7 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +109 -57
- package/src/pipeline/event-dispatcher.ts +167 -50
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/pending-gap-ranges.ts +72 -0
- package/src/pipeline/system-hooks.ts +8 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +31 -10
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
|
@@ -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
|
+
});
|