@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.
- package/package.json +7 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
- package/src/api/auth-routes.ts +3 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/arg-parser.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- package/src/derivatives/derivatives-context.ts +4 -0
- package/src/derivatives/index.ts +9 -1
- package/src/derivatives/variant-key.ts +68 -0
- package/src/engine/__tests__/schema-builder.test.ts +4 -4
- package/src/engine/extensions/storage-provider.ts +28 -0
- package/src/engine/extensions/user-data.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
- package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
- package/src/engine/feature-ast/extractors/index.ts +2 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -1
- package/src/engine/feature-ast/parse.ts +10 -25
- package/src/engine/feature-ast/patch.ts +14 -16
- package/src/engine/feature-ast/render.ts +3 -3
- package/src/engine/field-helpers.ts +1 -1
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/mixed-schemas.ts +6 -0
- package/src/engine/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +16 -2
- package/src/errors/__tests__/write-failures.test.ts +10 -0
- package/src/errors/classes.ts +14 -13
- package/src/errors/write-error-info.ts +1 -1
- package/src/files/__tests__/local-provider.contract.test.ts +14 -0
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- package/src/jobs/job-runner.ts +22 -10
- package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
- package/src/pipeline/dispatch-shared.ts +39 -2
- package/src/pipeline/dispatch-write.ts +48 -0
- package/src/pipeline/dispatcher.ts +5 -0
- package/src/pipeline/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +30 -44
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- package/src/stack/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/upgrade-cli.ts +12 -1
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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(
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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 {
|
|
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`).
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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 (
|
|
383
|
-
|
|
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
|
|
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,
|
|
95
|
+
function report(node: Node, note: string): void {
|
|
96
96
|
reports.push({
|
|
97
97
|
file: node.getSourceFile().getFilePath(),
|
|
98
98
|
line: node.getStartLineNumber(),
|
|
99
|
-
|
|
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
|
-
//
|
|
378
|
-
|
|
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.
|
|
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.
|
|
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;
|
package/src/upgrade-cli.ts
CHANGED
|
@@ -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
|
|
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
|
|