@syncular/server 0.39.1 → 0.40.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.
- package/README.md +18 -11
- package/dist/admin.d.ts +6 -0
- package/dist/admin.js +9 -0
- package/dist/d1-storage.d.ts +1 -0
- package/dist/d1-storage.js +56 -14
- package/dist/handler.js +56 -6
- package/dist/postgres-fanout.d.ts +18 -5
- package/dist/postgres-fanout.js +6 -0
- package/dist/postgres-storage.d.ts +2 -1
- package/dist/postgres-storage.js +94 -45
- package/dist/realtime.d.ts +2 -0
- package/dist/realtime.js +5 -0
- package/dist/relational-rows.d.ts +9 -0
- package/dist/relational-rows.js +30 -0
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +8 -0
- package/dist/sqlite-storage.d.ts +1 -0
- package/dist/sqlite-storage.js +45 -7
- package/dist/storage.d.ts +20 -1
- package/dist/storage.js +8 -0
- package/package.json +2 -2
- package/src/admin.ts +15 -0
- package/src/d1-storage.ts +102 -14
- package/src/handler.ts +78 -10
- package/src/postgres-fanout.ts +32 -9
- package/src/postgres-storage.ts +149 -58
- package/src/realtime.ts +5 -0
- package/src/relational-rows.ts +34 -0
- package/src/sqlite-dialect.ts +9 -0
- package/src/sqlite-storage.ts +87 -4
- package/src/storage.ts +39 -0
package/src/d1-storage.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune';
|
|
2
2
|
import { StorageQueryError } from './storage-errors';
|
|
3
3
|
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
4
|
-
import { PARTITION_TOUCH_INTERVAL_MS } from './storage';
|
|
4
|
+
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage';
|
|
5
5
|
/**
|
|
6
6
|
* Cloudflare D1 server storage for Workers deployments.
|
|
7
7
|
*
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
type StoredColumnLayout,
|
|
79
79
|
scanRowPageSql,
|
|
80
80
|
schemaDdl,
|
|
81
|
+
serverIndexDdl,
|
|
81
82
|
SQLITE_TABLE_INDEXES_SQL,
|
|
82
83
|
type SqliteTableIndexRow,
|
|
83
84
|
sqliteIndexSignatures,
|
|
@@ -104,6 +105,7 @@ import {
|
|
|
104
105
|
type SqliteRowRecord,
|
|
105
106
|
serializePushResult,
|
|
106
107
|
sqliteDdlStatements,
|
|
108
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
107
109
|
toStoredChange,
|
|
108
110
|
toStoredRow,
|
|
109
111
|
} from './sqlite-dialect';
|
|
@@ -999,6 +1001,15 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
999
1001
|
'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;',
|
|
1000
1002
|
);
|
|
1001
1003
|
}
|
|
1004
|
+
for (const [name, definition] of Object.entries(
|
|
1005
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
1006
|
+
)) {
|
|
1007
|
+
if (!results.some((column) => column.name === name)) {
|
|
1008
|
+
await this.#db.exec(
|
|
1009
|
+
`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition};`,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1002
1013
|
}
|
|
1003
1014
|
|
|
1004
1015
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
@@ -1129,6 +1140,44 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1129
1140
|
'sqlite',
|
|
1130
1141
|
);
|
|
1131
1142
|
}
|
|
1143
|
+
// A database from before the round-state columns gains them in place.
|
|
1144
|
+
statementsExecuted++;
|
|
1145
|
+
const clientColumns = await this.#db
|
|
1146
|
+
.prepare('PRAGMA table_info("sync_clients")')
|
|
1147
|
+
.all<{ name: string }>();
|
|
1148
|
+
const missingRoundColumns = Object.entries(
|
|
1149
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
1150
|
+
).filter(
|
|
1151
|
+
([name]) =>
|
|
1152
|
+
!clientColumns.results.some((column) => column.name === name),
|
|
1153
|
+
);
|
|
1154
|
+
if (missingRoundColumns.length > 0) {
|
|
1155
|
+
await batch(
|
|
1156
|
+
missingRoundColumns.map(([name, definition]) =>
|
|
1157
|
+
this.#db.prepare(
|
|
1158
|
+
`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
|
|
1159
|
+
),
|
|
1160
|
+
),
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
// Server-only indexes reconcile at every open of a database already at
|
|
1164
|
+
// this version: they never reach a client, so no version bump is needed.
|
|
1165
|
+
const indexes = new Map<string, ReadonlyMap<string, string>>();
|
|
1166
|
+
for (const table of tables) {
|
|
1167
|
+
statementsExecuted++;
|
|
1168
|
+
const { results } = await this.#db
|
|
1169
|
+
.prepare(SQLITE_TABLE_INDEXES_SQL)
|
|
1170
|
+
.bind(table.name)
|
|
1171
|
+
.all<SqliteTableIndexRow>();
|
|
1172
|
+
indexes.set(
|
|
1173
|
+
table.name,
|
|
1174
|
+
new Map(Object.entries(sqliteIndexSignatures(results))),
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
const reconcile = serverIndexDdl(schema, indexes);
|
|
1178
|
+
if (reconcile.length > 0) {
|
|
1179
|
+
await batch(reconcile.map((sql) => this.#db.prepare(sql)));
|
|
1180
|
+
}
|
|
1132
1181
|
this.#tables = schema.tables;
|
|
1133
1182
|
this.#schemaVersion = schema.version;
|
|
1134
1183
|
return { complete: true, statementsExecuted };
|
|
@@ -1248,19 +1297,20 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1248
1297
|
const columns = await this.#db
|
|
1249
1298
|
.prepare('PRAGMA table_info("sync_clients")')
|
|
1250
1299
|
.all<{ name: string }>();
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1300
|
+
const missingColumns = Object.entries({
|
|
1301
|
+
wire_version: 'INTEGER NOT NULL DEFAULT 1',
|
|
1302
|
+
...SYNC_CLIENT_ROUND_COLUMNS,
|
|
1303
|
+
}).filter(
|
|
1304
|
+
([name]) => !columns.results.some((column) => column.name === name),
|
|
1253
1305
|
);
|
|
1254
1306
|
if (
|
|
1255
1307
|
!(await save(
|
|
1256
1308
|
{ ...state, phase: 'prepare', offset: 0 },
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
]
|
|
1263
|
-
: [],
|
|
1309
|
+
missingColumns.map(([name, definition]) =>
|
|
1310
|
+
this.#db.prepare(
|
|
1311
|
+
`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
|
|
1312
|
+
),
|
|
1313
|
+
),
|
|
1264
1314
|
))
|
|
1265
1315
|
)
|
|
1266
1316
|
break;
|
|
@@ -2333,7 +2383,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2333
2383
|
): Promise<ClientRecord | undefined> {
|
|
2334
2384
|
const record = await this.#db
|
|
2335
2385
|
.prepare(
|
|
2336
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
2386
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? AND client_id=?',
|
|
2337
2387
|
)
|
|
2338
2388
|
.bind(partition, clientId)
|
|
2339
2389
|
.first<{
|
|
@@ -2343,6 +2393,9 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2343
2393
|
cursor: number;
|
|
2344
2394
|
subscriptions: string;
|
|
2345
2395
|
updated_at_ms: number;
|
|
2396
|
+
last_round_at_ms: number | null;
|
|
2397
|
+
last_error_code: string | null;
|
|
2398
|
+
consecutive_failures: number;
|
|
2346
2399
|
}>();
|
|
2347
2400
|
if (record === null) return undefined;
|
|
2348
2401
|
return {
|
|
@@ -2352,6 +2405,11 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2352
2405
|
cursor: record.cursor,
|
|
2353
2406
|
updatedAtMs: record.updated_at_ms,
|
|
2354
2407
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
2408
|
+
...clientRoundState(
|
|
2409
|
+
record.last_round_at_ms,
|
|
2410
|
+
record.last_error_code,
|
|
2411
|
+
record.consecutive_failures,
|
|
2412
|
+
),
|
|
2355
2413
|
};
|
|
2356
2414
|
}
|
|
2357
2415
|
|
|
@@ -2364,13 +2422,16 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2364
2422
|
// record; a delete-and-insert would drop and recreate the row instead.
|
|
2365
2423
|
await this.#db
|
|
2366
2424
|
.prepare(
|
|
2367
|
-
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (
|
|
2425
|
+
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
|
|
2368
2426
|
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
2369
2427
|
actor_id=excluded.actor_id,
|
|
2370
2428
|
wire_version=excluded.wire_version,
|
|
2371
2429
|
cursor=excluded.cursor,
|
|
2372
2430
|
subscriptions=excluded.subscriptions,
|
|
2373
|
-
updated_at_ms=excluded.updated_at_ms
|
|
2431
|
+
updated_at_ms=excluded.updated_at_ms,
|
|
2432
|
+
last_round_at_ms=excluded.last_round_at_ms,
|
|
2433
|
+
last_error_code=NULL,
|
|
2434
|
+
consecutive_failures=0`,
|
|
2374
2435
|
)
|
|
2375
2436
|
.bind(
|
|
2376
2437
|
partition,
|
|
@@ -2380,7 +2441,26 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2380
2441
|
record.cursor,
|
|
2381
2442
|
JSON.stringify(record.subscriptions),
|
|
2382
2443
|
record.updatedAtMs,
|
|
2444
|
+
record.updatedAtMs,
|
|
2445
|
+
)
|
|
2446
|
+
.run();
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
async recordClientRoundFailure(
|
|
2450
|
+
partition: string,
|
|
2451
|
+
clientId: string,
|
|
2452
|
+
actorId: string,
|
|
2453
|
+
errorCode: string,
|
|
2454
|
+
atMs: number,
|
|
2455
|
+
): Promise<void> {
|
|
2456
|
+
await this.#db
|
|
2457
|
+
.prepare(
|
|
2458
|
+
`UPDATE sync_clients
|
|
2459
|
+
SET last_round_at_ms=?, last_error_code=?,
|
|
2460
|
+
consecutive_failures=consecutive_failures+1
|
|
2461
|
+
WHERE partition=? AND client_id=? AND actor_id=?`,
|
|
2383
2462
|
)
|
|
2463
|
+
.bind(atMs, errorCode, partition, clientId, actorId)
|
|
2384
2464
|
.run();
|
|
2385
2465
|
}
|
|
2386
2466
|
|
|
@@ -2537,7 +2617,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2537
2617
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|
|
2538
2618
|
const { results } = await this.#db
|
|
2539
2619
|
.prepare(
|
|
2540
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
2620
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
2541
2621
|
)
|
|
2542
2622
|
.bind(partition)
|
|
2543
2623
|
.all<{
|
|
@@ -2547,6 +2627,9 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2547
2627
|
cursor: number;
|
|
2548
2628
|
subscriptions: string;
|
|
2549
2629
|
updated_at_ms: number;
|
|
2630
|
+
last_round_at_ms: number | null;
|
|
2631
|
+
last_error_code: string | null;
|
|
2632
|
+
consecutive_failures: number;
|
|
2550
2633
|
}>();
|
|
2551
2634
|
return results.map((record) => ({
|
|
2552
2635
|
clientId: record.client_id,
|
|
@@ -2555,6 +2638,11 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2555
2638
|
cursor: record.cursor,
|
|
2556
2639
|
updatedAtMs: record.updated_at_ms,
|
|
2557
2640
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
2641
|
+
...clientRoundState(
|
|
2642
|
+
record.last_round_at_ms,
|
|
2643
|
+
record.last_error_code,
|
|
2644
|
+
record.consecutive_failures,
|
|
2645
|
+
),
|
|
2558
2646
|
}));
|
|
2559
2647
|
}
|
|
2560
2648
|
|
package/src/handler.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
RESOLVER_OUTAGE,
|
|
31
31
|
touchAuthenticatedPartition,
|
|
32
32
|
} from './context';
|
|
33
|
-
import { SyncError, syncError } from './errors';
|
|
33
|
+
import { reportError, SyncError, syncError } from './errors';
|
|
34
34
|
import {
|
|
35
35
|
emitEvent,
|
|
36
36
|
type PullSubscriptionSummary,
|
|
@@ -734,6 +734,7 @@ async function* streamResponse(
|
|
|
734
734
|
report.outcome = 'error';
|
|
735
735
|
report.errorCode = error.code;
|
|
736
736
|
}
|
|
737
|
+
await recordRoundFailure(ctx, plan.header.clientId, error);
|
|
737
738
|
// §1.6: in-band ERROR, then END, nothing else.
|
|
738
739
|
yield encodeResponseFrame(
|
|
739
740
|
{
|
|
@@ -765,7 +766,14 @@ export async function createSyncResponseStream(
|
|
|
765
766
|
ctx: SyncRequestContext,
|
|
766
767
|
): Promise<AsyncIterable<Uint8Array>> {
|
|
767
768
|
const sink = ctx.events;
|
|
768
|
-
if (sink === undefined)
|
|
769
|
+
if (sink === undefined) {
|
|
770
|
+
try {
|
|
771
|
+
return await createStreamCore(bytes, ctx, undefined);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
await recordRoundFailure(ctx, requestClientId(bytes), error);
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
769
777
|
// Instrumented path: measure with the ctx clock, count bytes at the
|
|
770
778
|
// seam, emit exactly one `request.handled` per request. Every event of
|
|
771
779
|
// this request carries one `requestId`.
|
|
@@ -779,6 +787,7 @@ export async function createSyncResponseStream(
|
|
|
779
787
|
try {
|
|
780
788
|
return await createStreamCore(bytes, requestCtx, events, startedAtMs);
|
|
781
789
|
} catch (error) {
|
|
790
|
+
const clientId = requestClientId(bytes);
|
|
782
791
|
emitEvent(events, {
|
|
783
792
|
type: 'request.handled',
|
|
784
793
|
kind: 'sync',
|
|
@@ -789,11 +798,64 @@ export async function createSyncResponseStream(
|
|
|
789
798
|
bytesIn: bytes.length,
|
|
790
799
|
bytesOut: 0,
|
|
791
800
|
outcome: 'rejected',
|
|
801
|
+
...(clientId !== undefined ? { clientId } : {}),
|
|
792
802
|
errorCode: error instanceof SyncError ? error.code : 'internal',
|
|
793
803
|
pushCommits: 0,
|
|
794
804
|
pulled: false,
|
|
795
805
|
subscriptions: 0,
|
|
796
806
|
});
|
|
807
|
+
await recordRoundFailure(ctx, clientId, error);
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The `REQ_HEADER` clientId of a request that failed before planning. */
|
|
813
|
+
function requestClientId(bytes: Uint8Array): string | undefined {
|
|
814
|
+
try {
|
|
815
|
+
const message = decodeMessage(bytes);
|
|
816
|
+
const header = message.frames[0];
|
|
817
|
+
return message.msgKind === 'request' && header?.type === 'REQ_HEADER'
|
|
818
|
+
? header.clientId
|
|
819
|
+
: undefined;
|
|
820
|
+
} catch {
|
|
821
|
+
return undefined;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Record a failed round on the client's stored record so an operator can see
|
|
827
|
+
* a stuck client and why. A storage failure here is reported through
|
|
828
|
+
* `onError` and never replaces the round's own outcome.
|
|
829
|
+
*/
|
|
830
|
+
async function recordRoundFailure(
|
|
831
|
+
ctx: SyncRequestContext,
|
|
832
|
+
clientId: string | undefined,
|
|
833
|
+
error: unknown,
|
|
834
|
+
): Promise<void> {
|
|
835
|
+
if (clientId === undefined) return;
|
|
836
|
+
try {
|
|
837
|
+
await ctx.storage.recordClientRoundFailure(
|
|
838
|
+
ctx.partition,
|
|
839
|
+
clientId,
|
|
840
|
+
ctx.actorId,
|
|
841
|
+
error instanceof SyncError ? error.code : 'sync.internal_error',
|
|
842
|
+
clockOf(ctx)(),
|
|
843
|
+
);
|
|
844
|
+
} catch (failure) {
|
|
845
|
+
reportError(failure, ctx.onError, 'sync');
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Record a failure thrown while the response streams (after planning). */
|
|
850
|
+
async function* recordedStream(
|
|
851
|
+
stream: AsyncIterable<Uint8Array>,
|
|
852
|
+
ctx: SyncRequestContext,
|
|
853
|
+
clientId: string,
|
|
854
|
+
): AsyncGenerator<Uint8Array> {
|
|
855
|
+
try {
|
|
856
|
+
yield* stream;
|
|
857
|
+
} catch (error) {
|
|
858
|
+
await recordRoundFailure(ctx, clientId, error);
|
|
797
859
|
throw error;
|
|
798
860
|
}
|
|
799
861
|
}
|
|
@@ -883,15 +945,21 @@ async function createStreamCore(
|
|
|
883
945
|
if (changed !== undefined) throw serveNotReadyError(changed);
|
|
884
946
|
for (const chunk of buffered) yield chunk;
|
|
885
947
|
};
|
|
886
|
-
if (events === undefined)
|
|
887
|
-
|
|
888
|
-
|
|
948
|
+
if (events === undefined) {
|
|
949
|
+
return recordedStream(verified(), ctx, plan.header.clientId);
|
|
950
|
+
}
|
|
951
|
+
return recordedStream(
|
|
952
|
+
instrumentedStream(
|
|
953
|
+
verified(),
|
|
954
|
+
ctx,
|
|
955
|
+
events,
|
|
956
|
+
plan,
|
|
957
|
+
report,
|
|
958
|
+
bytes.length,
|
|
959
|
+
startedAtMs,
|
|
960
|
+
),
|
|
889
961
|
ctx,
|
|
890
|
-
|
|
891
|
-
plan,
|
|
892
|
-
report,
|
|
893
|
-
bytes.length,
|
|
894
|
-
startedAtMs,
|
|
962
|
+
plan.header.clientId,
|
|
895
963
|
);
|
|
896
964
|
}
|
|
897
965
|
|
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. */
|