@cosmicdrift/kumiko-bundled-features 0.215.5 → 0.215.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.215.5",
3
+ "version": "0.215.6",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.215.5",
130
- "@cosmicdrift/kumiko-framework": "0.215.5",
131
- "@cosmicdrift/kumiko-headless": "0.215.5",
132
- "@cosmicdrift/kumiko-renderer": "0.215.5",
133
- "@cosmicdrift/kumiko-renderer-web": "0.215.5",
134
- "@cosmicdrift/kumiko-types": "0.215.5",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.215.6",
130
+ "@cosmicdrift/kumiko-framework": "0.215.6",
131
+ "@cosmicdrift/kumiko-headless": "0.215.6",
132
+ "@cosmicdrift/kumiko-renderer": "0.215.6",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.215.6",
134
+ "@cosmicdrift/kumiko-types": "0.215.6",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,6 +160,6 @@
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
162
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.215.5"
163
+ "@cosmicdrift/kumiko-locale-de": "0.215.6"
164
164
  }
165
165
  }
@@ -1,5 +1,10 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { type DbConnection, fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ type DbConnection,
4
+ deleteMany,
5
+ fetchOne,
6
+ selectMany,
7
+ } from "@cosmicdrift/kumiko-framework/db";
3
8
  import { SYSTEM_TENANT_ID, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
4
9
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
10
  import {
@@ -77,6 +82,32 @@ describe("seedLegalContentFromJson", () => {
77
82
  expect(row.modifiedById).toBe(SYSTEM_USER_ID);
78
83
  });
79
84
 
85
+ test("orphan projection (row without events) is recreated on ifExists:update", async () => {
86
+ await seedLegalContentFromJson(db, [
87
+ { slug: "orphan-privacy", locale: "de", title: "DS", content: "old" },
88
+ ]);
89
+ const before = await read("orphan-privacy", "de");
90
+ expect(before).not.toBeNull();
91
+
92
+ // Same desync as prod: projection left behind, event stream empty.
93
+ await deleteMany(db, eventsTable, { aggregateId: String(before!.id) });
94
+ expect(await read("orphan-privacy", "de")).not.toBeNull();
95
+
96
+ await seedLegalContentFromJson(db, [
97
+ {
98
+ slug: "orphan-privacy",
99
+ locale: "de",
100
+ title: "DS",
101
+ content: "new from template",
102
+ },
103
+ ]);
104
+ const after = await read("orphan-privacy", "de");
105
+ expect(after).toMatchObject({ content: "new from template", title: "DS" });
106
+ const events = await selectMany(db, eventsTable, { aggregateId: String(after!.id) });
107
+ expect(events.length).toBeGreaterThan(0);
108
+ expect(events.some((e) => e.type === "template-resource.created")).toBe(true);
109
+ });
110
+
80
111
  test("identical re-seed is a no-op (no update event, version/modifiedAt stable)", async () => {
81
112
  const blocks: LegalContentBlock[] = [
82
113
  { slug: "terms", locale: "de", title: "AGB", content: "stable" },
@@ -10,18 +10,43 @@
10
10
  // bewusst nicht TestUsers (Prod-Seeds ≠ Test-Utilities).
11
11
 
12
12
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
13
- import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ createTenantDb,
15
+ type DbConnection,
16
+ deleteMany,
17
+ type EntityTableMeta,
18
+ } from "@cosmicdrift/kumiko-framework/db";
14
19
  import {
15
20
  createSystemUser,
16
21
  type SessionUser,
17
22
  SYSTEM_TENANT_ID,
18
23
  type TenantId,
19
24
  } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { getStreamVersion } from "@cosmicdrift/kumiko-framework/event-store";
20
26
  import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
21
27
  import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants";
22
28
  import { executor } from "./handlers/shared";
23
29
  import { type TemplateResourceRow, templateResourcesTable } from "./table";
24
30
 
31
+ /** Projection row without events — update() would 409 (expectedVersion from
32
+ * projection, stream currentVersion=0). Drop the orphan and treat as create
33
+ * so boot seeds (legal ifExists:update) survive desync. Stream version is the
34
+ * optimistic-lock source of truth when events exist. */
35
+ async function resolveExistingForEventStoreSeed(
36
+ db: DbConnection,
37
+ existingRow: { readonly id: string | number; readonly version: number } | null,
38
+ tenantId: TenantId,
39
+ ): Promise<{ id: string; version: number } | null> {
40
+ if (existingRow == null) return null;
41
+ const id = String(existingRow.id);
42
+ const streamVersion = await getStreamVersion(db, id, tenantId);
43
+ if (streamVersion === 0) {
44
+ await deleteMany(db, templateResourcesTable as EntityTableMeta, { id });
45
+ return null;
46
+ }
47
+ return { id, version: streamVersion };
48
+ }
49
+
25
50
  export type SeedSystemTemplateOptions = {
26
51
  readonly slug: string;
27
52
  readonly kind: UpsertKind;
@@ -43,12 +68,17 @@ export async function seedSystemTemplate(
43
68
  const by = opts.by ?? createSystemUser(tenantId);
44
69
  const tdb = createTenantDb(db, tenantId, "system");
45
70
 
46
- const existing = (await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
71
+ const existingRow = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
47
72
  tenantId,
48
73
  slug: opts.slug,
49
74
  kind: opts.kind,
50
75
  locale: opts.locale,
51
- })) as { id: string; version: number } | null;
76
+ });
77
+ const existing = await resolveExistingForEventStoreSeed(
78
+ db,
79
+ existingRow ? { id: existingRow.id, version: existingRow.version } : null,
80
+ tenantId,
81
+ );
52
82
 
53
83
  const variableSchema = JSON.stringify(opts.variableSchema ?? {});
54
84
  const linkedResources = JSON.stringify(opts.linkedResources ?? {});
@@ -127,9 +157,11 @@ export async function seedTextBlock(
127
157
  kind: TEXT_BLOCK_KIND,
128
158
  locale: opts.locale,
129
159
  });
130
- const existing = existingRow
131
- ? { id: String(existingRow.id), version: existingRow.version }
132
- : null;
160
+ const existing = await resolveExistingForEventStoreSeed(
161
+ db,
162
+ existingRow ? { id: existingRow.id, version: existingRow.version } : null,
163
+ opts.tenantId,
164
+ );
133
165
 
134
166
  const fields = {
135
167
  slug: opts.slug,