@cosmicdrift/kumiko-framework 0.377.0 → 0.378.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/examples/delivery-notifications.md +25 -2
- package/agents/examples/embedded.md +191 -0
- package/agents/features.md +2 -2
- package/agents/widgets.md +1 -0
- package/dist/db/money.d.ts +2 -1
- package/dist/db/money.js +2 -7
- package/dist/db/tenant-db.js +4 -0
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
- package/dist/engine/boot-validator/form-derivations.js +82 -0
- package/dist/engine/boot-validator/screens.js +16 -3
- package/dist/engine/build-config-feature-schema.js +33 -10
- package/dist/engine/embedded-derived.d.ts +1 -1
- package/dist/engine/embedded-derived.js +7 -1
- package/dist/engine/form-derivations.d.ts +21 -0
- package/dist/engine/form-derivations.js +175 -0
- package/dist/engine/index.d.ts +4 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/minor-units.d.ts +2 -0
- package/dist/engine/minor-units.js +8 -0
- package/dist/engine/template-variables.d.ts +2 -1
- package/dist/engine/template-variables.js +4 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/with-form-derivations.d.ts +9 -0
- package/dist/engine/with-form-derivations.js +16 -0
- package/dist/i18n/settings-hub-keys.js +1 -0
- package/dist/search/configure-tenant-index.d.ts +4 -0
- package/dist/search/configure-tenant-index.js +8 -0
- package/dist/search/index.d.ts +1 -0
- package/dist/search/index.js +1 -0
- package/dist/ui-types/index.d.ts +2 -0
- package/dist/ui-types/index.js +1 -0
- package/package.json +3 -3
- package/src/changes.json +32 -0
|
@@ -16,7 +16,7 @@ Shows how a feature sends notifications to multiple channels (InApp + Email + Pu
|
|
|
16
16
|
|
|
17
17
|
**Mail text is stored data:** The boot seed (`seedNotificationTemplates`) writes the declared `template` and `meta` into the template store as system templates. The email channel renders them with `renderer-mail-html`. A notification routed to email without `template` and `meta` fails its delivery.
|
|
18
18
|
|
|
19
|
-
**Tenant override:**
|
|
19
|
+
**Tenant override:** Only a SystemAdmin edits templates unless the app unlocks them. The app lists the mail in `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug, access: { roles: ["TenantAdmin"] } }] })`; then a tenant admin overrides the text through the template-resolver handlers (`upsertTenant`, then `publish`) and every other template is refused with 403. The next mail for that tenant uses the override; the system text stays for everyone else.
|
|
20
20
|
|
|
21
21
|
**Sending:** The write handler calls `ctx.notify(type, { to, data })`. The `data` keys are the template variables; sending fails loudly when a required variable is missing.
|
|
22
22
|
|
|
@@ -137,7 +137,9 @@ const assignee = createTestUser({ id: 5, roles: ["Support"] });
|
|
|
137
137
|
const features = [
|
|
138
138
|
createConfigFeature(),
|
|
139
139
|
createTenantFeature(),
|
|
140
|
-
createTemplateResolverFeature(
|
|
140
|
+
createTemplateResolverFeature({
|
|
141
|
+
tenantEditable: [{ kind: "mail", slug: TICKET_ASSIGNED, access: { roles: ["TenantAdmin"] } }],
|
|
142
|
+
}),
|
|
141
143
|
createRendererFoundationFeature(),
|
|
142
144
|
createRendererMailHtmlFeature(),
|
|
143
145
|
createDeliveryFeature(),
|
|
@@ -275,6 +277,27 @@ describe("delivery-notifications sample", () => {
|
|
|
275
277
|
expect(email?.html).toContain("Tenant text: Printer on fire");
|
|
276
278
|
});
|
|
277
279
|
|
|
280
|
+
test("a tenant admin may not override a mail the app did not list in tenantEditable", async () => {
|
|
281
|
+
const tenantAdmin = { ...admin, roles: ["TenantAdmin"] };
|
|
282
|
+
|
|
283
|
+
const error = await stack.http.writeErr(
|
|
284
|
+
TemplateResolverHandlers.upsertTenant,
|
|
285
|
+
{
|
|
286
|
+
slug: "support:notify:ticket-closed",
|
|
287
|
+
kind: "mail",
|
|
288
|
+
locale: "en",
|
|
289
|
+
content: "<p>Closed</p>",
|
|
290
|
+
contentFormat: "html",
|
|
291
|
+
meta: { subject: "Closed" },
|
|
292
|
+
variableSchema: {},
|
|
293
|
+
},
|
|
294
|
+
tenantAdmin,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect(error.code).toBe("access_denied");
|
|
298
|
+
expect(error.i18nKey).toBe("templateResolver.errors.templateNotTenantEditable");
|
|
299
|
+
});
|
|
300
|
+
|
|
278
301
|
test("ticket without assignee skips notification (recipient returns null)", async () => {
|
|
279
302
|
emailTransport.sent.length = 0;
|
|
280
303
|
pushTransport.sent.length = 0;
|
|
@@ -27,6 +27,14 @@ object and an embedded list rendered by a declarative `entityEdit` screen.
|
|
|
27
27
|
`createEmbeddedListField`; its renderer receives `embeddedListCells`, so no
|
|
28
28
|
custom screen component is required.
|
|
29
29
|
|
|
30
|
+
- **Derived head totals** (`src/screens/invoice-receive.screen.ts`): an
|
|
31
|
+
`actionForm` declares `derive` so net, VAT, and gross follow the lines
|
|
32
|
+
(`listSum`), or follow a typed net and VAT rate while the list is empty
|
|
33
|
+
(`whenEmpty`, `percent`). The renderer shows them read-only and live; the
|
|
34
|
+
handler wraps its schema with `withFormDerivations(schema, screen)`
|
|
35
|
+
(`src/handlers/invoice-receive.write.ts`), so totals sent by the client are
|
|
36
|
+
replaced on the server.
|
|
37
|
+
|
|
30
38
|
## When to reach for it
|
|
31
39
|
|
|
32
40
|
Use an embedded field or list when the nested data is created, changed, and
|
|
@@ -397,6 +405,74 @@ describe("invoice line items with select/reference/derived/totals metadata", ()
|
|
|
397
405
|
expect(error).toBeDefined();
|
|
398
406
|
});
|
|
399
407
|
});
|
|
408
|
+
|
|
409
|
+
// --- actionForm derive ---
|
|
410
|
+
|
|
411
|
+
describe("invoice receive form derives its totals on the server", () => {
|
|
412
|
+
type ReceiveTotals = {
|
|
413
|
+
netTotal: { amount: number; currency: string };
|
|
414
|
+
vatTotal: { amount: number; currency: string };
|
|
415
|
+
grossTotal: { amount: number; currency: string };
|
|
416
|
+
};
|
|
417
|
+
const eur = (amount: number) => ({ amount, currency: "EUR" });
|
|
418
|
+
const head = { supplier: "Papier AG", invoiceNumber: "RE-2026-0042" };
|
|
419
|
+
|
|
420
|
+
test("with lines, manipulated client totals are replaced by the line sums", async () => {
|
|
421
|
+
const totals = await stack.http.writeOk<ReceiveTotals>(
|
|
422
|
+
"contacts:write:invoice:receive",
|
|
423
|
+
{
|
|
424
|
+
...head,
|
|
425
|
+
lines: [
|
|
426
|
+
{ description: "Paper", amount: 1005, vatRate: 19, vatAmount: 5 },
|
|
427
|
+
{ description: "Toner", amount: 2000, vatRate: 7 },
|
|
428
|
+
],
|
|
429
|
+
netTotal: eur(1),
|
|
430
|
+
vatTotal: eur(2),
|
|
431
|
+
grossTotal: eur(3),
|
|
432
|
+
},
|
|
433
|
+
admin,
|
|
434
|
+
);
|
|
435
|
+
expect(totals).toEqual({
|
|
436
|
+
netTotal: eur(30.05),
|
|
437
|
+
vatTotal: eur(3.31),
|
|
438
|
+
grossTotal: eur(33.36),
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("without lines, the typed net stays and VAT and gross are computed from the rate", async () => {
|
|
443
|
+
const totals = await stack.http.writeOk<ReceiveTotals>(
|
|
444
|
+
"contacts:write:invoice:receive",
|
|
445
|
+
{ ...head, lines: [], netTotal: eur(100), vatRate: 19, vatTotal: eur(0), grossTotal: eur(0) },
|
|
446
|
+
admin,
|
|
447
|
+
);
|
|
448
|
+
expect(totals).toEqual({ netTotal: eur(100), vatTotal: eur(19), grossTotal: eur(119) });
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a stale gross total is overwritten", async () => {
|
|
452
|
+
const totals = await stack.http.writeOk<ReceiveTotals>(
|
|
453
|
+
"contacts:write:invoice:receive",
|
|
454
|
+
{
|
|
455
|
+
...head,
|
|
456
|
+
lines: [],
|
|
457
|
+
netTotal: eur(50),
|
|
458
|
+
vatRate: 20,
|
|
459
|
+
vatTotal: eur(0),
|
|
460
|
+
grossTotal: eur(999),
|
|
461
|
+
},
|
|
462
|
+
admin,
|
|
463
|
+
);
|
|
464
|
+
expect(totals.grossTotal).toEqual(eur(60));
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("a caller without the Admin role is rejected", async () => {
|
|
468
|
+
const error = await stack.http.writeErr(
|
|
469
|
+
"contacts:write:invoice:receive",
|
|
470
|
+
{ ...head, lines: [], netTotal: eur(50), vatRate: 20, vatTotal: eur(0), grossTotal: eur(0) },
|
|
471
|
+
viewer,
|
|
472
|
+
);
|
|
473
|
+
expect(error.code).toBe("access_denied");
|
|
474
|
+
});
|
|
475
|
+
});
|
|
400
476
|
```
|
|
401
477
|
|
|
402
478
|
### src/entities/contact.ts
|
|
@@ -529,6 +605,9 @@ export const productTable = buildEntityTable("product", productEntity);
|
|
|
529
605
|
// on its `lines` sub-fields (Issue #1835), plus a declarative
|
|
530
606
|
// entityEdit screen that renders the list end-to-end via
|
|
531
607
|
// EmbeddedListField, no custom-screen code required
|
|
608
|
+
// invoice-receive — actionForm whose net/VAT/gross head fields are derived
|
|
609
|
+
// from the lines (`derive`); the handler recomputes them with
|
|
610
|
+
// `withFormDerivations`, so client-sent totals are never trusted
|
|
532
611
|
|
|
533
612
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
534
613
|
import { contactEntity } from "./entities/contact";
|
|
@@ -536,6 +615,8 @@ import { invoiceEntity } from "./entities/invoice";
|
|
|
536
615
|
import { productEntity } from "./entities/product";
|
|
537
616
|
import { contactCreate } from "./handlers/contact-create.write";
|
|
538
617
|
import { contactDetail } from "./handlers/contact-detail.query";
|
|
618
|
+
import { invoiceReceive } from "./handlers/invoice-receive.write";
|
|
619
|
+
import { invoiceReceiveScreen } from "./screens/invoice-receive.screen";
|
|
539
620
|
|
|
540
621
|
export { contactEntity } from "./entities/contact";
|
|
541
622
|
export { invoiceEntity } from "./entities/invoice";
|
|
@@ -557,6 +638,7 @@ export const embeddedFeature = defineFeature("contacts", (r) => {
|
|
|
557
638
|
|
|
558
639
|
r.writeHandler(contactCreate);
|
|
559
640
|
r.queryHandler(contactDetail);
|
|
641
|
+
r.writeHandler(invoiceReceive);
|
|
560
642
|
|
|
561
643
|
r.crud("product", productEntity, { write: adminWrite, read: openRead });
|
|
562
644
|
r.crud("invoice", invoiceEntity, { write: adminWrite, read: openRead });
|
|
@@ -569,6 +651,8 @@ export const embeddedFeature = defineFeature("contacts", (r) => {
|
|
|
569
651
|
sections: [{ columns: 1, fields: ["customer", "lines"] }],
|
|
570
652
|
},
|
|
571
653
|
});
|
|
654
|
+
|
|
655
|
+
r.screen(invoiceReceiveScreen);
|
|
572
656
|
});
|
|
573
657
|
```
|
|
574
658
|
|
|
@@ -646,3 +730,110 @@ export const contactDetail = defineQueryHandler({
|
|
|
646
730
|
},
|
|
647
731
|
});
|
|
648
732
|
```
|
|
733
|
+
|
|
734
|
+
### src/handlers/invoice-receive.write.ts
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
import {
|
|
738
|
+
defineWriteHandler,
|
|
739
|
+
fieldToZod,
|
|
740
|
+
withFormDerivations,
|
|
741
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
742
|
+
import { z } from "zod";
|
|
743
|
+
import { invoiceReceiveLines, invoiceReceiveScreen } from "../screens/invoice-receive.screen";
|
|
744
|
+
|
|
745
|
+
const moneyPayload = z.object({ amount: z.number(), currency: z.string().min(1) });
|
|
746
|
+
|
|
747
|
+
// The preprocess recomputes net/VAT/gross before validation: totals sent by the
|
|
748
|
+
// client never reach the handler.
|
|
749
|
+
const receiveInput = withFormDerivations(
|
|
750
|
+
z.object({
|
|
751
|
+
supplier: z.string().min(1),
|
|
752
|
+
invoiceNumber: z.string().min(1),
|
|
753
|
+
lines: fieldToZod(invoiceReceiveLines, ["EUR"]),
|
|
754
|
+
netTotal: moneyPayload,
|
|
755
|
+
vatRate: z.number().optional(),
|
|
756
|
+
vatTotal: moneyPayload,
|
|
757
|
+
grossTotal: moneyPayload,
|
|
758
|
+
}),
|
|
759
|
+
invoiceReceiveScreen,
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
export const invoiceReceive = defineWriteHandler({
|
|
763
|
+
name: "invoice:receive",
|
|
764
|
+
schema: receiveInput,
|
|
765
|
+
access: { roles: ["Admin"] },
|
|
766
|
+
handler: async (event) => ({
|
|
767
|
+
isSuccess: true,
|
|
768
|
+
data: {
|
|
769
|
+
netTotal: event.payload.netTotal,
|
|
770
|
+
vatTotal: event.payload.vatTotal,
|
|
771
|
+
grossTotal: event.payload.grossTotal,
|
|
772
|
+
},
|
|
773
|
+
}),
|
|
774
|
+
});
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### src/screens/invoice-receive.screen.ts
|
|
778
|
+
|
|
779
|
+
```ts
|
|
780
|
+
import {
|
|
781
|
+
type ActionFormScreenDefinition,
|
|
782
|
+
createDecimalField,
|
|
783
|
+
createEmbeddedListField,
|
|
784
|
+
createTextField,
|
|
785
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
786
|
+
|
|
787
|
+
const euro = { kind: "literal", code: "EUR" } as const;
|
|
788
|
+
|
|
789
|
+
export const invoiceReceiveLines = createEmbeddedListField(
|
|
790
|
+
{
|
|
791
|
+
description: { type: "text", required: true },
|
|
792
|
+
amount: { type: "money", required: true },
|
|
793
|
+
vatRate: { type: "decimal", scale: 2, required: true },
|
|
794
|
+
vatAmount: { type: "money" },
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
derived: { vatAmount: { op: "percent", from: ["amount", "vatRate"] } },
|
|
798
|
+
totals: ["amount", "vatAmount"],
|
|
799
|
+
},
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
// Net/VAT/gross are derived: with line items they are the sum of the lines,
|
|
803
|
+
// without lines the net amount and the VAT rate are typed and VAT/gross follow.
|
|
804
|
+
export const invoiceReceiveScreen: ActionFormScreenDefinition = {
|
|
805
|
+
id: "invoice-receive",
|
|
806
|
+
type: "actionForm",
|
|
807
|
+
handler: "contacts:write:invoice:receive",
|
|
808
|
+
fields: {
|
|
809
|
+
supplier: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
810
|
+
invoiceNumber: createTextField({
|
|
811
|
+
required: true,
|
|
812
|
+
personal: false,
|
|
813
|
+
reason: "is_business_data",
|
|
814
|
+
}),
|
|
815
|
+
lines: invoiceReceiveLines,
|
|
816
|
+
netTotal: { type: "money", currency: euro },
|
|
817
|
+
vatRate: createDecimalField({ precision: 5, scale: 2 }),
|
|
818
|
+
vatTotal: { type: "money", currency: euro },
|
|
819
|
+
grossTotal: { type: "money", currency: euro },
|
|
820
|
+
},
|
|
821
|
+
derive: {
|
|
822
|
+
netTotal: { op: "listSum", list: "lines", cell: "amount" },
|
|
823
|
+
vatTotal: {
|
|
824
|
+
op: "listSum",
|
|
825
|
+
list: "lines",
|
|
826
|
+
cell: "vatAmount",
|
|
827
|
+
whenEmpty: { op: "percent", from: ["netTotal", "vatRate"] },
|
|
828
|
+
},
|
|
829
|
+
grossTotal: { op: "sum", from: ["netTotal", "vatTotal"] },
|
|
830
|
+
},
|
|
831
|
+
layout: {
|
|
832
|
+
sections: [
|
|
833
|
+
{ columns: 2, fields: ["supplier", "invoiceNumber"] },
|
|
834
|
+
{ columns: 1, fields: ["lines"] },
|
|
835
|
+
{ columns: 2, fields: ["netTotal", "vatRate", "vatTotal", "grossTotal"] },
|
|
836
|
+
],
|
|
837
|
+
},
|
|
838
|
+
};
|
|
839
|
+
```
|
package/agents/features.md
CHANGED
|
@@ -228,7 +228,7 @@ Requires: `file-derivatives`
|
|
|
228
228
|
|
|
229
229
|
## document-designer
|
|
230
230
|
|
|
231
|
-
Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`.
|
|
231
|
+
Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`. The tenant overview and the editors are for SystemAdmin only; the `access` option adds roles (normally `templateEditAccess(tenantEditable)` of the template-resolver mount), and the server still enforces per template which of them may save.
|
|
232
232
|
|
|
233
233
|
Category: content
|
|
234
234
|
|
|
@@ -644,7 +644,7 @@ Category: data
|
|
|
644
644
|
|
|
645
645
|
## template-resolver
|
|
646
646
|
|
|
647
|
-
Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
|
|
647
|
+
Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host's roles. Reading and delivery do not depend on it: a tenant override that exists is served. A collection mounted without `access` is SystemAdmin only. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
|
|
648
648
|
|
|
649
649
|
Category: notifications
|
|
650
650
|
|
package/agents/widgets.md
CHANGED
|
@@ -55,6 +55,7 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
|
|
|
55
55
|
| `NavTree` | |
|
|
56
56
|
| `NumberField` | Zahlenfeld = Field + Input(kind:"number") in einem — nimmt der Screen die wiederholte id/name/value/onChange-Verdrahtung ab. |
|
|
57
57
|
| `PageSection` | |
|
|
58
|
+
| `PdfPreview` | |
|
|
58
59
|
| `PercentField` | |
|
|
59
60
|
| `PhotoSlots` | Guided photo capture: one tile per requested shot in a two-column grid, each its own camera/gallery picker, filled tiles show the thumbnail. |
|
|
60
61
|
| `PlainContentEditor` | |
|
package/dist/db/money.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { toMinorUnits } from "../engine/minor-units.ts";
|
|
1
2
|
import type { EntityDefinition } from "../engine/types/index.ts";
|
|
2
|
-
export
|
|
3
|
+
export { toMinorUnits };
|
|
3
4
|
export declare function moneyPayloadToMinorUnits(raw: unknown): number | undefined;
|
|
4
5
|
export declare function flattenMoney(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
|
|
5
6
|
/** Shape of a single rehydrated money field — {amount major, currency,
|
package/dist/db/money.js
CHANGED
|
@@ -30,15 +30,10 @@
|
|
|
30
30
|
// Anders als locatedTimestamp behalten wir den Field-Namen `<name>` als
|
|
31
31
|
// amount-Spalte (Legacy DB-Convention für Money — `SUM(buying_price)` bleibt
|
|
32
32
|
// idiomatisch). `<name>Currency` ist die zusätzliche Spalte.
|
|
33
|
+
import { MINOR_UNIT_SCALE, toMinorUnits } from "../engine/minor-units.js";
|
|
33
34
|
import { DEFAULT_CURRENCIES } from "../engine/types/index.js";
|
|
34
35
|
const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
|
|
35
|
-
|
|
36
|
-
// (EUR/USD/GBP/...). No ISO-4217 minor-unit table yet (JPY=0, BHD=3) —
|
|
37
|
-
// upgrade path once a currency needing a different scale actually lands.
|
|
38
|
-
const MINOR_UNIT_SCALE = 100;
|
|
39
|
-
export function toMinorUnits(amount) {
|
|
40
|
-
return Math.round(amount * MINOR_UNIT_SCALE);
|
|
41
|
-
}
|
|
36
|
+
export { toMinorUnits };
|
|
42
37
|
function toMajorUnits(amountScaled) {
|
|
43
38
|
return amountScaled / MINOR_UNIT_SCALE;
|
|
44
39
|
}
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -5,6 +5,7 @@ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
|
5
5
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
6
6
|
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
7
7
|
import { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
|
|
8
|
+
import { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
8
9
|
import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
|
|
9
10
|
import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, } from "./tenant-db-runner.js";
|
|
10
11
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
@@ -555,6 +556,9 @@ function buildTenantDb(options) {
|
|
|
555
556
|
const filter = readWhere(table, where);
|
|
556
557
|
return withDbSpan("select", table, async () => bunCountWhere(db, table, filter));
|
|
557
558
|
},
|
|
559
|
+
advisoryXactLock(namespace, key) {
|
|
560
|
+
return acquireNamespacedAdvisoryLock(db, namespace, `${tenantId}:${key}`);
|
|
561
|
+
},
|
|
558
562
|
aggregate(table, spec, where) {
|
|
559
563
|
const filter = readWhere(table, where);
|
|
560
564
|
return withDbSpan("select", table, async () => bunAggregateWhere(db, table, spec, filter));
|
|
@@ -642,6 +642,9 @@ function validateEmbeddedDerivedCells(fieldName, entityName, field) {
|
|
|
642
642
|
if (!(derivedName in field.schema)) {
|
|
643
643
|
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema. Add the sub-field to the schema or remove the derived cell.`);
|
|
644
644
|
}
|
|
645
|
+
if (derivedDef.op === "percent" && derivedDef.from.length !== 2) {
|
|
646
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" with op "percent", which needs exactly two sources (base, rate).`);
|
|
647
|
+
}
|
|
645
648
|
for (const sourceName of derivedDef.from) {
|
|
646
649
|
if (!(sourceName in field.schema)) {
|
|
647
650
|
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}". Read only sub-fields that exist in the schema.`);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types/feature.ts";
|
|
2
|
+
import type { ActionFormScreenDefinition } from "../types/screen.ts";
|
|
3
|
+
export declare function validateFormDerivations(featureName: string, screenId: string, screen: ActionFormScreenDefinition): void;
|
|
4
|
+
export declare function validateFormDerivationsEnforced(featureName: string, screenId: string, screen: ActionFormScreenDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { orderFormDerivations } from "../form-derivations.js";
|
|
2
|
+
import { parseQn } from "../qualified-name.js";
|
|
3
|
+
import { formDerivationsOfSchema } from "../with-form-derivations.js";
|
|
4
|
+
const NUMERIC_FORM_FIELD_TYPES = ["money", "number", "decimal"];
|
|
5
|
+
const NUMERIC_SUB_FIELD_TYPES = ["money", "number", "decimal"];
|
|
6
|
+
function validateFromList({ prefix, screen, target }, derivation) {
|
|
7
|
+
if (derivation.op === "percent" && derivation.from.length !== 2) {
|
|
8
|
+
throw new Error(`${prefix}.${target}: "percent" needs exactly two sources (base, rate).`);
|
|
9
|
+
}
|
|
10
|
+
for (const source of derivation.from) {
|
|
11
|
+
if (!(source in screen.fields)) {
|
|
12
|
+
throw new Error(`${prefix}.${target}: unknown source field "${source}". Name a field of this screen.`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function validateListSum(check, derivation) {
|
|
17
|
+
const { prefix, screen, target } = check;
|
|
18
|
+
const listField = screen.fields[derivation.list];
|
|
19
|
+
if (listField === undefined) {
|
|
20
|
+
throw new Error(`${prefix}.${target}: unknown list field "${derivation.list}".`);
|
|
21
|
+
}
|
|
22
|
+
if (listField.type !== "embedded" || listField.multiple !== true) {
|
|
23
|
+
throw new Error(`${prefix}.${target}: "${derivation.list}" must be an embedded list field (multiple: true).`);
|
|
24
|
+
}
|
|
25
|
+
const cellType = listField.schema[derivation.cell]?.type;
|
|
26
|
+
if (cellType === undefined || !NUMERIC_SUB_FIELD_TYPES.includes(cellType)) {
|
|
27
|
+
throw new Error(`${prefix}.${target}: "${derivation.cell}" must be a money, number or decimal sub-field of "${derivation.list}".`);
|
|
28
|
+
}
|
|
29
|
+
const { whenEmpty } = derivation;
|
|
30
|
+
// skip: whenEmpty is optional
|
|
31
|
+
if (whenEmpty === undefined)
|
|
32
|
+
return;
|
|
33
|
+
// Type forbids a nested listSum; JS/cast callers can still pass one.
|
|
34
|
+
if (!("from" in whenEmpty)) {
|
|
35
|
+
throw new Error(`${prefix}.${target}: whenEmpty must not be a listSum.`);
|
|
36
|
+
}
|
|
37
|
+
validateFromList(check, whenEmpty);
|
|
38
|
+
}
|
|
39
|
+
// actionForm `derive`: every name must resolve, targets must be numeric, a
|
|
40
|
+
// listSum must point at a numeric cell of an embedded list, no cycles.
|
|
41
|
+
export function validateFormDerivations(featureName, screenId, screen) {
|
|
42
|
+
const derive = screen.derive;
|
|
43
|
+
// skip: the screen derives nothing
|
|
44
|
+
if (derive === undefined)
|
|
45
|
+
return;
|
|
46
|
+
const prefix = `[Feature ${featureName}] Screen "${screenId}" (actionForm) derive`;
|
|
47
|
+
for (const [target, derivation] of Object.entries(derive)) {
|
|
48
|
+
const targetField = screen.fields[target];
|
|
49
|
+
if (targetField === undefined) {
|
|
50
|
+
throw new Error(`${prefix}: key "${target}" is not a field of this screen.`);
|
|
51
|
+
}
|
|
52
|
+
if (!NUMERIC_FORM_FIELD_TYPES.includes(targetField.type)) {
|
|
53
|
+
throw new Error(`${prefix}.${target}: a derived field must be money, number or decimal, not "${targetField.type}".`);
|
|
54
|
+
}
|
|
55
|
+
const check = { prefix, screen, target };
|
|
56
|
+
if (derivation.op === "listSum")
|
|
57
|
+
validateListSum(check, derivation);
|
|
58
|
+
else
|
|
59
|
+
validateFromList(check, derivation);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
orderFormDerivations(derive);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Secure by default: a derive the handler schema doesn't recompute would let a
|
|
69
|
+
// client send its own totals, so the handler must wrap with this very screen.
|
|
70
|
+
export function validateFormDerivationsEnforced(featureName, screenId, screen, featureMap) {
|
|
71
|
+
// skip: the screen derives nothing
|
|
72
|
+
if (screen.derive === undefined)
|
|
73
|
+
return;
|
|
74
|
+
const { scope, name } = parseQn(screen.handler);
|
|
75
|
+
const schema = featureMap.get(scope)?.writeHandlers[name]?.schema;
|
|
76
|
+
// skip: wrapped with this screen
|
|
77
|
+
if (formDerivationsOfSchema(schema) === screen.derive)
|
|
78
|
+
return;
|
|
79
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (actionForm) declares derive, but handler ` +
|
|
80
|
+
`"${screen.handler}" does not recompute it on the server. Wrap the handler schema: ` +
|
|
81
|
+
`withFormDerivations(schema, screen) with this screen object.`);
|
|
82
|
+
}
|
|
@@ -18,6 +18,7 @@ import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js"
|
|
|
18
18
|
import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
19
19
|
import { metricField } from "../types/index.js";
|
|
20
20
|
import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
|
|
21
|
+
import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
|
|
21
22
|
import { isSelectOptionTone } from "./select-option-tone.js";
|
|
22
23
|
function isRowClickAction(action) {
|
|
23
24
|
return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
|
|
@@ -945,6 +946,10 @@ function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandl
|
|
|
945
946
|
const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
|
|
946
947
|
validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
|
|
947
948
|
validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
|
|
949
|
+
if (screen.type === "actionForm") {
|
|
950
|
+
validateFormDerivations(feature.name, screenId, screen);
|
|
951
|
+
validateFormDerivationsEnforced(feature.name, screenId, screen, featureMap);
|
|
952
|
+
}
|
|
948
953
|
validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
|
|
949
954
|
}
|
|
950
955
|
// SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
|
|
@@ -1287,17 +1292,25 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1287
1292
|
else if (action.kind === "clientAction") {
|
|
1288
1293
|
validateClientActionName(feature.name, screenId, "projectionList", "rowAction", action);
|
|
1289
1294
|
}
|
|
1295
|
+
else if (!allWriteHandlerQns.has(action.handler)) {
|
|
1296
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) rowAction "${action.id}" ` +
|
|
1297
|
+
`handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
|
|
1298
|
+
`(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
1299
|
+
}
|
|
1290
1300
|
}
|
|
1291
1301
|
validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
|
|
1292
1302
|
}
|
|
1293
|
-
//
|
|
1294
|
-
// here — plain navigate (no tab) and writeHandler toolbarActions on
|
|
1295
|
-
// projectionList have no boot check yet (pre-existing gap).
|
|
1303
|
+
// Plain navigate toolbarActions (no tab) have no boot check yet.
|
|
1296
1304
|
if (screen.toolbarActions !== undefined) {
|
|
1297
1305
|
for (const action of screen.toolbarActions) {
|
|
1298
1306
|
if (action.kind === "drawer") {
|
|
1299
1307
|
validateDrawerTargetAction(feature.name, screenId, "projectionList", "toolbarAction", action, feature.screens);
|
|
1300
1308
|
}
|
|
1309
|
+
else if (action.kind === "writeHandler" && !allWriteHandlerQns.has(action.handler)) {
|
|
1310
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) toolbarAction "${action.id}" ` +
|
|
1311
|
+
`handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
|
|
1312
|
+
`(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
1313
|
+
}
|
|
1301
1314
|
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
1302
1315
|
resolveRowActionNavigateTarget(feature.name, screenId, "projectionList", "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
1303
1316
|
}
|
|
@@ -46,6 +46,10 @@ const PROVIDER_PANEL_TITLE_ENTRY = {
|
|
|
46
46
|
es: "Proveedor",
|
|
47
47
|
};
|
|
48
48
|
const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
|
|
49
|
+
// The empty option of a provider select means "no own provider, the platform handles it". A selector
|
|
50
|
+
// names it through `<field label key>.platformDefault`, else this generic text applies.
|
|
51
|
+
const PLATFORM_DEFAULT_LABEL_SUFFIX = ".platformDefault";
|
|
52
|
+
const PLATFORM_DEFAULT_FALLBACK_KEY = "config.settings.provider.platformDefault";
|
|
49
53
|
const SECRETS_SECTION_DESCRIPTION_KEY = "config.secrets.description";
|
|
50
54
|
const audienceNavShortId = (scope) => `audience-${scope}`;
|
|
51
55
|
// Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
|
|
@@ -214,8 +218,8 @@ function buildFeatureNav(scope, feature, ordered, access, declaredTranslationKey
|
|
|
214
218
|
access,
|
|
215
219
|
};
|
|
216
220
|
}
|
|
217
|
-
// One configEdit screen + child-nav per feature; a tenant-scope selector owner
|
|
218
|
-
//
|
|
221
|
+
// One configEdit screen + child-nav per feature; a tenant-scope selector owner folds its
|
|
222
|
+
// gated plugins into its own screen (a dashboard when it carries extra panels).
|
|
219
223
|
function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess) {
|
|
220
224
|
const out = { screens: [], navs: [], translations: {} };
|
|
221
225
|
const ungated = visible.filter((v) => !gating.pluginFeatures.has(v.key.ownerFeature));
|
|
@@ -230,7 +234,7 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
|
|
|
230
234
|
out.navs.push(buildFeatureNav(scope, feature, ordered, access, hub.declaredTranslationKeys));
|
|
231
235
|
continue;
|
|
232
236
|
}
|
|
233
|
-
const ownerHub =
|
|
237
|
+
const ownerHub = buildSelectorOwnerScreens(feature, ordered, access, plugins, gating.ownerPanelsByGroup.get(feature) ?? [], hub, secretsAccess);
|
|
234
238
|
out.screens.push(...ownerHub.screens);
|
|
235
239
|
out.navs.push(buildFeatureNav(scope, feature, ordered, ownerHub.access, hub.declaredTranslationKeys));
|
|
236
240
|
Object.assign(out.translations, ownerHub.translations);
|
|
@@ -251,15 +255,19 @@ function assertUniquePanelIds(ownerGroup, panels) {
|
|
|
251
255
|
// One form for the provider and the settings of every provider: a single save writes
|
|
252
256
|
// all of it, so switching sections can't drop typed input. Each plugin's fields are
|
|
253
257
|
// shown only while its provider is picked in the form; hidden fields are not saved.
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
// Without extra panels the form is the owner's screen itself; with panels it sits
|
|
259
|
+
// chromeless in a dashboard.
|
|
260
|
+
function buildSelectorOwnerScreens(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
|
|
261
|
+
const hasPanels = ownerPanels.length > 0;
|
|
262
|
+
const selectionScreenId = hasPanels ? `${ownerGroup}-tenant-selection` : `${ownerGroup}-tenant`;
|
|
256
263
|
const form = new ProviderFormBuilder(selectionScreenId, buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
|
|
257
264
|
description: EXTENSION_SELECTOR_HINT_KEY,
|
|
258
|
-
sectionTitle: PROVIDER_SECTION_TITLE_KEY,
|
|
265
|
+
...(hasPanels && { sectionTitle: PROVIDER_SECTION_TITLE_KEY }),
|
|
259
266
|
}));
|
|
260
267
|
const accessRules = [ownerAccess];
|
|
261
268
|
const translations = {};
|
|
262
|
-
|
|
269
|
+
if (hasPanels)
|
|
270
|
+
translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
|
|
263
271
|
Object.assign(translations, providerOptionTranslations(form.base, hub));
|
|
264
272
|
for (const plugin of plugins) {
|
|
265
273
|
const providerFieldId = form.fieldIdOfConfigKey(plugin.selectorQn);
|
|
@@ -279,7 +287,10 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
279
287
|
}
|
|
280
288
|
}
|
|
281
289
|
const access = unionAccessRules(accessRules);
|
|
282
|
-
const
|
|
290
|
+
const providerForm = form.build(access);
|
|
291
|
+
if (!hasPanels)
|
|
292
|
+
return { screens: [providerForm], access, translations };
|
|
293
|
+
const selectionScreen = { ...providerForm, dormant: true };
|
|
283
294
|
const dashboard = {
|
|
284
295
|
id: `${ownerGroup}-tenant`,
|
|
285
296
|
type: "dashboard",
|
|
@@ -726,6 +737,14 @@ function providerOptionTranslations(selectionScreen, hub) {
|
|
|
726
737
|
const featureByPluginId = hub.selectorOptionFeatures.get(qn);
|
|
727
738
|
if (featureByPluginId === undefined)
|
|
728
739
|
continue;
|
|
740
|
+
const fieldLabelTranslationKey = selectionScreen.fieldLabels?.[fieldId];
|
|
741
|
+
const platformDefaultLabel = (fieldLabelTranslationKey !== undefined
|
|
742
|
+
? hub.translationsByKey.get(`${fieldLabelTranslationKey}${PLATFORM_DEFAULT_LABEL_SUFFIX}`)
|
|
743
|
+
: undefined) ?? hub.translationsByKey.get(PLATFORM_DEFAULT_FALLBACK_KEY);
|
|
744
|
+
if (platformDefaultLabel !== undefined) {
|
|
745
|
+
out[`${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:`] =
|
|
746
|
+
platformDefaultLabel;
|
|
747
|
+
}
|
|
729
748
|
for (const [pluginId, feature] of featureByPluginId) {
|
|
730
749
|
const label = hub.translationsByKey.get(`${feature}.settings`);
|
|
731
750
|
if (label === undefined)
|
|
@@ -738,8 +757,12 @@ function providerOptionTranslations(selectionScreen, hub) {
|
|
|
738
757
|
}
|
|
739
758
|
function deriveField(key, selectorPluginIds, screenKeys, fieldIdOf) {
|
|
740
759
|
const def = key.def;
|
|
741
|
-
if (selectorPluginIds !== undefined)
|
|
742
|
-
return createSelectField({
|
|
760
|
+
if (selectorPluginIds !== undefined) {
|
|
761
|
+
return createSelectField({
|
|
762
|
+
options: selectorPluginIds.length > 0 ? ["", ...selectorPluginIds] : selectorPluginIds,
|
|
763
|
+
display: "radio",
|
|
764
|
+
});
|
|
765
|
+
}
|
|
743
766
|
switch (def.type) {
|
|
744
767
|
case "number":
|
|
745
768
|
return createNumberField({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EmbeddedDerivedCellDef, EmbeddedSubFieldDef } from "./types/index.ts";
|
|
2
2
|
/** Computes a derived cell from its source values. Missing/non-numeric
|
|
3
|
-
* sources: "multiply" with any missing source → undefined; "sum"/"subtract"
|
|
3
|
+
* sources: "multiply"/"percent" with any missing source → undefined; "sum"/"subtract"
|
|
4
4
|
* treat missing as 0 only when at least one source is present — if every
|
|
5
5
|
* source is missing, return undefined (do not invent 0). Money cells are
|
|
6
6
|
* minor-unit integers — unit-agnostic arithmetic; `withDerivedCells` rounds
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
2
|
/** Computes a derived cell from its source values. Missing/non-numeric
|
|
3
|
-
* sources: "multiply" with any missing source → undefined; "sum"/"subtract"
|
|
3
|
+
* sources: "multiply"/"percent" with any missing source → undefined; "sum"/"subtract"
|
|
4
4
|
* treat missing as 0 only when at least one source is present — if every
|
|
5
5
|
* source is missing, return undefined (do not invent 0). Money cells are
|
|
6
6
|
* minor-unit integers — unit-agnostic arithmetic; `withDerivedCells` rounds
|
|
@@ -15,6 +15,12 @@ export function computeDerivedCellValue(op, values) {
|
|
|
15
15
|
}
|
|
16
16
|
return product;
|
|
17
17
|
}
|
|
18
|
+
if (op === "percent") {
|
|
19
|
+
const [base, rate] = values;
|
|
20
|
+
if (values.length !== 2 || base === undefined || rate === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
return (base * rate) / 100;
|
|
23
|
+
}
|
|
18
24
|
if (values.every((value) => value === undefined))
|
|
19
25
|
return undefined;
|
|
20
26
|
const numeric = values.map((value) => value ?? 0);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FormFieldDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
2
|
+
import type { FormFieldDerivation } from "./types/screen.ts";
|
|
3
|
+
export type FormDerivationScreen = {
|
|
4
|
+
readonly fields: Readonly<Record<string, FormFieldDefinition>>;
|
|
5
|
+
readonly derive?: Readonly<Record<string, FormFieldDerivation>>;
|
|
6
|
+
};
|
|
7
|
+
/** Targets ordered so every derived source is computed before its reader. */
|
|
8
|
+
export declare function orderFormDerivations(derive: Readonly<Record<string, FormFieldDerivation>>): readonly string[];
|
|
9
|
+
/** True when `field` is a `listSum` without `whenEmpty` whose list has no
|
|
10
|
+
* rows: the sum has nothing to add up, so the user types the value. */
|
|
11
|
+
export declare function isFormDerivationInputMode(screen: FormDerivationScreen, values: unknown, field: string): boolean;
|
|
12
|
+
/** True when `field` is only read as a `whenEmpty` operand of `listSum`
|
|
13
|
+
* derivations whose lists all have rows: nothing reads it, so it is locked
|
|
14
|
+
* rather than inviting an input that is silently dropped. */
|
|
15
|
+
export declare function isUnusedFallbackOperand(screen: FormDerivationScreen, values: unknown, field: string): boolean;
|
|
16
|
+
/** Recomputes every derived head field of an action form from its inputs.
|
|
17
|
+
* Pure and client-safe: the renderer runs it live, the server runs it
|
|
18
|
+
* again (`withFormDerivations`) and overwrites whatever the client sent.
|
|
19
|
+
* Money operands are converted to minor units for the arithmetic and money
|
|
20
|
+
* targets are written back as `{ amount, currency }` in major units. */
|
|
21
|
+
export declare function applyFormDerivations(values: unknown, screen: FormDerivationScreen): unknown;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "./embedded-derived.js";
|
|
3
|
+
import { MINOR_UNIT_SCALE, toMinorUnits } from "./minor-units.js";
|
|
4
|
+
function readMoneyOperand(raw) {
|
|
5
|
+
if (typeof raw === "number")
|
|
6
|
+
return { minor: toMinorUnits(raw), currency: undefined };
|
|
7
|
+
if (!isPlainObject(raw) || typeof raw["amount"] !== "number")
|
|
8
|
+
return undefined;
|
|
9
|
+
const currency = raw["currency"];
|
|
10
|
+
return {
|
|
11
|
+
minor: toMinorUnits(raw["amount"]),
|
|
12
|
+
currency: typeof currency === "string" && currency !== "" ? currency : undefined,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function readOperand(values, fields, name) {
|
|
16
|
+
const raw = values[name];
|
|
17
|
+
if (fields[name]?.type === "money") {
|
|
18
|
+
const money = readMoneyOperand(raw);
|
|
19
|
+
return { value: money?.minor, currency: money?.currency };
|
|
20
|
+
}
|
|
21
|
+
return { value: typeof raw === "number" ? raw : undefined, currency: undefined };
|
|
22
|
+
}
|
|
23
|
+
function dependenciesOf(derivation) {
|
|
24
|
+
if (derivation.op === "listSum") {
|
|
25
|
+
return derivation.whenEmpty === undefined ? [] : derivation.whenEmpty.from;
|
|
26
|
+
}
|
|
27
|
+
return derivation.from;
|
|
28
|
+
}
|
|
29
|
+
/** Targets ordered so every derived source is computed before its reader. */
|
|
30
|
+
export function orderFormDerivations(derive) {
|
|
31
|
+
const ordered = [];
|
|
32
|
+
const state = new Map();
|
|
33
|
+
const visit = (name, path) => {
|
|
34
|
+
const derivation = derive[name];
|
|
35
|
+
// skip: plain input field, nothing to order
|
|
36
|
+
if (derivation === undefined)
|
|
37
|
+
return;
|
|
38
|
+
const status = state.get(name);
|
|
39
|
+
// skip: already ordered via another path
|
|
40
|
+
if (status === "done")
|
|
41
|
+
return;
|
|
42
|
+
if (status === "visiting") {
|
|
43
|
+
throw new Error(`Form derivation cycle: ${[...path, name].join(" -> ")}`);
|
|
44
|
+
}
|
|
45
|
+
state.set(name, "visiting");
|
|
46
|
+
for (const dependency of dependenciesOf(derivation))
|
|
47
|
+
visit(dependency, [...path, name]);
|
|
48
|
+
state.set(name, "done");
|
|
49
|
+
ordered.push(name);
|
|
50
|
+
};
|
|
51
|
+
for (const name of Object.keys(derive))
|
|
52
|
+
visit(name, []);
|
|
53
|
+
return ordered;
|
|
54
|
+
}
|
|
55
|
+
function isNonEmptyList(value) {
|
|
56
|
+
return Array.isArray(value) && value.length > 0;
|
|
57
|
+
}
|
|
58
|
+
/** True when `field` is a `listSum` without `whenEmpty` whose list has no
|
|
59
|
+
* rows: the sum has nothing to add up, so the user types the value. */
|
|
60
|
+
export function isFormDerivationInputMode(screen, values, field) {
|
|
61
|
+
const derivation = screen.derive?.[field];
|
|
62
|
+
if (derivation === undefined || derivation.op !== "listSum")
|
|
63
|
+
return false;
|
|
64
|
+
if (derivation.whenEmpty !== undefined)
|
|
65
|
+
return false;
|
|
66
|
+
return !isPlainObject(values) || !isNonEmptyList(values[derivation.list]);
|
|
67
|
+
}
|
|
68
|
+
/** True when `field` is only read as a `whenEmpty` operand of `listSum`
|
|
69
|
+
* derivations whose lists all have rows: nothing reads it, so it is locked
|
|
70
|
+
* rather than inviting an input that is silently dropped. */
|
|
71
|
+
export function isUnusedFallbackOperand(screen, values, field) {
|
|
72
|
+
const derive = screen.derive;
|
|
73
|
+
if (derive === undefined || derive[field] !== undefined)
|
|
74
|
+
return false;
|
|
75
|
+
let usedAsFallback = false;
|
|
76
|
+
for (const derivation of Object.values(derive)) {
|
|
77
|
+
if (derivation.op !== "listSum") {
|
|
78
|
+
if (derivation.from.includes(field))
|
|
79
|
+
return false;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (derivation.whenEmpty?.from.includes(field) !== true)
|
|
83
|
+
continue;
|
|
84
|
+
if (!isPlainObject(values) || !isNonEmptyList(values[derivation.list]))
|
|
85
|
+
return false;
|
|
86
|
+
usedAsFallback = true;
|
|
87
|
+
}
|
|
88
|
+
return usedAsFallback;
|
|
89
|
+
}
|
|
90
|
+
function sumListCell(rows, listField, cell) {
|
|
91
|
+
const embedded = listField?.type === "embedded" ? listField : undefined;
|
|
92
|
+
let sum = 0;
|
|
93
|
+
for (const rawRow of rows) {
|
|
94
|
+
// Recompute the row's derived cells so a manipulated row value can't leak into the head.
|
|
95
|
+
const row = embedded?.derived === undefined
|
|
96
|
+
? rawRow
|
|
97
|
+
: withDerivedCells(rawRow, embedded.derived, embedded.schema);
|
|
98
|
+
if (isPlainObject(row) && typeof row[cell] === "number")
|
|
99
|
+
sum += row[cell];
|
|
100
|
+
}
|
|
101
|
+
return sum;
|
|
102
|
+
}
|
|
103
|
+
function targetCurrency(field, current, sourceCurrency) {
|
|
104
|
+
if (isPlainObject(current) &&
|
|
105
|
+
typeof current["currency"] === "string" &&
|
|
106
|
+
current["currency"] !== "") {
|
|
107
|
+
return current["currency"];
|
|
108
|
+
}
|
|
109
|
+
if (sourceCurrency !== undefined)
|
|
110
|
+
return sourceCurrency;
|
|
111
|
+
if (field.type === "money" && field.currency.kind === "literal")
|
|
112
|
+
return field.currency.code;
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/** Recomputes every derived head field of an action form from its inputs.
|
|
116
|
+
* Pure and client-safe: the renderer runs it live, the server runs it
|
|
117
|
+
* again (`withFormDerivations`) and overwrites whatever the client sent.
|
|
118
|
+
* Money operands are converted to minor units for the arithmetic and money
|
|
119
|
+
* targets are written back as `{ amount, currency }` in major units. */
|
|
120
|
+
export function applyFormDerivations(values, screen) {
|
|
121
|
+
if (!isPlainObject(values))
|
|
122
|
+
return values;
|
|
123
|
+
const derive = screen.derive;
|
|
124
|
+
if (derive === undefined)
|
|
125
|
+
return values;
|
|
126
|
+
const result = { ...values };
|
|
127
|
+
for (const target of orderFormDerivations(derive)) {
|
|
128
|
+
const derivation = derive[target];
|
|
129
|
+
const targetField = screen.fields[target];
|
|
130
|
+
if (derivation === undefined || targetField === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
let computed;
|
|
133
|
+
let sourceCurrency;
|
|
134
|
+
let ordinary;
|
|
135
|
+
if (derivation.op === "listSum") {
|
|
136
|
+
const rows = result[derivation.list];
|
|
137
|
+
if (isNonEmptyList(rows)) {
|
|
138
|
+
computed = sumListCell(rows, screen.fields[derivation.list], derivation.cell);
|
|
139
|
+
}
|
|
140
|
+
else if (derivation.whenEmpty === undefined) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
ordinary = derivation.whenEmpty;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
ordinary = derivation;
|
|
149
|
+
}
|
|
150
|
+
if (ordinary !== undefined) {
|
|
151
|
+
const operands = ordinary.from.map((name) => readOperand(result, screen.fields, name));
|
|
152
|
+
sourceCurrency = operands.find((operand) => operand.currency !== undefined)?.currency;
|
|
153
|
+
computed = computeDerivedCellValue(ordinary.op, operands.map((operand) => operand.value));
|
|
154
|
+
}
|
|
155
|
+
if (computed === undefined) {
|
|
156
|
+
delete result[target];
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (targetField.type === "money") {
|
|
160
|
+
const minor = roundDerivedCellValue(computed, { type: "money" });
|
|
161
|
+
const currency = targetCurrency(targetField, result[target], sourceCurrency);
|
|
162
|
+
result[target] =
|
|
163
|
+
currency === undefined
|
|
164
|
+
? { amount: minor / MINOR_UNIT_SCALE }
|
|
165
|
+
: { amount: minor / MINOR_UNIT_SCALE, currency };
|
|
166
|
+
}
|
|
167
|
+
else if (targetField.type === "decimal") {
|
|
168
|
+
result[target] = roundDerivedCellValue(computed, targetField);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
result[target] = computed;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, group
|
|
|
45
45
|
export { type ChangelogEntry, type ChangelogType, compareVersions, type FeatureChangelog, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.ts";
|
|
46
46
|
export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, type ManifestSystemEvent, serializeManifest, } from "./feature-manifest.ts";
|
|
47
47
|
export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.ts";
|
|
48
|
+
export type { FormDerivationScreen } from "./form-derivations.ts";
|
|
49
|
+
export { applyFormDerivations, isFormDerivationInputMode } from "./form-derivations.ts";
|
|
48
50
|
export { HANDLER_FIELD_REFERENCES_META_KEY, type HandlerFieldPathSegment, type HandlerFieldReference, type HandlerFieldReferenceAtPath, type HandlerFieldReferenceMeta, handlerFieldReference, handlerFieldReferences, readHandlerFieldReferences, } from "./handler-field-references.ts";
|
|
49
51
|
export { resolveName, withResponseData } from "./handler-helpers.ts";
|
|
50
52
|
export { i18nKey } from "./i18n-key.ts";
|
|
@@ -85,7 +87,8 @@ export { assertNotificationVariables, templateVariable, templateVariablesToZod,
|
|
|
85
87
|
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, requireTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.ts";
|
|
86
88
|
export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.ts";
|
|
87
89
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.ts";
|
|
88
|
-
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, BootOptionName, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityObjectDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookDefinition, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MaxPerTenantOptions, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreparedWriteEvent, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, ProviderPluginContext, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StackBootRequirement, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.ts";
|
|
90
|
+
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, BootOptionName, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityObjectDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookDefinition, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MaxPerTenantOptions, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreparedWriteEvent, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, ProviderPluginContext, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StackBootRequirement, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TemplateImagesVariableDef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.ts";
|
|
89
91
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.ts";
|
|
90
92
|
export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.ts";
|
|
91
93
|
export { runValidation } from "./validation.ts";
|
|
94
|
+
export { formDerivationsOfSchema, withFormDerivations } from "./with-form-derivations.ts";
|
package/dist/engine/index.js
CHANGED
|
@@ -40,6 +40,7 @@ export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, group
|
|
|
40
40
|
export { compareVersions, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
|
|
41
41
|
export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
|
|
42
42
|
export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
|
|
43
|
+
export { applyFormDerivations, isFormDerivationInputMode } from "./form-derivations.js";
|
|
43
44
|
export { HANDLER_FIELD_REFERENCES_META_KEY, handlerFieldReference, handlerFieldReferences, readHandlerFieldReferences, } from "./handler-field-references.js";
|
|
44
45
|
export { resolveName, withResponseData } from "./handler-helpers.js";
|
|
45
46
|
export { i18nKey } from "./i18n-key.js";
|
|
@@ -80,3 +81,4 @@ export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTra
|
|
|
80
81
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
81
82
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
82
83
|
export { runValidation } from "./validation.js";
|
|
84
|
+
export { formDerivationsOfSchema, withFormDerivations } from "./with-form-derivations.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// 2 decimal places (cents) — covers every currently-supported currency
|
|
2
|
+
// (EUR/USD/GBP/...). No ISO-4217 minor-unit table yet (JPY=0, BHD=3) —
|
|
3
|
+
// upgrade path once a currency needing a different scale actually lands.
|
|
4
|
+
// Client-safe on purpose: db/money.ts pulls in engine runtime values.
|
|
5
|
+
export const MINOR_UNIT_SCALE = 100;
|
|
6
|
+
export function toMinorUnits(amount) {
|
|
7
|
+
return Math.round(amount * MINOR_UNIT_SCALE);
|
|
8
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import type { Registry, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariables } from "./types/index.ts";
|
|
2
|
+
import type { Registry, TemplateImagesVariableDef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariables } from "./types/index.ts";
|
|
3
3
|
type CommonOptions = {
|
|
4
4
|
readonly label?: string;
|
|
5
5
|
readonly required?: boolean;
|
|
@@ -26,6 +26,7 @@ export declare const templateVariable: {
|
|
|
26
26
|
readonly percent: (opts?: CommonOptions & {
|
|
27
27
|
readonly example?: number;
|
|
28
28
|
}) => TemplateScalarVariableDef;
|
|
29
|
+
readonly images: (opts?: CommonOptions) => TemplateImagesVariableDef;
|
|
29
30
|
readonly list: (item: Readonly<Record<string, TemplateScalarVariableDef>>, opts?: CommonOptions & {
|
|
30
31
|
readonly example?: readonly Readonly<Record<string, unknown>>[];
|
|
31
32
|
}) => TemplateListVariableDef;
|
|
@@ -17,6 +17,7 @@ export const templateVariable = {
|
|
|
17
17
|
...opts,
|
|
18
18
|
}),
|
|
19
19
|
percent: (opts = {}) => ({ type: "percent", ...opts }),
|
|
20
|
+
images: (opts = {}) => ({ type: "images", ...opts }),
|
|
20
21
|
list: (item, opts = {}) => ({ type: "list", item, ...opts }),
|
|
21
22
|
};
|
|
22
23
|
const LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
|
@@ -53,7 +54,10 @@ function scalarToZod(def) {
|
|
|
53
54
|
return z.number().finite();
|
|
54
55
|
}
|
|
55
56
|
}
|
|
57
|
+
const imagesSchema = z.union([z.string(), z.array(z.string())]);
|
|
56
58
|
function variableToZod(def) {
|
|
59
|
+
if (def.type === "images")
|
|
60
|
+
return imagesSchema;
|
|
57
61
|
if (def.type !== "list")
|
|
58
62
|
return scalarToZod(def);
|
|
59
63
|
return z.array(z.object(Object.fromEntries(Object.entries(def.item).map(([name, item]) => [name, withOptionality(item)]))));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
|
|
2
|
-
export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, MaxPerTenantOptions, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, MaxPerTenantOptions, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TemplateImagesVariableDef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
|
|
3
3
|
export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "@cosmicdrift/kumiko-types/define-handler";
|
|
4
4
|
export type { EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "@cosmicdrift/kumiko-types/entity-handlers";
|
|
5
5
|
export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, } from "@cosmicdrift/kumiko-types/event-type-map";
|
|
@@ -17,7 +17,7 @@ export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@c
|
|
|
17
17
|
export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
|
|
18
18
|
export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionRowIdOf, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
|
|
19
19
|
export type { BelongsToRelation, EntityRelations, HasManyRelation, ManyToManyRelation, RelationDefinition, } from "@cosmicdrift/kumiko-types/relations";
|
|
20
|
-
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
20
|
+
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormFieldDerivation, FormWidth, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
21
21
|
export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
22
22
|
export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
|
|
23
23
|
export type { Subscribe, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, } from "@cosmicdrift/kumiko-types/tree-node";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { type FormDerivationScreen } from "./form-derivations.ts";
|
|
3
|
+
import type { FormFieldDerivation } from "./types/screen.ts";
|
|
4
|
+
/** The `derive` a schema was wrapped with via `withFormDerivations`, so the
|
|
5
|
+
* boot validator can prove an actionForm's handler recomputes its totals. */
|
|
6
|
+
export declare function formDerivationsOfSchema(schema: unknown): Readonly<Record<string, FormFieldDerivation>> | undefined;
|
|
7
|
+
/** Server counterpart of an actionForm's `derive`: recomputes the derived head
|
|
8
|
+
* fields before the schema validates them, so client-sent totals never count. */
|
|
9
|
+
export declare function withFormDerivations<Schema extends z.ZodType>(schema: Schema, screen: FormDerivationScreen): z.ZodPreprocess<Schema, unknown>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { applyFormDerivations } from "./form-derivations.js";
|
|
3
|
+
const derivationsBySchema = new WeakMap();
|
|
4
|
+
/** The `derive` a schema was wrapped with via `withFormDerivations`, so the
|
|
5
|
+
* boot validator can prove an actionForm's handler recomputes its totals. */
|
|
6
|
+
export function formDerivationsOfSchema(schema) {
|
|
7
|
+
return schema instanceof z.ZodType ? derivationsBySchema.get(schema) : undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Server counterpart of an actionForm's `derive`: recomputes the derived head
|
|
10
|
+
* fields before the schema validates them, so client-sent totals never count. */
|
|
11
|
+
export function withFormDerivations(schema, screen) {
|
|
12
|
+
const wrapped = z.preprocess((value) => applyFormDerivations(value, screen), schema);
|
|
13
|
+
if (screen.derive !== undefined)
|
|
14
|
+
derivationsBySchema.set(wrapped, screen.derive);
|
|
15
|
+
return wrapped;
|
|
16
|
+
}
|
|
@@ -19,6 +19,7 @@ export const SETTINGS_HUB_I18N = {
|
|
|
19
19
|
en: "Choose a provider. Its settings appear below and are saved together with it.",
|
|
20
20
|
},
|
|
21
21
|
"config.settings.provider": { en: "Provider" },
|
|
22
|
+
"config.settings.provider.platformDefault": { en: "Platform default" },
|
|
22
23
|
"config.settings.audience.system": {
|
|
23
24
|
en: "Applies to every tenant unless a tenant sets its own value.",
|
|
24
25
|
},
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Registry, TenantId } from "../engine/types/index.ts";
|
|
2
|
+
import type { SearchAdapter } from "./types.ts";
|
|
3
|
+
export declare function collectSearchableFieldNames(registry: Registry): readonly string[];
|
|
4
|
+
export declare function configureTenantSearchIndex(searchAdapter: SearchAdapter, registry: Registry, tenantId: TenantId): Promise<void>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { deriveSearchAdapterConfig } from "./derive-search-adapter-config.js";
|
|
2
|
+
export function collectSearchableFieldNames(registry) {
|
|
3
|
+
return deriveSearchAdapterConfig(registry)?.searchableFields ?? [];
|
|
4
|
+
}
|
|
5
|
+
export async function configureTenantSearchIndex(searchAdapter, registry, tenantId) {
|
|
6
|
+
const fields = collectSearchableFieldNames(registry);
|
|
7
|
+
await searchAdapter.configure(tenantId, { searchableFields: fields, rankingFields: fields });
|
|
8
|
+
}
|
package/dist/search/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { collectSearchableFieldNames, configureTenantSearchIndex, } from "./configure-tenant-index.ts";
|
|
1
2
|
export { deriveSearchAdapterConfig } from "./derive-search-adapter-config.ts";
|
|
2
3
|
export { createInMemorySearchAdapter } from "./in-memory-adapter.ts";
|
|
3
4
|
export { purgeSearchDocumentsForSubject } from "./purge-subject.ts";
|
package/dist/search/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Damit lädt der Main-Barrel keinen Meilisearch-Client beim bloßen Anfassen
|
|
3
3
|
// von SearchAdapter-Types. Apps die Meilisearch nicht nutzen, ziehen den
|
|
4
4
|
// Client-Code nicht mit rein.
|
|
5
|
+
export { collectSearchableFieldNames, configureTenantSearchIndex, } from "./configure-tenant-index.js";
|
|
5
6
|
export { deriveSearchAdapterConfig } from "./derive-search-adapter-config.js";
|
|
6
7
|
export { createInMemorySearchAdapter } from "./in-memory-adapter.js";
|
|
7
8
|
export { purgeSearchDocumentsForSubject } from "./purge-subject.js";
|
package/dist/ui-types/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
2
2
|
export type { DerivedCellRoundingTarget } from "../engine/embedded-derived.ts";
|
|
3
3
|
export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.ts";
|
|
4
|
+
export type { FormDerivationScreen } from "../engine/form-derivations.ts";
|
|
5
|
+
export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperand, } from "../engine/form-derivations.ts";
|
|
4
6
|
export type { ParsedRefTarget } from "../engine/parse-ref-target.ts";
|
|
5
7
|
export { parseRefTarget } from "../engine/parse-ref-target.ts";
|
|
6
8
|
export type { FieldsOrGroupsSection, UnavailableSelectOption } from "../engine/screen-helpers.ts";
|
package/dist/ui-types/index.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// helper with no cross-module side-effects.
|
|
22
22
|
export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
23
23
|
export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.js";
|
|
24
|
+
export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperand, } from "../engine/form-derivations.js";
|
|
24
25
|
export { parseRefTarget } from "../engine/parse-ref-target.js";
|
|
25
26
|
export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
|
|
26
27
|
export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.378.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"./package.json": "./package.json"
|
|
200
200
|
},
|
|
201
201
|
"dependencies": {
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.378.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-testing": "0.378.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.378.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "actionForm can derive head totals from an embedded list",
|
|
6
|
+
"detail": "An actionForm screen declares `derive` for head fields: sum, subtract, multiply, percent over other fields, or listSum over one cell of an embedded list with an optional whenEmpty fallback. The renderer recomputes them live and shows them read-only; a listSum without whenEmpty becomes an input while the list is empty. withFormDerivations(schema, screen) recomputes them on the server before validation, so client totals are never trusted; boot fails when the handler schema is not wrapped with the screen's own derive. A field that only feeds a `whenEmpty` fallback is read-only while the lists have rows. New embedded derived op `percent` (from[0] * from[1] / 100).",
|
|
7
|
+
"migration": "Add `derive` to the actionForm screen and wrap the handler schema with `withFormDerivations(schema, screen)`; new embedded derived op `percent`."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.378.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Extension-selector settings form is one standard configEdit screen",
|
|
13
|
+
"detail": "Without owner panels the provider form is the `<owner>-tenant` screen itself (no dashboard, no `<owner>-tenant-selection`). The provider select offers an empty \"platform default\" option next to the plugin ids."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.378.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "Search index setup helpers",
|
|
19
|
+
"detail": "collectSearchableFieldNames(registry) returns the deduplicated searchable fields of all entities and configureTenantSearchIndex(adapter, registry, tenantId) configures a tenant index with them, both exported from the search entry."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.378.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "TenantDb.advisoryXactLock",
|
|
25
|
+
"detail": "ctx.db.advisoryXactLock(namespace, key) takes a transaction-scoped pg_advisory_xact_lock keyed on the handle's own tenantId plus key, so tenants never contend and the caller cannot pick another tenant. It needs no unsafeRaw grant."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.378.0",
|
|
29
|
+
"type": "breaking",
|
|
30
|
+
"title": "Boot check for projectionList writeHandler actions",
|
|
31
|
+
"detail": "Boot now rejects projectionList rowActions and toolbarActions of kind writeHandler whose handler is not a registered write-handler QN. Such actions only failed on click before.",
|
|
32
|
+
"migration": "Fix the handler QN to \"<feature>:write:<short>\" of a handler declared via r.writeHandler(...), or remove the action. Apps that fail to boot name the screen and action id in the error."
|
|
33
|
+
},
|
|
2
34
|
{
|
|
3
35
|
"version": "0.377.0",
|
|
4
36
|
"type": "improvement",
|