@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.
- package/package.json +7 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- 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/api-constants.ts +10 -0
- package/src/api/auth-routes.ts +3 -0
- package/src/api/index.ts +1 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/api/server.ts +17 -1
- package/src/arg-parser.ts +1 -1
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +30 -14
- package/src/crypto/index.ts +1 -0
- package/src/crypto/is-self-pii-field.ts +8 -0
- package/src/crypto/subject-resolver.ts +4 -3
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
- package/src/db/__tests__/migrate-generator.test.ts +12 -0
- package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
- package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/event-store-executor-context.ts +2 -2
- package/src/db/event-store-executor-read.ts +7 -6
- package/src/db/event-store-executor-write.ts +103 -49
- package/src/db/index.ts +2 -0
- package/src/db/migrate-generator.ts +14 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
- package/src/db/queries/backfill-pii.ts +13 -10
- package/src/db/queries/raw-sql.ts +14 -2
- package/src/db/queries/seed-context.ts +8 -4
- package/src/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- 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__/boot-validator-pii-retention.test.ts +32 -26
- package/src/engine/__tests__/boot-validator.test.ts +29 -3
- package/src/engine/__tests__/role-assignment.test.ts +41 -17
- package/src/engine/__tests__/schema-builder.test.ts +7 -7
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
- package/src/engine/boot-validator/entity-handler.ts +5 -0
- package/src/engine/boot-validator/pii-retention.ts +16 -4
- package/src/engine/boot-validator/screens.ts +9 -2
- package/src/engine/embedded-derived.ts +11 -10
- 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__/patch.test.ts +10 -0
- 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 +23 -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/role-assignment.ts +36 -18
- package/src/engine/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +21 -2
- package/src/errors/__tests__/write-failures.test.ts +13 -3
- package/src/errors/classes.ts +14 -13
- package/src/errors/kumiko-error.ts +11 -11
- package/src/errors/write-error-info.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
- package/src/event-store/__tests__/perf.integration.test.ts +5 -1
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
- package/src/event-store/event-store.ts +7 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +181 -2
- package/src/files/__tests__/local-provider.contract.test.ts +14 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- package/src/i18n/__tests__/mail-registry.test.ts +13 -1
- package/src/i18n/__tests__/request-locale.test.ts +19 -0
- package/src/i18n/index.ts +7 -1
- package/src/i18n/mail-registry.ts +10 -0
- package/src/i18n/request-locale.ts +11 -2
- package/src/i18n/required-surface-keys.ts +3 -1
- package/src/jobs/job-runner.ts +22 -10
- package/src/lifecycle/signal-handlers.ts +2 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
- 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/distributed-lock.ts +3 -0
- package/src/pipeline/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +39 -48
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- package/src/search/purge-subject.ts +4 -3
- package/src/search/reindex-entity.ts +2 -2
- package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
- package/src/stack/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/ui-types/index.ts +1 -0
- 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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
100
|
-
//
|
|
101
|
-
// unlisten handle was mishandled, the subscription
|
|
102
|
-
//
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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 {
|
|
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) —
|
|
155
|
-
// allowlist blocks flag-like names
|
|
156
|
-
|
|
157
|
-
|
|
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`).
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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 (
|
|
378
|
-
|
|
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;
|