@bumail/queue 0.1.0 → 0.3.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.
Files changed (66) hide show
  1. package/README.md +71 -5
  2. package/dist/chunks/{index-xhsw4xcz.js → index-vg971acr.js} +70 -6
  3. package/dist/chunks/index-vg971acr.js.map +12 -0
  4. package/dist/chunks/index-y3n1w2az.js +18 -0
  5. package/dist/chunks/index-y3n1w2az.js.map +10 -0
  6. package/dist/contract/checks.d.ts +6 -0
  7. package/dist/contract/checks.d.ts.map +1 -1
  8. package/dist/errors.d.ts +2 -1
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/index.js +86 -55
  11. package/dist/index.js.map +8 -8
  12. package/dist/masked.d.ts +7 -0
  13. package/dist/masked.d.ts.map +1 -0
  14. package/dist/memory/index.js +3 -5
  15. package/dist/memory/index.js.map +2 -2
  16. package/dist/postgres/connect.d.ts +17 -0
  17. package/dist/postgres/connect.d.ts.map +1 -0
  18. package/dist/postgres/index.d.ts +2 -0
  19. package/dist/postgres/index.d.ts.map +1 -0
  20. package/dist/postgres/index.js +374 -0
  21. package/dist/postgres/index.js.map +14 -0
  22. package/dist/postgres/options.d.ts +37 -0
  23. package/dist/postgres/options.d.ts.map +1 -0
  24. package/dist/postgres/rows.d.ts +21 -0
  25. package/dist/postgres/rows.d.ts.map +1 -0
  26. package/dist/postgres/schema.d.ts +20 -0
  27. package/dist/postgres/schema.d.ts.map +1 -0
  28. package/dist/postgres/statements.d.ts +54 -0
  29. package/dist/postgres/statements.d.ts.map +1 -0
  30. package/dist/postgres/store.d.ts +38 -0
  31. package/dist/postgres/store.d.ts.map +1 -0
  32. package/dist/queue/deliver.d.ts +7 -0
  33. package/dist/queue/deliver.d.ts.map +1 -1
  34. package/dist/queue/lease.d.ts +41 -0
  35. package/dist/queue/lease.d.ts.map +1 -0
  36. package/dist/queue/outcome.d.ts.map +1 -1
  37. package/dist/queue/settings/index.d.ts.map +1 -1
  38. package/dist/queue/worker.d.ts.map +1 -1
  39. package/dist/redis/connect.d.ts +25 -0
  40. package/dist/redis/connect.d.ts.map +1 -0
  41. package/dist/redis/index.d.ts +3 -0
  42. package/dist/redis/index.d.ts.map +1 -0
  43. package/dist/redis/index.js +430 -0
  44. package/dist/redis/index.js.map +14 -0
  45. package/dist/redis/items.d.ts +13 -0
  46. package/dist/redis/items.d.ts.map +1 -0
  47. package/dist/redis/options.d.ts +47 -0
  48. package/dist/redis/options.d.ts.map +1 -0
  49. package/dist/redis/run.d.ts +12 -0
  50. package/dist/redis/run.d.ts.map +1 -0
  51. package/dist/redis/scripts.d.ts +60 -0
  52. package/dist/redis/scripts.d.ts.map +1 -0
  53. package/dist/redis/store.d.ts +30 -0
  54. package/dist/redis/store.d.ts.map +1 -0
  55. package/dist/sqlite/index.js +3 -5
  56. package/dist/sqlite/index.js.map +2 -2
  57. package/dist/text.d.ts +9 -3
  58. package/dist/text.d.ts.map +1 -1
  59. package/docs/README.md +1 -1
  60. package/docs/guide.md +314 -7
  61. package/docs/roadmap.md +73 -19
  62. package/docs/troubleshooting.md +317 -7
  63. package/package.json +15 -3
  64. package/dist/chunks/index-p4k4rjb7.js +0 -15
  65. package/dist/chunks/index-p4k4rjb7.js.map +0 -10
  66. package/dist/chunks/index-xhsw4xcz.js.map +0 -10
@@ -34,8 +34,12 @@ parts shown as … vary.
34
34
 
35
35
  - [`QueueError: The lease on … was lost before its outcome was recorded; another worker will try it again`](#queueerror-the-lease-on--was-lost-before-its-outcome-was-recorded-another-worker-will-try-it-again)
36
36
  - [`QueueError: The lease on … was lost while it was delivered`](#queueerror-the-lease-on--was-lost-while-it-was-delivered)
37
+ - [`QueueError: The lease on … expired before its outcome was recorded, and the item is gone: lost to another worker that finished it, the message then sent twice, or cancelled`](#queueerror-the-lease-on--expired-before-its-outcome-was-recorded-and-the-item-is-gone-lost-to-another-worker-that-finished-it-the-message-then-sent-twice-or-cancelled)
38
+ - [`QueueError: The lease on … was lost while it was delivered, and the item is gone: finished by the worker that took it, the message then sent twice, or cancelled`](#queueerror-the-lease-on--was-lost-while-it-was-delivered-and-the-item-is-gone-finished-by-the-worker-that-took-it-the-message-then-sent-twice-or-cancelled)
37
39
  - [`SmtpError: sendMail(): …` (`INVALID_OPTION`), with recipients deferred as `4.3.5`](#smtperror-sendmail--invalid_option-with-recipients-deferred-as-435)
38
40
  - [`SQLiteError: database is locked`, or another store error, during a delivery](#sqliteerror-database-is-locked-or-another-store-error-during-a-delivery)
41
+ - [`PostgresError: …`, or a connection error, during a delivery](#postgreserror--or-a-connection-error-during-a-delivery)
42
+ - [`RedisError: …` during a delivery: `Connection closed`, `OOM command not allowed …`, `READONLY …`](#rediserror--during-a-delivery-connection-closed-oom-command-not-allowed--readonly-)
39
43
 
40
44
  **The `bun:sqlite` store** (`@bumail/queue/sqlite`)
41
45
 
@@ -45,6 +49,25 @@ parts shown as … vary.
45
49
  - [`QueueError: busyTimeout must be an integer of at least 0, not …`](#queueerror-busytimeout-must-be-an-integer-of-at-least-0-not-)
46
50
  - [`QueueError: The queue store is closed`](#queueerror-the-queue-store-is-closed)
47
51
 
52
+ **The PostgreSQL store** (`@bumail/queue/postgres`)
53
+
54
+ - [`QueueError: A PostgreSQL queue store needs sql: a Bun.SQL client or a postgres:// URL`](#queueerror-a-postgresql-queue-store-needs-sql-a-bunsql-client-or-a-postgres-url)
55
+ - [`QueueError: sql is a … client; the queue needs a PostgreSQL one`](#queueerror-sql-is-a--client-the-queue-needs-a-postgresql-one)
56
+ - [`QueueError: tablePrefix must be lowercase letters, digits and underscores, starting with a letter or an underscore, at most 40 characters, not …`](#queueerror-tableprefix-must-be-lowercase-letters-digits-and-underscores-starting-with-a-letter-or-an-underscore-at-most-40-characters-not-)
57
+ - [`QueueError: The URL in sql cannot be opened: …`](#queueerror-the-url-in-sql-cannot-be-opened-)
58
+ - [`QueueError: The PostgreSQL queue cannot be set up: …`](#queueerror-the-postgresql-queue-cannot-be-set-up-)
59
+ - [`QueueError: The database is at schema version …, newer than this store's …`](#queueerror-the-database-is-at-schema-version--newer-than-this-stores-), and [`QueueError: The queue store is closed`](#queueerror-the-queue-store-is-closed), as for `bun:sqlite`
60
+
61
+ **The Redis store** (`@bumail/queue/redis`)
62
+
63
+ - [`QueueError: A Redis queue store needs client, a Bun.RedisClient, or url, a redis:// URL`](#queueerror-a-redis-queue-store-needs-client-a-bunredisclient-or-url-a-redis-url)
64
+ - [`QueueError: A Redis queue store takes client or url, not both`](#queueerror-a-redis-queue-store-takes-client-or-url-not-both)
65
+ - [`QueueError: keyPrefix must be lowercase letters, digits, '_', ':', '.' and '-', starting with a letter, at most 40 characters, not …`](#queueerror-keyprefix-must-be-lowercase-letters-digits-_---and---starting-with-a-letter-at-most-40-characters-not-)
66
+ - [`QueueError: The URL in url cannot be opened: …`](#queueerror-the-url-in-url-cannot-be-opened-)
67
+ - [`QueueError: The Redis queue cannot be set up: …`](#queueerror-the-redis-queue-cannot-be-set-up-)
68
+ - [`QueueError: The key …schema does not hold a layout version: is the prefix another application's?`](#queueerror-the-key-schema-does-not-hold-a-layout-version-is-the-prefix-another-applications)
69
+ - [`QueueError: The database is at schema version …, newer than this store's …`](#queueerror-the-database-is-at-schema-version--newer-than-this-stores-), and [`QueueError: The queue store is closed`](#queueerror-the-queue-store-is-closed), as for `bun:sqlite`
70
+
48
71
  **Listing** (`list`)
49
72
 
50
73
  - [`QueueError: limit must be an integer from 1 to 1000, not …`](#queueerror-limit-must-be-an-integer-from-1-to-1000-not-)
@@ -53,6 +76,10 @@ parts shown as … vary.
53
76
  **A store's own checks** (a store of your own, called directly)
54
77
 
55
78
  - [`QueueError: … must be a finite number`, `owner must be a non-empty string`, and the others](#a-stores-own-checks)
79
+ - [`QueueError: A reply is an object with a text`](#queueerror-a-reply-is-an-object-with-a-text)
80
+ - [`QueueError: reply.status must be a string`](#queueerror-replystatus-must-be-a-string)
81
+ - [`QueueError: reply.host must be a string`](#queueerror-replyhost-must-be-a-string)
82
+ - [`QueueError: … holds a NUL or a lone surrogate, which a store cannot keep`](#queueerror--holds-a-nul-or-a-lone-surrogate-which-a-store-cannot-keep)
56
83
 
57
84
  **Delivery**
58
85
 
@@ -228,8 +255,10 @@ or `… must be a number from … to …, not …`. A number option out of its r
228
255
 
229
256
  ### `QueueError: owner must be a non-empty string`
230
257
 
231
- **Code:** `INVALID`. Leave `owner` out for a random one, or give each
232
- worker its own name.
258
+ **Code:** `INVALID`, also as
259
+ [`owner holds a NUL or a lone surrogate, which a store cannot keep`](#queueerror--holds-a-nul-or-a-lone-surrogate-which-a-store-cannot-keep).
260
+ Leave `owner` out for a random one, or give
261
+ each worker its own name.
233
262
 
234
263
  ## The `error` event
235
264
 
@@ -245,8 +274,54 @@ the process stalled longer than that, or the store could not be reached.
245
274
 
246
275
  ### `QueueError: The lease on … was lost while it was delivered`
247
276
 
248
- **Code:** `LEASE_LOST`. A renewal found the lease taken: the same cause as
249
- above, seen sooner.
277
+ **Code:** `LEASE_LOST`. A renewal was refused while the item was still in
278
+ the store, held by another worker or by none (another worker took it and
279
+ let go of it): the same cause as above, seen sooner. A renewal that finds the item gone
280
+ says nothing: the outcome, once the sessions end, tells a cancel from a
281
+ lease lost (below).
282
+
283
+ ### `QueueError: The lease on … expired before its outcome was recorded, and the item is gone: lost to another worker that finished it, the message then sent twice, or cancelled`
284
+
285
+ **Code:** `LEASE_LOST`.
286
+ **When:** a delivery ran past its lease's expiry — no renewal reached the
287
+ store in time — and, when it tried to record its outcome, the item was no
288
+ longer in the store. No `delivered`, `deferred` or `failed` event and no
289
+ DSN come from this attempt.
290
+ **Why:** past its expiry, another worker may claim the item, deliver it
291
+ and drop it; but a `cancel` drops it too, and the store keeps no record of
292
+ which happened. The worker cannot tell them apart, so it says both rather
293
+ than report outcomes nobody recorded. When another worker took it, the
294
+ recipients both reached got the message twice; that worker's events tell
295
+ what it delivered.
296
+ **Fix:** as for the lease lost above: a longer `leaseMs`, or find what
297
+ stalled the process — an event loop blocked, a store out of reach. A
298
+ cancel under a lease that still held is told as before: its outcomes on
299
+ the events, with no error and no DSN.
300
+
301
+ When the instances' clocks are out of step, one whose clock runs ahead
302
+ can claim the item while this worker's own clock says the lease still
303
+ holds. If it finishes and drops the item between two of this worker's
304
+ renewals, no renewal saw it taken and the expiry has not passed by this
305
+ clock: the attempt reads as a cancel, its outcomes told on the events
306
+ with no error. Keep the machines on NTP; the 10-minute `leaseMs` leaves
307
+ room for seconds of skew.
308
+
309
+ ### `QueueError: The lease on … was lost while it was delivered, and the item is gone: finished by the worker that took it, the message then sent twice, or cancelled`
310
+
311
+ **Code:** `LEASE_LOST`, after
312
+ [`The lease on … was lost while it was delivered`](#queueerror-the-lease-on--was-lost-while-it-was-delivered).
313
+ **When:** a renewal was refused while the item was still in the store,
314
+ held by another worker or by none, and when this worker tried to record
315
+ its outcome the item was gone. No outcome event and no DSN come from this
316
+ attempt.
317
+ **Why:** another worker claimed the item once this one's lease had
318
+ expired by its own clock — this worker stalled, or the instances' clocks
319
+ disagree (an instance whose clock runs ahead sees leases expire early) —
320
+ and has most likely finished and dropped it, the recipients both reached
321
+ getting the message twice. A `cancel` after the renewal drops it too, and
322
+ the store keeps no record of which happened, so the message names both.
323
+ **Fix:** a longer `leaseMs`, find what stalled the process, and keep the
324
+ machines on NTP.
250
325
 
251
326
  ### `SmtpError: sendMail(): …` (`INVALID_OPTION`), with recipients deferred as `4.3.5`
252
327
 
@@ -271,6 +346,47 @@ tries again; only a renewal that finds the lease taken stops (as
271
346
  **Fix:** a longer `busyTimeout` when several processes write a lot; a
272
347
  store that keeps failing will lose the lease once it expires.
273
348
 
349
+ ### `PostgresError: …`, or a connection error, during a delivery
350
+
351
+ **When:** the `error` event, with the item's `id`, while it is delivered,
352
+ on `@bumail/queue/postgres`.
353
+ **Why:** a lease renewal failed: the database restarted, a connection
354
+ dropped, or the server refused one past its `max_connections`. As on
355
+ `bun:sqlite`, the next renewal tries again, and only a lease taken by
356
+ another instance stops them. A claim that fails the same way rejects
357
+ `deliverDue()`, or is told on `error` under `start()`, which tries again
358
+ at its next pass. An outcome that cannot be recorded is told on `error`
359
+ with the item's `id`; the item stays as it was, and is claimed again once
360
+ its lease expires — its recipients may then get the message twice.
361
+ **Fix:** check the database is up and reachable; when the server counts
362
+ too many connections, give each instance's client a smaller `max` (see
363
+ the guide's Pool size) or raise `max_connections`.
364
+
365
+ ### `RedisError: …` during a delivery: `Connection closed`, `OOM command not allowed …`, `READONLY …`
366
+
367
+ **When:** the `error` event, with the item's `id`, while it is delivered,
368
+ on `@bumail/queue/redis`. A call of yours — `enqueue`, `list`, `get`,
369
+ `retryNow`, `cancel` — rejects with the same `RedisError` for the same
370
+ reasons: an `enqueue` refused with `OOM …` kept nothing.
371
+ **Why:** a lease renewal failed. `Connection closed`: Redis restarted,
372
+ or the connection dropped, while the command was on its way; a command
373
+ sent while the client is reconnecting waits instead. `Max reconnection
374
+ attempts reached` or `Connection has failed`: Redis is out of reach, and
375
+ `Bun.RedisClient` gave up reconnecting (20 tries by default). `OOM
376
+ command not allowed when used memory > 'maxmemory'`: Redis is full and
377
+ refuses writes. `READONLY You can't write against a read only replica`:
378
+ the client reaches a replica, or a primary that was demoted by a
379
+ failover. As on PostgreSQL, the next renewal tries again, only a lease
380
+ taken by another instance stops them, a claim that fails rejects
381
+ `deliverDue()` or is told on `error` under `start()`, and an outcome
382
+ that cannot be recorded leaves the item to be claimed again once its
383
+ lease expires — its recipients may then get the message twice.
384
+ **Fix:** check Redis is up and that the URL names the primary (through
385
+ your provider's endpoint or Sentinel's, which follows a failover). For
386
+ `OOM`, give Redis more `maxmemory`, or drain the queue; keep
387
+ `maxmemory-policy noeviction` (the guide's [Redis](guide.md#redis)), or
388
+ Redis drops queue items to make room instead of refusing.
389
+
274
390
  ## The `bun:sqlite` store
275
391
 
276
392
  ### `QueueError: A SQLite queue store needs a directory`
@@ -287,8 +403,10 @@ should read the queue.
287
403
 
288
404
  ### `QueueError: The database is at schema version …, newer than this store's …`
289
405
 
290
- **Code:** `INVALID`. A newer version of the package wrote the database:
291
- upgrade this one.
406
+ **Code:** `INVALID`. A newer version of the package wrote the database
407
+ (`queue.sqlite`, on PostgreSQL the prefix's tables, on Redis the
408
+ prefix's keys, whose layout version is `<prefix>schema`): upgrade this
409
+ one.
292
410
 
293
411
  ### `QueueError: busyTimeout must be an integer of at least 0, not …`
294
412
 
@@ -297,7 +415,137 @@ upgrade this one.
297
415
  ### `QueueError: The queue store is closed`
298
416
 
299
417
  **Code:** `CLOSED`. The store was used after `close()`: call
300
- `queue.stop()` first, then `store.close()`.
418
+ `queue.stop()` first, then `store.close()`. The PostgreSQL and Redis
419
+ stores say the same.
420
+
421
+ ## The PostgreSQL store
422
+
423
+ ### `QueueError: A PostgreSQL queue store needs sql: a Bun.SQL client or a postgres:// URL`
424
+
425
+ **Code:** `INVALID`.
426
+ **When:** `PostgresQueueStore.open` without `sql`, or with a string that
427
+ is not a `postgres://` or `postgresql://` URL, or an object without
428
+ `unsafe`, `begin` and `close`. The message never repeats the URL, which
429
+ may hold a password.
430
+ **Fix:**
431
+
432
+ ```ts
433
+ PostgresQueueStore.open({ sql: 'postgres://bumail:secret@db.internal:5432/mail' });
434
+ // or a client of yours, sized as you need:
435
+ PostgresQueueStore.open({ sql: new Bun.SQL({ url, max: 10 }) });
436
+ ```
437
+
438
+ ### `QueueError: sql is a … client; the queue needs a PostgreSQL one`
439
+
440
+ **Code:** `INVALID`. A `Bun.SQL` client made for SQLite or MySQL
441
+ (`adapter: 'sqlite'`, a `mysql://` URL). For SQLite, use
442
+ `@bumail/queue/sqlite`; otherwise give a PostgreSQL client.
443
+
444
+ ### `QueueError: tablePrefix must be lowercase letters, digits and underscores, starting with a letter or an underscore, at most 40 characters, not …`
445
+
446
+ **Code:** `INVALID`.
447
+ **Why:** the prefix is written into every statement, never bound as a
448
+ value, so anything that is not a plain name is refused — and 40
449
+ characters keep the longest name built on it within PostgreSQL's 63.
450
+ **Fix:** `tablePrefix: 'mail_queue_'`. To put the tables in another
451
+ schema, set the connection's `search_path` rather than a dotted prefix.
452
+
453
+ ### `QueueError: The URL in sql cannot be opened: …`
454
+
455
+ **Code:** `INVALID`.
456
+ **When:** `PostgresQueueStore.open` with a URL `Bun.SQL` refuses before
457
+ connecting, such as a `sslmode` it does not know. The rest is Bun's
458
+ reason (`The argument 'sslmode' must be one of: disable, allow, prefer,
459
+ require, verify-ca, verify-full. Received '…'`); the URL is never
460
+ repeated, and the password is masked should the reason name it.
461
+ **Fix:** correct the parameter: `?sslmode=require`, or `verify-full` to
462
+ check the server's certificate.
463
+
464
+ ### `QueueError: The PostgreSQL queue cannot be set up: …`
465
+
466
+ **Code:** `INVALID`.
467
+ **When:** the first call on a store, or `migrate()`, when the tables
468
+ cannot be made or brought up to date. The rest of the message is the
469
+ database's: `Failed to connect`, `password authentication failed for
470
+ user "…"`, `permission denied for schema public`.
471
+ **Why:** the database is out of reach, the credentials are wrong, or the
472
+ tables are missing or behind and the role may not create them: a role
473
+ without `CREATE` can use tables that are current, never make them.
474
+ **Fix:** check the URL and that the server answers. For `permission
475
+ denied`, run `migrate()` once with an owner's role — on a new database,
476
+ and after an upgrade that adds a migration — and keep the workers on
477
+ their narrower role (the guide's [Migrations](guide.md#migrations)); or
478
+ give the role `CREATE` on the schema. The next call tries again: nothing is left half made,
479
+ since a migration is one transaction.
480
+
481
+ ## The Redis store
482
+
483
+ ### `QueueError: A Redis queue store needs client, a Bun.RedisClient, or url, a redis:// URL`
484
+
485
+ **Code:** `INVALID`.
486
+ **When:** `RedisQueueStore.open` with neither `client` nor `url`, a
487
+ `url` that is not a URL of a scheme `Bun.RedisClient` takes (`redis://`,
488
+ `rediss://`, `valkey://`, `valkeys://`, `redis+tls://`, `redis+unix://`,
489
+ `redis+tls+unix://`), or a `client` without `send`, `getBuffer` and
490
+ `close`. The message never repeats the URL, which may hold a password.
491
+ **Fix:**
492
+
493
+ ```ts
494
+ RedisQueueStore.open({ url: 'rediss://bumail:secret@cache.internal:6380/0' });
495
+ // or a client of yours, configured as you need:
496
+ RedisQueueStore.open({ client: new Bun.RedisClient(url, { connectionTimeout: 5000 }) });
497
+ ```
498
+
499
+ ### `QueueError: A Redis queue store takes client or url, not both`
500
+
501
+ **Code:** `INVALID`. Give one: `client` for a client you configure and
502
+ close, `url` for one the store opens and closes.
503
+
504
+ ### `QueueError: keyPrefix must be lowercase letters, digits, '_', ':', '.' and '-', starting with a letter, at most 40 characters, not …`
505
+
506
+ **Code:** `INVALID`.
507
+ **Why:** the prefix starts every key the store writes. Anything Redis or
508
+ a `SCAN MATCH` reads specially is refused: `{` (a Cluster hash tag), `*`,
509
+ `?`, `[` and `\` (glob characters), spaces and control characters.
510
+ **Fix:** `keyPrefix: 'mail:queue:'`. Give each queue in one Redis
511
+ database its own prefix.
512
+
513
+ ### `QueueError: The URL in url cannot be opened: …`
514
+
515
+ **Code:** `INVALID`.
516
+ **When:** `RedisQueueStore.open` with a URL `Bun.RedisClient` refuses
517
+ before connecting, such as a database that is not a number (`Invalid
518
+ database number in Redis URL: "…"`). The URL is never repeated, and the
519
+ password is masked should the reason name it.
520
+ **Fix:** correct the URL: the database is the path, `/0` to `/15` on a
521
+ default Redis.
522
+
523
+ ### `QueueError: The Redis queue cannot be set up: …`
524
+
525
+ **Code:** `INVALID`.
526
+ **When:** the first call on a store, which reads the layout version of
527
+ its keys (`<prefix>schema`), writing it on a new queue. The rest of the
528
+ message is Redis's or Bun's: `Max reconnection attempts reached` (out of
529
+ reach), `WRONGPASS invalid username-password pair or user is disabled.`,
530
+ `ERR DB index is out of range`, `NOPERM …` (an ACL user without
531
+ `EVALSHA`, `EVAL` or the keys).
532
+ **Why:** the server is out of reach, the credentials or the database are
533
+ wrong, or the user may not run scripts. With `Bun.RedisClient`'s
534
+ defaults an unreachable server takes about half a minute of reconnecting
535
+ before the call fails.
536
+ **Fix:** check the URL and that Redis answers. An ACL user needs the
537
+ commands the store sends and those its scripts call, on the keys under
538
+ its prefix, and nothing else (the guide's [Redis](guide.md#redis) has
539
+ the `ACL SETUSER` line). The next call tries again.
540
+
541
+ ### `QueueError: The key …schema does not hold a layout version: is the prefix another application's?`
542
+
543
+ **Code:** `INVALID`.
544
+ **When:** the first call on a store whose `<prefix>schema` key holds
545
+ something other than a whole number of at least 1.
546
+ **Why:** the store keeps its layout version there; anything else means
547
+ another application, or a hand, wrote under the same prefix.
548
+ **Fix:** give the queue a `keyPrefix` of its own. Nothing was written.
301
549
 
302
550
  ## Listing
303
551
 
@@ -337,9 +585,71 @@ never gives it. Each message names what is wrong:
337
585
  - `QueueError: delayNotified must be true or false`
338
586
  - `QueueError: recipients must be an array of { address, status:
339
587
  delivered, deferred or failed }`
588
+ - [`QueueError: A reply is an object with a text`](#queueerror-a-reply-is-an-object-with-a-text),
589
+ [`reply.status must be a string`](#queueerror-replystatus-must-be-a-string),
590
+ [`reply.host must be a string`](#queueerror-replyhost-must-be-a-string)
591
+ - [`QueueError: … holds a NUL or a lone surrogate, which a store cannot
592
+ keep`](#queueerror--holds-a-nul-or-a-lone-surrogate-which-a-store-cannot-keep)
340
593
 
341
594
  A list's `offset` and `limit` are checked as under [Listing](#listing).
342
595
 
596
+ ### `QueueError: A reply is an object with a text`
597
+
598
+ **Code:** `INVALID`.
599
+ **When:** `complete` called directly with a recipient whose `reply` is
600
+ not an object, or has no string `text`.
601
+ **Why:** a reply is a `Diagnostic`, `{ code?, status?, text, host? }`:
602
+ `text` is what a DSN and the events show, so every store needs it. The
603
+ queue's own outcomes always have one.
604
+ **Fix:** give the text, even for a reply with no code:
605
+
606
+ ```ts
607
+ await store.complete(id, owner, {
608
+ ...result,
609
+ recipients: [{ address, status: 'deferred', reply: { status: '4.4.1', text: 'No answer' } }],
610
+ });
611
+ ```
612
+
613
+ ### `QueueError: reply.status must be a string`
614
+
615
+ **Code:** `INVALID`.
616
+ **When:** `complete` called directly with a `reply.status` that is
617
+ neither left out nor a string — often the reply code given as a number.
618
+ **Why:** `status` is the enhanced status code, `x.y.z` (RFC 3463), kept
619
+ as text; the reply code goes in `code`.
620
+ **Fix:** `reply: { code: 451, status: '4.3.0', text: 'Try later' }`, or
621
+ leave `status` out.
622
+
623
+ ### `QueueError: reply.host must be a string`
624
+
625
+ **Code:** `INVALID`.
626
+ **When:** `complete` called directly with a `reply.host` that is neither
627
+ left out nor a string, such as an address object from a socket.
628
+ **Why:** `host` is the name or address the attempt was for, kept as text.
629
+ **Fix:** give it as text — `host: 'mx1.example.com'` or
630
+ `host: '192.0.2.1'` — or leave it out.
631
+
632
+ ### `QueueError: … holds a NUL or a lone surrogate, which a store cannot keep`
633
+
634
+ **Code:** `INVALID`. The … is `from`, `A recipient`, `owner`,
635
+ `reply.text`, `reply.status` or `reply.host`.
636
+ **When:** `add`, `complete`, or any call that takes an `owner`, called
637
+ directly with a text holding U+0000 or half of a surrogate pair — a string cut in the
638
+ middle of an emoji, say. `createQueue` refuses such an `owner` the same
639
+ way.
640
+ **Why:** PostgreSQL cannot keep a NUL in text, and no UTF-8 encoding can
641
+ carry a lone surrogate (`Bun.RedisClient` would write one as U+FFFD
642
+ without a word), so every store refuses them alike — whichever
643
+ store you give it, the same text is kept or refused. The queue's own
644
+ replies never hold one: it cuts a reply's text only between whole
645
+ characters.
646
+ **Fix:** drop the NULs and cut text on whole characters before you give
647
+ it to the store:
648
+
649
+ ```ts
650
+ const clean = text.replaceAll('\0', '').toWellFormed();
651
+ ```
652
+
343
653
  ## Delivery
344
654
 
345
655
  ### A recipient stays `deferred` with `4.4.1` or `4.4.2`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bumail/queue",
3
- "version": "0.1.0",
4
- "description": "The outbound mail queue of a mail server: every recipient's state, retries with back-off, delivery status notifications (RFC 3464), leases for several workers, and a memory and a bun:sqlite store, delivering through @bumail/smtp/client",
3
+ "version": "0.3.0",
4
+ "description": "The outbound mail queue of a mail server: every recipient's state, retries with back-off, delivery status notifications (RFC 3464), leases for several workers, and a memory, a bun:sqlite, a PostgreSQL (Bun.sql) and a Redis (Bun.redis) store, delivering through @bumail/smtp/client",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -29,6 +29,16 @@
29
29
  "import": "./dist/sqlite/index.js",
30
30
  "default": "./dist/sqlite/index.js"
31
31
  },
32
+ "./postgres": {
33
+ "types": "./dist/postgres/index.d.ts",
34
+ "import": "./dist/postgres/index.js",
35
+ "default": "./dist/postgres/index.js"
36
+ },
37
+ "./redis": {
38
+ "types": "./dist/redis/index.d.ts",
39
+ "import": "./dist/redis/index.js",
40
+ "default": "./dist/redis/index.js"
41
+ },
32
42
  "./package.json": "./package.json"
33
43
  },
34
44
  "repository": {
@@ -49,7 +59,9 @@
49
59
  "entrypoints": [
50
60
  "src/index.ts",
51
61
  "src/memory/index.ts",
52
- "src/sqlite/index.ts"
62
+ "src/sqlite/index.ts",
63
+ "src/postgres/index.ts",
64
+ "src/redis/index.ts"
53
65
  ]
54
66
  },
55
67
  "devDependencies": {
@@ -1,15 +0,0 @@
1
- // src/errors.ts
2
- class QueueError2 extends Error {
3
- name = "QueueError";
4
- code;
5
- constructor(code, message) {
6
- super(message);
7
- this.code = code;
8
- }
9
- }
10
- var invalid = (message) => new QueueError2("INVALID", message);
11
-
12
- export { QueueError2, invalid };
13
-
14
- //# debugId=465D2E3985AB194764756E2164756E21
15
- //# sourceMappingURL=index-p4k4rjb7.js.map
@@ -1,10 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/errors.ts"],
4
- "sourcesContent": [
5
- "/**\n * Why the queue or a store refused a call. `INVALID`: an option or an\n * argument it cannot take. `MESSAGE_TOO_BIG`, `TOO_MANY_RECIPIENTS` and\n * `QUEUE_FULL`: a limit `enqueue` enforces. `CLOSED`: a store used after\n * `close()`. `LEASE_LOST`: given to the `error` event when another worker\n * took an item before its outcome was recorded.\n */\nexport type QueueErrorCode =\n\t| 'INVALID'\n\t| 'MESSAGE_TOO_BIG'\n\t| 'TOO_MANY_RECIPIENTS'\n\t| 'QUEUE_FULL'\n\t| 'CLOSED'\n\t| 'LEASE_LOST';\n\n/** Thrown by the queue and by every store, whichever answers the contract, with the same codes. */\nexport class QueueError extends Error {\n\toverride readonly name = 'QueueError';\n\treadonly code: QueueErrorCode;\n\n\tconstructor(code: QueueErrorCode, message: string) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\t}\n}\n\n/** `INVALID`, for an option or an argument. */\nexport const invalid = (message: string) => new QueueError('INVALID', message);\n"
6
- ],
7
- "mappings": ";AAgBO,MAAM,oBAAmB,MAAM;AAAA,EACnB,OAAO;AAAA,EAChB;AAAA,EAET,WAAW,CAAC,MAAsB,SAAiB;AAAA,IAClD,MAAM,OAAO;AAAA,IACb,KAAK,OAAO;AAAA;AAEd;AAGO,IAAM,UAAU,CAAC,YAAoB,IAAI,YAAW,WAAW,OAAO;",
8
- "debugId": "465D2E3985AB194764756E2164756E21",
9
- "names": []
10
- }
@@ -1,10 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/contract/checks.ts"],
4
- "sourcesContent": [
5
- "import { invalid } from '../errors';\nimport type {\n\tAttemptResult,\n\tClaimRequest,\n\tNewQueueItem,\n\tQueueItem,\n\tQueueListOptions,\n\tRecipientState,\n} from './types';\n\n/** What every store checks the same way, so each refuses the same input with the same error. */\n\nconst isTime = (value: unknown): value is number =>\n\ttypeof value === 'number' && Number.isFinite(value);\n\n/** A time in milliseconds since the epoch, or `INVALID` naming it. */\nexport function checkTime(name: string, value: unknown): number {\n\tif (!isTime(value)) throw invalid(`${name} must be a finite number`);\n\treturn value;\n}\n\n/** A lease owner: a non-empty string. */\nexport function checkOwner(owner: unknown): string {\n\tif (typeof owner !== 'string' || owner === '') {\n\t\tthrow invalid('owner must be a non-empty string');\n\t}\n\treturn owner;\n}\n\nexport function checkNewItem(item: NewQueueItem): void {\n\tif (typeof item !== 'object' || item === null) {\n\t\tthrow invalid('A new item is an object');\n\t}\n\tif (typeof item.from !== 'string') throw invalid('from must be a string');\n\tconst to = item.to as unknown;\n\tif (\n\t\t!Array.isArray(to) ||\n\t\tto.length === 0 ||\n\t\t!to.every((a) => typeof a === 'string' && a !== '')\n\t) {\n\t\tthrow invalid('to must be a non-empty array of addresses');\n\t}\n\tif (!(item.message instanceof Uint8Array)) {\n\t\tthrow invalid('message must be a Uint8Array');\n\t}\n\tcheckTime('createdAt', item.createdAt);\n}\n\n/** `maxItems`: a positive integer, or nothing. */\nexport function checkMaxItems(max: unknown): number | undefined {\n\tif (max === undefined) return undefined;\n\tif (typeof max !== 'number' || !Number.isInteger(max) || max < 1) {\n\t\tthrow invalid(`maxItems must be an integer of at least 1, not ${max}`);\n\t}\n\treturn max;\n}\n\nexport function checkClaim(request: ClaimRequest): void {\n\tif (typeof request !== 'object' || request === null) {\n\t\tthrow invalid('A claim is an object');\n\t}\n\tcheckOwner(request.owner);\n\tcheckTime('now', request.now);\n\tif (!isTime(request.leaseMs) || request.leaseMs <= 0) {\n\t\tthrow invalid('leaseMs must be a positive number');\n\t}\n}\n\nexport function checkResult(result: AttemptResult): void {\n\tif (typeof result !== 'object' || result === null) {\n\t\tthrow invalid('An attempt result is an object');\n\t}\n\tcheckTime('now', result.now);\n\tcheckTime('nextAttemptAt', result.nextAttemptAt);\n\tif (!Number.isInteger(result.attempts) || result.attempts < 0) {\n\t\tthrow invalid('attempts must be an integer of at least 0');\n\t}\n\tif (typeof result.delayNotified !== 'boolean') {\n\t\tthrow invalid('delayNotified must be true or false');\n\t}\n\tconst statuses = ['delivered', 'deferred', 'failed'];\n\tif (\n\t\t!Array.isArray(result.recipients) ||\n\t\t!result.recipients.every(\n\t\t\t(u) => typeof u?.address === 'string' && statuses.includes(u.status),\n\t\t)\n\t) {\n\t\tthrow invalid(\n\t\t\t'recipients must be an array of { address, status: delivered, deferred or failed }',\n\t\t);\n\t}\n}\n\n/** The offset and limit of a list: integers, the limit 1 to 1000. */\nexport function checkList(options: QueueListOptions = {}): {\n\toffset: number;\n\tlimit: number;\n} {\n\tconst offset = options.offset ?? 0;\n\tconst limit = options.limit ?? 100;\n\tif (!Number.isInteger(offset) || offset < 0) {\n\t\tthrow invalid(`offset must be an integer of at least 0, not ${offset}`);\n\t}\n\tif (!Number.isInteger(limit) || limit < 1 || limit > 1000) {\n\t\tthrow invalid(`limit must be an integer from 1 to 1000, not ${limit}`);\n\t}\n\treturn { offset, limit };\n}\n\n/** Every recipient is `delivered` or `failed`. */\nexport const isDone = (item: Pick<QueueItem, 'recipients'>): boolean =>\n\titem.recipients.every(\n\t\t(r) => r.status === 'delivered' || r.status === 'failed',\n\t);\n\n/**\n * The item once an attempt is recorded: its recipients' outcomes, a final\n * state never changed again, the schedule, and no lease.\n */\nexport function applyAttempt(\n\titem: QueueItem,\n\tresult: AttemptResult,\n): QueueItem {\n\tconst updates = new Map(result.recipients.map((u) => [u.address, u]));\n\tconst recipients = item.recipients.map((r): RecipientState => {\n\t\tconst update = updates.get(r.address);\n\t\tif (!update || r.status === 'delivered' || r.status === 'failed') return r;\n\t\treturn {\n\t\t\taddress: r.address,\n\t\t\tstatus: update.status,\n\t\t\t...(update.reply ? { reply: update.reply } : {}),\n\t\t\tupdatedAt: result.now,\n\t\t};\n\t});\n\tconst { lease: _, ...rest } = item;\n\treturn {\n\t\t...rest,\n\t\trecipients,\n\t\tnextAttemptAt: result.nextAttemptAt,\n\t\tattempts: result.attempts,\n\t\tdelayNotified: result.delayNotified,\n\t};\n}\n\n/** A new item as a store first keeps it. */\nexport function newItem(id: string, item: NewQueueItem): QueueItem {\n\treturn {\n\t\tid,\n\t\tfrom: item.from,\n\t\trecipients: item.to.map((address) => ({ address, status: 'pending' })),\n\t\tsize: item.message.length,\n\t\tcreatedAt: item.createdAt,\n\t\tnextAttemptAt: item.createdAt,\n\t\tattempts: 0,\n\t\tdelayNotified: false,\n\t};\n}\n\n/** Whether a lease, if any, leaves the item free to claim at `now`. */\nexport const claimable = (item: QueueItem, now: number): boolean =>\n\titem.nextAttemptAt <= now &&\n\t(item.lease === undefined || item.lease.expiresAt <= now);\n"
6
- ],
7
- "mappings": "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",
8
- "debugId": "70E823C3942B47A164756E2164756E21",
9
- "names": []
10
- }