@cosmicdrift/kumiko-framework 0.340.0 → 0.342.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 (96) 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/crypto/kek-source.d.ts +3 -4
  8. package/dist/crypto/kek-source.js +2 -5
  9. package/dist/crypto/kms-wiring.d.ts +2 -2
  10. package/dist/crypto/kms-wiring.js +2 -2
  11. package/dist/db/index.d.ts +2 -1
  12. package/dist/db/index.js +2 -1
  13. package/dist/db/migrate-generator.d.ts +6 -0
  14. package/dist/db/migrate-generator.js +15 -1
  15. package/dist/db/migrate-runner.d.ts +1 -0
  16. package/dist/db/migrate-runner.js +11 -1
  17. package/dist/db/queries/shadow-swap.d.ts +2 -1
  18. package/dist/db/queries/shadow-swap.js +70 -7
  19. package/dist/db/retired-framework-tables.d.ts +10 -0
  20. package/dist/db/retired-framework-tables.js +72 -0
  21. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  22. package/dist/engine/boot-validator/entity-handler.js +48 -0
  23. package/dist/engine/define-handler.js +1 -0
  24. package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
  25. package/dist/engine/feature-ast/extractors/handlers.js +11 -1
  26. package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
  27. package/dist/engine/feature-ast/extractors/hooks.js +14 -0
  28. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  29. package/dist/engine/feature-ast/extractors/index.js +1 -1
  30. package/dist/engine/feature-ast/patch.js +1 -0
  31. package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
  32. package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
  33. package/dist/engine/feature-ast/patterns.d.ts +2 -1
  34. package/dist/engine/feature-ast/render.js +3 -0
  35. package/dist/engine/feature-entity-handlers.d.ts +2 -1
  36. package/dist/engine/feature-entity-handlers.js +4 -0
  37. package/dist/engine/feature-ui-extensions.js +9 -0
  38. package/dist/engine/index.d.ts +1 -1
  39. package/dist/engine/membership-roles.js +5 -1
  40. package/dist/engine/pattern-library/mixed-schemas.js +5 -0
  41. package/dist/engine/registry-state.js +38 -5
  42. package/dist/engine/registry-validate.js +2 -0
  43. package/dist/engine/soft-delete-cleanup.d.ts +2 -0
  44. package/dist/engine/soft-delete-cleanup.js +28 -9
  45. package/dist/engine/tier-resolver-extension.d.ts +2 -0
  46. package/dist/engine/types/index.d.ts +2 -2
  47. package/dist/entrypoint/index.js +25 -6
  48. package/dist/errors/classes.d.ts +5 -0
  49. package/dist/errors/classes.js +13 -0
  50. package/dist/errors/index.d.ts +1 -1
  51. package/dist/errors/index.js +1 -1
  52. package/dist/files/file-routes.js +7 -2
  53. package/dist/files/index.d.ts +1 -0
  54. package/dist/files/index.js +1 -0
  55. package/dist/files/provider-resolver.d.ts +2 -0
  56. package/dist/files/provider-resolver.js +16 -1
  57. package/dist/files/resolve-content-type.d.ts +11 -0
  58. package/dist/files/resolve-content-type.js +71 -0
  59. package/dist/files/types.d.ts +1 -1
  60. package/dist/files/types.js +5 -1
  61. package/dist/jobs/job-runner.js +8 -2
  62. package/dist/observability/escape-hatch-report.d.ts +10 -1
  63. package/dist/observability/escape-hatch-report.js +19 -1
  64. package/dist/observability/index.d.ts +2 -2
  65. package/dist/observability/index.js +2 -2
  66. package/dist/observability/standard-metrics.d.ts +1 -0
  67. package/dist/observability/standard-metrics.js +14 -0
  68. package/dist/pipeline/dispatch-shared.d.ts +4 -1
  69. package/dist/pipeline/dispatch-shared.js +70 -9
  70. package/dist/pipeline/dispatch-write.js +35 -10
  71. package/dist/pipeline/dispatcher.d.ts +2 -0
  72. package/dist/pipeline/dispatcher.js +17 -1
  73. package/dist/pipeline/projection-rebuild.js +1 -1
  74. package/dist/rate-limit/bucket.d.ts +4 -0
  75. package/dist/rate-limit/bucket.js +25 -0
  76. package/dist/rate-limit/index.d.ts +2 -2
  77. package/dist/rate-limit/index.js +2 -2
  78. package/dist/rate-limit/middleware.d.ts +9 -0
  79. package/dist/rate-limit/middleware.js +33 -2
  80. package/dist/rate-limit/resolver.js +2 -1
  81. package/dist/redis/cache-sync-bus.d.ts +24 -0
  82. package/dist/redis/cache-sync-bus.js +152 -0
  83. package/dist/redis/cache-sync-topics.d.ts +17 -0
  84. package/dist/redis/cache-sync-topics.js +22 -0
  85. package/dist/redis/index.d.ts +4 -0
  86. package/dist/redis/index.js +2 -0
  87. package/dist/redis/pubsub-signal.d.ts +1 -0
  88. package/dist/redis/pubsub-signal.js +28 -0
  89. package/dist/schema-cli.d.ts +8 -6
  90. package/dist/schema-cli.js +37 -4
  91. package/dist/stack/redis.d.ts +6 -1
  92. package/dist/stack/redis.js +7 -2
  93. package/dist/stack/test-stack.d.ts +15 -0
  94. package/dist/stack/test-stack.js +21 -1
  95. package/package.json +4 -4
  96. package/src/changes.json +75 -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;
@@ -4,15 +4,17 @@ export type SchemaCliOut = {
4
4
  readonly err: (line: string) => void;
5
5
  };
6
6
  export type RunSchemaCliOptions = {
7
+ readonly features?: undefined;
8
+ readonly kmsSlots?: undefined;
9
+ } | {
7
10
  /** Composed app features. When given, `apply` rebuilds the projections whose
8
11
  * tables a freshly applied migration changed (via its `.rebuild.json`
9
12
  * marker). Omitted (dev `kumiko schema`) → no rebuild, migrations only. */
10
- readonly features?: readonly FeatureDefinition[];
11
- /** Schema-declared Key Manager slots, typically `kmsSlotsOf(composedEnv.schema)`.
12
- * This CLI parses no env schema, so `apply`'s KMS wiring cannot derive them
13
- * itself; omitted, `resolvePlatformKeks` (inside `resolveKmsWiringAsync`)
14
- * falls back to its own default. */
15
- readonly kmsSlots?: readonly string[];
13
+ readonly features: readonly FeatureDefinition[];
14
+ /** Schema-declared Key Manager slots, `kmsSlotsOf(composedEnv.schema)`, or
15
+ * `[]` when the app wires none. This CLI parses no env schema, so the
16
+ * rebuild's KMS wiring cannot derive them itself. */
17
+ readonly kmsSlots: readonly string[];
16
18
  };
17
19
  /**
18
20
  * Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
@@ -10,7 +10,7 @@
10
10
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
11
11
  import { join, resolve as resolvePath } from "node:path";
12
12
  import { configureBlindIndexKey, configurePiiSubjectKms, resolveKmsWiringAsync, } from "./crypto/index.js";
13
- import { assertValidMigrationName, baselineMigrations, createDbConnection, diffReplayAgainstSnapshot, fetchAppliedMigrations, generateMigration, loadMigrationsFromDir, loadSnapshotJson, rebuildTablesFromDiff, replayMigrationsDir, runMigrationsFromDir, tableExists, writeRebuildMarker, writeSnapshotJson, } from "./db/index.js";
13
+ import { assertValidMigrationName, baselineMigrations, createDbConnection, diffReplayAgainstSnapshot, fetchAppliedMigrations, findCommentedDropTables, generateMigration, isRetiredFrameworkTable, loadMigrationsFromDir, loadSnapshotJson, readMigrationSqlTexts, rebuildTablesFromDiff, replayMigrationsDir, runMigrationsFromDir, tableExists, writeRebuildMarker, writeSnapshotJson, } from "./db/index.js";
14
14
  import { validateBoot } from "./engine/boot-validator.js";
15
15
  import { createRegistry } from "./engine/registry.js";
16
16
  import { createEventsTable } from "./event-store/index.js";
@@ -49,12 +49,40 @@ function nextSequenceNumber(migrationsDir) {
49
49
  }
50
50
  return max + 1;
51
51
  }
52
+ // A table that a migration only drops as a commented DESTRUCTIVE marker and
53
+ // that the snapshot no longer has is gone on paper but still in the live DB.
54
+ async function reportLeftoverDroppedTables(db, migrationsDir, out) {
55
+ const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
56
+ const snapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
57
+ const leftovers = [];
58
+ for (const tableName of findCommentedDropTables(readMigrationSqlTexts(migrationsDir), snapshot)) {
59
+ if (await tableExists(db, tableName))
60
+ leftovers.push(tableName);
61
+ }
62
+ // skip: nothing left over, nothing to report
63
+ if (leftovers.length === 0)
64
+ return;
65
+ out.log(" Tables dropped only as a commented DESTRUCTIVE marker but still in the database:");
66
+ for (const tableName of leftovers) {
67
+ out.log(isRetiredFrameworkTable(tableName)
68
+ ? ` ! ${tableName} — retired framework table: the next \`schema generate <name>\` creates the drop`
69
+ : ` ! ${tableName} — app table: after a backup, write a new migration with the drop`);
70
+ }
71
+ out.log("");
72
+ }
52
73
  const SCHEMA_SUBCOMMANDS = ["generate", "validate", "apply", "baseline", "status"];
53
74
  /**
54
75
  * Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
55
76
  * `kumiko/schema.ts` + `kumiko/migrations/` live). Returns a process exit code.
56
77
  */
57
78
  export async function runSchemaCli(argv, appCwd, out, options = {}) {
79
+ // Runtime guard for untyped callers: the rebuild after `apply` needs the
80
+ // slots, and failing there would leave migrations applied but unrebuilt.
81
+ if (options.features && !options.kmsSlots) {
82
+ out.err("runSchemaCli: `features` requires `kmsSlots` — pass kmsSlots: kmsSlotsOf(<composedEnv>.schema), " +
83
+ "or [] when the app wires no Key Manager slots.");
84
+ return 1;
85
+ }
58
86
  // runProdApp/runDevApp install this at boot; the standalone CLI (the
59
87
  // migrate-db initContainer, `bun kumiko.js schema apply`) never goes through
60
88
  // that boot path, so a projection rebuild's tz/timestamp coercion throws
@@ -114,10 +142,12 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
114
142
  prevSnapshot,
115
143
  name,
116
144
  sequenceNumber: nextSequenceNumber(migrationsDir),
145
+ priorMigrationsSql: readMigrationSqlTexts(migrationsDir),
117
146
  });
118
147
  const isEmpty = result.diff.newTables.length === 0 &&
119
148
  result.diff.changedTables.length === 0 &&
120
- result.diff.droppedTables.length === 0;
149
+ result.diff.droppedTables.length === 0 &&
150
+ result.retiredDrops.length === 0;
121
151
  if (isEmpty) {
122
152
  out.log(" No schema changes detected — kein neues Migration-File geschrieben.");
123
153
  return 0;
@@ -133,7 +163,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
133
163
  writeRebuildMarker(migrationsDir, result.filename, rebuildTables);
134
164
  out.log("");
135
165
  out.log(` ✓ ${result.filename}`);
136
- out.log(` new tables: ${result.diff.newTables.length}, changed: ${result.diff.changedTables.length}, dropped: ${result.diff.droppedTables.length}`);
166
+ out.log(` new tables: ${result.diff.newTables.length}, changed: ${result.diff.changedTables.length}, dropped: ${result.diff.droppedTables.length}, retired framework tables dropped: ${result.retiredDrops.length}`);
137
167
  if (rebuildTables.length > 0) {
138
168
  out.log(` rebuild-marker: ${result.filename.replace(/\.sql$/, ".rebuild.json")} (${rebuildTables.length} table(s))`);
139
169
  }
@@ -182,11 +212,13 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
182
212
  prevSnapshot,
183
213
  name: "validate",
184
214
  sequenceNumber: nextSequenceNumber(migrationsDir),
215
+ priorMigrationsSql: readMigrationSqlTexts(migrationsDir),
185
216
  });
186
217
  const pendingTables = [
187
218
  ...drift.diff.newTables.map((t) => t.tableName),
188
219
  ...drift.diff.changedTables.map((t) => t.tableName),
189
220
  ...drift.diff.droppedTables,
221
+ ...drift.retiredDrops.map((t) => t.tableName),
190
222
  ];
191
223
  if (pendingTables.length === 0) {
192
224
  out.log(" ✓ schema: migrations match the entity definitions");
@@ -308,7 +340,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
308
340
  wiring = await resolveKmsWiringAsync(process.env, {
309
341
  logPrefix: "[kumiko schema apply]",
310
342
  log: out.log,
311
- ...(options.kmsSlots && { slots: options.kmsSlots }),
343
+ slots: options.kmsSlots,
312
344
  });
313
345
  if ("kms" in wiring) {
314
346
  configurePiiSubjectKms(wiring.kms);
@@ -398,6 +430,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
398
430
  out.log("");
399
431
  out.log(` ${applied.size} applied, ${pending} pending.`);
400
432
  out.log("");
433
+ await reportLeftoverDroppedTables(db, migrationsDir, out);
401
434
  return pending === 0 ? 0 : 1;
402
435
  }
403
436
  finally {
@@ -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.342.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.342.0",
210
+ "@cosmicdrift/kumiko-types": "0.342.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.342.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,79 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "System cron escape hatches are audited once per process, with a use counter and audit retention",
6
+ "detail": "System crons audit their declared escape hatch once per process; audit events get a retention\nA cron job with a declared `escapeHatch` that uses `unsafeRaw()` or `db.global()` writes one `audit:event:escape-hatch-used` per process, handler and tenant instead of one per run. Manual runs by a user, identity switches, `acknowledge-cross-tenant` and `unsafe-all-tenants` keep the 60-second dedup window. Every use, audited or not, counts in the new metric `kumiko_escape_hatch_uses_total{handler,kind}`; `createEscapeHatchReporter` takes optional `processDedup`, `meter` and `now` for this. The audit feature gains a daily job `audit:job:escape-hatch-retention` that removes `escapeHatchUse` events through `pruneEvents`, with the period in the system config key `audit:config:escape-hatch-retention-days` (default 90). A new runbook, `docs/runbooks/kumiko-events-bloat.md`, shows how to measure `kumiko_events` bloat and reclaim the space.",
7
+ "migration": "The first run of `audit:job:escape-hatch-retention` deletes `escapeHatchUse` events older than 90 days. To keep them longer, set the system config key `audit:config:escape-hatch-retention-days` (for example through an app override) before the bump rolls out. The audit feature now also requires the `config` feature, which every app with `tenant` already mounts."
8
+ },
9
+ {
10
+ "version": "0.342.0",
11
+ "type": "breaking",
12
+ "title": "Key Manager slots are required, LEGACY_SLOTS default removed",
13
+ "detail": "Key Manager slots are required: no more LEGACY_SLOTS default\n`resolvePlatformKeks`, `resolveKmsWiringAsync` and `requireKmsWiringAsync` no longer fall back to the three platform slots; `slots` is a required option. `runSchemaCli` called with `features` now requires `kmsSlots` and exits 1 with a hint before any DB connect or migration apply when it is missing. `kmsSlots: []` stays valid for apps that wire no Key Manager slots. The scaffolded `bin/kumiko.ts` passes `kmsSlots` derived from the composed env schema.",
14
+ "migration": "Pass `slots` (for example `kmsSlotsOf(<app>ComposedEnv.schema)`) to `resolvePlatformKeks`, `resolveKmsWiringAsync` and `requireKmsWiringAsync`. In `bin/kumiko.ts`, `runSchemaCli(..., { features })` needs `kmsSlots: kmsSlotsOf(<app>ComposedEnv.schema)` (or `[]` when the app wires no Key Manager slots. With `[]` every `*_CIPHERTEXT` env var is ignored, so an app that ships a ciphertext must pass its slots); without it the CLI exits 1 before applying migrations.\nWorkspace state at release: show-pony `bin/kumiko.ts` and its docs copy `kumiko-platform/apps/docs/_samples/show-pony/bin/kumiko.ts` still call `runSchemaCli` with `features` only. publicstatus, solon, offlot-app, money-horse, phronexsis and kumiko-studio already pass `kmsSlots` / `slots`."
15
+ },
16
+ {
17
+ "version": "0.342.0",
18
+ "type": "fix",
19
+ "title": "Retired framework tables (read_job_runs) are dropped by the next schema generate",
20
+ "detail": "`kumiko schema generate` now writes an executable `DROP TABLE IF EXISTS \"read_job_runs\";` for retired framework tables (new `retiredFrameworkTables` list next to the migration code). Until now the drop stayed a commented `-- DESTRUCTIVE:` marker in every app, so the table lived on forever. A retired table is dropped only if an earlier migration created it or carries the commented drop, no earlier migration dropped it for real, and the schema no longer declares it. App tables stay commented out. `generate` writes a migration even when the retired drop is its only content, and `schema validate` reports it as pending. `kumiko schema status` now lists tables that a migration only drops as a commented marker but that still exist in the database, with a hint per case.",
21
+ "migration": "Run `kumiko schema generate <name>` in publicstatus, solon, money-horse, phronexsis, show-pony and kumiko-studio, review and commit the drop of read_job_runs (old job run history is not preserved), then apply."
22
+ },
23
+ {
24
+ "version": "0.342.0",
25
+ "type": "fix",
26
+ "title": "Soft-delete cleanup works for global tables such as user",
27
+ "detail": "Soft-delete cleanup no longer aborts on `tenancy: \"global\"` tables\n`soft-delete:job:cleanup` aborted for a tenant as soon as an entity with `tenancy: \"global\"` and a `tenantId` column (such as `user`) had softDelete enabled, so the tenant's other entities were not cleaned either. Both cleanup jobs now decide by the entity's tenancy declaration. The per-tenant job skips global tables; `soft-delete:job:cleanup-system` hard-deletes their expired rows once, system-wide, through `db.global()`. Jobs can now receive the `globalWrites` grant from their `escapeHatch` declaration.\nThe first run of `soft-delete:job:cleanup-system` after the update hard-deletes soft-deleted users older than the fixed 30-day grace period in every app with the user feature. Earlier versions never removed them.",
28
+ "migration": "None required. The first nightly run of `soft-delete:job:cleanup-system` after the bump hard-deletes soft-deleted users older than 30 days; restore any you still need before it runs."
29
+ },
30
+ {
31
+ "version": "0.341.0",
32
+ "type": "improvement",
33
+ "title": "rowIdOf on extendEntityProjection for derived row ids",
34
+ "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`."
35
+ },
36
+ {
37
+ "version": "0.341.0",
38
+ "type": "improvement",
39
+ "title": "CacheSyncBus keeps tier assignments and file providers in sync across pods",
40
+ "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."
41
+ },
42
+ {
43
+ "version": "0.341.0",
44
+ "type": "fix",
45
+ "title": "L3 rate-limit backend failure answers 503 rate_limit_unavailable instead of 500"
46
+ },
47
+ {
48
+ "version": "0.341.0",
49
+ "type": "improvement",
50
+ "title": "r.httpRoute rateLimit option",
51
+ "detail": "Per-IP limit enforced through the rate-limit resolver before the route guards; legal-pages and GET /user-export/by-token declare defaults."
52
+ },
53
+ {
54
+ "version": "0.341.0",
55
+ "type": "improvement",
56
+ "title": "Tenant timezone cache is invalidated across processes",
57
+ "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."
58
+ },
59
+ {
60
+ "version": "0.341.0",
61
+ "type": "improvement",
62
+ "title": "Per-recipient rate limits on payload fields (additionalRateLimits)",
63
+ "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."
64
+ },
65
+ {
66
+ "version": "0.341.0",
67
+ "type": "improvement",
68
+ "title": "429 bodies omit the payload bucket digest",
69
+ "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."
70
+ },
71
+ {
72
+ "version": "0.341.0",
73
+ "type": "fix",
74
+ "title": "429 bodies carry only the rate-limit scope",
75
+ "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."
76
+ },
2
77
  {
3
78
  "version": "0.340.0",
4
79
  "type": "improvement",