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