@trainstar/synchro-react-native 0.1.2 → 0.3.2

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 (64) 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 +1106 -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/example/src/timeouts.d.ts +2 -0
  34. package/lib/typescript/example/src/timeouts.d.ts.map +1 -0
  35. package/lib/typescript/src/NativeSynchro.d.ts +59 -17
  36. package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
  37. package/lib/typescript/src/SynchroClient.d.ts +27 -10
  38. package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
  39. package/lib/typescript/src/digest.d.ts +3 -0
  40. package/lib/typescript/src/digest.d.ts.map +1 -0
  41. package/lib/typescript/src/errors.d.ts +37 -9
  42. package/lib/typescript/src/errors.d.ts.map +1 -1
  43. package/lib/typescript/src/hooks/useQuery.d.ts +2 -2
  44. package/lib/typescript/src/hooks/useQuery.d.ts.map +1 -1
  45. package/lib/typescript/src/hooks/useSyncStatus.d.ts.map +1 -1
  46. package/lib/typescript/src/index.d.ts +4 -3
  47. package/lib/typescript/src/index.d.ts.map +1 -1
  48. package/lib/typescript/src/inspection.d.ts +43 -0
  49. package/lib/typescript/src/inspection.d.ts.map +1 -0
  50. package/lib/typescript/src/sqliteValues.d.ts +8 -0
  51. package/lib/typescript/src/sqliteValues.d.ts.map +1 -0
  52. package/lib/typescript/src/types.d.ts +203 -11
  53. package/lib/typescript/src/types.d.ts.map +1 -1
  54. package/package.json +20 -8
  55. package/src/NativeSynchro.ts +93 -26
  56. package/src/SynchroClient.ts +900 -120
  57. package/src/digest.ts +93 -0
  58. package/src/errors.ts +273 -33
  59. package/src/hooks/useQuery.ts +59 -16
  60. package/src/hooks/useSyncStatus.ts +40 -7
  61. package/src/index.ts +44 -3
  62. package/src/inspection.ts +226 -0
  63. package/src/sqliteValues.ts +105 -0
  64. package/src/types.ts +318 -16
@@ -1,55 +1,723 @@
1
1
  import { Platform } from 'react-native';
2
2
  import type { EventSubscription } from 'react-native';
3
3
  import NativeSynchro from './NativeSynchro';
4
- import { mapNativeError } from './errors';
4
+ import {
5
+ InvalidResponseError,
6
+ SynchroError,
7
+ mapNativeError,
8
+ parseSyncFailure,
9
+ } from './errors';
10
+ import { assertValidSQLiteBindParams } from './sqliteValues';
11
+ import { SYNC_OPERATION_KINDS, SYNC_STATUS_TYPES } from './types';
5
12
  import type {
6
13
  Row,
7
14
  ExecResult,
8
15
  BatchResult,
9
16
  SQLStatement,
17
+ SQLiteBindValue,
10
18
  ColumnDef,
11
19
  TableOptions,
20
+ ReadTransaction,
12
21
  Transaction,
13
- CheckpointMode,
14
22
  SyncStatus,
15
23
  SyncStatusType,
24
+ SyncEvent,
25
+ SyncOperationKind,
26
+ SchemaAction,
27
+ MutationStatus,
28
+ MutationRejectionCode,
29
+ PendingMutationInspection,
30
+ RejectedMutationInspection,
31
+ ClientStateInspection,
32
+ ScopeStateInspection,
33
+ ScopeRowInspection,
34
+ RebuildAttemptInspection,
35
+ TransportObservation,
36
+ TransportObservationSnapshot,
37
+ TransportOperationClass,
16
38
  ConflictEvent,
17
39
  SynchroConfig,
18
40
  Unsubscribe,
41
+ AsyncUnsubscribe,
19
42
  } from './types';
20
43
 
21
44
  let observerCounter = 0;
45
+ let activeNativeOwner: object | null = null;
46
+ const inspectionOwners = new WeakMap<SynchroClient, object>();
47
+ const inspectionCapacities = new WeakMap<SynchroClient, number>();
48
+ const inspectionRequireNewDatabases = new WeakMap<SynchroClient, boolean>();
49
+
22
50
  function nextObserverID(): string {
23
51
  return `obs_${++observerCounter}_${Date.now()}`;
24
52
  }
25
53
 
54
+ function nullableRow(row: unknown): Row | null {
55
+ return row == null ? null : (row as Row);
56
+ }
57
+
58
+ type NativeSyncStatus = {
59
+ status: unknown;
60
+ retryAt: unknown;
61
+ operation?: unknown;
62
+ failure?: unknown;
63
+ };
64
+
65
+ type NativeSyncEvent = {
66
+ type: unknown;
67
+ from: unknown;
68
+ to: unknown;
69
+ operation: unknown;
70
+ attempt: unknown;
71
+ retryAt: unknown;
72
+ source: unknown;
73
+ target: unknown;
74
+ action: unknown;
75
+ mutationID: unknown;
76
+ tableID: unknown;
77
+ mutationStatus: unknown;
78
+ rejectionCode: unknown;
79
+ scopeID: unknown;
80
+ rebuildID: unknown;
81
+ failure: unknown;
82
+ };
83
+
84
+ const SYNC_EVENT_TYPES = [
85
+ 'state_changed',
86
+ 'backoff',
87
+ 'schema_applying',
88
+ 'schema_applied',
89
+ 'mutation_accepted',
90
+ 'mutation_rejected',
91
+ 'rebuild_requested',
92
+ 'rebuild_completed',
93
+ 'failure',
94
+ ] as const;
95
+
96
+ type SyncEventType = (typeof SYNC_EVENT_TYPES)[number];
97
+
98
+ const SCHEMA_ACTIONS: readonly SchemaAction[] = [
99
+ 'none',
100
+ 'replace',
101
+ 'rebuild_local',
102
+ 'unsupported',
103
+ ];
104
+
105
+ const MUTATION_STATUSES: readonly MutationStatus[] = [
106
+ 'applied',
107
+ 'conflict',
108
+ 'rejected_terminal',
109
+ ];
110
+
111
+ const MUTATION_REJECTION_CODES: readonly MutationRejectionCode[] = [
112
+ 'version_conflict',
113
+ 'row_already_exists',
114
+ 'row_deleted',
115
+ 'row_not_found',
116
+ 'schema_incompatible',
117
+ 'policy_rejected',
118
+ 'validation_failed',
119
+ 'table_not_synced',
120
+ ];
121
+
122
+ const MUTATION_OPERATIONS = ['insert', 'upsert', 'update', 'delete'] as const;
123
+ const LOCAL_MUTATION_STATUSES = [
124
+ 'pending',
125
+ 'sealed',
126
+ 'superseded_before_send',
127
+ 'cancelled_before_send',
128
+ 'blocked_by_predecessor',
129
+ 'server_rejected',
130
+ 'exceeds_push_limit',
131
+ ] as const;
132
+
133
+ const TRANSPORT_OPERATION_CLASSES: readonly TransportOperationClass[] = [
134
+ 'connect',
135
+ 'pull',
136
+ 'push',
137
+ 'checkpoint',
138
+ 'schemas',
139
+ 'rebuild',
140
+ 'other',
141
+ ];
142
+
143
+ const LEGAL_SYNC_STATUS_TRANSITIONS: Readonly<
144
+ Record<SyncStatusType, readonly SyncStatusType[]>
145
+ > = {
146
+ uninitialized: ['local_ready', 'error', 'stopped'],
147
+ local_ready: ['connecting', 'error', 'stopped'],
148
+ connecting: ['schema_applying', 'ready', 'backoff', 'error', 'stopped'],
149
+ schema_applying: ['ready', 'rebuilding', 'error', 'stopped'],
150
+ ready: ['connecting', 'pushing', 'pulling', 'rebuilding', 'error', 'stopped'],
151
+ pushing: ['pushing', 'ready', 'pulling', 'connecting', 'backoff', 'error', 'stopped'],
152
+ pulling: ['pulling', 'ready', 'rebuilding', 'connecting', 'backoff', 'error', 'stopped'],
153
+ rebuilding: ['rebuilding', 'ready', 'connecting', 'backoff', 'error', 'stopped'],
154
+ backoff: ['connecting', 'pushing', 'pulling', 'rebuilding', 'error', 'stopped'],
155
+ error: ['local_ready', 'stopped'],
156
+ stopped: ['local_ready'],
157
+ };
158
+
159
+ function isRecord(value: unknown): value is Record<string, unknown> {
160
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
161
+ }
162
+
163
+ function requiredString(value: unknown, name: string): string {
164
+ if (typeof value !== 'string') {
165
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
166
+ }
167
+ return value;
168
+ }
169
+
170
+ function nullableString(value: unknown, name: string): string | null {
171
+ if (value == null) {
172
+ return null;
173
+ }
174
+ return requiredString(value, name);
175
+ }
176
+
177
+ function requiredSafeInteger(value: unknown, name: string): number {
178
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
179
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
180
+ }
181
+ return value;
182
+ }
183
+
184
+ function requiredNonnegativeSafeInteger(value: unknown, name: string): number {
185
+ const integer = requiredSafeInteger(value, name);
186
+ if (integer < 0) {
187
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
188
+ }
189
+ return integer;
190
+ }
191
+
192
+ function nullableSafeInteger(value: unknown, name: string): number | null {
193
+ return value == null ? null : requiredSafeInteger(value, name);
194
+ }
195
+
196
+ function parseDate(value: unknown, name: string): Date | null {
197
+ const stringValue = nullableString(value, name);
198
+ if (stringValue === null) {
199
+ return null;
200
+ }
201
+ const date = new Date(stringValue);
202
+ if (Number.isNaN(date.getTime())) {
203
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
204
+ }
205
+ return date;
206
+ }
207
+
208
+ function parseStatus(value: unknown): SyncStatus | null {
209
+ if (!isRecord(value) || typeof value.status !== 'string') {
210
+ throw new InvalidResponseError('Native bridge returned an invalid sync status');
211
+ }
212
+ if (!SYNC_STATUS_TYPES.includes(value.status as SyncStatusType)) {
213
+ return null;
214
+ }
215
+ const status = value.status as SyncStatusType;
216
+ const retryAt = parseDate(value.retryAt, 'retryAt');
217
+ const operationValue = nullableString(value.operation, 'status operation');
218
+ const operation = operationValue === null
219
+ ? null
220
+ : SYNC_OPERATION_KINDS.includes(operationValue as SyncOperationKind)
221
+ ? (operationValue as SyncOperationKind)
222
+ : (() => {
223
+ throw new InvalidResponseError('Native bridge returned an invalid status operation');
224
+ })();
225
+ const failure = value.failure == null ? null : parseSyncFailure(value.failure);
226
+
227
+ if (status === 'backoff') {
228
+ if (retryAt === null || operation === null || failure !== null) {
229
+ throw new InvalidResponseError('Native bridge returned invalid backoff status details');
230
+ }
231
+ return { status, retryAt, operation, failure: null };
232
+ }
233
+ if (status === 'error') {
234
+ if (retryAt !== null || operation !== null || failure === null) {
235
+ throw new InvalidResponseError('Native bridge returned invalid error status details');
236
+ }
237
+ return { status, retryAt: null, operation: null, failure };
238
+ }
239
+ if (retryAt !== null || operation !== null || failure !== null) {
240
+ throw new InvalidResponseError('Native bridge returned invalid state detail fields');
241
+ }
242
+ return { status, retryAt: null, operation: null, failure: null };
243
+ }
244
+
245
+ function parseSchemaRef(value: unknown, name: string) {
246
+ if (!isRecord(value) || typeof value.version !== 'number' || !Number.isSafeInteger(value.version)) {
247
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
248
+ }
249
+ return {
250
+ version: value.version,
251
+ hash: requiredString(value.hash, `${name} hash`),
252
+ };
253
+ }
254
+
255
+ function parseMutationStatus(value: unknown): MutationStatus {
256
+ if (!MUTATION_STATUSES.includes(value as MutationStatus)) {
257
+ throw new InvalidResponseError('Native bridge returned an invalid mutation status');
258
+ }
259
+ return value as MutationStatus;
260
+ }
261
+
262
+ function parseRejectionCode(value: unknown): MutationRejectionCode | null {
263
+ if (value == null) {
264
+ return null;
265
+ }
266
+ if (!MUTATION_REJECTION_CODES.includes(value as MutationRejectionCode)) {
267
+ throw new InvalidResponseError('Native bridge returned an invalid mutation rejection code');
268
+ }
269
+ return value as MutationRejectionCode;
270
+ }
271
+
272
+ function parseSchemaAction(value: unknown): SchemaAction {
273
+ if (!SCHEMA_ACTIONS.includes(value as SchemaAction)) {
274
+ throw new InvalidResponseError('Native bridge returned an invalid schema action');
275
+ }
276
+ return value as SchemaAction;
277
+ }
278
+
279
+ function parseSyncEvent(value: NativeSyncEvent): SyncEvent | null {
280
+ if (typeof value.type !== 'string') {
281
+ throw new InvalidResponseError('Native bridge returned an invalid sync event');
282
+ }
283
+ if (!SYNC_EVENT_TYPES.includes(value.type as SyncEventType)) {
284
+ return null;
285
+ }
286
+ switch (value.type as SyncEventType) {
287
+ case 'state_changed': {
288
+ if (typeof value.from !== 'string' || typeof value.to !== 'string') {
289
+ throw new InvalidResponseError('Native bridge returned an invalid lifecycle event');
290
+ }
291
+ if (
292
+ !SYNC_STATUS_TYPES.includes(value.from as SyncStatusType) ||
293
+ !SYNC_STATUS_TYPES.includes(value.to as SyncStatusType)
294
+ ) {
295
+ return null;
296
+ }
297
+ const from = value.from as SyncStatusType;
298
+ const to = value.to as SyncStatusType;
299
+ if (!LEGAL_SYNC_STATUS_TRANSITIONS[from].includes(to)) {
300
+ throw new InvalidResponseError('Native bridge returned an invalid lifecycle event');
301
+ }
302
+ return {
303
+ type: 'state_changed',
304
+ from,
305
+ to,
306
+ };
307
+ }
308
+ case 'backoff': {
309
+ const attempt = value.attempt;
310
+ if (typeof attempt !== 'number' || !Number.isSafeInteger(attempt) || attempt < 0) {
311
+ throw new InvalidResponseError('Native bridge returned an invalid backoff attempt');
312
+ }
313
+ const retryAt = parseDate(value.retryAt, 'backoff retryAt');
314
+ if (retryAt === null) {
315
+ throw new InvalidResponseError('Native bridge returned a missing backoff retryAt');
316
+ }
317
+ return {
318
+ type: 'backoff',
319
+ operation: SYNC_OPERATION_KINDS.includes(value.operation as SyncOperationKind)
320
+ ? (value.operation as SyncOperationKind)
321
+ : (() => {
322
+ throw new InvalidResponseError('Native bridge returned an invalid backoff operation');
323
+ })(),
324
+ attempt,
325
+ retryAt,
326
+ };
327
+ }
328
+ case 'schema_applying':
329
+ case 'schema_applied':
330
+ return {
331
+ type: value.type as 'schema_applying' | 'schema_applied',
332
+ source: parseSchemaRef(value.source, 'schema source'),
333
+ target: parseSchemaRef(value.target, 'schema target'),
334
+ action: parseSchemaAction(value.action),
335
+ };
336
+ case 'mutation_accepted':
337
+ case 'mutation_rejected': {
338
+ const mutationID = requiredString(value.mutationID, 'mutation ID');
339
+ return {
340
+ type: value.type as 'mutation_accepted' | 'mutation_rejected',
341
+ mutationID,
342
+ tableID: requiredString(value.tableID, 'mutation table ID'),
343
+ status: parseMutationStatus(value.mutationStatus),
344
+ rejectionCode: parseRejectionCode(value.rejectionCode),
345
+ };
346
+ }
347
+ case 'rebuild_requested':
348
+ case 'rebuild_completed':
349
+ return {
350
+ type: value.type as 'rebuild_requested' | 'rebuild_completed',
351
+ scopeID: requiredString(value.scopeID, 'rebuild scope ID'),
352
+ rebuildID: requiredString(value.rebuildID, 'rebuild ID'),
353
+ };
354
+ case 'failure': {
355
+ return { type: 'failure', failure: parseSyncFailure(value.failure) };
356
+ }
357
+ }
358
+ }
359
+
360
+ function parseNativeDTO<T>(json: string): T {
361
+ try {
362
+ return JSON.parse(json) as T;
363
+ } catch {
364
+ throw new InvalidResponseError('Native bridge returned malformed JSON');
365
+ }
366
+ }
367
+
368
+ function parseNativeArray(json: string, name: string): unknown[] {
369
+ const value = parseNativeDTO<unknown>(json);
370
+ if (!Array.isArray(value)) {
371
+ throw new InvalidResponseError(`Native bridge returned invalid ${name}`);
372
+ }
373
+ return value;
374
+ }
375
+
376
+ function parsePendingMutationInspection(
377
+ value: unknown,
378
+ index: number
379
+ ): PendingMutationInspection {
380
+ const name = `pending mutation at index ${index}`;
381
+ if (!isRecord(value)) {
382
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
383
+ }
384
+ if (!MUTATION_OPERATIONS.includes(value.operation as never)) {
385
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name} operation`);
386
+ }
387
+ if (!LOCAL_MUTATION_STATUSES.includes(value.status as never)) {
388
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name} status`);
389
+ }
390
+ if (!Array.isArray(value.authoredFields)) {
391
+ throw new InvalidResponseError(`Native bridge returned invalid ${name} fields`);
392
+ }
393
+
394
+ const authoredFields = value.authoredFields.map((field, fieldIndex) => {
395
+ if (!isRecord(field) || !Object.prototype.hasOwnProperty.call(field, 'value')) {
396
+ throw new InvalidResponseError(
397
+ `Native bridge returned an invalid ${name} field at index ${fieldIndex}`
398
+ );
399
+ }
400
+ return {
401
+ fieldID: requiredString(field.fieldID, `${name} field ID`),
402
+ logicalType: requiredString(field.logicalType, `${name} field logical type`),
403
+ value: field.value as PendingMutationInspection['authoredFields'][number]['value'],
404
+ };
405
+ });
406
+
407
+ return {
408
+ mutationID: requiredString(value.mutationID, `${name} ID`),
409
+ localOrder: requiredSafeInteger(value.localOrder, `${name} local order`),
410
+ tableID: requiredString(value.tableID, `${name} table ID`),
411
+ tableName: requiredString(value.tableName, `${name} table name`),
412
+ recordID: requiredString(value.recordID, `${name} record ID`),
413
+ primaryKeyFieldID: requiredString(
414
+ value.primaryKeyFieldID,
415
+ `${name} primary-key field ID`
416
+ ),
417
+ primaryKeyLogicalType: requiredString(
418
+ value.primaryKeyLogicalType,
419
+ `${name} primary-key logical type`
420
+ ),
421
+ operation: value.operation as PendingMutationInspection['operation'],
422
+ authoredSchema: parseSchemaRef(value.authoredSchema, `${name} authored schema`),
423
+ baseVersion: nullableString(value.baseVersion, `${name} base version`),
424
+ clientVersion: requiredString(value.clientVersion, `${name} client version`),
425
+ status: value.status as PendingMutationInspection['status'],
426
+ sourceKind: requiredString(value.sourceKind, `${name} source kind`),
427
+ dependsOnMutationID: nullableString(
428
+ value.dependsOnMutationID,
429
+ `${name} predecessor mutation ID`
430
+ ),
431
+ normalizedMutationID: nullableString(
432
+ value.normalizedMutationID,
433
+ `${name} normalized mutation ID`
434
+ ),
435
+ sealedBatchID: nullableString(value.sealedBatchID, `${name} sealed batch ID`),
436
+ sealedOrdinal: nullableSafeInteger(value.sealedOrdinal, `${name} sealed ordinal`),
437
+ authoredFields,
438
+ };
439
+ }
440
+
441
+ function parseRejectedMutationInspection(
442
+ value: unknown,
443
+ index: number
444
+ ): RejectedMutationInspection {
445
+ const name = `rejected mutation at index ${index}`;
446
+ if (!isRecord(value)) {
447
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
448
+ }
449
+ const code = parseRejectionCode(value.code);
450
+ if (code === null) {
451
+ throw new InvalidResponseError(`Native bridge returned a missing ${name} code`);
452
+ }
453
+ return {
454
+ mutationID: requiredString(value.mutationID, `${name} ID`),
455
+ tableName: requiredString(value.tableName, `${name} table name`),
456
+ recordID: requiredString(value.recordID, `${name} record ID`),
457
+ status: parseMutationStatus(value.status),
458
+ code,
459
+ message: nullableString(value.message, `${name} message`),
460
+ serverRowJSON: nullableString(value.serverRowJSON, `${name} server row JSON`),
461
+ serverVersion: nullableString(value.serverVersion, `${name} server version`),
462
+ mutationJSON: requiredString(value.mutationJSON, `${name} mutation JSON`),
463
+ rejectionJSON: requiredString(value.rejectionJSON, `${name} rejection JSON`),
464
+ createdAt: requiredString(value.createdAt, `${name} creation time`),
465
+ updatedAt: requiredString(value.updatedAt, `${name} update time`),
466
+ };
467
+ }
468
+
469
+ function parseScopeStateInspection(value: unknown, index: number): ScopeStateInspection {
470
+ const name = `scope state at index ${index}`;
471
+ if (!isRecord(value)) {
472
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
473
+ }
474
+ return {
475
+ scopeID: requiredString(value.scope_id, `${name} ID`),
476
+ cursor: nullableString(value.cursor, `${name} cursor`),
477
+ checksum: nullableString(value.checksum, `${name} checksum`),
478
+ localChecksum: requiredString(value.local_checksum, `${name} local checksum`),
479
+ generation: requiredSafeInteger(value.generation, `${name} generation`),
480
+ };
481
+ }
482
+
483
+ function parseScopeRowInspection(value: unknown, index: number): ScopeRowInspection {
484
+ const name = `scope row at index ${index}`;
485
+ if (!isRecord(value)) {
486
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
487
+ }
488
+ return {
489
+ scopeID: requiredString(value.scope_id, `${name} scope ID`),
490
+ tableName: requiredString(value.table_name, `${name} table name`),
491
+ recordID: requiredString(value.record_id, `${name} record ID`),
492
+ checksum: requiredString(value.checksum, `${name} checksum`),
493
+ generation: requiredSafeInteger(value.generation, `${name} generation`),
494
+ };
495
+ }
496
+
497
+ function parseRebuildAttemptInspection(value: unknown, index: number): RebuildAttemptInspection {
498
+ const name = `rebuild attempt at index ${index}`;
499
+ if (!isRecord(value)) {
500
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
501
+ }
502
+ return {
503
+ scopeID: requiredString(value.scope_id, `${name} scope ID`),
504
+ rebuildID: requiredString(value.rebuild_id, `${name} rebuild ID`),
505
+ clientGeneration: requiredSafeInteger(value.client_generation, `${name} client generation`),
506
+ schemaVersion: requiredSafeInteger(value.schema_version, `${name} schema version`),
507
+ schemaHash: requiredString(value.schema_hash, `${name} schema hash`),
508
+ generation: requiredSafeInteger(value.generation, `${name} generation`),
509
+ cursor: nullableString(value.cursor, `${name} cursor`),
510
+ pageLimit: requiredSafeInteger(value.page_limit, `${name} page limit`),
511
+ };
512
+ }
513
+
514
+ export function parseClientStateInspection(value: unknown): ClientStateInspection {
515
+ if (!isRecord(value)) {
516
+ throw new InvalidResponseError('Native bridge returned invalid client state inspection');
517
+ }
518
+ const schema = value.schema == null ? null : parseSchemaRef(value.schema, 'client schema');
519
+ if (!Array.isArray(value.scope_states) || !Array.isArray(value.scope_rows) || !Array.isArray(value.rebuild_attempts)) {
520
+ throw new InvalidResponseError('Native bridge returned incomplete client state inspection');
521
+ }
522
+ return {
523
+ schema,
524
+ scopeStates: value.scope_states.map(parseScopeStateInspection),
525
+ scopeRows: value.scope_rows.map(parseScopeRowInspection),
526
+ rebuildAttempts: value.rebuild_attempts.map(parseRebuildAttemptInspection),
527
+ applicationRowCount: requiredNonnegativeSafeInteger(
528
+ value.application_row_count,
529
+ 'application row count'
530
+ ),
531
+ mutationLedgerCount: requiredNonnegativeSafeInteger(
532
+ value.mutation_ledger_count,
533
+ 'mutation ledger count'
534
+ ),
535
+ mutationOutcomeCount: requiredNonnegativeSafeInteger(
536
+ value.mutation_outcome_count,
537
+ 'mutation outcome count'
538
+ ),
539
+ sealedBatchCount: requiredNonnegativeSafeInteger(
540
+ value.sealed_batch_count,
541
+ 'sealed batch count'
542
+ ),
543
+ rejectedMutationCount: requiredNonnegativeSafeInteger(
544
+ value.rejected_mutation_count,
545
+ 'rejected mutation count'
546
+ ),
547
+ scopeStateCount: requiredNonnegativeSafeInteger(
548
+ value.scope_state_count,
549
+ 'scope state count'
550
+ ),
551
+ scopeRowCount: requiredNonnegativeSafeInteger(
552
+ value.scope_row_count,
553
+ 'scope row count'
554
+ ),
555
+ provenanceCount: requiredNonnegativeSafeInteger(
556
+ value.provenance_count,
557
+ 'provenance count'
558
+ ),
559
+ rowMetadataCount: requiredNonnegativeSafeInteger(
560
+ value.row_metadata_count,
561
+ 'row metadata count'
562
+ ),
563
+ rebuildAttemptCount: requiredNonnegativeSafeInteger(
564
+ value.rebuild_attempt_count,
565
+ 'rebuild attempt count'
566
+ ),
567
+ rebuildReceiptCount: requiredNonnegativeSafeInteger(
568
+ value.rebuild_receipt_count,
569
+ 'rebuild receipt count'
570
+ ),
571
+ provenanceMaintenanceWorkCursor: requiredNonnegativeInt64String(
572
+ value.provenance_maintenance_work_cursor,
573
+ 'provenance maintenance work cursor'
574
+ ),
575
+ };
576
+ }
577
+
578
+ function requiredNonnegativeInt64String(value: unknown, name: string): string {
579
+ const maximum = '9223372036854775807';
580
+ if (
581
+ typeof value !== 'string' ||
582
+ !/^(0|[1-9][0-9]*)$/.test(value) ||
583
+ value.length > maximum.length ||
584
+ (value.length === maximum.length && value > maximum)
585
+ ) {
586
+ throw new InvalidResponseError(`Native bridge returned invalid ${name}`);
587
+ }
588
+ return value;
589
+ }
590
+
591
+ function optionalTransportFacts(value: unknown, name: string): Record<string, import('./types').JSONValue> | undefined {
592
+ if (value == null) {
593
+ return undefined;
594
+ }
595
+ if (!isRecord(value)) {
596
+ throw new InvalidResponseError(`Native bridge returned invalid ${name}`);
597
+ }
598
+ return value as Record<string, import('./types').JSONValue>;
599
+ }
600
+
601
+ function optionalTransportRequestFacts(
602
+ value: unknown,
603
+ name: string
604
+ ): Record<string, import('./types').JSONValue> | undefined {
605
+ const facts = optionalTransportFacts(value, name);
606
+ const mutationCount = facts?.mutation_count;
607
+ if (
608
+ facts !== undefined &&
609
+ Object.prototype.hasOwnProperty.call(facts, 'mutation_count') &&
610
+ (typeof mutationCount !== 'number' ||
611
+ !Number.isSafeInteger(mutationCount) ||
612
+ mutationCount < 0)
613
+ ) {
614
+ throw new InvalidResponseError(`Native bridge returned invalid ${name} mutation count`);
615
+ }
616
+ return facts;
617
+ }
618
+
619
+ function parseTransportObservation(value: unknown, index: number): TransportObservation {
620
+ const name = `transport observation at index ${index}`;
621
+ if (!isRecord(value) || !TRANSPORT_OPERATION_CLASSES.includes(value.operation_class as TransportOperationClass)) {
622
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
623
+ }
624
+ const cursorFingerprints = value.cursor_fingerprints;
625
+ if (cursorFingerprints !== undefined && (!Array.isArray(cursorFingerprints) || !cursorFingerprints.every((item) => typeof item === 'string'))) {
626
+ throw new InvalidResponseError(`Native bridge returned invalid ${name} cursor fingerprints`);
627
+ }
628
+ const cursorFingerprintsComplete = value.cursor_fingerprints_complete;
629
+ if (cursorFingerprintsComplete !== undefined && typeof cursorFingerprintsComplete !== 'boolean') {
630
+ throw new InvalidResponseError(`Native bridge returned invalid ${name} cursor completeness`);
631
+ }
632
+ return {
633
+ sequence: requiredSafeInteger(value.sequence, `${name} sequence`),
634
+ operationClass: value.operation_class as TransportOperationClass,
635
+ statusCode: requiredSafeInteger(value.status_code, `${name} status code`),
636
+ durationNanoseconds: requiredSafeInteger(value.duration_nanoseconds, `${name} duration`),
637
+ ...(cursorFingerprints === undefined ? {} : { cursorFingerprints: [...cursorFingerprints] as string[] }),
638
+ ...(cursorFingerprintsComplete === undefined ? {} : { cursorFingerprintsComplete }),
639
+ ...(value.request_facts == null ? {} : { requestFacts: optionalTransportRequestFacts(value.request_facts, `${name} request facts`)! }),
640
+ ...(value.rebuild_response_facts == null ? {} : { rebuildResponseFacts: optionalTransportFacts(value.rebuild_response_facts, `${name} rebuild response facts`)! }),
641
+ ...(value.pull_response_facts == null ? {} : { pullResponseFacts: optionalTransportFacts(value.pull_response_facts, `${name} pull response facts`)! }),
642
+ };
643
+ }
644
+
645
+ export function parseTransportObservationSnapshot(value: unknown): TransportObservationSnapshot {
646
+ if (!isRecord(value) || !Array.isArray(value.observations) || typeof value.overflowed !== 'boolean') {
647
+ throw new InvalidResponseError('Native bridge returned invalid transport observations');
648
+ }
649
+ return {
650
+ observations: value.observations.map(parseTransportObservation),
651
+ overflowed: value.overflowed,
652
+ sequenceCheckpoint: requiredSafeInteger(value.sequence_checkpoint, 'transport sequence checkpoint'),
653
+ };
654
+ }
655
+
656
+ function checkedParams(params: readonly SQLiteBindValue[]): readonly SQLiteBindValue[] {
657
+ return assertValidSQLiteBindParams(params);
658
+ }
659
+
26
660
  export class SynchroClient {
27
- private readonly native = NativeSynchro;
661
+ private readonly nativeOwner = {};
28
662
  private readonly config: SynchroConfig;
29
663
  private authSubscription: EventSubscription | null = null;
30
664
 
31
665
  constructor(config: SynchroConfig) {
32
666
  this.config = config;
667
+ inspectionOwners.set(this, this.nativeOwner);
668
+ }
669
+
670
+ private get native(): typeof NativeSynchro {
671
+ if (activeNativeOwner !== null && activeNativeOwner !== this.nativeOwner) {
672
+ throw new SynchroError(
673
+ 'CLIENT_ALREADY_ACTIVE',
674
+ 'Another SynchroClient owns the native runtime'
675
+ );
676
+ }
677
+ return NativeSynchro;
678
+ }
33
679
 
34
- // Wire auth callback using Codegen EventEmitter pattern
35
- this.authSubscription = this.native.onAuthRequest(
680
+ private installAuthSubscription(): void {
681
+ this.authSubscription?.remove();
682
+ const owner = this.nativeOwner;
683
+ this.authSubscription = NativeSynchro.onAuthRequest(
36
684
  (event: { requestID: string }) => {
37
685
  this.config
38
686
  .authProvider()
39
687
  .then((token) => {
40
- this.native.resolveAuthRequest(event.requestID, token);
688
+ if (activeNativeOwner === owner) {
689
+ NativeSynchro.resolveAuthRequest(event.requestID, token);
690
+ }
41
691
  })
42
692
  .catch((err) => {
43
- this.native.rejectAuthRequest(
44
- event.requestID,
45
- err instanceof Error ? err.message : String(err)
46
- );
693
+ if (activeNativeOwner === owner) {
694
+ NativeSynchro.rejectAuthRequest(
695
+ event.requestID,
696
+ err instanceof Error ? err.message : String(err)
697
+ );
698
+ }
47
699
  });
48
700
  }
49
701
  );
50
702
  }
51
703
 
704
+ private releaseNativeOwnership(): void {
705
+ this.authSubscription?.remove();
706
+ this.authSubscription = null;
707
+ if (activeNativeOwner === this.nativeOwner) {
708
+ activeNativeOwner = null;
709
+ }
710
+ }
711
+
52
712
  async initialize(): Promise<void> {
713
+ if (activeNativeOwner !== null && activeNativeOwner !== this.nativeOwner) {
714
+ throw new SynchroError(
715
+ 'CLIENT_ALREADY_ACTIVE',
716
+ 'Another SynchroClient owns the native runtime'
717
+ );
718
+ }
719
+ activeNativeOwner = this.nativeOwner;
720
+ this.installAuthSubscription();
53
721
  try {
54
722
  await this.native.initialize({
55
723
  dbPath: this.config.dbPath,
@@ -62,44 +730,56 @@ export class SynchroClient {
62
730
  maxRetryAttempts: this.config.maxRetryAttempts ?? 5,
63
731
  pullPageSize: this.config.pullPageSize ?? 100,
64
732
  pushBatchSize: this.config.pushBatchSize ?? 100,
65
- snapshotPageSize: this.config.snapshotPageSize ?? 100,
733
+ seedDatabasePath: this.config.seedDatabasePath,
734
+ transportObservationCapacity: inspectionCapacities.get(this) ?? 0,
735
+ requireNewDatabase: inspectionRequireNewDatabases.get(this) ?? false,
66
736
  });
67
737
  } catch (error) {
738
+ this.releaseNativeOwnership();
68
739
  throw mapNativeError(error);
69
740
  }
70
741
  }
71
742
 
72
- // -- Core SQL --
743
+ // Core SQL
73
744
 
74
- async query(sql: string, params?: unknown[]): Promise<Row[]> {
745
+ async query(sql: string, params?: readonly SQLiteBindValue[]): Promise<Row[]> {
75
746
  try {
76
- const json = await this.native.query(
77
- sql,
78
- JSON.stringify(params ?? [])
79
- );
80
- return JSON.parse(json) as Row[];
747
+ return [...(await this.native.query(sql, checkedParams(params ?? [])))] as Row[];
81
748
  } catch (error) {
82
749
  throw mapNativeError(error);
83
750
  }
84
751
  }
85
752
 
86
- async queryOne(sql: string, params?: unknown[]): Promise<Row | null> {
753
+ async queryOne(sql: string, params?: readonly SQLiteBindValue[]): Promise<Row | null> {
87
754
  try {
88
- const json = await this.native.queryOne(
89
- sql,
90
- JSON.stringify(params ?? [])
91
- );
92
- return json ? (JSON.parse(json) as Row) : null;
755
+ return nullableRow(await this.native.queryOne(sql, checkedParams(params ?? [])));
756
+ } catch (error) {
757
+ throw mapNativeError(error);
758
+ }
759
+ }
760
+
761
+ async execute(sql: string, params?: readonly SQLiteBindValue[]): Promise<ExecResult> {
762
+ try {
763
+ return await this.native.execute(sql, checkedParams(params ?? []));
93
764
  } catch (error) {
94
765
  throw mapNativeError(error);
95
766
  }
96
767
  }
97
768
 
98
- async execute(sql: string, params?: unknown[]): Promise<ExecResult> {
769
+ async executeAuthoredWrite(
770
+ tableName: string,
771
+ operation: string,
772
+ columnNames: readonly string[],
773
+ sql: string,
774
+ values?: readonly SQLiteBindValue[]
775
+ ): Promise<ExecResult> {
99
776
  try {
100
- return await this.native.execute(
777
+ return await this.native.executeAuthoredWrite(
778
+ tableName,
779
+ operation,
780
+ columnNames,
101
781
  sql,
102
- JSON.stringify(params ?? [])
782
+ checkedParams(values ?? [])
103
783
  );
104
784
  } catch (error) {
105
785
  throw mapNativeError(error);
@@ -110,15 +790,15 @@ export class SynchroClient {
110
790
  try {
111
791
  const payload = statements.map((s) => ({
112
792
  sql: s.sql,
113
- params: s.params ?? [],
793
+ params: checkedParams(s.params ?? []),
114
794
  }));
115
- return await this.native.executeBatch(JSON.stringify(payload));
795
+ return await this.native.executeBatch(payload);
116
796
  } catch (error) {
117
797
  throw mapNativeError(error);
118
798
  }
119
799
  }
120
800
 
121
- // -- Transactions --
801
+ // Transactions
122
802
 
123
803
  async writeTransaction<T>(
124
804
  callback: (tx: Transaction) => Promise<T>
@@ -133,27 +813,15 @@ export class SynchroClient {
133
813
  try {
134
814
  const tx: Transaction = {
135
815
  query: async (sql, params) => {
136
- const json = await this.native.txQuery(
137
- txID,
138
- sql,
139
- JSON.stringify(params ?? [])
140
- );
141
- return JSON.parse(json) as Row[];
816
+ return [...(await this.native.txQuery(txID, sql, checkedParams(params ?? [])))] as Row[];
142
817
  },
143
818
  queryOne: async (sql, params) => {
144
- const json = await this.native.txQueryOne(
145
- txID,
146
- sql,
147
- JSON.stringify(params ?? [])
819
+ return nullableRow(
820
+ await this.native.txQueryOne(txID, sql, checkedParams(params ?? []))
148
821
  );
149
- return json ? (JSON.parse(json) as Row) : null;
150
822
  },
151
823
  execute: async (sql, params) => {
152
- return await this.native.txExecute(
153
- txID,
154
- sql,
155
- JSON.stringify(params ?? [])
156
- );
824
+ return await this.native.txExecute(txID, sql, checkedParams(params ?? []));
157
825
  },
158
826
  };
159
827
  const result = await callback(tx);
@@ -170,7 +838,7 @@ export class SynchroClient {
170
838
  }
171
839
 
172
840
  async readTransaction<T>(
173
- callback: (tx: Transaction) => Promise<T>
841
+ callback: (tx: ReadTransaction) => Promise<T>
174
842
  ): Promise<T> {
175
843
  let txID: string;
176
844
  try {
@@ -180,28 +848,13 @@ export class SynchroClient {
180
848
  }
181
849
 
182
850
  try {
183
- const tx: Transaction = {
851
+ const tx: ReadTransaction = {
184
852
  query: async (sql, params) => {
185
- const json = await this.native.txQuery(
186
- txID,
187
- sql,
188
- JSON.stringify(params ?? [])
189
- );
190
- return JSON.parse(json) as Row[];
853
+ return [...(await this.native.txQuery(txID, sql, checkedParams(params ?? [])))] as Row[];
191
854
  },
192
855
  queryOne: async (sql, params) => {
193
- const json = await this.native.txQueryOne(
194
- txID,
195
- sql,
196
- JSON.stringify(params ?? [])
197
- );
198
- return json ? (JSON.parse(json) as Row) : null;
199
- },
200
- execute: async (sql, params) => {
201
- return await this.native.txExecute(
202
- txID,
203
- sql,
204
- JSON.stringify(params ?? [])
856
+ return nullableRow(
857
+ await this.native.txQueryOne(txID, sql, checkedParams(params ?? []))
205
858
  );
206
859
  },
207
860
  };
@@ -218,7 +871,7 @@ export class SynchroClient {
218
871
  }
219
872
  }
220
873
 
221
- // -- Schema (local-only) --
874
+ // Schema, local-only
222
875
 
223
876
  async createTable(
224
877
  name: string,
@@ -256,9 +909,9 @@ export class SynchroClient {
256
909
  }
257
910
  }
258
911
 
259
- // -- Observation --
912
+ // Observation
260
913
 
261
- onChange(tables: string[], callback: () => void): Unsubscribe {
914
+ async onChange(tables: string[], callback: () => void): Promise<AsyncUnsubscribe> {
262
915
  const observerID = nextObserverID();
263
916
 
264
917
  const subscription = this.native.onChange(
@@ -269,80 +922,151 @@ export class SynchroClient {
269
922
  }
270
923
  );
271
924
 
272
- this.native.addChangeObserver(observerID, tables);
925
+ try {
926
+ await this.native.addChangeObserver(observerID, tables);
927
+ } catch (error) {
928
+ subscription.remove();
929
+ throw mapNativeError(error);
930
+ }
273
931
 
274
- return () => {
932
+ return async () => {
275
933
  subscription.remove();
276
- this.native.removeObserver(observerID);
934
+ try {
935
+ await NativeSynchro.removeObserver(observerID);
936
+ } catch (error) {
937
+ throw mapNativeError(error);
938
+ }
277
939
  };
278
940
  }
279
941
 
280
- watch(
942
+ async watch(
281
943
  sql: string,
282
- params: unknown[] | undefined,
944
+ params: readonly SQLiteBindValue[] | undefined,
283
945
  tables: string[],
284
946
  callback: (rows: Row[]) => void
285
- ): Unsubscribe {
947
+ ): Promise<AsyncUnsubscribe> {
286
948
  const observerID = nextObserverID();
287
949
 
288
950
  const subscription = this.native.onQueryResult(
289
- (event: { observerID: string; rowsJson: string }) => {
951
+ (event) => {
290
952
  if (event.observerID === observerID) {
291
- callback(JSON.parse(event.rowsJson) as Row[]);
953
+ callback([...(event.rows as readonly Row[])]);
292
954
  }
293
955
  }
294
956
  );
295
957
 
296
- this.native.addQueryObserver(
297
- observerID,
298
- sql,
299
- JSON.stringify(params ?? []),
300
- tables
301
- );
958
+ try {
959
+ await this.native.addQueryObserver(
960
+ observerID,
961
+ sql,
962
+ checkedParams(params ?? []),
963
+ tables
964
+ );
965
+ } catch (error) {
966
+ subscription.remove();
967
+ throw mapNativeError(error);
968
+ }
302
969
 
303
- return () => {
970
+ return async () => {
304
971
  subscription.remove();
305
- this.native.removeObserver(observerID);
972
+ try {
973
+ await NativeSynchro.removeObserver(observerID);
974
+ } catch (error) {
975
+ throw mapNativeError(error);
976
+ }
306
977
  };
307
978
  }
308
979
 
309
- // -- WAL --
980
+ // Lifecycle
310
981
 
311
- async checkpoint(mode: CheckpointMode = 'passive'): Promise<void> {
982
+ async close(): Promise<void> {
983
+ if (activeNativeOwner !== null && activeNativeOwner !== this.nativeOwner) {
984
+ throw new SynchroError(
985
+ 'CLIENT_ALREADY_ACTIVE',
986
+ 'Another SynchroClient owns the native runtime'
987
+ );
988
+ }
312
989
  try {
313
- await this.native.checkpoint(mode);
990
+ await NativeSynchro.close();
991
+ this.releaseNativeOwnership();
314
992
  } catch (error) {
315
993
  throw mapNativeError(error);
316
994
  }
317
995
  }
318
996
 
319
- // -- Lifecycle --
997
+ async getPath(): Promise<string> {
998
+ try {
999
+ return await this.native.getPath();
1000
+ } catch (error) {
1001
+ throw mapNativeError(error);
1002
+ }
1003
+ }
320
1004
 
321
- async close(): Promise<void> {
1005
+ async pendingChangeCount(): Promise<number> {
322
1006
  try {
323
- await this.native.close();
1007
+ return await this.native.pendingChangeCount();
324
1008
  } catch (error) {
325
1009
  throw mapNativeError(error);
326
1010
  }
327
1011
  }
328
1012
 
329
- async getPath(): Promise<string> {
1013
+ async getSyncStatus(): Promise<SyncStatus> {
330
1014
  try {
331
- return await this.native.getPath();
1015
+ const status = parseNativeDTO<NativeSyncStatus>(
1016
+ await this.native.getSyncStatus()
1017
+ );
1018
+ const parsed = parseStatus(status);
1019
+ if (parsed === null) {
1020
+ throw new InvalidResponseError('Native bridge returned an unknown sync status');
1021
+ }
1022
+ return parsed;
332
1023
  } catch (error) {
333
1024
  throw mapNativeError(error);
334
1025
  }
335
1026
  }
336
1027
 
337
- async pendingChangeCount(): Promise<number> {
1028
+ async inspectPendingMutations(): Promise<PendingMutationInspection[]> {
338
1029
  try {
339
- return await this.native.pendingChangeCount();
1030
+ return parseNativeArray(
1031
+ await this.native.inspectPendingMutations(),
1032
+ 'pending mutation inspection'
1033
+ ).map(parsePendingMutationInspection);
1034
+ } catch (error) {
1035
+ throw mapNativeError(error);
1036
+ }
1037
+ }
1038
+
1039
+ async inspectRetainedMutations(): Promise<PendingMutationInspection[]> {
1040
+ try {
1041
+ return parseNativeArray(
1042
+ await this.native.inspectRetainedMutations(),
1043
+ 'retained mutation inspection'
1044
+ ).map(parsePendingMutationInspection);
1045
+ } catch (error) {
1046
+ throw mapNativeError(error);
1047
+ }
1048
+ }
1049
+
1050
+ async inspectRejectedMutations(): Promise<RejectedMutationInspection[]> {
1051
+ try {
1052
+ return parseNativeArray(
1053
+ await this.native.inspectRejectedMutations(),
1054
+ 'rejected mutation inspection'
1055
+ ).map(parseRejectedMutationInspection);
1056
+ } catch (error) {
1057
+ throw mapNativeError(error);
1058
+ }
1059
+ }
1060
+
1061
+ async clearRejectedMutations(): Promise<void> {
1062
+ try {
1063
+ await this.native.clearRejectedMutations();
340
1064
  } catch (error) {
341
1065
  throw mapNativeError(error);
342
1066
  }
343
1067
  }
344
1068
 
345
- // -- Sync --
1069
+ // Sync
346
1070
 
347
1071
  async start(): Promise<void> {
348
1072
  try {
@@ -360,6 +1084,38 @@ export class SynchroClient {
360
1084
  }
361
1085
  }
362
1086
 
1087
+ async enterBackground(): Promise<void> {
1088
+ try {
1089
+ await this.native.enterBackground();
1090
+ } catch (error) {
1091
+ throw mapNativeError(error);
1092
+ }
1093
+ }
1094
+
1095
+ async enterForeground(): Promise<void> {
1096
+ try {
1097
+ await this.native.enterForeground();
1098
+ } catch (error) {
1099
+ throw mapNativeError(error);
1100
+ }
1101
+ }
1102
+
1103
+ async retryAfterError(): Promise<void> {
1104
+ try {
1105
+ await this.native.retryAfterError();
1106
+ } catch (error) {
1107
+ throw mapNativeError(error);
1108
+ }
1109
+ }
1110
+
1111
+ async resetSchemaAndStart(): Promise<void> {
1112
+ try {
1113
+ await this.native.resetSchemaAndStart();
1114
+ } catch (error) {
1115
+ throw mapNativeError(error);
1116
+ }
1117
+ }
1118
+
363
1119
  async syncNow(): Promise<void> {
364
1120
  try {
365
1121
  await this.native.syncNow();
@@ -368,17 +1124,21 @@ export class SynchroClient {
368
1124
  }
369
1125
  }
370
1126
 
371
- // -- Status & Events --
1127
+ // Status and events
372
1128
 
373
1129
  onStatusChange(callback: (status: SyncStatus) => void): Unsubscribe {
374
- const subscription = this.native.onStatusChange(
375
- (event: { status: string; retryAt: string | null }) => {
376
- callback({
377
- status: event.status as SyncStatusType,
378
- retryAt: event.retryAt ? new Date(event.retryAt) : null,
379
- });
380
- }
381
- );
1130
+ const subscription = this.native.onStatusChange((event) => {
1131
+ const status = parseStatus(event);
1132
+ if (status !== null) callback(status);
1133
+ });
1134
+ return () => subscription.remove();
1135
+ }
1136
+
1137
+ onSyncEvent(callback: (event: SyncEvent) => void): Unsubscribe {
1138
+ const subscription = this.native.onSyncEvent((event) => {
1139
+ const parsed = parseSyncEvent(event);
1140
+ if (parsed !== null) callback(parsed);
1141
+ });
382
1142
  return () => subscription.remove();
383
1143
  }
384
1144
 
@@ -405,20 +1165,40 @@ export class SynchroClient {
405
1165
  return () => subscription.remove();
406
1166
  }
407
1167
 
408
- onSnapshotRequired(
409
- handler: () => Promise<boolean>
410
- ): Unsubscribe {
411
- const subscription = this.native.onSnapshotRequired(
412
- (event: { requestID: string }) => {
413
- handler()
414
- .then((approved) => {
415
- this.native.resolveSnapshotRequest(event.requestID, approved);
416
- })
417
- .catch(() => {
418
- this.native.resolveSnapshotRequest(event.requestID, false);
419
- });
420
- }
421
- );
422
- return () => subscription.remove();
1168
+ }
1169
+
1170
+ export function configureInspection(
1171
+ client: SynchroClient,
1172
+ transportObservationCapacity: number,
1173
+ requireNewDatabase: boolean
1174
+ ): void {
1175
+ const owner = inspectionOwners.get(client);
1176
+ if (
1177
+ owner !== undefined &&
1178
+ activeNativeOwner === owner &&
1179
+ transportObservationCapacity === 0 &&
1180
+ !requireNewDatabase
1181
+ ) {
1182
+ return;
1183
+ }
1184
+ if (
1185
+ owner === undefined ||
1186
+ activeNativeOwner === owner ||
1187
+ typeof requireNewDatabase !== 'boolean' ||
1188
+ !Number.isSafeInteger(transportObservationCapacity) ||
1189
+ transportObservationCapacity < 0 ||
1190
+ transportObservationCapacity > 512
1191
+ ) {
1192
+ throw new InvalidResponseError('Inspection configuration is invalid');
1193
+ }
1194
+ inspectionCapacities.set(client, transportObservationCapacity);
1195
+ inspectionRequireNewDatabases.set(client, requireNewDatabase);
1196
+ }
1197
+
1198
+ export function nativeForInspection(client: SynchroClient): typeof NativeSynchro {
1199
+ const owner = inspectionOwners.get(client);
1200
+ if (owner === undefined || activeNativeOwner !== owner) {
1201
+ throw new SynchroError('NOT_CONNECTED', 'SynchroClient is not initialized');
423
1202
  }
1203
+ return NativeSynchro;
424
1204
  }