@cosmicdrift/kumiko-framework 0.259.0 → 0.261.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 (34) hide show
  1. package/package.json +3 -3
  2. package/src/changes.json +13 -0
  3. package/src/db/__tests__/tenant-db-global.integration.test.ts +97 -0
  4. package/src/db/__tests__/tenant-db-global.test.ts +170 -0
  5. package/src/db/entity-table-meta.ts +8 -2
  6. package/src/db/table-builder.ts +37 -2
  7. package/src/db/tenant-db.ts +86 -4
  8. package/src/engine/__tests__/boot-validator.test.ts +41 -16
  9. package/src/engine/__tests__/build-app-schema.test.ts +14 -5
  10. package/src/engine/__tests__/engine.test.ts +10 -0
  11. package/src/engine/__tests__/entity-handlers.test.ts +48 -36
  12. package/src/engine/__tests__/extends-registrar.test.ts +27 -12
  13. package/src/engine/__tests__/projection-detail-tabs.test.ts +43 -0
  14. package/src/engine/__tests__/registry.test.ts +16 -5
  15. package/src/engine/__tests__/screen.test.ts +20 -0
  16. package/src/engine/access.ts +2 -1
  17. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +200 -0
  18. package/src/engine/boot-validator/access-declarations.ts +141 -0
  19. package/src/engine/boot-validator/entity-handler.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +2 -0
  21. package/src/engine/boot-validator/screens.ts +53 -38
  22. package/src/engine/define-handler.ts +1 -0
  23. package/src/engine/entity-handlers.ts +29 -19
  24. package/src/engine/factories.ts +5 -3
  25. package/src/engine/feature-ast/extractors/hooks.ts +5 -1
  26. package/src/engine/feature-entity-handlers.ts +40 -27
  27. package/src/engine/index.ts +4 -0
  28. package/src/engine/types/handlers.ts +1 -0
  29. package/src/engine/types/index.ts +4 -0
  30. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +93 -0
  31. package/src/pipeline/dispatch-shared.ts +3 -0
  32. package/src/secrets/__tests__/contains-secret.test.ts +5 -0
  33. package/src/testing/__tests__/e2e-generator.test.ts +11 -5
  34. package/src/ui-types/index.ts +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.259.0",
3
+ "version": "0.261.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.259.0",
201
+ "@cosmicdrift/kumiko-types": "0.261.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.259.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.261.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.261.0",
4
+ "type": "breaking",
5
+ "title": "access is required on handler definitions; openToAll accepts { reason } (+ publicIntake) (fw#2855).",
6
+ "detail": "`WriteHandlerDef`/`QueryHandlerDef`/`StreamHandlerDef`.access (`@cosmicdrift/kumiko-types/handlers`), `WriteHandlerDefinition`/`WriteHandlerInput`/`QueryHandlerDefinition`/`StreamHandlerDefinition`.access, `EntityHandlerOptions.access`, and the `r.writeHandler`/`r.queryHandler`/`r.streamHandler` positional `options` param are now required — a handler with no access rule fails to compile instead of silently registering unreachable (previously the boot-validator caught it at runtime). `registerEntityCrud` throws at registration when an enabled verb resolves no access (`write.access`/`read.access`/`verbAccess.<verb>` all unset) — was previously a boot-validator throw. `AccessRule.openToAll` accepts `{ reason: string }` (plus optional `publicIntake: true`); `true` still compiles (deprecated pre-#2855 form, tracked for removal in fw#2854's call-site migration) but a new boot-validator (`engine/boot-validator/access-declarations.ts`) rejects an empty `openToAll.reason`, an empty `escapeHatch.reason`, `publicIntake` on a query/stream handler, and — the main new catch — a write handler with `openToAll` that accepts a personal-data field (pii/userOwned/recordOwned) in its input schema without `publicIntake: true`. Code that read `access.openToAll` as a boolean must switch to `isOpenToAllGranted(rule)` (`@cosmicdrift/kumiko-types/handlers`, also re-exported from `@cosmicdrift/kumiko-framework/engine` and `/ui-types`), which handles both the `true` and `{ reason }` forms.",
7
+ "migration": "Add `access` to every handler definition: `r.writeHandler`/`r.queryHandler`/`r.streamHandler` object- and positional-form options, `defineWriteHandler`/`defineQueryHandler`/`definePagedQueryHandler`/`defineProjectionQueryHandler` options, `defineEntityCreate/Update/Delete/Restore/List/DetailHandler` options, and `registerEntityCrud`'s `write.access`/`read.access` (or per-verb `verbAccess`). Replace `openToAll: true` with `openToAll: { reason: \"...\" }` going forward — `true` still compiles but is deprecated (fw#2854 tracks the 471-site call-site migration; new code should use the object form). A write handler that already used `openToAll` and accepts a PII field now fails boot unless it also declares `publicIntake: true` (any authenticated user may legitimately submit that data) or is restricted to roles instead. Any code reading `access.openToAll` as a boolean must switch to `isOpenToAllGranted(rule)`."
8
+ },
9
+ {
10
+ "version": "0.261.0",
11
+ "type": "improvement",
12
+ "title": "EntityDefinition.tenancy + db.global(table), escapeHatch, UncheckedSystemDb.unsafeRaw(reason) (fw#2855).",
13
+ "detail": "`EntityDefinition.tenancy?: \"global\" | \"tenant\"` (default `\"tenant\"`; `createEntity({ tenancy: \"global\", ... })`) marks an entity's rows as shared across every tenant instead of row-scoped — only reachable via the new `TenantDb.global(table)`, which drops the tenant filter but keeps the caller's own `where` field-filter for reads, and rejects writes unless the calling write handler declared `escapeHatch: { reason: \"...\" }` (`WriteHandlerDef.escapeHatch`, threaded through `r.writeHandler`/`defineWriteHandler`/`registerEntityCrud`). `defineUnmanagedTable({ tenancy: \"global\", ... })` covers hand-built store tables the same way; a `\"tenant\"`-tenancy table passed to `db.global()` is a compile error (`TenancyBrand<\"global\">` required) and, for an erased/runtime-only table the brand can't catch, also a runtime `AccessDeniedError`. `UncheckedSystemDb.unsafeRaw(reason: string): DbRunner` replaces reaching for `ctx.systemDb`'s guarded `.raw` escape hatch with a named, auditable declaration (throws on an empty reason); `TenantDb.raw` is now `@deprecated` in favor of `unsafeRaw`/`db.global()`, removal tracked in fw#2860."
14
+ },
2
15
  {
3
16
  "version": "0.259.0",
4
17
  "type": "improvement",
@@ -0,0 +1,97 @@
1
+ // fw#2855 — db.global(table) against real Postgres: tenant filter lifted, caller's
2
+ // `where` still applies; a normal accessor on the same table still filters by tenant.
3
+ // Follows tenant-db-where-merge.integration.test.ts's setupTestStack pattern.
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { setupTestStack, type TestStack, testTenantId, unsafePushTables } from "../../stack";
7
+ import type { TableColumns } from "../dialect";
8
+ import { defineUnmanagedTable } from "../entity-table-meta";
9
+ import { insertOne } from "../query";
10
+ import { createTenantDb } from "../tenant-db";
11
+
12
+ const globalItemsTable = defineUnmanagedTable({
13
+ tableName: "store_fw2855_global_items_it",
14
+ tenancy: "global",
15
+ columns: [
16
+ {
17
+ name: "id",
18
+ pgType: "uuid",
19
+ notNull: true,
20
+ primaryKey: true,
21
+ defaultSql: "gen_random_uuid()",
22
+ },
23
+ { name: "tenant_id", pgType: "uuid", notNull: true },
24
+ { name: "some_field", pgType: "text", notNull: true },
25
+ ],
26
+ });
27
+
28
+ let stack: TestStack;
29
+
30
+ const tenantA = testTenantId(91);
31
+ const tenantB = testTenantId(92);
32
+
33
+ beforeAll(async () => {
34
+ stack = await setupTestStack({ features: [] });
35
+ await unsafePushTables(stack.db, { globalItems: globalItemsTable });
36
+ await insertOne(stack.db, globalItemsTable, { tenantId: tenantA, someField: "match" });
37
+ await insertOne(stack.db, globalItemsTable, { tenantId: tenantB, someField: "match" });
38
+ await insertOne(stack.db, globalItemsTable, { tenantId: tenantB, someField: "no-match" });
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await stack.cleanup();
43
+ });
44
+
45
+ describe("TenantDb.global() — reads, real Postgres", () => {
46
+ test("db.global(t).selectMany keeps the field filter but drops the tenant filter", async () => {
47
+ const tdb = createTenantDb(stack.db, tenantA);
48
+ const rows = await tdb.global(globalItemsTable).selectMany<{
49
+ tenantId: string;
50
+ someField: string;
51
+ }>({ someField: "match" });
52
+
53
+ const tenantIds = rows.map((r) => r.tenantId).sort();
54
+ expect(tenantIds).toEqual([tenantA, tenantB].sort());
55
+ expect(rows.every((r) => r.someField === "match")).toBe(true);
56
+ });
57
+
58
+ test("the normal (non-global) db.selectMany on the same table still filters by tenant", async () => {
59
+ const tdb = createTenantDb(stack.db, tenantA);
60
+ // selectMany's Table param types the branded-EntityTable shape only;
61
+ // EntityTableMeta reads work identically at runtime (both normalize via
62
+ // asEntityTableMeta) — same unbranded-view cast as tenant-db-where-merge.test.ts.
63
+ const rows = await tdb.selectMany<{ tenantId: string; someField: string }>(
64
+ globalItemsTable as unknown as TableColumns,
65
+ { someField: "match" },
66
+ );
67
+
68
+ expect(rows).toHaveLength(1);
69
+ expect(rows[0]?.tenantId).toBe(tenantA);
70
+ });
71
+ });
72
+
73
+ describe("TenantDb.global() — writes, real Postgres", () => {
74
+ test("insert/update/delete succeed when createTenantDb was given an escapeHatch", async () => {
75
+ const tdb = createTenantDb(stack.db, tenantA, "tenant", undefined, undefined, undefined, {
76
+ reason: "fw#2855 integration test — cross-tenant write via db.global()",
77
+ });
78
+ const globalDb = tdb.global(globalItemsTable);
79
+
80
+ const inserted = await globalDb.insertOne<{ id: string; tenantId: string }>({
81
+ tenantId: tenantB,
82
+ someField: "escape-hatch-insert",
83
+ });
84
+ if (!inserted) throw new Error("insertOne returned no row");
85
+ expect(inserted.tenantId).toBe(tenantB);
86
+
87
+ const updated = await globalDb.updateMany<{ someField: string }>(
88
+ { someField: "escape-hatch-update" },
89
+ { id: inserted.id },
90
+ );
91
+ expect(updated[0]?.someField).toBe("escape-hatch-update");
92
+
93
+ await globalDb.deleteMany({ id: inserted.id });
94
+ const remaining = await globalDb.selectMany({ id: inserted.id });
95
+ expect(remaining).toHaveLength(0);
96
+ });
97
+ });
@@ -0,0 +1,170 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
3
+ import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
4
+ import { createEntity, createTextField } from "../../engine";
5
+ import { testTenantId } from "../../stack";
6
+ import type { DbRunner } from "../connection";
7
+ import { defineUnmanagedTable, deriveEntityTableMeta } from "../entity-table-meta";
8
+ import { buildEntityTable } from "../table-builder";
9
+ import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../tenant-db";
10
+
11
+ // db.global()'s runtime + write-gate behaviour (fw#2855). No Postgres needed —
12
+ // the stub runner throws on first touch, so a test that reaches it fails loudly
13
+ // instead of silently "passing" on an accidental real write.
14
+ function unreachableRunner(): DbRunner {
15
+ return {
16
+ unsafe: async () => {
17
+ throw new Error("db.global(): must not touch the DB runner here");
18
+ },
19
+ begin: async () => {
20
+ throw new Error("db.global(): must not touch the DB runner here");
21
+ },
22
+ } as DbRunner;
23
+ }
24
+
25
+ const tenantEntity = createEntity({
26
+ table: "fw2855_global_tenant_items",
27
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
28
+ });
29
+ const tenantEntityTable = buildEntityTable("globalGuardTenantItem", tenantEntity);
30
+ const tenantMeta = deriveEntityTableMeta("globalGuardTenantMeta", tenantEntity);
31
+
32
+ const globalManagedEntity = createEntity({
33
+ table: "fw2855_global_managed_items",
34
+ tenancy: "global",
35
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
36
+ });
37
+ const globalManagedTable = buildEntityTable("globalGuardManagedItem", globalManagedEntity);
38
+
39
+ const globalUnmanagedTable = defineUnmanagedTable({
40
+ tableName: "store_fw2855_global_items",
41
+ tenancy: "global",
42
+ columns: [
43
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
44
+ { name: "name", pgType: "text", notNull: true },
45
+ ],
46
+ });
47
+
48
+ // Regression coverage for the tenancy-brand type hole (fw#2855 review): an
49
+ // EntityDefinition object literal (bypassing createEntity) must not be
50
+ // mis-branded "global" just because it lacks a `tenancy` property at all.
51
+ const bareLiteralTable = buildEntityTable("globalGuardBareLiteral", {
52
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
53
+ });
54
+ const explicitTenantLiteralTable = buildEntityTable("globalGuardExplicitTenant", {
55
+ tenancy: "tenant",
56
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
57
+ });
58
+ const explicitGlobalLiteralTable = buildEntityTable("globalGuardExplicitGlobal", {
59
+ tenancy: "global",
60
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
61
+ });
62
+
63
+ const own = testTenantId(1);
64
+
65
+ // Type-only assertions — never invoked, checked by `tsc --build` only.
66
+ function typeAssertions(tdb: TenantDb): void {
67
+ // @ts-expect-error db.global() on a "tenant"-tenancy table (default tenancy) is a type error.
68
+ tdb.global(tenantEntityTable);
69
+
70
+ // @ts-expect-error an EntityDefinition object literal without a `tenancy` key must not brand "global".
71
+ tdb.global(bareLiteralTable);
72
+
73
+ // @ts-expect-error an explicit `tenancy: "tenant"` literal must not brand "global".
74
+ tdb.global(explicitTenantLiteralTable);
75
+
76
+ // Compile-positive: an explicit `tenancy: "global"` literal is reachable through db.global().
77
+ void tdb.global(explicitGlobalLiteralTable).selectMany({ name: "x" });
78
+
79
+ // Compile-positive: an unmanaged "global" table is fully writable through db.global().
80
+ void tdb.global(globalUnmanagedTable).insertOne({ name: "x" });
81
+
82
+ const managedAccessor = tdb.global(globalManagedTable);
83
+ // @ts-expect-error managed global tables stay executor-only — no write methods through db.global() either.
84
+ void managedAccessor.insertOne({ name: "x" });
85
+ }
86
+ void typeAssertions;
87
+
88
+ describe("TenantDb.global()", () => {
89
+ test("on a tenant-tenancy table throws AccessDeniedError at runtime (erased/unbranded table)", () => {
90
+ const tdb = createTenantDb(unreachableRunner(), own);
91
+ expect(() =>
92
+ tdb.global(tenantMeta as unknown as EntityTableMeta & TenancyBrand<"global">),
93
+ ).toThrow(/tenancy: "global"/);
94
+ });
95
+
96
+ test("reads on a global table never touch the caller's tenant filter", async () => {
97
+ const captured: { sql: string; values: readonly unknown[] }[] = [];
98
+ const db: DbRunner = {
99
+ unsafe: async (sql: string, values: readonly unknown[]) => {
100
+ captured.push({ sql, values });
101
+ return [];
102
+ },
103
+ begin: async () => {
104
+ throw new Error("begin not used in this test");
105
+ },
106
+ } as DbRunner;
107
+ const tdb = createTenantDb(db, own);
108
+ await tdb.global(globalUnmanagedTable).selectMany({ name: "x" });
109
+ expect(captured).toHaveLength(1);
110
+ expect(captured[0]?.sql).not.toMatch(/tenant_id/i);
111
+ });
112
+
113
+ test("write without escapeHatch rejects before touching the runner", async () => {
114
+ const tdb = createTenantDb(unreachableRunner(), own);
115
+ await expect(tdb.global(globalUnmanagedTable).insertOne({ name: "x" })).rejects.toThrow(
116
+ /escapeHatch/,
117
+ );
118
+ });
119
+
120
+ test("write with an empty-reason escapeHatch rejects before touching the runner", async () => {
121
+ const tdb = createTenantDb(
122
+ unreachableRunner(),
123
+ own,
124
+ "tenant",
125
+ undefined,
126
+ undefined,
127
+ undefined,
128
+ {
129
+ reason: " ",
130
+ },
131
+ );
132
+ await expect(tdb.global(globalUnmanagedTable).insertOne({ name: "x" })).rejects.toThrow(
133
+ /escapeHatch/,
134
+ );
135
+ });
136
+
137
+ test("write with a non-empty escapeHatch reason reaches the runner", async () => {
138
+ const captured: { sql: string }[] = [];
139
+ const db: DbRunner = {
140
+ unsafe: async (sql: string) => {
141
+ captured.push({ sql });
142
+ return [{ id: "x" }];
143
+ },
144
+ begin: async () => {
145
+ throw new Error("begin not used in this test");
146
+ },
147
+ } as DbRunner;
148
+ const tdb = createTenantDb(db, own, "tenant", undefined, undefined, undefined, {
149
+ reason: "cross-tenant backfill job",
150
+ });
151
+ await tdb.global(globalUnmanagedTable).insertOne({ name: "x" });
152
+ expect(captured).toHaveLength(1);
153
+ });
154
+ });
155
+
156
+ describe("UncheckedSystemDb.unsafeRaw", () => {
157
+ test("throws on an empty or whitespace-only reason", () => {
158
+ const tdb = createTenantDb(unreachableRunner(), own);
159
+ const sysDb = createUncheckedSystemDb(tdb);
160
+ expect(() => sysDb.unsafeRaw("")).toThrow();
161
+ expect(() => sysDb.unsafeRaw(" ")).toThrow();
162
+ });
163
+
164
+ test("returns the underlying DbRunner for a non-empty reason", () => {
165
+ const rawDb = unreachableRunner();
166
+ const tdb = createTenantDb(rawDb, own);
167
+ const sysDb = createUncheckedSystemDb(tdb);
168
+ expect(sysDb.unsafeRaw("cleanup job")).toBe(rawDb);
169
+ });
170
+ });
@@ -16,6 +16,7 @@
16
16
  // hand-built path; app author owns tenant scoping / versioning. Prefer a
17
17
  // `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
18
18
 
19
+ import type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
19
20
  import { collectPiiSubjectFields } from "../crypto";
20
21
  import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
21
22
  import { SQL_EXPR_BRAND } from "./dialect";
@@ -385,6 +386,7 @@ export function deriveEntityTableMeta(
385
386
  indexes,
386
387
  source,
387
388
  ...(piiSubjectFields.length > 0 && { piiSubjectFields }),
389
+ ...(entity.tenancy === "global" && { tenancy: "global" as const }),
388
390
  };
389
391
  }
390
392
 
@@ -453,7 +455,9 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
453
455
  * table. Justify WHY in the call site; reviewers should scrutinize every new
454
456
  * unmanaged table.
455
457
  */
456
- export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
458
+ export function defineUnmanagedTable<const T extends EntityTenancy = "tenant">(
459
+ input: UnmanagedTableInput & { readonly tenancy?: T },
460
+ ): EntityTableMeta & TenancyBrand<T> {
457
461
  assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
458
462
  return {
459
463
  tableName: input.tableName,
@@ -463,7 +467,9 @@ export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMet
463
467
  compositePrimaryKey: input.compositePrimaryKey,
464
468
  }),
465
469
  source: "unmanaged",
466
- };
470
+ ...(input.tenancy === "global" && { tenancy: "global" as const }),
471
+ // @cast-boundary type-brand — TenancyBrand<T> is a phantom marker with no runtime representation.
472
+ } as EntityTableMeta & TenancyBrand<T>;
467
473
  }
468
474
 
469
475
  function assertUnmanagedTableName(tableName: string, via: string): void {
@@ -1,4 +1,7 @@
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
1
2
  import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
3
+ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
4
+ import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
2
5
  import type {
3
6
  EntityDefinition,
4
7
  EntityRelations,
@@ -408,6 +411,18 @@ type SoftDeleteColumnsType = {
408
411
 
409
412
  // ── ES-write brand ──────────────────────────────────────────────────────
410
413
  export type { ExecutorOnly, NotExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
414
+ // ── Tenancy brand ────────────────────────────────────────────────────────
415
+ export type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
416
+
417
+ // `E extends { tenancy?: "global" }` would wrongly match any E missing
418
+ // `tenancy` too (an absent optional property structurally satisfies an
419
+ // optional one) — index into E["tenancy"] instead so only a literal
420
+ // "global" (not undefined/"tenant"/a union/never) brands "global".
421
+ type EntityTableTenancy<E extends EntityDefinition> = [NonNullable<E["tenancy"]>] extends [never]
422
+ ? "tenant"
423
+ : [NonNullable<E["tenancy"]>] extends ["global"]
424
+ ? "global"
425
+ : "tenant";
411
426
 
412
427
  export type EntityTable<E extends EntityDefinition = EntityDefinition> =
413
428
  TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
@@ -415,7 +430,8 @@ export type EntityTable<E extends EntityDefinition = EntityDefinition> =
415
430
  BaseColumnsType<E> &
416
431
  SoftDeleteColumnsType &
417
432
  ColumnsForEntity<E["fields"]> &
418
- ExecutorOnly;
433
+ ExecutorOnly &
434
+ TenancyBrand<EntityTableTenancy<E>>;
419
435
 
420
436
  export function buildBaseColumns(softDelete: boolean, idType: "serial" | "uuid" = "uuid") {
421
437
  const idColumn =
@@ -474,6 +490,21 @@ export type BuildEntityTableOptions = {
474
490
  readonly relations?: EntityRelations;
475
491
  };
476
492
 
493
+ function stampGlobalTenancyMeta<E extends EntityDefinition>(
494
+ table: EntityTable<E>,
495
+ entity: E,
496
+ ): void {
497
+ // skip: non-global entities keep pgTable's default tenancy meta
498
+ if (entity.tenancy !== "global") return;
499
+ const meta = (table as unknown as Record<symbol, EntityTableMeta>)[KUMIKO_META_SYMBOL];
500
+ // skip: no meta stamp present — nothing to patch
501
+ if (!meta) return;
502
+ (table as unknown as Record<symbol, EntityTableMeta>)[KUMIKO_META_SYMBOL] = {
503
+ ...meta,
504
+ tenancy: "global",
505
+ };
506
+ }
507
+
477
508
  export function buildEntityTable<E extends EntityDefinition>(
478
509
  entityName: string,
479
510
  entity: E,
@@ -525,7 +556,7 @@ export function buildEntityTable<E extends EntityDefinition>(
525
556
  // hand in. Our typed signature narrows that to the static names from
526
557
  // EntityDefinition (kept in sync with fieldToColumns + buildBaseColumns).
527
558
  // Drizzle's runtime instance carries every needed method on top.
528
- return pgTable(
559
+ const built = pgTable(
529
560
  tableName,
530
561
  {
531
562
  ...baseColumns,
@@ -627,4 +658,8 @@ export function buildEntityTable<E extends EntityDefinition>(
627
658
  return indexes;
628
659
  },
629
660
  ) as unknown as EntityTable<E>;
661
+
662
+ // pgTable's own meta stamp doesn't see entity.tenancy — patch it so db.global()'s runtime guard matches the type brand.
663
+ stampGlobalTenancyMeta(built, entity);
664
+ return built;
630
665
  }
@@ -1,5 +1,9 @@
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
2
+ import type { EscapeHatchDeclaration } from "@cosmicdrift/kumiko-types/handlers";
1
3
  import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
4
+ import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
2
5
  import {
6
+ type GlobalTableDb,
3
7
  SYSTEM_SCOPE_CHECK_BRAND,
4
8
  type TenantDb,
5
9
  type TenantDbMode,
@@ -95,6 +99,13 @@ export function createUncheckedSystemDb(
95
99
  return db;
96
100
  },
97
101
 
102
+ unsafeRaw(reason) {
103
+ if (reason.trim().length === 0) {
104
+ throw new Error("unsafeRaw requires a non-empty reason");
105
+ }
106
+ return db.raw;
107
+ },
108
+
98
109
  outsideTransaction: {
99
110
  assertTenantMatch(tenantId) {
100
111
  if (tenantId !== db.tenantId) {
@@ -120,9 +131,10 @@ export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): rea
120
131
  return rows as unknown as readonly T[];
121
132
  }
122
133
 
123
- function tableNameOf(table: Table): string {
124
- const sym = table[KUMIKO_NAME_SYMBOL];
125
- return typeof sym === "string" ? sym : "<unknown>";
134
+ function tableNameOf(table: Table | EntityTableMeta): string {
135
+ const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
136
+ if (typeof sym === "string") return sym;
137
+ return asEntityTableMeta(table)?.tableName ?? "<unknown>";
126
138
  }
127
139
 
128
140
  // Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
@@ -143,12 +155,14 @@ export function createTenantDb(
143
155
  tracer?: Tracer,
144
156
  meter?: Meter,
145
157
  signal?: AbortSignal,
158
+ // Gates db.global(table)'s write methods; write-path-only (see dispatch-shared.ts buildHandlerContext).
159
+ escapeHatch?: EscapeHatchDeclaration,
146
160
  ): TenantDb {
147
161
  if (meter) registerStandardMetrics(meter);
148
162
 
149
163
  function withDbSpan<T>(
150
164
  operation: "select" | "insert" | "update" | "delete",
151
- table: Table,
165
+ table: Table | EntityTableMeta,
152
166
  runner: () => Promise<T>,
153
167
  ): Promise<T> {
154
168
  signal?.throwIfAborted();
@@ -227,10 +241,78 @@ export function createTenantDb(
227
241
  return { ...data, tenantId };
228
242
  }
229
243
 
244
+ function missingEscapeHatch(table: Table | EntityTableMeta): AccessDeniedError | undefined {
245
+ if (escapeHatch && escapeHatch.reason.trim().length > 0) return undefined;
246
+ return new AccessDeniedError({
247
+ message:
248
+ `db.global(${tableNameOf(table)}): write rejected — declare ` +
249
+ `\`escapeHatch: { reason: "..." }\` on the write handler to allow ` +
250
+ "writes through db.global().",
251
+ });
252
+ }
253
+
254
+ function globalTable<TTable extends (SchemaTable | EntityTableMeta) & TenancyBrand<"global">>(
255
+ table: TTable,
256
+ ): GlobalTableDb<TTable> {
257
+ const meta = asEntityTableMeta(table);
258
+ if (meta?.tenancy !== "global") {
259
+ throw new AccessDeniedError({
260
+ message: `db.global(${tableNameOf(table)}): table is not declared \`tenancy: "global"\`.`,
261
+ });
262
+ }
263
+ return {
264
+ selectMany<T = Record<string, unknown>>(
265
+ where?: WhereObject,
266
+ options?: SelectOptions,
267
+ ): Promise<readonly T[]> {
268
+ return withDbSpan("select", table, async () => bunSelectMany<T>(db, table, where, options));
269
+ },
270
+ fetchOne<T = Record<string, unknown>>(where: WhereObject): Promise<T | undefined> {
271
+ return withDbSpan("select", table, async () => bunFetchOne<T>(db, table, where));
272
+ },
273
+ insertOne<T = Record<string, unknown>>(
274
+ values: Record<string, unknown>,
275
+ ): Promise<T | undefined> {
276
+ const denied = missingEscapeHatch(table);
277
+ if (denied) return Promise.reject(denied);
278
+ return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
279
+ },
280
+ updateMany<T = Record<string, unknown>>(
281
+ set: Record<string, unknown>,
282
+ where: WhereObject,
283
+ ): Promise<readonly T[]> {
284
+ const denied = missingEscapeHatch(table);
285
+ if (denied) return Promise.reject(denied);
286
+ if (!where || Object.keys(where).length === 0) {
287
+ return Promise.reject(
288
+ new Error(
289
+ "db.global().updateMany without where would mass-update every tenant's rows. Pass at least one where condition.",
290
+ ),
291
+ );
292
+ }
293
+ return withDbSpan("update", table, async () => bunUpdateMany<T>(db, table, set, where));
294
+ },
295
+ deleteMany(where: WhereObject): Promise<void> {
296
+ const denied = missingEscapeHatch(table);
297
+ if (denied) return Promise.reject(denied);
298
+ if (!where || Object.keys(where).length === 0) {
299
+ return Promise.reject(
300
+ new Error(
301
+ "db.global().deleteMany without where would mass-delete every tenant's rows. Pass at least one where condition.",
302
+ ),
303
+ );
304
+ }
305
+ return withDbSpan("delete", table, async () => bunDeleteMany(db, table, where));
306
+ },
307
+ // @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
308
+ } as GlobalTableDb<TTable>;
309
+ }
310
+
230
311
  return {
231
312
  tenantId,
232
313
  mode,
233
314
  raw: db,
315
+ global: globalTable,
234
316
 
235
317
  selectMany<T = Record<string, unknown>>(
236
318
  table: Table,
@@ -578,41 +578,42 @@ describe("boot-validator", () => {
578
578
  // --- Handler access validation (default-deny) ---
579
579
 
580
580
  test("throws when a write handler has no access rule", () => {
581
- const features = [
581
+ // Access is a required option since fw#2855 — the registrar itself throws
582
+ // before validateBoot ever runs (was: caught at boot-validator level).
583
+ expect(() =>
582
584
  defineFeature("a", (r) => {
585
+ // @ts-expect-error options.access is required since fw#2855.
583
586
  r.writeHandler("createThing", z.object({ name: z.string() }), async () => ({
584
587
  isSuccess: true as const,
585
588
  data: {},
586
589
  }));
587
590
  }),
588
- ];
589
- expect(() => validateBoot(features)).toThrow(/a:write:createThing.*missing an access rule/i);
591
+ ).toThrow(/requires schema \+ handler \+ options\.access/);
590
592
  });
591
593
 
592
594
  test("throws when a query handler has no access rule", () => {
593
- const features = [
595
+ expect(() =>
594
596
  defineFeature("a", (r) => {
597
+ // @ts-expect-error options.access is required since fw#2855.
595
598
  r.queryHandler("list", z.object({}), async () => []);
596
599
  }),
597
- ];
598
- expect(() => validateBoot(features)).toThrow(/a:query:list.*missing an access rule/i);
600
+ ).toThrow(/requires schema \+ handler \+ options\.access/);
599
601
  });
600
602
 
601
- test("registerEntityCrud without access options fails boot (no implicit openToAll)", () => {
603
+ test("registerEntityCrud without access options fails registration (no implicit openToAll)", () => {
602
604
  const widgetEntity = createEntity({
603
605
  table: "phase0_widgets",
604
606
  fields: {
605
607
  name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
606
608
  },
607
609
  });
608
- const features = [
610
+ // Access is required since fw#2855 — registerEntityCrud now throws at
611
+ // registration time (was: caught at boot-validator level).
612
+ expect(() =>
609
613
  defineFeature("phase0", (r) => {
610
614
  registerEntityCrud(r, "widget", widgetEntity);
611
615
  }),
612
- ];
613
- expect(() => validateBoot(features)).toThrow(
614
- /phase0:write:widget:create.*missing an access rule/i,
615
- );
616
+ ).toThrow(/no access rule resolved/i);
616
617
  });
617
618
 
618
619
  test("accepts role-based access rule", () => {
@@ -684,12 +685,12 @@ describe("boot-validator", () => {
684
685
  });
685
686
 
686
687
  test("throws when a stream handler has no access rule", () => {
687
- const features = [
688
+ expect(() =>
688
689
  defineFeature("a", (r) => {
690
+ // @ts-expect-error options.access is required since fw#2855.
689
691
  r.streamHandler("chat:complete", z.object({}), async function* () {});
690
692
  }),
691
- ];
692
- expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
693
+ ).toThrow(/requires schema \+ handler \+ options\.access/);
693
694
  });
694
695
 
695
696
  test("object-form streamHandler with access rule passes boot", () => {
@@ -3167,7 +3168,10 @@ describe("boot-validator", () => {
3167
3168
  // Post-save navigation target, same validation as actionForm's redirect:
3168
3169
  // short screen-ID, same feature, must resolve to a registered screen.
3169
3170
  describe("entityEdit redirect", () => {
3170
- function makeFeature(redirect?: string, extraScreens: readonly string[] = []) {
3171
+ function makeFeature(
3172
+ redirect?: string | { readonly screen: string; readonly idFrom: string },
3173
+ extraScreens: readonly string[] = [],
3174
+ ) {
3171
3175
  return defineFeature("shop", (r) => {
3172
3176
  r.entity(
3173
3177
  "product",
@@ -3224,6 +3228,27 @@ describe("boot-validator", () => {
3224
3228
  /redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
3225
3229
  );
3226
3230
  });
3231
+
3232
+ // --- redirect object form with idFrom (same rule as actionForm's) ---
3233
+ test("redirect object → existing screen-id → kein Throw", () => {
3234
+ expect(() =>
3235
+ validateBoot([
3236
+ makeFeature({ screen: "product-list", idFrom: "parentId" }, ["product-list"]),
3237
+ ]),
3238
+ ).not.toThrow();
3239
+ });
3240
+
3241
+ test("redirect object → unknown screen-id → Throw", () => {
3242
+ expect(() =>
3243
+ validateBoot([makeFeature({ screen: "ghost-screen", idFrom: "parentId" })]),
3244
+ ).toThrow(/redirect "ghost-screen" does not resolve to a registered screen/);
3245
+ });
3246
+
3247
+ test("redirect object mit leerem idFrom → Throw", () => {
3248
+ expect(() =>
3249
+ validateBoot([makeFeature({ screen: "product-list", idFrom: " " }, ["product-list"])]),
3250
+ ).toThrow(/redirect\.idFrom is empty/);
3251
+ });
3227
3252
  });
3228
3253
 
3229
3254
  // --- no-widget field types + required (#1925) ---