@cosmicdrift/kumiko-framework 0.213.0 → 0.215.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.213.0",
3
+ "version": "0.215.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>",
@@ -194,7 +194,7 @@
194
194
  "./package.json": "./package.json"
195
195
  },
196
196
  "dependencies": {
197
- "@cosmicdrift/kumiko-types": "0.213.0",
197
+ "@cosmicdrift/kumiko-types": "0.215.0",
198
198
  "bullmq": "^5.76.7",
199
199
  "bun-types": "^1.3.13",
200
200
  "hono": "^4.13.1",
@@ -210,7 +210,7 @@
210
210
  "zod": "^4.4.3"
211
211
  },
212
212
  "devDependencies": {
213
- "@cosmicdrift/kumiko-dispatcher-live": "0.213.0",
213
+ "@cosmicdrift/kumiko-dispatcher-live": "0.215.0",
214
214
  "bun-types": "^1.3.13",
215
215
  "pino-pretty": "^13.1.3"
216
216
  },
@@ -1,8 +1,21 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ symlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
3
11
  import { tmpdir } from "node:os";
4
12
  import { join } from "node:path";
5
- import { resolveCodemodScript, runUpgradeCli, type UpgradeCliOut } from "../upgrade-cli";
13
+ import {
14
+ findCodemodScriptsRoot,
15
+ resolveCodemodScript,
16
+ runUpgradeCli,
17
+ type UpgradeCliOut,
18
+ } from "../upgrade-cli";
6
19
 
7
20
  function makeSpyOutput(): {
8
21
  readonly out: UpgradeCliOut;
@@ -165,9 +178,10 @@ describe("upgrade command — enterprise package layout", () => {
165
178
  });
166
179
  });
167
180
 
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.
181
+ // Codemod scripts ship inside packages/framework/src/scripts/codemod the
182
+ // published @cosmicdrift/kumiko-framework package (fw#2301).
170
183
  const REAL_REPO_ROOT = join(import.meta.dir, "../../../..");
184
+ const REAL_FRAMEWORK_SRC = join(import.meta.dir, "..");
171
185
  const REAL_CODEMOD = "scripts/codemod/crypto-shredding-testing-move.ts";
172
186
 
173
187
  function breakingEntryWithCodemod(codemod: string | undefined): string {
@@ -190,31 +204,46 @@ const LEGACY_IMPORT_FIXTURE = [
190
204
 
191
205
  describe("resolveCodemodScript", () => {
192
206
  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));
207
+ const resolved = resolveCodemodScript(REAL_FRAMEWORK_SRC, REAL_CODEMOD);
208
+ expect(resolved).toBe(join(REAL_FRAMEWORK_SRC, REAL_CODEMOD));
195
209
  });
196
210
 
197
211
  test("rejects an absolute path", () => {
198
- expect(resolveCodemodScript(REAL_REPO_ROOT, "/etc/passwd.ts")).toBeNull();
212
+ expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "/etc/passwd.ts")).toBeNull();
199
213
  });
200
214
 
201
215
  test("rejects path traversal that escapes scripts/codemod/", () => {
202
216
  expect(
203
- resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/../../package.json.ts"),
217
+ resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/../../package.json.ts"),
204
218
  ).toBeNull();
205
- expect(resolveCodemodScript(REAL_REPO_ROOT, "../outside/x.ts")).toBeNull();
219
+ expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "../outside/x.ts")).toBeNull();
206
220
  });
207
221
 
208
222
  test("rejects a non-.ts file", () => {
209
- expect(resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/README.md")).toBeNull();
223
+ expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/README.md")).toBeNull();
210
224
  });
211
225
 
212
226
  test("rejects a script that doesn't exist", () => {
213
- expect(resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/does-not-exist.ts")).toBeNull();
227
+ expect(
228
+ resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/does-not-exist.ts"),
229
+ ).toBeNull();
214
230
  });
215
231
 
216
232
  test("rejects an undefined codemod field", () => {
217
- expect(resolveCodemodScript(REAL_REPO_ROOT, undefined)).toBeNull();
233
+ expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, undefined)).toBeNull();
234
+ });
235
+
236
+ test("findCodemodScriptsRoot resolves through a hoisted node_modules symlink", () => {
237
+ const cwd = tmp({ "apps/web/package.json": "{}" });
238
+ const nmPkgDir = join(cwd, "node_modules/@cosmicdrift/kumiko-framework");
239
+ mkdirSync(join(nmPkgDir, ".."), { recursive: true });
240
+ symlinkSync(REAL_FRAMEWORK_SRC.replace(/\/src$/, ""), nmPkgDir, "dir");
241
+
242
+ const root = findCodemodScriptsRoot(join(cwd, "apps/web"));
243
+ expect(root).toBe(join(nmPkgDir, "src"));
244
+
245
+ const resolved = resolveCodemodScript(root!, REAL_CODEMOD);
246
+ expect(resolved).toBe(join(nmPkgDir, "src", REAL_CODEMOD));
218
247
  });
219
248
  });
220
249
 
@@ -94,6 +94,18 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
94
94
  const payload = await jwt.verify(await jwt.sign(user));
95
95
  expect(payload.timezone).toBeUndefined();
96
96
  });
97
+
98
+ // fw#2333 — SessionUser.locale must survive the sign/verify roundtrip,
99
+ // same contract as timezone above.
100
+ it("round-trips the locale claim when set", async () => {
101
+ const payload = await jwt.verify(await jwt.sign({ ...user, locale: "de-DE" }));
102
+ expect(payload.locale).toBe("de-DE");
103
+ });
104
+
105
+ it("omits the locale claim when unset", async () => {
106
+ const payload = await jwt.verify(await jwt.sign(user));
107
+ expect(payload.locale).toBeUndefined();
108
+ });
97
109
  });
98
110
 
99
111
  describe("createJwtHelper — keyring form", () => {
@@ -4,7 +4,7 @@
4
4
  // can't be exercised via createTestDispatcher, which skips the HTTP layer
5
5
  // entirely.
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { defineFeature, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import {
9
9
  createTestUser,
10
10
  setupTestStack,
@@ -22,11 +22,15 @@ const localeProbe = defineFeature("locale-probe", (r) => {
22
22
  );
23
23
  });
24
24
 
25
- async function readLocale(stack: TestStack, headers: Record<string, string>): Promise<string> {
25
+ async function readLocale(
26
+ stack: TestStack,
27
+ headers: Record<string, string>,
28
+ user: SessionUser = createTestUser({ id: 1 }),
29
+ ): Promise<string> {
26
30
  const res = await stack.http.writeWithHeaders(
27
31
  "locale-probe:write:read-locale",
28
32
  {},
29
- createTestUser({ id: 1 }),
33
+ user,
30
34
  headers,
31
35
  );
32
36
  const body = (await res.json()) as { data: { locale: string } };
@@ -94,3 +98,49 @@ describe("ctx.locale falls back to the app's boot-configured defaultLocale", ()
94
98
  expect(locale).toBe("ja");
95
99
  });
96
100
  });
101
+
102
+ // fw#2333 — SessionUser.locale (persisted at login) sits between the
103
+ // request-layer signal and the boot default: no live request signal falls
104
+ // back to it, but a live signal still wins over it.
105
+ describe("ctx.locale falls back to SessionUser.locale ahead of the boot default", () => {
106
+ let stack: TestStack;
107
+
108
+ beforeAll(async () => {
109
+ stack = await setupTestStack({
110
+ features: [localeProbe],
111
+ extraContext: { defaultLocale: "ja" },
112
+ });
113
+ });
114
+
115
+ afterAll(async () => {
116
+ await stack.cleanup();
117
+ });
118
+
119
+ test("no header signal falls back to the persisted SessionUser.locale", async () => {
120
+ const locale = await readLocale(stack, {}, createTestUser({ id: 1, locale: "de-DE" }));
121
+ expect(locale).toBe("de-DE");
122
+ });
123
+
124
+ test("X-Locale header still wins over a persisted SessionUser.locale", async () => {
125
+ const locale = await readLocale(
126
+ stack,
127
+ { [LOCALE_HEADER_NAME]: "fr-FR" },
128
+ createTestUser({ id: 1, locale: "de-DE" }),
129
+ );
130
+ expect(locale).toBe("fr-FR");
131
+ });
132
+
133
+ test("a forged/malformed SessionUser.locale falls through to the boot default", async () => {
134
+ const locale = await readLocale(
135
+ stack,
136
+ {},
137
+ createTestUser({ id: 1, locale: "<script>alert(1)</script>" }),
138
+ );
139
+ expect(locale).toBe("ja");
140
+ });
141
+
142
+ test("no SessionUser.locale set falls through to the boot default", async () => {
143
+ const locale = await readLocale(stack, {}, createTestUser({ id: 1 }));
144
+ expect(locale).toBe("ja");
145
+ });
146
+ });
@@ -324,6 +324,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
324
324
  tenantId: payload.tenantId,
325
325
  roles: derivedRoles ?? payload.roles,
326
326
  ...(payload.timezone ? { timezone: payload.timezone } : {}),
327
+ ...(payload.locale ? { locale: payload.locale } : {}),
327
328
  ...(payload.claims ? { claims: payload.claims } : {}),
328
329
  ...(payload.jti ? { sid: payload.jti } : {}),
329
330
  };
package/src/api/jwt.ts CHANGED
@@ -13,6 +13,9 @@ export type JwtPayload = {
13
13
  // IANA zone from user.timezone, set at login — see SessionUser.timezone
14
14
  // (fw#1636). Absent → ctx.tz.user falls back to ctx.tz.tenant.
15
15
  timezone?: string;
16
+ // BCP-47 tag from user.locale, set at login — see SessionUser.locale
17
+ // (fw#2333). Absent → ctx.locale falls back further down the chain.
18
+ locale?: string;
16
19
  // Optional — present when a feature has registered auth claims via the
17
20
  // `r.authClaims()` hook system. Absent for stateless-JWT deployments
18
21
  // without auth-claims wiring.
@@ -110,6 +113,7 @@ export function createJwtHelper(
110
113
  roles: [...user.roles],
111
114
  };
112
115
  if (user.timezone) body.timezone = user.timezone;
116
+ if (user.locale) body.locale = user.locale;
113
117
  if (user.claims) body.claims = { ...user.claims };
114
118
 
115
119
  const header: jose.JWTHeaderParameters = keyring.signKid
@@ -162,6 +166,9 @@ export function createJwtHelper(
162
166
  if (typeof payload["timezone"] === "string") {
163
167
  result.timezone = payload["timezone"];
164
168
  }
169
+ if (typeof payload["locale"] === "string") {
170
+ result.locale = payload["locale"];
171
+ }
165
172
  const claims = payload["claims"];
166
173
  if (claims && typeof claims === "object") {
167
174
  result.claims = claims as DbRow;
@@ -0,0 +1,108 @@
1
+ // #2323: event-store.ts's and projection-rebuild.ts's plain SELECT helpers
2
+ // read via asRawClient(db).unsafe() directly, bypassing the #1163
3
+ // closed-connection retry that only covered bun-db/query.ts's own
4
+ // selectMany/countWhere. Routed the non-locking read call sites through
5
+ // unsafeReadRetrying instead — this test mirrors
6
+ // bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
7
+ // the retry now fires. Writes (insertSubsequentEventRow, upsertSnapshot,
8
+ // markProjectionRebuilding, ...) stay unretried per #1358, and the
9
+ // FOR UPDATE / FOR UPDATE SKIP LOCKED reads in event-consumer.ts are always
10
+ // called inside transaction() (verified against their only call sites) — the
11
+ // retry guard there is a no-op, so those are left as asRawClient calls too.
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import {
15
+ selectAggregateMaxVersion,
16
+ selectEventsHighWaterMark,
17
+ selectNextEventIdAfter,
18
+ selectStreamMaxVersion,
19
+ } from "../event-store";
20
+ import {
21
+ countSubscribedEvents,
22
+ selectEventsForProjectionRebuildBatch,
23
+ } from "../projection-rebuild";
24
+
25
+ function closedConnectionError(): Error {
26
+ return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
27
+ }
28
+
29
+ type FakeClient = {
30
+ unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
31
+ begin: () => never;
32
+ calls: number;
33
+ };
34
+
35
+ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
36
+ const remaining = [...failures];
37
+ const client: FakeClient = {
38
+ calls: 0,
39
+ unsafe: async () => {
40
+ client.calls++;
41
+ const err = remaining.shift();
42
+ if (err) throw err;
43
+ return [row];
44
+ },
45
+ begin: () => {
46
+ throw new Error("not used in test");
47
+ },
48
+ };
49
+ return client;
50
+ }
51
+
52
+ describe("framework db/queries — closed-connection retry (#2323)", () => {
53
+ test("selectStreamMaxVersion retries once and returns the version", async () => {
54
+ const db = fakeClient([closedConnectionError()], { v: 5 });
55
+ const result = await selectStreamMaxVersion(db as never, "agg1", "t1");
56
+ expect(result).toBe(5);
57
+ expect(db.calls).toBe(2);
58
+ });
59
+
60
+ test("selectAggregateMaxVersion retries once and returns the version", async () => {
61
+ const db = fakeClient([closedConnectionError()], { v: 7 });
62
+ const result = await selectAggregateMaxVersion(db as never, "agg1");
63
+ expect(result).toBe(7);
64
+ expect(db.calls).toBe(2);
65
+ });
66
+
67
+ test("selectEventsHighWaterMark retries once and returns the max id", async () => {
68
+ const db = fakeClient([closedConnectionError()], { max: 42n });
69
+ const result = await selectEventsHighWaterMark(db as never);
70
+ expect(result).toBe(42n);
71
+ expect(db.calls).toBe(2);
72
+ });
73
+
74
+ test("selectNextEventIdAfter retries once and returns the next id", async () => {
75
+ const db = fakeClient([closedConnectionError()], { id: 43n });
76
+ const result = await selectNextEventIdAfter(db as never, 42n);
77
+ expect(result).toBe(43n);
78
+ expect(db.calls).toBe(2);
79
+ });
80
+
81
+ test("selectEventsForProjectionRebuildBatch retries once and returns rows", async () => {
82
+ const db = fakeClient([closedConnectionError()], { id: "1", type: "created" });
83
+ const rows = await selectEventsForProjectionRebuildBatch(
84
+ db as never,
85
+ ["user"],
86
+ ["user:created"],
87
+ 0n,
88
+ 100,
89
+ );
90
+ expect(rows).toHaveLength(1);
91
+ expect(db.calls).toBe(2);
92
+ });
93
+
94
+ test("countSubscribedEvents retries once and returns the count", async () => {
95
+ const db = fakeClient([closedConnectionError()], { n: 12n });
96
+ const result = await countSubscribedEvents(db as never, ["user"], ["user:created"]);
97
+ expect(result).toBe(12n);
98
+ expect(db.calls).toBe(2);
99
+ });
100
+
101
+ test("gives up after the single retry when the connection stays closed", async () => {
102
+ const db = fakeClient([closedConnectionError(), closedConnectionError()], { v: 5 });
103
+ await expect(selectStreamMaxVersion(db as never, "agg1", "t1")).rejects.toThrow(
104
+ "connection was closed",
105
+ );
106
+ expect(db.calls).toBe(2);
107
+ });
108
+ });
@@ -5,7 +5,7 @@ import {
5
5
  isUniqueViolation,
6
6
  } from "../pg-error";
7
7
  import type { AnyDb } from "../query";
8
- import { asRawClient } from "../query";
8
+ import { asRawClient, unsafeReadRetrying } from "../query";
9
9
 
10
10
  /** NOTIFY on commit — wakes LISTEN subscribers (event-dispatcher). */
11
11
  export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void> {
@@ -175,7 +175,8 @@ export async function selectStreamMaxVersion(
175
175
  aggregateId: string,
176
176
  tenantId: string,
177
177
  ): Promise<number> {
178
- const rows = (await asRawClient(db).unsafe(
178
+ const rows = (await unsafeReadRetrying(
179
+ db,
179
180
  `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`,
180
181
  [aggregateId, tenantId],
181
182
  )) as ReadonlyArray<{ v: number | null }>;
@@ -184,7 +185,8 @@ export async function selectStreamMaxVersion(
184
185
 
185
186
  /** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
186
187
  export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string): Promise<number> {
187
- const rows = (await asRawClient(db).unsafe(
188
+ const rows = (await unsafeReadRetrying(
189
+ db,
188
190
  `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`,
189
191
  [aggregateId],
190
192
  )) as ReadonlyArray<{ v: number | null }>;
@@ -192,8 +194,10 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
192
194
  }
193
195
 
194
196
  export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
195
- const rows = (await asRawClient(db).unsafe(
197
+ const rows = (await unsafeReadRetrying(
198
+ db,
196
199
  `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
200
+ [],
197
201
  )) as ReadonlyArray<{ max: bigint | string | number | null }>;
198
202
  const raw = rows[0]?.max;
199
203
  if (typeof raw === "bigint") return raw;
@@ -208,7 +212,8 @@ export async function selectEventsHeadId(db: AnyDb): Promise<bigint> {
208
212
  }
209
213
 
210
214
  export async function selectNextEventIdAfter(db: AnyDb, afterId: bigint): Promise<bigint | null> {
211
- const rows = (await asRawClient(db).unsafe(
215
+ const rows = (await unsafeReadRetrying(
216
+ db,
212
217
  `SELECT "id" FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT 1`,
213
218
  [afterId],
214
219
  )) as ReadonlyArray<{ id: string | bigint }>;
@@ -1,5 +1,5 @@
1
1
  import type { AnyDb } from "../query";
2
- import { asRawClient } from "../query";
2
+ import { asRawClient, unsafeReadRetrying } from "../query";
3
3
 
4
4
  export async function markProjectionRebuilding(db: AnyDb, projectionName: string): Promise<void> {
5
5
  await asRawClient(db).unsafe(
@@ -26,7 +26,8 @@ export async function selectEventsForProjectionRebuildBatch(
26
26
  // Archived streams don't replay (Marten-aligned): their aggregates are
27
27
  // frozen ops-tombstones — replaying them would resurrect rows (or, for
28
28
  // stranded duplicate-aggregates like fw#832, collide on unique indexes).
29
- return (await asRawClient(db).unsafe(
29
+ return (await unsafeReadRetrying(
30
+ db,
30
31
  `SELECT * FROM "kumiko_events" e
31
32
  WHERE e."aggregate_type" = ANY($1::text[])
32
33
  AND e."type" = ANY($2::text[])
@@ -53,7 +54,8 @@ export async function countSubscribedEvents(
53
54
  // Same archived-streams exclusion as the batch query — the #443 recompute
54
55
  // compares this count against applied events; a filter mismatch would make
55
56
  // every rebuild with an archived stream loop the full-re-replay forever.
56
- const rows = (await asRawClient(db).unsafe(
57
+ const rows = (await unsafeReadRetrying(
58
+ db,
57
59
  `SELECT count(*)::bigint AS n FROM "kumiko_events" e
58
60
  WHERE e."aggregate_type" = ANY($1::text[])
59
61
  AND e."type" = ANY($2::text[])
package/src/db/query.ts CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  type TableInfo,
25
25
  transaction,
26
26
  type UpsertOnConflictOptions,
27
+ unsafeReadRetrying,
27
28
  updateMany,
28
29
  upsertByPk,
29
30
  upsertOnConflict,
@@ -700,7 +700,7 @@ describe("validateBoot — retention", () => {
700
700
  expect(matchingWarn).toBeUndefined();
701
701
  });
702
702
 
703
- test("blockDelete with only a subjectRef-only field and no anonymize warns (#1645)", () => {
703
+ test("blockDelete with only a subjectRef-only field and no anonymize stays silent (#1645, narrowed by #2336)", () => {
704
704
  const feature = defineFeature("test", (r) => {
705
705
  r.entity(
706
706
  "lease",
@@ -718,6 +718,31 @@ describe("validateBoot — retention", () => {
718
718
  const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
719
719
  String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
720
720
  );
721
+ expect(matchingWarn).toBeUndefined();
722
+ });
723
+
724
+ test("blockDelete with a subjectRef field plus an anonymizable subject field and no anonymize still warns (#2336)", () => {
725
+ const feature = defineFeature("test", (r) => {
726
+ r.entity(
727
+ "lease",
728
+ createEntity({
729
+ fields: {
730
+ authorId: createTextField({
731
+ personal: "ref",
732
+ }),
733
+ customerName: createTextField({
734
+ personal: "self",
735
+ find: "none",
736
+ }),
737
+ },
738
+ retention: { keepFor: "10y", strategy: "blockDelete" },
739
+ }),
740
+ );
741
+ });
742
+ validateBoot([feature]);
743
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
744
+ String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
745
+ );
721
746
  expect(matchingWarn).toBeDefined();
722
747
  });
723
748
 
@@ -21,11 +21,10 @@ const FRAMEWORK_TIMESTAMP_FIELDS: ReadonlySet<string> = new Set([
21
21
  // werden statt erst beim ersten Cleanup-Run.
22
22
  const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
23
23
 
24
- // A field carries a subject binding pii/userOwned/tenantOwned mark
25
- // annotated content, subjectRef marks a bare FK into `user` with no
26
- // annotated content of its own but the same Art.17 obligations (#1645).
27
- function hasSubjectAnnotation(annot: ResolvedPiiFlags): boolean {
28
- return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.subjectRef);
24
+ // Excludes subjectRef: its `personal: "ref"` union member structurally
25
+ // forbids `anonymize` (packages/types/src/fields.ts) — #2336.
26
+ function hasAnonymizableSubjectField(annot: ResolvedPiiFlags): boolean {
27
+ return Boolean(annot.pii || annot.userOwned || annot.tenantOwned);
29
28
  }
30
29
 
31
30
  // --- PII / Subject-Key Annotations + Retention validation ---
@@ -222,7 +221,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
222
221
  // blockDelete on an entity with no subject field is the correct
223
222
  // "never auto-delete" choice; User-Forget never reaches those rows (#1622).
224
223
  const hasSubjectField = Object.values(fieldsByName).some(
225
- (f) => hasSubjectAnnotation(f as ResolvedPiiFlags), // @cast-boundary schema-walk
224
+ (f) => hasAnonymizableSubjectField(f as ResolvedPiiFlags), // @cast-boundary schema-walk
226
225
  );
227
226
  const hasAnonymize = Object.values(fieldsByName).some((f) => {
228
227
  const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
@@ -15,7 +15,7 @@ export type ChangelogEntry = {
15
15
  readonly detail?: string;
16
16
  /** Required when type=breaking. Shown in `kumiko upgrade` output. */
17
17
  readonly migration?: string;
18
- /** Path (repo-root-relative, under scripts/codemod/) run by `kumiko upgrade --apply`. */
18
+ /** Path (relative to packages/framework/src/, under scripts/codemod/) run by `kumiko upgrade --apply`. */
19
19
  readonly codemod?: string;
20
20
  };
21
21
 
@@ -51,7 +51,7 @@ import {
51
51
  } from "../event-store/snapshot";
52
52
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster";
53
53
  import { createFileContext } from "../files/file-handle";
54
- import { DEFAULT_LOCALE } from "../i18n/request-locale";
54
+ import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale";
55
55
  import {
56
56
  createMetricsHandle,
57
57
  createNoopMetricsHandle,
@@ -623,9 +623,13 @@ export async function buildHandlerContext(
623
623
 
624
624
  // ctx.locale — request-layer signal (X-Locale header → Accept-Language,
625
625
  // resolved once at the HTTP boundary by request-id-middleware.ts) wins;
626
- // falls back to the app's boot-configured defaultLocale, then
627
- // DEFAULT_LOCALE. Mirrors ctx.tz's Request Boot-Default chain above.
628
- const locale = reqCtx?.locale ?? context.defaultLocale ?? DEFAULT_LOCALE;
626
+ // then SessionUser.locale (set at login, see fw#2333 — stale until
627
+ // re-login, same tradeoff as ctx.tz.user); falls back to the app's
628
+ // boot-configured defaultLocale, then DEFAULT_LOCALE. Mirrors ctx.tz's
629
+ // Request → Session → Boot-Default chain above.
630
+ const safeUserLocale =
631
+ user.locale !== undefined && isValidLocaleTag(user.locale) ? user.locale : undefined;
632
+ const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
629
633
 
630
634
  return {
631
635
  ...context,
@@ -0,0 +1,36 @@
1
+ # consumer-facing codemods
2
+
3
+ This directory holds the codemod scripts `kumiko upgrade --apply` runs for
4
+ consumers of `@cosmicdrift/kumiko-framework`. It ships as part of the
5
+ published package (under `src/`), unlike `scripts/codemod/` at the repo
6
+ root, which is internal bun-cutover tooling for this repo's own migration
7
+ and never gets published.
8
+
9
+ `crypto-shredding-testing-move.ts` rewrites the moved
10
+ `resetPiiSubjectKmsForTests` import path; it's wired into
11
+ `packages/bundled-features/src/crypto-shredding/changes.json`'s `codemod`
12
+ field and runs automatically via `kumiko upgrade --apply`.
13
+
14
+ ## pii-personal-migration.ts
15
+
16
+ Migrates `create*Field(...)` calls from the old flag-based PII API (`pii`,
17
+ `userOwned`, `tenantOwned`, `subjectRef`, `allowPlaintext`, `lookupable`,
18
+ `searchable`, `sensitive`) to the author-facing `personal`/`find` API
19
+ (kumiko-framework#2250). Idempotent — safe to re-run against already
20
+ migrated code (no-ops on fields carrying `personal`).
21
+
22
+ `createTenantConfig`/`createUserConfig`/etc. are out of scope by
23
+ construction (name doesn't match `create*Field`).
24
+
25
+ ```bash
26
+ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run]
27
+ ```
28
+
29
+ Anything it can't map mechanically (two subject flags on one field,
30
+ `sensitive` + `lookupable`/`searchable` together, `piiEncrypted` on an
31
+ entity field, a raw object literal with subject flags outside a
32
+ `create*Field(...)` call) is reported with file:line instead of guessed
33
+ at — fix those by hand. The report also lists every field that newly
34
+ gains `lookupable` (searchable-only fields resolving to `find: "fuzzy"`)
35
+ — each of those needs a `_bidx` column migration, which this script does
36
+ not write.
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bun
2
+ // Rewrites `import { resetPiiSubjectKmsForTests, resetBlindIndexKeyForTests }
3
+ // from "@cosmicdrift/kumiko-framework/crypto"` to import both names from
4
+ // "@cosmicdrift/kumiko-framework/testing" instead (fw#1631). Only the
5
+ // package barrel specifier is rewritten — relative deep-imports of the
6
+ // defining module are left untouched, per the changes.json migration note.
7
+ // Idempotent: an already-migrated file has nothing left to move.
8
+ //
9
+ // Usage: bun scripts/codemod/crypto-shredding-testing-move.ts <targetDir> [--dry-run]
10
+
11
+ import { resolve } from "node:path";
12
+ import { Glob } from "bun";
13
+ import { Project } from "ts-morph";
14
+
15
+ const OLD_SPECIFIER = "@cosmicdrift/kumiko-framework/crypto";
16
+ const NEW_SPECIFIER = "@cosmicdrift/kumiko-framework/testing";
17
+ const MOVED_NAMES = new Set(["resetPiiSubjectKmsForTests", "resetBlindIndexKeyForTests"]);
18
+
19
+ function findTargetFiles(rootDir: string): string[] {
20
+ const glob = new Glob("**/*.{ts,tsx}");
21
+ const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
22
+ 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
+ }
28
+ return files.sort();
29
+ }
30
+
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;
51
+
52
+ const movedHere = oldImport.getNamedImports().filter((spec) => MOVED_NAMES.has(spec.getName()));
53
+ if (movedHere.length === 0) continue;
54
+
55
+ const names = movedHere.map((spec) => spec.getName());
56
+
57
+ const existingNewImport = sourceFile
58
+ .getImportDeclarations()
59
+ .find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER);
60
+ if (existingNewImport) {
61
+ const already = new Set(existingNewImport.getNamedImports().map((s) => s.getName()));
62
+ for (const name of names) {
63
+ if (!already.has(name)) existingNewImport.addNamedImport(name);
64
+ }
65
+ } else {
66
+ sourceFile.addImportDeclaration({ moduleSpecifier: NEW_SPECIFIER, namedImports: names });
67
+ }
68
+
69
+ for (const spec of movedHere) spec.remove();
70
+ const remaining =
71
+ oldImport.getNamedImports().length > 0 ||
72
+ !!oldImport.getDefaultImport() ||
73
+ !!oldImport.getNamespaceImport();
74
+ if (!remaining) oldImport.remove();
75
+
76
+ touchedFiles++;
77
+ movedNames += names.length;
78
+ if (!dryRun) sourceFile.saveSync();
79
+ }
80
+
81
+ console.log(`\nScanned ${files.length} files under ${rootDir}${dryRun ? " (dry-run)" : ""}.`);
82
+ console.log(
83
+ `Touched ${touchedFiles} files, moved ${movedNames} import(s) from "${OLD_SPECIFIER}" to "${NEW_SPECIFIER}".\n`,
84
+ );
85
+ }
86
+
87
+ await main();
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env bun
2
+ // Migrates create*Field(...) calls from the old flag-based PII API (pii,
3
+ // userOwned, tenantOwned, subjectRef, allowPlaintext, lookupable,
4
+ // searchable, sensitive) to the author-facing personal/find API
5
+ // (kumiko-framework#2250). Idempotent — re-running is a no-op on already
6
+ // migrated fields (skipped once a `personal` property is present).
7
+ //
8
+ // Mapping table (exact):
9
+ // pii: true -> personal: "self"
10
+ // userOwned: { ownerField: "x" } -> personal: { of: "x" }
11
+ // tenantOwned: true -> personal: "tenant"
12
+ // subjectRef: true -> personal: "ref"
13
+ // allowPlaintext: "R" -> personal: false, reason: "<R, normalized to snake_case>"
14
+ // lookupable: true (alone) -> find: "exact"
15
+ // lookupable: true + searchable: true -> find: "fuzzy"
16
+ // searchable: true (alone) -> find: "fuzzy"
17
+ // sensitive: true -> find: "secret"
18
+ // none of the above -> find: "none"
19
+ // `find` only applies to text/longText fields, and only when the subject
20
+ // is neither "ref" nor `personal: false` (see PersonalAnnotations in
21
+ // packages/types/src/fields.ts). Never guesses: anything outside this
22
+ // table is reported (file:line) instead of transformed.
23
+ //
24
+ // Usage: bun scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run]
25
+
26
+ import { relative, resolve } from "node:path";
27
+ import { Glob } from "bun";
28
+ import {
29
+ Node,
30
+ type ObjectLiteralExpression,
31
+ Project,
32
+ type PropertyAssignment,
33
+ type SourceFile,
34
+ SyntaxKind,
35
+ } from "ts-morph";
36
+
37
+ const SUBJECT_FLAG_NAMES = [
38
+ "pii",
39
+ "userOwned",
40
+ "tenantOwned",
41
+ "subjectRef",
42
+ "allowPlaintext",
43
+ ] as const;
44
+ type SubjectFlagName = (typeof SUBJECT_FLAG_NAMES)[number];
45
+
46
+ // `create*Field` factories that accept `find` (text/longText) — see
47
+ // packages/framework/src/engine/factories.ts.
48
+ const TEXT_FIND_FACTORIES = new Set(["createTextField"]);
49
+ const LONGTEXT_FIND_FACTORIES = new Set(["createLongTextField"]);
50
+ // Accept `personal` but never `find` (PersonalAnnotationsNoFind).
51
+ const NO_FIND_FACTORIES = new Set([
52
+ "createSelectField",
53
+ "createMultiSelectField",
54
+ "createNumberField",
55
+ "createBigIntField",
56
+ "createDecimalField",
57
+ "createEmbeddedField",
58
+ "createEmbeddedListField",
59
+ "createJsonbField",
60
+ "createDateField",
61
+ "createTimestampField",
62
+ "createTzField",
63
+ "createLocatedTimestampField",
64
+ ]);
65
+ // No personal-annotation support at all — a subject flag here is a bug.
66
+ const NO_PERSONAL_FACTORIES = new Set([
67
+ "createBooleanField",
68
+ "createMoneyField",
69
+ "createFileField",
70
+ "createImageField",
71
+ "createFilesField",
72
+ "createImagesField",
73
+ "createDerivedField",
74
+ ]);
75
+ // Overrides live in argument position 1, not 0.
76
+ const OVERRIDES_ARG_INDEX_1 = new Set(["createEmbeddedField", "createEmbeddedListField"]);
77
+
78
+ const FIELD_FACTORY_RE = /^create\w*Field$/;
79
+
80
+ type ReportEntry = { readonly file: string; readonly line: number; readonly reason: string };
81
+ type FindBucket = "exact" | "fuzzy" | "none" | "secret" | "ref" | "personal-false" | "no-find";
82
+
83
+ const reports: ReportEntry[] = [];
84
+ const newLookupableSites: ReportEntry[] = [];
85
+ const counts: Record<FindBucket, number> = {
86
+ exact: 0,
87
+ fuzzy: 0,
88
+ none: 0,
89
+ secret: 0,
90
+ ref: 0,
91
+ "personal-false": 0,
92
+ "no-find": 0,
93
+ };
94
+
95
+ function report(node: Node, reason: string): void {
96
+ reports.push({
97
+ file: node.getSourceFile().getFilePath(),
98
+ line: node.getStartLineNumber(),
99
+ reason,
100
+ });
101
+ }
102
+
103
+ function isTrueLiteral(node: Node | undefined): boolean {
104
+ return !!node && node.getKind() === SyntaxKind.TrueKeyword;
105
+ }
106
+
107
+ function findProp(obj: ObjectLiteralExpression, name: string): PropertyAssignment | undefined {
108
+ const p = obj.getProperty(name);
109
+ return p && Node.isPropertyAssignment(p) ? p : undefined;
110
+ }
111
+
112
+ // Reason-string convention (infra/guards/guard-error-reasons.ts):
113
+ // ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ — lowercase snake_case, optional
114
+ // dot-namespacing. Old allowPlaintext values are free-form kebab-case.
115
+ function normalizeReasonSlug(raw: string): string {
116
+ const slug = raw
117
+ .trim()
118
+ .toLowerCase()
119
+ .replace(/[^a-z0-9.]+/g, "_")
120
+ .replace(/_+/g, "_")
121
+ .replace(/^[._]+|[._]+$/g, "");
122
+ return /^[0-9]/.test(slug) ? `_${slug}` : slug;
123
+ }
124
+
125
+ type FieldCall = { readonly name: string; readonly objArg: ObjectLiteralExpression };
126
+
127
+ function collectFieldCalls(sourceFile: SourceFile): FieldCall[] {
128
+ const calls: FieldCall[] = [];
129
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
130
+ const exprNode = call.getExpression();
131
+ if (!Node.isIdentifier(exprNode)) continue;
132
+ const name = exprNode.getText();
133
+ // `createTenantConfig`/`createUserConfig`/etc. never match — they
134
+ // don't end in "Field", so they're out of scope by construction.
135
+ if (!FIELD_FACTORY_RE.test(name)) continue;
136
+
137
+ const argIndex = OVERRIDES_ARG_INDEX_1.has(name) ? 1 : 0;
138
+ const objArg = call.getArguments()[argIndex];
139
+ if (objArg && Node.isObjectLiteralExpression(objArg)) {
140
+ calls.push({ name, objArg });
141
+ }
142
+ }
143
+ return calls;
144
+ }
145
+
146
+ function reportRawSubjectLiterals(
147
+ sourceFile: SourceFile,
148
+ handled: ReadonlySet<ObjectLiteralExpression>,
149
+ ): void {
150
+ for (const objLit of sourceFile.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
151
+ if (handled.has(objLit)) continue;
152
+ const found = [...SUBJECT_FLAG_NAMES, "piiEncrypted"].filter((n) => objLit.getProperty(n));
153
+ if (found.length > 0) {
154
+ report(
155
+ objLit,
156
+ `subject flag(s) [${found.join(", ")}] on an object literal that is not a create*Field(...) call`,
157
+ );
158
+ }
159
+ }
160
+ }
161
+
162
+ // kumiko-lint-ignore complexity-budget migration codemod, one-time script — splitting would add risk without benefit
163
+ function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string): void {
164
+ // skip: field already carries `personal` — already migrated, idempotent no-op
165
+ if (obj.getProperty("personal")) return;
166
+
167
+ const piiEncryptedProp = obj.getProperty("piiEncrypted");
168
+ if (piiEncryptedProp) {
169
+ report(
170
+ piiEncryptedProp,
171
+ "piiEncrypted: true on an entity field (removed from the type system) — needs a human decision, not a mechanical mapping",
172
+ );
173
+ // skip: reported above — piiEncrypted needs a human decision, not a mechanical mapping
174
+ return;
175
+ }
176
+
177
+ const subjectProps = SUBJECT_FLAG_NAMES.map((n) => ({ name: n, prop: findProp(obj, n) })).filter(
178
+ (x): x is { name: SubjectFlagName; prop: PropertyAssignment } => !!x.prop,
179
+ );
180
+ const lookupableProp = findProp(obj, "lookupable");
181
+ const searchableProp = findProp(obj, "searchable");
182
+ const sensitiveProp = findProp(obj, "sensitive");
183
+ const anonymizeProp = obj.getProperty("anonymize");
184
+
185
+ if (subjectProps.length === 0) {
186
+ // `searchable`/`sensitive` alone remain valid, unrelated FieldDef
187
+ // properties — left untouched. `lookupable` alone can never compile
188
+ // (always an excess property without a `personal` to attach `find`
189
+ // to). `anonymize` alone has no PersonalAnnotations arm to live on.
190
+ if (lookupableProp) {
191
+ report(
192
+ lookupableProp,
193
+ "lookupable without any subject annotation — no `personal` to attach `find` to",
194
+ );
195
+ }
196
+ if (anonymizeProp) {
197
+ report(
198
+ anonymizeProp,
199
+ "anonymize without any subject annotation — no PersonalAnnotations arm accepts anonymize alone",
200
+ );
201
+ }
202
+ // skip: no subject flag on this field — stray lookupable/anonymize already reported above if present
203
+ return;
204
+ }
205
+
206
+ if (subjectProps.length > 1) {
207
+ report(
208
+ subjectProps[0]!.prop,
209
+ `multiple subject annotations on one field (${subjectProps.map((s) => s.name).join(", ")}) — needs a human decision on which subject is correct`,
210
+ );
211
+ // skip: reported above — multiple subject annotations need a human call on which one wins
212
+ return;
213
+ }
214
+
215
+ const subject = subjectProps[0]!;
216
+
217
+ if (NO_PERSONAL_FACTORIES.has(factoryName)) {
218
+ report(
219
+ subject.prop,
220
+ `${factoryName} has no personal-annotation support — subject flag "${subject.name}" cannot be expressed here`,
221
+ );
222
+ // skip: reported above — this factory has no personal-annotation support to migrate into
223
+ return;
224
+ }
225
+ const isTextFind = TEXT_FIND_FACTORIES.has(factoryName);
226
+ const isLongTextFind = LONGTEXT_FIND_FACTORIES.has(factoryName);
227
+ const isNoFind = NO_FIND_FACTORIES.has(factoryName);
228
+ if (!isTextFind && !isLongTextFind && !isNoFind) {
229
+ report(
230
+ subject.prop,
231
+ `unrecognized field factory "${factoryName}" — cannot verify its PersonalAnnotations shape`,
232
+ );
233
+ // skip: reported above — unknown factory shape, cannot verify safely
234
+ return;
235
+ }
236
+
237
+ let personalInit: string;
238
+ let reasonInit: string | undefined;
239
+ if (subject.name === "pii") {
240
+ if (!isTrueLiteral(subject.prop.getInitializer())) {
241
+ report(subject.prop, "pii is set to a non-`true` value — cannot infer intent");
242
+ // skip: reported above — non-`true` pii value, can't infer intent
243
+ return;
244
+ }
245
+ personalInit = `"self"`;
246
+ } else if (subject.name === "tenantOwned") {
247
+ if (!isTrueLiteral(subject.prop.getInitializer())) {
248
+ report(subject.prop, "tenantOwned is set to a non-`true` value — cannot infer intent");
249
+ // skip: reported above — non-`true` tenantOwned value, can't infer intent
250
+ return;
251
+ }
252
+ personalInit = `"tenant"`;
253
+ } else if (subject.name === "subjectRef") {
254
+ if (!isTrueLiteral(subject.prop.getInitializer())) {
255
+ report(subject.prop, "subjectRef is set to a non-`true` value — cannot infer intent");
256
+ // skip: reported above — non-`true` subjectRef value, can't infer intent
257
+ return;
258
+ }
259
+ personalInit = `"ref"`;
260
+ } else if (subject.name === "allowPlaintext") {
261
+ const init = subject.prop.getInitializer();
262
+ if (!init) {
263
+ report(subject.prop, "allowPlaintext has no value");
264
+ // skip: reported above — allowPlaintext has no value to migrate
265
+ return;
266
+ }
267
+ personalInit = "false";
268
+ reasonInit = Node.isStringLiteral(init)
269
+ ? `"${normalizeReasonSlug(init.getLiteralText())}"`
270
+ : init.getText();
271
+ } else {
272
+ const init = subject.prop.getInitializer();
273
+ if (!init || !Node.isObjectLiteralExpression(init)) {
274
+ report(subject.prop, "userOwned value is not an object literal — cannot extract ownerField");
275
+ // skip: reported above — userOwned value isn't an object literal, can't extract ownerField
276
+ return;
277
+ }
278
+ const ownerFieldProp = findProp(init, "ownerField");
279
+ if (!ownerFieldProp?.getInitializer()) {
280
+ report(subject.prop, 'userOwned is missing an "ownerField" property');
281
+ // skip: reported above — userOwned is missing its ownerField property
282
+ return;
283
+ }
284
+ personalInit = `{ of: ${ownerFieldProp.getInitializer()!.getText()} }`;
285
+ }
286
+
287
+ const subjectIsRefOrPlaintext =
288
+ subject.name === "subjectRef" || subject.name === "allowPlaintext";
289
+
290
+ if (subjectIsRefOrPlaintext) {
291
+ // These PersonalAnnotations arms carry no `find` field at all.
292
+ if (lookupableProp) {
293
+ report(
294
+ lookupableProp,
295
+ `lookupable combined with personal:${subject.name === "subjectRef" ? '"ref"' : "false"} — findability doesn't apply to this subject, table has no mapping`,
296
+ );
297
+ // skip: reported above — lookupable doesn't apply to this subject, no mapping to migrate
298
+ return;
299
+ }
300
+ applyTransform(obj, [subject.prop], {
301
+ personal: personalInit,
302
+ reason: reasonInit,
303
+ find: undefined,
304
+ });
305
+ counts[subject.name === "subjectRef" ? "ref" : "personal-false"]++;
306
+ // skip: already transformed above — nothing left to check for this subject
307
+ return;
308
+ }
309
+
310
+ if (isNoFind) {
311
+ const stray = lookupableProp ?? searchableProp ?? sensitiveProp;
312
+ if (stray) {
313
+ report(
314
+ stray,
315
+ `${factoryName} has no \`find\` in its PersonalAnnotations — findability flag present on a non-text field`,
316
+ );
317
+ // skip: reported above — findability flag on a non-text field, no valid find to attach
318
+ return;
319
+ }
320
+ applyTransform(obj, [subject.prop], {
321
+ personal: personalInit,
322
+ reason: undefined,
323
+ find: undefined,
324
+ });
325
+ counts["no-find"]++;
326
+ // skip: already transformed above — no-find factory has nothing further to check
327
+ return;
328
+ }
329
+
330
+ const hasLookupable = !!lookupableProp;
331
+ const hasSearchable = !!searchableProp;
332
+ const hasSensitive = !!sensitiveProp;
333
+
334
+ if (hasSensitive && (hasLookupable || hasSearchable)) {
335
+ report(
336
+ sensitiveProp!,
337
+ 'sensitive combined with lookupable/searchable — two find values ("secret" vs "exact"/"fuzzy"), needs a human decision',
338
+ );
339
+ // skip: reported above — sensitive plus lookupable/searchable is an ambiguous find value
340
+ return;
341
+ }
342
+
343
+ let find: "exact" | "fuzzy" | "none" | "secret";
344
+ if (hasSensitive) find = "secret";
345
+ else if (hasLookupable && hasSearchable) find = "fuzzy";
346
+ else if (hasLookupable) find = "exact";
347
+ else if (hasSearchable) find = "fuzzy";
348
+ else find = "none";
349
+
350
+ if (isLongTextFind && (find === "exact" || find === "fuzzy")) {
351
+ report(
352
+ (lookupableProp ?? searchableProp)!,
353
+ 'lookupable/searchable on createLongTextField — only "none"/"secret" are valid find values on longText',
354
+ );
355
+ // skip: reported above — exact/fuzzy find isn't valid on createLongTextField
356
+ return;
357
+ }
358
+
359
+ // searchable-alone -> "fuzzy" makes expandPersonalAnnotations add
360
+ // `lookupable: true`, which the field didn't carry before — that's a
361
+ // new `_bidx` column (DDL migration), tracked separately for the report.
362
+ if (hasSearchable && !hasLookupable) {
363
+ newLookupableSites.push({
364
+ file: obj.getSourceFile().getFilePath(),
365
+ line: subject.prop.getStartLineNumber(),
366
+ // find: "fuzzy" newly adds lookupable (was searchable-only) — needs a _bidx column migration
367
+ reason: "fuzzy_search_needs_bidx_migration",
368
+ });
369
+ }
370
+
371
+ const removedProps = [subject.prop, lookupableProp, searchableProp, sensitiveProp].filter(
372
+ (p): p is PropertyAssignment => !!p,
373
+ );
374
+ applyTransform(obj, removedProps, { personal: personalInit, reason: undefined, find });
375
+ counts[find]++;
376
+ }
377
+
378
+ function applyTransform(
379
+ obj: ObjectLiteralExpression,
380
+ removedProps: readonly PropertyAssignment[],
381
+ next: { personal: string; reason: string | undefined; find: string | undefined },
382
+ ): void {
383
+ const properties = obj.getProperties();
384
+ const anchor = removedProps[0]!;
385
+ const anchorIndex = properties.indexOf(anchor);
386
+ const removedSet = new Set<PropertyAssignment>(removedProps);
387
+ let insertIndex = 0;
388
+ for (let i = 0; i < anchorIndex; i++) {
389
+ if (!removedSet.has(properties[i] as PropertyAssignment)) insertIndex++;
390
+ }
391
+
392
+ for (const p of removedProps) p.remove();
393
+
394
+ const newProps: { name: string; initializer: string }[] = [
395
+ { name: "personal", initializer: next.personal },
396
+ ];
397
+ if (next.find) newProps.push({ name: "find", initializer: `"${next.find}"` });
398
+ if (next.reason !== undefined) newProps.push({ name: "reason", initializer: next.reason });
399
+
400
+ obj.insertPropertyAssignments(insertIndex, newProps);
401
+ }
402
+
403
+ function findTargetFiles(rootDir: string): string[] {
404
+ const glob = new Glob("**/*.{ts,tsx}");
405
+ // engine/factories.ts constructs raw ResolvedPiiFlags literals as the
406
+ // *implementation* of expandPersonalAnnotations (personal -> flags) —
407
+ // not an authored override, so it's not a migration target.
408
+ const EXCLUDE = ["/node_modules/", "/dist/", "/build/", "/engine/factories.ts"];
409
+ const files: string[] = [];
410
+ for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
411
+ const abs = resolve(rootDir, file);
412
+ if (EXCLUDE.some((p) => abs.includes(p))) continue;
413
+ files.push(abs);
414
+ }
415
+ return files.sort();
416
+ }
417
+
418
+ async function main(): Promise<void> {
419
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
420
+ const dryRun = process.argv.includes("--dry-run");
421
+ const rootDir = resolve(positional[0] ?? process.cwd());
422
+
423
+ const files = findTargetFiles(rootDir);
424
+ const project = new Project({
425
+ skipAddingFilesFromTsConfig: true,
426
+ skipFileDependencyResolution: true,
427
+ });
428
+
429
+ let touchedFiles = 0;
430
+ for (const file of files) {
431
+ const sourceFile = project.addSourceFileAtPath(file);
432
+ const calls = collectFieldCalls(sourceFile);
433
+ const handled = new Set(calls.map((c) => c.objArg));
434
+ reportRawSubjectLiterals(sourceFile, handled);
435
+
436
+ const countsBefore = { ...counts };
437
+ for (const { name, objArg } of calls) processObjectLiteral(objArg, name);
438
+ const changed = (Object.keys(counts) as FindBucket[]).some(
439
+ (k) => counts[k] !== countsBefore[k],
440
+ );
441
+
442
+ if (changed) {
443
+ touchedFiles++;
444
+ if (!dryRun) sourceFile.saveSync();
445
+ }
446
+ }
447
+
448
+ console.log(`\nScanned ${files.length} files under ${rootDir}${dryRun ? " (dry-run)" : ""}.`);
449
+ console.log(`Touched ${touchedFiles} files.\n`);
450
+ console.log("Transformed, by find/personal bucket:");
451
+ for (const [bucket, n] of Object.entries(counts)) {
452
+ if (n > 0) console.log(` ${bucket}: ${n}`);
453
+ }
454
+ console.log(`\nNewly gains lookupable (needs a _bidx migration): ${newLookupableSites.length}`);
455
+ for (const r of newLookupableSites) {
456
+ console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.reason}`);
457
+ }
458
+ console.log(`\nReported (not transformed): ${reports.length}`);
459
+ for (const r of reports) {
460
+ console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.reason}`);
461
+ }
462
+ }
463
+
464
+ await main();
@@ -164,19 +164,39 @@ export function findFeaturesDirs(cwd: string): string[] {
164
164
  return dirs;
165
165
  }
166
166
 
167
+ // Consumer-facing codemod scripts ship inside the published
168
+ // @cosmicdrift/kumiko-framework package (src/scripts/codemod), not at the
169
+ // git repo root — only the installed package path exists in a consumer's
170
+ // node_modules after a plain npm/bun install (fw#2301).
171
+ export function findCodemodScriptsRoot(repoRoot: string): string | null {
172
+ const local = join(repoRoot, "packages/framework/src");
173
+ if (existsSync(join(local, CODEMOD_SUBDIR))) return local;
174
+
175
+ let dir = repoRoot;
176
+ for (let i = 0; i < 10; i++) {
177
+ const nmSrc = join(dir, "node_modules/@cosmicdrift/kumiko-framework/src");
178
+ if (existsSync(join(nmSrc, CODEMOD_SUBDIR))) return nmSrc;
179
+ const parent = join(dir, "..");
180
+ if (parent === dir) break;
181
+ dir = parent;
182
+ }
183
+
184
+ return null;
185
+ }
186
+
167
187
  // Resolves a changes.json `codemod` field to an absolute script path,
168
188
  // refusing anything that would escape scripts/codemod/ (path traversal,
169
189
  // absolute paths, symlinks pointing outward) or that isn't a real .ts file.
170
190
  export function resolveCodemodScript(
171
- repoRoot: string,
191
+ codemodScriptsRoot: string,
172
192
  codemodField: string | undefined,
173
193
  ): string | null {
174
194
  if (!codemodField) return null;
175
195
  if (codemodField.includes("\0") || codemodField.startsWith("/") || !codemodField.endsWith(".ts"))
176
196
  return null;
177
197
 
178
- const scriptsRoot = join(repoRoot, CODEMOD_SUBDIR);
179
- const resolved = join(repoRoot, codemodField);
198
+ const scriptsRoot = join(codemodScriptsRoot, CODEMOD_SUBDIR);
199
+ const resolved = join(codemodScriptsRoot, codemodField);
180
200
  const rel = relative(scriptsRoot, resolved);
181
201
  if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
182
202
  if (!existsSync(resolved)) return null;
@@ -279,9 +299,12 @@ async function applyCodemods(
279
299
  return 0;
280
300
  }
281
301
 
302
+ const codemodScriptsRoot = findCodemodScriptsRoot(repoRoot);
282
303
  const ran: UpgradeMarkerCodemod[] = [];
283
304
  for (const e of codemodEntries) {
284
- const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
305
+ const scriptPath = codemodScriptsRoot
306
+ ? resolveCodemodScript(codemodScriptsRoot, e.codemod)
307
+ : null;
285
308
  if (!scriptPath) {
286
309
  out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
287
310
  return 1;