@synclib-io/sync 0.2.0 → 0.2.1

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,15 @@ declare class SyncClient {
552
552
  private serverHashColumns?;
553
553
  private isInitialized;
554
554
  private hasConnectedOnce;
555
+ /**
556
+ * Per-install identifier minted on first DB open and persisted in
557
+ * `_synclib_metadata`. Stable for the life of the SQLite/IndexedDB file.
558
+ * Sent on every join/push so the server can track per-device ack state
559
+ * and reliably trim the local outbound queue on reconnect (analog of the
560
+ * Dart `synclib_sync` implementation).
561
+ */
562
+ private deviceId;
563
+ getDeviceId(): string | null;
555
564
  private pendingAcks;
556
565
  private pendingChangeInfo;
557
566
  private remoteChangeListeners;
@@ -584,6 +593,16 @@ declare class SyncClient {
584
593
  private get pullChannels();
585
594
  /** Get all sync table names from channels config. */
586
595
  private get allSyncTables();
596
+ /**
597
+ * Load the per-install `device_id` from `_synclib_metadata`, or mint and
598
+ * persist one if it doesn't exist yet. UUID v4 via `crypto.randomUUID()`
599
+ * (Node 14.17+, modern browsers), falling back to a manual constructor
600
+ * if the host lacks it.
601
+ *
602
+ * `_synclib_metadata` is created here (idempotent CREATE TABLE IF NOT
603
+ * EXISTS) so this can be called before the row_hash migration runs.
604
+ */
605
+ private ensureDeviceId;
587
606
  /**
588
607
  * One-time migration: switch to server-authoritative row_hash.
589
608
  * Sets all local row_hash values to '' (sentinel) so merkle comparison
@@ -706,6 +725,24 @@ declare class SyncClient {
706
725
  * Extract server-driven hash_columns from a channel join response.
707
726
  */
708
727
  private extractServerHashColumns;
728
+ /**
729
+ * Reconcile the local outbound queue (`_sync_changes`) with the server's
730
+ * per-device high-water mark reported on the channel join response. If
731
+ * the server tells us "I've already applied your local_seqnum up to N for
732
+ * this device on this channel," DELETE everything at or below N from the
733
+ * queue — those writes are guaranteed-committed, even if their acks never
734
+ * reached us (network drop, app crash, server restart between apply and
735
+ * ack send). Idempotent and safe on every join.
736
+ *
737
+ * CRITICAL: scope by table_name. Local seqnums in `_sync_changes` are
738
+ * AUTOINCREMENT-global across the file (one counter for ALL channels),
739
+ * while the server's high-water mark is per-(device, channel). An
740
+ * unscoped `markSynced(N)` would `DELETE WHERE seqnum <= N` and could
741
+ * sweep up unpushed writes for OTHER channels whose seqnums happen to
742
+ * fall below N. Filtering by the channel's table set keeps the delete
743
+ * correct in the multi-channel case.
744
+ */
745
+ private trimQueueFromJoinResponse;
709
746
  /**
710
747
  * Send hello message to server
711
748
  */
package/dist/index.d.ts CHANGED
@@ -552,6 +552,15 @@ declare class SyncClient {
552
552
  private serverHashColumns?;
553
553
  private isInitialized;
554
554
  private hasConnectedOnce;
555
+ /**
556
+ * Per-install identifier minted on first DB open and persisted in
557
+ * `_synclib_metadata`. Stable for the life of the SQLite/IndexedDB file.
558
+ * Sent on every join/push so the server can track per-device ack state
559
+ * and reliably trim the local outbound queue on reconnect (analog of the
560
+ * Dart `synclib_sync` implementation).
561
+ */
562
+ private deviceId;
563
+ getDeviceId(): string | null;
555
564
  private pendingAcks;
556
565
  private pendingChangeInfo;
557
566
  private remoteChangeListeners;
@@ -584,6 +593,16 @@ declare class SyncClient {
584
593
  private get pullChannels();
585
594
  /** Get all sync table names from channels config. */
586
595
  private get allSyncTables();
596
+ /**
597
+ * Load the per-install `device_id` from `_synclib_metadata`, or mint and
598
+ * persist one if it doesn't exist yet. UUID v4 via `crypto.randomUUID()`
599
+ * (Node 14.17+, modern browsers), falling back to a manual constructor
600
+ * if the host lacks it.
601
+ *
602
+ * `_synclib_metadata` is created here (idempotent CREATE TABLE IF NOT
603
+ * EXISTS) so this can be called before the row_hash migration runs.
604
+ */
605
+ private ensureDeviceId;
587
606
  /**
588
607
  * One-time migration: switch to server-authoritative row_hash.
589
608
  * Sets all local row_hash values to '' (sentinel) so merkle comparison
@@ -706,6 +725,24 @@ declare class SyncClient {
706
725
  * Extract server-driven hash_columns from a channel join response.
707
726
  */
708
727
  private extractServerHashColumns;
728
+ /**
729
+ * Reconcile the local outbound queue (`_sync_changes`) with the server's
730
+ * per-device high-water mark reported on the channel join response. If
731
+ * the server tells us "I've already applied your local_seqnum up to N for
732
+ * this device on this channel," DELETE everything at or below N from the
733
+ * queue — those writes are guaranteed-committed, even if their acks never
734
+ * reached us (network drop, app crash, server restart between apply and
735
+ * ack send). Idempotent and safe on every join.
736
+ *
737
+ * CRITICAL: scope by table_name. Local seqnums in `_sync_changes` are
738
+ * AUTOINCREMENT-global across the file (one counter for ALL channels),
739
+ * while the server's high-water mark is per-(device, channel). An
740
+ * unscoped `markSynced(N)` would `DELETE WHERE seqnum <= N` and could
741
+ * sweep up unpushed writes for OTHER channels whose seqnums happen to
742
+ * fall below N. Filtering by the channel's table set keeps the delete
743
+ * correct in the multi-channel case.
744
+ */
745
+ private trimQueueFromJoinResponse;
709
746
  /**
710
747
  * Send hello message to server
711
748
  */
package/dist/index.js CHANGED
@@ -922,6 +922,14 @@ var SyncClient = class {
922
922
  constructor(config) {
923
923
  this.isInitialized = false;
924
924
  this.hasConnectedOnce = false;
925
+ /**
926
+ * Per-install identifier minted on first DB open and persisted in
927
+ * `_synclib_metadata`. Stable for the life of the SQLite/IndexedDB file.
928
+ * Sent on every join/push so the server can track per-device ack state
929
+ * and reliably trim the local outbound queue on reconnect (analog of the
930
+ * Dart `synclib_sync` implementation).
931
+ */
932
+ this.deviceId = null;
925
933
  this.pendingAcks = /* @__PURE__ */ new Set();
926
934
  // Track which table/rowId corresponds to each pending change seqnum
927
935
  this.pendingChangeInfo = /* @__PURE__ */ new Map();
@@ -961,6 +969,9 @@ var SyncClient = class {
961
969
  this.ws.onMessage(this.enqueueMessage.bind(this));
962
970
  this.ws.onStateChange(this.handleStateChange.bind(this));
963
971
  }
972
+ getDeviceId() {
973
+ return this.deviceId;
974
+ }
964
975
  /** Get channels that push (push or bidirectional role). */
965
976
  get pushChannels() {
966
977
  return this.config.channels.filter(
@@ -983,6 +994,69 @@ var SyncClient = class {
983
994
  }
984
995
  return Array.from(tables);
985
996
  }
997
+ /**
998
+ * Load the per-install `device_id` from `_synclib_metadata`, or mint and
999
+ * persist one if it doesn't exist yet. UUID v4 via `crypto.randomUUID()`
1000
+ * (Node 14.17+, modern browsers), falling back to a manual constructor
1001
+ * if the host lacks it.
1002
+ *
1003
+ * `_synclib_metadata` is created here (idempotent CREATE TABLE IF NOT
1004
+ * EXISTS) so this can be called before the row_hash migration runs.
1005
+ */
1006
+ ensureDeviceId() {
1007
+ try {
1008
+ this.db.exec(`
1009
+ CREATE TABLE IF NOT EXISTS _synclib_metadata (
1010
+ key TEXT PRIMARY KEY,
1011
+ value TEXT NOT NULL
1012
+ )
1013
+ `);
1014
+ const results = this.db.read(
1015
+ `SELECT value FROM _synclib_metadata WHERE key = 'device_id'`
1016
+ );
1017
+ if (results.length > 0 && results[0].value) {
1018
+ this.deviceId = String(results[0].value);
1019
+ console.log(`[SyncClient] Loaded device_id: ${this.deviceId}`);
1020
+ return;
1021
+ }
1022
+ const cryptoObj = globalThis.crypto;
1023
+ let uuid;
1024
+ if (cryptoObj?.randomUUID) {
1025
+ uuid = cryptoObj.randomUUID();
1026
+ } else if (cryptoObj?.getRandomValues) {
1027
+ const bytes = new Uint8Array(16);
1028
+ cryptoObj.getRandomValues(bytes);
1029
+ bytes[6] = bytes[6] & 15 | 64;
1030
+ bytes[8] = bytes[8] & 63 | 128;
1031
+ const hex = (b) => b.toString(16).padStart(2, "0");
1032
+ 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])}`;
1033
+ } else {
1034
+ const r = () => Math.floor(Math.random() * 65535).toString(16).padStart(4, "0");
1035
+ uuid = `${r()}${r()}-${r()}-4${r().slice(1)}-${((Math.random() * 4 | 0) + 8).toString(16)}${r().slice(1)}-${r()}${r()}${r()}`;
1036
+ }
1037
+ const escaped = uuid.replace(/'/g, "''");
1038
+ this.db.exec(
1039
+ `INSERT OR IGNORE INTO _synclib_metadata (key, value) VALUES ('device_id', '${escaped}')`
1040
+ );
1041
+ this.deviceId = uuid;
1042
+ try {
1043
+ const reread = this.db.read(
1044
+ `SELECT value FROM _synclib_metadata WHERE key = 'device_id'`
1045
+ );
1046
+ if (reread.length > 0 && reread[0].value) {
1047
+ this.deviceId = String(reread[0].value);
1048
+ }
1049
+ } catch (e) {
1050
+ console.warn(
1051
+ "[SyncClient] device_id re-read failed; keeping tentative mint:",
1052
+ e
1053
+ );
1054
+ }
1055
+ console.log(`[SyncClient] Minted device_id: ${this.deviceId}`);
1056
+ } catch (e) {
1057
+ console.warn("[SyncClient] Failed to ensure device_id:", e);
1058
+ }
1059
+ }
986
1060
  /**
987
1061
  * One-time migration: switch to server-authoritative row_hash.
988
1062
  * Sets all local row_hash values to '' (sentinel) so merkle comparison
@@ -1027,6 +1101,7 @@ var SyncClient = class {
1027
1101
  }
1028
1102
  console.log("Initializing sync client");
1029
1103
  this.migrateToServerAuthoritativeRowHash();
1104
+ this.ensureDeviceId();
1030
1105
  this.isInitialized = true;
1031
1106
  }
1032
1107
  /**
@@ -1073,9 +1148,11 @@ var SyncClient = class {
1073
1148
  console.log(`Joining channel: ${topic}`);
1074
1149
  const response = await this.ws.joinChannel(topic, {
1075
1150
  client_id: this.config.clientId,
1151
+ ...this.deviceId ? { device_id: this.deviceId } : {},
1076
1152
  ...channel.params
1077
1153
  });
1078
1154
  this.extractServerHashColumns(response);
1155
+ this.trimQueueFromJoinResponse(response, topic);
1079
1156
  }
1080
1157
  console.log("All channels joined successfully");
1081
1158
  this.hasConnectedOnce = true;
@@ -1093,9 +1170,11 @@ var SyncClient = class {
1093
1170
  console.log(`Joining additional channel: ${topic}`);
1094
1171
  const response = await this.ws.joinChannel(topic, {
1095
1172
  client_id: this.config.clientId,
1173
+ ...this.deviceId ? { device_id: this.deviceId } : {},
1096
1174
  ...channel.params
1097
1175
  });
1098
1176
  this.extractServerHashColumns(response);
1177
+ this.trimQueueFromJoinResponse(response, topic);
1099
1178
  console.log(`Successfully joined channel: ${topic}`);
1100
1179
  }
1101
1180
  /**
@@ -1176,6 +1255,7 @@ var SyncClient = class {
1176
1255
  const tableSeqnums = await this.getPerTableSeqnums(tablesToSync);
1177
1256
  const payload = {
1178
1257
  client_id: this.config.clientId,
1258
+ ...this.deviceId ? { device_id: this.deviceId } : {},
1179
1259
  schema_version: schemaVersion,
1180
1260
  table_seqnums: tableSeqnums,
1181
1261
  tables: tablesToSync
@@ -1353,6 +1433,54 @@ var SyncClient = class {
1353
1433
  console.log(`Server hash_columns: ${this.serverHashColumns.join(", ")}`);
1354
1434
  }
1355
1435
  }
1436
+ /**
1437
+ * Reconcile the local outbound queue (`_sync_changes`) with the server's
1438
+ * per-device high-water mark reported on the channel join response. If
1439
+ * the server tells us "I've already applied your local_seqnum up to N for
1440
+ * this device on this channel," DELETE everything at or below N from the
1441
+ * queue — those writes are guaranteed-committed, even if their acks never
1442
+ * reached us (network drop, app crash, server restart between apply and
1443
+ * ack send). Idempotent and safe on every join.
1444
+ *
1445
+ * CRITICAL: scope by table_name. Local seqnums in `_sync_changes` are
1446
+ * AUTOINCREMENT-global across the file (one counter for ALL channels),
1447
+ * while the server's high-water mark is per-(device, channel). An
1448
+ * unscoped `markSynced(N)` would `DELETE WHERE seqnum <= N` and could
1449
+ * sweep up unpushed writes for OTHER channels whose seqnums happen to
1450
+ * fall below N. Filtering by the channel's table set keeps the delete
1451
+ * correct in the multi-channel case.
1452
+ */
1453
+ trimQueueFromJoinResponse(response, channelTopic) {
1454
+ const lastApplied = response?.last_applied_local_seqnum;
1455
+ if (typeof lastApplied !== "number" || lastApplied <= 0) {
1456
+ return;
1457
+ }
1458
+ const channel = this.config.channels.find((c) => c.topic === channelTopic);
1459
+ if (!channel) {
1460
+ console.warn(
1461
+ `[SyncClient] No SyncChannel found for topic ${channelTopic}; skipping trim`
1462
+ );
1463
+ return;
1464
+ }
1465
+ const tables = (channel.tables || []).map((t) => t.name);
1466
+ if (tables.length === 0) {
1467
+ return;
1468
+ }
1469
+ try {
1470
+ const escapedTables = tables.map((t) => `'${t.replace(/'/g, "''")}'`).join(",");
1471
+ this.db.exec(
1472
+ `DELETE FROM _sync_changes WHERE seqnum <= ${lastApplied} AND table_name IN (${escapedTables})`
1473
+ );
1474
+ console.log(
1475
+ `[SyncClient] Reconciled outbound queue on ${channelTopic}: trimmed seqnum <= ${lastApplied} scoped to ${tables.length} table(s)`
1476
+ );
1477
+ } catch (e) {
1478
+ console.warn(
1479
+ `[SyncClient] Failed to trim queue at seqnum ${lastApplied} on ${channelTopic}:`,
1480
+ e
1481
+ );
1482
+ }
1483
+ }
1356
1484
  /**
1357
1485
  * Send hello message to server
1358
1486
  */
package/dist/index.mjs CHANGED
@@ -889,6 +889,14 @@ var SyncClient = class {
889
889
  constructor(config) {
890
890
  this.isInitialized = false;
891
891
  this.hasConnectedOnce = false;
892
+ /**
893
+ * Per-install identifier minted on first DB open and persisted in
894
+ * `_synclib_metadata`. Stable for the life of the SQLite/IndexedDB file.
895
+ * Sent on every join/push so the server can track per-device ack state
896
+ * and reliably trim the local outbound queue on reconnect (analog of the
897
+ * Dart `synclib_sync` implementation).
898
+ */
899
+ this.deviceId = null;
892
900
  this.pendingAcks = /* @__PURE__ */ new Set();
893
901
  // Track which table/rowId corresponds to each pending change seqnum
894
902
  this.pendingChangeInfo = /* @__PURE__ */ new Map();
@@ -928,6 +936,9 @@ var SyncClient = class {
928
936
  this.ws.onMessage(this.enqueueMessage.bind(this));
929
937
  this.ws.onStateChange(this.handleStateChange.bind(this));
930
938
  }
939
+ getDeviceId() {
940
+ return this.deviceId;
941
+ }
931
942
  /** Get channels that push (push or bidirectional role). */
932
943
  get pushChannels() {
933
944
  return this.config.channels.filter(
@@ -950,6 +961,69 @@ var SyncClient = class {
950
961
  }
951
962
  return Array.from(tables);
952
963
  }
964
+ /**
965
+ * Load the per-install `device_id` from `_synclib_metadata`, or mint and
966
+ * persist one if it doesn't exist yet. UUID v4 via `crypto.randomUUID()`
967
+ * (Node 14.17+, modern browsers), falling back to a manual constructor
968
+ * if the host lacks it.
969
+ *
970
+ * `_synclib_metadata` is created here (idempotent CREATE TABLE IF NOT
971
+ * EXISTS) so this can be called before the row_hash migration runs.
972
+ */
973
+ ensureDeviceId() {
974
+ try {
975
+ this.db.exec(`
976
+ CREATE TABLE IF NOT EXISTS _synclib_metadata (
977
+ key TEXT PRIMARY KEY,
978
+ value TEXT NOT NULL
979
+ )
980
+ `);
981
+ const results = this.db.read(
982
+ `SELECT value FROM _synclib_metadata WHERE key = 'device_id'`
983
+ );
984
+ if (results.length > 0 && results[0].value) {
985
+ this.deviceId = String(results[0].value);
986
+ console.log(`[SyncClient] Loaded device_id: ${this.deviceId}`);
987
+ return;
988
+ }
989
+ const cryptoObj = globalThis.crypto;
990
+ let uuid;
991
+ if (cryptoObj?.randomUUID) {
992
+ uuid = cryptoObj.randomUUID();
993
+ } else if (cryptoObj?.getRandomValues) {
994
+ const bytes = new Uint8Array(16);
995
+ cryptoObj.getRandomValues(bytes);
996
+ bytes[6] = bytes[6] & 15 | 64;
997
+ bytes[8] = bytes[8] & 63 | 128;
998
+ const hex = (b) => b.toString(16).padStart(2, "0");
999
+ 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])}`;
1000
+ } else {
1001
+ const r = () => Math.floor(Math.random() * 65535).toString(16).padStart(4, "0");
1002
+ uuid = `${r()}${r()}-${r()}-4${r().slice(1)}-${((Math.random() * 4 | 0) + 8).toString(16)}${r().slice(1)}-${r()}${r()}${r()}`;
1003
+ }
1004
+ const escaped = uuid.replace(/'/g, "''");
1005
+ this.db.exec(
1006
+ `INSERT OR IGNORE INTO _synclib_metadata (key, value) VALUES ('device_id', '${escaped}')`
1007
+ );
1008
+ this.deviceId = uuid;
1009
+ try {
1010
+ const reread = this.db.read(
1011
+ `SELECT value FROM _synclib_metadata WHERE key = 'device_id'`
1012
+ );
1013
+ if (reread.length > 0 && reread[0].value) {
1014
+ this.deviceId = String(reread[0].value);
1015
+ }
1016
+ } catch (e) {
1017
+ console.warn(
1018
+ "[SyncClient] device_id re-read failed; keeping tentative mint:",
1019
+ e
1020
+ );
1021
+ }
1022
+ console.log(`[SyncClient] Minted device_id: ${this.deviceId}`);
1023
+ } catch (e) {
1024
+ console.warn("[SyncClient] Failed to ensure device_id:", e);
1025
+ }
1026
+ }
953
1027
  /**
954
1028
  * One-time migration: switch to server-authoritative row_hash.
955
1029
  * Sets all local row_hash values to '' (sentinel) so merkle comparison
@@ -994,6 +1068,7 @@ var SyncClient = class {
994
1068
  }
995
1069
  console.log("Initializing sync client");
996
1070
  this.migrateToServerAuthoritativeRowHash();
1071
+ this.ensureDeviceId();
997
1072
  this.isInitialized = true;
998
1073
  }
999
1074
  /**
@@ -1040,9 +1115,11 @@ var SyncClient = class {
1040
1115
  console.log(`Joining channel: ${topic}`);
1041
1116
  const response = await this.ws.joinChannel(topic, {
1042
1117
  client_id: this.config.clientId,
1118
+ ...this.deviceId ? { device_id: this.deviceId } : {},
1043
1119
  ...channel.params
1044
1120
  });
1045
1121
  this.extractServerHashColumns(response);
1122
+ this.trimQueueFromJoinResponse(response, topic);
1046
1123
  }
1047
1124
  console.log("All channels joined successfully");
1048
1125
  this.hasConnectedOnce = true;
@@ -1060,9 +1137,11 @@ var SyncClient = class {
1060
1137
  console.log(`Joining additional channel: ${topic}`);
1061
1138
  const response = await this.ws.joinChannel(topic, {
1062
1139
  client_id: this.config.clientId,
1140
+ ...this.deviceId ? { device_id: this.deviceId } : {},
1063
1141
  ...channel.params
1064
1142
  });
1065
1143
  this.extractServerHashColumns(response);
1144
+ this.trimQueueFromJoinResponse(response, topic);
1066
1145
  console.log(`Successfully joined channel: ${topic}`);
1067
1146
  }
1068
1147
  /**
@@ -1143,6 +1222,7 @@ var SyncClient = class {
1143
1222
  const tableSeqnums = await this.getPerTableSeqnums(tablesToSync);
1144
1223
  const payload = {
1145
1224
  client_id: this.config.clientId,
1225
+ ...this.deviceId ? { device_id: this.deviceId } : {},
1146
1226
  schema_version: schemaVersion,
1147
1227
  table_seqnums: tableSeqnums,
1148
1228
  tables: tablesToSync
@@ -1320,6 +1400,54 @@ var SyncClient = class {
1320
1400
  console.log(`Server hash_columns: ${this.serverHashColumns.join(", ")}`);
1321
1401
  }
1322
1402
  }
1403
+ /**
1404
+ * Reconcile the local outbound queue (`_sync_changes`) with the server's
1405
+ * per-device high-water mark reported on the channel join response. If
1406
+ * the server tells us "I've already applied your local_seqnum up to N for
1407
+ * this device on this channel," DELETE everything at or below N from the
1408
+ * queue — those writes are guaranteed-committed, even if their acks never
1409
+ * reached us (network drop, app crash, server restart between apply and
1410
+ * ack send). Idempotent and safe on every join.
1411
+ *
1412
+ * CRITICAL: scope by table_name. Local seqnums in `_sync_changes` are
1413
+ * AUTOINCREMENT-global across the file (one counter for ALL channels),
1414
+ * while the server's high-water mark is per-(device, channel). An
1415
+ * unscoped `markSynced(N)` would `DELETE WHERE seqnum <= N` and could
1416
+ * sweep up unpushed writes for OTHER channels whose seqnums happen to
1417
+ * fall below N. Filtering by the channel's table set keeps the delete
1418
+ * correct in the multi-channel case.
1419
+ */
1420
+ trimQueueFromJoinResponse(response, channelTopic) {
1421
+ const lastApplied = response?.last_applied_local_seqnum;
1422
+ if (typeof lastApplied !== "number" || lastApplied <= 0) {
1423
+ return;
1424
+ }
1425
+ const channel = this.config.channels.find((c) => c.topic === channelTopic);
1426
+ if (!channel) {
1427
+ console.warn(
1428
+ `[SyncClient] No SyncChannel found for topic ${channelTopic}; skipping trim`
1429
+ );
1430
+ return;
1431
+ }
1432
+ const tables = (channel.tables || []).map((t) => t.name);
1433
+ if (tables.length === 0) {
1434
+ return;
1435
+ }
1436
+ try {
1437
+ const escapedTables = tables.map((t) => `'${t.replace(/'/g, "''")}'`).join(",");
1438
+ this.db.exec(
1439
+ `DELETE FROM _sync_changes WHERE seqnum <= ${lastApplied} AND table_name IN (${escapedTables})`
1440
+ );
1441
+ console.log(
1442
+ `[SyncClient] Reconciled outbound queue on ${channelTopic}: trimmed seqnum <= ${lastApplied} scoped to ${tables.length} table(s)`
1443
+ );
1444
+ } catch (e) {
1445
+ console.warn(
1446
+ `[SyncClient] Failed to trim queue at seqnum ${lastApplied} on ${channelTopic}:`,
1447
+ e
1448
+ );
1449
+ }
1450
+ }
1323
1451
  /**
1324
1452
  * Send hello message to server
1325
1453
  */
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.1",
4
4
  "description": "TypeScript/JavaScript sync client for coordinating database changes with Elixir Phoenix server",
5
5
  "publishConfig": {
6
6
  "access": "public"