@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.
- package/package.json +3 -3
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +91 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +51 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +67 -2
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +71 -19
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 [
|
|
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
|
|
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>>(
|
|
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
|
-
|
|
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
|
|
784
|
+
tenantDbRunner(db),
|
|
783
785
|
String(payload.id),
|
|
784
786
|
streamTenantFor(user),
|
|
785
787
|
);
|