@cosmicdrift/kumiko-framework 0.365.1 → 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 (103) 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 +1 -1
  49. package/agents/examples/writeform-section.md +21 -15
  50. package/agents/widgets.md +5 -0
  51. package/dist/api/server.js +12 -4
  52. package/dist/bun-db/query.js +16 -1
  53. package/dist/db/entity-table-meta.js +4 -4
  54. package/dist/engine/boot-validator/api-ext.js +2 -2
  55. package/dist/engine/boot-validator/config-deps.js +1 -1
  56. package/dist/engine/boot-validator/handler-field-references.js +3 -10
  57. package/dist/engine/boot-validator/screens.js +1 -1
  58. package/dist/engine/build-app-schema.js +3 -6
  59. package/dist/engine/define-feature.js +5 -5
  60. package/dist/engine/entity-handlers.d.ts +2 -2
  61. package/dist/engine/entity-handlers.js +5 -5
  62. package/dist/engine/factories.d.ts +11 -11
  63. package/dist/engine/factories.js +11 -11
  64. package/dist/engine/feature-ast/extractors/round2.js +16 -2
  65. package/dist/engine/feature-ast/render.js +4 -7
  66. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  67. package/dist/engine/feature-config-events-jobs.js +3 -0
  68. package/dist/engine/feature-entity-handlers.d.ts +4 -23
  69. package/dist/engine/feature-entity-handlers.js +77 -100
  70. package/dist/engine/feature-ui-extensions.d.ts +9 -14
  71. package/dist/engine/feature-ui-extensions.js +22 -14
  72. package/dist/engine/handler-field-references.d.ts +25 -0
  73. package/dist/engine/handler-field-references.js +80 -6
  74. package/dist/engine/index.d.ts +3 -3
  75. package/dist/engine/index.js +2 -2
  76. package/dist/engine/object-form.d.ts +2 -0
  77. package/dist/engine/object-form.js +13 -10
  78. package/dist/engine/registry-state.js +2 -2
  79. package/dist/engine/registry-validate.d.ts +1 -0
  80. package/dist/engine/registry-validate.js +21 -2
  81. package/dist/engine/registry.js +2 -1
  82. package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
  83. package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
  84. package/dist/engine/types/index.d.ts +1 -1
  85. package/dist/files/feature.js +1 -1
  86. package/dist/jobs/job-runner.js +16 -2
  87. package/dist/pipeline/dispatch-query.js +1 -1
  88. package/dist/pipeline/dispatch-shared.d.ts +2 -1
  89. package/dist/pipeline/dispatch-shared.js +3 -6
  90. package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
  91. package/dist/pipeline/tenant-timezone-config-key.js +6 -0
  92. package/dist/testing/index.d.ts +1 -0
  93. package/dist/testing/index.js +1 -0
  94. package/dist/testing/manual-clock.d.ts +14 -0
  95. package/dist/testing/manual-clock.js +12 -0
  96. package/dist/time/index.d.ts +1 -1
  97. package/dist/time/tz-context.d.ts +1 -1
  98. package/dist/time/tz-context.js +9 -4
  99. package/dist/upgrade-cli.js +13 -2
  100. package/package.json +4 -4
  101. package/src/changes.json +40 -0
  102. package/src/scripts/codemod/README.md +23 -0
  103. package/src/scripts/codemod/object-form.ts +709 -0
@@ -42,7 +42,8 @@ import { z } from "zod";
42
42
  defineFeature("designerDemo", (r) => {
43
43
  r.optionalRequires("analytics");
44
44
 
45
- r.entity("task", {
45
+ r.entity({
46
+ name: "task",
46
47
  fields: {
47
48
  title: { type: "text", required: true },
48
49
  done: { type: "boolean", default: false },
@@ -384,7 +384,7 @@ export const prospectsFeature = defineFeature("prospects", (r) => {
384
384
  read: openRead,
385
385
  verbs: { update: false },
386
386
  });
387
- r.entity("prospect", prospectEntity);
387
+ r.entity({ name: "prospect", ...prospectEntity });
388
388
 
389
389
  const { executor: suggestionExecutor } = createEntityExecutor("suggestion", suggestionEntity);
390
390
  const { executor: prospectExecutor } = createEntityExecutor("prospect", prospectEntity);
@@ -393,10 +393,10 @@ export const prospectsFeature = defineFeature("prospects", (r) => {
393
393
  // edited (RenderEdit's onChange `changes`, same delta the controlled mode
394
394
  // reports) — the handler owns merging that onto the suggestion it already
395
395
  // has, so an untouched field never has to round-trip through the browser.
396
- r.writeHandler(
397
- "prospect:accept",
398
- z.object({ suggestionId: z.uuid(), changes: acceptChangesSchema }),
399
- async (event, ctx) => {
396
+ r.writeHandler({
397
+ name: "prospect:accept",
398
+ schema: z.object({ suggestionId: z.uuid(), changes: acceptChangesSchema }),
399
+ handler: async (event, ctx) => {
400
400
  const suggestion = await suggestionExecutor.detail(
401
401
  { id: event.payload.suggestionId },
402
402
  event.user,
@@ -437,15 +437,15 @@ export const prospectsFeature = defineFeature("prospects", (r) => {
437
437
  if (!flipped.isSuccess) return flipped;
438
438
  return created;
439
439
  },
440
- adminWrite,
441
- );
440
+ ...adminWrite,
441
+ });
442
442
 
443
- r.queryHandler(
444
- "prospect:detail",
445
- z.object({ id: z.uuid() }),
446
- async (query, ctx) => prospectExecutor.detail(query.payload, query.user, ctx.db),
447
- openRead,
448
- );
443
+ r.queryHandler({
444
+ name: "prospect:detail",
445
+ schema: z.object({ id: z.uuid() }),
446
+ handler: async (query, ctx) => prospectExecutor.detail(query.payload, query.user, ctx.db),
447
+ ...openRead,
448
+ });
449
449
  });
450
450
  ```
451
451
 
@@ -554,7 +554,7 @@ const openRead = {
554
554
  } as const;
555
555
 
556
556
  export const embeddedFeature = defineFeature("contacts", (r) => {
557
- r.entity("contact", contactEntity);
557
+ r.entity({ name: "contact", ...contactEntity });
558
558
 
559
559
  r.writeHandler(contactCreate);
560
560
  r.queryHandler(contactDetail);
@@ -393,7 +393,7 @@ const chargeHandler = defineWriteHandler({
393
393
  });
394
394
 
395
395
  export const billingFeature = defineFeature(FEATURE, (r) => {
396
- r.requires("config");
396
+ r.requires({ features: ["config"] });
397
397
  r.config("stripe-api-key", stripeApiKeyDef);
398
398
  r.writeHandler(chargeHandler);
399
399
  // Kein r.screen/r.nav: der `mask`-Eintrag auf dem Key lässt
@@ -370,17 +370,17 @@ export const orderEntity = createEntity({
370
370
  const orderTable = buildEntityTable("order", orderEntity);
371
371
 
372
372
  export const ordersLiteFeature = defineFeature("orders-lite", (r) => {
373
- r.entity("order", orderEntity);
373
+ r.entity({ name: "order", ...orderEntity });
374
374
 
375
375
  // 1) Create — standard path. Zod schema rejects an empty cart; the
376
376
  // dispatcher converts the ZodIssue into a ValidationError with
377
377
  // details.fields[] (no handler code needed).
378
- r.writeHandler(
379
- "order:create",
380
- z.object({
378
+ r.writeHandler({
379
+ name: "order:create",
380
+ schema: z.object({
381
381
  totalCents: z.number().int().min(1, "cart_is_empty"),
382
382
  }),
383
- async (event, ctx) => {
383
+ handler: async (event, ctx) => {
384
384
  const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
385
385
  return crud.create(
386
386
  { ...event.payload, ownerId: event.user.id, status: "draft" },
@@ -388,8 +388,8 @@ export const ordersLiteFeature = defineFeature("orders-lite", (r) => {
388
388
  ctx.db,
389
389
  );
390
390
  },
391
- { access: { roles: ["User", "Admin"] } },
392
- );
391
+ access: { roles: ["User", "Admin"] },
392
+ });
393
393
 
394
394
  // 2) Pay — business rule: only placed orders can be paid. Shows three
395
395
  // different failure shapes in a single handler:
@@ -399,10 +399,10 @@ export const ordersLiteFeature = defineFeature("orders-lite", (r) => {
399
399
  // - UnprocessableError with reason from OrdersLiteReasons
400
400
  // - Framework-generated UnprocessableError with reason
401
401
  // FrameworkReasons.invalidTransition via guardTransition
402
- r.writeHandler(
403
- "order:pay",
404
- z.object({ id: z.uuid() }),
405
- async (event, ctx) => {
402
+ r.writeHandler({
403
+ name: "order:pay",
404
+ schema: z.object({ id: z.uuid() }),
405
+ handler: async (event, ctx) => {
406
406
  const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
407
407
 
408
408
  // NotFoundError — automatic reason = "order_not_found" via snake-case
@@ -449,16 +449,16 @@ export const ordersLiteFeature = defineFeature("orders-lite", (r) => {
449
449
  ctx.db,
450
450
  );
451
451
  },
452
- { access: { roles: ["User", "Admin"] } },
453
- );
452
+ access: { roles: ["User", "Admin"] },
453
+ });
454
454
 
455
455
  // 3) Cancel — shows ConflictError for a business conflict that isn't a
456
456
  // transition or a stale write. E.g. a refund window expired; the row
457
457
  // is fine but the action is no longer allowed.
458
- r.writeHandler(
459
- "order:cancel",
460
- z.object({ id: z.uuid() }),
461
- async (event, ctx) => {
458
+ r.writeHandler({
459
+ name: "order:cancel",
460
+ schema: z.object({ id: z.uuid() }),
461
+ handler: async (event, ctx) => {
462
462
  const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
463
463
  if (!current) return failNotFound("order", event.payload.id);
464
464
 
@@ -488,18 +488,18 @@ export const ordersLiteFeature = defineFeature("orders-lite", (r) => {
488
488
  ctx.db,
489
489
  );
490
490
  },
491
- { access: { roles: ["User", "Admin"] } },
492
- );
491
+ access: { roles: ["User", "Admin"] },
492
+ });
493
493
 
494
494
  // 4) Update — demonstrates the throw-based path (KumikoError raised
495
495
  // directly, not via writeFailure return). The dispatcher catches it
496
496
  // and wraps it in a WriteErrorInfo the same as the return path.
497
497
  // Use this style when a helper deep in the call tree needs to abort
498
498
  // without threading a WriteResult back up.
499
- r.writeHandler(
500
- "order:rename",
501
- z.object({ id: z.uuid(), nickname: z.string().min(1) }),
502
- async (event, ctx) => {
499
+ r.writeHandler({
500
+ name: "order:rename",
501
+ schema: z.object({ id: z.uuid(), nickname: z.string().min(1) }),
502
+ handler: async (event, ctx) => {
503
503
  const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
504
504
 
505
505
  if (!current) {
@@ -529,7 +529,7 @@ export const ordersLiteFeature = defineFeature("orders-lite", (r) => {
529
529
  },
530
530
  };
531
531
  },
532
- { access: { roles: ["User", "Admin"] } },
533
- );
532
+ access: { roles: ["User", "Admin"] },
533
+ });
534
534
  });
535
535
  ```
@@ -793,6 +793,32 @@ describe("Event Sourcing Showcase", () => {
793
793
  expect(body.error.details?.bucket).toBe("user");
794
794
  });
795
795
  });
796
+
797
+ describe("role-restricted write handlers", () => {
798
+ const viewer = createTestUser({ id: 2, roles: ["Viewer"] });
799
+
800
+ test("a caller without the Admin role is rejected on acknowledge and take-snapshot", async () => {
801
+ const { id } = await stack.http.writeOk<{ id: string }>(
802
+ "showcase:write:invoice:create",
803
+ { customer: "Acme" },
804
+ admin,
805
+ );
806
+
807
+ const acknowledgeError = await stack.http.writeErr(
808
+ "showcase:write:invoice:acknowledge",
809
+ { id, approverId: "u-42" },
810
+ viewer,
811
+ );
812
+ const snapshotError = await stack.http.writeErr(
813
+ "showcase:write:invoice:take-snapshot",
814
+ { id },
815
+ viewer,
816
+ );
817
+
818
+ expect(acknowledgeError.code).toBe("access_denied");
819
+ expect(snapshotError.code).toBe("access_denied");
820
+ });
821
+ });
796
822
  ```
797
823
 
798
824
  ### src/feature.ts
@@ -926,7 +952,7 @@ export const approverDirectoryTable = table("read_showcase_approver_directory",
926
952
  // --- Feature ---
927
953
 
928
954
  export const invoiceFeature = defineFeature("showcase", (r) => {
929
- r.entity("showcase-invoice", invoiceEntity);
955
+ r.entity({ name: "showcase-invoice", ...invoiceEntity });
930
956
 
931
957
  // Two domain events. "approved" is versioned — v1 stored `amount` as
932
958
  // string, v2 uses `amountCents` integer. The DECLARATIVE migration form
@@ -1059,21 +1085,21 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1059
1085
 
1060
1086
  // --- Write handlers ---
1061
1087
 
1062
- r.writeHandler(
1063
- "invoice:create",
1064
- z.object({ customer: z.string() }),
1065
- async (event, ctx) =>
1088
+ r.writeHandler({
1089
+ name: "invoice:create",
1090
+ schema: z.object({ customer: z.string() }),
1091
+ handler: async (event, ctx) =>
1066
1092
  invoiceExecutor.create(
1067
1093
  { customer: event.payload.customer, status: "draft" },
1068
1094
  event.user,
1069
1095
  ctx.db,
1070
1096
  ),
1071
- { access: { roles: ["Admin"] } },
1072
- );
1097
+ access: { roles: ["Admin"] },
1098
+ });
1073
1099
 
1074
- r.writeHandler(
1075
- "invoice:approve",
1076
- z.object({
1100
+ r.writeHandler({
1101
+ name: "invoice:approve",
1102
+ schema: z.object({
1077
1103
  id: z.uuid(),
1078
1104
  amountCents: z.number().int(),
1079
1105
  approvedBy: z.string(),
@@ -1082,7 +1108,7 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1082
1108
  geoRegion: z.string().optional(),
1083
1109
  abTestBucket: z.string().optional(),
1084
1110
  }),
1085
- async (event, ctx) => {
1111
+ handler: async (event, ctx) => {
1086
1112
  const headers: Record<string, string> = {};
1087
1113
  if (event.payload.geoRegion) headers["geoRegion"] = event.payload.geoRegion;
1088
1114
  if (event.payload.abTestBucket) headers["abTestBucket"] = event.payload.abTestBucket;
@@ -1095,13 +1121,13 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1095
1121
  });
1096
1122
  return { isSuccess: true as const, data: { id: event.payload.id } };
1097
1123
  },
1098
- { access: { roles: ["Admin"] } },
1099
- );
1124
+ access: { roles: ["Admin"] },
1125
+ });
1100
1126
 
1101
- r.writeHandler(
1102
- "invoice:acknowledge",
1103
- z.object({ id: z.uuid(), approverId: z.string() }),
1104
- async (event, ctx) => {
1127
+ r.writeHandler({
1128
+ name: "invoice:acknowledge",
1129
+ schema: z.object({ id: z.uuid(), approverId: z.string() }),
1130
+ handler: async (event, ctx) => {
1105
1131
  const row = await fetchOne<{ displayName: string }>(ctx.db, approverDirectoryTable, {
1106
1132
  approverId: event.payload.approverId,
1107
1133
  });
@@ -1114,13 +1140,13 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1114
1140
  });
1115
1141
  return { isSuccess: true as const, data: { id: event.payload.id } };
1116
1142
  },
1117
- { access: { roles: ["Admin"] } },
1118
- );
1143
+ access: { roles: ["Admin"] },
1144
+ });
1119
1145
 
1120
- r.writeHandler(
1121
- "invoice:pay",
1122
- z.object({ id: z.uuid(), amountCents: z.number().int() }),
1123
- async (event, ctx) => {
1146
+ r.writeHandler({
1147
+ name: "invoice:pay",
1148
+ schema: z.object({ id: z.uuid(), amountCents: z.number().int() }),
1149
+ handler: async (event, ctx) => {
1124
1150
  await ctx.appendEvent({
1125
1151
  aggregateId: event.payload.id,
1126
1152
  aggregateType: "showcase-invoice",
@@ -1129,35 +1155,33 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1129
1155
  });
1130
1156
  return { isSuccess: true as const, data: { id: event.payload.id } };
1131
1157
  },
1132
- {
1133
- access: { roles: ["Admin"] },
1134
- // Per-user cap: a single admin shouldn't fire more than 5 pay
1135
- // operations per minute. Real production caps would be per
1136
- // tenant + handler ("tenant+handler") to keep one admin from
1137
- // monopolising the tenant's quota — kept simple here for the
1138
- // sample. Demonstrates the fourth dispatcher gate (rate-limit
1139
- // → access → validation → handler).
1140
- rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
1141
- },
1142
- );
1158
+ access: { roles: ["Admin"] },
1159
+ // Per-user cap: a single admin shouldn't fire more than 5 pay
1160
+ // operations per minute. Real production caps would be per
1161
+ // tenant + handler ("tenant+handler") to keep one admin from
1162
+ // monopolising the tenant's quota — kept simple here for the
1163
+ // sample. Demonstrates the fourth dispatcher gate (rate-limit
1164
+ // → access → validation → handler).
1165
+ rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
1166
+ });
1143
1167
 
1144
- r.writeHandler(
1145
- "invoice:archive",
1146
- z.object({ id: z.uuid() }),
1147
- async (event, ctx) => {
1168
+ r.writeHandler({
1169
+ name: "invoice:archive",
1170
+ schema: z.object({ id: z.uuid() }),
1171
+ handler: async (event, ctx) => {
1148
1172
  await ctx.archiveStream(event.payload.id, { aggregateType: "showcase-invoice" });
1149
1173
  return { isSuccess: true as const, data: { id: event.payload.id } };
1150
1174
  },
1151
- { access: { roles: ["Admin"] } },
1152
- );
1175
+ access: { roles: ["Admin"] },
1176
+ });
1153
1177
 
1154
1178
  // Persist a snapshot of the current state. In practice, features schedule
1155
1179
  // this from a lifecycle hook (every N events, every M minutes) — here it's
1156
1180
  // an explicit write-handler so the integration test can drive it.
1157
- r.writeHandler(
1158
- "invoice:take-snapshot",
1159
- z.object({ id: z.uuid() }),
1160
- async (event, ctx) => {
1181
+ r.writeHandler({
1182
+ name: "invoice:take-snapshot",
1183
+ schema: z.object({ id: z.uuid() }),
1184
+ handler: async (event, ctx) => {
1161
1185
  // Rebuild the state from the current stream so the snapshot reflects
1162
1186
  // the committed event log, not an in-memory guess. Use the upcaster-
1163
1187
  // aware ctx.loadAggregate to stay consistent with read-time semantics.
@@ -1175,17 +1199,17 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1175
1199
  });
1176
1200
  return { isSuccess: true as const, data: { id: event.payload.id, snapshotVersion: version } };
1177
1201
  },
1178
- { access: { roles: ["Admin"] } },
1179
- );
1202
+ access: { roles: ["Admin"] },
1203
+ });
1180
1204
 
1181
1205
  // --- Query handlers ---
1182
1206
 
1183
1207
  // Live aggregation via ctx.loadAggregate — reduces events into a state
1184
1208
  // snapshot. Supports asOf for point-in-time reads.
1185
- r.queryHandler(
1186
- "invoice:state",
1187
- z.object({ id: z.uuid(), asOf: z.iso.datetime().optional() }),
1188
- async (query, ctx) => {
1209
+ r.queryHandler({
1210
+ name: "invoice:state",
1211
+ schema: z.object({ id: z.uuid(), asOf: z.iso.datetime().optional() }),
1212
+ handler: async (query, ctx) => {
1189
1213
  const events = await ctx.loadAggregate(query.payload.id, {
1190
1214
  ...(query.payload.asOf ? { asOf: Temporal.Instant.from(query.payload.asOf) } : {}),
1191
1215
  });
@@ -1195,24 +1219,22 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1195
1219
  }
1196
1220
  return state;
1197
1221
  },
1198
- {
1199
- access: {
1200
- openToAll: {
1201
- reason:
1202
- "demo recipe: any signed-in user may read the event-sourced invoice state; " +
1203
- "there is no per-user invoice ownership in this sample",
1204
- },
1222
+ access: {
1223
+ openToAll: {
1224
+ reason:
1225
+ "demo recipe: any signed-in user may read the event-sourced invoice state; " +
1226
+ "there is no per-user invoice ownership in this sample",
1205
1227
  },
1206
1228
  },
1207
- );
1229
+ });
1208
1230
 
1209
1231
  // Snapshot-aware fast path. Uses the latest snapshot if available and
1210
1232
  // only replays delta events past it. Identical state shape to
1211
1233
  // invoice:state — the two are interchangeable modulo the perf profile.
1212
- r.queryHandler(
1213
- "invoice:fast-state",
1214
- z.object({ id: z.uuid() }),
1215
- async (query, ctx) => {
1234
+ r.queryHandler({
1235
+ name: "invoice:fast-state",
1236
+ schema: z.object({ id: z.uuid() }),
1237
+ handler: async (query, ctx) => {
1216
1238
  const result = await ctx.loadAggregateWithSnapshot<InvoiceStateRecord>(
1217
1239
  query.payload.id,
1218
1240
  (state, evt) => {
@@ -1233,16 +1255,14 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1233
1255
  snapshotHit: result.snapshotHit,
1234
1256
  };
1235
1257
  },
1236
- {
1237
- access: {
1238
- openToAll: {
1239
- reason:
1240
- "demo recipe: any signed-in user may read the snapshot-accelerated invoice " +
1241
- "state; there is no per-user invoice ownership in this sample",
1242
- },
1258
+ access: {
1259
+ openToAll: {
1260
+ reason:
1261
+ "demo recipe: any signed-in user may read the snapshot-accelerated invoice " +
1262
+ "state; there is no per-user invoice ownership in this sample",
1243
1263
  },
1244
1264
  },
1245
- );
1265
+ });
1246
1266
 
1247
1267
  // Read-model query via ctx.queryProjection — auto-tenant-scoped. The
1248
1268
  // helper collapses the zero-argument "return this projection" pattern
@@ -1267,10 +1287,10 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1267
1287
  // bytes nobody will read. The handler checks ctx.signal at every
1268
1288
  // iteration; on abort, throws and the dispatcher returns an error to
1269
1289
  // the (already-disconnected) client.
1270
- r.queryHandler(
1271
- "ops:slow-export",
1272
- z.object({ chunks: z.number().int().min(1).max(1000) }),
1273
- async (query, ctx) => {
1290
+ r.queryHandler({
1291
+ name: "ops:slow-export",
1292
+ schema: z.object({ chunks: z.number().int().min(1).max(1000) }),
1293
+ handler: async (query, ctx) => {
1274
1294
  const accumulated: number[] = [];
1275
1295
  for (let i = 0; i < query.payload.chunks; i++) {
1276
1296
  ctx.signal?.throwIfAborted();
@@ -1281,7 +1301,7 @@ export const invoiceFeature = defineFeature("showcase", (r) => {
1281
1301
  }
1282
1302
  return { processed: accumulated.length };
1283
1303
  },
1284
- { access: { roles: ["Admin"] } },
1285
- );
1304
+ access: { roles: ["Admin"] },
1305
+ });
1286
1306
  });
1287
1307
  ```
@@ -209,11 +209,11 @@ export function createProductFeature(): FeatureDefinition {
209
209
  return defineFeature("product", (r) => {
210
210
  r.systemScope();
211
211
  r.toggleable({ default: true });
212
- r.entity("product", productEntity);
213
- r.writeHandler(
214
- "product:create",
215
- z.object({ name: z.string().min(1).max(100) }),
216
- async (event, ctx) => {
212
+ r.entity({ name: "product", ...productEntity });
213
+ r.writeHandler({
214
+ name: "product:create",
215
+ schema: z.object({ name: z.string().min(1).max(100) }),
216
+ handler: async (event, ctx) => {
217
217
  if (!ctx.systemDb) {
218
218
  throw new InternalError({
219
219
  message: "product:create requires ctx.systemDb — is r.systemScope() still set?",
@@ -224,8 +224,8 @@ export function createProductFeature(): FeatureDefinition {
224
224
  );
225
225
  return productCrud.create(event.payload, event.user, db);
226
226
  },
227
- { access: { roles: ["SystemAdmin"] } },
228
- );
227
+ access: { roles: ["SystemAdmin"] },
228
+ });
229
229
  });
230
230
  }
231
231
 
@@ -254,20 +254,28 @@ export function createProductAuditFeature(): FeatureDefinition {
254
254
  return defineFeature("product-audit", (r) => {
255
255
  r.systemScope();
256
256
  r.toggleable({ default: true });
257
- r.entity("product-audit", productAuditEntity);
258
-
259
- r.hook("postSave", { allOf: "product" }, async (result, ctx) => {
260
- if (result.kind !== "save" || !result.isNew) return;
261
- if (!ctx.systemDb) return;
262
- const name = (result.changes as Record<string, unknown>)["name"] as string | undefined;
263
- if (!name) return;
264
- const db = ctx.systemDb.acknowledgeCrossTenant(
265
- "audit sink is system-wide, mirrors the system-wide product catalog",
266
- );
267
- // Cross-feature audit sink is itself an r.entity projection — write it via
268
- // the executor (system actor, no human caller) so the row is event-backed
269
- // and survives a rebuild, not a direct method-form insert.
270
- await productAuditCrud.create({ productName: name }, createSystemUser(SYSTEM_TENANT_ID), db);
257
+ r.entity({ name: "product-audit", ...productAuditEntity });
258
+
259
+ r.hook({
260
+ type: "postSave",
261
+ target: { allOf: "product" },
262
+ handler: async (result, ctx) => {
263
+ if (result.kind !== "save" || !result.isNew) return;
264
+ if (!ctx.systemDb) return;
265
+ const name = (result.changes as Record<string, unknown>)["name"] as string | undefined;
266
+ if (!name) return;
267
+ const db = ctx.systemDb.acknowledgeCrossTenant(
268
+ "audit sink is system-wide, mirrors the system-wide product catalog",
269
+ );
270
+ // Cross-feature audit sink is itself an r.entity projection — write it via
271
+ // the executor (system actor, no human caller) so the row is event-backed
272
+ // and survives a rebuild, not a direct method-form insert.
273
+ await productAuditCrud.create(
274
+ { productName: name },
275
+ createSystemUser(SYSTEM_TENANT_ID),
276
+ db,
277
+ );
278
+ },
271
279
  });
272
280
  });
273
281
  }
@@ -343,8 +343,8 @@ export const publishedPhotoIds = new Set<string>();
343
343
  export const coverOrder: string[] = [];
344
344
 
345
345
  export const filesPostProcessingFeature = defineFeature("files-post-processing", (r) => {
346
- r.requires("file-derivatives");
347
- r.entity("photo", photoEntity);
346
+ r.requires({ features: ["file-derivatives"] });
347
+ r.entity({ name: "photo", ...photoEntity });
348
348
 
349
349
  // Uploading a file to a photo's field needs the same rights as saving the
350
350
  // photo, so the photo must exist and the uploader needs update access.
@@ -355,9 +355,9 @@ export const filesPostProcessingFeature = defineFeature("files-post-processing",
355
355
 
356
356
  // Entry point 2 — imperative. A real app would gate this behind real
357
357
  // roles; `openToAll` keeps the recipe's HTTP wiring to one line.
358
- r.writeHandler(
359
- "set-cover",
360
- z.object({
358
+ r.writeHandler({
359
+ name: "set-cover",
360
+ schema: z.object({
361
361
  fileRefId: z.string(),
362
362
  // Relative to the ORIGINAL image's pixels, clamped before resize —
363
363
  // runtime data (e.g. a detected face box), which is exactly why this
@@ -371,7 +371,7 @@ export const filesPostProcessingFeature = defineFeature("files-post-processing",
371
371
  })
372
372
  .optional(),
373
373
  }),
374
- async (event, ctx) => {
374
+ handler: async (event, ctx) => {
375
375
  if (!ctx.derivatives) {
376
376
  return writeFailure(
377
377
  new InternalError({ message: "ctx.derivatives missing — is file-derivatives mounted?" }),
@@ -391,36 +391,32 @@ export const filesPostProcessingFeature = defineFeature("files-post-processing",
391
391
  coverOrder.push(fileRefId);
392
392
  return { isSuccess: true as const, data: { fileRefId, storageKey: result.storageKey } };
393
393
  },
394
- {
395
- access: {
396
- openToAll: {
397
- reason:
398
- "demo recipe: any signed-in user may generate a cover-image derivative for " +
399
- "a file; there is no per-user file ownership check in this sample",
400
- },
394
+ access: {
395
+ openToAll: {
396
+ reason:
397
+ "demo recipe: any signed-in user may generate a cover-image derivative for " +
398
+ "a file; there is no per-user file ownership check in this sample",
401
399
  },
402
400
  },
403
- );
401
+ });
404
402
 
405
403
  // A real app would gate this behind real roles; `openToAll` keeps the
406
404
  // recipe's HTTP wiring to one line.
407
- r.writeHandler(
408
- "publish",
409
- z.object({ entityId: z.string() }),
410
- async (event) => {
405
+ r.writeHandler({
406
+ name: "publish",
407
+ schema: z.object({ entityId: z.string() }),
408
+ handler: async (event) => {
411
409
  publishedPhotoIds.add(`${event.user.tenantId}:${event.payload.entityId}`);
412
410
  return { isSuccess: true as const, data: { entityId: event.payload.entityId } };
413
411
  },
414
- {
415
- access: {
416
- openToAll: {
417
- reason:
418
- "demo recipe: any signed-in user may mark a photo entity published; a real " +
419
- "app would replace this with a role check as noted above",
420
- },
412
+ access: {
413
+ openToAll: {
414
+ reason:
415
+ "demo recipe: any signed-in user may mark a photo entity published; a real " +
416
+ "app would replace this with a role check as noted above",
421
417
  },
422
418
  },
423
- );
419
+ });
424
420
 
425
421
  // Entry point 3 — default-deny. No registration here means every entityId
426
422
  // 404s; a registration that returns false does the same. Only an explicit