@cosmicdrift/kumiko-framework 0.154.0 → 0.154.2

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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +1 -1
  3. package/src/api/__tests__/batch.integration.test.ts +9 -9
  4. package/src/engine/__tests__/engine.test.ts +16 -0
  5. package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
  6. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  7. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  8. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  9. package/src/engine/define-feature.ts +22 -4
  10. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +15 -28
  11. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  12. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  13. package/src/engine/feature-ast/__tests__/parse.test.ts +55 -14
  14. package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
  15. package/src/engine/feature-ast/__tests__/patcher.test.ts +12 -22
  16. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +247 -5
  17. package/src/engine/feature-ast/extractors/index.ts +0 -3
  18. package/src/engine/feature-ast/extractors/round4.ts +113 -287
  19. package/src/engine/feature-ast/index.ts +0 -4
  20. package/src/engine/feature-ast/parse.ts +0 -6
  21. package/src/engine/feature-ast/patch.ts +35 -45
  22. package/src/engine/feature-ast/patcher.ts +18 -40
  23. package/src/engine/feature-ast/patterns.ts +20 -41
  24. package/src/engine/feature-ast/render.ts +17 -27
  25. package/src/engine/feature-config-events-jobs.ts +150 -74
  26. package/src/engine/feature-entity-handlers.ts +24 -6
  27. package/src/engine/feature-ui-extensions.ts +116 -45
  28. package/src/engine/object-form.ts +12 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +0 -19
  30. package/src/engine/pattern-library/library.ts +0 -4
  31. package/src/engine/pattern-library/mixed-schemas.ts +9 -80
  32. package/src/engine/pattern-library/shared-fields.ts +1 -6
  33. package/src/engine/registry-ingest.ts +7 -2
  34. package/src/engine/registry-validate.ts +6 -6
  35. package/src/engine/types/feature.ts +76 -45
  36. package/src/engine/types/handlers.ts +6 -3
  37. package/src/engine/types/nav.ts +4 -0
  38. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  39. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  40. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  41. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  42. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  43. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  44. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  45. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  46. package/src/search/meilisearch-adapter.ts +13 -12
@@ -3,6 +3,7 @@ import { toTableName } from "../db/table-builder";
3
3
  import type { QueryHandlerDefinition, WriteHandlerDefinition } from "./define-handler";
4
4
  import { type RegisterEntityCrudOptions, registerEntityCrud } from "./entity-handlers";
5
5
  import type { FeatureBuilderState } from "./feature-builder-state";
6
+ import { splitNamedDefinition } from "./object-form";
6
7
  import type {
7
8
  AccessRule,
8
9
  EntityDefinition,
@@ -57,13 +58,17 @@ export function buildEntityHandlerMethods<TName extends string>(
57
58
  ) {
58
59
  return {
59
60
  entity(
60
- entityName: string,
61
- definition: EntityDefinition,
61
+ nameOrDefinition: string | ({ readonly name: string } & EntityDefinition),
62
+ definition?: EntityDefinition,
62
63
  options?: { readonly table?: unknown },
63
64
  ): EntityRef {
64
- state.entities[entityName] = definition;
65
+ const [entityName, entityDefinition] =
66
+ typeof nameOrDefinition === "string"
67
+ ? [nameOrDefinition, definition as EntityDefinition]
68
+ : splitNamedDefinition(nameOrDefinition);
69
+ state.entities[entityName] = entityDefinition;
65
70
  if (options?.table !== undefined) state.entityTables[entityName] = options.table;
66
- return { name: entityName, table: definition.table ?? toTableName(entityName) };
71
+ return { name: entityName, table: entityDefinition.table ?? toTableName(entityName) };
67
72
  },
68
73
  crud(
69
74
  entityName: string,
@@ -73,10 +78,23 @@ export function buildEntityHandlerMethods<TName extends string>(
73
78
  registerEntityCrud(getRegistrar(), entityName, definition, options);
74
79
  return { name: entityName, table: definition.table ?? toTableName(entityName) };
75
80
  },
76
- relation(entityRef: NameOrRef, relationName: string, definition: RelationDefinition): void {
81
+ relation(
82
+ entityRefOrDefinition:
83
+ | NameOrRef
84
+ | ({ readonly entity: NameOrRef; readonly name: string } & RelationDefinition),
85
+ relationName?: string,
86
+ definition?: RelationDefinition,
87
+ ): void {
88
+ const [entityRef, resolvedRelationName, resolvedDefinition] =
89
+ typeof entityRefOrDefinition === "object" && "entity" in entityRefOrDefinition
90
+ ? (() => {
91
+ const { entity, name: innerName, ...rest } = entityRefOrDefinition;
92
+ return [entity, innerName, rest as RelationDefinition] as const;
93
+ })()
94
+ : [entityRefOrDefinition, relationName as string, definition as RelationDefinition];
77
95
  const entityName = resolveName(entityRef);
78
96
  if (!state.relations[entityName]) state.relations[entityName] = {};
79
- state.relations[entityName][relationName] = definition;
97
+ state.relations[entityName][resolvedRelationName] = resolvedDefinition;
80
98
  },
81
99
  writeHandler<TName extends string, TSchema extends ZodType>(
82
100
  nameOrDef: string | WriteHandlerDefinition<TName, TSchema>,
@@ -31,6 +31,61 @@ import type { WorkspaceDefinition } from "./types/workspace";
31
31
 
32
32
  // Builds hooks/extensions/projections/screens/nav/workspace/tables/tree-actions
33
33
  // registrar methods.
34
+
35
+ type EntityWideHookType = "postSave" | "preDelete" | "postDelete" | "postQuery";
36
+
37
+ function isEntityWideHookType(type: LifecycleHookType | "validation"): type is EntityWideHookType {
38
+ return (
39
+ type === LifecycleHookTypes.postSave ||
40
+ type === LifecycleHookTypes.preDelete ||
41
+ type === LifecycleHookTypes.postDelete ||
42
+ type === LifecycleHookTypes.postQuery
43
+ );
44
+ }
45
+
46
+ // r.hook(type, { allOf: entity }, fn) — "all write/query handlers of this
47
+ // entity", replacing the old r.entityHook(type, entity, fn). Hook-fn casts
48
+ // below: @cast-boundary engine-bridge — typed Dev-API (LifecycleHookFn) →
49
+ // erased Map<entityName, fn>.
50
+ function registerEntityWideHook(
51
+ state: FeatureBuilderState,
52
+ featureName: string,
53
+ type: EntityWideHookType,
54
+ entityName: string,
55
+ fn: LifecycleHookFn,
56
+ options?: { phase?: HookPhase },
57
+ ): void {
58
+ if (type === LifecycleHookTypes.postSave) {
59
+ const phase = options?.phase ?? HookPhases.afterCommit;
60
+ if (!state.entityPostSave[entityName]) state.entityPostSave[entityName] = [];
61
+ state.entityPostSave[entityName].push({
62
+ fn: fn as PostSaveHookFn,
63
+ phase,
64
+ featureName,
65
+ }); // @cast-boundary engine-bridge
66
+ } else if (type === LifecycleHookTypes.preDelete) {
67
+ if (!state.entityPreDelete[entityName]) state.entityPreDelete[entityName] = [];
68
+ state.entityPreDelete[entityName].push({
69
+ fn: fn as PreDeleteHookFn, // @cast-boundary engine-bridge
70
+ phase: HookPhases.inTransaction,
71
+ featureName,
72
+ });
73
+ } else if (type === LifecycleHookTypes.postDelete) {
74
+ const phase = options?.phase ?? HookPhases.afterCommit;
75
+ if (!state.entityPostDelete[entityName]) state.entityPostDelete[entityName] = [];
76
+ state.entityPostDelete[entityName].push({
77
+ fn: fn as PostDeleteHookFn,
78
+ phase,
79
+ featureName,
80
+ }); // @cast-boundary engine-bridge
81
+ } else {
82
+ // postQuery is unphased (no inTransaction/afterCommit semantics — fires
83
+ // synchronously after query-handler-execute, before field-access-filter)
84
+ if (!state.entityPostQuery[entityName]) state.entityPostQuery[entityName] = [];
85
+ state.entityPostQuery[entityName].push({ fn: fn as PostQueryHookFn, featureName }); // @cast-boundary engine-bridge
86
+ }
87
+ }
88
+
34
89
  export function buildUiExtensionsMethods<TName extends string>(
35
90
  state: FeatureBuilderState,
36
91
  name: TName,
@@ -38,10 +93,36 @@ export function buildUiExtensionsMethods<TName extends string>(
38
93
  return {
39
94
  hook(
40
95
  type: LifecycleHookType | "validation",
41
- target: NameOrRef | readonly NameOrRef[],
96
+ target: NameOrRef | readonly NameOrRef[] | { readonly allOf: NameOrRef },
42
97
  fn: LifecycleHookFn | ValidationHookFn,
43
98
  options?: { phase?: HookPhase },
44
99
  ): void {
100
+ // Entity-wide target ("all write/query handlers of this entity") —
101
+ // replaces the old r.entityHook(type, entity, fn).
102
+ if (
103
+ typeof target === "object" &&
104
+ target !== null &&
105
+ !Array.isArray(target) &&
106
+ "allOf" in target
107
+ ) {
108
+ if (!isEntityWideHookType(type)) {
109
+ throw new Error(
110
+ `[Feature ${name}] r.hook("${type}", { allOf }, ...) only supports ` +
111
+ `postSave/preDelete/postDelete/postQuery, not "${type}".`,
112
+ );
113
+ }
114
+ registerEntityWideHook(
115
+ state,
116
+ name,
117
+ type,
118
+ resolveName(target.allOf),
119
+ fn as LifecycleHookFn,
120
+ options,
121
+ );
122
+ // skip: entity-wide target fully handled above, nothing more to do
123
+ return;
124
+ }
125
+
45
126
  const targets = Array.isArray(target) ? target : [target];
46
127
  const names = targets.map(resolveName);
47
128
 
@@ -81,43 +162,6 @@ export function buildUiExtensionsMethods<TName extends string>(
81
162
  bucket[n].push({ fn: fn as LifecycleHookFn, phase, featureName: name }); // @cast-boundary engine-bridge
82
163
  }
83
164
  },
84
- entityHook(
85
- type: "postSave" | "preDelete" | "postDelete" | "postQuery",
86
- entityRef: NameOrRef,
87
- fn: LifecycleHookFn,
88
- options?: { phase?: HookPhase },
89
- ): void {
90
- const entityName = resolveName(entityRef);
91
- if (type === LifecycleHookTypes.postSave) {
92
- const phase = options?.phase ?? HookPhases.afterCommit;
93
- if (!state.entityPostSave[entityName]) state.entityPostSave[entityName] = [];
94
- state.entityPostSave[entityName].push({
95
- fn: fn as PostSaveHookFn,
96
- phase,
97
- featureName: name,
98
- }); // @cast-boundary engine-bridge
99
- } else if (type === LifecycleHookTypes.preDelete) {
100
- if (!state.entityPreDelete[entityName]) state.entityPreDelete[entityName] = [];
101
- state.entityPreDelete[entityName].push({
102
- fn: fn as PreDeleteHookFn, // @cast-boundary engine-bridge
103
- phase: HookPhases.inTransaction,
104
- featureName: name,
105
- });
106
- } else if (type === LifecycleHookTypes.postDelete) {
107
- const phase = options?.phase ?? HookPhases.afterCommit;
108
- if (!state.entityPostDelete[entityName]) state.entityPostDelete[entityName] = [];
109
- state.entityPostDelete[entityName].push({
110
- fn: fn as PostDeleteHookFn,
111
- phase,
112
- featureName: name,
113
- }); // @cast-boundary engine-bridge
114
- } else if (type === LifecycleHookTypes.postQuery) {
115
- // postQuery is unphased (no inTransaction/afterCommit semantics — fires
116
- // synchronously after query-handler-execute, before field-access-filter)
117
- if (!state.entityPostQuery[entityName]) state.entityPostQuery[entityName] = [];
118
- state.entityPostQuery[entityName].push({ fn: fn as PostQueryHookFn, featureName: name }); // @cast-boundary engine-bridge
119
- }
120
- },
121
165
  searchPayloadExtension(entityRef: NameOrRef, fn: SearchPayloadContributorFn): void {
122
166
  const entityName = resolveName(entityRef);
123
167
  if (!state.searchPayloadExtensions[entityName])
@@ -128,11 +172,24 @@ export function buildUiExtensionsMethods<TName extends string>(
128
172
  state.registrarExtensions[extensionName] = def;
129
173
  },
130
174
  useExtension(
131
- extensionName: string,
132
- entityRef: NameOrRef,
175
+ extensionNameOrDefinition:
176
+ | string
177
+ | ({ readonly name: string; readonly entity: NameOrRef } & Record<string, unknown>),
178
+ entityRef?: NameOrRef,
133
179
  options?: Record<string, unknown>,
134
180
  ): void {
135
- state.extensionUsages.push({ extensionName, entityName: resolveName(entityRef), options });
181
+ const [extensionName, resolvedEntityRef, resolvedOptions] =
182
+ typeof extensionNameOrDefinition === "string"
183
+ ? [extensionNameOrDefinition, entityRef as NameOrRef, options]
184
+ : (() => {
185
+ const { name, entity, ...rest } = extensionNameOrDefinition;
186
+ return [name, entity, rest] as const;
187
+ })();
188
+ state.extensionUsages.push({
189
+ extensionName,
190
+ entityName: resolveName(resolvedEntityRef),
191
+ options: resolvedOptions,
192
+ });
136
193
  },
137
194
  extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
138
195
  if (state.extensionSelectors.some((s) => s.extensionName === extensionName)) {
@@ -232,14 +289,28 @@ export function buildUiExtensionsMethods<TName extends string>(
232
289
  state.entityProjectionExtensions[entityName] = list;
233
290
  },
234
291
  referenceData(
235
- entityRef: NameOrRef,
236
- data: readonly Record<string, unknown>[],
292
+ entityRefOrDefinition:
293
+ | NameOrRef
294
+ | {
295
+ readonly entity: NameOrRef;
296
+ readonly data: readonly Record<string, unknown>[];
297
+ readonly upsertKey?: string;
298
+ },
299
+ data?: readonly Record<string, unknown>[],
237
300
  options?: { upsertKey?: string },
238
301
  ): void {
302
+ const [entityRef, resolvedData, upsertKey] =
303
+ typeof entityRefOrDefinition === "object" && "entity" in entityRefOrDefinition
304
+ ? [
305
+ entityRefOrDefinition.entity,
306
+ entityRefOrDefinition.data,
307
+ entityRefOrDefinition.upsertKey,
308
+ ]
309
+ : [entityRefOrDefinition, data as readonly Record<string, unknown>[], options?.upsertKey];
239
310
  state.referenceData.push({
240
311
  entityName: resolveName(entityRef),
241
- data,
242
- upsertKey: options?.upsertKey,
312
+ data: resolvedData,
313
+ upsertKey,
243
314
  });
244
315
  },
245
316
  screen(definition: ScreenDefinition): void {
@@ -0,0 +1,12 @@
1
+ // Shared helper for registrar methods that accept an Object-Form call
2
+ // (`r.entity({ name: "item", ... })`) alongside the classic positional form
3
+ // (`r.entity("item", { ... })`). Object-Form is the shape the feature-ast
4
+ // renderer emits for Designer/AI-generated code — a single object argument
5
+ // with named fields is easier to generate correctly than positional args
6
+ // whose count/order vary per method.
7
+ export function splitNamedDefinition<T extends { readonly name: string }>(
8
+ definition: T,
9
+ ): [string, Omit<T, "name">] {
10
+ const { name, ...rest } = definition;
11
+ return [name, rest];
12
+ }
@@ -47,7 +47,6 @@ const ALL_KINDS: FeaturePatternKind[] = [
47
47
  "writeHandler",
48
48
  "queryHandler",
49
49
  "hook",
50
- "entityHook",
51
50
  "job",
52
51
  "notification",
53
52
  "authClaims",
@@ -55,7 +54,6 @@ const ALL_KINDS: FeaturePatternKind[] = [
55
54
  "projection",
56
55
  "multiStreamProjection",
57
56
  "defineEvent",
58
- "eventMigration",
59
57
  "extendsRegistrar",
60
58
  "usesApi",
61
59
  "exposesApi",
@@ -280,14 +278,6 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
280
278
  target: "x",
281
279
  fnBody: PLACEHOLDER_BODY_LOC,
282
280
  };
283
- case "entityHook":
284
- return {
285
- kind,
286
- source: PLACEHOLDER_LOC,
287
- hookType: "postSave",
288
- entityName: "x",
289
- fnBody: PLACEHOLDER_BODY_LOC,
290
- };
291
281
  case "job":
292
282
  return {
293
283
  kind,
@@ -332,15 +322,6 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
332
322
  eventName: "x",
333
323
  schemaSource: PLACEHOLDER_BODY_LOC,
334
324
  };
335
- case "eventMigration":
336
- return {
337
- kind,
338
- source: PLACEHOLDER_LOC,
339
- eventName: "x",
340
- fromVersion: 1,
341
- toVersion: 2,
342
- transformBody: PLACEHOLDER_BODY_LOC,
343
- };
344
325
  case "extendsRegistrar":
345
326
  return {
346
327
  kind,
@@ -17,8 +17,6 @@ import type { FeaturePatternKind } from "../feature-ast/patterns";
17
17
  import {
18
18
  authClaimsSchema,
19
19
  defineEventSchema,
20
- entityHookSchema,
21
- eventMigrationSchema,
22
20
  hookSchema,
23
21
  httpRouteSchema,
24
22
  jobSchema,
@@ -82,7 +80,6 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
82
80
  writeHandler: writeHandlerSchema,
83
81
  queryHandler: queryHandlerSchema,
84
82
  hook: hookSchema,
85
- entityHook: entityHookSchema,
86
83
  job: jobSchema,
87
84
  notification: notificationSchema,
88
85
  authClaims: authClaimsSchema,
@@ -90,7 +87,6 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
90
87
  projection: projectionSchema,
91
88
  multiStreamProjection: multiStreamProjectionSchema,
92
89
  defineEvent: defineEventSchema,
93
- eventMigration: eventMigrationSchema,
94
90
  extendsRegistrar: extendsRegistrarSchema,
95
91
  usesApi: usesApiSchema,
96
92
  exposesApi: exposesApiSchema,
@@ -1,11 +1,6 @@
1
1
  // Mixed pattern schemas (header form + opaque body source).
2
2
 
3
- import {
4
- accessRuleField,
5
- ENTITY_HOOK_TYPE_OPTIONS,
6
- HOOK_TYPE_OPTIONS,
7
- HTTP_METHOD_OPTIONS,
8
- } from "./shared-fields";
3
+ import { accessRuleField, HOOK_TYPE_OPTIONS, HTTP_METHOD_OPTIONS } from "./shared-fields";
9
4
  import type { PatternFormSchema } from "./types";
10
5
 
11
6
  // --- Mixed patterns (header form + opaque body source) --------------------
@@ -169,45 +164,6 @@ export const hookSchema: PatternFormSchema = {
169
164
  ],
170
165
  };
171
166
 
172
- export const entityHookSchema: PatternFormSchema = {
173
- kind: "entityHook",
174
- label: { en: "Entity hook", de: "Entity-Hook" },
175
- summary: { en: "Hook scoped to a single entity (no cross-entity targets)." },
176
- category: "behaviour",
177
- editability: "mixed",
178
- fields: [
179
- {
180
- path: "hookType",
181
- label: { en: "Type", de: "Typ" },
182
- input: "select",
183
- options: ENTITY_HOOK_TYPE_OPTIONS,
184
- required: true,
185
- },
186
- {
187
- path: "entityName",
188
- label: { en: "Entity", de: "Entität" },
189
- input: "entity-ref",
190
- required: true,
191
- },
192
- {
193
- path: "fnBody",
194
- label: { en: "Hook body (source)", de: "Hook-Body (Source)" },
195
- input: "code-block",
196
- language: "typescript",
197
- readOnly: true,
198
- },
199
- {
200
- path: "phase",
201
- label: { en: "Phase", de: "Phase" },
202
- input: "select",
203
- options: [
204
- { value: "inTransaction", label: { en: "In transaction" } },
205
- { value: "afterCommit", label: { en: "After commit" } },
206
- ],
207
- },
208
- ],
209
- };
210
-
211
167
  export const jobSchema: PatternFormSchema = {
212
168
  kind: "job",
213
169
  label: { en: "Job", de: "Job" },
@@ -443,42 +399,15 @@ export const defineEventSchema: PatternFormSchema = {
443
399
  input: "number",
444
400
  min: 1,
445
401
  },
446
- ],
447
- };
448
-
449
- export const eventMigrationSchema: PatternFormSchema = {
450
- kind: "eventMigration",
451
- label: { en: "Event migration", de: "Event-Migration" },
452
- summary: { en: "Step-wise transform between event versions." },
453
- category: "data",
454
- editability: "mixed",
455
- fields: [
456
- {
457
- path: "eventName",
458
- label: { en: "Event", de: "Event" },
459
- input: "text",
460
- required: true,
461
- },
462
- {
463
- path: "fromVersion",
464
- label: { en: "From version", de: "Von Version" },
465
- input: "number",
466
- min: 1,
467
- required: true,
468
- },
469
- {
470
- path: "toVersion",
471
- label: { en: "To version", de: "Auf Version" },
472
- input: "number",
473
- min: 2,
474
- required: true,
475
- },
476
402
  {
477
- path: "transformBody",
478
- label: { en: "Transform (source)", de: "Transform (Source)" },
479
- input: "code-block",
480
- language: "typescript",
481
- readOnly: true,
403
+ path: "migrations",
404
+ label: {
405
+ en: "Migrations (fromVersion → transform)",
406
+ de: "Migrationen (fromVersion → Transform)",
407
+ },
408
+ input: "key-value-map",
409
+ keyPlaceholder: "1",
410
+ valueInput: "code-block",
482
411
  },
483
412
  ],
484
413
  };
@@ -11,12 +11,7 @@ export const HOOK_TYPE_OPTIONS = [
11
11
  { value: "preDelete", label: { en: "Pre-Delete", de: "Vor Löschen" } },
12
12
  { value: "postDelete", label: { en: "Post-Delete", de: "Nach Löschen" } },
13
13
  { value: "preQuery", label: { en: "Pre-Query", de: "Vor Abfrage" } },
14
- ] as const;
15
-
16
- export const ENTITY_HOOK_TYPE_OPTIONS = [
17
- { value: "postSave", label: { en: "Post-Save", de: "Nach Speichern" } },
18
- { value: "preDelete", label: { en: "Pre-Delete", de: "Vor Löschen" } },
19
- { value: "postDelete", label: { en: "Post-Delete", de: "Nach Löschen" } },
14
+ { value: "postQuery", label: { en: "Post-Query", de: "Nach Abfrage" } },
20
15
  ] as const;
21
16
 
22
17
  export const HTTP_METHOD_OPTIONS = [
@@ -147,9 +147,14 @@ export function populateEvents(state: RegistryState, feature: FeatureDefinition)
147
147
 
148
148
  // Translations prefixed with featureName: (i18next namespace convention).
149
149
  export function populateTranslations(state: RegistryState, feature: FeatureDefinition): void {
150
- // Translations prefixed with featureName: (i18next namespace convention)
150
+ // Translations prefixed with featureName: (i18next namespace convention).
151
+ // Keys that already carry the feature's own namespace prefix (e.g. a nav
152
+ // label referencing "cap-counter:nav.cap-list" verbatim) must NOT be
153
+ // re-prefixed, else server-side t() can never resolve them (#1105).
154
+ const prefix = `${feature.name}:`;
151
155
  for (const [key, value] of Object.entries(feature.translations ?? {})) {
152
- state.mergedTranslations[`${feature.name}:${key}`] = value;
156
+ const qualifiedKey = key.startsWith(prefix) ? key : `${prefix}${key}`;
157
+ state.mergedTranslations[qualifiedKey] = value;
153
158
  }
154
159
  }
155
160
 
@@ -314,23 +314,23 @@ export function validateEventMigrationVersions(
314
314
  features: readonly FeatureDefinition[],
315
315
  ): void {
316
316
  // Build + validate event upcaster chains. Run AFTER all features are
317
- // ingested so r.eventMigration calls can reference events from any
318
- // feature (same feature in practice, but the check stays lax for future
319
- // cross-feature event packs).
317
+ // ingested so defineEvent's `migrations` option can reference events from
318
+ // any feature (same feature in practice, but the check stays lax for
319
+ // future cross-feature event packs).
320
320
  for (const feature of features) {
321
321
  for (const [shortName, migrations] of Object.entries(feature.eventMigrations ?? {})) {
322
322
  const qualified = qualify(feature.name, "event", shortName);
323
323
  const eventDef = state.eventMap.get(qualified);
324
324
  if (!eventDef) {
325
325
  throw new Error(
326
- `Feature "${feature.name}" registered r.eventMigration for "${shortName}" ` +
326
+ `Feature "${feature.name}" has migrations declared for event "${shortName}" ` +
327
327
  `but no r.defineEvent exists for that name. Register the event first.`,
328
328
  );
329
329
  }
330
330
  for (const m of migrations) {
331
331
  if (m.toVersion > eventDef.version) {
332
332
  throw new Error(
333
- `Feature "${feature.name}" has r.eventMigration("${shortName}", ${m.fromVersion}, ${m.toVersion}) ` +
333
+ `Feature "${feature.name}" declares a migration for event "${shortName}" from v${m.fromVersion} to v${m.toVersion} ` +
334
334
  `but r.defineEvent declares only version ${eventDef.version}. ` +
335
335
  `Bump the version in defineEvent to at least ${m.toVersion}, or remove the migration.`,
336
336
  );
@@ -364,7 +364,7 @@ export function buildEventUpcasterChains(
364
364
  if (!chainMap.has(v)) {
365
365
  throw new Error(
366
366
  `Event "${qualified}" declares version ${eventDef.version} but no migration ` +
367
- `covers the step v${v} → v${v + 1}. Register r.eventMigration("${qualified.split(":").pop() ?? qualified}", ${v}, ${v + 1}, transform) ` +
367
+ `covers the step v${v} → v${v + 1}. Add { fromVersion: ${v}, toVersion: ${v + 1}, transform } to r.defineEvent("${qualified.split(":").pop() ?? qualified}", ...)'s migrations array ` +
368
368
  `so stored v${v} payloads can be upcast on read.`,
369
369
  );
370
370
  }