@cosmicdrift/kumiko-framework 0.24.0 → 0.25.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/schema-cli-status.integration.test.ts +2 -1
  3. package/src/bun-db/__tests__/extract-table-info.test.ts +72 -0
  4. package/src/bun-db/query.ts +52 -94
  5. package/src/db/__tests__/source-shadow-create.integration.test.ts +54 -0
  6. package/src/db/__tests__/sql-inventory.test.ts +26 -1
  7. package/src/db/__tests__/table-builder-indexes.test.ts +15 -0
  8. package/src/db/__tests__/tenant-db-where-merge.test.ts +81 -0
  9. package/src/db/dialect.ts +6 -0
  10. package/src/db/entity-table-meta.ts +1 -1
  11. package/src/db/queries/event-store.ts +18 -7
  12. package/src/db/sql-inventory.ts +9 -0
  13. package/src/db/tenant-db.ts +5 -2
  14. package/src/engine/__tests__/boot-validator.test.ts +79 -0
  15. package/src/engine/__tests__/post-query-hook.test.ts +6 -6
  16. package/src/engine/__tests__/projection-helpers.test.ts +12 -7
  17. package/src/engine/__tests__/registry.test.ts +58 -0
  18. package/src/engine/__tests__/search-payload-extension.test.ts +49 -3
  19. package/src/engine/__tests__/unmanaged-table.test.ts +30 -1
  20. package/src/engine/boot-validator/api-ext.ts +1 -5
  21. package/src/engine/boot-validator/screens-nav.ts +18 -2
  22. package/src/engine/registry.ts +60 -29
  23. package/src/engine/types/fields.ts +2 -1
  24. package/src/engine/types/handlers.ts +1 -1
  25. package/src/engine/types/hooks.ts +4 -1
  26. package/src/engine/validate-projection-allowlist.ts +13 -3
  27. package/src/errors/__tests__/classes.test.ts +5 -0
  28. package/src/errors/classes.ts +4 -2
  29. package/src/event-store/event-store.ts +15 -0
  30. package/src/event-store/index.ts +1 -0
  31. package/src/files/__tests__/file-ref-entity.test.ts +34 -0
  32. package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
  33. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +50 -1
  34. package/src/pipeline/dispatcher.ts +57 -47
  35. package/src/pipeline/system-hooks.ts +17 -5
  36. package/src/stack/table-helpers.ts +19 -9
  37. package/src/stack/test-stack.ts +3 -4
  38. package/src/testing/db-cleanup.ts +7 -9
  39. package/src/errors/__tests__/field-issue-compat.test.ts +0 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.24.0",
3
+ "version": "0.25.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>",
@@ -65,7 +65,8 @@ describe("runSchemaCli status", () => {
65
65
  writeMigration("0001_init.sql", `SELECT 1;`);
66
66
  const { out, lines } = captureOut();
67
67
  const code = await runSchemaCli(["status"], appDir, out);
68
- expect(code).toBe(0);
68
+ // Exit 1 = pending-drift erkannt (1 Migration lokal, 0 applied); status gated CI non-zero.
69
+ expect(code).toBe(1);
69
70
  const joined = lines.join("\n");
70
71
  expect(joined).toContain("0 applied");
71
72
  expect(joined).toContain("1 pending");
@@ -0,0 +1,72 @@
1
+ // extractTableInfo discriminator-shadow regression.
2
+ //
3
+ // EntityTableMeta carries a `source: "managed" | "unmanaged"` discriminator.
4
+ // table() (dialect) spreads the column handles as enumerable props over the
5
+ // meta object, so an entity field literally named `source` overwrote the
6
+ // discriminator → extractTableInfo failed the meta check, fell into the (dead)
7
+ // drizzle branch, and typed timestamptz columns via getSQLType() as
8
+ // "timestamp with time zone". prepareValue only serializes Temporal.Instant for
9
+ // "timestamptz" → a raw Temporal reached postgres-js → "Cannot use valueOf" on
10
+ // every create of such an entity (e.g. pattern-storage's pattern-file).
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { buildEntityTable } from "../../db/table-builder";
14
+ import { extractTableInfo, resolveConflictColumns } from "../query";
15
+
16
+ describe("extractTableInfo — EntityTableMeta discriminator is shadow-proof", () => {
17
+ test("an entity field named `source` does not shadow the discriminator", () => {
18
+ const table = buildEntityTable("patternFile", {
19
+ fields: {
20
+ path: { type: "text", required: true },
21
+ source: { type: "text", required: true },
22
+ },
23
+ });
24
+ const info = extractTableInfo(table);
25
+ // The framework-canonical pgType the bun-db serializer matches on — NOT the
26
+ // drizzle getSQLType() spelling "timestamp with time zone".
27
+ expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
28
+ // The user-defined `source` column is still present + correctly typed.
29
+ expect(info.pgTypeOf("source")).toBe("text");
30
+ });
31
+
32
+ test.each([
33
+ "columns",
34
+ "tableName",
35
+ "indexes",
36
+ ])("an entity field named `%s` (another meta key) also does not shadow it", (fieldName) => {
37
+ const table = buildEntityTable("thing", {
38
+ fields: { [fieldName]: { type: "text", required: true } },
39
+ });
40
+ const info = extractTableInfo(table);
41
+ expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
42
+ });
43
+
44
+ test("control entity without a colliding field is unaffected", () => {
45
+ const table = buildEntityTable("note", {
46
+ fields: { title: { type: "text", required: true } },
47
+ });
48
+ const info = extractTableInfo(table);
49
+ expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
50
+ });
51
+ });
52
+
53
+ describe("resolveConflictColumns — shadow-proof PK inference", () => {
54
+ test("an entity field named `columns` does not shadow the inferred primary key", () => {
55
+ const table = buildEntityTable("thing", {
56
+ fields: { columns: { type: "text", required: true } },
57
+ });
58
+ const info = extractTableInfo(table);
59
+ // No explicit conflictKeys → infer the real PK (`id`) from the symbol-based
60
+ // meta. Reading `table.columns` directly would yield the `columns` field
61
+ // handle (the bug this guards), not the PK list.
62
+ expect(resolveConflictColumns(table, info, undefined)).toEqual(["id"]);
63
+ });
64
+
65
+ test("explicit conflictKeys are mapped through columnOf (control)", () => {
66
+ const table = buildEntityTable("thing", {
67
+ fields: { slug: { type: "text", required: true } },
68
+ });
69
+ const info = extractTableInfo(table);
70
+ expect(resolveConflictColumns(table, info, ["slug"])).toEqual(["slug"]);
71
+ });
72
+ });
@@ -37,32 +37,34 @@ function snakeToCamel(key: string): string {
37
37
  import type { BunDbRunner } from "./connection";
38
38
 
39
39
  // Drizzle-pgTable-Inspection via raw Symbol-access (kein drizzle-orm import).
40
- // drizzle stores the table name unter `Symbol.for("kumiko:schema:Name")` und die
41
- // column-map unter `Symbol.for("kumiko:schema:Columns")`.
42
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
43
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
44
-
45
- function getTableName(table: unknown): string | null {
46
- if (typeof table !== "object" || table === null) return null;
47
- const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
48
- return typeof name === "string" ? name : null;
49
- }
50
-
51
- function extractDrizzleColumns(table: unknown): Map<string, { name: string; sqlType?: string }> {
52
- const out = new Map<string, { name: string; sqlType?: string }>();
53
- if (typeof table !== "object" || table === null) return out;
54
- const cols = (table as Record<symbol, unknown>)[KUMIKO_COLUMNS_SYMBOL];
55
- if (typeof cols !== "object" || cols === null) return out;
56
- for (const [key, val] of Object.entries(cols as Record<string, unknown>)) {
57
- if (typeof val !== "object" || val === null) continue;
58
- const colObj = val as { name?: unknown; getSQLType?: () => string };
59
- const colName = colObj.name;
60
- if (typeof colName !== "string") continue;
61
- // Drizzle's getSQLType uses `this` call as method on colObj.
62
- const sqlType = typeof colObj.getSQLType === "function" ? colObj.getSQLType() : undefined;
63
- out.set(key, { name: colName, ...(sqlType !== undefined && { sqlType }) });
64
- }
65
- return out;
40
+ // table() (dialect) stores the canonical, shadow-proof EntityTableMeta under
41
+ // `Symbol.for("kumiko:schema:Meta")`. The column handles on the table object are
42
+ // enumerable props, so an entity field named `source`/`columns`/`tableName`/…
43
+ // would overwrite the matching meta key — reading the meta from the symbol is
44
+ // the only collision-safe path.
45
+ const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
46
+
47
+ function isEntityTableMeta(v: unknown): v is EntityTableMeta {
48
+ return (
49
+ v !== null &&
50
+ typeof v === "object" &&
51
+ typeof (v as EntityTableMeta).tableName === "string" &&
52
+ Array.isArray((v as EntityTableMeta).columns) &&
53
+ Array.isArray((v as EntityTableMeta).indexes) &&
54
+ ((v as EntityTableMeta).source === "managed" || (v as EntityTableMeta).source === "unmanaged")
55
+ );
56
+ }
57
+
58
+ // Resolve any framework table input to its canonical EntityTableMeta:
59
+ // - table()/buildEntityTable outputs carry it under KUMIKO_META_SYMBOL, immune
60
+ // to a column-handle shadowing a meta key.
61
+ // - buildEntityTableMeta / defineUnmanagedTable return a plain meta with no
62
+ // handle-spread, so its structural shape is itself unshadowable.
63
+ function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
64
+ if (table === null || typeof table !== "object") return undefined;
65
+ const fromSymbol = (table as Record<symbol, unknown>)[KUMIKO_META_SYMBOL];
66
+ if (isEntityTableMeta(fromSymbol)) return fromSymbol;
67
+ return isEntityTableMeta(table) ? table : undefined;
66
68
  }
67
69
 
68
70
  // `db` Input akzeptiert drei Shapes:
@@ -218,69 +220,30 @@ export type TableInfo = {
218
220
  };
219
221
 
220
222
  export function extractTableInfo(table: TableLike): TableInfo {
221
- // EntityTableMeta discriminator: hat source-property "managed" | "unmanaged"
222
- if (
223
- table !== null &&
224
- typeof table === "object" &&
225
- "source" in table &&
226
- (table.source === "managed" || table.source === "unmanaged")
227
- ) {
228
- const meta = table as EntityTableMeta;
229
- const colByField = new Map<string, string>();
230
- const fieldByCol = new Map<string, string>();
231
- const typeByCol = new Map<string, string>();
232
- const bigintModeByCol = new Map<string, "number" | "bigint">();
233
- for (const c of meta.columns) {
234
- typeByCol.set(c.name, c.pgType);
235
- if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
236
- // EntityTableMeta column names are snake_case. Map snake → snake AND
237
- // derive a camelCase JS field-name so result rows can be renamed back
238
- // to the API shape (`aggregate_id` → `aggregateId`).
239
- colByField.set(c.name, c.name);
240
- const camel = snakeToCamel(c.name);
241
- if (camel !== c.name) colByField.set(camel, c.name);
242
- fieldByCol.set(c.name, camel === c.name ? c.name : camel);
243
- }
244
- return {
245
- name: meta.tableName,
246
- columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
247
- pgTypeOf: (col) => typeByCol.get(col),
248
- bigintJsModeOf: (col) => bigintModeByCol.get(col),
249
- fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
250
- hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
251
- };
252
- }
253
- // drizzle pgTable: tableName via Symbol.for("kumiko:schema:Name"), columns via
254
- // enumerable properties (jeder col-object hat .name + .getSQLType()).
255
- // Wir lesen Beide via raw Symbol/Property-access — kein drizzle-orm import.
256
- const name = getTableName(table);
257
- if (!name) {
223
+ const meta = asEntityTableMeta(table);
224
+ if (!meta) {
258
225
  throw new Error(
259
- "bun-db.extractTableInfo: table-Argument ist weder EntityTableMeta noch drizzle pgTable",
226
+ "bun-db.extractTableInfo: table is not a kumiko EntityTableMeta " +
227
+ "build it via buildEntityTable / buildEntityTableMeta / table().",
260
228
  );
261
229
  }
262
- const cols = extractDrizzleColumns(table);
263
230
  const colByField = new Map<string, string>();
264
231
  const fieldByCol = new Map<string, string>();
265
232
  const typeByCol = new Map<string, string>();
266
233
  const bigintModeByCol = new Map<string, "number" | "bigint">();
267
- if (
268
- table !== null &&
269
- typeof table === "object" &&
270
- "columns" in table &&
271
- Array.isArray((table as EntityTableMeta).columns)
272
- ) {
273
- for (const c of (table as EntityTableMeta).columns) {
274
- if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
275
- }
276
- }
277
- for (const [field, { name: colName, sqlType }] of cols) {
278
- colByField.set(field, colName);
279
- fieldByCol.set(colName, field);
280
- if (sqlType) typeByCol.set(colName, sqlType);
234
+ for (const c of meta.columns) {
235
+ typeByCol.set(c.name, c.pgType);
236
+ if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
237
+ // EntityTableMeta column names are snake_case. Map snake → snake AND
238
+ // derive a camelCase JS field-name so result rows can be renamed back
239
+ // to the API shape (`aggregate_id` → `aggregateId`).
240
+ colByField.set(c.name, c.name);
241
+ const camel = snakeToCamel(c.name);
242
+ if (camel !== c.name) colByField.set(camel, c.name);
243
+ fieldByCol.set(c.name, camel === c.name ? c.name : camel);
281
244
  }
282
245
  return {
283
- name,
246
+ name: meta.tableName,
284
247
  columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
285
248
  pgTypeOf: (col) => typeByCol.get(col),
286
249
  bigintJsModeOf: (col) => bigintModeByCol.get(col),
@@ -394,7 +357,7 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
394
357
  ) {
395
358
  // Bun.SQL / some drivers return int4 as bigint or numeric string.
396
359
  // Drizzle coerced to number for numberField columns — match that.
397
- const n = typeof value === "bigint" ? Number(value) : Number(value);
360
+ const n = Number(value);
398
361
  if (!Number.isNaN(n)) coerced = n;
399
362
  }
400
363
  const fieldName = info.fieldOf(key);
@@ -731,17 +694,9 @@ function insertEntries(info: TableInfo, values: Record<string, unknown>): Insert
731
694
  });
732
695
  }
733
696
 
734
- function isEntityTableMeta(table: unknown): table is EntityTableMeta {
735
- return (
736
- typeof table === "object" &&
737
- table !== null &&
738
- "source" in table &&
739
- (table.source === "managed" || table.source === "unmanaged") &&
740
- "columns" in table
741
- );
742
- }
743
-
744
- function resolveConflictColumns(
697
+ // Exported for the shadow-proof regression test — `table.columns` would be the
698
+ // drizzle handle (not the PK list) when an entity has a field named `columns`.
699
+ export function resolveConflictColumns(
745
700
  table: TableLike,
746
701
  info: TableInfo,
747
702
  conflictKeys: readonly string[] | undefined,
@@ -749,8 +704,11 @@ function resolveConflictColumns(
749
704
  if (conflictKeys !== undefined && conflictKeys.length > 0) {
750
705
  return conflictKeys.map((field) => info.columnOf(field));
751
706
  }
752
- if (isEntityTableMeta(table)) {
753
- const pks = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
707
+ // Read the shadow-proof meta — `table.columns` directly would be the handle
708
+ // object when an entity has a field named `columns`.
709
+ const meta = asEntityTableMeta(table);
710
+ if (meta) {
711
+ const pks = meta.columns.filter((c) => c.primaryKey).map((c) => c.name);
754
712
  if (pks.length > 0) return pks;
755
713
  }
756
714
  if (info.hasColumn("id")) return [info.columnOf("id")];
@@ -0,0 +1,54 @@
1
+ // End-to-end regression for the EntityTableMeta discriminator shadow.
2
+ //
3
+ // An entity field literally named `source` overwrote the `source:
4
+ // "managed"|"unmanaged"` discriminator on the table-meta. extractTableInfo
5
+ // then failed its meta check, fell into the dead drizzle branch, and typed the
6
+ // timestamptz base-columns as "timestamp with time zone" (getSQLType spelling).
7
+ // prepareValue only serializes Temporal.Instant for "timestamptz", so a raw
8
+ // Temporal reached postgres-js → "Cannot use valueOf" on every create.
9
+ //
10
+ // extract-table-info.test.ts pins the proximate cause (pgTypeOf stays
11
+ // "timestamptz"). This proves the actual create-path no longer crashes — the
12
+ // integration proof the unit test cannot give.
13
+
14
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
+ import { insertOne, selectMany } from "../../db/query";
16
+ import { buildEntityTable } from "../../db/table-builder";
17
+ import { createEntity, createTextField } from "../../engine";
18
+ import { setupTestStack, type TestStack, unsafeCreateEntityTable } from "../../stack";
19
+
20
+ const sourceEntity = createEntity({
21
+ table: "ssc_source",
22
+ fields: {
23
+ // `source` collides with the EntityTableMeta discriminator key.
24
+ source: createTextField({ required: true }),
25
+ },
26
+ });
27
+ const sourceTable = buildEntityTable("source-row", sourceEntity);
28
+
29
+ let stack: TestStack;
30
+
31
+ beforeAll(async () => {
32
+ stack = await setupTestStack({ features: [] });
33
+ await unsafeCreateEntityTable(stack.db, sourceEntity, "source-row");
34
+ });
35
+
36
+ afterAll(async () => stack?.cleanup());
37
+
38
+ describe("entity with a `source` field — create-path is shadow-proof", () => {
39
+ test("insertOne serializes the timestamptz inserted_at — no Temporal valueOf crash", async () => {
40
+ // Passing a real Temporal.Instant exercises the timestamptz serializer
41
+ // path that the shadow used to bypass. Pre-fix this threw "Cannot use
42
+ // valueOf"; post-fix the row persists and round-trips as a Temporal.Instant.
43
+ await insertOne(stack.db, sourceTable, {
44
+ source: "import",
45
+ tenantId: "00000000-0000-4000-8000-000000000001",
46
+ insertedAt: Temporal.Instant.from("2026-01-15T12:00:00Z"),
47
+ });
48
+
49
+ const rows = await selectMany(stack.db, sourceTable);
50
+ expect(rows).toHaveLength(1);
51
+ expect(rows[0]?.["source"]).toBe("import");
52
+ expect(rows[0]?.["insertedAt"]).toBeInstanceOf(Temporal.Instant);
53
+ });
54
+ });
@@ -1,5 +1,11 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { formatReport, isRawSqlAllowed, joinPath, scanRepo } from "../sql-inventory";
2
+ import {
3
+ formatReport,
4
+ isRawSqlAllowed,
5
+ joinPath,
6
+ scanRepo,
7
+ toBaselineJson,
8
+ } from "../sql-inventory";
3
9
 
4
10
  const cleanups: string[] = [];
5
11
 
@@ -53,4 +59,23 @@ describe("sql-inventory", () => {
53
59
  expect(report.summary.byBucket.disallowed).toBeGreaterThanOrEqual(1);
54
60
  expect(formatReport(report)).toContain("sql inventory");
55
61
  });
62
+
63
+ test("toBaselineJson normalizes machine-specific root + scannedAt, keeps the rest", async () => {
64
+ const root = await tempRepo({
65
+ "packages/framework/src/handlers/bad.ts": `export async function y(db: unknown) {
66
+ return asRawClient(db).unsafe("DELETE FROM read_users");
67
+ }`,
68
+ });
69
+
70
+ const report = await scanRepo(root);
71
+ expect(report.root).toBe(root);
72
+ expect(report.scannedAt).not.toBe("");
73
+
74
+ const parsed = JSON.parse(toBaselineJson(report));
75
+ expect(parsed.root).toBe(".");
76
+ expect(parsed.scannedAt).toBe("");
77
+ expect(parsed.root).not.toContain(root);
78
+ expect(parsed.summary.disallowed).toBe(report.summary.disallowed);
79
+ expect(parsed.hits).toHaveLength(report.hits.length);
80
+ });
56
81
  });
@@ -147,6 +147,21 @@ describe("validateBoot — entity.indexes", () => {
147
147
  expect(() => validateBoot([feature])).toThrow(/redundant/);
148
148
  });
149
149
 
150
+ test("single-column UNIQUE auf tenantId ist erlaubt (1:1-Constraint)", () => {
151
+ // Der `&& !def.unique`-Branch in entity-handler.ts: ein UNIQUE-Index auf
152
+ // tenantId allein ist kein redundanter Read-Index, sondern ein
153
+ // 1:1-Constraint (genau ein Row pro Tenant). Nur die NON-unique-Variante
154
+ // oben ist redundant — beide Hälften der Bedingung sind damit gepinnt,
155
+ // sonst fliegt der Branch beim nächsten Revert/Refactor stumm raus.
156
+ const feature = defineFeature("widgetFeature", (r) => {
157
+ r.entity("widget", {
158
+ fields: { title: createTextField({}) },
159
+ indexes: [{ unique: true, columns: ["tenantId"] }],
160
+ });
161
+ });
162
+ expect(() => validateBoot([feature])).not.toThrow();
163
+ });
164
+
150
165
  test("composite mit tenantId ist OK (z.B. für unique über 3 Cols)", () => {
151
166
  const feature = defineFeature("widgetFeature", (r) => {
152
167
  r.entity("widget", {
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine";
3
+ import { testTenantId } from "../../stack";
4
+ import { buildEntityTable } from "../table-builder";
5
+ import { createTenantDb } from "../tenant-db";
6
+
7
+ // Tenant-isolation: a caller-supplied `where.tenantId` must NEVER override the
8
+ // enforced tenant scope. These tests drive the real query-builder against a
9
+ // recording fake runner and assert on the emitted SQL + bound values — no
10
+ // Postgres needed because the bug lives entirely in the WHERE-object merge.
11
+
12
+ const entity = createEntity({
13
+ table: "merge_items",
14
+ fields: { name: createTextField({ required: true }) },
15
+ });
16
+ const table = buildEntityTable("mergeItem", entity);
17
+
18
+ const own = testTenantId(1);
19
+ const foreign = testTenantId(2);
20
+
21
+ type Captured = { sql: string; values: readonly unknown[] };
22
+
23
+ function recordingDb(captured: Captured[]) {
24
+ return {
25
+ unsafe: async (sql: string, values: readonly unknown[]) => {
26
+ captured.push({ sql, values });
27
+ return [] as unknown[];
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("tenant-db WHERE merge — caller cannot override tenant scope", () => {
33
+ test("selectMany ignores a foreign where.tenantId and keeps the IN-scope filter", async () => {
34
+ const captured: Captured[] = [];
35
+ const tdb = createTenantDb(recordingDb(captured), own);
36
+
37
+ await tdb.selectMany(table, { tenantId: foreign });
38
+
39
+ expect(captured).toHaveLength(1);
40
+ // The enforced scope is an IN over [own, SYSTEM_TENANT_ID]; the foreign id
41
+ // must not appear as a sole-equality predicate (the pre-fix bug).
42
+ expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
43
+ expect(captured[0]?.values).toContain(own);
44
+ expect(captured[0]?.values).not.toContain(foreign);
45
+ });
46
+
47
+ test("updateMany forces own tenantId even when where.tenantId is foreign", async () => {
48
+ const captured: Captured[] = [];
49
+ const tdb = createTenantDb(recordingDb(captured), own);
50
+
51
+ await tdb.updateMany(table, { name: "x" }, { tenantId: foreign });
52
+
53
+ const update = captured.find((c) => /UPDATE/i.test(c.sql));
54
+ expect(update).toBeDefined();
55
+ expect(update?.values).toContain(own);
56
+ expect(update?.values).not.toContain(foreign);
57
+ });
58
+
59
+ test("deleteMany forces own tenantId even when where.tenantId is foreign", async () => {
60
+ const captured: Captured[] = [];
61
+ const tdb = createTenantDb(recordingDb(captured), own);
62
+
63
+ await tdb.deleteMany(table, { tenantId: foreign });
64
+
65
+ const del = captured.find((c) => /DELETE/i.test(c.sql));
66
+ expect(del).toBeDefined();
67
+ expect(del?.values).toContain(own);
68
+ expect(del?.values).not.toContain(foreign);
69
+ });
70
+
71
+ test("a non-tenantId where predicate is still applied alongside the scope", async () => {
72
+ const captured: Captured[] = [];
73
+ const tdb = createTenantDb(recordingDb(captured), own);
74
+
75
+ await tdb.selectMany(table, { name: "needle" });
76
+
77
+ expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
78
+ expect(captured[0]?.values).toContain("needle");
79
+ expect(captured[0]?.values).toContain(own);
80
+ });
81
+ });
package/src/db/dialect.ts CHANGED
@@ -40,6 +40,11 @@ export type ColumnHandle = {
40
40
 
41
41
  const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
42
42
  const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
43
+ // Shadow-proof handle on the EntityTableMeta. The column handles below are
44
+ // spread as enumerable props, so an entity field named `source`/`columns`/
45
+ // `tableName`/… would overwrite the matching meta key. extractTableInfo reads
46
+ // the canonical meta from this symbol instead of the (shadowable) props.
47
+ const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
43
48
 
44
49
  // SchemaTable — opaque shape with both EntityTableMeta + Symbol-based
45
50
  // introspection. Returned by `table(...)`.
@@ -469,6 +474,7 @@ export function table<TCols extends ColumnMap>(
469
474
  const out = Object.assign({}, base, handles, {
470
475
  [KUMIKO_NAME_SYMBOL]: tableName,
471
476
  [KUMIKO_COLUMNS_SYMBOL]: handles,
477
+ [KUMIKO_META_SYMBOL]: base,
472
478
  }) as SchemaTable;
473
479
  return out;
474
480
  }
@@ -253,7 +253,7 @@ function fieldToColumnMeta(
253
253
  }
254
254
  }
255
255
 
256
- function resolveTableName(
256
+ export function resolveTableName(
257
257
  entityName: string,
258
258
  entity: EntityDefinition,
259
259
  featureName: string | undefined,
@@ -81,6 +81,22 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
81
81
  return rows[0]?.v ?? 0;
82
82
  }
83
83
 
84
+ /** tenant_id the aggregate's events were written under — no membership/tenant
85
+ * filter. A r.systemScope() aggregate (e.g. user) lives in whichever tenant
86
+ * its creating executor used, which need not be a tenant the subject holds a
87
+ * membership in. Returns null for unknown streams. */
88
+ export async function selectAggregateStreamTenant(
89
+ db: AnyDb,
90
+ aggregateId: string,
91
+ aggregateType: string,
92
+ ): Promise<string | null> {
93
+ const rows = (await asRawClient(db).unsafe(
94
+ `SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
95
+ [aggregateId, aggregateType],
96
+ )) as ReadonlyArray<{ t: string | null }>;
97
+ return rows[0]?.t ?? null;
98
+ }
99
+
84
100
  export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
85
101
  const rows = (await asRawClient(db).unsafe(
86
102
  `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
@@ -91,14 +107,9 @@ export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
91
107
  return BigInt(raw);
92
108
  }
93
109
 
94
- /** Head event id for lag metrics — same aggregate as selectEventsHighWaterMark. */
110
+ /** Head event id for lag metrics — alias for selectEventsHighWaterMark. */
95
111
  export async function selectEventsHeadId(db: AnyDb): Promise<bigint> {
96
- const rows = (await asRawClient(db).unsafe(
97
- `SELECT COALESCE(MAX(id), 0)::bigint AS head FROM kumiko_events`,
98
- )) as ReadonlyArray<{ head?: bigint | string | null }>;
99
- const raw = rows[0]?.head;
100
- if (typeof raw === "bigint") return raw;
101
- return BigInt(raw ?? 0);
112
+ return selectEventsHighWaterMark(db);
102
113
  }
103
114
 
104
115
  export async function selectNextEventIdAfter(db: AnyDb, afterId: bigint): Promise<bigint | null> {
@@ -176,6 +176,15 @@ export async function scanRepo(repoRoot: string): Promise<SqlInventoryReport> {
176
176
  };
177
177
  }
178
178
 
179
+ // Serialize for the checked-in baseline. `root` (absolute scan path) and
180
+ // `scannedAt` (run timestamp) are machine-/run-specific noise that churned the
181
+ // baseline on every regen; --compare-baseline reads only summary.disallowed.
182
+ // Pin them to stable placeholders so the committed file is reproducible.
183
+ export function toBaselineJson(report: SqlInventoryReport): string {
184
+ const stable: SqlInventoryReport = { ...report, root: ".", scannedAt: "" };
185
+ return `${JSON.stringify(stable, null, 2)}\n`;
186
+ }
187
+
179
188
  export function formatReport(report: SqlInventoryReport): string {
180
189
  const lines: string[] = [
181
190
  "--- sql inventory ---",
@@ -133,15 +133,18 @@ export function createTenantDb(
133
133
 
134
134
  // Reads see own-tenant rows + reference data (tenantId === SYSTEM_TENANT_ID).
135
135
  // Writes never touch reference rows — those are system-mode only.
136
+ // The tenant filter is spread LAST so a caller-supplied `where.tenantId`
137
+ // cannot override the enforced scope — overriding it would be a
138
+ // tenant-isolation bypass.
136
139
  function readWhere(table: Table, where?: WhereObject): WhereObject | undefined {
137
140
  if (!hasTenantColumn(table) || mode === "system") return where;
138
141
  const tenantFilter: WhereObject = { tenantId: [tenantId, SYSTEM_TENANT_ID] };
139
- return where ? { ...tenantFilter, ...where } : tenantFilter;
142
+ return where ? { ...where, ...tenantFilter } : tenantFilter;
140
143
  }
141
144
 
142
145
  function writeWhere(table: Table, where: WhereObject): WhereObject {
143
146
  if (!hasTenantColumn(table) || mode === "system") return where;
144
- return { tenantId, ...where };
147
+ return { ...where, tenantId };
145
148
  }
146
149
 
147
150
  function insertValues(table: Table, data: Record<string, unknown>): Record<string, unknown> {