@cosmicdrift/kumiko-framework 0.154.1 → 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 (40) 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__/hook-phases.test.ts +5 -5
  6. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  7. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  8. package/src/engine/define-feature.ts +22 -4
  9. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +8 -7
  10. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  11. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  12. package/src/engine/feature-ast/__tests__/parse.test.ts +22 -8
  13. package/src/engine/feature-ast/__tests__/patcher.test.ts +8 -8
  14. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +210 -4
  15. package/src/engine/feature-ast/extractors/index.ts +0 -2
  16. package/src/engine/feature-ast/extractors/round4.ts +21 -151
  17. package/src/engine/feature-ast/index.ts +0 -2
  18. package/src/engine/feature-ast/parse.ts +0 -3
  19. package/src/engine/feature-ast/patch.ts +42 -11
  20. package/src/engine/feature-ast/patcher.ts +4 -20
  21. package/src/engine/feature-ast/patterns.ts +10 -22
  22. package/src/engine/feature-ast/render.ts +7 -14
  23. package/src/engine/feature-config-events-jobs.ts +74 -24
  24. package/src/engine/feature-entity-handlers.ts +24 -6
  25. package/src/engine/feature-ui-extensions.ts +116 -45
  26. package/src/engine/object-form.ts +12 -0
  27. package/src/engine/pattern-library/__tests__/library.test.ts +0 -9
  28. package/src/engine/pattern-library/library.ts +0 -2
  29. package/src/engine/pattern-library/mixed-schemas.ts +1 -45
  30. package/src/engine/pattern-library/shared-fields.ts +1 -6
  31. package/src/engine/registry-ingest.ts +7 -2
  32. package/src/engine/types/feature.ts +58 -28
  33. package/src/engine/types/nav.ts +4 -0
  34. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  35. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  36. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  37. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  38. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  39. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  40. package/src/search/meilisearch-adapter.ts +13 -12
@@ -23,7 +23,6 @@ import type {
23
23
  ConfigPattern,
24
24
  DefineEventPattern,
25
25
  DescribePattern,
26
- EntityHookPattern,
27
26
  EntityPattern,
28
27
  EnvSchemaPattern,
29
28
  ExposesApiPattern,
@@ -112,8 +111,6 @@ export function renderPattern(pattern: FeaturePattern): string {
112
111
  return renderQueryHandler(pattern);
113
112
  case "hook":
114
113
  return renderHook(pattern);
115
- case "entityHook":
116
- return renderEntityHook(pattern);
117
114
  case "job":
118
115
  return renderJob(pattern);
119
116
  case "notification":
@@ -398,20 +395,16 @@ function renderQueryHandler(p: QueryHandlerPattern): string {
398
395
  return lines.join("\n");
399
396
  }
400
397
 
401
- function renderHook(p: HookPattern): string {
402
- const lines: string[] = ["r.hook({"];
403
- lines.push(` type: ${JSON.stringify(p.hookType)},`);
404
- lines.push(` target: ${renderValue(typeof p.target === "string" ? p.target : [...p.target])},`);
405
- lines.push(` handler: ${reindentBody(p.fnBody.raw, PATTERN_INDENT)},`);
406
- if (p.phase !== undefined) lines.push(` phase: ${JSON.stringify(p.phase)},`);
407
- lines.push("});");
408
- return lines.join("\n");
398
+ function renderHookTarget(target: HookPattern["target"]): string {
399
+ if (typeof target === "string") return renderValue(target);
400
+ if ("allOf" in target) return `{ allOf: ${JSON.stringify(target.allOf)} }`;
401
+ return renderValue([...target]);
409
402
  }
410
403
 
411
- function renderEntityHook(p: EntityHookPattern): string {
412
- const lines: string[] = ["r.entityHook({"];
404
+ function renderHook(p: HookPattern): string {
405
+ const lines: string[] = ["r.hook({"];
413
406
  lines.push(` type: ${JSON.stringify(p.hookType)},`);
414
- lines.push(` entity: ${JSON.stringify(p.entityName)},`);
407
+ lines.push(` target: ${renderHookTarget(p.target)},`);
415
408
  lines.push(` handler: ${reindentBody(p.fnBody.raw, PATTERN_INDENT)},`);
416
409
  if (p.phase !== undefined) lines.push(` phase: ${JSON.stringify(p.phase)},`);
417
410
  lines.push("});");
@@ -1,5 +1,6 @@
1
1
  import { ZodObject, type ZodType, type z } from "zod";
2
2
  import type { FeatureBuilderState } from "./feature-builder-state";
3
+ import { splitNamedDefinition } from "./object-form";
3
4
  import { QnTypes, qn, toKebab } from "./qualified-name";
4
5
  import type {
5
6
  AuthClaimsFn,
@@ -154,38 +155,60 @@ export function buildConfigEventsJobsMethods<TName extends string>(
154
155
  return {
155
156
  config,
156
157
  job(
157
- jobName: string,
158
- options: Omit<JobDefinition, "name" | "handler">,
159
- handler: JobHandlerFn,
158
+ jobNameOrDefinition: string | JobDefinition,
159
+ options?: Omit<JobDefinition, "name" | "handler">,
160
+ handler?: JobHandlerFn,
160
161
  ): void {
162
+ const [jobName, jobOptions, jobHandler] =
163
+ typeof jobNameOrDefinition === "string"
164
+ ? [
165
+ jobNameOrDefinition,
166
+ options as Omit<JobDefinition, "name" | "handler">,
167
+ handler as JobHandlerFn,
168
+ ]
169
+ : (() => {
170
+ const { name, handler: h, ...rest } = jobNameOrDefinition;
171
+ return [name, rest, h] as const;
172
+ })();
161
173
  // Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
162
174
  // wird zu Array von resolved strings, Single bleibt single string —
163
175
  // job-runner unterscheidet anhand Array.isArray.
164
176
  const trigger =
165
- "on" in options.trigger
177
+ "on" in jobOptions.trigger
166
178
  ? {
167
- on: Array.isArray(options.trigger.on)
168
- ? options.trigger.on.map(resolveName)
169
- : resolveName(options.trigger.on as NameOrRef), // @cast-boundary engine-bridge
179
+ on: Array.isArray(jobOptions.trigger.on)
180
+ ? jobOptions.trigger.on.map(resolveName)
181
+ : resolveName(jobOptions.trigger.on as NameOrRef), // @cast-boundary engine-bridge
170
182
  }
171
- : options.trigger;
172
- state.jobs[jobName] = { ...options, trigger, name: jobName, handler };
183
+ : jobOptions.trigger;
184
+ state.jobs[jobName] = { ...jobOptions, trigger, name: jobName, handler: jobHandler };
173
185
  },
174
186
  notification(
175
- notificationName: string,
176
- definition: {
187
+ notificationNameOrDefinition:
188
+ | string
189
+ | ({ readonly name: string } & {
190
+ readonly trigger: { readonly on: NameOrRef };
191
+ readonly recipient: NotificationRecipientFn;
192
+ readonly data: NotificationDataFn;
193
+ readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
194
+ }),
195
+ definition?: {
177
196
  readonly trigger: { readonly on: NameOrRef };
178
197
  readonly recipient: NotificationRecipientFn;
179
198
  readonly data: NotificationDataFn;
180
199
  readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
181
200
  },
182
201
  ): void {
202
+ const [notificationName, resolvedDefinition] =
203
+ typeof notificationNameOrDefinition === "string"
204
+ ? [notificationNameOrDefinition, definition as NonNullable<typeof definition>]
205
+ : splitNamedDefinition(notificationNameOrDefinition);
183
206
  state.notifications[notificationName] = {
184
207
  name: notificationName,
185
- trigger: { on: resolveName(definition.trigger.on) },
186
- recipient: definition.recipient,
187
- data: definition.data,
188
- templates: definition.templates,
208
+ trigger: { on: resolveName(resolvedDefinition.trigger.on) },
209
+ recipient: resolvedDefinition.recipient,
210
+ data: resolvedDefinition.data,
211
+ templates: resolvedDefinition.templates,
189
212
  };
190
213
  },
191
214
  translations(def: TranslationsDef): void {
@@ -246,17 +269,29 @@ export function buildConfigEventsJobsMethods<TName extends string>(
246
269
  }
247
270
  return def;
248
271
  },
249
- readsConfig(...qualifiedKeys: string[]): void {
272
+ readsConfig(
273
+ ...args: readonly [{ readonly keys: readonly string[] }] | readonly string[]
274
+ ): void {
275
+ const [first] = args;
276
+ const qualifiedKeys =
277
+ typeof first === "object" && first !== null ? first.keys : (args as readonly string[]);
250
278
  state.configReads.push(...qualifiedKeys);
251
279
  },
252
- metric(shortName: string, options: MetricOptions): void {
280
+ metric(
281
+ shortNameOrDefinition: string | ({ readonly name: string } & MetricOptions),
282
+ options?: MetricOptions,
283
+ ): void {
284
+ const [shortName, metricOptions] =
285
+ typeof shortNameOrDefinition === "string"
286
+ ? [shortNameOrDefinition, options as MetricOptions]
287
+ : splitNamedDefinition(shortNameOrDefinition);
253
288
  if (state.metrics[shortName]) {
254
289
  throw new Error(
255
290
  `[Feature ${name}] Metric "${shortName}" already registered. ` +
256
291
  `Metric names must be unique per feature.`,
257
292
  );
258
293
  }
259
- state.metrics[shortName] = { shortName, ...options };
294
+ state.metrics[shortName] = { shortName, ...metricOptions };
260
295
  },
261
296
  envSchema(schema: z.ZodObject<z.ZodRawShape>): void {
262
297
  if (state.envSchema !== undefined) {
@@ -266,7 +301,14 @@ export function buildConfigEventsJobsMethods<TName extends string>(
266
301
  }
267
302
  state.envSchema = schema;
268
303
  },
269
- secret(shortName: string, options: SecretOptions): SecretKeyHandle {
304
+ secret(
305
+ shortNameOrDefinition: string | ({ readonly name: string } & SecretOptions),
306
+ options?: SecretOptions,
307
+ ): SecretKeyHandle {
308
+ const [shortName, secretOptions] =
309
+ typeof shortNameOrDefinition === "string"
310
+ ? [shortNameOrDefinition, options as SecretOptions]
311
+ : splitNamedDefinition(shortNameOrDefinition);
270
312
  if (state.secretKeys[shortName]) {
271
313
  throw new Error(
272
314
  `[Feature ${name}] Secret "${shortName}" already registered. ` +
@@ -282,14 +324,22 @@ export function buildConfigEventsJobsMethods<TName extends string>(
282
324
  state.secretKeys[shortName] = {
283
325
  shortName,
284
326
  qualifiedName,
285
- ...options,
327
+ ...secretOptions,
286
328
  };
287
329
  return { name: qualifiedName };
288
330
  },
289
331
  claimKey<T extends ClaimKeyType>(
290
- shortName: string,
291
- options: { readonly type: T },
332
+ shortNameOrDefinition: string | { readonly name: string; readonly type: T },
333
+ options?: { readonly type: T },
292
334
  ): ClaimKeyHandle<T> {
335
+ const shortName =
336
+ typeof shortNameOrDefinition === "string"
337
+ ? shortNameOrDefinition
338
+ : shortNameOrDefinition.name;
339
+ const claimType: T =
340
+ typeof shortNameOrDefinition === "string"
341
+ ? (options as { readonly type: T }).type
342
+ : shortNameOrDefinition.type;
293
343
  if (state.claimKeys[shortName]) {
294
344
  throw new Error(
295
345
  `[Feature ${name}] Claim key "${shortName}" already declared. ` +
@@ -306,9 +356,9 @@ export function buildConfigEventsJobsMethods<TName extends string>(
306
356
  state.claimKeys[shortName] = {
307
357
  shortName,
308
358
  qualifiedName,
309
- type: options.type,
359
+ type: claimType,
310
360
  };
311
- return { name: qualifiedName, type: options.type };
361
+ return { name: qualifiedName, type: claimType };
312
362
  },
313
363
  authClaims(fn: AuthClaimsFn): void {
314
364
  state.authClaimsHooks.push(fn);
@@ -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",
@@ -279,14 +278,6 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
279
278
  target: "x",
280
279
  fnBody: PLACEHOLDER_BODY_LOC,
281
280
  };
282
- case "entityHook":
283
- return {
284
- kind,
285
- source: PLACEHOLDER_LOC,
286
- hookType: "postSave",
287
- entityName: "x",
288
- fnBody: PLACEHOLDER_BODY_LOC,
289
- };
290
281
  case "job":
291
282
  return {
292
283
  kind,
@@ -17,7 +17,6 @@ import type { FeaturePatternKind } from "../feature-ast/patterns";
17
17
  import {
18
18
  authClaimsSchema,
19
19
  defineEventSchema,
20
- entityHookSchema,
21
20
  hookSchema,
22
21
  httpRouteSchema,
23
22
  jobSchema,
@@ -81,7 +80,6 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
81
80
  writeHandler: writeHandlerSchema,
82
81
  queryHandler: queryHandlerSchema,
83
82
  hook: hookSchema,
84
- entityHook: entityHookSchema,
85
83
  job: jobSchema,
86
84
  notification: notificationSchema,
87
85
  authClaims: authClaimsSchema,
@@ -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" },
@@ -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