@cosmicdrift/kumiko-framework 0.220.1 → 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 (110) hide show
  1. package/package.json +7 -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/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
  5. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
  7. package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
  8. package/src/api/api-constants.ts +10 -0
  9. package/src/api/auth-routes.ts +3 -0
  10. package/src/api/index.ts +1 -0
  11. package/src/api/pii-leak-guard.ts +4 -5
  12. package/src/api/server.ts +17 -1
  13. package/src/arg-parser.ts +1 -1
  14. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  15. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  16. package/src/bun-db/index.ts +1 -0
  17. package/src/bun-db/query.ts +30 -14
  18. package/src/crypto/index.ts +1 -0
  19. package/src/crypto/is-self-pii-field.ts +8 -0
  20. package/src/crypto/subject-resolver.ts +4 -3
  21. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  22. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  23. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  24. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  25. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  26. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
  27. package/src/db/blind-index-cleanup.ts +36 -19
  28. package/src/db/entity-table-meta.ts +6 -1
  29. package/src/db/event-store-executor-context.ts +2 -2
  30. package/src/db/event-store-executor-read.ts +7 -6
  31. package/src/db/event-store-executor-write.ts +103 -49
  32. package/src/db/index.ts +2 -0
  33. package/src/db/migrate-generator.ts +14 -0
  34. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  35. package/src/db/queries/backfill-pii.ts +13 -10
  36. package/src/db/queries/raw-sql.ts +14 -2
  37. package/src/db/queries/seed-context.ts +8 -4
  38. package/src/db/table-builder.ts +10 -3
  39. package/src/derivatives/__tests__/variant-key.test.ts +123 -1
  40. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  41. package/src/derivatives/derivatives-context.ts +4 -0
  42. package/src/derivatives/index.ts +9 -1
  43. package/src/derivatives/variant-key.ts +68 -0
  44. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  45. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  46. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  47. package/src/engine/__tests__/schema-builder.test.ts +7 -7
  48. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  49. package/src/engine/boot-validator/entity-handler.ts +5 -0
  50. package/src/engine/boot-validator/pii-retention.ts +16 -4
  51. package/src/engine/boot-validator/screens.ts +9 -2
  52. package/src/engine/embedded-derived.ts +11 -10
  53. package/src/engine/extensions/storage-provider.ts +28 -0
  54. package/src/engine/extensions/user-data.ts +4 -0
  55. package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
  56. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  57. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
  58. package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
  59. package/src/engine/feature-ast/extractors/index.ts +2 -0
  60. package/src/engine/feature-ast/extractors/shared.ts +26 -1
  61. package/src/engine/feature-ast/parse.ts +10 -25
  62. package/src/engine/feature-ast/patch.ts +23 -16
  63. package/src/engine/feature-ast/render.ts +3 -3
  64. package/src/engine/field-helpers.ts +1 -1
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/pattern-library/mixed-schemas.ts +6 -0
  67. package/src/engine/role-assignment.ts +36 -18
  68. package/src/engine/schema-builder.ts +14 -2
  69. package/src/errors/__tests__/classes.test.ts +21 -2
  70. package/src/errors/__tests__/write-failures.test.ts +13 -3
  71. package/src/errors/classes.ts +14 -13
  72. package/src/errors/kumiko-error.ts +11 -11
  73. package/src/errors/write-error-info.ts +1 -1
  74. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  75. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  76. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  77. package/src/event-store/event-store.ts +7 -0
  78. package/src/event-store/index.ts +1 -0
  79. package/src/files/__tests__/files.integration.test.ts +181 -2
  80. package/src/files/__tests__/local-provider.contract.test.ts +14 -0
  81. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  82. package/src/files/file-routes.ts +53 -6
  83. package/src/files/in-memory-provider.ts +4 -0
  84. package/src/files/local-provider.ts +22 -1
  85. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  86. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  87. package/src/i18n/index.ts +7 -1
  88. package/src/i18n/mail-registry.ts +10 -0
  89. package/src/i18n/request-locale.ts +11 -2
  90. package/src/i18n/required-surface-keys.ts +3 -1
  91. package/src/jobs/job-runner.ts +22 -10
  92. package/src/lifecycle/signal-handlers.ts +2 -0
  93. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  94. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
  95. package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
  96. package/src/pipeline/dispatch-shared.ts +39 -2
  97. package/src/pipeline/dispatch-write.ts +48 -0
  98. package/src/pipeline/dispatcher.ts +5 -0
  99. package/src/pipeline/distributed-lock.ts +3 -0
  100. package/src/pipeline/tenant-timezone-cache.ts +92 -0
  101. package/src/schema-cli.ts +39 -48
  102. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  103. package/src/scripts/codemod/pii-personal-migration.ts +7 -7
  104. package/src/search/purge-subject.ts +4 -3
  105. package/src/search/reindex-entity.ts +2 -2
  106. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  107. package/src/stack/__tests__/request-helper.test.ts +2 -2
  108. package/src/testing/file-provider-contract.ts +19 -0
  109. package/src/ui-types/index.ts +1 -0
  110. package/src/upgrade-cli.ts +112 -15
@@ -1,36 +1,18 @@
1
1
  // E.4 — PG LISTEN/NOTIFY wake-up. Without this, delivery latency is
2
2
  // bounded below by pollIntervalMs. With LISTEN, event-store.append fires
3
- // `pg_notify` on commit and any subscribed dispatcher wakes immediately
4
- // latency becomes TCP round-trip, typically sub-millisecond on localhost.
3
+ // `pg_notify` on commit and any subscribed dispatcher wakes immediately.
5
4
  //
6
- // The polling timer stays on as a safety net for dropped subscriptions
7
- // and crashes between commit and wake. These tests pin:
8
- //
9
- // 1. NOTIFY runOnce fires promptly, without waiting for the timer.
10
- // 2. The dispatcher starts cleanly when pgClient is wired and stops
11
- // without leaking the LISTEN connection, and still wakes on NOTIFY
12
- // after a restart cycle.
13
- //
14
- // #2042: these used to assert an absolute millisecond latency bound
15
- // (`< 40`, later `< 100`) against the test-stack's default 50ms polling
16
- // timer. On a shared CI runner a stalled event loop can push even a
17
- // working LISTEN's delivery past 100ms — measured up to 154ms — so no
18
- // millisecond bound both clears runner noise and stays under a 50ms
19
- // timer. Fix: push the polling timer out to 60s for this stack
20
- // (`eventDispatcherPollIntervalMs`) and assert delivery happens at all
21
- // inside a 5s window. If LISTEN is dead, nothing arrives before the 5s
22
- // deadline — a 12x margin under the 60s timer that no runner stall gets
23
- // anywhere near. If LISTEN works, delivery is near-instant regardless of
24
- // runner load. Verified by temporarily forcing pgClient to undefined in
25
- // test-stack.ts: both tests then fail at toHaveLength(1) with 0 received
26
- // after ~5s, confirming the assertion still discriminates.
5
+ // The polling timer stays as a safety net. These tests pin NOTIFY wake
6
+ // without waiting for the timer, and that start/stop still leaves LISTEN
7
+ // working. #2042: poll interval is 60s here; assert delivery inside 5s —
8
+ // if LISTEN is dead the timer cannot rescue the assertion.
27
9
 
28
10
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
29
11
  import { createEventStoreExecutor } from "../../db/event-store-executor";
30
12
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
31
13
  import { defineFeature } from "../../engine";
32
14
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
33
- import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
15
+ import { sharedWidgetEntity, sharedWidgetTable, waitFor } from "../../testing";
34
16
 
35
17
  // --- Fixture ---
36
18
 
@@ -85,21 +67,22 @@ describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
85
67
  try {
86
68
  await executor.create({ name: "latency-test" }, admin, tdb);
87
69
 
88
- const deadline = Date.now() + 5000;
89
- while (Date.now() < deadline && deliveryCount === 0) {
90
- await new Promise((r) => setTimeout(r, 5));
91
- }
92
- expect(deliveryCount).toBe(1);
70
+ await waitFor(
71
+ () => {
72
+ expect(deliveryCount).toBe(1);
73
+ },
74
+ { delays: [100, 500, 1000, 3500] },
75
+ );
93
76
  } finally {
94
77
  await stack.eventDispatcher?.stop();
95
78
  }
96
79
  });
97
80
 
98
81
  test("dispatcher start/stop cycle with LISTEN attached still delivers after restart", async () => {
99
- // Repeated start/stop must not leak connections or break LISTEN. After
100
- // 3 cycles the last .start() should still wake on NOTIFY — if the
101
- // unlisten handle was mishandled, the subscription would either be
102
- // stale (LISTEN on a closed connection) or double-registered.
82
+ // Repeated start/stop must not leak connections or break LISTEN.
83
+ // Two start/stop cycles, then a third start() that must still wake on
84
+ // NOTIFY — if the unlisten handle was mishandled, the subscription
85
+ // would be stale or double-registered.
103
86
  for (let i = 0; i < 2; i++) {
104
87
  await stack.eventDispatcher?.start();
105
88
  await stack.eventDispatcher?.stop();
@@ -110,11 +93,12 @@ describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
110
93
  try {
111
94
  await executor.create({ name: "restart-probe" }, admin, tdb);
112
95
 
113
- const deadline = Date.now() + 5000;
114
- while (Date.now() < deadline && deliveryCount === 0) {
115
- await new Promise((r) => setTimeout(r, 5));
116
- }
117
- expect(deliveryCount).toBe(1);
96
+ await waitFor(
97
+ () => {
98
+ expect(deliveryCount).toBe(1);
99
+ },
100
+ { delays: [100, 500, 1000, 3500] },
101
+ );
118
102
  } finally {
119
103
  await stack.eventDispatcher?.stop();
120
104
  }
@@ -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
  };
@@ -50,6 +50,9 @@ export function createDistributedLock(
50
50
  },
51
51
 
52
52
  async renew(key, token, ttlSeconds) {
53
+ // EXPIRE with 0/negative deletes the key but still returns 1 — reject
54
+ // before we claim ownership while releasing the lock.
55
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) return false;
53
56
  const result = (await redis.eval(
54
57
  renewScript,
55
58
  1,
@@ -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
@@ -11,15 +11,14 @@
11
11
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
12
12
  import { join, resolve as resolvePath } from "node:path";
13
13
  import {
14
+ assertValidMigrationName,
14
15
  baselineMigrations,
15
16
  createDbConnection,
16
- type DbConnection,
17
17
  diffReplayAgainstSnapshot,
18
18
  fetchAppliedMigrations,
19
19
  generateMigration,
20
20
  loadMigrationsFromDir,
21
21
  loadSnapshotJson,
22
- readRebuildMarker,
23
22
  rebuildTablesFromDiff,
24
23
  type renderTablesDdl,
25
24
  replayMigrationsDir,
@@ -32,12 +31,8 @@ import { validateBoot } from "./engine/boot-validator";
32
31
  import { createRegistry } from "./engine/registry";
33
32
  import type { FeatureDefinition } from "./engine/types/feature";
34
33
  import { createEventsTable } from "./event-store";
35
- import { buildProjectionTableIndex } from "./migrations";
36
- import {
37
- createEventConsumerStateTable,
38
- createProjectionStateTable,
39
- rebuildProjection,
40
- } from "./pipeline";
34
+ import { queueRebuildsFromMarkers, runPendingRebuilds } from "./migrations";
35
+ import { createEventConsumerStateTable, createProjectionStateTable } from "./pipeline";
41
36
  import { ensureTemporalPolyfill } from "./time";
42
37
 
43
38
  export type SchemaCliOut = {
@@ -85,33 +80,6 @@ function nextSequenceNumber(migrationsDir: string): number {
85
80
  return max + 1;
86
81
  }
87
82
 
88
- // Maps changed tables to their projections (via the app registry) and replays
89
- // the events. Tables without a registered projection are skipped.
90
- async function rebuildAffectedProjections(
91
- db: DbConnection,
92
- changedTables: readonly string[],
93
- features: readonly FeatureDefinition[],
94
- out: SchemaCliOut,
95
- ): Promise<void> {
96
- const registry = createRegistry(features);
97
- const tableToProjection = buildProjectionTableIndex(registry);
98
-
99
- const projections = new Set<string>();
100
- for (const table of changedTables) {
101
- const name = tableToProjection.get(table);
102
- if (name) projections.add(name);
103
- }
104
- // skip: no changed table maps to a registered projection — nothing to rebuild.
105
- if (projections.size === 0) return;
106
-
107
- out.log(` Rebuild ${projections.size} Projection(s)…`);
108
- for (const name of projections) {
109
- const r = await rebuildProjection(name, { db, registry });
110
- out.log(` ↻ ${name} (${r.eventsProcessed} events, ${r.durationMs}ms)`);
111
- }
112
- out.log("");
113
- }
114
-
115
83
  export type RunSchemaCliOptions = {
116
84
  /** Composed app features. When given, `apply` rebuilds the projections whose
117
85
  * tables a freshly applied migration changed (via its `.rebuild.json`
@@ -151,10 +119,14 @@ export async function runSchemaCli(
151
119
  out.err(" Usage: schema generate <name>");
152
120
  return 1;
153
121
  }
154
- // name lands unescaped in `${seq}_${name}.sql` (generateMigration) — this
155
- // allowlist blocks flag-like names and path traversal (`../../x`).
156
- if (name.startsWith("-") || !/^[A-Za-z0-9_-]+$/.test(name)) {
157
- out.err(` Invalid migration name "${name}" — use letters, digits, "-", "_" only.`);
122
+ // name lands unescaped in `${seq}_${name}.sql` (generateMigration) —
123
+ // shared allowlist blocks flag-like names, path traversal, and
124
+ // ENAMETOOLONG-length names.
125
+ try {
126
+ assertValidMigrationName(name);
127
+ } catch (e) {
128
+ const msg = e instanceof Error ? e.message : String(e);
129
+ out.err(` ${msg}`);
158
130
  out.err(" Usage: schema generate <name>");
159
131
  return 1;
160
132
  }
@@ -365,17 +337,36 @@ export async function runSchemaCli(
365
337
  out.log("");
366
338
 
367
339
  // Projection-rebuild for tables a freshly applied migration changed
368
- // (marker NNNN_<name>.rebuild.json from `generate`). Without it read_*
369
- // projections stay stale after a schema change. Needs the composed
340
+ // (marker NNNN_<name>.rebuild.json from `generate`). Needs the composed
370
341
  // feature set → only when the caller passed `features` (the app bin);
371
- // the dev CLI omits it and applies migrations only.
372
- if (options.features && result.applied.length > 0) {
373
- const changedTables = new Set<string>();
374
- for (const id of result.applied) {
375
- 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("");
376
361
  }
377
- if (changedTables.size > 0) {
378
- 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
+ );
379
370
  }
380
371
  }
381
372
  return 0;