UNPKG

@timecrisis/timecrisis-sqlite

Version:

A SQLite storage adapter for the Time Crisis job scheduler

1,340 lines (1,337 loc) 41.6 kB
// src/adapter.ts import * as path from "path"; import { fileURLToPath } from "url"; import { randomUUID } from "crypto"; import { Migrator } from "sqlite-up"; import { CreateJobSchema, UpdateJobSchema, JobSchema, CreateJobRunSchema, JobRunSchema, CreateJobLogSchema, JobLogEntrySchema, CreateScheduledJobSchema, UpdateScheduledJobSchema, ScheduledJobSchema, CreateDeadLetterJobSchema, DeadLetterJobSchema, JobNotFoundError, JobRunNotFoundError, ScheduledJobNotFoundError, WorkerSchema, WorkerNotFoundError } from "@timecrisis/timecrisis"; // src/statements.ts var SQLiteStatements = { insertJob: ` INSERT INTO jobs ( id, type, status, data, priority, max_retries, backoff_strategy, fail_reason, fail_count, reference_id, scheduled_job_id, expires_at, started_at, run_at, finished_at, created_at, updated_at ) VALUES ( @id, @type, @status, @data, @priority, @max_retries, @backoff_strategy, @fail_reason, @fail_count, @reference_id, @scheduled_job_id, @expires_at, @started_at, @run_at, @finished_at, @created_at, @updated_at ) `, selectJobById: ` SELECT * FROM jobs WHERE id = ? `, updateJob: ` UPDATE jobs SET type = @type, status = @status, data = @data, priority = @priority, max_retries = @max_retries, backoff_strategy = @backoff_strategy, fail_reason = @fail_reason, fail_count = @fail_count, reference_id = @reference_id, scheduled_job_id = @scheduled_job_id, expires_at = @expires_at, started_at = @started_at, run_at = @run_at, finished_at = @finished_at, updated_at = @updated_at WHERE id = @id `, deleteJob: ` DELETE FROM jobs WHERE id = ? `, selectFilteredJobs: ` SELECT * FROM jobs WHERE (@type IS NULL OR type = @type) AND (@referenceId IS NULL OR reference_id = @referenceId) AND (run_at IS NULL OR (@runAtBefore IS NULL OR run_at <= @runAtBefore)) AND (@status IS NULL OR status IN (SELECT value FROM json_each(@status))) AND (@expiresAtBefore IS NULL OR (expires_at IS NOT NULL AND expires_at <= @expiresAtBefore)) ORDER BY priority ASC, created_at ASC LIMIT CASE WHEN @limit IS NULL THEN -1 ELSE @limit END `, insertJobRun: ` INSERT INTO job_runs ( id, job_id, status, started_at, progress, finished_at, execution_duration, attempt, error, error_stack, touched_at ) VALUES ( @id, @job_id, @status, @started_at, @progress, @finished_at, @execution_duration, @attempt, @error, @error_stack, @touched_at ) `, selectJobRunById: ` SELECT * FROM job_runs WHERE id = ? `, updateJobRun: ` UPDATE job_runs SET status = @status, started_at = @started_at, progress = @progress, finished_at = @finished_at, execution_duration = @execution_duration, error = @error, error_stack = @error_stack, attempt = @attempt, touched_at = @touched_at WHERE id = @id `, selectJobRunsByJobId: ` SELECT * FROM job_runs WHERE job_id = ? ORDER BY started_at ASC `, deleteJobRunsByJobId: ` DELETE FROM job_runs WHERE job_id = ? `, insertJobLog: ` INSERT INTO job_logs ( id, job_id, job_run_id, timestamp, level, message, metadata ) VALUES ( @id, @job_id, @job_run_id, @timestamp, @level, @message, @metadata ) `, selectJobLogsByJobId: ` SELECT * FROM job_logs WHERE job_id = ? ORDER BY timestamp ASC `, selectJobLogsByJobAndRun: ` SELECT * FROM job_logs WHERE job_id = ? AND job_run_id = ? ORDER BY timestamp ASC `, deleteJobLogsByJobId: ` DELETE FROM job_logs WHERE job_id = ? `, insertScheduledJob: ` INSERT INTO scheduled_jobs ( id, name, type, schedule_type, schedule_value, time_zone, data, enabled, last_scheduled_at, next_run_at, reference_id, created_at, updated_at ) VALUES ( @id, @name, @type, @schedule_type, @schedule_value, @time_zone, @data, @enabled, @last_scheduled_at, @next_run_at, @reference_id, @created_at, @updated_at ) ON CONFLICT(name, type) DO UPDATE SET schedule_type = excluded.schedule_type, schedule_value = excluded.schedule_value, time_zone = excluded.time_zone, data = excluded.data, enabled = excluded.enabled, last_scheduled_at = excluded.last_scheduled_at, next_run_at = excluded.next_run_at, reference_id = excluded.reference_id, updated_at = excluded.updated_at RETURNING id `, selectScheduledJobById: ` SELECT * FROM scheduled_jobs WHERE id = ? `, selectScheduledJobByNameAndType: ` SELECT * FROM scheduled_jobs WHERE name = @name AND type = @type `, updateScheduledJob: ` UPDATE scheduled_jobs SET name = @name, type = @type, schedule_type = @schedule_type, schedule_value = @schedule_value, time_zone = @time_zone, data = @data, enabled = @enabled, last_scheduled_at = @last_scheduled_at, next_run_at = @next_run_at, reference_id = @reference_id, updated_at = @updated_at WHERE id = @id `, deleteScheduledJob: ` DELETE FROM scheduled_jobs WHERE id = ? `, selectAllScheduledJobs: ` SELECT * FROM scheduled_jobs `, selectFilteredScheduledJobs: ` SELECT * FROM scheduled_jobs WHERE (enabled = @enabled OR @enabled IS NULL) AND (next_run_at <= @next_run_before OR @next_run_before IS NULL) AND (@reference_id IS NULL OR reference_id = @reference_id) `, insertDeadLetterJob: ` INSERT INTO dead_letter_jobs ( id, job_id, job_type, data, failed_at, failed_reason ) VALUES ( @id, @job_id, @job_type, @data, @failed_at, @failed_reason ) `, selectAllDeadLetterJobs: ` SELECT * FROM dead_letter_jobs `, deleteDeadLetterBefore: ` DELETE FROM dead_letter_jobs WHERE failed_at < @failed_at `, listLocks: ` SELECT id, worker, expires_at FROM distributed_locks WHERE (@worker IS NULL OR worker = @worker) AND (@expiredBefore IS NULL OR expires_at <= @expiredBefore) `, selectLock: ` SELECT * FROM distributed_locks WHERE id = ? `, insertLock: ` INSERT INTO distributed_locks (id, worker, acquired_at, expires_at, created_at) VALUES (@id, @worker, @now, @expires, @now) ON CONFLICT(id) DO UPDATE SET worker = excluded.worker, acquired_at = excluded.acquired_at, expires_at = excluded.expires_at /* only overwrite if it was expired */ WHERE distributed_locks.expires_at <= excluded.acquired_at; `, updateLock: ` UPDATE distributed_locks SET expires_at = @newExpiry WHERE id = @lockId AND worker = @worker AND expires_at > @now `, deleteLock: ` DELETE FROM distributed_locks WHERE id = @id AND worker = @worker `, deleteExpiredLocks: ` DELETE FROM distributed_locks WHERE expires_at <= @expires_at `, cleanupCompleted: ` DELETE FROM jobs WHERE status = 'completed' AND updated_at < @updated_at `, cleanupFailed: ` DELETE FROM jobs WHERE status = 'failed' AND updated_at < @updated_at `, jobCounts: ` SELECT COUNT(*) as total, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN status = 'scheduled' THEN 1 ELSE 0 END) as scheduled, (SELECT COUNT(*) FROM dead_letter_jobs) as dead_letter FROM jobs `, avgDuration: ` SELECT j.type, COALESCE(AVG(r.execution_duration), 0) AS avg_duration FROM jobs j LEFT JOIN job_runs r ON j.id = r.job_id WHERE r.status = 'completed' AND r.execution_duration IS NOT NULL GROUP BY j.type `, failureRate: ` SELECT j.type, CAST( COALESCE( CAST(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS FLOAT) / NULLIF(COUNT(*), 0), 0 ) AS FLOAT ) as failure_rate FROM jobs j WHERE status IN ('completed', 'failed') GROUP BY type `, // Worker statements insertWorker: ` INSERT INTO workers (name, first_seen, last_heartbeat) VALUES (@name, @first_seen, @last_heartbeat) ON CONFLICT(name) DO UPDATE SET last_heartbeat = excluded.last_heartbeat `, updateWorkerHeartbeat: ` UPDATE workers SET last_heartbeat = @last_heartbeat WHERE name = @name `, selectWorkerByName: ` SELECT * FROM workers WHERE name = ? `, selectAllWorkers: ` SELECT * FROM workers `, selectInactiveWorkers: ` SELECT * FROM workers WHERE last_heartbeat < ? `, deleteWorker: ` DELETE FROM workers WHERE name = ? `, // Job type slot statements upsertJobTypeSlot: ` INSERT INTO job_type_slots (job_type, worker, slot_count) VALUES (@job_type, @worker, 1) ON CONFLICT (job_type, worker) DO UPDATE SET slot_count = slot_count + 1 `, getTotalJobTypeSlots: ` SELECT COALESCE(SUM(slot_count), 0) as total FROM job_type_slots WHERE job_type = ? `, getTotalRunningJobsByType: ` SELECT COALESCE(SUM(slot_count), 0) as total FROM job_type_slots WHERE job_type = ? `, getTotalRunningJobs: ` SELECT COALESCE(SUM(slot_count), 0) as total FROM job_type_slots `, decrementJobTypeSlot: ` UPDATE job_type_slots SET slot_count = slot_count - 1 WHERE job_type = @job_type AND worker = @worker AND slot_count > 0 `, deleteEmptyJobTypeSlots: ` DELETE FROM job_type_slots WHERE slot_count <= 0 `, deleteWorkerJobTypeSlots: ` DELETE FROM job_type_slots WHERE worker = ? ` }; // src/utils.ts function fromDate(date) { return date?.toISOString() ?? null; } function toDate(str) { if (!str) return void 0; try { return new Date(str); } catch { return void 0; } } function fromBoolean(value) { return value ? 1 : 0; } function toBoolean(value) { return value === 1; } function dateReviver(key, value) { if (typeof value === "string") { const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?$/; if (isoDateRegex.test(value)) { return new Date(value); } } return value; } function parseJSON(str) { if (!str) return void 0; try { return JSON.parse(str, dateReviver); } catch { return void 0; } } function serializeData(data) { const str = JSON.stringify(data ?? null); return str; } // src/adapter.ts var SQLiteJobStorage = class { /** * The SQLite database instance used for all operations */ db; /** * Prepared statements. */ stmtInsertJob; stmtSelectJobById; stmtUpdateJob; stmtDeleteJob; stmtSelectFilteredJobs; stmtInsertJobRun; stmtSelectJobRunById; stmtUpdateJobRun; stmtSelectJobRunsByJobId; stmtDeleteJobRunsByJobId; stmtInsertJobLog; stmtSelectJobLogsByJobId; stmtSelectJobLogsByJobAndRun; stmtDeleteJobLogsByJobId; stmtInsertScheduledJob; stmtSelectScheduledJobById; stmtUpdateScheduledJob; stmtSelectAllScheduledJobs; stmtSelectFilteredScheduledJobs; stmtInsertDeadLetterJob; stmtSelectAllDeadLetterJobs; stmtDeleteDeadLetterBefore; stmtListLocks; stmtInsertLock; stmtUpdateLock; stmtDeleteLock; stmtCleanupCompleted; stmtCleanupFailed; stmtJobCounts; stmtAvgDuration; stmtFailureRate; stmtInsertTypeSlot; stmtDecrementTypeSlot; stmtDeleteEmptyTypeSlots; stmtDeleteWorkerTypeSlots; stmtGetTotalRunningJobs; stmtGetTotalRunningJobsByType; stmtInsertWorker; stmtUpdateWorkerHeartbeat; stmtSelectWorkerByName; stmtSelectInactiveWorkers; stmtSelectAllWorkers; stmtDeleteWorker; constructor(db) { this.db = db; } /** * Initialize the SQLite storage provider * This will run any pending migrations and prepare all SQL statements * for improved performance */ async init({ runMigrations } = { runMigrations: true }) { if (runMigrations) { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const migrator = new Migrator({ db: this.db, migrationsDir: path.join(__dirname, "migrations") }); const result = await migrator.apply(); if (result.error) { throw result.error; } } this.stmtInsertJob = this.db.prepare(SQLiteStatements.insertJob); this.stmtSelectJobById = this.db.prepare(SQLiteStatements.selectJobById); this.stmtUpdateJob = this.db.prepare(SQLiteStatements.updateJob); this.stmtDeleteJob = this.db.prepare(SQLiteStatements.deleteJob); this.stmtSelectFilteredJobs = this.db.prepare(SQLiteStatements.selectFilteredJobs); this.stmtInsertJobRun = this.db.prepare(SQLiteStatements.insertJobRun); this.stmtSelectJobRunById = this.db.prepare(SQLiteStatements.selectJobRunById); this.stmtUpdateJobRun = this.db.prepare(SQLiteStatements.updateJobRun); this.stmtSelectJobRunsByJobId = this.db.prepare(SQLiteStatements.selectJobRunsByJobId); this.stmtDeleteJobRunsByJobId = this.db.prepare(SQLiteStatements.deleteJobRunsByJobId); this.stmtInsertJobLog = this.db.prepare(SQLiteStatements.insertJobLog); this.stmtSelectJobLogsByJobId = this.db.prepare(SQLiteStatements.selectJobLogsByJobId); this.stmtSelectJobLogsByJobAndRun = this.db.prepare(SQLiteStatements.selectJobLogsByJobAndRun); this.stmtDeleteJobLogsByJobId = this.db.prepare(SQLiteStatements.deleteJobLogsByJobId); this.stmtInsertScheduledJob = this.db.prepare(SQLiteStatements.insertScheduledJob); this.stmtSelectScheduledJobById = this.db.prepare(SQLiteStatements.selectScheduledJobById); this.stmtUpdateScheduledJob = this.db.prepare(SQLiteStatements.updateScheduledJob); this.stmtSelectAllScheduledJobs = this.db.prepare(SQLiteStatements.selectAllScheduledJobs); this.stmtSelectFilteredScheduledJobs = this.db.prepare(SQLiteStatements.selectFilteredScheduledJobs); this.stmtInsertDeadLetterJob = this.db.prepare(SQLiteStatements.insertDeadLetterJob); this.stmtSelectAllDeadLetterJobs = this.db.prepare(SQLiteStatements.selectAllDeadLetterJobs); this.stmtDeleteDeadLetterBefore = this.db.prepare(SQLiteStatements.deleteDeadLetterBefore); this.stmtListLocks = this.db.prepare(SQLiteStatements.listLocks); this.stmtInsertLock = this.db.prepare(SQLiteStatements.insertLock); this.stmtUpdateLock = this.db.prepare(SQLiteStatements.updateLock); this.stmtDeleteLock = this.db.prepare(SQLiteStatements.deleteLock); this.stmtCleanupCompleted = this.db.prepare(SQLiteStatements.cleanupCompleted); this.stmtCleanupFailed = this.db.prepare(SQLiteStatements.cleanupFailed); this.stmtJobCounts = this.db.prepare(SQLiteStatements.jobCounts); this.stmtAvgDuration = this.db.prepare(SQLiteStatements.avgDuration); this.stmtFailureRate = this.db.prepare(SQLiteStatements.failureRate); this.stmtInsertTypeSlot = this.db.prepare(SQLiteStatements.upsertJobTypeSlot); this.stmtDecrementTypeSlot = this.db.prepare(SQLiteStatements.decrementJobTypeSlot); this.stmtDeleteEmptyTypeSlots = this.db.prepare(SQLiteStatements.deleteEmptyJobTypeSlots); this.stmtDeleteWorkerTypeSlots = this.db.prepare(SQLiteStatements.deleteWorkerJobTypeSlots); this.stmtGetTotalRunningJobs = this.db.prepare(SQLiteStatements.getTotalRunningJobs); this.stmtGetTotalRunningJobsByType = this.db.prepare(SQLiteStatements.getTotalRunningJobsByType); this.stmtInsertWorker = this.db.prepare(SQLiteStatements.insertWorker); this.stmtUpdateWorkerHeartbeat = this.db.prepare(SQLiteStatements.updateWorkerHeartbeat); this.stmtSelectWorkerByName = this.db.prepare(SQLiteStatements.selectWorkerByName); this.stmtSelectInactiveWorkers = this.db.prepare(SQLiteStatements.selectInactiveWorkers); this.stmtSelectAllWorkers = this.db.prepare(SQLiteStatements.selectAllWorkers); this.stmtDeleteWorker = this.db.prepare(SQLiteStatements.deleteWorker); } /** * Close the database connection and clean up resources * This should be called when the storage provider is no longer needed */ async close() { this.db.close(); } /** * Execute a function within a transaction * This ensures ACID properties for all operations within the transaction * @param fn - Function to execute within the transaction * @returns Promise resolving to the function's result */ async transaction(fn) { return new Promise((resolve, reject) => { try { const wrapped = this.db.transaction((_) => { try { const result2 = fn(null); return result2; } catch (error) { reject(error); throw error; } }); const result = wrapped(null); resolve(result); } catch (error) { reject(error); } }); } /** * Create a new job in the database * @param job - Job data to create * @returns Unique identifier of the created job */ async createJob(job) { const validJob = CreateJobSchema.strict().parse(job); const id = randomUUID(); const now = /* @__PURE__ */ new Date(); const newJob = JobSchema.parse({ ...validJob, id, createdAt: now, updatedAt: now }); this.stmtInsertJob.run({ id: newJob.id, type: newJob.type, status: newJob.status, data: serializeData(newJob.data), priority: newJob.priority, max_retries: newJob.maxRetries, backoff_strategy: newJob.backoffStrategy, fail_reason: newJob.failReason ?? null, fail_count: newJob.failCount, reference_id: newJob.referenceId ?? null, scheduled_job_id: newJob.scheduledJobId ?? null, expires_at: fromDate(newJob.expiresAt), started_at: fromDate(newJob.startedAt), run_at: fromDate(newJob.runAt), finished_at: fromDate(newJob.finishedAt), created_at: newJob.createdAt.toISOString(), updated_at: newJob.updatedAt.toISOString() }); return newJob.id; } /** * Retrieve a job by its unique identifier * @param id - Unique identifier of the job to retrieve * @returns Job data or null if not found */ async getJob(id) { const row = this.stmtSelectJobById.get(id); if (!row) { return void 0; } return this.mapRowToJob(row); } /** * Update an existing job with new data * @param id - Unique identifier of the job to update * @param updates - Updated job data */ async updateJob(id, updates) { const existing = await this.getJob(id); if (!existing) { throw new JobNotFoundError(id); } const validUpdates = UpdateJobSchema.strict().parse(updates); const now = /* @__PURE__ */ new Date(); const updatedJob = JobSchema.parse({ ...existing, ...validUpdates, updatedAt: now }); this.stmtUpdateJob.run({ id: updatedJob.id, type: updatedJob.type, status: updatedJob.status, data: serializeData(updatedJob.data), priority: updatedJob.priority, max_retries: updatedJob.maxRetries, backoff_strategy: updatedJob.backoffStrategy, fail_reason: updatedJob.failReason ?? null, fail_count: updatedJob.failCount, reference_id: updatedJob.referenceId ?? null, scheduled_job_id: updatedJob.scheduledJobId ?? null, expires_at: fromDate(updatedJob.expiresAt), started_at: fromDate(updatedJob.startedAt), run_at: fromDate(updatedJob.runAt), finished_at: fromDate(updatedJob.finishedAt), updated_at: updatedJob.updatedAt.toISOString() }); } /** * Create a new job run for an existing job * @param jobRun - Job run data to create * @returns Unique identifier of the created job run */ async createJobRun(jobRun) { const valid = CreateJobRunSchema.strict().parse(jobRun); const id = randomUUID(); const newRun = JobRunSchema.parse({ ...valid, id }); this.stmtInsertJobRun.run({ id: newRun.id, job_id: newRun.jobId, status: newRun.status, started_at: fromDate(newRun.startedAt), progress: newRun.progress, finished_at: fromDate(newRun.finishedAt), execution_duration: newRun.executionDuration, attempt: newRun.attempt, error: newRun.error ?? null, error_stack: newRun.errorStack ?? null, touched_at: fromDate(newRun.touchedAt) }); return newRun.id; } /** * Update an existing job run with new data * @param id - Unique identifier of the job run to update * @param updates - Updated job run data */ async updateJobRun(id, updates) { const existingRow = this.stmtSelectJobRunById.get(id); if (!existingRow) { throw new JobRunNotFoundError(id); } const existing = this.mapRowToJobRun(existingRow); const updatedRun = JobRunSchema.strict().parse({ ...existing, ...updates // Always update touched_at when updating a job run }); this.stmtUpdateJobRun.run({ id: updatedRun.id, status: updatedRun.status, started_at: fromDate(updatedRun.startedAt), progress: updatedRun.progress, finished_at: fromDate(updatedRun.finishedAt), execution_duration: updatedRun.executionDuration, attempt: updatedRun.attempt, error: updatedRun.error ?? null, error_stack: updatedRun.errorStack ?? null, touched_at: fromDate(updatedRun.touchedAt) }); } /** * Retrieve a job run by its unique identifier * @param id - Unique identifier of the job run to retrieve * @returns Job run data or undefined if not found */ async getJobRun(id) { const row = this.stmtSelectJobRunById.get(id); if (!row) { return void 0; } return this.mapRowToJobRun(row); } /** * List job runs for a specific job * @param jobId - Unique identifier of the job * @returns Array of job runs for the specified job */ async listJobRuns(jobId) { const rows = this.stmtSelectJobRunsByJobId.all(jobId); return rows.map((r) => this.mapRowToJobRun(r)); } /** * List jobs based on the provided filter criteria * @param filter - Filter criteria (optional) * @returns Array of jobs matching the filter criteria */ async listJobs(filter) { const params = { type: filter?.type || null, referenceId: filter?.referenceId || null, runAtBefore: filter?.runAtBefore ? fromDate(filter.runAtBefore) : null, limit: filter?.limit || null, status: filter?.status ? JSON.stringify(filter.status) : null, expiresAtBefore: filter?.expiresAtBefore ? fromDate(filter.expiresAtBefore) : null }; const rows = this.stmtSelectFilteredJobs.all(params); return rows.map((row) => this.mapRowToJob(row)); } /** * Create a new log entry for a job run * @param log - Log entry data to create */ async createJobLog(log) { const validLog = CreateJobLogSchema.strict().parse(log); const newLog = JobLogEntrySchema.parse({ ...validLog, id: randomUUID() }); this.stmtInsertJobLog.run({ id: newLog.id, job_id: newLog.jobId, job_run_id: newLog.jobRunId ?? null, timestamp: newLog.timestamp.toISOString(), level: newLog.level, message: newLog.message, metadata: newLog.metadata ? JSON.stringify(newLog.metadata) : null }); } /** * List log entries for a specific job run * @param jobId - Unique identifier of the job * @param runId - Unique identifier of the job run (optional) * @returns Array of log entries for the specified job run */ async listJobLogs(jobId, runId) { if (runId) { const rows2 = this.stmtSelectJobLogsByJobAndRun.all([jobId, runId]); return rows2.map((r) => this.mapRowToJobLog(r)); } const rows = this.stmtSelectJobLogsByJobId.all([jobId]); return rows.map((r) => this.mapRowToJobLog(r)); } /** * Create a new scheduled job * @param job - Scheduled job data to create * @returns Unique identifier of the created scheduled job */ async createScheduledJob(job) { const validJob = CreateScheduledJobSchema.strict().parse(job); const now = /* @__PURE__ */ new Date(); const id = randomUUID(); const existingRow = this.db.prepare(SQLiteStatements.selectScheduledJobByNameAndType).get({ name: validJob.name, type: validJob.type }); let newJob; if (existingRow) { const existing = this.mapRowToScheduledJob(existingRow); newJob = ScheduledJobSchema.parse({ ...existing, ...validJob, id: existing.id, // Keep existing ID updatedAt: now }); } else { newJob = ScheduledJobSchema.parse({ ...validJob, id, createdAt: now, updatedAt: now }); } const dataStr = serializeData(newJob.data); const result = this.stmtInsertScheduledJob.get({ id: newJob.id, name: newJob.name, type: newJob.type, schedule_type: newJob.scheduleType, schedule_value: newJob.scheduleValue, time_zone: newJob.timeZone ?? null, data: dataStr, enabled: fromBoolean(newJob.enabled), last_scheduled_at: fromDate(newJob.lastScheduledAt), next_run_at: fromDate(newJob.nextRunAt), created_at: newJob.createdAt.toISOString(), updated_at: newJob.updatedAt.toISOString(), reference_id: newJob.referenceId ?? null }); return result.id; } /** * Update an existing scheduled job with new data * @param id - Unique identifier of the scheduled job to update * @param updates - Updated scheduled job data */ async updateScheduledJob(id, updates) { const row = this.stmtSelectScheduledJobById.get(id); if (!row) { throw new ScheduledJobNotFoundError(`Scheduled job with id=${id} not found`); } const existing = this.mapRowToScheduledJob(row); const validUpdates = UpdateScheduledJobSchema.strict().parse(updates); const updated = ScheduledJobSchema.parse({ ...existing, ...validUpdates, updatedAt: /* @__PURE__ */ new Date() }); const dataStr = serializeData(updated.data); this.stmtUpdateScheduledJob.run({ id, name: updated.name, type: updated.type, schedule_type: updated.scheduleType, schedule_value: updated.scheduleValue, time_zone: updated.timeZone ?? null, data: dataStr, enabled: fromBoolean(updated.enabled), last_scheduled_at: fromDate(updated.lastScheduledAt), next_run_at: fromDate(updated.nextRunAt), updated_at: updated.updatedAt.toISOString(), reference_id: updated.referenceId ?? null }); } /** * Retrieve a scheduled job by its unique identifier * @param id - Unique identifier of the scheduled job to retrieve * @returns Scheduled job data or null if not found */ async getScheduledJob(id) { const row = this.stmtSelectScheduledJobById.get(id); if (!row) { return null; } return this.mapRowToScheduledJob(row); } /** * List scheduled jobs based on the provided filter criteria * @param filter - Filter criteria (optional) * @returns Array of scheduled jobs matching the filter criteria */ async listScheduledJobs(filter) { if (!filter || filter.enabled === void 0 && !filter.nextRunBefore && !filter.referenceId) { const rows2 = this.stmtSelectAllScheduledJobs.all([]); return rows2.map((r) => this.mapRowToScheduledJob(r)); } const rows = this.stmtSelectFilteredScheduledJobs.all({ enabled: filter.enabled !== void 0 ? fromBoolean(filter.enabled) : null, next_run_before: filter.nextRunBefore ? filter.nextRunBefore.toISOString() : null, reference_id: filter.referenceId !== void 0 ? filter.referenceId : null }); return rows.map((row) => this.mapRowToScheduledJob(row)); } /** * Create a new dead letter job * @param job - Dead letter job data to create */ async createDeadLetterJob(job) { const validJob = CreateDeadLetterJobSchema.strict().parse(job); const newJob = DeadLetterJobSchema.parse({ ...validJob, id: randomUUID() }); const dataStr = serializeData(newJob.data); this.stmtInsertDeadLetterJob.run({ id: newJob.id, job_id: newJob.jobId, job_type: newJob.jobType, data: dataStr, failed_at: newJob.failedAt.toISOString(), failed_reason: newJob.failReason }); } /** * List dead letter jobs * @returns Array of dead letter jobs */ async listDeadLetterJobs() { const rows = this.stmtSelectAllDeadLetterJobs.all([]); return rows.map( (r) => DeadLetterJobSchema.parse({ id: r.id, jobId: r.job_id, jobType: r.job_type, data: parseJSON(r.data), failedAt: toDate(r.failed_at), failReason: r.failed_reason }) ); } /** * Acquire a lock for a specific job * @param lockId - Unique identifier of the lock * @param worker - Identifier of the lock worker * @param ttlMs - Time to live for the lock (in milliseconds) * @returns True if the lock was acquired, false otherwise */ async acquireLock(lockId, worker, ttlMs) { const now = /* @__PURE__ */ new Date(); const expiresAt = new Date(now.getTime() + ttlMs); return this.transaction(() => { const res = this.stmtInsertLock.run({ id: lockId, worker, now: now.toISOString(), acquired: now.toISOString(), expires: expiresAt.toISOString(), created: now.toISOString() }); return res.changes > 0; }); } /** * Renew an existing lock for a specific job * @param lockId - Unique identifier of the lock * @param worker - Identifier of the lock worker * @param ttlMs - Time to live for the lock (in milliseconds) * @returns True if the lock was renewed, false otherwise */ async renewLock(lockId, worker, ttlMs) { const now = /* @__PURE__ */ new Date(); const expiresAt = new Date(now.getTime() + ttlMs); return this.transaction(() => { const res = this.stmtUpdateLock.run({ lockId, worker, now: now.toISOString(), newExpiry: expiresAt.toISOString() }); return res.changes > 0; }); } /** * Release a lock. * @param lockId - Unique identifier of the lock * @param worker - Identifier of the lock worker * @returns True if the lock was released, false otherwise */ async releaseLock(lockId, worker) { return this.transaction(() => { const result = this.stmtDeleteLock.run({ id: lockId, worker }); return result.changes > 0; }); } /** * List all locks owned by a specific worker. * @returns An array of objects with lock details, each containing lockId, worker, and expiresAt */ async listLocks(filters) { const rows = this.stmtListLocks.all({ worker: filters?.worker, expiredBefore: filters?.expiredBefore ? fromDate(filters.expiredBefore) : null }); return rows.map((r) => this.mapRowToLock(r)); } /** * Clean up jobs and related data based on the provided retention periods * @param options - Retention periods for jobs, failed jobs, and dead letter jobs */ async cleanup(options) { const now = /* @__PURE__ */ new Date(); const DAY = 24 * 60 * 60 * 1e3; this.stmtCleanupCompleted.run({ updated_at: fromDate(new Date(now.getTime() - options.jobRetention * DAY)) }); this.stmtCleanupFailed.run({ updated_at: fromDate(new Date(now.getTime() - options.failedJobRetention * DAY)) }); this.stmtDeleteDeadLetterBefore.run({ failed_at: fromDate(new Date(now.getTime() - options.deadLetterRetention * DAY)) }); } /** * Delete a job and its related data (runs, logs) * @param jobId - Unique identifier of the job to delete */ async deleteJobAndRelatedData(jobId) { this.stmtDeleteJobLogsByJobId.run(jobId); this.stmtDeleteJobRunsByJobId.run(jobId); this.stmtDeleteJob.run(jobId); } /** * Get storage metrics including job counts and performance metrics * @returns Storage metrics */ async getMetrics() { const jobCounts = this.stmtJobCounts.get([]); const avgDurationRows = this.stmtAvgDuration.all([]); const failureRateRows = this.stmtFailureRate.all([]); const averageDurationByType = {}; const failureRateByType = {}; avgDurationRows.forEach((row) => { averageDurationByType[row.type] = row.avg_duration; }); failureRateRows.forEach((row) => { failureRateByType[row.type] = row.failure_rate; }); return { averageDurationByType, failureRateByType, jobs: { total: jobCounts.total ?? 0, pending: jobCounts.pending ?? 0, completed: jobCounts.completed ?? 0, failed: jobCounts.failed ?? 0, deadLetter: jobCounts.dead_letter ?? 0, scheduled: jobCounts.scheduled ?? 0 } }; } /** * Acquire a slot for a specific job type and worker * @param jobType - The type of the job * @param worker - The ID of the worker requesting the slot * @param maxConcurrent - Maximum allowed concurrent jobs for this type * @returns True if the slot was acquired, false otherwise */ async acquireTypeSlot(jobType, worker, maxConcurrent) { try { const result = this.transaction(() => { const totalSlots = this.stmtGetTotalRunningJobsByType.get(jobType); if (totalSlots.total >= maxConcurrent) { return false; } const res = this.stmtInsertTypeSlot.run({ job_type: jobType, worker, max_concurrent: maxConcurrent }); return res.changes > 0; }); return result; } catch (error) { if (error instanceof Error && (error.message.includes("SQLITE_BUSY") || error.message.includes("cannot start a transaction within a transaction"))) { return false; } throw error; } } /** * Release a slot for a specific job type and worker * @param jobType - The type of the job * @param worker - The worker releasing the slot */ async releaseTypeSlot(jobType, worker) { return this.transaction(() => { this.stmtDecrementTypeSlot.run({ job_type: jobType, worker }); this.stmtDeleteEmptyTypeSlots.run([]); }); } /** * Release all slots held by a specific worker * @param worker - The worker to release all slots for */ async releaseAllTypeSlots(worker) { return this.transaction(() => { this.stmtDeleteWorkerTypeSlots.run(worker); }); } /** * Get the current running count for a specific job type or total across all types * @param jobType - Optional, the type of the job to count. If not provided, returns total across all types * @returns Number of currently running jobs */ async getRunningCount(jobType) { if (jobType) { const result = this.stmtGetTotalRunningJobsByType.get(jobType); return result.total; } else { const result = this.stmtGetTotalRunningJobs.get([]); return result.total; } } /** * Register a new worker instance in the system * @param worker - Worker registration data containing the worker name * @returns Promise that resolves with the ID of the registered worker * @throws ZodError if the worker registration data is invalid */ async registerWorker(worker) { const now = /* @__PURE__ */ new Date(); const workerInstance = WorkerSchema.strict().parse({ ...worker, first_seen: now, last_heartbeat: now }); this.stmtInsertWorker.run({ name: workerInstance.name, first_seen: workerInstance.first_seen.toISOString(), last_heartbeat: workerInstance.last_heartbeat.toISOString() }); return workerInstance.name; } /** * Update a worker's heartbeat timestamp * @param workerName - Name of the worker to update * @param heartbeat - Heartbeat data containing the new timestamp * @throws WorkerNotFoundError if the worker doesn't exist * @throws ZodError if the heartbeat data is invalid */ async updateWorkerHeartbeat(workerName, heartbeat) { const worker = await this.getWorker(workerName); if (!worker) { throw new WorkerNotFoundError(workerName); } this.stmtUpdateWorkerHeartbeat.run({ name: workerName, last_heartbeat: heartbeat.last_heartbeat.toISOString() }); } /** * Get a worker by its name * @param workerName - Name of the worker to retrieve * @returns Promise that resolves with the worker data or null if not found */ async getWorker(workerName) { const worker = this.stmtSelectWorkerByName.get(workerName); if (!worker) { return null; } return WorkerSchema.parse({ ...worker, first_seen: new Date(worker.first_seen), last_heartbeat: new Date(worker.last_heartbeat) }); } /** * Get all workers that haven't sent a heartbeat since the specified time * @param lastHeartbeatBefore - Time threshold for considering workers inactive * @returns Promise that resolves with an array of inactive workers */ async getInactiveWorkers(lastHeartbeatBefore) { const workers = this.stmtSelectInactiveWorkers.all(lastHeartbeatBefore.toISOString()); return workers.map( (worker) => WorkerSchema.parse({ ...worker, first_seen: new Date(worker.first_seen), last_heartbeat: new Date(worker.last_heartbeat) }) ); } /** * Get all registered workers in the system * @returns Promise that resolves with an array of all workers */ async getWorkers() { const workers = this.stmtSelectAllWorkers.all([]); return workers.map( (worker) => WorkerSchema.parse({ ...worker, first_seen: new Date(worker.first_seen), last_heartbeat: new Date(worker.last_heartbeat) }) ); } /** * Delete a worker by its name * @param workerName - Name of the worker to delete * @throws WorkerNotFoundError if the worker doesn't exist */ async deleteWorker(workerName) { const worker = await this.getWorker(workerName); if (!worker) { throw new WorkerNotFoundError(workerName); } this.stmtDeleteWorker.run(workerName); } // eslint-disable-next-line @typescript-eslint/no-explicit-any mapRowToLock(row) { return { lockId: row.id, worker: row.worker, expiresAt: new Date(row.expires_at) }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any mapRowToJob(row) { return JobSchema.parse({ id: row.id, type: row.type, status: row.status, data: parseJSON(row.data), priority: row.priority, maxRetries: row.max_retries, backoffStrategy: row.backoff_strategy, failReason: row.fail_reason ?? void 0, failCount: row.fail_count, referenceId: row.reference_id ?? void 0, expiresAt: toDate(row.expires_at) ?? void 0, startedAt: toDate(row.started_at) ?? void 0, runAt: toDate(row.run_at) ?? void 0, finishedAt: toDate(row.finished_at) ?? void 0, scheduledJobId: row.scheduled_job_id, createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at) }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any mapRowToJobRun(row) { return JobRunSchema.parse({ id: row.id, jobId: row.job_id, status: row.status, startedAt: toDate(row.started_at), progress: row.progress, finishedAt: toDate(row.finished_at), executionDuration: row.execution_duration, attempt: row.attempt, error: row.error ?? void 0, errorStack: row.error_stack ?? void 0, touchedAt: toDate(row.touched_at) }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any mapRowToJobLog(row) { return JobLogEntrySchema.parse({ id: row.id, jobId: row.job_id, jobRunId: row.job_run_id ?? void 0, timestamp: new Date(row.timestamp), level: row.level, message: row.message, metadata: row.metadata ? parseJSON(row.metadata) : void 0 }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any mapRowToScheduledJob(row) { return ScheduledJobSchema.parse({ id: row.id, name: row.name, type: row.type, scheduleType: row.schedule_type, scheduleValue: row.schedule_value, timeZone: row.time_zone ?? void 0, data: parseJSON(row.data), enabled: toBoolean(row.enabled), lastScheduledAt: toDate(row.last_scheduled_at) ?? void 0, nextRunAt: toDate(row.next_run_at) ?? void 0, referenceId: row.reference_id, createdAt: toDate(row.created_at), updatedAt: toDate(row.updated_at) }); } }; export { SQLiteJobStorage }; //# sourceMappingURL=index.js.map