@synclib-io/sync 0.2.0 → 0.2.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/dist/index.d.mts +55 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +158 -7
- package/dist/index.mjs +158 -7
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -552,6 +552,33 @@ declare class SyncClient {
|
|
|
552
552
|
private serverHashColumns?;
|
|
553
553
|
private isInitialized;
|
|
554
554
|
private hasConnectedOnce;
|
|
555
|
+
/**
|
|
556
|
+
* True while a call to [connect] is in progress. Used by
|
|
557
|
+
* [handleStateChange] to skip its auto-rejoin of channels when an
|
|
558
|
+
* explicit caller is already going to call [joinChannels] itself.
|
|
559
|
+
*
|
|
560
|
+
* Without this guard, every reconnect after the first one races two
|
|
561
|
+
* `joinChannels()` invocations against each other: one from `connect()`'s
|
|
562
|
+
* `await this.joinChannels()` and one from `handleStateChange`'s fire-and-
|
|
563
|
+
* forget call. `WebSocketManager.joinChannel` (line 192-196) EVICTS any
|
|
564
|
+
* existing channel for the same topic before creating a new one, so the
|
|
565
|
+
* second call disposes the first call's channel — its push completer
|
|
566
|
+
* never sees the reply, the await hangs until the phoenix-js push
|
|
567
|
+
* timeout fires. Symptom server-side: two `JOINED <topic>` events ~50ms
|
|
568
|
+
* apart with the same device_id; client-side: 30s wait then timeout
|
|
569
|
+
* even though the channel is functionally joined via the surviving
|
|
570
|
+
* second call. Mirror of the Dart fix in synclib_sync/sync_client.dart.
|
|
571
|
+
*/
|
|
572
|
+
private explicitConnectInProgress;
|
|
573
|
+
/**
|
|
574
|
+
* Per-install identifier minted on first DB open and persisted in
|
|
575
|
+
* `_synclib_metadata`. Stable for the life of the SQLite/IndexedDB file.
|
|
576
|
+
* Sent on every join/push so the server can track per-device ack state
|
|
577
|
+
* and reliably trim the local outbound queue on reconnect (analog of the
|
|
578
|
+
* Dart `synclib_sync` implementation).
|
|
579
|
+
*/
|
|
580
|
+
private deviceId;
|
|
581
|
+
getDeviceId(): string | null;
|
|
555
582
|
private pendingAcks;
|
|
556
583
|
private pendingChangeInfo;
|
|
557
584
|
private remoteChangeListeners;
|
|
@@ -584,6 +611,16 @@ declare class SyncClient {
|
|
|
584
611
|
private get pullChannels();
|
|
585
612
|
/** Get all sync table names from channels config. */
|
|
586
613
|
private get allSyncTables();
|
|
614
|
+
/**
|
|
615
|
+
* Load the per-install `device_id` from `_synclib_metadata`, or mint and
|
|
616
|
+
* persist one if it doesn't exist yet. UUID v4 via `crypto.randomUUID()`
|
|
617
|
+
* (Node 14.17+, modern browsers), falling back to a manual constructor
|
|
618
|
+
* if the host lacks it.
|
|
619
|
+
*
|
|
620
|
+
* `_synclib_metadata` is created here (idempotent CREATE TABLE IF NOT
|
|
621
|
+
* EXISTS) so this can be called before the row_hash migration runs.
|
|
622
|
+
*/
|
|
623
|
+
private ensureDeviceId;
|
|
587
624
|
/**
|
|
588
625
|
* One-time migration: switch to server-authoritative row_hash.
|
|
589
626
|
* Sets all local row_hash values to '' (sentinel) so merkle comparison
|
|
@@ -706,6 +743,24 @@ declare class SyncClient {
|
|
|
706
743
|
* Extract server-driven hash_columns from a channel join response.
|
|
707
744
|
*/
|
|
708
745
|
private extractServerHashColumns;
|
|
746
|
+
/**
|
|
747
|
+
* Reconcile the local outbound queue (`_sync_changes`) with the server's
|
|
748
|
+
* per-device high-water mark reported on the channel join response. If
|
|
749
|
+
* the server tells us "I've already applied your local_seqnum up to N for
|
|
750
|
+
* this device on this channel," DELETE everything at or below N from the
|
|
751
|
+
* queue — those writes are guaranteed-committed, even if their acks never
|
|
752
|
+
* reached us (network drop, app crash, server restart between apply and
|
|
753
|
+
* ack send). Idempotent and safe on every join.
|
|
754
|
+
*
|
|
755
|
+
* CRITICAL: scope by table_name. Local seqnums in `_sync_changes` are
|
|
756
|
+
* AUTOINCREMENT-global across the file (one counter for ALL channels),
|
|
757
|
+
* while the server's high-water mark is per-(device, channel). An
|
|
758
|
+
* unscoped `markSynced(N)` would `DELETE WHERE seqnum <= N` and could
|
|
759
|
+
* sweep up unpushed writes for OTHER channels whose seqnums happen to
|
|
760
|
+
* fall below N. Filtering by the channel's table set keeps the delete
|
|
761
|
+
* correct in the multi-channel case.
|
|
762
|
+
*/
|
|
763
|
+
private trimQueueFromJoinResponse;
|
|
709
764
|
/**
|
|
710
765
|
* Send hello message to server
|
|
711
766
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -552,6 +552,33 @@ declare class SyncClient {
|
|
|
552
552
|
private serverHashColumns?;
|
|
553
553
|
private isInitialized;
|
|
554
554
|
private hasConnectedOnce;
|
|
555
|
+
/**
|
|
556
|
+
* True while a call to [connect] is in progress. Used by
|
|
557
|
+
* [handleStateChange] to skip its auto-rejoin of channels when an
|
|
558
|
+
* explicit caller is already going to call [joinChannels] itself.
|
|
559
|
+
*
|
|
560
|
+
* Without this guard, every reconnect after the first one races two
|
|
561
|
+
* `joinChannels()` invocations against each other: one from `connect()`'s
|
|
562
|
+
* `await this.joinChannels()` and one from `handleStateChange`'s fire-and-
|
|
563
|
+
* forget call. `WebSocketManager.joinChannel` (line 192-196) EVICTS any
|
|
564
|
+
* existing channel for the same topic before creating a new one, so the
|
|
565
|
+
* second call disposes the first call's channel — its push completer
|
|
566
|
+
* never sees the reply, the await hangs until the phoenix-js push
|
|
567
|
+
* timeout fires. Symptom server-side: two `JOINED <topic>` events ~50ms
|
|
568
|
+
* apart with the same device_id; client-side: 30s wait then timeout
|
|
569
|
+
* even though the channel is functionally joined via the surviving
|
|
570
|
+
* second call. Mirror of the Dart fix in synclib_sync/sync_client.dart.
|
|
571
|
+
*/
|
|
572
|
+
private explicitConnectInProgress;
|
|
573
|
+
/**
|
|
574
|
+
* Per-install identifier minted on first DB open and persisted in
|
|
575
|
+
* `_synclib_metadata`. Stable for the life of the SQLite/IndexedDB file.
|
|
576
|
+
* Sent on every join/push so the server can track per-device ack state
|
|
577
|
+
* and reliably trim the local outbound queue on reconnect (analog of the
|
|
578
|
+
* Dart `synclib_sync` implementation).
|
|
579
|
+
*/
|
|
580
|
+
private deviceId;
|
|
581
|
+
getDeviceId(): string | null;
|
|
555
582
|
private pendingAcks;
|
|
556
583
|
private pendingChangeInfo;
|
|
557
584
|
private remoteChangeListeners;
|
|
@@ -584,6 +611,16 @@ declare class SyncClient {
|
|
|
584
611
|
private get pullChannels();
|
|
585
612
|
/** Get all sync table names from channels config. */
|
|
586
613
|
private get allSyncTables();
|
|
614
|
+
/**
|
|
615
|
+
* Load the per-install `device_id` from `_synclib_metadata`, or mint and
|
|
616
|
+
* persist one if it doesn't exist yet. UUID v4 via `crypto.randomUUID()`
|
|
617
|
+
* (Node 14.17+, modern browsers), falling back to a manual constructor
|
|
618
|
+
* if the host lacks it.
|
|
619
|
+
*
|
|
620
|
+
* `_synclib_metadata` is created here (idempotent CREATE TABLE IF NOT
|
|
621
|
+
* EXISTS) so this can be called before the row_hash migration runs.
|
|
622
|
+
*/
|
|
623
|
+
private ensureDeviceId;
|
|
587
624
|
/**
|
|
588
625
|
* One-time migration: switch to server-authoritative row_hash.
|
|
589
626
|
* Sets all local row_hash values to '' (sentinel) so merkle comparison
|
|
@@ -706,6 +743,24 @@ declare class SyncClient {
|
|
|
706
743
|
* Extract server-driven hash_columns from a channel join response.
|
|
707
744
|
*/
|
|
708
745
|
private extractServerHashColumns;
|
|
746
|
+
/**
|
|
747
|
+
* Reconcile the local outbound queue (`_sync_changes`) with the server's
|
|
748
|
+
* per-device high-water mark reported on the channel join response. If
|
|
749
|
+
* the server tells us "I've already applied your local_seqnum up to N for
|
|
750
|
+
* this device on this channel," DELETE everything at or below N from the
|
|
751
|
+
* queue — those writes are guaranteed-committed, even if their acks never
|
|
752
|
+
* reached us (network drop, app crash, server restart between apply and
|
|
753
|
+
* ack send). Idempotent and safe on every join.
|
|
754
|
+
*
|
|
755
|
+
* CRITICAL: scope by table_name. Local seqnums in `_sync_changes` are
|
|
756
|
+
* AUTOINCREMENT-global across the file (one counter for ALL channels),
|
|
757
|
+
* while the server's high-water mark is per-(device, channel). An
|
|
758
|
+
* unscoped `markSynced(N)` would `DELETE WHERE seqnum <= N` and could
|
|
759
|
+
* sweep up unpushed writes for OTHER channels whose seqnums happen to
|
|
760
|
+
* fall below N. Filtering by the channel's table set keeps the delete
|
|
761
|
+
* correct in the multi-channel case.
|
|
762
|
+
*/
|
|
763
|
+
private trimQueueFromJoinResponse;
|
|
709
764
|
/**
|
|
710
765
|
* Send hello message to server
|
|
711
766
|
*/
|
package/dist/index.js
CHANGED
|
@@ -922,6 +922,32 @@ var SyncClient = class {
|
|
|
922
922
|
constructor(config) {
|
|
923
923
|
this.isInitialized = false;
|
|
924
924
|
this.hasConnectedOnce = false;
|
|
925
|
+
/**
|
|
926
|
+
* True while a call to [connect] is in progress. Used by
|
|
927
|
+
* [handleStateChange] to skip its auto-rejoin of channels when an
|
|
928
|
+
* explicit caller is already going to call [joinChannels] itself.
|
|
929
|
+
*
|
|
930
|
+
* Without this guard, every reconnect after the first one races two
|
|
931
|
+
* `joinChannels()` invocations against each other: one from `connect()`'s
|
|
932
|
+
* `await this.joinChannels()` and one from `handleStateChange`'s fire-and-
|
|
933
|
+
* forget call. `WebSocketManager.joinChannel` (line 192-196) EVICTS any
|
|
934
|
+
* existing channel for the same topic before creating a new one, so the
|
|
935
|
+
* second call disposes the first call's channel — its push completer
|
|
936
|
+
* never sees the reply, the await hangs until the phoenix-js push
|
|
937
|
+
* timeout fires. Symptom server-side: two `JOINED <topic>` events ~50ms
|
|
938
|
+
* apart with the same device_id; client-side: 30s wait then timeout
|
|
939
|
+
* even though the channel is functionally joined via the surviving
|
|
940
|
+
* second call. Mirror of the Dart fix in synclib_sync/sync_client.dart.
|
|
941
|
+
*/
|
|
942
|
+
this.explicitConnectInProgress = false;
|
|
943
|
+
/**
|
|
944
|
+
* Per-install identifier minted on first DB open and persisted in
|
|
945
|
+
* `_synclib_metadata`. Stable for the life of the SQLite/IndexedDB file.
|
|
946
|
+
* Sent on every join/push so the server can track per-device ack state
|
|
947
|
+
* and reliably trim the local outbound queue on reconnect (analog of the
|
|
948
|
+
* Dart `synclib_sync` implementation).
|
|
949
|
+
*/
|
|
950
|
+
this.deviceId = null;
|
|
925
951
|
this.pendingAcks = /* @__PURE__ */ new Set();
|
|
926
952
|
// Track which table/rowId corresponds to each pending change seqnum
|
|
927
953
|
this.pendingChangeInfo = /* @__PURE__ */ new Map();
|
|
@@ -961,6 +987,9 @@ var SyncClient = class {
|
|
|
961
987
|
this.ws.onMessage(this.enqueueMessage.bind(this));
|
|
962
988
|
this.ws.onStateChange(this.handleStateChange.bind(this));
|
|
963
989
|
}
|
|
990
|
+
getDeviceId() {
|
|
991
|
+
return this.deviceId;
|
|
992
|
+
}
|
|
964
993
|
/** Get channels that push (push or bidirectional role). */
|
|
965
994
|
get pushChannels() {
|
|
966
995
|
return this.config.channels.filter(
|
|
@@ -983,6 +1012,69 @@ var SyncClient = class {
|
|
|
983
1012
|
}
|
|
984
1013
|
return Array.from(tables);
|
|
985
1014
|
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Load the per-install `device_id` from `_synclib_metadata`, or mint and
|
|
1017
|
+
* persist one if it doesn't exist yet. UUID v4 via `crypto.randomUUID()`
|
|
1018
|
+
* (Node 14.17+, modern browsers), falling back to a manual constructor
|
|
1019
|
+
* if the host lacks it.
|
|
1020
|
+
*
|
|
1021
|
+
* `_synclib_metadata` is created here (idempotent CREATE TABLE IF NOT
|
|
1022
|
+
* EXISTS) so this can be called before the row_hash migration runs.
|
|
1023
|
+
*/
|
|
1024
|
+
ensureDeviceId() {
|
|
1025
|
+
try {
|
|
1026
|
+
this.db.exec(`
|
|
1027
|
+
CREATE TABLE IF NOT EXISTS _synclib_metadata (
|
|
1028
|
+
key TEXT PRIMARY KEY,
|
|
1029
|
+
value TEXT NOT NULL
|
|
1030
|
+
)
|
|
1031
|
+
`);
|
|
1032
|
+
const results = this.db.read(
|
|
1033
|
+
`SELECT value FROM _synclib_metadata WHERE key = 'device_id'`
|
|
1034
|
+
);
|
|
1035
|
+
if (results.length > 0 && results[0].value) {
|
|
1036
|
+
this.deviceId = String(results[0].value);
|
|
1037
|
+
console.log(`[SyncClient] Loaded device_id: ${this.deviceId}`);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const cryptoObj = globalThis.crypto;
|
|
1041
|
+
let uuid;
|
|
1042
|
+
if (cryptoObj?.randomUUID) {
|
|
1043
|
+
uuid = cryptoObj.randomUUID();
|
|
1044
|
+
} else if (cryptoObj?.getRandomValues) {
|
|
1045
|
+
const bytes = new Uint8Array(16);
|
|
1046
|
+
cryptoObj.getRandomValues(bytes);
|
|
1047
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
1048
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
1049
|
+
const hex = (b) => b.toString(16).padStart(2, "0");
|
|
1050
|
+
uuid = `${hex(bytes[0])}${hex(bytes[1])}${hex(bytes[2])}${hex(bytes[3])}-${hex(bytes[4])}${hex(bytes[5])}-${hex(bytes[6])}${hex(bytes[7])}-${hex(bytes[8])}${hex(bytes[9])}-${hex(bytes[10])}${hex(bytes[11])}${hex(bytes[12])}${hex(bytes[13])}${hex(bytes[14])}${hex(bytes[15])}`;
|
|
1051
|
+
} else {
|
|
1052
|
+
const r = () => Math.floor(Math.random() * 65535).toString(16).padStart(4, "0");
|
|
1053
|
+
uuid = `${r()}${r()}-${r()}-4${r().slice(1)}-${((Math.random() * 4 | 0) + 8).toString(16)}${r().slice(1)}-${r()}${r()}${r()}`;
|
|
1054
|
+
}
|
|
1055
|
+
const escaped = uuid.replace(/'/g, "''");
|
|
1056
|
+
this.db.exec(
|
|
1057
|
+
`INSERT OR IGNORE INTO _synclib_metadata (key, value) VALUES ('device_id', '${escaped}')`
|
|
1058
|
+
);
|
|
1059
|
+
this.deviceId = uuid;
|
|
1060
|
+
try {
|
|
1061
|
+
const reread = this.db.read(
|
|
1062
|
+
`SELECT value FROM _synclib_metadata WHERE key = 'device_id'`
|
|
1063
|
+
);
|
|
1064
|
+
if (reread.length > 0 && reread[0].value) {
|
|
1065
|
+
this.deviceId = String(reread[0].value);
|
|
1066
|
+
}
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
console.warn(
|
|
1069
|
+
"[SyncClient] device_id re-read failed; keeping tentative mint:",
|
|
1070
|
+
e
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
console.log(`[SyncClient] Minted device_id: ${this.deviceId}`);
|
|
1074
|
+
} catch (e) {
|
|
1075
|
+
console.warn("[SyncClient] Failed to ensure device_id:", e);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
986
1078
|
/**
|
|
987
1079
|
* One-time migration: switch to server-authoritative row_hash.
|
|
988
1080
|
* Sets all local row_hash values to '' (sentinel) so merkle comparison
|
|
@@ -1027,6 +1119,7 @@ var SyncClient = class {
|
|
|
1027
1119
|
}
|
|
1028
1120
|
console.log("Initializing sync client");
|
|
1029
1121
|
this.migrateToServerAuthoritativeRowHash();
|
|
1122
|
+
this.ensureDeviceId();
|
|
1030
1123
|
this.isInitialized = true;
|
|
1031
1124
|
}
|
|
1032
1125
|
/**
|
|
@@ -1039,12 +1132,17 @@ var SyncClient = class {
|
|
|
1039
1132
|
if (!this.isInitialized) {
|
|
1040
1133
|
throw new Error("Not initialized. Call initialize() first.");
|
|
1041
1134
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1135
|
+
this.explicitConnectInProgress = true;
|
|
1136
|
+
try {
|
|
1137
|
+
await this.ws.connect({
|
|
1138
|
+
token,
|
|
1139
|
+
client_id: this.config.clientId,
|
|
1140
|
+
...extra || {}
|
|
1141
|
+
});
|
|
1142
|
+
await this.joinChannels();
|
|
1143
|
+
} finally {
|
|
1144
|
+
this.explicitConnectInProgress = false;
|
|
1145
|
+
}
|
|
1048
1146
|
}
|
|
1049
1147
|
/**
|
|
1050
1148
|
* Update the auth token used on subsequent (re)connects without tearing
|
|
@@ -1073,9 +1171,11 @@ var SyncClient = class {
|
|
|
1073
1171
|
console.log(`Joining channel: ${topic}`);
|
|
1074
1172
|
const response = await this.ws.joinChannel(topic, {
|
|
1075
1173
|
client_id: this.config.clientId,
|
|
1174
|
+
...this.deviceId ? { device_id: this.deviceId } : {},
|
|
1076
1175
|
...channel.params
|
|
1077
1176
|
});
|
|
1078
1177
|
this.extractServerHashColumns(response);
|
|
1178
|
+
this.trimQueueFromJoinResponse(response, topic);
|
|
1079
1179
|
}
|
|
1080
1180
|
console.log("All channels joined successfully");
|
|
1081
1181
|
this.hasConnectedOnce = true;
|
|
@@ -1093,9 +1193,11 @@ var SyncClient = class {
|
|
|
1093
1193
|
console.log(`Joining additional channel: ${topic}`);
|
|
1094
1194
|
const response = await this.ws.joinChannel(topic, {
|
|
1095
1195
|
client_id: this.config.clientId,
|
|
1196
|
+
...this.deviceId ? { device_id: this.deviceId } : {},
|
|
1096
1197
|
...channel.params
|
|
1097
1198
|
});
|
|
1098
1199
|
this.extractServerHashColumns(response);
|
|
1200
|
+
this.trimQueueFromJoinResponse(response, topic);
|
|
1099
1201
|
console.log(`Successfully joined channel: ${topic}`);
|
|
1100
1202
|
}
|
|
1101
1203
|
/**
|
|
@@ -1176,6 +1278,7 @@ var SyncClient = class {
|
|
|
1176
1278
|
const tableSeqnums = await this.getPerTableSeqnums(tablesToSync);
|
|
1177
1279
|
const payload = {
|
|
1178
1280
|
client_id: this.config.clientId,
|
|
1281
|
+
...this.deviceId ? { device_id: this.deviceId } : {},
|
|
1179
1282
|
schema_version: schemaVersion,
|
|
1180
1283
|
table_seqnums: tableSeqnums,
|
|
1181
1284
|
tables: tablesToSync
|
|
@@ -1353,6 +1456,54 @@ var SyncClient = class {
|
|
|
1353
1456
|
console.log(`Server hash_columns: ${this.serverHashColumns.join(", ")}`);
|
|
1354
1457
|
}
|
|
1355
1458
|
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Reconcile the local outbound queue (`_sync_changes`) with the server's
|
|
1461
|
+
* per-device high-water mark reported on the channel join response. If
|
|
1462
|
+
* the server tells us "I've already applied your local_seqnum up to N for
|
|
1463
|
+
* this device on this channel," DELETE everything at or below N from the
|
|
1464
|
+
* queue — those writes are guaranteed-committed, even if their acks never
|
|
1465
|
+
* reached us (network drop, app crash, server restart between apply and
|
|
1466
|
+
* ack send). Idempotent and safe on every join.
|
|
1467
|
+
*
|
|
1468
|
+
* CRITICAL: scope by table_name. Local seqnums in `_sync_changes` are
|
|
1469
|
+
* AUTOINCREMENT-global across the file (one counter for ALL channels),
|
|
1470
|
+
* while the server's high-water mark is per-(device, channel). An
|
|
1471
|
+
* unscoped `markSynced(N)` would `DELETE WHERE seqnum <= N` and could
|
|
1472
|
+
* sweep up unpushed writes for OTHER channels whose seqnums happen to
|
|
1473
|
+
* fall below N. Filtering by the channel's table set keeps the delete
|
|
1474
|
+
* correct in the multi-channel case.
|
|
1475
|
+
*/
|
|
1476
|
+
trimQueueFromJoinResponse(response, channelTopic) {
|
|
1477
|
+
const lastApplied = response?.last_applied_local_seqnum;
|
|
1478
|
+
if (typeof lastApplied !== "number" || lastApplied <= 0) {
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const channel = this.config.channels.find((c) => c.topic === channelTopic);
|
|
1482
|
+
if (!channel) {
|
|
1483
|
+
console.warn(
|
|
1484
|
+
`[SyncClient] No SyncChannel found for topic ${channelTopic}; skipping trim`
|
|
1485
|
+
);
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
const tables = (channel.tables || []).map((t) => t.name);
|
|
1489
|
+
if (tables.length === 0) {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
try {
|
|
1493
|
+
const escapedTables = tables.map((t) => `'${t.replace(/'/g, "''")}'`).join(",");
|
|
1494
|
+
this.db.exec(
|
|
1495
|
+
`DELETE FROM _sync_changes WHERE seqnum <= ${lastApplied} AND table_name IN (${escapedTables})`
|
|
1496
|
+
);
|
|
1497
|
+
console.log(
|
|
1498
|
+
`[SyncClient] Reconciled outbound queue on ${channelTopic}: trimmed seqnum <= ${lastApplied} scoped to ${tables.length} table(s)`
|
|
1499
|
+
);
|
|
1500
|
+
} catch (e) {
|
|
1501
|
+
console.warn(
|
|
1502
|
+
`[SyncClient] Failed to trim queue at seqnum ${lastApplied} on ${channelTopic}:`,
|
|
1503
|
+
e
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1356
1507
|
/**
|
|
1357
1508
|
* Send hello message to server
|
|
1358
1509
|
*/
|
|
@@ -1986,7 +2137,7 @@ var SyncClient = class {
|
|
|
1986
2137
|
this.updateSyncState("error" /* ERROR */);
|
|
1987
2138
|
break;
|
|
1988
2139
|
}
|
|
1989
|
-
if (state === "connected" /* CONNECTED */ && this.hasConnectedOnce) {
|
|
2140
|
+
if (state === "connected" /* CONNECTED */ && this.hasConnectedOnce && !this.explicitConnectInProgress) {
|
|
1990
2141
|
console.log("Reconnected - rejoining channels");
|
|
1991
2142
|
this.joinChannels().catch((e) => {
|
|
1992
2143
|
console.error("Failed to rejoin channels after reconnect:", e);
|
package/dist/index.mjs
CHANGED
|
@@ -889,6 +889,32 @@ var SyncClient = class {
|
|
|
889
889
|
constructor(config) {
|
|
890
890
|
this.isInitialized = false;
|
|
891
891
|
this.hasConnectedOnce = false;
|
|
892
|
+
/**
|
|
893
|
+
* True while a call to [connect] is in progress. Used by
|
|
894
|
+
* [handleStateChange] to skip its auto-rejoin of channels when an
|
|
895
|
+
* explicit caller is already going to call [joinChannels] itself.
|
|
896
|
+
*
|
|
897
|
+
* Without this guard, every reconnect after the first one races two
|
|
898
|
+
* `joinChannels()` invocations against each other: one from `connect()`'s
|
|
899
|
+
* `await this.joinChannels()` and one from `handleStateChange`'s fire-and-
|
|
900
|
+
* forget call. `WebSocketManager.joinChannel` (line 192-196) EVICTS any
|
|
901
|
+
* existing channel for the same topic before creating a new one, so the
|
|
902
|
+
* second call disposes the first call's channel — its push completer
|
|
903
|
+
* never sees the reply, the await hangs until the phoenix-js push
|
|
904
|
+
* timeout fires. Symptom server-side: two `JOINED <topic>` events ~50ms
|
|
905
|
+
* apart with the same device_id; client-side: 30s wait then timeout
|
|
906
|
+
* even though the channel is functionally joined via the surviving
|
|
907
|
+
* second call. Mirror of the Dart fix in synclib_sync/sync_client.dart.
|
|
908
|
+
*/
|
|
909
|
+
this.explicitConnectInProgress = false;
|
|
910
|
+
/**
|
|
911
|
+
* Per-install identifier minted on first DB open and persisted in
|
|
912
|
+
* `_synclib_metadata`. Stable for the life of the SQLite/IndexedDB file.
|
|
913
|
+
* Sent on every join/push so the server can track per-device ack state
|
|
914
|
+
* and reliably trim the local outbound queue on reconnect (analog of the
|
|
915
|
+
* Dart `synclib_sync` implementation).
|
|
916
|
+
*/
|
|
917
|
+
this.deviceId = null;
|
|
892
918
|
this.pendingAcks = /* @__PURE__ */ new Set();
|
|
893
919
|
// Track which table/rowId corresponds to each pending change seqnum
|
|
894
920
|
this.pendingChangeInfo = /* @__PURE__ */ new Map();
|
|
@@ -928,6 +954,9 @@ var SyncClient = class {
|
|
|
928
954
|
this.ws.onMessage(this.enqueueMessage.bind(this));
|
|
929
955
|
this.ws.onStateChange(this.handleStateChange.bind(this));
|
|
930
956
|
}
|
|
957
|
+
getDeviceId() {
|
|
958
|
+
return this.deviceId;
|
|
959
|
+
}
|
|
931
960
|
/** Get channels that push (push or bidirectional role). */
|
|
932
961
|
get pushChannels() {
|
|
933
962
|
return this.config.channels.filter(
|
|
@@ -950,6 +979,69 @@ var SyncClient = class {
|
|
|
950
979
|
}
|
|
951
980
|
return Array.from(tables);
|
|
952
981
|
}
|
|
982
|
+
/**
|
|
983
|
+
* Load the per-install `device_id` from `_synclib_metadata`, or mint and
|
|
984
|
+
* persist one if it doesn't exist yet. UUID v4 via `crypto.randomUUID()`
|
|
985
|
+
* (Node 14.17+, modern browsers), falling back to a manual constructor
|
|
986
|
+
* if the host lacks it.
|
|
987
|
+
*
|
|
988
|
+
* `_synclib_metadata` is created here (idempotent CREATE TABLE IF NOT
|
|
989
|
+
* EXISTS) so this can be called before the row_hash migration runs.
|
|
990
|
+
*/
|
|
991
|
+
ensureDeviceId() {
|
|
992
|
+
try {
|
|
993
|
+
this.db.exec(`
|
|
994
|
+
CREATE TABLE IF NOT EXISTS _synclib_metadata (
|
|
995
|
+
key TEXT PRIMARY KEY,
|
|
996
|
+
value TEXT NOT NULL
|
|
997
|
+
)
|
|
998
|
+
`);
|
|
999
|
+
const results = this.db.read(
|
|
1000
|
+
`SELECT value FROM _synclib_metadata WHERE key = 'device_id'`
|
|
1001
|
+
);
|
|
1002
|
+
if (results.length > 0 && results[0].value) {
|
|
1003
|
+
this.deviceId = String(results[0].value);
|
|
1004
|
+
console.log(`[SyncClient] Loaded device_id: ${this.deviceId}`);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const cryptoObj = globalThis.crypto;
|
|
1008
|
+
let uuid;
|
|
1009
|
+
if (cryptoObj?.randomUUID) {
|
|
1010
|
+
uuid = cryptoObj.randomUUID();
|
|
1011
|
+
} else if (cryptoObj?.getRandomValues) {
|
|
1012
|
+
const bytes = new Uint8Array(16);
|
|
1013
|
+
cryptoObj.getRandomValues(bytes);
|
|
1014
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
1015
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
1016
|
+
const hex = (b) => b.toString(16).padStart(2, "0");
|
|
1017
|
+
uuid = `${hex(bytes[0])}${hex(bytes[1])}${hex(bytes[2])}${hex(bytes[3])}-${hex(bytes[4])}${hex(bytes[5])}-${hex(bytes[6])}${hex(bytes[7])}-${hex(bytes[8])}${hex(bytes[9])}-${hex(bytes[10])}${hex(bytes[11])}${hex(bytes[12])}${hex(bytes[13])}${hex(bytes[14])}${hex(bytes[15])}`;
|
|
1018
|
+
} else {
|
|
1019
|
+
const r = () => Math.floor(Math.random() * 65535).toString(16).padStart(4, "0");
|
|
1020
|
+
uuid = `${r()}${r()}-${r()}-4${r().slice(1)}-${((Math.random() * 4 | 0) + 8).toString(16)}${r().slice(1)}-${r()}${r()}${r()}`;
|
|
1021
|
+
}
|
|
1022
|
+
const escaped = uuid.replace(/'/g, "''");
|
|
1023
|
+
this.db.exec(
|
|
1024
|
+
`INSERT OR IGNORE INTO _synclib_metadata (key, value) VALUES ('device_id', '${escaped}')`
|
|
1025
|
+
);
|
|
1026
|
+
this.deviceId = uuid;
|
|
1027
|
+
try {
|
|
1028
|
+
const reread = this.db.read(
|
|
1029
|
+
`SELECT value FROM _synclib_metadata WHERE key = 'device_id'`
|
|
1030
|
+
);
|
|
1031
|
+
if (reread.length > 0 && reread[0].value) {
|
|
1032
|
+
this.deviceId = String(reread[0].value);
|
|
1033
|
+
}
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
console.warn(
|
|
1036
|
+
"[SyncClient] device_id re-read failed; keeping tentative mint:",
|
|
1037
|
+
e
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
console.log(`[SyncClient] Minted device_id: ${this.deviceId}`);
|
|
1041
|
+
} catch (e) {
|
|
1042
|
+
console.warn("[SyncClient] Failed to ensure device_id:", e);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
953
1045
|
/**
|
|
954
1046
|
* One-time migration: switch to server-authoritative row_hash.
|
|
955
1047
|
* Sets all local row_hash values to '' (sentinel) so merkle comparison
|
|
@@ -994,6 +1086,7 @@ var SyncClient = class {
|
|
|
994
1086
|
}
|
|
995
1087
|
console.log("Initializing sync client");
|
|
996
1088
|
this.migrateToServerAuthoritativeRowHash();
|
|
1089
|
+
this.ensureDeviceId();
|
|
997
1090
|
this.isInitialized = true;
|
|
998
1091
|
}
|
|
999
1092
|
/**
|
|
@@ -1006,12 +1099,17 @@ var SyncClient = class {
|
|
|
1006
1099
|
if (!this.isInitialized) {
|
|
1007
1100
|
throw new Error("Not initialized. Call initialize() first.");
|
|
1008
1101
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1102
|
+
this.explicitConnectInProgress = true;
|
|
1103
|
+
try {
|
|
1104
|
+
await this.ws.connect({
|
|
1105
|
+
token,
|
|
1106
|
+
client_id: this.config.clientId,
|
|
1107
|
+
...extra || {}
|
|
1108
|
+
});
|
|
1109
|
+
await this.joinChannels();
|
|
1110
|
+
} finally {
|
|
1111
|
+
this.explicitConnectInProgress = false;
|
|
1112
|
+
}
|
|
1015
1113
|
}
|
|
1016
1114
|
/**
|
|
1017
1115
|
* Update the auth token used on subsequent (re)connects without tearing
|
|
@@ -1040,9 +1138,11 @@ var SyncClient = class {
|
|
|
1040
1138
|
console.log(`Joining channel: ${topic}`);
|
|
1041
1139
|
const response = await this.ws.joinChannel(topic, {
|
|
1042
1140
|
client_id: this.config.clientId,
|
|
1141
|
+
...this.deviceId ? { device_id: this.deviceId } : {},
|
|
1043
1142
|
...channel.params
|
|
1044
1143
|
});
|
|
1045
1144
|
this.extractServerHashColumns(response);
|
|
1145
|
+
this.trimQueueFromJoinResponse(response, topic);
|
|
1046
1146
|
}
|
|
1047
1147
|
console.log("All channels joined successfully");
|
|
1048
1148
|
this.hasConnectedOnce = true;
|
|
@@ -1060,9 +1160,11 @@ var SyncClient = class {
|
|
|
1060
1160
|
console.log(`Joining additional channel: ${topic}`);
|
|
1061
1161
|
const response = await this.ws.joinChannel(topic, {
|
|
1062
1162
|
client_id: this.config.clientId,
|
|
1163
|
+
...this.deviceId ? { device_id: this.deviceId } : {},
|
|
1063
1164
|
...channel.params
|
|
1064
1165
|
});
|
|
1065
1166
|
this.extractServerHashColumns(response);
|
|
1167
|
+
this.trimQueueFromJoinResponse(response, topic);
|
|
1066
1168
|
console.log(`Successfully joined channel: ${topic}`);
|
|
1067
1169
|
}
|
|
1068
1170
|
/**
|
|
@@ -1143,6 +1245,7 @@ var SyncClient = class {
|
|
|
1143
1245
|
const tableSeqnums = await this.getPerTableSeqnums(tablesToSync);
|
|
1144
1246
|
const payload = {
|
|
1145
1247
|
client_id: this.config.clientId,
|
|
1248
|
+
...this.deviceId ? { device_id: this.deviceId } : {},
|
|
1146
1249
|
schema_version: schemaVersion,
|
|
1147
1250
|
table_seqnums: tableSeqnums,
|
|
1148
1251
|
tables: tablesToSync
|
|
@@ -1320,6 +1423,54 @@ var SyncClient = class {
|
|
|
1320
1423
|
console.log(`Server hash_columns: ${this.serverHashColumns.join(", ")}`);
|
|
1321
1424
|
}
|
|
1322
1425
|
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Reconcile the local outbound queue (`_sync_changes`) with the server's
|
|
1428
|
+
* per-device high-water mark reported on the channel join response. If
|
|
1429
|
+
* the server tells us "I've already applied your local_seqnum up to N for
|
|
1430
|
+
* this device on this channel," DELETE everything at or below N from the
|
|
1431
|
+
* queue — those writes are guaranteed-committed, even if their acks never
|
|
1432
|
+
* reached us (network drop, app crash, server restart between apply and
|
|
1433
|
+
* ack send). Idempotent and safe on every join.
|
|
1434
|
+
*
|
|
1435
|
+
* CRITICAL: scope by table_name. Local seqnums in `_sync_changes` are
|
|
1436
|
+
* AUTOINCREMENT-global across the file (one counter for ALL channels),
|
|
1437
|
+
* while the server's high-water mark is per-(device, channel). An
|
|
1438
|
+
* unscoped `markSynced(N)` would `DELETE WHERE seqnum <= N` and could
|
|
1439
|
+
* sweep up unpushed writes for OTHER channels whose seqnums happen to
|
|
1440
|
+
* fall below N. Filtering by the channel's table set keeps the delete
|
|
1441
|
+
* correct in the multi-channel case.
|
|
1442
|
+
*/
|
|
1443
|
+
trimQueueFromJoinResponse(response, channelTopic) {
|
|
1444
|
+
const lastApplied = response?.last_applied_local_seqnum;
|
|
1445
|
+
if (typeof lastApplied !== "number" || lastApplied <= 0) {
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const channel = this.config.channels.find((c) => c.topic === channelTopic);
|
|
1449
|
+
if (!channel) {
|
|
1450
|
+
console.warn(
|
|
1451
|
+
`[SyncClient] No SyncChannel found for topic ${channelTopic}; skipping trim`
|
|
1452
|
+
);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
const tables = (channel.tables || []).map((t) => t.name);
|
|
1456
|
+
if (tables.length === 0) {
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
try {
|
|
1460
|
+
const escapedTables = tables.map((t) => `'${t.replace(/'/g, "''")}'`).join(",");
|
|
1461
|
+
this.db.exec(
|
|
1462
|
+
`DELETE FROM _sync_changes WHERE seqnum <= ${lastApplied} AND table_name IN (${escapedTables})`
|
|
1463
|
+
);
|
|
1464
|
+
console.log(
|
|
1465
|
+
`[SyncClient] Reconciled outbound queue on ${channelTopic}: trimmed seqnum <= ${lastApplied} scoped to ${tables.length} table(s)`
|
|
1466
|
+
);
|
|
1467
|
+
} catch (e) {
|
|
1468
|
+
console.warn(
|
|
1469
|
+
`[SyncClient] Failed to trim queue at seqnum ${lastApplied} on ${channelTopic}:`,
|
|
1470
|
+
e
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1323
1474
|
/**
|
|
1324
1475
|
* Send hello message to server
|
|
1325
1476
|
*/
|
|
@@ -1953,7 +2104,7 @@ var SyncClient = class {
|
|
|
1953
2104
|
this.updateSyncState("error" /* ERROR */);
|
|
1954
2105
|
break;
|
|
1955
2106
|
}
|
|
1956
|
-
if (state === "connected" /* CONNECTED */ && this.hasConnectedOnce) {
|
|
2107
|
+
if (state === "connected" /* CONNECTED */ && this.hasConnectedOnce && !this.explicitConnectInProgress) {
|
|
1957
2108
|
console.log("Reconnected - rejoining channels");
|
|
1958
2109
|
this.joinChannels().catch((e) => {
|
|
1959
2110
|
console.error("Failed to rejoin channels after reconnect:", e);
|