@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.
- package/AGENTS.md +2 -2
- package/agents/examples/access-control.md +32 -30
- package/agents/examples/anonymous-access-multitenant.md +14 -16
- package/agents/examples/anonymous-access.md +36 -43
- package/agents/examples/auth-foundation-providers.md +1 -1
- package/agents/examples/basic-entity.md +1 -1
- package/agents/examples/boot-check.md +3 -3
- package/agents/examples/chat-channels.md +1 -1
- package/agents/examples/cross-feature-events.md +7 -7
- package/agents/examples/currencies-global.md +3 -3
- package/agents/examples/currencies-per-tenant.md +2 -2
- package/agents/examples/custom-fields-basic.md +4 -3
- package/agents/examples/custom-handlers.md +40 -44
- package/agents/examples/dashboard-stats.md +6 -6
- package/agents/examples/delivery-notifications.md +9 -9
- package/agents/examples/designer-demo.md +2 -1
- package/agents/examples/embedded-entity-form.md +13 -13
- package/agents/examples/embedded.md +1 -1
- package/agents/examples/encrypted-tenant-config.md +1 -1
- package/agents/examples/error-contract.md +25 -25
- package/agents/examples/event-sourcing.md +96 -76
- package/agents/examples/feature-toggles.md +29 -21
- package/agents/examples/files-post-processing.md +22 -26
- package/agents/examples/idempotency.md +17 -19
- package/agents/examples/inbound-mail-triage.md +1 -1
- package/agents/examples/lane-routing.md +10 -12
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +59 -39
- package/agents/examples/managed-config.md +9 -9
- package/agents/examples/managed-pages.md +1 -1
- package/agents/examples/notes-history-basic.md +3 -3
- package/agents/examples/pdf-audit-export.md +7 -7
- package/agents/examples/pipeline-basics.md +8 -8
- package/agents/examples/public-share-token.md +1 -1
- package/agents/examples/rate-limiting.md +7 -9
- package/agents/examples/record-detail-layout.md +26 -20
- package/agents/examples/reference-data.md +1 -1
- package/agents/examples/relations.md +2 -2
- package/agents/examples/screens-nav.md +2 -2
- package/agents/examples/search.md +17 -19
- package/agents/examples/security-baseline.md +1 -1
- package/agents/examples/state-machine.md +1 -1
- package/agents/examples/tags-basic.md +3 -3
- package/agents/examples/tenant-settings.md +2 -2
- package/agents/examples/timezones.md +12 -12
- package/agents/examples/user-data-rights.md +3 -3
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/webhook-step.md +1 -1
- package/agents/examples/writeform-section.md +21 -15
- package/agents/widgets.md +5 -0
- package/dist/api/server.js +12 -4
- package/dist/bun-db/query.js +16 -1
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/engine/boot-validator/api-ext.js +2 -2
- package/dist/engine/boot-validator/config-deps.js +1 -1
- package/dist/engine/boot-validator/handler-field-references.js +3 -10
- package/dist/engine/boot-validator/screens.js +1 -1
- package/dist/engine/build-app-schema.js +3 -6
- package/dist/engine/define-feature.js +5 -5
- package/dist/engine/entity-handlers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +5 -5
- package/dist/engine/factories.d.ts +11 -11
- package/dist/engine/factories.js +11 -11
- package/dist/engine/feature-ast/extractors/round2.js +16 -2
- package/dist/engine/feature-ast/render.js +4 -7
- package/dist/engine/feature-config-events-jobs.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +4 -23
- package/dist/engine/feature-entity-handlers.js +77 -100
- package/dist/engine/feature-ui-extensions.d.ts +9 -14
- package/dist/engine/feature-ui-extensions.js +22 -14
- package/dist/engine/handler-field-references.d.ts +25 -0
- package/dist/engine/handler-field-references.js +80 -6
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +2 -2
- package/dist/engine/object-form.d.ts +2 -0
- package/dist/engine/object-form.js +13 -10
- package/dist/engine/registry-state.js +2 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +21 -2
- package/dist/engine/registry.js +2 -1
- package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/files/feature.js +1 -1
- package/dist/jobs/job-runner.js +16 -2
- package/dist/pipeline/dispatch-query.js +1 -1
- package/dist/pipeline/dispatch-shared.d.ts +2 -1
- package/dist/pipeline/dispatch-shared.js +3 -6
- package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
- package/dist/pipeline/tenant-timezone-config-key.js +6 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/manual-clock.d.ts +14 -0
- package/dist/testing/manual-clock.js +12 -0
- package/dist/time/index.d.ts +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +9 -4
- package/dist/upgrade-cli.js +13 -2
- package/package.json +4 -4
- package/src/changes.json +40 -0
- package/src/scripts/codemod/README.md +23 -0
- 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(
|
|
59
|
-
`r.queryHandler(
|
|
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(
|
|
76
|
-
|
|
77
|
-
|
|
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(
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
);
|
|
429
|
+
access: { roles: ["Admin", "Support"] },
|
|
430
|
+
});
|
|
431
431
|
});
|
|
432
432
|
```
|