@cosmicdrift/kumiko-framework 0.156.2 → 0.157.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 +2 -2
- package/src/__tests__/{raw-table.integration.test.ts → store-table.integration.test.ts} +10 -10
- package/src/api/__tests__/batch.integration.test.ts +68 -0
- package/src/db/__tests__/collect-table-metas.test.ts +6 -6
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/__tests__/feature-table-sources.test.ts +7 -7
- package/src/db/collect-table-metas.ts +4 -4
- package/src/db/entity-table-meta.ts +5 -4
- package/src/db/feature-table-sources.ts +3 -3
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +96 -0
- package/src/engine/__tests__/{raw-table.test.ts → store-table.test.ts} +59 -42
- package/src/engine/boot-validator/action-wiring.ts +67 -17
- package/src/engine/define-feature.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/extractors/index.ts +1 -1
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/parse.ts +3 -3
- package/src/engine/feature-builder-state.ts +3 -3
- package/src/engine/feature-ui-extensions.ts +54 -33
- package/src/engine/registry-facade.ts +3 -3
- package/src/engine/registry-ingest.ts +6 -6
- package/src/engine/registry-state.ts +3 -3
- package/src/engine/types/feature.ts +17 -17
- package/src/engine/types/index.ts +3 -3
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/file-ref-entity.ts +2 -2
- package/src/migrations/pending-rebuilds.ts +1 -1
- package/src/search/__tests__/reindex-entity.integration.test.ts +121 -0
- package/src/search/index.ts +6 -0
- package/src/search/reindex-entity.ts +148 -0
- package/src/stack/push-entity-projection-tables.ts +2 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Unit tests for r.
|
|
2
|
-
// aggregation. Post-drizzle-cut merge of the former r.
|
|
1
|
+
// Unit tests for r.storeTable() — declaration-time validation + registry
|
|
2
|
+
// aggregation. Post-drizzle-cut merge of the former r.storeTable() (legacy
|
|
3
3
|
// Drizzle PgTable) and r.unmanagedTable() (EntityTableMeta) — this file
|
|
4
4
|
// absorbs the former unmanaged-table.test.ts coverage under the new name.
|
|
5
5
|
// Full DB roundtrip (setupTestStack pushes the table → INSERT / SELECT)
|
|
6
|
-
// lives in src/__tests__/
|
|
6
|
+
// lives in src/__tests__/store-table.integration.test.ts.
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import {
|
|
@@ -24,7 +24,7 @@ const probeMetaTwo = defineUnmanagedTable({
|
|
|
24
24
|
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
describe("r.
|
|
27
|
+
describe("r.storeTable — declaration", () => {
|
|
28
28
|
test("rejects an invalid table name", () => {
|
|
29
29
|
const badMeta = defineUnmanagedTable({
|
|
30
30
|
tableName: "BadName",
|
|
@@ -32,7 +32,7 @@ describe("r.rawTable — declaration", () => {
|
|
|
32
32
|
});
|
|
33
33
|
expect(() =>
|
|
34
34
|
defineFeature("probe", (r) => {
|
|
35
|
-
r.
|
|
35
|
+
r.storeTable(badMeta, { reason: "test" });
|
|
36
36
|
}),
|
|
37
37
|
).toThrow(/must be a valid identifier/);
|
|
38
38
|
});
|
|
@@ -40,8 +40,8 @@ describe("r.rawTable — declaration", () => {
|
|
|
40
40
|
test("rejects duplicate registrations within one feature", () => {
|
|
41
41
|
expect(() =>
|
|
42
42
|
defineFeature("probe", (r) => {
|
|
43
|
-
r.
|
|
44
|
-
r.
|
|
43
|
+
r.storeTable(probeMeta, { reason: "test" });
|
|
44
|
+
r.storeTable(probeMeta, { reason: "test" });
|
|
45
45
|
}),
|
|
46
46
|
).toThrow(/already registered/);
|
|
47
47
|
});
|
|
@@ -49,7 +49,7 @@ describe("r.rawTable — declaration", () => {
|
|
|
49
49
|
test("rejects empty reason", () => {
|
|
50
50
|
expect(() =>
|
|
51
51
|
defineFeature("probe", (r) => {
|
|
52
|
-
r.
|
|
52
|
+
r.storeTable(probeMeta, { reason: "" });
|
|
53
53
|
}),
|
|
54
54
|
).toThrow(/options\.reason must be a non-empty string/);
|
|
55
55
|
});
|
|
@@ -57,49 +57,62 @@ describe("r.rawTable — declaration", () => {
|
|
|
57
57
|
test("rejects whitespace-only reason", () => {
|
|
58
58
|
expect(() =>
|
|
59
59
|
defineFeature("probe", (r) => {
|
|
60
|
-
r.
|
|
60
|
+
r.storeTable(probeMeta, { reason: " " });
|
|
61
61
|
}),
|
|
62
62
|
).toThrow(/options\.reason must be a non-empty string/);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test("rejects an EntityTableMeta whose source is not 'unmanaged' (#1209)", () => {
|
|
66
|
+
const managedEntity = createEntity({
|
|
67
|
+
table: "rt_probe_managed",
|
|
68
|
+
fields: { name: createTextField() },
|
|
69
|
+
});
|
|
70
|
+
const managedMeta = buildEntityTableMeta("rt-probe-managed", managedEntity);
|
|
71
|
+
expect(() =>
|
|
72
|
+
defineFeature("probe", (r) => {
|
|
73
|
+
r.storeTable(managedMeta, { reason: "test" });
|
|
74
|
+
}),
|
|
75
|
+
).toThrow(/requires source: "unmanaged"/);
|
|
76
|
+
});
|
|
77
|
+
|
|
65
78
|
test("accepts valid registration and stores meta + reason", () => {
|
|
66
79
|
const feature = defineFeature("probe", (r) => {
|
|
67
|
-
r.
|
|
80
|
+
r.storeTable(probeMeta, {
|
|
68
81
|
reason: "read-side projection of an event-stream",
|
|
69
82
|
});
|
|
70
83
|
});
|
|
71
|
-
expect(feature.
|
|
72
|
-
expect(feature.
|
|
73
|
-
expect(feature.
|
|
84
|
+
expect(feature.storeTables).toHaveProperty("rt_probe");
|
|
85
|
+
expect(feature.storeTables["rt_probe"]?.reason).toBe("read-side projection of an event-stream");
|
|
86
|
+
expect(feature.storeTables["rt_probe"]?.meta).toBe(probeMeta);
|
|
74
87
|
});
|
|
75
88
|
|
|
76
|
-
test("two
|
|
89
|
+
test("two store tables on one feature register under their tableName", () => {
|
|
77
90
|
const feature = defineFeature("dual", (r) => {
|
|
78
|
-
r.
|
|
79
|
-
r.
|
|
91
|
+
r.storeTable(probeMeta, { reason: "one" });
|
|
92
|
+
r.storeTable(probeMetaTwo, { reason: "two" });
|
|
80
93
|
});
|
|
81
|
-
expect(Object.keys(feature.
|
|
94
|
+
expect(Object.keys(feature.storeTables).sort()).toEqual(["rt_probe", "rt_probe_two"]);
|
|
82
95
|
});
|
|
83
96
|
|
|
84
|
-
test("absent
|
|
97
|
+
test("absent storeTables on a feature is ok", () => {
|
|
85
98
|
const feat = defineFeature("plain", () => {
|
|
86
|
-
// no r.
|
|
99
|
+
// no r.storeTable calls
|
|
87
100
|
});
|
|
88
|
-
expect(feat.
|
|
101
|
+
expect(feat.storeTables).toEqual({});
|
|
89
102
|
});
|
|
90
103
|
});
|
|
91
104
|
|
|
92
|
-
describe("createRegistry —
|
|
93
|
-
test("aggregates
|
|
105
|
+
describe("createRegistry — storeTable aggregation", () => {
|
|
106
|
+
test("aggregates store tables across features and tags featureName", () => {
|
|
94
107
|
const featA = defineFeature("billing", (r) => {
|
|
95
|
-
r.
|
|
108
|
+
r.storeTable(probeMeta, { reason: "external API cache" });
|
|
96
109
|
});
|
|
97
110
|
const featB = defineFeature("inventory", (r) => {
|
|
98
|
-
r.
|
|
111
|
+
r.storeTable(probeMetaTwo, { reason: "imported pre-ES" });
|
|
99
112
|
});
|
|
100
113
|
|
|
101
114
|
const registry = createRegistry([featA, featB]);
|
|
102
|
-
const all = registry.
|
|
115
|
+
const all = registry.getAllStoreTables();
|
|
103
116
|
|
|
104
117
|
expect(all.size).toBe(2);
|
|
105
118
|
expect(all.get("rt_probe")?.featureName).toBe("billing");
|
|
@@ -111,10 +124,10 @@ describe("createRegistry — rawTable aggregation", () => {
|
|
|
111
124
|
// Two features can't share the same physical tableName — migrate-runner
|
|
112
125
|
// would race two CREATE TABLE statements. Boot-validator catches it.
|
|
113
126
|
const featA = defineFeature("a", (r) => {
|
|
114
|
-
r.
|
|
127
|
+
r.storeTable(probeMeta, { reason: "first" });
|
|
115
128
|
});
|
|
116
129
|
const featB = defineFeature("b", (r) => {
|
|
117
|
-
r.
|
|
130
|
+
r.storeTable(probeMeta, { reason: "second" });
|
|
118
131
|
});
|
|
119
132
|
expect(() => createRegistry([featA, featB])).toThrow(
|
|
120
133
|
/Raw-table "rt_probe" registered by both feature "a" and "b"/,
|
|
@@ -123,16 +136,20 @@ describe("createRegistry — rawTable aggregation", () => {
|
|
|
123
136
|
|
|
124
137
|
test("two features with distinct tableNames register cleanly", () => {
|
|
125
138
|
const featA = defineFeature("a", (r) => {
|
|
126
|
-
r.
|
|
139
|
+
r.storeTable(probeMeta, { reason: "first" });
|
|
127
140
|
});
|
|
128
141
|
const featB = defineFeature("b", (r) => {
|
|
129
|
-
r.
|
|
142
|
+
r.storeTable(probeMetaTwo, { reason: "second" });
|
|
130
143
|
});
|
|
131
144
|
expect(() => createRegistry([featA, featB])).not.toThrow();
|
|
132
145
|
});
|
|
133
146
|
|
|
134
|
-
test("rejects a
|
|
135
|
-
|
|
147
|
+
test("rejects a store-table that collides with an entity's physical name", () => {
|
|
148
|
+
// Custom `table` override without the read_ prefix — resolveTableName's
|
|
149
|
+
// default would carry read_, which r.storeTable now rejects outright
|
|
150
|
+
// (#1220), so the collision case needs a table name storeTable can
|
|
151
|
+
// actually register.
|
|
152
|
+
const widget = createEntity({ table: "shop_widgets", fields: { name: createTextField() } });
|
|
136
153
|
// resolveTableName mirrors the migrate-runner — pin the exact physical name.
|
|
137
154
|
const physical = resolveTableName("widget", widget, "shop");
|
|
138
155
|
const clashing = defineUnmanagedTable({
|
|
@@ -143,22 +160,22 @@ describe("createRegistry — rawTable aggregation", () => {
|
|
|
143
160
|
r.entity("widget", widget);
|
|
144
161
|
});
|
|
145
162
|
const tableFeature = defineFeature("other", (r) => {
|
|
146
|
-
r.
|
|
163
|
+
r.storeTable(clashing, { reason: "clash" });
|
|
147
164
|
});
|
|
148
165
|
|
|
149
|
-
// Entity registered first, then the colliding
|
|
166
|
+
// Entity registered first, then the colliding store table.
|
|
150
167
|
expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
|
|
151
168
|
new RegExp(`Raw-table "${physical}".*collides with the physical table of entity "widget"`),
|
|
152
169
|
);
|
|
153
170
|
|
|
154
|
-
// Order-independent:
|
|
171
|
+
// Order-independent: store table registered first, then the entity.
|
|
155
172
|
expect(() => createRegistry([tableFeature, entityFeature])).toThrow(
|
|
156
|
-
new RegExp(`Entity "widget".*collides with r.
|
|
173
|
+
new RegExp(`Entity "widget".*collides with r.storeTable\\("${physical}"\\)`),
|
|
157
174
|
);
|
|
158
175
|
});
|
|
159
176
|
});
|
|
160
177
|
|
|
161
|
-
describe("createRegistry —
|
|
178
|
+
describe("createRegistry — store tables with PII-annotated fields (#820)", () => {
|
|
162
179
|
const piiEntity = createEntity({
|
|
163
180
|
table: "rt_pii_probe",
|
|
164
181
|
fields: {
|
|
@@ -166,15 +183,15 @@ describe("createRegistry — raw tables with PII-annotated fields (#820)", () =>
|
|
|
166
183
|
ip: createTextField({ userOwned: { ownerField: "userId" } }),
|
|
167
184
|
},
|
|
168
185
|
});
|
|
169
|
-
const piiMeta = buildEntityTableMeta("rt-pii-probe", piiEntity);
|
|
186
|
+
const piiMeta = buildEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
|
|
170
187
|
|
|
171
188
|
test("buildEntityTableMeta records the subject-annotated field names", () => {
|
|
172
189
|
expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
|
|
173
190
|
});
|
|
174
191
|
|
|
175
|
-
test("rejects a PII-carrying
|
|
192
|
+
test("rejects a PII-carrying store table without piiEncryptedOnWrite", () => {
|
|
176
193
|
const feat = defineFeature("probe", (r) => {
|
|
177
|
-
r.
|
|
194
|
+
r.storeTable(piiMeta, { reason: "direct-write store" });
|
|
178
195
|
});
|
|
179
196
|
expect(() => createRegistry([feat])).toThrow(
|
|
180
197
|
/has PII-annotated fields \(ip\) but direct writes bypass the executor's PII encryption/,
|
|
@@ -183,7 +200,7 @@ describe("createRegistry — raw tables with PII-annotated fields (#820)", () =>
|
|
|
183
200
|
|
|
184
201
|
test("accepts it once the feature declares piiEncryptedOnWrite", () => {
|
|
185
202
|
const feat = defineFeature("probe", (r) => {
|
|
186
|
-
r.
|
|
203
|
+
r.storeTable(piiMeta, {
|
|
187
204
|
reason: "direct-write store",
|
|
188
205
|
piiEncryptedOnWrite: true,
|
|
189
206
|
});
|
|
@@ -191,9 +208,9 @@ describe("createRegistry — raw tables with PII-annotated fields (#820)", () =>
|
|
|
191
208
|
expect(() => createRegistry([feat])).not.toThrow();
|
|
192
209
|
});
|
|
193
210
|
|
|
194
|
-
test("a PII-free
|
|
211
|
+
test("a PII-free store table needs no declaration", () => {
|
|
195
212
|
const feat = defineFeature("probe", (r) => {
|
|
196
|
-
r.
|
|
213
|
+
r.storeTable(probeMeta, { reason: "plain store" });
|
|
197
214
|
});
|
|
198
215
|
expect(() => createRegistry([feat])).not.toThrow();
|
|
199
216
|
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
EditFieldSpec,
|
|
3
|
+
EditLayout,
|
|
4
|
+
FeatureDefinition,
|
|
5
|
+
ListColumnSpec,
|
|
6
|
+
RowAction,
|
|
7
|
+
ToolbarAction,
|
|
8
|
+
} from "../types";
|
|
2
9
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
3
10
|
|
|
4
11
|
const FUNCTION_DROPPED_HINT =
|
|
@@ -55,6 +62,7 @@ const EDIT_FIELD_FUNCTION_FIELDS = ["visible", "readOnly", "required"] as const;
|
|
|
55
62
|
function validateEditFieldNoFunctions(
|
|
56
63
|
featureName: string,
|
|
57
64
|
screenId: string,
|
|
65
|
+
screenType: string,
|
|
58
66
|
fieldSpec: EditFieldSpec,
|
|
59
67
|
): void {
|
|
60
68
|
const normalized = normalizeEditField(fieldSpec);
|
|
@@ -62,37 +70,79 @@ function validateEditFieldNoFunctions(
|
|
|
62
70
|
for (const key of EDIT_FIELD_FUNCTION_FIELDS) {
|
|
63
71
|
throwIfFunction(
|
|
64
72
|
record[key],
|
|
65
|
-
`[Feature ${featureName}] Screen "${screenId}" (
|
|
73
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) field "${normalized.field}" ` +
|
|
66
74
|
`${key} is a function — ${FUNCTION_DROPPED_HINT} Use a FieldCondition ` +
|
|
67
75
|
`(boolean or { field, eq }/{ field, ne }) instead.`,
|
|
68
76
|
);
|
|
69
77
|
}
|
|
70
78
|
throwIfFunction(
|
|
71
79
|
normalized.renderer,
|
|
72
|
-
`[Feature ${featureName}] Screen "${screenId}" (
|
|
80
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) field "${normalized.field}" ` +
|
|
73
81
|
`renderer is a function — ${FUNCTION_DROPPED_HINT} Use a FormatSpec ({ format: "..." }) instead.`,
|
|
74
82
|
);
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
function validateColumnsNoFunctions(
|
|
86
|
+
featureName: string,
|
|
87
|
+
screenId: string,
|
|
88
|
+
screenType: string,
|
|
89
|
+
columns: readonly ListColumnSpec[],
|
|
90
|
+
): void {
|
|
91
|
+
for (const col of columns) {
|
|
92
|
+
const normalized = normalizeListColumn(col);
|
|
93
|
+
throwIfFunction(
|
|
94
|
+
normalized.renderer,
|
|
95
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) column ` +
|
|
96
|
+
`"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
|
|
97
|
+
`Use a FormatSpec ({ format: "..." }) instead.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateEditLayoutNoFunctions(
|
|
103
|
+
featureName: string,
|
|
104
|
+
screenId: string,
|
|
105
|
+
screenType: string,
|
|
106
|
+
layout: EditLayout,
|
|
107
|
+
): void {
|
|
108
|
+
for (const section of layout.sections) {
|
|
109
|
+
if (isExtensionEditSection(section)) continue;
|
|
110
|
+
for (const fieldSpec of section.fields) {
|
|
111
|
+
validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Screen types that carry an EditLayout (entityEdit's layout shape, reused
|
|
117
|
+
// verbatim by actionForm/configEdit/projectionDetail) vs. ones that carry
|
|
118
|
+
// ListColumnSpec[] (entityList/projectionList, plus dashboard "list" panels)
|
|
119
|
+
// both funnel through the same two checks below — a function literal in
|
|
120
|
+
// either shape is dropped by JSON.stringify regardless of which screen type
|
|
121
|
+
// hosts it.
|
|
77
122
|
export function validateFieldWiring(feature: FeatureDefinition): void {
|
|
78
123
|
for (const screen of Object.values(feature.screens)) {
|
|
79
124
|
if (screen.type === "entityList" || screen.type === "projectionList") {
|
|
80
|
-
|
|
81
|
-
const normalized = normalizeListColumn(col);
|
|
82
|
-
throwIfFunction(
|
|
83
|
-
normalized.renderer,
|
|
84
|
-
`[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) column ` +
|
|
85
|
-
`"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
|
|
86
|
-
`Use a FormatSpec ({ format: "..." }) instead.`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
125
|
+
validateColumnsNoFunctions(feature.name, screen.id, screen.type, screen.columns);
|
|
89
126
|
continue;
|
|
90
127
|
}
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
128
|
+
if (
|
|
129
|
+
screen.type === "entityEdit" ||
|
|
130
|
+
screen.type === "actionForm" ||
|
|
131
|
+
screen.type === "configEdit" ||
|
|
132
|
+
screen.type === "projectionDetail"
|
|
133
|
+
) {
|
|
134
|
+
validateEditLayoutNoFunctions(feature.name, screen.id, screen.type, screen.layout);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (screen.type === "dashboard") {
|
|
138
|
+
for (const panel of screen.panels) {
|
|
139
|
+
if (panel.kind !== "list") continue;
|
|
140
|
+
validateColumnsNoFunctions(
|
|
141
|
+
feature.name,
|
|
142
|
+
`${screen.id}:${panel.id}`,
|
|
143
|
+
"dashboard-list",
|
|
144
|
+
panel.columns,
|
|
145
|
+
);
|
|
96
146
|
}
|
|
97
147
|
}
|
|
98
148
|
}
|
|
@@ -152,7 +152,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
152
152
|
navs: state.navs,
|
|
153
153
|
workspaces: state.workspaces,
|
|
154
154
|
httpRoutes: state.httpRoutes,
|
|
155
|
-
|
|
155
|
+
storeTables: state.storeTables,
|
|
156
156
|
...(state.treeActions !== undefined && { treeActions: state.treeActions }),
|
|
157
157
|
...(state.envSchema !== undefined && { envSchema: state.envSchema }),
|
|
158
158
|
};
|
|
@@ -69,7 +69,7 @@ const FEATURES: readonly RealFeature[] = [
|
|
|
69
69
|
path: "packages/bundled-features/src/sessions/feature.ts",
|
|
70
70
|
expectedFeatureName: "sessions",
|
|
71
71
|
recognisedKinds: ["hook", "writeHandler", "queryHandler"],
|
|
72
|
-
errorMethodNames: ["
|
|
72
|
+
errorMethodNames: ["storeTable", "job"],
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
path: "packages/bundled-features/src/auth-email-password/feature.ts",
|
|
@@ -95,7 +95,7 @@ export function extractExposesApi(
|
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function
|
|
98
|
+
export function extractStoreTable(
|
|
99
99
|
call: CallExpression,
|
|
100
100
|
sourceFile: SourceFile,
|
|
101
101
|
): ExtractOutput<never> {
|
|
@@ -104,8 +104,8 @@ export function extractRawTable(
|
|
|
104
104
|
// so there is nothing to extract statically. A clean ParseError (not
|
|
105
105
|
// UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
|
|
106
106
|
return fail(
|
|
107
|
-
"
|
|
107
|
+
"storeTable",
|
|
108
108
|
sourceLocationFromNode(call, sourceFile),
|
|
109
|
-
"
|
|
109
|
+
"storeTable meta is a factory/identifier argument, not a static literal",
|
|
110
110
|
);
|
|
111
111
|
}
|
|
@@ -51,13 +51,13 @@ import {
|
|
|
51
51
|
extractOptionalRequires,
|
|
52
52
|
extractProjection,
|
|
53
53
|
extractQueryHandler,
|
|
54
|
-
extractRawTable,
|
|
55
54
|
extractReadsConfig,
|
|
56
55
|
extractReferenceData,
|
|
57
56
|
extractRelation,
|
|
58
57
|
extractRequires,
|
|
59
58
|
extractScreen,
|
|
60
59
|
extractSecret,
|
|
60
|
+
extractStoreTable,
|
|
61
61
|
extractSystemScope,
|
|
62
62
|
extractToggleable,
|
|
63
63
|
extractTranslations,
|
|
@@ -468,8 +468,8 @@ function dispatchExtractor(
|
|
|
468
468
|
return extractUsesApi(call, sourceFile);
|
|
469
469
|
case "exposesApi":
|
|
470
470
|
return extractExposesApi(call, sourceFile);
|
|
471
|
-
case "
|
|
472
|
-
return
|
|
471
|
+
case "storeTable":
|
|
472
|
+
return extractStoreTable(call, sourceFile);
|
|
473
473
|
// Round 6 — Tree-Actions pattern
|
|
474
474
|
case "treeActions":
|
|
475
475
|
return extractTreeActions(call, sourceFile);
|
|
@@ -22,13 +22,13 @@ import type {
|
|
|
22
22
|
PreDeleteHookFn,
|
|
23
23
|
ProjectionDefinition,
|
|
24
24
|
QueryHandlerDef,
|
|
25
|
-
RawTableEntry,
|
|
26
25
|
ReferenceDataDef,
|
|
27
26
|
RegistrarExtensionDef,
|
|
28
27
|
RegistrarExtensionRegistration,
|
|
29
28
|
RelationDefinition,
|
|
30
29
|
SearchPayloadContributorFn,
|
|
31
30
|
SecretKeyDefinition,
|
|
31
|
+
StoreTableEntry,
|
|
32
32
|
TranslationKeys,
|
|
33
33
|
TreeActionDef,
|
|
34
34
|
UiHints,
|
|
@@ -86,7 +86,7 @@ export type FeatureBuilderState = {
|
|
|
86
86
|
projections: Record<string, ProjectionDefinition>;
|
|
87
87
|
multiStreamProjections: Record<string, MultiStreamProjectionDefinition>;
|
|
88
88
|
entityProjectionExtensions: Record<string, EntityProjectionExtension[]>;
|
|
89
|
-
|
|
89
|
+
storeTables: Record<string, StoreTableEntry>;
|
|
90
90
|
authClaimsHooks: AuthClaimsFn[];
|
|
91
91
|
claimKeys: Record<string, ClaimKeyDefinition>;
|
|
92
92
|
screens: Record<string, ScreenDefinition>;
|
|
@@ -144,7 +144,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
|
|
|
144
144
|
projections: {},
|
|
145
145
|
multiStreamProjections: {},
|
|
146
146
|
entityProjectionExtensions: {},
|
|
147
|
-
|
|
147
|
+
storeTables: {},
|
|
148
148
|
authClaimsHooks: [],
|
|
149
149
|
claimKeys: {},
|
|
150
150
|
screens: {},
|
|
@@ -14,9 +14,9 @@ import type {
|
|
|
14
14
|
PostSaveHookFn,
|
|
15
15
|
PreDeleteHookFn,
|
|
16
16
|
ProjectionDefinition,
|
|
17
|
-
RawTableOptions,
|
|
18
17
|
RegistrarExtensionDef,
|
|
19
18
|
SearchPayloadContributorFn,
|
|
19
|
+
StoreTableOptions,
|
|
20
20
|
TreeActionDef,
|
|
21
21
|
TreeActionsHandle,
|
|
22
22
|
ValidationHookFn,
|
|
@@ -89,6 +89,28 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
89
89
|
state: FeatureBuilderState,
|
|
90
90
|
name: TName,
|
|
91
91
|
) {
|
|
92
|
+
// Shared by r.nav() and r.screen()'s inline nav-sugar path — one place
|
|
93
|
+
// for id-validation + collision checks so future registration-time
|
|
94
|
+
// checks (e.g. parent-format, reserved ids) apply to both call sites
|
|
95
|
+
// instead of only the standalone r.nav() one.
|
|
96
|
+
function registerNav(navDefinition: NavDefinition): void {
|
|
97
|
+
if (!isKebabSegment(navDefinition.id)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`[Feature ${name}] Nav id "${navDefinition.id}" must be kebab-case ` +
|
|
100
|
+
`(lowercase letters, digits, dashes; start with a letter). ` +
|
|
101
|
+
`Got "${navDefinition.id}" — try "${toKebab(navDefinition.id).replace(/_/g, "-")}".`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (state.navs[navDefinition.id]) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`[Feature ${name}] Nav entry "${navDefinition.id}" already registered. ` +
|
|
107
|
+
`Nav ids must be unique per feature — remove the standalone ` +
|
|
108
|
+
`r.nav("${navDefinition.id}", ...) call or the screen's inline nav.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
state.navs[navDefinition.id] = navDefinition;
|
|
112
|
+
}
|
|
113
|
+
|
|
92
114
|
return {
|
|
93
115
|
hook(
|
|
94
116
|
type: LifecycleHookType | "validation",
|
|
@@ -334,42 +356,18 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
334
356
|
// Sugar for the common "one nav entry pointing at this screen"
|
|
335
357
|
// case — synthesizes id/screen from the screen's own id. Beyond
|
|
336
358
|
// label/icon/parent/order, declare a standalone r.nav() instead.
|
|
337
|
-
|
|
338
|
-
throw new Error(
|
|
339
|
-
`[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
|
|
340
|
-
`Nav ids must be unique per feature — remove the standalone ` +
|
|
341
|
-
`r.nav("${definition.id}", ...) call or the screen's inline nav.`,
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
const navDefinition: NavDefinition = {
|
|
359
|
+
registerNav({
|
|
345
360
|
id: definition.id,
|
|
346
361
|
label: definition.nav.label,
|
|
347
362
|
icon: definition.nav.icon,
|
|
348
363
|
parent: definition.nav.parent,
|
|
349
364
|
order: definition.nav.order,
|
|
350
365
|
screen: `${name}:screen:${definition.id}`,
|
|
351
|
-
};
|
|
352
|
-
state.navs[definition.id] = navDefinition;
|
|
366
|
+
});
|
|
353
367
|
}
|
|
354
368
|
},
|
|
355
369
|
nav(definition: NavDefinition): void {
|
|
356
|
-
|
|
357
|
-
// the feature file, not at registry-boot. Same guard pattern as
|
|
358
|
-
// r.projection / r.multiStreamProjection / r.screen.
|
|
359
|
-
if (!isKebabSegment(definition.id)) {
|
|
360
|
-
throw new Error(
|
|
361
|
-
`[Feature ${name}] Nav id "${definition.id}" must be kebab-case ` +
|
|
362
|
-
`(lowercase letters, digits, dashes; start with a letter). ` +
|
|
363
|
-
`Got "${definition.id}" — try "${toKebab(definition.id).replace(/_/g, "-")}".`,
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
if (state.navs[definition.id]) {
|
|
367
|
-
throw new Error(
|
|
368
|
-
`[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
|
|
369
|
-
`Nav ids must be unique per feature.`,
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
state.navs[definition.id] = definition;
|
|
370
|
+
registerNav(definition);
|
|
373
371
|
},
|
|
374
372
|
workspace(definition: WorkspaceDefinition): void {
|
|
375
373
|
// Same kebab guard as r.screen / r.nav so authoring-time mistakes
|
|
@@ -416,7 +414,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
416
414
|
}
|
|
417
415
|
state.httpRoutes[key] = definition;
|
|
418
416
|
},
|
|
419
|
-
|
|
417
|
+
storeTable(meta: EntityTableMeta, options: StoreTableOptions): void {
|
|
420
418
|
// Name comes from the meta itself — apps already give the table a
|
|
421
419
|
// name when calling defineUnmanagedTable, no need to repeat it.
|
|
422
420
|
const tableName = meta.tableName;
|
|
@@ -428,23 +426,46 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
428
426
|
`valid identifier (lowercase letters, digits, underscores; start with a letter).`,
|
|
429
427
|
);
|
|
430
428
|
}
|
|
431
|
-
if (state.
|
|
429
|
+
if (state.storeTables[tableName]) {
|
|
432
430
|
throw new Error(
|
|
433
|
-
`[Feature ${name}] r.
|
|
431
|
+
`[Feature ${name}] r.storeTable("${tableName}") already registered. ` +
|
|
434
432
|
`Raw-table names must be unique per feature.`,
|
|
435
433
|
);
|
|
436
434
|
}
|
|
435
|
+
// `read_` is reserved for r.entity()/r.projection() (managed,
|
|
436
|
+
// event-sourced, rebuildable). storeTable is the unmanaged
|
|
437
|
+
// direct-write escape hatch — the prefix must say so (#1220).
|
|
438
|
+
if (tableName.startsWith("read_")) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`[Feature ${name}] r.storeTable("${tableName}"): the "read_" prefix is reserved ` +
|
|
441
|
+
`for managed r.entity()/r.projection() tables. Pick an unprefixed name or a ` +
|
|
442
|
+
`distinct prefix (e.g. "store_${tableName.slice("read_".length)}").`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
// meta.source must agree with the r.storeTable() escape hatch, or the
|
|
446
|
+
// migrate-generator treats schema drift on this table as safe to
|
|
447
|
+
// DROP+rebuild-from-events — wiping direct-write data with no events
|
|
448
|
+
// to replay it from (#1209).
|
|
449
|
+
if (meta.source !== "unmanaged") {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`[Feature ${name}] r.storeTable("${tableName}") was given an EntityTableMeta with ` +
|
|
452
|
+
`source: "${meta.source}". r.storeTable() requires source: "unmanaged" (via ` +
|
|
453
|
+
`defineUnmanagedTable(), or buildEntityTableMeta(..., { source: "unmanaged" })) — ` +
|
|
454
|
+
`otherwise the migration generator will treat schema drift on this table as safe ` +
|
|
455
|
+
`to DROP+rebuild, wiping any direct-write data.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
437
458
|
// The `reason` is the marker that justifies the bypass — empty
|
|
438
459
|
// strings would defeat the audit trail. Reject early so the
|
|
439
460
|
// failure points at the feature file.
|
|
440
461
|
if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
|
|
441
462
|
throw new Error(
|
|
442
|
-
`[Feature ${name}] r.
|
|
463
|
+
`[Feature ${name}] r.storeTable("${tableName}"): options.reason must be a ` +
|
|
443
464
|
`non-empty string. The reason justifies the audit-trail bypass — ` +
|
|
444
465
|
`if you can't write one, declare data via r.entity() instead.`,
|
|
445
466
|
);
|
|
446
467
|
}
|
|
447
|
-
state.
|
|
468
|
+
state.storeTables[tableName] = {
|
|
448
469
|
name: tableName,
|
|
449
470
|
meta,
|
|
450
471
|
reason: options.reason,
|
|
@@ -22,7 +22,6 @@ import type {
|
|
|
22
22
|
PreSaveHookFn,
|
|
23
23
|
ProjectionDefinition,
|
|
24
24
|
QueryHandlerDef,
|
|
25
|
-
RawTableDef,
|
|
26
25
|
ReferenceDataDef,
|
|
27
26
|
RegistrarExtensionDef,
|
|
28
27
|
RegistrarExtensionRegistration,
|
|
@@ -30,6 +29,7 @@ import type {
|
|
|
30
29
|
ScreenDefinition,
|
|
31
30
|
SearchPayloadContributorFn,
|
|
32
31
|
SecretKeyDefinition,
|
|
32
|
+
StoreTableDef,
|
|
33
33
|
TranslationKeys,
|
|
34
34
|
TreeActionDef,
|
|
35
35
|
WorkspaceDefinition,
|
|
@@ -266,8 +266,8 @@ export function buildRegistryFacade(state: RegistryState): Registry {
|
|
|
266
266
|
return state.projectionMap;
|
|
267
267
|
},
|
|
268
268
|
|
|
269
|
-
|
|
270
|
-
return state.
|
|
269
|
+
getAllStoreTables(): ReadonlyMap<string, StoreTableDef> {
|
|
270
|
+
return state.storeTableMap;
|
|
271
271
|
},
|
|
272
272
|
|
|
273
273
|
getAllMultiStreamProjections(): ReadonlyMap<string, MultiStreamProjectionDefinition> {
|