@cosmicdrift/kumiko-framework 0.193.1 → 0.195.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__/http-route-rate-limit.integration.test.ts +71 -0
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/migrate-generator.test.ts +17 -0
- package/src/db/__tests__/money.test.ts +41 -5
- package/src/db/event-store-executor-read.ts +1 -1
- package/src/db/index.ts +1 -1
- package/src/db/migrate-generator.ts +13 -3
- package/src/db/money.ts +34 -4
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +73 -6
- package/src/derivatives/__tests__/derivatives-context.test.ts +9 -2
- package/src/derivatives/__tests__/variant-key.test.ts +2 -2
- package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
- package/src/derivatives/derivatives-context.ts +7 -7
- package/src/derivatives/variant-key.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +140 -0
- package/src/engine/__tests__/embedded-derived.test.ts +35 -0
- package/src/engine/__tests__/engine.test.ts +20 -0
- package/src/engine/__tests__/schema-builder.test.ts +93 -0
- package/src/engine/boot-validator/entity-handler.ts +64 -5
- package/src/engine/boot-validator/screens.ts +57 -36
- package/src/engine/embedded-derived.ts +9 -1
- package/src/engine/schema-builder.ts +33 -23
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +37 -6
- package/src/errors/zod-bridge.ts +4 -9
- package/src/event-store/__tests__/perf.integration.test.ts +2 -11
- package/src/files/file-routes.ts +20 -6
- package/src/files/storage-tracking.ts +2 -1
- package/src/jobs/job-runner.ts +18 -9
- package/src/logging/__tests__/fallback-logger.test.ts +43 -0
- package/src/logging/utils.ts +14 -1
- package/src/observability/__tests__/metric-validator.test.ts +10 -2
- package/src/observability/__tests__/metrics-handle.test.ts +30 -0
- package/src/observability/metric-validator.ts +4 -3
- package/src/observability/metrics-handle.ts +24 -12
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +69 -5
- 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.195.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.195.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.195.0",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// kumiko-framework#1977: r.httpRoute's systemQuery used to always run with
|
|
2
|
+
// requestContext.get()?.ip === undefined, so a `rateLimit: {per: "ip"}`
|
|
3
|
+
// handler invoked through it silently never bucketed — see server.ts's
|
|
4
|
+
// systemQuery wiring. Proves the fix: repeated calls through the same
|
|
5
|
+
// httpRoute, same client IP, DO hit the limit.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
10
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
11
|
+
import { RateLimitError } from "../../errors";
|
|
12
|
+
import { setupTestStack, type TestStack } from "../../stack";
|
|
13
|
+
|
|
14
|
+
const SYSTEM_TENANT_ID = "00000000-0000-4000-8000-000000000000" as TenantId;
|
|
15
|
+
|
|
16
|
+
const ipLimitedFeature = defineFeature("rl-http", (r) => {
|
|
17
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
18
|
+
r.queryHandler("ping", z.object({}), async () => ({ ok: true }), {
|
|
19
|
+
access: { roles: ["anonymous"] },
|
|
20
|
+
rateLimit: { per: "ip", limit: 2, windowSeconds: 60 },
|
|
21
|
+
});
|
|
22
|
+
r.httpRoute({
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/ping",
|
|
25
|
+
anonymous: true,
|
|
26
|
+
handler: async (c, deps) => {
|
|
27
|
+
try {
|
|
28
|
+
await deps.systemQuery("rl-http:query:ping", {}, SYSTEM_TENANT_ID);
|
|
29
|
+
return c.json({ ok: true });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err instanceof RateLimitError) return c.json({ ok: false }, 429);
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
stack = await setupTestStack({ features: [ipLimitedFeature] });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await stack.cleanup();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
await stack.redis.flushNamespace();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("r.httpRoute → systemQuery propagates requestContext for per-ip rate limiting", () => {
|
|
53
|
+
test("2 calls allowed, 3rd from the same IP is rate-limited", async () => {
|
|
54
|
+
const call = () => stack.app.request("/ping", { headers: { "x-forwarded-for": "9.9.9.1" } });
|
|
55
|
+
|
|
56
|
+
expect((await call()).status).toBe(200);
|
|
57
|
+
expect((await call()).status).toBe(200);
|
|
58
|
+
expect((await call()).status).toBe(429);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a different client IP gets its own bucket", async () => {
|
|
62
|
+
const callAs = (ip: string) =>
|
|
63
|
+
stack.app.request("/ping", { headers: { "x-forwarded-for": ip } });
|
|
64
|
+
|
|
65
|
+
expect((await callAs("9.9.9.2")).status).toBe(200);
|
|
66
|
+
expect((await callAs("9.9.9.2")).status).toBe(200);
|
|
67
|
+
expect((await callAs("9.9.9.2")).status).toBe(429);
|
|
68
|
+
|
|
69
|
+
expect((await callAs("9.9.9.3")).status).toBe(200);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/api/server.ts
CHANGED
|
@@ -778,7 +778,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
778
778
|
// systemQuery is silent dead-code: enforceRateLimit reads
|
|
779
779
|
// requestContext.get()?.ip, which is undefined here, so
|
|
780
780
|
// buildBucketKey always returns {kind: "skip"}.
|
|
781
|
-
requestContext.run(buildRequestContextData(c), () =>
|
|
781
|
+
requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
|
|
782
782
|
dispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
783
783
|
),
|
|
784
784
|
});
|
|
@@ -165,6 +165,23 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
|
|
|
165
165
|
expect(sql).not.toContain("WARN: column-type-change");
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
test("unmanaged: date → timestamptz type change emits the symmetric UTC-anchored USING clause", () => {
|
|
169
|
+
const prev = snapshotFromMetas([
|
|
170
|
+
meta("store_invoices", { name: "period_from", pgType: "date", notNull: true }),
|
|
171
|
+
]);
|
|
172
|
+
const next = snapshotFromMetas([
|
|
173
|
+
meta("store_invoices", { name: "period_from", pgType: "timestamptz", notNull: true }),
|
|
174
|
+
]);
|
|
175
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
176
|
+
name: "invoice-date-widen",
|
|
177
|
+
sequenceNumber: 8,
|
|
178
|
+
});
|
|
179
|
+
expect(sql).toContain(
|
|
180
|
+
'ALTER TABLE "store_invoices" ALTER COLUMN "period_from" TYPE timestamptz USING ("period_from"::timestamp AT TIME ZONE \'UTC\');',
|
|
181
|
+
);
|
|
182
|
+
expect(sql).not.toContain("WARN: column-type-change");
|
|
183
|
+
});
|
|
184
|
+
|
|
168
185
|
test("managed: multiple recreate reasons at once → all named in the warning", () => {
|
|
169
186
|
const prev = snapshotFromMetas([
|
|
170
187
|
meta("read_b", { name: "old_col", pgType: "text", notNull: false }, "managed"),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { createEntity, createMoneyField, createTextField } from "../../engine";
|
|
9
9
|
import type { EntityDefinition } from "../../engine/types";
|
|
10
|
-
import { flattenMoney, rehydrateMoney } from "../money";
|
|
10
|
+
import { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "../money";
|
|
11
11
|
|
|
12
12
|
const orderEntity: EntityDefinition = createEntity({
|
|
13
13
|
defaultCurrency: "EUR",
|
|
@@ -115,17 +115,16 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
115
115
|
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
test("amountMinor bleibt exakter Integer
|
|
118
|
+
test("amountMinor bleibt exakter Integer über mehrere Additionen (fw#1830)", () => {
|
|
119
119
|
const rows = [10, 20, 30].map(
|
|
120
120
|
(minor) =>
|
|
121
121
|
rehydrateMoney({ buyingPrice: minor, buyingPriceCurrency: "EUR" }, orderEntity)[
|
|
122
122
|
"buyingPrice"
|
|
123
|
-
] as
|
|
123
|
+
] as MoneyRead,
|
|
124
124
|
);
|
|
125
125
|
const [a, b, c] = rows;
|
|
126
126
|
|
|
127
127
|
expect(a!.amountMinor + b!.amountMinor).toBe(c!.amountMinor);
|
|
128
|
-
expect(a!.amount + b!.amount).not.toBe(c!.amount);
|
|
129
128
|
});
|
|
130
129
|
|
|
131
130
|
test("null/undefined amount → Field wird aus Output entfernt", () => {
|
|
@@ -178,7 +177,19 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
178
177
|
test("korrupte string-amount (kein number) → loud throw, kein silent drop", () => {
|
|
179
178
|
expect(() =>
|
|
180
179
|
rehydrateMoney({ buyingPrice: "not-a-number", buyingPriceCurrency: "EUR" }, orderEntity),
|
|
181
|
-
).toThrow(/not a
|
|
180
|
+
).toThrow(/not a safe integer — DB corruption/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("fractional string amount (fw#1833) → loud throw statt amountMinor mit Nachkommastelle", () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
rehydrateMoney({ buyingPrice: "45000.7", buyingPriceCurrency: "EUR" }, orderEntity),
|
|
186
|
+
).toThrow(/not a safe integer — DB corruption/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("string amount jenseits von Number.MAX_SAFE_INTEGER → loud throw statt Präzisionsverlust", () => {
|
|
190
|
+
expect(() =>
|
|
191
|
+
rehydrateMoney({ buyingPrice: "9007199254740993", buyingPriceCurrency: "EUR" }, orderEntity),
|
|
192
|
+
).toThrow(/not a safe integer — DB corruption/);
|
|
182
193
|
});
|
|
183
194
|
|
|
184
195
|
test("unerwarteter amount-Typ (boolean) → loud throw", () => {
|
|
@@ -222,3 +233,28 @@ describe("flattenMoney — Strict-Mode Throw", () => {
|
|
|
222
233
|
);
|
|
223
234
|
});
|
|
224
235
|
});
|
|
236
|
+
|
|
237
|
+
// kumiko-framework#1972: the write-handler-facing counterpart a custom
|
|
238
|
+
// totals check needs when reconciling a top-level money field's payload
|
|
239
|
+
// (major units, `{amount, currency}` or bare number) against an
|
|
240
|
+
// embedded-list row sum (minor-unit integers by convention).
|
|
241
|
+
describe("moneyPayloadToMinorUnits", () => {
|
|
242
|
+
test("{ amount, currency } object, crooked amount, rounds to the exact cent", () => {
|
|
243
|
+
expect(moneyPayloadToMinorUnits({ amount: 1234.56, currency: "EUR" })).toBe(123456);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("legacy bare-number payload (also major units, per flattenMoney's permissive-insert contract)", () => {
|
|
247
|
+
expect(moneyPayloadToMinorUnits(1234.56)).toBe(123456);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("a round amount does not mask a factor-of-100 regression: 30 major -> 3000 minor, not 30", () => {
|
|
251
|
+
expect(moneyPayloadToMinorUnits({ amount: 30, currency: "EUR" })).toBe(3000);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("undefined for a payload without a numeric amount (nothing to compare)", () => {
|
|
255
|
+
expect(moneyPayloadToMinorUnits({ currency: "EUR" })).toBeUndefined();
|
|
256
|
+
expect(moneyPayloadToMinorUnits("100")).toBeUndefined();
|
|
257
|
+
expect(moneyPayloadToMinorUnits(null)).toBeUndefined();
|
|
258
|
+
expect(moneyPayloadToMinorUnits(undefined)).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -166,7 +166,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
166
166
|
const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
167
167
|
|
|
168
168
|
const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
|
|
169
|
-
//
|
|
169
|
+
// Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
|
|
170
170
|
// Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
|
|
171
171
|
// column names, while compound-type lookups (rehydrateMoney et al.) and the
|
|
172
172
|
// encrypted/pii field lists are all camelCase — running either first on a
|
package/src/db/index.ts
CHANGED
|
@@ -100,7 +100,7 @@ export {
|
|
|
100
100
|
runMigrationsFromDir,
|
|
101
101
|
splitSqlStatements,
|
|
102
102
|
} from "./migrate-runner";
|
|
103
|
-
export { flattenMoney, rehydrateMoney } from "./money";
|
|
103
|
+
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money";
|
|
104
104
|
export {
|
|
105
105
|
constraintOf,
|
|
106
106
|
extractPgError,
|
|
@@ -284,10 +284,20 @@ function renderColumnChange(tableName: string, change: ColumnChange): readonly s
|
|
|
284
284
|
out.push(
|
|
285
285
|
`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE date USING (${col} AT TIME ZONE 'UTC')::date;`,
|
|
286
286
|
);
|
|
287
|
+
} else if (from === "date" && (to === "timestamptz" || to === "timestamptz(3)")) {
|
|
288
|
+
// Same non-determinism, reversed: a bare `date → timestamptz` cast
|
|
289
|
+
// interprets the calendar date at session-TimeZone midnight. Anchor
|
|
290
|
+
// explicitly at UTC midnight instead.
|
|
291
|
+
out.push(
|
|
292
|
+
`-- date → ${to}: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`,
|
|
293
|
+
);
|
|
294
|
+
out.push(
|
|
295
|
+
`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to} USING (${col}::timestamp AT TIME ZONE 'UTC');`,
|
|
296
|
+
);
|
|
287
297
|
} else {
|
|
288
|
-
// pg ALTER TYPE
|
|
289
|
-
// type
|
|
290
|
-
//
|
|
298
|
+
// pg ALTER TYPE often needs a USING clause for non-implicitly-castable
|
|
299
|
+
// type changes. Emitted as a reviewer comment + raw cast — the app
|
|
300
|
+
// author has to confirm it's intended.
|
|
291
301
|
out.push(`-- WARN: column-type-change ${from} → ${to}. Review USING-clause if needed.`);
|
|
292
302
|
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to};`);
|
|
293
303
|
}
|
package/src/db/money.ts
CHANGED
|
@@ -44,6 +44,27 @@ function toMajorUnits(amountMinor: number): number {
|
|
|
44
44
|
return amountMinor / MINOR_UNIT_SCALE;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// One money field's write payload — `{ amount, currency }` or a bare number
|
|
48
|
+
// (both MAJOR units, see file header) — reduced to minor units. This is the
|
|
49
|
+
// counterpart a write-handler needs when comparing a top-level money field
|
|
50
|
+
// against an `embeddedSubFieldToZod` list-row sum: rows are minor-unit
|
|
51
|
+
// integers by convention (currency lives on the head aggregate, not the
|
|
52
|
+
// row), so the two can only be compared once the sibling amount has been
|
|
53
|
+
// scaled the same way. `applyTotalsMatchRefinements` (schema-builder.ts)
|
|
54
|
+
// uses this internally for `EmbeddedFieldDef.totalsMatch`; exported so a
|
|
55
|
+
// custom write-handler doing its own total check doesn't have to re-derive
|
|
56
|
+
// this unwrap-and-scale step by hand (kumiko-framework#1972 — a hand-rolled
|
|
57
|
+
// comparison of a raw `{amount}` against a raw minor-unit row sum is exactly
|
|
58
|
+
// what silently fails 100x off).
|
|
59
|
+
export function moneyPayloadToMinorUnits(raw: unknown): number | undefined {
|
|
60
|
+
if (typeof raw === "number") return toMinorUnits(raw);
|
|
61
|
+
if (typeof raw === "object" && raw !== null && "amount" in raw) {
|
|
62
|
+
const amount = (raw as { amount: unknown }).amount;
|
|
63
|
+
if (typeof amount === "number") return toMinorUnits(amount);
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
/**
|
|
48
69
|
* API → DB: money-Felder zu zwei flachen Spalten flatten.
|
|
49
70
|
*
|
|
@@ -101,6 +122,15 @@ export function flattenMoney(
|
|
|
101
122
|
return result;
|
|
102
123
|
}
|
|
103
124
|
|
|
125
|
+
/** Shape of a single rehydrated money field — {amount major, currency,
|
|
126
|
+
* amountMinor exact integer cents}. Exported so consumers type their own
|
|
127
|
+
* copy against this instead of re-declaring the shape by hand. */
|
|
128
|
+
export type MoneyRead = {
|
|
129
|
+
readonly amount: number;
|
|
130
|
+
readonly currency: string;
|
|
131
|
+
readonly amountMinor: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
104
134
|
/**
|
|
105
135
|
* DB → API: zwei flache Spalten zu combined { amount, currency } rehydraten.
|
|
106
136
|
*
|
|
@@ -134,15 +164,15 @@ export function rehydrateMoney(
|
|
|
134
164
|
amountMinor = amountRaw;
|
|
135
165
|
} else if (typeof amountRaw === "bigint") {
|
|
136
166
|
amountMinor = Number(amountRaw);
|
|
137
|
-
if (Number.
|
|
138
|
-
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a
|
|
167
|
+
if (!Number.isSafeInteger(amountMinor)) {
|
|
168
|
+
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
|
|
139
169
|
}
|
|
140
170
|
} else if (typeof amountRaw === "string" && amountRaw !== "") {
|
|
141
171
|
// PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
142
172
|
amountMinor = Number(amountRaw);
|
|
143
|
-
if (Number.
|
|
173
|
+
if (!Number.isSafeInteger(amountMinor)) {
|
|
144
174
|
throw new Error(
|
|
145
|
-
`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a
|
|
175
|
+
`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`,
|
|
146
176
|
);
|
|
147
177
|
}
|
|
148
178
|
} else {
|
|
@@ -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 () => {
|
|
@@ -33,8 +33,8 @@ describe("variantSuffix", () => {
|
|
|
33
33
|
expect(variantSuffix("thumb", spec)).not.toBe(variantSuffix("card", spec));
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test("shape is `<name>-<
|
|
37
|
-
expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{
|
|
36
|
+
test("shape is `<name>-<16 hex chars>`", () => {
|
|
37
|
+
expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{16}$/);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
test("a path-traversal name is rejected — it would escape the tenant prefix in the derived key", () => {
|
|
@@ -94,6 +94,7 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
94
94
|
|
|
95
95
|
expect(res.status).toBe(200);
|
|
96
96
|
expect(res.headers.get("Content-Type")).toBe("image/webp");
|
|
97
|
+
expect(res.headers.get("Cache-Control")).toBe("private, max-age=31536000, immutable");
|
|
97
98
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
98
99
|
});
|
|
99
100
|
|
|
@@ -166,4 +167,31 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
166
167
|
|
|
167
168
|
expect(res.status).toBe(401);
|
|
168
169
|
});
|
|
170
|
+
|
|
171
|
+
// createImageField has no `accept` restriction here, so validateFile
|
|
172
|
+
// never rejects a non-image upload against this field — the source
|
|
173
|
+
// mimeType a renderer has to handle is client-controlled, not just a
|
|
174
|
+
// deployment-time mount decision.
|
|
175
|
+
test("a source mimeType with no matching renderer (client uploaded a non-image) returns 415, not a 500", async () => {
|
|
176
|
+
const token = await stack.jwt.sign(user);
|
|
177
|
+
const fd = new FormData();
|
|
178
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }));
|
|
179
|
+
fd.append("entityType", "photo");
|
|
180
|
+
fd.append("fieldName", "avatar");
|
|
181
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
182
|
+
const uploadRes = await stack.app.request("/api/files", {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
185
|
+
body,
|
|
186
|
+
});
|
|
187
|
+
expect(uploadRes.status).toBe(201);
|
|
188
|
+
const { id: fileId } = (await uploadRes.json()) as { id: string };
|
|
189
|
+
|
|
190
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
191
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(res.status).toBe(415);
|
|
195
|
+
expect(await res.json()).toEqual({ error: "unsupported_media_type" });
|
|
196
|
+
});
|
|
169
197
|
});
|
|
@@ -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);
|
|
@@ -22,7 +22,7 @@ export function canonicalJson(value: unknown): string {
|
|
|
22
22
|
// forever after a spec change; hashing the spec into the key means a
|
|
23
23
|
// changed spec is automatically a new URL.
|
|
24
24
|
export function specHash(spec: VariantSpec): string {
|
|
25
|
-
return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0,
|
|
25
|
+
return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 16);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
|