@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
|
@@ -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 },
|
|
@@ -3,15 +3,20 @@ import type { StoredEvent } from "../../event-store/event-store";
|
|
|
3
3
|
import { setFields } from "../projection-helpers";
|
|
4
4
|
import type { ProjectionTable } from "../types/projection";
|
|
5
5
|
|
|
6
|
-
// Minimal fake table:
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
6
|
+
// Minimal fake table: an EntityTableMeta (what bun-db introspects for
|
|
7
|
+
// table-name + column-mapping) plus a top-level `id` handle, which setFields
|
|
8
|
+
// existence-checks before building its apply fn. We don't run real SQL —
|
|
9
|
+
// unsafe() is mocked.
|
|
10
10
|
const fakeTable = Object.assign(
|
|
11
|
-
{ id:
|
|
11
|
+
{ id: { name: "id" } },
|
|
12
12
|
{
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
tableName: "fake_table",
|
|
14
|
+
source: "unmanaged",
|
|
15
|
+
indexes: [],
|
|
16
|
+
columns: [
|
|
17
|
+
{ name: "id", pgType: "uuid", notNull: true },
|
|
18
|
+
{ name: "status", pgType: "text", notNull: false },
|
|
19
|
+
],
|
|
15
20
|
},
|
|
16
21
|
) as unknown as ProjectionTable;
|
|
17
22
|
|
|
@@ -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
|
}
|
|
@@ -62,7 +62,15 @@ export function validateScreens(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
for (const section of screen.layout.sections) {
|
|
65
|
-
if (isExtensionEditSection(section))
|
|
65
|
+
if (isExtensionEditSection(section)) {
|
|
66
|
+
if (section.component.react === undefined && section.component.native === undefined) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) extension section ` +
|
|
69
|
+
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
66
74
|
if (section.fields.length === 0) {
|
|
67
75
|
throw new Error(
|
|
68
76
|
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
|
|
@@ -161,7 +169,15 @@ export function validateScreens(
|
|
|
161
169
|
);
|
|
162
170
|
}
|
|
163
171
|
for (const section of screen.layout.sections) {
|
|
164
|
-
if (isExtensionEditSection(section))
|
|
172
|
+
if (isExtensionEditSection(section)) {
|
|
173
|
+
if (section.component.react === undefined && section.component.native === undefined) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) extension section ` +
|
|
176
|
+
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
165
181
|
if (section.fields.length === 0) {
|
|
166
182
|
throw new Error(
|
|
167
183
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
|
package/src/engine/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
|
+
import { resolveTableName } from "../db/entity-table-meta";
|
|
2
3
|
import { buildEntityTable } from "../db/table-builder";
|
|
3
4
|
import { buildMetricName, validateMetricName } from "../observability";
|
|
4
5
|
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
@@ -174,6 +175,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
174
175
|
// Cousin of rawTables: same uniqueness-by-tableName invariant, different
|
|
175
176
|
// storage shape (post-drizzle migrate-runner consumes EntityTableMeta).
|
|
176
177
|
const unmanagedTableMap = new Map<string, UnmanagedTableDef>();
|
|
178
|
+
// Final physical table names (entity-derived + unmanaged) → owner. Catches
|
|
179
|
+
// a collision between an r.unmanagedTable() tableName and an r.entity()
|
|
180
|
+
// physical name at boot instead of as a duplicate CREATE TABLE in migrate.
|
|
181
|
+
const physicalTableOwners = new Map<
|
|
182
|
+
string,
|
|
183
|
+
{ kind: "entity" | "unmanaged"; owner: string; featureName: string }
|
|
184
|
+
>();
|
|
177
185
|
// Auth-claims hooks — tagged with featureName so the login resolver can
|
|
178
186
|
// auto-prefix each hook's returned keys with "<feature>:".
|
|
179
187
|
const authClaimsHooks: AuthClaimsHookDef[] = [];
|
|
@@ -321,6 +329,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
321
329
|
throw new Error(`Duplicate entity: "${name}" (registered by multiple features)`);
|
|
322
330
|
}
|
|
323
331
|
entityMap.set(name, entity);
|
|
332
|
+
const physical = resolveTableName(name, entity, feature.name);
|
|
333
|
+
const clash = physicalTableOwners.get(physical);
|
|
334
|
+
if (clash?.kind === "unmanaged") {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
337
|
+
`collides with r.unmanagedTable("${physical}") (feature "${clash.featureName}"). ` +
|
|
338
|
+
`Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
physicalTableOwners.set(physical, { kind: "entity", owner: name, featureName: feature.name });
|
|
324
342
|
}
|
|
325
343
|
|
|
326
344
|
// Relations: entityName (not prefixed)
|
|
@@ -422,18 +440,18 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
422
440
|
// Lifecycle hooks: keyed by handler QN. featureName rides along on each
|
|
423
441
|
// hook entry — defineFeature sets it, the registry just appends.
|
|
424
442
|
// Save/delete hooks target write handlers, query hooks target query handlers.
|
|
425
|
-
mergeHookListQualified(preSaveHooks, feature.hooks
|
|
426
|
-
mergeHookListQualified(postSaveHooks, feature.hooks
|
|
427
|
-
mergeHookListQualified(preDeleteHooks, feature.hooks
|
|
428
|
-
mergeHookListQualified(postDeleteHooks, feature.hooks
|
|
429
|
-
mergeHookListQualified(preQueryHooks, feature.hooks
|
|
430
|
-
mergeHookListQualified(postQueryHooks, feature.hooks
|
|
443
|
+
mergeHookListQualified(preSaveHooks, feature.hooks?.preSave, feature.name, "write");
|
|
444
|
+
mergeHookListQualified(postSaveHooks, feature.hooks?.postSave, feature.name, "write");
|
|
445
|
+
mergeHookListQualified(preDeleteHooks, feature.hooks?.preDelete, feature.name, "write");
|
|
446
|
+
mergeHookListQualified(postDeleteHooks, feature.hooks?.postDelete, feature.name, "write");
|
|
447
|
+
mergeHookListQualified(preQueryHooks, feature.hooks?.preQuery, feature.name, "query");
|
|
448
|
+
mergeHookListQualified(postQueryHooks, feature.hooks?.postQuery, feature.name, "query");
|
|
431
449
|
|
|
432
450
|
// Entity hooks: NOT prefixed, keyed by entity name
|
|
433
|
-
mergeHookList(entityPostSaveHooks, feature.entityHooks
|
|
434
|
-
mergeHookList(entityPreDeleteHooks, feature.entityHooks
|
|
435
|
-
mergeHookList(entityPostDeleteHooks, feature.entityHooks
|
|
436
|
-
mergeHookList(entityPostQueryHooks, feature.entityHooks
|
|
451
|
+
mergeHookList(entityPostSaveHooks, feature.entityHooks?.postSave);
|
|
452
|
+
mergeHookList(entityPreDeleteHooks, feature.entityHooks?.preDelete);
|
|
453
|
+
mergeHookList(entityPostDeleteHooks, feature.entityHooks?.postDelete);
|
|
454
|
+
mergeHookList(entityPostQueryHooks, feature.entityHooks?.postQuery);
|
|
437
455
|
|
|
438
456
|
// F3 search-payload-extensions: per-entity contributors merged additively
|
|
439
457
|
for (const [entityName, contributors] of Object.entries(
|
|
@@ -453,9 +471,9 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
453
471
|
}
|
|
454
472
|
extensionMap.set(extName, extDef);
|
|
455
473
|
}
|
|
456
|
-
extensionUsages.push(...feature.extensionUsages);
|
|
457
|
-
allReferenceData.push(...feature.referenceData);
|
|
458
|
-
allConfigSeeds.push(...feature.configSeeds);
|
|
474
|
+
extensionUsages.push(...(feature.extensionUsages ?? []));
|
|
475
|
+
allReferenceData.push(...(feature.referenceData ?? []));
|
|
476
|
+
allConfigSeeds.push(...(feature.configSeeds ?? []));
|
|
459
477
|
|
|
460
478
|
// Metrics: validate + qualify per feature. Collisions across features are
|
|
461
479
|
// rejected here — two features can't both register "created_total" under
|
|
@@ -477,7 +495,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
477
495
|
// Secret keys: already qualified during defineFeature (same "<feature>:<short>"
|
|
478
496
|
// convention used elsewhere). Reject cross-feature duplicates — extensions
|
|
479
497
|
// could theoretically register on another feature's namespace.
|
|
480
|
-
for (const def of Object.values(feature.secretKeys)) {
|
|
498
|
+
for (const def of Object.values(feature.secretKeys ?? {})) {
|
|
481
499
|
if (secretKeyMap.has(def.qualifiedName)) {
|
|
482
500
|
throw new Error(
|
|
483
501
|
`[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
|
|
@@ -559,6 +577,19 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
559
577
|
`"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
|
|
560
578
|
);
|
|
561
579
|
}
|
|
580
|
+
const physicalClash = physicalTableOwners.get(umName);
|
|
581
|
+
if (physicalClash?.kind === "entity") {
|
|
582
|
+
throw new Error(
|
|
583
|
+
`Unmanaged-table "${umName}" (feature "${feature.name}") collides with the physical ` +
|
|
584
|
+
`table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
|
|
585
|
+
`Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
physicalTableOwners.set(umName, {
|
|
589
|
+
kind: "unmanaged",
|
|
590
|
+
owner: umName,
|
|
591
|
+
featureName: feature.name,
|
|
592
|
+
});
|
|
562
593
|
unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
|
|
563
594
|
}
|
|
564
595
|
|
|
@@ -567,7 +598,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
567
598
|
// correctness — the only way to hit this is a hand-built FeatureDefinition
|
|
568
599
|
// bypassing defineFeature's per-feature duplicate check.
|
|
569
600
|
const declaredShortNames = new Set<string>();
|
|
570
|
-
for (const def of Object.values(feature.claimKeys)) {
|
|
601
|
+
for (const def of Object.values(feature.claimKeys ?? {})) {
|
|
571
602
|
if (claimKeyMap.has(def.qualifiedName)) {
|
|
572
603
|
throw new Error(
|
|
573
604
|
`[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
|
|
@@ -670,7 +701,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
670
701
|
// on undeclared inner-keys (typo / rename drift). Features that don't
|
|
671
702
|
// declare claimKeys skip the check entirely — it's opt-in.
|
|
672
703
|
const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
|
|
673
|
-
for (const fn of feature.authClaimsHooks) {
|
|
704
|
+
for (const fn of feature.authClaimsHooks ?? []) {
|
|
674
705
|
authClaimsHooks.push({
|
|
675
706
|
featureName: feature.name,
|
|
676
707
|
fn,
|
|
@@ -870,7 +901,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
870
901
|
if (!hasFieldAccessRules(feature)) continue;
|
|
871
902
|
|
|
872
903
|
// Write handlers: ALL must be entity-mapped (security-critical, writes need field-access checks)
|
|
873
|
-
for (const handlerName of Object.keys(feature.writeHandlers)) {
|
|
904
|
+
for (const handlerName of Object.keys(feature.writeHandlers ?? {})) {
|
|
874
905
|
const qualified = qualify(feature.name, "write", handlerName);
|
|
875
906
|
if (!handlerEntityMap.has(qualified)) {
|
|
876
907
|
throw new Error(
|
|
@@ -882,7 +913,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
882
913
|
|
|
883
914
|
// Query handlers: only those with a dash must resolve (typo protection).
|
|
884
915
|
// No dash = standalone query (dashboard, stats) — intentionally not entity-bound.
|
|
885
|
-
for (const handlerName of Object.keys(feature.queryHandlers)) {
|
|
916
|
+
for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
|
|
886
917
|
if (!handlerName.includes(":")) continue;
|
|
887
918
|
const qualified = qualify(feature.name, "query", handlerName);
|
|
888
919
|
if (!handlerEntityMap.has(qualified)) {
|
|
@@ -1035,7 +1066,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1035
1066
|
|
|
1036
1067
|
// Validate: all required features must be registered
|
|
1037
1068
|
for (const feature of features) {
|
|
1038
|
-
for (const required of feature.requires) {
|
|
1069
|
+
for (const required of feature.requires ?? []) {
|
|
1039
1070
|
if (!featureMap.has(required)) {
|
|
1040
1071
|
throw new Error(
|
|
1041
1072
|
`Feature "${feature.name}" requires feature "${required}" which is not registered`,
|
|
@@ -1131,23 +1162,23 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1131
1162
|
// postQuery-special.
|
|
1132
1163
|
const allEntities = new Set<string>();
|
|
1133
1164
|
for (const feature of features) {
|
|
1134
|
-
for (const entityName of Object.keys(feature.entities)) {
|
|
1165
|
+
for (const entityName of Object.keys(feature.entities ?? {})) {
|
|
1135
1166
|
allEntities.add(entityName);
|
|
1136
1167
|
}
|
|
1137
1168
|
}
|
|
1138
1169
|
const entityHookMaps = [
|
|
1139
|
-
{ map: entityPostSaveHooks, phase: "postSave (entityHook)" },
|
|
1140
|
-
{ map: entityPreDeleteHooks, phase: "preDelete (entityHook)" },
|
|
1141
|
-
{ map: entityPostDeleteHooks, phase: "postDelete (entityHook)" },
|
|
1142
|
-
{ map: entityPostQueryHooks, phase: "postQuery (entityHook)" },
|
|
1143
|
-
{ map: searchPayloadExtensions, phase: "searchPayloadExtension" },
|
|
1170
|
+
{ map: entityPostSaveHooks, phase: "postSave (entityHook)", kind: "hook" },
|
|
1171
|
+
{ map: entityPreDeleteHooks, phase: "preDelete (entityHook)", kind: "hook" },
|
|
1172
|
+
{ map: entityPostDeleteHooks, phase: "postDelete (entityHook)", kind: "hook" },
|
|
1173
|
+
{ map: entityPostQueryHooks, phase: "postQuery (entityHook)", kind: "hook" },
|
|
1174
|
+
{ map: searchPayloadExtensions, phase: "searchPayloadExtension", kind: "extension" },
|
|
1144
1175
|
] as const;
|
|
1145
|
-
for (const { map, phase } of entityHookMaps) {
|
|
1176
|
+
for (const { map, phase, kind } of entityHookMaps) {
|
|
1146
1177
|
for (const entityName of map.keys()) {
|
|
1147
1178
|
if (!allEntities.has(entityName)) {
|
|
1148
1179
|
throw new Error(
|
|
1149
|
-
`${phase}
|
|
1150
|
-
`Check for typos — the
|
|
1180
|
+
`${phase} ${kind} targets entity "${entityName}" but no entity with that name exists. ` +
|
|
1181
|
+
`Check for typos — the ${kind} will never fire.`,
|
|
1151
1182
|
);
|
|
1152
1183
|
}
|
|
1153
1184
|
}
|
|
@@ -1492,7 +1523,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1492
1523
|
|
|
1493
1524
|
/** Returns true if any entity in the feature has field-level access rules (read or write). */
|
|
1494
1525
|
function hasFieldAccessRules(feature: FeatureDefinition): boolean {
|
|
1495
|
-
for (const entity of Object.values(feature.entities)) {
|
|
1526
|
+
for (const entity of Object.values(feature.entities ?? {})) {
|
|
1496
1527
|
for (const field of Object.values(entity.fields)) {
|
|
1497
1528
|
if (field.access?.read?.length || field.access?.write?.length) {
|
|
1498
1529
|
return true;
|
|
@@ -484,7 +484,8 @@ export type TransitionMap = Readonly<Record<string, readonly string[]>>;
|
|
|
484
484
|
* vermeidet Migration-Churn beim Refactor.
|
|
485
485
|
*
|
|
486
486
|
* Single-column indices über `tenantId` sind redundant (buildEntityTable
|
|
487
|
-
* legt die immer automatisch an); die Boot-Validation warnt
|
|
487
|
+
* legt die immer automatisch an); die Boot-Validation warnt (außer
|
|
488
|
+
* `{ unique: true }` — semantische 1:1-Constraint, kein Performance-Hint). */
|
|
488
489
|
export type EntityIndexDef = {
|
|
489
490
|
readonly columns: readonly [string, ...string[]];
|
|
490
491
|
readonly unique?: boolean;
|
|
@@ -223,7 +223,7 @@ type SharedContextFields = {
|
|
|
223
223
|
// set handler needs to encrypt on write, the resolver needs to decrypt
|
|
224
224
|
// on read, and both reach for the same provider. Wired via extraContext.
|
|
225
225
|
readonly configEncryption?: import("../../db").EncryptionProvider;
|
|
226
|
-
// Rate-limit resolver. Wired by the framework when the `
|
|
226
|
+
// Rate-limit resolver. Wired by the framework when the `rate-limiting`
|
|
227
227
|
// feature is loaded — pipeline reads handler.rateLimit and calls
|
|
228
228
|
// .enforce() on this resolver before access-check. Absent when the
|
|
229
229
|
// app didn't load the feature: handlers with rateLimit set are
|
|
@@ -84,7 +84,10 @@ export type PreQueryHookFn = (
|
|
|
84
84
|
// on added fields (field-access-filter only knows entity's stammfields).
|
|
85
85
|
export type PostQueryHookFn = (
|
|
86
86
|
result: {
|
|
87
|
-
|
|
87
|
+
// undefined for standalone queries (no-colon handler names like
|
|
88
|
+
// "ns:dashboard") — those have no backing entity, but handler-keyed
|
|
89
|
+
// postQuery hooks still fire on them.
|
|
90
|
+
readonly entityName: string | undefined;
|
|
88
91
|
readonly rows: ReadonlyArray<Record<string, unknown>>;
|
|
89
92
|
},
|
|
90
93
|
context: AppContext,
|
|
@@ -55,13 +55,23 @@ function* walkAllSteps(steps: readonly StepInstance[]): Generator<StepInstance,
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// @cast-boundary drizzle-bridge — reads table name from
|
|
59
|
-
//
|
|
58
|
+
// @cast-boundary drizzle-bridge — reads table name from a Symbol without
|
|
59
|
+
// importing drizzle-orm (bun-db pattern, see bun-db/query.ts).
|
|
60
60
|
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
61
|
+
// table()/buildEntityTable spread column handles as enumerable props, so an
|
|
62
|
+
// entity field named `tableName`/`source` would shadow the matching meta key —
|
|
63
|
+
// the canonical meta under this symbol is the only collision-safe source.
|
|
64
|
+
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
61
65
|
|
|
62
66
|
function resolveTableNameFromStep(table: unknown): string {
|
|
63
67
|
if (typeof table === "object" && table !== null) {
|
|
64
|
-
//
|
|
68
|
+
// Canonical meta under the unshadowable symbol — preferred path.
|
|
69
|
+
const meta = (table as Record<symbol, unknown>)[KUMIKO_META_SYMBOL];
|
|
70
|
+
if (meta !== null && typeof meta === "object") {
|
|
71
|
+
const metaName = (meta as Record<string, unknown>)["tableName"];
|
|
72
|
+
if (typeof metaName === "string") return metaName;
|
|
73
|
+
}
|
|
74
|
+
// Plain meta (buildEntityTableMeta / defineUnmanagedTable — no handle-spread).
|
|
65
75
|
if (
|
|
66
76
|
"source" in table &&
|
|
67
77
|
"tableName" in table &&
|
|
@@ -241,6 +241,11 @@ describe("NotFoundError", () => {
|
|
|
241
241
|
const err = new NotFoundError("billing-period", 7);
|
|
242
242
|
expect((err.details as { reason: string }).reason).toBe("billing_period_not_found");
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
test("PascalCase entity name does not leak a leading underscore into the reason", () => {
|
|
246
|
+
const err = new NotFoundError("Invoice", 7);
|
|
247
|
+
expect((err.details as { reason: string }).reason).toBe("invoice_not_found");
|
|
248
|
+
});
|
|
244
249
|
});
|
|
245
250
|
|
|
246
251
|
describe("ConflictError + VersionConflictError", () => {
|