@cosmicdrift/kumiko-framework 0.181.0 → 0.182.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.181.0",
3
+ "version": "0.182.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.181.0",
185
+ "@cosmicdrift/kumiko-types": "0.182.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.181.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.182.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -30,6 +30,7 @@ import type {
30
30
  import { Temporal } from "temporal-polyfill";
31
31
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
32
32
  import type { EntityTableMeta } from "../db/entity-table-meta";
33
+ import { extractPgError } from "../db/pg-error";
33
34
  import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
34
35
  import { camelCase as envCamelCase } from "../env";
35
36
  import { parseJsonSafe } from "../utils/safe-json";
@@ -135,6 +136,38 @@ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promis
135
136
  return raw.savepoint(fn);
136
137
  }
137
138
 
139
+ // Same error-confinement as runInSavepoint, but for call sites that don't
140
+ // know whether `db` is a bare pool connection or an active transaction
141
+ // (e.g. the CRUD executor, invoked both from a dispatcher tx and directly
142
+ // against the pool by seeds/tests). A pool connection has no savepoint() —
143
+ // and doesn't need one, since each statement there is its own auto-committed
144
+ // unit and a failed statement can't poison anything downstream.
145
+ //
146
+ // `.savepoint` being present isn't proof the transaction is still open: an
147
+ // afterCommit hook closure captures the same handlerContext (and therefore
148
+ // the same TransactionSql-shaped db) that was live during the write, but by
149
+ // the time the hook fires the outer tx has already committed — the object
150
+ // still exposes `.savepoint`, calling it now fails with PG 25P01 ("no
151
+ // active sql transaction") because there's no BEGIN left to nest into. That
152
+ // SAVEPOINT command is the first thing the driver sends, before `fn` runs,
153
+ // so catching 25P01 and retrying directly is safe — nothing from `fn` has
154
+ // executed yet.
155
+ export async function runInSavepointIfSupported<T>(
156
+ db: unknown,
157
+ fn: (sp: unknown) => Promise<T>,
158
+ ): Promise<T> {
159
+ const raw = asRawClient(db) as unknown as {
160
+ savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
161
+ };
162
+ if (typeof raw.savepoint !== "function") return fn(db);
163
+ try {
164
+ return await raw.savepoint(fn);
165
+ } catch (e) {
166
+ if (extractPgError(e)?.code === "25P01") return fn(db);
167
+ throw e;
168
+ }
169
+ }
170
+
138
171
  /**
139
172
  * When handlers call `selectMany(ctx.db, …)` instead of `ctx.db.selectMany(…)`,
140
173
  * unwrap via asRawClient would bypass TenantDb scoping. Duck-type TenantDb and
@@ -5,7 +5,7 @@
5
5
  // verb entirely, and restore()'s two precondition failures.
6
6
 
7
7
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
- import { asRawClient } from "../../db/query";
8
+ import { asRawClient, transaction } from "../../db/query";
9
9
  import { createEntity, createTextField } from "../../engine";
10
10
  import { from } from "../../engine/ownership";
11
11
  import { createEventsTable } from "../../event-store";
@@ -419,6 +419,38 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
419
419
  expect(healthCheck[0]?.ok).toBe(1);
420
420
  });
421
421
 
422
+ // kumiko-framework#1778 — a real write handler runs create() inside the
423
+ // dispatcher's transaction (sql.begin()), not on the bare pool like the
424
+ // race test above. postgres.js/Bun.SQL poison the WHOLE begin() block
425
+ // once any statement inside it errors, even if the JS layer already
426
+ // caught and classified that error — so without the runInSavepoint fix
427
+ // in event-store-executor-write.ts, the LOSER's transaction() call itself
428
+ // rejects with the raw PostgresError instead of resolving to the
429
+ // version_conflict writeFailure create() already produced.
430
+ test("two concurrent first-time creates of the same id inside a transaction → one succeeds, one converges to version_conflict", async () => {
431
+ const id = "11111111-1111-4111-8111-111111111111";
432
+
433
+ const [a, b] = await Promise.all([
434
+ transaction(testDb.db, (tx) =>
435
+ crud.create({ id, email: "a@test.de" }, admin, createTenantDb(tx, admin.tenantId)),
436
+ ),
437
+ transaction(testDb.db, (tx) =>
438
+ crud.create({ id, email: "b@test.de" }, admin, createTenantDb(tx, admin.tenantId)),
439
+ ),
440
+ ]);
441
+
442
+ const results = [a, b];
443
+ expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
444
+ expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
445
+ 1,
446
+ );
447
+
448
+ const healthCheck = (await asRawClient(testDb.db).unsafe(`SELECT 1 AS ok`)) as Array<{
449
+ ok: number;
450
+ }>;
451
+ expect(healthCheck[0]?.ok).toBe(1);
452
+ });
453
+
422
454
  test("forget with entityCache clears the cache entry", async () => {
423
455
  const created = await crud.create({ email: "cache-forget@test.de" }, admin, tdb);
424
456
  if (!created.isSuccess) throw new Error("setup failed");
@@ -18,7 +18,7 @@ import {
18
18
  import { generateId } from "../utils";
19
19
  import { applyEntityEvent } from "./apply-entity-event";
20
20
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
21
- import type { DbRow } from "./connection";
21
+ import type { DbRow, DbRunner } from "./connection";
22
22
  import type { EventStoreExecutor } from "./event-store-executor";
23
23
  import {
24
24
  buildEventMetadata,
@@ -26,7 +26,7 @@ import {
26
26
  entityEventName,
27
27
  tryMapUniqueViolation,
28
28
  } from "./event-store-executor-context";
29
- import { selectMany } from "./query";
29
+ import { runInSavepointIfSupported, selectMany } from "./query";
30
30
 
31
31
  // The five write verbs (create/update/delete/forget/restore) of the event-
32
32
  // store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
@@ -190,30 +190,34 @@ export function createWriteVerbs(
190
190
  // selben catch (siehe line 493+).
191
191
  let event: Awaited<ReturnType<typeof append>>;
192
192
  try {
193
- event = await append(db.raw, {
194
- aggregateId,
195
- aggregateType: entityName,
196
- tenantId: streamTenantFor(user),
197
- expectedVersion: 0,
198
- type: entityEventName(entityName, "created"),
199
- payload: flatData,
200
- metadata: buildEventMetadata(user),
201
- });
193
+ // Savepoint-scoped: postgres.js/Bun.SQL poison the WHOLE surrounding
194
+ // begin() once any statement inside it errors, even if the JS error
195
+ // is caught (kumiko-framework#1778) — a losing concurrent create's
196
+ // unique-violation would otherwise abort the caller's outer
197
+ // transaction and surface as internal_error at commit time instead
198
+ // of the version_conflict this catch classifies. runInSavepointIfSupported
199
+ // confines the failed INSERT to a nested scope that rolls back on
200
+ // its own (same pattern as ctx.tryAppendEvent), and falls back to a
201
+ // plain call when db.raw is a bare pool connection with no active
202
+ // transaction to poison (seeds/tests calling the executor directly).
203
+ event = await runInSavepointIfSupported(db.raw, (sp) =>
204
+ append(sp as DbRunner, {
205
+ aggregateId,
206
+ aggregateType: entityName,
207
+ tenantId: streamTenantFor(user),
208
+ expectedVersion: 0,
209
+ type: entityEventName(entityName, "created"),
210
+ payload: flatData,
211
+ metadata: buildEventMetadata(user),
212
+ }),
213
+ );
202
214
  } catch (e) {
203
215
  if (e instanceof EventStoreVersionConflict) {
204
- // Try to look up the real stream-version for the diagnostic — but
205
- // wrap defensively: when `append` raised the unique-violation, the
206
- // current TX is already aborted, and a second query on the same
207
- // runner would re-throw "current transaction is aborted". Update-
208
- // path doesn't have this problem (it queries getStreamVersion
209
- // BEFORE the try-block). Falling back to a sentinel keeps the
210
- // version_conflict mapping reliable; the actual current version
211
- // is recoverable client-side via a fresh detail-query if needed.
212
216
  let currentVersion = -1;
213
217
  try {
214
218
  currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
215
219
  } catch {
216
- // Aborted TX or any lookup failure — keep the sentinel.
220
+ // Lookup failure — keep the sentinel.
217
221
  }
218
222
  return writeFailure(
219
223
  new FrameworkVersionConflict({
@@ -412,18 +416,24 @@ export function createWriteVerbs(
412
416
  // re-encrypt it before it's persisted so plaintext of pii/encrypted
413
417
  // fields doesn't land in the immutable log (flatChanges is already
414
418
  // ciphertext from encryptForStorage above).
415
- const event = await append(db.raw, {
416
- aggregateId: String(payload.id),
417
- aggregateType: entityName,
418
- tenantId: streamTenantFor(user),
419
- expectedVersion: currentVersion,
420
- type: entityEventName(entityName, "updated"),
421
- payload: {
422
- changes: flatChanges,
423
- previous: await encryptForStorage(previous, user),
424
- },
425
- metadata: buildEventMetadata(user),
426
- });
419
+ const encryptedPrevious = await encryptForStorage(previous, user);
420
+ // Savepoint-scoped — see the create() append() above for why:
421
+ // confines a losing writer's unique-violation to a nested scope
422
+ // instead of poisoning the whole outer transaction.
423
+ const event = await runInSavepointIfSupported(db.raw, (sp) =>
424
+ append(sp as DbRunner, {
425
+ aggregateId: String(payload.id),
426
+ aggregateType: entityName,
427
+ tenantId: streamTenantFor(user),
428
+ expectedVersion: currentVersion,
429
+ type: entityEventName(entityName, "updated"),
430
+ payload: {
431
+ changes: flatChanges,
432
+ previous: encryptedPrevious,
433
+ },
434
+ metadata: buildEventMetadata(user),
435
+ }),
436
+ );
427
437
 
428
438
  // Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
429
439
  // apply liest nur `changes`, und die sind live wie im Replay
package/src/db/query.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  insertOne,
19
19
  type OrderByClause,
20
20
  runInSavepoint,
21
+ runInSavepointIfSupported,
21
22
  type SelectOptions,
22
23
  selectMany,
23
24
  type TableInfo,
@@ -115,6 +115,65 @@ describe("r.contentCollection() — registration", () => {
115
115
  expect(feature.contentCollections?.["signatures"]?.ownership).toBe("user");
116
116
  });
117
117
 
118
+ test("records contentFormat so the client can resolve the right editor", () => {
119
+ const feature = defineFeature("mail", (r) => {
120
+ r.contentCollection({
121
+ id: "prompts",
122
+ kind: "ai-prompt",
123
+ contentFormat: "plain",
124
+ nav: { label: "mail:nav.prompts" },
125
+ });
126
+ });
127
+
128
+ expect(feature.contentCollections?.["prompts"]?.contentFormat).toBe("plain");
129
+ });
130
+
131
+ test("contentFormat is optional — undefined when the app didn't declare one", () => {
132
+ const feature = defineFeature("mail", (r) => {
133
+ r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "a" } });
134
+ });
135
+
136
+ expect(feature.contentCollections?.["templates"]?.contentFormat).toBeUndefined();
137
+ });
138
+
139
+ test("records contentFormat markdown as a third format alongside plain/rich", () => {
140
+ const feature = defineFeature("mail", (r) => {
141
+ r.contentCollection({
142
+ id: "notes",
143
+ kind: "text-block",
144
+ contentFormat: "markdown",
145
+ nav: { label: "mail:nav.notes" },
146
+ });
147
+ });
148
+
149
+ expect(feature.contentCollections?.["notes"]?.contentFormat).toBe("markdown");
150
+ });
151
+
152
+ test("records variableSchema so the client can offer the right chips", () => {
153
+ const feature = defineFeature("mail", (r) => {
154
+ r.contentCollection({
155
+ id: "prompts",
156
+ kind: "ai-prompt",
157
+ contentFormat: "plain",
158
+ variableSchema: { customerName: {}, orderId: {} },
159
+ nav: { label: "mail:nav.prompts" },
160
+ });
161
+ });
162
+
163
+ expect(feature.contentCollections?.["prompts"]?.variableSchema).toEqual({
164
+ customerName: {},
165
+ orderId: {},
166
+ });
167
+ });
168
+
169
+ test("variableSchema is optional — undefined when the app didn't declare one", () => {
170
+ const feature = defineFeature("mail", (r) => {
171
+ r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "a" } });
172
+ });
173
+
174
+ expect(feature.contentCollections?.["templates"]?.variableSchema).toBeUndefined();
175
+ });
176
+
118
177
  test("rejects a second collection with the same id", () => {
119
178
  expect(() =>
120
179
  defineFeature("mail", (r) => {
@@ -179,6 +238,7 @@ describe("buildAppSchema — content collections", () => {
179
238
  r.contentCollection({
180
239
  id: "templates",
181
240
  kind: "mail-html",
241
+ contentFormat: "rich",
182
242
  nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
183
243
  });
184
244
  }),
@@ -190,6 +250,7 @@ describe("buildAppSchema — content collections", () => {
190
250
  {
191
251
  id: "templates",
192
252
  kind: "mail-html",
253
+ contentFormat: "rich",
193
254
  nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
194
255
  navQn: "mail:nav:templates",
195
256
  },
@@ -199,6 +260,38 @@ describe("buildAppSchema — content collections", () => {
199
260
  expect(mail?.navs?.map((n) => n.id)).toContain("templates");
200
261
  });
201
262
 
263
+ test("projects variableSchema through to the client schema", () => {
264
+ const registry = createRegistry([
265
+ defineFeature("mail", (r) => {
266
+ r.contentCollection({
267
+ id: "prompts",
268
+ kind: "ai-prompt",
269
+ contentFormat: "plain",
270
+ variableSchema: { customerName: {} },
271
+ nav: { label: "mail:nav.prompts" },
272
+ });
273
+ }),
274
+ ]);
275
+
276
+ const schema = buildAppSchema(registry);
277
+ const mail = schema.features.find((f) => f.featureName === "mail");
278
+ expect(mail?.contentCollections?.[0]?.variableSchema).toEqual({ customerName: {} });
279
+ // buildAppSchema's JSON-safety check throws on undefined leaves — proves
280
+ // the new field survives that check instead of only the toEqual above.
281
+ expect(() =>
282
+ validateBoot([
283
+ defineFeature("mail2", (r) => {
284
+ r.contentCollection({
285
+ id: "prompts",
286
+ kind: "ai-prompt",
287
+ variableSchema: { customerName: {} },
288
+ nav: { label: "mail2:nav.prompts" },
289
+ });
290
+ }),
291
+ ]),
292
+ ).not.toThrow();
293
+ });
294
+
202
295
  test("omits the slot for features without collections", () => {
203
296
  const registry = createRegistry([
204
297
  defineFeature("shop", (r) => {
@@ -14,6 +14,9 @@ import {
14
14
  registerEntityCrud,
15
15
  } from "../entity-handlers";
16
16
  import { createEntity, createTextField } from "../factories";
17
+ // Barrel import, not "../entity-handlers": covers that entityListSchema is
18
+ // actually re-exported through engine/index.ts.
19
+ import { entityListSchema } from "../index";
17
20
  import type { QueryHandlerDef, WriteHandlerDef } from "../types";
18
21
 
19
22
  const VALID_UUID = "00000000-0000-4000-8000-000000000001";
@@ -119,6 +122,19 @@ describe("defineEntityQueryHandler", () => {
119
122
  expect(def.schema.safeParse({ sortDirection: "wrong" }).success).toBe(false);
120
123
  });
121
124
 
125
+ test("list: schema is the exported entityListSchema, not a private copy", () => {
126
+ const def = defineEntityListHandler("note", noteEntity);
127
+ expect(def.schema).toBe(entityListSchema);
128
+ // money-horse#293: a consumer copy silently dropped these fields when it
129
+ // drifted from the handler's actual schema — assert they still round-trip.
130
+ expect(
131
+ def.schema.safeParse({
132
+ includeDeleted: true,
133
+ filters: [{ field: "title", op: "eq", value: "x" }],
134
+ }).success,
135
+ ).toBe(true);
136
+ });
137
+
122
138
  test("detail: schema requires id", () => {
123
139
  const def = defineEntityDetailHandler("note", noteEntity);
124
140
  expect(def.schema.safeParse({ id: VALID_UUID }).success).toBe(true);
@@ -102,7 +102,7 @@ type ListPayload = {
102
102
  };
103
103
 
104
104
  const idSchema = z.object({ id: z.uuid() });
105
- const listSchema = z.object({
105
+ export const entityListSchema = z.object({
106
106
  cursor: z.string().optional(),
107
107
  limit: z.number().optional(),
108
108
  search: z.string().optional(),
@@ -292,7 +292,7 @@ export function defineEntityQueryHandler(
292
292
 
293
293
  switch (verb) {
294
294
  case "list":
295
- schema = listSchema;
295
+ schema = entityListSchema;
296
296
  handler = async (query, ctx) => {
297
297
  // Tier 2.7e Audit-Fix: SearchAdapter aus ctx durchreichen,
298
298
  // damit payload.search zur Laufzeit gegen Meilisearch/InMem
@@ -69,6 +69,7 @@ export {
69
69
  defineEntityWriteHandler,
70
70
  defineProjectionQueryHandler,
71
71
  type EntityCrudRegistrar,
72
+ entityListSchema,
72
73
  type RegisterEntityCrudOptions,
73
74
  registerEntityCrud,
74
75
  } from "./entity-handlers";