@cosmicdrift/kumiko-framework 0.24.1 → 0.26.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/bun-db/__tests__/extract-table-info.test.ts +22 -1
- package/src/bun-db/query.ts +5 -2
- 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/entity-table-meta.ts +1 -1
- package/src/db/queries/event-store.ts +2 -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__/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 +43 -12
- 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/files/__tests__/file-ref-entity.test.ts +34 -0
- package/src/files/__tests__/in-memory-provider.test.ts +94 -0
- package/src/logging/__tests__/fallback-logger.test.ts +47 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
- package/src/pipeline/dispatcher.ts +57 -57
- package/src/pipeline/system-hooks.ts +17 -5
- package/src/random/__tests__/words.test.ts +44 -0
- package/src/random/generate.ts +3 -3
- package/src/random/words.ts +3 -3
- package/src/stack/table-helpers.ts +2 -2
- package/src/stack/test-stack.ts +3 -4
- 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.26.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>",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { describe, expect, test } from "bun:test";
|
|
13
13
|
import { buildEntityTable } from "../../db/table-builder";
|
|
14
|
-
import { extractTableInfo } from "../query";
|
|
14
|
+
import { extractTableInfo, resolveConflictColumns } from "../query";
|
|
15
15
|
|
|
16
16
|
describe("extractTableInfo — EntityTableMeta discriminator is shadow-proof", () => {
|
|
17
17
|
test("an entity field named `source` does not shadow the discriminator", () => {
|
|
@@ -49,3 +49,24 @@ describe("extractTableInfo — EntityTableMeta discriminator is shadow-proof", (
|
|
|
49
49
|
expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
|
|
50
50
|
});
|
|
51
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
|
@@ -50,6 +50,7 @@ function isEntityTableMeta(v: unknown): v is EntityTableMeta {
|
|
|
50
50
|
typeof v === "object" &&
|
|
51
51
|
typeof (v as EntityTableMeta).tableName === "string" &&
|
|
52
52
|
Array.isArray((v as EntityTableMeta).columns) &&
|
|
53
|
+
Array.isArray((v as EntityTableMeta).indexes) &&
|
|
53
54
|
((v as EntityTableMeta).source === "managed" || (v as EntityTableMeta).source === "unmanaged")
|
|
54
55
|
);
|
|
55
56
|
}
|
|
@@ -356,7 +357,7 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
|
|
|
356
357
|
) {
|
|
357
358
|
// Bun.SQL / some drivers return int4 as bigint or numeric string.
|
|
358
359
|
// Drizzle coerced to number for numberField columns — match that.
|
|
359
|
-
const n =
|
|
360
|
+
const n = Number(value);
|
|
360
361
|
if (!Number.isNaN(n)) coerced = n;
|
|
361
362
|
}
|
|
362
363
|
const fieldName = info.fieldOf(key);
|
|
@@ -693,7 +694,9 @@ function insertEntries(info: TableInfo, values: Record<string, unknown>): Insert
|
|
|
693
694
|
});
|
|
694
695
|
}
|
|
695
696
|
|
|
696
|
-
|
|
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(
|
|
697
700
|
table: TableLike,
|
|
698
701
|
info: TableInfo,
|
|
699
702
|
conflictKeys: readonly string[] | undefined,
|
|
@@ -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
|
+
});
|
|
@@ -107,14 +107,9 @@ export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
|
|
|
107
107
|
return BigInt(raw);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/** Head event id for lag metrics —
|
|
110
|
+
/** Head event id for lag metrics — alias for selectEventsHighWaterMark. */
|
|
111
111
|
export async function selectEventsHeadId(db: AnyDb): Promise<bigint> {
|
|
112
|
-
|
|
113
|
-
`SELECT COALESCE(MAX(id), 0)::bigint AS head FROM kumiko_events`,
|
|
114
|
-
)) as ReadonlyArray<{ head?: bigint | string | null }>;
|
|
115
|
-
const raw = rows[0]?.head;
|
|
116
|
-
if (typeof raw === "bigint") return raw;
|
|
117
|
-
return BigInt(raw ?? 0);
|
|
112
|
+
return selectEventsHighWaterMark(db);
|
|
118
113
|
}
|
|
119
114
|
|
|
120
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> {
|
|
@@ -235,6 +235,20 @@ describe("boot-validator", () => {
|
|
|
235
235
|
expect(() => validateBoot([ext, consumer])).not.toThrow();
|
|
236
236
|
});
|
|
237
237
|
|
|
238
|
+
test("passes when a feature provides AND uses its own extension (self-extension)", () => {
|
|
239
|
+
// tier-engine pattern: a feature defines an extension-point and ships a
|
|
240
|
+
// default plugin for it, so providerFeature === feature.name. Requiring
|
|
241
|
+
// the feature to requires(self) would be circular — the validator must
|
|
242
|
+
// skip the requires-check for self-provided extensions. The cross-feature
|
|
243
|
+
// tests above only exercise providerFeature !== feature.name.
|
|
244
|
+
const self = defineFeature("tier-stub", (r) => {
|
|
245
|
+
r.extendsRegistrar("tenantTierResolver", { onRegister: () => {} });
|
|
246
|
+
r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
|
|
247
|
+
r.useExtension("tenantTierResolver", "dummy");
|
|
248
|
+
});
|
|
249
|
+
expect(() => validateBoot([self])).not.toThrow();
|
|
250
|
+
});
|
|
251
|
+
|
|
238
252
|
// --- FILE_STORAGE_PROVIDER ---
|
|
239
253
|
|
|
240
254
|
test("throws when file fields exist but FILE_STORAGE_PROVIDER not set", () => {
|
|
@@ -1372,6 +1386,20 @@ describe("boot-validator", () => {
|
|
|
1372
1386
|
/redirect "ghost-screen" does not resolve to a registered screen/,
|
|
1373
1387
|
);
|
|
1374
1388
|
});
|
|
1389
|
+
|
|
1390
|
+
test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
|
|
1391
|
+
// synthesizeActionFormScreen reicht die layout 1:1 an RenderEdit weiter —
|
|
1392
|
+
// eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
|
|
1393
|
+
const section = { kind: "extension", title: "Custom", component: {} };
|
|
1394
|
+
expect(() => validateBoot([makeFeature({ sections: [section] as never })])).toThrow(
|
|
1395
|
+
/\(actionForm\) extension section "Custom" has no component/,
|
|
1396
|
+
);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
test("extension section mit react component → kein Throw", () => {
|
|
1400
|
+
const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
|
|
1401
|
+
expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
|
|
1402
|
+
});
|
|
1375
1403
|
});
|
|
1376
1404
|
|
|
1377
1405
|
// --- configEdit-Screen ---
|
|
@@ -1474,6 +1502,57 @@ describe("boot-validator", () => {
|
|
|
1474
1502
|
]),
|
|
1475
1503
|
).toThrow(/Config-Key "shop:config:typo-here" ist in keiner Feature-Registry deklariert/);
|
|
1476
1504
|
});
|
|
1505
|
+
|
|
1506
|
+
test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
|
|
1507
|
+
// synthesizeConfigEditScreen reicht die layout 1:1 an RenderEdit weiter —
|
|
1508
|
+
// eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
|
|
1509
|
+
const section = { kind: "extension", title: "Custom", component: {} };
|
|
1510
|
+
expect(() => validateBoot([makeFeature({ sections: [section] as never })])).toThrow(
|
|
1511
|
+
/\(configEdit\) extension section "Custom" has no component/,
|
|
1512
|
+
);
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
test("extension section mit react component → kein Throw", () => {
|
|
1516
|
+
const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
|
|
1517
|
+
expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
// --- entityEdit extension section ---
|
|
1522
|
+
// Eine extension-Section delegiert das Rendering an eine feature-provided
|
|
1523
|
+
// PlatformComponent (custom-fields-Panel etc.), die client-seitig per Name
|
|
1524
|
+
// aufgelöst wird. Ohne react/native-Marker bliebe der Slot zur Laufzeit leer
|
|
1525
|
+
// — Boot-Fail statt stummem Loch. Field-Sections bleiben davon unberührt.
|
|
1526
|
+
describe("entityEdit extension section", () => {
|
|
1527
|
+
function makeFeature(component: unknown) {
|
|
1528
|
+
return defineFeature("shop", (r) => {
|
|
1529
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
1530
|
+
r.screen({
|
|
1531
|
+
id: "product-edit",
|
|
1532
|
+
type: "entityEdit",
|
|
1533
|
+
entity: "product",
|
|
1534
|
+
layout: {
|
|
1535
|
+
sections: [
|
|
1536
|
+
{ kind: "extension", title: "Custom Fields", component: component as never },
|
|
1537
|
+
],
|
|
1538
|
+
},
|
|
1539
|
+
});
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
test("extension section ohne react/native component → Throw", () => {
|
|
1544
|
+
expect(() => validateBoot([makeFeature({})])).toThrow(
|
|
1545
|
+
/extension section "Custom Fields" has no component — declare a react\/native component marker/,
|
|
1546
|
+
);
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
test("extension section mit react component → kein Throw", () => {
|
|
1550
|
+
expect(() => validateBoot([makeFeature({ react: "CustomFieldsPanel" })])).not.toThrow();
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
test("extension section mit native component → kein Throw", () => {
|
|
1554
|
+
expect(() => validateBoot([makeFeature({ native: "CustomFieldsPanel" })])).not.toThrow();
|
|
1555
|
+
});
|
|
1477
1556
|
});
|
|
1478
1557
|
|
|
1479
1558
|
// --- Tier 2.7e-3: ReferenceFieldDef ---
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createEntity, createRegistry, defineFeature } from "../index";
|
|
4
|
-
import type { PostQueryHookFn } from "../types";
|
|
4
|
+
import type { AppContext, PostQueryHookFn } from "../types";
|
|
5
|
+
|
|
6
|
+
// The hooks under test never read context (they only transform rows), so a
|
|
7
|
+
// stub at the cast-boundary is sufficient — no real db/redis/registry needed.
|
|
8
|
+
const stubContext = {} as unknown as AppContext;
|
|
5
9
|
|
|
6
10
|
// postQuery-Hook (F1) — feuert nach Query-Handler-Execute, vor Field-Access-
|
|
7
11
|
// Read-Filter. Zwei Registrierungs-Pfade:
|
|
@@ -112,11 +116,7 @@ describe("Hook function semantics", () => {
|
|
|
112
116
|
const hooks = registry.getEntityPostQueryHooks("thing");
|
|
113
117
|
const inputRows: ReadonlyArray<Record<string, unknown>> = [{ id: "1" }, { id: "2" }];
|
|
114
118
|
// Context shape is { user, db, ... } in real runtime; unit-tests stub.
|
|
115
|
-
const result = await hooks[0]?.(
|
|
116
|
-
{ entityName: "thing", rows: inputRows },
|
|
117
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
118
|
-
{} as never,
|
|
119
|
-
);
|
|
119
|
+
const result = await hooks[0]?.({ entityName: "thing", rows: inputRows }, stubContext);
|
|
120
120
|
expect(result?.rows).toEqual([
|
|
121
121
|
{ id: "1", enriched: true },
|
|
122
122
|
{ id: "2", enriched: true },
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createRegistry } from "../registry";
|
|
3
|
+
import type { FeatureDefinition } from "../types/feature";
|
|
4
|
+
|
|
5
|
+
// Hand-built FeatureDefinition that bypasses defineFeature() — the latter
|
|
6
|
+
// initializes every slot (entities, entityHooks, …) to an empty map. A
|
|
7
|
+
// FeatureDefinition assembled off that path (cast at a system boundary) can
|
|
8
|
+
// leave slots `undefined`, which the type forbids but createRegistry's
|
|
9
|
+
// entity-iteration paths must survive: `Object.entries/values(undefined)`
|
|
10
|
+
// throws. The double-cast is the deliberate type-violation that reproduces it.
|
|
11
|
+
function bareFeature(overrides: Record<string, unknown> = {}): FeatureDefinition {
|
|
12
|
+
return {
|
|
13
|
+
name: "probe",
|
|
14
|
+
requires: [],
|
|
15
|
+
optionalRequires: [],
|
|
16
|
+
...overrides,
|
|
17
|
+
} as unknown as FeatureDefinition;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("createRegistry slot robustness", () => {
|
|
21
|
+
// Regression for the hardening PRs (#95/#98/#210): the entity- and
|
|
22
|
+
// hook-iterating paths in createRegistry must not assume the optional
|
|
23
|
+
// `entities` / `entityHooks` slots are present. defineFeature masks this in
|
|
24
|
+
// every test that goes through the normal author API, so the gap only
|
|
25
|
+
// surfaced when a partial feature reached the boot path.
|
|
26
|
+
|
|
27
|
+
test("tolerates a hand-built feature with entities + entityHooks omitted", () => {
|
|
28
|
+
// Exercises the entity-iteration paths (allEntities loop + hasFieldAccessRules)
|
|
29
|
+
// — both crash on `Object.{keys,values}(undefined)` without the `?? {}` guard.
|
|
30
|
+
expect(() => createRegistry([bareFeature()])).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("tolerates entities: undefined (Object.keys/values guard)", () => {
|
|
34
|
+
expect(() => createRegistry([bareFeature({ entities: undefined })])).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("tolerates entityHooks with every slot undefined", () => {
|
|
38
|
+
expect(() =>
|
|
39
|
+
createRegistry([
|
|
40
|
+
bareFeature({
|
|
41
|
+
entities: {},
|
|
42
|
+
entityHooks: {
|
|
43
|
+
postSave: undefined,
|
|
44
|
+
preDelete: undefined,
|
|
45
|
+
postDelete: undefined,
|
|
46
|
+
postQuery: undefined,
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
]),
|
|
50
|
+
).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("tolerates entityHooks map itself undefined", () => {
|
|
54
|
+
expect(() =>
|
|
55
|
+
createRegistry([bareFeature({ entities: {}, entityHooks: undefined })]),
|
|
56
|
+
).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { buildSearchDocument } from "../../pipeline/system-hooks";
|
|
3
|
+
import { createEntity, createRegistry, createTextField, defineFeature } from "../index";
|
|
3
4
|
import type { SearchPayloadContributorFn } from "../types";
|
|
4
5
|
|
|
5
6
|
// F3 — Search-Payload-Extension registers per-entity contributors that
|
|
@@ -115,6 +116,36 @@ describe("effectiveFeatures filtering", () => {
|
|
|
115
116
|
});
|
|
116
117
|
});
|
|
117
118
|
|
|
119
|
+
describe("buildSearchDocument — contributor precedence (base fields win)", () => {
|
|
120
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
121
|
+
afterEach(() => warnSpy.mockClear());
|
|
122
|
+
|
|
123
|
+
function registryWith(contributor: SearchPayloadContributorFn) {
|
|
124
|
+
const feature = defineFeature("test", (r) => {
|
|
125
|
+
const thing = r.entity(
|
|
126
|
+
"thing",
|
|
127
|
+
createEntity({ table: "things", fields: { title: createTextField({ searchable: true }) } }),
|
|
128
|
+
);
|
|
129
|
+
r.searchPayloadExtension(thing, contributor);
|
|
130
|
+
});
|
|
131
|
+
return createRegistry([feature]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("a contributor cannot overwrite an indexed Stammfield value", async () => {
|
|
135
|
+
const registry = registryWith(() => ({ title: "from-contributor" }));
|
|
136
|
+
const doc = await buildSearchDocument("thing", "t1", { title: "real-value" }, registry);
|
|
137
|
+
expect(doc?.fields["title"]).toBe("real-value");
|
|
138
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a non-colliding contributor key is still merged in", async () => {
|
|
142
|
+
const registry = registryWith(() => ({ flatTags: "a,b" }));
|
|
143
|
+
const doc = await buildSearchDocument("thing", "t1", { title: "real-value" }, registry);
|
|
144
|
+
expect(doc?.fields).toMatchObject({ title: "real-value", flatTags: "a,b" });
|
|
145
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
118
149
|
describe("Boot-Validation", () => {
|
|
119
150
|
test("rejects searchPayloadExtension on unknown entity-name (sibling to entity-hooks)", () => {
|
|
120
151
|
expect(() =>
|
|
@@ -131,6 +162,21 @@ describe("Boot-Validation", () => {
|
|
|
131
162
|
r.searchPayloadExtension("propery", noop);
|
|
132
163
|
});
|
|
133
164
|
createRegistry([feature]);
|
|
134
|
-
}).toThrow(/searchPayloadExtension
|
|
165
|
+
}).toThrow(/searchPayloadExtension extension targets entity "propery" but no entity/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("error message calls a searchPayloadExtension an 'extension', not a 'hook'", () => {
|
|
169
|
+
const feature = defineFeature("test", (r) => {
|
|
170
|
+
r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
171
|
+
r.searchPayloadExtension("propery", noop);
|
|
172
|
+
});
|
|
173
|
+
let message = "";
|
|
174
|
+
try {
|
|
175
|
+
createRegistry([feature]);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
message = err instanceof Error ? err.message : String(err);
|
|
178
|
+
}
|
|
179
|
+
expect(message).toContain("searchPayloadExtension extension");
|
|
180
|
+
expect(message).not.toContain("searchPayloadExtension hook");
|
|
135
181
|
});
|
|
136
182
|
});
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// drizzle migrate-runner). See define-feature.ts / DX-4.
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
6
|
+
import { defineUnmanagedTable, resolveTableName } from "../../db/entity-table-meta";
|
|
7
7
|
import { defineFeature } from "../define-feature";
|
|
8
|
+
import { createEntity, createTextField } from "../index";
|
|
8
9
|
import { createRegistry } from "../registry";
|
|
9
10
|
|
|
10
11
|
const probeMeta = defineUnmanagedTable({
|
|
@@ -95,4 +96,32 @@ describe("createRegistry — unmanagedTable aggregation", () => {
|
|
|
95
96
|
});
|
|
96
97
|
expect(() => createRegistry([featA, featB])).not.toThrow();
|
|
97
98
|
});
|
|
99
|
+
|
|
100
|
+
test("rejects an unmanaged-table that collides with an entity's physical name", () => {
|
|
101
|
+
const widget = createEntity({ fields: { name: createTextField() } });
|
|
102
|
+
// resolveTableName mirrors the migrate-runner — pin the exact physical name.
|
|
103
|
+
const physical = resolveTableName("widget", widget, "shop");
|
|
104
|
+
const clashing = defineUnmanagedTable({
|
|
105
|
+
tableName: physical,
|
|
106
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
107
|
+
});
|
|
108
|
+
const entityFeature = defineFeature("shop", (r) => {
|
|
109
|
+
r.entity("widget", widget);
|
|
110
|
+
});
|
|
111
|
+
const tableFeature = defineFeature("other", (r) => {
|
|
112
|
+
r.unmanagedTable(clashing, { reason: "clash" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Entity registered first, then the colliding unmanaged table.
|
|
116
|
+
expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
|
|
117
|
+
new RegExp(
|
|
118
|
+
`Unmanaged-table "${physical}".*collides with the physical table of entity "widget"`,
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Order-independent: unmanaged table registered first, then the entity.
|
|
123
|
+
expect(() => createRegistry([tableFeature, entityFeature])).toThrow(
|
|
124
|
+
new RegExp(`Entity "widget".*collides with r.unmanagedTable\\("${physical}"\\)`),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
98
127
|
});
|
|
@@ -67,11 +67,7 @@ export function validateExtensionUsages(
|
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
// doesn't need requires(self) — that would be a circular declaration.
|
|
72
|
-
// tier-engine is the canonical case: defines + uses tenantTierResolver
|
|
73
|
-
// because it ships a default tier-resolver-plugin alongside the
|
|
74
|
-
// extension-point.
|
|
70
|
+
// self-extension is legitimate: requires(self) would be circular.
|
|
75
71
|
if (providerFeature === feature.name) {
|
|
76
72
|
continue;
|
|
77
73
|
}
|