@cosmicdrift/kumiko-framework 0.197.0 → 0.198.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/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/sse-route.test.ts +129 -0
- package/src/api/auth-routes.ts +21 -6
- package/src/api/sse-route.ts +13 -1
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
- package/src/bun-db/query.ts +5 -1
- package/src/db/__tests__/compound-types.test.ts +12 -2
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
- package/src/db/__tests__/money.test.ts +49 -18
- package/src/db/dialect.ts +13 -2
- package/src/db/event-store-executor-read.ts +12 -1
- package/src/db/money.ts +35 -15
- package/src/db/table-builder.ts +7 -1
- package/src/derivatives/__tests__/derivatives-context.test.ts +43 -0
- package/src/derivatives/__tests__/variant-route.integration.test.ts +48 -1
- package/src/derivatives/derivatives-context.ts +32 -2
- package/src/engine/__tests__/build-app-schema.test.ts +20 -0
- package/src/engine/__tests__/nav.test.ts +12 -4
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/index.ts +2 -1
- package/src/engine/types/index.ts +7 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
- package/src/entrypoint/index.ts +20 -3
- package/src/files/__tests__/files.integration.test.ts +16 -0
- package/src/files/file-routes.ts +12 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
- package/src/jobs/job-runner.ts +32 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
- package/src/pipeline/dispatch-batch.ts +2 -2
- package/src/pipeline/idempotency.ts +11 -6
- package/src/ui-types/index.ts +1 -1
package/src/db/money.ts
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
// Vertrag (siehe auch db/located-timestamp.ts — gleicher Compound-Type-Pattern):
|
|
4
4
|
// API-Form: { amount, currency } | number — amount in MAJOR units (56799.16 EUR)
|
|
5
5
|
// DB-Form: <name> BIGINT (minor units, e.g. cents) + <name>Currency TEXT
|
|
6
|
-
// Read-Form: { amount, currency,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Read-Form: { amount, currency, amountScaled } — amount in MAJOR units again,
|
|
7
|
+
// amountScaled sits alongside as the exact integer value in
|
|
8
|
+
// MINOR_UNIT_SCALE units (fw#1830) for callers that need
|
|
9
|
+
// scale-exact comparisons (e.g. invoice sums) instead of
|
|
10
|
+
// round-tripping the float amount through /100·*100.
|
|
11
|
+
// `amountMinor` stays as a @deprecated alias of the same
|
|
12
|
+
// value until #1976/4 and #1976/5 migrate their remaining
|
|
13
|
+
// consumers (renderer-web/primitives/index.tsx,
|
|
14
|
+
// renderer/components/render-field.tsx) off the old name.
|
|
10
15
|
//
|
|
11
16
|
// table-builder.ts's moneyAmount column has always documented BIGINT as
|
|
12
17
|
// "the integer minor unit" — this file used to just pass the API amount
|
|
@@ -40,8 +45,8 @@ export function toMinorUnits(amount: number): number {
|
|
|
40
45
|
return Math.round(amount * MINOR_UNIT_SCALE);
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
function toMajorUnits(
|
|
44
|
-
return
|
|
48
|
+
function toMajorUnits(amountScaled: number): number {
|
|
49
|
+
return amountScaled / MINOR_UNIT_SCALE;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
// One money field's write payload — `{ amount, currency }` or a bare number
|
|
@@ -123,11 +128,20 @@ export function flattenMoney(
|
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
/** Shape of a single rehydrated money field — {amount major, currency,
|
|
126
|
-
*
|
|
127
|
-
* copy against this instead of re-declaring the
|
|
131
|
+
* amountScaled exact integer value in MINOR_UNIT_SCALE units}. Exported so
|
|
132
|
+
* consumers type their own copy against this instead of re-declaring the
|
|
133
|
+
* shape by hand. */
|
|
128
134
|
export type MoneyRead = {
|
|
129
135
|
readonly amount: number;
|
|
130
136
|
readonly currency: string;
|
|
137
|
+
readonly amountScaled: number;
|
|
138
|
+
/**
|
|
139
|
+
* @deprecated Use `amountScaled` — this name implies ISO-4217 minor units
|
|
140
|
+
* (cents), which is wrong once a currency needing a different scale than
|
|
141
|
+
* the current flat MINOR_UNIT_SCALE=100 lands (e.g. JPY, 0 decimals).
|
|
142
|
+
* Alias of `amountScaled`, kept until #1976/4 and #1976/5 migrate their
|
|
143
|
+
* consumers off it.
|
|
144
|
+
*/
|
|
131
145
|
readonly amountMinor: number;
|
|
132
146
|
};
|
|
133
147
|
|
|
@@ -159,18 +173,18 @@ export function rehydrateMoney(
|
|
|
159
173
|
continue;
|
|
160
174
|
}
|
|
161
175
|
|
|
162
|
-
let
|
|
176
|
+
let amountScaled: number;
|
|
163
177
|
if (typeof amountRaw === "number") {
|
|
164
|
-
|
|
178
|
+
amountScaled = amountRaw;
|
|
165
179
|
} else if (typeof amountRaw === "bigint") {
|
|
166
|
-
|
|
167
|
-
if (!Number.isSafeInteger(
|
|
180
|
+
amountScaled = Number(amountRaw);
|
|
181
|
+
if (!Number.isSafeInteger(amountScaled)) {
|
|
168
182
|
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
|
|
169
183
|
}
|
|
170
184
|
} else if (typeof amountRaw === "string" && amountRaw !== "") {
|
|
171
185
|
// PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
172
|
-
|
|
173
|
-
if (!Number.isSafeInteger(
|
|
186
|
+
amountScaled = Number(amountRaw);
|
|
187
|
+
if (!Number.isSafeInteger(amountScaled)) {
|
|
174
188
|
throw new Error(
|
|
175
189
|
`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`,
|
|
176
190
|
);
|
|
@@ -184,7 +198,13 @@ export function rehydrateMoney(
|
|
|
184
198
|
const currency =
|
|
185
199
|
typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
|
|
186
200
|
|
|
187
|
-
|
|
201
|
+
// amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
|
|
202
|
+
result[name] = {
|
|
203
|
+
amount: toMajorUnits(amountScaled),
|
|
204
|
+
currency,
|
|
205
|
+
amountScaled,
|
|
206
|
+
amountMinor: amountScaled,
|
|
207
|
+
};
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
return result;
|
package/src/db/table-builder.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
moneyAmount,
|
|
26
26
|
table as pgTable,
|
|
27
27
|
plainDate,
|
|
28
|
+
SQL_EXPR_BRAND,
|
|
28
29
|
type SqlExpression,
|
|
29
30
|
serial,
|
|
30
31
|
sql,
|
|
@@ -586,7 +587,12 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
586
587
|
.filter((c, i) => c !== def.columns[i])
|
|
587
588
|
.map((c) => `"${toSnakeCase(c)}" IS NOT NULL`)
|
|
588
589
|
.join(" AND ");
|
|
589
|
-
const partialWhere: SqlExpression = {
|
|
590
|
+
const partialWhere: SqlExpression = {
|
|
591
|
+
kind: "sql-expr",
|
|
592
|
+
text: whereText,
|
|
593
|
+
params: [],
|
|
594
|
+
[SQL_EXPR_BRAND]: true,
|
|
595
|
+
};
|
|
590
596
|
indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
|
|
591
597
|
.on(...bidxCols)
|
|
592
598
|
.where(partialWhere);
|
|
@@ -171,4 +171,47 @@ describe("createDerivativesContext — variant()", () => {
|
|
|
171
171
|
expect(provider.mimeTypeOf(result.storageKey)).toBe(result.mimeType);
|
|
172
172
|
expect(result.mimeType).toBe("image/webp");
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
// #2021 — a variant with no spec.format used to fall straight through to
|
|
176
|
+
// the source's mimeType, which is client-controlled (`file.type` off the
|
|
177
|
+
// upload). A field without `accept` plus a broad `image/*` renderer
|
|
178
|
+
// wildcard (like `setup()`'s here) let a client ride an active-content
|
|
179
|
+
// type like `image/svg+xml` all the way to the response Content-Type.
|
|
180
|
+
test("no spec.format + a non-allowlisted source mimeType does not take over that mimeType", async () => {
|
|
181
|
+
const { ctx, provider } = await setup("image/svg+xml");
|
|
182
|
+
const spec = {} as const;
|
|
183
|
+
|
|
184
|
+
const result = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
185
|
+
|
|
186
|
+
expect(result.mimeType).not.toBe("image/svg+xml");
|
|
187
|
+
expect(result.mimeType).toBe("application/octet-stream");
|
|
188
|
+
expect(provider.mimeTypeOf(result.storageKey)).toBe("application/octet-stream");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("no spec.format + a non-allowlisted source mimeType — a cache hit returns the same normalized value", async () => {
|
|
192
|
+
const { ctx } = await setup("image/svg+xml");
|
|
193
|
+
const spec = {} as const;
|
|
194
|
+
|
|
195
|
+
const first = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
196
|
+
const second = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
197
|
+
|
|
198
|
+
expect(first.mimeType).toBe("application/octet-stream");
|
|
199
|
+
expect(second.mimeType).toBe("application/octet-stream");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("no spec.format keeps a known-safe source mimeType — resize-without-reformat stays intact", async () => {
|
|
203
|
+
const { ctx } = await setup("image/png");
|
|
204
|
+
|
|
205
|
+
const result = await ctx.variant(FILE_REF_ID, { maxEdge: 320 }, "thumb");
|
|
206
|
+
|
|
207
|
+
expect(result.mimeType).toBe("image/png");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("no spec.format normalizes a `; charset=` suffix off the source mimeType instead of leaking it raw", async () => {
|
|
211
|
+
const { ctx } = await setup("image/jpeg; charset=binary");
|
|
212
|
+
|
|
213
|
+
const result = await ctx.variant(FILE_REF_ID, {}, "thumb");
|
|
214
|
+
|
|
215
|
+
expect(result.mimeType).toBe("image/jpeg");
|
|
216
|
+
});
|
|
174
217
|
});
|
|
@@ -27,7 +27,14 @@ const fakeRender: DerivativeRendererPlugin["render"] = async () => {
|
|
|
27
27
|
const photoEntity = createEntity({
|
|
28
28
|
table: "variant_route_photos",
|
|
29
29
|
fields: {
|
|
30
|
-
avatar: createImageField({
|
|
30
|
+
avatar: createImageField({
|
|
31
|
+
variants: {
|
|
32
|
+
thumb: { maxEdge: 100, format: "webp" },
|
|
33
|
+
// No format — resize-without-reformat, the legitimate reason
|
|
34
|
+
// spec.format can be omitted (see #2021).
|
|
35
|
+
raw: { maxEdge: 100 },
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
31
38
|
},
|
|
32
39
|
});
|
|
33
40
|
|
|
@@ -207,4 +214,44 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
207
214
|
expect(res.status).toBe(415);
|
|
208
215
|
expect(await res.json()).toEqual({ error: "unsupported_media_type" });
|
|
209
216
|
});
|
|
217
|
+
|
|
218
|
+
// #2021 — the "raw" variant has no `format`, so its mimeType comes from
|
|
219
|
+
// outputMimeType's fallback branch. `avatar` has no `accept`, so the
|
|
220
|
+
// source mimeType is exactly the client-controlled `file.type` from the
|
|
221
|
+
// upload — this proves the route no longer echoes it verbatim.
|
|
222
|
+
test("a variant with no spec.format never echoes a non-safe, client-controlled source mimeType", async () => {
|
|
223
|
+
const token = await stack.jwt.sign(user);
|
|
224
|
+
const fd = new FormData();
|
|
225
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "avatar.svg", { type: "image/svg+xml" }));
|
|
226
|
+
fd.append("entityType", "photo");
|
|
227
|
+
fd.append("fieldName", "avatar");
|
|
228
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
229
|
+
const uploadRes = await stack.app.request("/api/files", {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
232
|
+
body,
|
|
233
|
+
});
|
|
234
|
+
expect(uploadRes.status).toBe(201);
|
|
235
|
+
const { id: fileId } = (await uploadRes.json()) as { id: string };
|
|
236
|
+
|
|
237
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/raw`, {
|
|
238
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(res.status).toBe(200);
|
|
242
|
+
expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
|
|
243
|
+
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a variant with no spec.format keeps a known-safe source mimeType through the full route (resize without reformat)", async () => {
|
|
247
|
+
const fileId = await uploadFile();
|
|
248
|
+
const token = await stack.jwt.sign(user);
|
|
249
|
+
|
|
250
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/raw`, {
|
|
251
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(res.status).toBe(200);
|
|
255
|
+
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
|
|
256
|
+
});
|
|
210
257
|
});
|
|
@@ -69,6 +69,23 @@ function isFileRefRow(row: Record<string, unknown>): row is FileRefRow {
|
|
|
69
69
|
return typeof row["storageKey"] === "string" && typeof row["mimeType"] === "string";
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// sourceMimeType is client-controlled (it's `file.type` off the upload — see
|
|
73
|
+
// file-routes.ts's "a field without `accept` lets a client upload anything")
|
|
74
|
+
// and, when spec.format is unset, a renderer is documented to preserve the
|
|
75
|
+
// source's own format (DerivativeRendererPlugin's doc comment) — so the
|
|
76
|
+
// bytes really are in this format, but the STRING itself must still be
|
|
77
|
+
// allowlisted before it becomes a response Content-Type or storage-provider
|
|
78
|
+
// metadata. Otherwise an app whose renderer wildcard is broad enough (e.g.
|
|
79
|
+
// `image/*`) would let a client-declared `image/svg+xml` ride straight
|
|
80
|
+
// through as an honestly-declared (non-sniffed) active-content type (#2021).
|
|
81
|
+
const KNOWN_SAFE_VARIANT_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
82
|
+
"image/jpeg",
|
|
83
|
+
"image/png",
|
|
84
|
+
"image/webp",
|
|
85
|
+
"image/avif",
|
|
86
|
+
"image/gif",
|
|
87
|
+
]);
|
|
88
|
+
|
|
72
89
|
// The spec — not the renderer — determines the output mimeType, so it's the
|
|
73
90
|
// single source of truth for both what gets written to storage and what the
|
|
74
91
|
// caller receives; a cache hit never runs the renderer, so there's nothing
|
|
@@ -81,11 +98,24 @@ function outputMimeType(spec: VariantSpec, sourceMimeType: string): string {
|
|
|
81
98
|
return "image/avif";
|
|
82
99
|
case "jpeg":
|
|
83
100
|
return "image/jpeg";
|
|
84
|
-
default:
|
|
85
|
-
|
|
101
|
+
default: {
|
|
102
|
+
const normalized = normalizeMimeType(sourceMimeType);
|
|
103
|
+
return KNOWN_SAFE_VARIANT_MIME_TYPES.has(normalized)
|
|
104
|
+
? normalized
|
|
105
|
+
: "application/octet-stream";
|
|
106
|
+
}
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
|
|
110
|
+
// `deps.db` is whatever the caller passes through — in the write-handler
|
|
111
|
+
// pipeline (dispatch-shared.ts) that's the handler's open DbTx. `variant()`
|
|
112
|
+
// then holds that transaction open across a full render (decode/resize/
|
|
113
|
+
// encode) plus the storage read+write, not just the fetchOne/exists checks.
|
|
114
|
+
// On a later rollback in the same handler, a variant already written to
|
|
115
|
+
// storage is orphaned there (row never committed, bytes never cleaned up).
|
|
116
|
+
// Prefer this context from the job/MSP path, where `deps.db` isn't tied to
|
|
117
|
+
// an in-flight write transaction; a synchronous write handler rendering a
|
|
118
|
+
// large image should hand off to a job instead of calling `variant()` inline.
|
|
89
119
|
export function createDerivativesContext(deps: DerivativesContextDeps): DerivativesContext {
|
|
90
120
|
return {
|
|
91
121
|
variant: async (fileRefId, spec, name) => {
|
|
@@ -71,6 +71,26 @@ describe("buildAppSchema", () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
// kumiko-framework#2034: createKumikoApp's boot diagnostic reads
|
|
75
|
+
// `screens[].dormant` from the CLIENT schema, not from the registry —
|
|
76
|
+
// this pins that the flag actually survives the server→client projection
|
|
77
|
+
// instead of only living in the registry's verbatim `feature.screens`.
|
|
78
|
+
test("custom screen's `dormant` flag survives the buildAppSchema projection verbatim (#2034)", () => {
|
|
79
|
+
const dormantScreenFeature = defineFeature("privacy", (r) => {
|
|
80
|
+
r.screen({
|
|
81
|
+
id: "privacy-center",
|
|
82
|
+
type: "custom",
|
|
83
|
+
renderer: { react: { __component: "PrivacyCenterScreen" } },
|
|
84
|
+
dormant: true,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const app = buildAppSchema(createRegistry([dormantScreenFeature]));
|
|
89
|
+
const screen = app.features.find((f) => f.featureName === "privacy")?.screens[0];
|
|
90
|
+
|
|
91
|
+
expect(screen).toMatchObject({ id: "privacy-center", dormant: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
74
94
|
test("Feature ohne r.translations lässt das Feld weg (omit-undefined-Pattern)", () => {
|
|
75
95
|
const f = defineFeature("bare", (r) => {
|
|
76
96
|
r.nav({ id: "x", label: "X" });
|
|
@@ -38,7 +38,7 @@ describe("r.nav() — registration", () => {
|
|
|
38
38
|
r.nav({
|
|
39
39
|
id: "products",
|
|
40
40
|
label: "shop:nav.products",
|
|
41
|
-
icon: "
|
|
41
|
+
icon: "package",
|
|
42
42
|
order: 10,
|
|
43
43
|
parent: "shop:nav:catalog",
|
|
44
44
|
screen: "shop:screen:products",
|
|
@@ -47,7 +47,7 @@ describe("r.nav() — registration", () => {
|
|
|
47
47
|
});
|
|
48
48
|
const nav = feature.navs["products"];
|
|
49
49
|
expect(nav).toMatchObject({
|
|
50
|
-
icon: "
|
|
50
|
+
icon: "package",
|
|
51
51
|
order: 10,
|
|
52
52
|
parent: "shop:nav:catalog",
|
|
53
53
|
screen: "shop:screen:products",
|
|
@@ -78,6 +78,14 @@ describe("r.nav() — registration", () => {
|
|
|
78
78
|
}),
|
|
79
79
|
).not.toThrow();
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
test("@ts-expect-error: icon must be a registered NavIconKey, not any string", () => {
|
|
83
|
+
const feature = defineFeature("shop", (r) => {
|
|
84
|
+
// @ts-expect-error — "seting" is a typo of "settings", not a NavIconKey
|
|
85
|
+
r.nav({ id: "catalog", label: "x", icon: "seting" });
|
|
86
|
+
});
|
|
87
|
+
expect(feature.navs["catalog"]).toBeDefined();
|
|
88
|
+
});
|
|
81
89
|
});
|
|
82
90
|
|
|
83
91
|
describe("r.screen({ nav }) — inline nav sugar", () => {
|
|
@@ -89,13 +97,13 @@ describe("r.screen({ nav }) — inline nav sugar", () => {
|
|
|
89
97
|
type: "entityList",
|
|
90
98
|
entity: "product",
|
|
91
99
|
columns: ["name"],
|
|
92
|
-
nav: { label: "shop:nav.products", icon: "
|
|
100
|
+
nav: { label: "shop:nav.products", icon: "package", order: 5 },
|
|
93
101
|
});
|
|
94
102
|
});
|
|
95
103
|
expect(feature.navs["products"]).toMatchObject({
|
|
96
104
|
id: "products",
|
|
97
105
|
label: "shop:nav.products",
|
|
98
|
-
icon: "
|
|
106
|
+
icon: "package",
|
|
99
107
|
order: 5,
|
|
100
108
|
screen: "shop:screen:products",
|
|
101
109
|
});
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
softDeleteCleanupJob,
|
|
10
10
|
softDeleteCleanupSystemJob,
|
|
11
11
|
} from "../soft-delete-cleanup";
|
|
12
|
-
import type {
|
|
12
|
+
import type { JobContext } from "../types/handlers";
|
|
13
13
|
|
|
14
14
|
function featureWith(softDelete: boolean | undefined) {
|
|
15
15
|
return defineFeature("probe-sd", (r) => {
|
|
@@ -53,7 +53,7 @@ describe("registry soft-delete auto-wiring", () => {
|
|
|
53
53
|
|
|
54
54
|
type DeleteCall = { table: unknown; where: Record<string, unknown> };
|
|
55
55
|
|
|
56
|
-
function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }):
|
|
56
|
+
function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): JobContext {
|
|
57
57
|
// Shaped to satisfy bun-db's tenantDbDelegate() probe so deleteMany() routes
|
|
58
58
|
// to this recorder instead of trying to extract real table metadata.
|
|
59
59
|
const fakeDb = {
|
|
@@ -96,7 +96,7 @@ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): AppContext
|
|
|
96
96
|
...(opts.graceDays !== undefined && {
|
|
97
97
|
configResolver: { get: async () => opts.graceDays },
|
|
98
98
|
}),
|
|
99
|
-
} as unknown as
|
|
99
|
+
} as unknown as JobContext;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
describe("softDeleteCleanupJob handler", () => {
|
|
@@ -134,7 +134,7 @@ describe("softDeleteCleanupJob handler", () => {
|
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
test("throws when the job context is missing db/registry", async () => {
|
|
137
|
-
await expect(softDeleteCleanupJob({}, {} as
|
|
137
|
+
await expect(softDeleteCleanupJob({}, {} as JobContext)).rejects.toThrow(
|
|
138
138
|
/ctx.db \+ ctx.registry/,
|
|
139
139
|
);
|
|
140
140
|
});
|
|
@@ -161,7 +161,7 @@ describe("softDeleteCleanupSystemJob handler", () => {
|
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
test("throws when the job context is missing db/registry", async () => {
|
|
164
|
-
await expect(softDeleteCleanupSystemJob({}, {} as
|
|
164
|
+
await expect(softDeleteCleanupSystemJob({}, {} as JobContext)).rejects.toThrow(
|
|
165
165
|
/ctx.db \+ ctx.registry/,
|
|
166
166
|
);
|
|
167
167
|
});
|
|
@@ -28,7 +28,7 @@ import type { ConfigKeyDefinition } from "./types/config";
|
|
|
28
28
|
import type { Registry } from "./types/feature";
|
|
29
29
|
import type { FieldDefinition } from "./types/fields";
|
|
30
30
|
import type { AccessRule } from "./types/handlers";
|
|
31
|
-
import type { NavDefinition } from "./types/nav";
|
|
31
|
+
import type { NavDefinition, NavIconKey } from "./types/nav";
|
|
32
32
|
import type {
|
|
33
33
|
ConfigEditScreenDefinition,
|
|
34
34
|
EditFieldsSection,
|
|
@@ -57,7 +57,7 @@ export type ConfigFeatureSchema = {
|
|
|
57
57
|
|
|
58
58
|
// Audience-Reihenfolge im Sidebar: Plattform vor Tenant vor Benutzer.
|
|
59
59
|
const SCOPE_ORDER: Record<ConfigScope, number> = { system: 10, tenant: 20, user: 30 };
|
|
60
|
-
const SCOPE_ICON: Record<ConfigScope,
|
|
60
|
+
const SCOPE_ICON: Record<ConfigScope, NavIconKey> = {
|
|
61
61
|
system: "shield",
|
|
62
62
|
tenant: "building",
|
|
63
63
|
user: "user",
|
package/src/engine/index.ts
CHANGED
|
@@ -353,6 +353,7 @@ export type {
|
|
|
353
353
|
MultiStreamProjectionDefinition,
|
|
354
354
|
NameOrRef,
|
|
355
355
|
NavDefinition,
|
|
356
|
+
NavIconKey,
|
|
356
357
|
NotificationDataFn,
|
|
357
358
|
NotificationDefinition,
|
|
358
359
|
NotificationRecipientFn,
|
|
@@ -412,7 +413,7 @@ export type {
|
|
|
412
413
|
WriteResult,
|
|
413
414
|
} from "./types";
|
|
414
415
|
export { DEFAULT_CURRENCIES, HookPhases } from "./types";
|
|
415
|
-
export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
|
|
416
|
+
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
|
|
416
417
|
export type {
|
|
417
418
|
PipelineBuildCtx,
|
|
418
419
|
PipelineCtx,
|
|
@@ -138,6 +138,7 @@ export type {
|
|
|
138
138
|
ClaimKeyJsType,
|
|
139
139
|
ClaimKeyType,
|
|
140
140
|
DeclarativeEventMigration,
|
|
141
|
+
DispatchWriteRef,
|
|
141
142
|
EntityRef,
|
|
142
143
|
EventDef,
|
|
143
144
|
EventMigrationDef,
|
|
@@ -204,10 +205,15 @@ export type {
|
|
|
204
205
|
export type { EntityId, TenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
205
206
|
export {
|
|
206
207
|
isSystemTenant,
|
|
208
|
+
isUuid,
|
|
207
209
|
parseTenantId,
|
|
208
210
|
SYSTEM_TENANT_ID,
|
|
209
211
|
} from "@cosmicdrift/kumiko-types/identifiers";
|
|
210
|
-
export type {
|
|
212
|
+
export type {
|
|
213
|
+
ContentCollectionDefinition,
|
|
214
|
+
NavDefinition,
|
|
215
|
+
NavIconKey,
|
|
216
|
+
} from "@cosmicdrift/kumiko-types/nav";
|
|
211
217
|
export type {
|
|
212
218
|
FromRule,
|
|
213
219
|
FromRuleKind,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Regression test for framework#2044 — attachDispatcher() wiring.
|
|
2
|
+
//
|
|
3
|
+
// #2043 added JobContext.write/queryAs plus JobRunner.attachDispatcher(), but
|
|
4
|
+
// left the entrypoint factories unwired: nothing ever called
|
|
5
|
+
// attachDispatcher() on the JobRunners they build, so ctx.write inside a job
|
|
6
|
+
// always hit the throwing stub in production too. This test proves the
|
|
7
|
+
// wiring closes that gap (a job's ctx.write actually commits when run
|
|
8
|
+
// through a real entrypoint) and that the gap is real (the same job, run
|
|
9
|
+
// against a bare createJobRunner() with no attachDispatcher() call, still
|
|
10
|
+
// throws the #2043 stub).
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
15
|
+
import { asRawClient } from "../../db/query";
|
|
16
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
17
|
+
import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
|
|
18
|
+
import { createJobRunner } from "../../jobs/job-runner";
|
|
19
|
+
import { createEventConsumerStateTable } from "../../pipeline";
|
|
20
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
21
|
+
import { waitFor } from "../../testing";
|
|
22
|
+
import { createWorkerEntrypoint } from "../index";
|
|
23
|
+
|
|
24
|
+
const writeProbeResults: Array<{ isSuccess: boolean }> = [];
|
|
25
|
+
const writeProbeFailures: string[] = [];
|
|
26
|
+
|
|
27
|
+
const writeProbeFeature = defineFeature("writeProbe", (r) => {
|
|
28
|
+
const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
|
|
29
|
+
r.writeHandler(
|
|
30
|
+
"note",
|
|
31
|
+
z.object({ note: z.string() }),
|
|
32
|
+
async (event, ctx) => {
|
|
33
|
+
await ctx.unsafeAppendEvent({
|
|
34
|
+
aggregateId: crypto.randomUUID(),
|
|
35
|
+
aggregateType: "write-probe-note",
|
|
36
|
+
type: noted.name,
|
|
37
|
+
payload: { note: event.payload.note },
|
|
38
|
+
});
|
|
39
|
+
return { isSuccess: true as const, data: { note: event.payload.note } };
|
|
40
|
+
},
|
|
41
|
+
{ access: { openToAll: true } },
|
|
42
|
+
);
|
|
43
|
+
r.job("write-via-job", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
44
|
+
try {
|
|
45
|
+
const result = await ctx.write("write-probe:write:note", {
|
|
46
|
+
note: payload["note"] as string,
|
|
47
|
+
});
|
|
48
|
+
writeProbeResults.push({ isSuccess: result.isSuccess });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
writeProbeFailures.push(error instanceof Error ? error.message : String(error));
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const JWT = "attach-dispatcher-test-secret-must-be-32-chars!";
|
|
57
|
+
|
|
58
|
+
let testDb: BunTestDb;
|
|
59
|
+
let testRedis: TestRedis;
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
[testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
|
|
63
|
+
await createEventsTable(testDb.db);
|
|
64
|
+
await createArchivedStreamsTable(testDb.db);
|
|
65
|
+
await createEventConsumerStateTable(testDb.db);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function uniquePrefix(label: string): string {
|
|
73
|
+
return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("createWorkerEntrypoint auto-wires attachDispatcher() (framework#2044)", () => {
|
|
77
|
+
test("ctx.write inside a job commits end-to-end through a real entrypoint", async () => {
|
|
78
|
+
writeProbeResults.length = 0;
|
|
79
|
+
const registry = createRegistry([writeProbeFeature]);
|
|
80
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
81
|
+
const worker = createWorkerEntrypoint({
|
|
82
|
+
registry,
|
|
83
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
84
|
+
jwtSecret: JWT,
|
|
85
|
+
redisUrl,
|
|
86
|
+
queueNamePrefix: uniquePrefix("attach-dispatcher"),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await worker.start();
|
|
90
|
+
try {
|
|
91
|
+
await worker.jobRunner.dispatch("write-probe:job:write-via-job", {
|
|
92
|
+
note: "written from the job",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await waitFor(() => {
|
|
96
|
+
expect(writeProbeResults.length).toBe(1);
|
|
97
|
+
expect(writeProbeResults[0]?.isSuccess).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const rows = await asRawClient(testDb.db).unsafe(
|
|
101
|
+
`SELECT payload FROM kumiko_events WHERE type = 'write-probe:event:noted'`,
|
|
102
|
+
);
|
|
103
|
+
expect(rows).toHaveLength(1);
|
|
104
|
+
expect((rows[0] as { payload: { note: string } }).payload.note).toBe("written from the job");
|
|
105
|
+
} finally {
|
|
106
|
+
await worker.stop();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("createJobRunner without attachDispatcher() still hits the #2043 stub", () => {
|
|
112
|
+
test("ctx.write throws — proves the entrypoint's attachDispatcher() call is the thing that makes writes work", async () => {
|
|
113
|
+
writeProbeFailures.length = 0;
|
|
114
|
+
const registry = createRegistry([writeProbeFeature]);
|
|
115
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
116
|
+
const runner = createJobRunner({
|
|
117
|
+
registry,
|
|
118
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
119
|
+
redisUrl,
|
|
120
|
+
consumerLane: "worker",
|
|
121
|
+
queueNamePrefix: uniquePrefix("attach-dispatcher-bare"),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await runner.start();
|
|
125
|
+
try {
|
|
126
|
+
await runner.dispatch("write-probe:job:write-via-job", { note: "should never land" });
|
|
127
|
+
|
|
128
|
+
await waitFor(() => {
|
|
129
|
+
expect(writeProbeFailures.length).toBe(1);
|
|
130
|
+
});
|
|
131
|
+
expect(writeProbeFailures[0]).toContain(
|
|
132
|
+
"JobContext.write called before dispatcher attached — call attachDispatcher() first",
|
|
133
|
+
);
|
|
134
|
+
} finally {
|
|
135
|
+
await runner.stop();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { buildServer, withFileProviderResolver } from "../api/server";
|
|
|
38
38
|
import type { SseBroker } from "../api/sse-broker";
|
|
39
39
|
import type { PgClient } from "../db/connection";
|
|
40
40
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
41
|
-
import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
|
|
41
|
+
import type { AppContext, DispatchWriteRef, JobRunIn, Registry, RunIn } from "../engine/types";
|
|
42
42
|
import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
|
|
43
43
|
import { createJobRunner } from "../jobs/job-runner";
|
|
44
44
|
import type { Lifecycle } from "../lifecycle";
|
|
@@ -142,8 +142,9 @@ export type WorkerEntrypoint = {
|
|
|
142
142
|
readonly eventDispatcher: EventDispatcher;
|
|
143
143
|
readonly jobRunner: JobRunner;
|
|
144
144
|
readonly observability: ObservabilityProvider;
|
|
145
|
-
// Same dispatcher the API process exposes.
|
|
146
|
-
//
|
|
145
|
+
// Same dispatcher the API process exposes. App-wired background
|
|
146
|
+
// components that need the dispatcher directly (not JobContext.write)
|
|
147
|
+
// still persist through it.
|
|
147
148
|
readonly dispatcher: Dispatcher;
|
|
148
149
|
readonly mode: "worker";
|
|
149
150
|
// Starts event-dispatcher poll + BullMQ worker. SIGTERM triggers
|
|
@@ -205,6 +206,18 @@ function contextWithObservability(
|
|
|
205
206
|
};
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
// Adapts the command-dispatcher's positional (type, payload, user) calls to
|
|
210
|
+
// DispatchWriteRef's (user, qn, payload) shape — JobContext.write/queryAs
|
|
211
|
+
// pass identity explicitly per call (boot-time singleton), while Dispatcher
|
|
212
|
+
// takes it last (request-scoped closure caller). Same underlying pipeline,
|
|
213
|
+
// different argument order.
|
|
214
|
+
function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
|
|
215
|
+
return {
|
|
216
|
+
write: (user, qn, payload) => dispatcher.write(qn, payload, user),
|
|
217
|
+
queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
208
221
|
// buildApiServer shapes ServerOptions from API-mode caller-options.
|
|
209
222
|
// AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
|
|
210
223
|
// subtyping makes the all-in-one path a valid caller without an explicit
|
|
@@ -381,6 +394,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
381
394
|
apiJobRunner,
|
|
382
395
|
runLocalDispatcher ? "both" : "api",
|
|
383
396
|
);
|
|
397
|
+
apiJobRunner?.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
384
398
|
|
|
385
399
|
return {
|
|
386
400
|
app: server.app,
|
|
@@ -422,6 +436,7 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
|
|
|
422
436
|
"jobRunner",
|
|
423
437
|
);
|
|
424
438
|
const server = buildWorkerServer({ ...options, context }, lifecycle, jobRunner);
|
|
439
|
+
jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
425
440
|
const eventDispatcher = requireDispatcher(server, "worker");
|
|
426
441
|
|
|
427
442
|
return {
|
|
@@ -491,6 +506,8 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
|
|
|
491
506
|
workerJobRunner,
|
|
492
507
|
"both",
|
|
493
508
|
);
|
|
509
|
+
workerJobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
510
|
+
apiJobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
494
511
|
const eventDispatcher = requireDispatcher(server, "all-in-one");
|
|
495
512
|
|
|
496
513
|
return {
|