@chidchanun/bcp 0.2.8 → 0.2.9

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.
@@ -0,0 +1,357 @@
1
+ # Job Scheduling Platform
2
+
3
+ BCP Framework `0.2.9` extends the Background Jobs Platform with recurring job schedules, dependency-free UTC cron expressions, interval schedules and a schedule-store lease contract for multi-instance deployments.
4
+
5
+ Import scheduling APIs from the server-only `bcp/jobs` entrypoint:
6
+
7
+ ```ts
8
+ import {
9
+ createJobQueue,
10
+ createJobScheduler,
11
+ } from "bcp/jobs";
12
+ ```
13
+
14
+ ## Create a scheduler
15
+
16
+ A scheduler enqueues work into an existing BCP background job queue:
17
+
18
+ ```ts
19
+ import {
20
+ createJobQueue,
21
+ createJobScheduler,
22
+ } from "bcp/jobs";
23
+
24
+ export const jobs =
25
+ createJobQueue();
26
+
27
+ export const scheduler =
28
+ createJobScheduler({
29
+ queue: jobs,
30
+ });
31
+ ```
32
+
33
+ The scheduler does not execute application work itself. It creates normal queue jobs when a schedule becomes due; queue workers execute the registered job handlers.
34
+
35
+ ## Interval schedules
36
+
37
+ Schedule a recurring job every five minutes:
38
+
39
+ ```ts
40
+ await scheduler.schedule(
41
+ "cache.cleanup",
42
+ {
43
+ scope: "expired",
44
+ },
45
+ {
46
+ id: "cache-cleanup",
47
+ everyMs: 5 * 60 * 1000,
48
+ }
49
+ );
50
+ ```
51
+
52
+ `everyMs` must be a positive integer.
53
+
54
+ By default the first run occurs one interval after the schedule is created.
55
+
56
+ Use `startAt` to select the first due time explicitly:
57
+
58
+ ```ts
59
+ await scheduler.schedule(
60
+ "report.daily",
61
+ {},
62
+ {
63
+ everyMs:
64
+ 24 * 60 * 60 * 1000,
65
+ startAt:
66
+ Date.now() + 10_000,
67
+ }
68
+ );
69
+ ```
70
+
71
+ `startAt` accepts either a millisecond timestamp or a `Date`.
72
+
73
+ ## Cron schedules
74
+
75
+ BCP `0.2.9` supports dependency-free five-field cron expressions:
76
+
77
+ ```text
78
+ minute hour day-of-month month day-of-week
79
+ ```
80
+
81
+ Example — every weekday at 09:30 UTC:
82
+
83
+ ```ts
84
+ await scheduler.schedule(
85
+ "report.weekday",
86
+ {},
87
+ {
88
+ cron: "30 9 * * 1-5",
89
+ }
90
+ );
91
+ ```
92
+
93
+ Example — every 15 minutes:
94
+
95
+ ```ts
96
+ await scheduler.schedule(
97
+ "sync.incremental",
98
+ {},
99
+ {
100
+ cron: "*/15 * * * *",
101
+ }
102
+ );
103
+ ```
104
+
105
+ Supported field syntax:
106
+
107
+ ```text
108
+ * every allowed value
109
+ */n step
110
+ 1,5,10 list
111
+ 1-5 range
112
+ 1-10/2 range with step
113
+ ```
114
+
115
+ Ranges:
116
+
117
+ ```text
118
+ minute 0-59
119
+ hour 0-23
120
+ day-of-month 1-31
121
+ month 1-12
122
+ day-of-week 0-7 (0 and 7 are Sunday)
123
+ ```
124
+
125
+ Cron evaluation is **UTC** in `0.2.9`. This avoids machine-local timezone differences between containers and servers.
126
+
127
+ When both day-of-month and day-of-week are restricted, BCP follows conventional cron OR semantics: either field may match.
128
+
129
+ Use `nextCronTime()` when application tooling needs to preview the next UTC occurrence:
130
+
131
+ ```ts
132
+ import {
133
+ nextCronTime,
134
+ } from "bcp/jobs";
135
+
136
+ const next =
137
+ nextCronTime(
138
+ "0 2 * * *",
139
+ Date.now()
140
+ );
141
+ ```
142
+
143
+ ## Run due schedules manually
144
+
145
+ `runDue()` is useful for deterministic tests or externally driven schedulers:
146
+
147
+ ```ts
148
+ const count =
149
+ await scheduler.runDue();
150
+ ```
151
+
152
+ The return value is the number of newly enqueued jobs.
153
+
154
+ Options:
155
+
156
+ ```ts
157
+ await scheduler.runDue({
158
+ limit: 100,
159
+ leaseMs: 30_000,
160
+ });
161
+ ```
162
+
163
+ Each call claims at most one occurrence per due schedule. If an application was offline for many intervals, later calls may progressively catch up.
164
+
165
+ ## Start a scheduler runner
166
+
167
+ For a long-running Node.js process:
168
+
169
+ ```ts
170
+ const runner =
171
+ scheduler.start({
172
+ pollIntervalMs: 1_000,
173
+ leaseMs: 30_000,
174
+ limit: 100,
175
+ onError(error) {
176
+ console.error(
177
+ "scheduler failure",
178
+ error
179
+ );
180
+ },
181
+ });
182
+ ```
183
+
184
+ Graceful shutdown:
185
+
186
+ ```ts
187
+ await runner.stop();
188
+ await scheduler.close();
189
+ await jobs.close();
190
+ ```
191
+
192
+ Stopping a scheduler does not close the queue automatically because applications may run queue workers independently.
193
+
194
+ ## Schedule records
195
+
196
+ `JobScheduleRecord` includes:
197
+
198
+ ```text
199
+ id
200
+ jobName
201
+ payload
202
+ schedule
203
+ createdAt
204
+ updatedAt
205
+ nextRunAt
206
+ lastRunAt
207
+ maxAttempts
208
+ leaseOwner
209
+ leaseUntil
210
+ ```
211
+
212
+ Inspect schedules:
213
+
214
+ ```ts
215
+ const schedules =
216
+ await scheduler.list();
217
+
218
+ const schedule =
219
+ await scheduler.get(
220
+ "cache-cleanup"
221
+ );
222
+ ```
223
+
224
+ Remove one:
225
+
226
+ ```ts
227
+ await scheduler.remove(
228
+ "cache-cleanup"
229
+ );
230
+ ```
231
+
232
+ Calling `schedule()` with an existing schedule id is an upsert. Use stable ids for application-owned recurring schedules.
233
+
234
+ ## Retry policy
235
+
236
+ A schedule may forward a queue retry limit:
237
+
238
+ ```ts
239
+ await scheduler.schedule(
240
+ "billing.reconcile",
241
+ {},
242
+ {
243
+ cron: "0 * * * *",
244
+ maxAttempts: 5,
245
+ }
246
+ );
247
+ ```
248
+
249
+ Retry timing is still owned by `createJobQueue()`:
250
+
251
+ ```ts
252
+ const jobs =
253
+ createJobQueue({
254
+ retryDelayMs: attempt =>
255
+ attempt * 5_000,
256
+ });
257
+ ```
258
+
259
+ The scheduler determines **when a recurring occurrence is created**. The queue determines **how that occurrence is retried after handler failure**.
260
+
261
+ ## Deterministic scheduled run IDs
262
+
263
+ Scheduled occurrences receive deterministic queue ids:
264
+
265
+ ```text
266
+ schedule:<schedule-id>:<scheduled-for-timestamp>
267
+ ```
268
+
269
+ This provides an additional duplicate-enqueue defense if a scheduler retries the same occurrence.
270
+
271
+ Applications should avoid manually creating queue job ids with the `schedule:` prefix.
272
+
273
+ ## Schedule store contract
274
+
275
+ The built-in schedule store is process-local:
276
+
277
+ ```ts
278
+ import {
279
+ createMemoryJobScheduleStore,
280
+ } from "bcp/jobs";
281
+ ```
282
+
283
+ It is useful for development, tests and single-process applications.
284
+
285
+ Production deployments that run multiple scheduler processes or must preserve schedules across restarts should implement `JobScheduleStore` using shared durable infrastructure.
286
+
287
+ Required operations:
288
+
289
+ ```text
290
+ upsert
291
+ get
292
+ list
293
+ remove
294
+ acquireDue
295
+ complete
296
+ release
297
+ close (optional)
298
+ ```
299
+
300
+ `acquireDue()` is the critical concurrency boundary. A durable implementation should atomically claim due schedules and assign a lease owner/expiry so two scheduler instances do not both own the same occurrence.
301
+
302
+ Conceptually:
303
+
304
+ ```text
305
+ scheduler A ─┐
306
+ ├─ shared JobScheduleStore ── atomic acquireDue()
307
+ scheduler B ─┘
308
+
309
+
310
+ shared/durable queue
311
+ ```
312
+
313
+ Examples of possible application adapters include PostgreSQL, Redis, DynamoDB or another transactional/atomic data store. `0.2.9` intentionally does not force one provider.
314
+
315
+ ## Lease behavior
316
+
317
+ A scheduler runner identifies itself with `ownerId`:
318
+
319
+ ```ts
320
+ const scheduler =
321
+ createJobScheduler({
322
+ queue: jobs,
323
+ store,
324
+ ownerId:
325
+ process.env.INSTANCE_ID,
326
+ });
327
+ ```
328
+
329
+ If `ownerId` is omitted, BCP generates a unique process-local scheduler id.
330
+
331
+ A due schedule is leased for `leaseMs`. The store releases the lease after the occurrence is committed. If a scheduler dies while holding a lease, another instance may reclaim it after lease expiry.
332
+
333
+ For durable adapters, choose a lease duration longer than the expected enqueue/store round trip but short enough for acceptable recovery.
334
+
335
+ ## Queue durability remains separate
336
+
337
+ A durable schedule store does not make the queue durable by itself.
338
+
339
+ For multi-instance production you will normally need both:
340
+
341
+ ```text
342
+ shared JobScheduleStore
343
+ +
344
+ shared JobQueueAdapter
345
+ ```
346
+
347
+ The built-in memory schedule store and memory queue are intentionally process-local.
348
+
349
+ ## Server-only boundary
350
+
351
+ `bcp/jobs` is server-only. Do not import it from page components, client islands or other modules reachable from browser bundles.
352
+
353
+ Use job/scheduler calls from server routes, loaders/actions, server startup modules or dedicated worker processes.
354
+
355
+ ## Compatibility
356
+
357
+ `0.2.9` does not intentionally break `0.2.8` Background Jobs APIs. Existing queue/worker code remains valid; scheduling is additive.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "framework": "bcp",
4
- "version": "0.2.8",
4
+ "version": "0.2.9",
5
5
  "releaseState": "unreleased",
6
- "baseline": "background-jobs-platform",
6
+ "baseline": "job-scheduling-platform",
7
7
  "runtime": {
8
8
  "node": ">=24.11.0",
9
9
  "react": "19",
@@ -73,6 +73,13 @@
73
73
  "jobRetries": true,
74
74
  "jobWorkerConcurrency": true,
75
75
  "jobCancellation": true,
76
+ "jobSchedulingPlatform": true,
77
+ "recurringJobs": true,
78
+ "cronScheduling": true,
79
+ "intervalScheduling": true,
80
+ "jobScheduleStoreContract": true,
81
+ "schedulerLeases": true,
82
+ "scheduledRunDeduplication": true,
76
83
  "databaseMigrations": true,
77
84
  "databaseAdapterContract": true,
78
85
  "databasePostgresql": true,
@@ -112,7 +119,7 @@
112
119
  "s3-compatible"
113
120
  ],
114
121
  "compatibility": {
115
- "previousBaseline": "0.2.7",
122
+ "previousBaseline": "0.2.8",
116
123
  "intentionalBreakingChangesFromPreviousBaseline": false,
117
124
  "migrationGuide": "migration-0.2.md"
118
125
  },
@@ -130,7 +137,8 @@
130
137
  "authorizationSecurity": "authorization-security.md",
131
138
  "observability": "observability.md",
132
139
  "backgroundJobs": "background-jobs.md",
140
+ "jobScheduling": "job-scheduling.md",
133
141
  "migrationGuide": "migration-0.2.md",
134
- "releaseNotes": "releases/0.2.8.md"
142
+ "releaseNotes": "releases/0.2.9.md"
135
143
  }
136
144
  }
@@ -0,0 +1,162 @@
1
+ # BCP Framework 0.2.9 — Job Scheduling Platform
2
+
3
+ > Release state: unreleased until the complete RC workflow passes, the release commit is tagged `v0.2.9`, and npm publication completes.
4
+
5
+ BCP Framework `0.2.9` extends the `0.2.8` Background Jobs Platform with recurring schedules, dependency-free UTC cron expressions and a lease-aware schedule-store contract.
6
+
7
+ ## Highlights
8
+
9
+ - Added `createJobScheduler()` through `bcp/jobs`.
10
+ - Added interval-based recurring jobs with `everyMs`.
11
+ - Added five-field UTC cron expressions.
12
+ - Added `nextCronTime()` and `nextScheduleTime()` helpers.
13
+ - Added `JobScheduleStore` for durable/shared scheduler state.
14
+ - Added `createMemoryJobScheduleStore()` for local development and tests.
15
+ - Added atomic-style `acquireDue()` lease semantics to the schedule-store contract.
16
+ - Added deterministic scheduled queue ids to reduce duplicate enqueue risk.
17
+ - Added scheduler polling lifecycle with `start()`, `stop()` and `close()`.
18
+ - Added schedule inspection, removal and stable-id upsert behavior.
19
+ - Added unit, package-smoke and platform-contract coverage.
20
+
21
+ ## Scheduling API
22
+
23
+ ```ts
24
+ import {
25
+ createJobQueue,
26
+ createJobScheduler,
27
+ } from "bcp/jobs";
28
+
29
+ const jobs =
30
+ createJobQueue();
31
+
32
+ const scheduler =
33
+ createJobScheduler({
34
+ queue: jobs,
35
+ });
36
+ ```
37
+
38
+ Interval:
39
+
40
+ ```ts
41
+ await scheduler.schedule(
42
+ "cache.cleanup",
43
+ {},
44
+ {
45
+ id: "cache-cleanup",
46
+ everyMs: 300_000,
47
+ }
48
+ );
49
+ ```
50
+
51
+ Cron:
52
+
53
+ ```ts
54
+ await scheduler.schedule(
55
+ "report.weekday",
56
+ {},
57
+ {
58
+ cron: "30 9 * * 1-5",
59
+ }
60
+ );
61
+ ```
62
+
63
+ Cron evaluation is UTC in `0.2.9`.
64
+
65
+ ## Cron syntax
66
+
67
+ Five fields are supported:
68
+
69
+ ```text
70
+ minute hour day-of-month month day-of-week
71
+ ```
72
+
73
+ Supported tokens:
74
+
75
+ ```text
76
+ *
77
+ */n
78
+ comma-separated values
79
+ ranges
80
+ range steps
81
+ ```
82
+
83
+ Day of week accepts `0` and `7` for Sunday. When both day-of-month and day-of-week are restricted, standard cron OR semantics are used.
84
+
85
+ ## Scheduler lifecycle
86
+
87
+ Long-running scheduler:
88
+
89
+ ```ts
90
+ const runner =
91
+ scheduler.start({
92
+ pollIntervalMs: 1_000,
93
+ leaseMs: 30_000,
94
+ });
95
+
96
+ // shutdown
97
+ await runner.stop();
98
+ await scheduler.close();
99
+ ```
100
+
101
+ `runDue()` is also available for tests, external control loops and deterministic execution.
102
+
103
+ ## Multi-instance contract
104
+
105
+ `JobScheduleStore.acquireDue()` is the scheduler concurrency boundary. Durable implementations should atomically claim due schedules with a lease owner and expiry.
106
+
107
+ The default memory store is process-local and is not suitable for schedules that must survive restarts or coordinate across multiple containers.
108
+
109
+ Production multi-instance systems will normally pair:
110
+
111
+ ```text
112
+ shared JobScheduleStore
113
+ +
114
+ shared JobQueueAdapter
115
+ ```
116
+
117
+ `0.2.9` intentionally leaves provider choice to the application instead of forcing Redis, PostgreSQL or another queue/scheduler backend.
118
+
119
+ ## Duplicate protection
120
+
121
+ Each scheduled occurrence uses a deterministic queue id:
122
+
123
+ ```text
124
+ schedule:<schedule-id>:<scheduled-for-timestamp>
125
+ ```
126
+
127
+ This complements store leasing and protects the built-in queue from enqueuing the same occurrence twice if the same schedule occurrence is retried.
128
+
129
+ ## Compatibility
130
+
131
+ This milestone is additive. There are no intentional breaking changes from `0.2.8`.
132
+
133
+ Existing `createJobQueue()`, handlers, delayed jobs, retries, workers and adapters continue to work without scheduling enabled.
134
+
135
+ ## Documentation
136
+
137
+ New guide:
138
+
139
+ ```text
140
+ docs/job-scheduling.md
141
+ ```
142
+
143
+ Background jobs remain documented in:
144
+
145
+ ```text
146
+ docs/background-jobs.md
147
+ ```
148
+
149
+ ## Validation
150
+
151
+ Before tagging/publishing `0.2.9`, run:
152
+
153
+ ```bash
154
+ npm run typecheck
155
+ npm run test:unit
156
+ npm run test:integration
157
+ npm run test:e2e
158
+ npm run test:package
159
+ npm run rc:check
160
+ ```
161
+
162
+ The release tag must point to the exact commit that passed the complete RC sequence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,7 +65,7 @@
65
65
  "./jobs": {
66
66
  "types": "./packages/client/src/jobs.ts",
67
67
  "browser": "./packages/client/src/server-only.browser.mjs",
68
- "default": "./packages/client/src/jobs.ts"
68
+ "default": "./packages/client/src/jobs.mjs"
69
69
  },
70
70
  "./observability": {
71
71
  "types": "./packages/client/src/observability.ts",