@aws-amplify/datastore 4.1.12-unstable.43d494b.2 → 4.2.0
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.
- package/lib/predicates/index.js +1 -1
- package/lib/predicates/index.js.map +1 -1
- package/lib/sync/index.js +7 -4
- package/lib/sync/index.js.map +1 -1
- package/lib/sync/processors/errorMaps.js +3 -2
- package/lib/sync/processors/errorMaps.js.map +1 -1
- package/lib/sync/processors/mutation.js +37 -16
- package/lib/sync/processors/mutation.js.map +1 -1
- package/lib/sync/processors/sync.js +47 -5
- package/lib/sync/processors/sync.js.map +1 -1
- package/lib-esm/predicates/index.js +1 -1
- package/lib-esm/predicates/index.js.map +1 -1
- package/lib-esm/sync/index.js +7 -4
- package/lib-esm/sync/index.js.map +1 -1
- package/lib-esm/sync/processors/errorMaps.js +3 -2
- package/lib-esm/sync/processors/errorMaps.js.map +1 -1
- package/lib-esm/sync/processors/mutation.js +37 -16
- package/lib-esm/sync/processors/mutation.js.map +1 -1
- package/lib-esm/sync/processors/sync.js +48 -6
- package/lib-esm/sync/processors/sync.js.map +1 -1
- package/package.json +7 -7
- package/src/predicates/index.ts +3 -1
- package/src/sync/index.ts +187 -180
- package/src/sync/processors/errorMaps.ts +2 -1
- package/src/sync/processors/mutation.ts +16 -0
- package/src/sync/processors/sync.ts +47 -14
package/src/sync/index.ts
CHANGED
|
@@ -225,199 +225,201 @@ export class SyncEngine {
|
|
|
225
225
|
|
|
226
226
|
// this is awaited at the bottom. so, we don't need to register
|
|
227
227
|
// this explicitly with the context. it's already contained.
|
|
228
|
-
const startPromise = new Promise(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this.online
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
this.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
228
|
+
const startPromise = new Promise<void>(
|
|
229
|
+
(doneStarting, failedStarting) => {
|
|
230
|
+
this.datastoreConnectivity.status().subscribe(
|
|
231
|
+
async ({ online }) =>
|
|
232
|
+
this.runningProcesses.isOpen &&
|
|
233
|
+
this.runningProcesses.add(async onTerminate => {
|
|
234
|
+
// From offline to online
|
|
235
|
+
if (online && !this.online) {
|
|
236
|
+
this.online = online;
|
|
237
|
+
|
|
238
|
+
observer.next({
|
|
239
|
+
type: ControlMessage.SYNC_ENGINE_NETWORK_STATUS,
|
|
240
|
+
data: {
|
|
241
|
+
active: this.online,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
let ctlSubsObservable: Observable<CONTROL_MSG>;
|
|
246
|
+
let dataSubsObservable: Observable<
|
|
247
|
+
[TransformerMutationType, SchemaModel, PersistentModel]
|
|
248
|
+
>;
|
|
249
|
+
|
|
250
|
+
// NOTE: need a way to override this conditional for testing.
|
|
251
|
+
if (isNode) {
|
|
252
|
+
logger.warn(
|
|
253
|
+
'Realtime disabled when in a server-side environment'
|
|
254
|
+
);
|
|
255
|
+
} else {
|
|
256
|
+
this.stopDisruptionListener =
|
|
257
|
+
this.startDisruptionListener();
|
|
258
|
+
//#region GraphQL Subscriptions
|
|
259
|
+
[ctlSubsObservable, dataSubsObservable] =
|
|
260
|
+
this.subscriptionsProcessor.start();
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
await new Promise<void>((resolve, reject) => {
|
|
264
|
+
onTerminate.then(reject);
|
|
265
|
+
const ctlSubsSubscription =
|
|
266
|
+
ctlSubsObservable.subscribe({
|
|
267
|
+
next: msg => {
|
|
268
|
+
if (msg === CONTROL_MSG.CONNECTED) {
|
|
269
|
+
resolve();
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
error: err => {
|
|
273
|
+
reject(err);
|
|
274
|
+
const handleDisconnect =
|
|
275
|
+
this.disconnectionHandler();
|
|
276
|
+
handleDisconnect(err);
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
subscriptions.push(ctlSubsSubscription);
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
observer.error(err);
|
|
284
|
+
failedStarting();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
260
287
|
|
|
288
|
+
logger.log('Realtime ready');
|
|
289
|
+
|
|
290
|
+
observer.next({
|
|
291
|
+
type: ControlMessage.SYNC_ENGINE_SUBSCRIPTIONS_ESTABLISHED,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
//#endregion
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
//#region Base & Sync queries
|
|
261
298
|
try {
|
|
262
|
-
await new Promise((resolve, reject) => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
299
|
+
await new Promise<void>((resolve, reject) => {
|
|
300
|
+
const syncQuerySubscription =
|
|
301
|
+
this.syncQueriesObservable().subscribe({
|
|
302
|
+
next: message => {
|
|
303
|
+
const { type } = message;
|
|
304
|
+
|
|
305
|
+
if (
|
|
306
|
+
type ===
|
|
307
|
+
ControlMessage.SYNC_ENGINE_SYNC_QUERIES_READY
|
|
308
|
+
) {
|
|
268
309
|
resolve();
|
|
269
310
|
}
|
|
311
|
+
|
|
312
|
+
observer.next(message);
|
|
270
313
|
},
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const handleDisconnect =
|
|
274
|
-
this.disconnectionHandler();
|
|
275
|
-
handleDisconnect(err);
|
|
314
|
+
complete: () => {
|
|
315
|
+
resolve();
|
|
276
316
|
},
|
|
277
|
-
|
|
278
|
-
|
|
317
|
+
error: error => {
|
|
318
|
+
reject(error);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
279
321
|
|
|
280
|
-
|
|
322
|
+
if (syncQuerySubscription) {
|
|
323
|
+
subscriptions.push(syncQuerySubscription);
|
|
324
|
+
}
|
|
281
325
|
});
|
|
282
|
-
} catch (
|
|
283
|
-
observer.error(
|
|
326
|
+
} catch (error) {
|
|
327
|
+
observer.error(error);
|
|
284
328
|
failedStarting();
|
|
285
329
|
return;
|
|
286
330
|
}
|
|
331
|
+
//#endregion
|
|
287
332
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
333
|
+
//#region process mutations (outbox)
|
|
334
|
+
subscriptions.push(
|
|
335
|
+
this.mutationsProcessor
|
|
336
|
+
.start()
|
|
337
|
+
.subscribe(
|
|
338
|
+
({ modelDefinition, model: item, hasMore }) =>
|
|
339
|
+
this.runningProcesses.add(async () => {
|
|
340
|
+
const modelConstructor = this.userModelClasses[
|
|
341
|
+
modelDefinition.name
|
|
342
|
+
] as PersistentModelConstructor<any>;
|
|
343
|
+
|
|
344
|
+
const model = this.modelInstanceCreator(
|
|
345
|
+
modelConstructor,
|
|
346
|
+
item
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await this.storage.runExclusive(storage =>
|
|
350
|
+
this.modelMerger.merge(
|
|
351
|
+
storage,
|
|
352
|
+
model,
|
|
353
|
+
modelDefinition
|
|
354
|
+
)
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
observer.next({
|
|
358
|
+
type: ControlMessage.SYNC_ENGINE_OUTBOX_MUTATION_PROCESSED,
|
|
359
|
+
data: {
|
|
360
|
+
model: modelConstructor,
|
|
361
|
+
element: model,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
observer.next({
|
|
366
|
+
type: ControlMessage.SYNC_ENGINE_OUTBOX_STATUS,
|
|
367
|
+
data: {
|
|
368
|
+
isEmpty: !hasMore,
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
}, 'mutation processor event')
|
|
372
|
+
)
|
|
373
|
+
);
|
|
294
374
|
//#endregion
|
|
295
|
-
}
|
|
296
375
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
376
|
+
//#region Merge subscriptions buffer
|
|
377
|
+
// TODO: extract to function
|
|
378
|
+
if (!isNode) {
|
|
379
|
+
subscriptions.push(
|
|
380
|
+
dataSubsObservable!.subscribe(
|
|
381
|
+
([_transformerMutationType, modelDefinition, item]) =>
|
|
382
|
+
this.runningProcesses.add(async () => {
|
|
383
|
+
const modelConstructor = this.userModelClasses[
|
|
384
|
+
modelDefinition.name
|
|
385
|
+
] as PersistentModelConstructor<any>;
|
|
386
|
+
|
|
387
|
+
const model = this.modelInstanceCreator(
|
|
388
|
+
modelConstructor,
|
|
389
|
+
item
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
await this.storage.runExclusive(storage =>
|
|
393
|
+
this.modelMerger.merge(
|
|
394
|
+
storage,
|
|
395
|
+
model,
|
|
396
|
+
modelDefinition
|
|
397
|
+
)
|
|
398
|
+
);
|
|
399
|
+
}, 'subscription dataSubsObservable event')
|
|
400
|
+
)
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
//#endregion
|
|
404
|
+
} else if (!online) {
|
|
405
|
+
this.online = online;
|
|
321
406
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
407
|
+
observer.next({
|
|
408
|
+
type: ControlMessage.SYNC_ENGINE_NETWORK_STATUS,
|
|
409
|
+
data: {
|
|
410
|
+
active: this.online,
|
|
411
|
+
},
|
|
325
412
|
});
|
|
326
|
-
} catch (error) {
|
|
327
|
-
observer.error(error);
|
|
328
|
-
failedStarting();
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
//#endregion
|
|
332
|
-
|
|
333
|
-
//#region process mutations (outbox)
|
|
334
|
-
subscriptions.push(
|
|
335
|
-
this.mutationsProcessor
|
|
336
|
-
.start()
|
|
337
|
-
.subscribe(({ modelDefinition, model: item, hasMore }) =>
|
|
338
|
-
this.runningProcesses.add(async () => {
|
|
339
|
-
const modelConstructor = this.userModelClasses[
|
|
340
|
-
modelDefinition.name
|
|
341
|
-
] as PersistentModelConstructor<any>;
|
|
342
|
-
|
|
343
|
-
const model = this.modelInstanceCreator(
|
|
344
|
-
modelConstructor,
|
|
345
|
-
item
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
await this.storage.runExclusive(storage =>
|
|
349
|
-
this.modelMerger.merge(
|
|
350
|
-
storage,
|
|
351
|
-
model,
|
|
352
|
-
modelDefinition
|
|
353
|
-
)
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
observer.next({
|
|
357
|
-
type: ControlMessage.SYNC_ENGINE_OUTBOX_MUTATION_PROCESSED,
|
|
358
|
-
data: {
|
|
359
|
-
model: modelConstructor,
|
|
360
|
-
element: model,
|
|
361
|
-
},
|
|
362
|
-
});
|
|
363
413
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
data: {
|
|
367
|
-
isEmpty: !hasMore,
|
|
368
|
-
},
|
|
369
|
-
});
|
|
370
|
-
}, 'mutation processor event')
|
|
371
|
-
)
|
|
372
|
-
);
|
|
373
|
-
//#endregion
|
|
374
|
-
|
|
375
|
-
//#region Merge subscriptions buffer
|
|
376
|
-
// TODO: extract to function
|
|
377
|
-
if (!isNode) {
|
|
378
|
-
subscriptions.push(
|
|
379
|
-
dataSubsObservable!.subscribe(
|
|
380
|
-
([_transformerMutationType, modelDefinition, item]) =>
|
|
381
|
-
this.runningProcesses.add(async () => {
|
|
382
|
-
const modelConstructor = this.userModelClasses[
|
|
383
|
-
modelDefinition.name
|
|
384
|
-
] as PersistentModelConstructor<any>;
|
|
385
|
-
|
|
386
|
-
const model = this.modelInstanceCreator(
|
|
387
|
-
modelConstructor,
|
|
388
|
-
item
|
|
389
|
-
);
|
|
390
|
-
|
|
391
|
-
await this.storage.runExclusive(storage =>
|
|
392
|
-
this.modelMerger.merge(
|
|
393
|
-
storage,
|
|
394
|
-
model,
|
|
395
|
-
modelDefinition
|
|
396
|
-
)
|
|
397
|
-
);
|
|
398
|
-
}, 'subscription dataSubsObservable event')
|
|
399
|
-
)
|
|
400
|
-
);
|
|
414
|
+
subscriptions.forEach(sub => sub.unsubscribe());
|
|
415
|
+
subscriptions = [];
|
|
401
416
|
}
|
|
402
|
-
//#endregion
|
|
403
|
-
} else if (!online) {
|
|
404
|
-
this.online = online;
|
|
405
|
-
|
|
406
|
-
observer.next({
|
|
407
|
-
type: ControlMessage.SYNC_ENGINE_NETWORK_STATUS,
|
|
408
|
-
data: {
|
|
409
|
-
active: this.online,
|
|
410
|
-
},
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
subscriptions.forEach(sub => sub.unsubscribe());
|
|
414
|
-
subscriptions = [];
|
|
415
|
-
}
|
|
416
417
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
418
|
+
doneStarting();
|
|
419
|
+
}, 'datastore connectivity event')
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
);
|
|
421
423
|
|
|
422
424
|
this.storage
|
|
423
425
|
.observe(null, null, ownSymbol)
|
|
@@ -567,7 +569,7 @@ export class SyncEngine {
|
|
|
567
569
|
let start: number;
|
|
568
570
|
let syncDuration: number;
|
|
569
571
|
let lastStartedAt: number;
|
|
570
|
-
await new Promise((resolve, reject) => {
|
|
572
|
+
await new Promise<void>((resolve, reject) => {
|
|
571
573
|
if (!this.runningProcesses.isOpen) resolve();
|
|
572
574
|
onTerminate.then(() => resolve());
|
|
573
575
|
syncQueriesSubscription = this.syncQueriesProcessor
|
|
@@ -1122,10 +1124,15 @@ export class SyncEngine {
|
|
|
1122
1124
|
* Schedule a sync to start when syncQueriesObservable enters sleep state
|
|
1123
1125
|
* Start sync immediately if syncQueriesObservable is already in sleep state
|
|
1124
1126
|
*/
|
|
1125
|
-
private scheduleSync()
|
|
1126
|
-
return
|
|
1127
|
-
|
|
1128
|
-
this.
|
|
1129
|
-
|
|
1127
|
+
private scheduleSync() {
|
|
1128
|
+
return (
|
|
1129
|
+
this.runningProcesses.isOpen &&
|
|
1130
|
+
this.runningProcesses.add(() =>
|
|
1131
|
+
this.waitForSleepState.then(() => {
|
|
1132
|
+
// unsleepSyncQueriesObservable will be set if waitForSleepState has resolved
|
|
1133
|
+
this.unsleepSyncQueriesObservable!();
|
|
1134
|
+
})
|
|
1135
|
+
)
|
|
1136
|
+
);
|
|
1130
1137
|
}
|
|
1131
1138
|
}
|
|
@@ -22,6 +22,7 @@ export const mutationErrorMap: ErrorMap = {
|
|
|
22
22
|
ConfigError: () => false,
|
|
23
23
|
Transient: error => connectionTimeout(error) || serverError(error),
|
|
24
24
|
Unauthorized: error =>
|
|
25
|
+
error.message === 'Unauthorized' ||
|
|
25
26
|
/^Request failed with status code 401/.test(error.message),
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -44,7 +45,7 @@ export const syncErrorMap: ErrorMap = {
|
|
|
44
45
|
BadRecord: error => /^Cannot return \w+ for [\w-_]+ type/.test(error.message),
|
|
45
46
|
ConfigError: () => false,
|
|
46
47
|
Transient: error => connectionTimeout(error) || serverError(error),
|
|
47
|
-
Unauthorized: ()
|
|
48
|
+
Unauthorized: error => (error as any).errorType === 'Unauthorized',
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
/**
|
|
@@ -231,6 +231,22 @@ class MutationProcessor {
|
|
|
231
231
|
operationAuthModes[authModeAttempts - 1]
|
|
232
232
|
}`
|
|
233
233
|
);
|
|
234
|
+
try {
|
|
235
|
+
await this.errorHandler({
|
|
236
|
+
recoverySuggestion:
|
|
237
|
+
'Ensure app code is up to date, auth directives exist and are correct on each model, and that server-side data has not been invalidated by a schema change. If the problem persists, search for or create an issue: https://github.com/aws-amplify/amplify-js/issues',
|
|
238
|
+
localModel: null!,
|
|
239
|
+
message: error.message,
|
|
240
|
+
model: modelConstructor.name,
|
|
241
|
+
operation: opName,
|
|
242
|
+
errorType: getMutationErrorType(error),
|
|
243
|
+
process: ProcessName.sync,
|
|
244
|
+
remoteModel: null!,
|
|
245
|
+
cause: error,
|
|
246
|
+
});
|
|
247
|
+
} catch (e) {
|
|
248
|
+
logger.error('Mutation error handler failed with:', e);
|
|
249
|
+
}
|
|
234
250
|
throw error;
|
|
235
251
|
}
|
|
236
252
|
logger.debug(
|
|
@@ -227,6 +227,7 @@ class SyncProcessor {
|
|
|
227
227
|
// Catch client-side (GraphQLAuthError) & 401/403 errors here so that we don't continue to retry
|
|
228
228
|
const clientOrForbiddenErrorMessage =
|
|
229
229
|
getClientSideAuthError(error) || getForbiddenError(error);
|
|
230
|
+
|
|
230
231
|
if (clientOrForbiddenErrorMessage) {
|
|
231
232
|
logger.error('Sync processor retry error:', error);
|
|
232
233
|
throw new NonRetryableError(clientOrForbiddenErrorMessage);
|
|
@@ -284,20 +285,44 @@ class SyncProcessor {
|
|
|
284
285
|
});
|
|
285
286
|
}
|
|
286
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Handle $util.unauthorized() in resolver request mapper, which responses with something
|
|
290
|
+
* like this:
|
|
291
|
+
*
|
|
292
|
+
* ```
|
|
293
|
+
* {
|
|
294
|
+
* data: { syncYourModel: null },
|
|
295
|
+
* errors: [
|
|
296
|
+
* {
|
|
297
|
+
* path: ['syncLegacyJSONComments'],
|
|
298
|
+
* data: null,
|
|
299
|
+
* errorType: 'Unauthorized',
|
|
300
|
+
* errorInfo: null,
|
|
301
|
+
* locations: [{ line: 2, column: 3, sourceName: null }],
|
|
302
|
+
* message:
|
|
303
|
+
* 'Not Authorized to access syncYourModel on type Query',
|
|
304
|
+
* },
|
|
305
|
+
* ],
|
|
306
|
+
* }
|
|
307
|
+
* ```
|
|
308
|
+
*
|
|
309
|
+
* The correct handling for this is to signal that we've encountered a non-retryable error,
|
|
310
|
+
* since the server has responded with an auth error and *NO DATA* at this point.
|
|
311
|
+
*/
|
|
287
312
|
if (unauthorized) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
313
|
+
this.errorHandler({
|
|
314
|
+
recoverySuggestion:
|
|
315
|
+
'Ensure app code is up to date, auth directives exist and are correct on each model, and that server-side data has not been invalidated by a schema change. If the problem persists, search for or create an issue: https://github.com/aws-amplify/amplify-js/issues',
|
|
316
|
+
localModel: null!,
|
|
317
|
+
message: error.message,
|
|
318
|
+
model: modelDefinition.name,
|
|
319
|
+
operation: opName,
|
|
320
|
+
errorType: getSyncErrorType(error.errors[0]),
|
|
321
|
+
process: ProcessName.sync,
|
|
322
|
+
remoteModel: null!,
|
|
323
|
+
cause: error,
|
|
324
|
+
});
|
|
325
|
+
throw new NonRetryableError(error);
|
|
301
326
|
}
|
|
302
327
|
|
|
303
328
|
if (result.data?.[opName].items?.length) {
|
|
@@ -405,7 +430,15 @@ class SyncProcessor {
|
|
|
405
430
|
} catch (e) {
|
|
406
431
|
logger.error('Sync error handler failed with:', e);
|
|
407
432
|
}
|
|
408
|
-
|
|
433
|
+
/**
|
|
434
|
+
* If there's an error, this model fails, but the rest of the sync should
|
|
435
|
+
* continue. To facilitate this, we explicitly mark this model as `done`
|
|
436
|
+
* with no items and allow the loop to continue organically. This ensures
|
|
437
|
+
* all callbacks (subscription messages) happen as normal, so anything
|
|
438
|
+
* waiting on them knows the model is as done as it can be.
|
|
439
|
+
*/
|
|
440
|
+
done = true;
|
|
441
|
+
items = [];
|
|
409
442
|
}
|
|
410
443
|
|
|
411
444
|
recordsReceived += items.length;
|