@syncular/client 0.0.1-72 → 0.0.1-83

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.
Files changed (43) hide show
  1. package/dist/blobs/index.js +3 -3
  2. package/dist/client.d.ts +1 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +63 -16
  5. package/dist/client.js.map +1 -1
  6. package/dist/conflicts.d.ts.map +1 -1
  7. package/dist/conflicts.js +1 -7
  8. package/dist/conflicts.js.map +1 -1
  9. package/dist/create-client.js +4 -4
  10. package/dist/engine/SyncEngine.d.ts +1 -0
  11. package/dist/engine/SyncEngine.d.ts.map +1 -1
  12. package/dist/engine/SyncEngine.js +30 -32
  13. package/dist/engine/SyncEngine.js.map +1 -1
  14. package/dist/engine/index.js +2 -2
  15. package/dist/handlers/create-handler.d.ts.map +1 -1
  16. package/dist/handlers/create-handler.js +1 -4
  17. package/dist/handlers/create-handler.js.map +1 -1
  18. package/dist/index.js +19 -19
  19. package/dist/mutations.d.ts.map +1 -1
  20. package/dist/mutations.js +2 -12
  21. package/dist/mutations.js.map +1 -1
  22. package/dist/outbox.d.ts.map +1 -1
  23. package/dist/outbox.js +1 -11
  24. package/dist/outbox.js.map +1 -1
  25. package/dist/plugins/index.js +2 -2
  26. package/dist/proxy/dialect.js +1 -1
  27. package/dist/proxy/driver.js +1 -1
  28. package/dist/proxy/index.js +4 -4
  29. package/dist/proxy/mutations.js +1 -1
  30. package/dist/push-engine.js +2 -2
  31. package/dist/query/QueryContext.js +1 -1
  32. package/dist/query/index.js +3 -3
  33. package/dist/query/tracked-select.js +1 -1
  34. package/dist/sync-loop.js +4 -4
  35. package/package.json +1 -1
  36. package/src/client.test.ts +369 -0
  37. package/src/client.ts +79 -13
  38. package/src/conflicts.ts +1 -10
  39. package/src/engine/SyncEngine.test.ts +157 -0
  40. package/src/engine/SyncEngine.ts +55 -29
  41. package/src/handlers/create-handler.ts +1 -5
  42. package/src/mutations.ts +1 -15
  43. package/src/outbox.ts +1 -15
@@ -0,0 +1,157 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import type { SyncChange, SyncTransport } from '@syncular/core';
3
+ import type { Kysely } from 'kysely';
4
+ import { sql } from 'kysely';
5
+ import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
6
+ import { ClientTableRegistry } from '../handlers/registry';
7
+ import { ensureClientSyncSchema } from '../migrate';
8
+ import type { SyncClientDb } from '../schema';
9
+ import { SyncEngine } from './SyncEngine';
10
+
11
+ interface TasksTable {
12
+ id: string;
13
+ title: string;
14
+ server_version: number;
15
+ }
16
+
17
+ interface TestDb extends SyncClientDb {
18
+ tasks: TasksTable;
19
+ }
20
+
21
+ const noopTransport: SyncTransport = {
22
+ async sync() {
23
+ return {};
24
+ },
25
+ async fetchSnapshotChunk() {
26
+ return new Uint8Array();
27
+ },
28
+ };
29
+
30
+ describe('SyncEngine WS inline apply', () => {
31
+ let db: Kysely<TestDb>;
32
+
33
+ beforeEach(async () => {
34
+ db = createBunSqliteDb<TestDb>({ path: ':memory:' });
35
+ await ensureClientSyncSchema(db);
36
+
37
+ await db.schema
38
+ .createTable('tasks')
39
+ .addColumn('id', 'text', (col) => col.primaryKey())
40
+ .addColumn('title', 'text', (col) => col.notNull())
41
+ .addColumn('server_version', 'integer', (col) =>
42
+ col.notNull().defaultTo(0)
43
+ )
44
+ .execute();
45
+
46
+ await db
47
+ .insertInto('tasks')
48
+ .values({
49
+ id: 't1',
50
+ title: 'old',
51
+ server_version: 1,
52
+ })
53
+ .execute();
54
+
55
+ await db
56
+ .insertInto('sync_subscription_state')
57
+ .values({
58
+ state_id: 'default',
59
+ subscription_id: 'sub-1',
60
+ shape: 'tasks',
61
+ scopes_json: '{}',
62
+ params_json: '{}',
63
+ cursor: 0,
64
+ bootstrap_state_json: null,
65
+ status: 'active',
66
+ created_at: Date.now(),
67
+ updated_at: Date.now(),
68
+ })
69
+ .execute();
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await db.destroy();
74
+ });
75
+
76
+ it('rolls back row updates and cursor when any inline WS change fails', async () => {
77
+ const shapes = new ClientTableRegistry<TestDb>().register({
78
+ table: 'tasks',
79
+ async applySnapshot() {},
80
+ async clearAll() {},
81
+ async applyChange(ctx, change) {
82
+ if (change.row_id === 'fail') {
83
+ throw new Error('forced apply failure');
84
+ }
85
+ const rowJson =
86
+ change.row_json && typeof change.row_json === 'object'
87
+ ? change.row_json
88
+ : null;
89
+ const title =
90
+ rowJson && 'title' in rowJson ? String(rowJson.title ?? '') : '';
91
+ await sql`
92
+ update ${sql.table('tasks')}
93
+ set
94
+ ${sql.ref('title')} = ${sql.val(title)},
95
+ ${sql.ref('server_version')} = ${sql.val(Number(change.row_version ?? 0))}
96
+ where ${sql.ref('id')} = ${sql.val(change.row_id)}
97
+ `.execute(ctx.trx);
98
+ },
99
+ });
100
+
101
+ const engine = new SyncEngine<TestDb>({
102
+ db,
103
+ transport: noopTransport,
104
+ shapes,
105
+ actorId: 'u1',
106
+ clientId: 'client-1',
107
+ subscriptions: [],
108
+ stateId: 'default',
109
+ });
110
+
111
+ const changes: SyncChange[] = [
112
+ {
113
+ table: 'tasks',
114
+ row_id: 't1',
115
+ op: 'upsert',
116
+ row_json: { id: 't1', title: 'new' },
117
+ row_version: 2,
118
+ scopes: {},
119
+ },
120
+ {
121
+ table: 'tasks',
122
+ row_id: 'fail',
123
+ op: 'upsert',
124
+ row_json: { id: 'fail', title: 'bad' },
125
+ row_version: 1,
126
+ scopes: {},
127
+ },
128
+ ];
129
+
130
+ const applyWsDeliveredChanges = Reflect.get(
131
+ engine,
132
+ 'applyWsDeliveredChanges'
133
+ );
134
+ if (typeof applyWsDeliveredChanges !== 'function') {
135
+ throw new Error('Expected applyWsDeliveredChanges to be callable');
136
+ }
137
+ const applied = await applyWsDeliveredChanges.call(engine, changes, 10);
138
+
139
+ expect(applied).toBe(false);
140
+
141
+ const task = await db
142
+ .selectFrom('tasks')
143
+ .select(['title', 'server_version'])
144
+ .where('id', '=', 't1')
145
+ .executeTakeFirstOrThrow();
146
+ expect(task.title).toBe('old');
147
+ expect(task.server_version).toBe(1);
148
+
149
+ const state = await db
150
+ .selectFrom('sync_subscription_state')
151
+ .select(['cursor'])
152
+ .where('state_id', '=', 'default')
153
+ .where('subscription_id', '=', 'sub-1')
154
+ .executeTakeFirstOrThrow();
155
+ expect(state.cursor).toBe(0);
156
+ });
157
+ });
@@ -10,6 +10,7 @@ import type {
10
10
  SyncPullResponse,
11
11
  SyncSubscriptionRequest,
12
12
  } from '@syncular/core';
13
+ import { isRecord } from '@syncular/core';
13
14
  import { type Kysely, sql } from 'kysely';
14
15
  import { syncPushOnce } from '../push-engine';
15
16
  import type {
@@ -71,10 +72,6 @@ function createSyncError(
71
72
  };
72
73
  }
73
74
 
74
- function isRecord(value: unknown): value is Record<string, unknown> {
75
- return typeof value === 'object' && value !== null && !Array.isArray(value);
76
- }
77
-
78
75
  /**
79
76
  * Sync engine that orchestrates push/pull cycles with proper lifecycle management.
80
77
  *
@@ -611,7 +608,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
611
608
  }
612
609
 
613
610
  // Refresh outbox stats (fire-and-forget — don't block sync:complete)
614
- this.refreshOutboxStats().catch(() => {});
611
+ this.refreshOutboxStats().catch((error) => {
612
+ console.warn(
613
+ '[SyncEngine] Failed to refresh outbox stats after sync:',
614
+ error
615
+ );
616
+ });
615
617
 
616
618
  return syncResult;
617
619
  } catch (err) {
@@ -680,13 +682,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
680
682
  try {
681
683
  await this.config.db.transaction().execute(async (trx) => {
682
684
  for (const change of changes) {
683
- try {
684
- const handler = this.config.shapes.get(change.table);
685
- if (!handler) continue;
686
- await handler.applyChange({ trx }, change);
687
- } catch {
688
- // Best-effort: individual change failures are fine
685
+ const handler = this.config.shapes.get(change.table);
686
+ if (!handler) {
687
+ throw new Error(
688
+ `Missing client table handler for WS change table "${change.table}"`
689
+ );
689
690
  }
691
+ await handler.applyChange({ trx }, change);
690
692
  }
691
693
 
692
694
  // Update subscription cursors
@@ -737,13 +739,19 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
737
739
  ): Promise<void> {
738
740
  // If a sync is already in-flight, let it handle everything
739
741
  if (this.syncPromise) {
740
- this.sync({ trigger: 'ws' });
742
+ this.triggerSyncInBackground(
743
+ { trigger: 'ws' },
744
+ 'ws delivery with in-flight sync'
745
+ );
741
746
  return;
742
747
  }
743
748
 
744
749
  // If there are pending outbox commits, need to push via HTTP
745
750
  if (this.state.pendingCount > 0) {
746
- this.sync({ trigger: 'ws' });
751
+ this.triggerSyncInBackground(
752
+ { trigger: 'ws' },
753
+ 'ws delivery with pending outbox'
754
+ );
747
755
  return;
748
756
  }
749
757
 
@@ -753,14 +761,20 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
753
761
  (p) => typeof p.afterPull === 'function'
754
762
  );
755
763
  if (hasAfterPullPlugins) {
756
- this.sync({ trigger: 'ws' });
764
+ this.triggerSyncInBackground(
765
+ { trigger: 'ws' },
766
+ 'ws delivery with afterPull plugins'
767
+ );
757
768
  return;
758
769
  }
759
770
 
760
771
  // Apply changes + update cursor
761
772
  const applied = await this.applyWsDeliveredChanges(changes, cursor);
762
773
  if (!applied) {
763
- this.sync({ trigger: 'ws' });
774
+ this.triggerSyncInBackground(
775
+ { trigger: 'ws' },
776
+ 'ws inline apply fallback'
777
+ );
764
778
  return;
765
779
  }
766
780
 
@@ -779,7 +793,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
779
793
  pullResponse: { ok: true, subscriptions: [] },
780
794
  });
781
795
 
782
- this.refreshOutboxStats().catch(() => {});
796
+ this.refreshOutboxStats().catch((error) => {
797
+ console.warn(
798
+ '[SyncEngine] Failed to refresh outbox stats after WS apply:',
799
+ error
800
+ );
801
+ });
783
802
  }
784
803
 
785
804
  private timestampCounter = 0;
@@ -888,7 +907,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
888
907
  this.retryTimeoutId = setTimeout(() => {
889
908
  this.retryTimeoutId = null;
890
909
  if (!this.isDestroyed) {
891
- this.sync();
910
+ this.triggerSyncInBackground(undefined, 'retry timer');
892
911
  }
893
912
  }, delay);
894
913
  }
@@ -898,13 +917,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
898
917
  this.config.onError?.(error);
899
918
  }
900
919
 
920
+ private triggerSyncInBackground(
921
+ opts?: { trigger?: 'ws' | 'local' | 'poll' },
922
+ reason = 'background'
923
+ ): void {
924
+ void this.sync(opts).catch((error) => {
925
+ console.error(
926
+ `[SyncEngine] Unexpected sync failure during ${reason}:`,
927
+ error
928
+ );
929
+ });
930
+ }
931
+
901
932
  private setupPolling(): void {
902
933
  this.stopPolling();
903
934
 
904
935
  const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
905
936
  this.pollerId = setInterval(() => {
906
937
  if (!this.state.isSyncing && !this.isDestroyed) {
907
- this.sync();
938
+ this.triggerSyncInBackground(undefined, 'polling interval');
908
939
  }
909
940
  }, interval);
910
941
 
@@ -966,7 +997,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
966
997
  this.handleWsDelivery(event.data.changes as SyncChange[], cursor);
967
998
  } else {
968
999
  // Cursor-only wake-up or no cursor — must HTTP sync
969
- this.sync({ trigger: 'ws' });
1000
+ this.triggerSyncInBackground({ trigger: 'ws' }, 'ws cursor wakeup');
970
1001
  }
971
1002
  }
972
1003
  },
@@ -977,7 +1008,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
977
1008
  this.hasRealtimeConnectedOnce = true;
978
1009
  this.setConnectionState('connected');
979
1010
  this.stopFallbackPolling();
980
- this.sync();
1011
+ this.triggerSyncInBackground(undefined, 'realtime connected state');
981
1012
  if (wasConnectedBefore) {
982
1013
  this.scheduleRealtimeReconnectCatchupSync();
983
1014
  }
@@ -1022,9 +1053,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1022
1053
  if (this.isDestroyed || !this.isEnabled()) return;
1023
1054
  if (this.state.connectionState !== 'connected') return;
1024
1055
 
1025
- this.sync().catch(() => {
1026
- // Best-effort catch-up sync after reconnect.
1027
- });
1056
+ this.triggerSyncInBackground(undefined, 'realtime reconnect catchup');
1028
1057
  }, REALTIME_RECONNECT_CATCHUP_DELAY_MS);
1029
1058
  }
1030
1059
 
@@ -1034,7 +1063,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1034
1063
  const interval = this.config.realtimeFallbackPollMs ?? 30_000;
1035
1064
  this.fallbackPollerId = setInterval(() => {
1036
1065
  if (!this.state.isSyncing && !this.isDestroyed) {
1037
- this.sync();
1066
+ this.triggerSyncInBackground(undefined, 'realtime fallback poll');
1038
1067
  }
1039
1068
  }, interval);
1040
1069
  }
@@ -1087,10 +1116,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1087
1116
  // Polling mode: restart the poller and trigger a sync immediately.
1088
1117
  if (this.state.transportMode === 'polling') {
1089
1118
  this.setupPolling();
1090
- // Trigger sync in background - errors are handled internally by sync()
1091
- this.sync().catch((err) => {
1092
- console.error('Unexpected error during reconnect sync:', err);
1093
- });
1119
+ this.triggerSyncInBackground(undefined, 'reconnect');
1094
1120
  }
1095
1121
  }
1096
1122
 
@@ -1247,7 +1273,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1247
1273
  ): void {
1248
1274
  this.config.subscriptions = subscriptions;
1249
1275
  // Trigger a sync to apply new subscriptions
1250
- this.sync();
1276
+ this.triggerSyncInBackground(undefined, 'subscription update');
1251
1277
  }
1252
1278
 
1253
1279
  /**
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ScopeDefinition, SyncChange, SyncSnapshot } from '@syncular/core';
6
- import { normalizeScopes } from '@syncular/core';
6
+ import { isRecord, normalizeScopes } from '@syncular/core';
7
7
  import { sql } from 'kysely';
8
8
  import type { SyncClientDb } from '../schema';
9
9
  import type {
@@ -13,10 +13,6 @@ import type {
13
13
  ClientTableHandler,
14
14
  } from './types';
15
15
 
16
- function isRecord(value: unknown): value is Record<string, unknown> {
17
- return typeof value === 'object' && value !== null && !Array.isArray(value);
18
- }
19
-
20
16
  /**
21
17
  * Coerce a value for SQL parameter binding.
22
18
  * - PostgreSQL (PGlite) does not implicitly cast booleans to integers,
package/src/mutations.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  SyncPushResponse,
20
20
  SyncTransport,
21
21
  } from '@syncular/core';
22
+ import { isRecord, randomId } from '@syncular/core';
22
23
  import type { Insertable, Kysely, Transaction, Updateable } from 'kysely';
23
24
  import { sql } from 'kysely';
24
25
  import { enqueueOutboxCommit } from './outbox';
@@ -138,21 +139,6 @@ export type MutationsApi<DB, CommitOptions = unknown> = {
138
139
  [T in KnownTableKey<DB>]: TableMutations<DB, T>;
139
140
  };
140
141
 
141
- function randomId(): string {
142
- if (
143
- typeof crypto !== 'undefined' &&
144
- typeof crypto.randomUUID === 'function'
145
- ) {
146
- return crypto.randomUUID();
147
- }
148
- // Very small fallback; good enough for tests.
149
- return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
150
- }
151
-
152
- function isRecord(value: unknown): value is Record<string, unknown> {
153
- return typeof value === 'object' && value !== null && !Array.isArray(value);
154
- }
155
-
156
142
  function sanitizePayload(
157
143
  payload: Record<string, unknown>,
158
144
  args: { omit: string[] }
package/src/outbox.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { SyncOperation } from '@syncular/core';
6
+ import { isRecord, randomId } from '@syncular/core';
6
7
  import type { Kysely } from 'kysely';
7
8
  import { sql } from 'kysely';
8
9
  import type { OutboxCommitStatus, SyncClientDb } from './schema';
@@ -22,21 +23,6 @@ export interface OutboxCommit {
22
23
  schema_version: number;
23
24
  }
24
25
 
25
- function randomId(): string {
26
- if (
27
- typeof crypto !== 'undefined' &&
28
- typeof crypto.randomUUID === 'function'
29
- ) {
30
- return crypto.randomUUID();
31
- }
32
- // Very small fallback; good enough for tests.
33
- return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
34
- }
35
-
36
- function isRecord(value: unknown): value is Record<string, unknown> {
37
- return typeof value === 'object' && value !== null && !Array.isArray(value);
38
- }
39
-
40
26
  function isSyncOperation(value: unknown): value is SyncOperation {
41
27
  if (!isRecord(value)) return false;
42
28
  if (typeof value.table !== 'string') return false;