@trainstar/synchro-react-native 0.1.1 → 0.3.1

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 (62) hide show
  1. package/README.md +103 -24
  2. package/SynchroReactNative.podspec +3 -2
  3. package/android/build.gradle +8 -3
  4. package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +1105 -238
  5. package/ios/SynchroModule.mm +178 -51
  6. package/ios/SynchroModule.swift +1382 -295
  7. package/lib/module/NativeSynchro.js +5 -0
  8. package/lib/module/NativeSynchro.js.map +1 -1
  9. package/lib/module/SynchroClient.js +637 -65
  10. package/lib/module/SynchroClient.js.map +1 -1
  11. package/lib/module/digest.js +82 -0
  12. package/lib/module/digest.js.map +1 -0
  13. package/lib/module/errors.js +165 -22
  14. package/lib/module/errors.js.map +1 -1
  15. package/lib/module/hooks/useQuery.js +37 -3
  16. package/lib/module/hooks/useQuery.js.map +1 -1
  17. package/lib/module/hooks/useSyncStatus.js +51 -6
  18. package/lib/module/hooks/useSyncStatus.js.map +1 -1
  19. package/lib/module/index.js +2 -1
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/inspection.js +121 -0
  22. package/lib/module/inspection.js.map +1 -0
  23. package/lib/module/sqliteValues.js +87 -0
  24. package/lib/module/sqliteValues.js.map +1 -0
  25. package/lib/module/types.js +10 -0
  26. package/lib/module/types.js.map +1 -1
  27. package/lib/typescript/example/e2e/corpus-harness.d.ts +34 -0
  28. package/lib/typescript/example/e2e/corpus-harness.d.ts.map +1 -0
  29. package/lib/typescript/example/src/conformance/runner.d.ts +125 -0
  30. package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -0
  31. package/lib/typescript/example/src/conformance/types.d.ts +71 -0
  32. package/lib/typescript/example/src/conformance/types.d.ts.map +1 -0
  33. package/lib/typescript/src/NativeSynchro.d.ts +59 -17
  34. package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
  35. package/lib/typescript/src/SynchroClient.d.ts +27 -10
  36. package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
  37. package/lib/typescript/src/digest.d.ts +3 -0
  38. package/lib/typescript/src/digest.d.ts.map +1 -0
  39. package/lib/typescript/src/errors.d.ts +37 -9
  40. package/lib/typescript/src/errors.d.ts.map +1 -1
  41. package/lib/typescript/src/hooks/useQuery.d.ts +2 -2
  42. package/lib/typescript/src/hooks/useQuery.d.ts.map +1 -1
  43. package/lib/typescript/src/hooks/useSyncStatus.d.ts.map +1 -1
  44. package/lib/typescript/src/index.d.ts +4 -3
  45. package/lib/typescript/src/index.d.ts.map +1 -1
  46. package/lib/typescript/src/inspection.d.ts +43 -0
  47. package/lib/typescript/src/inspection.d.ts.map +1 -0
  48. package/lib/typescript/src/sqliteValues.d.ts +8 -0
  49. package/lib/typescript/src/sqliteValues.d.ts.map +1 -0
  50. package/lib/typescript/src/types.d.ts +203 -11
  51. package/lib/typescript/src/types.d.ts.map +1 -1
  52. package/package.json +20 -8
  53. package/src/NativeSynchro.ts +93 -26
  54. package/src/SynchroClient.ts +899 -120
  55. package/src/digest.ts +93 -0
  56. package/src/errors.ts +273 -33
  57. package/src/hooks/useQuery.ts +59 -16
  58. package/src/hooks/useSyncStatus.ts +40 -7
  59. package/src/index.ts +44 -3
  60. package/src/inspection.ts +226 -0
  61. package/src/sqliteValues.ts +105 -0
  62. package/src/types.ts +317 -16
package/src/digest.ts ADDED
@@ -0,0 +1,93 @@
1
+ /** Return the SHA-256 digest of a string as lowercase hexadecimal. */
2
+ export function sha256Hex(value: string): string {
3
+ const bytes = utf8(value);
4
+ const bitLength = bytes.length * 8;
5
+ bytes.push(0x80);
6
+ while ((bytes.length + 8) % 64 !== 0) {
7
+ bytes.push(0);
8
+ }
9
+ for (let shift = 56; shift >= 0; shift -= 8) {
10
+ bytes.push(Math.floor(bitLength / 2 ** shift) & 0xff);
11
+ }
12
+ const hash = [
13
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
14
+ 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
15
+ ];
16
+ const constants = [
17
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
18
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
19
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
20
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
21
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
22
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
23
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
24
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
25
+ ];
26
+ for (let offset = 0; offset < bytes.length; offset += 64) {
27
+ const words = new Array<number>(64).fill(0);
28
+ for (let index = 0; index < 16; index += 1) {
29
+ words[index] = ((bytes[offset + index * 4] << 24) | (bytes[offset + index * 4 + 1] << 16) | (bytes[offset + index * 4 + 2] << 8) | bytes[offset + index * 4 + 3]) >>> 0;
30
+ }
31
+ for (let index = 16; index < 64; index += 1) {
32
+ const a = words[index - 15];
33
+ const b = words[index - 2];
34
+ const s0 = rotateRight(a, 7) ^ rotateRight(a, 18) ^ (a >>> 3);
35
+ const s1 = rotateRight(b, 17) ^ rotateRight(b, 19) ^ (b >>> 10);
36
+ words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
37
+ }
38
+ let [a, b, c, d, e, f, g, h] = hash;
39
+ for (let index = 0; index < 64; index += 1) {
40
+ const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
41
+ const choice = (e & f) ^ (~e & g);
42
+ const temp1 = (h + s1 + choice + constants[index] + words[index]) >>> 0;
43
+ const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
44
+ const majority = (a & b) ^ (a & c) ^ (b & c);
45
+ const temp2 = (s0 + majority) >>> 0;
46
+ h = g;
47
+ g = f;
48
+ f = e;
49
+ e = (d + temp1) >>> 0;
50
+ d = c;
51
+ c = b;
52
+ b = a;
53
+ a = (temp1 + temp2) >>> 0;
54
+ }
55
+ hash[0] = (hash[0] + a) >>> 0;
56
+ hash[1] = (hash[1] + b) >>> 0;
57
+ hash[2] = (hash[2] + c) >>> 0;
58
+ hash[3] = (hash[3] + d) >>> 0;
59
+ hash[4] = (hash[4] + e) >>> 0;
60
+ hash[5] = (hash[5] + f) >>> 0;
61
+ hash[6] = (hash[6] + g) >>> 0;
62
+ hash[7] = (hash[7] + h) >>> 0;
63
+ }
64
+ return hash.map((word) => word.toString(16).padStart(8, '0')).join('');
65
+ }
66
+
67
+ function rotateRight(value: number, shift: number): number {
68
+ return (value >>> shift) | (value << (32 - shift));
69
+ }
70
+
71
+ function utf8(value: string): number[] {
72
+ const bytes: number[] = [];
73
+ for (let index = 0; index < value.length; index += 1) {
74
+ let code = value.charCodeAt(index);
75
+ if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
76
+ const low = value.charCodeAt(index + 1);
77
+ if (low >= 0xdc00 && low <= 0xdfff) {
78
+ code = 0x10000 + ((code - 0xd800) << 10) + low - 0xdc00;
79
+ index += 1;
80
+ }
81
+ }
82
+ if (code < 0x80) {
83
+ bytes.push(code);
84
+ } else if (code < 0x800) {
85
+ bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
86
+ } else if (code < 0x10000) {
87
+ bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
88
+ } else {
89
+ bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
90
+ }
91
+ }
92
+ return bytes;
93
+ }
package/src/errors.ts CHANGED
@@ -1,7 +1,47 @@
1
+ import {
2
+ PROTOCOL_ERROR_CODES,
3
+ SYNC_FAILURE_CODES,
4
+ SYNC_OPERATION_KINDS,
5
+ SYNC_RECOVERY_ACTIONS,
6
+ SYNC_STATUS_TYPES,
7
+ } from './types';
8
+ import type {
9
+ ProtocolErrorCode,
10
+ Row,
11
+ SyncFailure,
12
+ SyncStatusType,
13
+ } from './types';
14
+
15
+ export const NATIVE_ERROR_CODES = [
16
+ 'NOT_CONNECTED',
17
+ 'SCHEMA_NOT_LOADED',
18
+ 'TABLE_NOT_SYNCED',
19
+ 'UPGRADE_REQUIRED',
20
+ 'SCHEMA_MISMATCH',
21
+ 'PUSH_REJECTED',
22
+ 'NETWORK_ERROR',
23
+ 'SERVER_ERROR',
24
+ 'PROTOCOL_ERROR',
25
+ 'DATABASE_ERROR',
26
+ 'INVALID_RESPONSE',
27
+ 'INVALID_SEED',
28
+ 'SYNC_BLOCKED',
29
+ 'UNSUPPORTED_SCHEMA',
30
+ 'INVALID_STATE_TRANSITION',
31
+ 'ALREADY_STARTED',
32
+ 'NOT_STARTED',
33
+ 'TRANSACTION_TIMEOUT',
34
+ 'INVALID_CONFIG',
35
+ 'CLIENT_ALREADY_ACTIVE',
36
+ 'UNKNOWN',
37
+ ] as const;
38
+
39
+ export type NativeErrorCode = (typeof NATIVE_ERROR_CODES)[number];
40
+
1
41
  export class SynchroError extends Error {
2
- readonly code: string;
42
+ readonly code: NativeErrorCode;
3
43
 
4
- constructor(code: string, message: string) {
44
+ constructor(code: NativeErrorCode, message: string) {
5
45
  super(message);
6
46
  this.code = code;
7
47
  this.name = 'SynchroError';
@@ -62,25 +102,22 @@ export class SchemaMismatchError extends SynchroError {
62
102
  }
63
103
  }
64
104
 
65
- export class SnapshotRequiredError extends SynchroError {
66
- constructor() {
67
- super('SNAPSHOT_REQUIRED', 'Full snapshot required');
68
- this.name = 'SnapshotRequiredError';
69
- }
70
- }
71
-
72
- export interface PushResultItem {
73
- recordID: string;
105
+ export interface PushRejectedMutation {
106
+ mutationID: string;
74
107
  table: string;
108
+ pk: Row;
75
109
  status: string;
110
+ code: string;
76
111
  message?: string;
112
+ serverRow?: Row | null;
113
+ serverVersion?: string | null;
77
114
  }
78
115
 
79
116
  export class PushRejectedError extends SynchroError {
80
- readonly results: PushResultItem[];
117
+ readonly results: PushRejectedMutation[];
81
118
 
82
- constructor(results: PushResultItem[]) {
83
- super('PUSH_REJECTED', `Push rejected: ${results.length} record(s)`);
119
+ constructor(results: PushRejectedMutation[]) {
120
+ super('PUSH_REJECTED', `Push rejected: ${results.length} mutation(s)`);
84
121
  this.name = 'PushRejectedError';
85
122
  this.results = results;
86
123
  }
@@ -117,6 +154,64 @@ export class InvalidResponseError extends SynchroError {
117
154
  }
118
155
  }
119
156
 
157
+ export const SCHEMA_UNSUPPORTED_REASONS = [
158
+ 'unknown_schema_lineage',
159
+ 'incompatible_schema_transition',
160
+ ] as const;
161
+
162
+ export type SchemaUnsupportedReason = (typeof SCHEMA_UNSUPPORTED_REASONS)[number];
163
+
164
+ export class ProtocolError extends SynchroError {
165
+ readonly status: number;
166
+ readonly protocolCode: ProtocolErrorCode;
167
+
168
+ constructor(status: number, protocolCode: ProtocolErrorCode, message: string) {
169
+ super('PROTOCOL_ERROR', `Protocol error ${status} ${protocolCode}: ${message}`);
170
+ this.name = 'ProtocolError';
171
+ this.status = status;
172
+ this.protocolCode = protocolCode;
173
+ }
174
+ }
175
+
176
+ export class SyncBlockedError extends SynchroError {
177
+ readonly failure: SyncFailure;
178
+
179
+ constructor(failure: SyncFailure) {
180
+ super('SYNC_BLOCKED', failure.message);
181
+ this.name = 'SyncBlockedError';
182
+ this.failure = failure;
183
+ }
184
+ }
185
+
186
+ export class UnsupportedSchemaError extends SynchroError {
187
+ readonly reason: SchemaUnsupportedReason;
188
+
189
+ constructor(reason: SchemaUnsupportedReason) {
190
+ super('UNSUPPORTED_SCHEMA', `Schema recovery is required: ${reason}`);
191
+ this.name = 'UnsupportedSchemaError';
192
+ this.reason = reason;
193
+ }
194
+ }
195
+
196
+ export class InvalidStateTransitionError extends SynchroError {
197
+ readonly from: SyncStatusType;
198
+ readonly to: SyncStatusType;
199
+
200
+ constructor(from: SyncStatusType, to: SyncStatusType) {
201
+ super('INVALID_STATE_TRANSITION', `Invalid sync state transition from ${from} to ${to}`);
202
+ this.name = 'InvalidStateTransitionError';
203
+ this.from = from;
204
+ this.to = to;
205
+ }
206
+ }
207
+
208
+ export class InvalidSeedError extends SynchroError {
209
+ constructor() {
210
+ super('INVALID_SEED', 'Seed database failed validation');
211
+ this.name = 'InvalidSeedError';
212
+ }
213
+ }
214
+
120
215
  export class AlreadyStartedError extends SynchroError {
121
216
  constructor() {
122
217
  super('ALREADY_STARTED', 'Sync has already been started');
@@ -141,7 +236,104 @@ export class TransactionTimeoutError extends SynchroError {
141
236
  interface NativeErrorLike {
142
237
  code?: string;
143
238
  message?: string;
144
- userInfo?: Record<string, string>;
239
+ userInfo?: Record<string, unknown>;
240
+ }
241
+
242
+ function isRecord(value: unknown): value is Record<string, unknown> {
243
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
244
+ }
245
+
246
+ function parseJSONValue(value: unknown, name: string): unknown {
247
+ if (typeof value !== 'string') {
248
+ return value;
249
+ }
250
+ try {
251
+ return JSON.parse(value) as unknown;
252
+ } catch {
253
+ throw new InvalidResponseError(`Native bridge returned invalid ${name}`);
254
+ }
255
+ }
256
+
257
+ function requiredString(value: unknown, name: string): string {
258
+ if (typeof value !== 'string' || value.length === 0) {
259
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
260
+ }
261
+ return value;
262
+ }
263
+
264
+ function requiredBoolean(value: unknown, name: string): boolean {
265
+ if (typeof value === 'boolean') {
266
+ return value;
267
+ }
268
+ if (value === 'true') {
269
+ return true;
270
+ }
271
+ if (value === 'false') {
272
+ return false;
273
+ }
274
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
275
+ }
276
+
277
+ function optionalString(value: unknown): string {
278
+ return typeof value === 'string' ? value : '';
279
+ }
280
+
281
+ function requiredEnum<T extends string>(value: unknown, values: readonly T[], name: string): T {
282
+ if (typeof value === 'string' && values.includes(value as T)) {
283
+ return value as T;
284
+ }
285
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
286
+ }
287
+
288
+ export function parseSyncFailure(value: unknown): SyncFailure {
289
+ const parsed = parseJSONValue(value, 'sync failure');
290
+ if (!isRecord(parsed)) {
291
+ throw new InvalidResponseError('Native bridge returned an invalid sync failure');
292
+ }
293
+
294
+ const message = requiredString(parsed.message, 'sync failure message');
295
+ if (message.length > 256) {
296
+ throw new InvalidResponseError('Native bridge returned an invalid sync failure message');
297
+ }
298
+
299
+ const metadataValue = parsed.metadata == null ? {} : parseJSONValue(parsed.metadata, 'sync failure metadata');
300
+ if (!isRecord(metadataValue) || Object.keys(metadataValue).length > 8) {
301
+ throw new InvalidResponseError('Native bridge returned invalid sync failure metadata');
302
+ }
303
+ const metadata: Record<string, string> = {};
304
+ Object.entries(metadataValue).forEach(([key, item]) => {
305
+ if (key.length === 0 || key.length > 64 || typeof item !== 'string' || item.length > 128) {
306
+ throw new InvalidResponseError('Native bridge returned invalid sync failure metadata');
307
+ }
308
+ metadata[key] = item;
309
+ });
310
+
311
+ return {
312
+ operation: requiredEnum(parsed.operation, SYNC_OPERATION_KINDS, 'sync failure operation'),
313
+ code: requiredEnum(parsed.code, SYNC_FAILURE_CODES, 'sync failure code'),
314
+ retryable: requiredBoolean(parsed.retryable, 'sync failure retry flag'),
315
+ message,
316
+ recoveryAction: requiredEnum(
317
+ parsed.recoveryAction,
318
+ SYNC_RECOVERY_ACTIONS,
319
+ 'sync failure recovery action'
320
+ ),
321
+ metadata,
322
+ };
323
+ }
324
+
325
+ function syncFailureFromUserInfo(userInfo: Record<string, unknown>, fallbackMessage: string): SyncFailure {
326
+ if (userInfo.failure != null) {
327
+ return parseSyncFailure(userInfo.failure);
328
+ }
329
+ return parseSyncFailure({
330
+ operation: userInfo.failureOperation,
331
+ code: userInfo.failureCode,
332
+ retryable: userInfo.failureRetryable,
333
+ message: userInfo.failureMessage ?? fallbackMessage,
334
+ recoveryAction: userInfo.failureRecoveryAction,
335
+ metadata: userInfo.failureMetadata ?? {},
336
+ });
145
337
  }
146
338
 
147
339
  export function mapNativeError(error: unknown): SynchroError {
@@ -160,46 +352,94 @@ export function mapNativeError(error: unknown): SynchroError {
160
352
  case 'SCHEMA_NOT_LOADED':
161
353
  return new SchemaNotLoadedError();
162
354
  case 'TABLE_NOT_SYNCED':
163
- return new TableNotSyncedError(userInfo.table ?? '');
355
+ return new TableNotSyncedError(optionalString(userInfo.table));
164
356
  case 'UPGRADE_REQUIRED':
165
357
  return new UpgradeRequiredError(
166
- userInfo.currentVersion ?? '',
167
- userInfo.minimumVersion ?? ''
358
+ optionalString(userInfo.currentVersion),
359
+ optionalString(userInfo.minimumVersion)
168
360
  );
169
361
  case 'SCHEMA_MISMATCH':
170
362
  return new SchemaMismatchError(
171
- parseInt(userInfo.serverVersion ?? '0', 10),
172
- userInfo.serverHash ?? ''
363
+ parseInt(optionalString(userInfo.serverVersion), 10),
364
+ optionalString(userInfo.serverHash)
173
365
  );
174
- case 'SNAPSHOT_REQUIRED':
175
- return new SnapshotRequiredError();
176
366
  case 'PUSH_REJECTED': {
177
- let results: PushResultItem[] = [];
178
367
  try {
179
- results = JSON.parse(userInfo.results ?? '[]');
180
- } catch {
181
- // leave empty
368
+ const parsed = parseJSONValue(userInfo.results ?? [], 'push rejection results');
369
+ if (!Array.isArray(parsed)) {
370
+ return new InvalidResponseError('Native bridge returned invalid push rejection results');
371
+ }
372
+ return new PushRejectedError(parsed as PushRejectedMutation[]);
373
+ } catch (parseError) {
374
+ return parseError instanceof SynchroError
375
+ ? parseError
376
+ : new InvalidResponseError('Native bridge returned invalid push rejection results');
182
377
  }
183
- return new PushRejectedError(results);
184
378
  }
185
379
  case 'NETWORK_ERROR':
186
- return new NetworkError(userInfo.message ?? message);
380
+ return new NetworkError(typeof userInfo.message === 'string' ? userInfo.message : message);
187
381
  case 'SERVER_ERROR':
188
382
  return new ServerError(
189
- parseInt(userInfo.status ?? '0', 10),
190
- userInfo.message ?? message
383
+ Number.parseInt(String(userInfo.status ?? '0'), 10),
384
+ typeof userInfo.message === 'string' ? userInfo.message : message
385
+ );
386
+ case 'PROTOCOL_ERROR': {
387
+ const status = Number.parseInt(String(userInfo.status ?? '0'), 10);
388
+ const protocolCode = requiredEnum(
389
+ userInfo.protocolCode,
390
+ PROTOCOL_ERROR_CODES,
391
+ 'protocol error code'
191
392
  );
393
+ return new ProtocolError(
394
+ status,
395
+ protocolCode,
396
+ typeof userInfo.message === 'string' ? userInfo.message : message
397
+ );
398
+ }
192
399
  case 'DATABASE_ERROR':
193
- return new DatabaseError(userInfo.message ?? message);
400
+ return new DatabaseError(typeof userInfo.message === 'string' ? userInfo.message : message);
194
401
  case 'INVALID_RESPONSE':
195
- return new InvalidResponseError(userInfo.message ?? message);
402
+ return new InvalidResponseError(typeof userInfo.message === 'string' ? userInfo.message : message);
403
+ case 'INVALID_SEED':
404
+ return new InvalidSeedError();
405
+ case 'SYNC_BLOCKED':
406
+ try {
407
+ return new SyncBlockedError(syncFailureFromUserInfo(userInfo, message));
408
+ } catch (parseError) {
409
+ return parseError instanceof SynchroError
410
+ ? parseError
411
+ : new InvalidResponseError('Native bridge returned invalid blocking failure details');
412
+ }
413
+ case 'UNSUPPORTED_SCHEMA':
414
+ try {
415
+ return new UnsupportedSchemaError(
416
+ requiredEnum(userInfo.reason, SCHEMA_UNSUPPORTED_REASONS, 'unsupported schema reason')
417
+ );
418
+ } catch (parseError) {
419
+ return parseError instanceof SynchroError
420
+ ? parseError
421
+ : new InvalidResponseError('Native bridge returned invalid unsupported schema details');
422
+ }
423
+ case 'INVALID_STATE_TRANSITION':
424
+ try {
425
+ return new InvalidStateTransitionError(
426
+ requiredEnum(userInfo.from, SYNC_STATUS_TYPES, 'state transition source'),
427
+ requiredEnum(userInfo.to, SYNC_STATUS_TYPES, 'state transition target')
428
+ );
429
+ } catch (parseError) {
430
+ return parseError instanceof SynchroError
431
+ ? parseError
432
+ : new InvalidResponseError('Native bridge returned invalid state transition details');
433
+ }
196
434
  case 'ALREADY_STARTED':
197
435
  return new AlreadyStartedError();
198
436
  case 'NOT_STARTED':
199
437
  return new NotStartedError();
200
438
  case 'TRANSACTION_TIMEOUT':
201
439
  return new TransactionTimeoutError();
440
+ case 'INVALID_CONFIG':
441
+ return new SynchroError('INVALID_CONFIG', message);
202
442
  default:
203
- return new SynchroError(code ?? 'UNKNOWN', message);
443
+ return new SynchroError('UNKNOWN', message);
204
444
  }
205
445
  }
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useRef } from 'react';
2
2
  import type { SynchroClient } from '../SynchroClient';
3
- import type { Row } from '../types';
3
+ import type { AsyncUnsubscribe, Row, SQLiteBindValue } from '../types';
4
4
  import type { SynchroError } from '../errors';
5
5
 
6
6
  interface UseQueryResult {
@@ -10,20 +10,41 @@ interface UseQueryResult {
10
10
  refresh: () => void;
11
11
  }
12
12
 
13
- function arraysEqual<T>(
14
- a?: readonly T[],
15
- b?: readonly T[]
13
+ function arraysEqual(
14
+ a?: readonly SQLiteBindValue[],
15
+ b?: readonly SQLiteBindValue[]
16
16
  ): boolean {
17
17
  if (a === b) return true;
18
18
  if (!a || !b) return !a && !b;
19
19
  if (a.length !== b.length) return false;
20
20
  for (let i = 0; i < a.length; i += 1) {
21
- if (a[i] !== b[i]) return false;
21
+ const left = a[i];
22
+ const right = b[i];
23
+ if (left === right) continue;
24
+ if (
25
+ typeof left !== 'object' || left === null ||
26
+ typeof right !== 'object' || right === null
27
+ ) return false;
28
+ const leftKeys = Object.keys(left);
29
+ const rightKeys = Object.keys(right);
30
+ if (
31
+ leftKeys.length !== 2 || rightKeys.length !== 2 ||
32
+ !leftKeys.includes('type') || !rightKeys.includes('type')
33
+ ) return false;
34
+ if (left.type === 'int64' && right.type === 'int64') {
35
+ if (!leftKeys.includes('value') || !rightKeys.includes('value')) return false;
36
+ if (typeof left.value !== 'string' || left.value !== right.value) return false;
37
+ } else if (left.type === 'bytes' && right.type === 'bytes') {
38
+ if (!leftKeys.includes('base64') || !rightKeys.includes('base64')) return false;
39
+ if (typeof left.base64 !== 'string' || left.base64 !== right.base64) return false;
40
+ } else {
41
+ return false;
42
+ }
22
43
  }
23
44
  return true;
24
45
  }
25
46
 
26
- function useStableArray<T>(value?: readonly T[]): readonly T[] | undefined {
47
+ function useStableArray<T extends SQLiteBindValue>(value?: readonly T[]): readonly T[] | undefined {
27
48
  const ref = useRef<readonly T[] | undefined>(value);
28
49
  if (!arraysEqual(ref.current, value)) {
29
50
  ref.current = value ? [...value] : value;
@@ -34,7 +55,7 @@ function useStableArray<T>(value?: readonly T[]): readonly T[] | undefined {
34
55
  export function useQuery(
35
56
  client: SynchroClient,
36
57
  sql: string,
37
- params?: unknown[],
58
+ params?: SQLiteBindValue[],
38
59
  tables?: string[]
39
60
  ): UseQueryResult {
40
61
  const [data, setData] = useState<Row[]>([]);
@@ -55,21 +76,43 @@ export function useQuery(
55
76
  // Reactive mode: use watch()
56
77
  setLoading(true);
57
78
  let firstResult = true;
58
- const unsubscribe = client.watch(sql, stableParams as unknown[] | undefined, stableTables as string[], (rows) => {
59
- setData(rows);
60
- setError(null);
61
- if (firstResult) {
62
- setLoading(false);
63
- firstResult = false;
79
+ let disposed = false;
80
+ let unsubscribe: AsyncUnsubscribe | null = null;
81
+ client
82
+ .watch(sql, stableParams, stableTables as string[], (rows) => {
83
+ if (disposed) return;
84
+ setData(rows);
85
+ setError(null);
86
+ if (firstResult) {
87
+ setLoading(false);
88
+ firstResult = false;
89
+ }
90
+ })
91
+ .then((remove) => {
92
+ if (disposed) {
93
+ void remove().catch(() => {});
94
+ } else {
95
+ unsubscribe = remove;
96
+ }
97
+ })
98
+ .catch((err) => {
99
+ if (!disposed) {
100
+ setError(err);
101
+ setLoading(false);
102
+ }
103
+ });
104
+ return () => {
105
+ disposed = true;
106
+ if (unsubscribe) {
107
+ void unsubscribe().catch(() => {});
64
108
  }
65
- });
66
- return unsubscribe;
109
+ };
67
110
  } else {
68
111
  // One-shot mode: use query()
69
112
  let cancelled = false;
70
113
  setLoading(true);
71
114
  client
72
- .query(sql, stableParams as unknown[] | undefined)
115
+ .query(sql, stableParams)
73
116
  .then((rows) => {
74
117
  if (!cancelled) {
75
118
  setData(rows);
@@ -1,17 +1,50 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import type { SynchroClient } from '../SynchroClient';
3
3
  import type { SyncStatus } from '../types';
4
+ import { mapNativeError, type SynchroError } from '../errors';
5
+
6
+ const uninitialized: SyncStatus = {
7
+ status: 'uninitialized',
8
+ retryAt: null,
9
+ operation: null,
10
+ failure: null,
11
+ };
4
12
 
5
13
  export function useSyncStatus(client: SynchroClient): SyncStatus {
6
- const [status, setStatus] = useState<SyncStatus>({
7
- status: 'idle',
8
- retryAt: null,
9
- });
14
+ const [snapshot, setSnapshot] = useState<{
15
+ client: SynchroClient;
16
+ status: SyncStatus;
17
+ error: SynchroError | null;
18
+ }>({ client, status: uninitialized, error: null });
10
19
 
11
20
  useEffect(() => {
12
- const unsubscribe = client.onStatusChange(setStatus);
13
- return unsubscribe;
21
+ let active = true;
22
+ let receivedEvent = false;
23
+ setSnapshot({ client, status: uninitialized, error: null });
24
+ const unsubscribe = client.onStatusChange((status) => {
25
+ if (!active) return;
26
+ receivedEvent = true;
27
+ setSnapshot({ client, status, error: null });
28
+ });
29
+ client.getSyncStatus().then(
30
+ (status) => {
31
+ if (active && !receivedEvent) {
32
+ setSnapshot({ client, status, error: null });
33
+ }
34
+ },
35
+ (error: unknown) => {
36
+ if (active && !receivedEvent) {
37
+ setSnapshot({ client, status: uninitialized, error: mapNativeError(error) });
38
+ }
39
+ }
40
+ );
41
+ return () => {
42
+ active = false;
43
+ unsubscribe();
44
+ };
14
45
  }, [client]);
15
46
 
16
- return status;
47
+ if (snapshot.client !== client) return uninitialized;
48
+ if (snapshot.error) throw snapshot.error;
49
+ return snapshot.status;
17
50
  }