@synclib-io/sync 0.1.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 +55 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +154 -0
- package/dist/index.mjs +154 -0
- package/package.json +7 -8
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
|
|
@@ -668,6 +687,24 @@ declare class SyncClient {
|
|
|
668
687
|
* Wait for WebSocket connection with timeout
|
|
669
688
|
*/
|
|
670
689
|
private waitForConnection;
|
|
690
|
+
/**
|
|
691
|
+
* Null out the `seqnum` column on every row of the given tables so the next
|
|
692
|
+
* sync treats them as needing a full pull. Also clears the in-memory
|
|
693
|
+
* `tableSeqnums` cache entry for each table so `getPerTableSeqnums` falls
|
|
694
|
+
* back to the local `MAX(seqnum)` query (which now returns null).
|
|
695
|
+
*
|
|
696
|
+
* Use after an event that changes the user's access claims (e.g. a
|
|
697
|
+
* subscription purchase): combined with a fresh-JWT reconnect, the server
|
|
698
|
+
* re-runs `sanitize_row` against the new claims and the affected tables
|
|
699
|
+
* come back unstripped on the next autosync.
|
|
700
|
+
*
|
|
701
|
+
* Resumability requires the server to stream rows in `seqnum ASC` order
|
|
702
|
+
* (see `apply_seqnum_filter/2` in sync_server). Without ordered pagination,
|
|
703
|
+
* an interrupted pull leaves permanent gaps.
|
|
704
|
+
*
|
|
705
|
+
* Tables without a `seqnum` column are skipped silently.
|
|
706
|
+
*/
|
|
707
|
+
nullOutSeqnums(tables: string[]): void;
|
|
671
708
|
/**
|
|
672
709
|
* Get per-table seqnums for incremental sync
|
|
673
710
|
*/
|
|
@@ -688,6 +725,24 @@ declare class SyncClient {
|
|
|
688
725
|
* Extract server-driven hash_columns from a channel join response.
|
|
689
726
|
*/
|
|
690
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;
|
|
691
746
|
/**
|
|
692
747
|
* Send hello message to server
|
|
693
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
|
|
@@ -668,6 +687,24 @@ declare class SyncClient {
|
|
|
668
687
|
* Wait for WebSocket connection with timeout
|
|
669
688
|
*/
|
|
670
689
|
private waitForConnection;
|
|
690
|
+
/**
|
|
691
|
+
* Null out the `seqnum` column on every row of the given tables so the next
|
|
692
|
+
* sync treats them as needing a full pull. Also clears the in-memory
|
|
693
|
+
* `tableSeqnums` cache entry for each table so `getPerTableSeqnums` falls
|
|
694
|
+
* back to the local `MAX(seqnum)` query (which now returns null).
|
|
695
|
+
*
|
|
696
|
+
* Use after an event that changes the user's access claims (e.g. a
|
|
697
|
+
* subscription purchase): combined with a fresh-JWT reconnect, the server
|
|
698
|
+
* re-runs `sanitize_row` against the new claims and the affected tables
|
|
699
|
+
* come back unstripped on the next autosync.
|
|
700
|
+
*
|
|
701
|
+
* Resumability requires the server to stream rows in `seqnum ASC` order
|
|
702
|
+
* (see `apply_seqnum_filter/2` in sync_server). Without ordered pagination,
|
|
703
|
+
* an interrupted pull leaves permanent gaps.
|
|
704
|
+
*
|
|
705
|
+
* Tables without a `seqnum` column are skipped silently.
|
|
706
|
+
*/
|
|
707
|
+
nullOutSeqnums(tables: string[]): void;
|
|
671
708
|
/**
|
|
672
709
|
* Get per-table seqnums for incremental sync
|
|
673
710
|
*/
|
|
@@ -688,6 +725,24 @@ declare class SyncClient {
|
|
|
688
725
|
* Extract server-driven hash_columns from a channel join response.
|
|
689
726
|
*/
|
|
690
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;
|
|
691
746
|
/**
|
|
692
747
|
* Send hello message to server
|
|
693
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
|
|
@@ -1250,6 +1330,32 @@ var SyncClient = class {
|
|
|
1250
1330
|
});
|
|
1251
1331
|
});
|
|
1252
1332
|
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Null out the `seqnum` column on every row of the given tables so the next
|
|
1335
|
+
* sync treats them as needing a full pull. Also clears the in-memory
|
|
1336
|
+
* `tableSeqnums` cache entry for each table so `getPerTableSeqnums` falls
|
|
1337
|
+
* back to the local `MAX(seqnum)` query (which now returns null).
|
|
1338
|
+
*
|
|
1339
|
+
* Use after an event that changes the user's access claims (e.g. a
|
|
1340
|
+
* subscription purchase): combined with a fresh-JWT reconnect, the server
|
|
1341
|
+
* re-runs `sanitize_row` against the new claims and the affected tables
|
|
1342
|
+
* come back unstripped on the next autosync.
|
|
1343
|
+
*
|
|
1344
|
+
* Resumability requires the server to stream rows in `seqnum ASC` order
|
|
1345
|
+
* (see `apply_seqnum_filter/2` in sync_server). Without ordered pagination,
|
|
1346
|
+
* an interrupted pull leaves permanent gaps.
|
|
1347
|
+
*
|
|
1348
|
+
* Tables without a `seqnum` column are skipped silently.
|
|
1349
|
+
*/
|
|
1350
|
+
nullOutSeqnums(tables) {
|
|
1351
|
+
for (const table of tables) {
|
|
1352
|
+
try {
|
|
1353
|
+
this.db.exec(`UPDATE ${this.quoteId(table)} SET "seqnum" = NULL`);
|
|
1354
|
+
this.tableSeqnums.delete(table);
|
|
1355
|
+
} catch (e) {
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1253
1359
|
/**
|
|
1254
1360
|
* Get per-table seqnums for incremental sync
|
|
1255
1361
|
*/
|
|
@@ -1327,6 +1433,54 @@ var SyncClient = class {
|
|
|
1327
1433
|
console.log(`Server hash_columns: ${this.serverHashColumns.join(", ")}`);
|
|
1328
1434
|
}
|
|
1329
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
|
+
}
|
|
1330
1484
|
/**
|
|
1331
1485
|
* Send hello message to server
|
|
1332
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
|
|
@@ -1217,6 +1297,32 @@ var SyncClient = class {
|
|
|
1217
1297
|
});
|
|
1218
1298
|
});
|
|
1219
1299
|
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Null out the `seqnum` column on every row of the given tables so the next
|
|
1302
|
+
* sync treats them as needing a full pull. Also clears the in-memory
|
|
1303
|
+
* `tableSeqnums` cache entry for each table so `getPerTableSeqnums` falls
|
|
1304
|
+
* back to the local `MAX(seqnum)` query (which now returns null).
|
|
1305
|
+
*
|
|
1306
|
+
* Use after an event that changes the user's access claims (e.g. a
|
|
1307
|
+
* subscription purchase): combined with a fresh-JWT reconnect, the server
|
|
1308
|
+
* re-runs `sanitize_row` against the new claims and the affected tables
|
|
1309
|
+
* come back unstripped on the next autosync.
|
|
1310
|
+
*
|
|
1311
|
+
* Resumability requires the server to stream rows in `seqnum ASC` order
|
|
1312
|
+
* (see `apply_seqnum_filter/2` in sync_server). Without ordered pagination,
|
|
1313
|
+
* an interrupted pull leaves permanent gaps.
|
|
1314
|
+
*
|
|
1315
|
+
* Tables without a `seqnum` column are skipped silently.
|
|
1316
|
+
*/
|
|
1317
|
+
nullOutSeqnums(tables) {
|
|
1318
|
+
for (const table of tables) {
|
|
1319
|
+
try {
|
|
1320
|
+
this.db.exec(`UPDATE ${this.quoteId(table)} SET "seqnum" = NULL`);
|
|
1321
|
+
this.tableSeqnums.delete(table);
|
|
1322
|
+
} catch (e) {
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1220
1326
|
/**
|
|
1221
1327
|
* Get per-table seqnums for incremental sync
|
|
1222
1328
|
*/
|
|
@@ -1294,6 +1400,54 @@ var SyncClient = class {
|
|
|
1294
1400
|
console.log(`Server hash_columns: ${this.serverHashColumns.join(", ")}`);
|
|
1295
1401
|
}
|
|
1296
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
|
+
}
|
|
1297
1451
|
/**
|
|
1298
1452
|
* Send hello message to server
|
|
1299
1453
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synclib-io/sync",
|
|
3
|
-
"version": "0.1
|
|
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"
|
|
@@ -18,12 +18,6 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"dist"
|
|
20
20
|
],
|
|
21
|
-
"scripts": {
|
|
22
|
-
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
23
|
-
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
24
|
-
"test": "vitest",
|
|
25
|
-
"prepublishOnly": "pnpm run build"
|
|
26
|
-
},
|
|
27
21
|
"keywords": [
|
|
28
22
|
"sync",
|
|
29
23
|
"websocket",
|
|
@@ -51,5 +45,10 @@
|
|
|
51
45
|
"repository": {
|
|
52
46
|
"type": "git",
|
|
53
47
|
"url": "https://github.com/synclib-io/synclib"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
51
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
52
|
+
"test": "vitest"
|
|
54
53
|
}
|
|
55
|
-
}
|
|
54
|
+
}
|