@stravigor/core 0.1.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 (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. package/tsconfig.json +4 -0
@@ -0,0 +1,338 @@
1
+ import { inject } from '../core/inject.ts'
2
+ import Configuration from '../config/configuration.ts'
3
+ import Database from '../database/database.ts'
4
+ import { ConfigurationError } from '../exceptions/errors.ts'
5
+
6
+ export interface JobOptions {
7
+ queue?: string
8
+ delay?: number
9
+ attempts?: number
10
+ timeout?: number
11
+ }
12
+
13
+ export interface QueueConfig {
14
+ default: string
15
+ maxAttempts: number
16
+ timeout: number
17
+ retryBackoff: 'exponential' | 'linear'
18
+ sleep: number
19
+ }
20
+
21
+ /** Metadata passed to job handlers alongside the payload. */
22
+ export interface JobMeta {
23
+ id: number
24
+ queue: string
25
+ job: string
26
+ attempts: number
27
+ maxAttempts: number
28
+ }
29
+
30
+ /** A raw job row from the _strav_jobs table. */
31
+ export interface JobRecord {
32
+ id: number
33
+ queue: string
34
+ job: string
35
+ payload: unknown
36
+ attempts: number
37
+ maxAttempts: number
38
+ timeout: number
39
+ availableAt: Date
40
+ reservedAt: Date | null
41
+ createdAt: Date
42
+ }
43
+
44
+ /** A raw row from the _strav_failed_jobs table. */
45
+ export interface FailedJobRecord {
46
+ id: number
47
+ queue: string
48
+ job: string
49
+ payload: unknown
50
+ error: string
51
+ failedAt: Date
52
+ }
53
+
54
+ export type JobHandler<T = any> = (payload: T, meta: JobMeta) => void | Promise<void>
55
+
56
+ /**
57
+ * PostgreSQL-backed job queue.
58
+ *
59
+ * Resolved once via the DI container — stores the database reference
60
+ * and parsed config for Worker and all static methods.
61
+ *
62
+ * @example
63
+ * app.singleton(Queue)
64
+ * app.resolve(Queue)
65
+ * await Queue.ensureTables()
66
+ *
67
+ * Queue.handle('send-email', async (payload) => { ... })
68
+ * await Queue.push('send-email', { to: 'user@example.com' })
69
+ */
70
+ @inject
71
+ export default class Queue {
72
+ private static _db: Database
73
+ private static _config: QueueConfig
74
+ private static _handlers = new Map<string, JobHandler>()
75
+
76
+ constructor(db: Database, config: Configuration) {
77
+ Queue._db = db
78
+ Queue._config = {
79
+ default: config.get('queue.default', 'default') as string,
80
+ maxAttempts: config.get('queue.maxAttempts', 3) as number,
81
+ timeout: config.get('queue.timeout', 60_000) as number,
82
+ retryBackoff: config.get('queue.retryBackoff', 'exponential') as 'exponential' | 'linear',
83
+ sleep: config.get('queue.sleep', 1000) as number,
84
+ }
85
+ }
86
+
87
+ static get db(): Database {
88
+ if (!Queue._db)
89
+ throw new ConfigurationError('Queue not configured. Resolve Queue through the container first.')
90
+ return Queue._db
91
+ }
92
+
93
+ static get config(): QueueConfig {
94
+ return Queue._config
95
+ }
96
+
97
+ static get handlers(): Map<string, JobHandler> {
98
+ return Queue._handlers
99
+ }
100
+
101
+ /** Create the internal jobs and failed_jobs tables if they don't exist. */
102
+ static async ensureTables(): Promise<void> {
103
+ const sql = Queue.db.sql
104
+
105
+ await sql`
106
+ CREATE TABLE IF NOT EXISTS "_strav_jobs" (
107
+ "id" BIGSERIAL PRIMARY KEY,
108
+ "queue" VARCHAR(255) NOT NULL DEFAULT 'default',
109
+ "job" VARCHAR(255) NOT NULL,
110
+ "payload" JSONB NOT NULL DEFAULT '{}',
111
+ "attempts" INT NOT NULL DEFAULT 0,
112
+ "max_attempts" INT NOT NULL DEFAULT 3,
113
+ "timeout" INT NOT NULL DEFAULT 60000,
114
+ "available_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
115
+ "reserved_at" TIMESTAMPTZ,
116
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
117
+ )
118
+ `
119
+
120
+ await sql`
121
+ CREATE INDEX IF NOT EXISTS "idx_strav_jobs_queue_available"
122
+ ON "_strav_jobs" ("queue", "available_at")
123
+ WHERE "reserved_at" IS NULL
124
+ `
125
+
126
+ await sql`
127
+ CREATE TABLE IF NOT EXISTS "_strav_failed_jobs" (
128
+ "id" BIGSERIAL PRIMARY KEY,
129
+ "queue" VARCHAR(255) NOT NULL,
130
+ "job" VARCHAR(255) NOT NULL,
131
+ "payload" JSONB NOT NULL DEFAULT '{}',
132
+ "error" TEXT NOT NULL,
133
+ "failed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
134
+ )
135
+ `
136
+ }
137
+
138
+ /** Register a handler for a named job. */
139
+ static handle<T = any>(name: string, handler: JobHandler<T>): void {
140
+ Queue._handlers.set(name, handler)
141
+ }
142
+
143
+ /**
144
+ * Push a job onto the queue. Returns the job ID.
145
+ */
146
+ static async push<T = any>(name: string, payload: T, options?: JobOptions): Promise<number> {
147
+ const sql = Queue.db.sql
148
+ const queue = options?.queue ?? Queue._config.default
149
+ const maxAttempts = options?.attempts ?? Queue._config.maxAttempts
150
+ const timeout = options?.timeout ?? Queue._config.timeout
151
+ const availableAt = options?.delay ? new Date(Date.now() + options.delay) : new Date()
152
+
153
+ const rows = await sql`
154
+ INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts", "timeout", "available_at")
155
+ VALUES (${queue}, ${name}, ${JSON.stringify(payload)}, ${maxAttempts}, ${timeout}, ${availableAt})
156
+ RETURNING "id"
157
+ `
158
+
159
+ return Number((rows[0] as Record<string, unknown>).id)
160
+ }
161
+
162
+ /**
163
+ * Create a listener function suitable for Emitter.on().
164
+ * When the event fires, the payload is pushed onto the queue.
165
+ *
166
+ * @example
167
+ * Emitter.on('user.registered', Queue.listener('send-welcome-email'))
168
+ */
169
+ static listener(jobName: string, options?: JobOptions): (payload: any) => Promise<void> {
170
+ return async (payload: any) => {
171
+ await Queue.push(jobName, payload, options)
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Introspection / Management
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** Count of pending (unreserved) jobs in a queue. */
180
+ static async size(queue?: string): Promise<number> {
181
+ const sql = Queue.db.sql
182
+ const q = queue ?? Queue._config.default
183
+
184
+ const rows = await sql`
185
+ SELECT COUNT(*)::int AS count FROM "_strav_jobs"
186
+ WHERE "queue" = ${q} AND "reserved_at" IS NULL
187
+ `
188
+ return (rows[0] as Record<string, unknown>).count as number
189
+ }
190
+
191
+ /** List pending jobs, most recent first. */
192
+ static async pending(queue?: string, limit = 25): Promise<JobRecord[]> {
193
+ const sql = Queue.db.sql
194
+ const q = queue ?? Queue._config.default
195
+
196
+ const rows = await sql`
197
+ SELECT * FROM "_strav_jobs"
198
+ WHERE "queue" = ${q}
199
+ ORDER BY "available_at" ASC
200
+ LIMIT ${limit}
201
+ `
202
+ return rows.map(hydrateJob)
203
+ }
204
+
205
+ /** List failed jobs, most recent first. */
206
+ static async failed(queue?: string, limit = 25): Promise<FailedJobRecord[]> {
207
+ const sql = Queue.db.sql
208
+
209
+ if (queue) {
210
+ const rows = await sql`
211
+ SELECT * FROM "_strav_failed_jobs"
212
+ WHERE "queue" = ${queue}
213
+ ORDER BY "failed_at" DESC
214
+ LIMIT ${limit}
215
+ `
216
+ return rows.map(hydrateFailedJob)
217
+ }
218
+
219
+ const rows = await sql`
220
+ SELECT * FROM "_strav_failed_jobs"
221
+ ORDER BY "failed_at" DESC
222
+ LIMIT ${limit}
223
+ `
224
+ return rows.map(hydrateFailedJob)
225
+ }
226
+
227
+ /** Delete all pending jobs in a queue. Returns the number deleted. */
228
+ static async clear(queue?: string): Promise<number> {
229
+ const sql = Queue.db.sql
230
+ const q = queue ?? Queue._config.default
231
+
232
+ const rows = await sql`
233
+ DELETE FROM "_strav_jobs" WHERE "queue" = ${q}
234
+ `
235
+ return rows.count
236
+ }
237
+
238
+ /** Move all failed jobs back to the jobs table. Returns the number retried. */
239
+ static async retryFailed(queue?: string): Promise<number> {
240
+ const sql = Queue.db.sql
241
+
242
+ if (queue) {
243
+ const failed = await sql`
244
+ DELETE FROM "_strav_failed_jobs" WHERE "queue" = ${queue} RETURNING *
245
+ `
246
+ for (const row of failed) {
247
+ const r = row as Record<string, unknown>
248
+ await sql`
249
+ INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts")
250
+ VALUES (${r.queue}, ${r.job}, ${typeof r.payload === 'string' ? r.payload : JSON.stringify(r.payload)}, ${Queue._config.maxAttempts})
251
+ `
252
+ }
253
+ return failed.length
254
+ }
255
+
256
+ const failed = await sql`
257
+ DELETE FROM "_strav_failed_jobs" RETURNING *
258
+ `
259
+ for (const row of failed) {
260
+ const r = row as Record<string, unknown>
261
+ await sql`
262
+ INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts")
263
+ VALUES (${r.queue}, ${r.job}, ${typeof r.payload === 'string' ? r.payload : JSON.stringify(r.payload)}, ${Queue._config.maxAttempts})
264
+ `
265
+ }
266
+ return failed.length
267
+ }
268
+
269
+ /** Delete all failed jobs for a queue (or all queues). Returns the number deleted. */
270
+ static async clearFailed(queue?: string): Promise<number> {
271
+ const sql = Queue.db.sql
272
+
273
+ if (queue) {
274
+ const rows = await sql`
275
+ DELETE FROM "_strav_failed_jobs" WHERE "queue" = ${queue}
276
+ `
277
+ return rows.count
278
+ }
279
+
280
+ const rows = await sql`
281
+ DELETE FROM "_strav_failed_jobs"
282
+ `
283
+ return rows.count
284
+ }
285
+
286
+ /** Delete all jobs and failed jobs across all queues. For dev/test only. */
287
+ static async flush(): Promise<void> {
288
+ const sql = Queue.db.sql
289
+ await sql`DELETE FROM "_strav_jobs"`
290
+ await sql`DELETE FROM "_strav_failed_jobs"`
291
+ }
292
+
293
+ /** Reset static state. For testing only. */
294
+ static reset(): void {
295
+ Queue._handlers.clear()
296
+ }
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Hydration helpers
301
+ // ---------------------------------------------------------------------------
302
+
303
+ function parsePayload(raw: unknown): unknown {
304
+ if (typeof raw === 'string') {
305
+ try {
306
+ return JSON.parse(raw)
307
+ } catch {
308
+ return raw
309
+ }
310
+ }
311
+ return raw
312
+ }
313
+
314
+ export function hydrateJob(row: Record<string, unknown>): JobRecord {
315
+ return {
316
+ id: Number(row.id),
317
+ queue: row.queue as string,
318
+ job: row.job as string,
319
+ payload: parsePayload(row.payload),
320
+ attempts: row.attempts as number,
321
+ maxAttempts: row.max_attempts as number,
322
+ timeout: row.timeout as number,
323
+ availableAt: row.available_at as Date,
324
+ reservedAt: (row.reserved_at as Date) ?? null,
325
+ createdAt: row.created_at as Date,
326
+ }
327
+ }
328
+
329
+ function hydrateFailedJob(row: Record<string, unknown>): FailedJobRecord {
330
+ return {
331
+ id: Number(row.id),
332
+ queue: row.queue as string,
333
+ job: row.job as string,
334
+ payload: parsePayload(row.payload),
335
+ error: row.error as string,
336
+ failedAt: row.failed_at as Date,
337
+ }
338
+ }
@@ -0,0 +1,197 @@
1
+ import Queue, { hydrateJob } from './queue.ts'
2
+ import type { JobRecord, JobMeta } from './queue.ts'
3
+
4
+ export interface WorkerOptions {
5
+ queue?: string
6
+ sleep?: number
7
+ }
8
+
9
+ /**
10
+ * Processes jobs from the queue.
11
+ *
12
+ * Uses `SELECT ... FOR UPDATE SKIP LOCKED` for safe concurrent polling.
13
+ * Supports job timeouts, exponential/linear backoff for retries,
14
+ * and graceful shutdown on SIGINT/SIGTERM.
15
+ *
16
+ * @example
17
+ * const worker = new Worker({ queue: 'emails', sleep: 500 })
18
+ * await worker.start() // blocks until worker.stop() is called
19
+ */
20
+ export default class Worker {
21
+ private running = false
22
+ private processing = false
23
+ private queue: string
24
+ private sleep: number
25
+
26
+ constructor(options: WorkerOptions = {}) {
27
+ this.queue = options.queue ?? Queue.config.default
28
+ this.sleep = options.sleep ?? Queue.config.sleep
29
+ }
30
+
31
+ /** Start the worker loop. Blocks until stop() is called. */
32
+ async start(): Promise<void> {
33
+ this.running = true
34
+
35
+ const onSignal = () => this.stop()
36
+ process.on('SIGINT', onSignal)
37
+ process.on('SIGTERM', onSignal)
38
+
39
+ let pollCount = 0
40
+
41
+ try {
42
+ while (this.running) {
43
+ // Periodically release stale jobs (every 60 cycles)
44
+ if (pollCount % 60 === 0) {
45
+ await this.releaseStaleJobs()
46
+ }
47
+ pollCount++
48
+
49
+ const job = await this.fetchNext()
50
+ if (job) {
51
+ this.processing = true
52
+ await this.process(job)
53
+ this.processing = false
54
+ } else {
55
+ await Bun.sleep(this.sleep)
56
+ }
57
+ }
58
+ } finally {
59
+ process.off('SIGINT', onSignal)
60
+ process.off('SIGTERM', onSignal)
61
+ }
62
+ }
63
+
64
+ /** Signal the worker to stop after the current job completes. */
65
+ stop(): void {
66
+ this.running = false
67
+ }
68
+
69
+ /** Whether the worker is currently processing a job. */
70
+ get busy(): boolean {
71
+ return this.processing
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Internal
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Fetch the next available job using FOR UPDATE SKIP LOCKED.
80
+ * Atomically reserves it by setting reserved_at and incrementing attempts.
81
+ */
82
+ private async fetchNext(): Promise<JobRecord | null> {
83
+ const sql = Queue.db.sql
84
+
85
+ const rows = await sql.begin(async (tx: any) => {
86
+ const result = await tx`
87
+ SELECT * FROM "_strav_jobs"
88
+ WHERE "queue" = ${this.queue}
89
+ AND "available_at" <= NOW()
90
+ AND "reserved_at" IS NULL
91
+ ORDER BY "available_at" ASC
92
+ LIMIT 1
93
+ FOR UPDATE SKIP LOCKED
94
+ `
95
+ if (result.length === 0) return []
96
+
97
+ const job = result[0] as Record<string, unknown>
98
+ await tx`
99
+ UPDATE "_strav_jobs"
100
+ SET "reserved_at" = NOW(), "attempts" = "attempts" + 1
101
+ WHERE "id" = ${job.id}
102
+ `
103
+ return [{ ...job, attempts: (job.attempts as number) + 1 }]
104
+ })
105
+
106
+ return rows.length > 0 ? hydrateJob(rows[0] as Record<string, unknown>) : null
107
+ }
108
+
109
+ /** Process a single job: run handler, handle success/failure. */
110
+ private async process(job: JobRecord): Promise<void> {
111
+ const handler = Queue.handlers.get(job.job)
112
+
113
+ if (!handler) {
114
+ await this.fail(job, new Error(`No handler registered for job "${job.job}"`))
115
+ return
116
+ }
117
+
118
+ const meta: JobMeta = {
119
+ id: job.id,
120
+ queue: job.queue,
121
+ job: job.job,
122
+ attempts: job.attempts,
123
+ maxAttempts: job.maxAttempts,
124
+ }
125
+
126
+ try {
127
+ await Promise.race([
128
+ Promise.resolve(handler(job.payload, meta)),
129
+ new Promise<never>((_, reject) =>
130
+ setTimeout(
131
+ () => reject(new Error(`Job "${job.job}" timed out after ${job.timeout}ms`)),
132
+ job.timeout
133
+ )
134
+ ),
135
+ ])
136
+ await this.complete(job)
137
+ } catch (error) {
138
+ const err = error instanceof Error ? error : new Error(String(error))
139
+ if (job.attempts >= job.maxAttempts) {
140
+ await this.fail(job, err)
141
+ } else {
142
+ await this.release(job)
143
+ }
144
+ }
145
+ }
146
+
147
+ /** Delete a completed job. */
148
+ private async complete(job: JobRecord): Promise<void> {
149
+ await Queue.db.sql`DELETE FROM "_strav_jobs" WHERE "id" = ${job.id}`
150
+ }
151
+
152
+ /** Move a job to the failed_jobs table and delete from jobs. */
153
+ private async fail(job: JobRecord, error: Error): Promise<void> {
154
+ const sql = Queue.db.sql
155
+ await sql.begin(async (tx: any) => {
156
+ await tx`
157
+ INSERT INTO "_strav_failed_jobs" ("queue", "job", "payload", "error")
158
+ VALUES (${job.queue}, ${job.job}, ${JSON.stringify(job.payload)}, ${error.message})
159
+ `
160
+ await tx`DELETE FROM "_strav_jobs" WHERE "id" = ${job.id}`
161
+ })
162
+ }
163
+
164
+ /** Release a job back to the queue with incremented backoff delay. */
165
+ private async release(job: JobRecord): Promise<void> {
166
+ const delay = this.backoffDelay(job.attempts)
167
+ const availableAt = new Date(Date.now() + delay)
168
+
169
+ await Queue.db.sql`
170
+ UPDATE "_strav_jobs"
171
+ SET "reserved_at" = NULL, "available_at" = ${availableAt}
172
+ WHERE "id" = ${job.id}
173
+ `
174
+ }
175
+
176
+ /** Calculate backoff delay in ms based on attempt number. */
177
+ backoffDelay(attempts: number): number {
178
+ if (Queue.config.retryBackoff === 'linear') {
179
+ return attempts * 5_000
180
+ }
181
+ // Exponential: 2^attempts * 1000, with jitter
182
+ const base = Math.pow(2, attempts) * 1000
183
+ const jitter = Math.random() * 1000
184
+ return base + jitter
185
+ }
186
+
187
+ /** Release jobs that have been reserved for too long (crashed workers). */
188
+ private async releaseStaleJobs(): Promise<void> {
189
+ await Queue.db.sql`
190
+ UPDATE "_strav_jobs"
191
+ SET "reserved_at" = NULL
192
+ WHERE "reserved_at" IS NOT NULL
193
+ AND "queue" = ${this.queue}
194
+ AND "reserved_at" < NOW() - MAKE_INTERVAL(secs => "timeout" * 2.0 / 1000)
195
+ `
196
+ }
197
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Minimal 5-field cron expression parser.
3
+ *
4
+ * Supports: `*`, exact (`5`), range (`1-5`), list (`1,3,5`),
5
+ * step (`*​/10`), and range+step (`1-30/5`).
6
+ *
7
+ * Fields: minute (0–59), hour (0–23), day-of-month (1–31),
8
+ * month (1–12), day-of-week (0–6, 0 = Sunday).
9
+ */
10
+
11
+ export interface CronExpression {
12
+ minute: number[]
13
+ hour: number[]
14
+ dayOfMonth: number[]
15
+ month: number[]
16
+ dayOfWeek: number[]
17
+ }
18
+
19
+ const FIELD_RANGES: [number, number][] = [
20
+ [0, 59], // minute
21
+ [0, 23], // hour
22
+ [1, 31], // day of month
23
+ [1, 12], // month
24
+ [0, 6], // day of week
25
+ ]
26
+
27
+ // Parse a 5-field cron string into expanded numeric arrays.
28
+ export function parseCron(expression: string): CronExpression {
29
+ const parts = expression.trim().split(/\s+/)
30
+ if (parts.length !== 5) {
31
+ throw new Error(
32
+ `Invalid cron expression "${expression}": expected 5 fields, got ${parts.length}`
33
+ )
34
+ }
35
+
36
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts.map((part, i) =>
37
+ parseField(part, FIELD_RANGES[i]![0], FIELD_RANGES[i]![1])
38
+ )
39
+
40
+ return { minute: minute!, hour: hour!, dayOfMonth: dayOfMonth!, month: month!, dayOfWeek: dayOfWeek! }
41
+ }
42
+
43
+ /**
44
+ * Check if a Date matches a parsed cron expression.
45
+ *
46
+ * Standard cron rule: if both day-of-month and day-of-week are restricted
47
+ * (not wildcards), either match satisfies the condition.
48
+ */
49
+ export function cronMatches(cron: CronExpression, date: Date): boolean {
50
+ const minute = date.getUTCMinutes()
51
+ const hour = date.getUTCHours()
52
+ const dayOfMonth = date.getUTCDate()
53
+ const month = date.getUTCMonth() + 1
54
+ const dayOfWeek = date.getUTCDay()
55
+
56
+ if (!cron.minute.includes(minute)) return false
57
+ if (!cron.hour.includes(hour)) return false
58
+ if (!cron.month.includes(month)) return false
59
+
60
+ // Standard cron: day-of-month and day-of-week are OR'd when both are restricted
61
+ const domRestricted = cron.dayOfMonth.length < 31
62
+ const dowRestricted = cron.dayOfWeek.length < 7
63
+
64
+ if (domRestricted && dowRestricted) {
65
+ return cron.dayOfMonth.includes(dayOfMonth) || cron.dayOfWeek.includes(dayOfWeek)
66
+ }
67
+
68
+ if (!cron.dayOfMonth.includes(dayOfMonth)) return false
69
+ if (!cron.dayOfWeek.includes(dayOfWeek)) return false
70
+
71
+ return true
72
+ }
73
+
74
+ /**
75
+ * Compute the next Date (UTC) after `after` that matches the expression.
76
+ * Searches up to 2 years ahead to prevent infinite loops.
77
+ */
78
+ export function nextCronDate(cron: CronExpression, after: Date): Date {
79
+ const date = new Date(after.getTime())
80
+ // Advance to the next whole minute
81
+ date.setUTCSeconds(0, 0)
82
+ date.setUTCMinutes(date.getUTCMinutes() + 1)
83
+
84
+ const limit = after.getTime() + 2 * 365 * 24 * 60 * 60 * 1000
85
+
86
+ while (date.getTime() <= limit) {
87
+ if (cronMatches(cron, date)) return date
88
+ date.setUTCMinutes(date.getUTCMinutes() + 1)
89
+ }
90
+
91
+ throw new Error('Could not find next matching date within 2 years')
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Field parsing
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function parseField(field: string, min: number, max: number): number[] {
99
+ const values = new Set<number>()
100
+
101
+ for (const part of field.split(',')) {
102
+ const stepMatch = part.match(/^(.+)\/(\d+)$/)
103
+
104
+ if (stepMatch) {
105
+ const [, rangePart, stepStr] = stepMatch
106
+ const step = parseInt(stepStr!, 10)
107
+ if (step <= 0) throw new Error(`Invalid step value: ${stepStr}`)
108
+
109
+ const [start, end] = rangePart === '*' ? [min, max] : parseRange(rangePart!, min, max)
110
+ for (let i = start; i <= end; i += step) {
111
+ values.add(i)
112
+ }
113
+ } else if (part === '*') {
114
+ for (let i = min; i <= max; i++) values.add(i)
115
+ } else if (part.includes('-')) {
116
+ const [start, end] = parseRange(part, min, max)
117
+ for (let i = start; i <= end; i++) values.add(i)
118
+ } else {
119
+ const n = parseInt(part, 10)
120
+ if (isNaN(n) || n < min || n > max) {
121
+ throw new Error(`Invalid cron value "${part}": must be ${min}–${max}`)
122
+ }
123
+ values.add(n)
124
+ }
125
+ }
126
+
127
+ return [...values].sort((a, b) => a - b)
128
+ }
129
+
130
+ function parseRange(part: string, min: number, max: number): [number, number] {
131
+ const [startStr, endStr] = part.split('-')
132
+ const start = parseInt(startStr!, 10)
133
+ const end = parseInt(endStr!, 10)
134
+
135
+ if (isNaN(start) || isNaN(end) || start < min || end > max || start > end) {
136
+ throw new Error(`Invalid cron range "${part}": must be within ${min}–${max}`)
137
+ }
138
+
139
+ return [start, end]
140
+ }
@@ -0,0 +1,7 @@
1
+ export { default as Scheduler } from './scheduler.ts'
2
+ export { Schedule } from './schedule.ts'
3
+ export { default as SchedulerRunner } from './runner.ts'
4
+ export { parseCron, cronMatches, nextCronDate } from './cron.ts'
5
+ export type { CronExpression } from './cron.ts'
6
+ export type { TaskHandler } from './schedule.ts'
7
+ export type { RunnerOptions } from './runner.ts'