@cosmicdrift/kumiko-framework 0.288.0 → 0.289.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/changes.json +57 -0
- package/src/compliance/__tests__/sub-processors.test.ts +10 -0
- package/src/compliance/sub-processors.ts +14 -1
- package/src/crypto/__tests__/kek-source.test.ts +298 -0
- package/src/crypto/index.ts +3 -0
- package/src/crypto/kek-source.ts +189 -0
- package/src/crypto/kms-wiring.ts +20 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +191 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
- package/src/db/event-store-executor-context.ts +79 -0
- package/src/db/event-store-executor-write.ts +42 -5
- package/src/db/index.ts +1 -0
- package/src/db/tenant-db.ts +8 -1
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -4
- package/src/engine/extensions/storage-provider.ts +17 -2
- package/src/engine/extensions/tenant-data.ts +9 -0
- package/src/engine/factories.ts +2 -0
- package/src/engine/screen-helpers.ts +17 -0
- package/src/errors/classes.ts +24 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/member-resolution.ts +12 -0
- package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
- package/src/event-store/provenance-append.ts +9 -1
- package/src/files/__tests__/file-handle.test.ts +28 -1
- package/src/files/file-handle.ts +26 -7
- package/src/files/index.ts +1 -1
- package/src/i18n/required-surface-keys.ts +24 -12
- package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
- package/src/pipeline/dispatch-shared.ts +14 -13
- package/src/pipeline/dispatch-stream.ts +8 -3
- package/src/pipeline/dispatch-write.ts +3 -2
- package/src/pipeline/event-dispatcher.ts +18 -5
- package/src/pipeline/member-read-only-transaction.ts +2 -6
- package/src/stack/table-helpers.ts +6 -0
package/src/crypto/kms-wiring.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// is wrong), `resolveKmsWiring` is the boot entry point that also constructs
|
|
9
9
|
// the adapter.
|
|
10
10
|
|
|
11
|
+
import { type KekSourceOptions, resolvePlatformKeks } from "./kek-source";
|
|
11
12
|
import { createPgKmsAdapter, type PgKmsAdapter, type PgKmsAdapterOptions } from "./pg-kms-adapter";
|
|
12
13
|
|
|
13
14
|
// The index signature is what lets callers pass `process.env` directly. Without
|
|
@@ -186,3 +187,22 @@ export function requireKmsWiring(
|
|
|
186
187
|
}
|
|
187
188
|
return wiring;
|
|
188
189
|
}
|
|
190
|
+
|
|
191
|
+
/** Same as `resolveKmsWiring`, but resolves `PLATFORM_KEK`/`PLATFORM_KEK_PREVIOUS`
|
|
192
|
+
* from a Key Manager ciphertext first when no plaintext is set — see
|
|
193
|
+
* `resolvePlatformKeks`. The validation stays in the sync function; this only
|
|
194
|
+
* adds the KEK-fetching step in front of it. */
|
|
195
|
+
export async function resolveKmsWiringAsync(
|
|
196
|
+
env: KmsWiringEnv,
|
|
197
|
+
options: KmsWiringOptions & KekSourceOptions = {},
|
|
198
|
+
): Promise<KmsWiring> {
|
|
199
|
+
return resolveKmsWiring(await resolvePlatformKeks(env, options), options);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
|
|
203
|
+
export async function requireKmsWiringAsync(
|
|
204
|
+
env: KmsWiringEnv,
|
|
205
|
+
options: KmsWiringOptions & KekSourceOptions = {},
|
|
206
|
+
): Promise<ActiveKmsWiring> {
|
|
207
|
+
return requireKmsWiring(await resolvePlatformKeks(env, options), options);
|
|
208
|
+
}
|
|
@@ -385,6 +385,197 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
|
|
|
385
385
|
});
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
+
// =============================================================================
|
|
389
|
+
// expect precondition (kumiko-framework#3024) — declarative "genau einmal"
|
|
390
|
+
// guard on update(). The version_conflict describe above proves the pre-
|
|
391
|
+
// existing stream-version race protection; these prove `expect:` closes the
|
|
392
|
+
// gap that leaves open: a caller (like updateUserLifecycle) that opts out of
|
|
393
|
+
// the optimistic lock entirely, so a "late" writer whose own version read is
|
|
394
|
+
// fresh never trips version_conflict at all.
|
|
395
|
+
// =============================================================================
|
|
396
|
+
|
|
397
|
+
const expectEntity = createEntity({
|
|
398
|
+
table: "read_es_write_expect",
|
|
399
|
+
fields: {
|
|
400
|
+
email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
401
|
+
status: createTextField({ personal: false, reason: "test_fixture" }),
|
|
402
|
+
note: createTextField({ personal: false, reason: "test_fixture" }),
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
const expectTable = buildEntityTable("esWriteExpect", expectEntity);
|
|
406
|
+
|
|
407
|
+
describe("event-store-executor write-verbs — expect precondition (#3024)", () => {
|
|
408
|
+
const crud = createEventStoreExecutor(expectTable, expectEntity, {
|
|
409
|
+
entityName: "esWriteExpect",
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
beforeAll(async () => {
|
|
413
|
+
await unsafeCreateEntityTable(testDb.db, expectEntity, "esWriteExpect");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
beforeEach(async () => {
|
|
417
|
+
await asRawClient(testDb.db).unsafe(
|
|
418
|
+
`TRUNCATE kumiko_events, read_es_write_expect RESTART IDENTITY CASCADE`,
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("expect matching the fresh row → applies", async () => {
|
|
423
|
+
const created = await crud.create({ email: "match@test.de", status: "Active" }, admin, tdb);
|
|
424
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
425
|
+
|
|
426
|
+
const result = await crud.update(
|
|
427
|
+
{ id: created.data.id, changes: { status: "Requested" } },
|
|
428
|
+
admin,
|
|
429
|
+
tdb,
|
|
430
|
+
{ skipOptimisticLock: true, expect: { status: "Active" } },
|
|
431
|
+
);
|
|
432
|
+
expect(result.isSuccess).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("expect not matching the fresh row → precondition_failed, row unchanged", async () => {
|
|
436
|
+
const created = await crud.create({ email: "stale@test.de", status: "Requested" }, admin, tdb);
|
|
437
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
438
|
+
|
|
439
|
+
const result = await crud.update(
|
|
440
|
+
{ id: created.data.id, changes: { status: "Deleted" } },
|
|
441
|
+
admin,
|
|
442
|
+
tdb,
|
|
443
|
+
{ skipOptimisticLock: true, expect: { status: "Active" } },
|
|
444
|
+
);
|
|
445
|
+
expect(result.isSuccess).toBe(false);
|
|
446
|
+
if (result.isSuccess) return;
|
|
447
|
+
expect(result.error.code).toBe("precondition_failed");
|
|
448
|
+
|
|
449
|
+
const row = await asRawClient(testDb.db).unsafe(
|
|
450
|
+
`SELECT status FROM read_es_write_expect WHERE id = $1`,
|
|
451
|
+
[created.data.id],
|
|
452
|
+
);
|
|
453
|
+
expect((row as unknown as { status: string }[])[0]?.status).toBe("Requested");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("multiple expect fields, one mismatches → precondition_failed", async () => {
|
|
457
|
+
const created = await crud.create(
|
|
458
|
+
{ email: "multi@test.de", status: "Active", note: "kept" },
|
|
459
|
+
admin,
|
|
460
|
+
tdb,
|
|
461
|
+
);
|
|
462
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
463
|
+
|
|
464
|
+
const result = await crud.update(
|
|
465
|
+
{ id: created.data.id, changes: { status: "Requested" } },
|
|
466
|
+
admin,
|
|
467
|
+
tdb,
|
|
468
|
+
{ skipOptimisticLock: true, expect: { status: "Active", note: "different" } },
|
|
469
|
+
);
|
|
470
|
+
expect(result.isSuccess).toBe(false);
|
|
471
|
+
if (result.isSuccess) return;
|
|
472
|
+
expect(result.error.code).toBe("precondition_failed");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("expect: null matches a null field, rejects a non-null one", async () => {
|
|
476
|
+
const withNullNote = await crud.create({ email: "null-note@test.de" }, admin, tdb);
|
|
477
|
+
if (!withNullNote.isSuccess) throw new Error("setup failed");
|
|
478
|
+
const matched = await crud.update(
|
|
479
|
+
{ id: withNullNote.data.id, changes: { note: "now set" } },
|
|
480
|
+
admin,
|
|
481
|
+
tdb,
|
|
482
|
+
{ skipOptimisticLock: true, expect: { note: null } },
|
|
483
|
+
);
|
|
484
|
+
expect(matched.isSuccess).toBe(true);
|
|
485
|
+
|
|
486
|
+
const withNote = await crud.create(
|
|
487
|
+
{ email: "present-note@test.de", note: "present" },
|
|
488
|
+
admin,
|
|
489
|
+
tdb,
|
|
490
|
+
);
|
|
491
|
+
if (!withNote.isSuccess) throw new Error("setup failed");
|
|
492
|
+
const rejected = await crud.update(
|
|
493
|
+
{ id: withNote.data.id, changes: { note: "overwritten" } },
|
|
494
|
+
admin,
|
|
495
|
+
tdb,
|
|
496
|
+
{ skipOptimisticLock: true, expect: { note: null } },
|
|
497
|
+
);
|
|
498
|
+
expect(rejected.isSuccess).toBe(false);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// The scenario version_conflict alone can't catch: both callers skip the
|
|
502
|
+
// optimistic lock (updateUserLifecycle's shape), so the second caller's own
|
|
503
|
+
// stream-version read is fresh and never collides — only the fresh expect
|
|
504
|
+
// re-read at write time sees that the first caller already moved the row on.
|
|
505
|
+
test("late writer: expect catches a precondition an earlier write already violated, with no version race", async () => {
|
|
506
|
+
const created = await crud.create({ email: "late@test.de", status: "Active" }, admin, tdb);
|
|
507
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
508
|
+
const id = created.data.id;
|
|
509
|
+
|
|
510
|
+
const first = await crud.update({ id, changes: { status: "Requested" } }, admin, tdb, {
|
|
511
|
+
skipOptimisticLock: true,
|
|
512
|
+
expect: { status: "Active" },
|
|
513
|
+
});
|
|
514
|
+
expect(first.isSuccess).toBe(true);
|
|
515
|
+
|
|
516
|
+
const second = await crud.update({ id, changes: { status: "Requested" } }, admin, tdb, {
|
|
517
|
+
skipOptimisticLock: true,
|
|
518
|
+
expect: { status: "Active" },
|
|
519
|
+
});
|
|
520
|
+
expect(second.isSuccess).toBe(false);
|
|
521
|
+
if (second.isSuccess) return;
|
|
522
|
+
expect(second.error.code).toBe("precondition_failed");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Wrapped in its own transaction per racer, like "two concurrent first-time
|
|
526
|
+
// creates ... inside a transaction" above — this is how the dispatcher
|
|
527
|
+
// always calls update() in production (the whole handler runs in one
|
|
528
|
+
// transaction), and it matters here: without it, a single writer's own
|
|
529
|
+
// event-append and projection-update commit as two SEPARATE, independently
|
|
530
|
+
// visible statements against the bare pool, so a second reader can
|
|
531
|
+
// observe "event committed, projection not yet" — a torn state that
|
|
532
|
+
// doesn't exist once both writes commit together as one transaction. The
|
|
533
|
+
// HTTP-level equivalent (anonymous-deletion.integration.test.ts, real
|
|
534
|
+
// dispatcher, real transaction) already covers the true production
|
|
535
|
+
// guarantee; this test pins the same guarantee at the executor level with
|
|
536
|
+
// an explicit transaction to match.
|
|
537
|
+
test("two concurrent updates with the same expect, both skipOptimisticLock → exactly one applies", async () => {
|
|
538
|
+
const created = await crud.create(
|
|
539
|
+
{ email: "race-expect@test.de", status: "Active" },
|
|
540
|
+
admin,
|
|
541
|
+
tdb,
|
|
542
|
+
);
|
|
543
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
544
|
+
const id = created.data.id;
|
|
545
|
+
const options = { skipOptimisticLock: true, expect: { status: "Active" } } as const;
|
|
546
|
+
|
|
547
|
+
const [a, b] = await Promise.all([
|
|
548
|
+
transaction(testDb.db, (tx) =>
|
|
549
|
+
crud.update(
|
|
550
|
+
{ id, changes: { status: "Requested" } },
|
|
551
|
+
admin,
|
|
552
|
+
createTenantDb(tx, admin.tenantId),
|
|
553
|
+
options,
|
|
554
|
+
),
|
|
555
|
+
),
|
|
556
|
+
transaction(testDb.db, (tx) =>
|
|
557
|
+
crud.update(
|
|
558
|
+
{ id, changes: { status: "Requested" } },
|
|
559
|
+
admin,
|
|
560
|
+
createTenantDb(tx, admin.tenantId),
|
|
561
|
+
options,
|
|
562
|
+
),
|
|
563
|
+
),
|
|
564
|
+
]);
|
|
565
|
+
|
|
566
|
+
const results = [a, b];
|
|
567
|
+
expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
|
|
568
|
+
const loser = results.find((r) => !r.isSuccess);
|
|
569
|
+
if (!loser || loser.isSuccess) throw new Error("expected exactly one loser");
|
|
570
|
+
expect(["precondition_failed", "version_conflict"]).toContain(loser.error.code);
|
|
571
|
+
|
|
572
|
+
const healthCheck = (await asRawClient(testDb.db).unsafe(`SELECT 1 AS ok`)) as Array<{
|
|
573
|
+
ok: number;
|
|
574
|
+
}>;
|
|
575
|
+
expect(healthCheck[0]?.ok).toBe(1);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
388
579
|
// =============================================================================
|
|
389
580
|
// Explicit-id create: cross-tenant isolation, no resurrection of a deleted row
|
|
390
581
|
// =============================================================================
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { EscapeHatchKind } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
3
|
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
4
|
-
import { AccessDeniedError, InternalError } from "../../errors";
|
|
4
|
+
import { AccessDeniedError, FrameworkReasons, InternalError } from "../../errors";
|
|
5
5
|
import { testTenantId } from "../../stack";
|
|
6
6
|
import type { DbRunner } from "../connection";
|
|
7
7
|
import {
|
|
8
|
+
acknowledgeConventionCrossTenant,
|
|
8
9
|
createTenantDb,
|
|
9
10
|
createUncheckedSystemDb,
|
|
10
11
|
unsafeRawForDeclaredStep,
|
|
@@ -102,3 +103,61 @@ describe("unsafeRawForDeclaredStep", () => {
|
|
|
102
103
|
expect(unsafeRawForDeclaredStep(regranted, REASON)).toBe(runner);
|
|
103
104
|
});
|
|
104
105
|
});
|
|
106
|
+
|
|
107
|
+
describe("memberReadOnly grant", () => {
|
|
108
|
+
function memberReadOnlyDb(report?: (kind: EscapeHatchKind, reason: string) => void): TenantDb {
|
|
109
|
+
return createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
|
|
110
|
+
unsafeRaw: { reason: "handler declared unsafeRaw" },
|
|
111
|
+
memberReadOnly: true,
|
|
112
|
+
...(report && { report }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function deniedReason(run: () => unknown): unknown {
|
|
117
|
+
try {
|
|
118
|
+
run();
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (!(e instanceof AccessDeniedError)) return e;
|
|
121
|
+
const details: unknown = e.details;
|
|
122
|
+
return typeof details === "object" && details !== null && "reason" in details
|
|
123
|
+
? details.reason
|
|
124
|
+
: details;
|
|
125
|
+
}
|
|
126
|
+
return "no throw";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test("denies unsafeRaw even with a declared escapeHatch, without reporting", () => {
|
|
130
|
+
const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
|
|
131
|
+
const tdb = memberReadOnlyDb((kind, reason) => {
|
|
132
|
+
reports.push({ kind, reason });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(deniedReason(() => tdb.unsafeRaw(REASON))).toBe(
|
|
136
|
+
FrameworkReasons.memberResolutionReadOnly,
|
|
137
|
+
);
|
|
138
|
+
expect(reports).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("denies the declared-step path and survives a withUnsafeRawGrant rebind", () => {
|
|
142
|
+
const tdb = memberReadOnlyDb();
|
|
143
|
+
const regranted = withUnsafeRawGrant(tdb, { reason: "hook re-grant" });
|
|
144
|
+
|
|
145
|
+
expect(deniedReason(() => unsafeRawForDeclaredStep(tdb, REASON))).toBe(
|
|
146
|
+
FrameworkReasons.memberResolutionReadOnly,
|
|
147
|
+
);
|
|
148
|
+
expect(deniedReason(() => regranted.unsafeRaw(REASON))).toBe(
|
|
149
|
+
FrameworkReasons.memberResolutionReadOnly,
|
|
150
|
+
);
|
|
151
|
+
expect(deniedReason(() => unsafeRawForDeclaredStep(regranted, REASON))).toBe(
|
|
152
|
+
FrameworkReasons.memberResolutionReadOnly,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("survives the acknowledgeConventionCrossTenant rebind", () => {
|
|
157
|
+
const crossTenant = acknowledgeConventionCrossTenant(memberReadOnlyDb(), "test: cross-tenant");
|
|
158
|
+
|
|
159
|
+
expect(deniedReason(() => crossTenant.unsafeRaw(REASON))).toBe(
|
|
160
|
+
FrameworkReasons.memberResolutionReadOnly,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -175,6 +175,12 @@ export type ExecutorContext = {
|
|
|
175
175
|
| { kind: "empty" }
|
|
176
176
|
| { kind: "sql"; sqlText: string; params: readonly unknown[] },
|
|
177
177
|
) => Promise<Record<string, unknown>[]>;
|
|
178
|
+
readonly loadExpectSnapshot: (
|
|
179
|
+
db: TenantDb,
|
|
180
|
+
id: EntityId,
|
|
181
|
+
streamTenantId: TenantId,
|
|
182
|
+
expectKeys: readonly string[],
|
|
183
|
+
) => Promise<{ readonly row: Record<string, unknown> | null; readonly streamVersion: number }>;
|
|
178
184
|
readonly encryptForStorage: (
|
|
179
185
|
row: Record<string, unknown>,
|
|
180
186
|
user: SessionUser,
|
|
@@ -411,6 +417,78 @@ export function buildExecutorContext(
|
|
|
411
417
|
];
|
|
412
418
|
}
|
|
413
419
|
|
|
420
|
+
// Combined, atomic read for the `expect:` precondition (#3024): the
|
|
421
|
+
// projection row's expect-checked fields AND the events table's current
|
|
422
|
+
// MAX(version) for this aggregate, in ONE SQL statement.
|
|
423
|
+
//
|
|
424
|
+
// Two separate reads (loadById then getStreamVersion, in either order)
|
|
425
|
+
// leave a real gap: applyEntityEvent writes the projection in a SEPARATE
|
|
426
|
+
// statement AFTER its event commits, so a second reader can see a version
|
|
427
|
+
// that already reflects a concurrent writer's event while its OWN read of
|
|
428
|
+
// the expect fields still reflects the pre-write projection row — verified
|
|
429
|
+
// empirically: ~40% of genuinely concurrent update() calls slipped a stale
|
|
430
|
+
// precondition through with two round-trips, regardless of read order. One
|
|
431
|
+
// query removes the gap: Postgres executes it against a single consistent
|
|
432
|
+
// snapshot.
|
|
433
|
+
//
|
|
434
|
+
// This also sidesteps a correctness bug the row's own `version` column
|
|
435
|
+
// can't be trusted for: applyEntityEvent only keeps row.version in
|
|
436
|
+
// lock-step with the OTHER projection columns for rows this executor
|
|
437
|
+
// itself wrote. A row seeded directly (raw INSERT — test fixtures, or
|
|
438
|
+
// legacy pre-#762 data) can carry a default version (e.g. 1) with ZERO
|
|
439
|
+
// matching events. Deriving expectedVersion from such a row.version makes
|
|
440
|
+
// the append below target a non-existent predecessor and fail outright.
|
|
441
|
+
// The events table's MAX(version) (0 for such a row) is the only value
|
|
442
|
+
// append() can safely use as expectedVersion — exactly what
|
|
443
|
+
// getStreamVersion() already returns for every other (non-`expect`)
|
|
444
|
+
// caller; this reads it in the same statement as the expect columns
|
|
445
|
+
// instead of a second round-trip.
|
|
446
|
+
//
|
|
447
|
+
// Reads raw column values — no decryptForRead pass — so `expect:` only
|
|
448
|
+
// supports plain (non-pii, non-encrypted) columns: business-state fields
|
|
449
|
+
// like status flags or foreign-key ids, not PII.
|
|
450
|
+
async function loadExpectSnapshot(
|
|
451
|
+
db: TenantDb,
|
|
452
|
+
id: EntityId,
|
|
453
|
+
streamTenantId: TenantId,
|
|
454
|
+
expectKeys: readonly string[],
|
|
455
|
+
): Promise<{ readonly row: Record<string, unknown> | null; readonly streamVersion: number }> {
|
|
456
|
+
const quote = (name: string): string => `"${name.replace(/"/g, '""')}"`;
|
|
457
|
+
const columnOf = (field: string): string =>
|
|
458
|
+
quote((table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field));
|
|
459
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
460
|
+
|
|
461
|
+
const selectCols = expectKeys.map((key) => `${columnOf(key)} AS ${quote(key)}`);
|
|
462
|
+
const whereParts: string[] = [`${columnOf("id")} = $1`];
|
|
463
|
+
const params: unknown[] = [id];
|
|
464
|
+
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
465
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
466
|
+
whereParts.push(`${columnOf("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
467
|
+
}
|
|
468
|
+
params.push(String(id), streamTenantId);
|
|
469
|
+
const streamAggregateIdx = params.length - 1;
|
|
470
|
+
const streamTenantIdx = params.length;
|
|
471
|
+
|
|
472
|
+
const sqlText =
|
|
473
|
+
`SELECT ${selectCols.join(", ")}, ` +
|
|
474
|
+
`(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${streamAggregateIdx} ` +
|
|
475
|
+
`AND "tenant_id" = $${streamTenantIdx}) AS "__streamVersion" ` +
|
|
476
|
+
`FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
477
|
+
|
|
478
|
+
const rows = await executeRawQueryRead<Record<string, unknown>>(
|
|
479
|
+
tenantDbRunner(db),
|
|
480
|
+
sqlText,
|
|
481
|
+
params,
|
|
482
|
+
);
|
|
483
|
+
const row = rows[0];
|
|
484
|
+
if (!row) return { row: null, streamVersion: 0 };
|
|
485
|
+
const { __streamVersion, ...fields } = row;
|
|
486
|
+
return {
|
|
487
|
+
row: fields,
|
|
488
|
+
streamVersion: typeof __streamVersion === "number" ? __streamVersion : 0,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
414
492
|
return {
|
|
415
493
|
table,
|
|
416
494
|
entity,
|
|
@@ -423,6 +501,7 @@ export function buildExecutorContext(
|
|
|
423
501
|
loadById,
|
|
424
502
|
assertStreamWritable,
|
|
425
503
|
loadWithOwnership,
|
|
504
|
+
loadExpectSnapshot,
|
|
426
505
|
encryptForStorage,
|
|
427
506
|
decryptForRead,
|
|
428
507
|
applyDefaults,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
IdempotentReplayError,
|
|
9
9
|
InternalError,
|
|
10
10
|
NotFoundError,
|
|
11
|
+
PreconditionFailedError,
|
|
11
12
|
UnprocessableError,
|
|
12
13
|
writeFailure,
|
|
13
14
|
} from "../errors";
|
|
@@ -114,6 +115,7 @@ export function createWriteVerbs(
|
|
|
114
115
|
stripSensitive,
|
|
115
116
|
loadById,
|
|
116
117
|
assertStreamWritable,
|
|
118
|
+
loadExpectSnapshot,
|
|
117
119
|
} = ctx;
|
|
118
120
|
|
|
119
121
|
return {
|
|
@@ -399,11 +401,46 @@ export function createWriteVerbs(
|
|
|
399
401
|
// aggregate); a stale row.version here would make the next CRUD write
|
|
400
402
|
// trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
|
|
401
403
|
// with version_conflict.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
404
|
+
//
|
|
405
|
+
// `expect:` (#3024) reads this same authoritative version, but through
|
|
406
|
+
// loadExpectSnapshot's single combined query instead of a second,
|
|
407
|
+
// separate getStreamVersion() round-trip: two reads (in either order)
|
|
408
|
+
// leave a real gap — applyEntityEvent writes the projection in a
|
|
409
|
+
// SEPARATE statement after its event commits, so a second reader's
|
|
410
|
+
// version-read can land in that gap and see a fresh, non-conflicting
|
|
411
|
+
// version paired with a still-stale projection row (verified
|
|
412
|
+
// empirically: ~40% of genuinely concurrent runs slipped through with
|
|
413
|
+
// two round-trips). One query removes the gap. It also avoids trusting
|
|
414
|
+
// the row's own `version` column for the expectedVersion: that column
|
|
415
|
+
// is only in lock-step with the rest of the row for rows THIS executor
|
|
416
|
+
// wrote — a raw-seeded row (test fixture, or legacy pre-#762 data) can
|
|
417
|
+
// carry a default version with zero matching events, which would make
|
|
418
|
+
// the append below target a non-existent predecessor and fail outright.
|
|
419
|
+
let currentVersion: number;
|
|
420
|
+
if (updateOptions?.expect) {
|
|
421
|
+
const expectKeys = Object.keys(updateOptions.expect);
|
|
422
|
+
const snapshot = await loadExpectSnapshot(
|
|
423
|
+
db,
|
|
424
|
+
payload.id,
|
|
425
|
+
streamTenantFor(user),
|
|
426
|
+
expectKeys,
|
|
427
|
+
);
|
|
428
|
+
if (!snapshot.row) {
|
|
429
|
+
return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: "id" }));
|
|
430
|
+
}
|
|
431
|
+
const mismatch = Object.entries(updateOptions.expect).find(
|
|
432
|
+
([key, expected]) => snapshot.row?.[key] !== expected,
|
|
433
|
+
);
|
|
434
|
+
if (mismatch) {
|
|
435
|
+
return writeFailure(
|
|
436
|
+
new PreconditionFailedError({ entityId: payload.id, field: mismatch[0] }),
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
currentVersion = snapshot.streamVersion;
|
|
440
|
+
} else {
|
|
441
|
+
currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
442
|
+
}
|
|
443
|
+
|
|
407
444
|
if (!updateOptions?.skipOptimisticLock) {
|
|
408
445
|
if (payload.version === undefined) {
|
|
409
446
|
return writeFailure(
|
package/src/db/index.ts
CHANGED
package/src/db/tenant-db.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
type WhereObject,
|
|
26
26
|
} from "../db/query";
|
|
27
27
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
28
|
-
import { AccessDeniedError, InternalError } from "../errors";
|
|
28
|
+
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
|
|
29
29
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
30
30
|
import { fallbackEscapeHatchReporter } from "../observability/escape-hatch-report";
|
|
31
31
|
import type { DbRunner } from "./connection";
|
|
@@ -202,6 +202,9 @@ export type TenantDbGrants = {
|
|
|
202
202
|
readonly globalWrites?: EscapeHatchDeclaration;
|
|
203
203
|
readonly unsafeRaw?: EscapeHatchDeclaration;
|
|
204
204
|
readonly report?: EscapeHatchReporter;
|
|
205
|
+
// Set for a resolved member principal (ctx.queryAsMember): no raw DbRunner leaves
|
|
206
|
+
// this TenantDb, so no handler can COMMIT/RELEASE SAVEPOINT out of the READ ONLY scope.
|
|
207
|
+
readonly memberReadOnly?: boolean;
|
|
205
208
|
};
|
|
206
209
|
|
|
207
210
|
const unsafeRawRebinders = new WeakMap<
|
|
@@ -419,6 +422,10 @@ export function createTenantDb(
|
|
|
419
422
|
if (reason.trim().length === 0) {
|
|
420
423
|
throw new Error("unsafeRaw requires a non-empty reason");
|
|
421
424
|
}
|
|
425
|
+
// Ahead of the grant check: a declared escapeHatch must not buy a raw runner here either.
|
|
426
|
+
if (grants?.memberReadOnly) {
|
|
427
|
+
throw memberResolutionReadOnlyDenied();
|
|
428
|
+
}
|
|
422
429
|
if (!hasGrant(grants?.unsafeRaw)) {
|
|
423
430
|
throw new AccessDeniedError({
|
|
424
431
|
message:
|
|
@@ -95,3 +95,59 @@ describe("validateBoot — i18n surface keys", () => {
|
|
|
95
95
|
expect(() => validateBoot([feature])).not.toThrow();
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
// fw#2986: requiredKeysFromScreen read only section.fields, so a field declared
|
|
100
|
+
// through section.groups — and every groups[].title — slipped past this guard.
|
|
101
|
+
describe("validateBoot — i18n surface keys from section.groups (fw#2986)", () => {
|
|
102
|
+
function groupsFeature(keys: Record<string, { de: string; en: string }>) {
|
|
103
|
+
return defineFeature("demo", (r) => {
|
|
104
|
+
r.entity(
|
|
105
|
+
"item",
|
|
106
|
+
createEntity({
|
|
107
|
+
table: "Items",
|
|
108
|
+
fields: {
|
|
109
|
+
name: createTextField({ sortable: true, personal: false, reason: "test_fixture" }),
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
r.screen({
|
|
114
|
+
id: "item-edit",
|
|
115
|
+
type: "entityEdit",
|
|
116
|
+
entity: "item",
|
|
117
|
+
layout: {
|
|
118
|
+
sections: [
|
|
119
|
+
{
|
|
120
|
+
fields: [],
|
|
121
|
+
groups: [{ title: "demo:group.basics", fields: ["name"] }],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
r.translations({ keys });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const complete = {
|
|
131
|
+
"screen:item-edit.title": { de: "Bearbeiten", en: "Edit" },
|
|
132
|
+
"demo:group.basics": { de: "Basis", en: "Basics" },
|
|
133
|
+
"demo:entity:item:field:name": { de: "Name", en: "Name" },
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
test("passes when the group title and the group's field label are translated", () => {
|
|
137
|
+
expect(() => validateBoot([groupsFeature(complete)])).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("throws for a missing label of a field declared only in groups", () => {
|
|
141
|
+
const { "demo:entity:item:field:name": _dropped, ...withoutFieldLabel } = complete;
|
|
142
|
+
expect(() => validateBoot([groupsFeature(withoutFieldLabel)])).toThrow(
|
|
143
|
+
/required translation key missing: "demo:entity:item:field:name"/,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("throws for a missing groups[].title translation", () => {
|
|
148
|
+
const { "demo:group.basics": _dropped, ...withoutGroupTitle } = complete;
|
|
149
|
+
expect(() => validateBoot([groupsFeature(withoutGroupTitle)])).toThrow(
|
|
150
|
+
/required translation key missing: "demo:group\.basics"/,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
});
|