@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.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- 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'
|