velocious 1.0.606 → 1.0.607

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 (64) hide show
  1. package/README.md +44 -1
  2. package/build/background-jobs/job-semantics.js +145 -0
  3. package/build/background-jobs/local-adapter.js +161 -0
  4. package/build/background-jobs/local-dispatcher.js +418 -0
  5. package/build/background-jobs/local-job-registry.js +59 -0
  6. package/build/background-jobs/local-store.js +1219 -0
  7. package/build/background-jobs/store.js +33 -97
  8. package/build/background-jobs/types.js +26 -1
  9. package/build/configuration-types.js +3 -1
  10. package/build/configuration.js +16 -1
  11. package/build/database/drivers/sqlite/index.js +46 -41
  12. package/build/environment-handlers/browser.js +10 -0
  13. package/build/src/background-jobs/job-semantics.d.ts +65 -0
  14. package/build/src/background-jobs/job-semantics.d.ts.map +1 -0
  15. package/build/src/background-jobs/job-semantics.js +125 -0
  16. package/build/src/background-jobs/local-adapter.d.ts +161 -0
  17. package/build/src/background-jobs/local-adapter.d.ts.map +1 -0
  18. package/build/src/background-jobs/local-adapter.js +141 -0
  19. package/build/src/background-jobs/local-dispatcher.d.ts +185 -0
  20. package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -0
  21. package/build/src/background-jobs/local-dispatcher.js +383 -0
  22. package/build/src/background-jobs/local-job-registry.d.ts +26 -0
  23. package/build/src/background-jobs/local-job-registry.d.ts.map +1 -0
  24. package/build/src/background-jobs/local-job-registry.js +51 -0
  25. package/build/src/background-jobs/local-store.d.ts +364 -0
  26. package/build/src/background-jobs/local-store.d.ts.map +1 -0
  27. package/build/src/background-jobs/local-store.js +1078 -0
  28. package/build/src/background-jobs/store.d.ts +0 -9
  29. package/build/src/background-jobs/store.d.ts.map +1 -1
  30. package/build/src/background-jobs/store.js +18 -88
  31. package/build/src/background-jobs/types.d.ts +97 -2
  32. package/build/src/background-jobs/types.d.ts.map +1 -1
  33. package/build/src/background-jobs/types.js +27 -2
  34. package/build/src/configuration-types.d.ts +9 -2
  35. package/build/src/configuration-types.d.ts.map +1 -1
  36. package/build/src/configuration-types.js +4 -2
  37. package/build/src/configuration.d.ts +5 -0
  38. package/build/src/configuration.d.ts.map +1 -1
  39. package/build/src/configuration.js +15 -2
  40. package/build/src/database/drivers/sqlite/index.d.ts +8 -0
  41. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  42. package/build/src/database/drivers/sqlite/index.js +47 -41
  43. package/build/src/environment-handlers/browser.d.ts +9 -0
  44. package/build/src/environment-handlers/browser.d.ts.map +1 -1
  45. package/build/src/environment-handlers/browser.js +10 -1
  46. package/build/src/testing/test-runner.d.ts +42 -10
  47. package/build/src/testing/test-runner.d.ts.map +1 -1
  48. package/build/src/testing/test-runner.js +135 -35
  49. package/build/testing/test-runner.js +141 -33
  50. package/build/tsconfig.tsbuildinfo +1 -1
  51. package/package.json +1 -1
  52. package/scripts/test-browser.js +5 -1
  53. package/src/background-jobs/job-semantics.js +145 -0
  54. package/src/background-jobs/local-adapter.js +161 -0
  55. package/src/background-jobs/local-dispatcher.js +418 -0
  56. package/src/background-jobs/local-job-registry.js +59 -0
  57. package/src/background-jobs/local-store.js +1219 -0
  58. package/src/background-jobs/store.js +33 -97
  59. package/src/background-jobs/types.js +26 -1
  60. package/src/configuration-types.js +3 -1
  61. package/src/configuration.js +16 -1
  62. package/src/database/drivers/sqlite/index.js +46 -41
  63. package/src/environment-handlers/browser.js +10 -0
  64. package/src/testing/test-runner.js +141 -33
@@ -0,0 +1,418 @@
1
+ // @ts-check
2
+
3
+ import BackgroundJobRescheduleSignal from "./reschedule-signal.js"
4
+ import performBackgroundJob from "./perform-job.js"
5
+
6
+ /** @typedef {{type: "completed"} | {type: "failed", error: ReturnType<typeof JSON.parse>} | {type: "rescheduled", delayMs: number}} LocalBackgroundJobAcknowledgement */
7
+
8
+ /**
9
+ * @typedef {object} PendingLocalBackgroundJobAcknowledgement
10
+ * @property {LocalBackgroundJobAcknowledgement} acknowledgement - Durable transition still owned by this dispatcher.
11
+ * @property {import("./types.js").BackgroundJobHandoff} handoff - Fenced handoff being acknowledged.
12
+ * @property {import("./types.js").BackgroundJobRow} job - Claimed job snapshot.
13
+ */
14
+
15
+ const MAX_TIMER_MS = 2_147_483_647
16
+ const ERROR_RECOVERY_DELAY_MS = 1_000
17
+
18
+ /** Configuration-owned, event-driven in-process local dispatcher. */
19
+ export default class LocalBackgroundJobsDispatcher {
20
+ /**
21
+ * Creates a dispatcher owned by one configuration and local store.
22
+ * @param {object} args - Dispatcher options.
23
+ * @param {import("../configuration.js").default} args.configuration - Owning configuration.
24
+ * @param {import("./types.js").LocalBackgroundJobsClock} args.clock - Dispatcher clock.
25
+ * @param {import("./local-job-registry.js").default} args.registry - Static job registry.
26
+ * @param {import("./local-store.js").default} args.store - Durable local store.
27
+ */
28
+ constructor({configuration, clock, registry, store}) {
29
+ this.clock = clock
30
+ this.configuration = configuration
31
+ this.registry = registry
32
+ this.store = store
33
+ this._accepting = false
34
+ this._started = false
35
+ /** @type {Promise<void> | null} */
36
+ this._startPromise = null
37
+ /** @type {Promise<void> | null} */
38
+ this._drainPromise = null
39
+ this._redrain = false
40
+ this._wakeQueued = false
41
+ /** @type {Set<Promise<void>>} */
42
+ this._inFlight = new Set()
43
+ /** @type {Map<string, PendingLocalBackgroundJobAcknowledgement>} */
44
+ this._pendingAcknowledgements = new Map()
45
+ /** @type {Set<() => void>} */
46
+ this._idleWaiters = new Set()
47
+ /** @type {ReturnType<typeof setTimeout> | number | undefined} */
48
+ this._scheduledTimer = undefined
49
+ /** @type {ReturnType<typeof setTimeout> | number | undefined} */
50
+ this._recoveryTimer = undefined
51
+ }
52
+
53
+ /**
54
+ * Starts, recovers, and catches up the local dispatcher.
55
+ * @returns {Promise<void>} - Resolves after admission starts.
56
+ */
57
+ async start() {
58
+ if (this._started) return
59
+ if (this._startPromise) return await this._startPromise
60
+
61
+ this._startPromise = (async () => {
62
+ this.configuration.setCurrent()
63
+ this.registry.ensureReady()
64
+ await this.configuration.initialize({type: "local-background-jobs"})
65
+ await this.store.ensureReady()
66
+ await this.store.reconcileQueueConcurrency()
67
+
68
+ const recoveredJobs = await this.store.recoverHandedOffJobs()
69
+
70
+ for (const job of recoveredJobs) {
71
+ this._emitBackgroundJobFailed({
72
+ error: new Error(job.lastError || "Local background job recovered after an interrupted dispatcher"),
73
+ job
74
+ })
75
+ }
76
+
77
+ this._accepting = true
78
+ this._started = true
79
+ this.wake()
80
+ })()
81
+
82
+ try {
83
+ await this._startPromise
84
+ } catch (error) {
85
+ this._reportFrameworkError({error, stage: "local-background-jobs-start"})
86
+ throw error
87
+ } finally {
88
+ this._startPromise = null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Coalesces a dispatcher wake onto one tracked microtask.
94
+ * @returns {void} - No return value.
95
+ */
96
+ wake() {
97
+ if (!this._accepting) return
98
+
99
+ if (this._drainPromise || this._wakeQueued) {
100
+ this._redrain = true
101
+ return
102
+ }
103
+
104
+ this._wakeQueued = true
105
+ const drain = Promise.resolve().then(async () => {
106
+ this._wakeQueued = false
107
+ await this._drain()
108
+ })
109
+ const drainPromise = drain
110
+ .catch((error) => {
111
+ this._reportFrameworkError({error, stage: "local-background-jobs-drain"})
112
+ this._armRecoveryTimer()
113
+ })
114
+ .finally(() => {
115
+ if (this._drainPromise === drainPromise) this._drainPromise = null
116
+
117
+ if (this._redrain && this._accepting) {
118
+ this._redrain = false
119
+ this.wake()
120
+ } else {
121
+ this._resolveIdleWaiters()
122
+ }
123
+ })
124
+
125
+ this._drainPromise = drainPromise
126
+ }
127
+
128
+ /**
129
+ * Fills local capacity with short durable claims.
130
+ * @returns {Promise<void>} - Resolves after one stable drain pass.
131
+ */
132
+ async _drain() {
133
+ await this._retryPendingAcknowledgements()
134
+
135
+ while (this._accepting && this._ownedPerformanceCount() < this._maxConcurrentJobs()) {
136
+ const job = await this.store.nextAvailableJob()
137
+
138
+ if (!job) break
139
+
140
+ const handoff = await this.store.markHandedOff({jobId: job.id})
141
+
142
+ if (!handoff) {
143
+ this._redrain = true
144
+ break
145
+ }
146
+
147
+ this._startPerformance({handoff, job})
148
+ }
149
+
150
+ await this._armScheduledTimer()
151
+ }
152
+
153
+ /**
154
+ * Runs one claimed performance without retaining its claim transaction connection.
155
+ * @param {{handoff: import("./types.js").BackgroundJobHandoff, job: import("./types.js").BackgroundJobRow}} args - Claimed job.
156
+ * @returns {void} - No return value.
157
+ */
158
+ _startPerformance({handoff, job}) {
159
+ const performance = this._perform({handoff, job})
160
+
161
+ this._inFlight.add(performance)
162
+ void performance
163
+ .catch((error) => this._reportFrameworkError({error, stage: "local-background-jobs-performance"}))
164
+ .finally(() => {
165
+ this._inFlight.delete(performance)
166
+ if (this._accepting) this.wake()
167
+ this._resolveIdleWaiters()
168
+ })
169
+ }
170
+
171
+ /**
172
+ * Performs and acknowledges one durable handoff.
173
+ * @param {{handoff: import("./types.js").BackgroundJobHandoff, job: import("./types.js").BackgroundJobRow}} args - Claimed job.
174
+ * @returns {Promise<void>} - Resolves after acknowledgement.
175
+ */
176
+ async _perform({handoff, job}) {
177
+ /** @type {LocalBackgroundJobAcknowledgement} */
178
+ let acknowledgement = {type: "completed"}
179
+
180
+ try {
181
+ const JobClass = this.registry.resolve(job.jobName)
182
+
183
+ await performBackgroundJob({
184
+ configuration: this.configuration,
185
+ JobClass,
186
+ jobArgs: job.args,
187
+ name: `Local background job: ${job.jobName}`
188
+ })
189
+ } catch (error) {
190
+ if (error instanceof BackgroundJobRescheduleSignal) {
191
+ acknowledgement = {delayMs: error.delayMs, type: "rescheduled"}
192
+ } else {
193
+ acknowledgement = {error, type: "failed"}
194
+ }
195
+ }
196
+
197
+ try {
198
+ await this._acknowledge({acknowledgement, handoff, job})
199
+ } catch (error) {
200
+ this._pendingAcknowledgements.set(job.id, {acknowledgement, handoff, job})
201
+ this._reportAcknowledgementError({acknowledgement, error, handoff, job})
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Applies one fenced durable acknowledgement.
207
+ * @param {PendingLocalBackgroundJobAcknowledgement} args - Owned acknowledgement.
208
+ * @returns {Promise<void>} - Resolves after the durable transition is settled.
209
+ */
210
+ async _acknowledge({acknowledgement, handoff, job}) {
211
+ if (acknowledgement.type === "rescheduled") {
212
+ await this.store.markRescheduled({delayMs: acknowledgement.delayMs, handoffId: handoff.handoffId, jobId: job.id})
213
+ return
214
+ }
215
+
216
+ if (acknowledgement.type === "failed") {
217
+ const updatedJob = await this.store.markFailed({error: acknowledgement.error, handoffId: handoff.handoffId, jobId: job.id})
218
+
219
+ if (updatedJob) this._emitBackgroundJobFailed({error: acknowledgement.error, job: updatedJob})
220
+ return
221
+ }
222
+
223
+ await this.store.markCompleted({handoffId: handoff.handoffId, jobId: job.id})
224
+ }
225
+
226
+ /**
227
+ * Replays each retained acknowledgement once at an event-driven wake boundary.
228
+ * @param {{throwOnError?: boolean}} [args] - Recovery behavior.
229
+ * @returns {Promise<void>} - Resolves after one bounded recovery pass.
230
+ */
231
+ async _retryPendingAcknowledgements({throwOnError = false} = {}) {
232
+ for (const [jobId, pendingAcknowledgement] of [...this._pendingAcknowledgements]) {
233
+ try {
234
+ await this._acknowledge(pendingAcknowledgement)
235
+ this._pendingAcknowledgements.delete(jobId)
236
+ } catch (error) {
237
+ this._reportAcknowledgementError({...pendingAcknowledgement, error})
238
+ if (throwOnError) throw error
239
+ this._armRecoveryTimer()
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Arms the exact next future job timer, chunking platform-sized delays.
246
+ * @returns {Promise<void>} - Resolves after timer reconciliation.
247
+ */
248
+ async _armScheduledTimer() {
249
+ if (this._scheduledTimer !== undefined) {
250
+ this.clock.clearTimeout(this._scheduledTimer)
251
+ this._scheduledTimer = undefined
252
+ }
253
+ if (!this._accepting) return
254
+
255
+ const nextJob = await this.store.nextScheduledJob()
256
+
257
+ if (!nextJob || nextJob.scheduledAtMs === null) return
258
+
259
+ const delayMs = Math.max(0, Math.min(nextJob.scheduledAtMs - this.clock.now(), MAX_TIMER_MS))
260
+
261
+ this._scheduledTimer = this.clock.setTimeout(() => {
262
+ this._scheduledTimer = undefined
263
+ this.wake()
264
+ }, delayMs)
265
+ }
266
+
267
+ /**
268
+ * Arms one bounded retry after an unexpected drain failure.
269
+ * @returns {void} - No return value.
270
+ */
271
+ _armRecoveryTimer() {
272
+ if (!this._accepting || this._recoveryTimer !== undefined) return
273
+
274
+ this._recoveryTimer = this.clock.setTimeout(() => {
275
+ this._recoveryTimer = undefined
276
+ this.wake()
277
+ }, ERROR_RECOVERY_DELAY_MS)
278
+ }
279
+
280
+ /**
281
+ * Waits for admission and every in-flight acknowledgement without polling.
282
+ * @returns {Promise<void>} - Resolves when idle.
283
+ */
284
+ async waitForIdle() {
285
+ if (this._isIdle()) return
286
+
287
+ await new Promise((resolve) => this._idleWaiters.add(() => resolve(undefined)))
288
+ }
289
+
290
+ /**
291
+ * Stops claims and waits for in-flight acknowledgement.
292
+ * @returns {Promise<void>} - Resolves after a graceful stop.
293
+ */
294
+ async stop() {
295
+ this._accepting = false
296
+ this._redrain = false
297
+
298
+ if (this._scheduledTimer !== undefined) {
299
+ this.clock.clearTimeout(this._scheduledTimer)
300
+ this._scheduledTimer = undefined
301
+ }
302
+ if (this._recoveryTimer !== undefined) {
303
+ this.clock.clearTimeout(this._recoveryTimer)
304
+ this._recoveryTimer = undefined
305
+ }
306
+
307
+ if (this._drainPromise) await this._drainPromise
308
+ if (this._inFlight.size > 0) await Promise.all([...this._inFlight])
309
+ await this._retryPendingAcknowledgements({throwOnError: true})
310
+
311
+ this._wakeQueued = false
312
+ this._started = false
313
+ this._resolveIdleWaiters()
314
+ }
315
+
316
+ /**
317
+ * Reports whether dispatcher admission has started.
318
+ * @returns {boolean} - Whether dispatcher admission has started.
319
+ */
320
+ isReady() { return this._started && this._accepting }
321
+
322
+ /**
323
+ * Reads the configuration-owned in-process performance cap.
324
+ * @returns {number} - Configuration-owned in-process performance cap.
325
+ */
326
+ _maxConcurrentJobs() { return this.configuration.getBackgroundJobsConfig().maxConcurrentInlineJobs }
327
+
328
+ /**
329
+ * Counts performances whose durable acknowledgement is still owned locally.
330
+ * @returns {number} - Active or pending-acknowledgement performances.
331
+ */
332
+ _ownedPerformanceCount() { return this._inFlight.size + this._pendingAcknowledgements.size }
333
+
334
+ /**
335
+ * Reports whether no admission or acknowledgement work remains.
336
+ * @returns {boolean} - Whether the dispatcher is idle.
337
+ */
338
+ _isIdle() {
339
+ return !this._wakeQueued && !this._drainPromise && this._inFlight.size === 0 && this._pendingAcknowledgements.size === 0
340
+ }
341
+
342
+ /**
343
+ * Resolves event-based idle waiters at a stable idle boundary.
344
+ * @returns {void} - No return value.
345
+ */
346
+ _resolveIdleWaiters() {
347
+ if (!this._isIdle()) return
348
+
349
+ const waiters = [...this._idleWaiters]
350
+
351
+ this._idleWaiters.clear()
352
+ for (const resolve of waiters) resolve()
353
+ }
354
+
355
+ /**
356
+ * Emits an expected job failure through the standard job/all-error channels.
357
+ * @param {{error: ReturnType<typeof JSON.parse>, job: import("./types.js").BackgroundJobRow}} args - Failure transition.
358
+ * @returns {void} - No return value.
359
+ */
360
+ _emitBackgroundJobFailed({error, job}) {
361
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
362
+ const payload = {
363
+ context: {
364
+ attempts: job.attempts,
365
+ jobArgs: job.args,
366
+ jobId: job.id,
367
+ jobName: job.jobName,
368
+ maxRetries: job.maxRetries,
369
+ stage: "background-job-failed",
370
+ status: job.status,
371
+ terminal: job.status === "failed",
372
+ willRetry: job.status === "queued",
373
+ workerId: "local"
374
+ },
375
+ error: normalizedError
376
+ }
377
+ const errorEvents = this.configuration.getErrorEvents()
378
+
379
+ errorEvents.emit("background-job-failed", payload)
380
+ errorEvents.emit("all-error", {...payload, errorType: "background-job-failed"})
381
+ }
382
+
383
+ /**
384
+ * Reports one failed durable acknowledgement attempt with its fence context.
385
+ * @param {PendingLocalBackgroundJobAcknowledgement & {error: ReturnType<typeof JSON.parse>}} args - Failed acknowledgement.
386
+ * @returns {void} - No return value.
387
+ */
388
+ _reportAcknowledgementError({acknowledgement, error, handoff, job}) {
389
+ this._reportFrameworkError({
390
+ context: {
391
+ acknowledgementType: acknowledgement.type,
392
+ handoffId: handoff.handoffId,
393
+ jobId: job.id,
394
+ jobName: job.jobName,
395
+ workerId: "local"
396
+ },
397
+ error,
398
+ stage: "local-background-jobs-acknowledgement"
399
+ })
400
+ }
401
+
402
+ /**
403
+ * Reports an unexpected dispatcher failure through framework channels.
404
+ * @param {object} args - Unexpected failure.
405
+ * @param {Record<string, ReturnType<typeof JSON.parse>>} [args.context] - Additional failure context.
406
+ * @param {ReturnType<typeof JSON.parse>} args.error - Unexpected error.
407
+ * @param {string} args.stage - Dispatcher stage.
408
+ * @returns {void} - No return value.
409
+ */
410
+ _reportFrameworkError({context = {}, error, stage}) {
411
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
412
+ const payload = {context: {...context, stage}, error: normalizedError}
413
+ const errorEvents = this.configuration.getErrorEvents()
414
+
415
+ errorEvents.emit("framework-error", payload)
416
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
417
+ }
418
+ }
@@ -0,0 +1,59 @@
1
+ // @ts-check
2
+
3
+ import VelociousJob from "./platform-job.js"
4
+
5
+ /** Static, bundler-safe local background-job registry. */
6
+ export default class LocalBackgroundJobRegistry {
7
+ /**
8
+ * Creates a registry from the configuration's statically imported job classes.
9
+ * @param {{jobClasses: Array<typeof VelociousJob>}} args - Registry options.
10
+ */
11
+ constructor({jobClasses}) {
12
+ this.jobClasses = jobClasses
13
+ /** @type {Map<string, typeof VelociousJob> | undefined} */
14
+ this.jobsByName = undefined
15
+ }
16
+
17
+ /**
18
+ * Validates and indexes the configured job classes.
19
+ * @returns {void} - No return value.
20
+ */
21
+ ensureReady() {
22
+ if (this.jobsByName) return
23
+ if (!Array.isArray(this.jobClasses)) throw new TypeError("backgroundJobs.jobClasses must be an array")
24
+
25
+ const jobsByName = new Map()
26
+
27
+ for (const JobClass of this.jobClasses) {
28
+ if (typeof JobClass !== "function" || JobClass === VelociousJob || !(JobClass.prototype instanceof VelociousJob)) {
29
+ throw new TypeError("backgroundJobs.jobClasses must contain VelociousJob subclasses")
30
+ }
31
+
32
+ const jobName = JobClass.jobName()
33
+
34
+ if (typeof jobName !== "string" || jobName.trim().length === 0) {
35
+ throw new TypeError("backgroundJobs.jobClasses must declare non-empty job names")
36
+ }
37
+ if (jobsByName.has(jobName)) throw new Error(`Duplicate local background job name: ${jobName}`)
38
+
39
+ jobsByName.set(jobName, JobClass)
40
+ }
41
+
42
+ this.jobsByName = jobsByName
43
+ }
44
+
45
+ /**
46
+ * Resolves a registered class.
47
+ * @param {string} jobName - Persisted job name.
48
+ * @returns {typeof VelociousJob} - Registered class.
49
+ */
50
+ resolve(jobName) {
51
+ this.ensureReady()
52
+
53
+ const JobClass = this.jobsByName?.get(jobName)
54
+
55
+ if (!JobClass) throw new Error(`Local background job is not registered in backgroundJobs.jobClasses: ${jobName}`)
56
+
57
+ return JobClass
58
+ }
59
+ }