@cosmicdrift/kumiko-framework 0.365.0 → 0.366.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 (114) 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 +3 -3
  47. package/agents/examples/user-profile.md +1 -1
  48. package/agents/examples/webhook-step.md +12 -2
  49. package/agents/examples/writeform-section.md +21 -15
  50. package/agents/features.md +1 -1
  51. package/agents/widgets.md +5 -0
  52. package/dist/api/server.js +20 -4
  53. package/dist/bun-db/query.js +16 -1
  54. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  55. package/dist/crypto/pg-kms-adapter.js +10 -6
  56. package/dist/db/entity-table-meta.js +4 -4
  57. package/dist/db/event-store-executor-write.js +16 -12
  58. package/dist/engine/boot-validator/api-ext.js +2 -2
  59. package/dist/engine/boot-validator/config-deps.js +1 -1
  60. package/dist/engine/boot-validator/handler-field-references.js +3 -10
  61. package/dist/engine/boot-validator/screens.js +1 -1
  62. package/dist/engine/build-app-schema.js +3 -6
  63. package/dist/engine/define-feature.js +5 -5
  64. package/dist/engine/entity-handlers.d.ts +2 -2
  65. package/dist/engine/entity-handlers.js +5 -5
  66. package/dist/engine/factories.d.ts +11 -11
  67. package/dist/engine/factories.js +11 -11
  68. package/dist/engine/feature-ast/extractors/round2.js +16 -2
  69. package/dist/engine/feature-ast/render.js +4 -7
  70. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  71. package/dist/engine/feature-config-events-jobs.js +3 -0
  72. package/dist/engine/feature-entity-handlers.d.ts +4 -23
  73. package/dist/engine/feature-entity-handlers.js +77 -100
  74. package/dist/engine/feature-ui-extensions.d.ts +9 -14
  75. package/dist/engine/feature-ui-extensions.js +22 -14
  76. package/dist/engine/handler-field-references.d.ts +25 -0
  77. package/dist/engine/handler-field-references.js +80 -6
  78. package/dist/engine/index.d.ts +3 -3
  79. package/dist/engine/index.js +2 -2
  80. package/dist/engine/object-form.d.ts +2 -0
  81. package/dist/engine/object-form.js +13 -10
  82. package/dist/engine/registry-state.js +2 -2
  83. package/dist/engine/registry-validate.d.ts +2 -0
  84. package/dist/engine/registry-validate.js +42 -2
  85. package/dist/engine/registry.js +3 -1
  86. package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
  87. package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
  88. package/dist/engine/types/index.d.ts +2 -2
  89. package/dist/files/feature.js +1 -1
  90. package/dist/jobs/job-runner.d.ts +1 -0
  91. package/dist/jobs/job-runner.js +83 -17
  92. package/dist/jobs/max-per-tenant.d.ts +8 -0
  93. package/dist/jobs/max-per-tenant.js +14 -0
  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/index.d.ts +2 -0
  98. package/dist/pipeline/index.js +1 -0
  99. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  100. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  101. package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
  102. package/dist/pipeline/tenant-timezone-config-key.js +6 -0
  103. package/dist/testing/index.d.ts +1 -0
  104. package/dist/testing/index.js +1 -0
  105. package/dist/testing/manual-clock.d.ts +14 -0
  106. package/dist/testing/manual-clock.js +12 -0
  107. package/dist/time/index.d.ts +1 -1
  108. package/dist/time/tz-context.d.ts +1 -1
  109. package/dist/time/tz-context.js +9 -4
  110. package/dist/upgrade-cli.js +13 -2
  111. package/package.json +4 -4
  112. package/src/changes.json +58 -0
  113. package/src/scripts/codemod/README.md +23 -0
  114. package/src/scripts/codemod/object-form.ts +709 -0
@@ -147,33 +147,31 @@ export const orderEntity = createEntity({
147
147
 
148
148
  const orderTable = buildEntityTable("order", orderEntity);
149
149
  export const orderFeature = defineFeature("orders", (r) => {
150
- r.entity("order", orderEntity);
150
+ r.entity({ name: "order", ...orderEntity });
151
151
 
152
152
  const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
153
153
 
154
- r.writeHandler(
155
- "order:place",
156
- z.object({
154
+ r.writeHandler({
155
+ name: "order:place",
156
+ schema: z.object({
157
157
  customerName: z.string().min(1),
158
158
  product: z.string().min(1),
159
159
  }),
160
- async (event, ctx) =>
160
+ handler: async (event, ctx) =>
161
161
  orderExecutor.create({ ...event.payload, status: "pending" }, event.user, ctx.db),
162
- { access: { roles: ["Admin", "Customer"] } },
163
- );
164
-
165
- r.queryHandler(
166
- "order:list",
167
- z.object({ limit: z.number().optional() }),
168
- async (query, ctx) => orderExecutor.list(query.payload, query.user, ctx.db),
169
- {
170
- access: {
171
- openToAll: {
172
- reason:
173
- "any signed-in user may list orders in this recipe; placing an order still requires a role above",
174
- },
162
+ access: { roles: ["Admin", "Customer"] },
163
+ });
164
+
165
+ r.queryHandler({
166
+ name: "order:list",
167
+ schema: z.object({ limit: z.number().optional() }),
168
+ handler: async (query, ctx) => orderExecutor.list(query.payload, query.user, ctx.db),
169
+ access: {
170
+ openToAll: {
171
+ reason:
172
+ "any signed-in user may list orders in this recipe; placing an order still requires a role above",
175
173
  },
176
174
  },
177
- );
175
+ });
178
176
  });
179
177
  ```
@@ -286,7 +286,7 @@ export function createMailTriageFeature(): FeatureDefinition {
286
286
  r.describe(
287
287
  "Sample consumer for inbound-mail-foundation: a worker-lane job triggered by the ingest-message write-handler turns every inbound mail into a triage item.",
288
288
  );
289
- r.requires("inbound-mail-foundation");
289
+ r.requires({ features: ["inbound-mail-foundation"] });
290
290
 
291
291
  // The job receives the dispatcher-validated ingest payload — i.e.
292
292
  // the PLAINTEXT normalized mail (PII encryption happens inside the
@@ -178,18 +178,18 @@ const orderEntity = createEntity({
178
178
 
179
179
  export function createLaneRoutingFeature(): FeatureDefinition {
180
180
  return defineFeature("orders", (r) => {
181
- r.entity("order", orderEntity);
181
+ r.entity({ name: "order", ...orderEntity });
182
182
 
183
183
  // Write handler — runs in the API process (or wherever the command
184
184
  // lands). Event "orders:write:order:create" is the auto-generated
185
185
  // write-event both jobs trigger on.
186
- r.writeHandler(
187
- "order:create",
188
- z.object({
186
+ r.writeHandler({
187
+ name: "order:create",
188
+ schema: z.object({
189
189
  customerName: z.string().min(1),
190
190
  amount: z.number().positive(),
191
191
  }),
192
- async (event) => ({
192
+ handler: async (event) => ({
193
193
  isSuccess: true as const,
194
194
  data: {
195
195
  id: 1,
@@ -197,15 +197,13 @@ export function createLaneRoutingFeature(): FeatureDefinition {
197
197
  amount: event.payload.amount,
198
198
  },
199
199
  }),
200
- {
201
- access: {
202
- openToAll: {
203
- reason:
204
- "demo recipe: any signed-in user may place an order; the handler is a stub with no persistence",
205
- },
200
+ access: {
201
+ openToAll: {
202
+ reason:
203
+ "demo recipe: any signed-in user may place an order; the handler is a stub with no persistence",
206
204
  },
207
205
  },
208
- );
206
+ });
209
207
 
210
208
  // Receipt-rendering: heavy CPU work. Pinned to `worker` so the
211
209
  // BullMQ consumer on the API process never picks it up — even if the
@@ -383,7 +383,7 @@ import {
383
383
  import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
384
384
 
385
385
  // Beide Features aktivieren — template-resolver ist Foundation, legal-pages
386
- // requires sie. r.requires("template-resolver") greift automatisch im
386
+ // requires sie. r.requires({ features: ["template-resolver"] }) greift automatisch im
387
387
  // legal-pages-Feature.
388
388
  export const templateResolverFeature = createTemplateResolverFeature();
389
389
  export const legalPagesFeature = createLegalPagesFeature();
@@ -20,19 +20,19 @@ back.
20
20
 
21
21
  ## What it shows
22
22
 
23
- - **`r.hook("validation", handlerRef, fn)`** — runs before the
23
+ - **`r.hook({ type: "validation", target: handlerRef, handler })`** — runs before the
24
24
  handler body executes. Returns either `null` (pass) or an array of
25
25
  `{ field, error }` objects (fail). Used here to reject titles
26
26
  containing the word "spam" and titles longer than 200 characters.
27
- - **`r.hook("postSave", { allOf: entityRef }, fn)`** — fires after every
27
+ - **`r.hook({ type: "postSave", target: { allOf: entityRef }, handler })`** — fires after every
28
28
  successful save (create or update) in the same transaction.
29
29
  `result.isNew` distinguishes create from update; `result.changes`
30
30
  carries the changed fields only.
31
- - **`r.hook("preDelete", { allOf: entityRef }, fn)`** — fires inside the
31
+ - **`r.hook({ type: "preDelete", target: { allOf: entityRef }, handler })`** — fires inside the
32
32
  delete transaction. Throws on invariant violations (here: "published
33
33
  articles cannot be deleted") to roll the delete back. The handler
34
34
  receives the full row snapshot — no extra load needed.
35
- - **`r.hook("postDelete", { allOf: entityRef }, fn)`** — fires after commit.
35
+ - **`r.hook({ type: "postDelete", target: { allOf: entityRef }, handler })`** — fires after commit.
36
36
  External side effects (logs, notifications) belong here because
37
37
  failures cannot roll the delete back.
38
38
 
@@ -301,7 +301,7 @@ export const hookLog: { type: string; data: Record<string, unknown> }[] = [];
301
301
  const editorWrite = { access: { roles: ["Admin", "Editor"] } } as const;
302
302
 
303
303
  export const articlesFeature = defineFeature("blog", (r) => {
304
- const article = r.entity("article", articleEntity);
304
+ const article = r.entity({ name: "article", ...articleEntity });
305
305
 
306
306
  const articleCreate = r.writeHandler(
307
307
  defineEntityCreateHandler("article", articleEntity, editorWrite),
@@ -312,33 +312,45 @@ export const articlesFeature = defineFeature("blog", (r) => {
312
312
  r.writeHandler(defineEntityDeleteHandler("article", articleEntity, editorWrite));
313
313
 
314
314
  // --- Validation hook on create: reject banned words + length ---
315
- r.hook("validation", articleCreate, (data) => {
316
- const title = data["title"] as string;
317
- if (title.toLowerCase().includes("spam")) {
318
- return [{ field: "title", error: "title_contains_banned_word" }];
319
- }
320
- if (title.length > 200) {
321
- return [{ field: "title", error: "title_too_long" }];
322
- }
323
- return null;
315
+ r.hook({
316
+ type: "validation",
317
+ target: articleCreate,
318
+ handler: (data) => {
319
+ const title = data["title"] as string;
320
+ if (title.toLowerCase().includes("spam")) {
321
+ return [{ field: "title", error: "title_contains_banned_word" }];
322
+ }
323
+ if (title.length > 200) {
324
+ return [{ field: "title", error: "title_too_long" }];
325
+ }
326
+ return null;
327
+ },
324
328
  });
325
329
 
326
330
  // --- Validation hook on update: length check on title changes ---
327
- r.hook("validation", articleUpdate, (data) => {
328
- const changes = data["changes"] as Record<string, unknown> | undefined;
329
- const title = changes?.["title"] as string | undefined;
330
- if (title && title.length > 200) {
331
- return [{ field: "title", error: "title_too_long" }];
332
- }
333
- return null;
331
+ r.hook({
332
+ type: "validation",
333
+ target: articleUpdate,
334
+ handler: (data) => {
335
+ const changes = data["changes"] as Record<string, unknown> | undefined;
336
+ const title = changes?.["title"] as string | undefined;
337
+ if (title && title.length > 200) {
338
+ return [{ field: "title", error: "title_too_long" }];
339
+ }
340
+ return null;
341
+ },
334
342
  });
335
343
 
336
344
  // --- postSave entity hook: log all saves ---
337
- r.hook("postSave", { allOf: article }, async (result: SaveContext) => {
338
- hookLog.push({
339
- type: result.isNew ? "created" : "updated",
340
- data: { id: result.id, changes: result.changes },
341
- });
345
+ r.hook({
346
+ type: "postSave",
347
+ target: { allOf: article },
348
+ handler: async (result: SaveContext) => {
349
+ hookLog.push({
350
+ type: result.isNew ? "created" : "updated",
351
+ data: { id: result.id, changes: result.changes },
352
+ });
353
+ },
342
354
  });
343
355
 
344
356
  // --- preDelete entity hook: block delete when the article is still published.
@@ -346,23 +358,31 @@ export const articlesFeature = defineFeature("blog", (r) => {
346
358
  // the delete rolls back before any projection or event is written. The
347
359
  // payload.data carries the full row snapshot taken just before delete —
348
360
  // no extra load needed.
349
- r.hook("preDelete", { allOf: article }, async (payload) => {
350
- if (payload.data["isPublished"] === true) {
351
- throw new ConflictError({
352
- message: `article ${payload.id} is still published`,
353
- i18nKey: "errors.publishedArticleCannotBeDeleted",
354
- details: { reason: "published_article_cannot_be_deleted", entityId: payload.id },
355
- });
356
- }
361
+ r.hook({
362
+ type: "preDelete",
363
+ target: { allOf: article },
364
+ handler: async (payload) => {
365
+ if (payload.data["isPublished"] === true) {
366
+ throw new ConflictError({
367
+ message: `article ${payload.id} is still published`,
368
+ i18nKey: "errors.publishedArticleCannotBeDeleted",
369
+ details: { reason: "published_article_cannot_be_deleted", entityId: payload.id },
370
+ });
371
+ }
372
+ },
357
373
  });
358
374
 
359
375
  // --- postDelete entity hook: log deletions after the transaction commits.
360
376
  // Default phase is afterCommit so failures here can't roll back the delete.
361
- r.hook("postDelete", { allOf: article }, async (payload) => {
362
- hookLog.push({
363
- type: "deleted",
364
- data: { id: payload.id },
365
- });
377
+ r.hook({
378
+ type: "postDelete",
379
+ target: { allOf: article },
380
+ handler: async (payload) => {
381
+ hookLog.push({
382
+ type: "deleted",
383
+ data: { id: payload.id },
384
+ });
385
+ },
366
386
  });
367
387
  });
368
388
  ```
@@ -326,7 +326,7 @@ describe("managed config — tenant cascade override", () => {
326
326
 
327
327
  describe("feature contract — backing:secrets declares the secrets dependency", () => {
328
328
  test("registry build rejects integrationsFeature without the secrets feature", () => {
329
- // r.requires("secrets") is what makes this throw: without the dependency a
329
+ // r.requires({ features: ["secrets"] }) is what makes this throw: without the dependency a
330
330
  // consumer would only discover the missing tenant_secrets table at runtime.
331
331
  // config is registered so the failure isolates the secrets requirement.
332
332
  expect(() => createRegistry([createConfigFeature(), integrationsFeature])).toThrow(/secrets/);
@@ -375,12 +375,12 @@ export const smtpHostHandle: ConfigKeyHandle<"text"> = {
375
375
  };
376
376
 
377
377
  export const integrationsFeature = defineFeature(FEATURE, (r) => {
378
- r.requires("config");
378
+ r.requires({ features: ["config"] });
379
379
  // backing:"secrets" stores payment-api-key in the secrets envelope, which
380
380
  // the secrets feature provisions — declare the dependency so registry-build
381
381
  // enforces it instead of leaving a consumer to discover the missing
382
382
  // tenant_secrets table at runtime.
383
- r.requires("secrets");
383
+ r.requires({ features: ["secrets"] });
384
384
 
385
385
  r.config({
386
386
  keys: {
@@ -407,15 +407,15 @@ export const integrationsFeature = defineFeature(FEATURE, (r) => {
407
407
 
408
408
  // Internal-read probe: the owning feature reads its own secrets-backed key
409
409
  // via ctx.config and receives the revealed plaintext, never the mask.
410
- r.queryHandler(
411
- "peek-payment-key",
412
- z.object({}),
413
- async (_query, ctx) => {
410
+ r.queryHandler({
411
+ name: "peek-payment-key",
412
+ schema: z.object({}),
413
+ handler: async (_query, ctx) => {
414
414
  if (!ctx.config) throw new Error("ctx.config not wired");
415
415
  return { value: await ctx.config(paymentApiKeyHandle) };
416
416
  },
417
- { access: { roles: ["SystemAdmin"] } },
418
- );
417
+ access: { roles: ["SystemAdmin"] },
418
+ });
419
419
 
420
420
  r.translations({
421
421
  keys: {
@@ -332,7 +332,7 @@ import {
332
332
  } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
333
333
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
334
334
 
335
- // managed-pages declares `r.requires("config")` for its branding keys — the
335
+ // managed-pages declares `r.requires({ features: ["config"] })` for its branding keys — the
336
336
  // config feature must be in the stack (runProdApp auto-mounts it; setupTestStack
337
337
  // does not, so the recipe lists it explicitly).
338
338
  export const configFeature = createConfigFeature();
@@ -76,7 +76,7 @@ The component itself is `notes-history/web/notes-section.tsx`
76
76
  ```
77
77
  notes-history → core bundle: note-entry entity, add-note handler, list query
78
78
  task-management → our feature: a plain `task` entity. Declares
79
- r.requires("notes-history") only so the bundle is
79
+ r.requires({ features: ["notes-history"] }) only so the bundle is
80
80
  mounted — the task itself is completely notes-agnostic.
81
81
  ```
82
82
 
@@ -274,9 +274,9 @@ export const taskFeature = defineFeature("task-management", (r) => {
274
274
  // notes-history is non-optional for this recipe: the demo notes tasks.
275
275
  // The task feature itself stays completely notes-agnostic — it only
276
276
  // declares the dependency so the bundle is mounted.
277
- r.requires("notes-history");
277
+ r.requires({ features: ["notes-history"] });
278
278
 
279
- r.entity("task", taskEntity);
279
+ r.entity({ name: "task", ...taskEntity });
280
280
 
281
281
  r.writeHandler({
282
282
  name: "task:create",
@@ -227,20 +227,20 @@ const AUDIT_ROW_LIMIT = 100;
227
227
  const access = { access: { roles: ["Admin", "User"] } } as const;
228
228
 
229
229
  export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
230
- r.entity("inspection", inspectionEntity);
230
+ r.entity({ name: "inspection", ...inspectionEntity });
231
231
  const { executor: inspectionExecutor } = createEntityExecutor("inspection", inspectionEntity);
232
232
 
233
- r.writeHandler(
234
- "inspection:create",
235
- z.object({
233
+ r.writeHandler({
234
+ name: "inspection:create",
235
+ schema: z.object({
236
236
  assetName: z.string().min(1),
237
237
  inspectedOn: z.iso.date(),
238
238
  result: z.enum(INSPECTION_RESULTS),
239
239
  inspector: z.string().optional(),
240
240
  }),
241
- async (event, ctx) => inspectionExecutor.create(event.payload, event.user, ctx.db),
242
- access,
243
- );
241
+ handler: async (event, ctx) => inspectionExecutor.create(event.payload, event.user, ctx.db),
242
+ ...access,
243
+ });
244
244
 
245
245
  r.writeHandler(
246
246
  defineWriteHandler({
@@ -47,7 +47,7 @@ and why the combination is the production-realistic shape (not just
47
47
  an API tour).
48
48
 
49
49
  If you've only ever seen the free-form handler signature
50
- (`r.writeHandler(name, schema, async fn, opts)`), compare:
50
+ (`r.writeHandler({ name, schema, handler, access })`), compare:
51
51
 
52
52
  - `samples/recipes/custom-handlers/src/feature.ts` — a similar
53
53
  domain, written in the free-form style.
@@ -439,7 +439,7 @@ const productExecutor = createEventStoreExecutor(productTable, productEntity, {
439
439
  });
440
440
 
441
441
  export const inventoryFeature = defineFeature("inventory", (r) => {
442
- r.entity("product", productEntity);
442
+ r.entity({ name: "product", ...productEntity });
443
443
  r.requires.projection("read_inventory_low_stock_alerts");
444
444
 
445
445
  // Domain-events on the product stream (alongside the auto-CRUD
@@ -779,14 +779,14 @@ export const inventoryFeature = defineFeature("inventory", (r) => {
779
779
  // only; queries get the existing handler signature. The list
780
780
  // below is what the integration test reads against.
781
781
  // -------------------------------------------------------------
782
- r.queryHandler(
783
- "low-stock-alerts:list",
784
- z.object({}),
785
- async (_query, ctx) => {
782
+ r.queryHandler({
783
+ name: "low-stock-alerts:list",
784
+ schema: z.object({}),
785
+ handler: async (_query, ctx) => {
786
786
  const rows = await selectMany(ctx.db, lowStockAlertsTable);
787
787
  return { rows };
788
788
  },
789
- { access: { roles: ["Admin"] } },
790
- );
789
+ access: { roles: ["Admin"] },
790
+ });
791
791
  });
792
792
  ```
@@ -343,7 +343,7 @@ export const shareByTokenQuery = defineQueryHandler({
343
343
  });
344
344
 
345
345
  export const publicShareTokenFeature = defineFeature("public-share", (r) => {
346
- r.entity("share-link", shareLinkEntity);
346
+ r.entity({ name: "share-link", ...shareLinkEntity });
347
347
  r.writeHandler(shareLinkCreateWrite);
348
348
  r.writeHandler(shareLinkRevokeWrite);
349
349
  r.queryHandler(shareByTokenQuery);
@@ -197,15 +197,13 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
197
197
  // Per-user budget. Real apps tune `limit` to actual handler cost — a
198
198
  // search call against a sharded index might warrant 5/min, a full
199
199
  // export 1/min. The bucket is `user:<userId>` (see rate-limit/bucket.ts).
200
- r.queryHandler(
201
- "expensive-search",
202
- z.object({ q: z.string().min(1) }),
203
- async ({ payload }) => ({ q: payload.q, hits: 0 }),
204
- {
205
- access: { roles: ["Admin", "User"] },
206
- rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
207
- },
208
- );
200
+ r.queryHandler({
201
+ name: "expensive-search",
202
+ schema: z.object({ q: z.string().min(1) }),
203
+ handler: async ({ payload }) => ({ q: payload.q, hits: 0 }),
204
+ access: { roles: ["Admin", "User"] },
205
+ rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
206
+ });
209
207
  });
210
208
  }
211
209
  ```
@@ -115,7 +115,10 @@ describe("record-detail-layout showcase — registration", () => {
115
115
  describe("record-detail-layout showcase — boot-validator catches author mistakes", () => {
116
116
  test("metric without a fieldLabels entry fails boot — no fallback to the raw column name", () => {
117
117
  const broken = defineFeature("broken-order-desk", (r) => {
118
- r.queryHandler("order:detail", z.object({ id: z.string() }), async () => ({ id: "1" }), {
118
+ r.queryHandler({
119
+ name: "order:detail",
120
+ schema: z.object({ id: z.string() }),
121
+ handler: async () => ({ id: "1" }),
119
122
  access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
120
123
  });
121
124
  r.screen({
@@ -133,7 +136,10 @@ describe("record-detail-layout showcase — boot-validator catches author mistak
133
136
 
134
137
  test("tabs with only one section fails boot", () => {
135
138
  const broken = defineFeature("broken-order-desk-tabs", (r) => {
136
- r.queryHandler("order:detail", z.object({ id: z.string() }), async () => ({ id: "1" }), {
139
+ r.queryHandler({
140
+ name: "order:detail",
141
+ schema: z.object({ id: z.string() }),
142
+ handler: async () => ({ id: "1" }),
137
143
  access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
138
144
  });
139
145
  r.screen({
@@ -183,10 +189,10 @@ export function createOrderDeskFeature(): FeatureDefinition {
183
189
  return defineFeature("order-desk", (r) => {
184
190
  r.systemScope();
185
191
 
186
- r.queryHandler(
187
- "order:detail",
188
- rowIdSchema,
189
- async (query) => ({
192
+ r.queryHandler({
193
+ name: "order:detail",
194
+ schema: rowIdSchema,
195
+ handler: async (query) => ({
190
196
  id: query.payload.id,
191
197
  customerName: "Aiko Tanaka",
192
198
  orderNumber: "SO-10482",
@@ -199,33 +205,33 @@ export function createOrderDeskFeature(): FeatureDefinition {
199
205
  billingAddress: "Karl-Marx-Straße 12, 12043 Berlin",
200
206
  salesRep: "Jonas Weber",
201
207
  }),
202
- { access: { roles: ["Support", "Admin"] } },
203
- );
208
+ access: { roles: ["Support", "Admin"] },
209
+ });
204
210
 
205
- r.queryHandler(
206
- "order:items",
207
- rowIdSchema,
208
- async () => ({
211
+ r.queryHandler({
212
+ name: "order:items",
213
+ schema: rowIdSchema,
214
+ handler: async () => ({
209
215
  rows: [
210
216
  { sku: "KB-100", description: "Mechanical keyboard", quantity: 1, unitPrice: 148 },
211
217
  { sku: "MS-220", description: "Wireless mouse", quantity: 2, unitPrice: 45 },
212
218
  { sku: "PAD-01", description: "Desk mat", quantity: 1, unitPrice: 22 },
213
219
  ],
214
220
  }),
215
- { access: { roles: ["Support", "Admin"] } },
216
- );
221
+ access: { roles: ["Support", "Admin"] },
222
+ });
217
223
 
218
- r.queryHandler(
219
- "order:payments",
220
- rowIdSchema,
221
- async () => ({
224
+ r.queryHandler({
225
+ name: "order:payments",
226
+ schema: rowIdSchema,
227
+ handler: async () => ({
222
228
  rows: [
223
229
  { paidAt: "2026-08-21", amount: 936, method: "card", status: "settled" },
224
230
  { paidAt: "2026-08-25", amount: 312, method: "invoice", status: "pending" },
225
231
  ],
226
232
  }),
227
- { access: { roles: ["Support", "Admin"] } },
228
- );
233
+ access: { roles: ["Support", "Admin"] },
234
+ });
229
235
 
230
236
  r.screen({
231
237
  id: "order-detail",
@@ -134,7 +134,7 @@ export const categoryTable = table("read_sample_categories", {
134
134
  });
135
135
 
136
136
  export const categoryFeature = defineFeature("catalog", (r) => {
137
- r.entity("category", categoryEntity);
137
+ r.entity({ name: "category", ...categoryEntity });
138
138
 
139
139
  // Static reference data — seeded on boot, upserted on changes
140
140
  r.referenceData(
@@ -244,8 +244,8 @@ const createOnly = {
244
244
  } as const;
245
245
 
246
246
  export const relationsFeature = defineFeature("org", (r) => {
247
- const team = r.entity("team", teamEntity);
248
- const member = r.entity("member", memberEntity);
247
+ const team = r.entity({ name: "team", ...teamEntity });
248
+ const member = r.entity({ name: "member", ...memberEntity });
249
249
 
250
250
  r.relation(team, "members", {
251
251
  type: "hasMany",
@@ -172,7 +172,7 @@ describe("screens-nav showcase — boot-validator catches author mistakes", () =
172
172
  // Typo-protection: field refs must exist on the entity. Mis-spelled
173
173
  // column names would otherwise silently vanish from the form.
174
174
  const broken = defineFeature("broken-shop", (r) => {
175
- r.entity("book", bookEntity);
175
+ r.entity({ name: "book", ...bookEntity });
176
176
  r.screen({
177
177
  id: "book-edit",
178
178
  type: "entityEdit",
@@ -256,7 +256,7 @@ export const bookEntity = createEntity({
256
256
  export function createBookshopFeature(): FeatureDefinition {
257
257
  return defineFeature("bookshop", (r) => {
258
258
  r.systemScope();
259
- r.entity("book", bookEntity);
259
+ r.entity({ name: "book", ...bookEntity });
260
260
 
261
261
  // entityList — columns carry string shorthand and the object form
262
262
  // side by side; the framework normalizes both through
@@ -186,48 +186,46 @@ export const productEntity = createEntity({
186
186
  const productTable = buildEntityTable("product", productEntity);
187
187
 
188
188
  export const productFeature = defineFeature("shop", (r) => {
189
- r.entity("product", productEntity);
189
+ r.entity({ name: "product", ...productEntity });
190
190
 
191
- r.writeHandler(
192
- "product:create",
193
- z.object({
191
+ r.writeHandler({
192
+ name: "product:create",
193
+ schema: z.object({
194
194
  name: z.string().min(1),
195
195
  brand: z.string().optional(),
196
196
  sku: z.string().min(1),
197
197
  category: z.string().optional(),
198
198
  }),
199
- async (event, ctx) => {
199
+ handler: async (event, ctx) => {
200
200
  const crud = createEventStoreExecutor(productTable, productEntity, {
201
201
  searchAdapter: ctx.searchAdapter,
202
202
  entityName: "product",
203
203
  });
204
204
  return crud.create(event.payload, event.user, ctx.db);
205
205
  },
206
- { access: { roles: ["Admin"] } },
207
- );
206
+ access: { roles: ["Admin"] },
207
+ });
208
208
 
209
- r.queryHandler(
210
- "product:list",
211
- z.object({
209
+ r.queryHandler({
210
+ name: "product:list",
211
+ schema: z.object({
212
212
  search: z.string().optional(),
213
213
  limit: z.number().optional(),
214
214
  }),
215
- async (query, ctx) => {
215
+ handler: async (query, ctx) => {
216
216
  const crud = createEventStoreExecutor(productTable, productEntity, {
217
217
  searchAdapter: ctx.searchAdapter,
218
218
  entityName: "product",
219
219
  });
220
220
  return crud.list(query.payload, query.user, ctx.db);
221
221
  },
222
- {
223
- access: {
224
- openToAll: {
225
- reason:
226
- "any signed-in user may search and list products in this recipe; creating " +
227
- "a product still requires the Admin role above",
228
- },
222
+ access: {
223
+ openToAll: {
224
+ reason:
225
+ "any signed-in user may search and list products in this recipe; creating " +
226
+ "a product still requires the Admin role above",
229
227
  },
230
228
  },
231
- );
229
+ });
232
230
  });
233
231
  ```
@@ -33,7 +33,7 @@ used to be silent; now the boot validator warns at `NODE_ENV=production`.
33
33
  ## Feature composition
34
34
 
35
35
  ```
36
- config → tenant's r.requires("config") dependency
36
+ config → tenant's r.requires({ features: ["config"] }) dependency
37
37
  user → cross-tenant identity (sessions' r.requires target)
38
38
  tenant → memberships (audit's r.requires target)
39
39
  auth-foundation → tokenVerifier extension point (sessions' r.requires target)