@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 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
- await this.ws.connect({
1043
- token,
1044
- client_id: this.config.clientId,
1045
- ...extra || {}
1046
- });
1047
- await this.joinChannels();
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
- await this.ws.connect({
1010
- token,
1011
- client_id: this.config.clientId,
1012
- ...extra || {}
1013
- });
1014
- await this.joinChannels();
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synclib-io/sync",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "TypeScript/JavaScript sync client for coordinating database changes with Elixir Phoenix server",
5
5
  "publishConfig": {
6
6
  "access": "public"