@holo-js/queue-db 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/dist/index.d.ts +109 -0
- package/dist/index.mjs +516 -0
- package/package.json +35 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Dialect, DatabaseContext } from '@holo-js/db';
|
|
2
|
+
import * as _holo_js_queue from '@holo-js/queue';
|
|
3
|
+
import { QueueJsonValue, QueueFailedJobRecord, QueueJobEnvelope, QueueAsyncDriver, NormalizedQueueDatabaseConnectionConfig, QueueDriverFactoryContext, QueueDriverDispatchResult, QueueReservedJob, QueueReleaseOptions, QueueDriverFactory, QueueFailedJobStore } from '@holo-js/queue';
|
|
4
|
+
|
|
5
|
+
type StoredQueueJobRow = {
|
|
6
|
+
id: unknown;
|
|
7
|
+
job: unknown;
|
|
8
|
+
connection: unknown;
|
|
9
|
+
queue: unknown;
|
|
10
|
+
payload: unknown;
|
|
11
|
+
attempts: unknown;
|
|
12
|
+
max_attempts: unknown;
|
|
13
|
+
available_at?: unknown;
|
|
14
|
+
created_at: unknown;
|
|
15
|
+
};
|
|
16
|
+
type StoredFailedQueueJobRow = {
|
|
17
|
+
id: unknown;
|
|
18
|
+
job_id: unknown;
|
|
19
|
+
payload: unknown;
|
|
20
|
+
exception: unknown;
|
|
21
|
+
failed_at: unknown;
|
|
22
|
+
};
|
|
23
|
+
declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
24
|
+
declare function assertQueueJsonValue(value: unknown, path: string, seen?: Set<unknown>): asserts value is QueueJsonValue;
|
|
25
|
+
declare function normalizeIdentifierPath(value: string, label: string): string;
|
|
26
|
+
declare function quoteIdentifierPath(dialect: Dialect, path: string): string;
|
|
27
|
+
declare function createPlaceholderList(dialect: Dialect, count: number, startIndex?: number): string;
|
|
28
|
+
declare function coerceRequiredString(value: unknown, label: string): string;
|
|
29
|
+
declare function coerceRequiredInteger(value: unknown, label: string): number;
|
|
30
|
+
declare function coerceOptionalInteger(value: unknown, label: string): number | undefined;
|
|
31
|
+
declare function parseStoredPayload(value: unknown, label: string): QueueJsonValue;
|
|
32
|
+
declare function parseStoredQueueJobRow(row: StoredQueueJobRow): QueueJobEnvelope<QueueJsonValue>;
|
|
33
|
+
declare function parseStoredQueueEnvelope(value: unknown): QueueJobEnvelope<QueueJsonValue>;
|
|
34
|
+
declare function parseStoredFailedQueueJobRow(row: StoredFailedQueueJobRow): QueueFailedJobRecord;
|
|
35
|
+
declare function serializeQueueJson(value: unknown): string;
|
|
36
|
+
declare function ensureConnectionReady(connection: DatabaseContext): Promise<DatabaseContext>;
|
|
37
|
+
|
|
38
|
+
declare const queueDatabaseInternals: {
|
|
39
|
+
assertQueueJsonValue: typeof assertQueueJsonValue;
|
|
40
|
+
coerceOptionalInteger: typeof coerceOptionalInteger;
|
|
41
|
+
coerceRequiredInteger: typeof coerceRequiredInteger;
|
|
42
|
+
coerceRequiredString: typeof coerceRequiredString;
|
|
43
|
+
createPlaceholderList: typeof createPlaceholderList;
|
|
44
|
+
ensureConnectionReady: typeof ensureConnectionReady;
|
|
45
|
+
isPlainObject: typeof isPlainObject;
|
|
46
|
+
normalizeIdentifierPath: typeof normalizeIdentifierPath;
|
|
47
|
+
parseStoredFailedQueueJobRow: typeof parseStoredFailedQueueJobRow;
|
|
48
|
+
parseStoredQueueEnvelope: typeof parseStoredQueueEnvelope;
|
|
49
|
+
parseStoredPayload: typeof parseStoredPayload;
|
|
50
|
+
parseStoredQueueJobRow: typeof parseStoredQueueJobRow;
|
|
51
|
+
quoteIdentifierPath: typeof quoteIdentifierPath;
|
|
52
|
+
serializeQueueJson: typeof serializeQueueJson;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
declare function normalizeDatabaseErrorMessage(error: unknown): string;
|
|
56
|
+
declare class DatabaseQueueDriverError extends Error {
|
|
57
|
+
constructor(connectionName: string, action: string, cause: unknown);
|
|
58
|
+
}
|
|
59
|
+
declare function wrapDatabaseError(connectionName: string, action: string, error: unknown): DatabaseQueueDriverError;
|
|
60
|
+
declare function normalizeQueueNames(queueNames: readonly string[] | undefined, fallbackQueueName: string): readonly string[];
|
|
61
|
+
declare class DatabaseQueueDriver implements QueueAsyncDriver {
|
|
62
|
+
private readonly connection;
|
|
63
|
+
private readonly context;
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly driver: "database";
|
|
66
|
+
readonly mode: "async";
|
|
67
|
+
private readonly tableName;
|
|
68
|
+
constructor(connection: NormalizedQueueDatabaseConnectionConfig, context: QueueDriverFactoryContext);
|
|
69
|
+
private getConnection;
|
|
70
|
+
private getQuotedTable;
|
|
71
|
+
private getExpiredReservationCutoff;
|
|
72
|
+
private createReservedJob;
|
|
73
|
+
dispatch<TPayload extends QueueJsonValue = QueueJsonValue, TResult = unknown>(job: QueueJobEnvelope<TPayload>): Promise<QueueDriverDispatchResult<TResult>>;
|
|
74
|
+
reserve<TPayload extends QueueJsonValue = QueueJsonValue>(input: {
|
|
75
|
+
readonly queueNames: readonly string[];
|
|
76
|
+
readonly workerId: string;
|
|
77
|
+
}): Promise<QueueReservedJob<TPayload> | null>;
|
|
78
|
+
acknowledge(job: QueueReservedJob): Promise<void>;
|
|
79
|
+
release(job: QueueReservedJob, options?: QueueReleaseOptions): Promise<void>;
|
|
80
|
+
delete(job: QueueReservedJob): Promise<void>;
|
|
81
|
+
clear(input?: {
|
|
82
|
+
readonly queueNames?: readonly string[];
|
|
83
|
+
}): Promise<number>;
|
|
84
|
+
close(): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
declare const databaseQueueDriverFactory: QueueDriverFactory<NormalizedQueueDatabaseConnectionConfig>;
|
|
87
|
+
declare const databaseQueueDriverInternals: {
|
|
88
|
+
normalizeDatabaseErrorMessage: typeof normalizeDatabaseErrorMessage;
|
|
89
|
+
normalizeQueueNames: typeof normalizeQueueNames;
|
|
90
|
+
wrapDatabaseError: typeof wrapDatabaseError;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
declare function getFailedStoreConfig(): false | _holo_js_queue.NormalizedQueueFailedStoreConfig;
|
|
94
|
+
declare function getFailedStoreConnection(): Promise<{
|
|
95
|
+
connection: DatabaseContext;
|
|
96
|
+
tableName: string;
|
|
97
|
+
} | null>;
|
|
98
|
+
declare const queueDbFailedJobStore: QueueFailedJobStore;
|
|
99
|
+
declare const queueDbFailedStoreInternals: {
|
|
100
|
+
getFailedStoreConfig: typeof getFailedStoreConfig;
|
|
101
|
+
getFailedStoreConnection: typeof getFailedStoreConnection;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
declare function createQueueDbRuntimeOptions(): Readonly<{
|
|
105
|
+
driverFactories: _holo_js_queue.QueueDriverFactory<_holo_js_queue.NormalizedQueueDatabaseConnectionConfig>[];
|
|
106
|
+
failedJobStore: _holo_js_queue.QueueFailedJobStore;
|
|
107
|
+
}>;
|
|
108
|
+
|
|
109
|
+
export { DatabaseQueueDriver, DatabaseQueueDriverError, type StoredFailedQueueJobRow, type StoredQueueJobRow, createQueueDbRuntimeOptions, databaseQueueDriverFactory, databaseQueueDriverInternals, queueDatabaseInternals, queueDbFailedJobStore, queueDbFailedStoreInternals };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// src/database.ts
|
|
2
|
+
function isPlainObject(value) {
|
|
3
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const prototype = Object.getPrototypeOf(value);
|
|
7
|
+
return prototype === Object.prototype || prototype === null;
|
|
8
|
+
}
|
|
9
|
+
function assertQueueJsonValue(value, path, seen = /* @__PURE__ */ new Set()) {
|
|
10
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value === "number") {
|
|
14
|
+
if (!Number.isFinite(value)) {
|
|
15
|
+
throw new TypeError(`[Holo Queue] ${path} must be JSON-serializable.`);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
|
|
20
|
+
throw new TypeError(`[Holo Queue] ${path} must be JSON-serializable.`);
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
if (seen.has(value)) {
|
|
24
|
+
throw new TypeError(`[Holo Queue] ${path} contains a circular reference.`);
|
|
25
|
+
}
|
|
26
|
+
seen.add(value);
|
|
27
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
28
|
+
assertQueueJsonValue(value[index], `${path}[${index}]`, seen);
|
|
29
|
+
}
|
|
30
|
+
seen.delete(value);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!isPlainObject(value)) {
|
|
34
|
+
throw new TypeError(`[Holo Queue] ${path} must be a plain JSON object, array, or primitive.`);
|
|
35
|
+
}
|
|
36
|
+
if (seen.has(value)) {
|
|
37
|
+
throw new TypeError(`[Holo Queue] ${path} contains a circular reference.`);
|
|
38
|
+
}
|
|
39
|
+
seen.add(value);
|
|
40
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
41
|
+
assertQueueJsonValue(nested, `${path}.${key}`, seen);
|
|
42
|
+
}
|
|
43
|
+
seen.delete(value);
|
|
44
|
+
}
|
|
45
|
+
function normalizeIdentifierPath(value, label) {
|
|
46
|
+
const normalized = value.trim();
|
|
47
|
+
if (!normalized) {
|
|
48
|
+
throw new Error(`[Holo Queue] ${label} must be a non-empty string.`);
|
|
49
|
+
}
|
|
50
|
+
const segments = normalized.split(".");
|
|
51
|
+
if (segments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment))) {
|
|
52
|
+
throw new Error(`[Holo Queue] ${label} must contain only valid SQL identifier segments.`);
|
|
53
|
+
}
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
function quoteIdentifierPath(dialect, path) {
|
|
57
|
+
return normalizeIdentifierPath(path, "Queue table name").split(".").map((segment) => dialect.quoteIdentifier(segment)).join(".");
|
|
58
|
+
}
|
|
59
|
+
function createPlaceholderList(dialect, count, startIndex = 1) {
|
|
60
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
61
|
+
throw new Error("[Holo Queue] Placeholder lists require at least one binding.");
|
|
62
|
+
}
|
|
63
|
+
return Array.from({ length: count }, (_, index) => dialect.createPlaceholder(startIndex + index)).join(", ");
|
|
64
|
+
}
|
|
65
|
+
function coerceRequiredString(value, label) {
|
|
66
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
67
|
+
throw new Error(`[Holo Queue] ${label} must be a non-empty string.`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function coerceRequiredInteger(value, label) {
|
|
72
|
+
if (typeof value === "number") {
|
|
73
|
+
if (!Number.isInteger(value)) {
|
|
74
|
+
throw new Error(`[Holo Queue] ${label} must be an integer.`);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
if (typeof value === "string" && /^-?\d+$/.test(value)) {
|
|
79
|
+
return Number.parseInt(value, 10);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`[Holo Queue] ${label} must be an integer.`);
|
|
82
|
+
}
|
|
83
|
+
function coerceOptionalInteger(value, label) {
|
|
84
|
+
if (value === null || typeof value === "undefined") {
|
|
85
|
+
return void 0;
|
|
86
|
+
}
|
|
87
|
+
return coerceRequiredInteger(value, label);
|
|
88
|
+
}
|
|
89
|
+
function parseStoredPayload(value, label) {
|
|
90
|
+
const serialized = coerceRequiredString(value, label);
|
|
91
|
+
const parsed = JSON.parse(serialized);
|
|
92
|
+
assertQueueJsonValue(parsed, label);
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
function parseStoredQueueJobRow(row) {
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
id: coerceRequiredString(row.id, "Stored queue job id"),
|
|
98
|
+
name: coerceRequiredString(row.job, "Stored queue job name"),
|
|
99
|
+
connection: coerceRequiredString(row.connection, "Stored queue job connection"),
|
|
100
|
+
queue: coerceRequiredString(row.queue, "Stored queue job queue"),
|
|
101
|
+
payload: parseStoredPayload(row.payload, "Stored queue job payload"),
|
|
102
|
+
attempts: coerceRequiredInteger(row.attempts, "Stored queue job attempts"),
|
|
103
|
+
maxAttempts: coerceRequiredInteger(row.max_attempts, "Stored queue job max attempts"),
|
|
104
|
+
...typeof row.available_at === "undefined" || row.available_at === null ? {} : { availableAt: coerceRequiredInteger(row.available_at, "Stored queue job availability") },
|
|
105
|
+
createdAt: coerceRequiredInteger(row.created_at, "Stored queue job creation time")
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function parseStoredQueueEnvelope(value) {
|
|
109
|
+
if (!isPlainObject(value)) {
|
|
110
|
+
throw new Error("[Holo Queue] Stored queue job payload must serialize a queue job envelope object.");
|
|
111
|
+
}
|
|
112
|
+
const record = value;
|
|
113
|
+
return Object.freeze({
|
|
114
|
+
id: coerceRequiredString(record.id, "Stored queue job id"),
|
|
115
|
+
name: coerceRequiredString(record.name, "Stored queue job name"),
|
|
116
|
+
connection: coerceRequiredString(record.connection, "Stored queue job connection"),
|
|
117
|
+
queue: coerceRequiredString(record.queue, "Stored queue job queue"),
|
|
118
|
+
payload: (() => {
|
|
119
|
+
assertQueueJsonValue(record.payload, "Stored queue job payload");
|
|
120
|
+
return record.payload;
|
|
121
|
+
})(),
|
|
122
|
+
attempts: coerceRequiredInteger(record.attempts, "Stored queue job attempts"),
|
|
123
|
+
maxAttempts: coerceRequiredInteger(record.maxAttempts, "Stored queue job max attempts"),
|
|
124
|
+
...typeof record.availableAt === "undefined" || record.availableAt === null ? {} : { availableAt: coerceRequiredInteger(record.availableAt, "Stored queue job availability") },
|
|
125
|
+
createdAt: coerceRequiredInteger(record.createdAt, "Stored queue job creation time")
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function parseStoredFailedQueueJobRow(row) {
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
id: coerceRequiredString(row.id, "Stored failed job id"),
|
|
131
|
+
jobId: coerceRequiredString(row.job_id, "Stored failed job job id"),
|
|
132
|
+
job: parseStoredQueueEnvelope(parseStoredPayload(row.payload, "Stored failed job payload")),
|
|
133
|
+
exception: coerceRequiredString(row.exception, "Stored failed job exception"),
|
|
134
|
+
failedAt: coerceRequiredInteger(row.failed_at, "Stored failed job timestamp")
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function serializeQueueJson(value) {
|
|
138
|
+
assertQueueJsonValue(value, "Queue JSON payload");
|
|
139
|
+
return JSON.stringify(value);
|
|
140
|
+
}
|
|
141
|
+
async function ensureConnectionReady(connection) {
|
|
142
|
+
await connection.initialize();
|
|
143
|
+
return connection;
|
|
144
|
+
}
|
|
145
|
+
var queueDatabaseInternals = {
|
|
146
|
+
assertQueueJsonValue,
|
|
147
|
+
coerceOptionalInteger,
|
|
148
|
+
coerceRequiredInteger,
|
|
149
|
+
coerceRequiredString,
|
|
150
|
+
createPlaceholderList,
|
|
151
|
+
ensureConnectionReady,
|
|
152
|
+
isPlainObject,
|
|
153
|
+
normalizeIdentifierPath,
|
|
154
|
+
parseStoredFailedQueueJobRow,
|
|
155
|
+
parseStoredQueueEnvelope,
|
|
156
|
+
parseStoredPayload,
|
|
157
|
+
parseStoredQueueJobRow,
|
|
158
|
+
quoteIdentifierPath,
|
|
159
|
+
serializeQueueJson
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// src/drivers/database.ts
|
|
163
|
+
import { randomUUID } from "crypto";
|
|
164
|
+
import { DB } from "@holo-js/db";
|
|
165
|
+
function normalizeDatabaseErrorMessage(error) {
|
|
166
|
+
if (error instanceof Error) {
|
|
167
|
+
return error.message;
|
|
168
|
+
}
|
|
169
|
+
return String(error);
|
|
170
|
+
}
|
|
171
|
+
var DatabaseQueueDriverError = class extends Error {
|
|
172
|
+
constructor(connectionName, action, cause) {
|
|
173
|
+
super(
|
|
174
|
+
`[Holo Queue] Database queue connection "${connectionName}" failed to ${action}: ${normalizeDatabaseErrorMessage(cause)}`,
|
|
175
|
+
{ cause }
|
|
176
|
+
);
|
|
177
|
+
this.name = "DatabaseQueueDriverError";
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
function wrapDatabaseError(connectionName, action, error) {
|
|
181
|
+
if (error instanceof DatabaseQueueDriverError) {
|
|
182
|
+
return error;
|
|
183
|
+
}
|
|
184
|
+
return new DatabaseQueueDriverError(connectionName, action, error);
|
|
185
|
+
}
|
|
186
|
+
function normalizeQueueNames(queueNames, fallbackQueueName) {
|
|
187
|
+
if (!queueNames || queueNames.length === 0) {
|
|
188
|
+
return Object.freeze([fallbackQueueName]);
|
|
189
|
+
}
|
|
190
|
+
const normalized = [...new Set(queueNames.map((name) => name.trim()).filter(Boolean))];
|
|
191
|
+
return Object.freeze(normalized.length > 0 ? normalized : [fallbackQueueName]);
|
|
192
|
+
}
|
|
193
|
+
function createPlaceholders(connection, count, startIndex = 1) {
|
|
194
|
+
return queueDatabaseInternals.createPlaceholderList(connection.getDialect(), count, startIndex).split(", ");
|
|
195
|
+
}
|
|
196
|
+
var DatabaseQueueDriver = class {
|
|
197
|
+
constructor(connection, context) {
|
|
198
|
+
this.connection = connection;
|
|
199
|
+
this.context = context;
|
|
200
|
+
this.name = connection.name;
|
|
201
|
+
this.tableName = queueDatabaseInternals.normalizeIdentifierPath(connection.table, "Queue table name");
|
|
202
|
+
}
|
|
203
|
+
name;
|
|
204
|
+
driver = "database";
|
|
205
|
+
mode = "async";
|
|
206
|
+
tableName;
|
|
207
|
+
async getConnection() {
|
|
208
|
+
return queueDatabaseInternals.ensureConnectionReady(DB.connection(this.connection.connection));
|
|
209
|
+
}
|
|
210
|
+
getQuotedTable(connection) {
|
|
211
|
+
return queueDatabaseInternals.quoteIdentifierPath(connection.getDialect(), this.tableName);
|
|
212
|
+
}
|
|
213
|
+
getExpiredReservationCutoff(now) {
|
|
214
|
+
return now - this.connection.retryAfter * 1e3;
|
|
215
|
+
}
|
|
216
|
+
createReservedJob(row, reservationId, reservedAt) {
|
|
217
|
+
return Object.freeze({
|
|
218
|
+
reservationId,
|
|
219
|
+
reservedAt,
|
|
220
|
+
envelope: queueDatabaseInternals.parseStoredQueueJobRow(row)
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
async dispatch(job) {
|
|
224
|
+
try {
|
|
225
|
+
const connection = await this.getConnection();
|
|
226
|
+
const quotedTable = this.getQuotedTable(connection);
|
|
227
|
+
const placeholders = createPlaceholders(connection, 9);
|
|
228
|
+
await connection.executeCompiled({
|
|
229
|
+
sql: `INSERT INTO ${quotedTable} (id, job, connection, queue, payload, attempts, max_attempts, available_at, created_at) VALUES (${placeholders.join(", ")})`,
|
|
230
|
+
bindings: [
|
|
231
|
+
job.id,
|
|
232
|
+
job.name,
|
|
233
|
+
job.connection,
|
|
234
|
+
job.queue,
|
|
235
|
+
queueDatabaseInternals.serializeQueueJson(job.payload),
|
|
236
|
+
job.attempts,
|
|
237
|
+
job.maxAttempts,
|
|
238
|
+
job.availableAt ?? job.createdAt,
|
|
239
|
+
job.createdAt
|
|
240
|
+
],
|
|
241
|
+
source: `queue:${this.name}:dispatch`
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
jobId: job.id,
|
|
245
|
+
synchronous: false
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
throw wrapDatabaseError(this.name, "enqueue job", error);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async reserve(input) {
|
|
252
|
+
try {
|
|
253
|
+
const connection = await this.getConnection();
|
|
254
|
+
const quotedTable = this.getQuotedTable(connection);
|
|
255
|
+
const queueNames = normalizeQueueNames(input.queueNames, this.connection.queue);
|
|
256
|
+
return await connection.transaction(async (tx) => {
|
|
257
|
+
for (const queueName of queueNames) {
|
|
258
|
+
while (true) {
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const expiredReservationCutoff = this.getExpiredReservationCutoff(now);
|
|
261
|
+
const [queuePlaceholder, availablePlaceholder, expiredPlaceholder] = createPlaceholders(tx, 3);
|
|
262
|
+
const selected = await tx.queryCompiled({
|
|
263
|
+
sql: `SELECT id, job, connection, queue, payload, attempts, max_attempts, available_at, created_at FROM ${quotedTable} WHERE queue = ${queuePlaceholder} AND available_at <= ${availablePlaceholder} AND (reserved_at IS NULL OR reserved_at <= ${expiredPlaceholder}) ORDER BY available_at ASC, created_at ASC, id ASC LIMIT 1`,
|
|
264
|
+
bindings: [queueName, now, expiredReservationCutoff],
|
|
265
|
+
source: `queue:${this.name}:reserve:select`
|
|
266
|
+
});
|
|
267
|
+
const row = selected.rows[0];
|
|
268
|
+
if (!row) {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const reservationId = `${input.workerId}:${randomUUID()}`;
|
|
272
|
+
const nextAttempts = queueDatabaseInternals.coerceRequiredInteger(row.attempts, "Stored queue job attempts") + 1;
|
|
273
|
+
const [
|
|
274
|
+
reservedAtPlaceholder,
|
|
275
|
+
reservationPlaceholder,
|
|
276
|
+
attemptsPlaceholder,
|
|
277
|
+
idPlaceholder,
|
|
278
|
+
queueNamePlaceholder,
|
|
279
|
+
expiredCutoffPlaceholder
|
|
280
|
+
] = createPlaceholders(tx, 6);
|
|
281
|
+
const updated = await tx.executeCompiled({
|
|
282
|
+
sql: `UPDATE ${quotedTable} SET reserved_at = ${reservedAtPlaceholder}, reservation_id = ${reservationPlaceholder}, attempts = ${attemptsPlaceholder} WHERE id = ${idPlaceholder} AND queue = ${queueNamePlaceholder} AND (reserved_at IS NULL OR reserved_at <= ${expiredCutoffPlaceholder})`,
|
|
283
|
+
bindings: [now, reservationId, nextAttempts, row.id, queueName, expiredReservationCutoff],
|
|
284
|
+
source: `queue:${this.name}:reserve:update`
|
|
285
|
+
});
|
|
286
|
+
if ((updated.affectedRows ?? 0) === 1) {
|
|
287
|
+
return this.createReservedJob(row, reservationId, now);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
});
|
|
293
|
+
} catch (error) {
|
|
294
|
+
throw wrapDatabaseError(this.name, "reserve job", error);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async acknowledge(job) {
|
|
298
|
+
try {
|
|
299
|
+
const connection = await this.getConnection();
|
|
300
|
+
const quotedTable = this.getQuotedTable(connection);
|
|
301
|
+
const [idPlaceholder, reservationPlaceholder] = createPlaceholders(connection, 2);
|
|
302
|
+
await connection.executeCompiled({
|
|
303
|
+
sql: `DELETE FROM ${quotedTable} WHERE id = ${idPlaceholder} AND reservation_id = ${reservationPlaceholder}`,
|
|
304
|
+
bindings: [job.envelope.id, job.reservationId],
|
|
305
|
+
source: `queue:${this.name}:acknowledge`
|
|
306
|
+
});
|
|
307
|
+
} catch (error) {
|
|
308
|
+
throw wrapDatabaseError(this.name, "acknowledge job", error);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async release(job, options) {
|
|
312
|
+
try {
|
|
313
|
+
const connection = await this.getConnection();
|
|
314
|
+
const quotedTable = this.getQuotedTable(connection);
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
const availableAt = now + (options?.delaySeconds ?? 0) * 1e3;
|
|
317
|
+
const [availablePlaceholder, idPlaceholder, reservationPlaceholder] = createPlaceholders(connection, 3);
|
|
318
|
+
await connection.executeCompiled({
|
|
319
|
+
sql: `UPDATE ${quotedTable} SET reserved_at = NULL, reservation_id = NULL, available_at = ${availablePlaceholder} WHERE id = ${idPlaceholder} AND reservation_id = ${reservationPlaceholder}`,
|
|
320
|
+
bindings: [availableAt, job.envelope.id, job.reservationId],
|
|
321
|
+
source: `queue:${this.name}:release`
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
throw wrapDatabaseError(this.name, "release job", error);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async delete(job) {
|
|
328
|
+
try {
|
|
329
|
+
const connection = await this.getConnection();
|
|
330
|
+
const quotedTable = this.getQuotedTable(connection);
|
|
331
|
+
const [idPlaceholder, reservationPlaceholder] = createPlaceholders(connection, 2);
|
|
332
|
+
await connection.executeCompiled({
|
|
333
|
+
sql: `DELETE FROM ${quotedTable} WHERE id = ${idPlaceholder} AND reservation_id = ${reservationPlaceholder}`,
|
|
334
|
+
bindings: [job.envelope.id, job.reservationId],
|
|
335
|
+
source: `queue:${this.name}:delete`
|
|
336
|
+
});
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw wrapDatabaseError(this.name, "delete job", error);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async clear(input) {
|
|
342
|
+
try {
|
|
343
|
+
const connection = await this.getConnection();
|
|
344
|
+
const quotedTable = this.getQuotedTable(connection);
|
|
345
|
+
const queueNames = normalizeQueueNames(input?.queueNames, this.connection.queue);
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
const expiredReservationCutoff = this.getExpiredReservationCutoff(now);
|
|
348
|
+
const conditions = [
|
|
349
|
+
`queue IN (${createPlaceholders(connection, queueNames.length).join(", ")})`,
|
|
350
|
+
`(reserved_at IS NULL OR reserved_at <= ${connection.getDialect().createPlaceholder(queueNames.length + 1)})`
|
|
351
|
+
];
|
|
352
|
+
const result = await connection.executeCompiled({
|
|
353
|
+
sql: `DELETE FROM ${quotedTable} WHERE ${conditions.join(" AND ")}`,
|
|
354
|
+
bindings: [...queueNames, expiredReservationCutoff],
|
|
355
|
+
source: `queue:${this.name}:clear`
|
|
356
|
+
});
|
|
357
|
+
return result.affectedRows ?? 0;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw wrapDatabaseError(this.name, "clear queued jobs", error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async close() {
|
|
363
|
+
void this.context;
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
var databaseQueueDriverFactory = {
|
|
367
|
+
driver: "database",
|
|
368
|
+
create(connection, context) {
|
|
369
|
+
return new DatabaseQueueDriver(connection, context);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
var databaseQueueDriverInternals = {
|
|
373
|
+
normalizeDatabaseErrorMessage,
|
|
374
|
+
normalizeQueueNames,
|
|
375
|
+
wrapDatabaseError
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// src/failed.ts
|
|
379
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
380
|
+
import { DB as DB2 } from "@holo-js/db";
|
|
381
|
+
import { getQueueRuntime } from "@holo-js/queue";
|
|
382
|
+
function getFailedStoreConfig() {
|
|
383
|
+
return getQueueRuntime().config.failed;
|
|
384
|
+
}
|
|
385
|
+
async function getFailedStoreConnection() {
|
|
386
|
+
const config = getFailedStoreConfig();
|
|
387
|
+
if (config === false) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
const tableName = queueDatabaseInternals.normalizeIdentifierPath(config.table, "Failed jobs table name");
|
|
391
|
+
const connection = await queueDatabaseInternals.ensureConnectionReady(DB2.connection(config.connection));
|
|
392
|
+
return {
|
|
393
|
+
connection,
|
|
394
|
+
tableName
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
var queueDbFailedJobStore = {
|
|
398
|
+
async persistFailedJob(reserved, error) {
|
|
399
|
+
const failedStore = await getFailedStoreConnection();
|
|
400
|
+
if (!failedStore) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
const quotedTable = queueDatabaseInternals.quoteIdentifierPath(failedStore.connection.getDialect(), failedStore.tableName);
|
|
404
|
+
const placeholders = queueDatabaseInternals.createPlaceholderList(failedStore.connection.getDialect(), 7);
|
|
405
|
+
const record = Object.freeze({
|
|
406
|
+
id: randomUUID2(),
|
|
407
|
+
jobId: reserved.envelope.id,
|
|
408
|
+
job: reserved.envelope,
|
|
409
|
+
exception: error.stack || error.message,
|
|
410
|
+
failedAt: Date.now()
|
|
411
|
+
});
|
|
412
|
+
await failedStore.connection.executeCompiled({
|
|
413
|
+
sql: `INSERT INTO ${quotedTable} (id, job_id, job, connection, queue, payload, exception, failed_at) VALUES (${placeholders}, ${failedStore.connection.getDialect().createPlaceholder(8)})`,
|
|
414
|
+
bindings: [
|
|
415
|
+
record.id,
|
|
416
|
+
record.jobId,
|
|
417
|
+
reserved.envelope.name,
|
|
418
|
+
reserved.envelope.connection,
|
|
419
|
+
reserved.envelope.queue,
|
|
420
|
+
queueDatabaseInternals.serializeQueueJson(reserved.envelope),
|
|
421
|
+
record.exception,
|
|
422
|
+
record.failedAt
|
|
423
|
+
],
|
|
424
|
+
source: "queue:failed:insert"
|
|
425
|
+
});
|
|
426
|
+
return record;
|
|
427
|
+
},
|
|
428
|
+
async listFailedJobs() {
|
|
429
|
+
const failedStore = await getFailedStoreConnection();
|
|
430
|
+
if (!failedStore) {
|
|
431
|
+
return Object.freeze([]);
|
|
432
|
+
}
|
|
433
|
+
const quotedTable = queueDatabaseInternals.quoteIdentifierPath(failedStore.connection.getDialect(), failedStore.tableName);
|
|
434
|
+
const result = await failedStore.connection.queryCompiled({
|
|
435
|
+
sql: `SELECT id, job_id, payload, exception, failed_at FROM ${quotedTable} ORDER BY failed_at DESC, id DESC`,
|
|
436
|
+
source: "queue:failed:list"
|
|
437
|
+
});
|
|
438
|
+
return Object.freeze(result.rows.map((row) => queueDatabaseInternals.parseStoredFailedQueueJobRow(row)));
|
|
439
|
+
},
|
|
440
|
+
async retryFailedJobs(identifier, retry) {
|
|
441
|
+
const failedStore = await getFailedStoreConnection();
|
|
442
|
+
if (!failedStore) {
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
const quotedTable = queueDatabaseInternals.quoteIdentifierPath(failedStore.connection.getDialect(), failedStore.tableName);
|
|
446
|
+
const dialect = failedStore.connection.getDialect();
|
|
447
|
+
const records = await (identifier === "all" ? await this.listFailedJobs() : (() => {
|
|
448
|
+
const placeholder = dialect.createPlaceholder(1);
|
|
449
|
+
return failedStore.connection.queryCompiled({
|
|
450
|
+
sql: `SELECT id, job_id, payload, exception, failed_at FROM ${quotedTable} WHERE id = ${placeholder}`,
|
|
451
|
+
bindings: [identifier],
|
|
452
|
+
source: "queue:failed:load"
|
|
453
|
+
}).then((result) => Object.freeze(result.rows.map((row) => queueDatabaseInternals.parseStoredFailedQueueJobRow(row))));
|
|
454
|
+
})());
|
|
455
|
+
let retried = 0;
|
|
456
|
+
for (const record of records) {
|
|
457
|
+
await retry(record);
|
|
458
|
+
const placeholder = dialect.createPlaceholder(1);
|
|
459
|
+
await failedStore.connection.executeCompiled({
|
|
460
|
+
sql: `DELETE FROM ${quotedTable} WHERE id = ${placeholder}`,
|
|
461
|
+
bindings: [record.id],
|
|
462
|
+
source: "queue:failed:forget-after-retry"
|
|
463
|
+
});
|
|
464
|
+
retried += 1;
|
|
465
|
+
}
|
|
466
|
+
return retried;
|
|
467
|
+
},
|
|
468
|
+
async forgetFailedJob(id) {
|
|
469
|
+
const failedStore = await getFailedStoreConnection();
|
|
470
|
+
if (!failedStore) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
const quotedTable = queueDatabaseInternals.quoteIdentifierPath(failedStore.connection.getDialect(), failedStore.tableName);
|
|
474
|
+
const placeholder = failedStore.connection.getDialect().createPlaceholder(1);
|
|
475
|
+
const result = await failedStore.connection.executeCompiled({
|
|
476
|
+
sql: `DELETE FROM ${quotedTable} WHERE id = ${placeholder}`,
|
|
477
|
+
bindings: [id],
|
|
478
|
+
source: "queue:failed:forget"
|
|
479
|
+
});
|
|
480
|
+
return (result.affectedRows ?? 0) > 0;
|
|
481
|
+
},
|
|
482
|
+
async flushFailedJobs() {
|
|
483
|
+
const failedStore = await getFailedStoreConnection();
|
|
484
|
+
if (!failedStore) {
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
const quotedTable = queueDatabaseInternals.quoteIdentifierPath(failedStore.connection.getDialect(), failedStore.tableName);
|
|
488
|
+
const result = await failedStore.connection.executeCompiled({
|
|
489
|
+
sql: `DELETE FROM ${quotedTable}`,
|
|
490
|
+
source: "queue:failed:flush"
|
|
491
|
+
});
|
|
492
|
+
return result.affectedRows ?? 0;
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
var queueDbFailedStoreInternals = {
|
|
496
|
+
getFailedStoreConfig,
|
|
497
|
+
getFailedStoreConnection
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// src/runtime.ts
|
|
501
|
+
function createQueueDbRuntimeOptions() {
|
|
502
|
+
return Object.freeze({
|
|
503
|
+
driverFactories: [databaseQueueDriverFactory],
|
|
504
|
+
failedJobStore: queueDbFailedJobStore
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
export {
|
|
508
|
+
DatabaseQueueDriver,
|
|
509
|
+
DatabaseQueueDriverError,
|
|
510
|
+
createQueueDbRuntimeOptions,
|
|
511
|
+
databaseQueueDriverFactory,
|
|
512
|
+
databaseQueueDriverInternals,
|
|
513
|
+
queueDatabaseInternals,
|
|
514
|
+
queueDbFailedJobStore,
|
|
515
|
+
queueDbFailedStoreInternals
|
|
516
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holo-js/queue-db",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Holo-JS Framework - DB-backed integrations for @holo-js/queue",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.mjs",
|
|
11
|
+
"default": "./dist/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.mjs",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"stub": "tsup",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"test": "vitest --run"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@holo-js/db": "workspace:*",
|
|
27
|
+
"@holo-js/queue": "workspace:*"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "catalog:",
|
|
31
|
+
"tsup": "catalog:",
|
|
32
|
+
"typescript": "catalog:",
|
|
33
|
+
"vitest": "catalog:"
|
|
34
|
+
}
|
|
35
|
+
}
|