@cosmicdrift/kumiko-framework 0.365.1 → 0.367.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 (110) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/access-control.md +32 -30
  3. package/agents/examples/anonymous-access-multitenant.md +14 -16
  4. package/agents/examples/anonymous-access.md +36 -43
  5. package/agents/examples/auth-foundation-providers.md +1 -1
  6. package/agents/examples/basic-entity.md +1 -1
  7. package/agents/examples/boot-check.md +3 -3
  8. package/agents/examples/chat-channels.md +1 -1
  9. package/agents/examples/cross-feature-events.md +7 -7
  10. package/agents/examples/currencies-global.md +3 -3
  11. package/agents/examples/currencies-per-tenant.md +2 -2
  12. package/agents/examples/custom-fields-basic.md +4 -3
  13. package/agents/examples/custom-handlers.md +40 -44
  14. package/agents/examples/dashboard-stats.md +6 -6
  15. package/agents/examples/delivery-notifications.md +9 -9
  16. package/agents/examples/designer-demo.md +2 -1
  17. package/agents/examples/embedded-entity-form.md +13 -13
  18. package/agents/examples/embedded.md +1 -1
  19. package/agents/examples/encrypted-tenant-config.md +1 -1
  20. package/agents/examples/error-contract.md +25 -25
  21. package/agents/examples/event-sourcing.md +96 -76
  22. package/agents/examples/feature-toggles.md +29 -21
  23. package/agents/examples/files-post-processing.md +22 -26
  24. package/agents/examples/idempotency.md +17 -19
  25. package/agents/examples/inbound-mail-triage.md +1 -1
  26. package/agents/examples/lane-routing.md +10 -12
  27. package/agents/examples/legal-pages.md +1 -1
  28. package/agents/examples/lifecycle-hooks.md +59 -39
  29. package/agents/examples/managed-config.md +9 -9
  30. package/agents/examples/managed-pages.md +1 -1
  31. package/agents/examples/notes-history-basic.md +3 -3
  32. package/agents/examples/pdf-audit-export.md +7 -7
  33. package/agents/examples/pipeline-basics.md +8 -8
  34. package/agents/examples/public-share-token.md +1 -1
  35. package/agents/examples/rate-limiting.md +7 -9
  36. package/agents/examples/record-detail-layout.md +26 -20
  37. package/agents/examples/reference-data.md +1 -1
  38. package/agents/examples/relations.md +2 -2
  39. package/agents/examples/screens-nav.md +2 -2
  40. package/agents/examples/search.md +17 -19
  41. package/agents/examples/security-baseline.md +1 -1
  42. package/agents/examples/state-machine.md +1 -1
  43. package/agents/examples/tags-basic.md +3 -3
  44. package/agents/examples/tenant-settings.md +2 -2
  45. package/agents/examples/timezones.md +12 -12
  46. package/agents/examples/user-data-rights.md +14 -29
  47. package/agents/examples/user-profile.md +1 -1
  48. package/agents/examples/webhook-step.md +1 -1
  49. package/agents/examples/writeform-section.md +21 -15
  50. package/agents/features.md +4 -4
  51. package/agents/widgets.md +5 -0
  52. package/dist/api/server.js +12 -4
  53. package/dist/bun-db/query.js +16 -1
  54. package/dist/db/entity-table-meta.js +4 -4
  55. package/dist/db/queries/seed-context.js +3 -3
  56. package/dist/engine/boot-validator/api-ext.js +2 -2
  57. package/dist/engine/boot-validator/config-deps.js +1 -1
  58. package/dist/engine/boot-validator/handler-field-references.js +3 -10
  59. package/dist/engine/boot-validator/screens.js +1 -1
  60. package/dist/engine/build-app-schema.js +3 -6
  61. package/dist/engine/define-feature.js +5 -5
  62. package/dist/engine/entity-handlers.d.ts +2 -2
  63. package/dist/engine/entity-handlers.js +5 -5
  64. package/dist/engine/factories.d.ts +11 -11
  65. package/dist/engine/factories.js +11 -11
  66. package/dist/engine/feature-ast/extractors/round2.js +16 -2
  67. package/dist/engine/feature-ast/render.js +4 -7
  68. package/dist/engine/feature-config-events-jobs.d.ts +6 -0
  69. package/dist/engine/feature-config-events-jobs.js +5 -0
  70. package/dist/engine/feature-entity-handlers.d.ts +4 -23
  71. package/dist/engine/feature-entity-handlers.js +77 -100
  72. package/dist/engine/feature-ui-extensions.d.ts +9 -14
  73. package/dist/engine/feature-ui-extensions.js +22 -14
  74. package/dist/engine/handler-field-references.d.ts +25 -0
  75. package/dist/engine/handler-field-references.js +80 -6
  76. package/dist/engine/index.d.ts +3 -3
  77. package/dist/engine/index.js +2 -2
  78. package/dist/engine/object-form.d.ts +2 -0
  79. package/dist/engine/object-form.js +13 -10
  80. package/dist/engine/registry-ingest.js +3 -0
  81. package/dist/engine/registry-state.js +2 -2
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +21 -2
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
  86. package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
  87. package/dist/engine/template-variables.d.ts +3 -0
  88. package/dist/engine/template-variables.js +3 -0
  89. package/dist/engine/types/index.d.ts +1 -1
  90. package/dist/files/create-file-ref-from-bytes.d.ts +1 -0
  91. package/dist/files/create-file-ref-from-bytes.js +7 -2
  92. package/dist/files/feature.js +1 -1
  93. package/dist/jobs/job-runner.js +16 -2
  94. package/dist/pipeline/dispatch-query.js +1 -1
  95. package/dist/pipeline/dispatch-shared.d.ts +2 -1
  96. package/dist/pipeline/dispatch-shared.js +3 -6
  97. package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
  98. package/dist/pipeline/tenant-timezone-config-key.js +6 -0
  99. package/dist/testing/index.d.ts +1 -0
  100. package/dist/testing/index.js +1 -0
  101. package/dist/testing/manual-clock.d.ts +14 -0
  102. package/dist/testing/manual-clock.js +12 -0
  103. package/dist/time/index.d.ts +1 -1
  104. package/dist/time/tz-context.d.ts +1 -1
  105. package/dist/time/tz-context.js +9 -4
  106. package/dist/upgrade-cli.js +13 -2
  107. package/package.json +4 -4
  108. package/src/changes.json +52 -0
  109. package/src/scripts/codemod/README.md +23 -0
  110. package/src/scripts/codemod/object-form.ts +709 -0
@@ -133,34 +133,34 @@ export declare function createTzField<R extends true | false = false>(overrides?
133
133
  required: R;
134
134
  };
135
135
  /**
136
- * Wall-Clock-Termin an einem Ort als ATOMARES Field. Empfohlene Form für
137
- * jeden Date-Wert mit Location-Bezug (Pickup, Delivery, Meeting, Schedule).
136
+ * Wall-clock time at a place as one atomic field. Use it for every date
137
+ * value tied to a location (pickup, delivery, meeting, schedule).
138
138
  *
139
- * EIN Schema-Feld → ZWEI DB-Spalten (`<name>_utc TIMESTAMPTZ` + `<name>_tz TEXT`)
140
- * → DREI API-Felder beim Read (`{ at, tz, utc }`).
139
+ * One schema field maps to two DB columns (`<name>_utc TIMESTAMPTZ` +
140
+ * `<name>_tz TEXT`) and to three API fields on read (`{ at, tz, utc }`).
141
141
  *
142
142
  * ```ts
143
- * r.entity("order", {
143
+ * r.entity({
144
+ * name: "order",
144
145
  * fields: {
145
146
  * pickup: createLocatedTimestampField(),
146
147
  * delivery: createLocatedTimestampField(),
147
148
  * },
148
149
  * });
149
150
  *
150
- * // Write — Caller schickt at+tz, Framework rechnet utc aus
151
+ * // Write: the caller sends at+tz, the framework computes utc
151
152
  * await create({ pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" } });
152
153
  *
153
- * // Read — Framework liefert alle drei Felder zurück
154
+ * // Read: the framework returns all three fields
154
155
  * // → { pickup: { at: "10:00", tz: "Europe/Lisbon", utc: "09:00Z" } }
155
156
  *
156
- * // Sort/Filter über die UTC-Spalte
157
+ * // Sort/filter on the UTC column
157
158
  * orderBy(asc(orderTable.pickupUtc))
158
159
  * where(between(orderTable.pickupUtc, start, end))
159
160
  * ```
160
161
  *
161
- * Default-Sicht für `at` ist Pickup-Ort-lokal (was eingegeben wurde, kommt
162
- * mit demselben Tag zurück). User-lokale Sicht kommt aus `utc` per
163
- * separater Berechnung.
162
+ * `at` is local to the place (what was entered comes back on the same day).
163
+ * A user-local view is computed separately from `utc`.
164
164
  */
165
165
  export declare function createLocatedTimestampField<R extends true | false = false>(overrides?: Partial<Omit<LocatedTimestampFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & {
166
166
  required?: R;
@@ -324,34 +324,34 @@ export function createTzField(overrides) {
324
324
  }; // @cast-boundary engine-payload
325
325
  }
326
326
  /**
327
- * Wall-Clock-Termin an einem Ort als ATOMARES Field. Empfohlene Form für
328
- * jeden Date-Wert mit Location-Bezug (Pickup, Delivery, Meeting, Schedule).
327
+ * Wall-clock time at a place as one atomic field. Use it for every date
328
+ * value tied to a location (pickup, delivery, meeting, schedule).
329
329
  *
330
- * EIN Schema-Feld → ZWEI DB-Spalten (`<name>_utc TIMESTAMPTZ` + `<name>_tz TEXT`)
331
- * → DREI API-Felder beim Read (`{ at, tz, utc }`).
330
+ * One schema field maps to two DB columns (`<name>_utc TIMESTAMPTZ` +
331
+ * `<name>_tz TEXT`) and to three API fields on read (`{ at, tz, utc }`).
332
332
  *
333
333
  * ```ts
334
- * r.entity("order", {
334
+ * r.entity({
335
+ * name: "order",
335
336
  * fields: {
336
337
  * pickup: createLocatedTimestampField(),
337
338
  * delivery: createLocatedTimestampField(),
338
339
  * },
339
340
  * });
340
341
  *
341
- * // Write — Caller schickt at+tz, Framework rechnet utc aus
342
+ * // Write: the caller sends at+tz, the framework computes utc
342
343
  * await create({ pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" } });
343
344
  *
344
- * // Read — Framework liefert alle drei Felder zurück
345
+ * // Read: the framework returns all three fields
345
346
  * // → { pickup: { at: "10:00", tz: "Europe/Lisbon", utc: "09:00Z" } }
346
347
  *
347
- * // Sort/Filter über die UTC-Spalte
348
+ * // Sort/filter on the UTC column
348
349
  * orderBy(asc(orderTable.pickupUtc))
349
350
  * where(between(orderTable.pickupUtc, start, end))
350
351
  * ```
351
352
  *
352
- * Default-Sicht für `at` ist Pickup-Ort-lokal (was eingegeben wurde, kommt
353
- * mit demselben Tag zurück). User-lokale Sicht kommt aus `utc` per
354
- * separater Berechnung.
353
+ * `at` is local to the place (what was entered comes back on the same day).
354
+ * A user-local view is computed separately from `utc`.
355
355
  */
356
356
  export function createLocatedTimestampField(overrides) {
357
357
  return {
@@ -1,7 +1,21 @@
1
1
  import { SyntaxKind } from "ts-morph";
2
2
  import { describeUnknownFieldType, findUnknownEntityFieldTypes } from "../entity-field-types.js";
3
3
  import { sourceLocationFromNode } from "../source-location.js";
4
- import { fail, isPlainObject, ok, readDataLiteralNode, readNameLiteral, readNameOrRef, } from "./shared.js";
4
+ import { fail, isPlainObject, isRawRefSentinel, ok, readDataLiteralNode, readNameLiteral, readNameOrRef, } from "./shared.js";
5
+ // `{ name: "x", ...xEntity }` — the definition is a reference the parser cannot
6
+ // inline, so it is kept as a raw-ref sentinel and re-rendered as the same spread.
7
+ // `backingTable` is runtime wiring (a table identifier), not part of the
8
+ // definition data, so it does not take part in the shape check.
9
+ function readSpreadOnlyDefinition(obj) {
10
+ const properties = obj
11
+ .getProperties()
12
+ .filter((property) => property.asKind(SyntaxKind.PropertyAssignment)?.getName() !== "backingTable");
13
+ const spread = properties.length === 2 ? properties[1]?.asKind(SyntaxKind.SpreadAssignment) : undefined;
14
+ if (!spread)
15
+ return undefined;
16
+ const reference = readDataLiteralNode(spread.getExpression());
17
+ return isRawRefSentinel(reference) ? reference : undefined;
18
+ }
5
19
  export function extractEntity(call, sourceFile) {
6
20
  const args = call.getArguments();
7
21
  const first = args[0];
@@ -18,7 +32,7 @@ export function extractEntity(call, sourceFile) {
18
32
  if (!nameInit) {
19
33
  return fail("entity", sourceLocationFromNode(call, sourceFile), "object form requires a string-literal `name` property");
20
34
  }
21
- const definition = readDataLiteralNode(obj);
35
+ const definition = readSpreadOnlyDefinition(obj) ?? readDataLiteralNode(obj);
22
36
  if (!isPlainObject(definition)) {
23
37
  return fail("entity", sourceLocationFromNode(call, sourceFile), "definition could not be read as a plain object (contains functions or identifiers)");
24
38
  }
@@ -202,15 +202,12 @@ function renderDescribe(p) {
202
202
  return `r.describe(${JSON.stringify(p.text)});`;
203
203
  }
204
204
  function renderEntity(p) {
205
- // A whole-value raw-ref (`r.entity("x", eventEntity)`) can't be spread
206
- // into the merged Object-Form without losing `name` to the sentinel
207
- // shortcut in renderValue — fall back to the classic positional form,
208
- // which keeps the reference verbatim alongside the name.
205
+ // A whole-value raw-ref (`eventEntity`) can't go through renderValue without
206
+ // losing `name` to the sentinel shortcut — spread the reference verbatim
207
+ // next to the name instead.
209
208
  if (isRawRefSentinel(p.definition)) {
210
- return `r.entity(${JSON.stringify(p.entityName)}, ${p.definition.__raw});`;
209
+ return `r.entity({ name: ${JSON.stringify(p.entityName)}, ...${p.definition.__raw} });`;
211
210
  }
212
- // Inline `name` into the definition object — canonical Object-Form
213
- // is a single arg with name-as-property.
214
211
  const merged = { name: p.entityName, ...p.definition };
215
212
  return `r.entity(${renderValue(merged)});`;
216
213
  }
@@ -25,6 +25,9 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
25
25
  readonly template?: Readonly<Record<string, NotificationTemplateBody>>;
26
26
  readonly meta?: Readonly<Record<string, NotificationTemplateMeta>>;
27
27
  readonly localizedTemplate?: () => NotificationLocalizedTemplate;
28
+ readonly renamedFrom?: readonly string[];
29
+ readonly systemOnly?: boolean;
30
+ readonly unsubscribe?: boolean;
28
31
  }), definition?: {
29
32
  readonly trigger?: {
30
33
  readonly on: NameOrRef;
@@ -37,6 +40,9 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
37
40
  readonly template?: Readonly<Record<string, NotificationTemplateBody>>;
38
41
  readonly meta?: Readonly<Record<string, NotificationTemplateMeta>>;
39
42
  readonly localizedTemplate?: () => NotificationLocalizedTemplate;
43
+ readonly renamedFrom?: readonly string[];
44
+ readonly systemOnly?: boolean;
45
+ readonly unsubscribe?: boolean;
40
46
  }): void;
41
47
  translations(def: TranslationsDef): void;
42
48
  defineEvent: <const TInner extends string, TPayload>(eventName: TInner, schema: ZodType<TPayload>, options: {
@@ -217,6 +217,11 @@ export function buildConfigEventsJobsMethods(state, name) {
217
217
  ...(resolvedDefinition.localizedTemplate !== undefined
218
218
  ? { localizedTemplate: resolvedDefinition.localizedTemplate }
219
219
  : {}),
220
+ ...(resolvedDefinition.renamedFrom !== undefined
221
+ ? { renamedFrom: resolvedDefinition.renamedFrom }
222
+ : {}),
223
+ ...(resolvedDefinition.systemOnly === true ? { systemOnly: true } : {}),
224
+ ...(resolvedDefinition.unsubscribe === true ? { unsubscribe: true } : {}),
220
225
  };
221
226
  },
222
227
  translations(def) {
@@ -3,34 +3,15 @@ import type { ZodType } from "zod";
3
3
  import { type QueryHandlerDefinition, type StreamHandlerDefinition, type WriteHandlerDefinition } from "./define-handler.js";
4
4
  import type { RegisterEntityCrudOptions } from "./entity-handlers.js";
5
5
  import type { FeatureBuilderState } from "./feature-builder-state.js";
6
- import type { AccessRule, AgentHandlerHints, EntityDefinition, EntityRef, EscapeHatchDeclaration, HandlerRef, KumikoEventTypeMap, NameOrRef, PayloadRateLimitOption, QueryHandlerFn, RateLimitDeclaration, RelationDefinition, StreamHandlerFn, WriteHandlerFn } from "./types/index.js";
6
+ import type { AccessRule, EntityDefinition, EntityObjectDefinition, EntityRef, EscapeHatchDeclaration, HandlerRef, KumikoEventTypeMap, NameOrRef, RateLimitDeclaration, RelationDefinition, StreamHandlerFn } from "./types/index.js";
7
7
  export declare function buildEntityHandlerMethods<TName extends string>(state: FeatureBuilderState, name: TName): {
8
- entity(nameOrDefinition: string | ({
9
- readonly name: string;
10
- } & EntityDefinition), definition?: EntityDefinition, options?: {
11
- readonly table?: unknown;
12
- }): EntityRef;
8
+ entity(definition: EntityObjectDefinition): EntityRef;
13
9
  relation(entityRefOrDefinition: NameOrRef | ({
14
10
  readonly entity: NameOrRef;
15
11
  readonly name: string;
16
12
  } & RelationDefinition), relationName?: string, definition?: RelationDefinition): void;
17
- writeHandler<TName_1 extends string, TSchema extends ZodType, TPrepared = undefined>(nameOrDef: string | WriteHandlerDefinition<TName_1, TSchema, unknown, KumikoEventTypeMap, TPrepared>, schema?: TSchema, handler?: WriteHandlerFn<z.infer<TSchema>>, options?: {
18
- access: AccessRule;
19
- rateLimit?: RateLimitDeclaration;
20
- additionalRateLimits?: readonly PayloadRateLimitOption[];
21
- description?: string;
22
- agent?: AgentHandlerHints;
23
- escapeHatch?: EscapeHatchDeclaration;
24
- }): HandlerRef;
25
- queryHandler<TName_1 extends string, TSchema extends ZodType>(nameOrDef: string | QueryHandlerDefinition<TName_1, TSchema>, schema?: TSchema, handler?: QueryHandlerFn<z.infer<TSchema>>, options?: {
26
- access: AccessRule;
27
- rateLimit?: RateLimitDeclaration;
28
- outputSchema?: ZodType;
29
- liveEntities?: readonly string[];
30
- description?: string;
31
- agent?: AgentHandlerHints;
32
- escapeHatch?: EscapeHatchDeclaration;
33
- }): HandlerRef;
13
+ writeHandler<TName_1 extends string, TSchema extends ZodType, TPrepared = undefined>(def: WriteHandlerDefinition<TName_1, TSchema, unknown, KumikoEventTypeMap, TPrepared>): HandlerRef;
14
+ queryHandler<TName_1 extends string, TSchema extends ZodType>(def: QueryHandlerDefinition<TName_1, TSchema>): HandlerRef;
34
15
  streamHandler<TName_1 extends string, TSchema extends ZodType>(nameOrDef: string | StreamHandlerDefinition<TName_1, TSchema>, schema?: TSchema, handler?: StreamHandlerFn<z.infer<TSchema>>, options?: {
35
16
  access: AccessRule;
36
17
  rateLimit?: RateLimitDeclaration;
@@ -1,9 +1,9 @@
1
1
  import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { toTableName } from "../db/table-builder.js";
3
3
  import { isEntityConventionQueryHandler, isPagedQueryHandler, PAGED_QUERY_HANDLER_BRAND, } from "./define-handler.js";
4
- import { registerEntityCrud } from "./entity-handlers.js";
4
+ import { registerCrudHandlers } from "./entity-handlers.js";
5
5
  import { resolveName } from "./handler-helpers.js";
6
- import { splitNamedDefinition } from "./object-form.js";
6
+ import { isObjectArgument, throwPositionalFormRemoved } from "./object-form.js";
7
7
  const CRUD_VERBS = new Set(["create", "update", "delete"]);
8
8
  const CRUD_REGISTERED_VERBS = new Set([...CRUD_VERBS, "restore", "list", "detail"]);
9
9
  // A silent overwrite would drop framework behaviour that rides on the r.crud
@@ -65,6 +65,13 @@ function transactionPhaseSlots(def) {
65
65
  ...(def.transaction && { transaction: def.transaction }),
66
66
  };
67
67
  }
68
+ // Typed callers cannot omit `access`; untyped ones (JS, designer output) must not get a
69
+ // handler that silently falls through to the boot-time default-deny.
70
+ function assertHandlerHasAccess(featureName, method, handlerName, access) {
71
+ if (access === undefined || access === null) {
72
+ throw new Error(`[Feature ${featureName}] r.${method}({ name: "${handlerName}" }) requires an access rule (access: { roles: [...] } or { openToAll: { reason } })`);
73
+ }
74
+ }
68
75
  function requireInlineHandlerArgs(kind, schema, handler, options) {
69
76
  if (!schema || !handler || !options?.access) {
70
77
  throw new Error(`${kind} inline form requires schema + handler + options.access`);
@@ -75,13 +82,13 @@ function requireInlineHandlerArgs(kind, schema, handler, options) {
75
82
  // methods that create or reference entities.
76
83
  export function buildEntityHandlerMethods(state, name) {
77
84
  const methods = {
78
- entity(nameOrDefinition, definition, options) {
79
- const [entityName, entityDefinition] = typeof nameOrDefinition === "string"
80
- ? [nameOrDefinition, definition]
81
- : splitNamedDefinition(nameOrDefinition);
85
+ entity(definition) {
86
+ if (!isObjectArgument(definition))
87
+ throwPositionalFormRemoved(name, "entity");
88
+ const { name: entityName, backingTable, ...entityDefinition } = definition;
82
89
  state.entities[entityName] = entityDefinition;
83
- if (options?.table !== undefined)
84
- state.entityTables[entityName] = options.table;
90
+ if (backingTable !== undefined)
91
+ state.entityTables[entityName] = backingTable;
85
92
  return { name: entityName, table: entityDefinition.table ?? toTableName(entityName) };
86
93
  },
87
94
  relation(entityRefOrDefinition, relationName, definition) {
@@ -96,105 +103,75 @@ export function buildEntityHandlerMethods(state, name) {
96
103
  state.relations[entityName] = {};
97
104
  state.relations[entityName][resolvedRelationName] = resolvedDefinition;
98
105
  },
99
- writeHandler(nameOrDef, schema, handler, options) {
100
- if (typeof nameOrDef === "object") {
101
- const def = nameOrDef;
102
- assertHandlerNameFree(state, name, "write", def.name);
103
- state.writeHandlers[def.name] = {
104
- name: def.name,
105
- schema: def.schema,
106
- // @cast-boundary engine-bridge — typed Dev-API's handler is
107
- // generic over the schema's parsed payload (`WriteEvent<output<TSchema>>`),
108
- // the storage form WriteHandlerFn carries `WriteEvent<unknown>`.
109
- // Function-arg variance: TS sees the typed handler as stricter
110
- // than the loose storage shape and rejects direct assignment.
111
- // The runtime value is identical — the cast crosses that boundary.
112
- // `satisfies` does not work here (it asserts assignability, which
113
- // is what fails). Explicit cast is the right tool.
114
- handler: def.handler,
115
- access: def.access,
116
- ...agentSlots(def),
117
- ...(def.excludedFields && { excludedFields: def.excludedFields }),
118
- ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
119
- ...(def.rateLimit && { rateLimit: def.rateLimit }),
120
- ...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
121
- ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
122
- ...transactionPhaseSlots(def),
123
- ...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
124
- // Forward the pipeline-build closure so boot-validators and
125
- // Designer/AI tooling can inspect the step list. Absent on
126
- // free-form handlers — defineWriteHandler only sets `perform`
127
- // when the author used the pipeline form. Variance cast
128
- // mirrors the handler-cast above: PipelineDef<output<TSchema>>
129
- // is stricter than PipelineDef<unknown> for the same reason.
130
- ...(def.perform !== undefined && {
131
- perform: def.perform,
132
- }),
133
- };
134
- tryMapEntity(state, name, def.name);
135
- return { name: def.name };
136
- }
137
- const inline = requireInlineHandlerArgs("writeHandler", schema, handler, options);
138
- assertHandlerNameFree(state, name, "write", nameOrDef);
139
- state.writeHandlers[nameOrDef] = {
140
- name: nameOrDef,
141
- schema: inline.schema,
142
- handler: inline.handler, // @cast-boundary engine-bridge
143
- access: inline.options.access,
144
- ...agentSlots(inline.options),
145
- ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
146
- ...(inline.options.additionalRateLimits && {
147
- additionalRateLimits: inline.options.additionalRateLimits,
106
+ writeHandler(def) {
107
+ if (!isObjectArgument(def))
108
+ throwPositionalFormRemoved(name, "writeHandler");
109
+ assertHandlerHasAccess(name, "writeHandler", def.name, def.access);
110
+ assertHandlerNameFree(state, name, "write", def.name);
111
+ state.writeHandlers[def.name] = {
112
+ name: def.name,
113
+ schema: def.schema,
114
+ // @cast-boundary engine-bridge — typed Dev-API's handler is
115
+ // generic over the schema's parsed payload (`WriteEvent<output<TSchema>>`),
116
+ // the storage form WriteHandlerFn carries `WriteEvent<unknown>`.
117
+ // Function-arg variance: TS sees the typed handler as stricter
118
+ // than the loose storage shape and rejects direct assignment.
119
+ // The runtime value is identical — the cast crosses that boundary.
120
+ // `satisfies` does not work here (it asserts assignability, which
121
+ // is what fails). Explicit cast is the right tool.
122
+ handler: def.handler,
123
+ access: def.access,
124
+ ...agentSlots(def),
125
+ ...(def.excludedFields && { excludedFields: def.excludedFields }),
126
+ ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
127
+ ...(def.rateLimit && { rateLimit: def.rateLimit }),
128
+ ...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
129
+ ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
130
+ ...transactionPhaseSlots(def),
131
+ ...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
132
+ // Forward the pipeline-build closure so boot-validators and
133
+ // Designer/AI tooling can inspect the step list. Absent on
134
+ // free-form handlers — defineWriteHandler only sets `perform`
135
+ // when the author used the pipeline form. Variance cast
136
+ // mirrors the handler-cast above: PipelineDef<output<TSchema>>
137
+ // is stricter than PipelineDef<unknown> for the same reason.
138
+ ...(def.perform !== undefined && {
139
+ perform: def.perform,
148
140
  }),
149
- ...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
150
141
  };
151
- tryMapEntity(state, name, nameOrDef);
152
- return { name: nameOrDef };
142
+ tryMapEntity(state, name, def.name);
143
+ return { name: def.name };
153
144
  },
154
- queryHandler(nameOrDef, schema, handler, options) {
155
- if (typeof nameOrDef === "object") {
156
- const def = nameOrDef;
157
- assertHandlerNameFree(state, name, "query", def.name);
158
- state.queryHandlers[def.name] = {
159
- name: def.name,
160
- schema: def.schema,
161
- // @cast-boundary engine-bridge — typed Dev-API → erased internal storage
162
- handler: def.handler, // @cast-boundary engine-bridge
163
- access: def.access,
164
- ...agentSlots(def),
165
- ...(def.rateLimit && { rateLimit: def.rateLimit }),
166
- ...(def.outputSchema && { outputSchema: def.outputSchema }),
167
- ...(def.liveEntities && { liveEntities: def.liveEntities }),
168
- ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
169
- // Carry the definePagedQueryHandler brand through — this rebuild
170
- // drops any field not explicitly listed.
171
- ...(isPagedQueryHandler(def) && { [PAGED_QUERY_HANDLER_BRAND]: true }),
172
- ...(isEntityConventionQueryHandler(def) && {
173
- [ENTITY_CONVENTION_QUERY_BRAND]: true,
174
- }),
175
- };
176
- tryMapEntity(state, name, def.name);
177
- return { name: def.name };
178
- }
179
- const inline = requireInlineHandlerArgs("queryHandler", schema, handler, options);
180
- assertHandlerNameFree(state, name, "query", nameOrDef);
181
- state.queryHandlers[nameOrDef] = {
182
- name: nameOrDef,
183
- schema: inline.schema,
184
- handler: inline.handler, // @cast-boundary engine-bridge
185
- access: inline.options.access,
186
- ...agentSlots(inline.options),
187
- ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
188
- ...(inline.options.outputSchema && { outputSchema: inline.options.outputSchema }),
189
- ...(inline.options.liveEntities && { liveEntities: inline.options.liveEntities }),
190
- ...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
145
+ queryHandler(def) {
146
+ if (!isObjectArgument(def))
147
+ throwPositionalFormRemoved(name, "queryHandler");
148
+ assertHandlerHasAccess(name, "queryHandler", def.name, def.access);
149
+ assertHandlerNameFree(state, name, "query", def.name);
150
+ state.queryHandlers[def.name] = {
151
+ name: def.name,
152
+ schema: def.schema,
153
+ // @cast-boundary engine-bridge — typed Dev-API → erased internal storage
154
+ handler: def.handler, // @cast-boundary engine-bridge
155
+ access: def.access,
156
+ ...agentSlots(def),
157
+ ...(def.rateLimit && { rateLimit: def.rateLimit }),
158
+ ...(def.outputSchema && { outputSchema: def.outputSchema }),
159
+ ...(def.liveEntities && { liveEntities: def.liveEntities }),
160
+ ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
161
+ // Carry the definePagedQueryHandler brand through — this rebuild
162
+ // drops any field not explicitly listed.
163
+ ...(isPagedQueryHandler(def) && { [PAGED_QUERY_HANDLER_BRAND]: true }),
164
+ ...(isEntityConventionQueryHandler(def) && {
165
+ [ENTITY_CONVENTION_QUERY_BRAND]: true,
166
+ }),
191
167
  };
192
- tryMapEntity(state, name, nameOrDef);
193
- return { name: nameOrDef };
168
+ tryMapEntity(state, name, def.name);
169
+ return { name: def.name };
194
170
  },
195
171
  streamHandler(nameOrDef, schema, handler, options) {
196
172
  if (typeof nameOrDef === "object") {
197
173
  const def = nameOrDef;
174
+ assertHandlerHasAccess(name, "streamHandler", def.name, def.access);
198
175
  state.streamHandlers[def.name] = {
199
176
  name: def.name,
200
177
  schema: def.schema,
@@ -221,7 +198,7 @@ export function buildEntityHandlerMethods(state, name) {
221
198
  return {
222
199
  ...methods,
223
200
  crud(entityName, entity, options) {
224
- registerEntityCrud(methods, entityName, entity, options);
201
+ registerCrudHandlers(methods, entityName, entity, options);
225
202
  },
226
203
  };
227
204
  }
@@ -2,17 +2,12 @@ import { type WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websock
2
2
  import type { EntityTableMeta } from "../db/entity-table-meta.js";
3
3
  import type { FeatureBuilderState } from "./feature-builder-state.js";
4
4
  import type { HttpRouteDefinition } from "./types/http-route.js";
5
- import type { BootCheckFn, EntityProjectionExtension, EscapeHatchDeclaration, ExtensionSelectorPanel, HookPhase, LifecycleHookFn, LifecycleHookType, MultiStreamProjectionDefinition, NameOrRef, ProjectionDefinition, RegistrarExtensionDef, SearchPayloadContributorFn, StoreTableOptions, TreeActionDef, TreeActionsHandle, ValidationHookFn } from "./types/index.js";
5
+ import type { BootCheckFn, EntityProjectionExtension, ExtensionSelectorPanel, HookDefinition, MultiStreamProjectionDefinition, NameOrRef, ProjectionDefinition, RegistrarExtensionDef, SearchPayloadContributorFn, StoreTableOptions, TreeActionDef, TreeActionsHandle } from "./types/index.js";
6
6
  import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
7
7
  import type { ScreenDefinition } from "./types/screen.js";
8
8
  import type { WorkspaceDefinition } from "./types/workspace.js";
9
9
  export declare function buildUiExtensionsMethods<TName extends string>(state: FeatureBuilderState, name: TName): {
10
- hook(type: LifecycleHookType | "validation", target: NameOrRef | readonly NameOrRef[] | {
11
- readonly allOf: NameOrRef;
12
- }, fn: LifecycleHookFn | ValidationHookFn, options?: {
13
- phase?: HookPhase;
14
- escapeHatch?: EscapeHatchDeclaration;
15
- }): void;
10
+ hook(definition: HookDefinition): void;
16
11
  searchPayloadExtension(entityRef: NameOrRef, fn: SearchPayloadContributorFn): void;
17
12
  extendsRegistrar(extensionName: string, def: RegistrarExtensionDef): void;
18
13
  useExtension(extensionNameOrDefinition: string | ({
@@ -25,20 +20,20 @@ export declare function buildUiExtensionsMethods<TName extends string>(state: Fe
25
20
  readonly panels?: readonly ExtensionSelectorPanel[];
26
21
  }): void;
27
22
  /**
28
- * Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
29
- * unter dem genannten Namen bereit. Die eigentliche Implementation
30
- * wird separat als Query- oder Write-Handler unter dem QN-Pattern
31
- * registriert; r.exposesApi ist reine Boot-Check-Surface.
23
+ * Marker declaration: this feature provides a cross-feature API under
24
+ * the given name. The implementation is registered separately as a query
25
+ * or write handler under the QN pattern; r.exposesApi only feeds the
26
+ * boot check.
32
27
  *
33
- * Beispiel:
28
+ * Example:
34
29
  * defineFeature("compliance-profiles", (r) => {
35
30
  * r.exposesApi("compliance.forTenant");
36
31
  * r.queryHandler({ name: "compliance:query:for-tenant", ... });
37
32
  * });
38
33
  * defineFeature("user-data-rights", (r) => {
39
- * r.requires("compliance-profiles");
34
+ * r.requires({ features: ["compliance-profiles"] });
40
35
  * r.usesApi("compliance.forTenant");
41
- * // ruft im Handler: ctx.callQuery("compliance:query:for-tenant", ...)
36
+ * // in a handler: ctx.callQuery("compliance:query:for-tenant", ...)
42
37
  * });
43
38
  */
44
39
  exposesApi(apiName: string): void;
@@ -4,6 +4,7 @@ import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES,
4
4
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
5
5
  import { LifecycleHookTypes } from "./constants.js";
6
6
  import { resolveName } from "./handler-helpers.js";
7
+ import { isObjectArgument, throwPositionalFormRemoved } from "./object-form.js";
7
8
  import { isKebabSegment, isValidQn, qualifyEntityName, toKebab } from "./qualified-name.js";
8
9
  import { HookPhases } from "./types/index.js";
9
10
  // Builds hooks/extensions/projections/screens/nav/workspace/tables/tree-actions
@@ -43,8 +44,8 @@ function isEntityWideHookType(type) {
43
44
  type === LifecycleHookTypes.postDelete ||
44
45
  type === LifecycleHookTypes.postQuery);
45
46
  }
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
47
+ // r.hook({ type, target: { allOf: entity }, handler }) — "all write/query handlers of
48
+ // this entity". Hook-fn casts
48
49
  // below: @cast-boundary engine-bridge — typed Dev-API (LifecycleHookFn) →
49
50
  // erased Map<entityName, fn>.
50
51
  function registerEntityWideHook(state, featureName, type, entityName, fn, options) {
@@ -107,21 +108,28 @@ export function buildUiExtensionsMethods(state, name) {
107
108
  state.navs[navDefinition.id] = Object.fromEntries(Object.entries(navDefinition).filter(([, value]) => value !== undefined));
108
109
  }
109
110
  return {
110
- hook(type, target, fn, options) {
111
+ hook(definition) {
112
+ if (!isObjectArgument(definition))
113
+ throwPositionalFormRemoved(name, "hook");
114
+ const { type, target, handler: fn } = definition;
115
+ const options = {
116
+ ...(definition.phase !== undefined && { phase: definition.phase }),
117
+ ...(definition.escapeHatch !== undefined && { escapeHatch: definition.escapeHatch }),
118
+ };
111
119
  if (type === "validation") {
112
120
  if (options?.escapeHatch !== undefined) {
113
- throw new Error(`[Feature ${name}] r.hook("validation", ...) does not accept { escapeHatch } — ` +
121
+ throw new Error(`[Feature ${name}] r.hook({ type: "validation", … }) does not accept escapeHatch — ` +
114
122
  "validation hooks receive no context to switch identity with.");
115
123
  }
116
124
  }
117
125
  else if (options?.escapeHatch !== undefined &&
118
126
  options.escapeHatch.reason.trim().length === 0) {
119
- throw new Error(`[Feature ${name}] r.hook("${type}", ...) declares { escapeHatch: { reason: "" } } — ` +
127
+ throw new Error(`[Feature ${name}] r.hook({ type: "${type}", … }) declares escapeHatch: { reason: "" } — ` +
120
128
  "the reason must be a non-empty string explaining why this hook switches identity to SYSTEM.");
121
129
  }
122
130
  const hookGrantsProblem = options?.escapeHatch && escapeHatchGrantsProblem(options.escapeHatch);
123
131
  if (hookGrantsProblem) {
124
- throw new Error(`[Feature ${name}] r.hook("${type}", ...) declares an invalid escapeHatch — ${hookGrantsProblem}`);
132
+ throw new Error(`[Feature ${name}] r.hook({ type: "${type}", … }) declares an invalid escapeHatch — ${hookGrantsProblem}`);
125
133
  }
126
134
  // Wrapped once for both branches below; validation hooks stay unwrapped (no context).
127
135
  const hookLabel = `${type} hook of feature "${name}"`;
@@ -135,7 +143,7 @@ export function buildUiExtensionsMethods(state, name) {
135
143
  !Array.isArray(target) &&
136
144
  "allOf" in target) {
137
145
  if (!isEntityWideHookType(type)) {
138
- throw new Error(`[Feature ${name}] r.hook("${type}", { allOf }, ...) only supports ` +
146
+ throw new Error(`[Feature ${name}] r.hook({ type: "${type}", target: { allOf }, … }) only supports ` +
139
147
  `postSave/preDelete/postDelete/postQuery, not "${type}".`);
140
148
  }
141
149
  registerEntityWideHook(state, name, type, resolveName(target.allOf), wrapped, // @cast-boundary engine-bridge
@@ -241,20 +249,20 @@ export function buildUiExtensionsMethods(state, name) {
241
249
  });
242
250
  },
243
251
  /**
244
- * Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
245
- * unter dem genannten Namen bereit. Die eigentliche Implementation
246
- * wird separat als Query- oder Write-Handler unter dem QN-Pattern
247
- * registriert; r.exposesApi ist reine Boot-Check-Surface.
252
+ * Marker declaration: this feature provides a cross-feature API under
253
+ * the given name. The implementation is registered separately as a query
254
+ * or write handler under the QN pattern; r.exposesApi only feeds the
255
+ * boot check.
248
256
  *
249
- * Beispiel:
257
+ * Example:
250
258
  * defineFeature("compliance-profiles", (r) => {
251
259
  * r.exposesApi("compliance.forTenant");
252
260
  * r.queryHandler({ name: "compliance:query:for-tenant", ... });
253
261
  * });
254
262
  * defineFeature("user-data-rights", (r) => {
255
- * r.requires("compliance-profiles");
263
+ * r.requires({ features: ["compliance-profiles"] });
256
264
  * r.usesApi("compliance.forTenant");
257
- * // ruft im Handler: ctx.callQuery("compliance:query:for-tenant", ...)
265
+ * // in a handler: ctx.callQuery("compliance:query:for-tenant", ...)
258
266
  * });
259
267
  */
260
268
  exposesApi(apiName) {
@@ -18,5 +18,30 @@ export type HandlerFieldReferenceReading = {
18
18
  readonly kind: "ok";
19
19
  readonly reference: HandlerFieldReference;
20
20
  };
21
+ export type HandlerFieldPathSegment = {
22
+ readonly kind: "field";
23
+ readonly name: string;
24
+ } | {
25
+ readonly kind: "element";
26
+ } | {
27
+ readonly kind: "recordKey";
28
+ } | {
29
+ readonly kind: "recordValue";
30
+ };
31
+ export type HandlerFieldReferenceAtPath = {
32
+ readonly path: string;
33
+ readonly segments: readonly HandlerFieldPathSegment[];
34
+ readonly reading: Exclude<HandlerFieldReferenceReading, {
35
+ kind: "none";
36
+ }>;
37
+ };
21
38
  export declare function readHandlerFieldReference(schema: z.ZodType, field: string): HandlerFieldReferenceReading;
39
+ /** All reference metas of a handler input schema, including fields nested in arrays,
40
+ * objects and records. `z.lazy()` is not followed. */
41
+ export declare function readHandlerFieldReferences(schema: z.ZodType): readonly HandlerFieldReferenceAtPath[];
42
+ export declare function handlerFieldReferences(schema: z.ZodType): readonly {
43
+ readonly path: string;
44
+ readonly segments: readonly HandlerFieldPathSegment[];
45
+ readonly reference: HandlerFieldReference;
46
+ }[];
22
47
  export declare function handlerFieldReference(schema: z.ZodType, field: string): HandlerFieldReference | undefined;