@cosmicdrift/kumiko-framework 0.149.1 → 0.150.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.149.1",
3
+ "version": "0.150.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>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.149.1",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.150.0",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -90,6 +90,81 @@ describe("r.screen() — registration", () => {
90
90
  expect(() => validateBoot(features)).toThrow(/empty or non-string query/i);
91
91
  });
92
92
 
93
+ test("stores a projectionDetail screen bound to an explicit query", () => {
94
+ const feature = defineFeature("app", (r) => {
95
+ r.screen({
96
+ id: "rent-detail",
97
+ type: "projectionDetail",
98
+ query: "ledger:query:schedule:detail",
99
+ layout: { sections: [{ title: "app:section.basics", fields: ["description"] }] },
100
+ });
101
+ });
102
+ const screen = feature.screens["rent-detail"];
103
+ expect(screen?.type).toBe("projectionDetail");
104
+ if (screen?.type !== "projectionDetail") throw new Error("type-narrow failed");
105
+ expect(screen.query).toBe("ledger:query:schedule:detail");
106
+ });
107
+
108
+ test("validateBoot rejects a projectionDetail with an empty query", () => {
109
+ const features = [
110
+ defineFeature("app", (r) => {
111
+ r.screen({
112
+ id: "x",
113
+ type: "projectionDetail",
114
+ query: "",
115
+ layout: { sections: [{ title: "s", fields: ["name"] }] },
116
+ });
117
+ }),
118
+ ];
119
+ expect(() => validateBoot(features)).toThrow(/empty or non-string query/i);
120
+ });
121
+
122
+ test("validateBoot rejects a projectionDetail with empty sections", () => {
123
+ const features = [
124
+ defineFeature("app", (r) => {
125
+ r.screen({
126
+ id: "x",
127
+ type: "projectionDetail",
128
+ query: "app:query:foo:detail",
129
+ layout: { sections: [] },
130
+ });
131
+ }),
132
+ ];
133
+ expect(() => validateBoot(features)).toThrow(/empty sections list/i);
134
+ });
135
+
136
+ test("validateBoot rejects a projectionDetail section with zero fields", () => {
137
+ const features = [
138
+ defineFeature("app", (r) => {
139
+ r.screen({
140
+ id: "x",
141
+ type: "projectionDetail",
142
+ query: "app:query:foo:detail",
143
+ layout: { sections: [{ title: "s", fields: [] }] },
144
+ });
145
+ }),
146
+ ];
147
+ expect(() => validateBoot(features)).toThrow(/zero fields/i);
148
+ });
149
+
150
+ test("validateBoot rejects a projectionDetail with an extension section (no entity to persist against)", () => {
151
+ const features = [
152
+ defineFeature("app", (r) => {
153
+ r.screen({
154
+ id: "x",
155
+ type: "projectionDetail",
156
+ query: "app:query:foo:detail",
157
+ layout: {
158
+ sections: [
159
+ { kind: "extension", title: "s", component: { react: { __component: "c" } } },
160
+ ],
161
+ },
162
+ });
163
+ }),
164
+ ];
165
+ expect(() => validateBoot(features)).toThrow(/extension section/i);
166
+ });
167
+
93
168
  test("stores an entityEdit screen with sections + conditional fields", () => {
94
169
  const feature = defineFeature("shop", (r) => {
95
170
  r.entity("product", productEntity());
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createSystemConfig } from "../../config-helpers";
3
+ import type { FeatureDefinition } from "../../types";
4
+ import { validateConfigKeyAllowPerRequest, validateConfigKeyComputed } from "../config-deps";
5
+
6
+ function fakeFeature(configKeys: FeatureDefinition["configKeys"]): FeatureDefinition {
7
+ return { name: "test-feature", configKeys } as unknown as FeatureDefinition;
8
+ }
9
+
10
+ describe("validateConfigKeyComputed", () => {
11
+ test("rejects computed + backing:secrets (encrypted-at-rest via backing, not the encrypted flag)", () => {
12
+ const feature = fakeFeature({
13
+ apiKey: createSystemConfig("text", { backing: "secrets", computed: async () => "x" }),
14
+ });
15
+ expect(() => validateConfigKeyComputed(feature)).toThrow(/mutually exclusive/);
16
+ });
17
+
18
+ test("rejects computed + encrypted:true", () => {
19
+ const feature = fakeFeature({
20
+ apiKey: createSystemConfig("text", { encrypted: true, computed: async () => "x" }),
21
+ });
22
+ expect(() => validateConfigKeyComputed(feature)).toThrow(/mutually exclusive/);
23
+ });
24
+
25
+ test("allows computed without encryption", () => {
26
+ const feature = fakeFeature({
27
+ apiKey: createSystemConfig("text", { computed: async () => "x" }),
28
+ });
29
+ expect(() => validateConfigKeyComputed(feature)).not.toThrow();
30
+ });
31
+ });
32
+
33
+ describe("validateConfigKeyAllowPerRequest", () => {
34
+ test("rejects allowPerRequest + backing:secrets on a number key", () => {
35
+ const feature = fakeFeature({
36
+ rateLimit: createSystemConfig("number", { backing: "secrets", allowPerRequest: true }),
37
+ });
38
+ expect(() => validateConfigKeyAllowPerRequest(feature)).toThrow(
39
+ /may not be set via query-params/,
40
+ );
41
+ });
42
+ });
@@ -1,3 +1,4 @@
1
+ import { isEncryptedAtRest } from "../config-helpers";
1
2
  import type { FeatureDefinition } from "../types";
2
3
 
3
4
  // --- Toggleable-dependency warnings ---
@@ -79,9 +80,9 @@ export function validateConfigKeyComputed(feature: FeatureDefinition): void {
79
80
  // returns a plain value, encrypted expects cipher-text in the row. The
80
81
  // cascade doesn't know which one to prefer on write. Rejecting at boot
81
82
  // is cheaper than surprising behaviour at runtime.
82
- if (keyDef.encrypted) {
83
+ if (isEncryptedAtRest(keyDef)) {
83
84
  throw new Error(
84
- `[Feature ${feature.name}] Config key "${keyName}" has both encrypted=true and a computed resolver — these are mutually exclusive paradigms`,
85
+ `[Feature ${feature.name}] Config key "${keyName}" has both encrypted/backing="secrets" and a computed resolver — these are mutually exclusive paradigms`,
85
86
  );
86
87
  }
87
88
  }
@@ -128,9 +129,9 @@ export function validateConfigKeyAllowPerRequest(feature: FeatureDefinition): vo
128
129
  // encrypted + per-request would expose a cipher-text interpretation
129
130
  // to query-strings. The secret-value shouldn't be transported this
130
131
  // way — reject as a paradigm-mismatch.
131
- if (keyDef.encrypted) {
132
+ if (isEncryptedAtRest(keyDef)) {
132
133
  throw new Error(
133
- `[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but encrypted=true — secret values may not be set via query-params`,
134
+ `[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but encrypted/backing="secrets" — secret values may not be set via query-params`,
134
135
  );
135
136
  }
136
137
  }
@@ -172,6 +172,39 @@ export function validateScreens(
172
172
  continue;
173
173
  }
174
174
 
175
+ if (screen.type === "projectionDetail") {
176
+ // Query-getrieben wie projectionList, aber Single-Row + Layout statt
177
+ // Columns. Kein Entity-Check möglich — Felder werden render-seitig
178
+ // gegen die Query-Response aufgelöst, nicht gegen eine Entity.
179
+ if (!screen.query || typeof screen.query !== "string") {
180
+ throw new Error(
181
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has empty or non-string query.`,
182
+ );
183
+ }
184
+ if (screen.layout.sections.length === 0) {
185
+ throw new Error(
186
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has an empty sections list — ` +
187
+ `declare at least one section.`,
188
+ );
189
+ }
190
+ for (const section of screen.layout.sections) {
191
+ if (isExtensionEditSection(section)) {
192
+ throw new Error(
193
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
194
+ `"${section.title}" is not supported — projectionDetail has no entity for an extension ` +
195
+ `section to persist against.`,
196
+ );
197
+ }
198
+ if (section.fields.length === 0) {
199
+ throw new Error(
200
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has a section "${section.title}" ` +
201
+ `with zero fields — drop the section or add fields to it.`,
202
+ );
203
+ }
204
+ }
205
+ continue;
206
+ }
207
+
175
208
  if (screen.type === "dashboard") {
176
209
  validateDashboardScreen(feature.name, screenId, screen);
177
210
  continue;
@@ -14,6 +14,17 @@ import type {
14
14
  CreateUserSeedOptions,
15
15
  } from "./types";
16
16
 
17
+ // A key backed by "secrets" is at-rest encrypted by the secrets store itself
18
+ // (MasterKeyProvider), even without an explicit `encrypted: true` — callers
19
+ // that gate on encryption (boot-validator mutual-exclusion checks, config
20
+ // query redaction) must treat the two as equivalent or a secrets-backed key
21
+ // silently skips the encrypted-only guard.
22
+ export function isEncryptedAtRest(
23
+ def: Pick<ConfigKeyDefinition, "encrypted" | "backing">,
24
+ ): boolean {
25
+ return def.encrypted === true || def.backing === "secrets";
26
+ }
27
+
17
28
  // --- Access Presets ---
18
29
 
19
30
  export const access = {
@@ -7,6 +7,7 @@
7
7
  // lesen.
8
8
 
9
9
  import { compareByCodepoint } from "../utils";
10
+ import { isEncryptedAtRest } from "./config-helpers";
10
11
  import { qualifyEntityName } from "./qualified-name";
11
12
  import type { Registry, UiHints } from "./types/feature";
12
13
 
@@ -106,7 +107,7 @@ export function buildManifestFromRegistry(
106
107
  type: def.type,
107
108
  scope: def.scope,
108
109
  default: def.default ?? null,
109
- encrypted: def.encrypted ?? def.backing === "secrets",
110
+ encrypted: isEncryptedAtRest(def),
110
111
  computed: def.computed !== undefined,
111
112
  options: def.options ?? null,
112
113
  bounds: def.bounds ?? null,
@@ -23,6 +23,7 @@ export {
23
23
  createTenantSeed,
24
24
  createUserConfig,
25
25
  createUserSeed,
26
+ isEncryptedAtRest,
26
27
  } from "./config-helpers";
27
28
  export type { SystemHookName } from "./constants";
28
29
  export {
@@ -393,6 +393,13 @@ export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedCon
393
393
  // (appendEvent) is the contract Designer/AI rely on.
394
394
  readonly unsafeAppendEvent: UnsafeAppendEventFn;
395
395
 
396
+ // Savepoint-scoped append. Use when the handler must gracefully continue
397
+ // after losing a race against a concurrent writer on the same aggregate
398
+ // stream (e.g. two idempotent ingest calls racing the same dedup key) —
399
+ // see TryAppendEventFn for why this doesn't poison the transaction the
400
+ // way a caught unsafeAppendEvent throw would.
401
+ readonly tryAppendEvent: TryAppendEventFn;
402
+
396
403
  // Marten FetchForWriting equivalent: load the current stream, optionally
397
404
  // enforce expectedVersion, and get a handle that appends further events
398
405
  // onto that stream without re-specifying aggregateId/aggregateType.
@@ -673,6 +680,20 @@ export type AppendEventFn<TMap extends object = KumikoEventTypeMap> = <K extends
673
680
 
674
681
  export type UnsafeAppendEventFn = (args: AppendEventArgs) => Promise<void>;
675
682
 
683
+ // Savepoint-scoped append — returns a discriminated result instead of
684
+ // throwing on VersionConflictError, so a handler can react gracefully to
685
+ // losing a race against a concurrent writer on the same aggregate stream
686
+ // (e.g. two idempotent ingest calls for the same dedup key). The append
687
+ // runs inside a driver-native SAVEPOINT: a conflict rolls back only that
688
+ // nested scope, leaving the rest of the handler's transaction usable —
689
+ // unlike unsafeAppendEvent, whose thrown VersionConflictError poisons the
690
+ // entire enclosing transaction.
691
+ export type TryAppendEventResult =
692
+ | { readonly ok: true; readonly event: import("../../event-store").StoredEvent }
693
+ | { readonly ok: false; readonly conflict: import("../../event-store").VersionConflictError };
694
+
695
+ export type TryAppendEventFn = (args: AppendEventArgs) => Promise<TryAppendEventResult>;
696
+
676
697
  // Args for ctx.fetchForWriting — Marten FetchForWriting equivalent. Returns
677
698
  // the current stream state + a handle that appends without re-specifying
678
699
  // aggregateId/aggregateType. When expectedVersion is provided, the handle
@@ -157,6 +157,8 @@ export type {
157
157
  RateLimitOption,
158
158
  RateLimitPer,
159
159
  SessionUser,
160
+ TryAppendEventFn,
161
+ TryAppendEventResult,
160
162
  UnsafeAppendEventFn,
161
163
  WriteEvent,
162
164
  WriteHandlerDef,
@@ -243,6 +245,7 @@ export type {
243
245
  FormatSpec,
244
246
  ListColumnSpec,
245
247
  PlatformComponent,
248
+ ProjectionDetailScreenDefinition,
246
249
  ProjectionListScreenDefinition,
247
250
  RowAction,
248
251
  RowActionNavigate,
@@ -324,6 +324,39 @@ export type ProjectionListScreenDefinition = {
324
324
  readonly access?: AccessRule;
325
325
  };
326
326
 
327
+ // --- projectionDetail ---
328
+
329
+ // Read-only counterpart to projectionList — a single-row inspector bound to
330
+ // an EXPLICIT query instead of an entity (`entityEdit` requires `r.entity`,
331
+ // which a direct-write/projection read-model like `jobs`/`sessions` doesn't
332
+ // have — see #255). There is no write path: the renderer forces every field
333
+ // readOnly structurally (not just by convention — see
334
+ // renderer/projection-detail-shim.ts), so no `<entity>:write:...:update`
335
+ // command is ever constructed. `idParam` names the query-payload key the
336
+ // route's row-id is passed under; defaults to "id", but a query handler
337
+ // owned by a different feature may already use a domain-specific param name
338
+ // (e.g. jobs' `detailQuery` expects `runId`) — this lets the primitive bind
339
+ // to it without forcing a handler rename. Extension sections aren't
340
+ // supported (no entity for them to persist against); the boot-validator
341
+ // rejects them.
342
+ export type ProjectionDetailScreenDefinition = {
343
+ readonly id: string;
344
+ readonly type: "projectionDetail";
345
+ readonly query: string;
346
+ /** Query-payload key for the row-id. Default "id". */
347
+ readonly idParam?: string;
348
+ readonly layout: EditLayout;
349
+ /** Optionaler per-Field-Label-i18n-Key (Field-Name → Key), analog zu
350
+ * entityEdit.fieldLabels. Die Pseudo-Entity `__projection-detail__` hat
351
+ * keinen natürlichen Field-Namespace — fehlt ein Eintrag, gilt die
352
+ * Konvention `<feature>:entity:__projection-detail__:field:<name>`. */
353
+ readonly fieldLabels?: Readonly<Record<string, string>>;
354
+ /** Parent list screen (kurze id) für eine "Zurück"-Navigation. */
355
+ readonly listScreenId?: string;
356
+ readonly slots?: ScreenSlots;
357
+ readonly access?: AccessRule;
358
+ };
359
+
327
360
  // --- dashboard ---
328
361
 
329
362
  // Deklaratives Panel-Grid — Kennzahlen, Verläufe und Kurzlisten ohne
@@ -697,6 +730,7 @@ export type ScreenSlots = {
697
730
  export type ScreenDefinition =
698
731
  | EntityListScreenDefinition
699
732
  | ProjectionListScreenDefinition
733
+ | ProjectionDetailScreenDefinition
700
734
  | DashboardScreenDefinition
701
735
  | EntityEditScreenDefinition
702
736
  | ActionFormScreenDefinition
@@ -176,27 +176,9 @@ function mergeDispatcherOptions(
176
176
  return { ...(caller ?? {}), jobRunner };
177
177
  }
178
178
 
179
- // Resolve the observability provider ONCE per entrypoint boot, before any
180
- // job-runner is built. `buildServer` (api/server.ts) independently falls
181
- // back to `options.observability ?? createNoopProvider()` and merges
182
- // tracer/meter into a NEW context object it builds internally — but the
183
- // job-runner is constructed from the caller's RAW `options.context`
184
- // *before* buildServer ever runs, so it never saw that merge. Its
185
- // queue-depth poller only starts `if (context.meter)` with no fallback
186
- // (unlike the tracer, which has its own `getFallbackTracer()`), so an
187
- // unset meter silently skipped the poller forever — `/metrics` showed the
188
- // `kumiko_job_queue_depth` HELP/TYPE header but never a single data line
189
- // (#1046). Resolving once here and threading it into the job-runner's
190
- // context fixes the gap. This resolved instance is ONLY used for the
191
- // job-runner — `buildApiServer`/`buildWorkerServer` still pass the
192
- // caller's original `options.observability` through unchanged (not this
193
- // resolved value), because `buildServer` also derives `shouldWrapRedis`
194
- // from whether `options.observability` was explicitly set — forcing it to
195
- // an always-defined value here would silently switch every app's Redis
196
- // client onto the tracer-wrapping Proxy path even with no provider
197
- // configured. buildServer's own independent Noop fallback is harmless in
198
- // that case: nothing scrapes `/metrics` without a real provider, so a
199
- // second, disconnected Noop meter costs nothing.
179
+ // Resolved once for the job-runner's context only buildServer still gets
180
+ // the caller's original `options.observability` (unresolved) since it
181
+ // derives `shouldWrapRedis` from whether that was explicitly set.
200
182
  function resolveObservability(
201
183
  observability: ObservabilityProvider | undefined,
202
184
  ): ObservabilityProvider {
@@ -8,6 +8,7 @@ import type {
8
8
  EntityListScreenDefinition,
9
9
  FeatureDefinition,
10
10
  NavDefinition,
11
+ ProjectionDetailScreenDefinition,
11
12
  ProjectionListScreenDefinition,
12
13
  RowAction,
13
14
  ScreenDefinition,
@@ -22,6 +23,9 @@ export const ACTION_FORM_ENTITY = "__action-form__";
22
23
  /** Pseudo-entity for configEdit field labels (renderer config-edit-shim). */
23
24
  export const CONFIG_EDIT_ENTITY = "__config-edit__";
24
25
 
26
+ /** Pseudo-entity for projectionDetail field labels (renderer projection-detail-shim). */
27
+ export const PROJECTION_DETAIL_ENTITY = "__projection-detail__";
28
+
25
29
  export function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string {
26
30
  return `${featureName}:entity:${entityName}:field:${fieldName}`;
27
31
  }
@@ -201,6 +205,20 @@ export function requiredKeysFromScreen(
201
205
  }
202
206
  break;
203
207
  }
208
+ case "projectionDetail": {
209
+ const detail = screen as ProjectionDetailScreenDefinition;
210
+ for (const section of detail.layout.sections) {
211
+ if (isExtensionEditSection(section)) continue; // rejected at boot, unreachable here
212
+ pushKey(out, section.title);
213
+ for (const f of section.fields) {
214
+ const fieldName = editFieldName(f);
215
+ const override = detail.fieldLabels?.[fieldName];
216
+ if (override !== undefined) pushKey(out, override);
217
+ else out.add(fieldLabelKey(featureName, PROJECTION_DETAIL_ENTITY, fieldName));
218
+ }
219
+ }
220
+ break;
221
+ }
204
222
  case "custom":
205
223
  break;
206
224
  }
@@ -0,0 +1,166 @@
1
+ // Issue #1038 — ctx.tryAppendEvent (savepoint-scoped append).
2
+ //
3
+ // Claims pinned here:
4
+ // 1. Happy path: first append on a fresh aggregate succeeds, returns
5
+ // { ok: true, event } with the stored event.
6
+ // 2. Non-poisoning: when tryAppendEvent loses a version-conflict race, the
7
+ // rest of the handler's transaction still commits (a write AFTER the
8
+ // call in the same handler is not rolled back) — this is the whole
9
+ // reason the primitive exists over a bare try/catch around
10
+ // unsafeAppendEvent (Bun.SQL/postgres.js abort the entire begin() on
11
+ // an uncaught statement error, SQLSTATE 25P02, even if the JS error is
12
+ // caught).
13
+ //
14
+ // The conflict branch can't be forced deterministically through the public
15
+ // interface — appendDomainEventCore reads the stream version fresh inside
16
+ // itself, so a lone sequential append never conflicts; only two concurrent
17
+ // transactions racing the same version produce the 23505. So claim 2 is
18
+ // tested as an invariant over concurrent writers (looped per house
19
+ // convention for probabilistic tests) rather than by asserting the conflict
20
+ // branch was hit on a specific call — depending on interleaving, Postgres
21
+ // may pick either writer as the winner.
22
+
23
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
24
+ import { z } from "zod";
25
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
26
+ import { asRawClient, selectMany } from "../../db/query";
27
+ import { buildEntityTable } from "../../db/table-builder";
28
+ import { createEntity, createTextField, defineFeature } from "../../engine";
29
+ import { loadAggregate } from "../../event-store";
30
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
31
+
32
+ const TAE_AGGREGATE_TYPE = "taeDoc";
33
+
34
+ // Marker entity — a plain CRUD write issued right after ctx.tryAppendEvent
35
+ // in the same handler/transaction. Its row landing (regardless of whether
36
+ // the append won or lost the race) is the non-poisoning proof.
37
+ const markerEntity = createEntity({
38
+ table: "tae_markers",
39
+ fields: {
40
+ note: createTextField({ required: true }),
41
+ },
42
+ });
43
+ const markerTable = buildEntityTable("taeMarker", markerEntity);
44
+ const markerExecutor = createEventStoreExecutor(markerTable, markerEntity, {
45
+ entityName: "taeMarker",
46
+ });
47
+
48
+ let stack: TestStack;
49
+ const admin = TestUsers.admin;
50
+
51
+ const tryAppendFeature = defineFeature("tae", (r) => {
52
+ r.entity("taeMarker", markerEntity);
53
+ const appended = r.defineEvent("appended", z.object({ note: z.string() }));
54
+
55
+ r.writeHandler(
56
+ "doc:try-append",
57
+ z.object({ aggregateId: z.uuid(), note: z.string() }),
58
+ async (event, ctx) => {
59
+ const result = await ctx.tryAppendEvent({
60
+ aggregateId: event.payload.aggregateId,
61
+ aggregateType: TAE_AGGREGATE_TYPE,
62
+ type: appended.name,
63
+ payload: { note: event.payload.note },
64
+ });
65
+ // Runs unconditionally — if tryAppendEvent's savepoint failed to
66
+ // confine the VersionConflictError, this write would fail too
67
+ // because the whole tx aborted (25P02).
68
+ const markerCreated = await markerExecutor.create(
69
+ { note: event.payload.note },
70
+ event.user,
71
+ ctx.db,
72
+ );
73
+ if (!markerCreated.isSuccess) return markerCreated;
74
+ return {
75
+ isSuccess: true as const,
76
+ data: {
77
+ ok: result.ok,
78
+ version: result.ok ? result.event.version : null,
79
+ },
80
+ };
81
+ },
82
+ { access: { roles: ["Admin"] } },
83
+ );
84
+ });
85
+
86
+ beforeAll(async () => {
87
+ stack = await setupTestStack({ features: [tryAppendFeature] });
88
+ await unsafeCreateEntityTable(stack.db, markerEntity, "taeMarker");
89
+ });
90
+
91
+ afterAll(async () => {
92
+ await stack.cleanup();
93
+ });
94
+
95
+ beforeEach(async () => {
96
+ await asRawClient(stack.db).unsafe(
97
+ `TRUNCATE kumiko_events, "${markerTable.tableName}" RESTART IDENTITY CASCADE`,
98
+ );
99
+ });
100
+
101
+ describe("Issue #1038 — ctx.tryAppendEvent", () => {
102
+ test("happy path: first append on a fresh aggregate returns { ok: true, event }", async () => {
103
+ const aggregateId = crypto.randomUUID();
104
+
105
+ const result = await stack.http.writeOk<{
106
+ ok: boolean;
107
+ version: number | null;
108
+ }>("tae:write:doc:try-append", { aggregateId, note: "hello" }, admin);
109
+
110
+ expect(result.ok).toBe(true);
111
+ expect(result.version).toBe(1);
112
+
113
+ const events = await loadAggregate(stack.db, aggregateId, admin.tenantId);
114
+ expect(events).toHaveLength(1);
115
+ expect(events[0]?.payload).toEqual({ note: "hello" });
116
+
117
+ const markers = await selectMany(stack.db, markerTable);
118
+ expect(markers).toHaveLength(1);
119
+ });
120
+
121
+ // Probabilistic — looped per house convention (mehrere Durchläufe, z.B.
122
+ // 20x) since a genuine 23505 race depends on transaction-timing
123
+ // interleaving that a single run can't force deterministically.
124
+ const ITERATIONS = 20;
125
+ const CONCURRENCY = 3;
126
+
127
+ test(`non-poisoning invariant holds across ${ITERATIONS} concurrent-writer rounds`, async () => {
128
+ for (let i = 0; i < ITERATIONS; i++) {
129
+ const aggregateId = crypto.randomUUID();
130
+
131
+ const responses = await Promise.all(
132
+ Array.from({ length: CONCURRENCY }, (_, n) =>
133
+ stack.http.write("tae:write:doc:try-append", { aggregateId, note: `writer-${n}` }, admin),
134
+ ),
135
+ );
136
+
137
+ // No writer's transaction was poisoned by a losing append — every
138
+ // request completes as a normal 200, never a 500 from an aborted tx.
139
+ let okCount = 0;
140
+ for (const res of responses) {
141
+ expect(res.status).toBe(200);
142
+ const body = (await res.json()) as { isSuccess: boolean; data: { ok: boolean } };
143
+ expect(body.isSuccess).toBe(true);
144
+ if (body.data.ok) okCount++;
145
+ }
146
+
147
+ // One event per successful append — interleaving-independent
148
+ // invariant. Serialized (non-racing) writers can legitimately all
149
+ // succeed at different versions; only actual conflicts return
150
+ // { ok: false }, so events.length must track okCount, not a fixed 1.
151
+ const events = await loadAggregate(stack.db, aggregateId, admin.tenantId);
152
+ expect(events).toHaveLength(okCount);
153
+ expect(okCount).toBeGreaterThanOrEqual(1);
154
+
155
+ // Every writer's marker insert landed regardless of win/lose — the
156
+ // savepoint confined the losers' VersionConflictError instead of
157
+ // aborting their whole transaction.
158
+ const markers = await selectMany(stack.db, markerTable);
159
+ expect(markers).toHaveLength(CONCURRENCY);
160
+
161
+ await asRawClient(stack.db).unsafe(
162
+ `TRUNCATE kumiko_events, "${markerTable.tableName}" RESTART IDENTITY CASCADE`,
163
+ );
164
+ }
165
+ });
166
+ });
@@ -1,6 +1,6 @@
1
1
  import { requestContext } from "../api/request-context";
2
- import type { DbConnection, DbTx } from "../db/connection";
3
- import { selectMany } from "../db/query";
2
+ import type { DbConnection, DbRunner, DbTx } from "../db/connection";
3
+ import { runInSavepoint, selectMany } from "../db/query";
4
4
  import type { buildEntityTable } from "../db/table-builder";
5
5
  import { createTenantDb } from "../db/tenant-db";
6
6
  import type { defineTransitions } from "../engine/state-machine";
@@ -30,6 +30,7 @@ import {
30
30
  isStreamArchived,
31
31
  restoreStream as restoreStreamHelper,
32
32
  } from "../event-store/archive";
33
+ import { VersionConflictError as EventStoreVersionConflictError } from "../event-store/errors";
33
34
  import {
34
35
  getStreamVersion,
35
36
  loadAggregate,
@@ -229,6 +230,42 @@ export function buildHandlerContext(
229
230
  unsafeAppendEvent: async (args: AppendEventArgs) => {
230
231
  await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
231
232
  },
233
+ // Savepoint-scoped append: catches a losing writer's VersionConflictError
234
+ // without poisoning the rest of the handler's transaction. Bun.SQL/
235
+ // postgres.js abort the WHOLE begin() on an uncaught statement error
236
+ // (SQLSTATE 25P02) even if the JS error is caught — runInSavepoint
237
+ // wraps the append in a real SAVEPOINT so only that nested scope rolls
238
+ // back on conflict, and subsequent statements in the outer tx (e.g. a
239
+ // dedup-anchor insert) still succeed. Use when a handler must react to
240
+ // losing a concurrent-append race instead of failing the whole write.
241
+ tryAppendEvent: async (args: AppendEventArgs) => {
242
+ if (!tx) {
243
+ throw new InternalError({
244
+ message: `ctx.tryAppendEvent("${args.type}") requires an active transaction — no tx is threaded through this call.`,
245
+ });
246
+ }
247
+ try {
248
+ const event = await runInSavepoint(tx, (sp) =>
249
+ appendDomainEventCore(
250
+ {
251
+ registry,
252
+ db: sp as DbRunner,
253
+ tenantId: user.tenantId,
254
+ userId: String(user.id),
255
+ callSiteLabel: "ctx.tryAppendEvent",
256
+ callerFeature: registry.getHandlerFeature(type),
257
+ },
258
+ args,
259
+ ),
260
+ );
261
+ return { ok: true as const, event };
262
+ } catch (e) {
263
+ if (e instanceof EventStoreVersionConflictError) {
264
+ return { ok: false as const, conflict: e };
265
+ }
266
+ throw e;
267
+ }
268
+ },
232
269
  fetchForWriting: async (args: FetchForWritingArgs): Promise<AggregateStreamHandle> => {
233
270
  const dbSource = resolveDbSource(ctx, tx);
234
271
  if (!dbSource) {
@@ -483,12 +520,8 @@ export function buildHandlerContext(
483
520
  // Propagate the feature-toggle resolver so the lifecycle pipeline,
484
521
  // MSP runner, and ctx.hasFeature all pull from the same source.
485
522
  ...(effectiveFeatures && { effectiveFeatures }),
486
- // Symmetric with job-runner.ts's jobContext: a manual write handler
487
- // reaches `ctx.jobRunner.dispatch(...)` the same way a follow-up job
488
- // does (bundled jobs feature's trigger.write.ts is the canonical
489
- // caller). Was never actually wired here — DispatchContext.jobRunner
490
- // only fed the internal afterCommit auto-trigger hook in
491
- // executeWriteInner, never the handler-facing ctx (#983).
523
+ // Lets write handlers call ctx.jobRunner.dispatch(...) directly, same
524
+ // as a follow-up job would (test-stack.ts wires the matching runner).
492
525
  ...(jobRunner && { jobRunner }),
493
526
  // ctx.user als Convenience-Alias auf event.user. Der typisch-
494
527
  // intuitive Pfad „der Context kennt seinen User" — ohne den
@@ -1,24 +1,7 @@
1
- // Regression test for #983 a write handler calling `ctx["jobRunner"]
2
- // .dispatch(...)` directly (the documented pattern, e.g. bundled jobs
3
- // feature's trigger.write.ts) threw `TypeError: undefined is not an
4
- // object` under both `setupTestStack` and `runDevApp`, because neither
5
- // built a JobRunner nor merged one into the dispatcher's context.
6
- //
7
- // Two things had to be fixed together (see dispatch-shared.ts and
8
- // test-stack.ts for the respective commits):
9
- // 1. `setupTestStack({ jobs: {...} })` now builds a real JobRunner and
10
- // merges it into dispatcherOptions — this test's `jobs: {}` opt-in.
11
- // 2. `buildHandlerContext` (packages/framework/src/pipeline/dispatch-
12
- // shared.ts) never spread `DispatchContext.jobRunner` into the
13
- // handler-facing ctx at all — a gap that predates this issue and
14
- // affects the prod entrypoint too, not just dev/test. Fixed there,
15
- // not band-aided into `context:` here — otherwise test/dev would
16
- // pass while prod's `ctx.jobRunner` stayed undefined.
17
- //
18
- // This test drives the manual-dispatch repro from the issue, not the
19
- // auto-trigger path (already covered by entrypoint-job-wiring.integration
20
- // .test.ts) — the two paths read from different context slots and a fix
21
- // for one doesn't guarantee the other.
1
+ // Drives the manual `ctx.jobRunner.dispatch(...)` path a write handler
2
+ // uses directly — distinct from the auto-trigger path covered by
3
+ // entrypoint-job-wiring.integration.test.ts, which reads a different
4
+ // context slot and isn't guaranteed by this test passing.
22
5
 
23
6
  import { afterEach, describe, expect, test } from "bun:test";
24
7
  import { z } from "zod";
@@ -124,19 +124,14 @@ export type TestStackOptions = {
124
124
  sseBroker: import("../api/sse-broker").SseBroker;
125
125
  redis: import("ioredis").default;
126
126
  }) => import("../api/server").ServerOptions["anonymousAccess"]);
127
- /** Opt-in JobRunner wired into ctx.jobRunner (write handlers'
128
- * `ctx.jobRunner.dispatch(...)`) and merged into dispatcherOptions so
129
- * event-triggered jobs enqueue on commit — mirrors the prod entrypoint's
130
- * `buildJobRunnerWithHook`. No-ops when no `r.job(...)` is registered
131
- * anywhere in the mounted features the bundled `createJobsFeature()`
132
- * (operator UI) is NOT required, matching prod's unconditional build.
133
- * Default `undefined` enqueuer-only, holds queue-clients for both
134
- * lanes so `ctx.jobRunner.dispatch(...)` routes correctly but starts no
135
- * local consumer/cron-scheduler (avoids double-running `runOnBoot`/cron
136
- * jobs when the caller, e.g. runDevApp, already runs its own consumer
137
- * for that lane). Pass `consumerLane` only when this IS the sole
138
- * consumer (e.g. a test that wants the dispatched job to actually
139
- * execute without a separate runner). */
127
+ /** Opt-in JobRunner wired into ctx.jobRunner and merged into
128
+ * dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
129
+ * the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
130
+ * always builds one), this only builds a runner when `registry.getAllJobs()`
131
+ * is non-empty a deliberate test-stack-only shortcut, not parity.
132
+ * Default `undefined`. Pass `consumerLane` only when this test IS the
133
+ * sole consumer (otherwise enqueuer-only, avoiding double-running
134
+ * `runOnBoot`/cron jobs against a caller-owned consumer). */
140
135
  jobs?: {
141
136
  consumerLane?: JobRunIn;
142
137
  queueNamePrefix?: string;
@@ -115,6 +115,8 @@ export function generateE2ESpec(
115
115
  // projectionList: query-getrieben, Author-spezifische Projection —
116
116
  // kein generischer CRUD-Spec ableitbar (wie actionForm/configEdit).
117
117
  if (screen.type === "projectionList") continue;
118
+ // projectionDetail: dito — read-only single-row inspector, kein CRUD-Zustand.
119
+ if (screen.type === "projectionDetail") continue;
118
120
  // dashboard: panel-getrieben (Stat/Chart/List-Queries) — dito, keine
119
121
  // generische CRUD-Annahme möglich.
120
122
  if (screen.type === "dashboard") continue;
@@ -46,6 +46,7 @@ export function bridgeStub(opts?: {
46
46
  | "writeAs"
47
47
  | "appendEvent"
48
48
  | "unsafeAppendEvent"
49
+ | "tryAppendEvent"
49
50
  | "fetchForWriting"
50
51
  | "loadAggregate"
51
52
  | "archiveStream"
@@ -92,6 +93,7 @@ export function bridgeStub(opts?: {
92
93
  unsafeAppendEvent: notAvailable("unsafeAppendEvent") as unknown as (
93
94
  args: AppendEventArgs,
94
95
  ) => Promise<void>,
96
+ tryAppendEvent: notAvailable("tryAppendEvent") as unknown as HandlerContext["tryAppendEvent"],
95
97
  fetchForWriting: notAvailable("fetchForWriting") as unknown as (
96
98
  args: FetchForWritingArgs,
97
99
  ) => ReturnType<HandlerContext["fetchForWriting"]>,
@@ -67,6 +67,7 @@ export type {
67
67
  FieldRenderer,
68
68
  ListColumnSpec,
69
69
  PlatformComponent,
70
+ ProjectionDetailScreenDefinition,
70
71
  ProjectionListScreenDefinition,
71
72
  RowAction,
72
73
  RowActionNavigate,