@cosmicdrift/kumiko-framework 0.402.0 → 0.404.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 (51) hide show
  1. package/agents/api/framework-engine.md +8 -1
  2. package/agents/api/framework-ui-types.md +4 -0
  3. package/agents/api/renderer-web.md +2 -1
  4. package/agents/api/types.md +56 -0
  5. package/agents/api.md +1 -1
  6. package/agents/boot-rules.md +12 -0
  7. package/agents/examples/dashboard-stats.md +58 -3
  8. package/agents/examples/lane-routing.md +40 -1
  9. package/dist/api/redis-sse-broker.d.ts +1 -0
  10. package/dist/api/redis-sse-broker.js +5 -3
  11. package/dist/engine/boot-validator/boot-phases-app.js +5 -0
  12. package/dist/engine/boot-validator/boot-rules.d.ts +36 -0
  13. package/dist/engine/boot-validator/boot-rules.js +2 -0
  14. package/dist/engine/boot-validator/dashboard-stat-navigate.d.ts +2 -0
  15. package/dist/engine/boot-validator/dashboard-stat-navigate.js +77 -0
  16. package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +5 -0
  17. package/dist/engine/boot-validator/entity-list-screens-rules.js +5 -0
  18. package/dist/engine/boot-validator/entity-list-screens.js +12 -0
  19. package/dist/engine/boot-validator/named-filter-rules.d.ts +32 -0
  20. package/dist/engine/boot-validator/named-filter-rules.js +32 -0
  21. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  22. package/dist/engine/entity-handlers.d.ts +1 -0
  23. package/dist/engine/entity-handlers.js +13 -1
  24. package/dist/engine/factories.d.ts +2 -0
  25. package/dist/engine/feature-config-events-jobs.js +11 -7
  26. package/dist/engine/index.d.ts +1 -0
  27. package/dist/engine/index.js +1 -0
  28. package/dist/engine/resolve-named-list-filters.d.ts +3 -0
  29. package/dist/engine/resolve-named-list-filters.js +30 -0
  30. package/dist/engine/soft-delete-cleanup.js +2 -2
  31. package/dist/entrypoint/index.d.ts +5 -2
  32. package/dist/entrypoint/index.js +15 -6
  33. package/dist/entrypoint/internals.d.ts +2 -0
  34. package/dist/files/feature.js +1 -1
  35. package/dist/jobs/cron-repeat-options.d.ts +9 -0
  36. package/dist/jobs/cron-repeat-options.js +9 -0
  37. package/dist/jobs/index.d.ts +1 -0
  38. package/dist/jobs/index.js +1 -0
  39. package/dist/jobs/job-runner-context.js +4 -1
  40. package/dist/jobs/job-runner-types.d.ts +5 -0
  41. package/dist/jobs/job-runner.js +14 -5
  42. package/dist/jobs/maintenance-window.d.ts +7 -0
  43. package/dist/jobs/maintenance-window.js +14 -0
  44. package/dist/redis/cache-sync-bus.d.ts +1 -0
  45. package/dist/redis/cache-sync-bus.js +15 -6
  46. package/dist/redis/client.d.ts +12 -0
  47. package/dist/redis/client.js +30 -5
  48. package/dist/redis/index.d.ts +2 -2
  49. package/dist/redis/index.js +1 -1
  50. package/package.json +3 -3
  51. package/src/changes.json +18 -0
@@ -1,5 +1,5 @@
1
1
  import { createFallbackLogger } from "../logging/utils.js";
2
- import { redisClientOptionsFromEnv } from "./client.js";
2
+ import { redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "./client.js";
3
3
  import { createRedisPubSubSignal } from "./pubsub-signal.js";
4
4
  const log = createFallbackLogger("kumiko:cache-sync");
5
5
  const CACHE_SYNC_CHANNEL = "kumiko:cache-sync";
@@ -80,7 +80,10 @@ export function createCacheSyncBusOverSignal(signal, opts) {
80
80
  const resyncMinIntervalMs = opts.resyncMinIntervalMs ?? RESYNC_MIN_INTERVAL_MS;
81
81
  const now = opts.now ?? Date.now;
82
82
  const fanout = createLocalFanout();
83
- signal.onMessage((_channel, payload) => {
83
+ signal.onMessage((channel, payload) => {
84
+ // skip: a wide subscribe pattern also delivers other features' channels
85
+ if (channel !== opts.channel)
86
+ return;
84
87
  // skip: foreign or malformed payload on the channel
85
88
  if (!isCacheSyncEnvelope(payload))
86
89
  return;
@@ -133,7 +136,7 @@ export function createRedisCacheSyncBus(opts) {
133
136
  const channel = `${opts.channelPrefix ?? ""}${CACHE_SYNC_CHANNEL}`;
134
137
  const signal = createRedisPubSubSignal({
135
138
  redisUrl: opts.redisUrl,
136
- channelPattern: channel,
139
+ channelPattern: opts.channelSubscribePattern ?? channel,
137
140
  label: "cache-sync",
138
141
  ...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
139
142
  });
@@ -143,9 +146,15 @@ export function createRedisCacheSyncBus(opts) {
143
146
  // set it. Without it invalidation stays inside this process.
144
147
  export function createDefaultCacheSyncBus(envVars = process.env) {
145
148
  const redisUrl = envVars["REDIS_URL"];
146
- return redisUrl
147
- ? createRedisCacheSyncBus({ redisUrl, clientOptions: redisClientOptionsFromEnv(envVars) })
148
- : createLocalCacheSyncBus();
149
+ if (!redisUrl)
150
+ return createLocalCacheSyncBus();
151
+ const { channelPrefix, channelSubscribePattern } = redisNamespaceFromEnv(envVars);
152
+ return createRedisCacheSyncBus({
153
+ redisUrl,
154
+ clientOptions: redisClientOptionsFromEnv(envVars),
155
+ channelPrefix,
156
+ ...(channelSubscribePattern !== undefined && { channelSubscribePattern }),
157
+ });
149
158
  }
150
159
  // An explicit bus wins (caller owns it), null opts out, undefined builds the default.
151
160
  export function resolveCacheSyncBus(explicit) {
@@ -8,5 +8,17 @@ export type RedisClientOptions = {
8
8
  };
9
9
  export declare function createRedisClient(url: string, options?: RedisClientOptions): Redis;
10
10
  export declare function logRedisConnectionState(redis: Redis, logger: Pick<Logger, "warn" | "info">): void;
11
+ export type RedisNamespace = {
12
+ /** ioredis keyPrefix for every non-BullMQ key; "" when unnamespaced. */
13
+ readonly keyPrefix: string;
14
+ /** BullMQ `prefix` option ("bull" is BullMQ's own default). BullMQ rejects an ioredis keyPrefix. */
15
+ readonly bullmqPrefix: string;
16
+ /** Prepended to every Pub/Sub channel (ioredis keyPrefix does not apply to Pub/Sub). */
17
+ readonly channelPrefix: string;
18
+ /** Set only with KUMIKO_REDIS_PREFIX. Redis checks PSUBSCRIBE patterns against `&` ACL rules literally,
19
+ * so `P:kumiko:sse:*` is NOPERM under `&P:*`; subscribe to `P:*` and filter by channel instead. */
20
+ readonly channelSubscribePattern: string | undefined;
21
+ };
22
+ export declare function redisNamespaceFromEnv(env?: Readonly<Record<string, string | undefined>>): RedisNamespace;
11
23
  export declare function redisChannelPrefixFromEnv(env?: Readonly<Record<string, string | undefined>>): string;
12
24
  export declare function redisClientOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): RedisClientOptions;
@@ -46,12 +46,37 @@ export function logRedisConnectionState(redis, logger) {
46
46
  logger.info("redis recovered");
47
47
  });
48
48
  }
49
- // Redis Pub/Sub ignores the DB index and ioredis keyPrefix, so apps or environments sharing one
50
- // Redis instance would see each other's SSE and toggle-sync traffic. An optional prefix
51
- // namespaces those channels; unset keeps the unprefixed channel names.
49
+ const BULLMQ_DEFAULT_PREFIX = "bull";
50
+ // No ":" and no glob characters: the ACL rules `~<P>:*` / `&<P>:*` must not match a sibling app ("app-x" vs "app-x2:").
51
+ const REDIS_PREFIX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/;
52
+ // KUMIKO_REDIS_PREFIX namespaces keys and channels for apps sharing one Redis behind per-app ACL users.
53
+ // KUMIKO_REDIS_CHANNEL_PREFIX alone keeps its older channels-only behaviour.
54
+ export function redisNamespaceFromEnv(env = process.env) {
55
+ const prefix = env["KUMIKO_REDIS_PREFIX"]?.trim();
56
+ const channelPrefix = env["KUMIKO_REDIS_CHANNEL_PREFIX"]?.trim();
57
+ if (!prefix) {
58
+ return {
59
+ keyPrefix: "",
60
+ bullmqPrefix: BULLMQ_DEFAULT_PREFIX,
61
+ channelPrefix: channelPrefix ? `${channelPrefix}:` : "",
62
+ channelSubscribePattern: undefined,
63
+ };
64
+ }
65
+ if (!REDIS_PREFIX_PATTERN.test(prefix)) {
66
+ throw new Error(`KUMIKO_REDIS_PREFIX "${prefix}" is invalid: use 1-63 characters of A-Z a-z 0-9 _ - starting with a letter or digit (no ":" or glob characters).`);
67
+ }
68
+ if (channelPrefix && channelPrefix !== prefix) {
69
+ throw new Error(`KUMIKO_REDIS_PREFIX "${prefix}" and KUMIKO_REDIS_CHANNEL_PREFIX "${channelPrefix}" differ; unset the channel prefix or make them equal.`);
70
+ }
71
+ return {
72
+ keyPrefix: `${prefix}:`,
73
+ bullmqPrefix: `${prefix}:${BULLMQ_DEFAULT_PREFIX}`,
74
+ channelPrefix: `${prefix}:`,
75
+ channelSubscribePattern: `${prefix}:*`,
76
+ };
77
+ }
52
78
  export function redisChannelPrefixFromEnv(env = process.env) {
53
- const raw = env["KUMIKO_REDIS_CHANNEL_PREFIX"]?.trim();
54
- return raw ? `${raw}:` : "";
79
+ return redisNamespaceFromEnv(env).channelPrefix;
55
80
  }
56
81
  // Env-var reader — mirrors dbConnectionOptionsFromEnv's contract. Strict:
57
82
  // malformed values throw at boot rather than silently falling back to
@@ -2,6 +2,6 @@ export type { CacheSyncBus, ClosableCacheSyncBus, } from "./cache-sync-bus.ts";
2
2
  export { createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, } from "./cache-sync-bus.ts";
3
3
  export type { TenantConfigSyncMessage, TierAssignmentSyncMessage } from "./cache-sync-topics.ts";
4
4
  export { CACHE_SYNC_TOPICS, isTierAssignmentSyncMessage, } from "./cache-sync-topics.ts";
5
- export type { RedisClientOptions } from "./client.ts";
6
- export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.ts";
5
+ export type { RedisClientOptions, RedisNamespace } from "./client.ts";
6
+ export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "./client.ts";
7
7
  export { createRedisPubSubSignal } from "./pubsub-signal.ts";
@@ -1,4 +1,4 @@
1
1
  export { createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, } from "./cache-sync-bus.js";
2
2
  export { CACHE_SYNC_TOPICS, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
3
- export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.js";
3
+ export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "./client.js";
4
4
  export { createRedisPubSubSignal } from "./pubsub-signal.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.402.0",
3
+ "version": "0.404.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,7 +203,7 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.402.0",
206
+ "@cosmicdrift/kumiko-types": "0.404.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "hono": "^4.13.14",
209
209
  "i18next": "^26.1.0",
@@ -225,7 +225,7 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.402.0",
228
+ "@cosmicdrift/kumiko-testing": "0.404.0",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.3",
231
231
  "meilisearch": "^0.58.0",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.404.0",
4
+ "type": "improvement",
5
+ "title": "Maintenance trigger and KUMIKO_MAINTENANCE_WINDOW",
6
+ "detail": "The job runner registers maintenance jobs as a daily UTC scheduler at JobRunnerOptions.maintenanceWindow (default 03:00), set from KUMIKO_MAINTENANCE_WINDOW by runProdApp and runWorkerApp; an invalid value aborts the boot. maintenanceWindowFromEnv is exported from the jobs barrel. files sweep-orphaned-uploads (hourly before) and the soft-delete cleanup jobs (03:00 and 03:15 before) now run in the window. A schedule stored with the old time is replaced on the next start."
7
+ },
8
+ {
9
+ "version": "0.404.0",
10
+ "type": "improvement",
11
+ "title": "KUMIKO_REDIS_PREFIX namespaces all Redis keys and channels",
12
+ "detail": "redisNamespaceFromEnv resolves keyPrefix, bullmqPrefix, channelPrefix and the subscribe pattern. Unset keeps today's layout (bull:*, unprefixed locks, rate limit, idempotency and cache keys). Set, the main ioredis client gets keyPrefix <P>:, the job runner passes prefix <P>:bull to every Queue and Worker (new JobRunnerOptions.redisNamespace, also on the entrypoint jobs block) and gives its lock client the keyPrefix, and the SSE broker, cache-sync bus and toggle signal use <P>: channels. Redis matches PSUBSCRIBE patterns literally against ACL channel rules, so with a prefix they subscribe to <P>:* and filter by channel. A value with \":\" or glob characters, or a KUMIKO_REDIS_CHANNEL_PREFIX that differs from it, aborts the boot. The cache-sync bus of runProdApp, runWorkerApp and createDefaultCacheSyncBus now also honours KUMIKO_REDIS_CHANNEL_PREFIX alone (before, only SSE and toggle channels did). JobRunner gains an optional stopConsuming(), which createJobRunner implements. The ACL rule set and the key layout for a wake-up service are in docs/reference/infra-requirements.md."
13
+ },
14
+ {
15
+ "version": "0.403.0",
16
+ "type": "improvement",
17
+ "title": "Entities define named list filters",
18
+ "detail": "createEntity takes namedFilters, a record of { access?, filters(scope) } where filters returns ListFilter conditions (sync or async). The default list query accepts namedFilter and appends the resolved conditions to filters, on the row path and the aggregate path. An unknown name is a validation error on namedFilter, a failed access rule is access_denied. resolveNamedListFilters is exported from the engine entry. The definition is function-valued and never reaches the client schema."
19
+ },
2
20
  {
3
21
  "version": "0.402.0",
4
22
  "type": "improvement",