@cosmicdrift/kumiko-framework 0.210.0 → 0.212.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.210.0",
3
+ "version": "0.212.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>",
@@ -159,6 +159,10 @@
159
159
  "types": "./src/consumer-cli.ts",
160
160
  "default": "./src/consumer-cli.ts"
161
161
  },
162
+ "./upgrade-cli": {
163
+ "types": "./src/upgrade-cli.ts",
164
+ "default": "./src/upgrade-cli.ts"
165
+ },
162
166
  "./stack": {
163
167
  "types": "./src/stack/index.ts",
164
168
  "default": "./src/stack/index.ts"
@@ -190,7 +194,7 @@
190
194
  "./package.json": "./package.json"
191
195
  },
192
196
  "dependencies": {
193
- "@cosmicdrift/kumiko-types": "0.210.0",
197
+ "@cosmicdrift/kumiko-types": "0.212.0",
194
198
  "bullmq": "^5.76.7",
195
199
  "bun-types": "^1.3.13",
196
200
  "hono": "^4.13.1",
@@ -206,7 +210,7 @@
206
210
  "zod": "^4.4.3"
207
211
  },
208
212
  "devDependencies": {
209
- "@cosmicdrift/kumiko-dispatcher-live": "0.210.0",
213
+ "@cosmicdrift/kumiko-dispatcher-live": "0.212.0",
210
214
  "bun-types": "^1.3.13",
211
215
  "pino-pretty": "^13.1.3"
212
216
  },
@@ -0,0 +1,370 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveCodemodScript, runUpgradeCli, type UpgradeCliOut } from "../upgrade-cli";
6
+
7
+ function makeSpyOutput(): {
8
+ readonly out: UpgradeCliOut;
9
+ readonly logs: string[];
10
+ readonly errs: string[];
11
+ } {
12
+ const logs: string[] = [];
13
+ const errs: string[] = [];
14
+ return {
15
+ logs,
16
+ errs,
17
+ out: {
18
+ log: (m: string) => logs.push(m),
19
+ err: (m: string) => errs.push(m),
20
+ },
21
+ };
22
+ }
23
+
24
+ function makeTempCwd(files?: Record<string, string>): {
25
+ readonly cwd: string;
26
+ readonly cleanup: () => void;
27
+ } {
28
+ const cwd = mkdtempSync(join(tmpdir(), "kumiko-upgrade-cli-"));
29
+ if (files) {
30
+ for (const [relPath, content] of Object.entries(files)) {
31
+ const full = join(cwd, relPath);
32
+ mkdirSync(join(full, ".."), { recursive: true });
33
+ writeFileSync(full, content, "utf-8");
34
+ }
35
+ }
36
+ return {
37
+ cwd,
38
+ cleanup: () => {
39
+ try {
40
+ rmSync(cwd, { recursive: true, force: true });
41
+ } catch {
42
+ // ignore — best-effort
43
+ }
44
+ },
45
+ };
46
+ }
47
+
48
+ const cleanups: Array<() => void> = [];
49
+ afterEach(() => {
50
+ for (const c of cleanups) c();
51
+ cleanups.length = 0;
52
+ });
53
+
54
+ const CORE_ENTRY = JSON.stringify([
55
+ {
56
+ version: "0.167.0",
57
+ type: "breaking",
58
+ title: "core helper moved",
59
+ migration: "import from /testing",
60
+ },
61
+ ]);
62
+
63
+ const FEATURE_ENTRY = JSON.stringify([{ version: "0.166.0", type: "fix", title: "feature fix" }]);
64
+
65
+ function tmp(files: Record<string, string>): string {
66
+ const t = makeTempCwd(files);
67
+ cleanups.push(t.cleanup);
68
+ return t.cwd;
69
+ }
70
+
71
+ async function runJson(cwd: string, from: string): Promise<{ pending: Array<{ title: string }> }> {
72
+ const spy = makeSpyOutput();
73
+ const exit = await runUpgradeCli(["--from", from, "--json"], cwd, spy.out);
74
+ expect(exit).toBe(0);
75
+ return JSON.parse(spy.logs.join("\n"));
76
+ }
77
+
78
+ describe("upgrade command — framework core changelog", () => {
79
+ test("collects core changes.json from the framework repo layout", async () => {
80
+ const cwd = tmp({
81
+ "packages/framework/src/changes.json": CORE_ENTRY,
82
+ "packages/bundled-features/src/user/changes.json": FEATURE_ENTRY,
83
+ });
84
+
85
+ const result = await runJson(cwd, "0.165.0");
86
+
87
+ expect(result.pending.map((e) => e.title)).toEqual(["core helper moved", "feature fix"]);
88
+ });
89
+
90
+ test("finds core changes.json in hoisted node_modules from an app subdir", async () => {
91
+ const cwd = tmp({
92
+ "node_modules/@cosmicdrift/kumiko-framework/src/changes.json": CORE_ENTRY,
93
+ "apps/web/package.json": "{}",
94
+ });
95
+
96
+ const result = await runJson(`${cwd}/apps/web`, "0.165.0");
97
+
98
+ expect(result.pending.map((e) => e.title)).toEqual(["core helper moved"]);
99
+ });
100
+
101
+ test("repo file wins over node_modules — no duplicate entries", async () => {
102
+ const cwd = tmp({
103
+ "packages/framework/src/changes.json": CORE_ENTRY,
104
+ "node_modules/@cosmicdrift/kumiko-framework/src/changes.json": CORE_ENTRY,
105
+ });
106
+
107
+ const result = await runJson(cwd, "0.165.0");
108
+
109
+ expect(result.pending).toHaveLength(1);
110
+ });
111
+
112
+ test("feature entries are not duplicated by the workspace symlink", async () => {
113
+ const cwd = tmp({
114
+ "packages/bundled-features/src/user/changes.json": FEATURE_ENTRY,
115
+ "node_modules/@cosmicdrift/kumiko-bundled-features/src/user/changes.json": FEATURE_ENTRY,
116
+ });
117
+
118
+ const result = await runJson(cwd, "0.165.0");
119
+
120
+ expect(result.pending).toHaveLength(1);
121
+ });
122
+
123
+ test("core entries older than the current version are filtered out", async () => {
124
+ const cwd = tmp({ "packages/framework/src/changes.json": CORE_ENTRY });
125
+
126
+ const result = await runJson(cwd, "0.167.0");
127
+
128
+ expect(result.pending).toEqual([]);
129
+ });
130
+
131
+ test("--from is rejected when it isn't a valid semver — no silent 'nothing new'", async () => {
132
+ const cwd = tmp({ "packages/framework/src/changes.json": CORE_ENTRY });
133
+ const spy = makeSpyOutput();
134
+
135
+ const exit = await runUpgradeCli(["--from", "latest", "--json"], cwd, spy.out);
136
+
137
+ expect(exit).toBe(1);
138
+ expect(spy.errs.join("\n")).toContain("Invalid version format");
139
+ expect(spy.logs).toEqual([]);
140
+ });
141
+ });
142
+
143
+ describe("upgrade command — enterprise package layout", () => {
144
+ // Layout is detected by presence of changes.json, not an "ai-" name
145
+ // prefix — the old heuristic silently dropped every enterprise package
146
+ // whose name didn't start with "ai-" (fw#1605).
147
+ test("collects changes.json from a package without an 'ai-' prefix", async () => {
148
+ const cwd = tmp({
149
+ "packages/billing-designer/src/changes.json": FEATURE_ENTRY,
150
+ });
151
+
152
+ const result = await runJson(cwd, "0.165.0");
153
+
154
+ expect(result.pending.map((e) => e.title)).toEqual(["feature fix"]);
155
+ });
156
+
157
+ test("flat layout (no src/ subdir) is also collected", async () => {
158
+ const cwd = tmp({
159
+ "packages/billing-designer/changes.json": FEATURE_ENTRY,
160
+ });
161
+
162
+ const result = await runJson(cwd, "0.165.0");
163
+
164
+ expect(result.pending.map((e) => e.title)).toEqual(["feature fix"]);
165
+ });
166
+ });
167
+
168
+ // The repo actually checked out on disk — scripts/codemod/ isn't published,
169
+ // so --apply only ever works against a real local framework checkout.
170
+ const REAL_REPO_ROOT = join(import.meta.dir, "../../../..");
171
+ const REAL_CODEMOD = "scripts/codemod/crypto-shredding-testing-move.ts";
172
+
173
+ function breakingEntryWithCodemod(codemod: string | undefined): string {
174
+ const entry: Record<string, unknown> = {
175
+ version: "0.167.0",
176
+ type: "breaking",
177
+ title: "helper moved",
178
+ migration: "import from /testing",
179
+ };
180
+ if (codemod !== undefined) entry["codemod"] = codemod;
181
+ return JSON.stringify([entry]);
182
+ }
183
+
184
+ const LEGACY_IMPORT_FIXTURE = [
185
+ 'import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/crypto";',
186
+ "",
187
+ "resetPiiSubjectKmsForTests();",
188
+ "",
189
+ ].join("\n");
190
+
191
+ describe("resolveCodemodScript", () => {
192
+ test("resolves a real script under scripts/codemod/", () => {
193
+ const resolved = resolveCodemodScript(REAL_REPO_ROOT, REAL_CODEMOD);
194
+ expect(resolved).toBe(join(REAL_REPO_ROOT, REAL_CODEMOD));
195
+ });
196
+
197
+ test("rejects an absolute path", () => {
198
+ expect(resolveCodemodScript(REAL_REPO_ROOT, "/etc/passwd.ts")).toBeNull();
199
+ });
200
+
201
+ test("rejects path traversal that escapes scripts/codemod/", () => {
202
+ expect(
203
+ resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/../../package.json.ts"),
204
+ ).toBeNull();
205
+ expect(resolveCodemodScript(REAL_REPO_ROOT, "../outside/x.ts")).toBeNull();
206
+ });
207
+
208
+ test("rejects a non-.ts file", () => {
209
+ expect(resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/README.md")).toBeNull();
210
+ });
211
+
212
+ test("rejects a script that doesn't exist", () => {
213
+ expect(resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/does-not-exist.ts")).toBeNull();
214
+ });
215
+
216
+ test("rejects an undefined codemod field", () => {
217
+ expect(resolveCodemodScript(REAL_REPO_ROOT, undefined)).toBeNull();
218
+ });
219
+ });
220
+
221
+ describe("upgrade command — --apply", () => {
222
+ test("runs the real codemod against a fixture file and writes the marker", async () => {
223
+ const cwd = tmp({
224
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
225
+ "legacy-test-helper.ts": LEGACY_IMPORT_FIXTURE,
226
+ });
227
+ const spy = makeSpyOutput();
228
+
229
+ const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
230
+ repoRoot: REAL_REPO_ROOT,
231
+ });
232
+
233
+ expect(exit).toBe(0);
234
+
235
+ const rewritten = readFileSync(join(cwd, "legacy-test-helper.ts"), "utf-8");
236
+ expect(rewritten).toContain('from "@cosmicdrift/kumiko-framework/testing"');
237
+ expect(rewritten).not.toContain('from "@cosmicdrift/kumiko-framework/crypto"');
238
+
239
+ const markerPath = join(cwd, ".kumiko/upgrade-state.json");
240
+ expect(existsSync(markerPath)).toBe(true);
241
+ const marker = JSON.parse(readFileSync(markerPath, "utf-8"));
242
+ expect(marker.version).toBe("0.167.0");
243
+ expect(typeof marker.appliedAt).toBe("string");
244
+ expect(marker.codemods).toEqual([
245
+ { version: "0.167.0", codemod: REAL_CODEMOD, title: "helper moved" },
246
+ ]);
247
+ });
248
+
249
+ test("--dry-run runs the codemod but changes nothing and writes no marker", async () => {
250
+ const cwd = tmp({
251
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
252
+ "legacy-test-helper.ts": LEGACY_IMPORT_FIXTURE,
253
+ });
254
+ const spy = makeSpyOutput();
255
+
256
+ const exit = await runUpgradeCli(["--from", "0.165.0", "--apply", "--dry-run"], cwd, spy.out, {
257
+ repoRoot: REAL_REPO_ROOT,
258
+ });
259
+
260
+ expect(exit).toBe(0);
261
+ expect(readFileSync(join(cwd, "legacy-test-helper.ts"), "utf-8")).toBe(LEGACY_IMPORT_FIXTURE);
262
+ expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
263
+ expect(spy.logs.join("\n")).toContain("Touched 1 files, moved 1 import(s)");
264
+ });
265
+
266
+ test("rejects a path-traversal codemod field and writes no marker", async () => {
267
+ const cwd = tmp({
268
+ "packages/framework/src/changes.json": breakingEntryWithCodemod("../../../etc/passwd.ts"),
269
+ });
270
+ const spy = makeSpyOutput();
271
+
272
+ const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
273
+ repoRoot: REAL_REPO_ROOT,
274
+ });
275
+
276
+ expect(exit).toBe(1);
277
+ expect(spy.errs.join("\n")).toContain("invalid codemod path");
278
+ expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
279
+ });
280
+
281
+ test("fails when the codemod script doesn't exist, writes no marker", async () => {
282
+ const cwd = tmp({
283
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(
284
+ "scripts/codemod/does-not-exist.ts",
285
+ ),
286
+ });
287
+ const spy = makeSpyOutput();
288
+
289
+ const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
290
+ repoRoot: REAL_REPO_ROOT,
291
+ });
292
+
293
+ expect(exit).toBe(1);
294
+ expect(spy.errs.join("\n")).toContain("invalid codemod path");
295
+ expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
296
+ });
297
+
298
+ test("breaking changes without a codemod field are reported as manual, no marker written", async () => {
299
+ const cwd = tmp({
300
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(undefined),
301
+ });
302
+ const spy = makeSpyOutput();
303
+
304
+ const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
305
+ repoRoot: REAL_REPO_ROOT,
306
+ });
307
+
308
+ expect(exit).toBe(0);
309
+ expect(spy.logs.join("\n")).toContain("no codemod, manual migration required");
310
+ expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
311
+ });
312
+
313
+ test("nothing pending: reports up to date, still bootstraps the marker", async () => {
314
+ const cwd = tmp({
315
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
316
+ });
317
+ const spy = makeSpyOutput();
318
+
319
+ const exit = await runUpgradeCli(["--from", "0.170.0", "--apply"], cwd, spy.out, {
320
+ repoRoot: REAL_REPO_ROOT,
321
+ });
322
+
323
+ expect(exit).toBe(0);
324
+ expect(spy.logs.join("\n")).toContain("Nothing new since your version");
325
+
326
+ const markerPath = join(cwd, ".kumiko/upgrade-state.json");
327
+ expect(existsSync(markerPath)).toBe(true);
328
+ const marker = JSON.parse(readFileSync(markerPath, "utf-8"));
329
+ expect(marker.version).toBe("0.170.0");
330
+ expect(marker.codemods).toEqual([]);
331
+ expect(typeof marker.appliedAt).toBe("string");
332
+ });
333
+
334
+ test("nothing pending + --dry-run: reports up to date, writes no marker", async () => {
335
+ const cwd = tmp({
336
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
337
+ });
338
+ const spy = makeSpyOutput();
339
+
340
+ const exit = await runUpgradeCli(["--from", "0.170.0", "--apply", "--dry-run"], cwd, spy.out, {
341
+ repoRoot: REAL_REPO_ROOT,
342
+ });
343
+
344
+ expect(exit).toBe(0);
345
+ expect(spy.logs.join("\n")).toContain("Nothing new since your version");
346
+ expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
347
+ });
348
+
349
+ test("--dir targets a different directory than cwd", async () => {
350
+ const cwd = tmp({
351
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
352
+ });
353
+ const target = tmp({ "legacy-test-helper.ts": LEGACY_IMPORT_FIXTURE });
354
+ const spy = makeSpyOutput();
355
+
356
+ const exit = await runUpgradeCli(
357
+ ["--from", "0.165.0", "--apply", "--dir", target],
358
+ cwd,
359
+ spy.out,
360
+ {
361
+ repoRoot: REAL_REPO_ROOT,
362
+ },
363
+ );
364
+
365
+ expect(exit).toBe(0);
366
+ const rewritten = readFileSync(join(target, "legacy-test-helper.ts"), "utf-8");
367
+ expect(rewritten).toContain('from "@cosmicdrift/kumiko-framework/testing"');
368
+ expect(existsSync(join(target, ".kumiko/upgrade-state.json"))).toBe(true);
369
+ });
370
+ });
@@ -0,0 +1,66 @@
1
+ // Small arg parser for CLI commands. Not as feature-rich as commander or
2
+ // yargs — just:
3
+ // - Positional args: cmd "value1" "value2"
4
+ // - Flags: --flag (boolean) / --key value
5
+ // - Negatable: --no-flag
6
+ //
7
+ // Copy of bin/commands/arg-parser.ts — registry-free, but upgrade-cli.ts
8
+ // lives in the published @cosmicdrift/kumiko-framework package and can't
9
+ // import from bin/commands/ (outside the package's export surface).
10
+
11
+ export type ParsedArgs = {
12
+ readonly positional: ReadonlyArray<string>;
13
+ readonly flags: ReadonlyMap<string, string | boolean>;
14
+ };
15
+
16
+ export function parseArgs(argv: ReadonlyArray<string>): ParsedArgs {
17
+ const positional: string[] = [];
18
+ const flags = new Map<string, string | boolean>();
19
+
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (!a) continue;
23
+ if (a.startsWith("--")) {
24
+ const key = a.slice(2);
25
+ // `--no-foo` form
26
+ if (key.startsWith("no-")) {
27
+ flags.set(key.slice(3), false);
28
+ continue;
29
+ }
30
+ // `--key=value` inline form
31
+ const eq = key.indexOf("=");
32
+ if (eq !== -1) {
33
+ flags.set(key.slice(0, eq), key.slice(eq + 1));
34
+ continue;
35
+ }
36
+ // `--key value` if next isn't a flag, else boolean
37
+ const next = argv[i + 1];
38
+ if (next !== undefined && !next.startsWith("--")) {
39
+ flags.set(key, next);
40
+ i++;
41
+ } else {
42
+ flags.set(key, true);
43
+ }
44
+ } else {
45
+ positional.push(a);
46
+ }
47
+ }
48
+
49
+ return { positional, flags };
50
+ }
51
+
52
+ export function getFlag(args: ParsedArgs, name: string): boolean {
53
+ return args.flags.get(name) === true;
54
+ }
55
+
56
+ export function getStringFlag(args: ParsedArgs, name: string): string | undefined {
57
+ const v = args.flags.get(name);
58
+ return typeof v === "string" ? v : undefined;
59
+ }
60
+
61
+ export function getNumberFlag(args: ParsedArgs, name: string): number | undefined {
62
+ const v = args.flags.get(name);
63
+ if (typeof v !== "string") return undefined;
64
+ const n = Number.parseInt(v, 10);
65
+ return Number.isNaN(n) ? undefined : n;
66
+ }
package/src/changes.json CHANGED
@@ -1,4 +1,60 @@
1
1
  [
2
+ {
3
+ "version": "0.209.1",
4
+ "type": "breaking",
5
+ "title": "Job runs no longer go through the event store; read_job_runs table renamed to store_job_runs (fw#2243).",
6
+ "detail": "Job runs (jobRun) no longer go through the event store. Every job execution used to append a run-started + run-completed/run-failed event replayed through two inline projections — in the busiest apps this was ~99% of all events ever written, for data nothing else replays or subscribes to. onJobStart/onJobComplete/onJobFailed now write straight into the (renamed) store_job_runs / store_job_run_logs tables, with a new daily jobs:job:retention-cleanup job (retentionDays, default 30) purging old rows so the tables don't grow forever.",
7
+ "migration": "Breaking for raw-SQL consumers: the table is renamed read_job_runs → store_job_runs (store_job_run_logs is unchanged). The migration drops read_job_runs outright — old run history is not preserved, it was operational/debug data, not a system of record. Apps that only use the shipped job-runs-screen/jobs:query:* handlers are unaffected; apps with a raw SQL dependency on read_job_runs need a follow-up on their side."
8
+ },
9
+ {
10
+ "version": "0.201.0",
11
+ "type": "breaking",
12
+ "title": "IdempotencyGuard.check()/.store() gain a discriminated result + token param on top of the 0.198.0 signature (fw#2139).",
13
+ "detail": "Fixes two idempotency-lock races that could let a duplicate request re-run a write handler or silently overwrite a fresher cached result. `waitTimeoutMs` (how long a duplicate request waits for the in-flight one) is now clamped to always exceed `pendingTtlSeconds` (the in-progress lock's own TTL) — previously the defaults (30s lock vs. 25s wait) let a retry give up and re-execute the handler while the original call was still legitimately running. `IdempotencyGuard.store()` now does an atomic compare-and-swap against the exact lock token the calling run acquired (Redis EVAL) instead of an unconditional SET, so a stale, slow-finishing run can no longer stomp the result a reclaiming run already persisted after the lock expired. `IdempotencyGuard.check()` now returns a discriminated `{ status: \"cached\", result }` / `{ status: \"acquired\", token }` union instead of `string | null`, and `store()` takes the acquired token as a new parameter.",
14
+ "migration": "Layered on top of the 0.198.0 signature change: check() is now check(tenantId, userId, requestId) returning { status: \"cached\", result } | { status: \"acquired\", token }; store() is now store(tenantId, userId, requestId, token). Both call sites in this repo (dispatch-batch.ts, the dispatcher test mock) are already updated; any code outside this repo calling IdempotencyGuard directly needs the same update."
15
+ },
16
+ {
17
+ "version": "0.201.0",
18
+ "type": "breaking",
19
+ "title": "GET /files/:id now sniffs bytes and serves svg/txt/csv/json/md as application/octet-stream instead of inline (fw#2140).",
20
+ "detail": "GET /files/:id served the stored mimeType as Content-Type without verifying it against the file's actual bytes — a client can declare any MIME at upload time, so an attacker could upload real HTML/SVG content and have it served back with a trusted-looking Content-Type from the app origin, enabling stored XSS. Uploads themselves are still accepted regardless of declared MIME (this is unchanged); the fix hardens serving instead. The download route now sniffs the file's magic bytes and only serves the sniffed Content-Type inline when it matches a known-safe binary signature (png/jpeg/gif/webp/pdf) AND matches the declared MIME from upload. Anything else — including a genuine mismatch, or file types with no reliable binary signature such as svg/txt/csv/json/md — is now served as application/octet-stream. This also adds X-Content-Type-Options: nosniff to GET /files/:id, which previously had none.",
21
+ "migration": "Breaking for consumers that render uploaded svg/txt/csv/json/md files inline (e.g. an <img src> pointing at GET /files/:id): those now download as application/octet-stream instead of rendering. Route such content through a purpose-built safe viewer if inline rendering is required."
22
+ },
23
+ {
24
+ "version": "0.198.0",
25
+ "type": "breaking",
26
+ "title": "IdempotencyGuard.check/.store signature changed to (tenantId, userId, requestId); SqlExpression is branded (fw#2049).",
27
+ "detail": "Security hardening (audit \"Welle 2\"): closes a request-supplied-JSON-can-forge-raw-SQL path and a cross-tenant idempotency-cache collision. `SqlExpression` is now branded — only the `sql` template tag and `sql.raw(...)` produce a value the query layer recognizes as raw SQL; an object literal built by hand (`{ kind: \"sql-expr\", sql: ..., params: ... }`) is no longer treated as raw SQL and gets bound as an ordinary JSON parameter instead, surfacing as a broken query rather than a silent vulnerability. `IdempotencyGuard.check`/`.store` moved from `(requestId)` to `(tenantId, userId, requestId)` so the idempotency cache can no longer be hit across tenants/users by an attacker who guesses or replays a requestId; the Redis key format changed from `${prefix}${requestId}` to `${prefix}${tenantId}:${userId}:${requestId}` with no compatibility shim.",
28
+ "migration": "Replace any hand-built SqlExpression object literal with the `sql` tag or `sql.raw(...)`. Any custom IdempotencyGuard implementation, or code calling `.check`/`.store` directly (outside the dispatcher's own runBatch, which already updated), needs the new (tenantId, userId, requestId) signature. On deploy, in-flight idempotent retries older than the request's own retry window may execute a second time — same as a first-ever request, not a correctness issue, just not a cache hit."
29
+ },
30
+ {
31
+ "version": "0.198.0",
32
+ "type": "breaking",
33
+ "title": "event-store-executor.list() now throws 422 search_adapter_not_wired instead of returning unfiltered results (fw#2032).",
34
+ "detail": "event-store-executor.list() silently dropped payload.search when no SearchAdapter was wired (neither at build time via options.searchAdapter nor at runtime via runtimeOptions.searchAdapter) — the list came back unfiltered, indistinguishable from a real search result. Now throws UnprocessableError (code: \"unprocessable\", details.reason: \"search_adapter_not_wired\", details.entity) instead.",
35
+ "migration": "Breaking for consumers whose entities are searchable but have no SearchAdapter wired: a search request that used to silently no-op now returns a 422. Wire a SearchAdapter (e.g. Meilisearch) for the entity, or stop marking the field/screen searchable."
36
+ },
37
+ {
38
+ "version": "0.198.0",
39
+ "type": "breaking",
40
+ "title": "NavIconKey closed union replaces icon?: string on nav/config-mask definitions (fw#2055).",
41
+ "detail": "NavDefinition.icon, ContentCollectionDefinition.nav.icon, ScreenNavSugar.icon and ConfigMask.icon were all icon?: string — any typo (icon: \"seting\") compiled fine and silently fell back to a dot in the sidebar. New NavIconKey union (@cosmicdrift/kumiko-types/nav-icon, re-exported from @cosmicdrift/kumiko-framework/{engine,ui-types}) types all four against the closed set of keys the web renderer actually registers, so an unregistered icon key is now a compile error at the r.nav()/r.screen({ nav })/config-mask call site instead of a missing icon at runtime. packages/renderer-web's NAV_ICONS map is checked against the same union via `as const satisfies Record<NavIconKey, …>`, so the type and the map can no longer drift.",
42
+ "migration": "Breaking for any app that passes an icon key outside the vocabulary in packages/types/src/nav-icon.ts — such a call site will fail to compile after this bump. Fix the typo or add the missing key to both NavIconKey and renderer-web's NAV_ICONS map in the same change."
43
+ },
44
+ {
45
+ "version": "0.193.0",
46
+ "type": "breaking",
47
+ "title": "Image fields get named derived variants; ImageFieldDef/ImagesFieldDef.thumbnails removed (fw#1973).",
48
+ "detail": "createImageField now accepts variants: Record<string, VariantSpec> — boot-validated named derived-image specs, served via GET /api/files/:id/variant/:name behind the same tenant + access guard as the download. A request carries only a NAME, never a spec, so no caller can drive an arbitrary render. The edit-form preview loads the first declared variant instead of the original.",
49
+ "migration": "ImageFieldDef.thumbnails / ImagesFieldDef.thumbnails are removed — the flag was never read by anything. Replace any reliance on it with a declared variants entry."
50
+ },
51
+ {
52
+ "version": "0.189.0",
53
+ "type": "breaking",
54
+ "title": "createDateField now backs a real Postgres DATE column, round-trips as Temporal.PlainDate (fw#1924).",
55
+ "detail": "type:\"date\" fields were silently aliased onto the same instant()/TIMESTAMPTZ column as type:\"timestamp\": reads returned a full ISO instant (\"2026-03-15T00:00:00Z\"), writes expected a bare \"yyyy-mm-dd\" string bound to a timestamptz column through the session's TimeZone — both directions were timezone-dependent for what is meant to be a pure calendar-day value. A date field now serializes as \"2026-03-15\" (Temporal.PlainDate's own toJSON()); a non-form client that Instant-parses a date field's JSON value now throws. Write shape is unchanged (bare \"yyyy-mm-dd\").",
56
+ "migration": "Managed (event-sourced projection) tables: the generator emits DROP TABLE + CREATE TABLE and replays from the event log automatically — factor in replay cost for entities with a large event history. Unmanaged (store_*, direct-write) tables: the generator emits an in-place ALTER TABLE … ALTER COLUMN … TYPE date USING (col AT TIME ZONE 'UTC')::date, anchored explicitly at UTC — do not hand-write a bare ALTER COLUMN … TYPE date without USING, which falls back to Postgres's session-TimeZone-dependent implicit cast."
57
+ },
2
58
  {
3
59
  "version": "0.177.0",
4
60
  "type": "breaking",
@@ -41,7 +41,7 @@ import { asRawClient } from "../../bun-db";
41
41
  import { quoteIdent } from "../../crypto/ciphertext-pattern";
42
42
  import { configuredEventPiiCatalog } from "../../crypto/event-pii";
43
43
  import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
44
- import { KeyErasedError } from "../../crypto/kms-adapter";
44
+ import { KeyErasedError, KeyNotFoundError } from "../../crypto/kms-adapter";
45
45
  import {
46
46
  configuredPiiSubjectKms,
47
47
  encryptPiiValueForSubject,
@@ -83,7 +83,8 @@ export type PiiBackfillResult = {
83
83
 
84
84
  export type PiiBackfillOptions = {
85
85
  readonly batchSize?: number;
86
- // Scan + count only, write nothing.
86
+ // Scan + count only, write nothing — including the subject KMS: outcomes
87
+ // are predicted from a read-only kms.getKey probe, never kms.createKey.
87
88
  readonly dryRun?: boolean;
88
89
  // Stage 2: fall back to the entity's projection row (by aggregate_id)
89
90
  // when a lifecycle event's payload doesn't name the owner field.
@@ -350,6 +351,7 @@ export async function backfillEventPiiEncryption(
350
351
  section[field] = PII_ERASED_SENTINEL;
351
352
  return "erased";
352
353
  }
354
+ if (options.dryRun) return predictEncryptOutcome(subject);
353
355
  try {
354
356
  section[field] = await encryptPiiValueForSubject(
355
357
  kms as LocalKeyKmsAdapter,
@@ -367,6 +369,21 @@ export async function backfillEventPiiEncryption(
367
369
  throw e;
368
370
  }
369
371
  }
372
+
373
+ // getKey alone never mints (unlike getOrCreateDek → createKey on the real
374
+ // path) — a real run's outcome is fully determined by whether a key
375
+ // already exists, so probing it read-only predicts the outcome without
376
+ // writing to the subject-keys store.
377
+ async function predictEncryptOutcome(subject: SubjectId): Promise<FieldOutcome> {
378
+ try {
379
+ await (kms as LocalKeyKmsAdapter).getKey(subject, kmsCtx);
380
+ return "encrypted";
381
+ } catch (e) {
382
+ if (e instanceof KeyErasedError) return "erased";
383
+ if (e instanceof KeyNotFoundError) return "encrypted";
384
+ throw e;
385
+ }
386
+ }
370
387
  }
371
388
 
372
389
  function isForgottenSubject(subject: SubjectId, aggregateId: string): boolean {