@syncular/client 0.0.6-158 → 0.0.6-165
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/create-client.d.ts.map +1 -1
- package/dist/create-client.js +3 -16
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +17 -0
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/handlers/collection.d.ts.map +1 -1
- package/dist/handlers/collection.js +2 -7
- package/dist/handlers/collection.js.map +1 -1
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +6 -22
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +2 -24
- package/dist/mutations.js.map +1 -1
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +41 -22
- package/dist/pull-engine.js.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +2 -4
- package/dist/sync.js.map +1 -1
- package/package.json +3 -3
- package/src/create-client.ts +3 -18
- package/src/engine/SyncEngine.test.ts +78 -0
- package/src/engine/SyncEngine.ts +19 -0
- package/src/handlers/collection.ts +5 -9
- package/src/handlers/create-handler.ts +7 -19
- package/src/mutations.ts +5 -33
- package/src/pull-engine.ts +49 -20
- package/src/sync.ts +6 -6
|
@@ -15,6 +15,7 @@ import { ensureClientSyncSchema } from '../migrate';
|
|
|
15
15
|
import { enqueueOutboxCommit } from '../outbox';
|
|
16
16
|
import type { SyncClientDb } from '../schema';
|
|
17
17
|
import { SyncEngine } from './SyncEngine';
|
|
18
|
+
import type { RealtimeTransportLike } from './types';
|
|
18
19
|
|
|
19
20
|
interface TasksTable {
|
|
20
21
|
id: string;
|
|
@@ -1016,6 +1017,83 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
1016
1017
|
}
|
|
1017
1018
|
});
|
|
1018
1019
|
|
|
1020
|
+
it('stops realtime reconnect and fallback polling after auth failures', async () => {
|
|
1021
|
+
let syncAttempts = 0;
|
|
1022
|
+
let disconnectCalls = 0;
|
|
1023
|
+
let reconnectCalls = 0;
|
|
1024
|
+
|
|
1025
|
+
const authFailingRealtimeTransport: RealtimeTransportLike = {
|
|
1026
|
+
async sync() {
|
|
1027
|
+
syncAttempts += 1;
|
|
1028
|
+
throw new SyncTransportError('unauthorized', 401);
|
|
1029
|
+
},
|
|
1030
|
+
async fetchSnapshotChunk() {
|
|
1031
|
+
return new Uint8Array();
|
|
1032
|
+
},
|
|
1033
|
+
connect(_args, _onEvent, onStateChange) {
|
|
1034
|
+
onStateChange?.('disconnected');
|
|
1035
|
+
return () => {
|
|
1036
|
+
disconnectCalls += 1;
|
|
1037
|
+
};
|
|
1038
|
+
},
|
|
1039
|
+
getConnectionState() {
|
|
1040
|
+
return 'disconnected';
|
|
1041
|
+
},
|
|
1042
|
+
reconnect() {
|
|
1043
|
+
reconnectCalls += 1;
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
1048
|
+
{
|
|
1049
|
+
table: 'tasks',
|
|
1050
|
+
async applySnapshot() {},
|
|
1051
|
+
async clearAll() {},
|
|
1052
|
+
async applyChange() {},
|
|
1053
|
+
},
|
|
1054
|
+
];
|
|
1055
|
+
|
|
1056
|
+
const engine = new SyncEngine<TestDb>({
|
|
1057
|
+
db,
|
|
1058
|
+
transport: authFailingRealtimeTransport,
|
|
1059
|
+
handlers,
|
|
1060
|
+
actorId: 'u1',
|
|
1061
|
+
clientId: 'client-auth-failed-realtime',
|
|
1062
|
+
subscriptions: [
|
|
1063
|
+
{
|
|
1064
|
+
id: 'sub-1',
|
|
1065
|
+
table: 'tasks',
|
|
1066
|
+
scopes: {},
|
|
1067
|
+
},
|
|
1068
|
+
],
|
|
1069
|
+
stateId: 'default',
|
|
1070
|
+
realtimeFallbackPollMs: 10,
|
|
1071
|
+
pollIntervalMs: 60_000,
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
await engine.start();
|
|
1075
|
+
|
|
1076
|
+
try {
|
|
1077
|
+
const state = engine.getState();
|
|
1078
|
+
expect(state.error?.code).toBe('AUTH_FAILED');
|
|
1079
|
+
expect(state.error?.retryable).toBe(false);
|
|
1080
|
+
expect(state.connectionState).toBe('disconnected');
|
|
1081
|
+
|
|
1082
|
+
const health = engine.getTransportHealth();
|
|
1083
|
+
expect(health.mode).toBe('disconnected');
|
|
1084
|
+
expect(health.connected).toBe(false);
|
|
1085
|
+
expect(health.fallbackReason).toBe('auth');
|
|
1086
|
+
|
|
1087
|
+
const attemptsAfterStart = syncAttempts;
|
|
1088
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 40));
|
|
1089
|
+
expect(syncAttempts).toBe(attemptsAfterStart);
|
|
1090
|
+
expect(disconnectCalls).toBeGreaterThanOrEqual(1);
|
|
1091
|
+
expect(reconnectCalls).toBe(0);
|
|
1092
|
+
} finally {
|
|
1093
|
+
engine.destroy();
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1019
1097
|
it('repairs rebootstrap-missing-chunks by clearing synced state and data', async () => {
|
|
1020
1098
|
const outboxId = 'outbox-1';
|
|
1021
1099
|
const now = Date.now();
|
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -1901,6 +1901,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1901
1901
|
this.handleError(error);
|
|
1902
1902
|
await this.emitNewConflictsSafe('sync error');
|
|
1903
1903
|
|
|
1904
|
+
if (error.code === 'AUTH_FAILED') {
|
|
1905
|
+
if (
|
|
1906
|
+
this.state.transportMode === 'realtime' &&
|
|
1907
|
+
isRealtimeTransport(this.config.transport)
|
|
1908
|
+
) {
|
|
1909
|
+
this.stopRealtime();
|
|
1910
|
+
this.setConnectionState('disconnected');
|
|
1911
|
+
this.updateTransportHealth({
|
|
1912
|
+
mode: 'disconnected',
|
|
1913
|
+
connected: false,
|
|
1914
|
+
fallbackReason: 'auth',
|
|
1915
|
+
});
|
|
1916
|
+
} else {
|
|
1917
|
+
this.updateTransportHealth({
|
|
1918
|
+
fallbackReason: 'auth',
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1904
1923
|
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
1905
1924
|
countSyncMetric('sync.client.sync.results', 1, {
|
|
1906
1925
|
attributes: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createTableLookup } from '@syncular/core';
|
|
1
2
|
import type { ClientTableHandler } from './types';
|
|
2
3
|
|
|
3
4
|
export type ClientHandlerCollection<DB> = ClientTableHandler<DB>[];
|
|
@@ -5,15 +6,10 @@ export type ClientHandlerCollection<DB> = ClientTableHandler<DB>[];
|
|
|
5
6
|
export function createClientHandlerCollection<DB>(
|
|
6
7
|
handlers: ClientTableHandler<DB>[]
|
|
7
8
|
): ClientHandlerCollection<DB> {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
`Client table handler already registered: ${handler.table}`
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
tables.add(handler.table);
|
|
16
|
-
}
|
|
9
|
+
createTableLookup(
|
|
10
|
+
handlers,
|
|
11
|
+
(table) => `Client table handler already registered: ${table}`
|
|
12
|
+
);
|
|
17
13
|
return handlers;
|
|
18
14
|
}
|
|
19
15
|
|
|
@@ -13,9 +13,9 @@ import type {
|
|
|
13
13
|
} from '@syncular/core';
|
|
14
14
|
import {
|
|
15
15
|
applyCodecsToDbRow,
|
|
16
|
+
createTableColumnCodecsResolver,
|
|
16
17
|
isRecord,
|
|
17
18
|
normalizeScopes,
|
|
18
|
-
toTableColumnCodecs,
|
|
19
19
|
} from '@syncular/core';
|
|
20
20
|
import { sql } from 'kysely';
|
|
21
21
|
import type { SyncClientDb } from '../schema';
|
|
@@ -176,25 +176,13 @@ export function createClientHandler<
|
|
|
176
176
|
options.primaryKey ?? ('id' as keyof DB[TableName] & string);
|
|
177
177
|
const versionColumn = options.versionColumn;
|
|
178
178
|
const codecDialect = options.codecDialect ?? 'sqlite';
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (columns.length === 0) return {};
|
|
185
|
-
const cacheKey = columns.slice().sort().join('\u0000');
|
|
186
|
-
const cached = codecCache.get(cacheKey);
|
|
187
|
-
if (cached) return cached;
|
|
188
|
-
const resolved = toTableColumnCodecs(table, codecs, columns, {
|
|
189
|
-
dialect: codecDialect,
|
|
190
|
-
});
|
|
191
|
-
codecCache.set(cacheKey, resolved);
|
|
192
|
-
return resolved;
|
|
193
|
-
};
|
|
179
|
+
const resolveRowCodecs = createTableColumnCodecsResolver(options.codecs, {
|
|
180
|
+
dialect: codecDialect,
|
|
181
|
+
});
|
|
182
|
+
const resolveTableCodecs = (row: Record<string, unknown>) =>
|
|
183
|
+
resolveRowCodecs(table, row);
|
|
194
184
|
|
|
195
|
-
|
|
196
|
-
const scopeColumnMap = normalizeScopes(scopeDefs);
|
|
197
|
-
const scopePatterns = Object.keys(scopeColumnMap);
|
|
185
|
+
const scopePatterns = Object.keys(normalizeScopes(scopeDefs));
|
|
198
186
|
|
|
199
187
|
// Default applySnapshot: upsert all rows
|
|
200
188
|
const defaultApplySnapshot = async (
|
package/src/mutations.ts
CHANGED
|
@@ -23,9 +23,9 @@ import type {
|
|
|
23
23
|
} from '@syncular/core';
|
|
24
24
|
import {
|
|
25
25
|
applyCodecsToDbRow,
|
|
26
|
+
createTableColumnCodecsResolver,
|
|
26
27
|
isRecord,
|
|
27
28
|
randomId,
|
|
28
|
-
toTableColumnCodecs,
|
|
29
29
|
} from '@syncular/core';
|
|
30
30
|
import type { Insertable, Kysely, Transaction, Updateable } from 'kysely';
|
|
31
31
|
import { sql } from 'kysely';
|
|
@@ -469,38 +469,10 @@ export function createOutboxCommit<DB extends SyncClientDb>(
|
|
|
469
469
|
.transaction()
|
|
470
470
|
.execute(async (trx) => {
|
|
471
471
|
const txTableCache = new Map<string, any>();
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const resolveTableCodecs = (
|
|
477
|
-
table: string,
|
|
478
|
-
row: Record<string, unknown>
|
|
479
|
-
) => {
|
|
480
|
-
const codecs = config.codecs;
|
|
481
|
-
if (!codecs) return {};
|
|
482
|
-
const columns = Object.keys(row);
|
|
483
|
-
if (columns.length === 0) return {};
|
|
484
|
-
|
|
485
|
-
let tableCache = tableCodecCache.get(table);
|
|
486
|
-
if (!tableCache) {
|
|
487
|
-
tableCache = new Map<
|
|
488
|
-
string,
|
|
489
|
-
ReturnType<typeof toTableColumnCodecs>
|
|
490
|
-
>();
|
|
491
|
-
tableCodecCache.set(table, tableCache);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const cacheKey = columns.slice().sort().join('\u0000');
|
|
495
|
-
const cached = tableCache.get(cacheKey);
|
|
496
|
-
if (cached) return cached;
|
|
497
|
-
|
|
498
|
-
const resolved = toTableColumnCodecs(table, codecs, columns, {
|
|
499
|
-
dialect: codecDialect,
|
|
500
|
-
});
|
|
501
|
-
tableCache.set(cacheKey, resolved);
|
|
502
|
-
return resolved;
|
|
503
|
-
};
|
|
472
|
+
const resolveTableCodecs = createTableColumnCodecsResolver(
|
|
473
|
+
config.codecs,
|
|
474
|
+
{ dialect: codecDialect }
|
|
475
|
+
);
|
|
504
476
|
|
|
505
477
|
const makeTxTable = (table: string) => {
|
|
506
478
|
const cached = txTableCache.get(table);
|
package/src/pull-engine.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
|
+
ScopeValues,
|
|
6
7
|
SyncBootstrapState,
|
|
7
8
|
SyncPullRequest,
|
|
8
9
|
SyncPullResponse,
|
|
@@ -307,12 +308,15 @@ async function computeSha256Hex(
|
|
|
307
308
|
|
|
308
309
|
async function fetchSnapshotChunkStream(
|
|
309
310
|
transport: SyncTransport,
|
|
310
|
-
|
|
311
|
+
request: {
|
|
312
|
+
chunkId: string;
|
|
313
|
+
scopeValues?: ScopeValues;
|
|
314
|
+
}
|
|
311
315
|
): Promise<ReadableStream<Uint8Array>> {
|
|
312
316
|
if (transport.fetchSnapshotChunkStream) {
|
|
313
|
-
return transport.fetchSnapshotChunkStream(
|
|
317
|
+
return transport.fetchSnapshotChunkStream(request);
|
|
314
318
|
}
|
|
315
|
-
const bytes = await transport.fetchSnapshotChunk(
|
|
319
|
+
const bytes = await transport.fetchSnapshotChunk(request);
|
|
316
320
|
return bytesToReadableStream(bytes);
|
|
317
321
|
}
|
|
318
322
|
|
|
@@ -404,7 +408,10 @@ async function materializeChunkedSnapshots(
|
|
|
404
408
|
async (chunk) => {
|
|
405
409
|
const promise =
|
|
406
410
|
chunkCache.get(chunk.id) ??
|
|
407
|
-
transport.fetchSnapshotChunk({
|
|
411
|
+
transport.fetchSnapshotChunk({
|
|
412
|
+
chunkId: chunk.id,
|
|
413
|
+
scopeValues: sub.scopes,
|
|
414
|
+
});
|
|
408
415
|
chunkCache.set(chunk.id, promise);
|
|
409
416
|
|
|
410
417
|
const raw = await promise;
|
|
@@ -451,6 +458,7 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
|
|
|
451
458
|
handler: Pick<ClientTableHandler<DB>, 'applySnapshot'>,
|
|
452
459
|
trx: Transaction<DB>,
|
|
453
460
|
snapshot: SyncSnapshot,
|
|
461
|
+
scopeValues: ScopeValues,
|
|
454
462
|
sha256Override?: (bytes: Uint8Array) => Promise<string>
|
|
455
463
|
): Promise<void> {
|
|
456
464
|
const chunks = snapshot.chunks ?? [];
|
|
@@ -465,7 +473,10 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
|
|
|
465
473
|
const chunk = chunks[chunkIndex];
|
|
466
474
|
if (!chunk) continue;
|
|
467
475
|
|
|
468
|
-
const rawStream = await fetchSnapshotChunkStream(transport,
|
|
476
|
+
const rawStream = await fetchSnapshotChunkStream(transport, {
|
|
477
|
+
chunkId: chunk.id,
|
|
478
|
+
scopeValues,
|
|
479
|
+
});
|
|
469
480
|
const decodedStream = await maybeGunzipStream(rawStream);
|
|
470
481
|
let applyStream = decodedStream;
|
|
471
482
|
let chunkHashPromise: Promise<string> | null = null;
|
|
@@ -777,6 +788,27 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
777
788
|
|
|
778
789
|
const subsById = new Map<string, (typeof options.subscriptions)[number]>();
|
|
779
790
|
for (const s of options.subscriptions ?? []) subsById.set(s.id, s);
|
|
791
|
+
const latestStateRows = await sql<{
|
|
792
|
+
subscription_id: string;
|
|
793
|
+
cursor: number | string | null;
|
|
794
|
+
}>`
|
|
795
|
+
select
|
|
796
|
+
${sql.ref('subscription_id')} as subscription_id,
|
|
797
|
+
${sql.ref('cursor')} as cursor
|
|
798
|
+
from ${sql.table('sync_subscription_state')}
|
|
799
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
800
|
+
`.execute(trx);
|
|
801
|
+
const latestCursorBySubscriptionId = new Map<string, number | null>();
|
|
802
|
+
for (const row of latestStateRows.rows) {
|
|
803
|
+
const raw = row.cursor;
|
|
804
|
+
const cursor =
|
|
805
|
+
typeof raw === 'number'
|
|
806
|
+
? raw
|
|
807
|
+
: raw === null || raw === undefined
|
|
808
|
+
? null
|
|
809
|
+
: Number(raw);
|
|
810
|
+
latestCursorBySubscriptionId.set(row.subscription_id, cursor);
|
|
811
|
+
}
|
|
780
812
|
|
|
781
813
|
for (const sub of responseToApply.subscriptions) {
|
|
782
814
|
const def = subsById.get(sub.id);
|
|
@@ -788,13 +820,7 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
788
820
|
: prevCursorRaw === null || prevCursorRaw === undefined
|
|
789
821
|
? null
|
|
790
822
|
: Number(prevCursorRaw);
|
|
791
|
-
const
|
|
792
|
-
select ${sql.ref('cursor')} as cursor
|
|
793
|
-
from ${sql.table('sync_subscription_state')}
|
|
794
|
-
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
795
|
-
and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
|
|
796
|
-
`.execute(trx);
|
|
797
|
-
const latestCursorRaw = latestStateResult.rows[0]?.cursor;
|
|
823
|
+
const latestCursorRaw = latestCursorBySubscriptionId.get(sub.id);
|
|
798
824
|
const latestCursor =
|
|
799
825
|
typeof latestCursorRaw === 'number'
|
|
800
826
|
? latestCursorRaw
|
|
@@ -841,10 +867,11 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
841
867
|
}
|
|
842
868
|
|
|
843
869
|
await sql`
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
870
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
871
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
872
|
+
and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
|
|
873
|
+
`.execute(trx);
|
|
874
|
+
latestCursorBySubscriptionId.delete(sub.id);
|
|
848
875
|
continue;
|
|
849
876
|
}
|
|
850
877
|
|
|
@@ -870,6 +897,7 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
870
897
|
handler,
|
|
871
898
|
trx,
|
|
872
899
|
snapshot,
|
|
900
|
+
sub.scopes,
|
|
873
901
|
options.sha256
|
|
874
902
|
);
|
|
875
903
|
} else {
|
|
@@ -919,7 +947,7 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
919
947
|
|
|
920
948
|
const table = def?.table ?? 'unknown';
|
|
921
949
|
await sql`
|
|
922
|
-
|
|
950
|
+
insert into ${sql.table('sync_subscription_state')} (
|
|
923
951
|
${sql.join([
|
|
924
952
|
sql.ref('state_id'),
|
|
925
953
|
sql.ref('subscription_id'),
|
|
@@ -953,9 +981,10 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
953
981
|
${sql.ref('params_json')} = ${sql.val(paramsJson)},
|
|
954
982
|
${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
|
|
955
983
|
${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
984
|
+
${sql.ref('status')} = ${sql.val('active')},
|
|
985
|
+
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
986
|
+
`.execute(trx);
|
|
987
|
+
latestCursorBySubscriptionId.set(sub.id, sub.nextCursor);
|
|
959
988
|
}
|
|
960
989
|
});
|
|
961
990
|
|
package/src/sync.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ScopeValuesFromPatterns,
|
|
7
7
|
SyncSubscriptionRequest,
|
|
8
8
|
} from '@syncular/core';
|
|
9
|
+
import { registerTableOrThrow } from '@syncular/core';
|
|
9
10
|
import {
|
|
10
11
|
type CreateClientHandlerOptions,
|
|
11
12
|
createClientHandler,
|
|
@@ -116,11 +117,11 @@ export function defineClientSync<
|
|
|
116
117
|
Identity
|
|
117
118
|
>
|
|
118
119
|
) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
|
|
120
|
+
registerTableOrThrow(
|
|
121
|
+
registeredTables,
|
|
122
|
+
handlerOptions.table,
|
|
123
|
+
(table) => `Client table handler already registered: ${table}`
|
|
124
|
+
);
|
|
124
125
|
|
|
125
126
|
handlers.push(
|
|
126
127
|
createClientHandler({
|
|
@@ -139,7 +140,6 @@ export function defineClientSync<
|
|
|
139
140
|
Identity
|
|
140
141
|
>['subscribe']
|
|
141
142
|
);
|
|
142
|
-
registeredTables.add(handlerOptions.table);
|
|
143
143
|
return sync;
|
|
144
144
|
},
|
|
145
145
|
subscriptions(
|