@cosmicdrift/kumiko-framework 0.339.0 → 0.341.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 (81) hide show
  1. package/dist/api/index.d.ts +2 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/route-registrars.d.ts +4 -2
  4. package/dist/api/route-registrars.js +9 -1
  5. package/dist/api/server.d.ts +2 -1
  6. package/dist/api/server.js +43 -5
  7. package/dist/bun-db/index.d.ts +2 -2
  8. package/dist/bun-db/index.js +1 -1
  9. package/dist/bun-db/query.d.ts +25 -1
  10. package/dist/bun-db/query.js +136 -15
  11. package/dist/db/queries/shadow-swap.d.ts +2 -1
  12. package/dist/db/queries/shadow-swap.js +70 -7
  13. package/dist/db/query-api.d.ts +1 -1
  14. package/dist/db/query.d.ts +1 -1
  15. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  16. package/dist/engine/boot-validator/entity-handler.js +48 -0
  17. package/dist/engine/define-handler.js +1 -0
  18. package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
  19. package/dist/engine/feature-ast/extractors/handlers.js +11 -1
  20. package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
  21. package/dist/engine/feature-ast/extractors/hooks.js +14 -0
  22. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  23. package/dist/engine/feature-ast/extractors/index.js +1 -1
  24. package/dist/engine/feature-ast/patch.js +1 -0
  25. package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
  26. package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
  27. package/dist/engine/feature-ast/patterns.d.ts +2 -1
  28. package/dist/engine/feature-ast/render.js +3 -0
  29. package/dist/engine/feature-entity-handlers.d.ts +2 -1
  30. package/dist/engine/feature-entity-handlers.js +4 -0
  31. package/dist/engine/feature-ui-extensions.js +9 -0
  32. package/dist/engine/index.d.ts +1 -1
  33. package/dist/engine/membership-roles.js +5 -1
  34. package/dist/engine/pattern-library/mixed-schemas.js +5 -0
  35. package/dist/engine/registry-state.js +38 -5
  36. package/dist/engine/registry-validate.js +2 -0
  37. package/dist/engine/steps/unsafe-projection-upsert.js +1 -1
  38. package/dist/engine/tier-resolver-extension.d.ts +2 -0
  39. package/dist/engine/types/index.d.ts +2 -2
  40. package/dist/entrypoint/index.js +25 -6
  41. package/dist/errors/classes.d.ts +5 -0
  42. package/dist/errors/classes.js +13 -0
  43. package/dist/errors/index.d.ts +1 -1
  44. package/dist/errors/index.js +1 -1
  45. package/dist/files/file-routes.js +7 -2
  46. package/dist/files/index.d.ts +1 -0
  47. package/dist/files/index.js +1 -0
  48. package/dist/files/provider-resolver.d.ts +2 -0
  49. package/dist/files/provider-resolver.js +16 -1
  50. package/dist/files/resolve-content-type.d.ts +11 -0
  51. package/dist/files/resolve-content-type.js +71 -0
  52. package/dist/files/types.d.ts +1 -1
  53. package/dist/files/types.js +5 -1
  54. package/dist/jobs/job-runner.js +1 -1
  55. package/dist/pipeline/dispatch-shared.d.ts +4 -1
  56. package/dist/pipeline/dispatch-shared.js +70 -9
  57. package/dist/pipeline/dispatch-write.js +35 -10
  58. package/dist/pipeline/dispatcher.d.ts +2 -0
  59. package/dist/pipeline/dispatcher.js +17 -1
  60. package/dist/pipeline/projection-rebuild.js +1 -1
  61. package/dist/rate-limit/bucket.d.ts +4 -0
  62. package/dist/rate-limit/bucket.js +25 -0
  63. package/dist/rate-limit/index.d.ts +2 -2
  64. package/dist/rate-limit/index.js +2 -2
  65. package/dist/rate-limit/middleware.d.ts +9 -0
  66. package/dist/rate-limit/middleware.js +33 -2
  67. package/dist/rate-limit/resolver.js +2 -1
  68. package/dist/redis/cache-sync-bus.d.ts +24 -0
  69. package/dist/redis/cache-sync-bus.js +152 -0
  70. package/dist/redis/cache-sync-topics.d.ts +17 -0
  71. package/dist/redis/cache-sync-topics.js +22 -0
  72. package/dist/redis/index.d.ts +4 -0
  73. package/dist/redis/index.js +2 -0
  74. package/dist/redis/pubsub-signal.d.ts +1 -0
  75. package/dist/redis/pubsub-signal.js +28 -0
  76. package/dist/stack/redis.d.ts +6 -1
  77. package/dist/stack/redis.js +7 -2
  78. package/dist/stack/test-stack.d.ts +15 -0
  79. package/dist/stack/test-stack.js +21 -1
  80. package/package.json +4 -4
  81. package/src/changes.json +54 -0
@@ -3,10 +3,11 @@ import { DEPRECATED_CROSS_TENANT_SIGNAL, isDeprecatedCrossTenantHandler, } from
3
3
  import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
4
4
  import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
5
5
  import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
6
+ import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
6
7
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
7
8
  import { runBatch, unwrapSingle } from "./dispatch-batch.js";
8
9
  import { executeQuery } from "./dispatch-query.js";
9
- import { resolveAuthClaimsFn } from "./dispatch-shared.js";
10
+ import { resolveAuthClaimsFn, TENANT_TIMEZONE_CONFIG_KEY } from "./dispatch-shared.js";
10
11
  import { executeStream } from "./dispatch-stream.js";
11
12
  import { resolveType } from "./dispatcher-utils.js";
12
13
  import { createMemberReaderFn } from "./member-reader.js";
@@ -54,6 +55,20 @@ export function createDispatcher(registry, context, options = {}) {
54
55
  // One per dispatcher instance (not a module-level singleton) so caches
55
56
  // never leak across separately-booted apps or test stacks.
56
57
  const tenantTimezoneCache = createTenantTimezoneCache();
58
+ const cacheSync = options.cacheSync ?? undefined;
59
+ cacheSync?.subscribe(CACHE_SYNC_TOPICS.tenantConfig, (message) => {
60
+ // skip: malformed message from another process
61
+ if (!isTenantConfigSyncMessage(message))
62
+ return;
63
+ // skip: a different config key, the timezone cache is unaffected
64
+ if (message.key !== undefined && message.key !== TENANT_TIMEZONE_CONFIG_KEY)
65
+ return;
66
+ if ("scope" in message)
67
+ tenantTimezoneCache.clear();
68
+ else
69
+ tenantTimezoneCache.invalidate(message.tenantId);
70
+ });
71
+ cacheSync?.onResync(() => tenantTimezoneCache.clear());
57
72
  const escapeHatchReportWindow = createEscapeHatchReportWindow();
58
73
  const dispatcherTracer = context.tracer ?? getFallbackTracer();
59
74
  const dispatcherMeter = context.meter ?? getFallbackMeter();
@@ -82,6 +97,7 @@ export function createDispatcher(registry, context, options = {}) {
82
97
  tableCache,
83
98
  transitionCache,
84
99
  tenantTimezoneCache,
100
+ cacheSync,
85
101
  escapeHatchReportWindow,
86
102
  tracer: dispatcherTracer,
87
103
  meter: dispatcherMeter,
@@ -186,7 +186,7 @@ export async function rebuildProjection(projectionName, deps) {
186
186
  // reconstruct (#498 ghost — direct-inserted without a .created event),
187
187
  // which the swap would silently drop. Implicit projections only.
188
188
  if (projection.isImplicit === true) {
189
- await assertNoUnreachableLiveRows(tx, projectionName, meta.tableName, sourcesList);
189
+ await assertNoUnreachableLiveRows(tx, projectionName, meta.tableName, sourcesList, projection.extraSourceRowIds);
190
190
  // Non-blocking counterpart: reports column-level drift (bidx excluded)
191
191
  // without aborting — see countColumnDrift for why this isn't fail-hard.
192
192
  const drift = await countColumnDrift(tx, meta.tableName, meta);
@@ -12,3 +12,7 @@ export type BucketResult = {
12
12
  readonly reason: string;
13
13
  };
14
14
  export declare function buildBucketKey(option: RateLimitOption, ctx: BucketContext): BucketResult;
15
+ export declare function normalizePayloadBucketValue(value: string): string;
16
+ export declare function buildPayloadBucketKey(handlerName: string, field: string, digest: string): string;
17
+ export declare function toPublicBucketName(bucket: string): string;
18
+ export declare function createPayloadDigest(masterSecret: string): (value: string) => string;
@@ -1,3 +1,5 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { derivePurposeSecret } from "../secrets/derive-purpose-secret.js";
1
3
  export function buildBucketKey(option, ctx) {
2
4
  switch (option.per) {
3
5
  case "user":
@@ -18,3 +20,26 @@ export function buildBucketKey(option, ctx) {
18
20
  return { kind: "key", key: `ip+handler:${ctx.ip}:${ctx.handlerName}` };
19
21
  }
20
22
  }
23
+ // Payload-field buckets: the value (e.g. a recipient address) is normalized
24
+ // and HMAC-hashed so Redis never holds it in plaintext.
25
+ const PAYLOAD_BUCKET_PURPOSE = "kumiko:rate-limit:payload-bucket";
26
+ export function normalizePayloadBucketValue(value) {
27
+ return value.trim().toLowerCase();
28
+ }
29
+ export function buildPayloadBucketKey(handlerName, field, digest) {
30
+ return `payload+handler:${handlerName}:${field}:${digest}`;
31
+ }
32
+ // Everything after the scope tag identifies the caller (IP, user/tenant id,
33
+ // auth target, payload digest). IPv6 addresses and handler names contain ":",
34
+ // so no later segment can be kept safely by parsing; the client already knows
35
+ // which handler/route it called.
36
+ export function toPublicBucketName(bucket) {
37
+ const separator = bucket.indexOf(":");
38
+ return separator === -1 ? bucket : bucket.slice(0, separator);
39
+ }
40
+ // Rotating the JWT secret only resets these buckets (new digests); they are
41
+ // ephemeral (TTL 2x window), so no migration is needed.
42
+ export function createPayloadDigest(masterSecret) {
43
+ const key = derivePurposeSecret(masterSecret, PAYLOAD_BUCKET_PURPOSE);
44
+ return (value) => createHmac("sha256", key).update(value).digest("hex");
45
+ }
@@ -1,3 +1,3 @@
1
- export { type BucketContext, type BucketResult, buildBucketKey } from "./bucket.js";
2
- export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, } from "./middleware.js";
1
+ export { type BucketContext, type BucketResult, buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.js";
2
+ export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, type HttpRouteRateLimitOptions, httpRouteRateLimit, } from "./middleware.js";
3
3
  export { createRateLimitResolver, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.js";
@@ -1,3 +1,3 @@
1
- export { buildBucketKey } from "./bucket.js";
2
- export { authEndpointRateLimit, globalIpRateLimit, } from "./middleware.js";
1
+ export { buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.js";
2
+ export { authEndpointRateLimit, globalIpRateLimit, httpRouteRateLimit, } from "./middleware.js";
3
3
  export { createRateLimitResolver, } from "./resolver.js";
@@ -1,5 +1,6 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import { type ClientIpResolver } from "../api/client-ip.js";
3
+ import type { HttpRouteRateLimit } from "../engine/types/http-route.js";
3
4
  import type { RateLimitResolver } from "./resolver.js";
4
5
  export type GlobalIpRateLimitOptions = {
5
6
  readonly resolver: RateLimitResolver;
@@ -11,6 +12,14 @@ export type GlobalIpRateLimitOptions = {
11
12
  readonly onFailClosed?: (err: unknown) => void;
12
13
  };
13
14
  export declare function globalIpRateLimit(opts: GlobalIpRateLimitOptions): MiddlewareHandler;
15
+ export type HttpRouteRateLimitOptions = {
16
+ readonly resolver: RateLimitResolver;
17
+ readonly rateLimit: HttpRouteRateLimit;
18
+ readonly routeKey: string;
19
+ readonly clientIpResolver: ClientIpResolver;
20
+ readonly onFailClosed?: (err: unknown) => void;
21
+ };
22
+ export declare function httpRouteRateLimit(opts: HttpRouteRateLimitOptions): MiddlewareHandler;
14
23
  export type AuthEndpointRateLimitOptions = {
15
24
  readonly resolver: RateLimitResolver;
16
25
  readonly limit?: number;
@@ -2,6 +2,7 @@ import { isAuthRateLimitExempt } from "../api/api-constants.js";
2
2
  import { clientIpSourceFromHonoContext, createClientIpResolver, } from "../api/client-ip.js";
3
3
  import { requestContext } from "../api/request-context.js";
4
4
  import { RateLimitError, serializeError } from "../errors/index.js";
5
+ import { toPublicBucketName } from "./bucket.js";
5
6
  export function globalIpRateLimit(opts) {
6
7
  const limit = opts.limit ?? 1000;
7
8
  const windowSeconds = opts.windowSeconds ?? 60;
@@ -33,6 +34,34 @@ export function globalIpRateLimit(opts) {
33
34
  await next();
34
35
  };
35
36
  }
37
+ // Per-route limit for r.httpRoute({ rateLimit }). Fail-closed like L1: these
38
+ // routes are anonymous, so an outage must not turn into an unbounded flood.
39
+ export function httpRouteRateLimit(opts) {
40
+ const { rateLimit, routeKey } = opts;
41
+ const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("http-route");
42
+ const config = {
43
+ limit: rateLimit.limit,
44
+ windowSeconds: rateLimit.windowSeconds,
45
+ ...(rateLimit.cost !== undefined && { cost: rateLimit.cost }),
46
+ };
47
+ return async (c, next) => {
48
+ const ip = opts.clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
49
+ if (!ip)
50
+ return next();
51
+ const bucket = rateLimit.per === "ip" ? `http:${ip}` : `http:${ip}:${routeKey}`;
52
+ try {
53
+ const decision = await opts.resolver.check(bucket, config);
54
+ if (!decision.allowed)
55
+ return respondRateLimited(c, decision, bucket);
56
+ setRateLimitHeaders(c, decision);
57
+ }
58
+ catch (e) {
59
+ onFailClosed(e);
60
+ return c.json({ error: { code: "rate_limit_unavailable", message: "Rate limiter unavailable" } }, 503);
61
+ }
62
+ await next();
63
+ };
64
+ }
36
65
  export function authEndpointRateLimit(opts) {
37
66
  const limit = opts.limit ?? 5;
38
67
  const windowSeconds = opts.windowSeconds ?? 60;
@@ -70,7 +99,9 @@ function defaultOnFailClosed(label) {
70
99
  // override this with a structured logger; the default keeps the
71
100
  // noise visible if no logger is wired.
72
101
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
73
- console.error(`[rate-limit ${label}] fail-closed (refusing request):`, err);
102
+ console.error(`[rate-limit ${label}] fail-closed (refusing request):`,
103
+ // ioredis errors can carry the command args, i.e. the bucket key with IP/target.
104
+ err instanceof Error ? err.message : String(err));
74
105
  };
75
106
  }
76
107
  function setRateLimitHeaders(c, decision) {
@@ -87,7 +118,7 @@ function respondRateLimited(c, decision, bucket) {
87
118
  // dispatcher path. serializeError adds i18nKey, requestId, timestamp —
88
119
  // fields a hand-rolled body would silently miss.
89
120
  const err = new RateLimitError({
90
- bucket,
121
+ bucket: toPublicBucketName(bucket),
91
122
  limit: decision.limit,
92
123
  windowSeconds: decision.windowSeconds,
93
124
  remaining: decision.remaining,
@@ -4,6 +4,7 @@
4
4
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
5
5
  import { RateLimitError } from "../errors/index.js";
6
6
  import { RedisKeys } from "../pipeline/redis-keys.js";
7
+ import { toPublicBucketName } from "./bucket.js";
7
8
  // Token-Bucket rate limiter, atomic via Redis Lua. One round-trip per
8
9
  // check — the script computes the bucket state inline and either deducts
9
10
  // or rejects.
@@ -148,7 +149,7 @@ export function createRateLimitResolver(opts) {
148
149
  if (decision.allowed)
149
150
  return decision;
150
151
  throw new RateLimitError({
151
- bucket,
152
+ bucket: toPublicBucketName(bucket),
152
153
  limit: decision.limit,
153
154
  windowSeconds: decision.windowSeconds,
154
155
  remaining: decision.remaining,
@@ -0,0 +1,24 @@
1
+ import type { CacheSyncBus, ClosableCacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
2
+ import { type RedisClientOptions } from "./client.js";
3
+ import { type PubSubSignal } from "./pubsub-signal.js";
4
+ export type { CacheSyncBus, ClosableCacheSyncBus };
5
+ export declare function createLocalCacheSyncBus(): ClosableCacheSyncBus;
6
+ export type CacheSyncBusOverSignalOptions = {
7
+ readonly channel: string;
8
+ readonly originId?: string;
9
+ readonly resyncMinIntervalMs?: number;
10
+ readonly now?: () => number;
11
+ };
12
+ export declare function createCacheSyncBusOverSignal(signal: PubSubSignal, opts: CacheSyncBusOverSignalOptions): ClosableCacheSyncBus;
13
+ export type RedisCacheSyncBusOptions = {
14
+ readonly redisUrl: string;
15
+ readonly clientOptions?: RedisClientOptions;
16
+ readonly channelPrefix?: string;
17
+ };
18
+ export declare function createRedisCacheSyncBus(opts: RedisCacheSyncBusOptions): ClosableCacheSyncBus;
19
+ export declare function createDefaultCacheSyncBus(envVars?: Record<string, string | undefined>): ClosableCacheSyncBus;
20
+ export type ResolvedCacheSyncBus = {
21
+ readonly bus: CacheSyncBus | undefined;
22
+ readonly owned: ClosableCacheSyncBus | undefined;
23
+ };
24
+ export declare function resolveCacheSyncBus(explicit: CacheSyncBus | null | undefined): ResolvedCacheSyncBus;
@@ -0,0 +1,152 @@
1
+ import { createFallbackLogger } from "../logging/utils.js";
2
+ import { redisClientOptionsFromEnv } from "./client.js";
3
+ import { createRedisPubSubSignal } from "./pubsub-signal.js";
4
+ const log = createFallbackLogger("kumiko:cache-sync");
5
+ const CACHE_SYNC_CHANNEL = "kumiko:cache-sync";
6
+ const RESYNC_MIN_INTERVAL_MS = 5_000;
7
+ function isCacheSyncEnvelope(value) {
8
+ return (typeof value === "object" &&
9
+ value !== null &&
10
+ "origin" in value &&
11
+ typeof value.origin === "string" &&
12
+ "topic" in value &&
13
+ typeof value.topic === "string" &&
14
+ "message" in value);
15
+ }
16
+ function createLocalFanout() {
17
+ const subscribers = new Map();
18
+ const resyncListeners = new Set();
19
+ return {
20
+ deliver(topic, message) {
21
+ const listeners = subscribers.get(topic);
22
+ // skip: nobody in this process listens on the topic
23
+ if (!listeners)
24
+ return;
25
+ for (const listener of [...listeners]) {
26
+ // A failing listener must not starve the others nor the publishing write or the redis event loop.
27
+ try {
28
+ listener(message);
29
+ }
30
+ catch (err) {
31
+ log.error("subscriber threw", { topic, err });
32
+ }
33
+ }
34
+ },
35
+ subscribe(topic, listener) {
36
+ let listeners = subscribers.get(topic);
37
+ if (!listeners) {
38
+ listeners = new Set();
39
+ subscribers.set(topic, listeners);
40
+ }
41
+ listeners.add(listener);
42
+ return () => {
43
+ listeners.delete(listener);
44
+ };
45
+ },
46
+ onResync(listener) {
47
+ resyncListeners.add(listener);
48
+ return () => {
49
+ resyncListeners.delete(listener);
50
+ };
51
+ },
52
+ fireResync() {
53
+ for (const listener of [...resyncListeners]) {
54
+ try {
55
+ listener();
56
+ }
57
+ catch (err) {
58
+ log.error("resync listener threw", { err });
59
+ }
60
+ }
61
+ },
62
+ };
63
+ }
64
+ export function createLocalCacheSyncBus() {
65
+ const fanout = createLocalFanout();
66
+ return {
67
+ publish: fanout.deliver,
68
+ subscribe: fanout.subscribe,
69
+ onResync: fanout.onResync,
70
+ async close() { },
71
+ };
72
+ }
73
+ export function createCacheSyncBusOverSignal(signal, opts) {
74
+ const origin = opts.originId ?? crypto.randomUUID();
75
+ const resyncMinIntervalMs = opts.resyncMinIntervalMs ?? RESYNC_MIN_INTERVAL_MS;
76
+ const now = opts.now ?? Date.now;
77
+ const fanout = createLocalFanout();
78
+ signal.onMessage((_channel, payload) => {
79
+ // skip: foreign or malformed payload on the channel
80
+ if (!isCacheSyncEnvelope(payload))
81
+ return;
82
+ // skip: own echo, already delivered locally by publish()
83
+ if (payload.origin === origin)
84
+ return;
85
+ fanout.deliver(payload.topic, payload.message);
86
+ });
87
+ // Reconnect bursts collapse into one reload per interval; the trailing timer
88
+ // keeps a reconnect inside the window from being dropped.
89
+ let lastResyncAt = Number.NEGATIVE_INFINITY;
90
+ let pendingResync;
91
+ let closed = false;
92
+ const runResync = () => {
93
+ pendingResync = undefined;
94
+ lastResyncAt = now();
95
+ fanout.fireResync();
96
+ };
97
+ signal.onReconnect(() => {
98
+ // skip: bus closed, or a resync is already scheduled
99
+ if (closed || pendingResync !== undefined)
100
+ return;
101
+ const wait = lastResyncAt + resyncMinIntervalMs - now();
102
+ if (wait <= 0) {
103
+ runResync();
104
+ }
105
+ else {
106
+ pendingResync = setTimeout(runResync, wait);
107
+ pendingResync.unref();
108
+ }
109
+ });
110
+ return {
111
+ publish(topic, message) {
112
+ fanout.deliver(topic, message);
113
+ const envelope = { origin, topic, message };
114
+ signal.publish(opts.channel, envelope);
115
+ },
116
+ subscribe: fanout.subscribe,
117
+ onResync: fanout.onResync,
118
+ async close() {
119
+ closed = true;
120
+ if (pendingResync !== undefined)
121
+ clearTimeout(pendingResync);
122
+ await signal.close();
123
+ },
124
+ };
125
+ }
126
+ export function createRedisCacheSyncBus(opts) {
127
+ const channel = `${opts.channelPrefix ?? ""}${CACHE_SYNC_CHANNEL}`;
128
+ const signal = createRedisPubSubSignal({
129
+ redisUrl: opts.redisUrl,
130
+ channelPattern: channel,
131
+ label: "cache-sync",
132
+ ...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
133
+ });
134
+ return createCacheSyncBusOverSignal(signal, { channel });
135
+ }
136
+ // Same REDIS_URL gate as createDefaultSseBroker: replicas > 1 are expected to
137
+ // set it. Without it invalidation stays inside this process.
138
+ export function createDefaultCacheSyncBus(envVars = process.env) {
139
+ const redisUrl = envVars["REDIS_URL"];
140
+ return redisUrl
141
+ ? createRedisCacheSyncBus({ redisUrl, clientOptions: redisClientOptionsFromEnv(envVars) })
142
+ : createLocalCacheSyncBus();
143
+ }
144
+ // An explicit bus wins (caller owns it), null opts out, undefined builds the default.
145
+ export function resolveCacheSyncBus(explicit) {
146
+ if (explicit)
147
+ return { bus: explicit, owned: undefined };
148
+ if (explicit === null)
149
+ return { bus: undefined, owned: undefined };
150
+ const owned = createDefaultCacheSyncBus();
151
+ return { bus: owned, owned };
152
+ }
@@ -0,0 +1,17 @@
1
+ import type { TenantId } from "../engine/types/identifiers.js";
2
+ export declare const CACHE_SYNC_TOPICS: {
3
+ readonly tenantConfig: "tenant-config";
4
+ readonly tierAssignment: "tier-assignment";
5
+ };
6
+ export type TenantConfigSyncMessage = {
7
+ readonly tenantId: TenantId;
8
+ readonly key?: string;
9
+ } | {
10
+ readonly scope: "all";
11
+ readonly key?: string;
12
+ };
13
+ export type TierAssignmentSyncMessage = {
14
+ readonly tenantId: TenantId;
15
+ };
16
+ export declare function isTenantConfigSyncMessage(value: unknown): value is TenantConfigSyncMessage;
17
+ export declare function isTierAssignmentSyncMessage(value: unknown): value is TierAssignmentSyncMessage;
@@ -0,0 +1,22 @@
1
+ export const CACHE_SYNC_TOPICS = {
2
+ tenantConfig: "tenant-config",
3
+ tierAssignment: "tier-assignment",
4
+ };
5
+ function hasOptionalStringKey(value) {
6
+ return !("key" in value) || value.key === undefined || typeof value.key === "string";
7
+ }
8
+ export function isTenantConfigSyncMessage(value) {
9
+ if (typeof value !== "object" || value === null)
10
+ return false;
11
+ if (!hasOptionalStringKey(value))
12
+ return false;
13
+ if ("scope" in value)
14
+ return value.scope === "all";
15
+ return "tenantId" in value && typeof value.tenantId === "string";
16
+ }
17
+ export function isTierAssignmentSyncMessage(value) {
18
+ return (typeof value === "object" &&
19
+ value !== null &&
20
+ "tenantId" in value &&
21
+ typeof value.tenantId === "string");
22
+ }
@@ -1,3 +1,7 @@
1
+ export type { CacheSyncBus, CacheSyncBusOverSignalOptions, ClosableCacheSyncBus, RedisCacheSyncBusOptions, ResolvedCacheSyncBus, } from "./cache-sync-bus.js";
2
+ export { createCacheSyncBusOverSignal, createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, resolveCacheSyncBus, } from "./cache-sync-bus.js";
3
+ export type { TenantConfigSyncMessage, TierAssignmentSyncMessage } from "./cache-sync-topics.js";
4
+ export { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
1
5
  export type { RedisClientOptions } from "./client.js";
2
6
  export { createRedisClient, redisClientOptionsFromEnv } from "./client.js";
3
7
  export type { PubSubSignal, PubSubSignalOptions } from "./pubsub-signal.js";
@@ -1,2 +1,4 @@
1
+ export { createCacheSyncBusOverSignal, createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, resolveCacheSyncBus, } from "./cache-sync-bus.js";
2
+ export { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
1
3
  export { createRedisClient, redisClientOptionsFromEnv } from "./client.js";
2
4
  export { createRedisPubSubSignal } from "./pubsub-signal.js";
@@ -2,6 +2,7 @@ import { type RedisClientOptions } from "./client.js";
2
2
  export type PubSubSignal = {
3
3
  publish(channel: string, payload: unknown): void;
4
4
  onMessage(listener: (channel: string, payload: unknown) => void): void;
5
+ onReconnect(listener: () => void): void;
5
6
  close(): Promise<void>;
6
7
  };
7
8
  export type PubSubSignalOptions = {
@@ -14,6 +14,7 @@ export function createRedisPubSubSignal(opts) {
14
14
  publisher.on("error", logConnectionError(opts.label, "publisher"));
15
15
  subscriber.on("error", logConnectionError(opts.label, "subscriber"));
16
16
  const listeners = new Set();
17
+ const reconnectListeners = new Set();
17
18
  // Sole purpose: stop close() from racing the initial psubscribe (see
18
19
  // below) and silence its logging once a shutdown is already underway —
19
20
  // not a defense against ioredis auto-reconnecting later (quit() below
@@ -56,6 +57,30 @@ export function createRedisPubSubSignal(opts) {
56
57
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
57
58
  console.error(`[kumiko:${opts.label}] psubscribe failed:`, err);
58
59
  });
60
+ // ioredis re-sends the psubscribe itself (autoResubscribe) but emits no ack
61
+ // event; repeating it here queues behind that one on the same connection,
62
+ // so its reply means the subscription is live again.
63
+ let hasBeenReady = false;
64
+ subscriber.on("ready", () => {
65
+ if (!hasBeenReady) {
66
+ hasBeenReady = true;
67
+ // skip: the first ready is the initial connect, not a reconnect
68
+ return;
69
+ }
70
+ subscriber.psubscribe(opts.channelPattern).then(() => {
71
+ // skip: close() ran while the resubscribe was in flight
72
+ if (closed)
73
+ return;
74
+ for (const listener of reconnectListeners)
75
+ listener();
76
+ }, (err) => {
77
+ // skip: a close() already underway intentionally quit the connection
78
+ if (closed)
79
+ return;
80
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
81
+ console.error(`[kumiko:${opts.label}] resubscribe failed:`, err);
82
+ });
83
+ });
59
84
  return {
60
85
  publish(channel, payload) {
61
86
  publisher.publish(channel, JSON.stringify(payload)).catch((err) => {
@@ -70,6 +95,9 @@ export function createRedisPubSubSignal(opts) {
70
95
  onMessage(listener) {
71
96
  listeners.add(listener);
72
97
  },
98
+ onReconnect(listener) {
99
+ reconnectListeners.add(listener);
100
+ },
73
101
  async close() {
74
102
  closed = true;
75
103
  await initialSubscribe;
@@ -9,4 +9,9 @@ export type TestRedis = {
9
9
  cleanup: () => Promise<void>;
10
10
  };
11
11
  export declare function queueNamePrefixForTestRedis(keyPrefix: string): string;
12
- export declare function createTestRedis(): Promise<TestRedis>;
12
+ export type CreateTestRedisOptions = {
13
+ /** Reuse another stack's keyPrefix on a fresh connection. cleanup() then only
14
+ * disconnects — the owning stack flushes the namespace and its queues. */
15
+ readonly borrowKeyPrefix?: string;
16
+ };
17
+ export declare function createTestRedis(opts?: CreateTestRedisOptions): Promise<TestRedis>;
@@ -6,13 +6,13 @@ import { requireEnv } from "./db.js";
6
6
  export function queueNamePrefixForTestRedis(keyPrefix) {
7
7
  return keyPrefix.split(":").filter(Boolean).join("-");
8
8
  }
9
- export async function createTestRedis() {
9
+ export async function createTestRedis(opts = {}) {
10
10
  const Redis = (await import("ioredis")).Redis;
11
11
  const redisUrl = requireEnv("REDIS_URL");
12
12
  // Every test gets a per-file key prefix on a shared DB (no DB-pool-of-15
13
13
  // round-robin). Collisions at birthday-paradox rates are gone — the
14
14
  // prefix space is unbounded. See Track B.3 in docs/plans/tests-refactor.
15
- const prefix = `kt:${generateId().slice(-8)}:`;
15
+ const prefix = opts.borrowKeyPrefix ?? `kt:${generateId().slice(-8)}:`;
16
16
  const redis = new Redis(redisUrl, { keyPrefix: prefix });
17
17
  async function deleteKeysMatching(pattern) {
18
18
  // Open a prefix-less client for the scan — ioredis' keyPrefix is applied
@@ -38,6 +38,11 @@ export async function createTestRedis() {
38
38
  keyPrefix: prefix,
39
39
  flushNamespace,
40
40
  cleanup: async () => {
41
+ if (opts.borrowKeyPrefix !== undefined) {
42
+ redis.disconnect();
43
+ // skip: the owning stack flushes the shared namespace and its queues
44
+ return;
45
+ }
41
46
  await flushNamespace();
42
47
  // The derived per-stack JobRunner queues sit outside the keyPrefix.
43
48
  await deleteKeysMatching(`bull:${queueNamePrefixForTestRedis(prefix)}-*`);
@@ -7,6 +7,7 @@ import { type JobRunner, type JobRunnerOptions } from "../jobs/index.js";
7
7
  import type { Lifecycle } from "../lifecycle/index.js";
8
8
  import { type ObservabilityProvider } from "../observability/index.js";
9
9
  import type { Dispatcher, EventDispatcher } from "../pipeline/index.js";
10
+ import { type ClosableCacheSyncBus } from "../redis/cache-sync-bus.js";
10
11
  import type { SearchAdapter } from "../search/types.js";
11
12
  import { type EventCollector } from "./event-collector.js";
12
13
  import { type TestRedis } from "./redis.js";
@@ -17,6 +18,7 @@ export type TestStack = {
17
18
  registry: Registry;
18
19
  db: import("../db/index.js").DbConnection;
19
20
  redis: TestRedis;
21
+ cacheSync: ClosableCacheSyncBus;
20
22
  search: SearchAdapter;
21
23
  events: EventCollector;
22
24
  http: RequestHelper;
@@ -157,5 +159,18 @@ export type TestStackOptions = {
157
159
  /** Forwarded to buildServer's top-level `ServerOptions.trustedProxyHops`
158
160
  * — see there. Default 0. */
159
161
  trustedProxyHops?: number;
162
+ /** Second stack on the Redis namespace of `owner`: own connection, same
163
+ * keyPrefix, so rate-limit buckets, locks, idempotency keys and the
164
+ * cache-sync channel are shared across both. The derived
165
+ * queueNamePrefix is identical too, so both stacks share BullMQ queues.
166
+ * Cleanup only disconnects; the owner flushes the namespace and queues, so
167
+ * clean up the borrowing stack first. The DB is NOT shared — pass the same
168
+ * `dbName` + `persistentDb` for that. */
169
+ sharedRedisWith?: TestStack;
170
+ /** Publish/receive cache invalidations (tenant timezone, file providers,
171
+ * tier sets) over Redis Pub/Sub, namespaced by this stack's keyPrefix.
172
+ * Implied by `sharedRedisWith`; set it on the owner stack too, or its writes
173
+ * never reach the borrower. Without it the stack uses a process-local bus. */
174
+ cacheSync?: boolean;
160
175
  };
161
176
  export declare function setupTestStack(options: TestStackOptions): Promise<TestStack>;
@@ -7,6 +7,7 @@ import { createJobRunner } from "../jobs/index.js";
7
7
  import { createNoopProvider } from "../observability/index.js";
8
8
  import { createEntityCache, createEventDedup, createIdempotencyGuard, dispatcherToWriteRef, } from "../pipeline/index.js";
9
9
  import { createRateLimitResolver } from "../rate-limit/index.js";
10
+ import { createLocalCacheSyncBus, createRedisCacheSyncBus, } from "../redis/cache-sync-bus.js";
10
11
  import { createInMemorySearchAdapter } from "../search/index.js";
11
12
  import { createTestDb } from "./db.js";
12
13
  import { createJobFailureTracker, drainJobs } from "./drain-jobs.js";
@@ -39,7 +40,7 @@ export async function setupTestStack(options) {
39
40
  ...(options.dbName !== undefined && { dbName: options.dbName }),
40
41
  ...(options.persistentDb !== undefined && { persistent: options.persistentDb }),
41
42
  }),
42
- createTestRedis(),
43
+ createTestRedis(options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix }),
43
44
  ]);
44
45
  // Any throw in this DDL region must still drop the just-created ephemeral
45
46
  // DB/Redis — the caller never gets the stack object back to call
@@ -103,6 +104,16 @@ export async function setupTestStack(options) {
103
104
  }
104
105
  throw error;
105
106
  }
107
+ // Redis only where two stacks can actually disagree (avoids two extra
108
+ // Pub/Sub connections); otherwise a local bus so invalidation still works
109
+ // inside this stack. Passing it explicitly keeps buildServer from using its
110
+ // REDIS_URL default (an unnamespaced channel).
111
+ const cacheSync = options.cacheSync || options.sharedRedisWith
112
+ ? createRedisCacheSyncBus({
113
+ redisUrl: testRedis.redisUrl,
114
+ channelPrefix: testRedis.keyPrefix,
115
+ })
116
+ : createLocalCacheSyncBus();
106
117
  const searchAdapter = createInMemorySearchAdapter();
107
118
  const events = createEventCollector();
108
119
  const registry = createRegistry([...options.features]);
@@ -262,6 +273,7 @@ export async function setupTestStack(options) {
262
273
  ...(options.observability ? { observability: options.observability } : {}),
263
274
  dispatcherOptions: {
264
275
  idempotency,
276
+ cacheSync,
265
277
  ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
266
278
  ...(jobRunner && { jobRunner }),
267
279
  },
@@ -338,6 +350,7 @@ export async function setupTestStack(options) {
338
350
  registry,
339
351
  db: testDb.db,
340
352
  redis: testRedis,
353
+ cacheSync,
341
354
  search: searchAdapter,
342
355
  events,
343
356
  http,
@@ -355,6 +368,7 @@ export async function setupTestStack(options) {
355
368
  if (eventDispatcher)
356
369
  await eventDispatcher.stop();
357
370
  await server.observability.shutdown();
371
+ await cacheSync.close();
358
372
  await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
359
373
  },
360
374
  };
@@ -375,6 +389,12 @@ export async function setupTestStack(options) {
375
389
  // ignore — `error` is the one that matters
376
390
  }
377
391
  }
392
+ try {
393
+ await cacheSync.close();
394
+ }
395
+ catch {
396
+ // ignore — `error` is the one that matters
397
+ }
378
398
  try {
379
399
  await testDb.cleanup();
380
400
  }