@syncular/client 0.0.6-212 → 0.0.6-219
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/engine/SyncEngine.d.ts +5 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +86 -0
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +33 -1
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/pull-engine.d.ts +12 -1
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +256 -212
- package/dist/pull-engine.js.map +1 -1
- package/package.json +3 -3
- package/src/engine/SyncEngine.ts +114 -0
- package/src/engine/types.ts +39 -0
- package/src/pull-engine.test.ts +247 -0
- package/src/pull-engine.ts +350 -245
package/dist/pull-engine.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @syncular/client - Sync pull engine
|
|
3
3
|
*/
|
|
4
|
-
import type
|
|
4
|
+
import { type SyncBootstrapApplyMode, type SyncChange, type SyncPullRequest, type SyncPullResponse, type SyncSubscriptionRequest, type SyncTransport } from '@syncular/core';
|
|
5
5
|
import { type Kysely, type Transaction } from 'kysely';
|
|
6
6
|
import { type ClientHandlerCollection } from './handlers/collection';
|
|
7
7
|
import type { SyncClientPlugin } from './plugins/types';
|
|
@@ -20,6 +20,17 @@ export interface SyncPullOnceOptions {
|
|
|
20
20
|
maxSnapshotPages?: number;
|
|
21
21
|
dedupeRows?: boolean;
|
|
22
22
|
stateId?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Controls how bootstrap snapshot results are committed to the local DB.
|
|
25
|
+
* - `single-transaction`: all subscriptions in one DB transaction
|
|
26
|
+
* - `per-subscription`: each subscription in its own DB transaction
|
|
27
|
+
* - `auto`: choose a sensible runtime-specific default
|
|
28
|
+
*
|
|
29
|
+
* `per-subscription` makes early subscriptions visible sooner and prevents a
|
|
30
|
+
* later large bootstrap table from hiding already-applied tables behind one
|
|
31
|
+
* long-running transaction.
|
|
32
|
+
*/
|
|
33
|
+
bootstrapApplyMode?: 'auto' | SyncBootstrapApplyMode;
|
|
23
34
|
/**
|
|
24
35
|
* Custom SHA-256 hash function for snapshot chunk integrity verification.
|
|
25
36
|
* Provide this on platforms where `crypto.subtle` is unavailable (e.g. React Native).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pull-engine.d.ts","sourceRoot":"","sources":["../src/pull-engine.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"pull-engine.d.ts","sourceRoot":"","sources":["../src/pull-engine.ts"],"names":[],"mappings":"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"}
|
package/dist/pull-engine.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @syncular/client - Sync pull engine
|
|
3
3
|
*/
|
|
4
|
+
import { bytesToReadableStream, decodeSnapshotRows, gunzipBytes, readAllBytesFromStream as readAllBytesFromCoreStream, } from '@syncular/core';
|
|
4
5
|
import { sql } from 'kysely';
|
|
5
6
|
import { getClientHandlerOrThrow, } from './handlers/collection.js';
|
|
6
7
|
// Simple JSON serialization cache to avoid repeated stringification
|
|
@@ -30,14 +31,6 @@ function serializeJsonCached(obj) {
|
|
|
30
31
|
function isGzipBytes(bytes) {
|
|
31
32
|
return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
|
|
32
33
|
}
|
|
33
|
-
function bytesToReadableStream(bytes) {
|
|
34
|
-
return new ReadableStream({
|
|
35
|
-
start(controller) {
|
|
36
|
-
controller.enqueue(bytes);
|
|
37
|
-
controller.close();
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
34
|
function concatBytes(chunks) {
|
|
42
35
|
if (chunks.length === 1) {
|
|
43
36
|
return chunks[0] ?? new Uint8Array();
|
|
@@ -111,7 +104,8 @@ async function maybeGunzipStream(stream) {
|
|
|
111
104
|
if (typeof DecompressionStream !== 'undefined') {
|
|
112
105
|
return replayStream.pipeThrough(new DecompressionStream('gzip'));
|
|
113
106
|
}
|
|
114
|
-
|
|
107
|
+
const compressedBytes = await readAllBytesFromCoreStream(replayStream);
|
|
108
|
+
return bytesToReadableStream(await gunzipBytes(compressedBytes));
|
|
115
109
|
}
|
|
116
110
|
async function* decodeSnapshotRowStreamBatches(stream, batchSize) {
|
|
117
111
|
const reader = stream.getReader();
|
|
@@ -250,6 +244,20 @@ async function readAllBytesFromStream(stream) {
|
|
|
250
244
|
return bytes;
|
|
251
245
|
}
|
|
252
246
|
async function materializeSnapshotChunkRows(transport, request, expectedHash, sha256Override) {
|
|
247
|
+
if (transport.capabilities?.snapshotChunkReadMode === 'bytes' &&
|
|
248
|
+
transport.fetchSnapshotChunk) {
|
|
249
|
+
let bytes = await transport.fetchSnapshotChunk(request);
|
|
250
|
+
if (isGzipBytes(bytes)) {
|
|
251
|
+
bytes = await gunzipBytes(bytes);
|
|
252
|
+
}
|
|
253
|
+
if (expectedHash) {
|
|
254
|
+
const actualHash = await computeSha256Hex(bytes, sha256Override);
|
|
255
|
+
if (actualHash !== expectedHash) {
|
|
256
|
+
throw new Error(`Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return decodeSnapshotRows(bytes);
|
|
260
|
+
}
|
|
253
261
|
const rawStream = await fetchSnapshotChunkStream(transport, request);
|
|
254
262
|
const decodedStream = await maybeGunzipStream(rawStream);
|
|
255
263
|
let streamForDecode = decodedStream;
|
|
@@ -645,7 +653,9 @@ export async function applyPullResponse(db, transport, handlers, options, pullSt
|
|
|
645
653
|
clientId: options.clientId,
|
|
646
654
|
};
|
|
647
655
|
const plugins = options.plugins ?? [];
|
|
648
|
-
const requiresMaterializedSnapshots = plugins.some((plugin) => !!plugin.afterPull)
|
|
656
|
+
const requiresMaterializedSnapshots = plugins.some((plugin) => !!plugin.afterPull) ||
|
|
657
|
+
transport.capabilities?.preferMaterializedSnapshots === true;
|
|
658
|
+
const bootstrapApplyMode = resolveBootstrapApplyMode(options, rawResponse, transport.capabilities);
|
|
649
659
|
let responseToApply = requiresMaterializedSnapshots
|
|
650
660
|
? await materializeChunkedSnapshots(transport, rawResponse, options.sha256)
|
|
651
661
|
: rawResponse;
|
|
@@ -657,219 +667,253 @@ export async function applyPullResponse(db, transport, handlers, options, pullSt
|
|
|
657
667
|
response: responseToApply,
|
|
658
668
|
});
|
|
659
669
|
}
|
|
670
|
+
const subsById = new Map();
|
|
671
|
+
for (const s of options.subscriptions ?? [])
|
|
672
|
+
subsById.set(s.id, s);
|
|
660
673
|
await db.transaction().execute(async (trx) => {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
674
|
+
await removeUndesiredSubscriptions(trx, handlers, existing, options.subscriptions ?? [], stateId);
|
|
675
|
+
});
|
|
676
|
+
if (bootstrapApplyMode === 'per-subscription') {
|
|
677
|
+
for (const sub of responseToApply.subscriptions) {
|
|
678
|
+
await db.transaction().execute(async (trx) => {
|
|
679
|
+
await applySubscriptionResponse({
|
|
680
|
+
trx,
|
|
681
|
+
handlers,
|
|
682
|
+
transport,
|
|
683
|
+
options,
|
|
684
|
+
stateId,
|
|
685
|
+
existingById,
|
|
686
|
+
subsById,
|
|
687
|
+
sub,
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
await db.transaction().execute(async (trx) => {
|
|
694
|
+
for (const sub of responseToApply.subscriptions) {
|
|
695
|
+
await applySubscriptionResponse({
|
|
696
|
+
trx,
|
|
697
|
+
handlers,
|
|
698
|
+
transport,
|
|
699
|
+
options,
|
|
700
|
+
stateId,
|
|
701
|
+
existingById,
|
|
702
|
+
subsById,
|
|
703
|
+
sub,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return responseToApply;
|
|
709
|
+
}
|
|
710
|
+
function resolveBootstrapApplyMode(options, response, capabilities) {
|
|
711
|
+
const mode = options.bootstrapApplyMode ?? 'auto';
|
|
712
|
+
if (mode === 'single-transaction' || mode === 'per-subscription') {
|
|
713
|
+
return mode;
|
|
714
|
+
}
|
|
715
|
+
if (!response.subscriptions.some((sub) => sub.bootstrap)) {
|
|
716
|
+
return 'single-transaction';
|
|
717
|
+
}
|
|
718
|
+
if (capabilities?.preferredBootstrapApplyMode) {
|
|
719
|
+
return capabilities.preferredBootstrapApplyMode;
|
|
720
|
+
}
|
|
721
|
+
if (capabilities?.snapshotChunkReadMode === 'bytes' ||
|
|
722
|
+
capabilities?.gzipDecompressionMode === 'buffered') {
|
|
723
|
+
return 'per-subscription';
|
|
724
|
+
}
|
|
725
|
+
return 'single-transaction';
|
|
726
|
+
}
|
|
727
|
+
async function removeUndesiredSubscriptions(trx, handlers, existing, desiredSubscriptions, stateId) {
|
|
728
|
+
const desiredIds = new Set(desiredSubscriptions.map((subscription) => subscription.id));
|
|
729
|
+
for (const row of existing) {
|
|
730
|
+
if (desiredIds.has(row.subscription_id))
|
|
731
|
+
continue;
|
|
732
|
+
if (row.table) {
|
|
733
|
+
try {
|
|
734
|
+
const scopes = row.scopes_json
|
|
735
|
+
? typeof row.scopes_json === 'string'
|
|
736
|
+
? JSON.parse(row.scopes_json)
|
|
737
|
+
: row.scopes_json
|
|
738
|
+
: {};
|
|
739
|
+
await getClientHandlerOrThrow(handlers, row.table).clearAll({
|
|
740
|
+
trx,
|
|
741
|
+
scopes,
|
|
742
|
+
});
|
|
682
743
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const subsById = new Map();
|
|
690
|
-
for (const s of options.subscriptions ?? [])
|
|
691
|
-
subsById.set(s.id, s);
|
|
692
|
-
const latestStateRows = await sql `
|
|
693
|
-
select
|
|
694
|
-
${sql.ref('subscription_id')} as subscription_id,
|
|
695
|
-
${sql.ref('cursor')} as cursor
|
|
696
|
-
from ${sql.table('sync_subscription_state')}
|
|
744
|
+
catch {
|
|
745
|
+
// ignore missing table handler
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
await sql `
|
|
749
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
697
750
|
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
751
|
+
and ${sql.ref('subscription_id')} = ${sql.val(row.subscription_id)}
|
|
698
752
|
`.execute(trx);
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
async function readLatestSubscriptionCursor(trx, stateId, subscriptionId) {
|
|
756
|
+
const result = await sql `
|
|
757
|
+
select ${sql.ref('cursor')} as cursor
|
|
758
|
+
from ${sql.table('sync_subscription_state')}
|
|
759
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
760
|
+
and ${sql.ref('subscription_id')} = ${sql.val(subscriptionId)}
|
|
761
|
+
limit 1
|
|
762
|
+
`.execute(trx);
|
|
763
|
+
const raw = result.rows[0]?.cursor;
|
|
764
|
+
return typeof raw === 'number'
|
|
765
|
+
? raw
|
|
766
|
+
: raw === null || raw === undefined
|
|
767
|
+
? null
|
|
768
|
+
: Number(raw);
|
|
769
|
+
}
|
|
770
|
+
async function applySubscriptionResponse(args) {
|
|
771
|
+
const { trx, handlers, transport, options, stateId, existingById, subsById, sub, } = args;
|
|
772
|
+
const def = subsById.get(sub.id);
|
|
773
|
+
const prev = existingById.get(sub.id);
|
|
774
|
+
const prevCursorRaw = prev?.cursor;
|
|
775
|
+
const prevCursor = typeof prevCursorRaw === 'number'
|
|
776
|
+
? prevCursorRaw
|
|
777
|
+
: prevCursorRaw === null || prevCursorRaw === undefined
|
|
778
|
+
? null
|
|
779
|
+
: Number(prevCursorRaw);
|
|
780
|
+
const latestCursor = await readLatestSubscriptionCursor(trx, stateId, sub.id);
|
|
781
|
+
const effectiveCursor = prevCursor !== null &&
|
|
782
|
+
Number.isFinite(prevCursor) &&
|
|
783
|
+
latestCursor !== null &&
|
|
784
|
+
Number.isFinite(latestCursor)
|
|
785
|
+
? Math.max(prevCursor, latestCursor)
|
|
786
|
+
: prevCursor !== null && Number.isFinite(prevCursor)
|
|
787
|
+
? prevCursor
|
|
788
|
+
: latestCursor !== null && Number.isFinite(latestCursor)
|
|
789
|
+
? latestCursor
|
|
790
|
+
: null;
|
|
791
|
+
const staleIncrementalResponse = !sub.bootstrap &&
|
|
792
|
+
effectiveCursor !== null &&
|
|
793
|
+
sub.nextCursor < effectiveCursor;
|
|
794
|
+
if (staleIncrementalResponse) {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (sub.status === 'revoked') {
|
|
798
|
+
if (prev?.table) {
|
|
799
|
+
try {
|
|
800
|
+
const scopes = parseScopeValuesJson(prev.scopes_json);
|
|
801
|
+
await getClientHandlerOrThrow(handlers, prev.table).clearAll({
|
|
802
|
+
trx,
|
|
803
|
+
scopes,
|
|
804
|
+
});
|
|
740
805
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
if (prev?.table) {
|
|
744
|
-
try {
|
|
745
|
-
const scopes = parseScopeValuesJson(prev.scopes_json);
|
|
746
|
-
await getClientHandlerOrThrow(handlers, prev.table).clearAll({
|
|
747
|
-
trx,
|
|
748
|
-
scopes,
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
catch {
|
|
752
|
-
// ignore missing handler
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
await sql `
|
|
756
|
-
delete from ${sql.table('sync_subscription_state')}
|
|
757
|
-
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
758
|
-
and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
|
|
759
|
-
`.execute(trx);
|
|
760
|
-
latestCursorBySubscriptionId.delete(sub.id);
|
|
761
|
-
continue;
|
|
806
|
+
catch {
|
|
807
|
+
// ignore missing handler
|
|
762
808
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
809
|
+
}
|
|
810
|
+
await sql `
|
|
811
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
812
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
813
|
+
and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
|
|
814
|
+
`.execute(trx);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const nextScopes = sub.scopes ?? def?.scopes ?? {};
|
|
818
|
+
const previousScopes = parseScopeValuesJson(prev?.scopes_json);
|
|
819
|
+
const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
|
|
820
|
+
if (sub.bootstrap && prev?.table && scopesChanged) {
|
|
821
|
+
try {
|
|
822
|
+
const clearScopes = resolveBootstrapClearScopes(previousScopes, nextScopes);
|
|
823
|
+
if (clearScopes !== 'none') {
|
|
824
|
+
await getClientHandlerOrThrow(handlers, prev.table).clearAll({
|
|
825
|
+
trx,
|
|
826
|
+
scopes: clearScopes ?? previousScopes,
|
|
827
|
+
});
|
|
779
828
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
}
|
|
799
|
-
// Call onSnapshotEnd hook when snapshot is complete
|
|
800
|
-
if (snapshot.isLastPage && handler.onSnapshotEnd) {
|
|
801
|
-
await handler.onSnapshotEnd({
|
|
802
|
-
trx,
|
|
803
|
-
table: snapshot.table,
|
|
804
|
-
scopes: sub.scopes,
|
|
805
|
-
});
|
|
806
|
-
}
|
|
807
|
-
}
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
// ignore missing handler
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (sub.bootstrap) {
|
|
835
|
+
for (const snapshot of sub.snapshots ?? []) {
|
|
836
|
+
const handler = getClientHandlerOrThrow(handlers, snapshot.table);
|
|
837
|
+
const hasChunkRefs = Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
|
|
838
|
+
if (snapshot.isFirstPage && handler.onSnapshotStart) {
|
|
839
|
+
await handler.onSnapshotStart({
|
|
840
|
+
trx,
|
|
841
|
+
table: snapshot.table,
|
|
842
|
+
scopes: sub.scopes,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
if (hasChunkRefs) {
|
|
846
|
+
await applyChunkedSnapshot(transport, handler, trx, snapshot, sub.scopes, options.sha256);
|
|
808
847
|
}
|
|
809
848
|
else {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}
|
|
849
|
+
await handler.applySnapshot({ trx }, snapshot);
|
|
850
|
+
}
|
|
851
|
+
if (snapshot.isLastPage && handler.onSnapshotEnd) {
|
|
852
|
+
await handler.onSnapshotEnd({
|
|
853
|
+
trx,
|
|
854
|
+
table: snapshot.table,
|
|
855
|
+
scopes: sub.scopes,
|
|
856
|
+
});
|
|
819
857
|
}
|
|
820
|
-
// Persist subscription cursor + metadata.
|
|
821
|
-
// Use cached JSON serialization to avoid repeated stringification
|
|
822
|
-
const now = Date.now();
|
|
823
|
-
const paramsJson = serializeJsonCached(def?.params ?? {});
|
|
824
|
-
const scopesJson = serializeJsonCached(nextScopes);
|
|
825
|
-
const bootstrapStateJson = sub.bootstrap
|
|
826
|
-
? sub.bootstrapState
|
|
827
|
-
? serializeJsonCached(sub.bootstrapState)
|
|
828
|
-
: null
|
|
829
|
-
: null;
|
|
830
|
-
const table = def?.table ?? 'unknown';
|
|
831
|
-
await sql `
|
|
832
|
-
insert into ${sql.table('sync_subscription_state')} (
|
|
833
|
-
${sql.join([
|
|
834
|
-
sql.ref('state_id'),
|
|
835
|
-
sql.ref('subscription_id'),
|
|
836
|
-
sql.ref('table'),
|
|
837
|
-
sql.ref('scopes_json'),
|
|
838
|
-
sql.ref('params_json'),
|
|
839
|
-
sql.ref('cursor'),
|
|
840
|
-
sql.ref('bootstrap_state_json'),
|
|
841
|
-
sql.ref('status'),
|
|
842
|
-
sql.ref('created_at'),
|
|
843
|
-
sql.ref('updated_at'),
|
|
844
|
-
])}
|
|
845
|
-
) values (
|
|
846
|
-
${sql.join([
|
|
847
|
-
sql.val(stateId),
|
|
848
|
-
sql.val(sub.id),
|
|
849
|
-
sql.val(table),
|
|
850
|
-
sql.val(scopesJson),
|
|
851
|
-
sql.val(paramsJson),
|
|
852
|
-
sql.val(sub.nextCursor),
|
|
853
|
-
sql.val(bootstrapStateJson),
|
|
854
|
-
sql.val('active'),
|
|
855
|
-
sql.val(now),
|
|
856
|
-
sql.val(now),
|
|
857
|
-
])}
|
|
858
|
-
)
|
|
859
|
-
on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
|
|
860
|
-
do update set
|
|
861
|
-
${sql.ref('table')} = ${sql.val(table)},
|
|
862
|
-
${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
|
|
863
|
-
${sql.ref('params_json')} = ${sql.val(paramsJson)},
|
|
864
|
-
${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
|
|
865
|
-
${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
|
|
866
|
-
${sql.ref('status')} = ${sql.val('active')},
|
|
867
|
-
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
868
|
-
`.execute(trx);
|
|
869
|
-
latestCursorBySubscriptionId.set(sub.id, sub.nextCursor);
|
|
870
858
|
}
|
|
871
|
-
}
|
|
872
|
-
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
for (const commit of sub.commits) {
|
|
862
|
+
await applyIncrementalCommitChanges(handlers, trx, {
|
|
863
|
+
changes: commit.changes,
|
|
864
|
+
commitSeq: commit.commitSeq ?? null,
|
|
865
|
+
actorId: commit.actorId ?? null,
|
|
866
|
+
createdAt: commit.createdAt ?? null,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const now = Date.now();
|
|
871
|
+
const paramsJson = serializeJsonCached(def?.params ?? {});
|
|
872
|
+
const scopesJson = serializeJsonCached(nextScopes);
|
|
873
|
+
const bootstrapStateJson = sub.bootstrap
|
|
874
|
+
? sub.bootstrapState
|
|
875
|
+
? serializeJsonCached(sub.bootstrapState)
|
|
876
|
+
: null
|
|
877
|
+
: null;
|
|
878
|
+
const table = def?.table ?? 'unknown';
|
|
879
|
+
await sql `
|
|
880
|
+
insert into ${sql.table('sync_subscription_state')} (
|
|
881
|
+
${sql.join([
|
|
882
|
+
sql.ref('state_id'),
|
|
883
|
+
sql.ref('subscription_id'),
|
|
884
|
+
sql.ref('table'),
|
|
885
|
+
sql.ref('scopes_json'),
|
|
886
|
+
sql.ref('params_json'),
|
|
887
|
+
sql.ref('cursor'),
|
|
888
|
+
sql.ref('bootstrap_state_json'),
|
|
889
|
+
sql.ref('status'),
|
|
890
|
+
sql.ref('created_at'),
|
|
891
|
+
sql.ref('updated_at'),
|
|
892
|
+
])}
|
|
893
|
+
) values (
|
|
894
|
+
${sql.join([
|
|
895
|
+
sql.val(stateId),
|
|
896
|
+
sql.val(sub.id),
|
|
897
|
+
sql.val(table),
|
|
898
|
+
sql.val(scopesJson),
|
|
899
|
+
sql.val(paramsJson),
|
|
900
|
+
sql.val(sub.nextCursor),
|
|
901
|
+
sql.val(bootstrapStateJson),
|
|
902
|
+
sql.val('active'),
|
|
903
|
+
sql.val(now),
|
|
904
|
+
sql.val(now),
|
|
905
|
+
])}
|
|
906
|
+
)
|
|
907
|
+
on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
|
|
908
|
+
do update set
|
|
909
|
+
${sql.ref('table')} = ${sql.val(table)},
|
|
910
|
+
${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
|
|
911
|
+
${sql.ref('params_json')} = ${sql.val(paramsJson)},
|
|
912
|
+
${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
|
|
913
|
+
${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
|
|
914
|
+
${sql.ref('status')} = ${sql.val('active')},
|
|
915
|
+
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
916
|
+
`.execute(trx);
|
|
873
917
|
}
|
|
874
918
|
export async function syncPullOnce(db, transport, handlers, options, pullStateOverride) {
|
|
875
919
|
const pullState = pullStateOverride ?? (await buildPullRequest(db, options));
|