@cosmicdrift/kumiko-framework 0.374.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.
Files changed (89) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/apex-landing.md +1 -1
  3. package/agents/examples/apex-surface-auth.md +3 -3
  4. package/agents/examples/compose-stacks.md +15 -8
  5. package/agents/examples/delivery-notifications.md +25 -2
  6. package/agents/examples/embedded-entity-form.md +1 -1
  7. package/agents/examples/embedded.md +191 -0
  8. package/agents/examples/notes-history-basic.md +1 -1
  9. package/agents/examples/tags-basic.md +1 -1
  10. package/agents/examples/user-profile.md +1 -1
  11. package/agents/examples/wizard-form.md +4 -4
  12. package/agents/features.md +2 -2
  13. package/agents/widgets.md +4 -3
  14. package/dist/api/index.d.ts +1 -1
  15. package/dist/api/index.js +1 -1
  16. package/dist/api/routes.js +2 -2
  17. package/dist/api/server.js +2 -1
  18. package/dist/config/index.d.ts +4 -0
  19. package/dist/db/index.d.ts +2 -2
  20. package/dist/db/index.js +1 -1
  21. package/dist/db/migrate-runner.js +12 -4
  22. package/dist/db/money.d.ts +2 -1
  23. package/dist/db/money.js +3 -8
  24. package/dist/db/query.js +14 -14
  25. package/dist/db/replay-migration-sql.d.ts +1 -0
  26. package/dist/db/replay-migration-sql.js +115 -11
  27. package/dist/db/tenant-db.d.ts +7 -0
  28. package/dist/db/tenant-db.js +21 -5
  29. package/dist/engine/boot-validator/entity-handler.js +3 -0
  30. package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
  31. package/dist/engine/boot-validator/form-derivations.js +82 -0
  32. package/dist/engine/boot-validator/screens.js +23 -4
  33. package/dist/engine/build-config-feature-schema.js +33 -10
  34. package/dist/engine/embedded-derived.d.ts +1 -1
  35. package/dist/engine/embedded-derived.js +7 -1
  36. package/dist/engine/form-derivations.d.ts +21 -0
  37. package/dist/engine/form-derivations.js +175 -0
  38. package/dist/engine/index.d.ts +5 -1
  39. package/dist/engine/index.js +3 -0
  40. package/dist/engine/minor-units.d.ts +2 -0
  41. package/dist/engine/minor-units.js +8 -0
  42. package/dist/engine/parse-ref-target.js +1 -1
  43. package/dist/engine/provider-plugin-context.d.ts +3 -0
  44. package/dist/engine/provider-plugin-context.js +28 -0
  45. package/dist/engine/schema-builder.js +7 -0
  46. package/dist/engine/template-variables.d.ts +2 -1
  47. package/dist/engine/template-variables.js +4 -0
  48. package/dist/engine/types/index.d.ts +3 -3
  49. package/dist/engine/with-form-derivations.d.ts +9 -0
  50. package/dist/engine/with-form-derivations.js +16 -0
  51. package/dist/env/index.js +1 -1
  52. package/dist/errors/zod-bridge.js +1 -1
  53. package/dist/files/file-ref-entity.d.ts +6 -6
  54. package/dist/files/file-ref-table.d.ts +6 -6
  55. package/dist/files/index.d.ts +1 -1
  56. package/dist/files/index.js +1 -1
  57. package/dist/files/provider-resolver.d.ts +17 -17
  58. package/dist/files/provider-resolver.js +1 -1
  59. package/dist/http/egress.d.ts +14 -0
  60. package/dist/http/egress.js +144 -0
  61. package/dist/http/index.d.ts +3 -2
  62. package/dist/http/index.js +2 -1
  63. package/dist/http/policy.d.ts +30 -0
  64. package/dist/http/policy.js +299 -0
  65. package/dist/i18n/required-surface-keys.js +2 -0
  66. package/dist/i18n/settings-hub-keys.js +1 -0
  67. package/dist/jobs/job-runner.js +4 -2
  68. package/dist/pipeline/config-accessor-factory.d.ts +3 -0
  69. package/dist/pipeline/config-accessor-factory.js +3 -0
  70. package/dist/pipeline/dispatch-shared.js +7 -5
  71. package/dist/pipeline/index.d.ts +1 -0
  72. package/dist/pipeline/index.js +1 -0
  73. package/dist/search/configure-tenant-index.d.ts +4 -0
  74. package/dist/search/configure-tenant-index.js +8 -0
  75. package/dist/search/index.d.ts +1 -0
  76. package/dist/search/index.js +1 -0
  77. package/dist/ui-types/index.d.ts +3 -1
  78. package/dist/ui-types/index.js +1 -0
  79. package/dist/upgrade-changelog.d.ts +25 -0
  80. package/dist/upgrade-changelog.js +48 -0
  81. package/dist/upgrade-cli.js +78 -13
  82. package/package.json +3 -5
  83. package/presets/biome-app.json +8 -0
  84. package/src/changes.json +103 -0
  85. package/src/scripts/codemod/README.md +25 -0
  86. package/src/scripts/codemod/codemod-support.ts +64 -6
  87. package/src/scripts/codemod/package-merge.ts +260 -0
  88. package/src/scripts/codemod/s3-imports.ts +4 -42
  89. package/src/scripts/codemod/testing-injection.ts +135 -0
package/AGENTS.md CHANGED
@@ -10,7 +10,7 @@ not exist in your version:
10
10
  | File | What it holds |
11
11
  | --- | --- |
12
12
  | `agents/features.md` | Every bundled feature: name, purpose, config keys, dependencies |
13
- | `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer-web` with its short description |
13
+ | `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer/web` with its short description |
14
14
  | `agents/examples.md` | Index of the worked examples, one per concept |
15
15
  | `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
16
16
 
@@ -238,7 +238,7 @@ produces code that compiles and then fails in production.
238
238
  - Never write your own JSX for a list, form, detail page or dashboard.
239
239
  Declare a screen. Use `type: "custom"` only when no screen type fits.
240
240
  - Inside a custom screen, never build your own card, table, badge, button or
241
- chart. Use the widgets from `@cosmicdrift/kumiko-renderer-web`
241
+ chart. Use the widgets from `@cosmicdrift/kumiko-renderer/web`
242
242
  (`agents/widgets.md`) and the primitives from `usePrimitives()`.
243
243
  - Never fetch data with `useEffect` plus `fetch`. Use `useQuery`
244
244
  (`live: true` for server-sent updates) and `useMutation`.
@@ -238,7 +238,7 @@ import {
238
238
  type ApexPage,
239
239
  type ApexPricingTier,
240
240
  renderApexPage,
241
- } from "@cosmicdrift/kumiko-headless/apex";
241
+ } from "@cosmicdrift/kumiko-renderer/headless/apex";
242
242
  import { HERO_SCREENSHOT } from "./constants";
243
243
 
244
244
  // --- Inputs: shapes the surrounding features hand you -----------------------
@@ -42,7 +42,7 @@ import {
42
42
  import {
43
43
  RequestAccountDeletionScreen, ConfirmAccountDeletionScreen,
44
44
  } from "@cosmicdrift/kumiko-bundled-features/user-data-rights/web";
45
- import { createPublicSurface } from "@cosmicdrift/kumiko-renderer-web";
45
+ import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
46
46
 
47
47
  // createLoginRoute, not a raw LoginScreen render — it owns the challenge-
48
48
  // swap for a second factor, so mounting auth-mfa later doesn't need this
@@ -252,7 +252,7 @@ describe("apex-surface-auth recipe", () => {
252
252
  // 2. Client (Kommentar unten + README): wie createPublicSurface + AuthShell
253
253
  // die Screens in der Apex-Chrome mounten.
254
254
  //
255
- // CLIENT-WIRING (apex.tsx der App — renderer-web, hier nur als Referenz, da
255
+ // CLIENT-WIRING (apex.tsx der App — renderer/web, hier nur als Referenz, da
256
256
  // Recipes keine Browser-Deps ziehen):
257
257
  //
258
258
  // import {
@@ -263,7 +263,7 @@ describe("apex-surface-auth recipe", () => {
263
263
  // RequestAccountDeletionScreen, ConfirmAccountDeletionScreen,
264
264
  // defaultTranslations as deletionI18n,
265
265
  // } from "@cosmicdrift/kumiko-bundled-features/user-data-rights/web";
266
- // import { createPublicSurface } from "@cosmicdrift/kumiko-renderer-web";
266
+ // import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
267
267
  //
268
268
  // // AuthShell: die Auth-Card rendert in der Marketing-Chrome statt
269
269
  // // Fullscreen. Default (ohne Provider) bleibt der Fullscreen-Wrapper.
@@ -44,22 +44,25 @@ the option.
44
44
  | `composeGdprStack` | `kmsMode` | `kms` or `allowPlaintextPii` |
45
45
  | `composeGdprStack({ selfService })`, `composeUserDataRightsStack` | `mail` | a mounted `channel-email` |
46
46
  | `composeSecurityStack` | `kmsMode`, `rateLimit` not `false` | `kms` or `allowPlaintextPii` |
47
- | `composeSaasStack({ billing: "stripe" })` | `billing` | a mounted subscription provider |
47
+ | `composeSaasStack({ billing: "stripe" })` | `billing` (warning only) | a mounted subscription provider |
48
48
 
49
49
  A missing option fails the boot with:
50
50
 
51
51
  ```
52
52
  [runProdApp] stacks in `features` need boot options that are not set:
53
- - composeSaasStack needs the boot option `billing`: set billing: { stripe: { priceToTier }, defaultTier }
53
+ - composeGdprStack needs the boot option `kmsMode`: set kmsMode: "required" (or { plaintextReason } for a demo)
54
54
  ```
55
55
 
56
+ A stripe `composeSaasStack` without `billing` boots and logs the same line as a warning, so an
57
+ app can go live before its prices exist.
58
+
56
59
  ## Flow
57
60
 
58
61
  1. `src/app.ts` lists the stacks and the boot options they need.
59
62
  2. The test boots with `KUMIKO_DRY_RUN_ENV=boot`, which runs the whole
60
63
  validation and stops before any DB or Redis connection: with the options
61
- the boot passes, without `billing` or `kmsMode` it fails and names the
62
- stacks.
64
+ the boot passes, without `kmsMode` it fails and names the stacks, and
65
+ without `billing` it warns.
63
66
 
64
67
  ## Tests
65
68
 
@@ -122,11 +125,15 @@ describe("compose stacks recipe", () => {
122
125
  expect(lines.some((line: string) => line.includes("boot validation OK"))).toBe(true);
123
126
  });
124
127
 
125
- test("a missing billing option names composeSaasStack and the option", async () => {
128
+ test("a missing billing option only warns and names composeSaasStack", async () => {
126
129
  const { billing: _billing, ...withoutBilling } = bootOptions();
127
- await expect(bootValidate(withoutBilling)).rejects.toThrow(
128
- "composeSaasStack needs the boot option `billing`",
129
- );
130
+ await bootValidate(withoutBilling);
131
+ const warnings = warnSpy.mock.calls.map((call: unknown[]) => String(call[0]));
132
+ expect(
133
+ warnings.some((line: string) =>
134
+ line.includes("composeSaasStack needs the boot option `billing`"),
135
+ ),
136
+ ).toBe(true);
130
137
  });
131
138
 
132
139
  test("a missing KMS decision names every stack that needs it", async () => {
@@ -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:** A tenant admin overrides the text through the template-resolver handlers (`upsertTenant`, then `publish`). The next mail for that tenant uses the override; the system text stays for everyone else.
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;
@@ -471,7 +471,7 @@ export const prospectAcceptScreen: EntityEditScreenDefinition = {
471
471
  ```tsx
472
472
  import type { RenderEditControls } from "@cosmicdrift/kumiko-renderer";
473
473
  import { RenderEdit, useDispatcher, usePrimitives } from "@cosmicdrift/kumiko-renderer";
474
- import { Drawer } from "@cosmicdrift/kumiko-renderer-web";
474
+ import { Drawer } from "@cosmicdrift/kumiko-renderer/web";
475
475
  import { type ReactNode, useMemo, useRef, useState } from "react";
476
476
  import { z } from "zod";
477
477
  import { prospectEntity } from "../entities/prospect";
@@ -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
+ ```
@@ -55,7 +55,7 @@ handler as above. Register `notesHistoryClient()` once (for its component +
55
55
  i18n), then mount it either way:
56
56
 
57
57
  ```tsx illustration
58
- import { createKumikoApp } from "@cosmicdrift/kumiko-renderer-web";
58
+ import { createKumikoApp } from "@cosmicdrift/kumiko-renderer/web";
59
59
  import { notesHistoryClient, NotesSection, NOTES_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/notes-history/web";
60
60
 
61
61
  // once, at app boot — required even for standalone use (registers i18n):
@@ -56,7 +56,7 @@ as above. Register `tagsClient()` once (for its component + i18n), then mount it
56
56
  either way:
57
57
 
58
58
  ```tsx illustration
59
- import { createKumikoApp } from "@cosmicdrift/kumiko-renderer-web";
59
+ import { createKumikoApp } from "@cosmicdrift/kumiko-renderer/web";
60
60
  import { tagsClient, TagSection, TAGS_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/tags/web";
61
61
 
62
62
  // once, at app boot — required even for standalone use (registers i18n):
@@ -54,7 +54,7 @@ itself needs no client registration):
54
54
  ```tsx illustration
55
55
  import { userProfileClient } from "@cosmicdrift/kumiko-bundled-features/user-profile/web";
56
56
  import { emailPasswordClient } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
57
- import { createKumikoApp } from "@cosmicdrift/kumiko-renderer-web";
57
+ import { createKumikoApp } from "@cosmicdrift/kumiko-renderer/web";
58
58
 
59
59
  createKumikoApp({
60
60
  clientFeatures: [emailPasswordClient(), userProfileClient()],
@@ -338,11 +338,11 @@ export const listingsFeature = defineFeature("listings", (r) => {
338
338
  // A `kind: "extension"` section only resolves through
339
339
  // clientFeatures.extensionSectionComponents (createKumikoApp → ExtensionSectionsProvider)
340
340
  // — never imported directly from feature.ts, which keeps the server file
341
- // free of the renderer-web graph. Without registering this export, an app
341
+ // free of the renderer/web graph. Without registering this export, an app
342
342
  // mounting this recipe gets RenderEdit's fallback placeholder instead of
343
343
  // the real review step.
344
344
 
345
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
345
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
346
346
  import { ListingReviewSection } from "./listing-review-section";
347
347
 
348
348
  export const listingsClient: ClientFeatureDefinition = {
@@ -355,13 +355,13 @@ export const listingsClient: ClientFeatureDefinition = {
355
355
 
356
356
  ```tsx
357
357
  import type { ExtensionSectionProps } from "@cosmicdrift/kumiko-renderer";
358
- import { DetailList } from "@cosmicdrift/kumiko-renderer-web";
358
+ import { DetailList } from "@cosmicdrift/kumiko-renderer/web";
359
359
  import type { ReactNode } from "react";
360
360
 
361
361
  // Registered under the `__component` name the feature's review section
362
362
  // points at (see feature.ts) — never imported by feature.ts itself, the
363
363
  // string name is the only link so the server file stays free of the
364
- // renderer-web graph.
364
+ // renderer/web graph.
365
365
  export function ListingReviewSection({ values }: ExtensionSectionProps): ReactNode {
366
366
  return (
367
367
  <DetailList
@@ -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
@@ -1,8 +1,8 @@
1
- <!-- Generated by `bun run gen:agent-docs` from packages/renderer-web/src/index.ts. Do not edit. -->
1
+ <!-- Generated by `bun run gen:agent-docs` from packages/renderer/src/web/index.ts. Do not edit. -->
2
2
 
3
3
  # Widgets
4
4
 
5
- Value exports of `@cosmicdrift/kumiko-renderer-web`.
5
+ Value exports of `@cosmicdrift/kumiko-renderer/web`.
6
6
 
7
7
  ## Components
8
8
 
@@ -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` | |
@@ -174,7 +175,7 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
174
175
  | --- | --- |
175
176
  | `BROWSER_LOCALE_STORAGE_KEY` | |
176
177
  | `STATUS_TONE_TEXT` | |
177
- | `cssVarTokens` | Tokens mit var-string-Werten. |
178
+ | `cssVarTokens` | Tokens with var-string values. |
178
179
  | `defaultPrimitives` | |
179
180
  | `defaultTokens` | Default-Tokens — identisch zu `cssVarTokens` (var-string-refs). |
180
181
  | `lightTokens` | |
@@ -3,7 +3,7 @@ export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.ts";
3
3
  export { collectAnonymousLiveEntities } from "./anonymous-live-entities.ts";
4
4
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.ts";
5
5
  export type { AnonymousAccessConfig, AnonymousAccessResolved, AuthMiddlewareOptions, AuthSessionChecker, AuthSessionCheckResult, AuthSessionStatus, TenantExists, TenantLifecycleStatusResolver, TenantResolver, TokenVerifier, } from "./auth-middleware.ts";
6
- export { authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.ts";
6
+ export { authMiddleware, CSRF_HEADER_NAME, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.ts";
7
7
  export type { AuthRoutesConfig, LoginRateLimiter, SessionChecker, SessionCreator, SessionMassRevoker, SessionMetadata, SessionRevoker, } from "./auth-routes.ts";
8
8
  export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.ts";
9
9
  export type { ClientIpHeaderSource, ClientIpResolver } from "./client-ip.ts";
package/dist/api/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
2
2
  export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
3
3
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
4
- export { authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
4
+ export { authMiddleware, CSRF_HEADER_NAME, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
5
5
  export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.js";
6
6
  export { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, extractSocketAddress, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, UNKNOWN_CLIENT_IP, } from "./client-ip.js";
7
7
  export { ExtraRouteEntries, ExtraRouteRejection, signatureRoute } from "./extra-route.js";
@@ -11,8 +11,8 @@ import { patAllows } from "./pat-scope.js";
11
11
  import { requestContext } from "./request-context.js";
12
12
  import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
13
13
  // SSE frame event names for POST /api/stream. Parallel definition lives in
14
- // @cosmicdrift/kumiko-headless (dispatcher-live imports that one) — framework
15
- // cannot depend on headless for four string literals.
14
+ // @cosmicdrift/kumiko-renderer/headless (the live dispatcher imports that one) — framework
15
+ // cannot depend on the renderer for four string literals.
16
16
  export const StreamFrame = {
17
17
  chunk: "chunk",
18
18
  ping: "ping",
@@ -18,6 +18,7 @@ import { createRecordWriteCheck } from "../files/record-write-check.js";
18
18
  import { createFallbackLogger } from "../logging/index.js";
19
19
  import { createNoopProvider, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
20
20
  import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
21
+ import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
21
22
  import { createDispatcher } from "../pipeline/dispatcher.js";
22
23
  import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state.js";
23
24
  import { createEventDispatcher } from "../pipeline/event-dispatcher.js";
@@ -63,7 +64,7 @@ export function withFileProviderResolver(registry, context, cacheSync) {
63
64
  ...context,
64
65
  _fileProviderResolver: makeFileProviderResolver({
65
66
  registry,
66
- _configAccessorFactory: context._configAccessorFactory,
67
+ _configAccessorFactory: configAccessorFactoryOf(context),
67
68
  secrets: context.secrets,
68
69
  db: context.db,
69
70
  ...(cacheSync ? { cacheSync } : {}),
@@ -27,6 +27,10 @@ export type AppConfig = {
27
27
  readonly features: readonly FeatureDefinition[];
28
28
  /** Features mounted only in production on top of `features`. */
29
29
  readonly prodFeatures?: readonly FeatureDefinition[];
30
+ /** Whether the app boots with the bundled auth features (config/user/tenant/auth-email-password)
31
+ * that composeFeatures adds; the schema tooling needs their tables. Unset: derived from the
32
+ * feature list. */
33
+ readonly includeBundled?: boolean;
30
34
  readonly client: AppClientConfig;
31
35
  readonly ports: AppPorts;
32
36
  /** Passed to validateBoot by `kumiko check` and, via the app's bin files, by the runners. */
@@ -35,5 +35,5 @@ export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, typ
35
35
  export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.ts";
36
36
  export { tableExists } from "./schema-inspection.ts";
37
37
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
38
- export type { CreateTenantDbOptions, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
39
- export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
38
+ export type { CreateTenantDbOptions, OutsideTransactionContext, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
39
+ export { castTenantRows, createSystemDbView, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
package/dist/db/index.js CHANGED
@@ -28,4 +28,4 @@ export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migrat
28
28
  export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
29
29
  export { tableExists } from "./schema-inspection.js";
30
30
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
31
- export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
31
+ export { castTenantRows, createSystemDbView, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
@@ -111,8 +111,18 @@ export function splitSqlStatements(sqlText) {
111
111
  current += ch;
112
112
  continue;
113
113
  }
114
- if (ch === "$" && /^\$([A-Za-z_]\w*)?\$/.test(sqlText.slice(i))) {
115
- throw new Error("splitSqlStatements: unsupported dollar-quoted body — migration SQL is malformed, refusing to split");
114
+ if (ch === "$") {
115
+ const opening = /^\$([A-Za-z_]\w*)?\$/.exec(sqlText.slice(i))?.[0];
116
+ if (opening !== undefined) {
117
+ // Body is copied verbatim: `;` and quotes inside a DO/function body belong to it.
118
+ const closeAt = sqlText.indexOf(opening, i + opening.length);
119
+ if (closeAt === -1) {
120
+ throw new Error(`splitSqlStatements: unterminated dollar-quoted body ${opening} — migration SQL is malformed, refusing to split`);
121
+ }
122
+ current += sqlText.slice(i, closeAt + opening.length);
123
+ i = closeAt + opening.length - 1;
124
+ continue;
125
+ }
116
126
  }
117
127
  if (ch === ";") {
118
128
  statements.push(current);
@@ -122,8 +132,6 @@ export function splitSqlStatements(sqlText) {
122
132
  current += ch;
123
133
  }
124
134
  if (state === "blockComment" || state === "singleQuote" || state === "doubleQuote") {
125
- // Does not track dollar-quoted (`$$...$$`) bodies — none of this repo's
126
- // migrations use them; add that state if one ever does.
127
135
  throw new Error(`splitSqlStatements: unterminated ${state} — migration SQL is malformed, refusing to split`);
128
136
  }
129
137
  statements.push(current);