@cosmicdrift/kumiko-framework 0.221.0 → 0.222.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 (48) hide show
  1. package/package.json +7 -3
  2. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
  3. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
  4. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
  5. package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
  6. package/src/api/auth-routes.ts +3 -0
  7. package/src/api/pii-leak-guard.ts +4 -5
  8. package/src/arg-parser.ts +1 -1
  9. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
  10. package/src/db/entity-table-meta.ts +6 -1
  11. package/src/db/table-builder.ts +10 -3
  12. package/src/derivatives/__tests__/variant-key.test.ts +123 -1
  13. package/src/derivatives/derivatives-context.ts +4 -0
  14. package/src/derivatives/index.ts +9 -1
  15. package/src/derivatives/variant-key.ts +68 -0
  16. package/src/engine/__tests__/schema-builder.test.ts +4 -4
  17. package/src/engine/extensions/storage-provider.ts +28 -0
  18. package/src/engine/extensions/user-data.ts +4 -0
  19. package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
  20. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
  21. package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
  22. package/src/engine/feature-ast/extractors/index.ts +2 -0
  23. package/src/engine/feature-ast/extractors/shared.ts +26 -1
  24. package/src/engine/feature-ast/parse.ts +10 -25
  25. package/src/engine/feature-ast/patch.ts +14 -16
  26. package/src/engine/feature-ast/render.ts +3 -3
  27. package/src/engine/field-helpers.ts +1 -1
  28. package/src/engine/index.ts +5 -0
  29. package/src/engine/pattern-library/mixed-schemas.ts +6 -0
  30. package/src/engine/schema-builder.ts +14 -2
  31. package/src/errors/__tests__/classes.test.ts +16 -2
  32. package/src/errors/__tests__/write-failures.test.ts +10 -0
  33. package/src/errors/classes.ts +14 -13
  34. package/src/errors/write-error-info.ts +1 -1
  35. package/src/files/__tests__/local-provider.contract.test.ts +14 -0
  36. package/src/files/in-memory-provider.ts +4 -0
  37. package/src/files/local-provider.ts +22 -1
  38. package/src/jobs/job-runner.ts +22 -10
  39. package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
  40. package/src/pipeline/dispatch-shared.ts +39 -2
  41. package/src/pipeline/dispatch-write.ts +48 -0
  42. package/src/pipeline/dispatcher.ts +5 -0
  43. package/src/pipeline/tenant-timezone-cache.ts +92 -0
  44. package/src/schema-cli.ts +30 -44
  45. package/src/scripts/codemod/pii-personal-migration.ts +7 -7
  46. package/src/stack/__tests__/request-helper.test.ts +2 -2
  47. package/src/testing/file-provider-contract.ts +19 -0
  48. package/src/upgrade-cli.ts +12 -1
@@ -206,10 +206,20 @@ function parseRedisOpts(url: string): { host: string; port: number; db?: number
206
206
  // would otherwise hang start() forever with no health endpoint to notice.
207
207
  const BOOT_REDIS_TIMEOUT_MS = 10_000;
208
208
 
209
- function timeoutReject(ms: number, message: string): Promise<never> {
210
- return new Promise((_, reject) => {
211
- setTimeout(() => reject(new Error(message)), ms);
209
+ function timeoutReject(
210
+ ms: number,
211
+ message: string,
212
+ ): { promise: Promise<never>; cancel: () => void } {
213
+ let timer: ReturnType<typeof setTimeout> | undefined;
214
+ const promise = new Promise<never>((_, reject) => {
215
+ timer = setTimeout(() => reject(new Error(message)), ms);
212
216
  });
217
+ return {
218
+ promise,
219
+ cancel: () => {
220
+ if (timer !== undefined) clearTimeout(timer);
221
+ },
222
+ };
213
223
  }
214
224
 
215
225
  export function createJobRunner(options: JobRunnerOptions): JobRunner {
@@ -592,13 +602,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
592
602
  // upsertJobScheduler()/add() below when the lane has a cron/boot job.
593
603
  // Racing a timeout against it keeps an unreachable Redis from hanging
594
604
  // start() forever — there's no worker health endpoint to notice.
595
- await Promise.race([
596
- worker.waitUntilReady(),
597
- timeoutReject(
598
- bootRedisTimeoutMs,
599
- `job-runner: Redis not reachable within ${bootRedisTimeoutMs}ms (lane=${consumerLane})`,
600
- ),
601
- ]);
605
+ const bootTimeout = timeoutReject(
606
+ bootRedisTimeoutMs,
607
+ `job-runner: Redis not reachable within ${bootRedisTimeoutMs}ms (lane=${consumerLane})`,
608
+ );
609
+ try {
610
+ await Promise.race([worker.waitUntilReady(), bootTimeout.promise]);
611
+ } finally {
612
+ bootTimeout.cancel();
613
+ }
602
614
 
603
615
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
604
616
  // assigned to the other lane get their cron/boot wiring from the
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TenantId } from "../../engine/types/identifiers";
3
+ import { createTenantTimezoneCache } from "../tenant-timezone-cache";
4
+
5
+ const tenantA = "tenant-a" as TenantId;
6
+ const tenantB = "tenant-b" as TenantId;
7
+
8
+ describe("TenantTimezoneCache", () => {
9
+ test("miss on an unset tenant", () => {
10
+ const cache = createTenantTimezoneCache();
11
+ expect(cache.get(tenantA)).toBeUndefined();
12
+ });
13
+
14
+ test("hit returns the cached value, including a cached 'no override' (undefined)", () => {
15
+ const cache = createTenantTimezoneCache();
16
+ cache.set(tenantA, "Asia/Tokyo");
17
+ cache.set(tenantB, undefined);
18
+
19
+ expect(cache.get(tenantA)).toEqual({ value: "Asia/Tokyo" });
20
+ // Distinct from a miss: tenantB was looked up and confirmed unset.
21
+ expect(cache.get(tenantB)).toEqual({ value: undefined });
22
+ });
23
+
24
+ test("invalidate() drops only the given tenant", () => {
25
+ const cache = createTenantTimezoneCache();
26
+ cache.set(tenantA, "Asia/Tokyo");
27
+ cache.set(tenantB, "Europe/Berlin");
28
+
29
+ cache.invalidate(tenantA);
30
+
31
+ expect(cache.get(tenantA)).toBeUndefined();
32
+ expect(cache.get(tenantB)).toEqual({ value: "Europe/Berlin" });
33
+ });
34
+
35
+ test("clear() drops every tenant", () => {
36
+ const cache = createTenantTimezoneCache();
37
+ cache.set(tenantA, "Asia/Tokyo");
38
+ cache.set(tenantB, "Europe/Berlin");
39
+
40
+ cache.clear();
41
+
42
+ expect(cache.size()).toBe(0);
43
+ expect(cache.get(tenantA)).toBeUndefined();
44
+ expect(cache.get(tenantB)).toBeUndefined();
45
+ });
46
+
47
+ test("entry expires after ttlMs", () => {
48
+ let t = 1_000_000;
49
+ const cache = createTenantTimezoneCache({ ttlMs: 1000, now: () => t });
50
+ cache.set(tenantA, "Asia/Tokyo");
51
+
52
+ expect(cache.get(tenantA)).toEqual({ value: "Asia/Tokyo" });
53
+
54
+ t += 1500;
55
+ expect(cache.get(tenantA)).toBeUndefined();
56
+ });
57
+
58
+ test("LRU: evicts oldest tenant when maxEntries is reached", () => {
59
+ const cache = createTenantTimezoneCache({ maxEntries: 2, ttlMs: 60_000 });
60
+ cache.set("a" as TenantId, "UTC");
61
+ cache.set("b" as TenantId, "UTC");
62
+ expect(cache.size()).toBe(2);
63
+
64
+ cache.set("c" as TenantId, "UTC");
65
+ expect(cache.size()).toBe(2);
66
+ expect(cache.get("a" as TenantId)).toBeUndefined();
67
+ expect(cache.get("c" as TenantId)).toEqual({ value: "UTC" });
68
+ });
69
+
70
+ test("LRU: touching an entry (get) moves it to the 'most recent' end", () => {
71
+ const cache = createTenantTimezoneCache({ maxEntries: 2, ttlMs: 60_000 });
72
+ cache.set("a" as TenantId, "UTC");
73
+ cache.set("b" as TenantId, "UTC");
74
+ cache.get("a" as TenantId); // touch a — b is now the oldest
75
+
76
+ cache.set("c" as TenantId, "UTC");
77
+
78
+ expect(cache.get("b" as TenantId)).toBeUndefined();
79
+ expect(cache.get("a" as TenantId)).toEqual({ value: "UTC" });
80
+ });
81
+
82
+ test("default maxEntries is 1000", () => {
83
+ const cache = createTenantTimezoneCache();
84
+ for (let i = 0; i < 1001; i++) {
85
+ cache.set(`tenant-${i}` as TenantId, "UTC");
86
+ }
87
+ expect(cache.size()).toBe(1000);
88
+ });
89
+ });
@@ -75,13 +75,22 @@ import {
75
75
  } from "./dispatcher-utils";
76
76
  import type { IdempotencyGuard } from "./idempotency";
77
77
  import type { LifecycleHooks } from "./lifecycle-pipeline";
78
+ import type { TenantTimezoneCache } from "./tenant-timezone-cache";
78
79
 
79
80
  // Framework/pipeline stays bundled-features-free, so this can't import the
80
81
  // `tenant` feature — the literal below IS the coupling to its `timezone`
81
82
  // config key. Renaming that key (or the "tenant" feature name) must update
82
83
  // this constant too; tenant-timezone-boot.integration.test.ts boots the real
83
84
  // createTenantFeature() and would catch a drift.
84
- const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
85
+ export const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
86
+
87
+ // Same bundled-features-free constraint as above — these name the config
88
+ // feature's write handlers so dispatch-write.ts can invalidate
89
+ // TENANT_TIMEZONE_CONFIG_KEY's cache entry on a successful write without
90
+ // importing the `config` feature. tenant-timezone-boot.integration.test.ts
91
+ // exercises both against the real config feature.
92
+ export const CONFIG_WRITE_SET_TYPE = "config:write:set";
93
+ export const CONFIG_WRITE_RESET_TYPE = "config:write:reset";
85
94
 
86
95
  export type BatchCommand = {
87
96
  readonly type: string;
@@ -110,6 +119,7 @@ export type DispatchContext = {
110
119
  sseBroker: SseBroker | undefined;
111
120
  tableCache: Map<string, ReturnType<typeof buildEntityTable>>;
112
121
  transitionCache: Map<string, ReturnType<typeof defineTransitions>>;
122
+ tenantTimezoneCache: TenantTimezoneCache;
113
123
  tracer: ReturnType<typeof getFallbackTracer>;
114
124
  meter: ReturnType<typeof getFallbackMeter>;
115
125
  };
@@ -606,7 +616,34 @@ export async function buildHandlerContext(
606
616
  // comes from SessionUser.timezone (set at login), else falls back to
607
617
  // tenant (createTzContext's own default). An app-injected GeoTzProvider
608
618
  // (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
609
- const tenantTz = config !== undefined ? await config(TENANT_TIMEZONE_CONFIG_KEY) : undefined;
619
+ //
620
+ // Resolving this via `config()` is a config-table SELECT on every single
621
+ // dispatch (fw#2462) — ctx.tenantTimezoneCache memoizes it per tenant.
622
+ // dispatch-write.ts invalidates on config:write:set/reset for this key.
623
+ // Only populate from a tx-free read (tx === undefined): a write-path
624
+ // lookup runs inside the caller's own open transaction, and caching a
625
+ // value read there could memoize state from a write earlier in that same
626
+ // transaction before it's known to commit. Reads always consult the
627
+ // cache regardless of tx, since serving a slightly stale cached value on
628
+ // the write path is harmless (bounded by ttlMs) and avoids reintroducing
629
+ // the SELECT on the hot path this fix targets.
630
+ let tenantTz: unknown;
631
+ if (config === undefined) {
632
+ tenantTz = undefined;
633
+ } else {
634
+ const cachedTz = ctx.tenantTimezoneCache.get(user.tenantId);
635
+ if (cachedTz) {
636
+ tenantTz = cachedTz.value;
637
+ } else {
638
+ tenantTz = await config(TENANT_TIMEZONE_CONFIG_KEY);
639
+ if (tx === undefined) {
640
+ ctx.tenantTimezoneCache.set(
641
+ user.tenantId,
642
+ typeof tenantTz === "string" ? tenantTz : undefined,
643
+ );
644
+ }
645
+ }
646
+ }
610
647
  // Guarded against garbage: an unvalidated string here (free-form config
611
648
  // key, legacy JWT claim predating validation) blows up every ctx.tz call
612
649
  // for the whole tenant with a RangeError. Fall back to UTC/tenant instead
@@ -3,6 +3,7 @@ import { selectRowForUpdateById } from "../db/queries/entity-read";
3
3
  import { asEntityTableMeta, selectMany } from "../db/query";
4
4
  import { buildEntityTable, toSnakeCase } from "../db/table-builder";
5
5
  import { hasAccess } from "../engine/access";
6
+ import { ConfigScopes } from "../engine/constants";
6
7
  import { checkWriteFieldRoles } from "../engine/field-access";
7
8
  import { defineTransitions, guardTransition } from "../engine/state-machine";
8
9
  import type { HandlerContext, SessionUser, WriteResult } from "../engine/types";
@@ -22,9 +23,12 @@ import { assertNoSecretLeak } from "../secrets";
22
23
  import type { DispatchContext } from "./dispatch-shared";
23
24
  import {
24
25
  buildHandlerContext,
26
+ CONFIG_WRITE_RESET_TYPE,
27
+ CONFIG_WRITE_SET_TYPE,
25
28
  checkFeatureEnabled,
26
29
  enforceRateLimit,
27
30
  runHandlerInstrumented,
31
+ TENANT_TIMEZONE_CONFIG_KEY,
28
32
  } from "./dispatch-shared";
29
33
  import {
30
34
  type AfterCommitHook,
@@ -73,6 +77,42 @@ function getTransitions(
73
77
  return transitions;
74
78
  }
75
79
 
80
+ // A successful config:write:set/reset returns { key, scope, ... } — see
81
+ // bundled-features/src/config/handlers/{set,reset}.write.ts. Narrows the
82
+ // otherwise-`unknown` WriteResult.data so invalidateTenantTimezoneCache
83
+ // below can check `key` without an unchecked cast.
84
+ function isConfigWriteResultForKey(
85
+ data: unknown,
86
+ key: string,
87
+ ): data is { key: string; scope?: string } {
88
+ if (typeof data !== "object" || data === null || !("key" in data)) return false;
89
+ return (data as { key: unknown }).key === key; // @cast-boundary engine-payload
90
+ }
91
+
92
+ // fw#2462: dispatch-shared.ts caches the resolved tenant:config:timezone
93
+ // value per tenant. A successful write to that key must drop the stale
94
+ // entry — tenant/user scope only affects the writing tenant, system scope
95
+ // changes the fallback every tenant without an override reads, so the
96
+ // whole cache is dropped instead of just one entry.
97
+ function invalidateTenantTimezoneCache(
98
+ ctx: DispatchContext,
99
+ type: string,
100
+ user: SessionUser,
101
+ result: WriteResult,
102
+ ): void {
103
+ // skip: failed write, nothing to invalidate
104
+ if (!result.isSuccess) return;
105
+ // skip: not a config:write:set/reset dispatch
106
+ if (type !== CONFIG_WRITE_SET_TYPE && type !== CONFIG_WRITE_RESET_TYPE) return;
107
+ // skip: write was for a different config key
108
+ if (!isConfigWriteResultForKey(result.data, TENANT_TIMEZONE_CONFIG_KEY)) return;
109
+ if (result.data.scope === ConfigScopes.system) {
110
+ ctx.tenantTimezoneCache.clear();
111
+ } else {
112
+ ctx.tenantTimezoneCache.invalidate(user.tenantId);
113
+ }
114
+ }
115
+
76
116
  // Runs lifecycle hooks for a handler result. inTransaction hooks fire NOW
77
117
  // (they see the tx via ctx.db when batch/write opens a transaction).
78
118
  // afterCommit hooks are queued into `afterCommitHooks` for the caller to
@@ -450,6 +490,14 @@ async function executeWriteInner(
450
490
  const eventData = (parsed.data ?? {}) as DbRow; // @cast-boundary engine-payload
451
491
  afterCommitHooks.push(() => jobRunner.handleEvent(type, eventData, user));
452
492
  }
493
+
494
+ invalidateTenantTimezoneCache(ctx, type, user, result);
495
+ // Again after commit: a query landing between the drop above and the
496
+ // commit would read the pre-write row (tx not yet visible) and
497
+ // repopulate the cache with the stale value for a full TTL.
498
+ afterCommitHooks.push(async () => {
499
+ invalidateTenantTimezoneCache(ctx, type, user, result);
500
+ });
453
501
  }
454
502
 
455
503
  // Response-guard: block Secret<> leaks in write responses (SaveContext
@@ -13,6 +13,7 @@ import { executeStream } from "./dispatch-stream";
13
13
  import { type HandlerType, resolveType } from "./dispatcher-utils";
14
14
  import type { IdempotencyGuard } from "./idempotency";
15
15
  import type { LifecycleHooks } from "./lifecycle-pipeline";
16
+ import { createTenantTimezoneCache } from "./tenant-timezone-cache";
16
17
 
17
18
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
18
19
  // HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
@@ -92,6 +93,9 @@ export function createDispatcher(
92
93
  // Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
93
94
  const tableCache = new Map<string, ReturnType<typeof buildEntityTable>>();
94
95
  const transitionCache = new Map<string, ReturnType<typeof defineTransitions>>();
96
+ // One per dispatcher instance (not a module-level singleton) so caches
97
+ // never leak across separately-booted apps or test stacks.
98
+ const tenantTimezoneCache = createTenantTimezoneCache();
95
99
 
96
100
  const dispatcherTracer = context.tracer ?? getFallbackTracer();
97
101
  const dispatcherMeter = context.meter ?? getFallbackMeter();
@@ -109,6 +113,7 @@ export function createDispatcher(
109
113
  sseBroker,
110
114
  tableCache,
111
115
  transitionCache,
116
+ tenantTimezoneCache,
112
117
  tracer: dispatcherTracer,
113
118
  meter: dispatcherMeter,
114
119
  };
@@ -0,0 +1,92 @@
1
+ // Per-dispatcher in-process cache for the resolved tenant:config:timezone
2
+ // value (fw#2462). dispatch-shared.ts's buildHandlerContext resolves that
3
+ // key on every dispatch to build ctx.tz — without a cache that's a config
4
+ // SELECT (sometimes two, cascade + fallback) per request, often inside an
5
+ // open write transaction. A tenant-keyed TTL cache removes the steady-state
6
+ // cost; dispatch-write.ts invalidates entries synchronously on
7
+ // config:write:set/reset for this key, so the TTL below only covers writes
8
+ // that bypass that path (migrations, seeds, direct DB edits).
9
+
10
+ import type { TenantId } from "../engine/types/identifiers";
11
+
12
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
13
+ const DEFAULT_MAX_ENTRIES = 1000;
14
+
15
+ export type TenantTimezoneCacheOptions = {
16
+ readonly ttlMs?: number;
17
+ // Cap on distinct tenants cached. On overflow, least-recently-used
18
+ // entries are evicted — mirrors secrets/dek-cache.ts.
19
+ readonly maxEntries?: number;
20
+ readonly now?: () => number;
21
+ };
22
+
23
+ export type TenantTimezoneCacheEntry = {
24
+ // The raw resolved config value, or undefined when the tenant has no
25
+ // override — undefined here is a cached fact ("looked up, no value"),
26
+ // distinct from a cache miss (get() returning undefined below).
27
+ readonly value: string | undefined;
28
+ };
29
+
30
+ export type TenantTimezoneCache = {
31
+ // Returns undefined on a cache miss (never looked up, or expired).
32
+ get(tenantId: TenantId): TenantTimezoneCacheEntry | undefined;
33
+ set(tenantId: TenantId, value: string | undefined): void;
34
+ invalidate(tenantId: TenantId): void;
35
+ // Drops every tenant's entry — used when a system-scope write changes
36
+ // the key, since the system row is the fallback default for every
37
+ // tenant without its own override (a system-scope change can affect
38
+ // all of them at once).
39
+ clear(): void;
40
+ size(): number;
41
+ };
42
+
43
+ export function createTenantTimezoneCache(
44
+ opts: TenantTimezoneCacheOptions = {},
45
+ ): TenantTimezoneCache {
46
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
47
+ const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
48
+ const now = opts.now ?? (() => Date.now());
49
+ // Map insertion order doubles as LRU order: touch = delete+re-insert.
50
+ const entries = new Map<TenantId, { value: string | undefined; expiresAt: number }>();
51
+
52
+ function evictOldestIfFull(): void {
53
+ // skip: cache has room, nothing to evict
54
+ if (entries.size < maxEntries) return;
55
+ const oldestKey = entries.keys().next().value;
56
+ // skip: defensive — only reachable if maxEntries is 0 (cache disabled) and entries is empty
57
+ if (oldestKey === undefined) return;
58
+ entries.delete(oldestKey);
59
+ }
60
+
61
+ return {
62
+ get(tenantId) {
63
+ const hit = entries.get(tenantId);
64
+ if (!hit) return undefined;
65
+ if (hit.expiresAt <= now()) {
66
+ entries.delete(tenantId);
67
+ return undefined;
68
+ }
69
+ entries.delete(tenantId);
70
+ entries.set(tenantId, hit);
71
+ return { value: hit.value };
72
+ },
73
+
74
+ set(tenantId, value) {
75
+ entries.delete(tenantId);
76
+ evictOldestIfFull();
77
+ entries.set(tenantId, { value, expiresAt: now() + ttlMs });
78
+ },
79
+
80
+ invalidate(tenantId) {
81
+ entries.delete(tenantId);
82
+ },
83
+
84
+ clear() {
85
+ entries.clear();
86
+ },
87
+
88
+ size() {
89
+ return entries.size;
90
+ },
91
+ };
92
+ }
package/src/schema-cli.ts CHANGED
@@ -14,13 +14,11 @@ import {
14
14
  assertValidMigrationName,
15
15
  baselineMigrations,
16
16
  createDbConnection,
17
- type DbConnection,
18
17
  diffReplayAgainstSnapshot,
19
18
  fetchAppliedMigrations,
20
19
  generateMigration,
21
20
  loadMigrationsFromDir,
22
21
  loadSnapshotJson,
23
- readRebuildMarker,
24
22
  rebuildTablesFromDiff,
25
23
  type renderTablesDdl,
26
24
  replayMigrationsDir,
@@ -33,12 +31,8 @@ import { validateBoot } from "./engine/boot-validator";
33
31
  import { createRegistry } from "./engine/registry";
34
32
  import type { FeatureDefinition } from "./engine/types/feature";
35
33
  import { createEventsTable } from "./event-store";
36
- import { buildProjectionTableIndex } from "./migrations";
37
- import {
38
- createEventConsumerStateTable,
39
- createProjectionStateTable,
40
- rebuildProjection,
41
- } from "./pipeline";
34
+ import { queueRebuildsFromMarkers, runPendingRebuilds } from "./migrations";
35
+ import { createEventConsumerStateTable, createProjectionStateTable } from "./pipeline";
42
36
  import { ensureTemporalPolyfill } from "./time";
43
37
 
44
38
  export type SchemaCliOut = {
@@ -86,33 +80,6 @@ function nextSequenceNumber(migrationsDir: string): number {
86
80
  return max + 1;
87
81
  }
88
82
 
89
- // Maps changed tables to their projections (via the app registry) and replays
90
- // the events. Tables without a registered projection are skipped.
91
- async function rebuildAffectedProjections(
92
- db: DbConnection,
93
- changedTables: readonly string[],
94
- features: readonly FeatureDefinition[],
95
- out: SchemaCliOut,
96
- ): Promise<void> {
97
- const registry = createRegistry(features);
98
- const tableToProjection = buildProjectionTableIndex(registry);
99
-
100
- const projections = new Set<string>();
101
- for (const table of changedTables) {
102
- const name = tableToProjection.get(table);
103
- if (name) projections.add(name);
104
- }
105
- // skip: no changed table maps to a registered projection — nothing to rebuild.
106
- if (projections.size === 0) return;
107
-
108
- out.log(` Rebuild ${projections.size} Projection(s)…`);
109
- for (const name of projections) {
110
- const r = await rebuildProjection(name, { db, registry });
111
- out.log(` ↻ ${name} (${r.eventsProcessed} events, ${r.durationMs}ms)`);
112
- }
113
- out.log("");
114
- }
115
-
116
83
  export type RunSchemaCliOptions = {
117
84
  /** Composed app features. When given, `apply` rebuilds the projections whose
118
85
  * tables a freshly applied migration changed (via its `.rebuild.json`
@@ -370,17 +337,36 @@ export async function runSchemaCli(
370
337
  out.log("");
371
338
 
372
339
  // Projection-rebuild for tables a freshly applied migration changed
373
- // (marker NNNN_<name>.rebuild.json from `generate`). Without it read_*
374
- // projections stay stale after a schema change. Needs the composed
340
+ // (marker NNNN_<name>.rebuild.json from `generate`). Needs the composed
375
341
  // feature set → only when the caller passed `features` (the app bin);
376
- // the dev CLI omits it and applies migrations only.
377
- if (options.features && result.applied.length > 0) {
378
- const changedTables = new Set<string>();
379
- for (const id of result.applied) {
380
- for (const table of readRebuildMarker(migrationsDir, id)) changedTables.add(table);
342
+ // the dev CLI omits it and applies migrations only. Runs unconditionally
343
+ // (not gated on result.applied.length > 0): a persistent queue
344
+ // (kumiko_pending_rebuilds) survives a failed/crashed rebuild from an
345
+ // earlier apply, so a later apply with zero new migrations still must
346
+ // retry it otherwise a failed rebuild is silently never retried,
347
+ // since the migration itself is already tracked applied (#2464).
348
+ if (options.features) {
349
+ const thisRunTables = await queueRebuildsFromMarkers(db, {
350
+ migrationsDir,
351
+ appliedIds: result.applied,
352
+ });
353
+ const registry = createRegistry(options.features);
354
+ const rebuildRun = await runPendingRebuilds(db, registry, { thisRunTables });
355
+ if (rebuildRun.rebuilt.length > 0) {
356
+ out.log(` Rebuild ${rebuildRun.rebuilt.length} Projection(s)…`);
357
+ for (const r of rebuildRun.rebuilt) {
358
+ out.log(` ↻ ${r.projection} (${r.eventsProcessed} events)`);
359
+ }
360
+ out.log("");
381
361
  }
382
- if (changedTables.size > 0) {
383
- await rebuildAffectedProjections(db, [...changedTables], options.features, out);
362
+ if (rebuildRun.failed.length > 0) {
363
+ throw new Error(
364
+ `Projection rebuild failed for: ${rebuildRun.failed
365
+ .map((f) => `${f.projection} (${f.error})`)
366
+ .join(
367
+ "; ",
368
+ )}. Table(s) stay queued in kumiko_pending_rebuilds — retried on the next apply.`,
369
+ );
384
370
  }
385
371
  }
386
372
  return 0;
@@ -77,7 +77,7 @@ const OVERRIDES_ARG_INDEX_1 = new Set(["createEmbeddedField", "createEmbeddedLis
77
77
 
78
78
  const FIELD_FACTORY_RE = /^create\w*Field$/;
79
79
 
80
- type ReportEntry = { readonly file: string; readonly line: number; readonly reason: string };
80
+ type ReportEntry = { readonly file: string; readonly line: number; readonly note: string };
81
81
  type FindBucket = "exact" | "fuzzy" | "none" | "secret" | "ref" | "personal-false" | "no-find";
82
82
 
83
83
  const reports: ReportEntry[] = [];
@@ -92,11 +92,11 @@ const counts: Record<FindBucket, number> = {
92
92
  "no-find": 0,
93
93
  };
94
94
 
95
- function report(node: Node, reason: string): void {
95
+ function report(node: Node, note: string): void {
96
96
  reports.push({
97
97
  file: node.getSourceFile().getFilePath(),
98
98
  line: node.getStartLineNumber(),
99
- reason,
99
+ note,
100
100
  });
101
101
  }
102
102
 
@@ -374,8 +374,8 @@ function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string)
374
374
  newLookupableSites.push({
375
375
  file: obj.getSourceFile().getFilePath(),
376
376
  line: subject.prop.getStartLineNumber(),
377
- // find: "fuzzy" newly adds lookupable (was searchable-only) needs a _bidx column migration
378
- reason: "fuzzy_search_needs_bidx_migration",
377
+ // Guard keys on property name `reason`; this is console copy, not an error code.
378
+ note: "needs a `_bidx` column migration",
379
379
  });
380
380
  }
381
381
 
@@ -469,11 +469,11 @@ async function main(): Promise<void> {
469
469
  }
470
470
  console.log(`\nNewly gains lookupable (needs a _bidx migration): ${newLookupableSites.length}`);
471
471
  for (const r of newLookupableSites) {
472
- console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.reason}`);
472
+ console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.note}`);
473
473
  }
474
474
  console.log(`\nReported (not transformed): ${reports.length}`);
475
475
  for (const r of reports) {
476
- console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.reason}`);
476
+ console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.note}`);
477
477
  }
478
478
  }
479
479
 
@@ -103,7 +103,7 @@ describe("createRequestHelper", () => {
103
103
  ),
104
104
  );
105
105
  const http = createRequestHelper(app, jwtStub().jwt);
106
- expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
106
+ await expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
107
107
  'Expected write "todo.create" to succeed but got error: internal_error (DbError: connection lost)',
108
108
  );
109
109
  });
@@ -119,7 +119,7 @@ describe("createRequestHelper", () => {
119
119
 
120
120
  const succeeding = appRecording(() => Response.json({ isSuccess: true, data: {} }));
121
121
  const http2 = createRequestHelper(succeeding.app, jwtStub().jwt);
122
- expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
122
+ await expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
123
123
  'Expected query "q.type" to fail but it succeeded',
124
124
  );
125
125
  });
@@ -90,6 +90,25 @@ export function describeFileProviderContract(
90
90
  await expect(it.next()).rejects.toThrow();
91
91
  });
92
92
 
93
+ test("list returns only keys under the given prefix", async () => {
94
+ const group = `contract/list-${crypto.randomUUID()}`;
95
+ const keyA = `${group}/a.txt`;
96
+ const keyB = `${group}/b.txt`;
97
+ const outsideKey = `contract/list-${crypto.randomUUID()}/c.txt`;
98
+ writtenKeys.push(keyA, keyB, outsideKey);
99
+ await provider.write(keyA, bytes("a"));
100
+ await provider.write(keyB, bytes("b"));
101
+ await provider.write(outsideKey, bytes("c"));
102
+
103
+ const listed = await provider.list(`${group}/`);
104
+ expect(new Set(listed)).toEqual(new Set([keyA, keyB]));
105
+ });
106
+
107
+ test("list on a prefix with no matches returns an empty array", async () => {
108
+ const listed = await provider.list(`contract/list-missing-${crypto.randomUUID()}/`);
109
+ expect(listed).toEqual([]);
110
+ });
111
+
93
112
  test("getSignedUrl, when implemented, returns a URL string", async () => {
94
113
  // skip: getSignedUrl is optional on the contract — feature-detected
95
114
  if (!provider.getSignedUrl) return;
@@ -271,10 +271,15 @@ type UpgradeMarkerCodemod = {
271
271
  readonly codemod: string;
272
272
  readonly title: string;
273
273
  };
274
+ type UpgradeMarkerManual = {
275
+ readonly version: string;
276
+ readonly title: string;
277
+ };
274
278
  type UpgradeMarker = {
275
279
  readonly version: string;
276
280
  readonly appliedAt: string;
277
281
  readonly codemods: readonly UpgradeMarkerCodemod[];
282
+ readonly pendingManual?: readonly UpgradeMarkerManual[];
278
283
  };
279
284
 
280
285
  function writeUpgradeMarker(targetDir: string, marker: UpgradeMarker): void {
@@ -324,6 +329,7 @@ function markerVersionForPending(
324
329
  // failure — no partial marker. Writes the marker whenever dryRun is false —
325
330
  // even with zero pending entries, so an already-current app still gets a
326
331
  // bootstrap marker recording its installed version (fw#2299).
332
+ // kumiko-lint-ignore complexity-budget sequential codemod runner with zero-pending bootstrap marker
327
333
  async function applyCodemods(
328
334
  out: UpgradeCliOut,
329
335
  pending: readonly ChangelogEntry[],
@@ -352,7 +358,7 @@ async function applyCodemods(
352
358
  const codemodEntries = breaking
353
359
  .filter(hasCodemod)
354
360
  .sort((a, b) => compareVersions(a.version, b.version));
355
- const manualEntries = breaking.filter((e) => !e.codemod);
361
+ const manualEntries = breaking.filter((e) => !hasCodemod(e));
356
362
 
357
363
  for (const e of manualEntries) {
358
364
  out.log(` ⚠ ${e.version} · ${e.title} — no codemod, manual migration required`);
@@ -407,14 +413,19 @@ async function applyCodemods(
407
413
  }
408
414
 
409
415
  const latestVersion = markerVersionForPending(pending, manualEntries, markerVersion);
416
+ const pendingManual = manualEntries.map((e) => ({ version: e.version, title: e.title }));
410
417
  writeUpgradeMarker(targetDir, {
411
418
  version: latestVersion,
412
419
  appliedAt: Temporal.Now.instant().toString(),
413
420
  codemods: ran,
421
+ ...(pendingManual.length > 0 && { pendingManual }),
414
422
  });
415
423
  out.log(
416
424
  ` ✓ Applied ${ran.length} codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`,
417
425
  );
426
+ if (pendingManual.length > 0) {
427
+ out.log(` ⚠ ${pendingManual.length} breaking change(s) still need manual migration.`);
428
+ }
418
429
  return 0;
419
430
  }
420
431