@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.
@@ -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();
@@ -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
- const tables = new Set<string>();
9
- for (const handler of handlers) {
10
- if (tables.has(handler.table)) {
11
- throw new Error(
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 codecCache = new Map<string, ReturnType<typeof toTableColumnCodecs>>();
180
- const resolveTableCodecs = (row: Record<string, unknown>) => {
181
- const codecs = options.codecs;
182
- if (!codecs) return {};
183
- const columns = Object.keys(row);
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
- // Normalize scopes to pattern map (stored for metadata)
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 tableCodecCache = new Map<
473
- string,
474
- Map<string, ReturnType<typeof toTableColumnCodecs>>
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);
@@ -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
- chunkId: string
311
+ request: {
312
+ chunkId: string;
313
+ scopeValues?: ScopeValues;
314
+ }
311
315
  ): Promise<ReadableStream<Uint8Array>> {
312
316
  if (transport.fetchSnapshotChunkStream) {
313
- return transport.fetchSnapshotChunkStream({ chunkId });
317
+ return transport.fetchSnapshotChunkStream(request);
314
318
  }
315
- const bytes = await transport.fetchSnapshotChunk({ chunkId });
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({ chunkId: chunk.id });
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, chunk.id);
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 latestStateResult = await sql<{ cursor: number | string | null }>`
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
- delete from ${sql.table('sync_subscription_state')}
845
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
846
- and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
847
- `.execute(trx);
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
- insert into ${sql.table('sync_subscription_state')} (
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
- ${sql.ref('status')} = ${sql.val('active')},
957
- ${sql.ref('updated_at')} = ${sql.val(now)}
958
- `.execute(trx);
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
- if (registeredTables.has(handlerOptions.table)) {
120
- throw new Error(
121
- `Client table handler already registered: ${handlerOptions.table}`
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(