@cosmicdrift/kumiko-framework 0.270.0 → 0.273.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +91 -0
  13. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  14. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  15. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  16. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  17. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  18. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  19. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  20. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  21. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  22. package/src/db/apply-entity-event.ts +1 -1
  23. package/src/db/assert-exists-in.ts +1 -1
  24. package/src/db/event-store-executor-context.ts +5 -2
  25. package/src/db/event-store-executor-read.ts +6 -4
  26. package/src/db/event-store-executor-write.ts +24 -18
  27. package/src/db/queries/shadow-swap.ts +36 -0
  28. package/src/db/replay-migration-sql.ts +5 -5
  29. package/src/db/tenant-db-runner.ts +23 -0
  30. package/src/db/tenant-db.ts +97 -24
  31. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  32. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  33. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  34. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  35. package/src/engine/__tests__/boot-validator.test.ts +51 -0
  36. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  37. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  38. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  39. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  40. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  41. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  42. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  43. package/src/engine/__tests__/screen.test.ts +150 -0
  44. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  45. package/src/engine/__tests__/steps-read.test.ts +204 -8
  46. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  47. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  48. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  49. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  50. package/src/engine/boot-validator/entity-handler.ts +21 -0
  51. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  52. package/src/engine/boot-validator/parent-ref.ts +1 -1
  53. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  54. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  55. package/src/engine/boot-validator/screens.ts +154 -31
  56. package/src/engine/build-config-feature-schema.ts +7 -4
  57. package/src/engine/entity-handlers.ts +71 -19
  58. package/src/engine/feature-entity-handlers.ts +4 -4
  59. package/src/engine/index.ts +5 -0
  60. package/src/engine/registry-validate.ts +12 -4
  61. package/src/engine/schema-builder.ts +6 -0
  62. package/src/engine/screen-helpers.ts +52 -0
  63. package/src/engine/steps/_read-source.ts +12 -0
  64. package/src/engine/steps/read-find-many.ts +8 -2
  65. package/src/engine/steps/read-find-one.ts +11 -8
  66. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  67. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  68. package/src/engine/types/index.ts +8 -1
  69. package/src/errors/i18n/de.yaml +14 -0
  70. package/src/errors/i18n/en.yaml +13 -0
  71. package/src/errors/reasons.ts +4 -0
  72. package/src/i18n/required-surface-keys.ts +6 -2
  73. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  74. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  75. package/src/jobs/job-runner.ts +35 -5
  76. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  77. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  78. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  79. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  80. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  81. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  82. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  83. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  84. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  85. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  86. package/src/pipeline/dispatch-query.ts +27 -8
  87. package/src/pipeline/dispatch-shared.ts +141 -57
  88. package/src/pipeline/dispatch-stream.ts +1 -3
  89. package/src/pipeline/dispatch-write.ts +62 -14
  90. package/src/pipeline/dispatcher.ts +20 -0
  91. package/src/pipeline/escape-hatch-report.ts +127 -0
  92. package/src/pipeline/index.ts +8 -0
  93. package/src/pipeline/member-read-only-transaction.ts +75 -0
  94. package/src/pipeline/msp-rebuild.ts +2 -0
  95. package/src/pipeline/projection-rebuild.ts +2 -0
  96. package/src/pipeline/projections-runner.ts +15 -13
  97. package/src/pipeline/system-identity-switch.ts +121 -9
  98. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  99. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  100. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  101. package/src/ui-types/index.ts +3 -0
  102. package/src/utils/__tests__/ids.test.ts +29 -1
  103. package/src/utils/ids.ts +10 -1
  104. package/src/utils/index.ts +1 -1
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { EscapeHatchKind } from "@cosmicdrift/kumiko-types/handlers";
3
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
4
+ import { AccessDeniedError, InternalError } from "../../errors";
5
+ import { testTenantId } from "../../stack";
6
+ import type { DbRunner } from "../connection";
7
+ import {
8
+ createTenantDb,
9
+ createUncheckedSystemDb,
10
+ unsafeRawForDeclaredStep,
11
+ withUnsafeRawGrant,
12
+ } from "../tenant-db";
13
+
14
+ const tenantId = testTenantId(1);
15
+ const REASON = "declared step forwarding unsafeRaw";
16
+
17
+ function fakeRunner(): DbRunner {
18
+ return {
19
+ unsafe: async () => [],
20
+ begin: async () => {
21
+ throw new Error("begin not used in these tests");
22
+ },
23
+ } as unknown as DbRunner;
24
+ }
25
+
26
+ describe("unsafeRawForDeclaredStep", () => {
27
+ test("throws AccessDeniedError without a grant, without reporting", () => {
28
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
29
+ const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
30
+ report: (kind, reason) => {
31
+ reports.push({ kind, reason });
32
+ },
33
+ });
34
+
35
+ expect(() => unsafeRawForDeclaredStep(tdb, REASON)).toThrow(AccessDeniedError);
36
+ expect(reports).toEqual([]);
37
+ });
38
+
39
+ test("returns exactly the bound runner and reports unsafe-raw exactly once, with a grant", () => {
40
+ const runner = fakeRunner();
41
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
42
+ const tdb = createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
43
+ unsafeRaw: { reason: "handler declared unsafeRaw" },
44
+ report: (kind, reason) => {
45
+ reports.push({ kind, reason });
46
+ },
47
+ });
48
+
49
+ expect(unsafeRawForDeclaredStep(tdb, REASON)).toBe(runner);
50
+ expect(reports).toEqual([{ kind: "unsafe-raw", reason: REASON }]);
51
+ });
52
+
53
+ test("throws for an empty or whitespace-only reason", () => {
54
+ const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
55
+ unsafeRaw: { reason: "handler declared unsafeRaw" },
56
+ });
57
+
58
+ expect(() => unsafeRawForDeclaredStep(tdb, "")).toThrow(/non-empty reason/);
59
+ expect(() => unsafeRawForDeclaredStep(tdb, " ")).toThrow(/non-empty reason/);
60
+ });
61
+
62
+ test("throws InternalError for a TenantDb not built by createTenantDb", () => {
63
+ const handBuilt = { tenantId, mode: "tenant" } as unknown as TenantDb;
64
+ expect(() => unsafeRawForDeclaredStep(handBuilt, REASON)).toThrow(InternalError);
65
+ });
66
+
67
+ test("throws InternalError for a throwing guard Proxy, without the proxy's get trap firing", () => {
68
+ let getTrapFired = false;
69
+ const guardProxy = new Proxy(
70
+ {},
71
+ {
72
+ get() {
73
+ getTrapFired = true;
74
+ throw new Error("systemScope guard: property access denied");
75
+ },
76
+ },
77
+ ) as unknown as TenantDb;
78
+
79
+ expect(() => unsafeRawForDeclaredStep(guardProxy, REASON)).toThrow(InternalError);
80
+ expect(getTrapFired).toBe(false);
81
+ });
82
+
83
+ test("resolves to the bound runner for a createUncheckedSystemDb holder, reporting once", () => {
84
+ const runner = fakeRunner();
85
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
86
+ const systemDb = createTenantDb(runner, tenantId, "system");
87
+ const unchecked = createUncheckedSystemDb(systemDb, undefined, (kind, reason) => {
88
+ reports.push({ kind, reason });
89
+ });
90
+
91
+ expect(unsafeRawForDeclaredStep(unchecked, REASON)).toBe(runner);
92
+ expect(reports).toEqual([{ kind: "unsafe-raw", reason: REASON }]);
93
+ });
94
+
95
+ test("still resolves after withUnsafeRawGrant rebinds the TenantDb", () => {
96
+ const runner = fakeRunner();
97
+ const tdb = createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
98
+ unsafeRaw: { reason: "original grant" },
99
+ });
100
+ const regranted = withUnsafeRawGrant(tdb, { reason: "rebind grant" });
101
+
102
+ expect(unsafeRawForDeclaredStep(regranted, REASON)).toBe(runner);
103
+ });
104
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
3
+ import type { EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
3
4
  import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
4
5
  import { createEntity, createTextField } from "../../engine";
5
6
  import { AccessDeniedError } from "../../errors";
@@ -69,6 +70,19 @@ const explicitGlobalLiteralTable = buildEntityTable("globalGuardExplicitGlobal",
69
70
 
70
71
  const own = testTenantId(1);
71
72
 
73
+ function recordingReporter(): {
74
+ readonly report: EscapeHatchReporter;
75
+ readonly calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }>;
76
+ } {
77
+ const calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }> = [];
78
+ return {
79
+ report: (kind, reason, target) => {
80
+ calls.push({ kind, reason, target });
81
+ },
82
+ calls,
83
+ };
84
+ }
85
+
72
86
  // Type-only assertions — never invoked, checked by `tsc --build` only.
73
87
  function typeAssertions(tdb: TenantDb): void {
74
88
  // @ts-expect-error db.global() on a "tenant"-tenancy table (default tenancy) is a type error.
@@ -157,6 +171,41 @@ describe("TenantDb.global()", () => {
157
171
  expect(captured).toHaveLength(1);
158
172
  });
159
173
 
174
+ test("write with an escapeHatch reports global-write once with the grant's reason", async () => {
175
+ const db: DbRunner = {
176
+ unsafe: async () => [{ id: "x" }],
177
+ begin: async () => {
178
+ throw new Error("begin not used in this test");
179
+ },
180
+ } as DbRunner;
181
+ const { report, calls } = recordingReporter();
182
+ const tdb = createTenantDb(db, own, "tenant", undefined, undefined, undefined, {
183
+ globalWrites: { reason: "cross-tenant backfill job" },
184
+ report,
185
+ });
186
+ await tdb.global(globalUnmanagedTable).insertOne({ name: "x" });
187
+ expect(calls).toEqual([
188
+ { kind: "global-write", reason: "cross-tenant backfill job", target: undefined },
189
+ ]);
190
+ });
191
+
192
+ test("denied global write (no grant) reports nothing", async () => {
193
+ const { report, calls } = recordingReporter();
194
+ const tdb = createTenantDb(
195
+ unreachableRunner(),
196
+ own,
197
+ "tenant",
198
+ undefined,
199
+ undefined,
200
+ undefined,
201
+ {
202
+ report,
203
+ },
204
+ );
205
+ await expect(tdb.global(globalUnmanagedTable).insertOne({ name: "x" })).rejects.toThrow();
206
+ expect(calls).toEqual([]);
207
+ });
208
+
160
209
  test("write with an explicit non-SYSTEM tenantId rejects even with a valid escapeHatch", async () => {
161
210
  const tdb = createTenantDb(
162
211
  unreachableRunner(),
@@ -214,6 +263,41 @@ describe("TenantDb.unsafeRaw()", () => {
214
263
  });
215
264
  expect(tdb.unsafeRaw("some reason")).toBe(rawDb);
216
265
  });
266
+
267
+ test("with a valid grant reports unsafe-raw once with the reason", () => {
268
+ const { report, calls } = recordingReporter();
269
+ const tdb = createTenantDb(
270
+ unreachableRunner(),
271
+ own,
272
+ "tenant",
273
+ undefined,
274
+ undefined,
275
+ undefined,
276
+ {
277
+ unsafeRaw: { reason: "granted" },
278
+ report,
279
+ },
280
+ );
281
+ tdb.unsafeRaw("cleanup read");
282
+ expect(calls).toEqual([{ kind: "unsafe-raw", reason: "cleanup read", target: undefined }]);
283
+ });
284
+
285
+ test("without a grant reports nothing", () => {
286
+ const { report, calls } = recordingReporter();
287
+ const tdb = createTenantDb(
288
+ unreachableRunner(),
289
+ own,
290
+ "tenant",
291
+ undefined,
292
+ undefined,
293
+ undefined,
294
+ {
295
+ report,
296
+ },
297
+ );
298
+ expect(() => tdb.unsafeRaw("some reason")).toThrow(AccessDeniedError);
299
+ expect(calls).toEqual([]);
300
+ });
217
301
  });
218
302
 
219
303
  describe("withUnsafeRawGrant", () => {
@@ -257,4 +341,12 @@ describe("UncheckedSystemDb.unsafeRaw", () => {
257
341
  const sysDb = createUncheckedSystemDb(tdb);
258
342
  expect(sysDb.unsafeRaw("cleanup job")).toBe(rawDb);
259
343
  });
344
+
345
+ test("reports unsafe-raw once with the reason", () => {
346
+ const tdb = createTenantDb(unreachableRunner(), own);
347
+ const { report, calls } = recordingReporter();
348
+ const sysDb = createUncheckedSystemDb(tdb, undefined, report);
349
+ sysDb.unsafeRaw("cleanup job");
350
+ expect(calls).toEqual([{ kind: "unsafe-raw", reason: "cleanup job", target: undefined }]);
351
+ });
260
352
  });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
+ import { InternalError } from "../../errors";
4
+ import { testTenantId } from "../../stack";
5
+ import type { DbRunner } from "../connection";
6
+ import { createTenantDb, createUncheckedSystemDb, withUnsafeRawGrant } from "../tenant-db";
7
+ import { tenantDbRunner } from "../tenant-db-runner";
8
+
9
+ // TenantDb.raw is gone from the type; framework-internal callers resolve
10
+ // the bound DbRunner through tenant-db-runner.ts's WeakMap instead.
11
+
12
+ const tenantId = testTenantId(1);
13
+
14
+ function fakeRunner(): DbRunner {
15
+ return {
16
+ unsafe: async () => [],
17
+ begin: async () => {
18
+ throw new Error("begin not used in these tests");
19
+ },
20
+ } as unknown as DbRunner;
21
+ }
22
+
23
+ describe("TenantDb has no .raw", () => {
24
+ test("compile-time: .raw is not a property of TenantDb", () => {
25
+ const tdb = createTenantDb(fakeRunner(), tenantId);
26
+ // @ts-expect-error TenantDb no longer exposes `raw`.
27
+ expect(tdb.raw).toBeUndefined();
28
+ });
29
+ });
30
+
31
+ describe("tenantDbRunner", () => {
32
+ test("returns the runner bound at createTenantDb() (identity)", () => {
33
+ const runner = fakeRunner();
34
+ const tdb = createTenantDb(runner, tenantId);
35
+ expect(tenantDbRunner(tdb)).toBe(runner);
36
+ });
37
+
38
+ test("withUnsafeRawGrant rebinds also resolve to the original runner", () => {
39
+ const runner = fakeRunner();
40
+ const tdb = createTenantDb(runner, tenantId);
41
+ const regranted = withUnsafeRawGrant(tdb, { reason: "test grant" });
42
+ expect(regranted).not.toBe(tdb);
43
+ expect(tenantDbRunner(regranted)).toBe(runner);
44
+ });
45
+
46
+ test("throws InternalError for a hand-built object not built by createTenantDb", () => {
47
+ const handBuilt = { tenantId, mode: "tenant" } as unknown as TenantDb;
48
+ expect(() => tenantDbRunner(handBuilt)).toThrow(InternalError);
49
+ });
50
+
51
+ test("throws InternalError for a throwing guard Proxy, without the proxy's get trap firing", () => {
52
+ let getTrapFired = false;
53
+ const guardProxy = new Proxy(
54
+ {},
55
+ {
56
+ get() {
57
+ getTrapFired = true;
58
+ throw new Error("systemScope guard: property access denied");
59
+ },
60
+ },
61
+ ) as unknown as TenantDb;
62
+
63
+ expect(() => tenantDbRunner(guardProxy)).toThrow(InternalError);
64
+ expect(getTrapFired).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe("createUncheckedSystemDb(...).unsafeRaw", () => {
69
+ test("resolves to the runner bound at createTenantDb()", () => {
70
+ const runner = fakeRunner();
71
+ const tdb = createTenantDb(runner, tenantId, "system");
72
+ const systemDb = createUncheckedSystemDb(tdb);
73
+ expect(systemDb.unsafeRaw("test reason")).toBe(runner);
74
+ });
75
+ });
@@ -110,7 +110,9 @@ describe("scoped mode (default)", () => {
110
110
 
111
111
  await tdb.insertOne(table, { name: "ColSelect" });
112
112
 
113
- const rows = await asRawClient(tdb).unsafe<Record<string, unknown>>(
113
+ // This probes a custom column projection, not tenant-scoping, so it goes
114
+ // straight at the underlying runner tdb was built from.
115
+ const rows = await asRawClient(testDb.db).unsafe<Record<string, unknown>>(
114
116
  `SELECT id, name FROM "tenant_db_items" WHERE name = $1 LIMIT 10`,
115
117
  ["ColSelect"],
116
118
  );
@@ -1,9 +1,23 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
2
3
  import { SYSTEM_TENANT_ID } from "../../engine";
3
4
  import { testTenantId } from "../../stack";
4
5
  import type { DbRunner } from "../connection";
5
6
  import { createTenantDb, createUncheckedSystemDb, SYSTEM_SCOPE_CHECK_BRAND } from "../tenant-db";
6
7
 
8
+ function recordingReporter(): {
9
+ readonly report: EscapeHatchReporter;
10
+ readonly calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }>;
11
+ } {
12
+ const calls: Array<{ kind: string; reason: string; target: EscapeHatchTarget | undefined }> = [];
13
+ return {
14
+ report: (kind, reason, target) => {
15
+ calls.push({ kind, reason, target });
16
+ },
17
+ calls,
18
+ };
19
+ }
20
+
7
21
  // createUncheckedSystemDb wraps a "system"-mode TenantDb (r.systemScope())
8
22
  // so a handler must explicitly clear a self-check before using it — none of
9
23
  // these checks execute a query, so a runner that always throws is enough to
@@ -44,6 +58,16 @@ describe("createUncheckedSystemDb", () => {
44
58
 
45
59
  expect(() => unchecked.assertTenantMatch(foreign)).toThrow(/tenant self-check failed/);
46
60
  });
61
+
62
+ test("does not report — it is a tenant-safe check", () => {
63
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
64
+ const { report, calls } = recordingReporter();
65
+ const unchecked = createUncheckedSystemDb(systemDb, undefined, report);
66
+
67
+ unchecked.assertTenantMatch(own);
68
+ expect(() => unchecked.assertTenantMatch(foreign)).toThrow();
69
+ expect(calls).toEqual([]);
70
+ });
47
71
  });
48
72
 
49
73
  describe("assertRowsTenant", () => {
@@ -88,6 +112,16 @@ describe("createUncheckedSystemDb", () => {
88
112
 
89
113
  expect(unchecked.assertRowsTenant(rows, "tenantId")).toBe(rows);
90
114
  });
115
+
116
+ test("does not report — it is a tenant-safe check", () => {
117
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
118
+ const { report, calls } = recordingReporter();
119
+ const unchecked = createUncheckedSystemDb(systemDb, undefined, report);
120
+ const rows = [{ tenantId: own, name: "a" }];
121
+
122
+ unchecked.assertRowsTenant(rows, "tenantId");
123
+ expect(calls).toEqual([]);
124
+ });
91
125
  });
92
126
 
93
127
  describe("acknowledgeCrossTenant", () => {
@@ -113,6 +147,30 @@ describe("createUncheckedSystemDb", () => {
113
147
 
114
148
  expect(() => unchecked.acknowledgeCrossTenant(" ")).toThrow(/non-empty reason/);
115
149
  });
150
+
151
+ test("reports acknowledge-cross-tenant once with the reason", () => {
152
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
153
+ const { report, calls } = recordingReporter();
154
+ const unchecked = createUncheckedSystemDb(systemDb, undefined, report);
155
+
156
+ unchecked.acknowledgeCrossTenant("user feature is cross-tenant by design");
157
+ expect(calls).toEqual([
158
+ {
159
+ kind: "acknowledge-cross-tenant",
160
+ reason: "user feature is cross-tenant by design",
161
+ target: undefined,
162
+ },
163
+ ]);
164
+ });
165
+
166
+ test("an empty reason throws and does not report", () => {
167
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
168
+ const { report, calls } = recordingReporter();
169
+ const unchecked = createUncheckedSystemDb(systemDb, undefined, report);
170
+
171
+ expect(() => unchecked.acknowledgeCrossTenant("")).toThrow();
172
+ expect(calls).toEqual([]);
173
+ });
116
174
  });
117
175
 
118
176
  describe("outsideTransaction", () => {
@@ -178,6 +236,24 @@ describe("createUncheckedSystemDb", () => {
178
236
  /no outside-transaction database source is configured/,
179
237
  );
180
238
  });
239
+
240
+ test("reports acknowledge-cross-tenant once with the reason", () => {
241
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
242
+ const outsideTxDb = createTenantDb(unusedRunner(), own, "system");
243
+ const { report, calls } = recordingReporter();
244
+ const unchecked = createUncheckedSystemDb(systemDb, outsideTxDb, report);
245
+
246
+ unchecked.outsideTransaction.acknowledgeCrossTenant(
247
+ "durability write is cross-tenant by design",
248
+ );
249
+ expect(calls).toEqual([
250
+ {
251
+ kind: "acknowledge-cross-tenant",
252
+ reason: "durability write is cross-tenant by design",
253
+ target: undefined,
254
+ },
255
+ ]);
256
+ });
181
257
  });
182
258
  });
183
259
  });
@@ -89,7 +89,7 @@ export async function applyEntityEvent(
89
89
  switch (verb) {
90
90
  case "created": {
91
91
  // tenantId-Resolution explizit, nicht via Spread-Reihenfolge:
92
- // Live-Pfad nutzt tx=db.raw (kein TenantDb-Wrapper-Auto-Inject),
92
+ // Live-Pfad nutzt tx=tenantDbRunner(db) (kein TenantDb-Wrapper-Auto-Inject),
93
93
  // beim Replay erst recht keiner. Default = event.tenantId; payload
94
94
  // gewinnt NUR wenn gültig string mit length > 0 (seedTenantMembership-
95
95
  // Pfad: Operator schreibt im Ziel-Tenant, Event im Operator-Tenant).
@@ -5,7 +5,7 @@ import type { DbConnection } from "./connection";
5
5
  import type { TenantDb } from "./tenant-db";
6
6
 
7
7
  function isTenantDb(db: DbConnection | TenantDb): db is TenantDb {
8
- return typeof (db as TenantDb).fetchOne === "function" && "raw" in db;
8
+ return "tenantId" in db && "fetchOne" in db && typeof db.fetchOne === "function";
9
9
  }
10
10
 
11
11
  /**
@@ -38,6 +38,7 @@ import type { EventStoreExecutorOptions } from "./event-store-executor";
38
38
  import { constraintOf, isUniqueViolation } from "./pg-error";
39
39
  import { toSnakeCase } from "./table-builder";
40
40
  import type { TenantDb } from "./tenant-db";
41
+ import { tenantDbRunner } from "./tenant-db-runner";
41
42
 
42
43
  // Shared context-building for the event-store-executor CRUD verbs (create/
43
44
  // update/delete/forget/restore/list/detail — see event-store-executor-write.ts
@@ -358,7 +359,7 @@ export function buildExecutorContext(
358
359
  id: EntityId,
359
360
  tenantId: TenantId,
360
361
  ): Promise<void> {
361
- if (await isStreamArchived(db.raw, tenantId, String(id))) {
362
+ if (await isStreamArchived(tenantDbRunner(db), tenantId, String(id))) {
362
363
  throw new ArchivedStreamError(tenantId, String(id));
363
364
  }
364
365
  }
@@ -401,7 +402,9 @@ export function buildExecutorContext(
401
402
  whereParts.push(shifted.sqlText);
402
403
  for (const p of shifted.params) params.push(p);
403
404
  const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
404
- return [...(await executeRawQueryRead<Record<string, unknown>>(db.raw, sqlText, params))];
405
+ return [
406
+ ...(await executeRawQueryRead<Record<string, unknown>>(tenantDbRunner(db), sqlText, params)),
407
+ ];
405
408
  }
406
409
 
407
410
  return {
@@ -22,6 +22,7 @@ import { buildFilterWhere, type ExecutorContext, type Table } from "./event-stor
22
22
  import { buildParentRefClause } from "./parent-ref-clause";
23
23
  import { buildEntityTable, physicalColumnName } from "./table-builder";
24
24
  import type { TenantDb, TenantDbMode } from "./tenant-db";
25
+ import { tenantDbRunner } from "./tenant-db-runner";
25
26
 
26
27
  // The two read verbs (list/detail) of the event-store-executor. Split out
27
28
  // of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
@@ -332,7 +333,7 @@ async function resolveReferenceMatches(
332
333
  const sql =
333
334
  `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
334
335
  `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
335
- const rows = await executeRawQueryRead<{ id: string }>(db.raw, sql, subParams);
336
+ const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, subParams);
336
337
  if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
337
338
  // skip: no match, or over the cap — drop the clause rather than
338
339
  // truncate to an arbitrary slice of matching targets
@@ -423,6 +424,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
423
424
  // list + detail are unchanged from crud-executor — projections are the
424
425
  // read-model and serve these queries directly.
425
426
  async list(payload, user, db, runtimeOptions) {
427
+ const runner = tenantDbRunner(db);
426
428
  const { limit, offset } = resolveListPagination(payload);
427
429
  const totalCount = payload.totalCount === true;
428
430
  const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
@@ -674,7 +676,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
674
676
  : "*";
675
677
  const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
676
678
 
677
- const rawRows = await executeRawQueryRead<Record<string, unknown>>(db.raw, listSql, params);
679
+ const rawRows = await executeRawQueryRead<Record<string, unknown>>(runner, listSql, params);
678
680
  const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
679
681
  if (referenceSortExpr !== undefined) {
680
682
  // The alias is a sort artifact, not a column of this entity — strip it
@@ -735,7 +737,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
735
737
  } else {
736
738
  const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
737
739
  const countRows = await executeRawQueryRead<{ count: number }>(
738
- db.raw,
740
+ runner,
739
741
  countSql,
740
742
  whereParams,
741
743
  );
@@ -779,7 +781,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
779
781
  row: Record<string, unknown>,
780
782
  ): Promise<Record<string, unknown>> => {
781
783
  const streamVersion = await getStreamVersion(
782
- db.raw,
784
+ tenantDbRunner(db),
783
785
  String(payload.id),
784
786
  streamTenantFor(user),
785
787
  );