@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
package/AGENTS.md CHANGED
@@ -55,8 +55,8 @@ need an access rule for both sides, `write: { access }` and
55
55
  overrides a single verb. Without them boot stops. Turn single
56
56
  verbs off with `verbs: { delete: false }` and register them yourself when
57
57
  they need different access or logic. Custom logic goes into
58
- `r.writeHandler(qn, schema, fn, { access })` and
59
- `r.queryHandler(qn, schema, fn, { access })`; inside, use the entity
58
+ `r.writeHandler({ name, schema, handler, access })` and
59
+ `r.queryHandler({ name, schema, handler, access })`; inside, use the entity
60
60
  executor from `createEntityExecutor(name, entity)`.
61
61
 
62
62
  **Screen.** A declarative page: `r.screen({ id, type, ... })`. Types include
@@ -72,12 +72,18 @@ describe("default-deny: refuses handlers without access", () => {
72
72
  expect(() =>
73
73
  defineFeature("broken-feature", (r) => {
74
74
  // @ts-expect-error — No { access: ... }: registration must reject this.
75
- r.writeHandler("forgotAccess", z.object({ name: z.string() }), async () => ({
76
- isSuccess: true as const,
77
- data: {},
78
- }));
75
+ r.writeHandler({
76
+ name: "forgotAccess",
77
+ schema: z.object({ name: z.string() }),
78
+ handler: async () => ({
79
+ isSuccess: true as const,
80
+ data: {},
81
+ }),
82
+ });
79
83
  }),
80
- ).toThrow(/requires schema \+ handler \+ options\.access/);
84
+ ).toThrow(
85
+ /\[Feature broken-feature\] r\.writeHandler\(\{ name: "forgotAccess" \}\) requires an access rule/,
86
+ );
81
87
  });
82
88
  });
83
89
 
@@ -273,8 +279,8 @@ export const taskTable = buildEntityTable("task", taskEntity, {
273
279
  });
274
280
 
275
281
  export const accessControlFeature = defineFeature("access-control", (r) => {
276
- r.entity("project", projectEntity);
277
- r.entity("task", taskEntity);
282
+ r.entity({ name: "project", ...projectEntity });
283
+ r.entity({ name: "task", ...taskEntity });
278
284
  r.relation("task", "project", taskRelations.project);
279
285
 
280
286
  // Admin-only: standard create — no business logic, helper handles schema +
@@ -289,14 +295,14 @@ export const accessControlFeature = defineFeature("access-control", (r) => {
289
295
  // hand-write this handler instead of using the helper. The framework helpers
290
296
  // are deliberately scope-limited — opt out the moment you need defaults.
291
297
  const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
292
- r.writeHandler(
293
- "task:create",
294
- z.object({
298
+ r.writeHandler({
299
+ name: "task:create",
300
+ schema: z.object({
295
301
  title: z.string().min(1),
296
302
  projectId: z.uuid(),
297
303
  assigneeId: z.uuid().optional(),
298
304
  }),
299
- async (event, ctx) =>
305
+ handler: async (event, ctx) =>
300
306
  taskExecutor.create(
301
307
  {
302
308
  ...event.payload,
@@ -305,22 +311,20 @@ export const accessControlFeature = defineFeature("access-control", (r) => {
305
311
  event.user,
306
312
  ctx.db,
307
313
  ),
308
- {
309
- access: {
310
- openToAll: {
311
- reason:
312
- "any signed-in tenant user may create a task; assigneeId defaults to the caller when omitted",
313
- },
314
+ access: {
315
+ openToAll: {
316
+ reason:
317
+ "any signed-in tenant user may create a task; assigneeId defaults to the caller when omitted",
314
318
  },
315
319
  },
316
- );
320
+ });
317
321
 
318
322
  // Update requires version — optimistic locking is the default and this
319
323
  // narrowed schema (only title + assigneeId mutable) is stricter than the
320
324
  // helper's generic update schema, so we keep it hand-written.
321
- r.writeHandler(
322
- "task:update",
323
- z.object({
325
+ r.writeHandler({
326
+ name: "task:update",
327
+ schema: z.object({
324
328
  id: z.uuid(),
325
329
  version: z.number(),
326
330
  changes: z.object({
@@ -328,17 +332,15 @@ export const accessControlFeature = defineFeature("access-control", (r) => {
328
332
  assigneeId: z.uuid().optional(),
329
333
  }),
330
334
  }),
331
- async (event, ctx) => taskExecutor.update(event.payload, event.user, ctx.db),
332
- {
333
- access: {
334
- openToAll: {
335
- reason:
336
- "any signed-in tenant user may update a task; optimistic locking (version) " +
337
- "guards against concurrent overwrites, not caller identity",
338
- },
335
+ handler: async (event, ctx) => taskExecutor.update(event.payload, event.user, ctx.db),
336
+ access: {
337
+ openToAll: {
338
+ reason:
339
+ "any signed-in tenant user may update a task; optimistic locking (version) " +
340
+ "guards against concurrent overwrites, not caller identity",
339
341
  },
340
342
  },
341
- );
343
+ });
342
344
 
343
345
  // Self-service list — no custom logic.
344
346
  r.queryHandler(
@@ -304,34 +304,32 @@ export const productEntity = createEntity({
304
304
  export const productTable = buildEntityTable("product", productEntity);
305
305
 
306
306
  export const multiTenantShopFeature = defineFeature("mtshop", (r) => {
307
- r.entity("product", productEntity);
307
+ r.entity({ name: "product", ...productEntity });
308
308
 
309
309
  // Public listing — every tenant has its own products, scoped by tenantId
310
310
  // automatically via the framework's tenant-isolation. Anonymous visitors
311
311
  // see only the tenant they landed on.
312
- r.queryHandler(
313
- "product:list",
314
- z.object({}),
315
- async (_event, ctx) => ctx.db.selectMany(productTable),
316
- {
317
- access: { roles: [...access.anonymous, "User", "Admin"] },
318
- rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
319
- },
320
- );
312
+ r.queryHandler({
313
+ name: "product:list",
314
+ schema: z.object({}),
315
+ handler: async (_event, ctx) => ctx.db.selectMany(productTable),
316
+ access: { roles: [...access.anonymous, "User", "Admin"] },
317
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
318
+ });
321
319
 
322
320
  // Admin-only — proves role-gating still works on top of the multi-tenant
323
321
  // anonymous resolution.
324
- r.writeHandler(
325
- "product:create",
326
- z.object({ name: z.string().min(1) }),
327
- async (event, ctx) => {
322
+ r.writeHandler({
323
+ name: "product:create",
324
+ schema: z.object({ name: z.string().min(1) }),
325
+ handler: async (event, ctx) => {
328
326
  const exec = createEventStoreExecutor(productTable, productEntity, {
329
327
  entityName: "product",
330
328
  });
331
329
  return exec.create(event.payload, event.user, ctx.db);
332
330
  },
333
- { access: { roles: ["Admin"] } },
334
- );
331
+ access: { roles: ["Admin"] },
332
+ });
335
333
  });
336
334
  ```
337
335
 
@@ -207,41 +207,37 @@ export const productTable = buildEntityTable("product", productEntity);
207
207
  export const guestOrderTable = buildEntityTable("guest-order", guestOrderEntity);
208
208
 
209
209
  export const anonymousAccessFeature = defineFeature("shop", (r) => {
210
- r.entity("product", productEntity);
211
- r.entity("guest-order", guestOrderEntity);
210
+ r.entity({ name: "product", ...productEntity });
211
+ r.entity({ name: "guest-order", ...guestOrderEntity });
212
212
 
213
213
  // Public listing: anonymous + authenticated customers see it. Combining
214
214
  // the roles in one handler is preferred over duplicating the read path —
215
215
  // the handler body can branch on `event.user.id === "anonymous"` if it
216
216
  // needs to personalise (e.g. show a sign-in CTA, hide a wishlist button).
217
- r.queryHandler(
218
- "product:list",
219
- z.object({}),
220
- async (_event, ctx) => ctx.db.selectMany(productTable),
221
- {
222
- access: { roles: [...access.anonymous, "User", "Admin"] },
223
- rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
224
- },
225
- );
217
+ r.queryHandler({
218
+ name: "product:list",
219
+ schema: z.object({}),
220
+ handler: async (_event, ctx) => ctx.db.selectMany(productTable),
221
+ access: { roles: [...access.anonymous, "User", "Admin"] },
222
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
223
+ });
226
224
 
227
225
  // Authenticated-only listing — same data, different access rule. The
228
226
  // openToAll declaration here is the regression-guard: even with
229
227
  // anonymousAccess enabled on the server, this endpoint stays gated to
230
228
  // logged-in users.
231
- r.queryHandler(
232
- "product:authenticated-only",
233
- z.object({}),
234
- async (_event, ctx) => ctx.db.selectMany(productTable),
235
- {
236
- access: {
237
- openToAll: {
238
- reason:
239
- "any signed-in (non-anonymous) user may list products here; this handler is " +
240
- "the regression-guard proving anonymousAccess never silently exposes an authenticated-only endpoint",
241
- },
229
+ r.queryHandler({
230
+ name: "product:authenticated-only",
231
+ schema: z.object({}),
232
+ handler: async (_event, ctx) => ctx.db.selectMany(productTable),
233
+ access: {
234
+ openToAll: {
235
+ reason:
236
+ "any signed-in (non-anonymous) user may list products here; this handler is " +
237
+ "the regression-guard proving anonymousAccess never silently exposes an authenticated-only endpoint",
242
238
  },
243
239
  },
244
- );
240
+ });
245
241
 
246
242
  // Anonymous + authenticated guest checkout. Stores the synthesised user-id
247
243
  // ("anonymous") in `placedBy` so audit-trail / support queries can tell
@@ -252,37 +248,34 @@ export const anonymousAccessFeature = defineFeature("shop", (r) => {
252
248
  const guestOrderExecutor = createEventStoreExecutor(guestOrderTable, guestOrderEntity, {
253
249
  entityName: "guest-order",
254
250
  });
255
- r.writeHandler(
256
- "guest-order:place",
257
- z.object({
251
+ r.writeHandler({
252
+ name: "guest-order:place",
253
+ schema: z.object({
258
254
  productId: z.uuid(),
259
255
  email: z.string().email(),
260
256
  }),
261
- async (event, ctx) =>
262
- guestOrderExecutor.create({ ...event.payload, placedBy: event.user.id }, event.user, ctx.db),
263
- {
264
- // `email` is recordOwned PII (personal: { of: "id" }) written by an
265
- // anonymous root handler — the runtime personal-data gate
266
- // (write-origin.ts) requires this explicit opt-in, the same way the
267
- // boot validator would reject an openToAll declaration missing it.
268
- access: { roles: [...access.anonymous, "User"], personalData: "public-intake" },
269
- rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
270
- },
271
- );
257
+ handler: async (event, ctx) =>
258
+ guestOrderExecutor.create({ ...event.payload, placedBy: event.user.id }, event.user, ctx.db), // `email` is recordOwned PII (personal: { of: "id" }) written by an
259
+ // anonymous root handler — the runtime personal-data gate
260
+ // (write-origin.ts) requires this explicit opt-in, the same way the
261
+ // boot validator would reject an openToAll declaration missing it.
262
+ access: { roles: [...access.anonymous, "User"], personalData: "public-intake" },
263
+ rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
264
+ });
272
265
 
273
266
  // Admin-only product CRUD — proves role-gated handlers still reject
274
267
  // anonymous callers. Same shape as any other handler in the framework:
275
268
  // anonymous is just a regular role, not a special case.
276
- r.writeHandler(
277
- "product:create",
278
- z.object({ name: z.string().min(1), priceCents: z.string() }),
279
- async (event, ctx) => {
269
+ r.writeHandler({
270
+ name: "product:create",
271
+ schema: z.object({ name: z.string().min(1), priceCents: z.string() }),
272
+ handler: async (event, ctx) => {
280
273
  const exec = createEventStoreExecutor(productTable, productEntity, {
281
274
  entityName: "product",
282
275
  });
283
276
  return exec.create(event.payload, event.user, ctx.db);
284
277
  },
285
- { access: { roles: ["Admin"] } },
286
- );
278
+ access: { roles: ["Admin"] },
279
+ });
287
280
  });
288
281
  ```
@@ -125,7 +125,7 @@ export function createSubdomainTenantRoutingFeature(
125
125
  const reserved = new Set(opts.reservedSubdomains ?? ["www", "app", "api", "admin"]);
126
126
 
127
127
  return defineFeature("sample-subdomain-tenant-routing", (r) => {
128
- r.requires("auth-foundation");
128
+ r.requires({ features: ["auth-foundation"] });
129
129
 
130
130
  const resolverPlugin: TenantResolverProvider = {
131
131
  trust,
@@ -36,7 +36,7 @@ task-management → single feature, single `task` entity, six CRUD handlers
36
36
  ```
37
37
 
38
38
  No bundled features required — this is the baseline before you add
39
- `r.requires("tenant")`, auth, or cross-feature extensions.
39
+ `r.requires({ features: ["tenant"] })`, auth, or cross-feature extensions.
40
40
 
41
41
  ## Flow
42
42
 
@@ -21,7 +21,7 @@ never mounted, and nothing caught it — until now.
21
21
  feature-prefixed message.
22
22
  - **Conditional invariants `r.requires` can't express** — the check only
23
23
  fails when *this* feature's own shape demands it (has a PII field), not
24
- unconditionally. A plain `r.requires("user-data-hook")` would fail even
24
+ unconditionally. A plain `r.requires({ features: ["user-data-hook"] })` would fail even
25
25
  for a prompt-store variant with no PII fields at all.
26
26
  - **`validateBoot`** — the framework's boot-time validator, run directly
27
27
  against a feature list. `bootCheck` has no DB dependency, so this is the
@@ -98,7 +98,7 @@ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumik
98
98
 
99
99
  // A prompt-store integration trap: a feature with a PII field was mounted
100
100
  // without its required companion feature, and nothing caught it at boot.
101
- // r.requires("user-data-hook") can't express this — it would
101
+ // r.requires({ features: ["user-data-hook"] }) can't express this — it would
102
102
  // fail even for a prompt-store variant with no PII fields at all.
103
103
  const promptFields = {
104
104
  text: createTextField({
@@ -108,7 +108,7 @@ const promptFields = {
108
108
  };
109
109
 
110
110
  export const promptStoreFeature = defineFeature("prompt-store", (r) => {
111
- r.entity("prompt", createEntity({ fields: promptFields }));
111
+ r.entity({ name: "prompt", ...createEntity({ fields: promptFields }) });
112
112
  r.bootCheck(({ features }) => {
113
113
  const hasPiiField = Object.values(promptFields).some((field) => field.pii);
114
114
  const hasUserDataHook = features.some((f) => f.name === "user-data-hook");
@@ -229,7 +229,7 @@ import * as z from "zod";
229
229
  export const OPS_ANNOUNCEMENT_TYPE = qn("ops", "notify", "announcement");
230
230
 
231
231
  export const opsFeature = defineFeature("ops", (r) => {
232
- r.requires("delivery");
232
+ r.requires({ features: ["delivery"] });
233
233
 
234
234
  r.writeHandler(
235
235
  defineWriteHandler({
@@ -258,7 +258,7 @@ export const capturedEvents: CapturedEvent[] = [];
258
258
  // --- Feature ---
259
259
 
260
260
  export const pubsubOrderFeature = defineFeature("pubsubOrders", (r) => {
261
- r.entity("pubsub-order", orderEntity);
261
+ r.entity({ name: "pubsub-order", ...orderEntity });
262
262
 
263
263
  // Define the event shape once. The returned `.name` is the qualified
264
264
  // name ("pubsub-orders:event:order-placed") — pass it to ctx.appendEvent.
@@ -270,10 +270,10 @@ export const pubsubOrderFeature = defineFeature("pubsubOrders", (r) => {
270
270
 
271
271
  const { executor: orderExecutor } = createEntityExecutor("pubsub-order", orderEntity);
272
272
 
273
- r.writeHandler(
274
- "order:place",
275
- z.object({ customer: z.string().min(1), product: z.string().min(1) }),
276
- async (event, ctx) => {
273
+ r.writeHandler({
274
+ name: "order:place",
275
+ schema: z.object({ customer: z.string().min(1), product: z.string().min(1) }),
276
+ handler: async (event, ctx) => {
277
277
  const result = await orderExecutor.create(event.payload, event.user, ctx.db);
278
278
  if (result.isSuccess) {
279
279
  // emitEvent runs inside the executor's TX and is typed against
@@ -294,8 +294,8 @@ export const pubsubOrderFeature = defineFeature("pubsubOrders", (r) => {
294
294
  }
295
295
  return result;
296
296
  },
297
- { access: { roles: ["Admin", "Customer"] } },
298
- );
297
+ access: { roles: ["Admin", "Customer"] },
298
+ });
299
299
 
300
300
  // Async consumer — Marten-style. Fires after commit, via the event-
301
301
  // dispatcher. No `table` means this MSP is pure side-effect (no state
@@ -453,9 +453,9 @@ const CURRENCY_CATALOG = [
453
453
  ] as const;
454
454
 
455
455
  export const currenciesGlobalFeature = defineFeature("currenciesGlobal", (r) => {
456
- const currency = r.entity("currency", currencyEntity);
457
- r.entity("tenant-currency", tenantCurrencyEntity);
458
- r.entity("invoice", invoiceEntity);
456
+ const currency = r.entity({ name: "currency", ...currencyEntity });
457
+ r.entity({ name: "tenant-currency", ...tenantCurrencyEntity });
458
+ r.entity({ name: "invoice", ...invoiceEntity });
459
459
 
460
460
  // Seed global currency table — available for all tenants to pick from
461
461
  r.referenceData(currency, [...CURRENCY_CATALOG], { upsertKey: "code" });
@@ -318,8 +318,8 @@ export { currencyEntity } from "./entities/currency";
318
318
  export { invoiceEntity } from "./entities/invoice";
319
319
 
320
320
  export const currenciesPerTenantFeature = defineFeature("currenciesPerTenant", (r) => {
321
- r.entity("currency", currencyEntity);
322
- r.entity("invoice", invoiceEntity);
321
+ r.entity({ name: "currency", ...currencyEntity });
322
+ r.entity({ name: "invoice", ...invoiceEntity });
323
323
 
324
324
  // No r.referenceData() — tenants create currencies themselves
325
325
  r.writeHandler(currencyCreate);
@@ -44,7 +44,7 @@ property-management → our feature: opts the `property` entity into
44
44
  custom-fields via wireCustomFieldsFor
45
45
  ```
46
46
 
47
- The `property-management` feature `r.requires("custom-fields")` — the
47
+ The `property-management` feature `r.requires({ features: ["custom-fields"] })` — the
48
48
  bundle is non-optional for this recipe because the wired entity would
49
49
  otherwise have an empty jsonb column and no way to write into it.
50
50
 
@@ -269,9 +269,10 @@ function propertyExecutor() {
269
269
  // --- Feature ---
270
270
 
271
271
  export const propertyFeature = defineFeature("property-management", (r) => {
272
- r.requires("custom-fields");
272
+ r.requires({ features: ["custom-fields"] });
273
273
 
274
- r.entity("property", {
274
+ r.entity({
275
+ name: "property",
275
276
  table: "read_sample_cf_properties",
276
277
  fields: {
277
278
  name: { type: "text", required: true, maxLength: 200 },
@@ -21,9 +21,9 @@ logic is yours.
21
21
 
22
22
  ## What it shows
23
23
 
24
- - **`r.writeHandler(name, schema, handler, options)`** — the positional
25
- inline form, useful when the handler body is short and stays close to
26
- its registration.
24
+ - **`r.writeHandler({ name, schema, handler, access })`** — the inline
25
+ form, useful when the handler body is short and stays close to its
26
+ registration.
27
27
  - **Read-modify-write with optimistic locking** — `increment` reads the
28
28
  current count, computes the new one, and updates with the version it
29
29
  read. Two concurrent increments cannot both succeed.
@@ -341,7 +341,7 @@ export const counterEntity = createEntity({
341
341
  });
342
342
 
343
343
  export const counterFeature = defineFeature("counters", (r) => {
344
- r.entity("counter", counterEntity);
344
+ r.entity({ name: "counter", ...counterEntity });
345
345
 
346
346
  // createEntityExecutor bundles buildEntityTable + createEventStoreExecutor —
347
347
  // the same pair every custom write-handler opens with. Collapses 3 lines
@@ -349,19 +349,19 @@ export const counterFeature = defineFeature("counters", (r) => {
349
349
  const { executor: counterExecutor } = createEntityExecutor("counter", counterEntity);
350
350
 
351
351
  // Standard create
352
- r.writeHandler(
353
- "counter:create",
354
- z.object({ name: z.string().min(1) }),
355
- async (event, ctx) =>
352
+ r.writeHandler({
353
+ name: "counter:create",
354
+ schema: z.object({ name: z.string().min(1) }),
355
+ handler: async (event, ctx) =>
356
356
  counterExecutor.create({ ...event.payload, count: 0 }, event.user, ctx.db),
357
- { access: { roles: ["Admin"] } },
358
- );
357
+ access: { roles: ["Admin"] },
358
+ });
359
359
 
360
360
  // Custom handler: increment with business logic
361
- r.writeHandler(
362
- "counter:increment",
363
- z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }),
364
- async (event, ctx) => {
361
+ r.writeHandler({
362
+ name: "counter:increment",
363
+ schema: z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }),
364
+ handler: async (event, ctx) => {
365
365
  const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db);
366
366
  if (!current) {
367
367
  return failNotFound("counter", event.payload.id);
@@ -381,14 +381,14 @@ export const counterFeature = defineFeature("counters", (r) => {
381
381
  ctx.db,
382
382
  );
383
383
  },
384
- { access: { roles: ["Admin", "User"] } },
385
- );
384
+ access: { roles: ["Admin", "User"] },
385
+ });
386
386
 
387
387
  // Custom handler: reset to zero
388
- r.writeHandler(
389
- "counter:reset",
390
- z.object({ id: z.uuid() }),
391
- async (event, ctx) =>
388
+ r.writeHandler({
389
+ name: "counter:reset",
390
+ schema: z.object({ id: z.uuid() }),
391
+ handler: async (event, ctx) =>
392
392
  // Admin reset: last-writer-wins is intentional — there's no useful
393
393
  // concurrent-reset race to guard against.
394
394
  counterExecutor.update(
@@ -397,16 +397,16 @@ export const counterFeature = defineFeature("counters", (r) => {
397
397
  ctx.db,
398
398
  { skipOptimisticLock: true },
399
399
  ),
400
- { access: { roles: ["Admin"] } },
401
- );
400
+ access: { roles: ["Admin"] },
401
+ });
402
402
 
403
403
  // Custom query: only counters above a threshold. list() returns one page
404
404
  // (default 50, max 200, MAX_LIST_LIMIT), so a JS filter would miss rows after
405
405
  // page 1; filter in the query instead. totalCount counts over the whole WHERE.
406
- r.queryHandler(
407
- "counter:active",
408
- z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
409
- async (query, ctx) =>
406
+ r.queryHandler({
407
+ name: "counter:active",
408
+ schema: z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
409
+ handler: async (query, ctx) =>
410
410
  counterExecutor.list(
411
411
  {
412
412
  filter: { field: "count", op: "gte", value: query.payload.minCount },
@@ -416,29 +416,25 @@ export const counterFeature = defineFeature("counters", (r) => {
416
416
  query.user,
417
417
  ctx.db,
418
418
  ),
419
- {
420
- access: {
421
- openToAll: {
422
- reason:
423
- "any signed-in user may list active counters; the sample has no per-user counter ownership",
424
- },
419
+ access: {
420
+ openToAll: {
421
+ reason:
422
+ "any signed-in user may list active counters; the sample has no per-user counter ownership",
425
423
  },
426
424
  },
427
- );
425
+ });
428
426
 
429
427
  // Standard detail
430
- r.queryHandler(
431
- "counter:detail",
432
- z.object({ id: z.uuid() }),
433
- async (query, ctx) => counterExecutor.detail(query.payload, query.user, ctx.db),
434
- {
435
- access: {
436
- openToAll: {
437
- reason:
438
- "any signed-in user may read a counter by id; the sample has no per-user counter ownership",
439
- },
428
+ r.queryHandler({
429
+ name: "counter:detail",
430
+ schema: z.object({ id: z.uuid() }),
431
+ handler: async (query, ctx) => counterExecutor.detail(query.payload, query.user, ctx.db),
432
+ access: {
433
+ openToAll: {
434
+ reason:
435
+ "any signed-in user may read a counter by id; the sample has no per-user counter ownership",
440
436
  },
441
437
  },
442
- );
438
+ });
443
439
  });
444
440
  ```
@@ -160,10 +160,10 @@ export const ticketFeature = defineFeature("tickets", (r) => {
160
160
  // Flat result: each dashboard stat reads one field via its valueField.
161
161
  // limit: 1 keeps the page tiny; totalCount counts over the whole WHERE in
162
162
  // the database, so no rows are loaded just to be counted.
163
- r.queryHandler(
164
- "ticket:stats",
165
- z.object({}),
166
- async (query, ctx) => {
163
+ r.queryHandler({
164
+ name: "ticket:stats",
165
+ schema: z.object({}),
166
+ handler: async (query, ctx) => {
167
167
  const countWithStatus = async (status: (typeof TICKET_STATUSES)[number]): Promise<number> => {
168
168
  const page = await ticketExecutor.list(
169
169
  { filter: { field: "status", op: "eq", value: status }, limit: 1, totalCount: true },
@@ -178,8 +178,8 @@ export const ticketFeature = defineFeature("tickets", (r) => {
178
178
  ]);
179
179
  return { open, closed, total: open + closed };
180
180
  },
181
- { access: readerRoles },
182
- );
181
+ access: readerRoles,
182
+ });
183
183
 
184
184
  r.translations({
185
185
  keys: {
@@ -36,7 +36,7 @@ channel-inApp / channel-email / channel-push
36
36
  support → our business logic (tickets + the notification declaration)
37
37
  ```
38
38
 
39
- The `support` feature `r.requires("delivery")`: channels are features that attach to delivery, but `support` doesn't need to know them.
39
+ The `support` feature `r.requires({ features: ["delivery"] })`: channels are features that attach to delivery, but `support` doesn't need to know them.
40
40
 
41
41
  ## Flow
42
42
 
@@ -364,9 +364,9 @@ function ticketExecutor() {
364
364
  export const TICKET_ASSIGNED = qn("support", QnTypes.notify, "ticket-assigned");
365
365
 
366
366
  export const supportFeature = defineFeature("support", (r) => {
367
- r.requires("delivery");
367
+ r.requires({ features: ["delivery"] });
368
368
 
369
- r.entity("ticket", ticketEntity);
369
+ r.entity({ name: "ticket", ...ticketEntity });
370
370
 
371
371
  r.notification("ticket-assigned", {
372
372
  variables: {
@@ -396,16 +396,16 @@ export const supportFeature = defineFeature("support", (r) => {
396
396
  },
397
397
  });
398
398
 
399
- r.writeHandler(
400
- "ticket:create",
401
- z.object({
399
+ r.writeHandler({
400
+ name: "ticket:create",
401
+ schema: z.object({
402
402
  title: z.string().min(1),
403
403
  description: z.string().optional(),
404
404
  assigneeId: z.uuid().optional(),
405
405
  priority: z.enum(["low", "normal", "critical"]),
406
406
  status: z.string().default("open"),
407
407
  }),
408
- async (event, ctx) => {
408
+ handler: async (event, ctx) => {
409
409
  const created = await ticketExecutor().create(event.payload, event.user, ctx.db);
410
410
  const assigneeId = event.payload.assigneeId;
411
411
  // No assignee, no notification.
@@ -426,7 +426,7 @@ export const supportFeature = defineFeature("support", (r) => {
426
426
  });
427
427
  return created;
428
428
  },
429
- { access: { roles: ["Admin", "Support"] } },
430
- );
429
+ access: { roles: ["Admin", "Support"] },
430
+ });
431
431
  });
432
432
  ```