@cosmicdrift/kumiko-framework 0.403.0 → 0.404.1

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 (59) hide show
  1. package/AGENTS.md +103 -103
  2. package/README.md +19 -17
  3. package/agents/api/framework-engine.md +3 -0
  4. package/agents/api/renderer-web.md +1 -0
  5. package/agents/api/types.md +23 -0
  6. package/agents/examples/apex-landing.md +3 -3
  7. package/agents/examples/apex-surface-auth.md +13 -9
  8. package/agents/examples/auth-mfa.md +2 -4
  9. package/agents/examples/boot-check.md +2 -2
  10. package/agents/examples/compliance-profiles-demo.md +5 -5
  11. package/agents/examples/compose-stacks.md +8 -8
  12. package/agents/examples/cross-feature-events.md +1 -0
  13. package/agents/examples/crypto-shredding-hr.md +3 -3
  14. package/agents/examples/custom-fields-basic.md +1 -1
  15. package/agents/examples/documents.md +28 -0
  16. package/agents/examples/encrypted-tenant-config.md +8 -8
  17. package/agents/examples/env-composition.md +10 -10
  18. package/agents/examples/error-contract.md +9 -9
  19. package/agents/examples/event-sourcing.md +9 -9
  20. package/agents/examples/lane-routing.md +40 -1
  21. package/agents/examples/ledger-invoicing.md +6 -6
  22. package/agents/examples/legal-pages.md +16 -16
  23. package/agents/examples/managed-config.md +15 -11
  24. package/agents/examples/managed-pages.md +7 -7
  25. package/agents/examples/pipeline-basics.md +13 -13
  26. package/agents/examples/public-share-token.md +4 -4
  27. package/agents/examples/security-baseline.md +4 -1
  28. package/agents/examples/session-revocation.md +1 -1
  29. package/agents/examples/tenant-settings.md +4 -4
  30. package/agents/examples/tier-admin.md +2 -2
  31. package/agents/examples/user-data-rights.md +21 -19
  32. package/dist/api/redis-sse-broker.d.ts +1 -0
  33. package/dist/api/redis-sse-broker.js +5 -3
  34. package/dist/engine/feature-config-events-jobs.js +11 -7
  35. package/dist/engine/soft-delete-cleanup.js +2 -2
  36. package/dist/entrypoint/index.d.ts +5 -2
  37. package/dist/entrypoint/index.js +15 -6
  38. package/dist/entrypoint/internals.d.ts +2 -0
  39. package/dist/files/feature.js +1 -1
  40. package/dist/jobs/cron-repeat-options.d.ts +9 -0
  41. package/dist/jobs/cron-repeat-options.js +9 -0
  42. package/dist/jobs/index.d.ts +1 -0
  43. package/dist/jobs/index.js +1 -0
  44. package/dist/jobs/job-runner-context.js +4 -1
  45. package/dist/jobs/job-runner-types.d.ts +5 -0
  46. package/dist/jobs/job-runner.js +14 -5
  47. package/dist/jobs/maintenance-window.d.ts +7 -0
  48. package/dist/jobs/maintenance-window.js +14 -0
  49. package/dist/pipeline/event-retention.js +3 -1
  50. package/dist/redis/cache-sync-bus.d.ts +1 -0
  51. package/dist/redis/cache-sync-bus.js +15 -6
  52. package/dist/redis/client.d.ts +12 -0
  53. package/dist/redis/client.js +30 -5
  54. package/dist/redis/index.d.ts +2 -2
  55. package/dist/redis/index.js +1 -1
  56. package/dist/search/meilisearch-peer.js +1 -1
  57. package/package.json +12 -12
  58. package/presets/biome-app.json +1 -1
  59. package/src/changes.json +12 -0
@@ -1,6 +1,6 @@
1
1
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
2
  import { createFallbackLogger } from "../logging/utils.js";
3
- import { redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "../redis/client.js";
3
+ import { redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "../redis/client.js";
4
4
  import { createRedisPubSubSignal } from "../redis/pubsub-signal.js";
5
5
  import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
6
6
  const log = createFallbackLogger("kumiko:sse-broker");
@@ -28,11 +28,13 @@ export function isRedisSseBroker(broker) {
28
28
  // REDIS_URL-based default never ran.
29
29
  export function createDefaultSseBroker(env = process.env) {
30
30
  const redisUrl = env["REDIS_URL"];
31
+ const { channelPrefix, channelSubscribePattern } = redisNamespaceFromEnv(env);
31
32
  const ownedRedisSseBroker = redisUrl
32
33
  ? createRedisSseBroker({
33
34
  redisUrl,
34
35
  clientOptions: redisClientOptionsFromEnv(env),
35
- channelPrefix: redisChannelPrefixFromEnv(env),
36
+ channelPrefix,
37
+ ...(channelSubscribePattern !== undefined && { channelSubscribePattern }),
36
38
  })
37
39
  : undefined;
38
40
  return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
@@ -104,7 +106,7 @@ export function createRedisSseBroker(opts) {
104
106
  const invalidationPrefix = `${opts.channelPrefix ?? ""}${INVALIDATION_PREFIX}`;
105
107
  const signal = createRedisPubSubSignal({
106
108
  redisUrl: opts.redisUrl,
107
- channelPattern: `${opts.channelPrefix ?? ""}${PSUBSCRIBE_SUFFIX}`,
109
+ channelPattern: opts.channelSubscribePattern ?? `${opts.channelPrefix ?? ""}${PSUBSCRIBE_SUFFIX}`,
108
110
  label: "sse-broker",
109
111
  ...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
110
112
  });
@@ -6,6 +6,16 @@ import { createEventDef } from "./event-def.js";
6
6
  import { resolveName } from "./handler-helpers.js";
7
7
  import { requireArgument, splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
8
8
  import { QnTypes, qn, toKebab } from "./qualified-name.js";
9
+ function assertScheduleTrigger(featureName, jobName, trigger) {
10
+ if ("maintenance" in trigger && trigger.maintenance !== true) {
11
+ throw new Error(`[Feature ${featureName}] r.job("${jobName}") declares trigger { maintenance: ${JSON.stringify(trigger.maintenance)} } — ` +
12
+ "the only valid value is true; the run time comes from KUMIKO_MAINTENANCE_WINDOW.");
13
+ }
14
+ if ("cron" in trigger && trigger.tz !== undefined && !isValidIanaTimeZone(trigger.tz)) {
15
+ throw new Error(`[Feature ${featureName}] r.job("${jobName}") declares trigger { cron, tz: ${JSON.stringify(trigger.tz)} } — ` +
16
+ 'tz must be an IANA time zone name such as "Europe/Berlin".');
17
+ }
18
+ }
9
19
  // Builds config/secrets/claims/events/jobs/notifications registrar methods.
10
20
  export function buildConfigEventsJobsMethods(state, name) {
11
21
  function config(arg1, arg2) {
@@ -90,13 +100,7 @@ export function buildConfigEventsJobsMethods(state, name) {
90
100
  if (jobGrantsProblem) {
91
101
  throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobGrantsProblem}`);
92
102
  }
93
- if ("cron" in jobOptions.trigger && jobOptions.trigger.tz !== undefined) {
94
- const tz = jobOptions.trigger.tz;
95
- if (!isValidIanaTimeZone(tz)) {
96
- throw new Error(`[Feature ${name}] r.job("${jobName}") declares trigger { cron, tz: ${JSON.stringify(tz)} } — ` +
97
- 'tz must be an IANA time zone name such as "Europe/Berlin".');
98
- }
99
- }
103
+ assertScheduleTrigger(name, jobName, jobOptions.trigger);
100
104
  // Resolve NameOrRef(s) in trigger.on. For the multi-trigger form, arrays
101
105
  // become arrays of resolved strings while a single value remains a string;
102
106
  // the job runner distinguishes them with Array.isArray.
@@ -161,7 +161,7 @@ export function buildSoftDeleteCleanupJob() {
161
161
  return {
162
162
  name: SOFT_DELETE_CLEANUP_JOB,
163
163
  handler: softDeleteCleanupJob,
164
- trigger: { cron: "0 3 * * *" },
164
+ trigger: { maintenance: true },
165
165
  perTenant: true,
166
166
  concurrency: "skip",
167
167
  runIn: "worker",
@@ -171,7 +171,7 @@ export function buildSoftDeleteCleanupSystemJob() {
171
171
  return {
172
172
  name: SOFT_DELETE_CLEANUP_SYSTEM_JOB,
173
173
  handler: softDeleteCleanupSystemJob,
174
- trigger: { cron: "15 3 * * *" },
174
+ trigger: { maintenance: true },
175
175
  concurrency: "skip",
176
176
  runIn: "worker",
177
177
  escapeHatch: {
@@ -11,6 +11,9 @@ import type { Dispatcher } from "../pipeline/dispatcher.ts";
11
11
  import type { EventDispatcher } from "../pipeline/index.ts";
12
12
  import type { LifecycleHooks } from "../pipeline/lifecycle-pipeline.ts";
13
13
  import type { AllInOneEntrypointOptions, BaseEntrypointOptions, JobsBlock, WorkerEntrypointOptions } from "./internals.ts";
14
+ export type EntrypointStopOptions = {
15
+ readonly drainTimeoutMs?: number;
16
+ };
14
17
  export type ApiEntrypointOptions = BaseEntrypointOptions & {
15
18
  readonly auth?: AuthRoutesConfig;
16
19
  readonly anonymousAccess?: ServerOptions["anonymousAccess"];
@@ -41,7 +44,7 @@ export type ApiEntrypoint = {
41
44
  readonly eventDispatcher?: EventDispatcher;
42
45
  readonly mode: "api";
43
46
  start(): Promise<void>;
44
- stop(): Promise<void>;
47
+ stop(options?: EntrypointStopOptions): Promise<void>;
45
48
  };
46
49
  export type WorkerEntrypoint = {
47
50
  readonly lifecycle: Lifecycle;
@@ -52,7 +55,7 @@ export type WorkerEntrypoint = {
52
55
  readonly dispatcher: Dispatcher;
53
56
  readonly mode: "worker";
54
57
  start(): Promise<void>;
55
- stop(): Promise<void>;
58
+ stop(options?: EntrypointStopOptions): Promise<void>;
56
59
  };
57
60
  export type AllInOneEntrypoint = Omit<ApiEntrypoint, "mode"> & Omit<WorkerEntrypoint, "mode"> & {
58
61
  readonly mode: "all-in-one";
@@ -176,6 +176,8 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
176
176
  redisUrl: jobs.redisUrl,
177
177
  consumerLane,
178
178
  queueNamePrefix: jobs.queueNamePrefix,
179
+ redisNamespace: jobs.redisNamespace,
180
+ maintenanceWindow: jobs.maintenanceWindow,
179
181
  getActiveTenantIds: jobs.getActiveTenantIds,
180
182
  onJobStart: jobs.onJobStart,
181
183
  onJobComplete: jobs.onJobComplete,
@@ -189,6 +191,13 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
189
191
  });
190
192
  return jobRunner;
191
193
  }
194
+ // Before the lifecycle drain, not as a hook: runner hooks drain one after another, and every other
195
+ // hook (cache sync, brokers) would otherwise run first, leaving a window in which a lane still
196
+ // fetches new jobs after SIGTERM.
197
+ async function drainEntrypoint(lifecycle, runners, stopOptions) {
198
+ await Promise.all(runners.map((runner) => runner.stopConsuming?.()));
199
+ await lifecycle.drain(stopOptions?.drainTimeoutMs !== undefined ? { timeoutMs: stopOptions.drainTimeoutMs } : {});
200
+ }
192
201
  // A worker-shaped process with no consumers (no SSE, no search adapter,
193
202
  // no MSPs) has nothing to drain — that's a caller-config bug, not a
194
203
  // usable process shape. Fail loud so ops sees it before prod takes
@@ -263,8 +272,8 @@ export function createApiEntrypoint(options) {
263
272
  if (server.eventDispatcher)
264
273
  await server.eventDispatcher.start();
265
274
  },
266
- async stop() {
267
- await lifecycle.drain();
275
+ async stop(stopOptions) {
276
+ await drainEntrypoint(lifecycle, apiJobRunner ? [apiJobRunner] : [], stopOptions);
268
277
  },
269
278
  };
270
279
  }
@@ -290,8 +299,8 @@ export function createWorkerEntrypoint(options) {
290
299
  await eventDispatcher.start();
291
300
  await jobRunner.start();
292
301
  },
293
- async stop() {
294
- await lifecycle.drain();
302
+ async stop(stopOptions) {
303
+ await drainEntrypoint(lifecycle, [jobRunner], stopOptions);
295
304
  },
296
305
  };
297
306
  }
@@ -339,8 +348,8 @@ export function createAllInOneEntrypoint(options) {
339
348
  await workerJobRunner.start();
340
349
  await apiJobRunner.start();
341
350
  },
342
- async stop() {
343
- await lifecycle.drain();
351
+ async stop(stopOptions) {
352
+ await drainEntrypoint(lifecycle, [workerJobRunner, apiJobRunner], stopOptions);
344
353
  },
345
354
  };
346
355
  }
@@ -27,6 +27,8 @@ export type BaseEntrypointOptions = {
27
27
  export type JobsBlock = {
28
28
  readonly redisUrl: string;
29
29
  readonly queueNamePrefix?: string;
30
+ readonly redisNamespace?: JobRunnerOptions["redisNamespace"];
31
+ readonly maintenanceWindow?: JobRunnerOptions["maintenanceWindow"];
30
32
  readonly getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
31
33
  readonly onJobStart?: JobRunnerOptions["onJobStart"];
32
34
  readonly onJobComplete?: JobRunnerOptions["onJobComplete"];
@@ -40,7 +40,7 @@ export function createFilesFeature(options = {}) {
40
40
  r.storeTable(fileUploadLedgerTable, { reason: "coordination.file_upload_ledger" });
41
41
  r.job({
42
42
  name: "sweep-orphaned-uploads",
43
- trigger: { cron: "0 * * * *" },
43
+ trigger: { maintenance: true },
44
44
  perTenant: true,
45
45
  concurrency: "skip",
46
46
  handler: async (_payload, ctx) => {
@@ -1,3 +1,5 @@
1
+ import type { JobTrigger } from "@cosmicdrift/kumiko-types/config";
2
+ import { type MaintenanceWindow } from "./maintenance-window.ts";
1
3
  export type CronTrigger = {
2
4
  readonly cron: string;
3
5
  readonly tz?: string | undefined;
@@ -6,4 +8,11 @@ export type CronRepeatOptions = {
6
8
  readonly pattern: string;
7
9
  readonly tz?: string;
8
10
  };
11
+ export type ScheduledTrigger = Extract<JobTrigger, {
12
+ readonly cron: string;
13
+ }> | Extract<JobTrigger, {
14
+ readonly maintenance: true;
15
+ }>;
16
+ export declare function isScheduledTrigger(trigger: JobTrigger): trigger is ScheduledTrigger;
9
17
  export declare function cronRepeatOptions(trigger: CronTrigger): CronRepeatOptions;
18
+ export declare function scheduleRepeatOptions(trigger: ScheduledTrigger, window?: MaintenanceWindow): CronRepeatOptions;
@@ -1,5 +1,14 @@
1
+ import { DEFAULT_MAINTENANCE_WINDOW } from "./maintenance-window.js";
2
+ export function isScheduledTrigger(trigger) {
3
+ return "cron" in trigger || "maintenance" in trigger;
4
+ }
1
5
  export function cronRepeatOptions(trigger) {
2
6
  return trigger.tz === undefined
3
7
  ? { pattern: trigger.cron }
4
8
  : { pattern: trigger.cron, tz: trigger.tz };
5
9
  }
10
+ export function scheduleRepeatOptions(trigger, window = DEFAULT_MAINTENANCE_WINDOW) {
11
+ return "cron" in trigger
12
+ ? cronRepeatOptions(trigger)
13
+ : { pattern: `${window.minute} ${window.hour} * * *`, tz: "UTC" };
14
+ }
@@ -1,2 +1,3 @@
1
1
  export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, } from "./job-runner.ts";
2
2
  export { createJobRunner, serializeJobSubject } from "./job-runner.ts";
3
+ export { DEFAULT_MAINTENANCE_WINDOW, MAINTENANCE_WINDOW_ENV, type MaintenanceWindow, maintenanceWindowFromEnv, } from "./maintenance-window.ts";
@@ -1 +1,2 @@
1
1
  export { createJobRunner, serializeJobSubject } from "./job-runner.js";
2
+ export { DEFAULT_MAINTENANCE_WINDOW, MAINTENANCE_WINDOW_ENV, maintenanceWindowFromEnv, } from "./maintenance-window.js";
@@ -12,6 +12,7 @@ import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js"
12
12
  import { buildContextServices } from "../pipeline/context-services.js";
13
13
  import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
14
14
  import { buildPersonalDataGate, parseWriteOrigin } from "../pipeline/write-origin.js";
15
+ import { isScheduledTrigger } from "./cron-repeat-options.js";
15
16
  import { createJobAppendEvent } from "./job-append-event.js";
16
17
  import { attemptNumberOf, createJobLogger, handlerPayloadOf, hasDeferBudgetLeft, isFinalAttempt, isJobTriggerEvent, jobSubjectKey, missingDbProxy, tenantIdOfJobData, } from "./job-runner-helpers.js";
17
18
  // Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
@@ -56,7 +57,9 @@ function parseJobWriteOrigin(rawData, jobName) {
56
57
  function createJobEscapeHatchReporter(scope, input, systemUser) {
57
58
  const { jobName, jobDef, tenantId, triggeredById } = input;
58
59
  // One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
59
- const isSystemCronRun = triggeredById === null && jobDef.escapeHatch !== undefined && "cron" in jobDef.trigger;
60
+ const isSystemCronRun = triggeredById === null &&
61
+ jobDef.escapeHatch !== undefined &&
62
+ isScheduledTrigger(jobDef.trigger);
60
63
  return createEscapeHatchReporter({
61
64
  handler: jobName,
62
65
  tenantId,
@@ -1,6 +1,8 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
2
  import type { DispatchWriteRef, JobTriggerEvent } from "@cosmicdrift/kumiko-types/handlers";
3
3
  import type { AppContext, JobRunIn, Registry, SessionUser, TenantId } from "../engine/types/index.ts";
4
+ import type { RedisNamespace } from "../redis/client.ts";
5
+ import type { MaintenanceWindow } from "./maintenance-window.ts";
4
6
  export type JobLogEntry = {
5
7
  level: "info" | "warn" | "error";
6
8
  message: string;
@@ -40,6 +42,7 @@ export type JobSubjectValue = string | number | boolean | null;
40
42
  export type JobRunner = {
41
43
  start(): Promise<void>;
42
44
  stop(): Promise<void>;
45
+ stopConsuming?(): Promise<void>;
43
46
  dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
44
47
  handleEvent(eventName: string, payload: Record<string, unknown>, user?: SessionUser, triggerEvent?: JobTriggerEvent): Promise<void>;
45
48
  handleCatchUpEvent(jobName: string, eventName: string, payload: Record<string, unknown>, user: SessionUser, triggerEvent: JobTriggerEvent): Promise<void>;
@@ -52,6 +55,8 @@ export type JobRunnerOptions = {
52
55
  redisUrl: string;
53
56
  consumerLane?: JobRunIn | undefined;
54
57
  queueNamePrefix?: string | undefined;
58
+ redisNamespace?: Pick<RedisNamespace, "keyPrefix" | "bullmqPrefix"> | undefined;
59
+ maintenanceWindow?: MaintenanceWindow | undefined;
55
60
  bootRedisTimeoutMs?: number | undefined;
56
61
  schedulerSeenRefreshMs?: number | undefined;
57
62
  jobRetention?: {
@@ -14,7 +14,7 @@ import { registerStandardMetrics } from "../observability/standard-metrics.js";
14
14
  import { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.js";
15
15
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
16
16
  import { RedisKeys } from "../pipeline/redis-keys.js";
17
- import { cronRepeatOptions } from "./cron-repeat-options.js";
17
+ import { isScheduledTrigger, scheduleRepeatOptions } from "./cron-repeat-options.js";
18
18
  import { createJobEventTriggers } from "./job-runner-event-triggers.js";
19
19
  import { applyHandlerDeferral, executeJobInSpan } from "./job-runner-execution.js";
20
20
  import { claimSequentialSlot, deferOverTenantLimit, fanOutPerTenantJob, PER_TENANT_PREFIX, perTenantTargetOf, releaseSequentialSlot, resolveJobDefinitionOrFail, startJobRun, } from "./job-runner-gates.js";
@@ -24,12 +24,15 @@ import { announceDropped, announceQueued, isOverPerTenantLimit, laneForJob, } fr
24
24
  import { resolveMaxPerTenant } from "./max-per-tenant.js";
25
25
  export { serializeJobSubject } from "./job-runner-helpers.js";
26
26
  export { bootJobIdForJobName, ORPHAN_SCHEDULER_GRACE_MS, schedulerIdForJobName, } from "./job-runner-schedulers.js";
27
+ const DEFAULT_REDIS_NAMESPACE = { keyPrefix: "", bullmqPrefix: "bull" };
27
28
  export function createJobRunner(options) {
28
29
  const { registry, context, redisUrl, consumerLane } = options;
29
30
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
30
31
  const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
31
32
  const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
32
33
  const redisOpts = parseRedisOpts(redisUrl);
34
+ // BullMQ rejects an ioredis keyPrefix on its connection, so it gets its own `prefix`; only lockRedis uses keyPrefix.
35
+ const { keyPrefix, bullmqPrefix } = options.redisNamespace ?? DEFAULT_REDIS_NAMESPACE;
33
36
  // Use the context's tracer when present (observability-provider injected at
34
37
  // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
35
38
  const tracer = context.tracer ?? getFallbackTracer();
@@ -133,7 +136,7 @@ export function createJobRunner(options) {
133
136
  let lockRedis = null;
134
137
  let sequentialLock = null;
135
138
  if (hasSequential) {
136
- lockRedis = new Redis(redisOpts);
139
+ lockRedis = new Redis({ ...redisOpts, keyPrefix });
137
140
  // Without a listener, a post-close 'error' (e.g. a teardown-race
138
141
  // "Connection is closed") is unhandled and crashes the process — in
139
142
  // bun:test it gets attributed to whichever test happens to run next.
@@ -161,10 +164,12 @@ export function createJobRunner(options) {
161
164
  const queues = {
162
165
  api: new Queue(queueNameFor(queueNamePrefix, "api"), {
163
166
  connection: redisOpts,
167
+ prefix: bullmqPrefix,
164
168
  defaultJobOptions: jobRetentionOpts,
165
169
  }),
166
170
  worker: new Queue(queueNameFor(queueNamePrefix, "worker"), {
167
171
  connection: redisOpts,
172
+ prefix: bullmqPrefix,
168
173
  defaultJobOptions: jobRetentionOpts,
169
174
  }),
170
175
  };
@@ -292,7 +297,7 @@ export function createJobRunner(options) {
292
297
  function cronSchedulerIdsForLane(lane) {
293
298
  const ids = new Set();
294
299
  for (const [name, jobDef] of allJobs) {
295
- if (laneForJob(jobDef) === lane && "cron" in jobDef.trigger) {
300
+ if (laneForJob(jobDef) === lane && isScheduledTrigger(jobDef.trigger)) {
296
301
  ids.add(schedulerIdForJobName(name));
297
302
  }
298
303
  }
@@ -302,8 +307,8 @@ export function createJobRunner(options) {
302
307
  for (const [name, jobDef] of allJobs) {
303
308
  if (laneForJob(jobDef) !== lane)
304
309
  continue;
305
- if ("cron" in jobDef.trigger) {
306
- await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
310
+ if (isScheduledTrigger(jobDef.trigger)) {
311
+ await queue.upsertJobScheduler(schedulerIdForJobName(name), scheduleRepeatOptions(jobDef.trigger, options.maintenanceWindow), {
307
312
  name: jobDef.perTenant ? `${PER_TENANT_PREFIX}${name}` : name,
308
313
  data: {},
309
314
  // Queue-level defaultJobOptions (jobRetentionOpts) covers
@@ -370,6 +375,7 @@ export function createJobRunner(options) {
370
375
  const consumerQueue = queues[consumerLane];
371
376
  worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), (job, token) => handleJob(job, token), {
372
377
  connection: redisOpts,
378
+ prefix: bullmqPrefix,
373
379
  concurrency: 5,
374
380
  // Not consuming until the boot gates passed: a job already queued from a
375
381
  // previous run must not execute against an unmet boot precondition.
@@ -460,6 +466,9 @@ export function createJobRunner(options) {
460
466
  // Last on purpose: a start() that fails earlier must not leave a timer stamping for a runner that is not running.
461
467
  schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
462
468
  },
469
+ async stopConsuming() {
470
+ await worker?.pause(true);
471
+ },
463
472
  async stop() {
464
473
  stopping = true;
465
474
  if (queueDepthTimer) {
@@ -0,0 +1,7 @@
1
+ export type MaintenanceWindow = {
2
+ readonly hour: number;
3
+ readonly minute: number;
4
+ };
5
+ export declare const MAINTENANCE_WINDOW_ENV = "KUMIKO_MAINTENANCE_WINDOW";
6
+ export declare const DEFAULT_MAINTENANCE_WINDOW: MaintenanceWindow;
7
+ export declare function maintenanceWindowFromEnv(env?: Record<string, string | undefined>): MaintenanceWindow;
@@ -0,0 +1,14 @@
1
+ export const MAINTENANCE_WINDOW_ENV = "KUMIKO_MAINTENANCE_WINDOW";
2
+ export const DEFAULT_MAINTENANCE_WINDOW = { hour: 3, minute: 0 };
3
+ const MAINTENANCE_WINDOW_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
4
+ // UTC wall clock, so the window does not move with the server time zone or DST.
5
+ export function maintenanceWindowFromEnv(env = process.env) {
6
+ const raw = env[MAINTENANCE_WINDOW_ENV]?.trim();
7
+ if (raw === undefined || raw === "")
8
+ return DEFAULT_MAINTENANCE_WINDOW;
9
+ const match = MAINTENANCE_WINDOW_PATTERN.exec(raw);
10
+ if (match === null) {
11
+ throw new Error(`${MAINTENANCE_WINDOW_ENV}="${raw}" is invalid: expected HH:MM in UTC, e.g. "03:00" or "22:45".`);
12
+ }
13
+ return { hour: Number(match[1]), minute: Number(match[2]) };
14
+ }
@@ -68,7 +68,9 @@ export async function pruneEvents(db, options) {
68
68
  if (candidates.length === 0) {
69
69
  return { deletedCount: 0, cutoff, aggregateTypes, dryRun };
70
70
  }
71
- const maxCandidateId = candidates.reduce((acc, row) => (row.id > acc ? row.id : acc), candidates[0]?.id ?? 0n);
71
+ const maxCandidateId = candidates.reduce(
72
+ // biome-ignore lint/complexity/useMathMinMax: ids are bigint, Math.max throws a TypeError on BigInt
73
+ (acc, row) => (row.id > acc ? row.id : acc), candidates[0]?.id ?? 0n);
72
74
  // Step 2 — safety guard: check every ACTIVE consumer has moved past
73
75
  // the candidate set. Disabled consumers are intentionally excluded —
74
76
  // ops disables them precisely to park them while pruning happens.
@@ -14,6 +14,7 @@ export type RedisCacheSyncBusOptions = {
14
14
  readonly redisUrl: string;
15
15
  readonly clientOptions?: RedisClientOptions;
16
16
  readonly channelPrefix?: string;
17
+ readonly channelSubscribePattern?: string;
17
18
  };
18
19
  export declare function createRedisCacheSyncBus(opts: RedisCacheSyncBusOptions): ClosableCacheSyncBus;
19
20
  export declare function createDefaultCacheSyncBus(envVars?: Record<string, string | undefined>): ClosableCacheSyncBus;
@@ -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";
@@ -1,5 +1,5 @@
1
1
  export const MEILISEARCH_PEER = {
2
2
  name: "meilisearch",
3
- range: "^0.58.0",
3
+ range: "^0.62.0",
4
4
  requiredBy: "@cosmicdrift/kumiko-framework/search/meilisearch",
5
5
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.403.0",
3
+ "version": "0.404.1",
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,21 +203,21 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.403.0",
207
- "bullmq": "^5.76.7",
206
+ "@cosmicdrift/kumiko-types": "0.404.1",
207
+ "bullmq": "^5.81.5",
208
208
  "hono": "^4.13.14",
209
- "i18next": "^26.1.0",
210
- "ioredis": "^5.10.1",
211
- "jose": "^6.2.13",
212
- "pino": "^10.3.1",
209
+ "i18next": "^26.5.11",
210
+ "ioredis": "^5.11.1",
211
+ "jose": "^6.2.14",
212
+ "pino": "^10.4.0",
213
213
  "temporal-polyfill": "^0.3.2",
214
- "uuid": "^14.0.0",
214
+ "uuid": "^14.0.3",
215
215
  "write-excel-file": "^4.1.1",
216
216
  "zod": "^4.6.7"
217
217
  },
218
218
  "peerDependencies": {
219
219
  "bun-types": "^1.4.4",
220
- "meilisearch": "^0.58.0"
220
+ "meilisearch": "^0.62.0"
221
221
  },
222
222
  "peerDependenciesMeta": {
223
223
  "meilisearch": {
@@ -225,11 +225,11 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.403.0",
228
+ "@cosmicdrift/kumiko-testing": "0.404.1",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.3",
231
- "meilisearch": "^0.58.0",
232
- "pino-pretty": "^13.1.3"
231
+ "meilisearch": "^0.62.0",
232
+ "pino-pretty": "^13.2.0"
233
233
  },
234
234
  "publishConfig": {
235
235
  "registry": "https://registry.npmjs.org",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
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
+ },
2
14
  {
3
15
  "version": "0.403.0",
4
16
  "type": "improvement",