@cosmicdrift/kumiko-framework 0.149.2 → 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 +2 -2
- package/src/engine/__tests__/screen.test.ts +75 -0
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +42 -0
- package/src/engine/boot-validator/config-deps.ts +5 -4
- package/src/engine/boot-validator/screens.ts +33 -0
- package/src/engine/config-helpers.ts +11 -0
- package/src/engine/feature-manifest.ts +2 -1
- package/src/engine/index.ts +1 -0
- package/src/engine/types/handlers.ts +21 -0
- package/src/engine/types/index.ts +3 -0
- package/src/engine/types/screen.ts +34 -0
- package/src/entrypoint/index.ts +3 -21
- package/src/i18n/required-surface-keys.ts +18 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
- package/src/pipeline/dispatch-shared.ts +41 -8
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +4 -21
- package/src/stack/test-stack.ts +8 -13
- package/src/testing/e2e-generator.ts +2 -0
- package/src/testing/handler-context.ts +2 -0
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
|
83
|
+
if (isEncryptedAtRest(keyDef)) {
|
|
83
84
|
throw new Error(
|
|
84
|
-
`[Feature ${feature.name}] Config key "${keyName}" has both encrypted=
|
|
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
|
|
132
|
+
if (isEncryptedAtRest(keyDef)) {
|
|
132
133
|
throw new Error(
|
|
133
|
-
`[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but encrypted=
|
|
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
|
|
110
|
+
encrypted: isEncryptedAtRest(def),
|
|
110
111
|
computed: def.computed !== undefined,
|
|
111
112
|
options: def.options ?? null,
|
|
112
113
|
bounds: def.bounds ?? null,
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -176,27 +176,9 @@ function mergeDispatcherOptions(
|
|
|
176
176
|
return { ...(caller ?? {}), jobRunner };
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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
|
-
//
|
|
487
|
-
//
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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";
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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"]>,
|