@cosmicdrift/kumiko-framework 0.188.0 → 0.190.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 +7 -3
- package/src/api/server.ts +12 -2
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +70 -0
- package/src/bun-db/__tests__/where-patterns.integration.test.ts +28 -0
- package/src/bun-db/query.ts +84 -14
- package/src/db/__tests__/column-ddl.integration.test.ts +9 -0
- package/src/db/__tests__/migrate-generator.test.ts +23 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +61 -1
- package/src/db/dialect.ts +10 -0
- package/src/db/entity-table-meta.ts +3 -0
- package/src/db/migrate-generator.ts +20 -7
- package/src/db/table-builder.ts +25 -19
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +162 -0
- package/src/derivatives/__tests__/derivatives-context.test.ts +167 -0
- package/src/derivatives/__tests__/variant-key.test.ts +55 -0
- package/src/derivatives/derivatives-context.ts +146 -0
- package/src/derivatives/index.ts +3 -0
- package/src/derivatives/variant-key.ts +40 -0
- package/src/engine/__tests__/boot-validator.test.ts +277 -0
- package/src/engine/boot-validator/screens.ts +81 -17
- package/src/engine/extension-names.ts +17 -0
- package/src/engine/index.ts +1 -0
- package/src/errors/__tests__/classes.test.ts +39 -0
- package/src/errors/zod-bridge.ts +18 -1
- package/src/event-store/__tests__/perf.integration.test.ts +33 -17
- package/src/files/in-memory-provider.ts +9 -0
- package/src/jobs/job-runner.ts +19 -2
- package/src/pipeline/dispatch-shared.ts +10 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Proves ctx.derivatives actually arrives on BOTH context-building paths
|
|
2
|
+
// this cut wires it into: the HTTP write-handler path (dispatch-shared.ts
|
|
3
|
+
// buildHandlerContext) and the job path (job-runner.ts handleJob). A unit
|
|
4
|
+
// test on createDerivativesContext alone can't catch a forgotten wiring
|
|
5
|
+
// point — this is that test.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { defineFeature, EXT_DERIVATIVE_RENDERER } from "../../engine";
|
|
11
|
+
import { InternalError, writeFailure } from "../../errors";
|
|
12
|
+
import { createFilesFeature } from "../../files/feature";
|
|
13
|
+
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
14
|
+
import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
|
|
15
|
+
import { buildMultipartBody, patchFileInstanceofForBunTest, waitFor } from "../../testing";
|
|
16
|
+
|
|
17
|
+
const fakeRender: DerivativeRendererPlugin["render"] = async () => new Uint8Array([9, 9, 9]);
|
|
18
|
+
|
|
19
|
+
const jobResults: Array<{ storageKey: string; mimeType: string; rendered: boolean }> = [];
|
|
20
|
+
|
|
21
|
+
const derivativesTestFeature = defineFeature("derivativestest", (r) => {
|
|
22
|
+
r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, { onRegister: () => {} });
|
|
23
|
+
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", { render: fakeRender });
|
|
24
|
+
|
|
25
|
+
r.writeHandler(
|
|
26
|
+
"make-variant",
|
|
27
|
+
z.object({ fileRefId: z.string() }),
|
|
28
|
+
async (event, ctx) => {
|
|
29
|
+
if (!ctx.derivatives) {
|
|
30
|
+
return writeFailure(
|
|
31
|
+
new InternalError({ message: "no ctx.derivatives on write-handler ctx" }),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const result = await ctx.derivatives.variant(
|
|
35
|
+
event.payload.fileRefId,
|
|
36
|
+
{ maxEdge: 100 },
|
|
37
|
+
"thumb",
|
|
38
|
+
);
|
|
39
|
+
return { isSuccess: true as const, data: result };
|
|
40
|
+
},
|
|
41
|
+
{ access: { openToAll: true } },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload, ctx) => {
|
|
45
|
+
const fileRefId = (payload as { fileRefId: string }).fileRefId;
|
|
46
|
+
if (!ctx.derivatives) {
|
|
47
|
+
throw new Error("no ctx.derivatives on job ctx");
|
|
48
|
+
}
|
|
49
|
+
const result = await ctx.derivatives.variant(fileRefId, { maxEdge: 200 }, "card");
|
|
50
|
+
jobResults.push(result);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let stack: TestStack;
|
|
55
|
+
const tenantId = testTenantId(1);
|
|
56
|
+
const user = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
|
|
57
|
+
const otherTenantId = testTenantId(2);
|
|
58
|
+
const otherTenantUser = createTestUser({ id: 10, tenantId: otherTenantId, roles: ["Admin"] });
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
patchFileInstanceofForBunTest();
|
|
62
|
+
stack = await setupTestStack({
|
|
63
|
+
features: [createFilesFeature(), derivativesTestFeature],
|
|
64
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
65
|
+
jobs: { consumerLane: "worker" },
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterAll(async () => {
|
|
70
|
+
await stack.cleanup();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function uploadFile(asUser = user): Promise<string> {
|
|
74
|
+
const token = await stack.jwt.sign(asUser);
|
|
75
|
+
const fd = new FormData();
|
|
76
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }));
|
|
77
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
78
|
+
const res = await stack.app.request("/api/files", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
81
|
+
body,
|
|
82
|
+
});
|
|
83
|
+
expect(res.status).toBe(201);
|
|
84
|
+
const json = (await res.json()) as { id: string };
|
|
85
|
+
return json.id;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("ctx.derivatives wiring — HTTP write-handler path", () => {
|
|
89
|
+
test("a real write-handler sees ctx.derivatives and variant() returns a storage key", async () => {
|
|
90
|
+
const fileId = await uploadFile();
|
|
91
|
+
|
|
92
|
+
const result = await stack.http.writeOk<{
|
|
93
|
+
storageKey: string;
|
|
94
|
+
mimeType: string;
|
|
95
|
+
rendered: boolean;
|
|
96
|
+
}>("derivativestest:write:make-variant", { fileRefId: fileId }, user);
|
|
97
|
+
|
|
98
|
+
expect(result.storageKey).toContain("thumb-");
|
|
99
|
+
expect(result.mimeType).toBe("image/jpeg");
|
|
100
|
+
expect(result.rendered).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("ctx.derivatives wiring — job path", () => {
|
|
105
|
+
test("a job handler sees ctx.derivatives and variant() returns a storage key", async () => {
|
|
106
|
+
jobResults.length = 0;
|
|
107
|
+
const fileId = await uploadFile();
|
|
108
|
+
|
|
109
|
+
// handleJob resolves tenant scope from payload.tenantId (falls back to
|
|
110
|
+
// SYSTEM_TENANT_ID otherwise) — without it the derivatives lookup can't
|
|
111
|
+
// find the file uploaded under the test tenant.
|
|
112
|
+
await stack.jobRunner?.dispatch("derivativestest:job:record", { fileRefId: fileId, tenantId });
|
|
113
|
+
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(jobResults.length).toBeGreaterThan(0);
|
|
116
|
+
});
|
|
117
|
+
expect(jobResults[0]?.storageKey).toContain("card-");
|
|
118
|
+
expect(jobResults[0]?.rendered).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// variant() filters on `id`, `tenantId`, and `isDeleted` — a unit test can't
|
|
123
|
+
// tell a real Postgres query from a stub that ignores those filters, so
|
|
124
|
+
// these three run against the real DB via setupTestStack.
|
|
125
|
+
describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () => {
|
|
126
|
+
test("an unknown fileRefId throws", async () => {
|
|
127
|
+
const err = await stack.http.writeErr(
|
|
128
|
+
"derivativestest:write:make-variant",
|
|
129
|
+
{ fileRefId: "00000000-0000-4000-8000-999999999999" },
|
|
130
|
+
user,
|
|
131
|
+
);
|
|
132
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(400);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a soft-deleted fileRef throws", async () => {
|
|
136
|
+
const fileId = await uploadFile();
|
|
137
|
+
const token = await stack.jwt.sign(user);
|
|
138
|
+
const deleteRes = await stack.app.request(`/api/files/${fileId}`, {
|
|
139
|
+
method: "DELETE",
|
|
140
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
141
|
+
});
|
|
142
|
+
expect(deleteRes.status).toBe(200);
|
|
143
|
+
|
|
144
|
+
const err = await stack.http.writeErr(
|
|
145
|
+
"derivativestest:write:make-variant",
|
|
146
|
+
{ fileRefId: fileId },
|
|
147
|
+
user,
|
|
148
|
+
);
|
|
149
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(400);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a fileRef uploaded under a different tenant is not resolvable", async () => {
|
|
153
|
+
const foreignFileId = await uploadFile(otherTenantUser);
|
|
154
|
+
|
|
155
|
+
const err = await stack.http.writeErr(
|
|
156
|
+
"derivativestest:write:make-variant",
|
|
157
|
+
{ fileRefId: foreignFileId },
|
|
158
|
+
user,
|
|
159
|
+
);
|
|
160
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(400);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
3
|
+
import type { Registry } from "../../engine/types";
|
|
4
|
+
import { createFileContext } from "../../files/file-handle";
|
|
5
|
+
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
6
|
+
import { createDerivativesContext, resolveRenderer } from "../derivatives-context";
|
|
7
|
+
|
|
8
|
+
const FILE_REF_ID = "11111111-1111-4111-8111-111111111111";
|
|
9
|
+
const TENANT_ID = "22222222-2222-4222-8222-222222222222";
|
|
10
|
+
|
|
11
|
+
// Minimal fake Registry — same pattern as provider-resolver.test.ts's
|
|
12
|
+
// fakeRegistry: only getExtensionUsages is exercised by resolveRenderer.
|
|
13
|
+
function fakeRegistry(usages: ReadonlyArray<{ entityName: string; options: unknown }>): Registry {
|
|
14
|
+
return {
|
|
15
|
+
getExtensionUsages: () => usages,
|
|
16
|
+
} as unknown as Registry;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function countingRenderer(): {
|
|
20
|
+
plugin: DerivativeRendererPlugin;
|
|
21
|
+
calls: () => number;
|
|
22
|
+
} {
|
|
23
|
+
let calls = 0;
|
|
24
|
+
const plugin: DerivativeRendererPlugin = {
|
|
25
|
+
render: async () => {
|
|
26
|
+
calls++;
|
|
27
|
+
return new Uint8Array([calls]);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return { plugin, calls: () => calls };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// bun-db's fetchOne(db, table, where) duck-types `db` as a TenantDb via
|
|
34
|
+
// tenantDbDelegate() and, when it matches, delegates straight to
|
|
35
|
+
// db.selectMany(...) instead of building raw SQL — so a fake satisfying that
|
|
36
|
+
// shape exercises variant()'s own logic without a live Postgres connection.
|
|
37
|
+
// selectMany evaluates `where` against the canned row instead of ignoring
|
|
38
|
+
// it: variant()'s two mandatory filters (tenantId, isDeleted) are real
|
|
39
|
+
// integration-test territory, but a fake that returns the row unconditionally
|
|
40
|
+
// would let those filters be deleted here without a single red test.
|
|
41
|
+
function fakeDbWithFileRef(row: { storageKey: string; mimeType: string }): unknown {
|
|
42
|
+
const canned: Record<string, unknown> = {
|
|
43
|
+
...row,
|
|
44
|
+
id: FILE_REF_ID,
|
|
45
|
+
tenantId: TENANT_ID,
|
|
46
|
+
isDeleted: false,
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
raw: { unsafe: () => {} },
|
|
50
|
+
tenantId: TENANT_ID,
|
|
51
|
+
selectMany: async (_table: unknown, where?: Record<string, unknown>) => {
|
|
52
|
+
const matches = Object.entries(where ?? {}).every(([key, value]) => canned[key] === value);
|
|
53
|
+
return matches ? [canned] : [];
|
|
54
|
+
},
|
|
55
|
+
fetchOne: async () => canned,
|
|
56
|
+
insertOne: async () => undefined,
|
|
57
|
+
updateMany: async () => [],
|
|
58
|
+
deleteMany: async () => {},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("resolveRenderer", () => {
|
|
63
|
+
test("exact MIME match wins over a wildcard", () => {
|
|
64
|
+
const { plugin: exactPlugin } = countingRenderer();
|
|
65
|
+
const { plugin: wildcardPlugin } = countingRenderer();
|
|
66
|
+
const registry = fakeRegistry([
|
|
67
|
+
{ entityName: "image/*", options: wildcardPlugin },
|
|
68
|
+
{ entityName: "image/png", options: exactPlugin },
|
|
69
|
+
]);
|
|
70
|
+
expect(resolveRenderer(registry, "image/png")).toBe(exactPlugin);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("falls back to a `<type>/*` wildcard when no exact match exists", () => {
|
|
74
|
+
const { plugin: wildcardPlugin } = countingRenderer();
|
|
75
|
+
const registry = fakeRegistry([{ entityName: "image/*", options: wildcardPlugin }]);
|
|
76
|
+
expect(resolveRenderer(registry, "image/jpeg")).toBe(wildcardPlugin);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("no match returns undefined — caller throws with the known-list", () => {
|
|
80
|
+
const registry = fakeRegistry([]);
|
|
81
|
+
expect(resolveRenderer(registry, "application/pdf")).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a `; charset=` suffix on the source mimeType doesn't break resolution", () => {
|
|
85
|
+
const { plugin } = countingRenderer();
|
|
86
|
+
const registry = fakeRegistry([{ entityName: "image/jpeg", options: plugin }]);
|
|
87
|
+
expect(resolveRenderer(registry, "image/jpeg; charset=binary")).toBe(plugin);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a registered usage without a render() throws instead of falling back to the wildcard", () => {
|
|
91
|
+
const { plugin: wildcardPlugin } = countingRenderer();
|
|
92
|
+
const registry = fakeRegistry([
|
|
93
|
+
{ entityName: "image/*", options: wildcardPlugin },
|
|
94
|
+
{ entityName: "image/png", options: { renderer: () => {} } },
|
|
95
|
+
]);
|
|
96
|
+
expect(() => resolveRenderer(registry, "image/png")).toThrow(/image\/png/);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("createDerivativesContext — variant()", () => {
|
|
101
|
+
async function setup(mimeType = "image/jpeg") {
|
|
102
|
+
const provider = createInMemoryFileProvider();
|
|
103
|
+
await provider.write("tenant/photo.jpg", new Uint8Array([1, 2, 3]), mimeType);
|
|
104
|
+
const files = createFileContext(() => Promise.resolve(provider));
|
|
105
|
+
const { plugin, calls } = countingRenderer();
|
|
106
|
+
const registry = fakeRegistry([{ entityName: "image/*", options: plugin }]);
|
|
107
|
+
const db = fakeDbWithFileRef({ storageKey: "tenant/photo.jpg", mimeType });
|
|
108
|
+
const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
|
|
109
|
+
return { ctx, calls, provider };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test("derive-on-first-use: first call renders, second call with the same spec hits the cache", async () => {
|
|
113
|
+
const { ctx, calls } = await setup();
|
|
114
|
+
const spec = { maxEdge: 320 } as const;
|
|
115
|
+
|
|
116
|
+
const first = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
117
|
+
expect(first.rendered).toBe(true);
|
|
118
|
+
expect(calls()).toBe(1);
|
|
119
|
+
|
|
120
|
+
const second = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
121
|
+
expect(second.rendered).toBe(false);
|
|
122
|
+
expect(calls()).toBe(1);
|
|
123
|
+
expect(second.storageKey).toBe(first.storageKey);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a changed spec renders again at a different key", async () => {
|
|
127
|
+
const { ctx, calls } = await setup();
|
|
128
|
+
const first = await ctx.variant(FILE_REF_ID, { maxEdge: 320 }, "thumb");
|
|
129
|
+
const second = await ctx.variant(FILE_REF_ID, { maxEdge: 640 }, "thumb");
|
|
130
|
+
|
|
131
|
+
expect(calls()).toBe(2);
|
|
132
|
+
expect(second.rendered).toBe(true);
|
|
133
|
+
expect(second.storageKey).not.toBe(first.storageKey);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("no renderer registered for the mimeType throws, naming the known patterns", async () => {
|
|
137
|
+
const provider = createInMemoryFileProvider();
|
|
138
|
+
await provider.write("tenant/doc.pdf", new Uint8Array([1]));
|
|
139
|
+
const files = createFileContext(() => Promise.resolve(provider));
|
|
140
|
+
const registry = fakeRegistry([{ entityName: "image/*", options: countingRenderer().plugin }]);
|
|
141
|
+
const db = fakeDbWithFileRef({ storageKey: "tenant/doc.pdf", mimeType: "application/pdf" });
|
|
142
|
+
const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
|
|
143
|
+
|
|
144
|
+
await expect(ctx.variant(FILE_REF_ID, {}, "thumb")).rejects.toThrow(/image\/\*/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("mimeType is consistent across a fresh render and a cache hit for the same spec", async () => {
|
|
148
|
+
const { ctx } = await setup();
|
|
149
|
+
const spec = { format: "webp" } as const;
|
|
150
|
+
|
|
151
|
+
const first = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
152
|
+
const second = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
153
|
+
|
|
154
|
+
expect(first.mimeType).toBe("image/webp");
|
|
155
|
+
expect(second.mimeType).toBe("image/webp");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("the mimeType written to storage matches the mimeType returned to the caller", async () => {
|
|
159
|
+
const { ctx, provider } = await setup();
|
|
160
|
+
const spec = { format: "webp" } as const;
|
|
161
|
+
|
|
162
|
+
const result = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
163
|
+
|
|
164
|
+
expect(provider.mimeTypeOf(result.storageKey)).toBe(result.mimeType);
|
|
165
|
+
expect(result.mimeType).toBe("image/webp");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { specHash, variantSuffix } from "../variant-key";
|
|
3
|
+
|
|
4
|
+
describe("specHash — key stability", () => {
|
|
5
|
+
test("key order doesn't matter", () => {
|
|
6
|
+
expect(specHash({ fit: "cover", maxEdge: 512 })).toBe(specHash({ maxEdge: 512, fit: "cover" }));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("an explicit undefined is neutral, same as omitting the key", () => {
|
|
10
|
+
expect(specHash({ fit: "cover" })).toBe(specHash({ fit: "cover", blur: undefined }));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("nested object keys are sorted too", () => {
|
|
14
|
+
expect(specHash({ size: { width: 1, height: 2 } })).toBe(
|
|
15
|
+
specHash({ size: { height: 2, width: 1 } }),
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a changed value produces a different hash — the whole point of hashing the spec", () => {
|
|
20
|
+
expect(specHash({ maxEdge: 512 })).not.toBe(specHash({ maxEdge: 640 }));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("variantSuffix", () => {
|
|
25
|
+
test("the name is part of the suffix — same spec, different name diverges", () => {
|
|
26
|
+
const spec = { maxEdge: 512 } as const;
|
|
27
|
+
expect(variantSuffix("thumb", spec)).not.toBe(variantSuffix("card", spec));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("shape is `<name>-<8 hex chars>`", () => {
|
|
31
|
+
expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{8}$/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a path-traversal name is rejected — it would escape the tenant prefix in the derived key", () => {
|
|
35
|
+
expect(() => variantSuffix("../evil", {})).toThrow(/variant name/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a name containing a slash is rejected", () => {
|
|
39
|
+
expect(() => variantSuffix("a/b", {})).toThrow(/variant name/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an empty name is rejected", () => {
|
|
43
|
+
expect(() => variantSuffix("", {})).toThrow(/variant name/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a name over 32 chars is rejected", () => {
|
|
47
|
+
expect(() => variantSuffix("a".repeat(40), {})).toThrow(/variant name/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("ordinary names pass through", () => {
|
|
51
|
+
expect(() => variantSuffix("thumb", {})).not.toThrow();
|
|
52
|
+
expect(() => variantSuffix("card-2x", {})).not.toThrow();
|
|
53
|
+
expect(() => variantSuffix("Hero", {})).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DerivativeRendererPlugin,
|
|
3
|
+
DerivativesContext,
|
|
4
|
+
VariantSpec,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
6
|
+
import { type AnyDb, fetchOne } from "../bun-db/query";
|
|
7
|
+
import { EXT_DERIVATIVE_RENDERER } from "../engine/extension-names";
|
|
8
|
+
import type { Registry, TenantId } from "../engine/types";
|
|
9
|
+
import type { FileContext } from "../files/file-handle";
|
|
10
|
+
import { fileRefsTable } from "../files/file-ref-table";
|
|
11
|
+
import { assertSafeStorageKey } from "../files/types";
|
|
12
|
+
import { variantSuffix } from "./variant-key";
|
|
13
|
+
|
|
14
|
+
export type DerivativesContextDeps = {
|
|
15
|
+
readonly files: FileContext;
|
|
16
|
+
readonly registry: Registry;
|
|
17
|
+
readonly db: AnyDb;
|
|
18
|
+
readonly tenantId: TenantId;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// extension-usage `options` is engine-payload (unknown) — structurally validate
|
|
22
|
+
// instead of casting blind, same pattern as isFileProviderPlugin.
|
|
23
|
+
function isDerivativeRendererPlugin(o: unknown): o is DerivativeRendererPlugin {
|
|
24
|
+
return typeof o === "object" && o !== null && "render" in o && typeof o.render === "function";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Mirrors validateFile's mime-normalization: strip a `; charset=…` suffix,
|
|
28
|
+
// trim, lowercase — so a provider-supplied `image/jpeg; charset=binary`
|
|
29
|
+
// still resolves.
|
|
30
|
+
function normalizeMimeType(mimeType: string): string {
|
|
31
|
+
return mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveRenderer(
|
|
35
|
+
registry: Registry,
|
|
36
|
+
mimeType: string,
|
|
37
|
+
): DerivativeRendererPlugin | undefined {
|
|
38
|
+
const normalized = normalizeMimeType(mimeType);
|
|
39
|
+
const usages = registry.getExtensionUsages(EXT_DERIVATIVE_RENDERER);
|
|
40
|
+
|
|
41
|
+
const exact = usages.find((u) => u.entityName === normalized);
|
|
42
|
+
if (exact) {
|
|
43
|
+
if (!isDerivativeRendererPlugin(exact.options))
|
|
44
|
+
throw new Error(
|
|
45
|
+
`derivatives.resolveRenderer: "${exact.entityName}" registered without a render(input, spec, sourceMimeType) — extension options must be a DerivativeRendererPlugin.`,
|
|
46
|
+
);
|
|
47
|
+
return exact.options;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const wildcard = usages.find((u) => u.entityName === `${normalized.split("/")[0]}/*`);
|
|
51
|
+
if (wildcard) {
|
|
52
|
+
if (!isDerivativeRendererPlugin(wildcard.options))
|
|
53
|
+
throw new Error(
|
|
54
|
+
`derivatives.resolveRenderer: "${wildcard.entityName}" registered without a render(input, spec, sourceMimeType) — extension options must be a DerivativeRendererPlugin.`,
|
|
55
|
+
);
|
|
56
|
+
return wildcard.options;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type FileRefRow = {
|
|
63
|
+
readonly storageKey: string;
|
|
64
|
+
readonly mimeType: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function isFileRefRow(row: Record<string, unknown>): row is FileRefRow {
|
|
68
|
+
return typeof row["storageKey"] === "string" && typeof row["mimeType"] === "string";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The spec — not the renderer — determines the output mimeType, so it's the
|
|
72
|
+
// single source of truth for both what gets written to storage and what the
|
|
73
|
+
// caller receives; a cache hit never runs the renderer, so there's nothing
|
|
74
|
+
// else to derive it from.
|
|
75
|
+
function outputMimeType(spec: VariantSpec, sourceMimeType: string): string {
|
|
76
|
+
switch (spec.format) {
|
|
77
|
+
case "webp":
|
|
78
|
+
return "image/webp";
|
|
79
|
+
case "avif":
|
|
80
|
+
return "image/avif";
|
|
81
|
+
case "jpeg":
|
|
82
|
+
return "image/jpeg";
|
|
83
|
+
default:
|
|
84
|
+
return sourceMimeType;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createDerivativesContext(deps: DerivativesContextDeps): DerivativesContext {
|
|
89
|
+
return {
|
|
90
|
+
variant: async (fileRefId, spec, name) => {
|
|
91
|
+
const row = await fetchOne<Record<string, unknown>>(deps.db, fileRefsTable, {
|
|
92
|
+
id: fileRefId,
|
|
93
|
+
tenantId: deps.tenantId,
|
|
94
|
+
isDeleted: false,
|
|
95
|
+
});
|
|
96
|
+
if (!row) {
|
|
97
|
+
throw new Error(`derivatives.variant: no fileRef found for id "${fileRefId}"`);
|
|
98
|
+
}
|
|
99
|
+
if (!isFileRefRow(row)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`derivatives.variant: fileRef "${fileRefId}" is missing storageKey/mimeType`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const renderer = resolveRenderer(deps.registry, row.mimeType);
|
|
106
|
+
if (!renderer) {
|
|
107
|
+
const known =
|
|
108
|
+
deps.registry
|
|
109
|
+
.getExtensionUsages(EXT_DERIVATIVE_RENDERER)
|
|
110
|
+
.map((u) => u.entityName)
|
|
111
|
+
.join(", ") || "<none>";
|
|
112
|
+
throw new Error(
|
|
113
|
+
`derivatives.variant: no renderer registered for mimeType "${row.mimeType}". Known: ${known}.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const src = deps.files.ref(row.storageKey);
|
|
118
|
+
// ponytail: derived key keeps the source extension regardless of the
|
|
119
|
+
// spec's format — widen deriveKey if a storage backend ever routes on
|
|
120
|
+
// extension.
|
|
121
|
+
const target = src.derive(variantSuffix(name, spec));
|
|
122
|
+
// Belt-and-suspenders: variantSuffix already rejects an unsafe name,
|
|
123
|
+
// this catches a traversal segment reaching the key through any other
|
|
124
|
+
// path (e.g. a future deriveKey change).
|
|
125
|
+
assertSafeStorageKey(target.key);
|
|
126
|
+
const mimeType = outputMimeType(spec, row.mimeType);
|
|
127
|
+
|
|
128
|
+
if (await target.exists()) {
|
|
129
|
+
return {
|
|
130
|
+
storageKey: target.key,
|
|
131
|
+
mimeType,
|
|
132
|
+
rendered: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const original = await src.read();
|
|
137
|
+
const result = await renderer.render(original, spec, row.mimeType);
|
|
138
|
+
await target.write(result, mimeType);
|
|
139
|
+
return {
|
|
140
|
+
storageKey: target.key,
|
|
141
|
+
mimeType,
|
|
142
|
+
rendered: true,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
3
|
+
|
|
4
|
+
// Deterministic JSON: object keys sorted (recursively), `undefined` values
|
|
5
|
+
// dropped, arrays keep their order. Two specs that are semantically equal
|
|
6
|
+
// (same keys, different insertion order; explicit `undefined` vs. omitted)
|
|
7
|
+
// serialize identically.
|
|
8
|
+
export function canonicalJson(value: unknown): string {
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
11
|
+
}
|
|
12
|
+
if (value !== null && typeof value === "object") {
|
|
13
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
14
|
+
.filter(([, v]) => v !== undefined)
|
|
15
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
16
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(value) ?? "null";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A key built from the variant name alone would keep serving stale pixels
|
|
22
|
+
// forever after a spec change; hashing the spec into the key means a
|
|
23
|
+
// changed spec is automatically a new URL.
|
|
24
|
+
export function specHash(spec: VariantSpec): string {
|
|
25
|
+
return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 8);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
|
|
29
|
+
|
|
30
|
+
export function variantSuffix(name: string, spec: VariantSpec): string {
|
|
31
|
+
// `name` ends up in a storage key (see deriveKey) — an unvalidated value
|
|
32
|
+
// like "../../other-tenant/x" would escape the tenant prefix the key is
|
|
33
|
+
// built under.
|
|
34
|
+
if (!VARIANT_NAME_PATTERN.test(name)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`derivatives: variant name must match ${VARIANT_NAME_PATTERN.source} (letters, digits, hyphens; max 32 chars), got "${name}"`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return `${name}-${specHash(spec)}`;
|
|
40
|
+
}
|