UNPKG

@aws-amplify/datastore

Version:

AppSyncLocal support for aws-amplify

326 lines (324 loc) • 16.9 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.SyncProcessor = void 0; const internals_1 = require("@aws-amplify/api/internals"); const rxjs_1 = require("rxjs"); const utils_1 = require("@aws-amplify/core/internals/utils"); const core_1 = require("@aws-amplify/core"); const types_1 = require("../../types"); const utils_2 = require("../utils"); const predicates_1 = require("../../predicates"); const errorMaps_1 = require("./errorMaps"); const opResultDefaults = { items: [], nextToken: null, startedAt: null, }; const logger = new core_1.ConsoleLogger('DataStore'); class SyncProcessor { constructor(schema, syncPredicates, amplifyConfig = {}, authModeStrategy, errorHandler, amplifyContext) { this.schema = schema; this.syncPredicates = syncPredicates; this.amplifyConfig = amplifyConfig; this.authModeStrategy = authModeStrategy; this.errorHandler = errorHandler; this.amplifyContext = amplifyContext; this.typeQuery = new WeakMap(); this.runningProcesses = new utils_1.BackgroundProcessManager(); amplifyContext.InternalAPI = amplifyContext.InternalAPI || internals_1.InternalAPI; this.generateQueries(); } generateQueries() { Object.values(this.schema.namespaces).forEach(namespace => { Object.values(namespace.models) .filter(({ syncable }) => syncable) .forEach(model => { const [[, ...opNameQuery]] = (0, utils_2.buildGraphQLOperation)(namespace, model, 'LIST'); this.typeQuery.set(model, opNameQuery); }); }); } graphqlFilterFromPredicate(model) { if (!this.syncPredicates) { return null; } const predicatesGroup = predicates_1.ModelPredicateCreator.getPredicates(this.syncPredicates.get(model), false); if (!predicatesGroup) { return null; } return (0, utils_2.predicateToGraphQLFilter)(predicatesGroup); } async retrievePage(modelDefinition, lastSync, nextToken, limit = null, filter, onTerminate) { const [opName, query] = this.typeQuery.get(modelDefinition); const variables = { limit, nextToken, lastSync, filter, }; const modelAuthModes = await (0, utils_2.getModelAuthModes)({ authModeStrategy: this.authModeStrategy, defaultAuthMode: this.amplifyConfig.aws_appsync_authenticationType, modelName: modelDefinition.name, schema: this.schema, }); // sync only needs the READ auth mode(s) const readAuthModes = modelAuthModes.READ; let authModeAttempts = 0; const authModeRetry = async () => { if (!this.runningProcesses.isOpen) { throw new Error('sync.retreievePage termination was requested. Exiting.'); } try { logger.debug(`Attempting sync with authMode: ${readAuthModes[authModeAttempts]}`); const response = await this.jitteredRetry({ query, variables, opName, modelDefinition, authMode: readAuthModes[authModeAttempts], onTerminate, }); logger.debug(`Sync successful with authMode: ${readAuthModes[authModeAttempts]}`); return response; } catch (error) { authModeAttempts++; if (authModeAttempts >= readAuthModes.length) { const authMode = readAuthModes[authModeAttempts - 1]; logger.debug(`Sync failed with authMode: ${authMode}`, error); if ((0, utils_2.getClientSideAuthError)(error) || (0, utils_2.getForbiddenError)(error)) { // return empty list of data so DataStore will continue to sync other models logger.warn(`User is unauthorized to query ${opName} with auth mode ${authMode}. No data could be returned.`); return { data: { [opName]: opResultDefaults, }, }; } throw error; } logger.debug(`Sync failed with authMode: ${readAuthModes[authModeAttempts - 1]}. Retrying with authMode: ${readAuthModes[authModeAttempts]}`); return authModeRetry(); } }; const { data } = await authModeRetry(); const { [opName]: opResult } = data; const { items, nextToken: newNextToken, startedAt } = opResult; return { nextToken: newNextToken, startedAt, items, }; } async jitteredRetry({ query, variables, opName, modelDefinition, authMode, onTerminate, }) { return (0, utils_1.jitteredExponentialRetry)(async (retriedQuery, retriedVariables) => { try { const authToken = await (0, utils_2.getTokenForCustomAuth)(authMode, this.amplifyConfig); const customUserAgentDetails = { category: utils_1.Category.DataStore, action: utils_1.DataStoreAction.GraphQl, }; return await this.amplifyContext.InternalAPI.graphql({ query: retriedQuery, variables: retriedVariables, authMode, authToken, }, undefined, customUserAgentDetails); // TODO: onTerminate.then(() => API.cancel(...)) } catch (error) { // Catch client-side (GraphQLAuthError) & 401/403 errors here so that we don't continue to retry const clientOrForbiddenErrorMessage = (0, utils_2.getClientSideAuthError)(error) || (0, utils_2.getForbiddenError)(error); if (clientOrForbiddenErrorMessage) { logger.error('Sync processor retry error:', error); throw new utils_1.NonRetryableError(clientOrForbiddenErrorMessage); } const hasItems = Boolean(error?.data?.[opName]?.items); const unauthorized = error?.errors && error.errors.some(err => err.errorType === 'Unauthorized'); const otherErrors = error?.errors && error.errors.filter(err => err.errorType !== 'Unauthorized'); const result = error; if (hasItems) { result.data[opName].items = result.data[opName].items.filter(item => item !== null); } if (hasItems && otherErrors?.length) { await Promise.all(otherErrors.map(async (err) => { try { // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression await this.errorHandler({ recoverySuggestion: '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', localModel: null, message: err.message, model: modelDefinition.name, operation: opName, errorType: (0, errorMaps_1.getSyncErrorType)(err), process: types_1.ProcessName.sync, remoteModel: null, cause: err, }); } catch (e) { logger.error('Sync error handler failed with:', e); } })); core_1.Hub.dispatch('datastore', { event: 'nonApplicableDataReceived', data: { errors: otherErrors, modelName: modelDefinition.name, }, }); } /** * Handle $util.unauthorized() in resolver request mapper, which responses with something * like this: * * ``` * { * data: { syncYourModel: null }, * errors: [ * { * path: ['syncLegacyJSONComments'], * data: null, * errorType: 'Unauthorized', * errorInfo: null, * locations: [{ line: 2, column: 3, sourceName: null }], * message: * 'Not Authorized to access syncYourModel on type Query', * }, * ], * } * ``` * * The correct handling for this is to signal that we've encountered a non-retryable error, * since the server has responded with an auth error and *NO DATA* at this point. */ if (unauthorized) { this.errorHandler({ recoverySuggestion: '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', localModel: null, message: error.message, model: modelDefinition.name, operation: opName, errorType: (0, errorMaps_1.getSyncErrorType)(error.errors[0]), process: types_1.ProcessName.sync, remoteModel: null, cause: error, }); throw new utils_1.NonRetryableError(error); } if (result.data?.[opName]?.items?.length) { return result; } throw error; } }, [query, variables], undefined, onTerminate); } start(typesLastSync) { const { maxRecordsToSync, syncPageSize } = this.amplifyConfig; const parentPromises = new Map(); const observable = new rxjs_1.Observable(observer => { const sortedTypesLastSyncs = Object.values(this.schema.namespaces).reduce((map, namespace) => { for (const modelName of Array.from(namespace.modelTopologicalOrdering.keys())) { const typeLastSync = typesLastSync.get(namespace.models[modelName]); map.set(namespace.models[modelName], typeLastSync); } return map; }, new Map()); const allModelsReady = Array.from(sortedTypesLastSyncs.entries()) .filter(([{ syncable }]) => syncable) .map(([modelDefinition, [namespace, lastSync]]) => this.runningProcesses.isOpen && this.runningProcesses.add(async (onTerminate) => { let done = false; let nextToken = null; let startedAt = null; let items = null; let recordsReceived = 0; const filter = this.graphqlFilterFromPredicate(modelDefinition); const parents = this.schema.namespaces[namespace].modelTopologicalOrdering.get(modelDefinition.name); const promises = parents.map(parent => parentPromises.get(`${namespace}_${parent}`)); // eslint-disable-next-line no-async-promise-executor const promise = new Promise(async (resolve) => { await Promise.all(promises); do { /** * If `runningProcesses` is not open, it means that the sync processor has been * stopped (for example by calling `DataStore.clear()` upstream) and has not yet * finished terminating and/or waiting for its background processes to complete. */ if (!this.runningProcesses.isOpen) { logger.debug(`Sync processor has been stopped, terminating sync for ${modelDefinition.name}`); resolve(); return; } const limit = Math.min(maxRecordsToSync - recordsReceived, syncPageSize); /** * It's possible that `retrievePage` will fail. * If it does fail, continue merging the rest of the data, * and invoke the error handler for non-applicable data. */ try { ({ items, nextToken, startedAt } = await this.retrievePage(modelDefinition, lastSync, nextToken, limit, filter, onTerminate)); } catch (error) { try { // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression await this.errorHandler({ recoverySuggestion: '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', localModel: null, message: error.message, model: modelDefinition.name, operation: null, errorType: (0, errorMaps_1.getSyncErrorType)(error), process: types_1.ProcessName.sync, remoteModel: null, cause: error, }); } catch (e) { logger.error('Sync error handler failed with:', e); } /** * If there's an error, this model fails, but the rest of the sync should * continue. To facilitate this, we explicitly mark this model as `done` * with no items and allow the loop to continue organically. This ensures * all callbacks (subscription messages) happen as normal, so anything * waiting on them knows the model is as done as it can be. */ done = true; items = []; } recordsReceived += items.length; done = nextToken === null || recordsReceived >= maxRecordsToSync; observer.next({ namespace, modelDefinition, items, done, startedAt, isFullSync: !lastSync, }); } while (!done); resolve(); }); parentPromises.set(`${namespace}_${modelDefinition.name}`, promise); await promise; }, `adding model ${modelDefinition.name}`)); Promise.all(allModelsReady).then(() => { observer.complete(); }); }); return observable; } async stop() { logger.debug('stopping sync processor'); await this.runningProcesses.close(); await this.runningProcesses.open(); logger.debug('sync processor stopped'); } } exports.SyncProcessor = SyncProcessor; //# sourceMappingURL=sync.js.map