@cosmicdrift/kumiko-framework 0.269.2 → 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/README.md +5 -1
- package/package.json +3 -3
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- 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__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
- package/src/__tests__/full-stack.integration.test.ts +2 -2
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/__tests__/transition-guard.integration.test.ts +7 -7
- package/src/api/__tests__/api.test.ts +2 -2
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- 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 +99 -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__/list-filter-field-access.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.integration.test.ts +2 -2
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +69 -18
- package/src/engine/__tests__/build-app-schema.test.ts +10 -6
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
- package/src/engine/__tests__/engine.test.ts +39 -25
- 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 +47 -8
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
- 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__/extends-registrar.test.ts +3 -3
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
- package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +6 -2
- package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
- package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
- package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
- package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/registry.test.ts +19 -13
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +176 -20
- 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__/access-declarations.test.ts +5 -4
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/access-declarations.ts +3 -5
- 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/nav.ts +4 -3
- 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 +12 -2
- package/src/engine/entity-handlers.ts +72 -20
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
- package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
- package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
- package/src/engine/feature-ast/extractors/handlers.ts +16 -2
- package/src/engine/feature-ast/extractors/hooks.ts +25 -4
- package/src/engine/feature-ast/patcher.ts +15 -3
- package/src/engine/feature-ast/patterns.ts +4 -0
- package/src/engine/feature-ast/render.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/shared-fields.ts +7 -3
- 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/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -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/es-ops/__tests__/runner.integration.test.ts +2 -2
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
- package/src/jobs/job-runner.ts +35 -5
- package/src/observability/__tests__/observability.integration.test.ts +7 -7
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
- 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__/lifecycle-pipeline.test.ts +10 -10
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- 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 +172 -17
- 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 +249 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -5
- package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +19 -5
- 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,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
|
);
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
tryMapUniqueViolation,
|
|
31
31
|
} from "./event-store-executor-context";
|
|
32
32
|
import { runInSavepointIfSupported } from "./query";
|
|
33
|
+
import { tenantDbRunner } from "./tenant-db-runner";
|
|
33
34
|
|
|
34
35
|
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
35
36
|
// per-role ownership map can never cover it, and a silent deny means the
|
|
@@ -117,6 +118,7 @@ export function createWriteVerbs(
|
|
|
117
118
|
|
|
118
119
|
return {
|
|
119
120
|
async create(payload, user, db, options) {
|
|
121
|
+
const runner = tenantDbRunner(db);
|
|
120
122
|
if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
|
|
121
123
|
throw new AccessDeniedError({
|
|
122
124
|
message:
|
|
@@ -215,9 +217,9 @@ export function createWriteVerbs(
|
|
|
215
217
|
// of the version_conflict this catch classifies. runInSavepointIfSupported
|
|
216
218
|
// confines the failed INSERT to a nested scope that rolls back on
|
|
217
219
|
// its own (same pattern as ctx.tryAppendEvent), and falls back to a
|
|
218
|
-
// plain call when
|
|
220
|
+
// plain call when the runner is a bare pool connection with no active
|
|
219
221
|
// transaction to poison (seeds/tests calling the executor directly).
|
|
220
|
-
event = await runInSavepointIfSupported(
|
|
222
|
+
event = await runInSavepointIfSupported(runner, async (sp) =>
|
|
221
223
|
append(sp, {
|
|
222
224
|
aggregateId,
|
|
223
225
|
aggregateType: entityName,
|
|
@@ -232,7 +234,7 @@ export function createWriteVerbs(
|
|
|
232
234
|
if (e instanceof EventStoreVersionConflict) {
|
|
233
235
|
let currentVersion = -1;
|
|
234
236
|
try {
|
|
235
|
-
currentVersion = await getStreamVersion(
|
|
237
|
+
currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user));
|
|
236
238
|
} catch {
|
|
237
239
|
// Lookup failure — keep the sentinel.
|
|
238
240
|
}
|
|
@@ -268,7 +270,7 @@ export function createWriteVerbs(
|
|
|
268
270
|
// failed INSERT back; the error still propagates and stays catchable.
|
|
269
271
|
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
270
272
|
try {
|
|
271
|
-
result = await runInSavepointIfSupported(
|
|
273
|
+
result = await runInSavepointIfSupported(runner, async (sp) =>
|
|
272
274
|
applyEntityEvent(event, table, entity, sp),
|
|
273
275
|
);
|
|
274
276
|
} catch (e) {
|
|
@@ -308,6 +310,7 @@ export function createWriteVerbs(
|
|
|
308
310
|
},
|
|
309
311
|
|
|
310
312
|
async update(payload, user, db, updateOptions) {
|
|
313
|
+
const runner = tenantDbRunner(db);
|
|
311
314
|
if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
|
|
312
315
|
throw new AccessDeniedError({
|
|
313
316
|
message:
|
|
@@ -397,7 +400,7 @@ export function createWriteVerbs(
|
|
|
397
400
|
// trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
|
|
398
401
|
// with version_conflict.
|
|
399
402
|
const currentVersion = await getStreamVersion(
|
|
400
|
-
|
|
403
|
+
runner,
|
|
401
404
|
String(payload.id),
|
|
402
405
|
streamTenantFor(user),
|
|
403
406
|
);
|
|
@@ -452,7 +455,7 @@ export function createWriteVerbs(
|
|
|
452
455
|
// Savepoint-scoped — see the create() append() above for why:
|
|
453
456
|
// confines a losing writer's unique-violation to a nested scope
|
|
454
457
|
// instead of poisoning the whole outer transaction.
|
|
455
|
-
const event = await runInSavepointIfSupported(
|
|
458
|
+
const event = await runInSavepointIfSupported(runner, (sp) =>
|
|
456
459
|
append(sp, {
|
|
457
460
|
aggregateId: String(payload.id),
|
|
458
461
|
aggregateType: entityName,
|
|
@@ -480,7 +483,7 @@ export function createWriteVerbs(
|
|
|
480
483
|
// failed nested one throws 500 instead of failing cleanly.
|
|
481
484
|
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
482
485
|
try {
|
|
483
|
-
result = await runInSavepointIfSupported(
|
|
486
|
+
result = await runInSavepointIfSupported(runner, async (sp) =>
|
|
484
487
|
applyEntityEvent(event, table, entity, sp),
|
|
485
488
|
);
|
|
486
489
|
} catch (e) {
|
|
@@ -539,6 +542,7 @@ export function createWriteVerbs(
|
|
|
539
542
|
},
|
|
540
543
|
|
|
541
544
|
async delete(payload, user, db) {
|
|
545
|
+
const runner = tenantDbRunner(db);
|
|
542
546
|
const existing = await loadById(payload.id, db);
|
|
543
547
|
if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
544
548
|
|
|
@@ -565,7 +569,7 @@ export function createWriteVerbs(
|
|
|
565
569
|
|
|
566
570
|
// Stream-version authoritative (see update() for rationale).
|
|
567
571
|
const currentVersion = await getStreamVersion(
|
|
568
|
-
|
|
572
|
+
runner,
|
|
569
573
|
String(payload.id),
|
|
570
574
|
streamTenantFor(user),
|
|
571
575
|
);
|
|
@@ -578,7 +582,7 @@ export function createWriteVerbs(
|
|
|
578
582
|
// land in the immutable log.
|
|
579
583
|
let event: Awaited<ReturnType<typeof append>>;
|
|
580
584
|
try {
|
|
581
|
-
event = await runInSavepointIfSupported(
|
|
585
|
+
event = await runInSavepointIfSupported(runner, async (sp) =>
|
|
582
586
|
append(sp, {
|
|
583
587
|
aggregateId: String(payload.id),
|
|
584
588
|
aggregateType: entityName,
|
|
@@ -606,7 +610,7 @@ export function createWriteVerbs(
|
|
|
606
610
|
}
|
|
607
611
|
|
|
608
612
|
// Live==Rebuild via applyEntityEvent. Savepoint like create/update.
|
|
609
|
-
const deleteResult = await runInSavepointIfSupported(
|
|
613
|
+
const deleteResult = await runInSavepointIfSupported(runner, async (sp) =>
|
|
610
614
|
applyEntityEvent(event, table, entity, sp),
|
|
611
615
|
);
|
|
612
616
|
if (deleteResult.kind !== "applied") {
|
|
@@ -636,6 +640,7 @@ export function createWriteVerbs(
|
|
|
636
640
|
// the erasure replays on rebuild (created → forgotten → row gone). Loads
|
|
637
641
|
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
638
642
|
async forget(payload, user, db) {
|
|
643
|
+
const runner = tenantDbRunner(db);
|
|
639
644
|
const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
|
|
640
645
|
if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
641
646
|
const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
|
|
@@ -660,14 +665,14 @@ export function createWriteVerbs(
|
|
|
660
665
|
|
|
661
666
|
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
662
667
|
const currentVersion = await getStreamVersion(
|
|
663
|
-
|
|
668
|
+
runner,
|
|
664
669
|
String(payload.id),
|
|
665
670
|
streamTenantFor(user),
|
|
666
671
|
);
|
|
667
672
|
|
|
668
673
|
let event: Awaited<ReturnType<typeof append>>;
|
|
669
674
|
try {
|
|
670
|
-
event = await runInSavepointIfSupported(
|
|
675
|
+
event = await runInSavepointIfSupported(runner, async (sp) =>
|
|
671
676
|
append(sp, {
|
|
672
677
|
aggregateId: String(payload.id),
|
|
673
678
|
aggregateType: entityName,
|
|
@@ -696,7 +701,7 @@ export function createWriteVerbs(
|
|
|
696
701
|
throw e;
|
|
697
702
|
}
|
|
698
703
|
|
|
699
|
-
const forgetResult = await runInSavepointIfSupported(
|
|
704
|
+
const forgetResult = await runInSavepointIfSupported(runner, async (sp) =>
|
|
700
705
|
applyEntityEvent(event, table, entity, sp),
|
|
701
706
|
);
|
|
702
707
|
if (forgetResult.kind !== "applied") {
|
|
@@ -722,6 +727,7 @@ export function createWriteVerbs(
|
|
|
722
727
|
},
|
|
723
728
|
|
|
724
729
|
async restore(payload, user, db) {
|
|
730
|
+
const runner = tenantDbRunner(db);
|
|
725
731
|
if (!softDelete) {
|
|
726
732
|
return writeFailure(
|
|
727
733
|
new UnprocessableError("soft_delete_not_enabled", {
|
|
@@ -731,8 +737,8 @@ export function createWriteVerbs(
|
|
|
731
737
|
}
|
|
732
738
|
|
|
733
739
|
// Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
|
|
734
|
-
// selectMany(
|
|
735
|
-
// tenant's row by id. "system"-mode dbs (r.systemScope() /
|
|
740
|
+
// selectMany(runner, ...) did not — any caller could un-delete a foreign
|
|
741
|
+
// tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
|
|
736
742
|
// handlers) still read unfiltered. No isDeleted filter here: restore
|
|
737
743
|
// targets exactly the soft-deleted row.
|
|
738
744
|
const row = await db.fetchOne(table, { id: payload.id });
|
|
@@ -766,7 +772,7 @@ export function createWriteVerbs(
|
|
|
766
772
|
|
|
767
773
|
// Stream-version authoritative (see update() for rationale).
|
|
768
774
|
const currentVersion = await getStreamVersion(
|
|
769
|
-
|
|
775
|
+
runner,
|
|
770
776
|
String(payload.id),
|
|
771
777
|
streamTenantFor(user),
|
|
772
778
|
);
|
|
@@ -777,7 +783,7 @@ export function createWriteVerbs(
|
|
|
777
783
|
// already ciphertext, no re-encrypt needed.
|
|
778
784
|
let event: Awaited<ReturnType<typeof append>>;
|
|
779
785
|
try {
|
|
780
|
-
event = await runInSavepointIfSupported(
|
|
786
|
+
event = await runInSavepointIfSupported(runner, (sp) =>
|
|
781
787
|
append(sp, {
|
|
782
788
|
aggregateId: String(payload.id),
|
|
783
789
|
aggregateType: entityName,
|
|
@@ -807,7 +813,7 @@ export function createWriteVerbs(
|
|
|
807
813
|
// Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
|
|
808
814
|
// plus the version bump, so there is no sensitive-field drift and no
|
|
809
815
|
// payload override is needed.
|
|
810
|
-
const restoreResult = await runInSavepointIfSupported(
|
|
816
|
+
const restoreResult = await runInSavepointIfSupported(runner, async (sp) =>
|
|
811
817
|
applyEntityEvent(event, table, entity, sp),
|
|
812
818
|
);
|
|
813
819
|
if (restoreResult.kind !== "applied" || restoreResult.row === null) {
|
|
@@ -105,6 +105,40 @@ export async function assertLiveColumnsMatchMeta(
|
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// EntityTableMeta carries no RLS/policies, so the swap would silently drop them (#2907).
|
|
109
|
+
export async function assertLiveTableHasNoRowLevelSecurity(
|
|
110
|
+
tx: AnyDb,
|
|
111
|
+
tableName: string,
|
|
112
|
+
projectionName?: string,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const rows = await asRawClient(tx).unsafe<{
|
|
115
|
+
rls_enabled: boolean;
|
|
116
|
+
rls_forced: boolean;
|
|
117
|
+
policy_count: number;
|
|
118
|
+
}>(
|
|
119
|
+
`SELECT c.relrowsecurity AS rls_enabled, c.relforcerowsecurity AS rls_forced,
|
|
120
|
+
(SELECT count(*)::int FROM pg_policy p WHERE p.polrelid = c.oid) AS policy_count
|
|
121
|
+
FROM pg_class c
|
|
122
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
123
|
+
WHERE n.nspname = 'public' AND c.relname = $1`,
|
|
124
|
+
[tableName],
|
|
125
|
+
);
|
|
126
|
+
const row = rows[0];
|
|
127
|
+
// skip: no live table yet — nothing to have RLS on
|
|
128
|
+
if (!row) return;
|
|
129
|
+
// skip: no RLS flags and no policies — nothing the swap could drop
|
|
130
|
+
if (!row.rls_enabled && !row.rls_forced && row.policy_count === 0) return;
|
|
131
|
+
const context =
|
|
132
|
+
projectionName !== undefined ? `projection-rebuild "${projectionName}"` : "shadow swap";
|
|
133
|
+
throw new Error(
|
|
134
|
+
`${context}: live table "${tableName}" has row level security (enabled: ${row.rls_enabled}, ` +
|
|
135
|
+
`forced: ${row.rls_forced}, policies: ${row.policy_count}). The shadow swap rebuilds the table ` +
|
|
136
|
+
"from EntityTableMeta and would silently drop RLS and every policy. Kumiko does not support RLS " +
|
|
137
|
+
"on rebuildable tables — see kumiko-platform docs/plans/rls-evaluation.md. Rebuild aborted; live " +
|
|
138
|
+
"table untouched.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
108
142
|
// Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
|
|
109
143
|
// Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
|
|
110
144
|
// rollback), drops any leftover shadow from a crashed run, then builds the
|
|
@@ -300,6 +334,8 @@ export async function countColumnDrift(
|
|
|
300
334
|
// object depends on the live table the swap fails loud and the whole rebuild
|
|
301
335
|
// rolls back, leaving the old table untouched.
|
|
302
336
|
export async function swapShadowIntoLive(tx: AnyDb, tableName: string): Promise<void> {
|
|
337
|
+
// Re-checked right before DROP: RLS could have been enabled after the early check.
|
|
338
|
+
await assertLiveTableHasNoRowLevelSecurity(tx, tableName);
|
|
303
339
|
const raw = asRawClient(tx);
|
|
304
340
|
const ident = quoteTableIdent(tableName);
|
|
305
341
|
await raw.unsafe(`DROP TABLE public.${ident}`);
|
|
@@ -84,7 +84,7 @@ const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp(
|
|
|
84
84
|
`ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
|
|
85
85
|
`ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
|
|
86
86
|
`(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
|
|
87
|
-
`\\b(ENABLE|DISABLE)\\s+ROW LEVEL SECURITY\\b`,
|
|
87
|
+
`\\b(ENABLE|DISABLE|(NO\\s+)?FORCE)\\s+ROW LEVEL SECURITY\\b`,
|
|
88
88
|
`^OWNER TO\\b`,
|
|
89
89
|
].join("|"),
|
|
90
90
|
"gi",
|
|
@@ -149,9 +149,9 @@ function applyStatement(
|
|
|
149
149
|
else table.columns.delete(name);
|
|
150
150
|
}
|
|
151
151
|
// Shape-neutral clauses (ALTER COLUMN TYPE, SET/DROP NOT NULL, SET/DROP
|
|
152
|
-
// DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL
|
|
153
|
-
// TO) — none add/remove/rename a column, so this replay
|
|
154
|
-
// tracks column presence) correctly has nothing to do for them.
|
|
152
|
+
// DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE/[NO] FORCE ROW LEVEL
|
|
153
|
+
// SECURITY, OWNER TO) — none add/remove/rename a column, so this replay
|
|
154
|
+
// (which only tracks column presence) correctly has nothing to do for them.
|
|
155
155
|
if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE)) matchedAClause = true;
|
|
156
156
|
// An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
|
|
157
157
|
// but whose body contains no recognized clause (e.g. RENAME TO/RENAME
|
|
@@ -177,7 +177,7 @@ function applyStatement(
|
|
|
177
177
|
`starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
|
|
178
178
|
`recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
|
|
179
179
|
`COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
|
|
180
|
-
`ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER TO — ` +
|
|
180
|
+
`ADD/DROP CONSTRAINT, ENABLE/DISABLE/[NO] FORCE ROW LEVEL SECURITY, OWNER TO — ` +
|
|
181
181
|
`optionally-quoted identifiers). Likely RENAME TO/RENAME COLUMN (real ` +
|
|
182
182
|
`identity change, not trackable here) or genuinely unparsed hand-written ` +
|
|
183
183
|
`DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
2
|
+
import { InternalError } from "../errors";
|
|
3
|
+
import type { DbRunner } from "./connection";
|
|
4
|
+
|
|
5
|
+
const tenantDbRunners = new WeakMap<TenantDb, DbRunner>();
|
|
6
|
+
|
|
7
|
+
export function bindTenantDbRunner(tenantDb: TenantDb, runner: DbRunner): void {
|
|
8
|
+
tenantDbRunners.set(tenantDb, runner);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// WeakMap.get never reads a property off tenantDb — safe even when tenantDb is a
|
|
12
|
+
// throwing systemScope guard Proxy that rejects every property access.
|
|
13
|
+
export function tenantDbRunner(tenantDb: TenantDb): DbRunner {
|
|
14
|
+
const runner = tenantDbRunners.get(tenantDb);
|
|
15
|
+
if (!runner) {
|
|
16
|
+
throw new InternalError({
|
|
17
|
+
message:
|
|
18
|
+
"framework infrastructure received a TenantDb not built by createTenantDb (or a " +
|
|
19
|
+
"fail-closed systemScope guard) — no connection bound.",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return runner;
|
|
23
|
+
}
|