@syncular/server 0.39.1 → 0.39.2

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
@@ -1292,15 +1292,13 @@ install no fanout at all.
1292
1292
  ```ts
1293
1293
  import { PostgresFanout, type PgNotificationConnection } from '@syncular/server';
1294
1294
 
1295
- // node-postgres: a dedicated Client for LISTEN + the pool for NOTIFY.
1295
+ // Bun.sql over the direct (unpooled) URL: LISTEN needs a session-mode
1296
+ // connection, and Bun.sql reconnects a dropped listener and re-runs onListen.
1297
+ const sql = new Bun.SQL(process.env.DATABASE_URL_DIRECT!);
1296
1298
  const conn: PgNotificationConnection = {
1297
- async listen(channel, handler) {
1298
- const client = await pool.connect(); // long-lived, NOT released
1299
- client.on('notification', (m) => m.payload && handler(m.payload));
1300
- await client.query(`LISTEN ${channel}`);
1301
- },
1302
- notify: (channel, payload) =>
1303
- pool.query('SELECT pg_notify($1, $2)', [channel, payload]).then(() => {}),
1299
+ listen: (channel, handler, onListen) =>
1300
+ sql.listen(channel, handler, onListen).then(() => {}),
1301
+ notify: (channel, payload) => sql.notify(channel, payload).then(() => {}),
1304
1302
  };
1305
1303
  const fanout = new PostgresFanout(conn);
1306
1304
  await fanout.install(hub); // start the LISTEN loop
@@ -1308,10 +1306,16 @@ await fanout.install(hub); // start the LISTEN loop
1308
1306
  await fanout.notifyCommit(partition, commitSeq);
1309
1307
  ```
1310
1308
 
1309
+ Notifications sent while a listener session is down are lost. Every
1310
+ `onListen` after the first therefore wakes all local sessions with
1311
+ `catchup-required` (`hub.wakeAll`). A node-postgres adapter reconnects its
1312
+ dedicated `Client` on `end` and calls `onListen` after each `LISTEN`.
1313
+
1311
1314
  pglite is single-connection and cannot exercise cross-connection NOTIFY,
1312
1315
  so the fanout integration test is env-gated on `SYNCULAR_PG_URL` (it wires
1313
- Bun.sql as a worked example) and skips cleanly; the payload encode/parse
1314
- and wake wiring are unit-tested hermetically.
1316
+ Bun.sql as a worked example and terminates the listener backend to cover the
1317
+ reconnect path) and skips cleanly; the payload encode/parse and wake wiring
1318
+ are unit-tested hermetically.
1315
1319
 
1316
1320
  ### Bench lane
1317
1321
 
@@ -29,6 +29,14 @@
29
29
  * The payload carries `commitSeq` purely for observability/ordering in logs;
30
30
  * the wake itself is seq-agnostic (a session pulls whatever it is behind).
31
31
  *
32
+ * ## Listener gaps
33
+ *
34
+ * Postgres drops notifications for a session that is not listening, so a
35
+ * LISTEN connection that dies and reconnects (server restart, failover, a
36
+ * serverless compute suspending) misses every commit in between. The
37
+ * connection reports each LISTEN that takes effect through `onListen`; every
38
+ * report after the first wakes all local sessions with `catchup-required`.
39
+ *
32
40
  * pglite cannot exercise cross-connection NOTIFY, so the integration
33
41
  * (`postgres-fanout.integration.test.ts`) is env-gated on `SYNCULAR_PG_URL`
34
42
  * and skips cleanly; the payload encode/parse is unit-tested hermetically.
@@ -51,18 +59,23 @@ export declare function parseFanoutPayload(text: string): FanoutPayload | undefi
51
59
  /** The hub surface the fanout wakes (a `RealtimeHub` satisfies this). */
52
60
  export interface FanoutWakeTarget {
53
61
  wake(partition: string, reason: WakeReason): void;
62
+ wakeAll(reason: WakeReason): void;
54
63
  }
55
64
  /**
56
65
  * A raw notification connection. Production wires a driver's LISTEN support:
66
+ * - Bun.sql and postgres.js: `sql.listen(channel, handler, onListen)`; both
67
+ * reconnect a dropped listener and call `onListen` again;
57
68
  * - node-postgres: a dedicated `Client` with `client.on('notification', …)`
58
- * after `LISTEN syncular_commit`;
59
- * - Bun.sql: `sql.listen(channel, handler)`.
69
+ * after `LISTEN syncular_commit`, reconnected by the host.
60
70
  * The `notify` side can reuse the main pool (`SELECT pg_notify($1,$2)`), but
61
- * LISTEN needs its own long-lived connection.
71
+ * LISTEN needs its own long-lived, unpooled connection.
62
72
  */
63
73
  export interface PgNotificationConnection {
64
- /** Register a payload handler for the channel and begin listening. */
65
- listen(channel: string, handler: (payload: string) => void): Promise<void> | void;
74
+ /**
75
+ * Register a payload handler for the channel and begin listening. Call
76
+ * `onListen` every time LISTEN takes effect, including after a reconnect.
77
+ */
78
+ listen(channel: string, handler: (payload: string) => void, onListen: () => void): Promise<void> | void;
66
79
  /** Send a notification on the channel. */
67
80
  notify(channel: string, payload: string): Promise<void>;
68
81
  close?(): Promise<void>;
@@ -54,11 +54,17 @@ export class PostgresFanout {
54
54
  if (this.#installed)
55
55
  throw new Error('fanout already installed');
56
56
  this.#installed = true;
57
+ let listening = false;
57
58
  await this.#conn.listen(FANOUT_CHANNEL, (payload) => {
58
59
  const parsed = parseFanoutPayload(payload);
59
60
  if (parsed === undefined)
60
61
  return; // ignore malformed frames
61
62
  hub.wake(parsed.partition, FANOUT_WAKE_REASON);
63
+ }, () => {
64
+ // A repeated LISTEN follows a gap whose notifications are lost.
65
+ if (listening)
66
+ hub.wakeAll(FANOUT_WAKE_REASON);
67
+ listening = true;
62
68
  });
63
69
  }
64
70
  /** Fan an applied commit out to the other instances. */
@@ -257,6 +257,8 @@ export declare class RealtimeHub {
257
257
  refreshScopes(partition: string, actorId?: string): Promise<void>;
258
258
  /** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
259
259
  wake(partition: string, reason: WakeReason): void;
260
+ /** Wake every session on every partition (fanout listener gap, §8.3). */
261
+ wakeAll(reason: WakeReason): void;
260
262
  }
261
263
  export declare function createRealtimeHub(config: RealtimeHubConfig): RealtimeHub;
262
264
  export {};
package/dist/realtime.js CHANGED
@@ -1012,6 +1012,11 @@ export class RealtimeHub {
1012
1012
  session.sendWake(reason);
1013
1013
  }
1014
1014
  }
1015
+ /** Wake every session on every partition (fanout listener gap, §8.3). */
1016
+ wakeAll(reason) {
1017
+ for (const session of this.#sessions)
1018
+ session.sendWake(reason);
1019
+ }
1015
1020
  }
1016
1021
  export function createRealtimeHub(config) {
1017
1022
  return new RealtimeHub(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.39.1",
3
+ "version": "0.39.2",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.39.1"
71
+ "@syncular/core": "0.39.2"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
@@ -29,6 +29,14 @@
29
29
  * The payload carries `commitSeq` purely for observability/ordering in logs;
30
30
  * the wake itself is seq-agnostic (a session pulls whatever it is behind).
31
31
  *
32
+ * ## Listener gaps
33
+ *
34
+ * Postgres drops notifications for a session that is not listening, so a
35
+ * LISTEN connection that dies and reconnects (server restart, failover, a
36
+ * serverless compute suspending) misses every commit in between. The
37
+ * connection reports each LISTEN that takes effect through `onListen`; every
38
+ * report after the first wakes all local sessions with `catchup-required`.
39
+ *
32
40
  * pglite cannot exercise cross-connection NOTIFY, so the integration
33
41
  * (`postgres-fanout.integration.test.ts`) is env-gated on `SYNCULAR_PG_URL`
34
42
  * and skips cleanly; the payload encode/parse is unit-tested hermetically.
@@ -84,21 +92,27 @@ export function parseFanoutPayload(text: string): FanoutPayload | undefined {
84
92
  /** The hub surface the fanout wakes (a `RealtimeHub` satisfies this). */
85
93
  export interface FanoutWakeTarget {
86
94
  wake(partition: string, reason: WakeReason): void;
95
+ wakeAll(reason: WakeReason): void;
87
96
  }
88
97
 
89
98
  /**
90
99
  * A raw notification connection. Production wires a driver's LISTEN support:
100
+ * - Bun.sql and postgres.js: `sql.listen(channel, handler, onListen)`; both
101
+ * reconnect a dropped listener and call `onListen` again;
91
102
  * - node-postgres: a dedicated `Client` with `client.on('notification', …)`
92
- * after `LISTEN syncular_commit`;
93
- * - Bun.sql: `sql.listen(channel, handler)`.
103
+ * after `LISTEN syncular_commit`, reconnected by the host.
94
104
  * The `notify` side can reuse the main pool (`SELECT pg_notify($1,$2)`), but
95
- * LISTEN needs its own long-lived connection.
105
+ * LISTEN needs its own long-lived, unpooled connection.
96
106
  */
97
107
  export interface PgNotificationConnection {
98
- /** Register a payload handler for the channel and begin listening. */
108
+ /**
109
+ * Register a payload handler for the channel and begin listening. Call
110
+ * `onListen` every time LISTEN takes effect, including after a reconnect.
111
+ */
99
112
  listen(
100
113
  channel: string,
101
114
  handler: (payload: string) => void,
115
+ onListen: () => void,
102
116
  ): Promise<void> | void;
103
117
  /** Send a notification on the channel. */
104
118
  notify(channel: string, payload: string): Promise<void>;
@@ -122,11 +136,20 @@ export class PostgresFanout {
122
136
  async install(hub: FanoutWakeTarget): Promise<void> {
123
137
  if (this.#installed) throw new Error('fanout already installed');
124
138
  this.#installed = true;
125
- await this.#conn.listen(FANOUT_CHANNEL, (payload) => {
126
- const parsed = parseFanoutPayload(payload);
127
- if (parsed === undefined) return; // ignore malformed frames
128
- hub.wake(parsed.partition, FANOUT_WAKE_REASON);
129
- });
139
+ let listening = false;
140
+ await this.#conn.listen(
141
+ FANOUT_CHANNEL,
142
+ (payload) => {
143
+ const parsed = parseFanoutPayload(payload);
144
+ if (parsed === undefined) return; // ignore malformed frames
145
+ hub.wake(parsed.partition, FANOUT_WAKE_REASON);
146
+ },
147
+ () => {
148
+ // A repeated LISTEN follows a gap whose notifications are lost.
149
+ if (listening) hub.wakeAll(FANOUT_WAKE_REASON);
150
+ listening = true;
151
+ },
152
+ );
130
153
  }
131
154
 
132
155
  /** Fan an applied commit out to the other instances. */
package/src/realtime.ts CHANGED
@@ -1313,6 +1313,11 @@ export class RealtimeHub {
1313
1313
  session.sendWake(reason);
1314
1314
  }
1315
1315
  }
1316
+
1317
+ /** Wake every session on every partition (fanout listener gap, §8.3). */
1318
+ wakeAll(reason: WakeReason): void {
1319
+ for (const session of this.#sessions) session.sendWake(reason);
1320
+ }
1316
1321
  }
1317
1322
 
1318
1323
  export function createRealtimeHub(config: RealtimeHubConfig): RealtimeHub {