@cosmicdrift/kumiko-dev-server 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # @cosmicdrift/kumiko-dev-server
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7ff69ab: feat(es-ops): Phase 1 — file-based seed-migrations
8
+
9
+ Neues first-class Operations-Pattern fürs Framework. Liefert `seed-migrations`
10
+ als drizzle-migrate-equivalent für Event-Sourcing-Aggregate-Updates die
11
+ idempotent-Seeder nicht erfassen können (z.B. „Member hat schon eine
12
+ Rolle, aber jetzt soll noch eine dazukommen").
13
+
14
+ Public-API:
15
+
16
+ - `runProdApp({ seedsDir })` — Auto-apply pending Migrations beim Boot
17
+ - `SeedMigration`-Interface (default-Export einer `seeds/<id>.ts`-File)
18
+ - `SeedMigrationContext` mit `systemWriteAs` (ruft existing write-handler
19
+ als System-User) + Read-Helpers (`findUserByEmail`,
20
+ `findMembershipsOfUser`, `findTenants`)
21
+ - CLI: `bunx kumiko ops seed:new|status|apply`
22
+ - Tracking-Table `kumiko_es_operations` mit `operation_type`-Discriminator
23
+ (vorbereitet auf Phase 2+ Operations: projection-rebuild, event-replay,
24
+ stream-migration, ...)
25
+ - Env-Flags: `KUMIKO_SKIP_ES_OPS=1` (alle skippen für Recovery),
26
+ `KUMIKO_SKIP_ES_OPS_<ID>=1` (einzelne kaputte skippen)
27
+
28
+ Garantien: single-run via tracking, atomic via per-migration-Tx,
29
+ chronological order via filename-prefix, fail-stop bei Failure (kein
30
+ Partial-Apply), ES-konform via Handler-Dispatch.
31
+
32
+ Sub-path-Export: `@cosmicdrift/kumiko-framework/es-ops`
33
+
34
+ Plan-Doc: `kumiko-platform/docs/plans/features/es-ops.md`
35
+ Recipe: `samples/recipes/seed-migration/`
36
+ Driver-Use-Case: publicstatus admin-roles-drift (parallel-Branch
37
+ `feat/es-ops-driver-admin-roles`).
38
+
39
+ Phase 2+ skizziert + offen markiert — Implementation pro Use-Case.
40
+
41
+ ### Patch Changes
42
+
43
+ - Updated dependencies [7ff69ab]
44
+ - @cosmicdrift/kumiko-framework@0.5.0
45
+ - @cosmicdrift/kumiko-bundled-features@0.5.0
46
+
47
+ ## 0.4.1
48
+
49
+ ### Patch Changes
50
+
51
+ - 010b410: feat(auth-email-password): "Bestätigungs-Mail erneut senden" im LoginScreen
52
+
53
+ LoginScreen bietet bei reason=email_not_verified jetzt einen Resend-Link
54
+ im Fehler-Banner — der existierende `requestEmailVerification`-Endpoint
55
+ wird direkt aufgerufen, der Banner wechselt nach Erfolg zum Info-Variant
56
+ ("Wir haben dir eine neue Bestätigungs-Mail geschickt.").
57
+
58
+ UX-Details:
59
+
60
+ - Bei 429 → inline-Hint "Bitte warte kurz und versuche es erneut."
61
+ - Bei Netzwerk/sonstigen Fehlern → inline-Hint "Konnte nicht senden."
62
+ - Anti-Typo-Gate: ändert der User die Email-Eingabe nach dem Login-Fail,
63
+ verschwindet der Resend-Link — sonst würde Resend silent-success an die
64
+ geänderte (potentiell typoed) Adresse gehen ohne User-Feedback.
65
+ - Andere Failure-Codes (invalid_credentials etc.) zeigen weiterhin keinen
66
+ Resend-Link.
67
+
68
+ i18n: 4 neue Keys (DE+EN) im `auth.login.resend*`-Namespace, additive.
69
+ Apps die ihre Translations override-en müssen nichts ändern.
70
+
71
+ Additive UI-Feature — keine API-Breaks, keine Schema-Migration.
72
+
73
+ - Updated dependencies [010b410]
74
+ - @cosmicdrift/kumiko-framework@0.4.1
75
+ - @cosmicdrift/kumiko-bundled-features@0.4.1
76
+
3
77
  ## 0.4.0
4
78
 
5
79
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  "kumiko-dev": "./bin/kumiko-dev.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-bundled-features": "0.4.0",
52
- "@cosmicdrift/kumiko-framework": "0.4.0"
51
+ "@cosmicdrift/kumiko-bundled-features": "0.5.0",
52
+ "@cosmicdrift/kumiko-framework": "0.5.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,157 @@
1
+ // Pins the boot-wiring contract for config-seeds. runDevApp's onAfterSetup
2
+ // and runProdApp's seed-block both call `applyBootSeeds(...)` — this test
3
+ // calls the SAME helper, so if someone removes the call site from
4
+ // runDevApp / runProdApp the helper still has at least one caller (this
5
+ // test). Code review then sees an orphaned helper, not a silently broken
6
+ // boot. For a stricter end-to-end pin you'd start an actual runDevApp;
7
+ // that's heavy and not done here.
8
+ //
9
+ // Tests:
10
+ // 1. seed rows land in the projection after applyBootSeeds runs,
11
+ // 2. a re-boot is a no-op (idempotent),
12
+ // 3. an admin set on top of a seed wins the resolver cascade; coexistence
13
+ // vs. override semantics depend on the admin user's tenantId.
14
+
15
+ import {
16
+ configValuesTable,
17
+ createConfigAccessorFactory,
18
+ createConfigFeature,
19
+ createConfigResolver,
20
+ } from "@cosmicdrift/kumiko-bundled-features/config";
21
+ import {
22
+ access,
23
+ createSystemConfig,
24
+ createSystemSeed,
25
+ createTenantConfig,
26
+ createTenantSeed,
27
+ defineFeature,
28
+ } from "@cosmicdrift/kumiko-framework/engine";
29
+ import {
30
+ setupTestStack,
31
+ type TestStack,
32
+ TestUsers,
33
+ unsafePushTables,
34
+ } from "@cosmicdrift/kumiko-framework/stack";
35
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
36
+ import { applyBootSeeds } from "../boot/apply-boot-seeds";
37
+
38
+ const bootSeedsFeature = defineFeature("boot-seeds-test", (r) => {
39
+ r.requires("config");
40
+ r.config({
41
+ keys: {
42
+ siteName: createTenantConfig("text", {
43
+ default: "DEFAULT_SITE",
44
+ read: access.all,
45
+ write: access.all,
46
+ }),
47
+ maintenance: createSystemConfig("boolean", {
48
+ default: false,
49
+ read: access.all,
50
+ write: access.systemAdmin,
51
+ }),
52
+ },
53
+ seeds: {
54
+ siteName: createTenantSeed({ value: "from-seed" }),
55
+ maintenance: createSystemSeed({ value: true }),
56
+ },
57
+ });
58
+ });
59
+
60
+ const SITE_KEY = "boot-seeds-test:config:site-name";
61
+ const MAINT_KEY = "boot-seeds-test:config:maintenance";
62
+
63
+ let stack: TestStack;
64
+ const resolver = createConfigResolver();
65
+
66
+ beforeAll(async () => {
67
+ stack = await setupTestStack({
68
+ features: [createConfigFeature(), bootSeedsFeature],
69
+ extraContext: ({ registry }) => ({
70
+ configResolver: resolver,
71
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
72
+ }),
73
+ });
74
+ await unsafePushTables(stack.db, { configValuesTable });
75
+ });
76
+
77
+ afterAll(async () => {
78
+ await stack.cleanup();
79
+ });
80
+
81
+ describe("config-seed boot wiring", () => {
82
+ test("first boot: applyBootSeeds writes one row per seed", async () => {
83
+ await applyBootSeeds({ registry: stack.registry, db: stack.db });
84
+
85
+ const rows = await stack.db.select().from(configValuesTable);
86
+ expect(rows.length).toBe(2);
87
+
88
+ const siteKeyDef = stack.registry.getConfigKey(SITE_KEY);
89
+ expect(siteKeyDef).toBeDefined();
90
+ const sitePeek = await resolver.get(
91
+ SITE_KEY,
92
+ siteKeyDef!,
93
+ TestUsers.systemAdmin.tenantId,
94
+ TestUsers.systemAdmin.id,
95
+ stack.db,
96
+ );
97
+ expect(sitePeek).toBe("from-seed");
98
+
99
+ const maintKeyDef = stack.registry.getConfigKey(MAINT_KEY);
100
+ expect(maintKeyDef).toBeDefined();
101
+ const maintPeek = await resolver.get(
102
+ MAINT_KEY,
103
+ maintKeyDef!,
104
+ TestUsers.systemAdmin.tenantId,
105
+ TestUsers.systemAdmin.id,
106
+ stack.db,
107
+ );
108
+ expect(maintPeek).toBe(true);
109
+ });
110
+
111
+ test("re-boot: idempotent — every seed already on disk → no extra rows", async () => {
112
+ await applyBootSeeds({ registry: stack.registry, db: stack.db });
113
+
114
+ const rows = await stack.db.select().from(configValuesTable);
115
+ expect(rows.length).toBe(2);
116
+ });
117
+
118
+ test("admin set on top of seed wins resolver — Re-Boot preserves admin", async () => {
119
+ // siteName is a TENANT-scope key. The seed writes a row under
120
+ // SYSTEM_TENANT_ID (= "for all tenants"). An admin on a real tenant
121
+ // writes a row under THAT tenantId — higher specificity. Both rows
122
+ // coexist; the resolver returns the more specific one.
123
+ //
124
+ // If the admin happens to write as SYSTEM_TENANT_ID (e.g. test user
125
+ // is the system-admin on the system-tenant), the admin write hits
126
+ // the seed-row directly and updates the same aggregate stream. Both
127
+ // paths end up with the admin value winning — the row-count
128
+ // assertion makes the path explicit.
129
+ await stack.http.writeOk(
130
+ "config:write:set",
131
+ { key: SITE_KEY, value: "admin-override", scope: "tenant" },
132
+ TestUsers.systemAdmin,
133
+ );
134
+
135
+ await applyBootSeeds({ registry: stack.registry, db: stack.db });
136
+
137
+ const siteKeyDef = stack.registry.getConfigKey(SITE_KEY);
138
+ expect(siteKeyDef).toBeDefined();
139
+ const peek = await resolver.get(
140
+ SITE_KEY,
141
+ siteKeyDef!,
142
+ TestUsers.systemAdmin.tenantId,
143
+ TestUsers.systemAdmin.id,
144
+ stack.db,
145
+ );
146
+ expect(peek).toBe("admin-override");
147
+
148
+ // Row-count tells us which path was hit:
149
+ // - 2 rows = override path (admin tenantId === SYSTEM_TENANT_ID,
150
+ // updated the seed-stream in place).
151
+ // - 3 rows = coexistence path (admin tenantId !== SYSTEM_TENANT_ID,
152
+ // new specific-tenant row sits next to the seed system-row).
153
+ // Either is correct as long as the resolver picks the admin value.
154
+ const rows = await stack.db.select().from(configValuesTable);
155
+ expect([2, 3]).toContain(rows.length);
156
+ });
157
+ });
@@ -0,0 +1,17 @@
1
+ import { seedAllConfigValues } from "@cosmicdrift/kumiko-bundled-features/config";
2
+ import type { DbConnection, EncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
4
+
5
+ // Single boot-seed entry-point. runDevApp + runProdApp both call this
6
+ // from their post-stack hook, so the wiring lives in exactly one place
7
+ // — config-seed-boot.integration.ts pins this helper, which means a
8
+ // missing call site (e.g. someone deletes the line from runDevApp)
9
+ // surfaces as a missing-helper-use in code review rather than silently
10
+ // shipping a server that never seeds.
11
+ export async function applyBootSeeds(deps: {
12
+ registry: Registry;
13
+ db: DbConnection;
14
+ encryption?: EncryptionProvider;
15
+ }): Promise<void> {
16
+ await seedAllConfigValues(deps.registry, deps.db, deps.encryption);
17
+ }
@@ -24,7 +24,6 @@ import {
24
24
  type SessionCallbacks,
25
25
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
26
26
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
27
-
28
27
  import type { SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
29
28
  import {
30
29
  type EffectiveFeaturesResolver,
@@ -35,6 +34,7 @@ import {
35
34
  type TierResolverPlugin,
36
35
  } from "@cosmicdrift/kumiko-framework/engine";
37
36
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
37
+ import { applyBootSeeds } from "./boot/apply-boot-seeds";
38
38
 
39
39
  import { watchAndRegenerate } from "./codegen";
40
40
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
@@ -325,6 +325,11 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
325
325
  if (options.auth) {
326
326
  await seedAdmin(stack.db, options.auth.admin);
327
327
  }
328
+ // Apply r.config({ seeds }) declared by any registered feature.
329
+ // Runs before user-supplied seed callbacks so those can read /
330
+ // override the deploy-defaults. The helper indirection is what
331
+ // config-seed-boot.integration.ts pins — keep it as a single call.
332
+ await applyBootSeeds({ registry: stack.registry, db: stack.db });
328
333
  for (const seed of options.seeds ?? []) {
329
334
  await seed(stack);
330
335
  }
@@ -42,7 +42,7 @@ import { createSessionCallbacks } from "@cosmicdrift/kumiko-bundled-features/ses
42
42
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
43
43
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
44
44
  import { createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
45
- import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
45
+ import { createDbConnection, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
46
46
  import {
47
47
  buildAppSchema,
48
48
  createRegistry,
@@ -58,13 +58,20 @@ import {
58
58
  type ApiEntrypointOptions,
59
59
  createApiEntrypoint,
60
60
  } from "@cosmicdrift/kumiko-framework/entrypoint";
61
+ import {
62
+ createEsOperationsTable,
63
+ createSeedMigrationContext,
64
+ runPendingSeedMigrations,
65
+ } from "@cosmicdrift/kumiko-framework/es-ops";
61
66
  import { assertSchemaCurrent, SchemaDriftError } from "@cosmicdrift/kumiko-framework/migrations";
62
67
  import {
68
+ createDispatcher,
63
69
  createEntityCache,
64
70
  createEventDedup,
65
71
  createIdempotencyGuard,
66
72
  } from "@cosmicdrift/kumiko-framework/pipeline";
67
73
  import Redis from "ioredis";
74
+ import { applyBootSeeds } from "./boot/apply-boot-seeds";
68
75
  import { ASSETS_DIR } from "./build-prod-bundle";
69
76
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
70
77
  import { injectSchema } from "./inject-schema";
@@ -274,6 +281,12 @@ export type RunProdAppOptions = {
274
281
  readonly auth?: RunProdAppAuthOptions;
275
282
  /** Custom seed functions, run after the admin seed (when auth-mode). */
276
283
  readonly seeds?: readonly ProdSeedFn[];
284
+ /** Pfad zum seeds-Directory für ES-Operations / Seed-Migrations
285
+ * (file-basiert wie drizzle-migrate). Wenn gesetzt + KUMIKO_SKIP_ES_OPS
286
+ * != "1": runProdApp scannt das Verzeichnis nach `<id>.ts` Files,
287
+ * diff vs kumiko_es_operations-Table, läuft pending in Tx.
288
+ * Plan: kumiko-platform/docs/plans/features/es-ops.md */
289
+ readonly seedsDir?: string;
277
290
  /** Anonymous-access for public endpoints (same shape as runDevApp).
278
291
  * Akzeptiert entweder einen statischen Config-Object ODER eine
279
292
  * Factory `({db, redis, registry}) => Config` — die Factory wird
@@ -589,17 +602,42 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
589
602
  // statt eines fixed JSON-Strings. Heute: registry-static, also OK.
590
603
  const appSchemaJson = JSON.stringify(buildAppSchema(registry));
591
604
 
592
- // 9. Seeds: admin first, then app-specific. Both expected to be
593
- // idempotent — runProdApp doesn't gate "first boot" via flag,
594
- // seeds check their own preconditions. seedAdmin checks email,
595
- // app seeds typically check "is my fixture row there?".
605
+ // 9. Seeds: admin first, then config-seeds from r.config({seeds}),
606
+ // then app-specific. All idempotent — runProdApp doesn't gate
607
+ // "first boot" via flag, every seed-step checks its own
608
+ // preconditions. Config-seeds rely on a deterministic
609
+ // aggregate-id so re-boot becomes a version_conflict skip.
596
610
  if (options.auth) {
597
611
  await seedAdmin(db, options.auth.admin);
598
612
  }
613
+ await applyBootSeeds({ registry, db });
599
614
  for (const seed of options.seeds ?? []) {
600
615
  await seed({ db });
601
616
  }
602
617
 
618
+ // ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
619
+ // existing seeds-array — die deklarativen Seeds sind die "always-insert-
620
+ // if-missing"-Schicht; seed-migrations sind die "diff-and-update"-
621
+ // Schicht für Drift den existing Seeds nicht erfassen können (z.B.
622
+ // Membership-Roles-Change nach initialer Seed-Erstellung).
623
+ if (options.seedsDir !== undefined && process.env["KUMIKO_SKIP_ES_OPS"] !== "1") {
624
+ await createEsOperationsTable(db);
625
+ const seedDispatcher = createDispatcher(registry, {
626
+ db,
627
+ redis,
628
+ entityCache,
629
+ registry,
630
+ ...extraContext,
631
+ });
632
+ await runPendingSeedMigrations({
633
+ db,
634
+ seedsDir: options.seedsDir,
635
+ appliedBy: "boot",
636
+ createContext: (dbRunner: DbRunner) =>
637
+ createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
638
+ });
639
+ }
640
+
603
641
  await entrypoint.start();
604
642
 
605
643
  // 10. App-eigene HTTP-Routes mounten — vor dem static-fallback. Hono