@syncular/client 0.0.6-126 → 0.0.6-136

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 (49) hide show
  1. package/dist/blobs/index.d.ts +0 -1
  2. package/dist/blobs/index.d.ts.map +1 -1
  3. package/dist/blobs/index.js +0 -1
  4. package/dist/blobs/index.js.map +1 -1
  5. package/dist/client.d.ts +21 -0
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +12 -0
  8. package/dist/client.js.map +1 -1
  9. package/dist/create-client.d.ts +17 -0
  10. package/dist/create-client.d.ts.map +1 -1
  11. package/dist/create-client.js +3 -0
  12. package/dist/create-client.js.map +1 -1
  13. package/dist/engine/SyncEngine.d.ts +11 -0
  14. package/dist/engine/SyncEngine.d.ts.map +1 -1
  15. package/dist/engine/SyncEngine.js +181 -27
  16. package/dist/engine/SyncEngine.js.map +1 -1
  17. package/dist/engine/types.d.ts +17 -0
  18. package/dist/engine/types.d.ts.map +1 -1
  19. package/dist/migrate.d.ts +1 -1
  20. package/dist/migrate.d.ts.map +1 -1
  21. package/dist/migrate.js +0 -126
  22. package/dist/migrate.js.map +1 -1
  23. package/dist/mutations.d.ts.map +1 -1
  24. package/dist/mutations.js +9 -1
  25. package/dist/mutations.js.map +1 -1
  26. package/dist/query/fingerprint.d.ts +1 -1
  27. package/dist/query/fingerprint.d.ts.map +1 -1
  28. package/dist/query/fingerprint.js +29 -6
  29. package/dist/query/fingerprint.js.map +1 -1
  30. package/dist/sync-loop.d.ts.map +1 -1
  31. package/dist/sync-loop.js +29 -19
  32. package/dist/sync-loop.js.map +1 -1
  33. package/package.json +3 -3
  34. package/src/blobs/index.ts +0 -1
  35. package/src/client.ts +37 -0
  36. package/src/create-client.ts +21 -0
  37. package/src/engine/SyncEngine.test.ts +257 -0
  38. package/src/engine/SyncEngine.ts +214 -27
  39. package/src/engine/types.ts +17 -0
  40. package/src/migrate.ts +1 -190
  41. package/src/mutations.ts +9 -1
  42. package/src/query/fingerprint.test.ts +73 -0
  43. package/src/query/fingerprint.ts +33 -6
  44. package/src/sync-loop.ts +29 -19
  45. package/dist/blobs/manager.d.ts +0 -345
  46. package/dist/blobs/manager.d.ts.map +0 -1
  47. package/dist/blobs/manager.js +0 -749
  48. package/dist/blobs/manager.js.map +0 -1
  49. package/src/blobs/manager.ts +0 -1027
@@ -262,6 +262,23 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
262
262
  onConflict?: (conflict: ConflictInfo) => void;
263
263
  /** Data change callback */
264
264
  onDataChange?: (scopes: string[]) => void;
265
+ /**
266
+ * Debounce window for coalescing `data:change` emissions.
267
+ * - `0` (default): emit immediately
268
+ * - `>0`: merge scopes and emit once per window
269
+ */
270
+ dataChangeDebounceMs?: number;
271
+ /**
272
+ * Override debounce window while `isSyncing === true`.
273
+ * If omitted, `dataChangeDebounceMs` is used.
274
+ */
275
+ dataChangeDebounceMsWhenSyncing?: number;
276
+ /**
277
+ * Override debounce window while `connectionState === "reconnecting"`.
278
+ * If omitted, `dataChangeDebounceMsWhenSyncing` (if syncing) or
279
+ * `dataChangeDebounceMs` is used.
280
+ */
281
+ dataChangeDebounceMsWhenReconnecting?: number;
265
282
  /** Optional client plugins (e.g. encryption) */
266
283
  plugins?: SyncClientPlugin[];
267
284
  /** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
package/src/migrate.ts CHANGED
@@ -2,195 +2,9 @@
2
2
  * @syncular/client - Sync migrations (SQLite reference)
3
3
  */
4
4
 
5
- import { type Kysely, sql } from 'kysely';
5
+ import type { Kysely } from 'kysely';
6
6
  import type { SyncClientDb } from './schema';
7
7
 
8
- type SyncInternalTable =
9
- | 'sync_subscription_state'
10
- | 'sync_outbox_commits'
11
- | 'sync_conflicts';
12
-
13
- function toErrorMessage(error: unknown): string {
14
- return error instanceof Error ? error.message : String(error);
15
- }
16
-
17
- function isMissingTableError(message: string): boolean {
18
- const normalized = message.toLowerCase();
19
- return (
20
- normalized.includes('no such table') ||
21
- (normalized.includes('relation') && normalized.includes('does not exist'))
22
- );
23
- }
24
-
25
- function isMissingColumnError(message: string): boolean {
26
- const normalized = message.toLowerCase();
27
- return (
28
- normalized.includes('no such column') ||
29
- (normalized.includes('column') && normalized.includes('does not exist'))
30
- );
31
- }
32
-
33
- function isDuplicateColumnError(message: string): boolean {
34
- const normalized = message.toLowerCase();
35
- return (
36
- normalized.includes('duplicate column name') ||
37
- (normalized.includes('column') && normalized.includes('already exists'))
38
- );
39
- }
40
-
41
- async function getColumnNames<DB extends SyncClientDb>(
42
- db: Kysely<DB>,
43
- tableName: SyncInternalTable
44
- ): Promise<Set<string> | null> {
45
- try {
46
- const sqlite = await sql<{ name: string }>`
47
- select name from pragma_table_info(${sql.val(tableName)})
48
- `.execute(db);
49
- return new Set(sqlite.rows.map((row) => String(row.name)));
50
- } catch {
51
- // Not SQLite or pragma unavailable.
52
- }
53
-
54
- try {
55
- const postgres = await sql<{ name: string }>`
56
- select column_name as name
57
- from information_schema.columns
58
- where table_name = ${sql.val(tableName)}
59
- `.execute(db);
60
- return new Set(postgres.rows.map((row) => String(row.name)));
61
- } catch {
62
- // Introspection unavailable; caller falls back to probing.
63
- }
64
-
65
- return null;
66
- }
67
-
68
- async function hasColumn<DB extends SyncClientDb>(
69
- db: Kysely<DB>,
70
- tableName: SyncInternalTable,
71
- columnName: string
72
- ): Promise<boolean> {
73
- const columns = await getColumnNames(db, tableName);
74
- if (columns) {
75
- return columns.has(columnName);
76
- }
77
-
78
- try {
79
- await sql`select ${sql.ref(columnName)} from ${sql.table(tableName)} limit 1`.execute(
80
- db
81
- );
82
- return true;
83
- } catch (error) {
84
- const message = toErrorMessage(error);
85
- if (isMissingTableError(message) || isMissingColumnError(message)) {
86
- return false;
87
- }
88
- throw error;
89
- }
90
- }
91
-
92
- async function addColumnIfMissing<DB extends SyncClientDb>(
93
- db: Kysely<DB>,
94
- tableName: SyncInternalTable,
95
- columnName: string,
96
- addColumn: () => Promise<void>
97
- ): Promise<void> {
98
- if (await hasColumn(db, tableName, columnName)) {
99
- return;
100
- }
101
- try {
102
- await addColumn();
103
- } catch (error) {
104
- const message = toErrorMessage(error);
105
- if (isDuplicateColumnError(message)) {
106
- return;
107
- }
108
- throw error;
109
- }
110
- }
111
-
112
- async function ensureClientSyncSchemaCompat<DB extends SyncClientDb>(
113
- db: Kysely<DB>
114
- ): Promise<void> {
115
- const hasTableColumn = await hasColumn(
116
- db,
117
- 'sync_subscription_state',
118
- 'table'
119
- );
120
- if (
121
- !hasTableColumn &&
122
- (await hasColumn(db, 'sync_subscription_state', 'shape'))
123
- ) {
124
- try {
125
- await sql`alter table ${sql.table('sync_subscription_state')} rename column ${sql.ref('shape')} to ${sql.ref('table')}`.execute(
126
- db
127
- );
128
- } catch {
129
- await addColumnIfMissing(
130
- db,
131
- 'sync_subscription_state',
132
- 'table',
133
- async () => {
134
- await db.schema
135
- .alterTable('sync_subscription_state')
136
- .addColumn('table', 'text', (col) => col.notNull().defaultTo(''))
137
- .execute();
138
- }
139
- );
140
- await sql`update ${sql.table('sync_subscription_state')}
141
- set ${sql.ref('table')} = ${sql.ref('shape')}
142
- where ${sql.ref('table')} = ${sql.val('')}`.execute(db);
143
- }
144
- }
145
-
146
- await addColumnIfMissing(
147
- db,
148
- 'sync_subscription_state',
149
- 'bootstrap_state_json',
150
- async () => {
151
- await db.schema
152
- .alterTable('sync_subscription_state')
153
- .addColumn('bootstrap_state_json', 'text')
154
- .execute();
155
- }
156
- );
157
-
158
- await addColumnIfMissing(
159
- db,
160
- 'sync_outbox_commits',
161
- 'schema_version',
162
- async () => {
163
- await db.schema
164
- .alterTable('sync_outbox_commits')
165
- .addColumn('schema_version', 'integer', (col) =>
166
- col.notNull().defaultTo(1)
167
- )
168
- .execute();
169
- }
170
- );
171
-
172
- await addColumnIfMissing(db, 'sync_conflicts', 'resolved_at', async () => {
173
- await db.schema
174
- .alterTable('sync_conflicts')
175
- .addColumn('resolved_at', 'bigint')
176
- .execute();
177
- });
178
-
179
- await addColumnIfMissing(db, 'sync_conflicts', 'resolution', async () => {
180
- await db.schema
181
- .alterTable('sync_conflicts')
182
- .addColumn('resolution', 'text')
183
- .execute();
184
- });
185
-
186
- await db.schema
187
- .createIndex('idx_sync_outbox_commits_status_updated_at')
188
- .ifNotExists()
189
- .on('sync_outbox_commits')
190
- .columns(['status', 'updated_at', 'created_at'])
191
- .execute();
192
- }
193
-
194
8
  /**
195
9
  * Ensures the client sync schema exists in the database.
196
10
  * Safe to call multiple times (idempotent).
@@ -249,9 +63,6 @@ export async function ensureClientSyncSchema<DB extends SyncClientDb>(
249
63
  .addColumn('resolution', 'text')
250
64
  .execute();
251
65
 
252
- // Apply framework-managed compatibility upgrades for legacy sync tables.
253
- await ensureClientSyncSchemaCompat(db);
254
-
255
66
  await db.schema
256
67
  .createIndex('idx_sync_subscription_state_state_sub')
257
68
  .ifNotExists()
package/src/mutations.ts CHANGED
@@ -168,7 +168,7 @@ function coerceBaseVersion(value: unknown): number | null {
168
168
  if (value === null || value === undefined) return null;
169
169
  const n = typeof value === 'number' ? value : Number(value);
170
170
  if (!Number.isFinite(n)) return null;
171
- if (n <= 0) return null;
171
+ if (n < 0) return null;
172
172
  return n;
173
173
  }
174
174
 
@@ -498,6 +498,9 @@ export function createOutboxCommit<DB extends SyncClientDb>(
498
498
  },
499
499
 
500
500
  async insertMany(rows) {
501
+ if (rows.length === 0) {
502
+ throw new Error('insertMany requires at least one row');
503
+ }
501
504
  const ids: string[] = [];
502
505
  const toInsert: Record<string, unknown>[] = [];
503
506
 
@@ -672,6 +675,7 @@ export function createOutboxCommit<DB extends SyncClientDb>(
672
675
  get(_target, prop) {
673
676
  if (prop === 'then') return undefined;
674
677
  if (typeof prop !== 'string') return undefined;
678
+ validateTableName(prop);
675
679
  return makeTxTable(prop);
676
680
  },
677
681
  }
@@ -777,6 +781,9 @@ export function createPushCommit<DB = AnyDb>(
777
781
  },
778
782
 
779
783
  async insertMany(rows) {
784
+ if (rows.length === 0) {
785
+ throw new Error('insertMany requires at least one row');
786
+ }
780
787
  const ids: string[] = [];
781
788
  const toUpsert: Record<string, unknown>[] = [];
782
789
 
@@ -885,6 +892,7 @@ export function createPushCommit<DB = AnyDb>(
885
892
  get(_target, prop) {
886
893
  if (prop === 'then') return undefined;
887
894
  if (typeof prop !== 'string') return undefined;
895
+ validateTableName(prop);
888
896
  return makeTxTable(prop);
889
897
  },
890
898
  }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { computeRowFingerprint } from './fingerprint';
3
+
4
+ function createTimestampSource(values: Record<string, number>): {
5
+ getMutationTimestamp: (table: string, rowId: string) => number;
6
+ } {
7
+ return {
8
+ getMutationTimestamp(table: string, rowId: string): number {
9
+ return values[`${table}:${rowId}`] ?? 0;
10
+ },
11
+ };
12
+ }
13
+
14
+ describe('computeRowFingerprint', () => {
15
+ it('is deterministic for the same input rows', () => {
16
+ const source = createTimestampSource({
17
+ 'tasks:a': 1,
18
+ 'tasks:b': 2,
19
+ });
20
+ const rows = [{ id: 'a' }, { id: 'b' }];
21
+
22
+ const first = computeRowFingerprint(rows, 'tasks', source, 'id');
23
+ const second = computeRowFingerprint(rows, 'tasks', source, 'id');
24
+
25
+ expect(first).toBe(second);
26
+ });
27
+
28
+ it('changes when row order changes', () => {
29
+ const source = createTimestampSource({
30
+ 'tasks:a': 1,
31
+ 'tasks:b': 2,
32
+ });
33
+
34
+ const first = computeRowFingerprint(
35
+ [{ id: 'a' }, { id: 'b' }],
36
+ 'tasks',
37
+ source,
38
+ 'id'
39
+ );
40
+ const second = computeRowFingerprint(
41
+ [{ id: 'b' }, { id: 'a' }],
42
+ 'tasks',
43
+ source,
44
+ 'id'
45
+ );
46
+
47
+ expect(first).not.toBe(second);
48
+ });
49
+
50
+ it('changes when mutation timestamps change', () => {
51
+ const rows = [{ id: 'a' }];
52
+ const first = computeRowFingerprint(
53
+ rows,
54
+ 'tasks',
55
+ createTimestampSource({ 'tasks:a': 1 }),
56
+ 'id'
57
+ );
58
+ const second = computeRowFingerprint(
59
+ rows,
60
+ 'tasks',
61
+ createTimestampSource({ 'tasks:a': 2 }),
62
+ 'id'
63
+ );
64
+
65
+ expect(first).not.toBe(second);
66
+ });
67
+
68
+ it('returns compact hash format', () => {
69
+ const source = createTimestampSource({});
70
+ const fingerprint = computeRowFingerprint([], 'tasks', source, 'id');
71
+ expect(fingerprint).toMatch(/^tasks:0:[0-9a-f]{8}$/);
72
+ });
73
+ });
@@ -9,6 +9,32 @@ export interface MutationTimestampSource {
9
9
  getMutationTimestamp(table: string, rowId: string): number;
10
10
  }
11
11
 
12
+ const FNV_OFFSET_BASIS = 0x811c9dc5;
13
+ const FNV_PRIME = 0x01000193;
14
+
15
+ function hashMix(hash: number, value: number): number {
16
+ return Math.imul(hash ^ value, FNV_PRIME) >>> 0;
17
+ }
18
+
19
+ function hashString(hash: number, value: string): number {
20
+ let next = hash;
21
+ for (let i = 0; i < value.length; i++) {
22
+ next = hashMix(next, value.charCodeAt(i));
23
+ }
24
+ return next;
25
+ }
26
+
27
+ function hashTimestamp(hash: number, value: number): number {
28
+ if (!Number.isFinite(value)) {
29
+ return hashMix(hash, 0);
30
+ }
31
+ // Keep three decimal places to preserve sub-millisecond precision.
32
+ const scaled = Math.round(value * 1000);
33
+ const lowBits = scaled >>> 0;
34
+ const highBits = Math.floor(scaled / 0x1_0000_0000) >>> 0;
35
+ return hashMix(hashMix(hash, lowBits), highBits);
36
+ }
37
+
12
38
  /**
13
39
  * Compute a fingerprint for query results based on length + ids + mutation timestamps.
14
40
  * Much faster than deep equality for large datasets.
@@ -69,7 +95,7 @@ export function canFingerprint<T>(rows: T[], keyField = 'id'): boolean {
69
95
 
70
96
  /**
71
97
  * Compute row-level fingerprint from query results.
72
- * Format: `table:count:id1@ts1,id2@ts2,...`
98
+ * Format: `table:count:hash`
73
99
  */
74
100
  export function computeRowFingerprint(
75
101
  rows: unknown[],
@@ -77,17 +103,18 @@ export function computeRowFingerprint(
77
103
  engine: MutationTimestampSource,
78
104
  keyField: string
79
105
  ): string {
80
- if (rows.length === 0) return `${table}:0:`;
81
-
82
- const parts: string[] = [];
106
+ let hash = hashMix(FNV_OFFSET_BASIS, rows.length);
83
107
  for (const row of rows) {
84
108
  const r = row as Record<string, unknown>;
85
109
  const id = String(r[keyField] ?? '');
86
110
  const ts = engine.getMutationTimestamp(table, id);
87
- parts.push(`${id}@${ts}`);
111
+ hash = hashString(hash, id);
112
+ hash = hashMix(hash, 0); // separator
113
+ hash = hashTimestamp(hash, ts);
114
+ hash = hashMix(hash, 1); // separator
88
115
  }
89
116
 
90
- return `${table}:${rows.length}:${parts.join(',')}`;
117
+ return `${table}:${rows.length}:${hash.toString(16).padStart(8, '0')}`;
91
118
  }
92
119
 
93
120
  /**
package/src/sync-loop.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type {
8
+ SyncCombinedResponse,
8
9
  SyncPullResponse,
9
10
  SyncPullSubscriptionResponse,
10
11
  SyncPushRequest,
@@ -244,25 +245,34 @@ async function syncOnceCombined<DB extends SyncClientDb>(
244
245
  }
245
246
  }
246
247
 
247
- const combined = await transport.sync({
248
- clientId,
249
- ...(pushRequest && !wsPushResponse
250
- ? {
251
- push: {
252
- clientCommitId: pushRequest.clientCommitId,
253
- operations: pushRequest.operations,
254
- schemaVersion: pushRequest.schemaVersion,
255
- },
256
- }
257
- : {}),
258
- pull: {
259
- limitCommits: pullState.request.limitCommits,
260
- limitSnapshotRows: pullState.request.limitSnapshotRows,
261
- maxSnapshotPages: pullState.request.maxSnapshotPages,
262
- dedupeRows: pullState.request.dedupeRows,
263
- subscriptions: pullState.request.subscriptions,
264
- },
265
- });
248
+ let combined: SyncCombinedResponse;
249
+ try {
250
+ combined = await transport.sync({
251
+ clientId,
252
+ ...(pushRequest && !wsPushResponse
253
+ ? {
254
+ push: {
255
+ clientCommitId: pushRequest.clientCommitId,
256
+ operations: pushRequest.operations,
257
+ schemaVersion: pushRequest.schemaVersion,
258
+ },
259
+ }
260
+ : {}),
261
+ pull: {
262
+ limitCommits: pullState.request.limitCommits,
263
+ limitSnapshotRows: pullState.request.limitSnapshotRows,
264
+ maxSnapshotPages: pullState.request.maxSnapshotPages,
265
+ dedupeRows: pullState.request.dedupeRows,
266
+ subscriptions: pullState.request.subscriptions,
267
+ },
268
+ });
269
+ } catch (err) {
270
+ if (outbox) {
271
+ const message = err instanceof Error ? err.message : 'Unknown error';
272
+ await markOutboxCommitPending(db, { id: outbox.id, error: message });
273
+ }
274
+ throw err;
275
+ }
266
276
 
267
277
  // Process push response
268
278
  let pushedCommits = 0;