@cosmicdrift/kumiko-framework 0.391.1 → 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.
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +35 -35
- package/agents/examples/compliance-profiles-demo.md +4 -4
- package/agents/examples/crypto-shredding-hr.md +1 -1
- package/agents/examples/designer-demo.md +6 -6
- package/agents/examples/e2e-generator.md +18 -18
- package/agents/examples/encrypted-tenant-config.md +31 -31
- package/agents/examples/files-post-processing.md +1 -1
- package/agents/examples/inbound-mail-triage.md +1 -1
- package/agents/examples/lane-routing.md +5 -5
- package/agents/examples/legal-pages.md +36 -36
- package/agents/examples/record-detail-layout.md +41 -8
- package/agents/examples/seed-migration.md +44 -44
- package/agents/examples/user-data-rights.md +21 -21
- package/agents/examples/user-profile.md +5 -5
- package/agents/examples/wizard-form.md +1 -1
- package/agents/features.md +3 -3
- package/dist/db/entity-column-meta.d.ts +3 -0
- package/dist/db/entity-column-meta.js +113 -0
- package/dist/db/entity-table-indexes.d.ts +7 -0
- package/dist/db/entity-table-indexes.js +124 -0
- package/dist/db/entity-table-meta.js +1 -153
- package/dist/db/migrate-change-render.d.ts +40 -0
- package/dist/db/migrate-change-render.js +89 -0
- package/dist/db/migrate-generator.d.ts +5 -37
- package/dist/db/migrate-generator.js +99 -171
- package/dist/db/table-builder.d.ts +1 -1
- package/dist/db/table-builder.js +42 -133
- package/dist/engine/boot-validator/query-refs.js +22 -9
- package/dist/engine/boot-validator/screens-actions.js +11 -8
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +8 -0
- package/dist/engine/build-app-schema.js +5 -0
- package/dist/engine/extensions/user-data.js +0 -2
- package/dist/engine/project-query-live-entities.d.ts +2 -0
- package/dist/engine/project-query-live-entities.js +11 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +1 -1
- package/dist/env/index.d.ts +1 -0
- package/dist/env/index.js +1 -0
- package/dist/env/required-env.d.ts +2 -0
- package/dist/env/required-env.js +9 -0
- package/dist/i18n/required-surface-keys.js +4 -0
- package/dist/ui-types/app-schema.d.ts +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +3 -3
- package/src/changes.json +36 -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
|
|
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
|
|
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
|
|
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.
|
|
872
|
-
//
|
|
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
|
-
//
|
|
108
|
+
// Local smoke test for seed migrations.
|
|
109
109
|
//
|
|
110
|
-
//
|
|
111
|
-
// 1.
|
|
112
|
-
// →
|
|
113
|
-
// 2.
|
|
114
|
-
//
|
|
115
|
-
// (
|
|
116
|
-
// 3.
|
|
117
|
-
//
|
|
118
|
-
// (
|
|
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
|
-
//
|
|
120
|
+
// Writes NOTHING to the DB. Pure read-only validation.
|
|
121
121
|
//
|
|
122
122
|
// Usage:
|
|
123
|
-
// bun scripts/smoke.ts (
|
|
123
|
+
// bun scripts/smoke.ts (against ./seeds/)
|
|
124
124
|
// bun scripts/smoke.ts --seeds-dir foo (custom path)
|
|
125
125
|
//
|
|
126
|
-
// In CI:
|
|
127
|
-
//
|
|
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-
|
|
132
|
-
//
|
|
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
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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
|
|
236
|
+
// Initial seed: create demo tenants.
|
|
237
237
|
//
|
|
238
|
-
// Note: in real apps
|
|
239
|
-
// seed
|
|
240
|
-
//
|
|
241
|
-
// seed
|
|
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:
|
|
244
|
-
//
|
|
245
|
-
// (find*)
|
|
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
|
-
//
|
|
253
|
-
// seed), skip
|
|
254
|
-
//
|
|
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
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
// access_denied
|
|
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
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// updateMemberRoles
|
|
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
|
|
301
|
-
// m.tenantId (payload
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// publicstatus
|
|
305
|
-
//
|
|
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-
|
|
137
|
+
// User-data-rights recipe — integration test.
|
|
138
138
|
//
|
|
139
|
-
//
|
|
140
|
-
// README
|
|
141
|
-
// 1.
|
|
142
|
-
// 2.
|
|
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
|
|
145
|
-
//
|
|
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,
|
|
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 —
|
|
439
|
+
// User-Data-Rights — recipe
|
|
440
440
|
//
|
|
441
|
-
//
|
|
442
|
-
// (export, delete)
|
|
443
|
-
//
|
|
444
|
-
//
|
|
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
|
|
447
|
-
// title, body).
|
|
448
|
-
// 1.
|
|
449
|
-
// 2.
|
|
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
|
-
//
|
|
452
|
-
//
|
|
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
|
|
455
|
-
// - Compliance
|
|
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
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// bundled-feature
|
|
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
|
|
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
|
|
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);
|
package/agents/features.md
CHANGED
|
@@ -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.
|
|
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
|
+
}
|