diff --git a/package.json b/package.json index a651c789bb0..99b15ec4857 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "coverage": "codecov || exit 0", "docs": "typedoc packages/**/src --name amplify-js --hideGenerator --excludePrivate --ignoreCompilerErrors --mode file --out docs/api --theme docs/amplify-theme/typedoc/ --readme README.md", "build": "lerna run build --stream", + "build:watch": "concurrently 'lerna run build:cjs:watch --parallel' 'lerna run build:esm:watch --parallel' --raw", "build:esm:watch": "lerna run build:esm:watch --parallel", "build:cjs:watch": "lerna run build:cjs:watch --parallel", "clean": "lerna run clean --parallel", diff --git a/packages/amazon-cognito-identity-js/package.json b/packages/amazon-cognito-identity-js/package.json index 352383b5b01..22fea046f5d 100644 --- a/packages/amazon-cognito-identity-js/package.json +++ b/packages/amazon-cognito-identity-js/package.json @@ -43,10 +43,12 @@ ], "scripts": { "clean": "rimraf lib es", - "build:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib", - "build:es": "cross-env BABEL_ENV=es babel src --out-dir es", + "build:cjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib", + "build:cjs:watch": "cross-env BABEL_ENV=commonjs babel src --out-dir lib --watch", + "build:esm": "cross-env BABEL_ENV=es babel src --out-dir es", + "build:esm:watch": "cross-env BABEL_ENV=es babel src --out-dir es --watch", "build:umd": "webpack", - "build": "npm run clean && npm run build:commonjs && npm run build:es && npm run build:umd", + "build": "npm run clean && npm run build:cjs && npm run build:esm && npm run build:umd", "doc": "jsdoc src -d docs", "lint": "eslint src", "lint2": "eslint enhance-rn.js", @@ -64,6 +66,7 @@ "dependencies": { "buffer": "4.9.1", "crypto-js": "^3.3.0", + "isomorphic-unfetch": "^3.0.0", "js-cookie": "^2.2.1" }, "devDependencies": { diff --git a/packages/amazon-cognito-identity-js/src/Client.js b/packages/amazon-cognito-identity-js/src/Client.js index d810dcf4c58..eb5117ddcdd 100644 --- a/packages/amazon-cognito-identity-js/src/Client.js +++ b/packages/amazon-cognito-identity-js/src/Client.js @@ -1,3 +1,5 @@ +import 'isomorphic-unfetch'; + import UserAgent from './UserAgent'; class CognitoError extends Error { diff --git a/packages/amplify-ui-angular/tsconfig.json b/packages/amplify-ui-angular/tsconfig.json index cbebfa84bf9..6aabea2a22d 100644 --- a/packages/amplify-ui-angular/tsconfig.json +++ b/packages/amplify-ui-angular/tsconfig.json @@ -8,6 +8,7 @@ "fullTemplateTypeCheck": false }, "compilerOptions": { + "skipLibCheck": true, "alwaysStrict": true, "strict": true, "allowSyntheticDefaultImports": true, diff --git a/packages/amplify-ui-components/package.json b/packages/amplify-ui-components/package.json index 6e02a8dffdd..235d1fa560e 100644 --- a/packages/amplify-ui-components/package.json +++ b/packages/amplify-ui-components/package.json @@ -30,6 +30,7 @@ "storybook": "concurrently 'start-storybook -p 3000 -s ./www' 'yarn:stencil:watch' --raw", "build-with-test": "npm run clean && npm test && npm run stencil", "build": "npm run clean && npm run stencil --ci", + "build:esm:watch": "npm run clean && npm run stencil:watch", "build:watch": "npm run clean && npm run stencil:watch", "clean": "rimraf dist .stencil" }, @@ -38,6 +39,8 @@ "uuid": "^8.2.0" }, "devDependencies": { + "@aws-amplify/auth": "^3.3.6", + "@aws-amplify/core": "^3.4.7", "@stencil/angular-output-target": "^0.0.2", "@stencil/core": "1.15.0", "@stencil/eslint-plugin": "0.2.1", diff --git a/packages/analytics/src/Analytics.ts b/packages/analytics/src/Analytics.ts index c696b5508ad..0b1fe4ffd51 100644 --- a/packages/analytics/src/Analytics.ts +++ b/packages/analytics/src/Analytics.ts @@ -75,7 +75,6 @@ export class AnalyticsClass { Hub.listen('auth', listener); Hub.listen('storage', listener); Hub.listen('analytics', listener); - Amplify.register(this); } public getModuleName() { @@ -402,3 +401,4 @@ const sendEvents = () => { }; export const Analytics = new AnalyticsClass(); +Amplify.register(Analytics); diff --git a/packages/api-graphql/src/GraphQLAPI.ts b/packages/api-graphql/src/GraphQLAPI.ts index 43b56cbe2ff..a7dca9ef259 100644 --- a/packages/api-graphql/src/GraphQLAPI.ts +++ b/packages/api-graphql/src/GraphQLAPI.ts @@ -16,10 +16,11 @@ import { OperationDefinitionNode } from 'graphql/language'; import { print } from 'graphql/language/printer'; import { parse } from 'graphql/language/parser'; import Observable from 'zen-observable-ts'; -import Amplify, { +import { + Amplify, ConsoleLogger as Logger, - Credentials, Constants, + Credentials, INTERNAL_AWS_APPSYNC_REALTIME_PUBSUB_PROVIDER, } from '@aws-amplify/core'; import PubSub from '@aws-amplify/pubsub'; @@ -46,13 +47,16 @@ export class GraphQLAPIClass { private _options; private _api = null; + Auth = Auth; + Cache = Cache; + Credentials = Credentials; + /** * Initialize GraphQL API with AWS configuration * @param {Object} options - Configuration object for API */ constructor(options) { this._options = options; - Amplify.register(this); logger.debug('API Options', this._options); } @@ -100,6 +104,9 @@ export class GraphQLAPIClass { logger.debug('create Rest instance'); if (this._options) { this._api = new RestClient(this._options); + // Share instance Credentials with client for SSR + this._api.Credentials = this.Credentials; + return true; } else { return Promise.reject('API not configured'); @@ -132,7 +139,7 @@ export class GraphQLAPIClass { } break; case 'OPENID_CONNECT': - const federatedInfo = await Cache.getItem('federatedInfo'); + const federatedInfo = await this.Cache.getItem('federatedInfo'); if (!federatedInfo || !federatedInfo.token) { throw new Error('No federated jwt'); @@ -142,7 +149,7 @@ export class GraphQLAPIClass { }; break; case 'AMAZON_COGNITO_USER_POOLS': - const session = await Auth.currentSession(); + const session = await this.Auth.currentSession(); headers = { Authorization: session.getAccessToken().getJwtToken(), }; @@ -355,10 +362,10 @@ export class GraphQLAPIClass { * @private */ _ensureCredentials() { - return Credentials.get() + return this.Credentials.get() .then(credentials => { if (!credentials) return false; - const cred = Credentials.shear(credentials); + const cred = this.Credentials.shear(credentials); logger.debug('set credentials for api', cred); return true; @@ -371,3 +378,4 @@ export class GraphQLAPIClass { } export const GraphQLAPI = new GraphQLAPIClass(null); +Amplify.register(GraphQLAPI); diff --git a/packages/api-rest/src/RestAPI.ts b/packages/api-rest/src/RestAPI.ts index 17dbb2e74ec..0dfa1e84003 100644 --- a/packages/api-rest/src/RestAPI.ts +++ b/packages/api-rest/src/RestAPI.ts @@ -11,7 +11,11 @@ * and limitations under the License. */ import { RestClient } from './RestClient'; -import Amplify, { ConsoleLogger as Logger } from '@aws-amplify/core'; +import { + Amplify, + ConsoleLogger as Logger, + Credentials, +} from '@aws-amplify/core'; import { ApiInfo } from './types'; const logger = new Logger('RestAPI'); @@ -26,13 +30,14 @@ export class RestAPIClass { private _options; private _api: RestClient = null; + Credentials = Credentials; + /** * Initialize Rest API with AWS configuration * @param {Object} options - Configuration object for API */ constructor(options) { this._options = options; - Amplify.register(this); logger.debug('API Options', this._options); } @@ -96,6 +101,9 @@ export class RestAPIClass { createInstance() { logger.debug('create Rest API instance'); this._api = new RestClient(this._options); + + // Share Amplify instance with client for SSR + this._api.Credentials = this.Credentials; return true; } @@ -328,3 +336,4 @@ export class RestAPIClass { } export const RestAPI = new RestAPIClass(null); +Amplify.register(RestAPI); diff --git a/packages/api-rest/src/RestClient.ts b/packages/api-rest/src/RestClient.ts index 756bece6f15..3bbf8bc7ed7 100644 --- a/packages/api-rest/src/RestClient.ts +++ b/packages/api-rest/src/RestClient.ts @@ -13,10 +13,10 @@ import { ConsoleLogger as Logger, + Credentials, DateUtils, Signer, Platform, - Credentials, } from '@aws-amplify/core'; import { apiOptions, ApiInfo } from './types'; @@ -60,6 +60,8 @@ export class RestClient { */ private _cancelTokenMap: WeakMap = null; + Credentials = Credentials; + /** * @param {RestClientOptions} [options] - Instance options */ @@ -181,7 +183,7 @@ export class RestClient { } // Signing the request in case there credentials are available - return Credentials.get().then( + return this.Credentials.get().then( credentials => { return this._signed({ ...params }, credentials, isAllResponse, { region, diff --git a/packages/api/src/API.ts b/packages/api/src/API.ts index cc913c56a5e..668eb9bcebe 100644 --- a/packages/api/src/API.ts +++ b/packages/api/src/API.ts @@ -10,13 +10,19 @@ * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ +import { Auth } from '@aws-amplify/auth'; +import Cache from '@aws-amplify/cache'; import { RestAPIClass } from '@aws-amplify/api-rest'; import { GraphQLAPIClass, GraphQLOptions, GraphQLResult, } from '@aws-amplify/api-graphql'; -import { Amplify, ConsoleLogger as Logger } from '@aws-amplify/core'; +import { + Amplify, + ConsoleLogger as Logger, + Credentials, +} from '@aws-amplify/core'; import Observable from 'zen-observable-ts'; const logger = new Logger('API'); @@ -34,6 +40,10 @@ export class APIClass { private _restApi: RestAPIClass; private _graphqlApi; + Auth = Auth; + Cache = Cache; + Credentials = Credentials; + /** * Initialize API with AWS configuration * @param {Object} options - Configuration object for API @@ -42,7 +52,6 @@ export class APIClass { this._options = options; this._restApi = new RestAPIClass(options); this._graphqlApi = new GraphQLAPIClass(options); - Amplify.register(this); logger.debug('API Options', this._options); } @@ -57,6 +66,14 @@ export class APIClass { */ configure(options) { this._options = Object.assign({}, this._options, options); + + // Share Amplify instance with client for SSR + this._restApi.Credentials = this.Credentials; + + this._graphqlApi.Auth = this.Auth; + this._graphqlApi.Cache = this.Cache; + this._graphqlApi.Credentials = this.Credentials; + const restAPIConfig = this._restApi.configure(this._options); const graphQLAPIConfig = this._graphqlApi.configure(this._options); @@ -179,3 +196,4 @@ export class APIClass { } export const API = new APIClass(null); +Amplify.register(API); diff --git a/packages/auth/src/Auth.ts b/packages/auth/src/Auth.ts index 6c9f1684d52..2a143f34024 100644 --- a/packages/auth/src/Auth.ts +++ b/packages/auth/src/Auth.ts @@ -36,12 +36,13 @@ import { import { Amplify, ConsoleLogger as Logger, - Hub, Credentials, + Hub, StorageHelper, ICredentials, Parser, JS, + UniversalStorage, } from '@aws-amplify/core'; import { CookieStorage, @@ -94,6 +95,8 @@ export class AuthClass { private _storageSync; private oAuthFlowInProgress: boolean = false; + Credentials = Credentials; + /** * Initialize Auth with AWS configurations * @param {Object} config - Configuration of the Auth @@ -116,7 +119,6 @@ export class AuthClass { break; } }); - Amplify.register(this); } public getModuleName() { @@ -150,7 +152,9 @@ export class AuthClass { // backward compatability if (cookieStorage) this._storage = new CookieStorage(cookieStorage); else { - this._storage = new StorageHelper().getStorage(); + this._storage = config.ssr + ? new UniversalStorage() + : new StorageHelper().getStorage(); } } else { if (!this._isValidAuthStorage(this._config.storage)) { @@ -175,7 +179,7 @@ export class AuthClass { this.userPool = new CognitoUserPool(userPoolData); } - Credentials.configure({ + this.Credentials.configure({ mandatorySignIn, region: identityPoolRegion || region, userPoolId, @@ -474,8 +478,8 @@ export class AuthClass { delete user['challengeName']; delete user['challengeParam']; try { - await Credentials.clear(); - const cred = await Credentials.set(session, 'session'); + await this.Credentials.clear(); + const cred = await this.Credentials.set(session, 'session'); logger.debug('succeed to get cognito credentials', cred); } catch (e) { logger.debug('cannot get cognito credentials', e); @@ -695,7 +699,9 @@ export class AuthClass { user: CognitoUser | any, mfaMethod: 'TOTP' | 'SMS' | 'NOMFA' ): Promise { - const userData = await this._getUserData(user, { bypassCache: true }); + const userData = await this._getUserData(user, { + bypassCache: true, + }); let smsMfaSettings = null; let totpMfaSettings = null; @@ -897,8 +903,8 @@ export class AuthClass { onSuccess: async session => { logger.debug(session); try { - await Credentials.clear(); - const cred = await Credentials.set(session, 'session'); + await this.Credentials.clear(); + const cred = await this.Credentials.set(session, 'session'); logger.debug('succeed to get cognito credentials', cred); } catch (e) { logger.debug('cannot get cognito credentials', e); @@ -939,8 +945,8 @@ export class AuthClass { onSuccess: async session => { logger.debug(session); try { - await Credentials.clear(); - const cred = await Credentials.set(session, 'session'); + await this.Credentials.clear(); + const cred = await this.Credentials.set(session, 'session'); logger.debug('succeed to get cognito credentials', cred); } catch (e) { logger.debug('cannot get cognito credentials', e); @@ -1161,7 +1167,7 @@ export class AuthClass { const bypassCache = params ? params.bypassCache : false; if (bypassCache) { - await Credentials.clear(); + await this.Credentials.clear(); } // validate the token's scope first before calling this function @@ -1361,23 +1367,23 @@ export class AuthClass { if (federatedInfo) { // refresh the jwt token here if necessary - return Credentials.refreshFederatedToken(federatedInfo); + return this.Credentials.refreshFederatedToken(federatedInfo); } else { return this.currentSession() .then(session => { logger.debug('getting session success', session); - return Credentials.set(session, 'session'); + return this.Credentials.set(session, 'session'); }) .catch(error => { logger.debug('getting session failed', error); - return Credentials.set(null, 'guest'); + return this.Credentials.set(null, 'guest'); }); } } public currentCredentials(): Promise { logger.debug('getting current credentials'); - return Credentials.get(); + return this.Credentials.get(); } /** @@ -1569,7 +1575,7 @@ export class AuthClass { private async cleanCachedItems() { // clear cognito cached item - await Credentials.clear(); + await this.Credentials.clear(); } /** @@ -1715,7 +1721,7 @@ export class AuthClass { * @return {Object }- current User's information */ public async currentUserInfo() { - const source = Credentials.getCredSource(); + const source = this.Credentials.getCredSource(); if (!source || source === 'aws' || source === 'userPool') { const user = await this.currentUserPoolUser().catch(err => @@ -1839,9 +1845,9 @@ export class AuthClass { } catch (e) {} const { token, identity_id, expires_at } = response; - // Because Credentials.set would update the user info with identity id + // Because this.Credentials.set would update the user info with identity id // So we need to retrieve the user again. - const credentials = await Credentials.set( + const credentials = await this.Credentials.set( { provider, token, identity_id, user, expires_at }, 'federation' ); @@ -1908,13 +1914,15 @@ export class AuthClass { RefreshToken: new CognitoRefreshToken({ RefreshToken: refreshToken, }), - AccessToken: new CognitoAccessToken({ AccessToken: accessToken }), + AccessToken: new CognitoAccessToken({ + AccessToken: accessToken, + }), }); let credentials; // Get AWS Credentials & store if Identity Pool is defined if (this._config.identityPoolId) { - credentials = await Credentials.set(session, 'session'); + credentials = await this.Credentials.set(session, 'session'); logger.debug('AWS credentials', credentials); } @@ -2075,3 +2083,5 @@ export class AuthClass { } export const Auth = new AuthClass(null); + +Amplify.register(Auth); diff --git a/packages/aws-amplify/__tests__/exports-test.ts b/packages/aws-amplify/__tests__/exports-test.ts index 243ac72a3d3..364f13e9263 100644 --- a/packages/aws-amplify/__tests__/exports-test.ts +++ b/packages/aws-amplify/__tests__/exports-test.ts @@ -88,6 +88,7 @@ describe('aws-amplify', () => { "Signer", "I18n", "ServiceWorker", + "withSSRContext", "default", ] `); diff --git a/packages/aws-amplify/__tests__/withSSRContext-test.ts b/packages/aws-amplify/__tests__/withSSRContext-test.ts new file mode 100644 index 00000000000..6e487f8757f --- /dev/null +++ b/packages/aws-amplify/__tests__/withSSRContext-test.ts @@ -0,0 +1,80 @@ +import { Amplify, CredentialsClass, UniversalStorage } from '@aws-amplify/core'; + +import { withSSRContext } from '../src/withSSRContext'; + +describe('withSSRContext', () => { + it('should not require context (for client-side requests)', () => { + expect(() => withSSRContext()).not.toThrow(); + }); + + it('should create a new instance of Amplify', () => { + const amplify = withSSRContext(); + + expect(amplify).not.toBe(Amplify); + }); + + it('should extend the global Amplify config', () => { + // ! Amplify is global across all tests, so we use a value that won't negatively affect others + Amplify.configure({ TEST_VALUE: true }); + expect(Amplify.configure()).toEqual({ TEST_VALUE: true }); + + const amplify = withSSRContext(); + expect(amplify.configure({ TEST_VALUE2: true })).toEqual( + expect.objectContaining({ + storage: expect.any(UniversalStorage), + TEST_VALUE: true, + TEST_VALUE2: true, + }) + ); + }); + + describe('API', () => { + it('should be a different instance than Amplify.Auth', () => { + expect(withSSRContext().API).not.toBe(Amplify.API); + }); + + it('should use different Credentials than Amplify', () => { + const amplify = withSSRContext(); + const config = amplify.configure(); + + // GraphQLAPI uses Credentials internally + expect(Amplify.API._graphqlApi.Credentials).not.toBe( + amplify.API._graphqlApi.Credentials + ); + + // RestAPI._api is a RestClient with Credentials + expect(Amplify.API._restApi._api.Credentials).not.toBe( + amplify.API._restApi._api.Credentials + ); + }); + }); + + describe('Auth', () => { + it('should be a different instance than Amplify.Auth', () => { + expect(withSSRContext().Auth).not.toBe(Amplify.Auth); + }); + + it('should be created with UniversalStorage', () => { + expect(withSSRContext().Auth._storage).toBeInstanceOf(UniversalStorage); + }); + + it('should use different Credentials than Amplify', () => { + const amplify = withSSRContext(); + + expect(Amplify.Auth.Credentials).not.toBe(amplify.Auth.Credentials); + }); + }); + + describe('DataStore', () => { + it('should be a different instance than Amplify.DataStore', () => { + expect(withSSRContext().DataStore).not.toBe(Amplify.DataStore); + }); + }); + + describe('I18n', () => { + // I18n isn't scoped to SSR (yet) + it.skip('should be the same instance as Amplify.I18n', () => { + expect(withSSRContext().I18n).toBe(Amplify.I18n); + }); + }); +}); diff --git a/packages/aws-amplify/src/index.ts b/packages/aws-amplify/src/index.ts index 4e8f336b8b4..5a1b709c29c 100644 --- a/packages/aws-amplify/src/index.ts +++ b/packages/aws-amplify/src/index.ts @@ -49,6 +49,7 @@ export { I18n, ServiceWorker, } from '@aws-amplify/core'; +export { withSSRContext } from './withSSRContext'; export { Amplify }; diff --git a/packages/aws-amplify/src/withSSRContext.ts b/packages/aws-amplify/src/withSSRContext.ts new file mode 100644 index 00000000000..b1be3a8aefa --- /dev/null +++ b/packages/aws-amplify/src/withSSRContext.ts @@ -0,0 +1,49 @@ +import API from '@aws-amplify/api'; +import { Auth } from '@aws-amplify/auth'; +import { Credentials } from '@aws-amplify/core'; +import { AmplifyClass, UniversalStorage } from '@aws-amplify/core'; + +import { DataStore } from '@aws-amplify/datastore'; + +// ! We have to use this exact reference, since it gets mutated with Amplify.Auth +import { Amplify } from './index'; + +const requiredModules = [ + // API cannot function without Auth + Auth, + // Auth cannot function without Credentials + Credentials, +]; + +// These modules have been tested with SSR +const defaultModules = [API, Auth, DataStore]; + +type Context = { + req?: any; + modules?: any[]; +}; + +export function withSSRContext(context: Context = {}) { + const { modules = defaultModules, req } = context; + const previousConfig = Amplify.configure(); + const amplify = new AmplifyClass(); + const storage = new UniversalStorage({ req }); + + requiredModules.forEach(m => { + if (!modules.includes(m)) { + // @ts-ignore This expression is not constructable. + // Type 'Function' has no construct signatures.ts(2351) + amplify.register(new m.constructor()); + } + }); + + // Associate new module instances with this amplify + modules.forEach(m => { + amplify.register(new m.constructor()); + }); + + // Configure new Amplify instances with previous configuration + amplify.configure({ ...previousConfig, storage }); + + return amplify; +} diff --git a/packages/core/package.json b/packages/core/package.json index 8fd0e552bb4..4d88885b64f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,6 +52,7 @@ "@aws-sdk/types": "1.0.0-gamma.3", "@aws-sdk/util-hex-encoding": "1.0.0-gamma.3", "@aws-sdk/util-user-agent-browser": "1.0.0-gamma.3", + "universal-cookie": "^4.0.3", "url": "^0.11.0", "zen-observable-ts": "0.8.19" }, diff --git a/packages/core/src/Amplify.ts b/packages/core/src/Amplify.ts index d47ba04ea85..98c9bac4ecd 100644 --- a/packages/core/src/Amplify.ts +++ b/packages/core/src/Amplify.ts @@ -2,34 +2,40 @@ import { ConsoleLogger as LoggerClass } from './Logger'; const logger = new LoggerClass('Amplify'); -export class Amplify { - private static _components = []; - private static _config = {}; +export class AmplifyClass { + // Everything that is `register`ed is tracked here + private _components = []; + private _config = {}; + + // All modules (with `getModuleName()`) are stored here for dependency injection + private _modules = {}; // for backward compatibility to avoid breaking change // if someone is using like Amplify.Auth - static Auth = null; - static Analytics = null; - static API = null; - static Storage = null; - static I18n = null; - static Cache = null; - static PubSub = null; - static Interactions = null; - static Pushnotification = null; - static UI = null; - static XR = null; - static Predictions = null; - static DataStore = null; + Auth = null; + Analytics = null; + API = null; + Credentials = null; + Storage = null; + I18n = null; + Cache = null; + PubSub = null; + Interactions = null; + Pushnotification = null; + UI = null; + XR = null; + Predictions = null; + DataStore = null; - static Logger = LoggerClass; - static ServiceWorker = null; + Logger = LoggerClass; + ServiceWorker = null; - static register(comp) { + register(comp) { logger.debug('component registered in amplify', comp); this._components.push(comp); if (typeof comp.getModuleName === 'function') { - Amplify[comp.getModuleName()] = comp; + this._modules[comp.getModuleName()] = comp; + this[comp.getModuleName()] = comp; } else { logger.debug('no getModuleName method for component', comp); } @@ -43,11 +49,25 @@ export class Amplify { comp.configure(this._config); } - static configure(config) { + configure(config?) { if (!config) return this._config; this._config = Object.assign(this._config, config); logger.debug('amplify config', this._config); + + // Dependency Injection via property-setting. + // This avoids introducing a public method/interface/setter that's difficult to remove later. + // Plus, it reduces `if` statements within the `constructor` and `configure` of each module + Object.entries(this._modules).forEach(([Name, comp]) => { + // e.g. Auth.* + Object.keys(comp).forEach(property => { + // e.g. Auth["Credentials"] = this._modules["Credentials"] when set + if (this._modules[property]) { + comp[property] = this._modules[property]; + } + }); + }); + this._components.map(comp => { comp.configure(this._config); }); @@ -55,7 +75,7 @@ export class Amplify { return this._config; } - static addPluggable(pluggable) { + addPluggable(pluggable) { if ( pluggable && pluggable['getCategory'] && @@ -73,6 +93,8 @@ export class Amplify { } } +export const Amplify = new AmplifyClass(); + /** * @deprecated use named import */ diff --git a/packages/core/src/Credentials.ts b/packages/core/src/Credentials.ts index 0a469644acc..dcc8f4b10f8 100644 --- a/packages/core/src/Credentials.ts +++ b/packages/core/src/Credentials.ts @@ -33,12 +33,19 @@ export class CredentialsClass { private _identityId; private _nextCredentialsRefresh: Number; + // `Amplify.Auth` will either be `Auth` or `null` depending on if Auth was imported + Auth = Amplify.Auth; + constructor(config) { this.configure(config); this._refreshHandlers['google'] = GoogleOAuth.refreshGoogleToken; this._refreshHandlers['facebook'] = FacebookOAuth.refreshFacebookToken; } + public getModuleName() { + return 'Credentials'; + } + public getCredSource() { return this._credentials_source; } @@ -58,6 +65,7 @@ export class CredentialsClass { } this._storage = this._config.storage; + if (!this._storage) { this._storage = new StorageHelper().getStorage(); } @@ -95,11 +103,10 @@ export class CredentialsClass { } logger.debug('need to get a new credential or refresh the existing one'); - if ( - Amplify.Auth && - typeof Amplify.Auth.currentUserCredentials === 'function' - ) { - return Amplify.Auth.currentUserCredentials(); + + // Some use-cases don't require Auth for signing in, but use Credentials for guest users (e.g. Analytics) + if (this.Auth && typeof this.Auth.currentUserCredentials === 'function') { + return this.Auth.currentUserCredentials(); } else { return Promise.reject('No Auth module registered in Amplify'); } @@ -536,6 +543,8 @@ export class CredentialsClass { export const Credentials = new CredentialsClass(null); +Amplify.register(Credentials); + /** * @deprecated use named import */ diff --git a/packages/core/src/UniversalStorage/index.ts b/packages/core/src/UniversalStorage/index.ts new file mode 100644 index 00000000000..754049dea86 --- /dev/null +++ b/packages/core/src/UniversalStorage/index.ts @@ -0,0 +1,108 @@ +import Cookies from 'universal-cookie'; +import { browserOrNode } from '../JS'; + +type Store = Record; + +const { isBrowser } = browserOrNode(); + +// Avoid using @types/next because @aws-amplify/ui-angular's version of TypeScript is too old to support it +type Context = { req?: any }; + +export class UniversalStorage implements Storage { + cookies = new Cookies(); + store: Store = isBrowser ? window.localStorage : Object.create(null); + + constructor(context: Context = {}) { + this.cookies = context.req + ? new Cookies(context.req.headers.cookie) + : new Cookies(); + + Object.assign(this.store, this.cookies.getAll()); + } + + get length() { + return Object.entries(this.store).length; + } + + clear() { + Array.from(new Array(this.length)) + .map((value, i) => this.key(i)) + .forEach(key => this.removeItem(key)); + } + + getItem(key: keyof Store) { + return this.getLocalItem(key); + } + + protected getLocalItem(key: keyof Store) { + return Object.prototype.hasOwnProperty.call(this.store, key) + ? this.store[key] + : null; + } + + protected getUniversalItem(key: keyof Store) { + return this.cookies.get(key); + } + + key(index: number) { + return Object.keys(this.store)[index]; + } + + removeItem(key: string) { + this.removeLocalItem(key); + this.removeUniversalItem(key); + } + + protected removeLocalItem(key: keyof Store) { + delete this.store[key]; + } + + protected removeUniversalItem(key: keyof Store) { + this.cookies.remove(key); + } + + setItem(key: keyof Store, value: string) { + this.setLocalItem(key, value); + + // keys take the shape: + // 1. `${ProviderPrefix}.${userPoolClientId}.${username}.${tokenType} + // 2. `${ProviderPrefix}.${userPoolClientId}.LastAuthUser + const tokenType = key.split('.').pop(); + + switch (tokenType) { + // LastAuthUser is needed for computing other key names + case 'LastAuthUser': + + // accessToken is required for CognitoUserSession + case 'accessToken': + + // Required for CognitoUserSession + case 'idToken': + this.setUniversalItem(key, value); + + // userData is used when `Auth.currentAuthenticatedUser({ bypassCache: false })`. + // Can be persisted to speed up calls to `Auth.currentAuthenticatedUser()` + // case 'userData': + + // refreshToken isn't shared with the server so that the client handles refreshing + // case 'refreshToken': + + // Ignoring clockDrift on the server for now, but needs testing + // case 'clockDrift': + } + } + + protected setLocalItem(key: keyof Store, value: string) { + this.store[key] = value; + } + + protected setUniversalItem(key: keyof Store, value: string) { + this.cookies.set(key, value, { + path: '/', + // `httpOnly` cannot be set via JavaScript: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#JavaScript_access_using_Document.cookie + sameSite: true, + // Allow unsecure requests to http://localhost:3000/ when in development. + secure: window.location.hostname === 'localhost' ? false : true, + }); + } +} diff --git a/packages/core/src/Util/Reachability.ts b/packages/core/src/Util/Reachability.ts index 94a9ed22d8e..d7f367a0450 100644 --- a/packages/core/src/Util/Reachability.ts +++ b/packages/core/src/Util/Reachability.ts @@ -1,3 +1,4 @@ +import { browserOrNode } from '@aws-amplify/core'; import Observable, { ZenObservable } from 'zen-observable-ts'; type NetworkStatus = { @@ -10,6 +11,10 @@ export default class ReachabilityNavigator implements Reachability { > = []; networkMonitor(netInfo?: any): Observable { + if (browserOrNode().isNode) { + return Observable.from([{ online: true }]); + } + return new Observable(observer => { observer.next({ online: window.navigator.onLine }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 99b699ccaa9..aea8ffee5f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ import { Amplify } from './Amplify'; import { Platform } from './Platform'; +export { AmplifyClass } from './Amplify'; export { ClientDevice } from './ClientDevice'; export { ConsoleLogger, ConsoleLogger as Logger } from './Logger'; export * from './Errors'; @@ -24,10 +25,11 @@ export { Signer } from './Signer'; export * from './Parser'; export { FacebookOAuth, GoogleOAuth } from './OAuthHelper'; export * from './RNComponents'; -export { Credentials } from './Credentials'; +export { Credentials, CredentialsClass } from './Credentials'; export { ServiceWorker } from './ServiceWorker'; export { ICredentials } from './types'; export { StorageHelper, MemoryStorage } from './StorageHelper'; +export { UniversalStorage } from './UniversalStorage'; export { Platform, getAmplifyUserAgent } from './Platform'; export * from './constants'; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 7a6415b9ddd..b2f7c8dcda8 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -4,6 +4,7 @@ export interface AmplifyConfig { API?: object; Storage?: object; Cache?: object; + ssr?: boolean; } export interface ICredentials { diff --git a/packages/core/tslint.json b/packages/core/tslint.json index 1bb9e144d24..9ede9f85fac 100644 --- a/packages/core/tslint.json +++ b/packages/core/tslint.json @@ -5,7 +5,13 @@ "jsRules": {}, "rules": { "prefer-const": true, - "max-line-length": [true, 120], + "max-line-length": [ + true, + { + "ignore-pattern": "//", + "limit": 120 + } + ], "no-empty-interface": true, "no-var-keyword": true, "object-literal-shorthand": true, diff --git a/packages/datastore/__tests__/AsyncStorage.ts b/packages/datastore/__tests__/AsyncStorage.ts index b24f66be6c1..454f1a91b1a 100644 --- a/packages/datastore/__tests__/AsyncStorage.ts +++ b/packages/datastore/__tests__/AsyncStorage.ts @@ -36,32 +36,35 @@ let PostMetadata: NonModelTypeConstructor>; const inmemoryMap = new Map(); -jest.mock('react-native', () => { - return { - AsyncStorage: { - getAllKeys: async () => { - return Array.from(inmemoryMap.keys()); - }, - multiGet: async (keys: string[]) => { - return keys.reduce( - (res, k) => (res.push([k, inmemoryMap.get(k)]), res), - [] - ); - }, - multiRemove: async (keys: string[]) => { - return keys.forEach(k => inmemoryMap.delete(k)); - }, - setItem: async (key: string, value: string) => { - return inmemoryMap.set(key, value); - }, - removeItem: async (key: string) => { - return inmemoryMap.delete(key); - }, - getItem: async (key: string) => { - return inmemoryMap.get(key); - }, - }, - }; + +// ! We have to mock the same storage interface the AsyncStorageDatabase depends on +// ! as a singleton so that new instances all share the same underlying data structure. +jest.mock('../src/storage/adapter/InMemoryStore', () => { + class InMemoryStore { + getAllKeys = async () => { + return Array.from(inmemoryMap.keys()); + }; + multiGet = async (keys: string[]) => { + return keys.reduce( + (res, k) => (res.push([k, inmemoryMap.get(k)]), res), + [] + ); + }; + multiRemove = async (keys: string[]) => { + return keys.forEach(k => inmemoryMap.delete(k)); + }; + setItem = async (key: string, value: string) => { + return inmemoryMap.set(key, value); + }; + removeItem = async (key: string) => { + return inmemoryMap.delete(key); + }; + getItem = async (key: string) => { + return inmemoryMap.get(key); + }; + } + + return { InMemoryStore }; }); jest.mock('../src/storage/adapter/getDefaultAdapter/index', () => () => @@ -110,7 +113,8 @@ function setUpSchema(beforeSetUp?: Function) { } describe('AsyncStorage tests', () => { - const { AsyncStorage } = require('react-native'); + const { InMemoryStore } = require('../src/storage/adapter/InMemoryStore'); + const AsyncStorage = new InMemoryStore(); let blog: InstanceType, blog2: InstanceType, diff --git a/packages/datastore/__tests__/DataStore.ts b/packages/datastore/__tests__/DataStore.ts index fb051d03c60..c6b9609b29b 100644 --- a/packages/datastore/__tests__/DataStore.ts +++ b/packages/datastore/__tests__/DataStore.ts @@ -102,9 +102,13 @@ describe('DataStore tests', () => { test('initSchema is executed only once', () => { initSchema(testSchema()); + const spy = jest.spyOn(console, 'warn'); + expect(() => { initSchema(testSchema()); - }).toThrow('The schema has already been initialized'); + }).not.toThrow(); + + expect(spy).toBeCalledWith('The schema has already been initialized'); }); test('Non @model class is created', () => { diff --git a/packages/datastore/package.json b/packages/datastore/package.json index 5199369af87..7d454555339 100644 --- a/packages/datastore/package.json +++ b/packages/datastore/package.json @@ -47,8 +47,10 @@ "@aws-amplify/pubsub": "^3.0.25", "idb": "5.0.2", "immer": "6.0.1", + "isomorphic-ws": "^4.0.1", "ulid": "2.3.0", "uuid": "3.3.2", + "ws": "^7.2.3", "zen-observable-ts": "0.8.19", "zen-push": "0.2.1" }, diff --git a/packages/datastore/src/datastore/datastore.ts b/packages/datastore/src/datastore/datastore.ts index ba45075e612..0ff72de6cb6 100644 --- a/packages/datastore/src/datastore/datastore.ts +++ b/packages/datastore/src/datastore/datastore.ts @@ -1,4 +1,4 @@ -import { Amplify, ConsoleLogger as Logger, Hub } from '@aws-amplify/core'; +import { Amplify, ConsoleLogger as Logger, Hub, JS } from '@aws-amplify/core'; import { Draft, immerable, produce, setAutoFreeze } from 'immer'; import { v4 as uuid4 } from 'uuid'; import Observable, { ZenObservable } from 'zen-observable-ts'; @@ -53,6 +53,7 @@ setAutoFreeze(true); const logger = new Logger('DataStore'); const ulid = monotonicUlidFactory(Date.now()); +const { isNode } = JS.browserOrNode(); declare class Setting { constructor(init: ModelInit); @@ -67,7 +68,6 @@ declare class Setting { const SETTING_SCHEMA_VERSION = 'schemaVersion'; -let storage: Storage; let schema: InternalSchema; const modelNamespaceMap = new WeakMap< PersistentModelConstructor, @@ -101,7 +101,9 @@ let storageClasses: TypeConstructorMap; const initSchema = (userSchema: Schema) => { if (schema !== undefined) { - throw new Error('The schema has already been initialized'); + console.warn('The schema has already been initialized'); + + return userClasses; } logger.log('validating schema', { schema: userSchema }); @@ -334,6 +336,23 @@ const createModelClass = ( }); }); } + + // "private" method (that's hidden via `Setting`) for `withSSRContext` to use + // to gain access to `modelInstanceCreator` and `clazz` for persisting IDs from server to client. + static fromJSON(json: T | T[]) { + if (Array.isArray(json)) { + return json.map(init => this.fromJSON(init)); + } + + const instance = modelInstanceCreator(clazz, json); + const modelValidator = validateModelFields(modelDefinition); + + Object.entries(instance).forEach(([k, v]) => { + modelValidator(k, v); + }); + + return instance; + } }); clazz[immerable] = true; @@ -364,409 +383,16 @@ const createNonModelClass = (typeDefinition: SchemaNonModel) => { return clazz; }; -const save = async ( - model: T, - condition?: ProducerModelPredicate -): Promise => { - await start(); - - const modelConstructor: PersistentModelConstructor = model - ? >model.constructor - : undefined; - - if (!isValidModelConstructor(modelConstructor)) { - const msg = 'Object is not an instance of a valid model'; - logger.error(msg, { model }); - - throw new Error(msg); - } - - const modelDefinition = getModelDefinition(modelConstructor); - - const producedCondition = ModelPredicateCreator.createFromExisting( - modelDefinition, - condition - ); - - const [savedModel] = await storage.runExclusive(async s => { - await s.save(model, producedCondition); - - return s.query( - modelConstructor, - ModelPredicateCreator.createForId(modelDefinition, model.id) - ); - }); - - return savedModel; -}; - -const remove: { - ( - model: T, - condition?: ProducerModelPredicate - ): Promise; - ( - modelConstructor: PersistentModelConstructor, - id: string - ): Promise; - ( - modelConstructor: PersistentModelConstructor, - condition: ProducerModelPredicate | typeof PredicateAll - ): Promise; -} = async ( - modelOrConstructor: T | PersistentModelConstructor, - idOrCriteria?: string | ProducerModelPredicate | typeof PredicateAll -) => { - await start(); - - let condition: ModelPredicate; - - if (!modelOrConstructor) { - const msg = 'Model or Model Constructor required'; - logger.error(msg, { modelOrConstructor }); - - throw new Error(msg); - } - - if (isValidModelConstructor(modelOrConstructor)) { - const modelConstructor = modelOrConstructor; - - if (!idOrCriteria) { - const msg = - 'Id to delete or criteria required. Do you want to delete all? Pass Predicates.ALL'; - logger.error(msg, { idOrCriteria }); - - throw new Error(msg); - } - - if (typeof idOrCriteria === 'string') { - condition = ModelPredicateCreator.createForId( - getModelDefinition(modelConstructor), - idOrCriteria - ); - } else { - condition = ModelPredicateCreator.createFromExisting( - getModelDefinition(modelConstructor), - /** - * idOrCriteria is always a ProducerModelPredicate, never a symbol. - * The symbol is used only for typing purposes. e.g. see Predicates.ALL - */ - idOrCriteria as ProducerModelPredicate - ); - - if (!condition || !ModelPredicateCreator.isValidPredicate(condition)) { - const msg = - 'Criteria required. Do you want to delete all? Pass Predicates.ALL'; - logger.error(msg, { condition }); - - throw new Error(msg); - } - } - - const [deleted] = await storage.delete(modelConstructor, condition); - - return deleted; - } else { - const model = modelOrConstructor; - const modelConstructor = Object.getPrototypeOf(model || {}) - .constructor as PersistentModelConstructor; - - if (!isValidModelConstructor(modelConstructor)) { - const msg = 'Object is not an instance of a valid model'; - logger.error(msg, { model }); - - throw new Error(msg); - } - - const modelDefinition = getModelDefinition(modelConstructor); - - const idPredicate = ModelPredicateCreator.createForId( - modelDefinition, - model.id - ); - - if (idOrCriteria) { - if (typeof idOrCriteria !== 'function') { - const msg = 'Invalid criteria'; - logger.error(msg, { idOrCriteria }); - - throw new Error(msg); - } - - condition = idOrCriteria(idPredicate); - } else { - condition = idPredicate; - } - - const [[deleted]] = await storage.delete(model, condition); - - return deleted; - } -}; -const observe: { - (): Observable>; - - (model: T): Observable>; - - ( - modelConstructor: PersistentModelConstructor, - criteria?: string | ProducerModelPredicate - ): Observable>; -} = ( - modelOrConstructor?: T | PersistentModelConstructor, - idOrCriteria?: string | ProducerModelPredicate -): Observable> => { - let predicate: ModelPredicate; - - const modelConstructor: PersistentModelConstructor = - modelOrConstructor && isValidModelConstructor(modelOrConstructor) - ? modelOrConstructor - : undefined; - - if (modelOrConstructor && modelConstructor === undefined) { - const model = modelOrConstructor; - const modelConstructor = - model && (Object.getPrototypeOf(model)).constructor; - - if (isValidModelConstructor(modelConstructor)) { - if (idOrCriteria) { - logger.warn('idOrCriteria is ignored when using a model instance', { - model, - idOrCriteria, - }); - } - - return observe(modelConstructor, model.id); - } else { - const msg = - 'The model is not an instance of a PersistentModelConstructor'; - logger.error(msg, { model }); - - throw new Error(msg); - } - } - - if (idOrCriteria !== undefined && modelConstructor === undefined) { - const msg = 'Cannot provide criteria without a modelConstructor'; - logger.error(msg, idOrCriteria); - throw new Error(msg); - } - - if (modelConstructor && !isValidModelConstructor(modelConstructor)) { - const msg = 'Constructor is not for a valid model'; - logger.error(msg, { modelConstructor }); - - throw new Error(msg); - } - - if (typeof idOrCriteria === 'string') { - predicate = ModelPredicateCreator.createForId( - getModelDefinition(modelConstructor), - idOrCriteria - ); - } else { - predicate = - modelConstructor && - ModelPredicateCreator.createFromExisting( - getModelDefinition(modelConstructor), - idOrCriteria - ); - } - - return new Observable>(observer => { - let handle: ZenObservable.Subscription; - - (async () => { - await start(); - - handle = storage - .observe(modelConstructor, predicate) - .filter(({ model }) => namespaceResolver(model) === USER) - .subscribe(observer); - })(); - - return () => { - if (handle) { - handle.unsubscribe(); - } - }; - }); -}; - function isQueryOne(obj: any): obj is string { return typeof obj === 'string'; } -const query: { - ( - modelConstructor: PersistentModelConstructor, - id: string - ): Promise; - ( - modelConstructor: PersistentModelConstructor, - criteria?: ProducerModelPredicate | typeof PredicateAll, - pagination?: PaginationInput - ): Promise; -} = async ( - modelConstructor: PersistentModelConstructor, - idOrCriteria?: string | ProducerModelPredicate | typeof PredicateAll, - pagination?: PaginationInput -): Promise => { - await start(); - - //#region Input validation - - if (!isValidModelConstructor(modelConstructor)) { - const msg = 'Constructor is not for a valid model'; - logger.error(msg, { modelConstructor }); - - throw new Error(msg); - } - - if (typeof idOrCriteria === 'string') { - if (pagination !== undefined) { - logger.warn('Pagination is ignored when querying by id'); - } - } - - const modelDefinition = getModelDefinition(modelConstructor); - let predicate: ModelPredicate; - - if (isQueryOne(idOrCriteria)) { - predicate = ModelPredicateCreator.createForId( - modelDefinition, - idOrCriteria - ); - } else { - if (isPredicatesAll(idOrCriteria)) { - // Predicates.ALL means "all records", so no predicate (undefined) - predicate = undefined; - } else { - predicate = ModelPredicateCreator.createFromExisting( - modelDefinition, - idOrCriteria - ); - } - } - - const { limit, page } = pagination || {}; - - if (page !== undefined && limit === undefined) { - throw new Error('Limit is required when requesting a page'); - } - - if (page !== undefined) { - if (typeof page !== 'number') { - throw new Error('Page should be a number'); - } - - if (page < 0) { - throw new Error("Page can't be negative"); - } - } - - if (limit !== undefined) { - if (typeof limit !== 'number') { - throw new Error('Limit should be a number'); - } - - if (limit < 0) { - throw new Error("Limit can't be negative"); - } - } - - //#endregion - - logger.debug('params ready', { - modelConstructor, - predicate: ModelPredicateCreator.getPredicates(predicate, false), - pagination, - }); - - const result = await storage.query(modelConstructor, predicate, pagination); - - return isQueryOne(idOrCriteria) ? result[0] : result; -}; - -let sync: SyncEngine; -let amplifyConfig: Record = {}; -let conflictHandler: ConflictHandler; -let errorHandler: (error: SyncError) => void; -let maxRecordsToSync: number; -let syncPageSize: number; -let fullSyncInterval: number; - -function configure(config: DataStoreConfig = {}) { - const { - DataStore: configDataStore, - conflictHandler: configConflictHandler, - errorHandler: configErrorHandler, - maxRecordsToSync: configMaxRecordsToSync, - syncPageSize: configSyncPageSize, - fullSyncInterval: configFullSyncInterval, - ...configFromAmplify - } = config; - - amplifyConfig = { ...configFromAmplify, ...amplifyConfig }; - - conflictHandler = setConflictHandler(config); - errorHandler = setErrorHandler(config); - - maxRecordsToSync = - (configDataStore && configDataStore.maxRecordsToSync) || - maxRecordsToSync || - config.maxRecordsToSync; - - syncPageSize = - (configDataStore && configDataStore.syncPageSize) || - syncPageSize || - config.syncPageSize; - - fullSyncInterval = - (configDataStore && configDataStore.fullSyncInterval) || - configFullSyncInterval || - config.fullSyncInterval || - 24 * 60; // 1 day -} - function defaultConflictHandler(conflictData: SyncConflict): PersistentModel { const { localModel, modelConstructor, remoteModel } = conflictData; const { _version } = remoteModel; return modelInstanceCreator(modelConstructor, { ...localModel, _version }); } -function setConflictHandler(config: DataStoreConfig): ConflictHandler { - const { DataStore: configDataStore } = config; - - const conflictHandlerIsDefault: () => boolean = () => - conflictHandler === defaultConflictHandler; - - if (configDataStore) { - return configDataStore.conflictHandler; - } - if (conflictHandlerIsDefault() && config.conflictHandler) { - return config.conflictHandler; - } - - return conflictHandler || defaultConflictHandler; -} - -function setErrorHandler(config: DataStoreConfig): ErrorHandler { - const { DataStore: configDataStore } = config; - - const errorHandlerIsDefault: () => boolean = () => - errorHandler === defaultErrorHandler; - - if (configDataStore) { - return configDataStore.errorHandler; - } - if (errorHandlerIsDefault() && config.errorHandler) { - return config.errorHandler; - } - - return errorHandler || defaultErrorHandler; -} - function defaultErrorHandler(error: SyncError) { logger.warn(error); } @@ -819,6 +445,7 @@ async function checkSchemaVersion( const [schemaVersionSetting] = await s.query( Setting, ModelPredicateCreator.createFromExisting(modelDefinition, c => + // @ts-ignore Argument of type '"eq"' is not assignable to parameter of type 'never'. c.key('eq', SETTING_SCHEMA_VERSION) ), { page: 0, limit: 1 } @@ -843,97 +470,6 @@ async function checkSchemaVersion( let syncSubscription: ZenObservable.Subscription; -let initResolve: Function; -let initReject: Function; -let initialized: Promise; -async function start(): Promise { - if (initialized === undefined) { - logger.debug('Starting DataStore'); - initialized = new Promise((res, rej) => { - initResolve = res; - initReject = rej; - }); - } else { - await initialized; - - return; - } - - storage = new Storage( - schema, - namespaceResolver, - getModelConstructorByModelName, - modelInstanceCreator - ); - - await storage.init(); - - await checkSchemaVersion(storage, schema.version); - - const { aws_appsync_graphqlEndpoint } = amplifyConfig; - - if (aws_appsync_graphqlEndpoint) { - logger.debug('GraphQL endpoint available', aws_appsync_graphqlEndpoint); - - sync = new SyncEngine( - schema, - namespaceResolver, - syncClasses, - userClasses, - storage, - modelInstanceCreator, - maxRecordsToSync, - syncPageSize, - conflictHandler, - errorHandler - ); - - const fullSyncIntervalInMilliseconds = fullSyncInterval * 1000 * 60; // fullSyncInterval from param is in minutes - syncSubscription = sync - .start({ fullSyncInterval: fullSyncIntervalInMilliseconds }) - .subscribe({ - next: ({ type, data }) => { - if (type === ControlMessage.SYNC_ENGINE_STORAGE_SUBSCRIBED) { - initResolve(); - } - - Hub.dispatch('datastore', { - event: type, - data, - }); - }, - error: err => { - logger.warn('Sync error', err); - initReject(); - }, - }); - } else { - logger.warn("Data won't be synchronized. No GraphQL endpoint configured. Did you forget `Amplify.configure(awsconfig)`?", { - config: amplifyConfig, - }); - - initResolve(); - } - - await initialized; -} - -async function clear() { - if (storage === undefined) { - return; - } - - if (syncSubscription && !syncSubscription.closed) { - syncSubscription.unsubscribe(); - } - - await storage.clear(); - - initialized = undefined; // Should re-initialize when start() is called. - storage = undefined; - sync = undefined; -} - function getNamespace(): SchemaNamespace { const namespace: SchemaNamespace = { name: DATASTORE, @@ -973,21 +509,512 @@ function getNamespace(): SchemaNamespace { } class DataStore { - constructor() { - Amplify.register(this); - } + private amplifyConfig: Record = {}; + private conflictHandler: ConflictHandler; + private errorHandler: (error: SyncError) => void; + private fullSyncInterval: number; + private initialized: Promise; + private initReject: Function; + private initResolve: Function; + private maxRecordsToSync: number; + private storage: Storage; + private sync: SyncEngine; + private syncPageSize: number; + getModuleName() { return 'DataStore'; } - start = start; - query = query; - save = save; - delete = remove; - observe = observe; - configure = configure; - clear = clear; + + start = async (): Promise => { + if (this.initialized === undefined) { + logger.debug('Starting DataStore'); + this.initialized = new Promise((res, rej) => { + this.initResolve = res; + this.initReject = rej; + }); + } else { + await this.initialized; + + return; + } + + this.storage = new Storage( + schema, + namespaceResolver, + getModelConstructorByModelName, + modelInstanceCreator + ); + + await this.storage.init(); + + await checkSchemaVersion(this.storage, schema.version); + + const { aws_appsync_graphqlEndpoint } = this.amplifyConfig; + + if (aws_appsync_graphqlEndpoint) { + logger.debug('GraphQL endpoint available', aws_appsync_graphqlEndpoint); + + this.sync = new SyncEngine( + schema, + namespaceResolver, + syncClasses, + userClasses, + this.storage, + modelInstanceCreator, + this.maxRecordsToSync, + this.syncPageSize, + this.conflictHandler, + this.errorHandler + ); + + // tslint:disable-next-line:max-line-length + const fullSyncIntervalInMilliseconds = this.fullSyncInterval * 1000 * 60; // fullSyncInterval from param is in minutes + syncSubscription = this.sync + .start({ fullSyncInterval: fullSyncIntervalInMilliseconds }) + .subscribe({ + next: ({ type, data }) => { + // In Node, we need to wait for queries to be synced to prevent returning empty arrays. + // In the Browser, we can begin returning data once subscriptions are in place. + const readyType = isNode + ? ControlMessage.SYNC_ENGINE_SYNC_QUERIES_READY + : ControlMessage.SYNC_ENGINE_STORAGE_SUBSCRIBED; + + if (type === readyType) { + this.initResolve(); + } + + Hub.dispatch('datastore', { + event: type, + data, + }); + }, + error: err => { + logger.warn('Sync error', err); + this.initReject(); + }, + }); + } else { + logger.warn( + "Data won't be synchronized. No GraphQL endpoint configured. Did you forget `Amplify.configure(awsconfig)`?", + { + config: this.amplifyConfig, + } + ); + + this.initResolve(); + } + + await this.initialized; + }; + + query: { + ( + modelConstructor: PersistentModelConstructor, + id: string + ): Promise; + ( + modelConstructor: PersistentModelConstructor, + criteria?: ProducerModelPredicate | typeof PredicateAll, + pagination?: PaginationInput + ): Promise; + } = async ( + modelConstructor: PersistentModelConstructor, + idOrCriteria?: string | ProducerModelPredicate | typeof PredicateAll, + pagination?: PaginationInput + ): Promise => { + await this.start(); + + //#region Input validation + + if (!isValidModelConstructor(modelConstructor)) { + const msg = 'Constructor is not for a valid model'; + logger.error(msg, { modelConstructor }); + + throw new Error(msg); + } + + if (typeof idOrCriteria === 'string') { + if (pagination !== undefined) { + logger.warn('Pagination is ignored when querying by id'); + } + } + + const modelDefinition = getModelDefinition(modelConstructor); + let predicate: ModelPredicate; + + if (isQueryOne(idOrCriteria)) { + predicate = ModelPredicateCreator.createForId( + modelDefinition, + idOrCriteria + ); + } else { + if (isPredicatesAll(idOrCriteria)) { + // Predicates.ALL means "all records", so no predicate (undefined) + predicate = undefined; + } else { + predicate = ModelPredicateCreator.createFromExisting( + modelDefinition, + idOrCriteria + ); + } + } + + const { limit, page } = pagination || {}; + + if (page !== undefined && limit === undefined) { + throw new Error('Limit is required when requesting a page'); + } + + if (page !== undefined) { + if (typeof page !== 'number') { + throw new Error('Page should be a number'); + } + + if (page < 0) { + throw new Error("Page can't be negative"); + } + } + + if (limit !== undefined) { + if (typeof limit !== 'number') { + throw new Error('Limit should be a number'); + } + + if (limit < 0) { + throw new Error("Limit can't be negative"); + } + } + + //#endregion + + logger.debug('params ready', { + modelConstructor, + predicate: ModelPredicateCreator.getPredicates(predicate, false), + pagination, + }); + + const result = await this.storage.query( + modelConstructor, + predicate, + pagination + ); + + return isQueryOne(idOrCriteria) ? result[0] : result; + }; + + save = async ( + model: T, + condition?: ProducerModelPredicate + ): Promise => { + await this.start(); + + const modelConstructor: PersistentModelConstructor = model + ? >model.constructor + : undefined; + + if (!isValidModelConstructor(modelConstructor)) { + const msg = 'Object is not an instance of a valid model'; + logger.error(msg, { model }); + + throw new Error(msg); + } + + const modelDefinition = getModelDefinition(modelConstructor); + + const producedCondition = ModelPredicateCreator.createFromExisting( + modelDefinition, + condition + ); + + const [savedModel] = await this.storage.runExclusive(async s => { + await s.save(model, producedCondition); + + return s.query( + modelConstructor, + ModelPredicateCreator.createForId(modelDefinition, model.id) + ); + }); + + return savedModel; + }; + + setConflictHandler = (config: DataStoreConfig): ConflictHandler => { + const { DataStore: configDataStore } = config; + + const conflictHandlerIsDefault: () => boolean = () => + this.conflictHandler === defaultConflictHandler; + + if (configDataStore) { + return configDataStore.conflictHandler; + } + if (conflictHandlerIsDefault() && config.conflictHandler) { + return config.conflictHandler; + } + + return this.conflictHandler || defaultConflictHandler; + }; + + setErrorHandler = (config: DataStoreConfig): ErrorHandler => { + const { DataStore: configDataStore } = config; + + const errorHandlerIsDefault: () => boolean = () => + this.errorHandler === defaultErrorHandler; + + if (configDataStore) { + return configDataStore.errorHandler; + } + if (errorHandlerIsDefault() && config.errorHandler) { + return config.errorHandler; + } + + return this.errorHandler || defaultErrorHandler; + }; + + delete: { + ( + model: T, + condition?: ProducerModelPredicate + ): Promise; + ( + modelConstructor: PersistentModelConstructor, + id: string + ): Promise; + ( + modelConstructor: PersistentModelConstructor, + condition: ProducerModelPredicate | typeof PredicateAll + ): Promise; + } = async ( + modelOrConstructor: T | PersistentModelConstructor, + idOrCriteria?: string | ProducerModelPredicate | typeof PredicateAll + ) => { + await this.start(); + + let condition: ModelPredicate; + + if (!modelOrConstructor) { + const msg = 'Model or Model Constructor required'; + logger.error(msg, { modelOrConstructor }); + + throw new Error(msg); + } + + if (isValidModelConstructor(modelOrConstructor)) { + const modelConstructor = modelOrConstructor; + + if (!idOrCriteria) { + const msg = + 'Id to delete or criteria required. Do you want to delete all? Pass Predicates.ALL'; + logger.error(msg, { idOrCriteria }); + + throw new Error(msg); + } + + if (typeof idOrCriteria === 'string') { + condition = ModelPredicateCreator.createForId( + getModelDefinition(modelConstructor), + idOrCriteria + ); + } else { + condition = ModelPredicateCreator.createFromExisting( + getModelDefinition(modelConstructor), + /** + * idOrCriteria is always a ProducerModelPredicate, never a symbol. + * The symbol is used only for typing purposes. e.g. see Predicates.ALL + */ + idOrCriteria as ProducerModelPredicate + ); + + if (!condition || !ModelPredicateCreator.isValidPredicate(condition)) { + const msg = + 'Criteria required. Do you want to delete all? Pass Predicates.ALL'; + logger.error(msg, { condition }); + + throw new Error(msg); + } + } + + const [deleted] = await this.storage.delete(modelConstructor, condition); + + return deleted; + } else { + const model = modelOrConstructor; + const modelConstructor = Object.getPrototypeOf(model || {}) + .constructor as PersistentModelConstructor; + + if (!isValidModelConstructor(modelConstructor)) { + const msg = 'Object is not an instance of a valid model'; + logger.error(msg, { model }); + + throw new Error(msg); + } + + const modelDefinition = getModelDefinition(modelConstructor); + + const idPredicate = ModelPredicateCreator.createForId( + modelDefinition, + model.id + ); + + if (idOrCriteria) { + if (typeof idOrCriteria !== 'function') { + const msg = 'Invalid criteria'; + logger.error(msg, { idOrCriteria }); + + throw new Error(msg); + } + + condition = idOrCriteria(idPredicate); + } else { + condition = idPredicate; + } + + const [[deleted]] = await this.storage.delete(model, condition); + + return deleted; + } + }; + + observe: { + (): Observable>; + + (model: T): Observable>; + + ( + modelConstructor: PersistentModelConstructor, + criteria?: string | ProducerModelPredicate + ): Observable>; + } = ( + modelOrConstructor?: T | PersistentModelConstructor, + idOrCriteria?: string | ProducerModelPredicate + ): Observable> => { + let predicate: ModelPredicate; + + const modelConstructor: PersistentModelConstructor = + modelOrConstructor && isValidModelConstructor(modelOrConstructor) + ? modelOrConstructor + : undefined; + + if (modelOrConstructor && modelConstructor === undefined) { + const model = modelOrConstructor; + const modelConstructor = + model && (Object.getPrototypeOf(model)).constructor; + + if (isValidModelConstructor(modelConstructor)) { + if (idOrCriteria) { + logger.warn('idOrCriteria is ignored when using a model instance', { + model, + idOrCriteria, + }); + } + + return this.observe(modelConstructor, model.id); + } else { + const msg = + 'The model is not an instance of a PersistentModelConstructor'; + logger.error(msg, { model }); + + throw new Error(msg); + } + } + + if (idOrCriteria !== undefined && modelConstructor === undefined) { + const msg = 'Cannot provide criteria without a modelConstructor'; + logger.error(msg, idOrCriteria); + throw new Error(msg); + } + + if (modelConstructor && !isValidModelConstructor(modelConstructor)) { + const msg = 'Constructor is not for a valid model'; + logger.error(msg, { modelConstructor }); + + throw new Error(msg); + } + + if (typeof idOrCriteria === 'string') { + predicate = ModelPredicateCreator.createForId( + getModelDefinition(modelConstructor), + idOrCriteria + ); + } else { + predicate = + modelConstructor && + ModelPredicateCreator.createFromExisting( + getModelDefinition(modelConstructor), + idOrCriteria + ); + } + + return new Observable>(observer => { + let handle: ZenObservable.Subscription; + + (async () => { + await this.start(); + + handle = this.storage + .observe(modelConstructor, predicate) + .filter(({ model }) => namespaceResolver(model) === USER) + .subscribe(observer); + })(); + + return () => { + if (handle) { + handle.unsubscribe(); + } + }; + }); + }; + + configure = (config: DataStoreConfig = {}) => { + const { + DataStore: configDataStore, + conflictHandler: configConflictHandler, + errorHandler: configErrorHandler, + maxRecordsToSync: configMaxRecordsToSync, + syncPageSize: configSyncPageSize, + fullSyncInterval: configFullSyncInterval, + ...configFromAmplify + } = config; + + this.amplifyConfig = { ...configFromAmplify, ...this.amplifyConfig }; + + this.conflictHandler = this.setConflictHandler(config); + this.errorHandler = this.setErrorHandler(config); + + this.maxRecordsToSync = + (configDataStore && configDataStore.maxRecordsToSync) || + this.maxRecordsToSync || + config.maxRecordsToSync; + + this.syncPageSize = + (configDataStore && configDataStore.syncPageSize) || + this.syncPageSize || + config.syncPageSize; + + this.fullSyncInterval = + (configDataStore && configDataStore.fullSyncInterval) || + configFullSyncInterval || + config.fullSyncInterval || + 24 * 60; // 1 day + }; + + clear = async function clear() { + if (this.storage === undefined) { + return; + } + + if (syncSubscription && !syncSubscription.closed) { + syncSubscription.unsubscribe(); + } + + await this.storage.clear(); + + this.initialized = undefined; // Should re-initialize when start() is called. + this.storage = undefined; + this.sync = undefined; + }; } const instance = new DataStore(); +Amplify.register(instance); -export { initSchema, instance as DataStore }; +export { DataStore as DataStoreClass, initSchema, instance as DataStore }; diff --git a/packages/datastore/src/index.ts b/packages/datastore/src/index.ts index 1c2ea141e65..7689c7c7a5c 100644 --- a/packages/datastore/src/index.ts +++ b/packages/datastore/src/index.ts @@ -1,5 +1,3 @@ -import { DataStore, initSchema } from './datastore/datastore'; -import { Predicates } from './predicates'; - +export { DataStore, DataStoreClass, initSchema } from './datastore/datastore'; +export { Predicates } from './predicates'; export * from './types'; -export { DataStore, initSchema, Predicates }; diff --git a/packages/datastore/src/ssr/index.ts b/packages/datastore/src/ssr/index.ts new file mode 100644 index 00000000000..acbc1862bd4 --- /dev/null +++ b/packages/datastore/src/ssr/index.ts @@ -0,0 +1,25 @@ +import { + PersistentModel, + PersistentModelConstructor, +} from '@aws-amplify/datastore'; + +// Helper for converting JSON back into DataStore models (while respecting IDs) +export function deserializeModel( + Model: PersistentModelConstructor, + init: T | T[] +) { + if (Array.isArray(init)) { + return init.map(init => deserializeModel(Model, init)); + } + + // `fromJSON` is intentionally hidden from types as a "private" method (though it exists on the instance) + // @ts-ignore Property 'fromJSON' does not exist on type 'PersistentModelConstructor'.ts(2339) + return Model.fromJSON(init); +} + +// Helper for converting DataStore models to JSON +export function serializeModel( + model: T | T[] +): JSON { + return JSON.parse(JSON.stringify(model)); +} diff --git a/packages/datastore/src/storage/adapter/AsyncStorageAdapter.ts b/packages/datastore/src/storage/adapter/AsyncStorageAdapter.ts new file mode 100644 index 00000000000..2915d819889 --- /dev/null +++ b/packages/datastore/src/storage/adapter/AsyncStorageAdapter.ts @@ -0,0 +1,567 @@ +import { ConsoleLogger as Logger } from '@aws-amplify/core'; +import AsyncStorageDatabase from './AsyncStorageDatabase'; +import { Adapter } from './index'; +import { ModelInstanceCreator } from '../../datastore/datastore'; +import { ModelPredicateCreator } from '../../predicates'; +import { + InternalSchema, + isPredicateObj, + ModelInstanceMetadata, + ModelPredicate, + NamespaceResolver, + OpType, + PaginationInput, + PersistentModel, + PersistentModelConstructor, + PredicateObject, + QueryOne, + RelationType, +} from '../../types'; +import { + exhaustiveCheck, + getIndex, + getIndexFromAssociation, + isModelConstructor, + traverseModel, + validatePredicate, +} from '../../util'; + +const logger = new Logger('DataStore'); + +export class AsyncStorageAdapter implements Adapter { + private schema: InternalSchema; + private namespaceResolver: NamespaceResolver; + private modelInstanceCreator: ModelInstanceCreator; + private getModelConstructorByModelName: ( + namsespaceName: string, + modelName: string + ) => PersistentModelConstructor; + private db: AsyncStorageDatabase; + private initPromise: Promise; + private resolve: (value?: any) => void; + private reject: (value?: any) => void; + + private getStorenameForModel( + modelConstructor: PersistentModelConstructor + ) { + const namespace = this.namespaceResolver(modelConstructor); + const { name: modelName } = modelConstructor; + + return this.getStorename(namespace, modelName); + } + + private getStorename(namespace: string, modelName: string) { + const storeName = `${namespace}_${modelName}`; + + return storeName; + } + + async setUp( + theSchema: InternalSchema, + namespaceResolver: NamespaceResolver, + modelInstanceCreator: ModelInstanceCreator, + getModelConstructorByModelName: ( + namsespaceName: string, + modelName: string + ) => PersistentModelConstructor + ) { + if (!this.initPromise) { + this.initPromise = new Promise((res, rej) => { + this.resolve = res; + this.reject = rej; + }); + } else { + await this.initPromise; + return; + } + this.schema = theSchema; + this.namespaceResolver = namespaceResolver; + this.modelInstanceCreator = modelInstanceCreator; + this.getModelConstructorByModelName = getModelConstructorByModelName; + try { + if (!this.db) { + this.db = new AsyncStorageDatabase(); + await this.db.init(); + this.resolve(); + } + } catch (error) { + this.reject(error); + } + } + + async save( + model: T, + condition?: ModelPredicate + ): Promise<[T, OpType.INSERT | OpType.UPDATE][]> { + const modelConstructor = Object.getPrototypeOf(model) + .constructor as PersistentModelConstructor; + const storeName = this.getStorenameForModel(modelConstructor); + const connectedModels = traverseModel( + modelConstructor.name, + model, + this.schema.namespaces[this.namespaceResolver(modelConstructor)], + this.modelInstanceCreator, + this.getModelConstructorByModelName + ); + const namespaceName = this.namespaceResolver(modelConstructor); + const set = new Set(); + const connectionStoreNames = Object.values(connectedModels).map( + ({ modelName, item, instance }) => { + const storeName = this.getStorename(namespaceName, modelName); + set.add(storeName); + return { storeName, item, instance }; + } + ); + const fromDB = await this.db.get(model.id, storeName); + + if (condition && fromDB) { + const predicates = ModelPredicateCreator.getPredicates(condition); + const { predicates: predicateObjs, type } = predicates; + + const isValid = validatePredicate(fromDB, type, predicateObjs); + + if (!isValid) { + const msg = 'Conditional update failed'; + logger.error(msg, { model: fromDB, condition: predicateObjs }); + + throw new Error(msg); + } + } + + const result: [T, OpType.INSERT | OpType.UPDATE][] = []; + + for await (const resItem of connectionStoreNames) { + const { storeName, item, instance } = resItem; + + const { id } = item; + + const opType: OpType = (await this.db.get(id, storeName)) + ? OpType.UPDATE + : OpType.INSERT; + + if (id === model.id) { + await this.db.save(item, storeName); + + result.push([instance, opType]); + } else { + if (opType === OpType.INSERT) { + await this.db.save(item, storeName); + + result.push([instance, opType]); + } + } + } + + return result; + } + + private async load( + namespaceName: string, + srcModelName: string, + records: T[] + ): Promise { + const namespace = this.schema.namespaces[namespaceName]; + const relations = namespace.relationships[srcModelName].relationTypes; + const connectionStoreNames = relations.map(({ modelName }) => { + return this.getStorename(namespaceName, modelName); + }); + const modelConstructor = this.getModelConstructorByModelName( + namespaceName, + srcModelName + ); + + if (connectionStoreNames.length === 0) { + return records.map(record => + this.modelInstanceCreator(modelConstructor, record) + ); + } + + for await (const relation of relations) { + const { fieldName, modelName, targetName, relationType } = relation; + const storeName = this.getStorename(namespaceName, modelName); + const modelConstructor = this.getModelConstructorByModelName( + namespaceName, + modelName + ); + + switch (relationType) { + case 'HAS_ONE': + for await (const recordItem of records) { + if (recordItem[fieldName]) { + const connectionRecord = await this.db.get( + recordItem[fieldName], + storeName + ); + + recordItem[fieldName] = + connectionRecord && + this.modelInstanceCreator(modelConstructor, connectionRecord); + } + } + + break; + case 'BELONGS_TO': + for await (const recordItem of records) { + if (recordItem[targetName]) { + const connectionRecord = await this.db.get( + recordItem[targetName], + storeName + ); + + recordItem[fieldName] = + connectionRecord && + this.modelInstanceCreator(modelConstructor, connectionRecord); + delete recordItem[targetName]; + } + } + + break; + case 'HAS_MANY': + // TODO: Lazy loading + break; + default: + exhaustiveCheck(relationType); + break; + } + } + + return records.map(record => + this.modelInstanceCreator(modelConstructor, record) + ); + } + + async query( + modelConstructor: PersistentModelConstructor, + predicate?: ModelPredicate, + pagination?: PaginationInput + ): Promise { + const storeName = this.getStorenameForModel(modelConstructor); + const namespaceName = this.namespaceResolver(modelConstructor); + + if (predicate) { + const predicates = ModelPredicateCreator.getPredicates(predicate); + if (predicates) { + const { predicates: predicateObjs, type } = predicates; + const idPredicate = + predicateObjs.length === 1 && + (predicateObjs.find( + p => isPredicateObj(p) && p.field === 'id' && p.operator === 'eq' + ) as PredicateObject); + + if (idPredicate) { + const { operand: id } = idPredicate; + + const record = await this.db.get(id, storeName); + + if (record) { + const [x] = await this.load(namespaceName, modelConstructor.name, [ + record, + ]); + return [x]; + } + return []; + } + + const all = await this.db.getAll(storeName); + + const filtered = predicateObjs + ? all.filter(m => validatePredicate(m, type, predicateObjs)) + : all; + + return await this.load( + namespaceName, + modelConstructor.name, + this.inMemoryPagination(filtered, pagination) + ); + } + } + + const all = await this.db.getAll(storeName, pagination); + + return await this.load(namespaceName, modelConstructor.name, all); + } + + private inMemoryPagination( + records: T[], + pagination?: PaginationInput + ): T[] { + if (pagination) { + const { page = 0, limit = 0 } = pagination; + const start = Math.max(0, page * limit) || 0; + + const end = limit > 0 ? start + limit : records.length; + + return records.slice(start, end); + } + + return records; + } + + async queryOne( + modelConstructor: PersistentModelConstructor, + firstOrLast: QueryOne = QueryOne.FIRST + ): Promise { + const storeName = this.getStorenameForModel(modelConstructor); + const result = await this.db.getOne(firstOrLast, storeName); + return result && this.modelInstanceCreator(modelConstructor, result); + } + + async delete( + modelOrModelConstructor: T | PersistentModelConstructor, + condition?: ModelPredicate + ): Promise<[T[], T[]]> { + const deleteQueue: { storeName: string; items: T[] }[] = []; + + if (isModelConstructor(modelOrModelConstructor)) { + const modelConstructor = modelOrModelConstructor; + const nameSpace = this.namespaceResolver(modelConstructor); + + // models to be deleted. + const models = await this.query(modelConstructor, condition); + // TODO: refactor this to use a function like getRelations() + const relations = this.schema.namespaces[nameSpace].relationships[ + modelConstructor.name + ].relationTypes; + + if (condition !== undefined) { + await this.deleteTraverse( + relations, + models, + modelConstructor.name, + nameSpace, + deleteQueue + ); + + await this.deleteItem(deleteQueue); + + const deletedModels = deleteQueue.reduce( + (acc, { items }) => acc.concat(items), + [] + ); + return [models, deletedModels]; + } else { + await this.deleteTraverse( + relations, + models, + modelConstructor.name, + nameSpace, + deleteQueue + ); + + await this.deleteItem(deleteQueue); + + const deletedModels = deleteQueue.reduce( + (acc, { items }) => acc.concat(items), + [] + ); + + return [models, deletedModels]; + } + } else { + const model = modelOrModelConstructor; + + const modelConstructor = Object.getPrototypeOf(model) + .constructor as PersistentModelConstructor; + const nameSpace = this.namespaceResolver(modelConstructor); + + const storeName = this.getStorenameForModel(modelConstructor); + if (condition) { + const fromDB = await this.db.get(model.id, storeName); + + if (fromDB === undefined) { + const msg = 'Model instance not found in storage'; + logger.warn(msg, { model }); + + return [[model], []]; + } + + const predicates = ModelPredicateCreator.getPredicates(condition); + const { predicates: predicateObjs, type } = predicates; + + const isValid = validatePredicate(fromDB, type, predicateObjs); + if (!isValid) { + const msg = 'Conditional update failed'; + logger.error(msg, { model: fromDB, condition: predicateObjs }); + + throw new Error(msg); + } + + const relations = this.schema.namespaces[nameSpace].relationships[ + modelConstructor.name + ].relationTypes; + await this.deleteTraverse( + relations, + [model], + modelConstructor.name, + nameSpace, + deleteQueue + ); + } else { + const relations = this.schema.namespaces[nameSpace].relationships[ + modelConstructor.name + ].relationTypes; + + await this.deleteTraverse( + relations, + [model], + modelConstructor.name, + nameSpace, + deleteQueue + ); + } + + await this.deleteItem(deleteQueue); + + const deletedModels = deleteQueue.reduce( + (acc, { items }) => acc.concat(items), + [] + ); + + return [[model], deletedModels]; + } + } + + private async deleteItem( + deleteQueue?: { storeName: string; items: T[] | IDBValidKey[] }[] + ) { + for await (const deleteItem of deleteQueue) { + const { storeName, items } = deleteItem; + + for await (const item of items) { + if (item) { + if (typeof item === 'object') { + const id = item['id']; + await this.db.delete(id, storeName); + } + } + } + } + } + /** + * Populates the delete Queue with all the items to delete + * @param relations + * @param models + * @param srcModel + * @param nameSpace + * @param deleteQueue + */ + private async deleteTraverse( + relations: RelationType[], + models: T[], + srcModel: string, + nameSpace: string, + deleteQueue: { storeName: string; items: T[] }[] + ): Promise { + for await (const rel of relations) { + const { relationType, modelName } = rel; + const storeName = this.getStorename(nameSpace, modelName); + + const index: string = + getIndex( + this.schema.namespaces[nameSpace].relationships[modelName] + .relationTypes, + srcModel + ) || + // if we were unable to find an index via relationTypes + // i.e. for keyName connections, attempt to find one by the + // associatedWith property + getIndexFromAssociation( + this.schema.namespaces[nameSpace].relationships[modelName].indexes, + rel.associatedWith + ); + + switch (relationType) { + case 'HAS_ONE': + for await (const model of models) { + const allRecords = await this.db.getAll(storeName); + const recordToDelete = allRecords.filter( + childItem => childItem[index] === model.id + ); + + await this.deleteTraverse( + this.schema.namespaces[nameSpace].relationships[modelName] + .relationTypes, + recordToDelete, + modelName, + nameSpace, + deleteQueue + ); + } + break; + case 'HAS_MANY': + for await (const model of models) { + const allRecords = await this.db.getAll(storeName); + const childrenArray = allRecords.filter( + childItem => childItem[index] === model.id + ); + + await this.deleteTraverse( + this.schema.namespaces[nameSpace].relationships[modelName] + .relationTypes, + childrenArray, + modelName, + nameSpace, + deleteQueue + ); + } + break; + case 'BELONGS_TO': + // Intentionally blank + break; + default: + exhaustiveCheck(relationType); + break; + } + } + + deleteQueue.push({ + storeName: this.getStorename(nameSpace, srcModel), + items: models.map(record => + this.modelInstanceCreator( + this.getModelConstructorByModelName(nameSpace, srcModel), + record + ) + ), + }); + } + + async clear(): Promise { + await this.db.clear(); + + this.db = undefined; + this.initPromise = undefined; + } + + async batchSave( + modelConstructor: PersistentModelConstructor, + items: ModelInstanceMetadata[] + ): Promise<[T, OpType][]> { + const { name: modelName } = modelConstructor; + const namespaceName = this.namespaceResolver(modelConstructor); + const storeName = this.getStorename(namespaceName, modelName); + + const batch: ModelInstanceMetadata[] = []; + + for (const item of items) { + const { id } = item; + + const connectedModels = traverseModel( + modelConstructor.name, + this.modelInstanceCreator(modelConstructor, item), + this.schema.namespaces[this.namespaceResolver(modelConstructor)], + this.modelInstanceCreator, + this.getModelConstructorByModelName + ); + + const { instance } = connectedModels.find( + ({ instance }) => instance.id === id + ); + + batch.push(instance); + } + + return await this.db.batchSave(storeName, batch); + } +} + +export default new AsyncStorageAdapter(); diff --git a/packages/datastore/src/storage/adapter/AsyncStorageDatabase.ts b/packages/datastore/src/storage/adapter/AsyncStorageDatabase.ts index 0e42619195a..d42cc4360ac 100644 --- a/packages/datastore/src/storage/adapter/AsyncStorageDatabase.ts +++ b/packages/datastore/src/storage/adapter/AsyncStorageDatabase.ts @@ -1,4 +1,3 @@ -import { AsyncStorage } from 'react-native'; import { ULID } from 'ulid'; import { ModelInstanceMetadata, @@ -8,6 +7,7 @@ import { QueryOne, } from '../../types'; import { monotonicUlidFactory } from '../../util'; +import { InMemoryStore } from './InMemoryStore'; const DB_NAME = '@AmplifyDatastore'; const COLLECTION = 'Collection'; @@ -21,6 +21,8 @@ class AsyncStorageDatabase { */ private _collectionInMemoryIndex = new Map>(); + private storage = new InMemoryStore(); + private getCollectionIndex(storeName: string) { if (!this._collectionInMemoryIndex.has(storeName)) { this._collectionInMemoryIndex.set(storeName, new Map()); @@ -40,7 +42,7 @@ class AsyncStorageDatabase { async init(): Promise { this._collectionInMemoryIndex.clear(); - const allKeys: string[] = await AsyncStorage.getAllKeys(); + const allKeys: string[] = await this.storage.getAllKeys(); const keysForCollectionEntries = []; @@ -61,10 +63,10 @@ class AsyncStorageDatabase { const oldKey = this.getLegacyKeyForItem(storeName, id); const newKey = this.getKeyForItem(storeName, id, newUlid); - const item = await AsyncStorage.getItem(oldKey); + const item = await this.storage.getItem(oldKey); - await AsyncStorage.setItem(newKey, item); - await AsyncStorage.removeItem(oldKey); + await this.storage.setItem(newKey, item); + await this.storage.removeItem(oldKey); ulid = newUlid; } else { @@ -79,7 +81,7 @@ class AsyncStorageDatabase { } if (keysForCollectionEntries.length > 0) { - await AsyncStorage.multiRemove(keysForCollectionEntries); + await this.storage.multiRemove(keysForCollectionEntries); } } @@ -92,7 +94,7 @@ class AsyncStorageDatabase { this.getCollectionIndex(storeName).set(item.id, ulid); - await AsyncStorage.setItem(itemKey, JSON.stringify(item)); + await this.storage.setItem(itemKey, JSON.stringify(item)); } async batchSave( @@ -127,7 +129,7 @@ class AsyncStorageDatabase { } } - const existingRecordsMap: [string, string][] = await AsyncStorage.multiGet( + const existingRecordsMap: [string, string][] = await this.storage.multiGet( allItemsKeys ); const existingRecordsKeys = existingRecordsMap @@ -146,7 +148,7 @@ class AsyncStorageDatabase { collection.delete(itemsMap[key].model.id) ); - AsyncStorage.multiRemove(keysToDeleteArray, (errors?: Error[]) => { + this.storage.multiRemove(keysToDeleteArray, (errors?: Error[]) => { if (errors && errors.length > 0) { reject(errors); } else { @@ -175,7 +177,7 @@ class AsyncStorageDatabase { collection.set(id, ulid); }); - AsyncStorage.multiSet(entriesToSet, (errors?: Error[]) => { + this.storage.multiSet(entriesToSet, (errors?: Error[]) => { if (errors && errors.length > 0) { reject(errors); } else { @@ -204,7 +206,7 @@ class AsyncStorageDatabase { ): Promise { const ulid = this.getCollectionIndex(storeName).get(id); const itemKey = this.getKeyForItem(storeName, id, ulid); - const recordAsString = await AsyncStorage.getItem(itemKey); + const recordAsString = await this.storage.getItem(itemKey); const record = recordAsString && JSON.parse(recordAsString); return record; } @@ -225,7 +227,7 @@ class AsyncStorageDatabase { return [id, ulid]; })(); const itemKey = this.getKeyForItem(storeName, itemId, ulid); - const itemString = itemKey && (await AsyncStorage.getItem(itemKey)); + const itemString = itemKey && (await this.storage.getItem(itemKey)); const result = itemString ? JSON.parse(itemString) || undefined : undefined; @@ -262,7 +264,7 @@ class AsyncStorageDatabase { } } - const storeRecordStrings = await AsyncStorage.multiGet(keysForStore); + const storeRecordStrings = await this.storage.multiGet(keysForStore); const records = storeRecordStrings .filter(([, value]) => value) .map(([, value]) => JSON.parse(value)); @@ -275,16 +277,16 @@ class AsyncStorageDatabase { const itemKey = this.getKeyForItem(storeName, id, ulid); this.getCollectionIndex(storeName).delete(id); - await AsyncStorage.removeItem(itemKey); + await this.storage.removeItem(itemKey); } /** * Clear the AsyncStorage of all DataStore entries */ async clear() { - const allKeys = await AsyncStorage.getAllKeys(); + const allKeys = await this.storage.getAllKeys(); const allDataStoreKeys = allKeys.filter(key => key.startsWith(DB_NAME)); - await AsyncStorage.multiRemove(allDataStoreKeys); + await this.storage.multiRemove(allDataStoreKeys); this._collectionInMemoryIndex.clear(); } diff --git a/packages/datastore/src/storage/adapter/InMemoryStore.native.ts b/packages/datastore/src/storage/adapter/InMemoryStore.native.ts new file mode 100644 index 00000000000..c3b32c98ea2 --- /dev/null +++ b/packages/datastore/src/storage/adapter/InMemoryStore.native.ts @@ -0,0 +1,16 @@ +import { AsyncStorage } from 'react-native'; + +// See: https://reactnative.dev/docs/asyncstorage +export class InMemoryStore { + getItem = AsyncStorage.getItem; + setItem = AsyncStorage.setItem; + removeItem = AsyncStorage.removeItem; + // We don't use `mergeItem()` + // We don't use `clear()` + getAllKeys = AsyncStorage.getAllKeys; + // We don't use `flushGetRequests()` + multiGet = AsyncStorage.multiGet; + multiSet = AsyncStorage.multiSet; + multiRemove = AsyncStorage.multiRemove; + // We don't use `multiMerge()` +} diff --git a/packages/datastore/src/storage/adapter/InMemoryStore.ts b/packages/datastore/src/storage/adapter/InMemoryStore.ts new file mode 100644 index 00000000000..6ad14db0b81 --- /dev/null +++ b/packages/datastore/src/storage/adapter/InMemoryStore.ts @@ -0,0 +1,37 @@ +export class InMemoryStore { + db = new Map(); + + getAllKeys = async () => { + return Array.from(this.db.keys()); + }; + + multiGet = async (keys: string[]) => { + return keys.reduce((res, k) => (res.push([k, this.db.get(k)]), res), []); + }; + + multiRemove = async (keys: string[], callback?) => { + keys.forEach(k => this.db.delete(k)); + + callback(); + }; + + multiSet = async (entries: string[][], callback?) => { + entries.forEach(([key, value]) => { + this.setItem(key, value); + }); + + callback(); + }; + + setItem = async (key: string, value: string) => { + return this.db.set(key, value); + }; + + removeItem = async (key: string) => { + return this.db.delete(key); + }; + + getItem = async (key: string) => { + return this.db.get(key); + }; +} diff --git a/packages/datastore/src/storage/adapter/IndexedDBAdapter.ts b/packages/datastore/src/storage/adapter/IndexedDBAdapter.ts new file mode 100644 index 00000000000..dc3047ffe20 --- /dev/null +++ b/packages/datastore/src/storage/adapter/IndexedDBAdapter.ts @@ -0,0 +1,797 @@ +import { ConsoleLogger as Logger } from '@aws-amplify/core'; +import * as idb from 'idb'; +import { ModelInstanceCreator } from '../../datastore/datastore'; +import { ModelPredicateCreator } from '../../predicates'; +import { + InternalSchema, + isPredicateObj, + ModelInstanceMetadata, + ModelPredicate, + NamespaceResolver, + OpType, + PaginationInput, + PersistentModel, + PersistentModelConstructor, + PredicateObject, + QueryOne, + RelationType, +} from '../../types'; +import { + exhaustiveCheck, + getIndex, + getIndexFromAssociation, + isModelConstructor, + isPrivateMode, + traverseModel, + validatePredicate, +} from '../../util'; +import { Adapter } from './index'; + +const logger = new Logger('DataStore'); + +const DB_NAME = 'amplify-datastore'; + +class IndexedDBAdapter implements Adapter { + private schema: InternalSchema; + private namespaceResolver: NamespaceResolver; + private modelInstanceCreator: ModelInstanceCreator; + private getModelConstructorByModelName: ( + namsespaceName: string, + modelName: string + ) => PersistentModelConstructor; + private db: idb.IDBPDatabase; + private initPromise: Promise; + private resolve: (value?: any) => void; + private reject: (value?: any) => void; + + private async checkPrivate() { + const isPrivate = await isPrivateMode().then(isPrivate => { + return isPrivate; + }); + if (isPrivate) { + logger.error("IndexedDB not supported in this browser's private mode"); + return Promise.reject( + "IndexedDB not supported in this browser's private mode" + ); + } else { + return Promise.resolve(); + } + } + + private getStorenameForModel( + modelConstructor: PersistentModelConstructor + ) { + const namespace = this.namespaceResolver(modelConstructor); + const { name: modelName } = modelConstructor; + + return this.getStorename(namespace, modelName); + } + + private getStorename(namespace: string, modelName: string) { + const storeName = `${namespace}_${modelName}`; + + return storeName; + } + + async setUp( + theSchema: InternalSchema, + namespaceResolver: NamespaceResolver, + modelInstanceCreator: ModelInstanceCreator, + getModelConstructorByModelName: ( + namsespaceName: string, + modelName: string + ) => PersistentModelConstructor + ) { + await this.checkPrivate(); + if (!this.initPromise) { + this.initPromise = new Promise((res, rej) => { + this.resolve = res; + this.reject = rej; + }); + } else { + await this.initPromise; + } + + this.schema = theSchema; + this.namespaceResolver = namespaceResolver; + this.modelInstanceCreator = modelInstanceCreator; + this.getModelConstructorByModelName = getModelConstructorByModelName; + + try { + if (!this.db) { + const VERSION = 2; + this.db = await idb.openDB(DB_NAME, VERSION, { + upgrade: async (db, oldVersion, newVersion, txn) => { + if (oldVersion === 0) { + Object.keys(theSchema.namespaces).forEach(namespaceName => { + const namespace = theSchema.namespaces[namespaceName]; + + Object.keys(namespace.models).forEach(modelName => { + const storeName = this.getStorename(namespaceName, modelName); + const store = db.createObjectStore(storeName, { + autoIncrement: true, + }); + + const indexes = this.schema.namespaces[namespaceName] + .relationships[modelName].indexes; + indexes.forEach(index => store.createIndex(index, index)); + + store.createIndex('byId', 'id', { unique: true }); + }); + }); + + return; + } + + if (oldVersion === 1 && newVersion === 2) { + try { + for (const storeName of txn.objectStoreNames) { + const origStore = txn.objectStore(storeName); + + // rename original store + const tmpName = `tmp_${storeName}`; + origStore.name = tmpName; + + // create new store with original name + const newStore = db.createObjectStore(storeName, { + keyPath: undefined, + autoIncrement: true, + }); + + newStore.createIndex('byId', 'id', { unique: true }); + + let cursor = await origStore.openCursor(); + let count = 0; + + // Copy data from original to new + while (cursor && cursor.value) { + // we don't pass key, since they are all new entries in the new store + await newStore.put(cursor.value); + + cursor = await cursor.continue(); + count++; + } + + // delete original + db.deleteObjectStore(tmpName); + + logger.debug(`${count} ${storeName} records migrated`); + } + } catch (error) { + logger.error('Error migrating IndexedDB data', error); + txn.abort(); + throw error; + } + + return; + } + }, + }); + + this.resolve(); + } + } catch (error) { + this.reject(error); + } + } + + private async _get( + storeOrStoreName: idb.IDBPObjectStore | string, + id: string + ): Promise { + let index: idb.IDBPIndex; + + if (typeof storeOrStoreName === 'string') { + const storeName = storeOrStoreName; + index = this.db.transaction(storeName, 'readonly').store.index('byId'); + } else { + const store = storeOrStoreName; + index = store.index('byId'); + } + + const result = await index.get(id); + + return result; + } + + async save( + model: T, + condition?: ModelPredicate + ): Promise<[T, OpType.INSERT | OpType.UPDATE][]> { + await this.checkPrivate(); + const modelConstructor = Object.getPrototypeOf(model) + .constructor as PersistentModelConstructor; + const storeName = this.getStorenameForModel(modelConstructor); + const connectedModels = traverseModel( + modelConstructor.name, + model, + this.schema.namespaces[this.namespaceResolver(modelConstructor)], + this.modelInstanceCreator, + this.getModelConstructorByModelName + ); + const namespaceName = this.namespaceResolver(modelConstructor); + + const set = new Set(); + const connectionStoreNames = Object.values(connectedModels).map( + ({ modelName, item, instance }) => { + const storeName = this.getStorename(namespaceName, modelName); + set.add(storeName); + return { storeName, item, instance }; + } + ); + const tx = this.db.transaction( + [storeName, ...Array.from(set.values())], + 'readwrite' + ); + const store = tx.objectStore(storeName); + + const fromDB = await this._get(store, model.id); + + if (condition && fromDB) { + const predicates = ModelPredicateCreator.getPredicates(condition); + const { predicates: predicateObjs, type } = predicates; + + const isValid = validatePredicate(fromDB, type, predicateObjs); + + if (!isValid) { + const msg = 'Conditional update failed'; + logger.error(msg, { model: fromDB, condition: predicateObjs }); + + throw new Error(msg); + } + } + + const result: [T, OpType.INSERT | OpType.UPDATE][] = []; + + for await (const resItem of connectionStoreNames) { + const { storeName, item, instance } = resItem; + const store = tx.objectStore(storeName); + + const { id } = item; + + const opType: OpType = + (await this._get(store, id)) === undefined + ? OpType.INSERT + : OpType.UPDATE; + + // It is me + if (id === model.id) { + const key = await store.index('byId').getKey(item.id); + await store.put(item, key); + + result.push([instance, opType]); + } else { + if (opType === OpType.INSERT) { + // Even if the parent is an INSERT, the child might not be, so we need to get its key + const key = await store.index('byId').getKey(item.id); + await store.put(item, key); + + result.push([instance, opType]); + } + } + } + + await tx.done; + + return result; + } + + private async load( + namespaceName: string, + srcModelName: string, + records: T[] + ): Promise { + const namespace = this.schema.namespaces[namespaceName]; + const relations = namespace.relationships[srcModelName].relationTypes; + const connectionStoreNames = relations.map(({ modelName }) => { + return this.getStorename(namespaceName, modelName); + }); + const modelConstructor = this.getModelConstructorByModelName( + namespaceName, + srcModelName + ); + + if (connectionStoreNames.length === 0) { + return records.map(record => + this.modelInstanceCreator(modelConstructor, record) + ); + } + + const tx = this.db.transaction([...connectionStoreNames], 'readonly'); + + for await (const relation of relations) { + const { fieldName, modelName, targetName } = relation; + const storeName = this.getStorename(namespaceName, modelName); + const store = tx.objectStore(storeName); + const modelConstructor = this.getModelConstructorByModelName( + namespaceName, + modelName + ); + + switch (relation.relationType) { + case 'HAS_ONE': + for await (const recordItem of records) { + if (recordItem[fieldName]) { + const connectionRecord = await this._get( + store, + recordItem[fieldName] + ); + + recordItem[fieldName] = + connectionRecord && + this.modelInstanceCreator(modelConstructor, connectionRecord); + } + } + + break; + case 'BELONGS_TO': + for await (const recordItem of records) { + if (recordItem[targetName]) { + const connectionRecord = await this._get( + store, + recordItem[targetName] + ); + + recordItem[fieldName] = + connectionRecord && + this.modelInstanceCreator(modelConstructor, connectionRecord); + delete recordItem[targetName]; + } + } + + break; + case 'HAS_MANY': + // TODO: Lazy loading + break; + default: + exhaustiveCheck(relation.relationType); + break; + } + } + + return records.map(record => + this.modelInstanceCreator(modelConstructor, record) + ); + } + + async query( + modelConstructor: PersistentModelConstructor, + predicate?: ModelPredicate, + pagination?: PaginationInput + ): Promise { + await this.checkPrivate(); + const storeName = this.getStorenameForModel(modelConstructor); + const namespaceName = this.namespaceResolver(modelConstructor); + + if (predicate) { + const predicates = ModelPredicateCreator.getPredicates(predicate); + if (predicates) { + const { predicates: predicateObjs, type } = predicates; + const idPredicate = + predicateObjs.length === 1 && + (predicateObjs.find( + p => isPredicateObj(p) && p.field === 'id' && p.operator === 'eq' + ) as PredicateObject); + + if (idPredicate) { + const { operand: id } = idPredicate; + + const record = await this._get(storeName, id); + + if (record) { + const [x] = await this.load(namespaceName, modelConstructor.name, [ + record, + ]); + + return [x]; + } + return []; + } + + // TODO: Use indices if possible + const all = await this.db.getAll(storeName); + + const filtered = predicateObjs + ? all.filter(m => validatePredicate(m, type, predicateObjs)) + : all; + + return await this.load( + namespaceName, + modelConstructor.name, + this.inMemoryPagination(filtered, pagination) + ); + } + } + + return await this.load( + namespaceName, + modelConstructor.name, + await this.enginePagination(storeName, pagination) + ); + } + + private inMemoryPagination( + records: T[], + pagination?: PaginationInput + ): T[] { + if (pagination) { + const { page = 0, limit = 0 } = pagination; + const start = Math.max(0, page * limit) || 0; + + const end = limit > 0 ? start + limit : records.length; + + return records.slice(start, end); + } + + return records; + } + + private async enginePagination( + storeName: string, + pagination?: PaginationInput + ): Promise { + let result: T[]; + + if (pagination) { + const { page = 0, limit = 0 } = pagination; + const initialRecord = Math.max(0, page * limit) || 0; + + let cursor = await this.db + .transaction(storeName) + .objectStore(storeName) + .openCursor(); + + if (cursor && initialRecord > 0) { + await cursor.advance(initialRecord); + } + + const pageResults: T[] = []; + + const hasLimit = typeof limit === 'number' && limit > 0; + let moreRecords = true; + let itemsLeft = limit; + while (moreRecords && cursor && cursor.value) { + pageResults.push(cursor.value); + + cursor = await cursor.continue(); + + if (hasLimit) { + itemsLeft--; + moreRecords = itemsLeft > 0 && cursor !== null; + } else { + moreRecords = cursor !== null; + } + } + + result = pageResults; + } else { + result = await this.db.getAll(storeName); + } + + return result; + } + + async queryOne( + modelConstructor: PersistentModelConstructor, + firstOrLast: QueryOne = QueryOne.FIRST + ): Promise { + await this.checkPrivate(); + const storeName = this.getStorenameForModel(modelConstructor); + + const cursor = await this.db + .transaction([storeName], 'readonly') + .objectStore(storeName) + .openCursor(undefined, firstOrLast === QueryOne.FIRST ? 'next' : 'prev'); + + const result = cursor ? cursor.value : undefined; + + return result && this.modelInstanceCreator(modelConstructor, result); + } + + async delete( + modelOrModelConstructor: T | PersistentModelConstructor, + condition?: ModelPredicate + ): Promise<[T[], T[]]> { + await this.checkPrivate(); + const deleteQueue: { storeName: string; items: T[] }[] = []; + + if (isModelConstructor(modelOrModelConstructor)) { + const modelConstructor = modelOrModelConstructor; + const nameSpace = this.namespaceResolver(modelConstructor); + + const storeName = this.getStorenameForModel(modelConstructor); + + const models = await this.query(modelConstructor, condition); + const relations = this.schema.namespaces[nameSpace].relationships[ + modelConstructor.name + ].relationTypes; + + if (condition !== undefined) { + await this.deleteTraverse( + relations, + models, + modelConstructor.name, + nameSpace, + deleteQueue + ); + + await this.deleteItem(deleteQueue); + + const deletedModels = deleteQueue.reduce( + (acc, { items }) => acc.concat(items), + [] + ); + + return [models, deletedModels]; + } else { + await this.deleteTraverse( + relations, + models, + modelConstructor.name, + nameSpace, + deleteQueue + ); + + // Delete all + await this.db + .transaction([storeName], 'readwrite') + .objectStore(storeName) + .clear(); + + const deletedModels = deleteQueue.reduce( + (acc, { items }) => acc.concat(items), + [] + ); + + return [models, deletedModels]; + } + } else { + const model = modelOrModelConstructor; + + const modelConstructor = Object.getPrototypeOf(model) + .constructor as PersistentModelConstructor; + const nameSpace = this.namespaceResolver(modelConstructor); + + const storeName = this.getStorenameForModel(modelConstructor); + + if (condition) { + const tx = this.db.transaction([storeName], 'readwrite'); + const store = tx.objectStore(storeName); + + const fromDB = await this._get(store, model.id); + + if (fromDB === undefined) { + const msg = 'Model instance not found in storage'; + logger.warn(msg, { model }); + + return [[model], []]; + } + + const predicates = ModelPredicateCreator.getPredicates(condition); + const { predicates: predicateObjs, type } = predicates; + + const isValid = validatePredicate(fromDB, type, predicateObjs); + + if (!isValid) { + const msg = 'Conditional update failed'; + logger.error(msg, { model: fromDB, condition: predicateObjs }); + + throw new Error(msg); + } + await tx.done; + + const relations = this.schema.namespaces[nameSpace].relationships[ + modelConstructor.name + ].relationTypes; + + await this.deleteTraverse( + relations, + [model], + modelConstructor.name, + nameSpace, + deleteQueue + ); + } else { + const relations = this.schema.namespaces[nameSpace].relationships[ + modelConstructor.name + ].relationTypes; + + await this.deleteTraverse( + relations, + [model], + modelConstructor.name, + nameSpace, + deleteQueue + ); + } + + await this.deleteItem(deleteQueue); + + const deletedModels = deleteQueue.reduce( + (acc, { items }) => acc.concat(items), + [] + ); + + return [[model], deletedModels]; + } + } + + private async deleteItem( + deleteQueue?: { storeName: string; items: T[] | IDBValidKey[] }[] + ) { + const connectionStoreNames = deleteQueue.map(({ storeName }) => { + return storeName; + }); + + const tx = this.db.transaction([...connectionStoreNames], 'readwrite'); + for await (const deleteItem of deleteQueue) { + const { storeName, items } = deleteItem; + const store = tx.objectStore(storeName); + + for await (const item of items) { + if (item) { + let key: IDBValidKey; + + if (typeof item === 'object') { + key = await store.index('byId').getKey(item['id']); + } else { + key = await store.index('byId').getKey(item.toString()); + } + + if (key !== undefined) { + await store.delete(key); + } + } + } + } + } + + private async deleteTraverse( + relations: RelationType[], + models: T[], + srcModel: string, + nameSpace: string, + deleteQueue: { storeName: string; items: T[] }[] + ): Promise { + for await (const rel of relations) { + const { relationType, fieldName, modelName } = rel; + const storeName = this.getStorename(nameSpace, modelName); + + const index: string = + getIndex( + this.schema.namespaces[nameSpace].relationships[modelName] + .relationTypes, + srcModel + ) || + // if we were unable to find an index via relationTypes + // i.e. for keyName connections, attempt to find one by the + // associatedWith property + getIndexFromAssociation( + this.schema.namespaces[nameSpace].relationships[modelName].indexes, + rel.associatedWith + ); + + switch (relationType) { + case 'HAS_ONE': + for await (const model of models) { + const recordToDelete = await this.db + .transaction(storeName, 'readwrite') + .objectStore(storeName) + .index(index) + .get(model.id); + + await this.deleteTraverse( + this.schema.namespaces[nameSpace].relationships[modelName] + .relationTypes, + recordToDelete ? [recordToDelete] : [], + modelName, + nameSpace, + deleteQueue + ); + } + break; + case 'HAS_MANY': + for await (const model of models) { + const childrenArray = await this.db + .transaction(storeName, 'readwrite') + .objectStore(storeName) + .index(index) + .getAll(model['id']); + + await this.deleteTraverse( + this.schema.namespaces[nameSpace].relationships[modelName] + .relationTypes, + childrenArray, + modelName, + nameSpace, + deleteQueue + ); + } + break; + case 'BELONGS_TO': + // Intentionally blank + break; + default: + exhaustiveCheck(relationType); + break; + } + } + + deleteQueue.push({ + storeName: this.getStorename(nameSpace, srcModel), + items: models.map(record => + this.modelInstanceCreator( + this.getModelConstructorByModelName(nameSpace, srcModel), + record + ) + ), + }); + } + + async clear(): Promise { + await this.checkPrivate(); + + this.db.close(); + + await idb.deleteDB(DB_NAME); + + this.db = undefined; + this.initPromise = undefined; + } + + async batchSave( + modelConstructor: PersistentModelConstructor, + items: ModelInstanceMetadata[] + ): Promise<[T, OpType][]> { + if (items.length === 0) { + return []; + } + + await this.checkPrivate(); + + const result: [T, OpType][] = []; + + const storeName = this.getStorenameForModel(modelConstructor); + + const txn = this.db.transaction(storeName, 'readwrite'); + const store = txn.store; + + for (const item of items) { + const connectedModels = traverseModel( + modelConstructor.name, + this.modelInstanceCreator(modelConstructor, item), + this.schema.namespaces[this.namespaceResolver(modelConstructor)], + this.modelInstanceCreator, + this.getModelConstructorByModelName + ); + + const { id, _deleted } = item; + const index = store.index('byId'); + const key = await index.getKey(id); + + if (!_deleted) { + const { instance } = connectedModels.find( + ({ instance }) => instance.id === id + ); + + result.push([ + (instance), + key ? OpType.UPDATE : OpType.INSERT, + ]); + await store.put(instance, key); + } else { + result.push([(item), OpType.DELETE]); + + if (key) { + await store.delete(key); + } + } + } + + await txn.done; + + return result; + } +} + +export default new IndexedDBAdapter(); diff --git a/packages/datastore/src/storage/adapter/asyncstorage.ts b/packages/datastore/src/storage/adapter/asyncstorage.ts index 4381b498da7..2915d819889 100644 --- a/packages/datastore/src/storage/adapter/asyncstorage.ts +++ b/packages/datastore/src/storage/adapter/asyncstorage.ts @@ -28,7 +28,7 @@ import { const logger = new Logger('DataStore'); -class AsyncStorageAdapter implements Adapter { +export class AsyncStorageAdapter implements Adapter { private schema: InternalSchema; private namespaceResolver: NamespaceResolver; private modelInstanceCreator: ModelInstanceCreator; diff --git a/packages/datastore/src/storage/adapter/getDefaultAdapter/index.ts b/packages/datastore/src/storage/adapter/getDefaultAdapter/index.ts index 8e09cdfafbc..e8d0af0bfc5 100644 --- a/packages/datastore/src/storage/adapter/getDefaultAdapter/index.ts +++ b/packages/datastore/src/storage/adapter/getDefaultAdapter/index.ts @@ -1,12 +1,16 @@ +import { browserOrNode } from '@aws-amplify/core'; import { Adapter } from '..'; const getDefaultAdapter: () => Adapter = () => { - if (window.indexedDB) { + const { isBrowser } = browserOrNode(); + + if (isBrowser && window.indexedDB) { return require('../indexeddb').default; } - if (process && process.env) { - throw new Error('Node is not supported'); - } + + const { AsyncStorageAdapter } = require('../asyncstorage'); + + return new AsyncStorageAdapter(); }; export default getDefaultAdapter; diff --git a/packages/datastore/src/sync/processors/subscription.ts b/packages/datastore/src/sync/processors/subscription.ts index 3b8cfb2fce4..a3da3472b83 100644 --- a/packages/datastore/src/sync/processors/subscription.ts +++ b/packages/datastore/src/sync/processors/subscription.ts @@ -239,6 +239,7 @@ class SubscriptionProcessor { (async () => { try { // retrieving current AWS Credentials + // TODO Should this use `this.amplify.Auth` for SSR? const credentials = await Auth.currentCredentials(); userCredentials = credentials.authenticated ? USER_CREDENTIALS.auth @@ -249,6 +250,7 @@ class SubscriptionProcessor { try { // retrieving current token info from Cognito UserPools + // TODO Should this use `this.amplify.Auth` for SSR? const session = await Auth.currentSession(); cognitoTokenPayload = session.getIdToken().decodePayload(); } catch (err) { diff --git a/packages/datastore/ssr/package.json b/packages/datastore/ssr/package.json new file mode 100644 index 00000000000..eb1d598398e --- /dev/null +++ b/packages/datastore/ssr/package.json @@ -0,0 +1,8 @@ +{ + "main": "../lib/ssr/index.js", + "module": "../lib-esm/ssr/index.js", + "typings": "../lib-esm/ssr/index.d.ts", + "react-native": { + "../lib/ssr/index": "../lib-esm/ssr/index.js" + } +} \ No newline at end of file diff --git a/packages/datastore/tslint.json b/packages/datastore/tslint.json index 1bb9e144d24..8eafab1d2b4 100644 --- a/packages/datastore/tslint.json +++ b/packages/datastore/tslint.json @@ -39,7 +39,12 @@ "allow-snake-case", "allow-leading-underscore" ], - "semicolon": [true, "always", "ignore-interfaces"] + "semicolon": [ + true, + "always", + "ignore-interfaces", + "ignore-bound-class-methods" + ] }, "rulesDirectory": [] } diff --git a/packages/interactions/src/Interactions.ts b/packages/interactions/src/Interactions.ts index b5c6e269d56..6d64d0beb76 100644 --- a/packages/interactions/src/Interactions.ts +++ b/packages/interactions/src/Interactions.ts @@ -35,7 +35,6 @@ export class InteractionsClass { this._options = options; logger.debug('Interactions Options', this._options); this._pluggables = {}; - Amplify.register(this); } public getModuleName() { @@ -148,3 +147,4 @@ export class InteractionsClass { } export const Interactions = new InteractionsClass(null); +Amplify.register(Interactions); diff --git a/packages/predictions/src/Predictions.ts b/packages/predictions/src/Predictions.ts index dc097daccf2..2e10779df9e 100644 --- a/packages/predictions/src/Predictions.ts +++ b/packages/predictions/src/Predictions.ts @@ -42,7 +42,6 @@ export class PredictionsClass { this._convertPluggables = []; this._identifyPluggables = []; this._interpretPluggables = []; - Amplify.register(this); } public getModuleName() { @@ -247,3 +246,4 @@ export class PredictionsClass { } export const Predictions = new PredictionsClass({}); +Amplify.register(Predictions); diff --git a/packages/predictions/src/Providers/AmazonAIConvertPredictionsProvider.ts b/packages/predictions/src/Providers/AmazonAIConvertPredictionsProvider.ts index 16228431b86..695777d90b2 100644 --- a/packages/predictions/src/Providers/AmazonAIConvertPredictionsProvider.ts +++ b/packages/predictions/src/Providers/AmazonAIConvertPredictionsProvider.ts @@ -24,6 +24,7 @@ import { MessageHeaderValue, } from '@aws-sdk/eventstream-marshaller'; import { fromUtf8, toUtf8 } from '@aws-sdk/util-utf8-node'; + const logger = new Logger('AmazonAIConvertPredictionsProvider'); const eventBuilder = new EventStreamMarshaller(toUtf8, fromUtf8); diff --git a/packages/pubsub/src/Providers/AWSAppSyncRealTimeProvider.ts b/packages/pubsub/src/Providers/AWSAppSyncRealTimeProvider.ts index ec4943a1a36..dcb5ce6de5c 100644 --- a/packages/pubsub/src/Providers/AWSAppSyncRealTimeProvider.ts +++ b/packages/pubsub/src/Providers/AWSAppSyncRealTimeProvider.ts @@ -28,6 +28,7 @@ import { } from '@aws-amplify/core'; import Cache from '@aws-amplify/cache'; import Auth from '@aws-amplify/auth'; +import WebSocket from 'isomorphic-ws'; import { AbstractPubSubProvider } from './PubSubProvider'; import { CONTROL_MSG } from '../index'; diff --git a/packages/pubsub/src/PubSub.ts b/packages/pubsub/src/PubSub.ts index 3716557f350..929aa74c9d2 100644 --- a/packages/pubsub/src/PubSub.ts +++ b/packages/pubsub/src/PubSub.ts @@ -71,7 +71,6 @@ export class PubSubClass { logger.debug('PubSub Options', this._options); this._pluggables = []; this.subscribe = this.subscribe.bind(this); - Amplify.register(this); } public getModuleName() { @@ -178,3 +177,4 @@ export class PubSubClass { } export const PubSub = new PubSubClass(null); +Amplify.register(PubSub); diff --git a/packages/pushnotification/__tests__/PushNotification-test.ts b/packages/pushnotification/__tests__/PushNotification-test.ts index db083391c2d..dee1f893218 100644 --- a/packages/pushnotification/__tests__/PushNotification-test.ts +++ b/packages/pushnotification/__tests__/PushNotification-test.ts @@ -1,6 +1,7 @@ import { DeviceEventEmitter, Platform, NativeModules } from 'react-native'; import Amplify from 'aws-amplify'; import PushNotificationIOS from '@react-native-community/push-notification-ios'; +import RegisteredPushNotification from '../src'; import PushNotification from '../src/PushNotification'; const defaultPlatform = 'ios'; @@ -64,10 +65,20 @@ describe('PushNotification:', () => { test('should register with Amplify', () => { const registerSpy = jest.spyOn(Amplify, 'register'); - expect.assertions(2); + expect.assertions(4); + + // Global Amplify should register with `import "@aws-amplify/pushnotification"` + expect(Amplify.Pushnotification).toEqual(RegisteredPushNotification); + + // Spy should be at 0 (it was already called on import) + expect(registerSpy).toHaveBeenCalledTimes(0); + + // Global Amplify should keep original instance, not new instances + const NewPushNotification = new PushNotification(null); + expect(Amplify.Pushnotification).not.toEqual(NewPushNotification); + + // Amplify.register should not have been called for the new instance expect(registerSpy).toHaveBeenCalledTimes(0); - new PushNotification(null); - expect(registerSpy).toHaveBeenCalledTimes(1); registerSpy.mockClear(); }); }); diff --git a/packages/pushnotification/src/PushNotification.ts b/packages/pushnotification/src/PushNotification.ts index bf5217fbeab..e34e85c75cd 100644 --- a/packages/pushnotification/src/PushNotification.ts +++ b/packages/pushnotification/src/PushNotification.ts @@ -56,8 +56,6 @@ export default class PushNotification { this._iosInitialized = false; this._notificationOpenedHandlers = []; - - Amplify.register(this); } getModuleName() { diff --git a/packages/pushnotification/src/index.ts b/packages/pushnotification/src/index.ts index fba93124fae..9e093d7f107 100644 --- a/packages/pushnotification/src/index.ts +++ b/packages/pushnotification/src/index.ts @@ -10,9 +10,11 @@ * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ +import { Amplify } from '@aws-amplify/core'; import NotificationClass from './PushNotification'; const _instance = new NotificationClass(null); const PushNotification = _instance; +Amplify.register(PushNotification); export default PushNotification; diff --git a/packages/storage/src/Storage.ts b/packages/storage/src/Storage.ts index d0e5f06eacd..a32b76c7690 100644 --- a/packages/storage/src/Storage.ts +++ b/packages/storage/src/Storage.ts @@ -11,7 +11,7 @@ * and limitations under the License. */ -import { Amplify, ConsoleLogger as Logger, Parser } from '@aws-amplify/core'; +import { ConsoleLogger as Logger, Parser } from '@aws-amplify/core'; import { AWSS3Provider } from './providers'; import { StorageProvider } from './types'; @@ -46,7 +46,6 @@ export class Storage { this.put = this.put.bind(this); this.remove = this.remove.bind(this); this.list = this.list.bind(this); - Amplify.register(this); } public getModuleName() { diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 1b914b06c01..e0a1a4f114e 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -13,7 +13,7 @@ import { Storage as StorageClass } from './Storage'; -import { ConsoleLogger as Logger } from '@aws-amplify/core'; +import { Amplify, ConsoleLogger as Logger } from '@aws-amplify/core'; const logger = new Logger('Storage'); @@ -48,6 +48,7 @@ const getInstance = () => { }; export const Storage: StorageClass = getInstance(); +Amplify.register(Storage); /** * @deprecated use named import diff --git a/packages/xr/src/XR.ts b/packages/xr/src/XR.ts index ec725d26a71..a38bcb60f7d 100644 --- a/packages/xr/src/XR.ts +++ b/packages/xr/src/XR.ts @@ -38,7 +38,6 @@ export class XRClass { // Add default provider this.addPluggable(new SumerianProvider()); - Amplify.register(this); } /** @@ -212,3 +211,4 @@ export class XRClass { } export const XR = new XRClass(null); +Amplify.register(XR);