@cosmicdrift/kumiko-framework 0.183.1 → 0.184.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.184.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.184.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.184.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
17
17
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
18
|
-
import { asRawClient, selectMany } from "../../db/query";
|
|
18
|
+
import { asRawClient, selectMany, transaction } from "../../db/query";
|
|
19
19
|
import { createEntity, createTextField } from "../../engine";
|
|
20
20
|
import { createEventsTable } from "../../event-store";
|
|
21
21
|
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
@@ -142,6 +142,37 @@ describe("F8 — entity-level unique-violation auf update", () => {
|
|
|
142
142
|
// restore — kein try-catch nötig (drift-pin: dokumentiert die Annahme)
|
|
143
143
|
// =============================================================================
|
|
144
144
|
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// projection-INSERT savepoint — a step-2 failure inside a shared transaction
|
|
147
|
+
// must not poison the enclosing begin() block
|
|
148
|
+
// =============================================================================
|
|
149
|
+
|
|
150
|
+
describe("F8 — a projection violation must not poison the enclosing transaction", () => {
|
|
151
|
+
test("a sibling write in the same tx survives a unique-violation from the projection INSERT", async () => {
|
|
152
|
+
// The path #1778 already covers for the event append but which was open for
|
|
153
|
+
// the projection INSERT (step 2): on a pool connection each write
|
|
154
|
+
// auto-commits on its own, so a 23505 can poison nothing. Only inside a
|
|
155
|
+
// shared begin() — the kind a real handler runs, whose second write follows
|
|
156
|
+
// a failed nested one — does postgres.js/Bun.SQL tear the whole block down
|
|
157
|
+
// once a statement failed, even after the JS layer caught the error.
|
|
158
|
+
// Without the savepoint around the projection INSERT the sibling write
|
|
159
|
+
// below throws raw 25P02 instead of running cleanly.
|
|
160
|
+
const outcome = await transaction(testDb.db, async (tx) => {
|
|
161
|
+
const txTdb = createTenantDb(tx, admin.tenantId);
|
|
162
|
+
const first = await exec.create({ email: "dup@x.de", displayName: "A" }, admin, txTdb);
|
|
163
|
+
const dup = await exec.create({ email: "dup@x.de", displayName: "B" }, admin, txTdb);
|
|
164
|
+
const sibling = await exec.create({ email: "fresh@x.de", displayName: "C" }, admin, txTdb);
|
|
165
|
+
return { first, dup, sibling };
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(outcome.first.isSuccess).toBe(true);
|
|
169
|
+
expect(outcome.dup.isSuccess).toBe(false);
|
|
170
|
+
if (!outcome.dup.isSuccess) expect(outcome.dup.error.code).toBe("unique_violation");
|
|
171
|
+
// The point: the tx survives the caught violation.
|
|
172
|
+
expect(outcome.sibling.isSuccess).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
145
176
|
describe("F8 — restore touch'd nur isDeleted, kein 23505-Pfad", () => {
|
|
146
177
|
test("restore einer soft-gedeleteten row mit unique-field läuft konfliktfrei durch", async () => {
|
|
147
178
|
// Audit-Annahme (advisor-Punkt verifiziert): restore mutiert nur
|
|
@@ -243,9 +243,17 @@ export function createWriteVerbs(
|
|
|
243
243
|
// unhandled exception → 500 internal_error. Map auf
|
|
244
244
|
// UniqueViolationError 409 damit Designer/Frontend einen sauberen
|
|
245
245
|
// "duplicate" zeigen können statt cryptic "internal server error".
|
|
246
|
+
// Savepoint like the append above: an unclassified DB error out of the
|
|
247
|
+
// projection INSERT would otherwise poison the enclosing tx (Bun.SQL
|
|
248
|
+
// rejects the whole begin() once any statement in it failed), so a later
|
|
249
|
+
// write sharing that tx — a sibling write after a failed nested one —
|
|
250
|
+
// throws raw as a 500 instead of failing cleanly. The savepoint rolls the
|
|
251
|
+
// failed INSERT back; the error still propagates and stays catchable.
|
|
246
252
|
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
247
253
|
try {
|
|
248
|
-
result = await
|
|
254
|
+
result = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
255
|
+
applyEntityEvent(event, table, entity, sp as DbRunner),
|
|
256
|
+
);
|
|
249
257
|
} catch (e) {
|
|
250
258
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
251
259
|
if (mapped) return mapped;
|
|
@@ -443,9 +451,14 @@ export function createWriteVerbs(
|
|
|
443
451
|
// — ein update das einen unique-Index verletzt (z.B. email-update
|
|
444
452
|
// auf einen schon-existierenden Wert) wird mit 409 unique_violation
|
|
445
453
|
// statt 500 internal_error rückgemeldet.
|
|
454
|
+
// Savepoint like the create path: a raw DB error out of the projection
|
|
455
|
+
// UPDATE poisons the enclosing tx otherwise, so a sibling write after a
|
|
456
|
+
// failed nested one throws 500 instead of failing cleanly.
|
|
446
457
|
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
447
458
|
try {
|
|
448
|
-
result = await
|
|
459
|
+
result = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
460
|
+
applyEntityEvent(event, table, entity, sp as DbRunner),
|
|
461
|
+
);
|
|
449
462
|
} catch (e) {
|
|
450
463
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
451
464
|
if (mapped) return mapped;
|
|
@@ -554,7 +567,12 @@ export function createWriteVerbs(
|
|
|
554
567
|
// wird vom soft/hard-delete-Code gar nicht in die Tabelle geschrieben
|
|
555
568
|
// (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
|
|
556
569
|
// dasselbe — kein payload-override nötig.
|
|
557
|
-
|
|
570
|
+
// Savepoint like the create/update paths: a raw DB error out of the
|
|
571
|
+
// projection delete poisons the enclosing tx otherwise, surfacing as a
|
|
572
|
+
// 500 on the next write that shares it rather than a clean failure.
|
|
573
|
+
const deleteResult = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
574
|
+
applyEntityEvent(event, table, entity, sp as DbRunner),
|
|
575
|
+
);
|
|
558
576
|
if (deleteResult.kind !== "applied") {
|
|
559
577
|
return writeFailure(
|
|
560
578
|
new InternalError({ message: "projection delete: applyEntityEvent skipped" }),
|