@cosmicdrift/kumiko-framework 0.220.1 → 0.221.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.
Files changed (69) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/store-table.integration.test.ts +2 -1
  3. package/src/__tests__/upgrade-cli.test.ts +81 -12
  4. package/src/api/api-constants.ts +10 -0
  5. package/src/api/index.ts +1 -0
  6. package/src/api/server.ts +17 -1
  7. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  8. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  9. package/src/bun-db/index.ts +1 -0
  10. package/src/bun-db/query.ts +30 -14
  11. package/src/crypto/index.ts +1 -0
  12. package/src/crypto/is-self-pii-field.ts +8 -0
  13. package/src/crypto/subject-resolver.ts +4 -3
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  15. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  16. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  17. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  18. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  19. package/src/db/blind-index-cleanup.ts +36 -19
  20. package/src/db/event-store-executor-context.ts +2 -2
  21. package/src/db/event-store-executor-read.ts +7 -6
  22. package/src/db/event-store-executor-write.ts +103 -49
  23. package/src/db/index.ts +2 -0
  24. package/src/db/migrate-generator.ts +14 -0
  25. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  26. package/src/db/queries/backfill-pii.ts +13 -10
  27. package/src/db/queries/raw-sql.ts +14 -2
  28. package/src/db/queries/seed-context.ts +8 -4
  29. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  30. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  31. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  32. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  33. package/src/engine/__tests__/schema-builder.test.ts +3 -3
  34. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  35. package/src/engine/boot-validator/entity-handler.ts +5 -0
  36. package/src/engine/boot-validator/pii-retention.ts +16 -4
  37. package/src/engine/boot-validator/screens.ts +9 -2
  38. package/src/engine/embedded-derived.ts +11 -10
  39. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  40. package/src/engine/feature-ast/patch.ts +9 -0
  41. package/src/engine/role-assignment.ts +36 -18
  42. package/src/errors/__tests__/classes.test.ts +5 -0
  43. package/src/errors/__tests__/write-failures.test.ts +3 -3
  44. package/src/errors/kumiko-error.ts +11 -11
  45. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  46. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  47. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  48. package/src/event-store/event-store.ts +7 -0
  49. package/src/event-store/index.ts +1 -0
  50. package/src/files/__tests__/files.integration.test.ts +181 -2
  51. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  52. package/src/files/file-routes.ts +53 -6
  53. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  54. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  55. package/src/i18n/index.ts +7 -1
  56. package/src/i18n/mail-registry.ts +10 -0
  57. package/src/i18n/request-locale.ts +11 -2
  58. package/src/i18n/required-surface-keys.ts +3 -1
  59. package/src/lifecycle/signal-handlers.ts +2 -0
  60. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  61. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
  62. package/src/pipeline/distributed-lock.ts +3 -0
  63. package/src/schema-cli.ts +9 -4
  64. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  65. package/src/search/purge-subject.ts +4 -3
  66. package/src/search/reindex-entity.ts +2 -2
  67. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  68. package/src/ui-types/index.ts +1 -0
  69. package/src/upgrade-cli.ts +100 -14
@@ -90,6 +90,18 @@ describe("distributed lock", () => {
90
90
  expect(await lock.acquire("test-lock-7")).not.toBeNull();
91
91
  });
92
92
 
93
+ test("renew with ttlSeconds 0 does not drop the lock", async () => {
94
+ const lock = createDistributedLock(testRedis.redis);
95
+ const token = await lock.acquire("test-lock-ttl0", { ttlSeconds: 5 });
96
+ if (!token) throw new Error("expected token");
97
+ const renewed = await lock.renew("test-lock-ttl0", token, 0);
98
+ expect(renewed).toBe(false);
99
+ // Original lock still held — a peer acquire must fail.
100
+ const peer = await lock.acquire("test-lock-ttl0", { ttlSeconds: 5 });
101
+ expect(peer).toBeNull();
102
+ await lock.release("test-lock-ttl0", token);
103
+ });
104
+
93
105
  test("renew on an expired/absent key fails", async () => {
94
106
  const lock = createDistributedLock(testRedis.redis);
95
107
  const renewed = await lock.renew("test-lock-8-never-acquired", "some-token", 5);
@@ -1,36 +1,18 @@
1
1
  // E.4 — PG LISTEN/NOTIFY wake-up. Without this, delivery latency is
2
2
  // bounded below by pollIntervalMs. With LISTEN, event-store.append fires
3
- // `pg_notify` on commit and any subscribed dispatcher wakes immediately
4
- // latency becomes TCP round-trip, typically sub-millisecond on localhost.
3
+ // `pg_notify` on commit and any subscribed dispatcher wakes immediately.
5
4
  //
6
- // The polling timer stays on as a safety net for dropped subscriptions
7
- // and crashes between commit and wake. These tests pin:
8
- //
9
- // 1. NOTIFY runOnce fires promptly, without waiting for the timer.
10
- // 2. The dispatcher starts cleanly when pgClient is wired and stops
11
- // without leaking the LISTEN connection, and still wakes on NOTIFY
12
- // after a restart cycle.
13
- //
14
- // #2042: these used to assert an absolute millisecond latency bound
15
- // (`< 40`, later `< 100`) against the test-stack's default 50ms polling
16
- // timer. On a shared CI runner a stalled event loop can push even a
17
- // working LISTEN's delivery past 100ms — measured up to 154ms — so no
18
- // millisecond bound both clears runner noise and stays under a 50ms
19
- // timer. Fix: push the polling timer out to 60s for this stack
20
- // (`eventDispatcherPollIntervalMs`) and assert delivery happens at all
21
- // inside a 5s window. If LISTEN is dead, nothing arrives before the 5s
22
- // deadline — a 12x margin under the 60s timer that no runner stall gets
23
- // anywhere near. If LISTEN works, delivery is near-instant regardless of
24
- // runner load. Verified by temporarily forcing pgClient to undefined in
25
- // test-stack.ts: both tests then fail at toHaveLength(1) with 0 received
26
- // after ~5s, confirming the assertion still discriminates.
5
+ // The polling timer stays as a safety net. These tests pin NOTIFY wake
6
+ // without waiting for the timer, and that start/stop still leaves LISTEN
7
+ // working. #2042: poll interval is 60s here; assert delivery inside 5s —
8
+ // if LISTEN is dead the timer cannot rescue the assertion.
27
9
 
28
10
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
29
11
  import { createEventStoreExecutor } from "../../db/event-store-executor";
30
12
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
31
13
  import { defineFeature } from "../../engine";
32
14
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
33
- import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
15
+ import { sharedWidgetEntity, sharedWidgetTable, waitFor } from "../../testing";
34
16
 
35
17
  // --- Fixture ---
36
18
 
@@ -85,21 +67,22 @@ describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
85
67
  try {
86
68
  await executor.create({ name: "latency-test" }, admin, tdb);
87
69
 
88
- const deadline = Date.now() + 5000;
89
- while (Date.now() < deadline && deliveryCount === 0) {
90
- await new Promise((r) => setTimeout(r, 5));
91
- }
92
- expect(deliveryCount).toBe(1);
70
+ await waitFor(
71
+ () => {
72
+ expect(deliveryCount).toBe(1);
73
+ },
74
+ { delays: [100, 500, 1000, 3500] },
75
+ );
93
76
  } finally {
94
77
  await stack.eventDispatcher?.stop();
95
78
  }
96
79
  });
97
80
 
98
81
  test("dispatcher start/stop cycle with LISTEN attached still delivers after restart", async () => {
99
- // Repeated start/stop must not leak connections or break LISTEN. After
100
- // 3 cycles the last .start() should still wake on NOTIFY — if the
101
- // unlisten handle was mishandled, the subscription would either be
102
- // stale (LISTEN on a closed connection) or double-registered.
82
+ // Repeated start/stop must not leak connections or break LISTEN.
83
+ // Two start/stop cycles, then a third start() that must still wake on
84
+ // NOTIFY — if the unlisten handle was mishandled, the subscription
85
+ // would be stale or double-registered.
103
86
  for (let i = 0; i < 2; i++) {
104
87
  await stack.eventDispatcher?.start();
105
88
  await stack.eventDispatcher?.stop();
@@ -110,11 +93,12 @@ describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
110
93
  try {
111
94
  await executor.create({ name: "restart-probe" }, admin, tdb);
112
95
 
113
- const deadline = Date.now() + 5000;
114
- while (Date.now() < deadline && deliveryCount === 0) {
115
- await new Promise((r) => setTimeout(r, 5));
116
- }
117
- expect(deliveryCount).toBe(1);
96
+ await waitFor(
97
+ () => {
98
+ expect(deliveryCount).toBe(1);
99
+ },
100
+ { delays: [100, 500, 1000, 3500] },
101
+ );
118
102
  } finally {
119
103
  await stack.eventDispatcher?.stop();
120
104
  }
@@ -50,6 +50,9 @@ export function createDistributedLock(
50
50
  },
51
51
 
52
52
  async renew(key, token, ttlSeconds) {
53
+ // EXPIRE with 0/negative deletes the key but still returns 1 — reject
54
+ // before we claim ownership while releasing the lock.
55
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) return false;
53
56
  const result = (await redis.eval(
54
57
  renewScript,
55
58
  1,
package/src/schema-cli.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
12
12
  import { join, resolve as resolvePath } from "node:path";
13
13
  import {
14
+ assertValidMigrationName,
14
15
  baselineMigrations,
15
16
  createDbConnection,
16
17
  type DbConnection,
@@ -151,10 +152,14 @@ export async function runSchemaCli(
151
152
  out.err(" Usage: schema generate <name>");
152
153
  return 1;
153
154
  }
154
- // name lands unescaped in `${seq}_${name}.sql` (generateMigration) — this
155
- // allowlist blocks flag-like names and path traversal (`../../x`).
156
- if (name.startsWith("-") || !/^[A-Za-z0-9_-]+$/.test(name)) {
157
- out.err(` Invalid migration name "${name}" — use letters, digits, "-", "_" only.`);
155
+ // name lands unescaped in `${seq}_${name}.sql` (generateMigration) —
156
+ // shared allowlist blocks flag-like names, path traversal, and
157
+ // ENAMETOOLONG-length names.
158
+ try {
159
+ assertValidMigrationName(name);
160
+ } catch (e) {
161
+ const msg = e instanceof Error ? e.message : String(e);
162
+ out.err(` ${msg}`);
158
163
  out.err(" Usage: schema generate <name>");
159
164
  return 1;
160
165
  }
@@ -8,47 +8,55 @@
8
8
  //
9
9
  // Usage: bun scripts/codemod/crypto-shredding-testing-move.ts <targetDir> [--dry-run]
10
10
 
11
- import { resolve } from "node:path";
12
- import { Glob } from "bun";
13
- import { Project } from "ts-morph";
11
+ import { type Dirent, readdirSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { Project, type SourceFile } from "ts-morph";
14
14
 
15
15
  const OLD_SPECIFIER = "@cosmicdrift/kumiko-framework/crypto";
16
16
  const NEW_SPECIFIER = "@cosmicdrift/kumiko-framework/testing";
17
17
  const MOVED_NAMES = new Set(["resetPiiSubjectKmsForTests", "resetBlindIndexKeyForTests"]);
18
18
 
19
+ const EXCLUDE_DIRS = new Set(["node_modules", "dist", "build"]);
20
+
19
21
  function findTargetFiles(rootDir: string): string[] {
20
- const glob = new Glob("**/*.{ts,tsx}");
21
- const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
22
+ // Walk the tree and skip excluded dirs while descending — filtering the
23
+ // absolute path after a full Glob scan silently no-ops when the repo root
24
+ // itself contains "/build/" or "/node_modules/" (fw#2289).
22
25
  const files: string[] = [];
23
- for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
24
- const abs = resolve(rootDir, file);
25
- if (EXCLUDE.some((p) => abs.includes(p))) continue;
26
- files.push(abs);
27
- }
26
+ const walk = (dir: string): void => {
27
+ let entries: Dirent[];
28
+ try {
29
+ entries = readdirSync(dir, { withFileTypes: true });
30
+ } catch {
31
+ // skip: unreadable directory during walk — treat as empty.
32
+ return;
33
+ }
34
+ for (const ent of entries) {
35
+ if (ent.isDirectory()) {
36
+ if (EXCLUDE_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
37
+ walk(join(dir, ent.name));
38
+ continue;
39
+ }
40
+ if (ent.isFile() && /\.(ts|tsx)$/.test(ent.name)) {
41
+ files.push(join(dir, ent.name));
42
+ }
43
+ }
44
+ };
45
+ walk(rootDir);
28
46
  return files.sort();
29
47
  }
30
48
 
31
- async function main(): Promise<void> {
32
- const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
33
- const dryRun = process.argv.includes("--dry-run");
34
- const rootDir = resolve(positional[0] ?? process.cwd());
35
-
36
- const files = findTargetFiles(rootDir);
37
- const project = new Project({
38
- skipAddingFilesFromTsConfig: true,
39
- skipFileDependencyResolution: true,
40
- });
41
-
42
- let touchedFiles = 0;
43
- let movedNames = 0;
44
-
45
- for (const file of files) {
46
- const sourceFile = project.addSourceFileAtPath(file);
47
- const oldImport = sourceFile
48
- .getImportDeclarations()
49
- .find((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER);
50
- if (!oldImport) continue;
49
+ /** Move matching value imports from crypto → testing. Returns count of names moved. */
50
+ function migrateFile(sourceFile: SourceFile): number {
51
+ // Value imports only — a pre-existing `import type { … } from testing`
52
+ // must not receive runtime helpers (they would be erased).
53
+ const oldImports = sourceFile
54
+ .getImportDeclarations()
55
+ .filter((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER && !d.isTypeOnly());
56
+ if (oldImports.length === 0) return 0;
51
57
 
58
+ let fileMoved = 0;
59
+ for (const oldImport of oldImports) {
52
60
  const movedHere = oldImport.getNamedImports().filter((spec) => MOVED_NAMES.has(spec.getName()));
53
61
  if (movedHere.length === 0) continue;
54
62
 
@@ -56,7 +64,7 @@ async function main(): Promise<void> {
56
64
 
57
65
  const existingNewImport = sourceFile
58
66
  .getImportDeclarations()
59
- .find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER);
67
+ .find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER && !d.isTypeOnly());
60
68
  if (existingNewImport) {
61
69
  const already = new Set(existingNewImport.getNamedImports().map((s) => s.getName()));
62
70
  for (const name of names) {
@@ -73,8 +81,32 @@ async function main(): Promise<void> {
73
81
  !!oldImport.getNamespaceImport();
74
82
  if (!remaining) oldImport.remove();
75
83
 
84
+ fileMoved += names.length;
85
+ }
86
+ return fileMoved;
87
+ }
88
+
89
+ async function main(): Promise<void> {
90
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
91
+ const dryRun = process.argv.includes("--dry-run");
92
+ const rootDir = resolve(positional[0] ?? process.cwd());
93
+
94
+ const files = findTargetFiles(rootDir);
95
+ const project = new Project({
96
+ skipAddingFilesFromTsConfig: true,
97
+ skipFileDependencyResolution: true,
98
+ });
99
+
100
+ let touchedFiles = 0;
101
+ let movedNames = 0;
102
+
103
+ for (const file of files) {
104
+ const sourceFile = project.addSourceFileAtPath(file);
105
+ const fileMoved = migrateFile(sourceFile);
106
+ if (fileMoved === 0) continue;
107
+
76
108
  touchedFiles++;
77
- movedNames += names.length;
109
+ movedNames += fileMoved;
78
110
  if (!dryRun) sourceFile.saveSync();
79
111
  }
80
112
 
@@ -9,11 +9,12 @@
9
9
  // that still carry the subject key in encrypted columns.
10
10
 
11
11
  import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
12
+ import { isSelfPiiField } from "../crypto/is-self-pii-field";
12
13
  import type { SubjectId } from "../crypto/kms-adapter";
13
14
  import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
14
15
  import type { DbRunner } from "../db/connection";
15
16
  import { resolveTableName } from "../db/entity-table-meta";
16
- import { executeRawQuery } from "../db/queries/raw-sql";
17
+ import { executeRawQueryRead } from "../db/queries/raw-sql";
17
18
  import type { FeatureDefinition } from "../engine/types";
18
19
  import type { EntityDefinition } from "../engine/types/fields";
19
20
  import type { EntityId, TenantId } from "../engine/types/identifiers";
@@ -46,7 +47,7 @@ function ownershipPredicates(
46
47
  params.push(subject.userId);
47
48
  parts.push(`${quoteIdent(col)} = $${n}`);
48
49
  }
49
- } else if ("pii" in field && field.pii === true && selfIdN === undefined) {
50
+ } else if (isSelfPiiField(field) && selfIdN === undefined) {
50
51
  selfIdN = nextParam();
51
52
  params.push(subject.userId);
52
53
  parts.push(`${quoteIdent("id")} = $${selfIdN}`);
@@ -78,7 +79,7 @@ async function collectMatchingRowsForEntity(
78
79
  let offset = 0;
79
80
  for (;;) {
80
81
  const offsetN = params.length + 1;
81
- const page = await executeRawQuery<MatchedRow>(
82
+ const page = await executeRawQueryRead<MatchedRow>(
82
83
  db,
83
84
  `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${whereSql}
84
85
  ORDER BY ${quoteIdent("id")} ASC
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { DbRunner } from "../db/connection";
9
9
  import { resolveTableName } from "../db/entity-table-meta";
10
- import { executeRawQuery } from "../db/queries/raw-sql";
10
+ import { executeRawQueryRead } from "../db/queries/raw-sql";
11
11
  import type { Registry, TenantId } from "../engine/types";
12
12
  import {
13
13
  buildSearchDocument,
@@ -112,7 +112,7 @@ export async function reindexEntity(
112
112
  // across both types needs a text cast that breaks integer ordering.
113
113
  // This is a one-time backfill over existing rows, not a live hot path;
114
114
  // switch to keyset if it ever needs to run against a churning table.
115
- const rows = await executeRawQuery<Record<string, unknown>>(
115
+ const rows = await executeRawQueryRead<Record<string, unknown>>(
116
116
  db,
117
117
  `SELECT * FROM ${quoteIdent(tableName)}
118
118
  WHERE ${quoteIdent("tenant_id")} = $1 ${deletedFilter}
@@ -10,7 +10,7 @@ import { NotFoundError, UnprocessableError, writeFailure } from "../../errors";
10
10
  import { setupTestStack, type TestStack } from "../test-stack";
11
11
  import { TestUsers } from "../test-users";
12
12
 
13
- let stack: TestStack;
13
+ let stack: TestStack | undefined;
14
14
 
15
15
  const pingFeature = defineFeature("reqhelp", (r) => {
16
16
  r.writeHandler(
@@ -49,12 +49,12 @@ beforeAll(async () => {
49
49
  });
50
50
 
51
51
  afterAll(async () => {
52
- await stack.cleanup();
52
+ await stack?.cleanup();
53
53
  });
54
54
 
55
55
  describe("createRequestHelper via setupTestStack.http", () => {
56
56
  test("writeOk posts to /api/write and returns handler data", async () => {
57
- const data = await stack.http.writeOk<{ note: string; userId: string }>(
57
+ const data = await stack!.http.writeOk<{ note: string; userId: string }>(
58
58
  "reqhelp:write:echo",
59
59
  { note: "hello" },
60
60
  TestUsers.admin,
@@ -63,7 +63,7 @@ describe("createRequestHelper via setupTestStack.http", () => {
63
63
  });
64
64
 
65
65
  test("queryOk posts to /api/query and returns handler data", async () => {
66
- const data = await stack.http.queryOk<{ id: string; ok: boolean }>(
66
+ const data = await stack!.http.queryOk<{ id: string; ok: boolean }>(
67
67
  "reqhelp:query:lookup",
68
68
  { id: "abc" },
69
69
  TestUsers.admin,
@@ -72,14 +72,14 @@ describe("createRequestHelper via setupTestStack.http", () => {
72
72
  });
73
73
 
74
74
  test("writeErr returns structured WriteErrorInfo with httpStatus", async () => {
75
- const err = await stack.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
75
+ const err = await stack!.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
76
76
  expect(err.code).toBe("unprocessable");
77
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
77
+ expect(err.httpStatus).toBe(422);
78
78
  expect(err.i18nKey).toBe("errors.unprocessable");
79
79
  });
80
80
 
81
81
  test("queryErr returns structured WriteErrorInfo for not-found", async () => {
82
- const err = await stack.http.queryErr(
82
+ const err = await stack!.http.queryErr(
83
83
  "reqhelp:query:lookup",
84
84
  { id: "missing" },
85
85
  TestUsers.admin,
@@ -89,13 +89,13 @@ describe("createRequestHelper via setupTestStack.http", () => {
89
89
  });
90
90
 
91
91
  test("writeOk throws when the write fails (so suites cannot ignore failures)", async () => {
92
- await expect(stack.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
92
+ await expect(stack!.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
93
93
  /reqhelp:write:boom/,
94
94
  );
95
95
  });
96
96
 
97
97
  test("batch posts commands and returns per-command results", async () => {
98
- const res = await stack.http.batch(
98
+ const res = await stack!.http.batch(
99
99
  [
100
100
  { type: "reqhelp:write:echo", payload: { note: "a" } },
101
101
  { type: "reqhelp:write:echo", payload: { note: "b" } },
@@ -112,7 +112,7 @@ describe("createRequestHelper via setupTestStack.http", () => {
112
112
  });
113
113
 
114
114
  test("writeWithHeaders forwards extra headers alongside auth", async () => {
115
- const res = await stack.http.writeWithHeaders(
115
+ const res = await stack!.http.writeWithHeaders(
116
116
  "reqhelp:write:echo",
117
117
  { note: "hdr" },
118
118
  TestUsers.admin,
@@ -122,5 +122,19 @@ describe("createRequestHelper via setupTestStack.http", () => {
122
122
  const body = (await res.json()) as { isSuccess?: boolean; data?: { note?: string } };
123
123
  expect(body.isSuccess).toBe(true);
124
124
  expect(body.data?.note).toBe("hdr");
125
+ expect(res.headers.get("X-Correlation-ID")).toBe("corr-42");
126
+ });
127
+
128
+ test("queryWithHeaders forwards extra headers alongside auth", async () => {
129
+ const res = await stack!.http.queryWithHeaders(
130
+ "reqhelp:query:lookup",
131
+ { id: "abc" },
132
+ TestUsers.admin,
133
+ { "X-Correlation-ID": "corr-query" },
134
+ );
135
+ expect(res.ok).toBe(true);
136
+ expect(res.headers.get("X-Correlation-ID")).toBe("corr-query");
137
+ const body = (await res.json()) as { data?: { id?: string; ok?: boolean } };
138
+ expect(body.data).toEqual({ id: "abc", ok: true });
125
139
  });
126
140
  });
@@ -77,6 +77,7 @@ export type {
77
77
  EntityEditScreenDefinition,
78
78
  EntityListScreenDefinition,
79
79
  FieldCondition,
80
+ FieldIconKey,
80
81
  FieldRenderer,
81
82
  FormWidth,
82
83
  ListColumnSpec,
@@ -4,6 +4,7 @@ import {
4
4
  readdirSync,
5
5
  readFileSync,
6
6
  realpathSync,
7
+ statSync,
7
8
  writeFileSync,
8
9
  } from "node:fs";
9
10
  import { isAbsolute, join, relative, resolve } from "node:path";
@@ -170,7 +171,23 @@ export function findFeaturesDirs(cwd: string): string[] {
170
171
  // node_modules after a plain npm/bun install (fw#2301).
171
172
  export function findCodemodScriptsRoot(repoRoot: string): string | null {
172
173
  const local = join(repoRoot, "packages/framework/src");
173
- if (existsSync(join(local, CODEMOD_SUBDIR))) return local;
174
+ if (existsSync(join(local, CODEMOD_SUBDIR))) {
175
+ // If package.json exists, require it to be kumiko-framework so a generic
176
+ // monorepo `packages/framework` cannot shadow the installed package.
177
+ // Missing package.json (test fixtures / incomplete trees) keeps prior behavior.
178
+ const pkgPath = join(repoRoot, "packages/framework/package.json");
179
+ if (existsSync(pkgPath)) {
180
+ try {
181
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string };
182
+ if (pkg.name === "@cosmicdrift/kumiko-framework") return local;
183
+ // wrong name → fall through
184
+ } catch {
185
+ // unreadable → fall through
186
+ }
187
+ } else {
188
+ return local;
189
+ }
190
+ }
174
191
 
175
192
  let dir = repoRoot;
176
193
  for (let i = 0; i < 10; i++) {
@@ -187,7 +204,10 @@ export function findCodemodScriptsRoot(repoRoot: string): string | null {
187
204
  // Resolves a changes.json `codemod` field to an absolute script path,
188
205
  // refusing anything that would escape scripts/codemod/ (path traversal,
189
206
  // absolute paths, symlinks pointing outward) or that isn't a real .ts file.
190
- export function resolveCodemodScript(
207
+ // First arg is the consumer repo root (same as findCodemodScriptsRoot) —
208
+ // not the scripts root — so out-of-tree callers keep compiling against the
209
+ // public upgrade-cli subpath without silently resolving to null (fw#2341).
210
+ function resolveCodemodScriptAt(
191
211
  codemodScriptsRoot: string,
192
212
  codemodField: string | undefined,
193
213
  ): string | null {
@@ -213,6 +233,15 @@ export function resolveCodemodScript(
213
233
  return resolved;
214
234
  }
215
235
 
236
+ export function resolveCodemodScript(
237
+ repoRoot: string,
238
+ codemodField: string | undefined,
239
+ ): string | null {
240
+ const scriptsRoot = findCodemodScriptsRoot(repoRoot);
241
+ if (!scriptsRoot) return null;
242
+ return resolveCodemodScriptAt(scriptsRoot, codemodField);
243
+ }
244
+
216
245
  type CodemodRunResult = { readonly ok: boolean; readonly output: string };
217
246
 
218
247
  // Array-form argv only — never a shell string. The script itself decides
@@ -254,6 +283,42 @@ function writeUpgradeMarker(targetDir: string, marker: UpgradeMarker): void {
254
283
  writeFileSync(join(dir, "upgrade-state.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
255
284
  }
256
285
 
286
+ /** Highest pending version strictly below the earliest open manual breaking
287
+ * entry (no codemod). Falls back to highest non-breaking pending when every
288
+ * pending version is at/after that manual — never advances onto the manual. */
289
+ /** Highest pending version strictly below the earliest open manual breaking
290
+ * entry (no codemod). Falls back to highest non-breaking pending when every
291
+ * pending version is at/after that manual. If only manuals remain, returns
292
+ * `fallbackInstalled` so the marker stamp moves without claiming manuals done. */
293
+ function markerVersionForPending(
294
+ pending: readonly ChangelogEntry[],
295
+ manualEntries: readonly ChangelogEntry[],
296
+ fallbackInstalled: string,
297
+ ): string {
298
+ const earliestManual = [...manualEntries].sort((a, b) =>
299
+ compareVersions(a.version, b.version),
300
+ )[0];
301
+ const eligible = pending.filter(
302
+ (e) => earliestManual === undefined || compareVersions(e.version, earliestManual.version) < 0,
303
+ );
304
+ const headEligible = eligible[0];
305
+ if (headEligible !== undefined) {
306
+ return eligible.reduce(
307
+ (max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
308
+ headEligible.version,
309
+ );
310
+ }
311
+ const nonBreaking = pending.filter((e) => e.type !== "breaking");
312
+ const headNonBreaking = nonBreaking[0];
313
+ if (headNonBreaking !== undefined) {
314
+ return nonBreaking.reduce(
315
+ (max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
316
+ headNonBreaking.version,
317
+ );
318
+ }
319
+ return fallbackInstalled;
320
+ }
321
+
257
322
  // Runs every pending breaking entry's codemod, oldest version first (so a
258
323
  // later codemod can assume an earlier one already ran). Stops on the first
259
324
  // failure — no partial marker. Writes the marker whenever dryRun is false —
@@ -265,13 +330,16 @@ async function applyCodemods(
265
330
  repoRoot: string,
266
331
  targetDir: string,
267
332
  dryRun: boolean,
268
- currentVersion: string,
333
+ // Installed version for the zero-pending bootstrap marker — never a
334
+ // `--from` filter override (that would permanently skip real pending
335
+ // codemods once the CI guard compares against the fake marker).
336
+ markerVersion: string,
269
337
  ): Promise<number> {
270
338
  if (pending.length === 0) {
271
339
  out.log(" ✓ Nothing new since your version.");
272
340
  if (!dryRun) {
273
341
  writeUpgradeMarker(targetDir, {
274
- version: currentVersion,
342
+ version: markerVersion,
275
343
  appliedAt: Temporal.Now.instant().toString(),
276
344
  codemods: [],
277
345
  });
@@ -296,15 +364,28 @@ async function applyCodemods(
296
364
  ? " No automatable codemods among the pending breaking changes."
297
365
  : " ✓ No breaking changes pending.",
298
366
  );
367
+ if (!dryRun) {
368
+ const markerVer = markerVersionForPending(pending, manualEntries, markerVersion);
369
+ writeUpgradeMarker(targetDir, {
370
+ version: markerVer,
371
+ appliedAt: Temporal.Now.instant().toString(),
372
+ codemods: [],
373
+ });
374
+ out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
375
+ }
299
376
  return 0;
300
377
  }
301
378
 
302
379
  const codemodScriptsRoot = findCodemodScriptsRoot(repoRoot);
380
+ if (codemodScriptsRoot === null) {
381
+ out.err(
382
+ ` ✗ could not locate @cosmicdrift/kumiko-framework/src/scripts/codemod — is the framework installed? searched from ${repoRoot} upward`,
383
+ );
384
+ return 1;
385
+ }
303
386
  const ran: UpgradeMarkerCodemod[] = [];
304
387
  for (const e of codemodEntries) {
305
- const scriptPath = codemodScriptsRoot
306
- ? resolveCodemodScript(codemodScriptsRoot, e.codemod)
307
- : null;
388
+ const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
308
389
  if (!scriptPath) {
309
390
  out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
310
391
  return 1;
@@ -325,12 +406,7 @@ async function applyCodemods(
325
406
  return 0;
326
407
  }
327
408
 
328
- const firstPending = pending[0];
329
- if (!firstPending) return 0;
330
- const latestVersion = pending.reduce(
331
- (max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
332
- firstPending.version,
333
- );
409
+ const latestVersion = markerVersionForPending(pending, manualEntries, markerVersion);
334
410
  writeUpgradeMarker(targetDir, {
335
411
  version: latestVersion,
336
412
  appliedAt: Temporal.Now.instant().toString(),
@@ -398,9 +474,19 @@ export async function runUpgradeCli(
398
474
  if (getFlag(args, "apply")) {
399
475
  const dirFlag = getStringFlag(args, "dir");
400
476
  const targetDir = dirFlag ? resolve(dirFlag) : cwd;
477
+ if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) {
478
+ out.err("");
479
+ out.err(` --dir path is not a directory: ${targetDir}`);
480
+ out.err("");
481
+ return 1;
482
+ }
483
+ // Marker must reflect what is actually installed under the target (or
484
+ // cwd), not a `--from` filter override — otherwise CI stays green forever.
485
+ const markerVersion =
486
+ (dirFlag ? readCurrentVersion(targetDir) : null) ?? installedVersion ?? currentVersion;
401
487
  const dryRun = getFlag(args, "dry-run");
402
488
  out.log("");
403
- const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, currentVersion);
489
+ const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, markerVersion);
404
490
  out.log("");
405
491
  return code;
406
492
  }