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