@remit/queue-sidecar 0.0.14 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/queue-sidecar",
3
- "version": "0.0.14",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -21,9 +21,9 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@remit/logger-lambda": "*",
24
- "better-sqlite3": "^12.11.1",
25
- "@types/better-sqlite3": "^7.6.13",
26
- "prom-client": "^15.1.3"
24
+ "prom-client": "^15.1.3",
25
+ "@remit/drizzle-service": "*",
26
+ "drizzle-orm": "1.0.0-rc.5"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@aws-sdk/client-sqs": "*",
package/src/store.test.ts CHANGED
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { after, beforeEach, describe, it } from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
- import Database from "better-sqlite3";
6
+ import { openSqlite } from "@remit/drizzle-service/sqlite";
7
7
  import { bootstrapQueues, parseQueuesConfig } from "./queues-config.js";
8
8
  import {
9
9
  InvalidParameterValueError,
@@ -322,8 +322,13 @@ describe("QueueStore", () => {
322
322
  const seedBacklog = (rows: number, groups: number): void => {
323
323
  const path = join(dir, "queue.db");
324
324
  store.close();
325
- const seed = new Database(path);
326
- seed
325
+ const seed = openSqlite({
326
+ filename: path,
327
+ mode: "open",
328
+ synchronous: "FULL",
329
+ schema: {},
330
+ });
331
+ seed.sqlite
327
332
  .prepare(
328
333
  `WITH RECURSIVE n(i) AS (
329
334
  SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ?
package/src/store.ts CHANGED
@@ -1,5 +1,59 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import Database from "better-sqlite3";
2
+ import { openSqlite, type SqliteHandle } from "@remit/drizzle-service/sqlite";
3
+ import { and, asc, count, eq, gt, lt, lte, sql } from "drizzle-orm";
4
+ import {
5
+ integer,
6
+ primaryKey,
7
+ sqliteTable,
8
+ text,
9
+ } from "drizzle-orm/sqlite-core";
10
+
11
+ const queues = sqliteTable("queues", {
12
+ name: text("name").primaryKey(),
13
+ fifo: integer("fifo").notNull().default(0),
14
+ visibilityTimeoutSeconds: integer("visibility_timeout_seconds")
15
+ .notNull()
16
+ .default(30),
17
+ deadLetterTargetName: text("dead_letter_target_name"),
18
+ maxReceiveCount: integer("max_receive_count"),
19
+ contentBasedDeduplication: integer("content_based_deduplication")
20
+ .notNull()
21
+ .default(0),
22
+ });
23
+
24
+ const messages = sqliteTable("messages", {
25
+ id: integer("id").primaryKey({ autoIncrement: true }),
26
+ messageId: text("message_id").notNull(),
27
+ queueName: text("queue_name").notNull(),
28
+ body: text("body").notNull(),
29
+ md5Body: text("md5_body").notNull(),
30
+ groupId: text("group_id"),
31
+ dedupId: text("dedup_id"),
32
+ sequenceNumber: text("sequence_number"),
33
+ receiveCount: integer("receive_count").notNull().default(0),
34
+ visibleAt: integer("visible_at").notNull().default(0),
35
+ receiptHandle: text("receipt_handle"),
36
+ sentAt: integer("sent_at").notNull(),
37
+ firstReceivedAt: integer("first_received_at"),
38
+ });
39
+
40
+ const dedup = sqliteTable(
41
+ "dedup",
42
+ {
43
+ queueName: text("queue_name").notNull(),
44
+ dedupId: text("dedup_id").notNull(),
45
+ messageId: text("message_id").notNull(),
46
+ createdAt: integer("created_at").notNull(),
47
+ },
48
+ (table) => [primaryKey({ columns: [table.queueName, table.dedupId] })],
49
+ );
50
+
51
+ const releaseRedrive = sqliteTable("release_redrive", {
52
+ id: integer("id").primaryKey(),
53
+ release: text("release").notNull(),
54
+ });
55
+
56
+ const schema = { queues, messages, dedup, releaseRedrive };
3
57
 
4
58
  export interface QueueDefinition {
5
59
  readonly name: string;
@@ -46,43 +100,24 @@ export interface QueueAttributes {
46
100
  readonly redrivePolicy: string | null;
47
101
  }
48
102
 
49
- interface QueueRow {
50
- name: string;
51
- fifo: number;
52
- visibility_timeout_seconds: number;
53
- dead_letter_target_name: string | null;
54
- max_receive_count: number | null;
55
- content_based_deduplication: number;
56
- }
103
+ type QueueRow = typeof queues.$inferSelect;
57
104
 
58
- interface MessageRow {
59
- id: number;
60
- message_id: string;
61
- queue_name: string;
62
- body: string;
63
- md5_body: string;
64
- group_id: string | null;
65
- dedup_id: string | null;
66
- sequence_number: string | null;
67
- receive_count: number;
68
- visible_at: number;
69
- receipt_handle: string | null;
70
- sent_at: number;
71
- first_received_at: number | null;
72
- }
105
+ type MessageRow = typeof messages.$inferSelect;
73
106
 
74
107
  const DEDUP_WINDOW_MS = 5 * 60 * 1000;
75
108
 
109
+ const IMMEDIATE = { behavior: "immediate" } as const;
110
+
76
111
  const md5Hex = (value: string): string =>
77
112
  createHash("md5").update(value, "utf8").digest("hex");
78
113
 
79
114
  const toQueueRecord = (row: QueueRow): QueueRecord => ({
80
115
  name: row.name,
81
116
  fifo: row.fifo === 1,
82
- visibilityTimeoutSeconds: row.visibility_timeout_seconds,
83
- deadLetterTargetName: row.dead_letter_target_name,
84
- maxReceiveCount: row.max_receive_count,
85
- contentBasedDeduplication: row.content_based_deduplication === 1,
117
+ visibilityTimeoutSeconds: row.visibilityTimeoutSeconds,
118
+ deadLetterTargetName: row.deadLetterTargetName,
119
+ maxReceiveCount: row.maxReceiveCount,
120
+ contentBasedDeduplication: row.contentBasedDeduplication === 1,
86
121
  });
87
122
 
88
123
  /**
@@ -95,27 +130,31 @@ const toQueueRecord = (row: QueueRow): QueueRecord => ({
95
130
  * here so the behaviour is testable without a running HTTP server.
96
131
  */
97
132
  export class QueueStore {
98
- private readonly db: Database.Database;
133
+ private readonly handle: SqliteHandle<typeof schema>;
134
+ private readonly db: SqliteHandle<typeof schema>["db"];
99
135
  private sequenceCounter: bigint;
100
136
  private readonly startedAt = Date.now();
101
137
  private readonly lastReceiveAt = new Map<string, number>();
102
138
 
103
139
  constructor(filename: string) {
104
- this.db = new Database(filename);
105
- this.db.pragma("journal_mode = WAL");
106
- this.db.pragma("busy_timeout = 5000");
107
140
  // FULL, not NORMAL: the outbox path makes durability non-negotiable
108
141
  // (ADR constraint 5) — a queued send lost is a mail the user believes
109
142
  // left but never did. FULL flushes the WAL on every commit, so a
110
143
  // committed send survives host power-loss, not only a process restart.
111
144
  // At this queue's write volume the extra fsync cost is irrelevant.
112
- this.db.pragma("synchronous = FULL");
145
+ this.handle = openSqlite({
146
+ filename,
147
+ mode: "create",
148
+ synchronous: "FULL",
149
+ schema,
150
+ });
151
+ this.db = this.handle.db;
113
152
  this.migrate();
114
153
  this.sequenceCounter = this.readMaxSequence();
115
154
  }
116
155
 
117
156
  private migrate(): void {
118
- this.db.exec(`
157
+ this.handle.exec(`
119
158
  CREATE TABLE IF NOT EXISTS queues (
120
159
  name TEXT PRIMARY KEY,
121
160
  fifo INTEGER NOT NULL DEFAULT 0,
@@ -159,11 +198,14 @@ export class QueueStore {
159
198
 
160
199
  private readMaxSequence(): bigint {
161
200
  const row = this.db
162
- .prepare(
163
- "SELECT MAX(CAST(sequence_number AS INTEGER)) AS max FROM messages",
164
- )
165
- .get() as { max: number | null };
166
- return BigInt(row.max ?? 0);
201
+ .select({
202
+ max: sql<
203
+ number | null
204
+ >`MAX(CAST(${messages.sequenceNumber} AS INTEGER))`,
205
+ })
206
+ .from(messages)
207
+ .get();
208
+ return BigInt(row?.max ?? 0);
167
209
  }
168
210
 
169
211
  private nextSequence(): string {
@@ -172,32 +214,22 @@ export class QueueStore {
172
214
  }
173
215
 
174
216
  close(): void {
175
- this.db.close();
217
+ this.handle.close();
176
218
  }
177
219
 
178
220
  upsertQueue(def: QueueDefinition): QueueRecord {
221
+ const settings = {
222
+ fifo: def.fifo ? 1 : 0,
223
+ visibilityTimeoutSeconds: def.visibilityTimeoutSeconds,
224
+ deadLetterTargetName: def.deadLetterTargetName ?? null,
225
+ maxReceiveCount: def.maxReceiveCount ?? null,
226
+ contentBasedDeduplication: def.contentBasedDeduplication ? 1 : 0,
227
+ };
179
228
  this.db
180
- .prepare(
181
- `INSERT INTO queues (
182
- name, fifo, visibility_timeout_seconds,
183
- dead_letter_target_name, max_receive_count,
184
- content_based_deduplication
185
- ) VALUES (?, ?, ?, ?, ?, ?)
186
- ON CONFLICT(name) DO UPDATE SET
187
- fifo = excluded.fifo,
188
- visibility_timeout_seconds = excluded.visibility_timeout_seconds,
189
- dead_letter_target_name = excluded.dead_letter_target_name,
190
- max_receive_count = excluded.max_receive_count,
191
- content_based_deduplication = excluded.content_based_deduplication`,
192
- )
193
- .run(
194
- def.name,
195
- def.fifo ? 1 : 0,
196
- def.visibilityTimeoutSeconds,
197
- def.deadLetterTargetName ?? null,
198
- def.maxReceiveCount ?? null,
199
- def.contentBasedDeduplication ? 1 : 0,
200
- );
229
+ .insert(queues)
230
+ .values({ name: def.name, ...settings })
231
+ .onConflictDoUpdate({ target: queues.name, set: settings })
232
+ .run();
201
233
  const created = this.getQueue(def.name);
202
234
  if (!created) throw new Error(`queue ${def.name} vanished after upsert`);
203
235
  return created;
@@ -205,15 +237,15 @@ export class QueueStore {
205
237
 
206
238
  getQueue(name: string): QueueRecord | undefined {
207
239
  const row = this.db
208
- .prepare("SELECT * FROM queues WHERE name = ?")
209
- .get(name) as QueueRow | undefined;
240
+ .select()
241
+ .from(queues)
242
+ .where(eq(queues.name, name))
243
+ .get();
210
244
  return row ? toQueueRecord(row) : undefined;
211
245
  }
212
246
 
213
247
  listQueues(): QueueRecord[] {
214
- const rows = this.db
215
- .prepare("SELECT * FROM queues ORDER BY name")
216
- .all() as QueueRow[];
248
+ const rows = this.db.select().from(queues).orderBy(asc(queues.name)).all();
217
249
  return rows.map(toQueueRecord);
218
250
  }
219
251
 
@@ -236,26 +268,29 @@ export class QueueStore {
236
268
  (queue.contentBasedDeduplication ? md5 : undefined))
237
269
  : undefined;
238
270
 
239
- const send = this.db.transaction((): SendResult => {
271
+ return this.db.transaction((): SendResult => {
240
272
  if (dedupId) {
241
273
  this.pruneDedup(input.queueName, now);
242
274
  const existing = this.db
243
- .prepare(
244
- "SELECT message_id FROM dedup WHERE queue_name = ? AND dedup_id = ?",
275
+ .select({ messageId: dedup.messageId })
276
+ .from(dedup)
277
+ .where(
278
+ and(
279
+ eq(dedup.queueName, input.queueName),
280
+ eq(dedup.dedupId, dedupId),
281
+ ),
245
282
  )
246
- .get(input.queueName, dedupId) as { message_id: string } | undefined;
283
+ .get();
247
284
  if (existing) {
248
285
  const seq = this.db
249
- .prepare(
250
- "SELECT sequence_number FROM messages WHERE message_id = ?",
251
- )
252
- .get(existing.message_id) as
253
- | { sequence_number: string | null }
254
- | undefined;
286
+ .select({ sequenceNumber: messages.sequenceNumber })
287
+ .from(messages)
288
+ .where(eq(messages.messageId, existing.messageId))
289
+ .get();
255
290
  return {
256
- messageId: existing.message_id,
291
+ messageId: existing.messageId,
257
292
  md5OfBody: md5,
258
- sequenceNumber: seq?.sequence_number ?? null,
293
+ sequenceNumber: seq?.sequenceNumber ?? null,
259
294
  deduplicated: true,
260
295
  };
261
296
  }
@@ -263,31 +298,27 @@ export class QueueStore {
263
298
 
264
299
  const messageId = randomUUID();
265
300
  const sequenceNumber = queue.fifo ? this.nextSequence() : null;
266
- this.db
267
- .prepare(
268
- `INSERT INTO messages (
269
- message_id, queue_name, body, md5_body, group_id,
270
- dedup_id, sequence_number, receive_count, visible_at,
271
- receipt_handle, sent_at, first_received_at
272
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?, NULL)`,
273
- )
274
- .run(
275
- messageId,
276
- input.queueName,
277
- input.body,
278
- md5,
279
- input.groupId ?? null,
280
- dedupId ?? null,
281
- sequenceNumber,
282
- now,
283
- );
301
+ this.insertMessage({
302
+ messageId,
303
+ queueName: input.queueName,
304
+ body: input.body,
305
+ md5Body: md5,
306
+ groupId: input.groupId ?? null,
307
+ dedupId: dedupId ?? null,
308
+ sequenceNumber,
309
+ sentAt: now,
310
+ });
284
311
 
285
312
  if (dedupId) {
286
313
  this.db
287
- .prepare(
288
- "INSERT INTO dedup (queue_name, dedup_id, message_id, created_at) VALUES (?, ?, ?, ?)",
289
- )
290
- .run(input.queueName, dedupId, messageId, now);
314
+ .insert(dedup)
315
+ .values({
316
+ queueName: input.queueName,
317
+ dedupId,
318
+ messageId,
319
+ createdAt: now,
320
+ })
321
+ .run();
291
322
  }
292
323
 
293
324
  return {
@@ -296,9 +327,11 @@ export class QueueStore {
296
327
  sequenceNumber,
297
328
  deduplicated: false,
298
329
  };
299
- });
330
+ }, IMMEDIATE);
331
+ }
300
332
 
301
- return send();
333
+ private insertMessage(message: typeof messages.$inferInsert): void {
334
+ this.db.insert(messages).values(message).run();
302
335
  }
303
336
 
304
337
  receiveMessages(input: {
@@ -312,7 +345,7 @@ export class QueueStore {
312
345
  const visibilityTimeout =
313
346
  input.visibilityTimeoutSeconds ?? queue.visibilityTimeoutSeconds;
314
347
 
315
- const receive = this.db.transaction((): ReceivedMessage[] => {
348
+ const received = this.db.transaction((): ReceivedMessage[] => {
316
349
  const picked: ReceivedMessage[] = [];
317
350
 
318
351
  for (const candidate of this.nextCandidates(
@@ -323,49 +356,46 @@ export class QueueStore {
323
356
  if (
324
357
  queue.maxReceiveCount !== null &&
325
358
  queue.deadLetterTargetName !== null &&
326
- candidate.receive_count >= queue.maxReceiveCount
359
+ candidate.receiveCount >= queue.maxReceiveCount
327
360
  ) {
328
361
  this.db
329
- .prepare("UPDATE messages SET queue_name = ? WHERE id = ?")
330
- .run(queue.deadLetterTargetName, candidate.id);
362
+ .update(messages)
363
+ .set({ queueName: queue.deadLetterTargetName })
364
+ .where(eq(messages.id, candidate.id))
365
+ .run();
331
366
  continue;
332
367
  }
333
368
 
334
369
  const receiptHandle = randomUUID();
335
- const receiveCount = candidate.receive_count + 1;
336
- const firstReceived = candidate.first_received_at ?? now;
370
+ const receiveCount = candidate.receiveCount + 1;
371
+ const firstReceived = candidate.firstReceivedAt ?? now;
337
372
  this.db
338
- .prepare(
339
- `UPDATE messages SET
340
- receive_count = ?, visible_at = ?, receipt_handle = ?,
341
- first_received_at = ?
342
- WHERE id = ?`,
343
- )
344
- .run(
373
+ .update(messages)
374
+ .set({
345
375
  receiveCount,
346
- now + visibilityTimeout * 1000,
376
+ visibleAt: now + visibilityTimeout * 1000,
347
377
  receiptHandle,
348
- firstReceived,
349
- candidate.id,
350
- );
378
+ firstReceivedAt: firstReceived,
379
+ })
380
+ .where(eq(messages.id, candidate.id))
381
+ .run();
351
382
 
352
383
  picked.push({
353
- messageId: candidate.message_id,
384
+ messageId: candidate.messageId,
354
385
  body: candidate.body,
355
- md5OfBody: candidate.md5_body,
386
+ md5OfBody: candidate.md5Body,
356
387
  receiptHandle,
357
388
  receiveCount,
358
- sentTimestamp: candidate.sent_at,
389
+ sentTimestamp: candidate.sentAt,
359
390
  firstReceivedTimestamp: firstReceived,
360
- groupId: candidate.group_id,
361
- sequenceNumber: candidate.sequence_number,
391
+ groupId: candidate.groupId,
392
+ sequenceNumber: candidate.sequenceNumber,
362
393
  });
363
394
  }
364
395
 
365
396
  return picked;
366
- });
397
+ }, IMMEDIATE);
367
398
 
368
- const received = receive();
369
399
  if (received.length > 0) this.lastReceiveAt.set(input.queueName, now);
370
400
  return received;
371
401
  }
@@ -379,33 +409,41 @@ export class QueueStore {
379
409
  deleteMessage(queueName: string, receiptHandle: string): void {
380
410
  this.requireQueue(queueName);
381
411
  this.db
382
- .prepare(
383
- "DELETE FROM messages WHERE queue_name = ? AND receipt_handle = ?",
412
+ .delete(messages)
413
+ .where(
414
+ and(
415
+ eq(messages.queueName, queueName),
416
+ eq(messages.receiptHandle, receiptHandle),
417
+ ),
384
418
  )
385
- .run(queueName, receiptHandle);
419
+ .run();
386
420
  }
387
421
 
388
422
  purgeQueue(queueName: string): void {
389
423
  this.requireQueue(queueName);
390
- this.db.prepare("DELETE FROM messages WHERE queue_name = ?").run(queueName);
424
+ this.db.delete(messages).where(eq(messages.queueName, queueName)).run();
391
425
  }
392
426
 
393
427
  getQueueAttributes(queueName: string, now = Date.now()): QueueAttributes {
394
428
  const queue = this.requireQueue(queueName);
395
429
  const visible = this.db
396
- .prepare(
397
- "SELECT COUNT(*) AS n FROM messages WHERE queue_name = ? AND visible_at <= ?",
430
+ .select({ n: count() })
431
+ .from(messages)
432
+ .where(
433
+ and(eq(messages.queueName, queueName), lte(messages.visibleAt, now)),
398
434
  )
399
- .get(queueName, now) as { n: number };
435
+ .get();
400
436
  const notVisible = this.db
401
- .prepare(
402
- "SELECT COUNT(*) AS n FROM messages WHERE queue_name = ? AND visible_at > ?",
437
+ .select({ n: count() })
438
+ .from(messages)
439
+ .where(
440
+ and(eq(messages.queueName, queueName), gt(messages.visibleAt, now)),
403
441
  )
404
- .get(queueName, now) as { n: number };
442
+ .get();
405
443
 
406
444
  return {
407
- approximateNumberOfMessages: visible.n,
408
- approximateNumberOfMessagesNotVisible: notVisible.n,
445
+ approximateNumberOfMessages: visible?.n ?? 0,
446
+ approximateNumberOfMessagesNotVisible: notVisible?.n ?? 0,
409
447
  visibilityTimeout: queue.visibilityTimeoutSeconds,
410
448
  fifoQueue: queue.fifo,
411
449
  redrivePolicy:
@@ -425,34 +463,42 @@ export class QueueStore {
425
463
  ): MessageRow[] {
426
464
  if (!queue.fifo) {
427
465
  return this.db
428
- .prepare(
429
- `SELECT * FROM messages
430
- WHERE queue_name = ? AND visible_at <= ?
431
- ORDER BY id ASC LIMIT ?`,
466
+ .select()
467
+ .from(messages)
468
+ .where(
469
+ and(eq(messages.queueName, queue.name), lte(messages.visibleAt, now)),
432
470
  )
433
- .all(queue.name, now, limit) as MessageRow[];
471
+ .orderBy(asc(messages.id))
472
+ .limit(limit)
473
+ .all();
434
474
  }
435
475
 
476
+ const groupHeads = sql`WITH RECURSIVE g(gid) AS (
477
+ SELECT MIN(group_id) FROM messages WHERE queue_name = ${queue.name}
478
+ UNION ALL
479
+ SELECT (
480
+ SELECT MIN(group_id) FROM messages
481
+ WHERE queue_name = ${queue.name} AND group_id > g.gid
482
+ )
483
+ FROM g WHERE g.gid IS NOT NULL
484
+ )
485
+ SELECT (
486
+ SELECT MIN(id) FROM messages
487
+ WHERE queue_name = ${queue.name} AND group_id = g.gid
488
+ ) FROM g`;
489
+
436
490
  return this.db
437
- .prepare(
438
- `WITH RECURSIVE g(gid) AS (
439
- SELECT MIN(group_id) FROM messages WHERE queue_name = @queue
440
- UNION ALL
441
- SELECT (
442
- SELECT MIN(group_id) FROM messages
443
- WHERE queue_name = @queue AND group_id > g.gid
444
- )
445
- FROM g WHERE g.gid IS NOT NULL
446
- )
447
- SELECT m.* FROM g
448
- JOIN messages m ON m.id = (
449
- SELECT MIN(id) FROM messages
450
- WHERE queue_name = @queue AND group_id = g.gid
451
- )
452
- WHERE m.visible_at <= @now
453
- ORDER BY m.id ASC LIMIT @limit`,
491
+ .select()
492
+ .from(messages)
493
+ .where(
494
+ and(
495
+ sql`${messages.id} IN (${groupHeads})`,
496
+ lte(messages.visibleAt, now),
497
+ ),
454
498
  )
455
- .all({ queue: queue.name, now, limit }) as MessageRow[];
499
+ .orderBy(asc(messages.id))
500
+ .limit(limit)
501
+ .all();
456
502
  }
457
503
 
458
504
  /**
@@ -462,6 +508,15 @@ export class QueueStore {
462
508
  * a FIFO group still sees it after anything sent since.
463
509
  */
464
510
  redriveDeadLetters(deadLetterName: string, now = Date.now()): number {
511
+ return this.db.transaction(
512
+ (): number => this.moveDeadLetters(deadLetterName, now),
513
+ IMMEDIATE,
514
+ );
515
+ }
516
+
517
+ // The body of a redrive, for a caller already inside a transaction:
518
+ // node:sqlite cannot begin one inside another.
519
+ private moveDeadLetters(deadLetterName: string, now: number): number {
465
520
  this.requireQueue(deadLetterName);
466
521
  const source = this.listQueues().find(
467
522
  (queue) => queue.deadLetterTargetName === deadLetterName,
@@ -471,31 +526,32 @@ export class QueueStore {
471
526
  `${deadLetterName} is not the dead-letter queue of any queue`,
472
527
  );
473
528
  }
474
- const destination = source.name;
475
-
476
- const redrive = this.db.transaction((): number => {
477
- const rows = this.db
478
- .prepare("SELECT * FROM messages WHERE queue_name = ? ORDER BY id ASC")
479
- .all(deadLetterName) as MessageRow[];
480
- for (const row of rows) this.moveToQueue(row, destination, now);
481
- return rows.length;
482
- });
483
- return redrive();
529
+ const rows = this.db
530
+ .select()
531
+ .from(messages)
532
+ .where(eq(messages.queueName, deadLetterName))
533
+ .orderBy(asc(messages.id))
534
+ .all();
535
+ for (const row of rows) this.moveToQueue(row, source.name, now);
536
+ return rows.length;
484
537
  }
485
538
 
486
539
  redriveForRelease(release: string, deadLetterNames: string[]): void {
487
540
  this.db.transaction((): void => {
488
541
  const marker = this.db
489
- .prepare("SELECT release FROM release_redrive WHERE id = 1")
490
- .get() as { release: string } | undefined;
542
+ .select({ release: releaseRedrive.release })
543
+ .from(releaseRedrive)
544
+ .where(eq(releaseRedrive.id, 1))
545
+ .get();
491
546
  if (marker?.release === release) return;
492
- for (const name of deadLetterNames) this.redriveDeadLetters(name);
547
+ const now = Date.now();
548
+ for (const name of deadLetterNames) this.moveDeadLetters(name, now);
493
549
  this.db
494
- .prepare(
495
- "INSERT INTO release_redrive (id, release) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET release = excluded.release",
496
- )
497
- .run(release);
498
- })();
550
+ .insert(releaseRedrive)
551
+ .values({ id: 1, release })
552
+ .onConflictDoUpdate({ target: releaseRedrive.id, set: { release } })
553
+ .run();
554
+ }, IMMEDIATE);
499
555
  }
500
556
 
501
557
  private moveToQueue(
@@ -503,31 +559,29 @@ export class QueueStore {
503
559
  targetName: string,
504
560
  now: number,
505
561
  ): void {
506
- this.db.prepare("DELETE FROM messages WHERE id = ?").run(message.id);
507
- this.db
508
- .prepare(
509
- `INSERT INTO messages (
510
- message_id, queue_name, body, md5_body, group_id, dedup_id,
511
- sequence_number, receive_count, visible_at, receipt_handle,
512
- sent_at, first_received_at
513
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?, NULL)`,
514
- )
515
- .run(
516
- message.message_id,
517
- targetName,
518
- message.body,
519
- message.md5_body,
520
- message.group_id,
521
- message.dedup_id,
522
- message.sequence_number,
523
- now,
524
- );
562
+ this.db.delete(messages).where(eq(messages.id, message.id)).run();
563
+ this.insertMessage({
564
+ messageId: message.messageId,
565
+ queueName: targetName,
566
+ body: message.body,
567
+ md5Body: message.md5Body,
568
+ groupId: message.groupId,
569
+ dedupId: message.dedupId,
570
+ sequenceNumber: message.sequenceNumber,
571
+ sentAt: now,
572
+ });
525
573
  }
526
574
 
527
575
  private pruneDedup(queueName: string, now: number): void {
528
576
  this.db
529
- .prepare("DELETE FROM dedup WHERE queue_name = ? AND created_at < ?")
530
- .run(queueName, now - DEDUP_WINDOW_MS);
577
+ .delete(dedup)
578
+ .where(
579
+ and(
580
+ eq(dedup.queueName, queueName),
581
+ lt(dedup.createdAt, now - DEDUP_WINDOW_MS),
582
+ ),
583
+ )
584
+ .run();
531
585
  }
532
586
 
533
587
  private requireQueue(name: string): QueueRecord {