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