@cosmicdrift/kumiko-framework 0.146.3 → 0.147.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 (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. package/src/engine/deep-link.ts +0 -23
@@ -47,6 +47,25 @@ export function readBooleanProperty(
47
47
  return undefined;
48
48
  }
49
49
 
50
+ /**
51
+ * Marks a value the parser could not resolve into a literal (an Identifier
52
+ * reference, a call expression, ...) while preserving its exact source text.
53
+ * `renderValue` re-emits `__raw` verbatim instead of re-serializing the
54
+ * value, so round-tripping a reference like `eventEntity` never expands it
55
+ * into an inlined object literal.
56
+ */
57
+ export type RawRefSentinel = { readonly __raw: string };
58
+
59
+ export function isRawRefSentinel(value: unknown): value is RawRefSentinel {
60
+ return (
61
+ value !== null &&
62
+ typeof value === "object" &&
63
+ !Array.isArray(value) &&
64
+ "__raw" in value &&
65
+ typeof (value as { __raw: unknown }).__raw === "string"
66
+ );
67
+ }
68
+
50
69
  export function readDataLiteralNode(node: Node): unknown {
51
70
  const kind = node.getKind();
52
71
  switch (kind) {
@@ -103,11 +122,50 @@ export function readDataLiteralNode(node: Node): unknown {
103
122
  return readDataLiteralNode(
104
123
  node.asKindOrThrow(SyntaxKind.ParenthesizedExpression).getExpression(),
105
124
  );
125
+ case SyntaxKind.Identifier:
126
+ case SyntaxKind.CallExpression:
127
+ case SyntaxKind.PropertyAccessExpression:
128
+ // Unresolvable reference (const identifier, factory call, member access).
129
+ // Keep the exact source text so renderValue can re-emit it verbatim —
130
+ // resolving/inlining it would silently rewrite the source on the next
131
+ // parse->render roundtrip (see readDataLiteralNode doc above).
132
+ return { __raw: node.getText() } satisfies RawRefSentinel;
106
133
  default:
107
134
  return undefined;
108
135
  }
109
136
  }
110
137
 
138
+ /**
139
+ * A node's literal string value, or the raw-ref sentinel when it resolves to
140
+ * an unresolvable identifier/factory-call/member-access (see
141
+ * readDataLiteralNode). undefined for anything else (number, boolean, ...).
142
+ */
143
+ export function readStringOrRaw(node: Node): string | RawRefSentinel | undefined {
144
+ const value = readDataLiteralNode(node);
145
+ if (typeof value === "string") return value;
146
+ if (isRawRefSentinel(value)) return value;
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Like readStringLiteralArgs, but an argument that resolves to a raw-ref
152
+ * sentinel is kept as that sentinel instead of failing the whole call. Used
153
+ * where a value stands in for a single name/key and inlining its resolved
154
+ * string would corrupt the render roundtrip (e.g. `r.requires(someFeature.name)`
155
+ * — see #1009).
156
+ */
157
+ export function readStringOrRawArgs(
158
+ call: CallExpression,
159
+ ): readonly (string | RawRefSentinel)[] | undefined {
160
+ const out: (string | RawRefSentinel)[] = [];
161
+ for (const arg of call.getArguments()) {
162
+ const value = readStringOrRaw(arg);
163
+ if (value === undefined) return undefined;
164
+ out.push(value);
165
+ }
166
+ return out;
167
+ }
168
+
111
169
  export { isPlainObject } from "../../../utils/is-plain-object";
112
170
 
113
171
  export function readPropertyKey(propAssign: import("ts-morph").PropertyAssignment): string {
@@ -150,7 +208,7 @@ export function readNameOrRefOrList(node: Node): string | readonly string[] | un
150
208
  export function readVarargsOrArrayProp(
151
209
  call: CallExpression,
152
210
  arrayPropName: "features" | "keys",
153
- ): readonly string[] | undefined {
211
+ ): readonly (string | RawRefSentinel)[] | undefined {
154
212
  const args = call.getArguments();
155
213
  if (args.length === 1) {
156
214
  const obj = args[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
@@ -162,15 +220,15 @@ export function readVarargsOrArrayProp(
162
220
  if (propInit) {
163
221
  const arr = propInit.asKind(SyntaxKind.ArrayLiteralExpression);
164
222
  if (!arr) return undefined;
165
- const out: string[] = [];
223
+ const out: (string | RawRefSentinel)[] = [];
166
224
  for (const el of arr.getElements()) {
167
- const lit = el.asKind(SyntaxKind.StringLiteral);
168
- if (!lit) return undefined;
169
- out.push(lit.getLiteralValue());
225
+ const value = readStringOrRaw(el);
226
+ if (value === undefined) return undefined;
227
+ out.push(value);
170
228
  }
171
229
  return out;
172
230
  }
173
231
  }
174
232
  }
175
- return readStringLiteralArgs(call);
233
+ return readStringOrRawArgs(call);
176
234
  }
@@ -21,7 +21,13 @@
21
21
  // Per-pattern extractors fill in iteratively (C1.5) — each round adds
22
22
  // one extractor + a focused test.
23
23
 
24
- import type { ArrowFunction, CallExpression, ParameterDeclaration, SourceFile } from "ts-morph";
24
+ import type {
25
+ ArrowFunction,
26
+ CallExpression,
27
+ Node,
28
+ ParameterDeclaration,
29
+ SourceFile,
30
+ } from "ts-morph";
25
31
  import { Project, SyntaxKind } from "ts-morph";
26
32
 
27
33
  import {
@@ -63,6 +69,7 @@ import {
63
69
  extractUsesApi,
64
70
  extractWorkspace,
65
71
  extractWriteHandler,
72
+ findFunctionLiteral,
66
73
  } from "./extractors";
67
74
  import type { FeaturePattern, UnknownPattern } from "./patterns";
68
75
  import { type SourceLocation, sourceLocationFromNode } from "./source-location";
@@ -145,7 +152,7 @@ export function parseSourceFile(sourceFile: SourceFile): ParseResult {
145
152
  const patterns: FeaturePattern[] = [];
146
153
  const errors: ParseError[] = [];
147
154
 
148
- walkSetupCallback(setupCallback, registrarParamName, sourceFile, patterns, errors);
155
+ walkSetupCallback(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
149
156
 
150
157
  return { featureName, patterns, errors };
151
158
  }
@@ -228,25 +235,131 @@ function extractRegistrarParamName(setup: ArrowFunction): string | undefined {
228
235
  * structurally impossible.
229
236
  */
230
237
  function walkSetupCallback(
231
- setup: ArrowFunction,
238
+ body: Node,
232
239
  registrarParamName: string,
233
240
  sourceFile: SourceFile,
234
241
  patterns: FeaturePattern[],
235
242
  errors: ParseError[],
243
+ visitedWrapperDecls: Set<Node> = new Set(),
236
244
  ): void {
237
- const body = setup.getBody();
238
245
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
239
246
  const methodName = extractRegistrarMethodName(call, registrarParamName);
240
- if (!methodName) continue; // Not a registrar call — ignore.
241
- const result = dispatchExtractor(methodName, call, sourceFile);
242
- if (result.kind === "pattern") {
243
- patterns.push(result.pattern);
244
- } else {
245
- errors.push(result.error);
247
+ if (methodName) {
248
+ const result = dispatchExtractor(methodName, call, sourceFile);
249
+ if (result.kind === "pattern") {
250
+ patterns.push(result.pattern);
251
+ } else {
252
+ errors.push(result.error);
253
+ }
254
+ continue;
246
255
  }
256
+
257
+ // Not a direct `<registrarParam>.<method>(...)` call. Check whether it's
258
+ // a registrar-wrapper call — a function that receives the registrar as
259
+ // a bare argument, e.g. `registerShowPonyScreens(r)`. Such wrappers are
260
+ // invisible to a receiver-shape match, so we resolve the callee (same
261
+ // file, or the declaring file of a named import — #1008) and recurse
262
+ // into its body with its own parameter name for the registrar slot.
263
+ const wrapper = resolveRegistrarWrapperCall(call, registrarParamName);
264
+ if (!wrapper) continue; // Free function call unrelated to the registrar — ignore.
265
+ if (visitedWrapperDecls.has(wrapper.declNode)) continue; // Cycle guard.
266
+ visitedWrapperDecls.add(wrapper.declNode);
267
+ // wrapper.sourceFile, not the outer `sourceFile` param: a cross-file
268
+ // wrapper's body lives in a different file, and sourceLocationFromNode
269
+ // uses whichever SourceFile it's given for both the `file` path and
270
+ // the line/column table — passing the wrong one silently corrupts
271
+ // both for every pattern found inside the wrapper.
272
+ walkSetupCallback(
273
+ wrapper.body,
274
+ wrapper.paramName,
275
+ wrapper.sourceFile,
276
+ patterns,
277
+ errors,
278
+ visitedWrapperDecls,
279
+ );
247
280
  }
248
281
  }
249
282
 
283
+ /**
284
+ * Resolves a bare call like `registerShowPonyScreens(r)` to the callee's
285
+ * body + its own parameter name for the registrar slot. The callee may be
286
+ * declared in the same file (function declaration, or a const initialized
287
+ * with an arrow/function expression), or imported by name from another
288
+ * file — resolved via the module specifier's SourceFile (#1008; only
289
+ * works against a real-filesystem Project, e.g. parseFeatureFile's. The
290
+ * in-memory Designer Project has no files to resolve against and
291
+ * getModuleSpecifierSourceFile() returns undefined there, same as any
292
+ * other unresolvable reference).
293
+ */
294
+ function resolveRegistrarWrapperCall(
295
+ call: CallExpression,
296
+ registrarParamName: string,
297
+ ):
298
+ | {
299
+ readonly body: Node;
300
+ readonly paramName: string;
301
+ readonly declNode: Node;
302
+ readonly sourceFile: SourceFile;
303
+ }
304
+ | undefined {
305
+ const callee = call.getExpression().asKind(SyntaxKind.Identifier);
306
+ if (!callee) return undefined;
307
+
308
+ const args = call.getArguments();
309
+ const argIndex = args.findIndex((arg) => arg.getText() === registrarParamName);
310
+ if (argIndex === -1) return undefined; // Registrar not passed to this call.
311
+
312
+ const callSourceFile = call.getSourceFile();
313
+ const name = callee.getText();
314
+
315
+ const local = resolveWrapperDeclarationInFile(callSourceFile, name, argIndex);
316
+ if (local) return { ...local, sourceFile: callSourceFile };
317
+
318
+ const importDecl = callSourceFile
319
+ .getImportDeclarations()
320
+ .find((d) =>
321
+ d.getNamedImports().some((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === name),
322
+ );
323
+ if (!importDecl) return undefined;
324
+ const exportedName =
325
+ importDecl
326
+ .getNamedImports()
327
+ .find((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === name)
328
+ ?.getName() ?? name;
329
+ const targetFile = importDecl.getModuleSpecifierSourceFile();
330
+ if (!targetFile) return undefined; // Unresolvable: in-memory project, external package, or missing file.
331
+
332
+ const imported = resolveWrapperDeclarationInFile(targetFile, exportedName, argIndex);
333
+ return imported ? { ...imported, sourceFile: targetFile } : undefined;
334
+ }
335
+
336
+ function resolveWrapperDeclarationInFile(
337
+ sourceFile: SourceFile,
338
+ name: string,
339
+ argIndex: number,
340
+ ): { readonly body: Node; readonly paramName: string; readonly declNode: Node } | undefined {
341
+ const fnDecl = sourceFile.getFunction(name);
342
+ if (fnDecl) {
343
+ const param = fnDecl.getParameters()[argIndex];
344
+ const body = fnDecl.getBody();
345
+ if (!param || !body) return undefined;
346
+ return { body, paramName: param.getName(), declNode: fnDecl };
347
+ }
348
+
349
+ const varDecl = sourceFile.getVariableDeclaration(name);
350
+ const init = varDecl?.getInitializer();
351
+ const literal = init ? findFunctionLiteral(init) : undefined;
352
+ const fnLiteral =
353
+ literal?.asKind(SyntaxKind.ArrowFunction) ?? literal?.asKind(SyntaxKind.FunctionExpression);
354
+ if (fnLiteral) {
355
+ const param = fnLiteral.getParameters()[argIndex];
356
+ if (!param) return undefined;
357
+ return { body: fnLiteral.getBody(), paramName: param.getName(), declNode: fnLiteral };
358
+ }
359
+
360
+ return undefined;
361
+ }
362
+
250
363
  /**
251
364
  * Returns the method name when the call has the form
252
365
  * `<registrarParamName>.<method>(...)`, otherwise undefined.
@@ -341,14 +341,20 @@ export type ScreenPattern = {
341
341
  export type WriteHandlerPattern = {
342
342
  readonly kind: "writeHandler";
343
343
  readonly source: SourceLocation;
344
- readonly handlerName: string;
344
+ // handlerName/schemaSource/handlerBody are set together, or all left
345
+ // undefined for a single reference standing in for the whole handler
346
+ // (`r.writeHandler(handlerRef)`) that couldn't be resolved to an object
347
+ // literal — see #1007. An opaque pattern re-emits `source.raw` verbatim
348
+ // and, like UnknownPattern, has no PatternId variant (not individually
349
+ // patchable).
350
+ readonly handlerName?: string;
345
351
  // schemaSource: the Zod call as source text (e.g. "z.object({...})").
346
352
  // We keep it as an opaque block instead of decoding it back to JSON
347
353
  // schema — Zod's full surface is too rich for a faithful round-trip.
348
- readonly schemaSource: SourceLocation;
354
+ readonly schemaSource?: SourceLocation;
349
355
  // handlerBody: the closure body as source text. Always opaque — AI
350
356
  // generates raw TypeScript, no DSL interpretation.
351
- readonly handlerBody: SourceLocation;
357
+ readonly handlerBody?: SourceLocation;
352
358
  readonly access?: AccessRule;
353
359
  readonly rateLimit?: RateLimitOption;
354
360
  readonly unsafeSkipTransitionGuard?: boolean;
@@ -356,13 +362,14 @@ export type WriteHandlerPattern = {
356
362
 
357
363
  // `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
358
364
  // handler closure, plus optional `access` and `rateLimit` rules. Read-side
359
- // counterpart of `r.writeHandler` with the same header/body split.
365
+ // counterpart of `r.writeHandler` with the same header/body split. Opaque
366
+ // (no handlerName) for the same single-reference case as WriteHandlerPattern.
360
367
  export type QueryHandlerPattern = {
361
368
  readonly kind: "queryHandler";
362
369
  readonly source: SourceLocation;
363
- readonly handlerName: string;
364
- readonly schemaSource: SourceLocation;
365
- readonly handlerBody: SourceLocation;
370
+ readonly handlerName?: string;
371
+ readonly schemaSource?: SourceLocation;
372
+ readonly handlerBody?: SourceLocation;
366
373
  readonly access?: AccessRule;
367
374
  readonly rateLimit?: RateLimitOption;
368
375
  };
@@ -16,6 +16,7 @@
16
16
  // `// kumiko-feature-version: 1`. Future format bumps run a dedicated
17
17
  // migrator over the version comment.
18
18
 
19
+ import { isRawRefSentinel } from "./extractors/shared";
19
20
  import type {
20
21
  AuthClaimsPattern,
21
22
  ClaimKeyPattern,
@@ -174,6 +175,7 @@ const SINGLE_LINE_WIDTH = 80;
174
175
  * multi-line otherwise — biome-stable in both branches.
175
176
  */
176
177
  export function renderValue(value: unknown, indent = 0): string {
178
+ if (isRawRefSentinel(value)) return value.__raw;
177
179
  if (value === null) return "null";
178
180
  if (typeof value === "string") return JSON.stringify(value);
179
181
  if (typeof value === "number" || typeof value === "boolean") return String(value);
@@ -240,6 +242,13 @@ function renderDescribe(p: DescribePattern): string {
240
242
  }
241
243
 
242
244
  function renderEntity(p: EntityPattern): string {
245
+ // A whole-value raw-ref (`r.entity("x", eventEntity)`) can't be spread
246
+ // into the merged Object-Form without losing `name` to the sentinel
247
+ // shortcut in renderValue — fall back to the classic positional form,
248
+ // which keeps the reference verbatim alongside the name.
249
+ if (isRawRefSentinel(p.definition)) {
250
+ return `r.entity(${JSON.stringify(p.entityName)}, ${p.definition.__raw});`;
251
+ }
243
252
  // Inline `name` into the definition object — canonical Object-Form
244
253
  // is a single arg with name-as-property.
245
254
  const merged = { name: p.entityName, ...p.definition };
@@ -247,6 +256,9 @@ function renderEntity(p: EntityPattern): string {
247
256
  }
248
257
 
249
258
  function renderRelation(p: RelationPattern): string {
259
+ if (isRawRefSentinel(p.definition)) {
260
+ return `r.relation(${JSON.stringify(p.entityName)}, ${JSON.stringify(p.relationName)}, ${p.definition.__raw});`;
261
+ }
250
262
  const merged = { entity: p.entityName, name: p.relationName, ...p.definition };
251
263
  return `r.relation(${renderValue(merged)});`;
252
264
  }
@@ -268,11 +280,17 @@ function renderTranslations(p: TranslationsPattern): string {
268
280
  }
269
281
 
270
282
  function renderMetric(p: MetricPattern): string {
283
+ if (isRawRefSentinel(p.options)) {
284
+ return `r.metric(${JSON.stringify(p.shortName)}, ${p.options.__raw});`;
285
+ }
271
286
  const merged = { name: p.shortName, ...p.options };
272
287
  return `r.metric(${renderValue(merged)});`;
273
288
  }
274
289
 
275
290
  function renderSecret(p: SecretPattern): string {
291
+ if (isRawRefSentinel(p.options)) {
292
+ return `r.secret(${JSON.stringify(p.shortName)}, ${p.options.__raw});`;
293
+ }
276
294
  const merged = { name: p.shortName, ...p.options };
277
295
  return `r.secret(${renderValue(merged)});`;
278
296
  }
@@ -291,6 +309,9 @@ function renderReferenceData(p: ReferenceDataPattern): string {
291
309
  }
292
310
 
293
311
  function renderUseExtension(p: UseExtensionPattern): string {
312
+ if (isRawRefSentinel(p.options)) {
313
+ return `r.useExtension(${JSON.stringify(p.extensionName)}, ${JSON.stringify(p.entityName)}, ${p.options.__raw});`;
314
+ }
294
315
  const merged: Record<string, unknown> = {
295
316
  name: p.extensionName,
296
317
  entity: p.entityName,
@@ -356,10 +377,11 @@ function reindentBody(raw: string, newIndent: string): string {
356
377
  }
357
378
 
358
379
  function renderWriteHandler(p: WriteHandlerPattern): string {
380
+ if (p.handlerName === undefined) return p.source.raw;
359
381
  const lines: string[] = ["r.writeHandler({"];
360
382
  lines.push(` name: ${JSON.stringify(p.handlerName)},`);
361
- lines.push(` schema: ${reindentBody(p.schemaSource.raw, PATTERN_INDENT)},`);
362
- lines.push(` handler: ${reindentBody(p.handlerBody.raw, PATTERN_INDENT)},`);
383
+ lines.push(` schema: ${reindentBody(p.schemaSource?.raw ?? "", PATTERN_INDENT)},`);
384
+ lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
363
385
  if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
364
386
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
365
387
  if (p.unsafeSkipTransitionGuard === true) lines.push(" unsafeSkipTransitionGuard: true,");
@@ -368,10 +390,11 @@ function renderWriteHandler(p: WriteHandlerPattern): string {
368
390
  }
369
391
 
370
392
  function renderQueryHandler(p: QueryHandlerPattern): string {
393
+ if (p.handlerName === undefined) return p.source.raw;
371
394
  const lines: string[] = ["r.queryHandler({"];
372
395
  lines.push(` name: ${JSON.stringify(p.handlerName)},`);
373
- lines.push(` schema: ${reindentBody(p.schemaSource.raw, PATTERN_INDENT)},`);
374
- lines.push(` handler: ${reindentBody(p.handlerBody.raw, PATTERN_INDENT)},`);
396
+ lines.push(` schema: ${reindentBody(p.schemaSource?.raw ?? "", PATTERN_INDENT)},`);
397
+ lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
375
398
  if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
376
399
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
377
400
  lines.push("});");
@@ -563,9 +586,7 @@ function weaveOpaque(
563
586
  }
564
587
 
565
588
  function renderValueWithRawSlots(value: WovenValue, indent: number): string {
566
- if (value !== null && typeof value === "object" && !Array.isArray(value) && "__raw" in value) {
567
- return (value as { __raw: string }).__raw;
568
- }
589
+ if (isRawRefSentinel(value)) return value.__raw;
569
590
  if (Array.isArray(value)) {
570
591
  if (value.length === 0) return "[]";
571
592
  const inner = value
@@ -0,0 +1,165 @@
1
+ import type { ZodType, z } from "zod";
2
+ import { LifecycleHookTypes } from "./constants";
3
+ import type {
4
+ AuthClaimsFn,
5
+ ClaimKeyDefinition,
6
+ ConfigKeyDefinition,
7
+ ConfigSeedDef,
8
+ EntityDefinition,
9
+ EntityProjectionExtension,
10
+ EventMigrationDef,
11
+ ExtensionSelectorDef,
12
+ FeatureMetricDef,
13
+ JobDefinition,
14
+ LifecycleHookFn,
15
+ MultiStreamProjectionDefinition,
16
+ NotificationDefinition,
17
+ OwnedFn,
18
+ PhasedHook,
19
+ PostDeleteHookFn,
20
+ PostQueryHookFn,
21
+ PostSaveHookFn,
22
+ PreDeleteHookFn,
23
+ ProjectionDefinition,
24
+ QueryHandlerDef,
25
+ RawTableEntry,
26
+ ReferenceDataDef,
27
+ RegistrarExtensionDef,
28
+ RegistrarExtensionRegistration,
29
+ RelationDefinition,
30
+ SearchPayloadContributorFn,
31
+ SecretKeyDefinition,
32
+ TranslationKeys,
33
+ TreeActionDef,
34
+ UiHints,
35
+ UnmanagedTableEntry,
36
+ ValidationHookFn,
37
+ WriteHandlerDef,
38
+ } from "./types";
39
+ import type { HttpRouteDefinition } from "./types/http-route";
40
+ import type { NavDefinition } from "./types/nav";
41
+ import type { ScreenDefinition } from "./types/screen";
42
+ import type { WorkspaceDefinition } from "./types/workspace";
43
+
44
+ const LIFECYCLE_TYPES = Object.values(LifecycleHookTypes);
45
+
46
+ // Bundles every Record/Set/array/scalar defineFeature populates while the
47
+ // registrar's ~40 methods run — hoisted out of defineFeature's closure so a
48
+ // future move-diff (extracting each method into its own module) can thread
49
+ // it explicitly. Every field is held BY REFERENCE — no destructured copies.
50
+ export type FeatureBuilderState = {
51
+ requires: string[];
52
+ optionalRequires: string[];
53
+ requiredProjections: Set<string>;
54
+ requiredSteps: Set<string>;
55
+ entities: Record<string, EntityDefinition>;
56
+ entityTables: Record<string, unknown>;
57
+ relations: Record<string, Record<string, RelationDefinition>>;
58
+ writeHandlers: Record<string, WriteHandlerDef>;
59
+ queryHandlers: Record<string, QueryHandlerDef>;
60
+ validationHooks: Record<string, ValidationHookFn>;
61
+ lifecycleHooks: Record<string, Record<string, OwnedFn<LifecycleHookFn>[]>>;
62
+ phasedLifecycleHooks: Record<
63
+ "postSave" | "preDelete" | "postDelete",
64
+ Record<string, PhasedHook<LifecycleHookFn>[]>
65
+ >;
66
+ configKeys: Record<string, ConfigKeyDefinition>;
67
+ configSeeds: ConfigSeedDef[];
68
+ jobs: Record<string, JobDefinition>;
69
+ events: Record<string, { name: string; schema: ZodType; version: number }>;
70
+ eventMigrations: Record<string, EventMigrationDef[]>;
71
+ configReads: string[];
72
+ entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]>;
73
+ entityPreDelete: Record<string, PhasedHook<PreDeleteHookFn>[]>;
74
+ entityPostDelete: Record<string, PhasedHook<PostDeleteHookFn>[]>;
75
+ entityPostQuery: Record<string, OwnedFn<PostQueryHookFn>[]>;
76
+ searchPayloadExtensions: Record<string, OwnedFn<SearchPayloadContributorFn>[]>;
77
+ notifications: Record<string, NotificationDefinition>;
78
+ registrarExtensions: Record<string, RegistrarExtensionDef>;
79
+ extensionUsages: RegistrarExtensionRegistration[];
80
+ extensionSelectors: ExtensionSelectorDef[];
81
+ exposedApis: Set<string>;
82
+ usedApis: Set<string>;
83
+ referenceData: ReferenceDataDef[];
84
+ handlerEntityMappings: Record<string, string>;
85
+ metrics: Record<string, FeatureMetricDef>;
86
+ secretKeys: Record<string, SecretKeyDefinition>;
87
+ projections: Record<string, ProjectionDefinition>;
88
+ multiStreamProjections: Record<string, MultiStreamProjectionDefinition>;
89
+ entityProjectionExtensions: Record<string, EntityProjectionExtension[]>;
90
+ rawTables: Record<string, RawTableEntry>;
91
+ unmanagedTables: Record<string, UnmanagedTableEntry>;
92
+ authClaimsHooks: AuthClaimsFn[];
93
+ claimKeys: Record<string, ClaimKeyDefinition>;
94
+ screens: Record<string, ScreenDefinition>;
95
+ navs: Record<string, NavDefinition>;
96
+ workspaces: Record<string, WorkspaceDefinition>;
97
+ httpRoutes: Record<string, HttpRouteDefinition>;
98
+ translations: TranslationKeys;
99
+ isSystemScoped: boolean;
100
+ toggleableDefault: boolean | undefined;
101
+ description: string | undefined;
102
+ uiHints: UiHints | undefined;
103
+ treeActions: Readonly<Record<string, TreeActionDef>> | undefined;
104
+ envSchema: z.ZodObject<z.ZodRawShape> | undefined;
105
+ };
106
+
107
+ export function createInitialFeatureBuilderState(): FeatureBuilderState {
108
+ const lifecycleHooks: Record<string, Record<string, OwnedFn<LifecycleHookFn>[]>> = {};
109
+ for (const t of LIFECYCLE_TYPES) {
110
+ lifecycleHooks[t] = {};
111
+ }
112
+ return {
113
+ requires: [],
114
+ optionalRequires: [],
115
+ requiredProjections: new Set<string>(),
116
+ requiredSteps: new Set<string>(),
117
+ entities: {},
118
+ entityTables: {},
119
+ relations: {},
120
+ writeHandlers: {},
121
+ queryHandlers: {},
122
+ validationHooks: {},
123
+ lifecycleHooks,
124
+ phasedLifecycleHooks: { postSave: {}, preDelete: {}, postDelete: {} },
125
+ configKeys: {},
126
+ configSeeds: [],
127
+ jobs: {},
128
+ events: {},
129
+ eventMigrations: {},
130
+ configReads: [],
131
+ entityPostSave: {},
132
+ entityPreDelete: {},
133
+ entityPostDelete: {},
134
+ entityPostQuery: {},
135
+ searchPayloadExtensions: {},
136
+ notifications: {},
137
+ registrarExtensions: {},
138
+ extensionUsages: [],
139
+ extensionSelectors: [],
140
+ exposedApis: new Set(),
141
+ usedApis: new Set(),
142
+ referenceData: [],
143
+ handlerEntityMappings: {},
144
+ metrics: {},
145
+ secretKeys: {},
146
+ projections: {},
147
+ multiStreamProjections: {},
148
+ entityProjectionExtensions: {},
149
+ rawTables: {},
150
+ unmanagedTables: {},
151
+ authClaimsHooks: [],
152
+ claimKeys: {},
153
+ screens: {},
154
+ navs: {},
155
+ workspaces: {},
156
+ httpRoutes: {},
157
+ translations: {},
158
+ isSystemScoped: false,
159
+ toggleableDefault: undefined,
160
+ description: undefined,
161
+ uiHints: undefined,
162
+ treeActions: undefined,
163
+ envSchema: undefined,
164
+ };
165
+ }