@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.
Files changed (32) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/{raw-table.integration.test.ts → store-table.integration.test.ts} +10 -10
  3. package/src/api/__tests__/batch.integration.test.ts +68 -0
  4. package/src/db/__tests__/collect-table-metas.test.ts +6 -6
  5. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  6. package/src/db/__tests__/feature-table-sources.test.ts +7 -7
  7. package/src/db/collect-table-metas.ts +4 -4
  8. package/src/db/entity-table-meta.ts +5 -4
  9. package/src/db/feature-table-sources.ts +3 -3
  10. package/src/db/queries/shadow-swap.ts +1 -1
  11. package/src/engine/__tests__/boot-validator.test.ts +96 -0
  12. package/src/engine/__tests__/{raw-table.test.ts → store-table.test.ts} +59 -42
  13. package/src/engine/boot-validator/action-wiring.ts +67 -17
  14. package/src/engine/define-feature.ts +1 -1
  15. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  16. package/src/engine/feature-ast/extractors/index.ts +1 -1
  17. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  18. package/src/engine/feature-ast/parse.ts +3 -3
  19. package/src/engine/feature-builder-state.ts +3 -3
  20. package/src/engine/feature-ui-extensions.ts +54 -33
  21. package/src/engine/registry-facade.ts +3 -3
  22. package/src/engine/registry-ingest.ts +6 -6
  23. package/src/engine/registry-state.ts +3 -3
  24. package/src/engine/types/feature.ts +17 -17
  25. package/src/engine/types/index.ts +3 -3
  26. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  27. package/src/files/file-ref-entity.ts +2 -2
  28. package/src/migrations/pending-rebuilds.ts +1 -1
  29. package/src/search/__tests__/reindex-entity.integration.test.ts +121 -0
  30. package/src/search/index.ts +6 -0
  31. package/src/search/reindex-entity.ts +148 -0
  32. package/src/stack/push-entity-projection-tables.ts +2 -2
@@ -1,9 +1,9 @@
1
- // Unit tests for r.rawTable() — declaration-time validation + registry
2
- // aggregation. Post-drizzle-cut merge of the former r.rawTable() (legacy
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__/raw-table.integration.test.ts.
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.rawTable — declaration", () => {
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.rawTable(badMeta, { reason: "test" });
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.rawTable(probeMeta, { reason: "test" });
44
- r.rawTable(probeMeta, { reason: "test" });
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.rawTable(probeMeta, { reason: "" });
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.rawTable(probeMeta, { reason: " " });
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.rawTable(probeMeta, {
80
+ r.storeTable(probeMeta, {
68
81
  reason: "read-side projection of an event-stream",
69
82
  });
70
83
  });
71
- expect(feature.rawTables).toHaveProperty("rt_probe");
72
- expect(feature.rawTables["rt_probe"]?.reason).toBe("read-side projection of an event-stream");
73
- expect(feature.rawTables["rt_probe"]?.meta).toBe(probeMeta);
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 raw tables on one feature register under their tableName", () => {
89
+ test("two store tables on one feature register under their tableName", () => {
77
90
  const feature = defineFeature("dual", (r) => {
78
- r.rawTable(probeMeta, { reason: "one" });
79
- r.rawTable(probeMetaTwo, { reason: "two" });
91
+ r.storeTable(probeMeta, { reason: "one" });
92
+ r.storeTable(probeMetaTwo, { reason: "two" });
80
93
  });
81
- expect(Object.keys(feature.rawTables).sort()).toEqual(["rt_probe", "rt_probe_two"]);
94
+ expect(Object.keys(feature.storeTables).sort()).toEqual(["rt_probe", "rt_probe_two"]);
82
95
  });
83
96
 
84
- test("absent rawTables on a feature is ok", () => {
97
+ test("absent storeTables on a feature is ok", () => {
85
98
  const feat = defineFeature("plain", () => {
86
- // no r.rawTable calls
99
+ // no r.storeTable calls
87
100
  });
88
- expect(feat.rawTables).toEqual({});
101
+ expect(feat.storeTables).toEqual({});
89
102
  });
90
103
  });
91
104
 
92
- describe("createRegistry — rawTable aggregation", () => {
93
- test("aggregates raw tables across features and tags featureName", () => {
105
+ describe("createRegistry — storeTable aggregation", () => {
106
+ test("aggregates store tables across features and tags featureName", () => {
94
107
  const featA = defineFeature("billing", (r) => {
95
- r.rawTable(probeMeta, { reason: "external API cache" });
108
+ r.storeTable(probeMeta, { reason: "external API cache" });
96
109
  });
97
110
  const featB = defineFeature("inventory", (r) => {
98
- r.rawTable(probeMetaTwo, { reason: "imported pre-ES" });
111
+ r.storeTable(probeMetaTwo, { reason: "imported pre-ES" });
99
112
  });
100
113
 
101
114
  const registry = createRegistry([featA, featB]);
102
- const all = registry.getAllRawTables();
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.rawTable(probeMeta, { reason: "first" });
127
+ r.storeTable(probeMeta, { reason: "first" });
115
128
  });
116
129
  const featB = defineFeature("b", (r) => {
117
- r.rawTable(probeMeta, { reason: "second" });
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.rawTable(probeMeta, { reason: "first" });
139
+ r.storeTable(probeMeta, { reason: "first" });
127
140
  });
128
141
  const featB = defineFeature("b", (r) => {
129
- r.rawTable(probeMetaTwo, { reason: "second" });
142
+ r.storeTable(probeMetaTwo, { reason: "second" });
130
143
  });
131
144
  expect(() => createRegistry([featA, featB])).not.toThrow();
132
145
  });
133
146
 
134
- test("rejects a raw-table that collides with an entity's physical name", () => {
135
- const widget = createEntity({ fields: { name: createTextField() } });
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.rawTable(clashing, { reason: "clash" });
163
+ r.storeTable(clashing, { reason: "clash" });
147
164
  });
148
165
 
149
- // Entity registered first, then the colliding raw table.
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: raw table registered first, then the entity.
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.rawTable\\("${physical}"\\)`),
173
+ new RegExp(`Entity "widget".*collides with r.storeTable\\("${physical}"\\)`),
157
174
  );
158
175
  });
159
176
  });
160
177
 
161
- describe("createRegistry — raw tables with PII-annotated fields (#820)", () => {
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 raw table without piiEncryptedOnWrite", () => {
192
+ test("rejects a PII-carrying store table without piiEncryptedOnWrite", () => {
176
193
  const feat = defineFeature("probe", (r) => {
177
- r.rawTable(piiMeta, { reason: "direct-write store" });
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.rawTable(piiMeta, {
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 raw table needs no declaration", () => {
211
+ test("a PII-free store table needs no declaration", () => {
195
212
  const feat = defineFeature("probe", (r) => {
196
- r.rawTable(probeMeta, { reason: "plain store" });
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 { EditFieldSpec, FeatureDefinition, RowAction, ToolbarAction } from "../types";
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}" (entityEdit) field "${normalized.field}" ` +
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}" (entityEdit) field "${normalized.field}" ` +
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
- for (const col of screen.columns) {
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 (screen.type !== "entityEdit") continue;
92
- for (const section of screen.layout.sections) {
93
- if (isExtensionEditSection(section)) continue;
94
- for (const fieldSpec of section.fields) {
95
- validateEditFieldNoFunctions(feature.name, screen.id, fieldSpec);
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
- rawTables: state.rawTables,
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: ["rawTable", "job"],
72
+ errorMethodNames: ["storeTable", "job"],
73
73
  },
74
74
  {
75
75
  path: "packages/bundled-features/src/auth-email-password/feature.ts",
@@ -60,7 +60,7 @@ export {
60
60
  extractEnvSchema,
61
61
  extractExposesApi,
62
62
  extractExtendsRegistrar,
63
- extractRawTable,
63
+ extractStoreTable,
64
64
  extractUsesApi,
65
65
  } from "./round5";
66
66
  export { extractTreeActions } from "./round6";
@@ -95,7 +95,7 @@ export function extractExposesApi(
95
95
  });
96
96
  }
97
97
 
98
- export function extractRawTable(
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
- "rawTable",
107
+ "storeTable",
108
108
  sourceLocationFromNode(call, sourceFile),
109
- "rawTable meta is a factory/identifier argument, not a static literal",
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 "rawTable":
472
- return extractRawTable(call, sourceFile);
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
- rawTables: Record<string, RawTableEntry>;
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
- rawTables: {},
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
- if (state.navs[definition.id]) {
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
- // Reject kebab-drift at registration-time so the stack trace points at
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
- rawTable(meta: EntityTableMeta, options: RawTableOptions): void {
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.rawTables[tableName]) {
429
+ if (state.storeTables[tableName]) {
432
430
  throw new Error(
433
- `[Feature ${name}] r.rawTable("${tableName}") already registered. ` +
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.rawTable("${tableName}"): options.reason must be a ` +
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.rawTables[tableName] = {
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
- getAllRawTables(): ReadonlyMap<string, RawTableDef> {
270
- return state.rawTableMap;
269
+ getAllStoreTables(): ReadonlyMap<string, StoreTableDef> {
270
+ return state.storeTableMap;
271
271
  },
272
272
 
273
273
  getAllMultiStreamProjections(): ReadonlyMap<string, MultiStreamProjectionDefinition> {