@cosmicdrift/kumiko-framework 0.391.0 → 0.392.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 (47) hide show
  1. package/agents/examples/apex-landing.md +1 -1
  2. package/agents/examples/apex-surface-auth.md +35 -35
  3. package/agents/examples/compliance-profiles-demo.md +4 -4
  4. package/agents/examples/crypto-shredding-hr.md +1 -1
  5. package/agents/examples/designer-demo.md +6 -6
  6. package/agents/examples/e2e-generator.md +18 -18
  7. package/agents/examples/encrypted-tenant-config.md +31 -31
  8. package/agents/examples/files-post-processing.md +1 -1
  9. package/agents/examples/inbound-mail-triage.md +1 -1
  10. package/agents/examples/lane-routing.md +5 -5
  11. package/agents/examples/legal-pages.md +36 -36
  12. package/agents/examples/record-detail-layout.md +41 -8
  13. package/agents/examples/seed-migration.md +44 -44
  14. package/agents/examples/user-data-rights.md +21 -21
  15. package/agents/examples/user-profile.md +5 -5
  16. package/agents/examples/wizard-form.md +1 -1
  17. package/agents/features.md +3 -3
  18. package/dist/db/entity-column-meta.d.ts +3 -0
  19. package/dist/db/entity-column-meta.js +113 -0
  20. package/dist/db/entity-table-indexes.d.ts +7 -0
  21. package/dist/db/entity-table-indexes.js +124 -0
  22. package/dist/db/entity-table-meta.js +1 -153
  23. package/dist/db/migrate-change-render.d.ts +40 -0
  24. package/dist/db/migrate-change-render.js +89 -0
  25. package/dist/db/migrate-generator.d.ts +5 -37
  26. package/dist/db/migrate-generator.js +99 -171
  27. package/dist/db/row-level-security-boot-check.js +4 -4
  28. package/dist/db/table-builder.d.ts +1 -1
  29. package/dist/db/table-builder.js +42 -133
  30. package/dist/engine/boot-validator/query-refs.js +22 -9
  31. package/dist/engine/boot-validator/screens-actions.js +11 -8
  32. package/dist/engine/boot-validator/screens-kind-projection-detail.js +8 -0
  33. package/dist/engine/build-app-schema.js +5 -0
  34. package/dist/engine/extensions/user-data.js +0 -2
  35. package/dist/engine/project-query-live-entities.d.ts +2 -0
  36. package/dist/engine/project-query-live-entities.js +11 -0
  37. package/dist/engine/strip-disabled-features-from-app-schema.js +1 -1
  38. package/dist/env/index.d.ts +1 -0
  39. package/dist/env/index.js +1 -0
  40. package/dist/env/required-env.d.ts +2 -0
  41. package/dist/env/required-env.js +9 -0
  42. package/dist/i18n/required-surface-keys.js +4 -0
  43. package/dist/migrations/kumiko-drift.js +4 -4
  44. package/dist/ui-types/app-schema.d.ts +1 -0
  45. package/dist/ui-types/index.d.ts +1 -1
  46. package/package.json +3 -3
  47. package/src/changes.json +42 -0
@@ -443,7 +443,7 @@ function tabsLocator(page: Page) {
443
443
  return page.getByTestId("kumiko-screen-projection-detail-tabs");
444
444
  }
445
445
 
446
- test.describe("record-detail-layout — tab-panel height (fw#2778)", () => {
446
+ test.describe("record-detail-layout — tab-panel height", () => {
447
447
  // The design refresh (#3381) made detail tabs a full-bleed "board": the
448
448
  // relatedList scroll region fills the panel down to the footer instead of
449
449
  // the card sizing to its rows (fw#2778's original behavior). What must
@@ -616,9 +616,7 @@ test("record-detail-layout — all tab kinds + screen actions, one screenshot pe
616
616
 
617
617
  import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
618
618
 
619
- test("record-detail-layout — tab strip fits the phone viewport width (fw#3234)", async ({
620
- page,
621
- }) => {
619
+ test("record-detail-layout — tab strip fits the phone viewport width", async ({ page }) => {
622
620
  await page.goto("/");
623
621
  await expect(page.getByTestId("render-edit-form")).toBeVisible();
624
622
  await expect(page.getByTestId("kumiko-screen-projection-detail-tabs")).toBeVisible();
@@ -794,10 +792,11 @@ describe("record-detail-layout showcase — registration", () => {
794
792
  ]);
795
793
  });
796
794
 
797
- test("screen-level actions carry a refresh-order navigate action", () => {
795
+ test("screen-level actions carry refresh-order and keep download-invoice inline", () => {
798
796
  const screen = registry.getScreen("order-desk:screen:order-detail");
799
797
  if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
800
- expect(screen.actions?.map((a) => a.id)).toEqual(["refresh-order"]);
798
+ expect(screen.actions?.map((a) => a.id)).toEqual(["refresh-order", "download-invoice"]);
799
+ expect(screen.inlineActions).toEqual(["download-invoice"]);
801
800
  });
802
801
 
803
802
  test("relatedList columns collapse string shorthand into the object form", () => {
@@ -809,6 +808,21 @@ describe("record-detail-layout showcase — registration", () => {
809
808
  });
810
809
  });
811
810
 
811
+ describe("record-detail-layout showcase — empty states", () => {
812
+ test("the payments tab declares a record-dependent empty text ahead of its default", () => {
813
+ const screen = registry.getScreen("order-desk:screen:order-detail");
814
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
815
+ const payments = screen.layout.sections.find((section) => section.id === "payments");
816
+ if (payments?.kind !== "relatedList") throw new Error("expected the payments relatedList");
817
+ expect(payments.emptyState).toEqual({
818
+ title: "order-desk.payments.empty",
819
+ variants: [
820
+ { when: { field: "status", eq: "pending" }, title: "order-desk.payments.empty-pending" },
821
+ ],
822
+ });
823
+ });
824
+ });
825
+
812
826
  describe("record-detail-layout showcase — boot-validator catches author mistakes", () => {
813
827
  test("metric with an empty label fails boot — no fallback to the raw column name", () => {
814
828
  const broken = defineFeature("broken-order-desk", (r) => {
@@ -868,8 +882,10 @@ describe("record-detail-layout showcase — boot-validator catches author mistak
868
882
  // one `extension` section (a plugin-mounted component, fw#3234 padding-
869
883
  // parity check — every tab kind must sit in the same Card padding, no
870
884
  // section carries extra chrome of its own).
871
- // 4. A screen-level `actions` entry (fw#3234) renders next to the header,
872
- // not inside any tab's Card.
885
+ // 4. The payments tab's emptyState picks its text from the order record through `variants`.
886
+ // 5. A screen-level `actions` entry (fw#3234) renders next to the header,
887
+ // not inside any tab's Card. `inlineActions` keeps download-invoice a visible
888
+ // button next to it where the page header would otherwise fold it into the menu.
873
889
  //
874
890
  // projectionDetail has no entity — it binds to an explicit query instead, so
875
891
  // every field name below (header, metrics, fields) is a column of that
@@ -965,6 +981,15 @@ export function createOrderDeskFeature(): FeatureDefinition {
965
981
  "method",
966
982
  "status",
967
983
  ],
984
+ emptyState: {
985
+ title: "order-desk.payments.empty",
986
+ variants: [
987
+ {
988
+ when: { field: "status", eq: "pending" },
989
+ title: "order-desk.payments.empty-pending",
990
+ },
991
+ ],
992
+ },
968
993
  },
969
994
  {
970
995
  id: "details",
@@ -989,7 +1014,15 @@ export function createOrderDeskFeature(): FeatureDefinition {
989
1014
  screen: "order-detail",
990
1015
  icon: "refresh",
991
1016
  },
1017
+ {
1018
+ kind: "navigate",
1019
+ id: "download-invoice",
1020
+ label: "order-desk.action.downloadInvoice",
1021
+ screen: "order-detail",
1022
+ icon: "download",
1023
+ },
992
1024
  ],
1025
+ inlineActions: ["download-invoice"],
993
1026
  access: { roles: ["Support", "Admin"] },
994
1027
  });
995
1028
  });
@@ -105,41 +105,41 @@ Wenn eine kaputte Migration den Boot blockiert: setze `skippable: true` im seed-
105
105
  ```ts
106
106
  // biome-ignore-all lint/suspicious/noConsole: CLI tool output
107
107
  //
108
- // Lokaler Smoke-Test für seed-migrations.
108
+ // Local smoke test for seed migrations.
109
109
  //
110
- // Was er prüft (offline, ohne Pod-Deploy):
111
- // 1. Alle seed-files in ./seeds/ können dynamic-import geladen werden
112
- // → catched syntax-errors + missing default-exports + falsche paths
113
- // 2. Jeder handler-QN den die seeds referenzieren ist in der echten
114
- // App-Registry registriert → catched camelCase-typos + drift
115
- // (Bug-Klasse 3 aus Phase 1 retro)
116
- // 3. Jeder system-user (mit oder ohne tenantIdOverride) hat Access auf
117
- // die referenzierten Handler → catched access-rule-Drift
118
- // (Bug-Klasse 4 aus Phase 1 retro)
110
+ // What it checks (offline, without a pod deploy):
111
+ // 1. All seed files in ./seeds/ can be loaded via dynamic import
112
+ // → catches syntax errors + missing default exports + wrong paths
113
+ // 2. Every handler QN the seeds reference is registered in the real
114
+ // app registry → catches camelCase typos + drift
115
+ // (bug class 3 from the phase 1 retro)
116
+ // 3. Every system user (with or without tenantIdOverride) has access to
117
+ // the referenced handlers → catches access rule drift
118
+ // (bug class 4 from the phase 1 retro)
119
119
  //
120
- // Schreibt NICHTS in die DB. Reine Read-only Validation.
120
+ // Writes NOTHING to the DB. Pure read-only validation.
121
121
  //
122
122
  // Usage:
123
- // bun scripts/smoke.ts (gegen ./seeds/)
123
+ // bun scripts/smoke.ts (against ./seeds/)
124
124
  // bun scripts/smoke.ts --seeds-dir foo (custom path)
125
125
  //
126
- // In CI: vor `build`-Step laufen lassen — fängt 80% der Phase-1-Bugs
127
- // lokal in <5 Sekunden statt nach 8-Minuten-Docker-Build im Pod-Boot.
126
+ // In CI: run before the `build` step — catches 80% of the phase 1 bugs
127
+ // locally in <5 seconds instead of after an 8-minute Docker build in the pod boot.
128
128
 
129
129
  import { existsSync, readdirSync, readFileSync } from "node:fs";
130
130
  import { resolve } from "node:path";
131
- // App-spezifisch: nimm die EFFEKTIVEN Features die deine App zur Runtime
132
- // nutzt. Im typischen Setup mit `runProdApp({ features, auth })`:
131
+ // App-specific: take the EFFECTIVE features your app uses
132
+ // at runtime. In the typical setup with `runProdApp({ features, auth })`:
133
133
  //
134
134
  // import { composeFeatures } from "@cosmicdrift/kumiko-dev-server";
135
135
  // import { APP_FEATURES } from "../src/run-config";
136
136
  // const composed = composeFeatures(APP_FEATURES, { includeBundled: true, ... });
137
137
  // const features = composed.features;
138
138
  //
139
- // Bei reiner App-Feature-Setup (kein auth-mode) reicht ein direkter
140
- // Import der App-Features. Hier im Recipe der minimale set für die
141
- // referenzierten Recipe-Seeds (tenant + config) — App-Author tauscht
142
- // gegen sein eigenes Feature-Set.
139
+ // For a pure app-feature setup (no auth mode) a direct
140
+ // import of the app features is enough. Here in the recipe the minimal set for the
141
+ // referenced recipe seeds (tenant + config) — the app author swaps it
142
+ // for their own feature set.
143
143
  import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
144
144
  import { createTenantFeature } from "@cosmicdrift/kumiko-bundled-features/tenant";
145
145
  import {
@@ -233,25 +233,25 @@ console.log(`\n✓ all ${seedFiles.length} seed-file(s) pass smoke.`);
233
233
  ### seeds/2026-01-01-initial-tenants.ts
234
234
 
235
235
  ```ts
236
- // Initial-Seed: Demo-Tenants anlegen.
236
+ // Initial seed: create demo tenants.
237
237
  //
238
- // Note: in real apps wäre "initial tenants" wahrscheinlich nicht als
239
- // seed-migration sondern als `r.config({seeds})` oder `auth.admin`-
240
- // Config umgesetzt — die laufen jedes-Boot idempotent. Hier ist es als
241
- // seed-migration gezeigt um das Pattern zu demonstrieren.
238
+ // Note: in real apps "initial tenants" would probably not be implemented as a
239
+ // seed migration but as `r.config({seeds})` or `auth.admin`
240
+ // config — those run idempotently on every boot. Here it is shown as a
241
+ // seed migration to demonstrate the pattern.
242
242
  //
243
- // Pattern: jede seed-migration arbeitet write-only via `systemWriteAs`
244
- // damit alles als Event-Stream persistiert wird. Read-only Lookups
245
- // (find*) am Context oder direkt via ctx.db.
243
+ // Pattern: every seed migration works write-only via `systemWriteAs`
244
+ // so everything is persisted as an event stream. Read-only lookups
245
+ // (find*) on the context or directly via ctx.db.
246
246
 
247
247
  import type { SeedMigration } from "@cosmicdrift/kumiko-framework/es-ops";
248
248
 
249
249
  export default {
250
250
  description: "initial demo tenants (alice-corp, beta-inc)",
251
251
  run: async (ctx) => {
252
- // Idempotent-Check: wenn Tenants schon existieren (z.B. via auth.admin-
253
- // seed), skip diese Migration. Verhindert Konflikt beim re-boot wenn
254
- // jemand den Marker manuell gelöscht hat.
252
+ // Idempotency check: if tenants already exist (e.g. via an auth.admin
253
+ // seed), skip this migration. Prevents a conflict on re-boot if
254
+ // someone deleted the marker manually.
255
255
  const existing = await ctx.findTenants();
256
256
  if (existing.some((t) => t.tenantKey === "alice-corp")) return;
257
257
 
@@ -270,14 +270,14 @@ export default {
270
270
  ### seeds/2026-02-01-fix-admin-roles.ts
271
271
 
272
272
  ```ts
273
- // Driver-Pattern: ergänze fehlende Rolle auf existierenden Memberships.
274
- // Spiegelt den realen publicstatus-Bug 2026-05-20 (admin@publicstatus.eu
275
- // hatte "Admin" aber nicht "TenantAdmin" → text-content + legal-pages
276
- // access_denied weil bundled-features TenantAdmin erwarten).
273
+ // Driver pattern: add a missing role to existing memberships.
274
+ // Mirrors the real publicstatus bug of 2026-05-20 (admin@publicstatus.eu
275
+ // had "Admin" but not "TenantAdmin" → text-content + legal-pages
276
+ // access_denied because bundled-features expect TenantAdmin).
277
277
  //
278
- // Funktioniert für jede App mit dem gleichen Pattern: User-Lookup per
279
- // E-Mail → alle Memberships durchgehen → bei Bedarf via existierendem
280
- // updateMemberRoles-Handler korrigieren.
278
+ // Works for any app with the same pattern: user lookup by
279
+ // email → go through all memberships → correct via the existing
280
+ // updateMemberRoles handler if needed.
281
281
 
282
282
  import type { SeedMigration } from "@cosmicdrift/kumiko-framework/es-ops";
283
283
 
@@ -297,12 +297,12 @@ export default {
297
297
  tenantId: m.tenantId,
298
298
  roles: Array.from(new Set([...m.roles, "TenantAdmin"])),
299
299
  },
300
- // tenantIdOverride: stream-tenant aus kumiko_events.v1 — NICHT
301
- // m.tenantId (payload-tenant). Beide stimmen oft überein, weichen
302
- // aber ab wenn das Aggregate ursprünglich von einem fremden
303
- // Executor angelegt wurde (seedTenantMembership by=systemAdmin —
304
- // publicstatus-Pattern). findMembershipsOfUser liefert beide,
305
- // kein eigener JOIN nötig.
300
+ // tenantIdOverride: stream tenant from kumiko_events.v1 — NOT
301
+ // m.tenantId (payload tenant). The two often match, but differ
302
+ // when the aggregate was originally created by a foreign
303
+ // executor (seedTenantMembership by=systemAdmin —
304
+ // publicstatus pattern). findMembershipsOfUser returns both,
305
+ // no separate JOIN needed.
306
306
  m.streamTenantId,
307
307
  );
308
308
  }
@@ -134,15 +134,15 @@ export const features = [
134
134
  ### src/__tests__/feature.integration.test.ts
135
135
 
136
136
  ```ts
137
- // User-Data-Rights Recipe — Integration-Test.
137
+ // User-data-rights recipe — integration test.
138
138
  //
139
- // Pinst dass die zwei Hooks aus feature.ts genau das tun was die Recipe-
140
- // README verspricht:
141
- // 1. Notes des Users landen via export-Hook im Bundle-Snippet.
142
- // 2. Forget-Cleanup-Cron entfernt die Notes via delete-Hook (strategy
139
+ // Pins that the two hooks from feature.ts do exactly what the recipe
140
+ // README promises:
141
+ // 1. The user's notes land in the bundle snippet via the export hook.
142
+ // 2. The forget-cleanup cron removes the notes via the delete hook (strategy
143
143
  // "delete", default).
144
- // 3. Strategy "anonymize" (HR-Override) setzt authorId=null statt zu
145
- // löschen — Row bleibt für Multi-User-Refs intakt.
144
+ // 3. Strategy "anonymize" (HR override) sets authorId=null instead of
145
+ // deleting — the row stays intact for multi-user refs.
146
146
 
147
147
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
148
148
  import { createAuthFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
@@ -294,7 +294,7 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
294
294
  expect(remaining.length).toBe(0);
295
295
  });
296
296
 
297
- test("Strategy=anonymize: authorId=null, Row bleibt (HR-Compliance-Pfad)", async () => {
297
+ test("Strategy=anonymize: authorId=null, row remains (HR-Compliance path)", async () => {
298
298
  await seedAlice();
299
299
  await seedNote(noteId(1), "stays-anonymous");
300
300
 
@@ -436,23 +436,23 @@ describe("user-data-rights recipe :: retention run removes a hardDelete entity's
436
436
  ### src/feature.ts
437
437
 
438
438
  ```ts
439
- // User-Data-Rights — Recipe
439
+ // User-Data-Rights — recipe
440
440
  //
441
- // Wie eine App-Domain DSGVO-Pipeline integriert: pro Entity einen
442
- // (export, delete)-Hook ueber EXT_USER_DATA. Forget-Cron, Export-ZIP-Bau
443
- // und Magic-Link-Versand kommen vollstaendig aus user-data-rights —
444
- // App-Author schreibt nur die zwei Hooks pro Entity.
441
+ // How an app domain integrates the GDPR pipeline: one
442
+ // (export, delete) hook per entity via EXT_USER_DATA. Forget cron, export ZIP build
443
+ // and magic link delivery come entirely from user-data-rights —
444
+ // the app author writes only the two hooks per entity.
445
445
  //
446
- // Demo-Domain: minimaler Note-Service. Note hat (id, tenantId, authorId,
447
- // title, body). Pinst:
448
- // 1. Hook export liefert Note-Rows als JSON-Snippet ins Export-Bundle.
449
- // 2. Hook delete entfernt Note-Rows beim Forget-Cleanup-Cron.
446
+ // Demo domain: minimal note service. A note has (id, tenantId, authorId,
447
+ // title, body). Pins:
448
+ // 1. The export hook delivers note rows as a JSON snippet into the export bundle.
449
+ // 2. The delete hook removes note rows during the forget-cleanup cron.
450
450
  //
451
- // Was nicht im Recipe ist (siehe samples/apps/user-data-rights-demo
452
- // fuer eine vollstaendige App):
451
+ // What is not in the recipe (see samples/apps/user-data-rights-demo
452
+ // for a complete app):
453
453
  // - Strategy-aware delete (anonymize vs hardDelete)
454
- // - HTTP-Endpoints fuer create/list
455
- // - Compliance-Profile-Wiring + Cron-Scheduling
454
+ // - HTTP endpoints for create/list
455
+ // - Compliance profile wiring + cron scheduling
456
456
 
457
457
  import {
458
458
  deleteMany,
@@ -98,10 +98,10 @@ matches `UserProfileHandlers.changeEmail`.
98
98
  ### src/__tests__/feature.test.ts
99
99
 
100
100
  ```ts
101
- // Boot-Validation der vollen user-profile-Komposition: Require-Kette
102
- // erfüllt, Screen + Nav registriert, change-email-Handler unter der
103
- // dokumentierten QN. Kein DB/HTTP nötig — der HTTP-Beweis lebt im
104
- // bundled-feature-Test (change-email.integration.test.ts).
101
+ // Boot validation of the full user-profile composition: require chain
102
+ // satisfied, screen + nav registered, change-email handler under the
103
+ // documented QN. No DB/HTTP needed — the HTTP proof lives in the
104
+ // bundled-feature test (change-email.integration.test.ts).
105
105
 
106
106
  import { describe, expect, test } from "bun:test";
107
107
  import { UserProfileHandlers } from "@cosmicdrift/kumiko-bundled-features/user-profile";
@@ -141,7 +141,7 @@ describe("user-profile recipe — boot validation", () => {
141
141
  ]);
142
142
  });
143
143
 
144
- test("change-email QN entspricht der dokumentierten Konstante", () => {
144
+ test("change-email QN matches the documented constant", () => {
145
145
  expect(UserProfileHandlers.changeEmail).toBe("user-profile:write:change-email");
146
146
  });
147
147
  });
@@ -680,7 +680,7 @@ async function assertCssIsLive(page: Page): Promise<void> {
680
680
  expect(display).toBe("flex");
681
681
  }
682
682
 
683
- test.describe("wizard-form — 375px mobile chrome (#1917)", () => {
683
+ test.describe("wizard-form — 375px mobile chrome", () => {
684
684
  test("step 1: step label and Next fit the viewport without overlap", async ({ page }) => {
685
685
  await gotoWizard(page);
686
686
  await assertCssIsLive(page);
@@ -14,7 +14,7 @@ Administration group in the sidebar of this sample app.
14
14
 
15
15
  ## admin-shell
16
16
 
17
- Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `audit:screen:audit-log-all-tenants`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards, built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.
17
+ Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `audit:screen:audit-log-all-tenants`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards (the tenant one can be dropped with `includeTenantOverview: false`), built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.
18
18
 
19
19
  Category: operations
20
20
 
@@ -381,7 +381,7 @@ Requires: `auth-foundation`, `tenant`
381
381
 
382
382
  ## inbound-mail-foundation
383
383
 
384
- Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push). The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.
384
+ Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push) and send. `sendMailFromAccount(ctx, { accountId, message })` sends from a connected mailbox through its provider (From = the mailbox address, no fallback to the app sender) and answers typed failures (not configured, login rejected, server unreachable, rejected, invalid message); app write handlers call it, it is no endpoint of its own. The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.
385
385
 
386
386
  Category: notifications
387
387
 
@@ -389,7 +389,7 @@ Requires: `tenant-lifecycle`
389
389
 
390
390
  ## inbound-provider-imap
391
391
 
392
- Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Store the per-account connection JSON (host/port/secure/user/password) in the account credential secret slot before the first sync.
392
+ Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Tenant admins connect a mailbox through the "Connect mailbox" form on the mail accounts list (`connect-mailbox` checks the IMAP login and the SMTP login of the outgoing server first, then creates the account and its credential secret in one transaction); programmatic setups store the per-account connection JSON (host/port/secure/user/password, plus smtpHost/smtpPort for sending) in the account credential secret slot before the first sync. Implements the provider `send`: `sendMailFromAccount` sends over the SMTP server of the mailbox itself (implicit TLS, same login) with the mailbox address as From and stores a copy in its sent folder.
393
393
 
394
394
  Category: notifications
395
395
 
@@ -0,0 +1,3 @@
1
+ import type { EntityDefinition, FieldDefinition } from "@cosmicdrift/kumiko-types/fields";
2
+ import type { ColumnMeta } from "./entity-table-meta-types.ts";
3
+ export declare function fieldToColumnMeta(name: string, field: FieldDefinition, entity: EntityDefinition): readonly ColumnMeta[];
@@ -0,0 +1,113 @@
1
+ // Field definition → physical ColumnMeta[]. Lock-step twin of COLUMN_EMITTERS
2
+ // in table-builder.ts: every field type must produce the same PG columns there.
3
+ import { toSnakeCase } from "../utils/case.js";
4
+ const EMPTY_JSONB_ARRAY = "'[]'::jsonb";
5
+ const EMPTY_JSONB_OBJECT = "'{}'::jsonb";
6
+ function quoteSql(literal) {
7
+ return `'${literal.replace(/'/g, "''")}'`;
8
+ }
9
+ function fieldDefaultLiteral(field) {
10
+ if (!("default" in field) || field.default === undefined)
11
+ return undefined;
12
+ const v = field.default;
13
+ if (typeof v === "string")
14
+ return quoteSql(v);
15
+ if (typeof v === "number")
16
+ return String(v);
17
+ if (typeof v === "boolean")
18
+ return v ? "true" : "false";
19
+ return undefined;
20
+ }
21
+ function declaredDefault(field) {
22
+ const literal = fieldDefaultLiteral(field);
23
+ return literal === undefined ? {} : { defaultSql: literal };
24
+ }
25
+ function scalarColumn(snake, pgType, field, extra = {}) {
26
+ return {
27
+ name: snake,
28
+ pgType,
29
+ notNull: field.required === true,
30
+ ...extra,
31
+ ...declaredDefault(field),
32
+ };
33
+ }
34
+ function requiredColumn(snake, pgType, field) {
35
+ return [{ name: snake, pgType, notNull: field.required === true }];
36
+ }
37
+ function jsonbColumn(snake, defaultSql) {
38
+ return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql }];
39
+ }
40
+ // lookupable → HMAC blind-index counterpart. Nullable is mandatory:
41
+ // managedChangeRequiresRecreate would diff a NOT-NULL column without
42
+ // a default on existing tables as DROP+rebuild.
43
+ const textColumnMeta = (snake, field) => {
44
+ const cols = [scalarColumn(snake, "text", field)];
45
+ if (field.type === "text" && field.lookupable === true) {
46
+ cols.push({ name: `${snake}_bidx`, pgType: "text", notNull: false });
47
+ }
48
+ return cols;
49
+ };
50
+ const booleanColumnMeta = (snake, field) => {
51
+ const declared = declaredDefault(field);
52
+ return [
53
+ {
54
+ name: snake,
55
+ pgType: "boolean",
56
+ notNull: declared.defaultSql !== undefined || field.required === true,
57
+ ...declared,
58
+ },
59
+ ];
60
+ };
61
+ const referenceColumnMeta = (snake, field) => field.multiple === true
62
+ ? jsonbColumn(snake, EMPTY_JSONB_ARRAY)
63
+ : requiredColumn(snake, "uuid", field);
64
+ const moneyColumnMeta = (snake, field, entity) => [
65
+ { name: snake, pgType: "bigint", notNull: field.required === true, bigintJsMode: "bigint" },
66
+ {
67
+ name: `${snake}_currency`,
68
+ pgType: "text",
69
+ notNull: true,
70
+ defaultSql: quoteSql(entity.defaultCurrency ?? "EUR"),
71
+ },
72
+ ];
73
+ const locatedTimestampColumnMeta = (snake, field) => [
74
+ { name: `${snake}_utc`, pgType: "timestamptz", notNull: field.required === true },
75
+ { name: `${snake}_tz`, pgType: "text", notNull: field.required === true },
76
+ ];
77
+ const COLUMN_META_EMITTERS = {
78
+ text: textColumnMeta,
79
+ longText: textColumnMeta,
80
+ boolean: booleanColumnMeta,
81
+ select: (snake, field) => [scalarColumn(snake, "text", field)],
82
+ multiSelect: (snake) => jsonbColumn(snake, EMPTY_JSONB_ARRAY),
83
+ number: (snake, field) => [
84
+ scalarColumn(snake, field.integer === true ? "integer" : "double precision", field),
85
+ ],
86
+ bigInt: (snake, field) => [scalarColumn(snake, "bigint", field, { bigintJsMode: "number" })],
87
+ decimal: (snake, field) => [
88
+ scalarColumn(snake, `numeric(${field.precision},${field.scale})`, field),
89
+ ],
90
+ reference: referenceColumnMeta,
91
+ money: moneyColumnMeta,
92
+ embedded: (snake, field) => jsonbColumn(snake, field.multiple === true ? EMPTY_JSONB_ARRAY : EMPTY_JSONB_OBJECT),
93
+ jsonb: (snake) => jsonbColumn(snake, EMPTY_JSONB_OBJECT),
94
+ // Real PG `date` — Temporal.PlainDate, no time-of-day/timezone component.
95
+ date: (snake, field) => requiredColumn(snake, "date", field),
96
+ timestamp: (snake, field) => requiredColumn(snake, "timestamptz", field),
97
+ tz: (snake, field) => requiredColumn(snake, "text", field),
98
+ locatedTimestamp: locatedTimestampColumnMeta,
99
+ file: (snake, field) => requiredColumn(snake, "uuid", field),
100
+ image: (snake, field) => requiredColumn(snake, "uuid", field),
101
+ files: (snake) => jsonbColumn(snake, EMPTY_JSONB_ARRAY),
102
+ images: (snake) => jsonbColumn(snake, EMPTY_JSONB_ARRAY),
103
+ };
104
+ function emitColumnMeta(type, snake, field, entity) {
105
+ // A type from untyped input must not reach an Object.prototype member.
106
+ if (!Object.hasOwn(COLUMN_META_EMITTERS, type))
107
+ return [];
108
+ const emit = COLUMN_META_EMITTERS[type];
109
+ return emit(snake, field, entity);
110
+ }
111
+ export function fieldToColumnMeta(name, field, entity) {
112
+ return emitColumnMeta(field.type, toSnakeCase(name), field, entity);
113
+ }
@@ -0,0 +1,7 @@
1
+ import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
2
+ import { type ColumnHandle, type IndexBuilderWithCols } from "./dialect.ts";
3
+ export declare const SOFT_DELETE_LIVE_ROW_PREDICATE = "\"is_deleted\" = false";
4
+ type ColumnHandles = Readonly<Record<string, ColumnHandle>>;
5
+ type IndexRecord = Record<string, IndexBuilderWithCols>;
6
+ export declare function buildEntityIndexes(tableName: string, entity: EntityDefinition, handles: ColumnHandles, foreignKeyFields: ReadonlySet<string>): IndexRecord;
7
+ export {};
@@ -0,0 +1,124 @@
1
+ // Index set of a buildEntityTable table. Lock-step twin of the index derivation
2
+ // in deriveEntityTableMeta (entity-table-meta.ts): both must name and shape
3
+ // indexes identically.
4
+ import { toSnakeCase } from "../utils/case.js";
5
+ import { index, isSqlExpression, sql, uniqueIndex, } from "./dialect.js";
6
+ // Partial-index predicate for soft-delete entities; table-builder and
7
+ // entity-table-meta must render identical index definitions.
8
+ export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
9
+ function definedColumns(handles, keys) {
10
+ const cols = keys
11
+ .map((key) => handles[key])
12
+ .filter((col) => col !== undefined);
13
+ return cols.length === keys.length ? cols : undefined;
14
+ }
15
+ function foreignKeyIndexEntries(tableName, handles, foreignKeyFields) {
16
+ const entries = [];
17
+ for (const fieldName of foreignKeyFields) {
18
+ const column = handles[fieldName];
19
+ if (!column)
20
+ continue;
21
+ const indexName = `${tableName}_${toSnakeCase(fieldName)}_idx`;
22
+ entries.push([indexName, index(indexName).on(column)]);
23
+ }
24
+ return entries;
25
+ }
26
+ // lookupable fields: index the bidx column.
27
+ function blindIndexEntries(tableName, entity, handles) {
28
+ const entries = [];
29
+ for (const [name, field] of Object.entries(entity.fields)) {
30
+ if (field.type !== "text" || field.lookupable !== true)
31
+ continue;
32
+ const column = handles[`${name}Bidx`];
33
+ if (!column)
34
+ continue;
35
+ const indexName = `${tableName}_${toSnakeCase(name)}_bidx_idx`;
36
+ entries.push([indexName, index(indexName).on(column)]);
37
+ }
38
+ return entries;
39
+ }
40
+ function lookupableBidxFieldNames(entity) {
41
+ const bidxFieldByField = new Map();
42
+ for (const [name, field] of Object.entries(entity.fields)) {
43
+ if (field.type === "text" && field.lookupable === true) {
44
+ bidxFieldByField.set(name, `${name}Bidx`);
45
+ }
46
+ }
47
+ return bidxFieldByField;
48
+ }
49
+ function declaredIndexName(tableName, def) {
50
+ const suffix = def.unique === true ? "unique" : "idx";
51
+ return def.name ?? `${tableName}_${def.columns.map((c) => toSnakeCase(c)).join("_")}_${suffix}`;
52
+ }
53
+ function declaredIndexWhere(tableName, indexName, entity, def) {
54
+ if (def.where !== undefined) {
55
+ // A silently dropped where would build a full (unique) index that
56
+ // entity-table-meta flags as needsManualWhere.
57
+ if (!isSqlExpression(def.where)) {
58
+ throw new Error(`[${tableName}] index "${indexName}": where must be a sql\`...\` expression`);
59
+ }
60
+ return def.where;
61
+ }
62
+ // Soft-deleted rows keep their value, so a full unique index would
63
+ // permanently block reuse; kept in lock-step with entity-table-meta.ts.
64
+ if (def.unique === true && entity.softDelete === true) {
65
+ return sql.raw(SOFT_DELETE_LIVE_ROW_PREDICATE);
66
+ }
67
+ return undefined;
68
+ }
69
+ // Partial bidx counterpart for unique indexes over lookupable fields.
70
+ function bidxCounterpartEntry(indexName, entity, def, handles, bidxFieldByField) {
71
+ if (def.unique !== true || def.where !== undefined)
72
+ return undefined;
73
+ const bidxFieldNames = def.columns.map((c) => bidxFieldByField.get(c) ?? c);
74
+ const swapped = bidxFieldNames.filter((c, i) => c !== def.columns[i]);
75
+ if (swapped.length === 0)
76
+ return undefined;
77
+ const bidxCols = definedColumns(handles, bidxFieldNames);
78
+ if (!bidxCols)
79
+ return undefined;
80
+ const notNullParts = swapped.map((c) => `"${toSnakeCase(c)}" IS NOT NULL`);
81
+ // Soft-deleted rows keep their bidx hash — without this, a restored row
82
+ // can't reuse a value a soft-deleted sibling still holds.
83
+ const whereText = (entity.softDelete === true ? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE] : notNullParts).join(" AND ");
84
+ const bidxIndexName = `${indexName}_bidx`;
85
+ return [
86
+ bidxIndexName,
87
+ uniqueIndex(bidxIndexName)
88
+ .on(...bidxCols)
89
+ .where(sql.raw(whereText)),
90
+ ];
91
+ }
92
+ // Columns are addressed by field name (camelCase); a def naming an unknown
93
+ // column is skipped.
94
+ function declaredIndexEntries(tableName, entity, handles) {
95
+ const bidxFieldByField = lookupableBidxFieldNames(entity);
96
+ return (entity.indexes ?? []).flatMap((def) => {
97
+ const cols = definedColumns(handles, def.columns);
98
+ if (!cols)
99
+ return [];
100
+ const indexName = declaredIndexName(tableName, def);
101
+ const builder = def.unique === true ? uniqueIndex(indexName) : index(indexName);
102
+ const where = declaredIndexWhere(tableName, indexName, entity, def);
103
+ const chain = builder.on(...cols);
104
+ const counterpart = bidxCounterpartEntry(indexName, entity, def, handles, bidxFieldByField);
105
+ return [
106
+ [indexName, where === undefined ? chain : chain.where(where)],
107
+ ...(counterpart ? [counterpart] : []),
108
+ ];
109
+ });
110
+ }
111
+ // Every multi-tenant query filters by tenant_id; without this index list
112
+ // queries scan the whole table across all tenants.
113
+ export function buildEntityIndexes(tableName, entity, handles, foreignKeyFields) {
114
+ const tenantIdColumn = handles["tenantId"];
115
+ if (!tenantIdColumn)
116
+ throw new Error(`[${tableName}] entity table has no tenantId column`);
117
+ const tenantIndexName = `${tableName}_tenant_id_idx`;
118
+ return Object.fromEntries([
119
+ [tenantIndexName, index(tenantIndexName).on(tenantIdColumn)],
120
+ ...foreignKeyIndexEntries(tableName, handles, foreignKeyFields),
121
+ ...blindIndexEntries(tableName, entity, handles),
122
+ ...declaredIndexEntries(tableName, entity, handles),
123
+ ]);
124
+ }