@cosmicdrift/kumiko-framework 0.154.1 → 0.155.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +9 -9
- package/src/engine/__tests__/engine.test.ts +16 -0
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/define-feature.ts +23 -5
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +8 -7
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +22 -8
- package/src/engine/feature-ast/__tests__/patcher.test.ts +8 -8
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +210 -4
- package/src/engine/feature-ast/extractors/index.ts +0 -2
- package/src/engine/feature-ast/extractors/round4.ts +21 -151
- package/src/engine/feature-ast/index.ts +0 -2
- package/src/engine/feature-ast/parse.ts +0 -3
- package/src/engine/feature-ast/patch.ts +42 -11
- package/src/engine/feature-ast/patcher.ts +4 -20
- package/src/engine/feature-ast/patterns.ts +10 -22
- package/src/engine/feature-ast/render.ts +7 -14
- package/src/engine/feature-config-events-jobs.ts +74 -24
- package/src/engine/feature-entity-handlers.ts +26 -21
- package/src/engine/feature-ui-extensions.ts +116 -45
- package/src/engine/object-form.ts +12 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -9
- package/src/engine/pattern-library/library.ts +0 -2
- package/src/engine/pattern-library/mixed-schemas.ts +1 -45
- package/src/engine/pattern-library/shared-fields.ts +1 -6
- package/src/engine/registry-ingest.ts +7 -2
- package/src/engine/types/feature.ts +58 -34
- package/src/engine/types/nav.ts +4 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- package/src/search/meilisearch-adapter.ts +13 -12
- package/src/engine/__tests__/define-feature-crud.test.ts +0 -55
|
@@ -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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
412
|
-
const lines: string[] = ["r.
|
|
404
|
+
function renderHook(p: HookPattern): string {
|
|
405
|
+
const lines: string[] = ["r.hook({"];
|
|
413
406
|
lines.push(` type: ${JSON.stringify(p.hookType)},`);
|
|
414
|
-
lines.push(`
|
|
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
|
-
|
|
158
|
-
options
|
|
159
|
-
handler
|
|
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
|
|
177
|
+
"on" in jobOptions.trigger
|
|
166
178
|
? {
|
|
167
|
-
on: Array.isArray(
|
|
168
|
-
?
|
|
169
|
-
: resolveName(
|
|
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
|
-
:
|
|
172
|
-
state.jobs[jobName] = { ...
|
|
183
|
+
: jobOptions.trigger;
|
|
184
|
+
state.jobs[jobName] = { ...jobOptions, trigger, name: jobName, handler: jobHandler };
|
|
173
185
|
},
|
|
174
186
|
notification(
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
186
|
-
recipient:
|
|
187
|
-
data:
|
|
188
|
-
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(
|
|
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(
|
|
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, ...
|
|
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(
|
|
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
|
-
...
|
|
327
|
+
...secretOptions,
|
|
286
328
|
};
|
|
287
329
|
return { name: qualifiedName };
|
|
288
330
|
},
|
|
289
331
|
claimKey<T extends ClaimKeyType>(
|
|
290
|
-
|
|
291
|
-
options
|
|
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:
|
|
359
|
+
type: claimType,
|
|
310
360
|
};
|
|
311
|
-
return { name: qualifiedName, type:
|
|
361
|
+
return { name: qualifiedName, type: claimType };
|
|
312
362
|
},
|
|
313
363
|
authClaims(fn: AuthClaimsFn): void {
|
|
314
364
|
state.authClaimsHooks.push(fn);
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
2
|
import { toTableName } from "../db/table-builder";
|
|
3
3
|
import type { QueryHandlerDefinition, WriteHandlerDefinition } from "./define-handler";
|
|
4
|
-
import { type RegisterEntityCrudOptions, registerEntityCrud } from "./entity-handlers";
|
|
5
4
|
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
5
|
+
import { splitNamedDefinition } from "./object-form";
|
|
6
6
|
import type {
|
|
7
7
|
AccessRule,
|
|
8
8
|
EntityDefinition,
|
|
9
9
|
EntityRef,
|
|
10
|
-
FeatureRegistrar,
|
|
11
10
|
HandlerRef,
|
|
12
11
|
NameOrRef,
|
|
13
12
|
QueryHandlerFn,
|
|
@@ -46,37 +45,43 @@ function tryMapEntity(state: FeatureBuilderState, name: string, handlerName: str
|
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
// Builds entity/
|
|
50
|
-
// methods that create or reference entities.
|
|
51
|
-
// registrar (self-reference into registerEntityCrud); getRegistrar is a lazy
|
|
52
|
-
// thunk since the registrar object doesn't exist yet while this is composed.
|
|
48
|
+
// Builds entity/relation/writeHandler/queryHandler — the registrar
|
|
49
|
+
// methods that create or reference entities.
|
|
53
50
|
export function buildEntityHandlerMethods<TName extends string>(
|
|
54
51
|
state: FeatureBuilderState,
|
|
55
52
|
name: TName,
|
|
56
|
-
getRegistrar: () => FeatureRegistrar<TName>,
|
|
57
53
|
) {
|
|
58
54
|
return {
|
|
59
55
|
entity(
|
|
60
|
-
|
|
61
|
-
definition
|
|
56
|
+
nameOrDefinition: string | ({ readonly name: string } & EntityDefinition),
|
|
57
|
+
definition?: EntityDefinition,
|
|
62
58
|
options?: { readonly table?: unknown },
|
|
63
59
|
): EntityRef {
|
|
64
|
-
|
|
60
|
+
const [entityName, entityDefinition] =
|
|
61
|
+
typeof nameOrDefinition === "string"
|
|
62
|
+
? [nameOrDefinition, definition as EntityDefinition]
|
|
63
|
+
: splitNamedDefinition(nameOrDefinition);
|
|
64
|
+
state.entities[entityName] = entityDefinition;
|
|
65
65
|
if (options?.table !== undefined) state.entityTables[entityName] = options.table;
|
|
66
|
-
return { name: entityName, table:
|
|
66
|
+
return { name: entityName, table: entityDefinition.table ?? toTableName(entityName) };
|
|
67
67
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
relation(
|
|
69
|
+
entityRefOrDefinition:
|
|
70
|
+
| NameOrRef
|
|
71
|
+
| ({ readonly entity: NameOrRef; readonly name: string } & RelationDefinition),
|
|
72
|
+
relationName?: string,
|
|
73
|
+
definition?: RelationDefinition,
|
|
74
|
+
): void {
|
|
75
|
+
const [entityRef, resolvedRelationName, resolvedDefinition] =
|
|
76
|
+
typeof entityRefOrDefinition === "object" && "entity" in entityRefOrDefinition
|
|
77
|
+
? (() => {
|
|
78
|
+
const { entity, name: innerName, ...rest } = entityRefOrDefinition;
|
|
79
|
+
return [entity, innerName, rest as RelationDefinition] as const;
|
|
80
|
+
})()
|
|
81
|
+
: [entityRefOrDefinition, relationName as string, definition as RelationDefinition];
|
|
77
82
|
const entityName = resolveName(entityRef);
|
|
78
83
|
if (!state.relations[entityName]) state.relations[entityName] = {};
|
|
79
|
-
state.relations[entityName][
|
|
84
|
+
state.relations[entityName][resolvedRelationName] = resolvedDefinition;
|
|
80
85
|
},
|
|
81
86
|
writeHandler<TName extends string, TSchema extends ZodType>(
|
|
82
87
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
const qualifiedKey = key.startsWith(prefix) ? key : `${prefix}${key}`;
|
|
157
|
+
state.mergedTranslations[qualifiedKey] = value;
|
|
153
158
|
}
|
|
154
159
|
}
|
|
155
160
|
|