@bumail/queue 0.10.0 → 0.12.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 (68) hide show
  1. package/README.md +52 -1
  2. package/dist/chunks/{index-sz13a17b.js → index-t6q7zxgj.js} +14 -3
  3. package/dist/chunks/{index-sz13a17b.js.map → index-t6q7zxgj.js.map} +4 -4
  4. package/dist/conformance/claims.d.ts +4 -0
  5. package/dist/conformance/claims.d.ts.map +1 -0
  6. package/dist/conformance/complete-dsns.d.ts +4 -0
  7. package/dist/conformance/complete-dsns.d.ts.map +1 -0
  8. package/dist/conformance/concurrency.d.ts +4 -0
  9. package/dist/conformance/concurrency.d.ts.map +1 -0
  10. package/dist/conformance/describe-queue-store.d.ts +11 -0
  11. package/dist/conformance/describe-queue-store.d.ts.map +1 -0
  12. package/dist/conformance/doubt.d.ts +5 -0
  13. package/dist/conformance/doubt.d.ts.map +1 -0
  14. package/dist/conformance/dsn.d.ts +4 -0
  15. package/dist/conformance/dsn.d.ts.map +1 -0
  16. package/dist/conformance/index.d.ts +3 -0
  17. package/dist/conformance/index.d.ts.map +1 -0
  18. package/dist/conformance/index.js +820 -0
  19. package/dist/conformance/index.js.map +18 -0
  20. package/dist/conformance/items.d.ts +4 -0
  21. package/dist/conformance/items.d.ts.map +1 -0
  22. package/dist/conformance/outcomes.d.ts +4 -0
  23. package/dist/conformance/outcomes.d.ts.map +1 -0
  24. package/dist/conformance/setup.d.ts +40 -0
  25. package/dist/conformance/setup.d.ts.map +1 -0
  26. package/dist/contract/checks.d.ts +4 -0
  27. package/dist/contract/checks.d.ts.map +1 -1
  28. package/dist/contract/queue-store.d.ts +15 -7
  29. package/dist/contract/queue-store.d.ts.map +1 -1
  30. package/dist/contract/types.d.ts +5 -0
  31. package/dist/contract/types.d.ts.map +1 -1
  32. package/dist/errors.d.ts +3 -1
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +64 -26
  37. package/dist/index.js.map +4 -4
  38. package/dist/memory/index.js +10 -6
  39. package/dist/memory/index.js.map +3 -3
  40. package/dist/memory/store.d.ts +2 -2
  41. package/dist/memory/store.d.ts.map +1 -1
  42. package/dist/postgres/complete.d.ts +13 -0
  43. package/dist/postgres/complete.d.ts.map +1 -0
  44. package/dist/postgres/index.js +85 -74
  45. package/dist/postgres/index.js.map +6 -5
  46. package/dist/postgres/store.d.ts +2 -2
  47. package/dist/postgres/store.d.ts.map +1 -1
  48. package/dist/queue/deliver.d.ts.map +1 -1
  49. package/dist/queue/notify.d.ts +35 -6
  50. package/dist/queue/notify.d.ts.map +1 -1
  51. package/dist/redis/complete.d.ts +11 -0
  52. package/dist/redis/complete.d.ts.map +1 -0
  53. package/dist/redis/index.js +133 -110
  54. package/dist/redis/index.js.map +7 -6
  55. package/dist/redis/scripts.d.ts +8 -5
  56. package/dist/redis/scripts.d.ts.map +1 -1
  57. package/dist/redis/store.d.ts +2 -2
  58. package/dist/redis/store.d.ts.map +1 -1
  59. package/dist/sqlite/index.js +29 -20
  60. package/dist/sqlite/index.js.map +5 -4
  61. package/dist/sqlite/insert.d.ts +5 -0
  62. package/dist/sqlite/insert.d.ts.map +1 -0
  63. package/dist/sqlite/store.d.ts +2 -2
  64. package/dist/sqlite/store.d.ts.map +1 -1
  65. package/docs/guide.md +113 -2
  66. package/docs/roadmap.md +46 -12
  67. package/docs/troubleshooting.md +106 -1
  68. package/package.json +11 -5
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/sqlite/open.ts", "../src/sqlite/schema.ts", "../src/sqlite/rows.ts", "../src/sqlite/store.ts"],
3
+ "sources": ["../src/sqlite/insert.ts", "../src/sqlite/open.ts", "../src/sqlite/schema.ts", "../src/sqlite/rows.ts", "../src/sqlite/store.ts"],
4
4
  "sourcesContent": [
5
+ "import type { Database } from 'bun:sqlite';\nimport { dsnColumn } from '../contract/dsn-column';\nimport type { QueueItem } from '../contract/types';\n\n/** Stores a new item and its message; the caller holds the transaction. */\nexport function insertItem(\n\tdb: Database,\n\tadded: QueueItem,\n\tmessage: Uint8Array,\n): void {\n\tdb.query(\n\t\t`INSERT INTO items (id, sender, recipients, size, created_at,\n\t\tnext_attempt_at, attempts, delay_notified, dsn)\n\t\tVALUES ($id, $sender, $recipients, $size, $created_at,\n\t\t$next_attempt_at, 0, 0, $dsn)`,\n\t).run({\n\t\tid: added.id,\n\t\tsender: added.from,\n\t\trecipients: JSON.stringify(added.recipients),\n\t\tsize: added.size,\n\t\tcreated_at: added.createdAt,\n\t\tnext_attempt_at: added.nextAttemptAt,\n\t\tdsn: dsnColumn(added),\n\t});\n\tdb.query('INSERT INTO messages (item_id, content) VALUES (?, ?)').run(\n\t\tadded.id,\n\t\tmessage,\n\t);\n}\n",
5
6
  "import { Database } from 'bun:sqlite';\nimport { chmodSync, existsSync, mkdirSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { QueueError } from '../errors';\nimport type { SqliteQueueStoreOptions } from './options';\nimport { migrate } from './schema';\n\n/**\n * The queue's files, and the directory when the store makes it: its\n * owner's alone, as mail is. A directory that already exists keeps its\n * mode — it is the operator's, who may share it with a group on purpose.\n */\nconst PRIVATE_DIRECTORY = 0o700;\nconst PRIVATE_FILE = 0o600;\n\n/**\n * Several processes may share the database: WAL lets readers go on while\n * one writes, and a writer waits up to `busy_timeout` for another. Every\n * write is flushed before it is acknowledged.\n */\nfunction configure(db: Database, busyTimeout: number): void {\n\tdb.exec(`PRAGMA busy_timeout = ${busyTimeout}`);\n\tdb.exec('PRAGMA journal_mode = WAL');\n\tdb.exec('PRAGMA synchronous = FULL');\n\t// On macOS a plain fsync leaves the data in the drive's cache.\n\tdb.exec('PRAGMA fullfsync = ON');\n\tdb.exec('PRAGMA foreign_keys = ON');\n}\n\nfunction keepPrivate(file: string): void {\n\tfor (const path of [file, `${file}-wal`, `${file}-shm`]) {\n\t\tif (existsSync(path)) chmodSync(path, PRIVATE_FILE);\n\t}\n}\n\nfunction busyTimeoutOf(value: unknown): number {\n\tif (value === undefined) return 5000;\n\tif (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {\n\t\tthrow new QueueError(\n\t\t\t'INVALID',\n\t\t\t`busyTimeout must be an integer of at least 0, not ${value}`,\n\t\t);\n\t}\n\treturn value;\n}\n\nconst messageOf = (error: unknown) =>\n\terror instanceof Error ? error.message : String(error);\n\n/**\n * Opens the queue's database in `directory`, at the last migration.\n * Whatever fails is `INVALID`, naming the directory.\n */\nexport function openDatabase(options: SqliteQueueStoreOptions): Database {\n\tconst directory = (options as SqliteQueueStoreOptions | undefined)?.directory;\n\tif (typeof directory !== 'string' || directory === '') {\n\t\tthrow new QueueError('INVALID', 'A SQLite queue store needs a directory');\n\t}\n\tconst busyTimeout = busyTimeoutOf(options.busyTimeout);\n\tlet db: Database | undefined;\n\ttry {\n\t\tif (!existsSync(directory)) {\n\t\t\tmkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY });\n\t\t\t// The mode given to mkdir is narrowed by the umask, never widened: set it.\n\t\t\tchmodSync(directory, PRIVATE_DIRECTORY);\n\t\t}\n\t\tconst file = join(directory, 'queue.sqlite');\n\t\tdb = new Database(file, { create: true, readwrite: true, strict: true });\n\t\tconfigure(db, busyTimeout);\n\t\tkeepPrivate(file);\n\t\tmigrate(db);\n\t\treturn db;\n\t} catch (error) {\n\t\tdb?.close();\n\t\tif (error instanceof QueueError) throw error;\n\t\tthrow new QueueError(\n\t\t\t'INVALID',\n\t\t\t`The queue at \"${directory}\" cannot be opened: ${messageOf(error)}`,\n\t\t);\n\t}\n}\n",
6
7
  "import type { Database } from 'bun:sqlite';\nimport { QueueError } from '../errors';\n\n/**\n * The migrations, in order: the database's `PRAGMA user_version` is how many\n * of them it has run. A migration is never edited once released; a change\n * is a new one at the end.\n */\nexport const MIGRATIONS: readonly string[] = [\n\t// 1: the items, and their messages in a table of their own.\n\t`\n\tCREATE TABLE items (\n\t\tseq INTEGER PRIMARY KEY,\n\t\tid TEXT NOT NULL UNIQUE,\n\t\tsender TEXT NOT NULL,\n\t\trecipients TEXT NOT NULL CHECK (json_valid(recipients)),\n\t\tsize INTEGER NOT NULL,\n\t\tcreated_at REAL NOT NULL,\n\t\tnext_attempt_at REAL NOT NULL,\n\t\tattempts INTEGER NOT NULL,\n\t\tdelay_notified INTEGER NOT NULL,\n\t\tlease_owner TEXT,\n\t\tlease_expires_at REAL,\n\t\tCHECK ((lease_owner IS NULL) = (lease_expires_at IS NULL))\n\t) STRICT;\n\tCREATE INDEX items_due ON items (next_attempt_at, seq);\n\n\tCREATE TABLE messages (\n\t\titem_id TEXT PRIMARY KEY REFERENCES items (id) ON DELETE CASCADE,\n\t\tcontent BLOB NOT NULL\n\t) STRICT;\n\t`,\n\t// 2: the item's DSN parameters (RET, ENVID) as JSON; NULL for the rows of before.\n\t`ALTER TABLE items ADD COLUMN dsn TEXT CHECK (dsn IS NULL OR json_valid(dsn))`,\n];\n\n/**\n * Brings the database to the last migration, in one transaction: a\n * migration that fails leaves the database as it was. A database written\n * by a newer store, with more migrations than this one knows, is refused.\n */\nexport function migrate(db: Database): void {\n\tdb.transaction(() => {\n\t\tconst { user_version: version } = db\n\t\t\t.query<{ user_version: number }, []>('PRAGMA user_version')\n\t\t\t.get() as { user_version: number };\n\t\tif (version > MIGRATIONS.length) {\n\t\t\tthrow new QueueError(\n\t\t\t\t'INVALID',\n\t\t\t\t`The database is at schema version ${version}, newer than this store's ${MIGRATIONS.length}`,\n\t\t\t);\n\t\t}\n\t\tfor (const sql of MIGRATIONS.slice(version)) db.exec(sql);\n\t\tif (version < MIGRATIONS.length) {\n\t\t\tdb.exec(`PRAGMA user_version = ${MIGRATIONS.length}`);\n\t\t}\n\t}).immediate();\n}\n",
7
8
  "import { dsnParams } from '../contract/dsn-column';\nimport type { QueueItem, RecipientState } from '../contract/types';\n\n/** A row of `items`, as `bun:sqlite` reads it. */\nexport interface ItemRow {\n\treadonly id: string;\n\treadonly sender: string;\n\treadonly recipients: string;\n\treadonly size: number;\n\treadonly created_at: number;\n\treadonly next_attempt_at: number;\n\treadonly attempts: number;\n\treadonly delay_notified: number;\n\treadonly lease_owner: string | null;\n\treadonly lease_expires_at: number | null;\n\treadonly dsn: string | null;\n}\n\n/** The columns a query reads to build an item. */\nexport const COLUMNS =\n\t'id, sender, recipients, size, created_at, next_attempt_at, attempts, delay_notified, lease_owner, lease_expires_at, dsn';\n\nexport function itemOf(row: ItemRow): QueueItem {\n\tconst item: QueueItem = {\n\t\tid: row.id,\n\t\tfrom: row.sender,\n\t\trecipients: JSON.parse(row.recipients) as RecipientState[],\n\t\tsize: row.size,\n\t\tcreatedAt: row.created_at,\n\t\tnextAttemptAt: row.next_attempt_at,\n\t\tattempts: row.attempts,\n\t\tdelayNotified: row.delay_notified === 1,\n\t\t...dsnParams(row.dsn),\n\t};\n\tif (row.lease_owner === null || row.lease_expires_at === null) return item;\n\treturn {\n\t\t...item,\n\t\tlease: { owner: row.lease_owner, expiresAt: row.lease_expires_at },\n\t};\n}\n\n/** The named parameters that write an item's state back. */\nexport function stateOf(item: QueueItem) {\n\treturn {\n\t\tid: item.id,\n\t\trecipients: JSON.stringify(item.recipients),\n\t\tnext_attempt_at: item.nextAttemptAt,\n\t\tattempts: item.attempts,\n\t\tdelay_notified: item.delayNotified ? 1 : 0,\n\t\tlease_owner: item.lease?.owner ?? null,\n\t\tlease_expires_at: item.lease?.expiresAt ?? null,\n\t};\n}\n",
8
- "import type { Database } from 'bun:sqlite';\nimport {\n\tapplyAttempt,\n\tcheckClaim,\n\tcheckList,\n\tcheckMaxItems,\n\tcheckNewItem,\n\tcheckOwner,\n\tcheckResult,\n\tcheckTime,\n\tisDone,\n\tnewItem,\n} from '../contract/checks';\nimport { dsnColumn } from '../contract/dsn-column';\nimport type { QueueStore } from '../contract/queue-store';\nimport type {\n\tAddOptions,\n\tAttemptResult,\n\tClaimRequest,\n\tNewQueueItem,\n\tQueueItem,\n\tQueueListOptions,\n} from '../contract/types';\nimport { QueueError } from '../errors';\nimport { openDatabase } from './open';\nimport type { SqliteQueueStoreOptions } from './options';\nimport { COLUMNS, type ItemRow, itemOf, stateOf } from './rows';\n\nexport type { SqliteQueueStoreOptions } from './options';\n\nconst DUE = `next_attempt_at <= $now\n\tAND (lease_owner IS NULL OR lease_expires_at <= $now)`;\n\n/**\n * A `QueueStore` on `bun:sqlite`: one directory holding `queue.sqlite`,\n * which several processes on one machine may open together. Every\n * operation is one transaction — a claim is a single `UPDATE … RETURNING`\n * — so two workers never take the same item. Messages live in a table of\n * their own, dropped with their item.\n */\nexport class SqliteQueueStore implements QueueStore {\n\treadonly #db: Database;\n\t#closed = false;\n\n\tprivate constructor(db: Database) {\n\t\tthis.#db = db;\n\t}\n\n\t/** Opens the store in `directory`, creating it if need be; what fails is `INVALID`. */\n\tstatic open(options: SqliteQueueStoreOptions): SqliteQueueStore {\n\t\treturn new SqliteQueueStore(openDatabase(options));\n\t}\n\n\t/** Lets go of the database; closing twice is fine. */\n\tclose(): void {\n\t\tif (this.#closed) return;\n\t\tthis.#closed = true;\n\t\tthis.#db.close();\n\t}\n\n\tget #open(): Database {\n\t\tif (this.#closed) {\n\t\t\tthrow new QueueError('CLOSED', 'The queue store is closed');\n\t\t}\n\t\treturn this.#db;\n\t}\n\n\t#row(id: string, owner?: string): ItemRow | null {\n\t\tconst db = this.#open;\n\t\tif (owner === undefined) {\n\t\t\treturn db\n\t\t\t\t.query<ItemRow, [string]>(`SELECT ${COLUMNS} FROM items WHERE id = ?`)\n\t\t\t\t.get(id);\n\t\t}\n\t\treturn db\n\t\t\t.query<ItemRow, [string, string]>(\n\t\t\t\t`SELECT ${COLUMNS} FROM items WHERE id = ? AND lease_owner = ?`,\n\t\t\t)\n\t\t\t.get(id, owner);\n\t}\n\n\t#write(item: QueueItem): void {\n\t\tthis.#open\n\t\t\t.query(\n\t\t\t\t`UPDATE items SET recipients = $recipients,\n\t\t\t\tnext_attempt_at = $next_attempt_at, attempts = $attempts,\n\t\t\t\tdelay_notified = $delay_notified, lease_owner = $lease_owner,\n\t\t\t\tlease_expires_at = $lease_expires_at WHERE id = $id`,\n\t\t\t)\n\t\t\t.run(stateOf(item));\n\t}\n\n\tasync add(item: NewQueueItem, options: AddOptions = {}): Promise<QueueItem> {\n\t\tcheckNewItem(item);\n\t\tconst max = checkMaxItems(options.maxItems);\n\t\tconst db = this.#open;\n\t\tconst added = newItem(crypto.randomUUID(), item);\n\t\tdb.transaction(() => {\n\t\t\tif (max !== undefined) {\n\t\t\t\tconst { n } = db\n\t\t\t\t\t.query<{ n: number }, []>('SELECT count(*) AS n FROM items')\n\t\t\t\t\t.get() as { n: number };\n\t\t\t\tif (n >= max) {\n\t\t\t\t\tthrow new QueueError(\n\t\t\t\t\t\t'QUEUE_FULL',\n\t\t\t\t\t\t`The queue holds ${n} items, its limit`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tdb.query(\n\t\t\t\t`INSERT INTO items (id, sender, recipients, size, created_at,\n\t\t\t\tnext_attempt_at, attempts, delay_notified, dsn)\n\t\t\t\tVALUES ($id, $sender, $recipients, $size, $created_at,\n\t\t\t\t$next_attempt_at, 0, 0, $dsn)`,\n\t\t\t).run({\n\t\t\t\tid: added.id,\n\t\t\t\tsender: added.from,\n\t\t\t\trecipients: JSON.stringify(added.recipients),\n\t\t\t\tsize: added.size,\n\t\t\t\tcreated_at: added.createdAt,\n\t\t\t\tnext_attempt_at: added.nextAttemptAt,\n\t\t\t\tdsn: dsnColumn(added),\n\t\t\t});\n\t\t\tdb.query('INSERT INTO messages (item_id, content) VALUES (?, ?)').run(\n\t\t\t\tadded.id,\n\t\t\t\titem.message,\n\t\t\t);\n\t\t}).immediate();\n\t\treturn added;\n\t}\n\n\tasync get(id: string): Promise<QueueItem | undefined> {\n\t\tconst row = this.#row(id);\n\t\treturn row ? itemOf(row) : undefined;\n\t}\n\n\tasync list(options?: QueueListOptions): Promise<QueueItem[]> {\n\t\tconst { offset, limit } = checkList(options);\n\t\treturn this.#open\n\t\t\t.query<ItemRow, [number, number]>(\n\t\t\t\t`SELECT ${COLUMNS} FROM items ORDER BY next_attempt_at, seq\n\t\t\t\tLIMIT ? OFFSET ?`,\n\t\t\t)\n\t\t\t.all(limit, offset)\n\t\t\t.map(itemOf);\n\t}\n\n\tasync count(): Promise<number> {\n\t\tconst row = this.#open\n\t\t\t.query<{ n: number }, []>('SELECT count(*) AS n FROM items')\n\t\t\t.get();\n\t\treturn row?.n ?? 0;\n\t}\n\n\tasync readMessage(id: string): Promise<Uint8Array | undefined> {\n\t\tconst row = this.#open\n\t\t\t.query<{ content: Uint8Array }, [string]>(\n\t\t\t\t'SELECT content FROM messages WHERE item_id = ?',\n\t\t\t)\n\t\t\t.get(id);\n\t\treturn row ? new Uint8Array(row.content) : undefined;\n\t}\n\n\tasync claim(request: ClaimRequest): Promise<QueueItem | undefined> {\n\t\tcheckClaim(request);\n\t\tconst row = this.#open\n\t\t\t.query<ItemRow, Record<string, string | number>>(\n\t\t\t\t`UPDATE items SET lease_owner = $owner, lease_expires_at = $expires\n\t\t\t\tWHERE seq = (SELECT seq FROM items WHERE ${DUE}\n\t\t\t\t\tORDER BY next_attempt_at, seq LIMIT 1)\n\t\t\t\tRETURNING ${COLUMNS}`,\n\t\t\t)\n\t\t\t.get({\n\t\t\t\towner: request.owner,\n\t\t\t\tnow: request.now,\n\t\t\t\texpires: request.now + request.leaseMs,\n\t\t\t});\n\t\treturn row ? itemOf(row) : undefined;\n\t}\n\n\tasync renew(id: string, owner: string, expiresAt: number): Promise<boolean> {\n\t\tcheckOwner(owner);\n\t\tcheckTime('expiresAt', expiresAt);\n\t\tconst { changes } = this.#open\n\t\t\t.query(\n\t\t\t\t'UPDATE items SET lease_expires_at = ? WHERE id = ? AND lease_owner = ?',\n\t\t\t)\n\t\t\t.run(expiresAt, id, owner);\n\t\treturn changes > 0;\n\t}\n\n\tasync complete(\n\t\tid: string,\n\t\towner: string,\n\t\tresult: AttemptResult,\n\t): Promise<QueueItem | undefined> {\n\t\tcheckOwner(owner);\n\t\tcheckResult(result);\n\t\tconst db = this.#open;\n\t\treturn db\n\t\t\t.transaction(() => {\n\t\t\t\tconst row = this.#row(id, owner);\n\t\t\t\tif (!row) return undefined;\n\t\t\t\tconst item = applyAttempt(itemOf(row), result);\n\t\t\t\tif (isDone(item)) {\n\t\t\t\t\tdb.query('DELETE FROM items WHERE id = ?').run(id);\n\t\t\t\t} else {\n\t\t\t\t\tthis.#write(item);\n\t\t\t\t}\n\t\t\t\treturn item;\n\t\t\t})\n\t\t\t.immediate();\n\t}\n\n\tasync reschedule(id: string, at: number, owner?: string): Promise<boolean> {\n\t\tcheckTime('at', at);\n\t\tconst db = this.#open;\n\t\tif (owner === undefined) {\n\t\t\treturn (\n\t\t\t\tdb\n\t\t\t\t\t.query('UPDATE items SET next_attempt_at = ? WHERE id = ?')\n\t\t\t\t\t.run(at, id).changes > 0\n\t\t\t);\n\t\t}\n\t\tcheckOwner(owner);\n\t\tconst { changes } = db\n\t\t\t.query(\n\t\t\t\t`UPDATE items SET next_attempt_at = ?, lease_owner = NULL,\n\t\t\t\tlease_expires_at = NULL WHERE id = ? AND lease_owner = ?`,\n\t\t\t)\n\t\t\t.run(at, id, owner);\n\t\treturn changes > 0;\n\t}\n\n\tasync cancel(id: string): Promise<QueueItem | undefined> {\n\t\tconst db = this.#open;\n\t\treturn db\n\t\t\t.transaction(() => {\n\t\t\t\tconst row = this.#row(id);\n\t\t\t\tif (!row) return undefined;\n\t\t\t\tdb.query('DELETE FROM items WHERE id = ?').run(id);\n\t\t\t\treturn itemOf(row);\n\t\t\t})\n\t\t\t.immediate();\n\t}\n}\n"
9
+ "import type { Database } from 'bun:sqlite';\nimport {\n\tapplyAttempt,\n\tcheckClaim,\n\tcheckComplete,\n\tcheckList,\n\tcheckMaxItems,\n\tcheckNewItem,\n\tcheckOwner,\n\tcheckTime,\n\tisDone,\n\tnewItem,\n} from '../contract/checks';\nimport type { QueueStore } from '../contract/queue-store';\nimport type {\n\tAddOptions,\n\tAttemptResult,\n\tClaimRequest,\n\tCompleted,\n\tNewQueueItem,\n\tQueueItem,\n\tQueueListOptions,\n} from '../contract/types';\nimport { QueueError } from '../errors';\nimport { insertItem } from './insert';\nimport { openDatabase } from './open';\nimport type { SqliteQueueStoreOptions } from './options';\nimport { COLUMNS, type ItemRow, itemOf, stateOf } from './rows';\n\nexport type { SqliteQueueStoreOptions } from './options';\n\nconst DUE = `next_attempt_at <= $now\n\tAND (lease_owner IS NULL OR lease_expires_at <= $now)`;\n\n/**\n * A `QueueStore` on `bun:sqlite`: one directory holding `queue.sqlite`,\n * which several processes on one machine may open together. Every\n * operation is one transaction — a claim is a single `UPDATE … RETURNING`\n * — so two workers never take the same item. Messages live in a table of\n * their own, dropped with their item.\n */\nexport class SqliteQueueStore implements QueueStore {\n\treadonly #db: Database;\n\t#closed = false;\n\n\tprivate constructor(db: Database) {\n\t\tthis.#db = db;\n\t}\n\n\t/** Opens the store in `directory`, creating it if need be; what fails is `INVALID`. */\n\tstatic open(options: SqliteQueueStoreOptions): SqliteQueueStore {\n\t\treturn new SqliteQueueStore(openDatabase(options));\n\t}\n\n\t/** Lets go of the database; closing twice is fine. */\n\tclose(): void {\n\t\tif (this.#closed) return;\n\t\tthis.#closed = true;\n\t\tthis.#db.close();\n\t}\n\n\tget #open(): Database {\n\t\tif (this.#closed) {\n\t\t\tthrow new QueueError('CLOSED', 'The queue store is closed');\n\t\t}\n\t\treturn this.#db;\n\t}\n\n\t#row(id: string, owner?: string): ItemRow | null {\n\t\tconst db = this.#open;\n\t\tif (owner === undefined) {\n\t\t\treturn db\n\t\t\t\t.query<ItemRow, [string]>(`SELECT ${COLUMNS} FROM items WHERE id = ?`)\n\t\t\t\t.get(id);\n\t\t}\n\t\treturn db\n\t\t\t.query<ItemRow, [string, string]>(\n\t\t\t\t`SELECT ${COLUMNS} FROM items WHERE id = ? AND lease_owner = ?`,\n\t\t\t)\n\t\t\t.get(id, owner);\n\t}\n\n\t#write(item: QueueItem): void {\n\t\tthis.#open\n\t\t\t.query(\n\t\t\t\t`UPDATE items SET recipients = $recipients,\n\t\t\t\tnext_attempt_at = $next_attempt_at, attempts = $attempts,\n\t\t\t\tdelay_notified = $delay_notified, lease_owner = $lease_owner,\n\t\t\t\tlease_expires_at = $lease_expires_at WHERE id = $id`,\n\t\t\t)\n\t\t\t.run(stateOf(item));\n\t}\n\n\tasync add(item: NewQueueItem, options: AddOptions = {}): Promise<QueueItem> {\n\t\tcheckNewItem(item);\n\t\tconst max = checkMaxItems(options.maxItems);\n\t\tconst db = this.#open;\n\t\tconst added = newItem(crypto.randomUUID(), item);\n\t\tdb.transaction(() => {\n\t\t\tif (max !== undefined) {\n\t\t\t\tconst { n } = db\n\t\t\t\t\t.query<{ n: number }, []>('SELECT count(*) AS n FROM items')\n\t\t\t\t\t.get() as { n: number };\n\t\t\t\tif (n >= max) {\n\t\t\t\t\tthrow new QueueError(\n\t\t\t\t\t\t'QUEUE_FULL',\n\t\t\t\t\t\t`The queue holds ${n} items, its limit`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tinsertItem(db, added, item.message);\n\t\t}).immediate();\n\t\treturn added;\n\t}\n\n\tasync get(id: string): Promise<QueueItem | undefined> {\n\t\tconst row = this.#row(id);\n\t\treturn row ? itemOf(row) : undefined;\n\t}\n\n\tasync list(options?: QueueListOptions): Promise<QueueItem[]> {\n\t\tconst { offset, limit } = checkList(options);\n\t\treturn this.#open\n\t\t\t.query<ItemRow, [number, number]>(\n\t\t\t\t`SELECT ${COLUMNS} FROM items ORDER BY next_attempt_at, seq\n\t\t\t\tLIMIT ? OFFSET ?`,\n\t\t\t)\n\t\t\t.all(limit, offset)\n\t\t\t.map(itemOf);\n\t}\n\n\tasync count(): Promise<number> {\n\t\tconst row = this.#open\n\t\t\t.query<{ n: number }, []>('SELECT count(*) AS n FROM items')\n\t\t\t.get();\n\t\treturn row?.n ?? 0;\n\t}\n\n\tasync readMessage(id: string): Promise<Uint8Array | undefined> {\n\t\tconst row = this.#open\n\t\t\t.query<{ content: Uint8Array }, [string]>(\n\t\t\t\t'SELECT content FROM messages WHERE item_id = ?',\n\t\t\t)\n\t\t\t.get(id);\n\t\treturn row ? new Uint8Array(row.content) : undefined;\n\t}\n\n\tasync claim(request: ClaimRequest): Promise<QueueItem | undefined> {\n\t\tcheckClaim(request);\n\t\tconst row = this.#open\n\t\t\t.query<ItemRow, Record<string, string | number>>(\n\t\t\t\t`UPDATE items SET lease_owner = $owner, lease_expires_at = $expires\n\t\t\t\tWHERE seq = (SELECT seq FROM items WHERE ${DUE}\n\t\t\t\t\tORDER BY next_attempt_at, seq LIMIT 1)\n\t\t\t\tRETURNING ${COLUMNS}`,\n\t\t\t)\n\t\t\t.get({\n\t\t\t\towner: request.owner,\n\t\t\t\tnow: request.now,\n\t\t\t\texpires: request.now + request.leaseMs,\n\t\t\t});\n\t\treturn row ? itemOf(row) : undefined;\n\t}\n\n\tasync renew(id: string, owner: string, expiresAt: number): Promise<boolean> {\n\t\tcheckOwner(owner);\n\t\tcheckTime('expiresAt', expiresAt);\n\t\tconst { changes } = this.#open\n\t\t\t.query(\n\t\t\t\t'UPDATE items SET lease_expires_at = ? WHERE id = ? AND lease_owner = ?',\n\t\t\t)\n\t\t\t.run(expiresAt, id, owner);\n\t\treturn changes > 0;\n\t}\n\n\tasync complete(\n\t\tid: string,\n\t\towner: string,\n\t\tresult: AttemptResult,\n\t\tdsns: readonly NewQueueItem[],\n\t): Promise<Completed | undefined> {\n\t\tcheckComplete(owner, result, dsns);\n\t\tconst db = this.#open;\n\t\treturn db\n\t\t\t.transaction((): Completed | undefined => {\n\t\t\t\tconst row = this.#row(id, owner);\n\t\t\t\tif (!row) return undefined;\n\t\t\t\tconst item = applyAttempt(itemOf(row), result);\n\t\t\t\tif (isDone(item)) {\n\t\t\t\t\tdb.query('DELETE FROM items WHERE id = ?').run(id);\n\t\t\t\t} else {\n\t\t\t\t\tthis.#write(item);\n\t\t\t\t}\n\t\t\t\tconst added = dsns.map((dsn) => {\n\t\t\t\t\tconst stored = newItem(crypto.randomUUID(), dsn);\n\t\t\t\t\tinsertItem(db, stored, dsn.message);\n\t\t\t\t\treturn stored;\n\t\t\t\t});\n\t\t\t\treturn { item, dsns: added };\n\t\t\t})\n\t\t\t.immediate();\n\t}\n\n\tasync reschedule(id: string, at: number, owner?: string): Promise<boolean> {\n\t\tcheckTime('at', at);\n\t\tconst db = this.#open;\n\t\tif (owner === undefined) {\n\t\t\treturn (\n\t\t\t\tdb\n\t\t\t\t\t.query('UPDATE items SET next_attempt_at = ? WHERE id = ?')\n\t\t\t\t\t.run(at, id).changes > 0\n\t\t\t);\n\t\t}\n\t\tcheckOwner(owner);\n\t\tconst { changes } = db\n\t\t\t.query(\n\t\t\t\t`UPDATE items SET next_attempt_at = ?, lease_owner = NULL,\n\t\t\t\tlease_expires_at = NULL WHERE id = ? AND lease_owner = ?`,\n\t\t\t)\n\t\t\t.run(at, id, owner);\n\t\treturn changes > 0;\n\t}\n\n\tasync cancel(id: string): Promise<QueueItem | undefined> {\n\t\tconst db = this.#open;\n\t\treturn db\n\t\t\t.transaction(() => {\n\t\t\t\tconst row = this.#row(id);\n\t\t\t\tif (!row) return undefined;\n\t\t\t\tdb.query('DELETE FROM items WHERE id = ?').run(id);\n\t\t\t\treturn itemOf(row);\n\t\t\t})\n\t\t\t.immediate();\n\t}\n}\n"
9
10
  ],
10
- "mappings": "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",
11
- "debugId": "92D7A7C42951038864756E2164756E21",
11
+ "mappings": "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",
12
+ "debugId": "69534685BB7970EA64756E2164756E21",
12
13
  "names": []
13
14
  }
@@ -0,0 +1,5 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { QueueItem } from '../contract/types';
3
+ /** Stores a new item and its message; the caller holds the transaction. */
4
+ export declare function insertItem(db: Database, added: QueueItem, message: Uint8Array): void;
5
+ //# sourceMappingURL=insert.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../src/sqlite/insert.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEnD,2EAA2E;AAC3E,wBAAgB,UAAU,CACzB,EAAE,EAAE,QAAQ,EACZ,KAAK,EAAE,SAAS,EAChB,OAAO,EAAE,UAAU,GACjB,IAAI,CAmBN"}
@@ -1,5 +1,5 @@
1
1
  import type { QueueStore } from '../contract/queue-store';
2
- import type { AddOptions, AttemptResult, ClaimRequest, NewQueueItem, QueueItem, QueueListOptions } from '../contract/types';
2
+ import type { AddOptions, AttemptResult, ClaimRequest, Completed, NewQueueItem, QueueItem, QueueListOptions } from '../contract/types';
3
3
  import type { SqliteQueueStoreOptions } from './options';
4
4
  export type { SqliteQueueStoreOptions } from './options';
5
5
  /**
@@ -23,7 +23,7 @@ export declare class SqliteQueueStore implements QueueStore {
23
23
  readMessage(id: string): Promise<Uint8Array | undefined>;
24
24
  claim(request: ClaimRequest): Promise<QueueItem | undefined>;
25
25
  renew(id: string, owner: string, expiresAt: number): Promise<boolean>;
26
- complete(id: string, owner: string, result: AttemptResult): Promise<QueueItem | undefined>;
26
+ complete(id: string, owner: string, result: AttemptResult, dsns: readonly NewQueueItem[]): Promise<Completed | undefined>;
27
27
  reschedule(id: string, at: number, owner?: string): Promise<boolean>;
28
28
  cancel(id: string): Promise<QueueItem | undefined>;
29
29
  }
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sqlite/store.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,KAAK,EACX,UAAU,EACV,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,gBAAgB,EAChB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAGzD,YAAY,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAKzD;;;;;;GAMG;AACH,qBAAa,gBAAiB,YAAW,UAAU;;IAIlD,OAAO;IAIP,uFAAuF;IACvF,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,uBAAuB,GAAG,gBAAgB;IAI/D,sDAAsD;IACtD,KAAK,IAAI,IAAI;IAsCP,GAAG,CAAC,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,SAAS,CAAC;IAuCrE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAK/C,IAAI,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAWtD,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAOxB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC;IASxD,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAiB5D,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAWrE,QAAQ,CACb,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,aAAa,GACnB,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAmB3B,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAoBpE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;CAWxD"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sqlite/store.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,KAAK,EACX,UAAU,EACV,aAAa,EACb,YAAY,EACZ,SAAS,EACT,YAAY,EACZ,SAAS,EACT,gBAAgB,EAChB,MAAM,mBAAmB,CAAC;AAI3B,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAGzD,YAAY,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAKzD;;;;;;GAMG;AACH,qBAAa,gBAAiB,YAAW,UAAU;;IAIlD,OAAO;IAIP,uFAAuF;IACvF,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,uBAAuB,GAAG,gBAAgB;IAI/D,sDAAsD;IACtD,KAAK,IAAI,IAAI;IAsCP,GAAG,CAAC,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,SAAS,CAAC;IAsBrE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAK/C,IAAI,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAWtD,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAOxB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC;IASxD,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAiB5D,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAWrE,QAAQ,CACb,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,aAAa,EACrB,IAAI,EAAE,SAAS,YAAY,EAAE,GAC3B,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAuB3B,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAoBpE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;CAWxD"}
package/docs/guide.md CHANGED
@@ -296,6 +296,21 @@ ran, and nothing was recorded. An item that is gone along with its
296
296
  message (cancelled, or finished by another worker) is left alone, as
297
297
  before.
298
298
 
299
+ The DSNs of an outcome are built before `complete` and stored by it, in
300
+ the same step as the outcome, so a crash or a store error cannot leave
301
+ the outcome recorded without its failure, relayed or delay DSN. They are
302
+ delivered at least once: a DSN stored by `complete` is an item like any
303
+ other, and a worker that crashes after the step but before the `dsn` event
304
+ only misses the event. When a DSN cannot be built (a `MimeError`, say),
305
+ the outcome is recorded without it and the `error` event says so, once the
306
+ outcome is recorded; a delayed DSN that cannot be built leaves
307
+ `delayNotified` set, since building it again would fail the same way. When
308
+ the store's write of a DSN fails, `complete` throws, nothing is recorded
309
+ and the item is redelivered after its lease expires. The `delivered` and
310
+ `failed` DSNs an application writes for a local delivery are its own
311
+ `enqueue`: a crash between the delivery and that call gives a duplicate
312
+ message, not a lost DSN.
313
+
299
314
  A store keeps an item from being claimed before its message can be read
300
315
  — the message written first, or both in one step — so a message missing
301
316
  at a claim is always damage, never an add still under way. Every store
@@ -992,6 +1007,20 @@ codes, and keeps its promises:
992
1007
  of the lease, in one step and only for the lease's owner; a final
993
1008
  status is never changed; an item whose every recipient is final is
994
1009
  dropped with its message.
1010
+ - `complete(id, owner, result, dsns)` stores the DSN items the outcome
1011
+ calls for (`dsns`, each a `NewQueueItem` as `add` takes it) in the same
1012
+ step as the outcome, and returns `{ item, dsns }` (`Completed`): the item
1013
+ as it now stands, and the items inserted, in the order given. All or
1014
+ nothing: a lost lease (not the owner, or the item gone) returns
1015
+ `undefined` and writes no DSN; a DSN the store cannot keep throws and
1016
+ leaves the item as it was, lease held, so it is redelivered once the
1017
+ lease expires. A bad DSN is refused as `INVALID` before the store opens
1018
+ a connection or a transaction. DSNs ignore `limits.maxItems`: a full
1019
+ queue still bounces, and the count may exceed the limit by the DSNs it
1020
+ holds. A store that returns the bare item, as stores written
1021
+ before 0.11 do, still runs, but its DSNs are added one by one, outside
1022
+ the step (see [Queue store
1023
+ outdated](./troubleshooting.md#queueerror-queue-store-outdated-complete-returned-the-item-alone-not--item-dsns--its-dsns-are-added-one-by-one-outside-the-step-that-records-the-outcome)).
995
1024
  - `complete` records a recipient's `mayHaveReachedMx: true` and
996
1025
  `mayHaveReachedFallback: true` (on its `RecipientUpdate`) and keeps
997
1026
  them: `get`, `list` and `claim` return it on
@@ -1017,8 +1046,90 @@ codes, and keeps its promises:
1017
1046
  The queue never gives a store such text: it cleans every reply.
1018
1047
 
1019
1048
  The contract's specs, `describeQueueStore`, which every store here runs,
1020
- are internal for now: a store written outside this package cannot run
1021
- them yet.
1049
+ are exported as `@bumail/queue/conformance`: see
1050
+ [Holding your own store to the contract](#holding-your-own-store-to-the-contract).
1051
+
1052
+ ## Holding your own store to the contract
1053
+
1054
+ A store you write (over a file, a database, a service) is only a
1055
+ `QueueStore` if it answers the contract above to the letter, in the cases
1056
+ nobody thinks of: a lease that expired, a DSN that fails the checks, a
1057
+ second process claiming at the same time. `describeQueueStore` is the
1058
+ suite the memory, SQLite, PostgreSQL and Redis stores run; call
1059
+ it from a spec of yours and it registers them.
1060
+
1061
+ **It needs Bun.** The suite imports `bun:test`, so the spec runs with
1062
+ `bun test`, not with Node, Vitest or Jest. It is a development
1063
+ dependency: install `@bumail/queue` and write the spec.
1064
+
1065
+ ```sh
1066
+ bun add -d @bumail/queue
1067
+ ```
1068
+
1069
+ ```ts
1070
+ // my-store.spec.ts
1071
+ import { describeQueueStore } from '@bumail/queue/conformance';
1072
+ import { MyQueueStore } from './my-store';
1073
+
1074
+ describeQueueStore('MyQueueStore', {
1075
+ // a new store, empty: called once per test
1076
+ create: () => MyQueueStore.open({ url: process.env.MY_STORE_URL }),
1077
+ // another handle on the same queue, as a second connection or process
1078
+ share: (store) => (store as MyQueueStore).connectAgain(),
1079
+ // closes a store `create` made, after the test that used it
1080
+ dispose: (store) => (store as MyQueueStore).close(),
1081
+ });
1082
+ ```
1083
+
1084
+ ```sh
1085
+ bun test my-store.spec.ts
1086
+ ```
1087
+
1088
+ A spec project typechecked with `tsc` needs `@types/bun` (for `bun:test`), as
1089
+ the example's `devDependencies` have it.
1090
+
1091
+ [`examples/custom-store`](https://github.com/softistx/bumail/tree/develop/examples/custom-store)
1092
+ in the repository holds a complete one, a store over the memory store with its
1093
+ spec, that passes.
1094
+
1095
+ ### What it covers
1096
+
1097
+ | group | what is held |
1098
+ | --- | --- |
1099
+ | `items` | `add` keeps the envelope, every recipient `pending`, due at once; the null sender; `readMessage` and every item are copies; an unknown id, or one no store could hold, is `undefined`; `list` order and paging; `cancel`; `maxItems` and `QUEUE_FULL`; what is not an item is `INVALID`; the DSN parameters (`ret`, `envid`, `notify`, `orcpt`) and their limits |
1100
+ | `claims and leases` | `claim` takes the earliest due, then the oldest, and only what is due; an expired lease is claimed again and its old owner has lost it; `renew`; `reschedule` with and without an owner; a bad request is `INVALID` |
1101
+ | `outcomes` | `complete` records each recipient, the schedule and lets go of the lease; a finished item is dropped with its message; text with a NUL or a lone surrogate is refused; a final state never changes; the DSNs stored in the same step (new ids, in order, none on a lost lease, all refused when one is bad); `mayHaveReachedMx` and `mayHaveReachedFallback` recorded and kept |
1102
+ | `several workers` | many claimers on one queue deliver every item once; a crashed worker's items are claimed again when its leases expire |
1103
+
1104
+ ### `share`: a second handle
1105
+
1106
+ The two `several workers` tests need `share`, another handle on **the
1107
+ same queue**: a second connection to the same database, as a second
1108
+ process would open. Without it they are registered as skipped, named
1109
+ `… (skipped: harness has no share)`, because one handle cannot show that
1110
+ a claim is atomic across handles.
1111
+
1112
+ A store that lives in one process, such as an in-memory one, may return a
1113
+ second object over the same data, as the example does. A **networked**
1114
+ store (PostgreSQL, Redis, any server) should pass a real second
1115
+ connection: that is where two workers race, and where a claim that reads
1116
+ then writes loses. Passing none for a store like that leaves the one
1117
+ property that matters unproved.
1118
+
1119
+ ### `dispose`
1120
+
1121
+ `dispose(store)` runs after every test, for each store `create` made in
1122
+ that test, once the test is over (pass or fail). Use it to close a
1123
+ connection or remove a file. It may be async.
1124
+
1125
+ - Every created store is disposed, even when the disposal of another
1126
+ one fails.
1127
+ - One failure is rethrown as it is. Several in one test are thrown
1128
+ together as an `AggregateError` (`several stores failed to dispose`),
1129
+ each reason in its `errors`.
1130
+ - Handles returned by `share` are **not** passed to `dispose`: the
1131
+ harness closes them, for instance in the same `dispose`, from the store
1132
+ they were made from.
1022
1133
 
1023
1134
  ## Options
1024
1135
 
package/docs/roadmap.md CHANGED
@@ -6,7 +6,7 @@ only number on it.
6
6
 
7
7
  ## Now
8
8
 
9
- Nothing is under way besides what is marked shipped below.
9
+ Nothing under way.
10
10
 
11
11
  ## Next
12
12
 
@@ -18,14 +18,11 @@ Nothing is under way besides what is marked shipped below.
18
18
  - **One session for several messages** — the queue hands several items
19
19
  for one domain to one SMTP session, once `@bumail/smtp`'s client reuses
20
20
  connections.
21
- - **The contract's specs, exported**, so a store written outside this
22
- package can hold itself to them.
23
- - **A DSN that cannot be lost on a crash** — today a crash between
24
- recording a failure and enqueuing its DSN loses that DSN; the store
25
- will take both in one step.
26
21
 
27
22
  ## Later
28
23
 
24
+ - **A suite that is not tied to `bun:test`** — the contract's specs run under
25
+ any test runner, for a store written where Bun is not the runner.
29
26
  - **REQUIRETLS (RFC 8689)** — a message that asks for TLS all the way is
30
27
  never sent in clear, and fails rather than downgrade.
31
28
  - **Retries per recipient** — a schedule of its own for each recipient,
@@ -49,7 +46,35 @@ Nothing is under way besides what is marked shipped below.
49
46
 
50
47
  ## Shipped
51
48
 
52
- ### Unreleased
49
+ ### 0.12.0
50
+
51
+ - **The contract's specs, exported** — `@bumail/queue/conformance` exports
52
+ `describeQueueStore(name, { create, share?, dispose? })`, run under
53
+ `bun test`, so a queue store written outside this package runs the same
54
+ contract suite as the built-in ones; a capability a store lacks shows as a
55
+ skipped test with its reason. Additive.
56
+
57
+ ### 0.11.0
58
+
59
+ - **A DSN recorded in the same step as the outcome** — the failure, relayed
60
+ and delay DSNs are written in the same store step as the outcome they
61
+ report, in the memory, SQLite, PostgreSQL and Redis stores, so a crash or
62
+ a failed add no longer loses them. Breaking for a custom `QueueStore`:
63
+ `complete(id, owner, result, dsns)` returns `{ item, dsns }`; a store that
64
+ answers the old shape is reported on the `error` event and its DSNs fall
65
+ back to `add`. The `delivered` and `failed` DSNs a server writes for a
66
+ local delivery are not this gap: a crash there gives a duplicate, not a
67
+ loss.
68
+
69
+ ### 0.10.0
70
+
71
+ - **`failedDsn()`** — builds the `failed` DSN of a recipient the
72
+ application could not deliver locally, the counterpart of `deliveredDsn()`:
73
+ it follows `NOTIFY` (none for `NEVER` or a list without `FAILURE`, none for
74
+ a null reverse-path), `ORCPT`, `ENVID` and `RET`; `DEFAULT_MAX_RETURN` (64
75
+ KiB) is the default of what a DSN returns of the original.
76
+
77
+ ### 0.9.0
53
78
 
54
79
  - **DSN requests (RFC 3461)**: the queue honours a sender's `NOTIFY`, `RET`,
55
80
  `ENVID` and `ORCPT` given at enqueue. `NOTIFY=NEVER` silences failure
@@ -58,22 +83,31 @@ Nothing is under way besides what is marked shipped below.
58
83
  `Original-Recipient`; and `deliveredDsn()` builds the `delivered` DSN for
59
84
  a local delivery. The SQLite and PostgreSQL stores run migration 2: stop
60
85
  every old worker before starting the new ones.
61
- - **The MX's doubt is remembered** — a recipient whose session by MX ended
62
- after the final dot with no reply is marked `mayHaveReachedMx`, kept by
63
- every store: no later attempt falls back to the smarthost on its own, it
64
- stays on the MX until `retry.giveUpAfter` (a `4.4.7` DSN that says so),
65
- and the `deferred` and `failed` events carry the mark.
86
+
87
+ ### 0.8.0
88
+
66
89
  - **The fallback smarthost's doubt is remembered** — a recipient whose
67
90
  session by the fallback smarthost ended after the final dot with no reply
68
91
  is marked `mayHaveReachedFallback`, kept by every store: later attempts
69
92
  skip the MX for it and go to the smarthost again, until
70
93
  `retry.giveUpAfter` (a `4.4.7` DSN that says so), and the `deferred` and
71
94
  `failed` events carry the mark.
95
+
96
+ ### 0.7.1
97
+
72
98
  - **Only the recipients the MX did not refuse are in doubt** — in a session that
73
99
  failed after the final dot, a recipient the MX refused at `RCPT` is
74
100
  settled from its own reply (5xx failed, 4xx deferred) and not marked, so
75
101
  it may fall back to the smarthost.
76
102
 
103
+ ### 0.7.0
104
+
105
+ - **The MX's doubt is remembered** — a recipient whose session by MX ended
106
+ after the final dot with no reply is marked `mayHaveReachedMx`, kept by
107
+ every store: no later attempt falls back to the smarthost on its own, it
108
+ stays on the MX until `retry.giveUpAfter` (a `4.4.7` DSN that says so),
109
+ and the `deferred` and `failed` events carry the mark.
110
+
77
111
  ### 0.5.0
78
112
 
79
113
  - **A fallback smarthost** — `fallback`: a recipient
@@ -31,6 +31,7 @@ parts shown as … vary.
31
31
  - [`QueueError: dsn.returnContent must be 'headers' or 'full', not …`](#queueerror-dsnreturncontent-must-be-headers-or-full-not-)
32
32
  - [`QueueError: createQueue() takes an options object`](#queueerror-createqueue-takes-an-options-object)
33
33
  - [`QueueError: owner must be a non-empty string`](#queueerror-owner-must-be-a-non-empty-string)
34
+ - [`QueueError: dsns must be an array`](#queueerror-dsns-must-be-an-array)
34
35
 
35
36
  **The `error` event**
36
37
 
@@ -39,6 +40,8 @@ parts shown as … vary.
39
40
  - [`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)
40
41
  - [`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)
41
42
  - [`QueueError: The message of … is unreadable: the store holds the item but not its message, so every pending recipient failed`](#queueerror-the-message-of--is-unreadable-the-store-holds-the-item-but-not-its-message-so-every-pending-recipient-failed)
43
+ - [`QueueError: Queue store outdated: complete returned the item alone, not { item, dsns }; its DSNs are added one by one, outside the step that records the outcome`](#queueerror-queue-store-outdated-complete-returned-the-item-alone-not--item-dsns--its-dsns-are-added-one-by-one-outside-the-step-that-records-the-outcome)
44
+ - [`QueueError: The queue store stored … of the … DSNs of …: a … DSN to … is lost`](#queueerror-the-queue-store-stored--of-the--dsns-of--a--dsn-to--is-lost)
42
45
  - [`SmtpError: sendMail(): …` (`INVALID_OPTION`), with recipients deferred as `4.3.5`](#smtperror-sendmail--invalid_option-with-recipients-deferred-as-435)
43
46
  - [`SQLiteError: database is locked`, or another store error, during a delivery](#sqliteerror-database-is-locked-or-another-store-error-during-a-delivery)
44
47
  - [`PostgresError: …`, or a connection error, during a delivery](#postgreserror--or-a-connection-error-during-a-delivery)
@@ -86,6 +89,11 @@ parts shown as … vary.
86
89
  - [`QueueError: reply.host must be a string`](#queueerror-replyhost-must-be-a-string)
87
90
  - [`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)
88
91
 
92
+ **A store's spec** (`describeQueueStore`)
93
+
94
+ - [`AggregateError: several stores failed to dispose`](#aggregateerror-several-stores-failed-to-dispose)
95
+ - [`… (skipped: harness has no share)` in the test names](#-skipped-harness-has-no-share-in-the-test-names)
96
+
89
97
  **Delivery**
90
98
 
91
99
  - [A recipient stays `deferred` with `4.4.1` or `4.4.2`](#a-recipient-stays-deferred-with-441-or-442)
@@ -302,6 +310,21 @@ or `… must be a number from … to …, not …`. A number option out of its r
302
310
 
303
311
  **Code:** `INVALID`.
304
312
 
313
+ ### `QueueError: dsns must be an array`
314
+
315
+ **Code:** `INVALID`.
316
+ **When:** `complete(id, owner, result, dsns)` of a store of this package
317
+ was called with something other than an array as `dsns`, typically by a
318
+ caller of the store that still uses the three-argument form.
319
+ **Why:** since 0.11 `complete` takes the DSN items the outcome calls for
320
+ (an empty array for none); the store checks them before it opens a
321
+ connection or a transaction, so nothing was written.
322
+ **Fix:** pass an array, `[]` when the outcome calls for no DSN:
323
+
324
+ ```ts
325
+ await store.complete(id, owner, result, []);
326
+ ```
327
+
305
328
  ### `QueueError: owner must be a non-empty string`
306
329
 
307
330
  **Code:** `INVALID`, also as
@@ -395,6 +418,51 @@ guide's [Redis](guide.md#redis)); never delete a queue's keys or rows by
395
418
  hand, `cancel(id)` drops an item with its message. The sender has the
396
419
  DSN and may send again.
397
420
 
421
+ ### `QueueError: Queue store outdated: complete returned the item alone, not { item, dsns }; its DSNs are added one by one, outside the step that records the outcome`
422
+
423
+ **Code:** `INVALID`, on the `error` event, with the item's `id`.
424
+ **When:** a custom `QueueStore` still answers `complete` as before 0.11,
425
+ with the bare `QueueItem`, and an
426
+ outcome calls for a DSN (a failure, a `relayed` or a delay).
427
+ **Why:** since 0.11 the queue hands the DSNs to `complete`, which stores
428
+ them in the same step as the outcome and returns `{ item, dsns }`. A store
429
+ that answers with the item alone has stored none of them: the queue adds
430
+ each one with `add` afterwards, as it did before, so a crash or an `add`
431
+ error between the two steps still loses a DSN, and an `add` that fails is
432
+ one more `error` event. The stores of this package are not affected.
433
+ **Fix:** port the store's `complete`: take `dsns`, insert them in the
434
+ transaction (or script) that records the outcome, and return them (the
435
+ `this.*` calls are your store's own helpers, a sketch):
436
+
437
+ ```ts
438
+ async complete(id, owner, result, dsns) {
439
+ return this.transaction(async () => {
440
+ const item = await this.apply(id, owner, result);
441
+ if (!item) return undefined; // lease lost: no DSN written
442
+ const stored = [];
443
+ for (const dsn of dsns) stored.push(await this.insert(dsn)); // in order
444
+ return { item, dsns: stored };
445
+ });
446
+ }
447
+ ```
448
+
449
+ See [Writing a store](./guide.md#writing-a-store).
450
+
451
+ ### `QueueError: The queue store stored … of the … DSNs of …: a … DSN to … is lost`
452
+
453
+ **Code:** `INVALID`, on the `error` event, with the item's `id`.
454
+ **When:** a custom `QueueStore`'s `complete` returned `{ item, dsns }` with
455
+ fewer DSNs than it was given, and an outcome called for them (a failure, a
456
+ `relayed` or a delay).
457
+ **Why:** the queue matches the returned DSNs to the ones it asked for, in
458
+ order. A DSN with no stored counterpart was not kept, so its notice to the
459
+ sender is lost: no `dsn` event is emitted for it. The stores of this
460
+ package return every DSN or throw.
461
+ **Fix:** make `complete` return one stored item per DSN, in the order
462
+ given, or throw to leave the item leased (see the sketch under
463
+ [Queue store outdated](#queueerror-queue-store-outdated-complete-returned-the-item-alone-not--item-dsns--its-dsns-are-added-one-by-one-outside-the-step-that-records-the-outcome)
464
+ and [Writing a store](./guide.md#writing-a-store)).
465
+
398
466
  ### `SmtpError: sendMail(): …` (`INVALID_OPTION`), with recipients deferred as `4.3.5`
399
467
 
400
468
  **When:** `sendMail` refused the options of a session — the route, the
@@ -482,6 +550,43 @@ it.
482
550
  default. The error from a transaction of your own on the same client is
483
551
  yours to retry.
484
552
 
553
+ ## Your own store's spec
554
+
555
+ ### `AggregateError: several stores failed to dispose`
556
+
557
+ `describeQueueStore`, after a test. The test created more than one store
558
+ (a spec that calls `create` again, as the several-workers tests do) and
559
+ the `dispose` of more than one of them threw. The suite disposes every
560
+ store it created, collects the failures and throws them together; each
561
+ reason is in `error.errors`. A single failure is thrown as it is, not
562
+ wrapped.
563
+
564
+ **Fix:** read `error.errors`; each entry is one store's failure. The suite
565
+ calls `dispose` once per created store, so the causes are a store's own
566
+ `close` failing (each entry says why), or a `create` that hands back a
567
+ shared object, so that each test's `dispose` closes the same thing. Have
568
+ `create` return a fresh store each call. Handles from `share` are not passed
569
+ to `dispose`; close them in the harness.
570
+
571
+ ```ts
572
+ create: () => new MyQueueStore(freshPath()), // a new store each call, never a shared one
573
+ ```
574
+
575
+ ### `… (skipped: harness has no share)` in the test names
576
+
577
+ `describeQueueStore`. The harness passed no `share`, so the two tests of
578
+ "several workers" (many claimers race, a crashed worker's items) are
579
+ registered as skipped. This is not a failure, but the atomicity of a claim
580
+ across handles is then unproved.
581
+
582
+ **Fix:** pass `share`, which returns another handle on the same queue (a
583
+ second connection to the same database), and give a networked store a real
584
+ second connection:
585
+
586
+ ```ts
587
+ share: (store) => (store as MyQueueStore).connectAgain(),
588
+ ```
589
+
485
590
  ## The `bun:sqlite` store
486
591
 
487
592
  ### `QueueError: A SQLite queue store needs a directory`
@@ -720,7 +825,7 @@ queue's own outcomes always have one.
720
825
  await store.complete(id, owner, {
721
826
  ...result,
722
827
  recipients: [{ address, status: 'deferred', reply: { status: '4.4.1', text: 'No answer' } }],
723
- });
828
+ }, []); // the DSN items the outcome calls for, none here
724
829
  ```
725
830
 
726
831
  ### `QueueError: reply.status must be a string`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bumail/queue",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
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",
@@ -39,6 +39,11 @@
39
39
  "import": "./dist/redis/index.js",
40
40
  "default": "./dist/redis/index.js"
41
41
  },
42
+ "./conformance": {
43
+ "types": "./dist/conformance/index.d.ts",
44
+ "import": "./dist/conformance/index.js",
45
+ "default": "./dist/conformance/index.js"
46
+ },
42
47
  "./package.json": "./package.json"
43
48
  },
44
49
  "repository": {
@@ -61,18 +66,19 @@
61
66
  "src/memory/index.ts",
62
67
  "src/sqlite/index.ts",
63
68
  "src/postgres/index.ts",
64
- "src/redis/index.ts"
69
+ "src/redis/index.ts",
70
+ "src/conformance/index.ts"
65
71
  ]
66
72
  },
67
73
  "devDependencies": {
68
- "@bumail/dns": "^0.2.1",
74
+ "@bumail/dns": "^0.2.2",
69
75
  "@bumail/mime": "^0.2.0",
70
- "@bumail/smtp": "^0.11.0",
76
+ "@bumail/smtp": "^0.11.1",
71
77
  "@types/bun": "^1.4.2"
72
78
  },
73
79
  "peerDependencies": {
74
80
  "@bumail/mime": "^0.2.0",
75
- "@bumail/smtp": "^0.11.0",
81
+ "@bumail/smtp": "^0.11.1",
76
82
  "typescript": "^6.0.3 || ^7.0.0"
77
83
  },
78
84
  "peerDependenciesMeta": {