@cosmicdrift/kumiko-bundled-features 0.215.4 → 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.4",
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.4",
130
- "@cosmicdrift/kumiko-framework": "0.215.4",
131
- "@cosmicdrift/kumiko-headless": "0.215.4",
132
- "@cosmicdrift/kumiko-renderer": "0.215.4",
133
- "@cosmicdrift/kumiko-renderer-web": "0.215.4",
134
- "@cosmicdrift/kumiko-types": "0.215.4",
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.4"
163
+ "@cosmicdrift/kumiko-locale-de": "0.215.6"
164
164
  }
165
165
  }
@@ -266,7 +266,9 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
266
266
  // worker). The row already has started_at from onJobStart.
267
267
  const row = await fetchOne<{ startedAt: Temporal.Instant }>(db, jobRunsTable, { id: runId });
268
268
  const now = Temporal.Now.instant();
269
- const duration = row ? Number(now.since(row.startedAt).total({ unit: "millisecond" })) : 0;
269
+ const duration = row
270
+ ? Math.round(Number(now.since(row.startedAt).total({ unit: "millisecond" })))
271
+ : 0;
270
272
  const payload = runFailedSchema.parse({
271
273
  duration,
272
274
  finishedAt: now.toString(),
@@ -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,
@@ -519,7 +519,7 @@ async function runInSubTransaction(
519
519
  begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
520
520
  savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
521
521
  };
522
- // savepoint-FIRST — empirisch (Bun 1.3.14) sind die Flächen NICHT
522
+ // savepoint-FIRST — empirisch (Bun 1.4.0) sind die Flächen NICHT
523
523
  // mutually exclusive: eine TransactionSql exposed begin UND savepoint,
524
524
  // nur die Top-Level-Connection hat ausschließlich begin. begin-first
525
525
  // wählte im Tx-Fall das nested BEGIN (Prod-Incident-Klasse, s. Header);