@relaybus/postgres 0.3.1 → 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,15 +102,30 @@ 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 not integration-tested yet.** The adapter is typed structurally (no Prisma dependency) and unit-tested
106
- against a fake client only. Prisma interactive transactions default to a 5 s timeout, so pass
107
- `createPrismaExecutor(prisma, { timeout, maxWait })` if handlers run longer.
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`
107
+ driver adapter: the full event store contract, publishing in `prisma.$transaction`, rollback and concurrent
108
+ transactions. The adapter is typed structurally, so the package has no Prisma dependency. Prisma interactive
109
+ transactions default to a 5 s timeout, so pass `createPrismaExecutor(prisma, { timeout, maxWait })` if handlers run
110
+ longer.
111
+
112
+ **Give an ORM store the `pg` pool too.** ORMs send raw statements unprepared and offer no connection to `LISTEN` on.
113
+ Pass the `pg` pool your ORM's driver uses as `pool` next to `executor`: the statements the store sends outside your
114
+ transactions (the dispatcher's claims, completions and renewals) then run on it as prepared statements, and the
115
+ store listens for wake-ups, so an event published in your ORM transaction is dispatched right after that
116
+ transaction commits. Your transactions still go through the ORM, and the store never closes the pool.
117
+
118
+ ```ts
119
+ const pool = new Pool({ connectionString });
120
+ const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
121
+ const store = new PostgresEventStore({ executor: createPrismaExecutor(prisma), pool });
122
+ ```
108
123
 
109
124
  ## Options
110
125
 
111
126
  | Option | Default | Meaning |
112
127
  | ---------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------ |
113
- | `pool` / `connectionString` / `executor` | required, exactly one | How to reach the database. |
128
+ | `pool` / `connectionString` / `executor` | required, exactly one | How to reach the database. With `executor`, `pool` may be given too (see above). |
114
129
  | `poolConfig` | none | Extra `pg` pool settings when `connectionString` is used. |
115
130
  | `schema` | `'public'` | Schema of the tables. Created by `setup()` when it is not `public`. |
116
131
  | `tablePrefix` | `'relaybus_'` | Tables are `<prefix>events`, `<prefix>deliveries`, `<prefix>key_sequences`, `<prefix>lanes`. |
@@ -141,14 +156,14 @@ import { createPostgresSchemaSql, createPostgresSchemaStatements } from '@relayb
141
156
  const migration = createPostgresSchemaSql({ schema: 'app', tablePrefix: 'bus_' });
142
157
  ```
143
158
 
144
- | Table | Purpose |
145
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
146
- | `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). |
147
- | `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`. |
148
- | `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). |
149
- | `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. |
150
- | `key_sequences` | One counter row per ordering key. |
151
- | `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. |
152
167
 
153
168
  A delivery row is inserted once and deleted once when it completes. Claiming, renewing a lease and completing never
154
169
  update it: they update the lane row (claim, renewal) or delete the delivery (completion), so the indexed columns of a
@@ -243,10 +258,12 @@ tool, run it as one transaction with the workers stopped. Running 0.1.0 and the
243
258
  - **Key counters.** `key_sequences` keeps one row per key ever published and rows are never deleted; the table grows
244
259
  with the number of distinct keys (a bigint counter each). Pick keys with bounded cardinality, or prune counters of keys
245
260
  you know are retired once nothing for them is pending.
246
- - **Stored JSON.** Payloads and headers are stored as `jsonb`: object keys come back **reordered** (shorter keys first,
247
- then bytewise) and duplicate keys collapse, values are otherwise identical. A string containing `\u0000` (NUL) or a
248
- lone UTF-16 surrogate is rejected by PostgreSQL and fails the publish with a database error; strip or encode such
249
- 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.
250
267
  - **Sequence gaps.** Republishing an existing event id consumes a sequence number. Gaps are normal; only order matters.
251
268
  - **Vacuum.** Completing a delivery deletes its row, which leaves a dead tuple until autovacuum runs. The tables are
252
269
  created with `autovacuum_vacuum_scale_factor = 0.02`; for sustained throughput above a few thousand deliveries per
@@ -274,11 +291,16 @@ tool, run it as one transaction with the workers stopped. Running 0.1.0 and the
274
291
  that channel (`<schema>.<prefix>deliveries`, hashed when longer than 63 characters) on one connection of its own,
275
292
  opened with the pool's settings outside the pool, so an event published by any process runs within milliseconds
276
293
  instead of at the next poll. A lost listening connection is replaced after a second; polling remains the safety
277
- net, so a missed notification only costs latency. A store built on an `executor` cannot listen and relies on
278
- polling (and on the wake-up of its own process after a publish). `wakeUp: 'poll'` sends and listens for nothing.
294
+ net, so a missed notification only costs latency. The notification of a publish in your own transaction is sent
295
+ by PostgreSQL at its commit, however late that is, so the handler starts within milliseconds of the commit
296
+ (`wake-after-commit.integration.test.ts`). A store built on an `executor` listens only when it is given `pool`
297
+ as well; without it, it relies on polling and on the wake-ups of its own process after a publish (see
298
+ `bus.wake()`). `wakeUp: 'poll'` sends and listens for nothing.
279
299
  - **Prepared statements.** With `preparedStatements: true` (the default) the publish, claim, completion and renewal
280
300
  statements are named prepared statements: each connection parses and plans them once. Only `PgExecutor` (the
281
- default `pool` / `connectionString` path) prepares; the library adapters send every statement unprepared. See
301
+ default `pool` / `connectionString` path, and the `pool` given next to an `executor`) prepares; the library
302
+ adapters send the statements of your transactions unprepared (a dispatch batch on an ORM store parses 2 statements
303
+ without `pool`, 0 with it: `perf-design-orm-executors.integration.test.ts`). See
282
304
  `docs/performance/publish-path.md` for what that saves.
283
305
  - **Pooling.** Claim, complete, extend and purge need a free connection besides your transactions; size the pool for
284
306
  `dispatcher.concurrency` plus publishers; the listening connection comes on top of the pool's `max`. Listening and
@@ -296,8 +318,8 @@ tool, run it as one transaction with the workers stopped. Running 0.1.0 and the
296
318
  `createPostgresSchemaSql()`.
297
319
  - `permission denied for schema`: the role needs `CREATE` on the schema for `setup()`; with `setupStore: false` it needs
298
320
  only DML rights.
299
- - `invalid input syntax for type json` / `\u0000 cannot be converted to text`: payloads with NUL characters are not valid
300
- `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.
301
323
  - Foreign key error on append: the only known cause is republishing an event id in the same instant its last delivery
302
324
  is purged. The publishing transaction rolls back and can simply be retried.
303
325
  - A key looks stuck: a pending or processing head blocks it. Inspect `listDeliveries({ status: 'processing' })` and
@@ -310,12 +332,12 @@ tool, run it as one transaction with the workers stopped. Running 0.1.0 and the
310
332
  ## Testing
311
333
 
312
334
  Integration tests use Testcontainers against `postgres:17-alpine` and `postgres:13-alpine` (override with
313
- `RELAYBUS_POSTGRES_IMAGES`). Every adapter except Prisma runs the full `@relaybus/testing` event store contract plus
314
- transaction tests against the real database.
335
+ `RELAYBUS_POSTGRES_IMAGES`). Every adapter (Kysely, Knex, TypeORM, Drizzle, Prisma with and without `pool`) runs the
336
+ full `@relaybus/testing` event store contract plus transaction tests against the real database.
315
337
 
316
338
  ## When to use @relaybus/postgres, and when not to
317
339
 
318
- Use it when your data lives in PostgreSQL 13 or newer and events must commit or roll back with it. It is not for MySQL or MongoDB (see `@relaybus/mongodb` for MongoDB), and the Prisma adapter is typed and unit-tested but not yet integration-tested.
340
+ Use it when your data lives in PostgreSQL 13 or newer and events must commit or roll back with it. It is not for MySQL or MongoDB (see `@relaybus/mongodb` for MongoDB).
319
341
 
320
342
  Documentation: <https://berkcansavur.github.io/relaybus/packages/postgres>
321
343
 
@@ -1,5 +1,5 @@
1
1
  import { SQL } from 'drizzle-orm';
2
- import { P as PostgresExecutor } from './executor-CfJgktC6.cjs';
2
+ import { PostgresExecutor } from './index.cjs';
3
3
 
4
4
  /** What a Drizzle database and a Drizzle transaction have in common. */
5
5
  interface DrizzleQueryable {
@@ -17,4 +17,5 @@ interface DrizzleLike extends DrizzleQueryable {
17
17
  */
18
18
  declare function createDrizzleExecutor(database: DrizzleLike): PostgresExecutor<DrizzleQueryable>;
19
19
 
20
- export { type DrizzleLike, type DrizzleQueryable, createDrizzleExecutor };
20
+ export { createDrizzleExecutor };
21
+ export type { DrizzleLike, DrizzleQueryable };
package/dist/drizzle.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SQL } from 'drizzle-orm';
2
- import { P as PostgresExecutor } from './executor-CfJgktC6.js';
2
+ import { PostgresExecutor } from './index.js';
3
3
 
4
4
  /** What a Drizzle database and a Drizzle transaction have in common. */
5
5
  interface DrizzleQueryable {
@@ -17,4 +17,5 @@ interface DrizzleLike extends DrizzleQueryable {
17
17
  */
18
18
  declare function createDrizzleExecutor(database: DrizzleLike): PostgresExecutor<DrizzleQueryable>;
19
19
 
20
- export { type DrizzleLike, type DrizzleQueryable, createDrizzleExecutor };
20
+ export { createDrizzleExecutor };
21
+ export type { DrizzleLike, DrizzleQueryable };
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
  `;
@@ -493,6 +529,19 @@ var NotificationListener = class {
493
529
  await drop?.();
494
530
  }
495
531
  };
532
+
533
+ // src/root-pool-executor.ts
534
+ function withRootPool(executor, root) {
535
+ return {
536
+ ...executor.sessionTimeouts === void 0 ? {} : { sessionTimeouts: executor.sessionTimeouts },
537
+ transaction: (work) => executor.transaction(work),
538
+ ...executor.transactionWithBudget === void 0 ? {} : {
539
+ transactionWithBudget: (work, timeoutMs) => executor.transactionWithBudget(work, timeoutMs)
540
+ },
541
+ // eslint-disable-next-line relaybus/max-two-params -- PostgresExecutor port signature
542
+ query: (sql, parameters, transaction, options) => transaction === void 0 ? root.query(sql, parameters, void 0, options) : executor.query(sql, parameters, transaction, options)
543
+ };
544
+ }
496
545
  function text(row, column) {
497
546
  return row[column];
498
547
  }
@@ -1433,10 +1482,71 @@ var PostgresMaintenance = class {
1433
1482
  });
1434
1483
  }
1435
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
+ }
1436
1546
  var WAKE_UPS = ["notify", "poll"];
1437
1547
  var DEFAULT_PURGE_BATCH_SIZE = 1e3;
1438
1548
  function resolveStoreSettings(options) {
1439
- const sources = [options.pool, options.connectionString, options.executor].filter(
1549
+ const sources = [options.executor ?? options.pool, options.connectionString].filter(
1440
1550
  (source) => source !== void 0
1441
1551
  );
1442
1552
  if (sources.length !== 1) {
@@ -1507,8 +1617,8 @@ WHERE (SELECT count(*) FROM woken) >= 0`;
1507
1617
  reserved.sequence - count(*) OVER (PARTITION BY incoming.key)
1508
1618
  + row_number() OVER (PARTITION BY incoming.key ORDER BY incoming.ordinal)
1509
1619
  END AS sequence
1510
- FROM jsonb_to_recordset($1::jsonb) AS incoming (
1511
- 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,
1512
1622
  correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1513
1623
  LEFT JOIN reserved ON reserved.key = incoming.key
1514
1624
  )`;
@@ -1519,7 +1629,7 @@ WHERE (SELECT count(*) FROM woken) >= 0`;
1519
1629
  INSERT INTO ${this.#names.events}
1520
1630
  (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1521
1631
  SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1522
- COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
1632
+ COALESCE(incoming.payload, 'null'::json), COALESCE(incoming.headers, '{}'::json),
1523
1633
  incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz,
1524
1634
  COALESCE(incoming.handlers, '{}')
1525
1635
  FROM incoming
@@ -1542,9 +1652,13 @@ WHERE (SELECT count(*) FROM woken) >= 0`;
1542
1652
  RETURNING available_at
1543
1653
  )`;
1544
1654
  }
1545
- /** One wake-up notification on {@link PostgresNames.notificationChannel}, outside any publishing transaction. */
1655
+ /**
1656
+ * One wake-up notification on {@link PostgresNames.notificationChannel}, outside any publishing
1657
+ * transaction. Returns a boolean column rather than the `void` of `pg_notify`, which some libraries
1658
+ * cannot read (Prisma's raw queries reject a `void` column).
1659
+ */
1546
1660
  get notify() {
1547
- return `SELECT pg_notify('${this.#names.notificationChannel}', '')`;
1661
+ return `SELECT true AS notified FROM pg_notify('${this.#names.notificationChannel}', '')`;
1548
1662
  }
1549
1663
  /**
1550
1664
  * A CTE `woken` that sends one notification on {@link PostgresNames.notificationChannel} when a row of
@@ -1568,11 +1682,11 @@ WHERE (SELECT count(*) FROM woken) >= 0`;
1568
1682
  return `INSERT INTO ${this.#names.events}
1569
1683
  (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1570
1684
  SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1571
- COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
1685
+ COALESCE(incoming.payload, 'null'::json), COALESCE(incoming.headers, '{}'::json),
1572
1686
  incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz,
1573
1687
  COALESCE(incoming.handlers, '{}')
1574
- FROM jsonb_to_recordset($1::jsonb) AS incoming (
1575
- 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,
1576
1690
  correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1577
1691
  ORDER BY incoming.id
1578
1692
  ON CONFLICT (id) DO NOTHING
@@ -2822,8 +2936,10 @@ var SetupStatements = class {
2822
2936
  return `relaybus:${this.#names.schema}`;
2823
2937
  }
2824
2938
  /**
2825
- * Whether the lane table carries `marker` as its comment, that is whether the schema statements
2826
- * 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).
2827
2943
  */
2828
2944
  get schemaCurrent() {
2829
2945
  return `SELECT EXISTS (
@@ -2832,10 +2948,21 @@ var SetupStatements = class {
2832
2948
  JOIN pg_catalog.pg_description AS d
2833
2949
  ON d.objoid = c.oid AND d.classoid = 'pg_catalog.pg_class'::regclass AND d.objsubid = 0
2834
2950
  WHERE n.nspname = '${this.#names.schema}' AND c.relname = '${this.#names.lanesTableName}' AND d.description = $1::text
2835
- ) 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`;
2836
2959
  }
2960
+ /**
2961
+ * Takes the setup lock. Returns a boolean column rather than the `void` of the lock function, which
2962
+ * some libraries cannot read (Prisma's raw queries reject a `void` column).
2963
+ */
2837
2964
  get setupLock() {
2838
- return "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))";
2965
+ return "SELECT true AS locked FROM pg_advisory_xact_lock(hashtextextended($1::text, 0))";
2839
2966
  }
2840
2967
  };
2841
2968
 
@@ -2866,6 +2993,7 @@ var PostgresEventStore = class {
2866
2993
  #claims;
2867
2994
  #completion;
2868
2995
  #maintenance;
2996
+ #setup;
2869
2997
  #owned = new OwnedConnections();
2870
2998
  constructor(options) {
2871
2999
  const { purgeBatchSize, wakeUp, queryOptions } = resolveStoreSettings(options);
@@ -2879,7 +3007,7 @@ var PostgresEventStore = class {
2879
3007
  statements,
2880
3008
  schemaStatements,
2881
3009
  setupLockKey: statements.setup.setupLockKey,
2882
- schemaMarker: postgresSchemaMarker(schemaStatements.slice(0, -1)),
3010
+ schemaMarker: version040SchemaMarker(options),
2883
3011
  purgeBatchSize,
2884
3012
  completionHistory: names.completionHistory,
2885
3013
  logger,
@@ -2903,42 +3031,20 @@ var PostgresEventStore = class {
2903
3031
  this.#claims = new PostgresClaims(state);
2904
3032
  this.#completion = new PostgresCompletion(state);
2905
3033
  this.#maintenance = new PostgresMaintenance(state);
3034
+ this.#setup = new PostgresSetup(state, options);
2906
3035
  }
2907
3036
  /**
2908
3037
  * Creates the schema, tables and indexes if they do not exist. Safe to call
2909
3038
  * from many processes at once: a transaction-scoped advisory lock serializes
2910
3039
  * the DDL, so racing `CREATE TABLE IF NOT EXISTS` statements cannot collide.
2911
- * When the lane table already carries the marker of the current statements
2912
- * ({@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
2913
3042
  * that start next to running dispatchers cannot deadlock with their claims.
2914
3043
  * Skip it (`setupStore: false`) when a migration tool applies
2915
3044
  * `createPostgresSchemaSql()` instead.
2916
3045
  */
2917
3046
  async setup() {
2918
- if (await this.#schemaIsCurrent()) {
2919
- return;
2920
- }
2921
- await this.#state.executor.transaction(async (transaction) => {
2922
- await this.#state.executor.query(
2923
- this.#state.statements.setup.setupLock,
2924
- [this.#state.setupLockKey],
2925
- transaction
2926
- );
2927
- if (await this.#schemaIsCurrent(transaction)) {
2928
- return;
2929
- }
2930
- for (const statement of this.#state.schemaStatements) {
2931
- await this.#state.executor.query(statement, [], transaction);
2932
- }
2933
- });
2934
- }
2935
- async #schemaIsCurrent(transaction) {
2936
- const rows = await this.#state.executor.query(
2937
- this.#state.statements.setup.schemaCurrent,
2938
- [this.#state.schemaMarker],
2939
- transaction
2940
- );
2941
- return rows[0]["current"] === true;
3047
+ await this.#setup.run();
2942
3048
  }
2943
3049
  /**
2944
3050
  * Runs `work` in a transaction of the executor. With `options.timeoutMs` (from `bus.transaction`)
@@ -3104,7 +3210,12 @@ var PostgresEventStore = class {
3104
3210
  };
3105
3211
  function connect(options, { logger, owned }) {
3106
3212
  if (options.executor !== void 0) {
3107
- return { executor: options.executor, pool: void 0, ownedPool: void 0 };
3213
+ const { executor: executor2, pool: pool2 } = options;
3214
+ return {
3215
+ executor: pool2 === void 0 ? executor2 : withRootPool(executor2, new PgExecutor(pool2, { logger })),
3216
+ pool: pool2,
3217
+ ownedPool: void 0
3218
+ };
3108
3219
  }
3109
3220
  const pool = options.pool ?? new pg.Pool({
3110
3221
  ...options.poolConfig,