@cosmicdrift/kumiko-framework 0.340.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 (74) 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/db/queries/shadow-swap.d.ts +2 -1
  8. package/dist/db/queries/shadow-swap.js +70 -7
  9. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  10. package/dist/engine/boot-validator/entity-handler.js +48 -0
  11. package/dist/engine/define-handler.js +1 -0
  12. package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
  13. package/dist/engine/feature-ast/extractors/handlers.js +11 -1
  14. package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
  15. package/dist/engine/feature-ast/extractors/hooks.js +14 -0
  16. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  17. package/dist/engine/feature-ast/extractors/index.js +1 -1
  18. package/dist/engine/feature-ast/patch.js +1 -0
  19. package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
  20. package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
  21. package/dist/engine/feature-ast/patterns.d.ts +2 -1
  22. package/dist/engine/feature-ast/render.js +3 -0
  23. package/dist/engine/feature-entity-handlers.d.ts +2 -1
  24. package/dist/engine/feature-entity-handlers.js +4 -0
  25. package/dist/engine/feature-ui-extensions.js +9 -0
  26. package/dist/engine/index.d.ts +1 -1
  27. package/dist/engine/membership-roles.js +5 -1
  28. package/dist/engine/pattern-library/mixed-schemas.js +5 -0
  29. package/dist/engine/registry-state.js +38 -5
  30. package/dist/engine/registry-validate.js +2 -0
  31. package/dist/engine/tier-resolver-extension.d.ts +2 -0
  32. package/dist/engine/types/index.d.ts +2 -2
  33. package/dist/entrypoint/index.js +25 -6
  34. package/dist/errors/classes.d.ts +5 -0
  35. package/dist/errors/classes.js +13 -0
  36. package/dist/errors/index.d.ts +1 -1
  37. package/dist/errors/index.js +1 -1
  38. package/dist/files/file-routes.js +7 -2
  39. package/dist/files/index.d.ts +1 -0
  40. package/dist/files/index.js +1 -0
  41. package/dist/files/provider-resolver.d.ts +2 -0
  42. package/dist/files/provider-resolver.js +16 -1
  43. package/dist/files/resolve-content-type.d.ts +11 -0
  44. package/dist/files/resolve-content-type.js +71 -0
  45. package/dist/files/types.d.ts +1 -1
  46. package/dist/files/types.js +5 -1
  47. package/dist/jobs/job-runner.js +1 -1
  48. package/dist/pipeline/dispatch-shared.d.ts +4 -1
  49. package/dist/pipeline/dispatch-shared.js +70 -9
  50. package/dist/pipeline/dispatch-write.js +35 -10
  51. package/dist/pipeline/dispatcher.d.ts +2 -0
  52. package/dist/pipeline/dispatcher.js +17 -1
  53. package/dist/pipeline/projection-rebuild.js +1 -1
  54. package/dist/rate-limit/bucket.d.ts +4 -0
  55. package/dist/rate-limit/bucket.js +25 -0
  56. package/dist/rate-limit/index.d.ts +2 -2
  57. package/dist/rate-limit/index.js +2 -2
  58. package/dist/rate-limit/middleware.d.ts +9 -0
  59. package/dist/rate-limit/middleware.js +33 -2
  60. package/dist/rate-limit/resolver.js +2 -1
  61. package/dist/redis/cache-sync-bus.d.ts +24 -0
  62. package/dist/redis/cache-sync-bus.js +152 -0
  63. package/dist/redis/cache-sync-topics.d.ts +17 -0
  64. package/dist/redis/cache-sync-topics.js +22 -0
  65. package/dist/redis/index.d.ts +4 -0
  66. package/dist/redis/index.js +2 -0
  67. package/dist/redis/pubsub-signal.d.ts +1 -0
  68. package/dist/redis/pubsub-signal.js +28 -0
  69. package/dist/stack/redis.d.ts +6 -1
  70. package/dist/stack/redis.js +7 -2
  71. package/dist/stack/test-stack.d.ts +15 -0
  72. package/dist/stack/test-stack.js +21 -1
  73. package/package.json +4 -4
  74. package/src/changes.json +47 -0
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.340.0",
3
+ "version": "0.341.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.340.0",
210
- "@cosmicdrift/kumiko-types": "0.340.0",
209
+ "@cosmicdrift/kumiko-http": "0.341.0",
210
+ "@cosmicdrift/kumiko-types": "0.341.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.340.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.341.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,51 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "rowIdOf on extendEntityProjection for derived row ids",
6
+ "detail": "`r.extendEntityProjection` accepts `rowIdOf`, the row id the extension's applies write for events on its `sources` when that id is not the aggregate id (for example a tenant-salted `uuidv5(tenantId|aggregateId)` over workflow events). The ghost-row guard of the projection rebuild now counts a live row as backed when its id is `rowIdOf` of a source event, so such projections can be rebuilt instead of aborting. The guard stays strict: a row keyed by the raw aggregate id of a derived source still aborts the rebuild.\n`rowIdOf` requires `sources`, must not name the entity's own stream, and must return a uuid; a source shared by several extensions needs the same `rowIdOf` in all of them. Violations fail at boot. `ProjectionDefinition` carries the result as `extraSourceRowIds`, and `assertNoUnreachableLiveRows` takes it as an optional fifth parameter. New type `ProjectionRowIdOf`."
7
+ },
8
+ {
9
+ "version": "0.341.0",
10
+ "type": "improvement",
11
+ "title": "CacheSyncBus keeps tier assignments and file providers in sync across pods",
12
+ "detail": "A Redis-backed CacheSyncBus (one channel, envelope with origin id, echo dropped, malformed messages ignored) now carries typed invalidation topics between pods and fires a debounced resync after a Redis reconnect. Tier assignments are published after commit and reloaded on the other pods with a per-tenant out-of-order guard; the tier cache is swapped atomically on resync. The file provider resolver drops cached providers when tenant config or secrets change on any pod. Prod, worker and dev entrypoints wire one bus per process, and TierResolverPlugin.build accepts an optional cacheSync."
13
+ },
14
+ {
15
+ "version": "0.341.0",
16
+ "type": "fix",
17
+ "title": "L3 rate-limit backend failure answers 503 rate_limit_unavailable instead of 500"
18
+ },
19
+ {
20
+ "version": "0.341.0",
21
+ "type": "improvement",
22
+ "title": "r.httpRoute rateLimit option",
23
+ "detail": "Per-IP limit enforced through the rate-limit resolver before the route guards; legal-pages and GET /user-export/by-token declare defaults."
24
+ },
25
+ {
26
+ "version": "0.341.0",
27
+ "type": "improvement",
28
+ "title": "Tenant timezone cache is invalidated across processes",
29
+ "detail": "A config write to tenant:config:timezone now invalidates the cached value on every API and worker pod after commit, instead of letting it live until the 5 minute TTL. The signal travels over the new CacheSyncBus (Redis when REDIS_URL is set, process-local otherwise); DispatcherOptions.cacheSync overrides it and null opts out. setupTestStack gains cacheSync, implied by sharedRedisWith, to test two instances on one Redis. This replaces the earlier tenantTimezoneSync option."
30
+ },
31
+ {
32
+ "version": "0.341.0",
33
+ "type": "improvement",
34
+ "title": "Per-recipient rate limits on payload fields (additionalRateLimits)",
35
+ "detail": "Write handlers can declare `additionalRateLimits: [{ per: { payloadField: \"email\" }, limit, windowSeconds }]` next to `rateLimit`. The named string field of the validated payload is trimmed, lowercased and HMAC-hashed (key derived from the server's current JWT signing secret) into the bucket key `payload+handler:<handler>:<field>:<digest>`, so one address is limited across all IPs and Redis never holds it in plaintext. The check runs after schema validation and before the handler, answers 429 `rate_limited`, behaves the same for matching and non-matching values, and is skipped for system callers. Rotating the JWT secret only resets these buckets.\nBoot rejects `additionalRateLimits` on an anonymous handler without a real ip-keyed `rateLimit` (the payload bucket only complements the IP bucket), an empty list, a `payloadField` that is not a field of the Zod object schema, and a non-positive `limit` or `windowSeconds`. A handler with `additionalRateLimits` makes `buildServer` wire the rate-limit resolver. The option exists on write handlers only and appears in the feature-ast patterns, render and patch schema.\nBehavior change: `request-contract-termination` and `signup-request` now allow 3 requests per email address per 24 hours, and each token-request endpoint (`request-password-reset`, `request-email-verification`, `request-account-unlock`) allows 5 per address per 24 hours, in addition to the existing per-IP limits. The public `/api/auth` token-request routes still answer `{ isSuccess: true }` when the limit is hit; only the mail is not sent."
36
+ },
37
+ {
38
+ "version": "0.341.0",
39
+ "type": "improvement",
40
+ "title": "429 bodies omit the payload bucket digest",
41
+ "detail": "A 429 caused by a payload bucket (`additionalRateLimits`) no longer carries the HMAC digest of the bucketed value in `details.bucket` or the error message: it reads `payload+handler`. The digest is a stable pseudonym of the address, so anyone who could guess an address could confirm it from the response. Redis keys are unchanged.\n`waitlist:write:submit` additionally limits each email address (case-insensitive) to 3 submits per day, across all IPs, so a botnet cannot flood one address with confirmation mails. `createWaitlistFeature({ emailRateLimits })` overrides it (`[]` disables). The admin notice mail now carries its `locale` (`en` or `de`) in the notify call."
42
+ },
43
+ {
44
+ "version": "0.341.0",
45
+ "type": "fix",
46
+ "title": "429 bodies carry only the rate-limit scope",
47
+ "detail": "429 bodies (`details.bucket` and the message) now carry only the bucket scope tag (`l1`, `http`, `l2`, `user`, `ip+handler`, `payload+handler`, ...) instead of IP, user/tenant id, auth target or payload digest. Fail-closed logging prints only the error message, since Redis errors can carry the bucket key. Redis keys are unchanged."
48
+ },
2
49
  {
3
50
  "version": "0.340.0",
4
51
  "type": "improvement",