@cosmicdrift/kumiko-framework 0.304.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.
- package/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- 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 +108 -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/event-store-executor-write.ts +7 -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/tenant-db.ts +140 -16
- 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/boot-validator/access-declarations.ts +5 -66
- 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 +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -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 +38 -3
- package/src/jobs/job-runner.ts +170 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -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 +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- 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
|
@@ -177,35 +177,52 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
177
177
|
},
|
|
178
178
|
);
|
|
179
179
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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
|
-
|
|
198
|
-
|
|
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: {
|
|
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
|
|
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?: {
|
|
405
|
+
data?: { signalSeen: boolean };
|
|
389
406
|
};
|
|
390
407
|
expect(body.isSuccess).toBe(true);
|
|
391
|
-
expect(body.data?.
|
|
408
|
+
expect(body.data?.signalSeen).toBe(false);
|
|
392
409
|
|
|
393
|
-
//
|
|
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
|
+
});
|