@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.
- package/package.json +1 -1
- package/src/__tests__/schema-cli-status.integration.test.ts +2 -1
- package/src/bun-db/__tests__/extract-table-info.test.ts +72 -0
- package/src/bun-db/query.ts +52 -94
- package/src/db/__tests__/source-shadow-create.integration.test.ts +54 -0
- package/src/db/__tests__/sql-inventory.test.ts +26 -1
- package/src/db/__tests__/table-builder-indexes.test.ts +15 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +81 -0
- package/src/db/dialect.ts +6 -0
- package/src/db/entity-table-meta.ts +1 -1
- package/src/db/queries/event-store.ts +18 -7
- package/src/db/sql-inventory.ts +9 -0
- package/src/db/tenant-db.ts +5 -2
- package/src/engine/__tests__/boot-validator.test.ts +79 -0
- package/src/engine/__tests__/post-query-hook.test.ts +6 -6
- package/src/engine/__tests__/projection-helpers.test.ts +12 -7
- package/src/engine/__tests__/registry.test.ts +58 -0
- package/src/engine/__tests__/search-payload-extension.test.ts +49 -3
- package/src/engine/__tests__/unmanaged-table.test.ts +30 -1
- package/src/engine/boot-validator/api-ext.ts +1 -5
- package/src/engine/boot-validator/screens-nav.ts +18 -2
- package/src/engine/registry.ts +60 -29
- package/src/engine/types/fields.ts +2 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +4 -1
- package/src/engine/validate-projection-allowlist.ts +13 -3
- package/src/errors/__tests__/classes.test.ts +5 -0
- package/src/errors/classes.ts +4 -2
- package/src/event-store/event-store.ts +15 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/file-ref-entity.test.ts +34 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +50 -1
- package/src/pipeline/dispatcher.ts +57 -47
- package/src/pipeline/system-hooks.ts +17 -5
- package/src/stack/table-helpers.ts +19 -9
- package/src/stack/test-stack.ts +3 -4
- package/src/testing/db-cleanup.ts +7 -9
- 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.
|
|
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
|
-
|
|
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
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 =
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
753
|
-
|
|
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 {
|
|
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
|
}
|
|
@@ -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 —
|
|
110
|
+
/** Head event id for lag metrics — alias for selectEventsHighWaterMark. */
|
|
95
111
|
export async function selectEventsHeadId(db: AnyDb): Promise<bigint> {
|
|
96
|
-
|
|
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> {
|
package/src/db/sql-inventory.ts
CHANGED
|
@@ -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 ---",
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -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 ? { ...
|
|
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 {
|
|
147
|
+
return { ...where, tenantId };
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
function insertValues(table: Table, data: Record<string, unknown>): Record<string, unknown> {
|