@trainstar/synchro-react-native 0.3.2 → 0.4.0-rc.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 (42) hide show
  1. package/README.md +58 -4
  2. package/SynchroReactNative.podspec +6 -0
  3. package/android/build.gradle +5 -1
  4. package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/SynchroModuleTransactionTest.kt +591 -0
  5. package/android/src/androidTest/kotlin/com/trainstar/synchro/rn/TransactionSessionTest.kt +314 -0
  6. package/android/src/main/kotlin/com/trainstar/synchro/rn/SynchroModule.kt +306 -110
  7. package/ios/SynchroModule.mm +27 -4
  8. package/ios/SynchroModule.swift +228 -84
  9. package/ios-tests/SynchroModuleTransactionTests.swift +707 -0
  10. package/ios-tests/TransactionSessionTests.swift +66 -0
  11. package/lib/module/NativeSynchro.js.map +1 -1
  12. package/lib/module/SynchroClient.js +115 -2
  13. package/lib/module/SynchroClient.js.map +1 -1
  14. package/lib/module/errors.js +15 -1
  15. package/lib/module/errors.js.map +1 -1
  16. package/lib/module/index.js +1 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/inspection.js +31 -3
  19. package/lib/module/inspection.js.map +1 -1
  20. package/lib/module/types.js +18 -0
  21. package/lib/module/types.js.map +1 -1
  22. package/lib/typescript/example/src/conformance/runner.d.ts +1 -0
  23. package/lib/typescript/example/src/conformance/runner.d.ts.map +1 -1
  24. package/lib/typescript/src/NativeSynchro.d.ts +7 -1
  25. package/lib/typescript/src/NativeSynchro.d.ts.map +1 -1
  26. package/lib/typescript/src/SynchroClient.d.ts +29 -1
  27. package/lib/typescript/src/SynchroClient.d.ts.map +1 -1
  28. package/lib/typescript/src/errors.d.ts +7 -1
  29. package/lib/typescript/src/errors.d.ts.map +1 -1
  30. package/lib/typescript/src/index.d.ts +3 -3
  31. package/lib/typescript/src/index.d.ts.map +1 -1
  32. package/lib/typescript/src/inspection.d.ts +13 -2
  33. package/lib/typescript/src/inspection.d.ts.map +1 -1
  34. package/lib/typescript/src/types.d.ts +44 -1
  35. package/lib/typescript/src/types.d.ts.map +1 -1
  36. package/package.json +3 -2
  37. package/src/NativeSynchro.ts +6 -1
  38. package/src/SynchroClient.ts +128 -1
  39. package/src/errors.ts +30 -0
  40. package/src/index.ts +7 -0
  41. package/src/inspection.ts +44 -2
  42. package/src/types.ts +49 -1
@@ -28,6 +28,8 @@ import type {
28
28
  MutationRejectionCode,
29
29
  PendingMutationInspection,
30
30
  RejectedMutationInspection,
31
+ RetainedMutationInspection,
32
+ RetainedRejectionInspection,
31
33
  ClientStateInspection,
32
34
  ScopeStateInspection,
33
35
  ScopeRowInspection,
@@ -117,6 +119,7 @@ const MUTATION_REJECTION_CODES: readonly MutationRejectionCode[] = [
117
119
  'policy_rejected',
118
120
  'validation_failed',
119
121
  'table_not_synced',
122
+ 'atomic_batch_rejected',
120
123
  ];
121
124
 
122
125
  const MUTATION_OPERATIONS = ['insert', 'upsert', 'update', 'delete'] as const;
@@ -438,6 +441,40 @@ function parsePendingMutationInspection(
438
441
  };
439
442
  }
440
443
 
444
+ export function parseRetainedMutationInspection(
445
+ value: unknown,
446
+ index: number
447
+ ): RetainedMutationInspection {
448
+ const name = `pending mutation at index ${index}`;
449
+ if (!isRecord(value)) {
450
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
451
+ }
452
+ if (value.representation === 'current') {
453
+ return { representation: 'current', ...parsePendingMutationInspection(value, index) };
454
+ }
455
+ if (value.representation !== 'legacy') {
456
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name} representation`);
457
+ }
458
+ if (!MUTATION_OPERATIONS.includes(value.operation as never)) {
459
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name} operation`);
460
+ }
461
+ if (!LOCAL_MUTATION_STATUSES.includes(value.status as never)) {
462
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name} status`);
463
+ }
464
+ return {
465
+ representation: 'legacy',
466
+ mutationID: requiredString(value.mutationID, `${name} ID`),
467
+ localOrder: requiredSafeInteger(value.localOrder, `${name} local order`),
468
+ tableName: requiredString(value.tableName, `${name} table name`),
469
+ recordID: requiredString(value.recordID, `${name} record ID`),
470
+ operation: value.operation as PendingMutationInspection['operation'],
471
+ baseVersion: nullableString(value.baseVersion, `${name} base version`),
472
+ clientVersion: requiredString(value.clientVersion, `${name} client version`),
473
+ status: value.status as PendingMutationInspection['status'],
474
+ sourceKind: requiredString(value.sourceKind, `${name} source kind`),
475
+ };
476
+ }
477
+
441
478
  function parseRejectedMutationInspection(
442
479
  value: unknown,
443
480
  index: number
@@ -466,6 +503,39 @@ function parseRejectedMutationInspection(
466
503
  };
467
504
  }
468
505
 
506
+ export function parseRetainedRejectionInspection(
507
+ value: unknown,
508
+ index: number
509
+ ): RetainedRejectionInspection {
510
+ const name = `rejected mutation at index ${index}`;
511
+ if (!isRecord(value)) {
512
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name}`);
513
+ }
514
+ if (value.representation === 'current') {
515
+ return { representation: 'current', ...parseRejectedMutationInspection(value, index) };
516
+ }
517
+ if (value.representation !== 'legacy') {
518
+ throw new InvalidResponseError(`Native bridge returned an invalid ${name} representation`);
519
+ }
520
+ const code = parseRejectionCode(value.code);
521
+ if (code === null) {
522
+ throw new InvalidResponseError(`Native bridge returned a missing ${name} code`);
523
+ }
524
+ return {
525
+ representation: 'legacy',
526
+ mutationID: requiredString(value.mutationID, `${name} ID`),
527
+ tableName: requiredString(value.tableName, `${name} table name`),
528
+ recordID: requiredString(value.recordID, `${name} record ID`),
529
+ status: parseMutationStatus(value.status),
530
+ code,
531
+ message: nullableString(value.message, `${name} message`),
532
+ serverRowJSON: nullableString(value.serverRowJSON, `${name} server row JSON`),
533
+ serverVersion: nullableString(value.serverVersion, `${name} server version`),
534
+ createdAt: requiredString(value.createdAt, `${name} creation time`),
535
+ updatedAt: requiredString(value.updatedAt, `${name} update time`),
536
+ };
537
+ }
538
+
469
539
  function parseScopeStateInspection(value: unknown, index: number): ScopeStateInspection {
470
540
  const name = `scope state at index ${index}`;
471
541
  if (!isRecord(value)) {
@@ -802,10 +872,28 @@ export class SynchroClient {
802
872
 
803
873
  async writeTransaction<T>(
804
874
  callback: (tx: Transaction) => Promise<T>
875
+ ): Promise<T> {
876
+ return this.runWriteTransaction(() => this.native.beginWriteTransaction(), callback);
877
+ }
878
+
879
+ /**
880
+ * Runs a write transaction whose synced mutations the server applies all
881
+ * together or not at all. The native SDK owns the group. An invalid group
882
+ * rolls back and rejects with `AtomicGroupInvalidError`.
883
+ */
884
+ async atomicWriteTransaction<T>(
885
+ callback: (tx: Transaction) => Promise<T>
886
+ ): Promise<T> {
887
+ return this.runWriteTransaction(() => this.native.beginAtomicWriteTransaction(), callback);
888
+ }
889
+
890
+ private async runWriteTransaction<T>(
891
+ begin: () => Promise<string>,
892
+ callback: (tx: Transaction) => Promise<T>
805
893
  ): Promise<T> {
806
894
  let txID: string;
807
895
  try {
808
- txID = await this.native.beginWriteTransaction();
896
+ txID = await begin();
809
897
  } catch (error) {
810
898
  throw mapNativeError(error);
811
899
  }
@@ -1025,6 +1113,11 @@ export class SynchroClient {
1025
1113
  }
1026
1114
  }
1027
1115
 
1116
+ /**
1117
+ * Returns the unresolved queue records.
1118
+ * @deprecated Use {@link SynchroClient.inspectRetainedMutationRecords}. This method keeps its
1119
+ * published behavior. It rejects when the native queue holds a legacy import.
1120
+ */
1028
1121
  async inspectPendingMutations(): Promise<PendingMutationInspection[]> {
1029
1122
  try {
1030
1123
  return parseNativeArray(
@@ -1036,6 +1129,11 @@ export class SynchroClient {
1036
1129
  }
1037
1130
  }
1038
1131
 
1132
+ /**
1133
+ * Returns every retained queue record, including local terminal states.
1134
+ * @deprecated Use {@link SynchroClient.inspectRetainedMutationRecords}. This method keeps its
1135
+ * published behavior. It rejects when the native queue holds a legacy import.
1136
+ */
1039
1137
  async inspectRetainedMutations(): Promise<PendingMutationInspection[]> {
1040
1138
  try {
1041
1139
  return parseNativeArray(
@@ -1047,6 +1145,23 @@ export class SynchroClient {
1047
1145
  }
1048
1146
  }
1049
1147
 
1148
+ /** Returns every retained queue record in its stored representation. */
1149
+ async inspectRetainedMutationRecords(): Promise<RetainedMutationInspection[]> {
1150
+ try {
1151
+ return parseNativeArray(
1152
+ await this.native.inspectRetainedMutationRecords(),
1153
+ 'retained mutation inspection'
1154
+ ).map(parseRetainedMutationInspection);
1155
+ } catch (error) {
1156
+ throw mapNativeError(error);
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * Returns the retained terminal outcomes.
1162
+ * @deprecated Use {@link SynchroClient.inspectRejectedMutationRecords}. This method keeps its
1163
+ * published behavior. It rejects when a legacy rejection is present.
1164
+ */
1050
1165
  async inspectRejectedMutations(): Promise<RejectedMutationInspection[]> {
1051
1166
  try {
1052
1167
  return parseNativeArray(
@@ -1058,6 +1173,18 @@ export class SynchroClient {
1058
1173
  }
1059
1174
  }
1060
1175
 
1176
+ /** Returns the retained terminal outcomes in their stored representation. */
1177
+ async inspectRejectedMutationRecords(): Promise<RetainedRejectionInspection[]> {
1178
+ try {
1179
+ return parseNativeArray(
1180
+ await this.native.inspectRejectedMutationRecords(),
1181
+ 'rejected mutation inspection'
1182
+ ).map(parseRetainedRejectionInspection);
1183
+ } catch (error) {
1184
+ throw mapNativeError(error);
1185
+ }
1186
+ }
1187
+
1061
1188
  async clearRejectedMutations(): Promise<void> {
1062
1189
  try {
1063
1190
  await this.native.clearRejectedMutations();
package/src/errors.ts CHANGED
@@ -33,6 +33,7 @@ export const NATIVE_ERROR_CODES = [
33
33
  'TRANSACTION_TIMEOUT',
34
34
  'INVALID_CONFIG',
35
35
  'CLIENT_ALREADY_ACTIVE',
36
+ 'atomic_group_invalid',
36
37
  'UNKNOWN',
37
38
  ] as const;
38
39
 
@@ -233,6 +234,25 @@ export class TransactionTimeoutError extends SynchroError {
233
234
  }
234
235
  }
235
236
 
237
+ export const ATOMIC_GROUP_INVALID_REASONS = [
238
+ 'deleteFollowedByWrite',
239
+ 'tooManyMutations',
240
+ 'mutationTooLarge',
241
+ 'requestTooLarge',
242
+ ] as const;
243
+
244
+ export type AtomicGroupInvalidReason = (typeof ATOMIC_GROUP_INVALID_REASONS)[number];
245
+
246
+ export class AtomicGroupInvalidError extends SynchroError {
247
+ readonly reason: AtomicGroupInvalidReason;
248
+
249
+ constructor(reason: AtomicGroupInvalidReason) {
250
+ super('atomic_group_invalid', `Atomic write group is invalid: ${reason}`);
251
+ this.name = 'AtomicGroupInvalidError';
252
+ this.reason = reason;
253
+ }
254
+ }
255
+
236
256
  interface NativeErrorLike {
237
257
  code?: string;
238
258
  message?: string;
@@ -439,6 +459,16 @@ export function mapNativeError(error: unknown): SynchroError {
439
459
  return new TransactionTimeoutError();
440
460
  case 'INVALID_CONFIG':
441
461
  return new SynchroError('INVALID_CONFIG', message);
462
+ case 'atomic_group_invalid':
463
+ try {
464
+ return new AtomicGroupInvalidError(
465
+ requiredEnum(userInfo.reason, ATOMIC_GROUP_INVALID_REASONS, 'atomic group invalid reason')
466
+ );
467
+ } catch (parseError) {
468
+ return parseError instanceof SynchroError
469
+ ? parseError
470
+ : new InvalidResponseError('Native bridge returned invalid atomic group details');
471
+ }
442
472
  default:
443
473
  return new SynchroError('UNKNOWN', message);
444
474
  }
package/src/index.ts CHANGED
@@ -30,7 +30,11 @@ export type {
30
30
  SchemaRef,
31
31
  AuthoredMutationField,
32
32
  PendingMutationInspection,
33
+ LegacyMutationInspection,
34
+ RetainedMutationInspection,
33
35
  RejectedMutationInspection,
36
+ LegacyRejectionInspection,
37
+ RetainedRejectionInspection,
34
38
  SchemaAction,
35
39
  SyncSchemaEvent,
36
40
  SyncMutationEvent,
@@ -71,6 +75,8 @@ export {
71
75
  AlreadyStartedError,
72
76
  NotStartedError,
73
77
  TransactionTimeoutError,
78
+ AtomicGroupInvalidError,
79
+ ATOMIC_GROUP_INVALID_REASONS,
74
80
  NATIVE_ERROR_CODES,
75
81
  mapNativeError,
76
82
  } from './errors';
@@ -78,5 +84,6 @@ export {
78
84
  export type {
79
85
  NativeErrorCode,
80
86
  SchemaUnsupportedReason,
87
+ AtomicGroupInvalidReason,
81
88
  PushRejectedMutation,
82
89
  } from './errors';
package/src/inspection.ts CHANGED
@@ -2,12 +2,19 @@ import {
2
2
  configureInspection,
3
3
  nativeForInspection,
4
4
  parseClientStateInspection,
5
+ parseRetainedMutationInspection,
6
+ parseRetainedRejectionInspection,
5
7
  parseTransportObservationSnapshot,
6
8
  SynchroClient,
7
9
  } from './SynchroClient';
8
10
  import { InvalidResponseError, mapNativeError } from './errors';
11
+ import { assertValidSQLiteBindParams } from './sqliteValues';
9
12
  import type {
10
13
  ClientStateInspection,
14
+ RetainedMutationInspection,
15
+ RetainedRejectionInspection,
16
+ Row,
17
+ SQLStatement,
11
18
  TransportObservationSnapshot,
12
19
  TransportOperationClass,
13
20
  } from './types';
@@ -45,6 +52,18 @@ export interface DurableStateInspection {
45
52
  }>;
46
53
  }
47
54
 
55
+ /**
56
+ * One read-only snapshot of client state and retained details. A detail list is
57
+ * null when its record count exceeds the native capture bound. `applicationRows`
58
+ * holds the rows that the requested read statements returned inside the snapshot.
59
+ */
60
+ export interface ClientStateSnapshotInspection {
61
+ clientState: ClientStateInspection;
62
+ retainedMutations: RetainedMutationInspection[] | null;
63
+ rejectedMutations: RetainedRejectionInspection[] | null;
64
+ applicationRows: Row[];
65
+ }
66
+
48
67
  export interface SynchroInspectionOptions {
49
68
  transportObservationCapacity?: number;
50
69
  requireNewDatabase?: boolean;
@@ -62,9 +81,22 @@ export class SynchroInspection {
62
81
  );
63
82
  }
64
83
 
65
- async clientState(): Promise<ClientStateInspection> {
84
+ async captureSnapshot(rowStatements: SQLStatement[] = []): Promise<ClientStateSnapshotInspection> {
66
85
  try {
67
- return parseClientStateInspection(parseJSON(await nativeForInspection(this.client).inspectClientState()));
86
+ const statements = rowStatements.map((statement) => ({
87
+ sql: statement.sql,
88
+ params: assertValidSQLiteBindParams(statement.params ?? []),
89
+ }));
90
+ const result = await nativeForInspection(this.client).inspectClientStateSnapshot(statements);
91
+ const snapshot = requireRecord(parseJSON(result.inspection), 'client state snapshot');
92
+ return {
93
+ clientState: parseClientStateInspection(snapshot.client_state),
94
+ retainedMutations:
95
+ nullableArray(snapshot.retained_mutations, 'retained mutations')?.map(parseRetainedMutationInspection) ?? null,
96
+ rejectedMutations:
97
+ nullableArray(snapshot.rejected_mutations, 'rejected mutations')?.map(parseRetainedRejectionInspection) ?? null,
98
+ applicationRows: [...result.applicationRows] as Row[],
99
+ };
68
100
  } catch (error) {
69
101
  throw mapNativeError(error);
70
102
  }
@@ -187,6 +219,16 @@ function parseDurableState(value: unknown): DurableStateInspection {
187
219
  return proof as unknown as DurableStateInspection;
188
220
  }
189
221
 
222
+ function nullableArray(value: unknown, name: string): unknown[] | null {
223
+ if (value === null) {
224
+ return null;
225
+ }
226
+ if (!Array.isArray(value)) {
227
+ throw new InvalidResponseError(`Native bridge returned invalid ${name}`);
228
+ }
229
+ return value;
230
+ }
231
+
190
232
  function requireRecord(value: unknown, name: string): Record<string, unknown> {
191
233
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
192
234
  throw new InvalidResponseError(`Native bridge returned invalid ${name}`);
package/src/types.ts CHANGED
@@ -186,7 +186,8 @@ export type MutationRejectionCode =
186
186
  | 'schema_incompatible'
187
187
  | 'policy_rejected'
188
188
  | 'validation_failed'
189
- | 'table_not_synced';
189
+ | 'table_not_synced'
190
+ | 'atomic_batch_rejected';
190
191
 
191
192
  export interface SchemaRef {
192
193
  version: number;
@@ -199,6 +200,7 @@ export interface AuthoredMutationField {
199
200
  value: JSONValue;
200
201
  }
201
202
 
203
+ /** A retained mutation with its complete ledger binding and authored values. */
202
204
  export interface PendingMutationInspection {
203
205
  mutationID: string;
204
206
  localOrder: number;
@@ -220,6 +222,29 @@ export interface PendingMutationInspection {
220
222
  authoredFields: AuthoredMutationField[];
221
223
  }
222
224
 
225
+ /**
226
+ * A retained mutation that a queue from before the mutation ledger imported.
227
+ * It has only the fields that the old queue stored.
228
+ */
229
+ export interface LegacyMutationInspection {
230
+ representation: 'legacy';
231
+ mutationID: string;
232
+ localOrder: number;
233
+ tableName: string;
234
+ recordID: string;
235
+ operation: MutationOperation;
236
+ baseVersion: string | null;
237
+ clientVersion: string;
238
+ status: LocalMutationStatus;
239
+ sourceKind: string;
240
+ }
241
+
242
+ /** One retained mutation in its stored representation. */
243
+ export type RetainedMutationInspection =
244
+ | (PendingMutationInspection & { representation: 'current' })
245
+ | LegacyMutationInspection;
246
+
247
+ /** A retained rejection with its exact mutation and rejection JSON. */
223
248
  export interface RejectedMutationInspection {
224
249
  mutationID: string;
225
250
  tableName: string;
@@ -235,6 +260,29 @@ export interface RejectedMutationInspection {
235
260
  updatedAt: string;
236
261
  }
237
262
 
263
+ /**
264
+ * A retained rejection that a database from before the mutation ledger stored.
265
+ * It has only the fields that the old rejection table stored.
266
+ */
267
+ export interface LegacyRejectionInspection {
268
+ representation: 'legacy';
269
+ mutationID: string;
270
+ tableName: string;
271
+ recordID: string;
272
+ status: MutationStatus;
273
+ code: MutationRejectionCode;
274
+ message: string | null;
275
+ serverRowJSON: string | null;
276
+ serverVersion: string | null;
277
+ createdAt: string;
278
+ updatedAt: string;
279
+ }
280
+
281
+ /** One retained rejection in its stored representation. */
282
+ export type RetainedRejectionInspection =
283
+ | (RejectedMutationInspection & { representation: 'current' })
284
+ | LegacyRejectionInspection;
285
+
238
286
  export interface ScopeStateInspection {
239
287
  scopeID: string;
240
288
  cursor: string | null;