@cosmicdrift/kumiko-framework 0.194.0 → 0.196.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 +3 -3
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +73 -6
- package/src/derivatives/__tests__/derivatives-context.test.ts +9 -2
- package/src/derivatives/derivatives-context.ts +7 -7
- package/src/engine/boot-validator/screens.ts +5 -13
- package/src/observability/__tests__/metric-validator.test.ts +10 -2
- package/src/observability/metric-validator.ts +4 -3
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +64 -0
- package/src/pipeline/dispatch-shared.ts +12 -9
- 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.196.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>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.196.0",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.196.0",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
8
8
|
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { defineFeature, EXT_DERIVATIVE_RENDERER } from "../../engine";
|
|
11
|
-
import { InternalError, writeFailure } from "../../errors";
|
|
11
|
+
import { InternalError, NotFoundError, writeFailure } from "../../errors";
|
|
12
12
|
import { createFilesFeature } from "../../files/feature";
|
|
13
13
|
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
14
14
|
import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
|
|
@@ -41,6 +41,37 @@ const derivativesTestFeature = defineFeature("derivativestest", (r) => {
|
|
|
41
41
|
{ access: { openToAll: true } },
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
+
// Catches the error INSIDE the handler (not via HTTP serialization) so the
|
|
45
|
+
// test can assert `instanceof` on the concrete error class — the wire
|
|
46
|
+
// response only carries the serialized WireErrorInfo, which loses that.
|
|
47
|
+
r.writeHandler(
|
|
48
|
+
"probe-typed-error",
|
|
49
|
+
z.object({ fileRefId: z.string() }),
|
|
50
|
+
async (event, ctx) => {
|
|
51
|
+
if (!ctx.derivatives) {
|
|
52
|
+
return writeFailure(
|
|
53
|
+
new InternalError({ message: "no ctx.derivatives on write-handler ctx" }),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await ctx.derivatives.variant(event.payload.fileRefId, { maxEdge: 100 }, "thumb");
|
|
58
|
+
return { isSuccess: true as const, data: { threw: false } };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return {
|
|
61
|
+
isSuccess: true as const,
|
|
62
|
+
data: {
|
|
63
|
+
threw: true,
|
|
64
|
+
isNotFoundError: err instanceof NotFoundError,
|
|
65
|
+
notFoundHttpStatus: err instanceof NotFoundError ? err.httpStatus : undefined,
|
|
66
|
+
isInternalError: err instanceof InternalError,
|
|
67
|
+
internalHttpStatus: err instanceof InternalError ? err.httpStatus : undefined,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{ access: { openToAll: true } },
|
|
73
|
+
);
|
|
74
|
+
|
|
44
75
|
r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload, ctx) => {
|
|
45
76
|
const fileRefId = (payload as { fileRefId: string }).fileRefId;
|
|
46
77
|
if (!ctx.derivatives) {
|
|
@@ -70,10 +101,13 @@ afterAll(async () => {
|
|
|
70
101
|
await stack.cleanup();
|
|
71
102
|
});
|
|
72
103
|
|
|
73
|
-
async function uploadFile(
|
|
104
|
+
async function uploadFile(
|
|
105
|
+
asUser = user,
|
|
106
|
+
file = new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }),
|
|
107
|
+
): Promise<string> {
|
|
74
108
|
const token = await stack.jwt.sign(asUser);
|
|
75
109
|
const fd = new FormData();
|
|
76
|
-
fd.append("file",
|
|
110
|
+
fd.append("file", file);
|
|
77
111
|
const { body, contentType } = await buildMultipartBody(fd);
|
|
78
112
|
const res = await stack.app.request("/api/files", {
|
|
79
113
|
method: "POST",
|
|
@@ -129,7 +163,7 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
|
|
|
129
163
|
{ fileRefId: "00000000-0000-4000-8000-999999999999" },
|
|
130
164
|
user,
|
|
131
165
|
);
|
|
132
|
-
expect(err.httpStatus).
|
|
166
|
+
expect(err.httpStatus).toBe(404);
|
|
133
167
|
});
|
|
134
168
|
|
|
135
169
|
test("a soft-deleted fileRef throws", async () => {
|
|
@@ -146,7 +180,7 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
|
|
|
146
180
|
{ fileRefId: fileId },
|
|
147
181
|
user,
|
|
148
182
|
);
|
|
149
|
-
expect(err.httpStatus).
|
|
183
|
+
expect(err.httpStatus).toBe(404);
|
|
150
184
|
});
|
|
151
185
|
|
|
152
186
|
test("a fileRef uploaded under a different tenant is not resolvable", async () => {
|
|
@@ -157,6 +191,39 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
|
|
|
157
191
|
{ fileRefId: foreignFileId },
|
|
158
192
|
user,
|
|
159
193
|
);
|
|
160
|
-
expect(err.httpStatus).
|
|
194
|
+
expect(err.httpStatus).toBe(404);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("ctx.derivatives — typed errors from variant()", () => {
|
|
199
|
+
test("an unknown fileRefId throws NotFoundError with httpStatus 404", async () => {
|
|
200
|
+
const result = await stack.http.writeOk<{
|
|
201
|
+
threw: boolean;
|
|
202
|
+
isNotFoundError: boolean;
|
|
203
|
+
notFoundHttpStatus?: number;
|
|
204
|
+
}>(
|
|
205
|
+
"derivativestest:write:probe-typed-error",
|
|
206
|
+
{ fileRefId: "00000000-0000-4000-8000-999999999999" },
|
|
207
|
+
user,
|
|
208
|
+
);
|
|
209
|
+
expect(result.threw).toBe(true);
|
|
210
|
+
expect(result.isNotFoundError).toBe(true);
|
|
211
|
+
expect(result.notFoundHttpStatus).toBe(404);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("no renderer registered for the mimeType throws InternalError with httpStatus 500", async () => {
|
|
215
|
+
const fileId = await uploadFile(
|
|
216
|
+
user,
|
|
217
|
+
new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const result = await stack.http.writeOk<{
|
|
221
|
+
threw: boolean;
|
|
222
|
+
isInternalError: boolean;
|
|
223
|
+
internalHttpStatus?: number;
|
|
224
|
+
}>("derivativestest:write:probe-typed-error", { fileRefId: fileId }, user);
|
|
225
|
+
expect(result.threw).toBe(true);
|
|
226
|
+
expect(result.isInternalError).toBe(true);
|
|
227
|
+
expect(result.internalHttpStatus).toBe(500);
|
|
161
228
|
});
|
|
162
229
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
3
3
|
import type { Registry } from "../../engine/types";
|
|
4
|
+
import { InternalError } from "../../errors";
|
|
4
5
|
import { createFileContext } from "../../files/file-handle";
|
|
5
6
|
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
6
7
|
import { createDerivativesContext, resolveRenderer } from "../derivatives-context";
|
|
@@ -133,7 +134,7 @@ describe("createDerivativesContext — variant()", () => {
|
|
|
133
134
|
expect(second.storageKey).not.toBe(first.storageKey);
|
|
134
135
|
});
|
|
135
136
|
|
|
136
|
-
test("no renderer registered for the mimeType throws, naming the known patterns", async () => {
|
|
137
|
+
test("no renderer registered for the mimeType throws InternalError, naming the known patterns in details", async () => {
|
|
137
138
|
const provider = createInMemoryFileProvider();
|
|
138
139
|
await provider.write("tenant/doc.pdf", new Uint8Array([1]));
|
|
139
140
|
const files = createFileContext(() => Promise.resolve(provider));
|
|
@@ -141,7 +142,13 @@ describe("createDerivativesContext — variant()", () => {
|
|
|
141
142
|
const db = fakeDbWithFileRef({ storageKey: "tenant/doc.pdf", mimeType: "application/pdf" });
|
|
142
143
|
const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
|
|
143
144
|
|
|
144
|
-
|
|
145
|
+
// The renderer list moved out of the client-visible `message` into
|
|
146
|
+
// `details` — InternalError's serialize() drops `details` from the wire
|
|
147
|
+
// response, so it must not be the only place a caller can find it.
|
|
148
|
+
const err = await ctx.variant(FILE_REF_ID, {}, "thumb").catch((e) => e);
|
|
149
|
+
expect(err).toBeInstanceOf(InternalError);
|
|
150
|
+
expect((err as InternalError).httpStatus).toBe(500);
|
|
151
|
+
expect((err as InternalError).details).toEqual({ knownRenderers: "image/*" });
|
|
145
152
|
});
|
|
146
153
|
|
|
147
154
|
test("mimeType is consistent across a fresh render and a cache hit for the same spec", async () => {
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
import { type AnyDb, fetchOne } from "../bun-db/query";
|
|
7
7
|
import { EXT_DERIVATIVE_RENDERER } from "../engine/extension-names";
|
|
8
8
|
import type { Registry, TenantId } from "../engine/types";
|
|
9
|
+
import { InternalError, NotFoundError } from "../errors";
|
|
9
10
|
import type { FileContext } from "../files/file-handle";
|
|
10
11
|
import { fileRefsTable } from "../files/file-ref-table";
|
|
11
12
|
import { assertSafeStorageKey } from "../files/types";
|
|
@@ -94,12 +95,10 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
|
|
|
94
95
|
isDeleted: false,
|
|
95
96
|
});
|
|
96
97
|
if (!row) {
|
|
97
|
-
throw new
|
|
98
|
+
throw new NotFoundError("fileRef", fileRefId);
|
|
98
99
|
}
|
|
99
100
|
if (!isFileRefRow(row)) {
|
|
100
|
-
throw new
|
|
101
|
-
`derivatives.variant: fileRef "${fileRefId}" is missing storageKey/mimeType`,
|
|
102
|
-
);
|
|
101
|
+
throw new NotFoundError("fileRef", fileRefId);
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
const renderer = resolveRenderer(deps.registry, row.mimeType);
|
|
@@ -109,9 +108,10 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
|
|
|
109
108
|
.getExtensionUsages(EXT_DERIVATIVE_RENDERER)
|
|
110
109
|
.map((u) => u.entityName)
|
|
111
110
|
.join(", ") || "<none>";
|
|
112
|
-
throw new
|
|
113
|
-
`derivatives.variant: no renderer registered for mimeType "${row.mimeType}"
|
|
114
|
-
|
|
111
|
+
throw new InternalError({
|
|
112
|
+
message: `derivatives.variant: no renderer registered for mimeType "${row.mimeType}".`,
|
|
113
|
+
details: { knownRenderers: known },
|
|
114
|
+
});
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
const src = deps.files.ref(row.storageKey);
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// into validateColumnRendererForm, splitting them would create a
|
|
5
5
|
// same-folder require cycle.
|
|
6
6
|
|
|
7
|
+
import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
7
8
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
8
9
|
import { isValidQn, qualifyEntityName } from "../qualified-name";
|
|
9
10
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
10
11
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
11
|
-
import type { EntityDefinition, FeatureDefinition
|
|
12
|
+
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
12
13
|
import type {
|
|
13
14
|
DashboardCustomPanel,
|
|
14
15
|
DashboardFilterDefinition,
|
|
@@ -24,16 +25,6 @@ import type {
|
|
|
24
25
|
ToolbarAction,
|
|
25
26
|
} from "../types/screen";
|
|
26
27
|
|
|
27
|
-
// Mirrors FIELD_TYPES_WITHOUT_WIDGET in packages/renderer/src/app/form-schema.ts.
|
|
28
|
-
// Can't import it directly — renderer depends on framework, not the reverse.
|
|
29
|
-
// Keep both lists in sync when a field type gains or loses an auto-wired widget.
|
|
30
|
-
const NO_WIDGET_FIELD_TYPES: ReadonlySet<FieldDefinition["type"]> = new Set([
|
|
31
|
-
"jsonb",
|
|
32
|
-
"embedded",
|
|
33
|
-
"files",
|
|
34
|
-
"images",
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
28
|
// A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
|
|
38
29
|
// entityEdit path (#1925) — a required field the user can never fill would
|
|
39
30
|
// block every save. Only the statically-resolvable case is caught here: a
|
|
@@ -48,7 +39,7 @@ function validateNoWidgetRequiredField(
|
|
|
48
39
|
): void {
|
|
49
40
|
const fieldDef = entityDef.fields[fieldSpec.field];
|
|
50
41
|
// skip: field doesn't exist or its type already has a widget — nothing to validate.
|
|
51
|
-
if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.
|
|
42
|
+
if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.includes(fieldDef.type)) return;
|
|
52
43
|
// Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
|
|
53
44
|
// grid widget (#1838) — only plain (non-list) embedded has no widget.
|
|
54
45
|
const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
|
|
@@ -124,7 +115,7 @@ function validateRowActionNavigateParams(
|
|
|
124
115
|
function validateWizardLayout(
|
|
125
116
|
featureName: string,
|
|
126
117
|
screenId: string,
|
|
127
|
-
screenType: "entityEdit" | "actionForm",
|
|
118
|
+
screenType: "entityEdit" | "actionForm" | "configEdit",
|
|
128
119
|
layout: EditLayout,
|
|
129
120
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
130
121
|
): void {
|
|
@@ -458,6 +449,7 @@ export function validateScreens(
|
|
|
458
449
|
}
|
|
459
450
|
}
|
|
460
451
|
}
|
|
452
|
+
validateWizardLayout(feature.name, screenId, "configEdit", screen.layout, featureMap);
|
|
461
453
|
// configKeys: jeder fieldName muss einen Mapping-Eintrag haben,
|
|
462
454
|
// jeder qualifizierte Key muss in der Registry existieren.
|
|
463
455
|
for (const fname of fieldNames) {
|
|
@@ -66,8 +66,16 @@ describe("buildMetricName", () => {
|
|
|
66
66
|
expect(buildMetricName("orders", "created_total")).toBe("kumiko_orders_created_total");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it("rejects
|
|
70
|
-
|
|
69
|
+
it("rejects a feature name that stays invalid after kebab-normalization", () => {
|
|
70
|
+
// A single leading capital ("Orders") now normalizes cleanly via toKebab
|
|
71
|
+
// — this must still reject a space, which toKebab doesn't touch.
|
|
72
|
+
expect(() => buildMetricName("orders team", "created_total")).toThrow(/snake_case/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("normalizes camelCase feature names the same as their kebab-case equivalent", () => {
|
|
76
|
+
expect(buildMetricName("aiFoundation", "created_total")).toBe(
|
|
77
|
+
buildMetricName("ai-foundation", "created_total"),
|
|
78
|
+
);
|
|
71
79
|
});
|
|
72
80
|
});
|
|
73
81
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toKebab } from "../engine/qualified-name";
|
|
1
2
|
import { assertUnreachable } from "../utils";
|
|
2
3
|
import type { MetricType } from "./types";
|
|
3
4
|
|
|
@@ -74,11 +75,11 @@ export function validateMetricName(name: string, type: MetricType): void {
|
|
|
74
75
|
// path (registry-ingest.ts) and the read path (ctx.metrics / ctx.metricsFor),
|
|
75
76
|
// instead of the kebab form being rejected outright (framework#1844).
|
|
76
77
|
export function buildMetricName(featureName: string, shortName: string): string {
|
|
77
|
-
const normalizedFeatureName = featureName.replace(/-/g, "_");
|
|
78
|
+
const normalizedFeatureName = toKebab(featureName).replace(/-/g, "_");
|
|
78
79
|
if (!SNAKE_CASE.test(normalizedFeatureName)) {
|
|
79
80
|
throw new Error(
|
|
80
|
-
`[Kumiko Observability] Feature name "${featureName}" must be kebab-case
|
|
81
|
-
`(a-z, 0-9, "-" or "_").`,
|
|
81
|
+
`[Kumiko Observability] Feature name "${featureName}" must be kebab-case, camelCase, ` +
|
|
82
|
+
`or snake_case (a-z, 0-9, "-" or "_").`,
|
|
82
83
|
);
|
|
83
84
|
}
|
|
84
85
|
return `kumiko_${normalizedFeatureName}_${shortName}`;
|
|
@@ -149,6 +149,39 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
149
149
|
{ access: { roles: ["Admin"] } },
|
|
150
150
|
);
|
|
151
151
|
|
|
152
|
+
// Records whether ctx.db threw (should, once the request signal is
|
|
153
|
+
// aborted) and whether the ctx.dbOutsideTransaction insert still landed
|
|
154
|
+
// (should — durability writes must survive a client disconnect).
|
|
155
|
+
//
|
|
156
|
+
// The throwing side reads via ctx.db.selectMany, not the event-store
|
|
157
|
+
// executor's create() — the latter writes through db.raw (bypassing
|
|
158
|
+
// TenantDb's withDbSpan/signal check entirely), so it wouldn't exercise
|
|
159
|
+
// the signal wiring this test is meant to prove. insertOne isn't an
|
|
160
|
+
// option either: bagTable is executor-managed (WritableTable rejects its
|
|
161
|
+
// EXECUTOR_ONLY brand) — direct writes would drift it past its event
|
|
162
|
+
// stream. selectMany has no such restriction (reads keep the plain
|
|
163
|
+
// SchemaTable param) and still goes through the same signal check.
|
|
164
|
+
r.writeHandler(
|
|
165
|
+
"bag:create-signal-probe",
|
|
166
|
+
z.object({ label: z.string() }),
|
|
167
|
+
async (event, ctx) => {
|
|
168
|
+
const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
|
|
169
|
+
let dbThrewAbortError = false;
|
|
170
|
+
try {
|
|
171
|
+
await ctx.db?.selectMany(bagTable, {});
|
|
172
|
+
} catch (err) {
|
|
173
|
+
dbThrewAbortError = err instanceof Error && err.name === "AbortError";
|
|
174
|
+
}
|
|
175
|
+
const outsideTx = ctx.dbOutsideTransaction;
|
|
176
|
+
if (!outsideTx) {
|
|
177
|
+
throw new Error("bag:create-signal-probe requires ctx.dbOutsideTransaction");
|
|
178
|
+
}
|
|
179
|
+
await crud.create({ label: `${event.payload.label}-outside-tx` }, event.user, outsideTx);
|
|
180
|
+
return { isSuccess: true as const, data: { dbThrewAbortError } };
|
|
181
|
+
},
|
|
182
|
+
{ access: { roles: ["Admin"] } },
|
|
183
|
+
);
|
|
184
|
+
|
|
152
185
|
// afterCommit hook on bag — fires once per outer commit.
|
|
153
186
|
r.hook("postSave", { allOf: bag }, async (result) => {
|
|
154
187
|
afterCommitLog.push(`bag:${result.data["label"]}`);
|
|
@@ -266,4 +299,35 @@ describe("ctx.dbOutsideTransaction", () => {
|
|
|
266
299
|
const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
|
|
267
300
|
expect(labels).toEqual(["probe-outside-tx"]);
|
|
268
301
|
});
|
|
302
|
+
|
|
303
|
+
test("an already-aborted request signal fails ctx.db but not ctx.dbOutsideTransaction", async () => {
|
|
304
|
+
const controller = new AbortController();
|
|
305
|
+
controller.abort();
|
|
306
|
+
const token = await stack.jwt.sign(admin);
|
|
307
|
+
|
|
308
|
+
const res = await stack.app.request(
|
|
309
|
+
new Request("http://test.local/api/write", {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
type: "ctxbridge:write:bag:create-signal-probe",
|
|
314
|
+
payload: { label: "signal-probe" },
|
|
315
|
+
}),
|
|
316
|
+
signal: controller.signal,
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const body = (await res.json()) as {
|
|
321
|
+
isSuccess: boolean;
|
|
322
|
+
data?: { dbThrewAbortError: boolean };
|
|
323
|
+
};
|
|
324
|
+
expect(body.isSuccess).toBe(true);
|
|
325
|
+
expect(body.data?.dbThrewAbortError).toBe(true);
|
|
326
|
+
|
|
327
|
+
// Only the outside-tx insert landed — ctx.db's insert threw before it
|
|
328
|
+
// could write, and there was nothing to roll back for it.
|
|
329
|
+
const bags = await selectMany(stack.db, bagTable);
|
|
330
|
+
const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
|
|
331
|
+
expect(labels).toEqual(["signal-probe-outside-tx"]);
|
|
332
|
+
});
|
|
269
333
|
});
|
|
@@ -170,24 +170,27 @@ export async function buildHandlerContext(
|
|
|
170
170
|
// but at this point we're the root of the pipeline — cast is safe.
|
|
171
171
|
const dbSource = resolveDbSource(ctx, tx);
|
|
172
172
|
const reqCtx = requestContext.get();
|
|
173
|
-
const buildTenantScopedDb = (source: DbConnection | DbTx) =>
|
|
173
|
+
const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
|
|
174
174
|
createTenantDb(
|
|
175
175
|
source,
|
|
176
176
|
user.tenantId,
|
|
177
177
|
isSystem ? "system" : "tenant",
|
|
178
178
|
context.tracer,
|
|
179
179
|
context.meter,
|
|
180
|
-
|
|
181
|
-
// throws when the client has disconnected — handlers with many
|
|
182
|
-
// sequential queries skip the rest of the chain instead of
|
|
183
|
-
// burning DB-CPU for results no one reads.
|
|
184
|
-
reqCtx?.signal,
|
|
180
|
+
signal,
|
|
185
181
|
);
|
|
186
|
-
|
|
182
|
+
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
183
|
+
// the client has disconnected — handlers with many sequential queries skip
|
|
184
|
+
// the rest of the chain instead of burning DB-CPU for results no one reads.
|
|
185
|
+
const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
|
|
187
186
|
// Unbound pool, tenant-scoped like `db` but never tx-bound — writes
|
|
188
|
-
// through it survive a rollback of the handler's own transaction.
|
|
187
|
+
// through it survive a rollback of the handler's own transaction. No
|
|
188
|
+
// AbortSignal here: a client disconnect must not abort a durability write
|
|
189
|
+
// that is meant to outlive the request.
|
|
189
190
|
const outsideTxSource = resolveDbSource(ctx, undefined);
|
|
190
|
-
const dbOutsideTransaction = outsideTxSource
|
|
191
|
+
const dbOutsideTransaction = outsideTxSource
|
|
192
|
+
? buildTenantScopedDb(outsideTxSource, undefined)
|
|
193
|
+
: undefined;
|
|
191
194
|
const log = context.log?.child({
|
|
192
195
|
handler: type,
|
|
193
196
|
tenantId: user.tenantId,
|
package/src/ui-types/index.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// When adding a symbol here, verify it's either a type or a pure
|
|
21
21
|
// helper with no cross-module side-effects.
|
|
22
22
|
|
|
23
|
+
export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
23
24
|
export type { DerivedCellRoundingTarget } from "../engine/embedded-derived";
|
|
24
25
|
export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived";
|
|
25
26
|
export type { ParsedRefTarget } from "../engine/parse-ref-target";
|