@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.
- package/dist/api/index.d.ts +2 -1
- package/dist/api/index.js +1 -1
- package/dist/api/route-registrars.d.ts +4 -2
- package/dist/api/route-registrars.js +9 -1
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +43 -5
- package/dist/bun-db/index.d.ts +2 -2
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +25 -1
- package/dist/bun-db/query.js +136 -15
- package/dist/db/queries/shadow-swap.d.ts +2 -1
- package/dist/db/queries/shadow-swap.js +70 -7
- package/dist/db/query-api.d.ts +1 -1
- package/dist/db/query.d.ts +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +48 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/handlers.js +11 -1
- package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/hooks.js +14 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
- package/dist/engine/feature-ast/patterns.d.ts +2 -1
- package/dist/engine/feature-ast/render.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +2 -1
- package/dist/engine/feature-entity-handlers.js +4 -0
- package/dist/engine/feature-ui-extensions.js +9 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/membership-roles.js +5 -1
- package/dist/engine/pattern-library/mixed-schemas.js +5 -0
- package/dist/engine/registry-state.js +38 -5
- package/dist/engine/registry-validate.js +2 -0
- package/dist/engine/steps/unsafe-projection-upsert.js +1 -1
- package/dist/engine/tier-resolver-extension.d.ts +2 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/entrypoint/index.js +25 -6
- package/dist/errors/classes.d.ts +5 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/files/file-routes.js +7 -2
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/provider-resolver.d.ts +2 -0
- package/dist/files/provider-resolver.js +16 -1
- package/dist/files/resolve-content-type.d.ts +11 -0
- package/dist/files/resolve-content-type.js +71 -0
- package/dist/files/types.d.ts +1 -1
- package/dist/files/types.js +5 -1
- package/dist/jobs/job-runner.js +1 -1
- package/dist/pipeline/dispatch-shared.d.ts +4 -1
- package/dist/pipeline/dispatch-shared.js +70 -9
- package/dist/pipeline/dispatch-write.js +35 -10
- package/dist/pipeline/dispatcher.d.ts +2 -0
- package/dist/pipeline/dispatcher.js +17 -1
- package/dist/pipeline/projection-rebuild.js +1 -1
- package/dist/rate-limit/bucket.d.ts +4 -0
- package/dist/rate-limit/bucket.js +25 -0
- package/dist/rate-limit/index.d.ts +2 -2
- package/dist/rate-limit/index.js +2 -2
- package/dist/rate-limit/middleware.d.ts +9 -0
- package/dist/rate-limit/middleware.js +33 -2
- package/dist/rate-limit/resolver.js +2 -1
- package/dist/redis/cache-sync-bus.d.ts +24 -0
- package/dist/redis/cache-sync-bus.js +152 -0
- package/dist/redis/cache-sync-topics.d.ts +17 -0
- package/dist/redis/cache-sync-topics.js +22 -0
- package/dist/redis/index.d.ts +4 -0
- package/dist/redis/index.js +2 -0
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +28 -0
- package/dist/stack/redis.d.ts +6 -1
- package/dist/stack/redis.js +7 -2
- package/dist/stack/test-stack.d.ts +15 -0
- package/dist/stack/test-stack.js +21 -1
- package/package.json +4 -4
- 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";
|
package/dist/rate-limit/index.js
CHANGED
|
@@ -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):`,
|
|
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
|
+
}
|
package/dist/redis/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/redis/index.js
CHANGED
|
@@ -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;
|
package/dist/stack/redis.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/stack/redis.js
CHANGED
|
@@ -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>;
|
package/dist/stack/test-stack.js
CHANGED
|
@@ -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
|
}
|