@relaybus/postgres 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -102,7 +102,8 @@ Where the budget lives depends on who owns the pool:
102
102
  - **A pool or executor you pass in**: the budget is transaction-scoped (`SET LOCAL` semantics), set with `BEGIN` by
103
103
  `PgExecutor` and by its own statement for the other adapters. Session settings of your pool are never changed.
104
104
 
105
- **Prisma** is integration-tested with a generated Prisma Client 7.10 (and 6.19 in CI) on the `@prisma/adapter-pg`
105
+ **Prisma** 5, 6 and 7 are supported (optional peer `@prisma/client` `^5.0.0 || ^6.0.0 || ^7.0.0`). It is
106
+ integration-tested with a generated Prisma Client 7.10 (and 6.19 and 5.22 in CI) on the `@prisma/adapter-pg`
106
107
  driver adapter: the full event store contract, publishing in `prisma.$transaction`, rollback and concurrent
107
108
  transactions. The adapter is typed structurally, so the package has no Prisma dependency. Prisma interactive
108
109
  transactions default to a 5 s timeout, so pass `createPrismaExecutor(prisma, { timeout, maxWait })` if handlers run
@@ -155,14 +156,14 @@ import { createPostgresSchemaSql, createPostgresSchemaStatements } from '@relayb
155
156
  const migration = createPostgresSchemaSql({ schema: 'app', tablePrefix: 'bus_' });
156
157
  ```
157
158
 
158
- | Table | Purpose |
159
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
160
- | `events` | One row per event: `id` (**text**, so any caller id works), `type`, `source`, `key`, `sequence`, `payload jsonb`, `headers jsonb`, correlation and causation ids, `occurred_at`, `created_at`, and `handlers`, the handlers that ever got a delivery (it outlives the deliveries). |
161
- | `deliveries` | One row per (event, handler) that is not finished: `lane`, `status` (`pending` or `dead`), `attempts`, `available_at`, `last_error jsonb`, key and sequence copied from the event, timestamps. `unique (event_id, handler)`, foreign key to events with `ON DELETE CASCADE`. |
162
- | `lanes` | 256 rows, one per lane: `epoch` (the fencing token), `lease_owner`, `lease_expires_at` and `in_flight` (ids of the deliveries the owner claimed). `fillfactor = 50` and no secondary index, so its updates are heap-only (HOT). |
163
- | `event_completions` | One row per event that had a delivery complete, with the time of its last completion. Retention of completed work (`retention.completed`) is measured from it, however old the event is; events that never had a delivery complete are measured from their creation. |
164
- | `key_sequences` | One counter row per ordering key. |
165
- | `completed_deliveries` | Only with `completionHistory`: one insert-only row per completed delivery. |
159
+ | Table | Purpose |
160
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
161
+ | `events` | One row per event: `id` (**text**, so any caller id works), `type`, `source`, `key`, `sequence`, `payload json`, `headers json`, correlation and causation ids, `occurred_at`, `created_at`, and `handlers`, the handlers that ever got a delivery (it outlives the deliveries). |
162
+ | `deliveries` | One row per (event, handler) that is not finished: `lane`, `status` (`pending` or `dead`), `attempts`, `available_at`, `last_error jsonb`, key and sequence copied from the event, timestamps. `unique (event_id, handler)`, foreign key to events with `ON DELETE CASCADE`. |
163
+ | `lanes` | 256 rows, one per lane: `epoch` (the fencing token), `lease_owner`, `lease_expires_at` and `in_flight` (ids of the deliveries the owner claimed). `fillfactor = 50` and no secondary index, so its updates are heap-only (HOT). |
164
+ | `event_completions` | One row per event that had a delivery complete, with the time of its last completion. Retention of completed work (`retention.completed`) is measured from it, however old the event is; events that never had a delivery complete are measured from their creation. |
165
+ | `key_sequences` | One counter row per ordering key. |
166
+ | `completed_deliveries` | Only with `completionHistory`: one insert-only row per completed delivery. |
166
167
 
167
168
  A delivery row is inserted once and deleted once when it completes. Claiming, renewing a lease and completing never
168
169
  update it: they update the lane row (claim, renewal) or delete the delivery (completion), so the indexed columns of a
@@ -257,10 +258,12 @@ tool, run it as one transaction with the workers stopped. Running 0.1.0 and the
257
258
  - **Key counters.** `key_sequences` keeps one row per key ever published and rows are never deleted; the table grows
258
259
  with the number of distinct keys (a bigint counter each). Pick keys with bounded cardinality, or prune counters of keys
259
260
  you know are retired once nothing for them is pending.
260
- - **Stored JSON.** Payloads and headers are stored as `jsonb`: object keys come back **reordered** (shorter keys first,
261
- then bytewise) and duplicate keys collapse, values are otherwise identical. A string containing `\u0000` (NUL) or a
262
- lone UTF-16 surrogate is rejected by PostgreSQL and fails the publish with a database error; strip or encode such
263
- text before publishing.
261
+ - **Stored JSON.** Payloads and headers are stored as `json`, the text the producer serialized: an event read back
262
+ serializes byte for byte as it was published, key order included. (Before 0.4.1 they were `jsonb`, which re-orders
263
+ object keys; `setup()` converts the columns, and rows stored before the upgrade keep the order `jsonb` gave them. See
264
+ [Upgrading](https://berkcansavur.github.io/relaybus/upgrading).) A string containing `\u0000` (NUL) or a lone UTF-16
265
+ surrogate is rejected by PostgreSQL and fails the publish with a database error; strip or encode such text before
266
+ publishing.
264
267
  - **Sequence gaps.** Republishing an existing event id consumes a sequence number. Gaps are normal; only order matters.
265
268
  - **Vacuum.** Completing a delivery deletes its row, which leaves a dead tuple until autovacuum runs. The tables are
266
269
  created with `autovacuum_vacuum_scale_factor = 0.02`; for sustained throughput above a few thousand deliveries per
@@ -315,8 +318,8 @@ tool, run it as one transaction with the workers stopped. Running 0.1.0 and the
315
318
  `createPostgresSchemaSql()`.
316
319
  - `permission denied for schema`: the role needs `CREATE` on the schema for `setup()`; with `setupStore: false` it needs
317
320
  only DML rights.
318
- - `invalid input syntax for type json` / `\u0000 cannot be converted to text`: payloads with NUL characters are not valid
319
- `jsonb`.
321
+ - `invalid input syntax for type json` / `unsupported Unicode escape sequence`: the payload holds a NUL character or a
322
+ lone UTF-16 surrogate, which PostgreSQL rejects.
320
323
  - Foreign key error on append: the only known cause is republishing an event id in the same instant its last delivery
321
324
  is purged. The publishing transaction rolls back and can simply be retried.
322
325
  - A key looks stuck: a pending or processing head blocks it. Inspect `listDeliveries({ status: 'processing' })` and
package/dist/index.cjs CHANGED
@@ -230,6 +230,7 @@ function resolvePostgresNames(options = {}) {
230
230
  schema,
231
231
  quotedSchema,
232
232
  events: table("events"),
233
+ eventsTableName: name("events"),
233
234
  deliveries: table("deliveries"),
234
235
  keySequences: table("key_sequences"),
235
236
  lanes: table("lanes"),
@@ -256,8 +257,17 @@ function laneExpression(keyExpression, eventIdExpression) {
256
257
 
257
258
  // src/schema.ts
258
259
  var SCHEMA_MARKER_DIGITS = 32;
260
+ var PAYLOAD_COLUMNS = ` payload json NOT NULL,
261
+ headers json NOT NULL DEFAULT '{}'::json,`;
262
+ var JSONB_PAYLOAD_COLUMNS = ` payload jsonb NOT NULL,
263
+ headers jsonb NOT NULL DEFAULT '{}'::jsonb,`;
259
264
  function createPostgresSchemaStatements(options = {}) {
260
265
  const names = resolvePostgresNames(options);
266
+ const statements = schemaBodyStatements(names);
267
+ statements.push(`COMMENT ON TABLE ${names.lanes} IS '${version040Marker(names, statements)}'`);
268
+ return statements;
269
+ }
270
+ function schemaBodyStatements(names) {
261
271
  const statements = [...eventAndDeliveryStatements(names), ...laneAndCompletionStatements(names)];
262
272
  if (names.completionHistory) {
263
273
  statements.push(...completionHistoryStatements(names));
@@ -265,7 +275,6 @@ function createPostgresSchemaStatements(options = {}) {
265
275
  if (names.schema !== "public") {
266
276
  statements.unshift(`CREATE SCHEMA IF NOT EXISTS ${names.quotedSchema}`);
267
277
  }
268
- statements.push(`COMMENT ON TABLE ${names.lanes} IS '${postgresSchemaMarker(statements)}'`);
269
278
  return statements;
270
279
  }
271
280
  function eventAndDeliveryStatements(names) {
@@ -279,14 +288,14 @@ function eventAndKeySequenceStatements(names) {
279
288
  source text NOT NULL,
280
289
  key text,
281
290
  sequence bigint,
282
- payload jsonb NOT NULL,
283
- headers jsonb NOT NULL DEFAULT '{}'::jsonb,
291
+ ${PAYLOAD_COLUMNS}
284
292
  correlation_id text,
285
293
  causation_id text,
286
294
  occurred_at timestamptz NOT NULL,
287
295
  created_at timestamptz NOT NULL,
288
296
  handlers text[] NOT NULL DEFAULT '{}'
289
297
  )`,
298
+ upgradePayloadToJson(names),
290
299
  `CREATE INDEX IF NOT EXISTS ${names.indexes.eventCreated} ON ${names.events} (created_at, id)`,
291
300
  `CREATE TABLE IF NOT EXISTS ${names.keySequences} (
292
301
  key text PRIMARY KEY,
@@ -382,6 +391,33 @@ BEGIN
382
391
  END
383
392
  $relaybus_upgrade$`;
384
393
  }
394
+ function upgradePayloadToJson(names) {
395
+ return `DO $relaybus_payload_json$
396
+ BEGIN
397
+ IF EXISTS (
398
+ SELECT 1 FROM pg_attribute
399
+ WHERE attrelid = to_regclass('${names.events}') AND attname IN ('payload', 'headers')
400
+ AND atttypid = 'jsonb'::regtype AND NOT attisdropped
401
+ ) THEN
402
+ ALTER TABLE ${names.events}
403
+ ALTER COLUMN headers DROP DEFAULT,
404
+ ALTER COLUMN payload TYPE json USING payload::json,
405
+ ALTER COLUMN headers TYPE json USING headers::json,
406
+ ALTER COLUMN headers SET DEFAULT '{}'::json;
407
+ END IF;
408
+ END
409
+ $relaybus_payload_json$`;
410
+ }
411
+ function version040SchemaMarker(options = {}) {
412
+ const names = resolvePostgresNames(options);
413
+ return version040Marker(names, schemaBodyStatements(names));
414
+ }
415
+ function version040Marker(names, statements) {
416
+ const conversion = upgradePayloadToJson(names);
417
+ return postgresSchemaMarker(
418
+ statements.filter((statement) => statement !== conversion).map((statement) => statement.replace(PAYLOAD_COLUMNS, JSONB_PAYLOAD_COLUMNS))
419
+ );
420
+ }
385
421
  function createPostgresSchemaSql(options = {}) {
386
422
  return `${createPostgresSchemaStatements(options).join(";\n\n")};
387
423
  `;
@@ -1446,6 +1482,67 @@ var PostgresMaintenance = class {
1446
1482
  });
1447
1483
  }
1448
1484
  };
1485
+
1486
+ // src/postgres-setup.ts
1487
+ var PostgresSetup = class {
1488
+ #state;
1489
+ /** The conversion of `jsonb` payload columns. */
1490
+ #payloadUpgrade;
1491
+ constructor(state, options) {
1492
+ this.#state = state;
1493
+ this.#payloadUpgrade = upgradePayloadToJson(resolvePostgresNames(options));
1494
+ }
1495
+ /** Implements {@link PostgresEventStore.setup}. */
1496
+ async run() {
1497
+ if (isCurrent(await this.#schemaState())) {
1498
+ return;
1499
+ }
1500
+ await this.#underSetupLock(async (transaction) => {
1501
+ await this.#run(this.#payloadUpgrade, transaction);
1502
+ });
1503
+ await this.#underSetupLock(async (transaction, schema) => {
1504
+ if (schema.marked) {
1505
+ await this.#run(this.#payloadUpgrade, transaction);
1506
+ return;
1507
+ }
1508
+ for (const statement of this.#state.schemaStatements) {
1509
+ await this.#run(statement, transaction);
1510
+ }
1511
+ });
1512
+ }
1513
+ /**
1514
+ * Runs `work` in a transaction under the setup lock, unless the schema became current. The
1515
+ * payload conversion goes first in a transaction of its own, so it holds no other table lock
1516
+ * while it waits for the event table (a completion locks deliveries before the event rows).
1517
+ */
1518
+ async #underSetupLock(work) {
1519
+ await this.#state.executor.transaction(async (transaction) => {
1520
+ await this.#state.executor.query(
1521
+ this.#state.statements.setup.setupLock,
1522
+ [this.#state.setupLockKey],
1523
+ transaction
1524
+ );
1525
+ const schema = await this.#schemaState(transaction);
1526
+ if (!isCurrent(schema)) {
1527
+ await work(transaction, schema);
1528
+ }
1529
+ });
1530
+ }
1531
+ async #run(statement, transaction) {
1532
+ await this.#state.executor.query(statement, [], transaction);
1533
+ }
1534
+ async #schemaState(transaction) {
1535
+ const rows = await this.#state.executor.query(
1536
+ this.#state.statements.setup.schemaCurrent,
1537
+ [this.#state.schemaMarker],
1538
+ transaction
1539
+ );
1540
+ return { marked: rows[0]["marked"] === true, payloadJson: rows[0]["payload_json"] === true };
1541
+ }
1542
+ };
1543
+ function isCurrent(schema) {
1544
+ return schema.marked && schema.payloadJson;
1545
+ }
1449
1546
  var WAKE_UPS = ["notify", "poll"];
1450
1547
  var DEFAULT_PURGE_BATCH_SIZE = 1e3;
1451
1548
  function resolveStoreSettings(options) {
@@ -1520,8 +1617,8 @@ WHERE (SELECT count(*) FROM woken) >= 0`;
1520
1617
  reserved.sequence - count(*) OVER (PARTITION BY incoming.key)
1521
1618
  + row_number() OVER (PARTITION BY incoming.key ORDER BY incoming.ordinal)
1522
1619
  END AS sequence
1523
- FROM jsonb_to_recordset($1::jsonb) AS incoming (
1524
- ordinal int, id text, type text, source text, key text, payload jsonb, headers jsonb,
1620
+ FROM json_to_recordset($1::json) AS incoming (
1621
+ ordinal int, id text, type text, source text, key text, payload json, headers json,
1525
1622
  correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1526
1623
  LEFT JOIN reserved ON reserved.key = incoming.key
1527
1624
  )`;
@@ -1532,7 +1629,7 @@ WHERE (SELECT count(*) FROM woken) >= 0`;
1532
1629
  INSERT INTO ${this.#names.events}
1533
1630
  (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1534
1631
  SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1535
- COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
1632
+ COALESCE(incoming.payload, 'null'::json), COALESCE(incoming.headers, '{}'::json),
1536
1633
  incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz,
1537
1634
  COALESCE(incoming.handlers, '{}')
1538
1635
  FROM incoming
@@ -1585,11 +1682,11 @@ WHERE (SELECT count(*) FROM woken) >= 0`;
1585
1682
  return `INSERT INTO ${this.#names.events}
1586
1683
  (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1587
1684
  SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1588
- COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
1685
+ COALESCE(incoming.payload, 'null'::json), COALESCE(incoming.headers, '{}'::json),
1589
1686
  incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz,
1590
1687
  COALESCE(incoming.handlers, '{}')
1591
- FROM jsonb_to_recordset($1::jsonb) AS incoming (
1592
- id text, type text, source text, key text, sequence bigint, payload jsonb, headers jsonb,
1688
+ FROM json_to_recordset($1::json) AS incoming (
1689
+ id text, type text, source text, key text, sequence bigint, payload json, headers json,
1593
1690
  correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1594
1691
  ORDER BY incoming.id
1595
1692
  ON CONFLICT (id) DO NOTHING
@@ -2839,8 +2936,10 @@ var SetupStatements = class {
2839
2936
  return `relaybus:${this.#names.schema}`;
2840
2937
  }
2841
2938
  /**
2842
- * Whether the lane table carries `marker` as its comment, that is whether the schema statements
2843
- * that produce that marker already ran. Takes no table lock. Parameters: `[marker]`.
2939
+ * The state of the schema, taking no table lock. Parameters: `[marker]`. Columns: `marked`, whether
2940
+ * the lane table carries `marker` as its comment (the schema statements that produce it already
2941
+ * ran), and `payload_json`, whether the payload and headers columns of the event table are both
2942
+ * `json` (the conversion of the `jsonb` columns of schemas before 0.4.1 ran).
2844
2943
  */
2845
2944
  get schemaCurrent() {
2846
2945
  return `SELECT EXISTS (
@@ -2849,7 +2948,14 @@ var SetupStatements = class {
2849
2948
  JOIN pg_catalog.pg_description AS d
2850
2949
  ON d.objoid = c.oid AND d.classoid = 'pg_catalog.pg_class'::regclass AND d.objsubid = 0
2851
2950
  WHERE n.nspname = '${this.#names.schema}' AND c.relname = '${this.#names.lanesTableName}' AND d.description = $1::text
2852
- ) AS current`;
2951
+ ) AS marked, (
2952
+ SELECT count(*) FROM pg_catalog.pg_attribute AS a
2953
+ JOIN pg_catalog.pg_class AS c ON c.oid = a.attrelid
2954
+ JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
2955
+ WHERE n.nspname = '${this.#names.schema}' AND c.relname = '${this.#names.eventsTableName}'
2956
+ AND a.attname IN ('payload', 'headers') AND a.atttypid = 'pg_catalog.json'::pg_catalog.regtype
2957
+ AND NOT a.attisdropped
2958
+ ) = 2 AS payload_json`;
2853
2959
  }
2854
2960
  /**
2855
2961
  * Takes the setup lock. Returns a boolean column rather than the `void` of the lock function, which
@@ -2887,6 +2993,7 @@ var PostgresEventStore = class {
2887
2993
  #claims;
2888
2994
  #completion;
2889
2995
  #maintenance;
2996
+ #setup;
2890
2997
  #owned = new OwnedConnections();
2891
2998
  constructor(options) {
2892
2999
  const { purgeBatchSize, wakeUp, queryOptions } = resolveStoreSettings(options);
@@ -2900,7 +3007,7 @@ var PostgresEventStore = class {
2900
3007
  statements,
2901
3008
  schemaStatements,
2902
3009
  setupLockKey: statements.setup.setupLockKey,
2903
- schemaMarker: postgresSchemaMarker(schemaStatements.slice(0, -1)),
3010
+ schemaMarker: version040SchemaMarker(options),
2904
3011
  purgeBatchSize,
2905
3012
  completionHistory: names.completionHistory,
2906
3013
  logger,
@@ -2924,42 +3031,20 @@ var PostgresEventStore = class {
2924
3031
  this.#claims = new PostgresClaims(state);
2925
3032
  this.#completion = new PostgresCompletion(state);
2926
3033
  this.#maintenance = new PostgresMaintenance(state);
3034
+ this.#setup = new PostgresSetup(state, options);
2927
3035
  }
2928
3036
  /**
2929
3037
  * Creates the schema, tables and indexes if they do not exist. Safe to call
2930
3038
  * from many processes at once: a transaction-scoped advisory lock serializes
2931
3039
  * the DDL, so racing `CREATE TABLE IF NOT EXISTS` statements cannot collide.
2932
- * When the lane table already carries the marker of the current statements
2933
- * ({@link postgresSchemaMarker}) nothing runs and no table is locked, so instances
3040
+ * When the lane table already carries the schema marker ({@link version040SchemaMarker})
3041
+ * and the payload columns are `json`, nothing runs and no table is locked, so instances
2934
3042
  * that start next to running dispatchers cannot deadlock with their claims.
2935
3043
  * Skip it (`setupStore: false`) when a migration tool applies
2936
3044
  * `createPostgresSchemaSql()` instead.
2937
3045
  */
2938
3046
  async setup() {
2939
- if (await this.#schemaIsCurrent()) {
2940
- return;
2941
- }
2942
- await this.#state.executor.transaction(async (transaction) => {
2943
- await this.#state.executor.query(
2944
- this.#state.statements.setup.setupLock,
2945
- [this.#state.setupLockKey],
2946
- transaction
2947
- );
2948
- if (await this.#schemaIsCurrent(transaction)) {
2949
- return;
2950
- }
2951
- for (const statement of this.#state.schemaStatements) {
2952
- await this.#state.executor.query(statement, [], transaction);
2953
- }
2954
- });
2955
- }
2956
- async #schemaIsCurrent(transaction) {
2957
- const rows = await this.#state.executor.query(
2958
- this.#state.statements.setup.schemaCurrent,
2959
- [this.#state.schemaMarker],
2960
- transaction
2961
- );
2962
- return rows[0]["current"] === true;
3047
+ await this.#setup.run();
2963
3048
  }
2964
3049
  /**
2965
3050
  * Runs `work` in a transaction of the executor. With `options.timeoutMs` (from `bus.transaction`)