@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.220.1",
3
+ "version": "0.221.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.220.1",
197
+ "@cosmicdrift/kumiko-types": "0.221.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.220.1",
213
+ "@cosmicdrift/kumiko-dispatcher-live": "0.221.0",
214
214
  "bun-types": "^1.3.13",
215
215
  "pino-pretty": "^13.1.3"
216
216
  },
@@ -4,6 +4,7 @@
4
4
  // kumiko-platform/docs/plans/architecture/table-ddl-guard.md (Stufe 3).
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { Temporal } from "temporal-polyfill";
7
8
  import { defineUnmanagedTable } from "../db/entity-table-meta";
8
9
  import { asRawClient, insertOne, selectMany } from "../db/query";
9
10
  import { defineFeature } from "../engine";
@@ -49,7 +50,7 @@ describe("r.storeTable — DB roundtrip via setupTestStack", () => {
49
50
 
50
51
  expect(rows).toHaveLength(1);
51
52
  expect(rows[0]?.payload).toBe(payload);
52
- expect(String(rows[0]?.receivedAt)).toMatch(/^\d{4}-\d{2}-\d{2}T/);
53
+ expect(rows[0]?.receivedAt).toBeInstanceOf(Temporal.Instant);
53
54
  });
54
55
 
55
56
  test("registry exposes the store table with its reason and featureName", () => {
@@ -243,35 +243,46 @@ const LEGACY_IMPORT_FIXTURE = [
243
243
  "",
244
244
  ].join("\n");
245
245
 
246
+ // A dedicated fixture — not the real repo's scripts/codemod/ — so the
247
+ // non-.ts and doesn't-exist cases below are isolated from whatever files
248
+ // happen to exist in the real tree. README.md is created for real so a
249
+ // non-.ts rejection is provably caused by the extension check, not by the
250
+ // existsSync guard rejecting a file that never existed.
251
+ function makeCodemodScriptsFixture(): string {
252
+ return tmp({ "scripts/codemod/README.md": "# Codemods\n" });
253
+ }
254
+
246
255
  describe("resolveCodemodScript", () => {
247
256
  test("resolves a real script under scripts/codemod/", () => {
248
- const resolved = resolveCodemodScript(REAL_FRAMEWORK_SRC, REAL_CODEMOD);
257
+ const resolved = resolveCodemodScript(REAL_REPO_ROOT, REAL_CODEMOD);
249
258
  expect(resolved).toBe(join(REAL_FRAMEWORK_SRC, REAL_CODEMOD));
250
259
  });
251
260
 
252
261
  test("rejects an absolute path", () => {
253
- expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "/etc/passwd.ts")).toBeNull();
262
+ expect(resolveCodemodScript(REAL_REPO_ROOT, "/etc/passwd.ts")).toBeNull();
254
263
  });
255
264
 
256
265
  test("rejects path traversal that escapes scripts/codemod/", () => {
257
266
  expect(
258
- resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/../../package.json.ts"),
267
+ resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/../../package.json.ts"),
259
268
  ).toBeNull();
260
- expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "../outside/x.ts")).toBeNull();
269
+ expect(resolveCodemodScript(REAL_REPO_ROOT, "../outside/x.ts")).toBeNull();
261
270
  });
262
271
 
263
272
  test("rejects a non-.ts file", () => {
264
- expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/README.md")).toBeNull();
273
+ const root = makeCodemodScriptsFixture();
274
+
275
+ expect(resolveCodemodScript(root, "scripts/codemod/README.md")).toBeNull();
265
276
  });
266
277
 
267
278
  test("rejects a script that doesn't exist", () => {
268
- expect(
269
- resolveCodemodScript(REAL_FRAMEWORK_SRC, "scripts/codemod/does-not-exist.ts"),
270
- ).toBeNull();
279
+ const root = makeCodemodScriptsFixture();
280
+
281
+ expect(resolveCodemodScript(root, "scripts/codemod/does-not-exist.ts")).toBeNull();
271
282
  });
272
283
 
273
284
  test("rejects an undefined codemod field", () => {
274
- expect(resolveCodemodScript(REAL_FRAMEWORK_SRC, undefined)).toBeNull();
285
+ expect(resolveCodemodScript(REAL_REPO_ROOT, undefined)).toBeNull();
275
286
  });
276
287
 
277
288
  test("findCodemodScriptsRoot resolves through a hoisted node_modules symlink", () => {
@@ -283,7 +294,7 @@ describe("resolveCodemodScript", () => {
283
294
  const root = findCodemodScriptsRoot(join(cwd, "apps/web"));
284
295
  expect(root).toBe(join(nmPkgDir, "src"));
285
296
 
286
- const resolved = resolveCodemodScript(root!, REAL_CODEMOD);
297
+ const resolved = resolveCodemodScript(join(cwd, "apps/web"), REAL_CODEMOD);
287
298
  expect(resolved).toBe(join(nmPkgDir, "src", REAL_CODEMOD));
288
299
  });
289
300
  });
@@ -365,7 +376,27 @@ describe("upgrade command — --apply", () => {
365
376
  expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
366
377
  });
367
378
 
368
- test("breaking changes without a codemod field are reported as manual, no marker written", async () => {
379
+ test("stops and writes no marker when the codemod script exits non-zero", async () => {
380
+ const cwd = tmp({
381
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(
382
+ "scripts/codemod/always-fail.ts",
383
+ ),
384
+ });
385
+ const failingRepoRoot = tmp({
386
+ "packages/framework/src/scripts/codemod/always-fail.ts": "process.exit(1);\n",
387
+ });
388
+ const spy = makeSpyOutput();
389
+
390
+ const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
391
+ repoRoot: failingRepoRoot,
392
+ });
393
+
394
+ expect(exit).toBe(1);
395
+ expect(spy.errs.join("\n")).toContain("scripts/codemod/always-fail.ts failed");
396
+ expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
397
+ });
398
+
399
+ test("breaking changes without a codemod field are reported as manual; marker still written", async () => {
369
400
  const cwd = tmp({
370
401
  "packages/framework/src/changes.json": breakingEntryWithCodemod(undefined),
371
402
  });
@@ -377,7 +408,10 @@ describe("upgrade command — --apply", () => {
377
408
 
378
409
  expect(exit).toBe(0);
379
410
  expect(spy.logs.join("\n")).toContain("no codemod, manual migration required");
380
- expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
411
+ // Marker must be written so guard-upgrade-state is not stuck when only
412
+ // manuals/improvements remain (fw#2299 / #2308). Manuals stay pending via
413
+ // markerVersionForPending not advancing onto them when mixed with later work.
414
+ expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(true);
381
415
  });
382
416
 
383
417
  test("nothing pending: reports up to date, still bootstraps the marker", async () => {
@@ -401,6 +435,41 @@ describe("upgrade command — --apply", () => {
401
435
  expect(typeof marker.appliedAt).toBe("string");
402
436
  });
403
437
 
438
+ test("nothing pending + --from: marker records installed version, not the filter", async () => {
439
+ const cwd = tmp({
440
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
441
+ "packages/bundled-features/package.json": JSON.stringify({ version: "0.190.0" }),
442
+ });
443
+ const spy = makeSpyOutput();
444
+
445
+ const exit = await runUpgradeCli(["--from", "0.999.0", "--apply"], cwd, spy.out, {
446
+ repoRoot: REAL_REPO_ROOT,
447
+ });
448
+
449
+ expect(exit).toBe(0);
450
+ const marker = JSON.parse(readFileSync(join(cwd, ".kumiko/upgrade-state.json"), "utf-8"));
451
+ expect(marker.version).toBe("0.190.0");
452
+ });
453
+
454
+ test("--dir that is not a directory exits 1 without writing a marker", async () => {
455
+ const cwd = tmp({
456
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
457
+ "not-a-dir.txt": "x",
458
+ });
459
+ const spy = makeSpyOutput();
460
+
461
+ const exit = await runUpgradeCli(
462
+ ["--from", "0.165.0", "--apply", "--dir", join(cwd, "not-a-dir.txt")],
463
+ cwd,
464
+ spy.out,
465
+ { repoRoot: REAL_REPO_ROOT },
466
+ );
467
+
468
+ expect(exit).toBe(1);
469
+ expect(spy.errs.join("\n")).toContain("not a directory");
470
+ expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
471
+ });
472
+
404
473
  test("nothing pending + --dry-run: reports up to date, writes no marker", async () => {
405
474
  const cwd = tmp({
406
475
  "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
@@ -125,4 +125,14 @@ export const TENANT_COOKIE_NAME = "kumiko_tenant";
125
125
  // request-locale.ts and dispatch-shared.ts's ctx.locale resolution.
126
126
  export const LOCALE_HEADER_NAME = "X-Locale";
127
127
 
128
+ // Internal-only signal set by buildServer's app.notFound() to mark "no
129
+ // route matched" 404s so tryHonoFirst (server-runtime) can tell them apart
130
+ // from a matched route's own deliberate 404 (e.g. default-deny reads) —
131
+ // see kumiko-framework#2435. Never reaches a client: tryHonoFirst and every
132
+ // direct app.fetch() passthrough strip it before the response leaves the
133
+ // process, since its presence would otherwise let a caller distinguish
134
+ // "unregistered path" from "registered path, access denied" — a leak the
135
+ // denying routes deliberately collapse into one status code.
136
+ export const NO_ROUTE_MATCH_HEADER_NAME = "X-Kumiko-No-Route-Match";
137
+
128
138
  export type Route = (typeof Routes)[keyof typeof Routes];
package/src/api/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { SetTenantCookieOptions } from "./anonymous-cookie";
2
2
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie";
3
+ export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants";
3
4
  export type {
4
5
  AnonymousAccessConfig,
5
6
  AnonymousAccessResolved,
package/src/api/server.ts CHANGED
@@ -49,7 +49,7 @@ import {
49
49
  } from "../rate-limit";
50
50
  import type { SearchAdapter } from "../search/types";
51
51
  import { assertUnreachable, generateId } from "../utils";
52
- import { PUBLIC_API_PATHS } from "./api-constants";
52
+ import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
53
53
  import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
54
54
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
55
55
  import { csrfMiddleware } from "./csrf-middleware";
@@ -836,6 +836,22 @@ export function buildServer(options: ServerOptions): KumikoServer {
836
836
  // Default-Handler aus BUILD_VERSION/BUILD_TIME-env-vars.
837
837
  registerVersionRoute(app);
838
838
 
839
+ // Marks "no route matched this path" so tryHonoFirst (server-runtime)
840
+ // can tell it apart from a matched route's OWN deliberate 404 (e.g.
841
+ // default-deny reads like file-derivatives' public-variant route) —
842
+ // without this, tryHonoFirst's only signal was the status code, and a
843
+ // matched route answering 404 got silently rewritten to the SPA shell
844
+ // with status 200 (kumiko-framework#2435). Must be the LAST thing wired
845
+ // on `app`: this only fires when the router found no handler at all, so
846
+ // a route's own `c.text(..., 404)` never passes through here. A handler
847
+ // that calls `c.notFound()` instead of building its own Response DOES
848
+ // route through this same handler and stays indistinguishable from a
849
+ // routing miss — pre-existing Hono semantics, not something this fix
850
+ // changes; deliberate 404s must build their own Response.
851
+ // The header is an internal signal only — tryHonoFirst and every direct
852
+ // app.fetch() passthrough strip it before a response reaches a client.
853
+ app.notFound((c) => c.text("Not Found", 404, { [NO_ROUTE_MATCH_HEADER_NAME]: "1" }));
854
+
839
855
  return {
840
856
  app,
841
857
  jwt,
@@ -28,6 +28,7 @@ describe("coerceRow — date → Temporal.PlainDate", () => {
28
28
  // (2026-03-15T00:00:00.000Z) regardless of process TZ.
29
29
  const row = { published_at: new Date(Date.UTC(2026, 2, 15)) };
30
30
  const result = coerceRow(row, dateTableInfo());
31
+ expect(result.published_at).toBeInstanceOf(Temporal.PlainDate);
31
32
  expect((result.published_at as unknown as Temporal.PlainDate).toString()).toBe("2026-03-15");
32
33
  });
33
34
 
@@ -50,6 +51,7 @@ describe("coerceRow — date → Temporal.PlainDate", () => {
50
51
  test("coerces a plain 'yyyy-mm-dd' driver string", () => {
51
52
  const row = { published_at: "2026-12-01" };
52
53
  const result = coerceRow(row, dateTableInfo());
54
+ expect(result.published_at).toBeInstanceOf(Temporal.PlainDate);
53
55
  expect((result.published_at as unknown as Temporal.PlainDate).toString()).toBe("2026-12-01");
54
56
  });
55
57
 
@@ -8,7 +8,7 @@
8
8
  // coerceRow — proving the coercion no longer depends on the global at all.
9
9
 
10
10
  import { describe, expect, test } from "bun:test";
11
- import type { Temporal } from "temporal-polyfill";
11
+ import { Temporal } from "temporal-polyfill";
12
12
  import { coerceRow, type TableInfo } from "../query";
13
13
 
14
14
  function timestamptzTableInfo(): TableInfo {
@@ -29,6 +29,7 @@ describe("coerceRow — timestamptz → Temporal.Instant", () => {
29
29
  try {
30
30
  const row = { updated_at: new Date("2026-04-18T10:00:00Z") };
31
31
  const result = coerceRow(row, timestamptzTableInfo());
32
+ expect(result.updated_at).toBeInstanceOf(Temporal.Instant);
32
33
  expect((result.updated_at as unknown as Temporal.Instant).toString()).toBe(
33
34
  "2026-04-18T10:00:00Z",
34
35
  );
@@ -25,6 +25,7 @@ export {
25
25
  insertMany,
26
26
  insertOne,
27
27
  runInSavepoint,
28
+ runInSavepointIfSupported,
28
29
  selectMany,
29
30
  transaction,
30
31
  type UpsertOnConflictOptions,
@@ -25,8 +25,8 @@ import type {
25
25
  WhereObject,
26
26
  WhereOperator,
27
27
  } from "@cosmicdrift/kumiko-types/where-clause-types";
28
- // Static import, not the ambient global: Bun doesn't expose Temporal on
29
- // globalThis, so instantFromDriver crashed on timestamptz reads (#1480).
28
+ // Static polyfill import (not getTemporal()): coercion results stay on one
29
+ // Temporal implementation repo-wide, even when Bun exposes a global (#1480).
30
30
  import { Temporal } from "temporal-polyfill";
31
31
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
32
32
  import { SQL_EXPR_BRAND } from "../db/dialect";
@@ -46,6 +46,7 @@ function snakeToCamel(key: string): string {
46
46
  return envCamelCase(key);
47
47
  }
48
48
 
49
+ import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
49
50
  import type { BunDbRunner } from "./connection";
50
51
 
51
52
  // Drizzle-pgTable-Inspection via raw Symbol-access (kein drizzle-orm import).
@@ -155,16 +156,18 @@ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promis
155
156
  // executed yet.
156
157
  export async function runInSavepointIfSupported<T>(
157
158
  db: unknown,
158
- fn: (sp: unknown) => Promise<T>,
159
+ fn: (sp: DbRunner) => Promise<T>,
159
160
  ): Promise<T> {
160
161
  const raw = asRawClient(db) as unknown as {
161
162
  savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
162
163
  };
163
- if (typeof raw.savepoint !== "function") return fn(db);
164
+ // @cast-boundary driver savepoint handle DbRunner at the single seam
165
+ const asRunner = (sp: unknown): DbRunner => sp as DbRunner;
166
+ if (typeof raw.savepoint !== "function") return fn(asRunner(db));
164
167
  try {
165
- return await raw.savepoint(fn);
168
+ return await raw.savepoint((sp) => fn(asRunner(sp)));
166
169
  } catch (e) {
167
- if (extractPgError(e)?.code === "25P01") return fn(db);
170
+ if (extractPgError(e)?.code === "25P01") return fn(asRunner(db));
168
171
  throw e;
169
172
  }
170
173
  }
@@ -328,11 +331,11 @@ function quoteIdent(name: string): string {
328
331
  // Symmetric on write: Temporal.Instant doesn't bind directly into postgres-js
329
332
  // params — convert to ISO string when the column pgType is timestamptz.
330
333
 
331
- function isTemporalInstant(v: unknown): boolean {
334
+ function isTemporalInstant(v: unknown): v is Temporal.Instant {
332
335
  return (
333
336
  typeof v === "object" &&
334
337
  v !== null &&
335
- typeof (v as { epochNanoseconds?: unknown }).epochNanoseconds === "bigint"
338
+ (v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "Temporal.Instant"
336
339
  );
337
340
  }
338
341
 
@@ -377,12 +380,21 @@ function instantFromDriver(value: unknown): Temporal.Instant | null {
377
380
  return null;
378
381
  }
379
382
 
380
- function isTemporalPlainDate(v: unknown): boolean {
383
+ function isTemporalPlainDate(v: unknown): v is Temporal.PlainDate {
381
384
  return (
382
385
  typeof v === "object" &&
383
386
  v !== null &&
384
- typeof (v as { day?: unknown }).day === "number" &&
385
- typeof (v as { calendarId?: unknown }).calendarId === "string"
387
+ (v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "Temporal.PlainDate"
388
+ );
389
+ }
390
+
391
+ function isTemporalPlainDateTimeLike(v: unknown): v is { toPlainDate(): Temporal.PlainDate } {
392
+ return (
393
+ typeof v === "object" &&
394
+ v !== null &&
395
+ ((v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "Temporal.PlainDateTime" ||
396
+ (v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "Temporal.ZonedDateTime") &&
397
+ typeof (v as { toPlainDate?: unknown }).toPlainDate === "function"
386
398
  );
387
399
  }
388
400
 
@@ -397,7 +409,7 @@ function isTemporalPlainDate(v: unknown): boolean {
397
409
  // `.getTime()`), just anchored to a fixed zone instead of process-local.
398
410
  function plainDateFromDriver(value: unknown): Temporal.PlainDate | null {
399
411
  if (value === null || value === undefined) return null;
400
- if (isTemporalPlainDate(value)) return value as Temporal.PlainDate;
412
+ if (isTemporalPlainDate(value)) return value;
401
413
  if (typeof value === "string") {
402
414
  const isoDay = /^\d{4}-\d{2}-\d{2}/.exec(value)?.[0];
403
415
  if (isoDay === undefined) return null;
@@ -520,10 +532,13 @@ function isSqlExpression(v: unknown): v is { kind: "sql-expr"; text: string } {
520
532
  // driver can't serialize.
521
533
  function prepareDateValue(value: unknown): PreparedValue | undefined {
522
534
  if (isTemporalPlainDate(value)) {
523
- return { kind: "param", sql: "", bound: (value as Temporal.PlainDate).toString() };
535
+ return { kind: "param", sql: "", bound: value.toString() };
536
+ }
537
+ if (isTemporalPlainDateTimeLike(value)) {
538
+ return { kind: "param", sql: "", bound: value.toPlainDate().toString() };
524
539
  }
525
540
  if (isTemporalInstant(value)) {
526
- const day = (value as Temporal.Instant).toZonedDateTimeISO("UTC").toPlainDate();
541
+ const day = value.toZonedDateTimeISO("UTC").toPlainDate();
527
542
  return { kind: "param", sql: "", bound: day.toString() };
528
543
  }
529
544
  return undefined;
@@ -688,6 +703,7 @@ function isClosedConnectionError(err: unknown): boolean {
688
703
  // Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
689
704
  // db/queries/*.ts) can opt into the same #1163 retry instead of calling
690
705
  // asRawClient(db).unsafe(...) directly and losing it.
706
+ // READS ONLY — retry re-executes the statement; never pass INSERT/UPDATE/DELETE.
691
707
  export async function unsafeReadRetrying<TRow>(
692
708
  db: AnyDb,
693
709
  sqlText: string,
@@ -14,6 +14,7 @@ export {
14
14
  encryptEventPayloadPii,
15
15
  } from "./event-pii";
16
16
  export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
17
+ export { isSelfPiiField } from "./is-self-pii-field";
17
18
  export {
18
19
  isLocalKeyKmsAdapter,
19
20
  KeyAlreadyExistsError,
@@ -0,0 +1,8 @@
1
+ import type { EntityDefinition } from "../engine/types/fields";
2
+
3
+ type AnyField = EntityDefinition["fields"][string];
4
+
5
+ /** True when the field's own row id is the PII subject (`pii: true`). */
6
+ export function isSelfPiiField(field: AnyField): boolean {
7
+ return "pii" in field && field.pii === true;
8
+ }
@@ -1,5 +1,6 @@
1
1
  import type { EntityDefinition } from "../engine/types/fields";
2
2
  import type { TenantId } from "../engine/types/identifiers";
3
+ import { isSelfPiiField } from "./is-self-pii-field";
3
4
  import type { SubjectId } from "./kms-adapter";
4
5
 
5
6
  // Thrown when a field IS pii-annotated but the row can't name its subject —
@@ -63,7 +64,7 @@ export function resolveSubjectForField(
63
64
  return { kind: "tenant", tenantId };
64
65
  }
65
66
 
66
- if ("pii" in field && field.pii === true) {
67
+ if (isSelfPiiField(field)) {
67
68
  // pii: true = the entity itself is the subject (user.email belongs to
68
69
  // that user row). Serial ids are stringified — subject keys are text.
69
70
  const id = row["id"];
@@ -85,7 +86,7 @@ export function collectPiiSubjectFields(entity: EntityDefinition): readonly stri
85
86
  ([, field]) =>
86
87
  ("userOwned" in field && field.userOwned !== undefined) ||
87
88
  ("tenantOwned" in field && field.tenantOwned === true) ||
88
- ("pii" in field && field.pii === true),
89
+ isSelfPiiField(field),
89
90
  )
90
91
  .map(([name]) => name);
91
92
  }
@@ -97,7 +98,7 @@ export function collectSearchableSubjectFields(entity: EntityDefinition): readon
97
98
  const subject =
98
99
  ("userOwned" in field && field.userOwned !== undefined) ||
99
100
  ("tenantOwned" in field && field.tenantOwned === true) ||
100
- ("pii" in field && field.pii === true);
101
+ isSelfPiiField(field);
101
102
  if (!subject) return false;
102
103
  if ("sensitive" in field && field.sensitive === true) return false;
103
104
  return "searchable" in field && field.searchable === true;
@@ -506,7 +506,9 @@ describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-F
506
506
  expect(res.rows).toHaveLength(0);
507
507
  });
508
508
 
509
- test("system-mode list: search uses SYSTEM_TENANT_ID (not session tenant)", async () => {
509
+ test("system-mode list: search still uses session tenantId (streamTenantFor)", async () => {
510
+ // Non-systemStream entities are indexed under event.tenantId (= session),
511
+ // even when listed via r.systemScope() — db.mode must not redirect search.
510
512
  const systemTdb = createTenantDb(testDb.db, admin.tenantId, "system");
511
513
  let searchedTenant: string | undefined;
512
514
  const mockAdapter = {
@@ -523,7 +525,7 @@ describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-F
523
525
  await exec.list({ limit: 50, search: "x" }, admin, systemTdb, {
524
526
  searchAdapter: mockAdapter,
525
527
  });
526
- expect(searchedTenant).toBe(SYSTEM_TENANT_ID);
528
+ expect(searchedTenant).toBe(admin.tenantId);
527
529
  });
528
530
 
529
531
  test("tenant-mode list: search still uses session tenantId", async () => {
@@ -544,6 +546,33 @@ describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-F
544
546
  });
545
547
  expect(searchedTenant).toBe(admin.tenantId);
546
548
  });
549
+
550
+ test("tenant-mode list: systemStream entity searches SYSTEM_TENANT_ID", async () => {
551
+ const sysEntity = createEntity({
552
+ table: "read_pager_sys",
553
+ systemStream: true,
554
+ fields: { title: createTextField({ required: true }) },
555
+ });
556
+ const sysTable = buildEntityTable("pagerSys", sysEntity);
557
+ await unsafeCreateEntityTable(testDb.db, sysEntity, "pagerSys");
558
+ const sysExec = createEventStoreExecutor(sysTable, sysEntity, { entityName: "pagerSys" });
559
+ let searchedTenant: string | undefined;
560
+ const mockAdapter = {
561
+ configure: async () => {},
562
+ index: async () => {},
563
+ indexBatch: async () => {},
564
+ remove: async () => {},
565
+ search: async (tenantId: string) => {
566
+ searchedTenant = tenantId;
567
+ return [];
568
+ },
569
+ reset: async () => {},
570
+ } as never;
571
+ await sysExec.list({ limit: 50, search: "x" }, admin, tdb, {
572
+ searchAdapter: mockAdapter,
573
+ });
574
+ expect(searchedTenant).toBe(SYSTEM_TENANT_ID);
575
+ });
547
576
  });
548
577
 
549
578
  describe("event-store-executor.list — keyset cursor mit custom sort (#2265)", () => {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { EntityTableMeta } from "../entity-table-meta";
3
3
  import {
4
+ assertValidMigrationName,
4
5
  diffSnapshots,
5
6
  generateMigration,
6
7
  renderMigrationSql,
@@ -197,3 +198,14 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
197
198
  expect(sql).toContain("new NOT NULL column(s) without default: envelope");
198
199
  });
199
200
  });
201
+
202
+ describe("assertValidMigrationName", () => {
203
+ test("accepts alphanumeric hyphenated names", () => {
204
+ expect(() => assertValidMigrationName("add-user-table")).not.toThrow();
205
+ });
206
+
207
+ test("rejects leading hyphen and oversized names", () => {
208
+ expect(() => assertValidMigrationName("-foo")).toThrow(/Invalid migration name/);
209
+ expect(() => assertValidMigrationName(`a${"x".repeat(64)}`)).toThrow(/Invalid migration name/);
210
+ });
211
+ });
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
11
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
12
+ import { Temporal } from "temporal-polyfill";
12
13
  import { selectMany } from "../../db/query";
13
14
  import { buildEntityTable } from "../../db/table-builder";
14
15
  import { createEntity, createTextField } from "../../engine";
@@ -61,6 +62,7 @@ describe("instant() customType is forgiving with ISO strings", () => {
61
62
  expect(rows).toHaveLength(1);
62
63
  const insertedAt = rows[0]?.["insertedAt"];
63
64
  expect(insertedAt).toBeDefined();
65
+ expect(insertedAt).toBeInstanceOf(Temporal.Instant);
64
66
  expect((insertedAt as Temporal.Instant).toString()).toBe(isoString);
65
67
  });
66
68
  });
@@ -314,6 +314,7 @@ describe("date field — real DATE column, no TZ dependence (kumiko-framework#19
314
314
  const rows = await selectMany(testDb.db, table);
315
315
  expect(rows).toHaveLength(1);
316
316
  const periodFrom = (rows[0] as { periodFrom: unknown }).periodFrom;
317
+ expect(periodFrom).toBeInstanceOf(Temporal.PlainDate);
317
318
  expect((periodFrom as Temporal.PlainDate).toString()).toBe("2026-03-15");
318
319
  });
319
320
 
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
15
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
16
+ import { Temporal } from "temporal-polyfill";
16
17
  import { selectMany } from "../../db/query";
17
18
  import { buildEntityTable } from "../../db/table-builder";
18
19
  import { createEntity, createTextField } from "../../engine";
@@ -52,6 +53,7 @@ describe("entity with a `source` field — create-path is shadow-proof", () => {
52
53
  expect(rows[0]?.["source"]).toBe("import");
53
54
  const insertedAt = rows[0]?.["insertedAt"];
54
55
  expect(insertedAt).toBeDefined();
56
+ expect(insertedAt).toBeInstanceOf(Temporal.Instant);
55
57
  expect((insertedAt as Temporal.Instant).toString()).toBe("2026-01-15T12:00:00Z");
56
58
  });
57
59
  });