@timecrisis/timecrisis-sqlite
Version:
A SQLite storage adapter for the Time Crisis job scheduler
1,340 lines (1,337 loc) • 41.6 kB
JavaScript
// 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 (
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
)
`,
selectJobById: `
SELECT * FROM jobs WHERE id = ?
`,
updateJob: `
UPDATE jobs
SET
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 = ,
updated_at =
WHERE id =
`,
deleteJob: `
DELETE FROM jobs WHERE id = ?
`,
selectFilteredJobs: `
SELECT *
FROM jobs
WHERE ( IS NULL OR type = )
AND ( IS NULL OR reference_id = )
AND (run_at IS NULL OR ( IS NULL OR run_at <= ))
AND ( IS NULL OR status IN (SELECT value FROM json_each()))
AND ( IS NULL OR (expires_at IS NOT NULL AND expires_at <= ))
ORDER BY priority ASC, created_at ASC
LIMIT CASE WHEN IS NULL THEN -1 ELSE END
`,
insertJobRun: `
INSERT INTO job_runs (
id,
job_id,
status,
started_at,
progress,
finished_at,
execution_duration,
attempt,
error,
error_stack,
touched_at
) VALUES (
,
,
,
,
,
,
,
,
,
,
)
`,
selectJobRunById: `
SELECT * FROM job_runs WHERE id = ?
`,
updateJobRun: `
UPDATE job_runs
SET
status = ,
started_at = ,
progress = ,
finished_at = ,
execution_duration = ,
error = ,
error_stack = ,
attempt = ,
touched_at =
WHERE 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 (
,
,
,
,
,
,
)
`,
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 (
,
,
,
,
,
,
,
,
,
,
,
,
)
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 = AND type =
`,
updateScheduledJob: `
UPDATE scheduled_jobs
SET
name = ,
type = ,
schedule_type = ,
schedule_value = ,
time_zone = ,
data = ,
enabled = ,
last_scheduled_at = ,
next_run_at = ,
reference_id = ,
updated_at =
WHERE id =
`,
deleteScheduledJob: `
DELETE FROM scheduled_jobs WHERE id = ?
`,
selectAllScheduledJobs: `
SELECT * FROM scheduled_jobs
`,
selectFilteredScheduledJobs: `
SELECT *
FROM scheduled_jobs
WHERE (enabled = OR IS NULL)
AND (next_run_at <= OR IS NULL)
AND ( IS NULL OR reference_id = )
`,
insertDeadLetterJob: `
INSERT INTO dead_letter_jobs (
id,
job_id,
job_type,
data,
failed_at,
failed_reason
) VALUES (
,
,
,
,
,
)
`,
selectAllDeadLetterJobs: `
SELECT * FROM dead_letter_jobs
`,
deleteDeadLetterBefore: `
DELETE FROM dead_letter_jobs
WHERE failed_at <
`,
listLocks: `
SELECT id, worker, expires_at
FROM distributed_locks
WHERE ( IS NULL OR worker = )
AND ( IS NULL OR expires_at <= )
`,
selectLock: `
SELECT *
FROM distributed_locks
WHERE id = ?
`,
insertLock: `
INSERT INTO distributed_locks (id, worker, acquired_at, expires_at, created_at)
VALUES (, , , , )
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 =
WHERE id =
AND worker =
AND expires_at >
`,
deleteLock: `
DELETE FROM distributed_locks
WHERE id = AND worker =
`,
deleteExpiredLocks: `
DELETE FROM distributed_locks
WHERE expires_at <=
`,
cleanupCompleted: `
DELETE FROM jobs
WHERE status = 'completed'
AND updated_at <
`,
cleanupFailed: `
DELETE FROM jobs
WHERE status = 'failed'
AND 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 (, , )
ON CONFLICT(name) DO UPDATE SET
last_heartbeat = excluded.last_heartbeat
`,
updateWorkerHeartbeat: `
UPDATE workers
SET last_heartbeat =
WHERE 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 (, , 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 = AND 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