@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
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import type { SchemaTable } from "../../db";
|
|
7
|
+
import type { DbRunner } from "../../db/connection";
|
|
7
8
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
8
|
-
import { asRawClient, selectMany } from "../../db/query";
|
|
9
|
+
import { asRawClient, runInSavepoint, selectMany } from "../../db/query";
|
|
9
10
|
import { buildEntityTable } from "../../db/table-builder";
|
|
10
|
-
import type
|
|
11
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
11
12
|
import { createEntity, createSystemUser, createTextField, defineFeature } from "../../engine";
|
|
12
13
|
import { SYSTEM_ROLE } from "../../engine/system-user";
|
|
13
14
|
import type { TenantId } from "../../engine/types";
|
|
@@ -273,6 +274,185 @@ const featureA = defineFeature("intakea", (r) => {
|
|
|
273
274
|
},
|
|
274
275
|
{ access: { roles: ["anonymous", "Admin"] }, rateLimit: RATE_LIMIT },
|
|
275
276
|
);
|
|
277
|
+
|
|
278
|
+
// (g) createTenantDb() built by the HANDLER ITSELF from ctx.db.unsafeRaw(reason) — the
|
|
279
|
+
// returned runner carries no TenantDb of its own, so without gate inheritance on the
|
|
280
|
+
// runner (tenant-db.ts's runnerPersonalDataGates) this fresh TenantDb would have no gate.
|
|
281
|
+
const UNSAFE_RAW_REASON =
|
|
282
|
+
"test: proves createTenantDb() built from ctx.db.unsafeRaw() inherits the caller's personal-data gate";
|
|
283
|
+
|
|
284
|
+
r.writeHandler(
|
|
285
|
+
"unsafe-raw-tenant-db-no-declare",
|
|
286
|
+
z.object({ note: z.string() }),
|
|
287
|
+
async (event, ctx) => {
|
|
288
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
289
|
+
await createTenantDb(raw, event.user.tenantId, "system").insertOne(
|
|
290
|
+
contactTable as unknown as SchemaTable,
|
|
291
|
+
{ email: "leak-unsafe-raw@example.com", note: event.payload.note },
|
|
292
|
+
);
|
|
293
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
access: { roles: ["anonymous"] },
|
|
297
|
+
rateLimit: RATE_LIMIT,
|
|
298
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
r.writeHandler(
|
|
303
|
+
"unsafe-raw-tenant-db-declare",
|
|
304
|
+
z.object({ note: z.string() }),
|
|
305
|
+
async (event, ctx) => {
|
|
306
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
307
|
+
await createTenantDb(raw, event.user.tenantId, "system").insertOne(
|
|
308
|
+
contactTable as unknown as SchemaTable,
|
|
309
|
+
{ email: "leak-unsafe-raw-declared@example.com", note: event.payload.note },
|
|
310
|
+
);
|
|
311
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
access: { roles: ["anonymous"], personalData: "public-intake" },
|
|
315
|
+
rateLimit: RATE_LIMIT,
|
|
316
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Nested tx: the handler already runs inside the request's own transaction, so its
|
|
321
|
+
// unsafeRaw runner is a tx handle (.savepoint, not .begin — see asRawClient's own
|
|
322
|
+
// comment). The savepoint-scoped tx a callback receives must inherit the same gate,
|
|
323
|
+
// otherwise a handler could dodge the gate by moving the write inside a savepoint.
|
|
324
|
+
r.writeHandler(
|
|
325
|
+
"unsafe-raw-savepoint-tenant-db-no-declare",
|
|
326
|
+
z.object({ note: z.string() }),
|
|
327
|
+
async (event, ctx) => {
|
|
328
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
329
|
+
await runInSavepoint(raw, async (sp) => {
|
|
330
|
+
await createTenantDb(sp as DbRunner, event.user.tenantId, "system").insertOne(
|
|
331
|
+
contactTable as unknown as SchemaTable,
|
|
332
|
+
{ email: "leak-unsafe-raw-tx@example.com", note: event.payload.note },
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
access: { roles: ["anonymous"] },
|
|
339
|
+
rateLimit: RATE_LIMIT,
|
|
340
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
r.writeHandler(
|
|
345
|
+
"unsafe-raw-savepoint-tenant-db-declare",
|
|
346
|
+
z.object({ note: z.string() }),
|
|
347
|
+
async (event, ctx) => {
|
|
348
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
349
|
+
await runInSavepoint(raw, async (sp) => {
|
|
350
|
+
await createTenantDb(sp as DbRunner, event.user.tenantId, "system").insertOne(
|
|
351
|
+
contactTable as unknown as SchemaTable,
|
|
352
|
+
{ email: "leak-unsafe-raw-tx-declared@example.com", note: event.payload.note },
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
access: { roles: ["anonymous"], personalData: "public-intake" },
|
|
359
|
+
rateLimit: RATE_LIMIT,
|
|
360
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// Raw SQL through the gated proxy itself must stay ungated (decision: unsafeRaw's raw SQL
|
|
365
|
+
// is an escape hatch + audit trail, not something the personal-data gate blocks) — this
|
|
366
|
+
// just proves the proxy stays transparent for a direct tagged-template call.
|
|
367
|
+
r.writeHandler(
|
|
368
|
+
"unsafe-raw-tagged-query-declare",
|
|
369
|
+
z.object({ note: z.string() }),
|
|
370
|
+
async (event, ctx) => {
|
|
371
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
372
|
+
// @cast-boundary test-fixture — DbRunner's RawClient arm has no tagged-template call
|
|
373
|
+
// signature; the underlying driver instance (postgres-js/Bun.SQL) does.
|
|
374
|
+
const tagged = raw as unknown as (
|
|
375
|
+
strings: TemplateStringsArray,
|
|
376
|
+
...values: unknown[]
|
|
377
|
+
) => Promise<readonly Record<string, unknown>[]>;
|
|
378
|
+
const rows = await tagged`SELECT 1 AS one`;
|
|
379
|
+
if (rows[0]?.["one"] !== 1) throw new Error("tagged query through the proxy failed");
|
|
380
|
+
await createTenantDb(raw, event.user.tenantId, "system").insertOne(
|
|
381
|
+
contactTable as unknown as SchemaTable,
|
|
382
|
+
{ email: "leak-unsafe-raw-tagged@example.com", note: event.payload.note },
|
|
383
|
+
);
|
|
384
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
access: { roles: ["anonymous"], personalData: "public-intake" },
|
|
388
|
+
rateLimit: RATE_LIMIT,
|
|
389
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Authenticated session: same ctx.db.unsafeRaw() -> createTenantDb() path, but the root
|
|
394
|
+
// isn't anonymous, so ctx.db carries no personal-data gate to inherit — unaffected.
|
|
395
|
+
r.writeHandler(
|
|
396
|
+
"unsafe-raw-tenant-db-authenticated",
|
|
397
|
+
z.object({ note: z.string() }),
|
|
398
|
+
async (event, ctx) => {
|
|
399
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
400
|
+
await createTenantDb(raw, event.user.tenantId, "system").insertOne(
|
|
401
|
+
contactTable as unknown as SchemaTable,
|
|
402
|
+
{ email: "leak-unsafe-raw-authenticated@example.com", note: event.payload.note },
|
|
403
|
+
);
|
|
404
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
405
|
+
},
|
|
406
|
+
{ access: { roles: ["Admin"] }, escapeHatch: { reason: UNSAFE_RAW_REASON } },
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
// The CRUD executor writes through tenantDbRunner + assertPersonalDataWrite, not
|
|
410
|
+
// insertOne — a TenantDb inheriting its gate only from the runner (not from an explicit
|
|
411
|
+
// grants.personalDataGate) must gate the executor path too, not just direct insertOne.
|
|
412
|
+
r.writeHandler(
|
|
413
|
+
"unsafe-raw-executor-no-declare",
|
|
414
|
+
z.object({ note: z.string() }),
|
|
415
|
+
async (event, ctx) => {
|
|
416
|
+
const scopedDb = createTenantDb(
|
|
417
|
+
ctx.db.unsafeRaw(UNSAFE_RAW_REASON),
|
|
418
|
+
event.user.tenantId,
|
|
419
|
+
"system",
|
|
420
|
+
);
|
|
421
|
+
const crud = createEventStoreExecutor(contactTable, contactEntity, { entityName: "contact" });
|
|
422
|
+
return crud.create(
|
|
423
|
+
{ email: "leak-unsafe-raw-executor@example.com", note: event.payload.note },
|
|
424
|
+
event.user,
|
|
425
|
+
scopedDb,
|
|
426
|
+
);
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
access: { roles: ["anonymous"] },
|
|
430
|
+
rateLimit: RATE_LIMIT,
|
|
431
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// Same inherited-gate TenantDb, but only a non-PII field — proves the proxy/executor
|
|
436
|
+
// combination still appends events and lets the projection run when there is nothing
|
|
437
|
+
// for the gate to block, not merely that it throws.
|
|
438
|
+
r.writeHandler(
|
|
439
|
+
"unsafe-raw-executor-non-pii-no-declare",
|
|
440
|
+
z.object({ note: z.string() }),
|
|
441
|
+
async (event, ctx) => {
|
|
442
|
+
const scopedDb = createTenantDb(
|
|
443
|
+
ctx.db.unsafeRaw(UNSAFE_RAW_REASON),
|
|
444
|
+
event.user.tenantId,
|
|
445
|
+
"system",
|
|
446
|
+
);
|
|
447
|
+
const crud = createEventStoreExecutor(probeTable, probeEntity, { entityName: "probe" });
|
|
448
|
+
return crud.create({ note: event.payload.note }, event.user, scopedDb);
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
access: { roles: ["anonymous"] },
|
|
452
|
+
rateLimit: RATE_LIMIT,
|
|
453
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
454
|
+
},
|
|
455
|
+
);
|
|
276
456
|
});
|
|
277
457
|
|
|
278
458
|
describe("public-intake runtime gate", () => {
|
|
@@ -422,4 +602,83 @@ describe("public-intake runtime gate", () => {
|
|
|
422
602
|
expect(res.status).toBe(200);
|
|
423
603
|
expect(await rowCount()).toBe(1);
|
|
424
604
|
});
|
|
605
|
+
|
|
606
|
+
test("(g) createTenantDb(ctx.db.unsafeRaw(reason), ...) — blocked without declaration", async () => {
|
|
607
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
608
|
+
type: "intakea:write:unsafe-raw-tenant-db-no-declare",
|
|
609
|
+
payload: { note: "x" },
|
|
610
|
+
});
|
|
611
|
+
expect(res.status).toBe(403);
|
|
612
|
+
const body = (await res.json()) as { error: { details: { reason: string } } };
|
|
613
|
+
expect(body.error.details.reason).toBe("public_intake_required");
|
|
614
|
+
expect(await rowCount()).toBe(0);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("(g) createTenantDb(ctx.db.unsafeRaw(reason), ...) — allowed once declared", async () => {
|
|
618
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
619
|
+
type: "intakea:write:unsafe-raw-tenant-db-declare",
|
|
620
|
+
payload: { note: "x" },
|
|
621
|
+
});
|
|
622
|
+
expect(res.status).toBe(200);
|
|
623
|
+
expect(await rowCount()).toBe(1);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("(g) createTenantDb(tx, ...) inside savepoint() — blocked without declaration", async () => {
|
|
627
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
628
|
+
type: "intakea:write:unsafe-raw-savepoint-tenant-db-no-declare",
|
|
629
|
+
payload: { note: "x" },
|
|
630
|
+
});
|
|
631
|
+
expect(res.status).toBe(403);
|
|
632
|
+
const body = (await res.json()) as { error: { details: { reason: string } } };
|
|
633
|
+
expect(body.error.details.reason).toBe("public_intake_required");
|
|
634
|
+
expect(await rowCount()).toBe(0);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("(g) createTenantDb(tx, ...) inside savepoint() — allowed once declared", async () => {
|
|
638
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
639
|
+
type: "intakea:write:unsafe-raw-savepoint-tenant-db-declare",
|
|
640
|
+
payload: { note: "x" },
|
|
641
|
+
});
|
|
642
|
+
expect(res.status).toBe(200);
|
|
643
|
+
expect(await rowCount()).toBe(1);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("(g) a tagged-template query through the gated unsafeRaw proxy still works", async () => {
|
|
647
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
648
|
+
type: "intakea:write:unsafe-raw-tagged-query-declare",
|
|
649
|
+
payload: { note: "x" },
|
|
650
|
+
});
|
|
651
|
+
expect(res.status).toBe(200);
|
|
652
|
+
expect(await rowCount()).toBe(1);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("(g) authenticated session on the same unsafeRaw->createTenantDb path — unaffected", async () => {
|
|
656
|
+
const res = await stack.http.write(
|
|
657
|
+
"intakea:write:unsafe-raw-tenant-db-authenticated",
|
|
658
|
+
{ note: "x" },
|
|
659
|
+
TestUsers.admin,
|
|
660
|
+
);
|
|
661
|
+
expect(res.status).toBe(200);
|
|
662
|
+
expect(await rowCount()).toBe(1);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("(g) createEventStoreExecutor.create through an inherited-gate TenantDb — blocked without declaration", async () => {
|
|
666
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
667
|
+
type: "intakea:write:unsafe-raw-executor-no-declare",
|
|
668
|
+
payload: { note: "x" },
|
|
669
|
+
});
|
|
670
|
+
expect(res.status).toBe(403);
|
|
671
|
+
const body = (await res.json()) as { error: { details: { reason: string } } };
|
|
672
|
+
expect(body.error.details.reason).toBe("public_intake_required");
|
|
673
|
+
expect(await rowCount()).toBe(0);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("(g) createEventStoreExecutor.create of a non-PII field through the same inherited-gate TenantDb — allowed", async () => {
|
|
677
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
678
|
+
type: "intakea:write:unsafe-raw-executor-non-pii-no-declare",
|
|
679
|
+
payload: { note: "x" },
|
|
680
|
+
});
|
|
681
|
+
expect(res.status).toBe(200);
|
|
682
|
+
expect(await probeRowCount()).toBe(1);
|
|
683
|
+
});
|
|
425
684
|
});
|
|
@@ -223,6 +223,65 @@ describe("idempotency guard", () => {
|
|
|
223
223
|
if (final.status !== "cached") throw new Error("expected cached value");
|
|
224
224
|
expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
test("release() frees the lock — a waiting check() reclaims immediately instead of polling out", async () => {
|
|
228
|
+
const guard = createIdempotencyGuard(testRedis.redis, {
|
|
229
|
+
pendingTtlSeconds: 5,
|
|
230
|
+
pollIntervalMs: 20,
|
|
231
|
+
waitTimeoutMs: 10_000,
|
|
232
|
+
});
|
|
233
|
+
const requestId = "req-release-1";
|
|
234
|
+
|
|
235
|
+
const first = await guard.check(tenantA, userA, requestId);
|
|
236
|
+
expect(first.status).toBe("acquired");
|
|
237
|
+
if (first.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
238
|
+
|
|
239
|
+
// Waiter starts BEFORE release — if release is a no-op this only resolves
|
|
240
|
+
// once waitTimeoutMs elapses (10s), which the 500ms race below catches.
|
|
241
|
+
const waiterPromise = guard.check(tenantA, userA, requestId);
|
|
242
|
+
|
|
243
|
+
await guard.release(tenantA, userA, requestId, first.token);
|
|
244
|
+
|
|
245
|
+
const raced = await Promise.race([
|
|
246
|
+
waiterPromise.then((v) => ({ done: true as const, v })),
|
|
247
|
+
new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), 500)),
|
|
248
|
+
]);
|
|
249
|
+
expect(raced.done).toBe(true);
|
|
250
|
+
if (!raced.done) throw new Error("waiter did not reclaim after release()");
|
|
251
|
+
expect(raced.v.status).toBe("acquired");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("release() with a stale token is a no-op — it must not clear a new owner's lock", async () => {
|
|
255
|
+
const guard = createIdempotencyGuard(testRedis.redis, {
|
|
256
|
+
pendingTtlSeconds: 1,
|
|
257
|
+
pollIntervalMs: 20,
|
|
258
|
+
waitTimeoutMs: 6_000,
|
|
259
|
+
});
|
|
260
|
+
const requestId = "req-release-stale";
|
|
261
|
+
|
|
262
|
+
const original = await guard.check(tenantA, userA, requestId);
|
|
263
|
+
expect(original.status).toBe("acquired");
|
|
264
|
+
if (original.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
265
|
+
|
|
266
|
+
// Let the lock expire, then a new owner reclaims it.
|
|
267
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
268
|
+
const reclaimer = await guard.check(tenantA, userA, requestId);
|
|
269
|
+
expect(reclaimer.status).toBe("acquired");
|
|
270
|
+
if (reclaimer.status !== "acquired") throw new Error("expected to reclaim the lock");
|
|
271
|
+
|
|
272
|
+
// The original (stale) owner's release() must not touch the reclaimer's lock.
|
|
273
|
+
await guard.release(tenantA, userA, requestId, original.token);
|
|
274
|
+
|
|
275
|
+
await guard.store(tenantA, userA, requestId, reclaimer.token, {
|
|
276
|
+
isSuccess: true,
|
|
277
|
+
data: { owner: "reclaimer" },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const final = await guard.check(tenantA, userA, requestId);
|
|
281
|
+
expect(final.status).toBe("cached");
|
|
282
|
+
if (final.status !== "cached") throw new Error("expected cached value");
|
|
283
|
+
expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
|
|
284
|
+
});
|
|
226
285
|
});
|
|
227
286
|
|
|
228
287
|
// --- Event Dedup ---
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
|
+
import { requestContext, runWithWriteOrigin } from "../api/request-context";
|
|
1
3
|
import type { DbConnection } from "../db/connection";
|
|
2
4
|
import { transaction } from "../db/query";
|
|
3
5
|
import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
|
|
@@ -13,7 +15,20 @@ import {
|
|
|
13
15
|
isLifecycleResult,
|
|
14
16
|
wrapToKumiko,
|
|
15
17
|
} from "./dispatcher-utils";
|
|
16
|
-
import { rootWriteOrigin } from "./write-origin";
|
|
18
|
+
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin";
|
|
19
|
+
|
|
20
|
+
// afterCommit hooks fire in flushAfterCommit, outside the command's scope.
|
|
21
|
+
function rewrapHooksWithOrigin(
|
|
22
|
+
afterCommitHooks: AfterCommitHook[],
|
|
23
|
+
fromIndex: number,
|
|
24
|
+
origin: WriteOrigin,
|
|
25
|
+
): void {
|
|
26
|
+
for (let i = fromIndex; i < afterCommitHooks.length; i++) {
|
|
27
|
+
const original = afterCommitHooks[i];
|
|
28
|
+
if (!original) continue;
|
|
29
|
+
afterCommitHooks[i] = () => runWithWriteOrigin(origin, original);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
17
32
|
|
|
18
33
|
// Core batch logic extracted so write() and command() can reuse it
|
|
19
34
|
// (a single write = batch of one, running in its own transaction).
|
|
@@ -22,6 +37,26 @@ export async function runBatch(
|
|
|
22
37
|
commands: readonly BatchCommand[],
|
|
23
38
|
user: SessionUser,
|
|
24
39
|
requestId?: string,
|
|
40
|
+
inheritedOrigin?: WriteOrigin,
|
|
41
|
+
): Promise<BatchResult> {
|
|
42
|
+
const current = requestContext.get();
|
|
43
|
+
if (!current?.signal) {
|
|
44
|
+
return runBatchBody(ctx, commands, user, requestId, inheritedOrigin);
|
|
45
|
+
}
|
|
46
|
+
// Strip the signal: a disconnect would roll back the tx, idempotency would
|
|
47
|
+
// cache a 500 for the uncommitted write and afterCommit effects would be lost.
|
|
48
|
+
const { signal: _signal, ...withoutSignal } = current;
|
|
49
|
+
return requestContext.run(withoutSignal, () =>
|
|
50
|
+
runBatchBody(ctx, commands, user, requestId, inheritedOrigin),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function runBatchBody(
|
|
55
|
+
ctx: DispatchContext,
|
|
56
|
+
commands: readonly BatchCommand[],
|
|
57
|
+
user: SessionUser,
|
|
58
|
+
requestId?: string,
|
|
59
|
+
inheritedOrigin?: WriteOrigin,
|
|
25
60
|
): Promise<BatchResult> {
|
|
26
61
|
const { idempotency, lifecycle, appContext: context } = ctx;
|
|
27
62
|
if (commands.length === 0) {
|
|
@@ -45,8 +80,8 @@ export async function runBatch(
|
|
|
45
80
|
}
|
|
46
81
|
}
|
|
47
82
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
83
|
+
// Cache the result under requestId so retries get the same answer. Only a
|
|
84
|
+
// provably rolled-back 5xx releases the lock instead (releaseOrFinalize).
|
|
50
85
|
const finalize = async (result: BatchResult): Promise<BatchResult> => {
|
|
51
86
|
if (requestId && idempotency && idempotencyToken) {
|
|
52
87
|
await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
|
|
@@ -54,6 +89,19 @@ export async function runBatch(
|
|
|
54
89
|
return result;
|
|
55
90
|
};
|
|
56
91
|
|
|
92
|
+
// Never for the no-tx fallback: without a rollback, a re-run would repeat
|
|
93
|
+
// the side effects of the commands that already ran.
|
|
94
|
+
const releaseOrFinalize = async (
|
|
95
|
+
result: BatchResult,
|
|
96
|
+
isRetryableRollback: boolean,
|
|
97
|
+
): Promise<BatchResult> => {
|
|
98
|
+
if (isRetryableRollback && requestId && idempotency && idempotencyToken) {
|
|
99
|
+
await idempotency.release(user.tenantId, user.id, requestId, idempotencyToken);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
return finalize(result);
|
|
103
|
+
};
|
|
104
|
+
|
|
57
105
|
const afterCommitHooks: AfterCommitHook[] = [];
|
|
58
106
|
const results: WriteResult[] = [];
|
|
59
107
|
|
|
@@ -79,10 +127,12 @@ export async function runBatch(
|
|
|
79
127
|
}
|
|
80
128
|
};
|
|
81
129
|
|
|
130
|
+
const origins: WriteOrigin[] = [];
|
|
131
|
+
|
|
82
132
|
// Fires the batch-level system hooks with every successful save/delete
|
|
83
133
|
// context from this run. Called after flushAfterCommit so per-save hooks
|
|
84
134
|
// have all completed first; errors are isolated inside lifecycleHooks.
|
|
85
|
-
const
|
|
135
|
+
const flushBatchHooksInner = async () => {
|
|
86
136
|
try {
|
|
87
137
|
const saves: SaveContext[] = [];
|
|
88
138
|
const deletes: DeleteContext[] = [];
|
|
@@ -103,6 +153,14 @@ export async function runBatch(
|
|
|
103
153
|
}
|
|
104
154
|
};
|
|
105
155
|
|
|
156
|
+
// Batch hooks see every command's saves, so they run under the strictest origin.
|
|
157
|
+
const flushBatchHooks = async () => {
|
|
158
|
+
const strictest = origins.find(isPersonalDataGated) ?? origins[0];
|
|
159
|
+
// skip: no command ran, so batch hooks have no saves or deletes to see
|
|
160
|
+
if (!strictest) return;
|
|
161
|
+
await runWithWriteOrigin(strictest, flushBatchHooksInner);
|
|
162
|
+
};
|
|
163
|
+
|
|
106
164
|
// batch() opens its own outer transaction — needs the top-level
|
|
107
165
|
// connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
|
|
108
166
|
const db = resolveDbSource(ctx, undefined) as DbConnection | undefined;
|
|
@@ -113,15 +171,16 @@ export async function runBatch(
|
|
|
113
171
|
for (let i = 0; i < commands.length; i++) {
|
|
114
172
|
const cmd = commands[i];
|
|
115
173
|
if (!cmd) continue;
|
|
116
|
-
const
|
|
117
|
-
ctx,
|
|
118
|
-
cmd.type,
|
|
119
|
-
cmd.payload,
|
|
120
|
-
user,
|
|
174
|
+
const origin = effectiveWriteOrigin(
|
|
121
175
|
rootWriteOrigin(ctx.registry, cmd.type, user),
|
|
122
|
-
|
|
123
|
-
|
|
176
|
+
inheritedOrigin,
|
|
177
|
+
);
|
|
178
|
+
origins.push(origin);
|
|
179
|
+
const hookStart = afterCommitHooks.length;
|
|
180
|
+
const res = await runWithWriteOrigin(origin, () =>
|
|
181
|
+
executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, undefined, afterCommitHooks),
|
|
124
182
|
);
|
|
183
|
+
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
125
184
|
results.push(res);
|
|
126
185
|
if (!res.isSuccess) {
|
|
127
186
|
// No tx means no rollback — but we still drop afterCommit hooks,
|
|
@@ -134,41 +193,55 @@ export async function runBatch(
|
|
|
134
193
|
return finalize({ isSuccess: true, results });
|
|
135
194
|
}
|
|
136
195
|
|
|
196
|
+
let transactionCallbackCompleted = false;
|
|
137
197
|
try {
|
|
138
198
|
await transaction(db, async (tx) => {
|
|
139
199
|
for (let i = 0; i < commands.length; i++) {
|
|
140
200
|
const cmd = commands[i];
|
|
141
201
|
if (!cmd) continue;
|
|
142
|
-
const
|
|
143
|
-
ctx,
|
|
144
|
-
cmd.type,
|
|
145
|
-
cmd.payload,
|
|
146
|
-
user,
|
|
202
|
+
const origin = effectiveWriteOrigin(
|
|
147
203
|
rootWriteOrigin(ctx.registry, cmd.type, user),
|
|
148
|
-
|
|
149
|
-
afterCommitHooks,
|
|
204
|
+
inheritedOrigin,
|
|
150
205
|
);
|
|
206
|
+
origins.push(origin);
|
|
207
|
+
const hookStart = afterCommitHooks.length;
|
|
208
|
+
const res = await runWithWriteOrigin(origin, () =>
|
|
209
|
+
executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks),
|
|
210
|
+
);
|
|
211
|
+
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
151
212
|
results.push(res);
|
|
152
213
|
if (!res.isSuccess) {
|
|
153
214
|
throw new BatchRollback(i, res.error);
|
|
154
215
|
}
|
|
155
216
|
}
|
|
217
|
+
transactionCallbackCompleted = true;
|
|
156
218
|
});
|
|
157
219
|
} catch (e) {
|
|
158
220
|
if (e instanceof BatchRollback) {
|
|
159
|
-
|
|
221
|
+
// Thrown inside the callback, so the tx rolled back. A 4xx is
|
|
222
|
+
// deterministic and stays cached; a 5xx may be transient.
|
|
223
|
+
return releaseOrFinalize(
|
|
224
|
+
{
|
|
225
|
+
isSuccess: false,
|
|
226
|
+
error: e.failureError,
|
|
227
|
+
failedIndex: e.failedIndex,
|
|
228
|
+
results,
|
|
229
|
+
},
|
|
230
|
+
e.failureError.httpStatus >= 500,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
// A completed callback means the throw came from COMMIT (outcome unknown),
|
|
234
|
+
// so cache it; otherwise COMMIT was never sent and releasing is safe.
|
|
235
|
+
const error = toWriteErrorInfo(wrapToKumiko(e));
|
|
236
|
+
return releaseOrFinalize(
|
|
237
|
+
{
|
|
160
238
|
isSuccess: false,
|
|
161
|
-
error
|
|
162
|
-
failedIndex:
|
|
239
|
+
error,
|
|
240
|
+
failedIndex: results.length,
|
|
163
241
|
results,
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
isSuccess: false,
|
|
168
|
-
error: toWriteErrorInfo(wrapToKumiko(e)),
|
|
169
|
-
failedIndex: results.length,
|
|
170
|
-
results,
|
|
171
|
-
});
|
|
242
|
+
},
|
|
243
|
+
!transactionCallbackCompleted && error.httpStatus >= 500,
|
|
244
|
+
);
|
|
172
245
|
}
|
|
173
246
|
|
|
174
247
|
// Commit succeeded — fire deferred side-effects.
|
|
@@ -74,9 +74,13 @@ async function* executeStreamInner(
|
|
|
74
74
|
const invalidated = new Promise<void>((resolve) => {
|
|
75
75
|
resolveInvalidated = resolve;
|
|
76
76
|
});
|
|
77
|
-
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(
|
|
78
|
+
user.id,
|
|
79
|
+
() => {
|
|
80
|
+
resolveInvalidated?.();
|
|
81
|
+
},
|
|
82
|
+
user.sid,
|
|
83
|
+
);
|
|
80
84
|
|
|
81
85
|
let iterator: AsyncIterator<unknown> | undefined;
|
|
82
86
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
@@ -6,7 +6,14 @@ import type {
|
|
|
6
6
|
SessionUser,
|
|
7
7
|
WriteResult,
|
|
8
8
|
} from "../engine/types";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type FieldIssue,
|
|
11
|
+
type KumikoError,
|
|
12
|
+
toKumikoError,
|
|
13
|
+
VersionConflictError,
|
|
14
|
+
type WriteErrorInfo,
|
|
15
|
+
} from "../errors";
|
|
16
|
+
import { VersionConflictError as EventStoreVersionConflictError } from "../event-store/errors";
|
|
10
17
|
|
|
11
18
|
export type FailedWriteResult = Extract<WriteResult, { isSuccess: false }>;
|
|
12
19
|
|
|
@@ -177,4 +184,16 @@ export function resolveType(type: HandlerType): string {
|
|
|
177
184
|
return typeof type === "string" ? type : type.name;
|
|
178
185
|
}
|
|
179
186
|
|
|
180
|
-
|
|
187
|
+
// A custom write losing an append race (ctx.appendEvent, stream.append) is a
|
|
188
|
+
// retryable 409 like the CRUD executor's, not an internal error. Mapped here
|
|
189
|
+
// because errors/ is client code and must not import the event store.
|
|
190
|
+
// currentVersion -1 is the executor's "not looked up" sentinel.
|
|
191
|
+
export function wrapToKumiko(e: unknown): KumikoError {
|
|
192
|
+
if (e instanceof EventStoreVersionConflictError) {
|
|
193
|
+
return new VersionConflictError(
|
|
194
|
+
{ entityId: e.aggregateId, expectedVersion: e.expectedVersion, currentVersion: -1 },
|
|
195
|
+
{ cause: e },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return toKumikoError(e);
|
|
199
|
+
}
|