effect-mq 0.4.2 → 0.5.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 (46) hide show
  1. package/dist/Job.d.ts +6 -0
  2. package/dist/Job.d.ts.map +1 -1
  3. package/dist/Job.js +1 -0
  4. package/dist/Job.js.map +1 -1
  5. package/dist/JobSchedules.d.ts +112 -0
  6. package/dist/JobSchedules.d.ts.map +1 -0
  7. package/dist/JobSchedules.js +106 -0
  8. package/dist/JobSchedules.js.map +1 -0
  9. package/dist/JobStore.d.ts +8 -0
  10. package/dist/JobStore.d.ts.map +1 -1
  11. package/dist/JobStore.js.map +1 -1
  12. package/dist/MemoryJobStore.d.ts.map +1 -1
  13. package/dist/MemoryJobStore.js +2 -1
  14. package/dist/MemoryJobStore.js.map +1 -1
  15. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  16. package/dist/drizzle-postgres/DrizzleJobStore.js +9 -4
  17. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  18. package/dist/drizzle-postgres/schema.d.ts +17 -0
  19. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  20. package/dist/drizzle-postgres/schema.js +2 -0
  21. package/dist/drizzle-postgres/schema.js.map +1 -1
  22. package/dist/index.d.ts +7 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  27. package/dist/redis/RedisJobStore.js +2 -1
  28. package/dist/redis/RedisJobStore.js.map +1 -1
  29. package/dist/redis/scripts.d.ts +1 -1
  30. package/dist/redis/scripts.d.ts.map +1 -1
  31. package/dist/redis/scripts.js +5 -3
  32. package/dist/redis/scripts.js.map +1 -1
  33. package/dist/testing/conformance.d.ts.map +1 -1
  34. package/dist/testing/conformance.js +37 -0
  35. package/dist/testing/conformance.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/Job.ts +7 -0
  38. package/src/JobSchedules.ts +223 -0
  39. package/src/JobStore.ts +8 -0
  40. package/src/MemoryJobStore.ts +2 -1
  41. package/src/drizzle-postgres/DrizzleJobStore.ts +10 -4
  42. package/src/drizzle-postgres/schema.ts +2 -0
  43. package/src/index.ts +8 -0
  44. package/src/redis/RedisJobStore.ts +2 -0
  45. package/src/redis/scripts.ts +5 -2
  46. package/src/testing/conformance.ts +44 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Declarative schedule reconciliation.
3
+ *
4
+ * `.schedule()` is imperative: it creates and updates, and a schedule whose
5
+ * call was deleted from code keeps firing forever (deletion drift). This
6
+ * module declares the FULL schedule set for a service as a layer; on
7
+ * startup it upserts everything declared (idempotent, cadence-preserving)
8
+ * and detects group members that are no longer declared:
9
+ *
10
+ * ```ts
11
+ * const SchedulesLive = JobSchedules.layer({
12
+ * group: "billing-service",
13
+ * schedules: [
14
+ * JobSchedules.schedule(SendDigest, "daily", { cron: "0 9 * * *", payload: {} }),
15
+ * JobSchedules.schedule(GenerateInvoice, "monthly", { cron: "0 0 1 * *", payload: {} })
16
+ * ],
17
+ * removal: "group", // default "warn": log drift, prune nothing
18
+ * removeAfter: "10 minutes" // optional grace window for rolling deploys
19
+ * })
20
+ * ```
21
+ *
22
+ * Safety model: pruning is scoped to the ownership `group`. Schedules
23
+ * created by plain `.schedule()` calls carry no group and are NEVER pruned;
24
+ * other groups' schedules are never touched. The default `removal: "warn"`
25
+ * only logs — destructive pruning is an explicit opt-in.
26
+ *
27
+ * @since 0.5.0
28
+ */
29
+ import { type Context, Duration, Effect, Layer } from "effect"
30
+ import type { ScheduleOptions } from "./Job.ts"
31
+ import type { ScheduleKey, Service as StoreService } from "./JobStore.ts"
32
+
33
+ /**
34
+ * The structural view of a `Job.make` class that `schedule` needs: its tag,
35
+ * its store key, and its bound `schedule` verb.
36
+ *
37
+ * @since 0.5.0
38
+ */
39
+ export interface SchedulableJob<PayloadInput, R> {
40
+ readonly _tag: string
41
+ readonly store: Context.Key<any, StoreService>
42
+ readonly schedule: (
43
+ key: string,
44
+ options: ScheduleOptions<PayloadInput>
45
+ ) => Effect.Effect<ScheduleKey, never, R>
46
+ }
47
+
48
+ /**
49
+ * One declared schedule: a job, its key, and its cadence/options. Built
50
+ * with `JobSchedules.schedule`; consumed by `JobSchedules.layer`.
51
+ *
52
+ * @since 0.5.0
53
+ */
54
+ export interface ScheduleEntry<R> {
55
+ readonly jobName: string
56
+ readonly key: string
57
+ readonly store: Context.Key<any, StoreService>
58
+ readonly register: (group: string) => Effect.Effect<ScheduleKey, never, R>
59
+ }
60
+
61
+ /**
62
+ * Declare one schedule for the reconciled set. Identical semantics to
63
+ * `MyJob.schedule(key, options)` — including cadence preservation on
64
+ * unchanged `cron`/`tz`/`every` — plus the layer's ownership `group`.
65
+ *
66
+ * @since 0.5.0
67
+ */
68
+ export const schedule = <PayloadInput, R>(
69
+ job: SchedulableJob<PayloadInput, R>,
70
+ key: string,
71
+ options: Omit<ScheduleOptions<PayloadInput>, "group">
72
+ ): ScheduleEntry<R> => ({
73
+ jobName: job._tag,
74
+ key,
75
+ store: job.store,
76
+ register: (group) => job.schedule(key, { ...options, group })
77
+ })
78
+
79
+ /**
80
+ * Options for `JobSchedules.layer`.
81
+ *
82
+ * @since 0.5.0
83
+ */
84
+ export interface ReconcileOptions<
85
+ Entries extends ReadonlyArray<ScheduleEntry<any>>,
86
+ Stores extends ReadonlyArray<Context.Key<any, StoreService>>
87
+ > {
88
+ /**
89
+ * Ownership label. Everything this layer declares is upserted with this
90
+ * group, and only schedules carrying this group are candidates for drift
91
+ * detection and pruning. Use one group per service/deployable.
92
+ */
93
+ readonly group: string
94
+ /** The full declared schedule set for this group. */
95
+ readonly schedules: Entries
96
+ /**
97
+ * What to do with group members that are no longer declared:
98
+ * - `"warn"` (default): log them at warning level, prune nothing.
99
+ * - `"group"`: remove them (never touches unlabeled or other-group rows).
100
+ */
101
+ readonly removal?: "warn" | "group" | undefined
102
+ /**
103
+ * Grace window before pruning (requires `removal: "group"`). During a
104
+ * rolling deploy, replicas on the previous release re-declare schedules
105
+ * the new release dropped; pruning immediately and re-adding would
106
+ * re-anchor `every` grids and double-fire crons. With a window, the prune
107
+ * runs this long after startup, re-checks the store, and removes only
108
+ * members still undeclared. Shutting down before the window fires skips
109
+ * the prune (the next startup re-evaluates).
110
+ */
111
+ readonly removeAfter?: Duration.Input | undefined
112
+ /**
113
+ * Extra store keys to reconcile even when no declared entry references
114
+ * them — needed when a release drops the LAST schedule a store had, since
115
+ * drift detection only reaches stores it can see.
116
+ */
117
+ readonly stores?: Stores | undefined
118
+ }
119
+
120
+ const reconcile = (options: {
121
+ readonly group: string
122
+ readonly schedules: ReadonlyArray<ScheduleEntry<any>>
123
+ readonly removal?: "warn" | "group" | undefined
124
+ readonly removeAfter?: Duration.Input | undefined
125
+ readonly stores?: ReadonlyArray<Context.Key<any, StoreService>> | undefined
126
+ }) =>
127
+ Effect.gen(function*() {
128
+ const removal = options.removal ?? "warn"
129
+ if (options.removeAfter !== undefined && removal !== "group") {
130
+ return yield* Effect.die(
131
+ new Error(`effect-mq: \`removeAfter\` requires \`removal: "group"\``)
132
+ )
133
+ }
134
+ // Duplicate declarations are a config bug: the second would silently
135
+ // overwrite the first's cadence/payload on every startup.
136
+ const seen = new Set<string>()
137
+ for (const entry of options.schedules) {
138
+ const id = `${entry.jobName}/${entry.key}`
139
+ if (seen.has(id)) {
140
+ return yield* Effect.die(
141
+ new Error(`effect-mq: schedule "${id}" is declared twice in group "${options.group}"`)
142
+ )
143
+ }
144
+ seen.add(id)
145
+ }
146
+
147
+ // Upsert every declared schedule, collecting the declared key set per
148
+ // store (Context.Key identity groups entries onto their stores).
149
+ const byStore = new Map<Context.Key<any, StoreService>, Set<string>>()
150
+ for (const storeKey of options.stores ?? []) {
151
+ byStore.set(storeKey, new Set())
152
+ }
153
+ for (const entry of options.schedules) {
154
+ const registered = yield* entry.register(options.group)
155
+ const declared = byStore.get(entry.store) ?? new Set<string>()
156
+ declared.add(registered)
157
+ byStore.set(entry.store, declared)
158
+ }
159
+
160
+ // Drift detection per store: group members not in the declared set.
161
+ for (const [storeKey, declared] of byStore) {
162
+ const store = yield* storeKey
163
+ const prune = Effect.gen(function*() {
164
+ const members = yield* store.listSchedules({ group: options.group }).pipe(
165
+ Effect.retry({ times: 5 }),
166
+ Effect.orDie
167
+ )
168
+ const undeclared = members.filter((member) => !declared.has(member.key))
169
+ if (undeclared.length === 0) return
170
+ if (removal === "warn") {
171
+ return yield* Effect.logWarning(
172
+ `effect-mq: ${undeclared.length} schedule(s) in group "${options.group}" ` +
173
+ `are no longer declared and keep firing; \`removal: "group"\` would prune them`,
174
+ undeclared.map((member) => member.key)
175
+ )
176
+ }
177
+ for (const member of undeclared) {
178
+ yield* store.removeSchedule(member.key).pipe(Effect.retry({ times: 5 }), Effect.orDie)
179
+ }
180
+ yield* Effect.logInfo(
181
+ `effect-mq: pruned ${undeclared.length} undeclared schedule(s) from group "${options.group}"`,
182
+ undeclared.map((member) => member.key)
183
+ )
184
+ })
185
+ if (removal === "group" && options.removeAfter !== undefined) {
186
+ // Deferred prune, tied to the layer scope: it re-lists at fire time,
187
+ // so anything re-declared during the window survives.
188
+ yield* prune.pipe(
189
+ Effect.delay(Duration.toMillis(options.removeAfter)),
190
+ Effect.catchCause((cause) =>
191
+ Effect.logError(
192
+ `effect-mq: deferred schedule prune failed (group "${options.group}")`,
193
+ cause
194
+ )
195
+ ),
196
+ Effect.forkScoped
197
+ )
198
+ } else {
199
+ yield* prune
200
+ }
201
+ }
202
+ })
203
+
204
+ /**
205
+ * Reconcile the declared schedule set on startup: upsert everything
206
+ * declared, then warn about (or, with `removal: "group"`, prune) group
207
+ * members that are no longer declared. See the module docs for the safety
208
+ * model.
209
+ *
210
+ * @since 0.5.0
211
+ */
212
+ export const layer = <
213
+ const Entries extends ReadonlyArray<ScheduleEntry<any>>,
214
+ const Stores extends ReadonlyArray<Context.Key<any, StoreService>> = readonly []
215
+ >(
216
+ options: ReconcileOptions<Entries, Stores>
217
+ ): Layer.Layer<never, never, EntryServices<Entries[number]> | StoreId<Stores[number]>> =>
218
+ Layer.effectDiscard(reconcile(options))
219
+
220
+ // Naked-parameter conditionals so empty tuples distribute to `never` instead
221
+ // of inferring `unknown` from nothing.
222
+ type EntryServices<E> = E extends ScheduleEntry<infer R> ? R : never
223
+ type StoreId<K> = K extends Context.Key<infer Id, StoreService> ? Id : never
package/src/JobStore.ts CHANGED
@@ -450,6 +450,12 @@ export interface ScheduleRecord {
450
450
  readonly backoff: BackoffPolicy | undefined
451
451
  readonly keep: KeepPolicy | undefined
452
452
  readonly timeoutMs: number | undefined
453
+ /**
454
+ * Ownership label for declarative reconciliation (`JobSchedules.layer`):
455
+ * a reconciler only ever prunes schedules carrying ITS group. Unlabeled
456
+ * schedules (plain `.schedule()` calls) are never pruned.
457
+ */
458
+ readonly group: string | undefined
453
459
  /** Epoch millis of the next occurrence to enqueue. */
454
460
  readonly nextRunAt: number
455
461
  }
@@ -796,6 +802,8 @@ export interface Service {
796
802
  readonly listSchedules: (options?: {
797
803
  readonly jobName?: string | undefined
798
804
  readonly queue?: QueueName | undefined
805
+ /** Only schedules carrying this ownership group. */
806
+ readonly group?: string | undefined
799
807
  }) => Effect.Effect<ReadonlyArray<ScheduleRecord>, JobStoreError>
800
808
 
801
809
  /** Schedules whose `nextRunAt` is due (per the Effect `Clock`). */
@@ -753,7 +753,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
753
753
  Effect.sync(() =>
754
754
  Array.from(schedules.values()).filter((schedule) =>
755
755
  (options?.jobName === undefined || schedule.jobName === options.jobName) &&
756
- (options?.queue === undefined || schedule.queue === options.queue)
756
+ (options?.queue === undefined || schedule.queue === options.queue) &&
757
+ (options?.group === undefined || schedule.group === options.group)
757
758
  )
758
759
  ),
759
760
 
@@ -136,6 +136,7 @@ type ScheduleRow = {
136
136
  readonly backoff: JobStore.BackoffPolicy | null
137
137
  readonly keep: JobStore.KeepPolicy | null
138
138
  readonly timeoutMs: number | string | null
139
+ readonly group: string | null
139
140
  readonly nextRunAt: Date
140
141
  }
141
142
 
@@ -153,6 +154,7 @@ const toSchedule = (row: ScheduleRow): JobStore.ScheduleRecord => ({
153
154
  backoff: row.backoff ?? undefined,
154
155
  keep: row.keep ?? undefined,
155
156
  timeoutMs: row.timeoutMs === null ? undefined : Number(row.timeoutMs),
157
+ group: row.group ?? undefined,
156
158
  nextRunAt: row.nextRunAt.getTime()
157
159
  })
158
160
 
@@ -1340,20 +1342,21 @@ export const make = (
1340
1342
  upsertSchedule: (schedule) =>
1341
1343
  db.execute(sql`
1342
1344
  INSERT INTO ${schedules} (key, job_name, queue, cron, tz, every_ms, payload, metadata,
1343
- priority, attempts_max, backoff, keep, timeout_ms, next_run_at)
1345
+ priority, attempts_max, backoff, keep, timeout_ms, group_name, next_run_at)
1344
1346
  VALUES (${schedule.key}, ${schedule.jobName}, ${schedule.queue},
1345
1347
  ${schedule.cron ?? null}, ${schedule.tz ?? null}, ${schedule.everyMs ?? null},
1346
1348
  ${JSON.stringify(schedule.payload ?? null)}::jsonb, ${JSON.stringify(schedule.metadata)}::jsonb,
1347
1349
  ${schedule.priority}, ${schedule.attemptsMax},
1348
1350
  ${schedule.backoff === undefined ? null : JSON.stringify(schedule.backoff)}::jsonb,
1349
1351
  ${schedule.keep === undefined ? null : JSON.stringify(schedule.keep)}::jsonb,
1350
- ${schedule.timeoutMs ?? null}, ${new Date(schedule.nextRunAt)})
1352
+ ${schedule.timeoutMs ?? null}, ${schedule.group ?? null}, ${new Date(schedule.nextRunAt)})
1351
1353
  ON CONFLICT (key) DO UPDATE SET
1352
1354
  job_name = EXCLUDED.job_name, queue = EXCLUDED.queue, cron = EXCLUDED.cron,
1353
1355
  tz = EXCLUDED.tz, every_ms = EXCLUDED.every_ms, payload = EXCLUDED.payload,
1354
1356
  metadata = EXCLUDED.metadata, priority = EXCLUDED.priority,
1355
1357
  attempts_max = EXCLUDED.attempts_max, backoff = EXCLUDED.backoff,
1356
1358
  keep = EXCLUDED.keep, timeout_ms = EXCLUDED.timeout_ms,
1359
+ group_name = EXCLUDED.group_name,
1357
1360
  next_run_at = CASE
1358
1361
  WHEN ${schedules.cron} IS NOT DISTINCT FROM EXCLUDED.cron
1359
1362
  AND ${schedules.tz} IS NOT DISTINCT FROM EXCLUDED.tz
@@ -1383,6 +1386,9 @@ export const make = (
1383
1386
  if (listOptions?.queue !== undefined) {
1384
1387
  conditions.push(sql`${schedules.queue} = ${listOptions.queue}`)
1385
1388
  }
1389
+ if (listOptions?.group !== undefined) {
1390
+ conditions.push(sql`${schedules.group} = ${listOptions.group}`)
1391
+ }
1386
1392
  const rows = rowsOf(yield* db.execute<ScheduleRow>(sql`
1387
1393
  SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
1388
1394
  ${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
@@ -1390,7 +1396,7 @@ export const make = (
1390
1396
  ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
1391
1397
  ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
1392
1398
  ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
1393
- ${schedules.nextRunAt} AS "nextRunAt"
1399
+ ${schedules.group} AS "group", ${schedules.nextRunAt} AS "nextRunAt"
1394
1400
  FROM ${schedules}
1395
1401
  WHERE ${sql.join(conditions, sql` AND `)}
1396
1402
  ORDER BY ${schedules.key}
@@ -1408,7 +1414,7 @@ export const make = (
1408
1414
  ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
1409
1415
  ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
1410
1416
  ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
1411
- ${schedules.nextRunAt} AS "nextRunAt"
1417
+ ${schedules.group} AS "group", ${schedules.nextRunAt} AS "nextRunAt"
1412
1418
  FROM ${schedules}
1413
1419
  WHERE ${schedules.nextRunAt} <= ${now}
1414
1420
  ORDER BY ${schedules.nextRunAt} ASC
@@ -201,6 +201,8 @@ const schedulesColumns = <JobName extends string, Queue extends string>() => ({
201
201
  backoff: jsonb("backoff").$type<BackoffPolicy>(),
202
202
  keep: jsonb("keep").$type<KeepPolicy>(),
203
203
  timeoutMs: bigint("timeout_ms", { mode: "number" }),
204
+ /** Ownership label for declarative reconciliation; NULL = never pruned. */
205
+ group: text("group_name"),
204
206
  nextRunAt: timestamp("next_run_at", { withTimezone: true, mode: "date" }).notNull()
205
207
  })
206
208
 
package/src/index.ts CHANGED
@@ -11,6 +11,14 @@
11
11
  */
12
12
  export * as Job from "./Job.ts"
13
13
 
14
+ /**
15
+ * Declarative schedule reconciliation: declare a service's full schedule
16
+ * set as a layer; startup upserts it and detects deletion drift.
17
+ *
18
+ * @since 0.5.0
19
+ */
20
+ export * as JobSchedules from "./JobSchedules.ts"
21
+
14
22
  /**
15
23
  * The storage seam: the `JobStore` service, job records and typed errors.
16
24
  *
@@ -107,6 +107,7 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
107
107
  backoff: optionalJson<JobStore.BackoffPolicy>(hash.get("backoff")),
108
108
  keep: optionalJson<JobStore.KeepPolicy>(hash.get("keep")),
109
109
  timeoutMs: optionalNumber(hash.get("timeoutMs")),
110
+ group: optionalString(hash.get("group")),
110
111
  nextRunAt: Number(hash.get("nextRunAt") ?? 0)
111
112
  })
112
113
 
@@ -746,6 +747,7 @@ export const make = (
746
747
  schedule.backoff === undefined ? "" : JSON.stringify(schedule.backoff),
747
748
  schedule.keep === undefined ? "" : JSON.stringify(schedule.keep),
748
749
  schedule.timeoutMs === undefined ? "" : String(schedule.timeoutMs),
750
+ schedule.group ?? "",
749
751
  schedule.nextRunAt
750
752
  ).pipe(
751
753
  Effect.mapError(storeError("upsertSchedule failed")),
@@ -833,6 +833,7 @@ export const upsertSchedule = Redis.script(
833
833
  backoffJson: string,
834
834
  keepJson: string,
835
835
  timeoutMs: string,
836
+ group: string,
836
837
  nextRunAt: number
837
838
  ) => [
838
839
  prefix,
@@ -849,6 +850,7 @@ export const upsertSchedule = Redis.script(
849
850
  backoffJson,
850
851
  keepJson,
851
852
  timeoutMs,
853
+ group,
852
854
  nextRunAt
853
855
  ],
854
856
  {
@@ -856,7 +858,7 @@ export const upsertSchedule = Redis.script(
856
858
  lua: `${HELPERS}
857
859
  local key = ARGV[2]
858
860
  local sk = prefix .. ":schedule:" .. key
859
- local nextRunAt = tonumber(ARGV[15])
861
+ local nextRunAt = tonumber(ARGV[16])
860
862
  local prevCron = redis.call("HGET", sk, "cron")
861
863
  if prevCron ~= false
862
864
  and prevCron == ARGV[5]
@@ -872,7 +874,7 @@ redis.call("HSET", sk,
872
874
  "payload", ARGV[8], "metadata", ARGV[9],
873
875
  "priority", ARGV[10], "attemptsMax", ARGV[11],
874
876
  "backoff", ARGV[12], "keep", ARGV[13], "timeoutMs", ARGV[14],
875
- "nextRunAt", fmt(nextRunAt))
877
+ "group", ARGV[15], "nextRunAt", fmt(nextRunAt))
876
878
  redis.call("ZADD", prefix .. ":schedules", nextRunAt, key)
877
879
  return '{"ok":true}'
878
880
  `
@@ -908,6 +910,7 @@ for _, key in ipairs(keys) do
908
910
  local matches = true
909
911
  if filters.jobName ~= nil and byName.jobName ~= filters.jobName then matches = false end
910
912
  if matches and filters.queue ~= nil and byName.queue ~= filters.queue then matches = false end
913
+ if matches and filters.group ~= nil and byName.group ~= filters.group then matches = false end
911
914
  if matches then out[#out + 1] = record end
912
915
  end
913
916
  if #out == 0 then return "[]" end
@@ -805,6 +805,7 @@ export const jobStoreConformance = (
805
805
  backoff: { _tag: "fixed", delayMs: 1_000 },
806
806
  keep: undefined,
807
807
  timeoutMs: 5_000,
808
+ group: undefined,
808
809
  nextRunAt: 60_000
809
810
  }
810
811
  yield* store.upsertSchedule(schedule)
@@ -847,9 +848,49 @@ export const jobStoreConformance = (
847
848
  backoff: undefined,
848
849
  keep: undefined,
849
850
  timeoutMs: undefined,
851
+ group: undefined,
850
852
  nextRunAt: 60_000
851
853
  })
852
854
 
855
+ it.effect("schedule group labels persist and filter listSchedules", () =>
856
+ withStore((store) =>
857
+ Effect.gen(function*() {
858
+ const base = minutelySchedule()
859
+ yield* store.upsertSchedule({
860
+ ...base,
861
+ key: JobStore.ScheduleKey("TestJob/labeled"),
862
+ group: "svc-a"
863
+ })
864
+ yield* store.upsertSchedule({
865
+ ...base,
866
+ key: JobStore.ScheduleKey("TestJob/other"),
867
+ group: "svc-b"
868
+ })
869
+ yield* store.upsertSchedule({ ...base, key: JobStore.ScheduleKey("TestJob/plain") })
870
+
871
+ const labeled = yield* store.listSchedules({ group: "svc-a" })
872
+ expect(labeled.map((schedule) => schedule.key)).toEqual(["TestJob/labeled"])
873
+ expect(labeled[0]?.group).toBe("svc-a")
874
+
875
+ const all = yield* store.listSchedules()
876
+ expect(all).toHaveLength(3)
877
+ expect(all.find((schedule) => schedule.key === "TestJob/plain")?.group).toBeUndefined()
878
+
879
+ // Re-upsert relabels; an unchanged cadence still keeps its next
880
+ // occurrence (the reconciler re-registers on every startup).
881
+ yield* store.upsertSchedule({
882
+ ...base,
883
+ key: JobStore.ScheduleKey("TestJob/labeled"),
884
+ group: "svc-c",
885
+ nextRunAt: 999_999
886
+ })
887
+ const relabeled = yield* store.listSchedules({ group: "svc-c" })
888
+ expect(relabeled.map((schedule) => schedule.key)).toEqual(["TestJob/labeled"])
889
+ expect(relabeled[0]?.nextRunAt).toBe(60_000)
890
+ expect(yield* store.listSchedules({ group: "svc-a" })).toEqual([])
891
+ })
892
+ ))
893
+
853
894
  it.effect("tickSchedule fires a slot exactly once and advances atomically", () =>
854
895
  withStore((store) =>
855
896
  Effect.gen(function*() {
@@ -1226,6 +1267,7 @@ export const jobStoreConformance = (
1226
1267
  backoff: undefined,
1227
1268
  keep: undefined,
1228
1269
  timeoutMs: undefined,
1270
+ group: undefined,
1229
1271
  nextRunAt: 60_000
1230
1272
  }
1231
1273
  yield* store.upsertSchedule(base)
@@ -1261,6 +1303,7 @@ export const jobStoreConformance = (
1261
1303
  backoff: undefined,
1262
1304
  keep: undefined,
1263
1305
  timeoutMs: undefined,
1306
+ group: undefined,
1264
1307
  nextRunAt: 90_000
1265
1308
  }
1266
1309
  const cronSchedule: JobStore.ScheduleRecord = {
@@ -1352,6 +1395,7 @@ export const jobStoreConformance = (
1352
1395
  backoff: undefined,
1353
1396
  keep: undefined,
1354
1397
  timeoutMs: undefined,
1398
+ group: undefined,
1355
1399
  nextRunAt: 60_000
1356
1400
  }
1357
1401
  yield* store.upsertSchedule(schedule)