@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 +14 -10
- package/dist/postgres-fanout.d.ts +18 -5
- package/dist/postgres-fanout.js +6 -0
- package/dist/realtime.d.ts +2 -0
- package/dist/realtime.js +5 -0
- package/package.json +2 -2
- package/src/postgres-fanout.ts +32 -9
- package/src/realtime.ts +5 -0
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
|
-
//
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
|
1314
|
-
and
|
|
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
|
-
/**
|
|
65
|
-
|
|
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>;
|
package/dist/postgres-fanout.js
CHANGED
|
@@ -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. */
|
package/dist/realtime.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
71
|
+
"@syncular/core": "0.39.2"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/postgres-fanout.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 {
|