Repository: dexie/Dexie.js
Branch: master
Commit: fdd54dd81a5b
Files: 792
Total size: 2.3 MB
Directory structure:
gitextract_2syan8s_/
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── dexie-cloud-common.yml
│ └── main.yml
├── .gitignore
├── .npmignore
├── .npmrc
├── .prettierrc
├── .prettierrc.yml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── NOTICE
├── README.md
├── SECURITY.md
├── addons/
│ ├── Dexie.Observable/
│ │ ├── .gitignore
│ │ ├── .npmignore
│ │ ├── README.md
│ │ ├── api.d.ts
│ │ ├── api.js
│ │ ├── dist/
│ │ │ └── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── .eslintrc.json
│ │ │ ├── Dexie.Observable.d.ts
│ │ │ ├── Dexie.Observable.js
│ │ │ ├── change_types.js
│ │ │ ├── delete-old-changes.js
│ │ │ ├── hooks/
│ │ │ │ ├── creating.js
│ │ │ │ ├── crud-monitor.js
│ │ │ │ ├── deleting.js
│ │ │ │ └── updating.js
│ │ │ ├── intercomm.js
│ │ │ ├── on-storage.js
│ │ │ ├── override-create-transaction.js
│ │ │ ├── override-open.js
│ │ │ ├── override-parse-stores-spec.js
│ │ │ ├── utils.js
│ │ │ └── wakeup-observers.js
│ │ ├── test/
│ │ │ ├── gh-actions.sh
│ │ │ ├── integration/
│ │ │ │ ├── karma-env.js
│ │ │ │ ├── karma.conf.js
│ │ │ │ └── test-observable-dexie-tests.html
│ │ │ ├── typings/
│ │ │ │ ├── test-typings.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── unit/
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── deep-equal.js
│ │ │ ├── hooks/
│ │ │ │ ├── tests-creating.js
│ │ │ │ ├── tests-deleting.js
│ │ │ │ └── tests-updating.js
│ │ │ ├── karma.conf.js
│ │ │ ├── run-unit-tests.html
│ │ │ ├── tests-observable-misc.js
│ │ │ ├── tests-override-open.js
│ │ │ ├── tests-override-parse-stores-spec.js
│ │ │ └── unit-tests-all.js
│ │ └── tools/
│ │ ├── build-configs/
│ │ │ ├── banner.txt
│ │ │ ├── rollup.config.mjs
│ │ │ ├── rollup.tests.config.js
│ │ │ ├── rollup.tests.config.mjs
│ │ │ ├── rollup.tests.unit.config.js
│ │ │ └── rollup.tests.unit.config.mjs
│ │ └── replaceVersionAndDate.js
│ ├── Dexie.Syncable/
│ │ ├── .gitignore
│ │ ├── .npmignore
│ │ ├── README.md
│ │ ├── api.d.ts
│ │ ├── api.js
│ │ ├── dist/
│ │ │ └── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── .eslintrc.json
│ │ │ ├── Dexie.Syncable.d.ts
│ │ │ ├── Dexie.Syncable.js
│ │ │ ├── PersistedContext.js
│ │ │ ├── apply-changes.js
│ │ │ ├── bulk-update.js
│ │ │ ├── change_types.js
│ │ │ ├── combine-create-and-update.js
│ │ │ ├── combine-update-and-update.js
│ │ │ ├── connect-fn.js
│ │ │ ├── connect-protocol.js
│ │ │ ├── enqueue.js
│ │ │ ├── finally-commit-all-changes.js
│ │ │ ├── get-local-changes-for-node/
│ │ │ │ ├── get-base-revision-and-max-client-revision.js
│ │ │ │ ├── get-changes-since-revision.js
│ │ │ │ ├── get-local-changes-for-node.js
│ │ │ │ └── get-table-objects-as-changes.js
│ │ │ ├── get-or-create-sync-node.js
│ │ │ ├── merge-change.js
│ │ │ ├── save-to-uncommitted-changes.js
│ │ │ ├── statuses.js
│ │ │ └── syncable-connect.js
│ │ ├── test/
│ │ │ ├── gh-actions.sh
│ │ │ ├── integration/
│ │ │ │ ├── dummy-sync-protocol.js
│ │ │ │ ├── karma-env.js
│ │ │ │ ├── karma.conf.js
│ │ │ │ └── test-syncable-dexie-tests.html
│ │ │ ├── test-typings/
│ │ │ │ ├── test-typings.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── unit/
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── get-local-changes-for-node/
│ │ │ │ ├── tests-get-base-revision-and-max-client-revision.js
│ │ │ │ ├── tests-get-changes-since-revision.js
│ │ │ │ └── tests-get-local-changes-for-node.js
│ │ │ ├── karma-env.js
│ │ │ ├── karma.conf.js
│ │ │ ├── run-unit-tests.html
│ │ │ ├── tests-PersistedContext.js
│ │ │ ├── tests-WebSocketSyncServer.js
│ │ │ ├── tests-apply-changes.js
│ │ │ ├── tests-bulk-update.js
│ │ │ ├── tests-changing-options.js
│ │ │ ├── tests-combine-create-and-update.js
│ │ │ ├── tests-combine-update-and-update.js
│ │ │ ├── tests-finally-commit-all-changes.js
│ │ │ ├── tests-get-or-create-sync-node.js
│ │ │ ├── tests-merge-change.js
│ │ │ ├── tests-register-sync-protocol.js
│ │ │ ├── tests-save-to-uncommitted-changes.js
│ │ │ ├── tests-syncable-partials.js
│ │ │ ├── tests-syncable.js
│ │ │ ├── tests-syncprovider.js
│ │ │ └── unit-tests-all.js
│ │ └── tools/
│ │ ├── build-configs/
│ │ │ ├── banner.txt
│ │ │ ├── rollup.config.mjs
│ │ │ ├── rollup.tests.config.js
│ │ │ └── rollup.tests.config.mjs
│ │ └── replaceVersionAndDate.js
│ ├── dexie-cloud/
│ │ ├── .gitignore
│ │ ├── .npmignore
│ │ ├── .vscode/
│ │ │ └── settings.json
│ │ ├── README.md
│ │ ├── TODO-SOCIALAUTH.md
│ │ ├── dexie-cloud-import.json
│ │ ├── oauth_flow.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── DISABLE_SERVICEWORKER_STRATEGY.ts
│ │ │ ├── DXCWebSocketStatus.ts
│ │ │ ├── DexieCloudAPI.ts
│ │ │ ├── DexieCloudOptions.ts
│ │ │ ├── DexieCloudSyncOptions.ts
│ │ │ ├── DexieCloudTable.ts
│ │ │ ├── InvalidLicenseError.ts
│ │ │ ├── Invite.ts
│ │ │ ├── PermissionChecker.ts
│ │ │ ├── TSON.ts
│ │ │ ├── WSObservable.ts
│ │ │ ├── associate.ts
│ │ │ ├── authentication/
│ │ │ │ ├── AuthPersistedContext.ts
│ │ │ │ ├── TokenErrorResponseError.ts
│ │ │ │ ├── TokenExpiredError.ts
│ │ │ │ ├── UNAUTHORIZED_USER.ts
│ │ │ │ ├── authenticate.ts
│ │ │ │ ├── currentUserObservable.ts
│ │ │ │ ├── exchangeOAuthCode.ts
│ │ │ │ ├── fetchAuthProviders.ts
│ │ │ │ ├── handleOAuthCallback.ts
│ │ │ │ ├── interactWithUser.ts
│ │ │ │ ├── login.ts
│ │ │ │ ├── logout.ts
│ │ │ │ ├── oauthLogin.ts
│ │ │ │ ├── otpFetchTokenCallback.ts
│ │ │ │ ├── setCurrentUser.ts
│ │ │ │ └── waitUntil.ts
│ │ │ ├── computeSyncState.ts
│ │ │ ├── createSharedValueObservable.ts
│ │ │ ├── currentUserEmitter.ts
│ │ │ ├── db/
│ │ │ │ ├── DexieCloudDB.ts
│ │ │ │ └── entities/
│ │ │ │ ├── BaseRevisionMapEntry.ts
│ │ │ │ ├── EntityCommon.ts
│ │ │ │ ├── GuardedJob.ts
│ │ │ │ ├── Member.ts
│ │ │ │ ├── PersistedSyncState.ts
│ │ │ │ ├── Realm.ts
│ │ │ │ ├── Role.ts
│ │ │ │ └── UserLogin.ts
│ │ │ ├── default-ui/
│ │ │ │ ├── Dialog.tsx
│ │ │ │ ├── LoginDialog.tsx
│ │ │ │ ├── OptionButton.tsx
│ │ │ │ ├── Styles.ts
│ │ │ │ └── index.tsx
│ │ │ ├── define-ydoc-trigger.ts
│ │ │ ├── dexie-cloud-addon.ts
│ │ │ ├── dexie-cloud-client.ts
│ │ │ ├── errors/
│ │ │ │ ├── HttpError.ts
│ │ │ │ ├── OAuthError.ts
│ │ │ │ └── OAuthRedirectError.ts
│ │ │ ├── extend-dexie-interface.ts
│ │ │ ├── getGlobalRolesObservable.ts
│ │ │ ├── getInternalAccessControlObservable.ts
│ │ │ ├── getInvitesObservable.ts
│ │ │ ├── getPermissionsLookupObservable.ts
│ │ │ ├── getTiedRealmId.ts
│ │ │ ├── helpers/
│ │ │ │ ├── BroadcastedAndLocalEvent.ts
│ │ │ │ ├── CancelToken.ts
│ │ │ │ ├── IS_SERVICE_WORKER.ts
│ │ │ │ ├── SWBroadcastChannel.ts
│ │ │ │ ├── allSettled.ts
│ │ │ │ ├── bulkUpdate.ts
│ │ │ │ ├── computeRealmSetHash.ts
│ │ │ │ ├── date-constants.ts
│ │ │ │ ├── flatten.ts
│ │ │ │ ├── getMutationTable.ts
│ │ │ │ ├── getSyncableTables.ts
│ │ │ │ ├── getTableFromMutationTable.ts
│ │ │ │ ├── makeArray.ts
│ │ │ │ ├── randomString.ts
│ │ │ │ ├── resolveText.ts
│ │ │ │ ├── throwVersionIncrementNeeded.ts
│ │ │ │ └── visibilityState.ts
│ │ │ ├── isEagerSyncDisabled.ts
│ │ │ ├── isFirefox.ts
│ │ │ ├── isSafari.ts
│ │ │ ├── mapValueObservable.ts
│ │ │ ├── mergePermissions.ts
│ │ │ ├── middleware-helpers/
│ │ │ │ ├── guardedTable.ts
│ │ │ │ └── idGenerationHelpers.ts
│ │ │ ├── middlewares/
│ │ │ │ ├── blobResolveMiddleware.ts
│ │ │ │ ├── createIdGenerationMiddleware.ts
│ │ │ │ ├── createImplicitPropSetterMiddleware.ts
│ │ │ │ ├── createMutationTrackingMiddleware.ts
│ │ │ │ └── outstandingTransaction.ts
│ │ │ ├── overrideParseStoresSpec.ts
│ │ │ ├── performInitialSync.ts
│ │ │ ├── permissions.ts
│ │ │ ├── prodLog.ts
│ │ │ ├── service-worker.ts
│ │ │ ├── sync/
│ │ │ │ ├── BLOB_TODO.md
│ │ │ │ ├── BlobDownloadTracker.ts
│ │ │ │ ├── BlobSavingQueue.ts
│ │ │ │ ├── DEXIE_CLOUD_SYNCER_ID.ts
│ │ │ │ ├── LocalSyncWorker.ts
│ │ │ │ ├── SyncRequiredError.ts
│ │ │ │ ├── applyServerChanges.ts
│ │ │ │ ├── blobOffloading.test.ts
│ │ │ │ ├── blobOffloading.ts
│ │ │ │ ├── blobProgress.ts
│ │ │ │ ├── blobResolve.ts
│ │ │ │ ├── connectWebSocket.ts
│ │ │ │ ├── eagerBlobDownloader.ts
│ │ │ │ ├── encodeIdsForServer.ts
│ │ │ │ ├── extractRealm.ts
│ │ │ │ ├── getLatestRevisionsPerTable.ts
│ │ │ │ ├── getTablesToSyncify.ts
│ │ │ │ ├── isOnline.ts
│ │ │ │ ├── isSyncNeeded.ts
│ │ │ │ ├── listClientChanges.ts
│ │ │ │ ├── listSyncifiedChanges.ts
│ │ │ │ ├── loadCachedAccessToken.ts
│ │ │ │ ├── messageConsumerIsReady.ts
│ │ │ │ ├── messagesFromServerQueue.ts
│ │ │ │ ├── modifyLocalObjectsWithNewUserId.ts
│ │ │ │ ├── myId.ts
│ │ │ │ ├── numUnsyncedMutations.ts
│ │ │ │ ├── old_startSyncingClientChanges.ts
│ │ │ │ ├── performGuardedJob.ts
│ │ │ │ ├── ratelimit.ts
│ │ │ │ ├── registerSyncEvent.ts
│ │ │ │ ├── sync.ts
│ │ │ │ ├── syncIfPossible.ts
│ │ │ │ ├── syncWithServer.ts
│ │ │ │ ├── triggerSync.ts
│ │ │ │ └── updateBaseRevs.ts
│ │ │ ├── tsconfig.json
│ │ │ ├── types/
│ │ │ │ ├── DXCAlert.ts
│ │ │ │ ├── DXCInputField.ts
│ │ │ │ ├── DXCUserInteraction.ts
│ │ │ │ ├── NewIdOptions.ts
│ │ │ │ ├── SWMessageEvent.ts
│ │ │ │ ├── SWSyncEvent.ts
│ │ │ │ ├── SyncState.ts
│ │ │ │ └── TXExpandos.ts
│ │ │ ├── updateSchemaFromOptions.ts
│ │ │ ├── userIsActive.ts
│ │ │ ├── verifyConfig.ts
│ │ │ ├── verifySchema.ts
│ │ │ └── yjs/
│ │ │ ├── YDexieCloudSyncState.ts
│ │ │ ├── YTable.ts
│ │ │ ├── applyYMessages.ts
│ │ │ ├── awareness.ts
│ │ │ ├── createYClientUpdateObservable.ts
│ │ │ ├── createYHandler.ts
│ │ │ ├── downloadYDocsFromServer.ts
│ │ │ ├── getUpdatesTable.ts
│ │ │ ├── listUpdatesSince.ts
│ │ │ ├── listYClientMessagesAndStateVector.ts
│ │ │ ├── reopenDocSignal.ts
│ │ │ └── updateYSyncStates.ts
│ │ ├── test/
│ │ │ ├── promisedTest.ts
│ │ │ ├── qunit.d.ts
│ │ │ ├── tsconfig.json
│ │ │ └── unit/
│ │ │ ├── index.ts
│ │ │ ├── karma-env.js
│ │ │ ├── karma.conf.cjs
│ │ │ ├── run-unit-tests.html
│ │ │ ├── test-dexie-cloud-client.ts
│ │ │ ├── tests-github-issues.ts
│ │ │ └── tests-migrate-to-cloud.ts
│ │ └── tools/
│ │ ├── build-configs/
│ │ │ ├── banner.txt
│ │ │ ├── rollup.config.mjs
│ │ │ └── rollup.test.unit.config.js
│ │ ├── release-dexie-cloud-addon.sh
│ │ └── replaceVersionAndDate.cjs
│ ├── dexie-export-import/
│ │ ├── .gitignore
│ │ ├── .npmignore
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── dexie-export-import.ts
│ │ │ ├── export.ts
│ │ │ ├── helpers.ts
│ │ │ ├── import.ts
│ │ │ ├── index.ts
│ │ │ ├── json-stream.ts
│ │ │ ├── json-structure.ts
│ │ │ ├── tsconfig.json
│ │ │ ├── tson-arraybuffer.ts
│ │ │ ├── tson-typed-array.ts
│ │ │ └── tson.ts
│ │ ├── test/
│ │ │ ├── .gitignore
│ │ │ ├── basic-tests.ts
│ │ │ ├── edge-cases.ts
│ │ │ ├── gh-actions.sh
│ │ │ ├── index.html
│ │ │ ├── index.ts
│ │ │ ├── karma.conf.js
│ │ │ ├── qunit.d.ts
│ │ │ ├── test-data.ts
│ │ │ ├── tools.ts
│ │ │ └── tsconfig.json
│ │ └── tools/
│ │ └── build-configs/
│ │ ├── banner.txt
│ │ ├── fake-stream.js
│ │ ├── rollup.config.mjs
│ │ └── rollup.tests.config.mjs
│ └── y-dexie/
│ ├── .gitignore
│ ├── .npmignore
│ ├── README.md
│ ├── package.json
│ ├── src/
│ │ ├── DexieYProvider.ts
│ │ ├── TODO.md
│ │ ├── compressYDocs.ts
│ │ ├── createYDocProperty.ts
│ │ ├── createYjsMiddleware.ts
│ │ ├── currentUpdateRow.ts
│ │ ├── docCache.ts
│ │ ├── getOrCreateDocument.ts
│ │ ├── helpers/
│ │ │ ├── Disposable.ts
│ │ │ ├── hasOwn.ts
│ │ │ ├── nonStoppableEventChain.ts
│ │ │ ├── nop.ts
│ │ │ └── promisableChain.ts
│ │ ├── observeYDocUpdates.ts
│ │ ├── periodicGC.ts
│ │ ├── tsconfig.json
│ │ ├── types/
│ │ │ ├── DexieYDocMeta.ts
│ │ │ ├── YDocCache.ts
│ │ │ ├── YLastCompressed.ts
│ │ │ ├── YSyncState.ts
│ │ │ ├── YUpdateRow.ts
│ │ │ └── index.ts
│ │ └── y-dexie.ts
│ ├── test/
│ │ ├── gh-actions.sh
│ │ ├── promisedTest.ts
│ │ ├── qunit.d.ts
│ │ ├── tsconfig.json
│ │ └── unit/
│ │ ├── dexie-unittest-utils.js
│ │ ├── index.ts
│ │ ├── karma-env.js
│ │ ├── karma.conf.cjs
│ │ ├── run-unit-tests.html
│ │ ├── tests-dummy.ts
│ │ └── tests-yjs.ts
│ └── tools/
│ ├── build-configs/
│ │ ├── banner.txt
│ │ ├── rollup.config.mjs
│ │ └── rollup.test.unit.config.js
│ ├── release-y-dexie.sh
│ └── replaceVersionAndDate.cjs
├── dist/
│ └── README.md
├── import-wrapper-prod.d.mts
├── import-wrapper-prod.mjs
├── import-wrapper.d.mts
├── import-wrapper.mjs
├── libs/
│ ├── dexie-cloud-common/
│ │ ├── .gitignore
│ │ ├── .npmignore
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── AuthProvidersResponse.ts
│ │ │ ├── AuthorizationCodeTokenRequest.ts
│ │ │ ├── BaseRevisionMapEntry.ts
│ │ │ ├── DBOperation.ts
│ │ │ ├── DBOperationsSet.ts
│ │ │ ├── DBPermissionSet.ts
│ │ │ ├── DexieCloudSchema.ts
│ │ │ ├── OAuthProviderInfo.ts
│ │ │ ├── SyncChange.ts
│ │ │ ├── SyncChangeSet.ts
│ │ │ ├── SyncRequest.ts
│ │ │ ├── SyncResponse.ts
│ │ │ ├── async-generators/
│ │ │ │ ├── asyncIterablePipeline.ts
│ │ │ │ ├── consumeChunkedBinaryStream.test.ts
│ │ │ │ ├── consumeChunkedBinaryStream.ts
│ │ │ │ ├── getFetchResponseBodyGenerator.ts
│ │ │ │ └── produceChunkedBinaryStream.ts
│ │ │ ├── change-processing/
│ │ │ │ ├── DBKeyMutation.ts
│ │ │ │ ├── DBKeyMutationSet.ts
│ │ │ │ ├── applyOperation.ts
│ │ │ │ ├── applyOperations.ts
│ │ │ │ ├── subtractChanges.ts
│ │ │ │ ├── toDBOperationSet.ts
│ │ │ │ └── toSyncChangeSet.ts
│ │ │ ├── common/
│ │ │ │ ├── _global.ts
│ │ │ │ ├── b64lex.ts
│ │ │ │ ├── base64.ts
│ │ │ │ └── bigint-conversion.ts
│ │ │ ├── entities/
│ │ │ │ ├── DBRealm.ts
│ │ │ │ ├── DBRealmMember.ts
│ │ │ │ ├── DBRealmRole.ts
│ │ │ │ └── DBSyncedObject.ts
│ │ │ ├── getDbNameFromDbUrl.ts
│ │ │ ├── index.ts
│ │ │ ├── newId.ts
│ │ │ ├── tson/
│ │ │ │ ├── FakeBlob.ts
│ │ │ │ ├── FakeFile.ts
│ │ │ │ ├── StreamingSyncProcessor.ts
│ │ │ │ ├── TSONRef.ts
│ │ │ │ ├── TypeDef.ts
│ │ │ │ ├── TypeDefSet.ts
│ │ │ │ ├── TypesonSimplified.ts
│ │ │ │ ├── __tests__/
│ │ │ │ │ ├── TSONRef.test.ts
│ │ │ │ │ ├── TypesonSimplified.test.ts
│ │ │ │ │ ├── encoding.test.ts
│ │ │ │ │ ├── newId.test.ts
│ │ │ │ │ └── undefined.test.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── presets/
│ │ │ │ │ └── builtin.ts
│ │ │ │ ├── readBlobSync.ts
│ │ │ │ ├── readableStreamIterator.ts
│ │ │ │ ├── string2ArrayBuffer.ts
│ │ │ │ └── types/
│ │ │ │ ├── ArrayBuffer.ts
│ │ │ │ ├── Blob.ts
│ │ │ │ ├── BlobRef.ts
│ │ │ │ ├── Date.ts
│ │ │ │ ├── FakeBlob.ts
│ │ │ │ ├── FakeFile.ts
│ │ │ │ ├── File.ts
│ │ │ │ ├── Map.ts
│ │ │ │ ├── Set.ts
│ │ │ │ ├── TypedArray.ts
│ │ │ │ ├── bigint.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── number.ts
│ │ │ │ └── undefined.ts
│ │ │ ├── types.ts
│ │ │ ├── typings/
│ │ │ │ └── TypedArray.ts
│ │ │ ├── utils.ts
│ │ │ ├── validation/
│ │ │ │ ├── isValidSyncableID.ts
│ │ │ │ └── toStringTag.ts
│ │ │ └── yjs/
│ │ │ ├── YMessage.ts
│ │ │ ├── decoding.ts
│ │ │ └── encoding.ts
│ │ └── tsconfig.json
│ ├── dexie-react-hooks/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── rollup.config.mjs
│ │ ├── src/
│ │ │ ├── dexie-react-hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── types/
│ │ │ │ ├── y-dexie.d.ts
│ │ │ │ └── yjs.d.ts
│ │ │ ├── useDocument.ts
│ │ │ ├── useLiveQuery.ts
│ │ │ ├── useObservable.ts
│ │ │ ├── usePermissions.ts
│ │ │ ├── usePromise.ts
│ │ │ ├── useSuspendingLiveQuery.ts
│ │ │ └── useSuspendingObservable.ts
│ │ ├── test/
│ │ │ ├── components/
│ │ │ │ ├── App.tsx
│ │ │ │ ├── ErrorBoundrary.tsx
│ │ │ │ ├── ItemComponent.tsx
│ │ │ │ ├── ItemListComponent.tsx
│ │ │ │ └── ItemLoaderComponent.tsx
│ │ │ ├── db/
│ │ │ │ └── index.ts
│ │ │ ├── gh-actions.sh
│ │ │ ├── index.html
│ │ │ ├── index.ts
│ │ │ ├── karma.conf.js
│ │ │ ├── models/
│ │ │ │ └── Item.ts
│ │ │ ├── tsconfig.json
│ │ │ ├── utils/
│ │ │ │ ├── BinarySemaphore.ts
│ │ │ │ ├── closest.ts
│ │ │ │ ├── sleep.ts
│ │ │ │ ├── timeout.ts
│ │ │ │ ├── waitTilEqual.ts
│ │ │ │ └── waitTilOk.ts
│ │ │ └── webpack.config.js
│ │ ├── tsconfig.json
│ │ └── webpack.config.js
│ └── dexie-svelte-query/
│ ├── .gitignore
│ ├── .npmrc
│ ├── README.md
│ ├── package.json
│ ├── src/
│ │ └── lib/
│ │ ├── index.ts
│ │ └── stateQuery.svelte.ts
│ └── tsconfig.json
├── package.json
├── pnpm-workspace.yaml
├── samples/
│ ├── angular/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── angular.json
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ ├── app.component.ts
│ │ │ │ ├── db.ts
│ │ │ │ └── item-list.component.ts
│ │ │ ├── index.html
│ │ │ └── main.ts
│ │ └── tsconfig.json
│ ├── dexie-cloud-todo-app/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── components.json
│ │ ├── configure-app.sh
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── postcss.config.js
│ │ ├── public/
│ │ │ └── robots.txt
│ │ ├── src/
│ │ │ ├── App.test.tsx
│ │ │ ├── App.tsx
│ │ │ ├── components/
│ │ │ │ ├── AddTodoItem.tsx
│ │ │ │ ├── AddTodoList.tsx
│ │ │ │ ├── ResetDatabaseButton.tsx
│ │ │ │ ├── TodoItemView.tsx
│ │ │ │ ├── TodoListView.tsx
│ │ │ │ ├── TodoLists.tsx
│ │ │ │ ├── access-control/
│ │ │ │ │ ├── EditMember.tsx
│ │ │ │ │ ├── EditMemberAccess.tsx
│ │ │ │ │ ├── Invites.tsx
│ │ │ │ │ └── SharingForm.tsx
│ │ │ │ ├── navbar/
│ │ │ │ │ ├── NavBar.tsx
│ │ │ │ │ └── SyncStatusIcon.tsx
│ │ │ │ └── ui/
│ │ │ │ ├── CheckedSign.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ └── input.tsx
│ │ │ ├── data/
│ │ │ │ ├── demoUsers.json
│ │ │ │ └── roles.json
│ │ │ ├── db/
│ │ │ │ ├── TodoDB.ts
│ │ │ │ ├── TodoItem.ts
│ │ │ │ ├── TodoList.ts
│ │ │ │ ├── db.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── logout.ts
│ │ │ ├── helpers/
│ │ │ │ ├── handleError.ts
│ │ │ │ ├── simplify-debugging.ts
│ │ │ │ └── usePersistedOpenState.ts
│ │ │ ├── index.css
│ │ │ ├── index.tsx
│ │ │ ├── lib/
│ │ │ │ └── utils.ts
│ │ │ ├── serviceWorkerRegistration.ts
│ │ │ ├── setupTests.ts
│ │ │ ├── sw.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tailwind.config.js
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ ├── vite.config.ts
│ │ └── vitest.config.ts
│ ├── full-text-search/
│ │ └── FullTextSearch.js
│ ├── liveQuery/
│ │ └── liveQuery.html
│ ├── open-existing-db/
│ │ └── dump-databases.html
│ ├── react/
│ │ └── README.md
│ ├── remote-sync/
│ │ ├── ajax/
│ │ │ ├── AjaxSyncProtocol.js
│ │ │ └── jquery-2.1.0.js
│ │ └── websocket/
│ │ ├── WebSocketSyncProtocol.js
│ │ ├── WebSocketSyncServer.js
│ │ └── websocketserver-shim.js
│ ├── svelte/
│ │ └── README.md
│ ├── vanilla-js/
│ │ ├── hello-world-modern.html
│ │ └── hello-world.html
│ └── vue/
│ ├── .gitignore
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── public/
│ │ └── index.html
│ ├── src/
│ │ ├── App.vue
│ │ ├── components/
│ │ │ ├── AddTodo.vue
│ │ │ ├── Todo.vue
│ │ │ └── TodoList.vue
│ │ ├── database.js
│ │ └── main.js
│ └── vite.config.js
├── src/
│ ├── classes/
│ │ ├── collection/
│ │ │ ├── collection-constructor.ts
│ │ │ ├── collection-helpers.ts
│ │ │ ├── collection.ts
│ │ │ └── index.ts
│ │ ├── dexie/
│ │ │ ├── dexie-dom-dependencies.ts
│ │ │ ├── dexie-open.ts
│ │ │ ├── dexie-static-props.ts
│ │ │ ├── dexie.ts
│ │ │ ├── generate-middleware-stacks.ts
│ │ │ ├── index.ts
│ │ │ ├── transaction-helpers.ts
│ │ │ └── vip.ts
│ │ ├── entity/
│ │ │ └── Entity.ts
│ │ ├── observable/
│ │ │ └── observable.ts
│ │ ├── table/
│ │ │ ├── index.ts
│ │ │ ├── table-constructor.ts
│ │ │ ├── table-helpers.ts
│ │ │ └── table.ts
│ │ ├── transaction/
│ │ │ ├── index.ts
│ │ │ ├── transaction-constructor.ts
│ │ │ └── transaction.ts
│ │ ├── version/
│ │ │ ├── schema-helpers.ts
│ │ │ ├── version-constructor.ts
│ │ │ └── version.ts
│ │ └── where-clause/
│ │ ├── where-clause-constructor.ts
│ │ ├── where-clause-helpers.ts
│ │ └── where-clause.ts
│ ├── dbcore/
│ │ ├── cache-existing-values-middleware.ts
│ │ ├── dbcore-indexeddb.ts
│ │ ├── get-effective-keys.ts
│ │ ├── get-key-extractor.ts
│ │ ├── keyrange.ts
│ │ ├── proxy-cursor.ts
│ │ └── virtual-index-middleware.ts
│ ├── errors/
│ │ ├── errors.d.ts
│ │ ├── errors.js
│ │ └── index.ts
│ ├── functions/
│ │ ├── apply-update-spec.ts
│ │ ├── bulk-delete.ts
│ │ ├── chaining-functions.js
│ │ ├── cmp.ts
│ │ ├── combine.ts
│ │ ├── compare-functions.ts
│ │ ├── event-wrappers.ts
│ │ ├── get-object-diff.ts
│ │ ├── is-promise-like.ts
│ │ ├── make-class-constructor.ts
│ │ ├── propmods/
│ │ │ ├── add.ts
│ │ │ ├── index.ts
│ │ │ ├── remove.ts
│ │ │ └── replace-prefix.ts
│ │ ├── quirks.ts
│ │ ├── stringify-key.d.ts
│ │ ├── stringify-key.js
│ │ ├── temp-transaction.ts
│ │ ├── utils.ts
│ │ └── workaround-undefined-primkey.ts
│ ├── globals/
│ │ ├── connections.ts
│ │ ├── constants.ts
│ │ ├── global-events.ts
│ │ └── global.ts
│ ├── helpers/
│ │ ├── Events.js
│ │ ├── database-enumerator.ts
│ │ ├── debug.ts
│ │ ├── index-spec.ts
│ │ ├── promise.d.ts
│ │ ├── promise.js
│ │ ├── prop-modification.ts
│ │ ├── rangeset.ts
│ │ ├── table-schema.ts
│ │ ├── vipify.ts
│ │ └── yield-support.ts
│ ├── hooks/
│ │ └── hooks-middleware.ts
│ ├── index-umd.ts
│ ├── index.ts
│ ├── live-query/
│ │ ├── cache/
│ │ │ ├── adjust-optimistic-request-from-failures.ts
│ │ │ ├── apply-optimistic-ops.ts
│ │ │ ├── are-ranges-equal.ts
│ │ │ ├── cache-middleware.ts
│ │ │ ├── cache.ts
│ │ │ ├── does-ranges-overlap.ts
│ │ │ ├── find-compatible-query.ts
│ │ │ ├── is-cachable-context.ts
│ │ │ ├── is-cachable-request.ts
│ │ │ ├── is-super-range.ts
│ │ │ ├── is-within-range.ts
│ │ │ ├── signalSubscribers.ts
│ │ │ └── subscribe-cachentry.ts
│ │ ├── enable-broadcast.ts
│ │ ├── extend-observability-set.ts
│ │ ├── index.ts
│ │ ├── live-query.ts
│ │ ├── obs-sets-overlap.ts
│ │ ├── observability-middleware.ts
│ │ └── propagate-locally.ts
│ ├── public/
│ │ ├── index.d.ts
│ │ └── types/
│ │ ├── _insert-type.d.ts
│ │ ├── cache.d.ts
│ │ ├── collection.d.ts
│ │ ├── db-events.d.ts
│ │ ├── db-schema.d.ts
│ │ ├── dbcore.d.ts
│ │ ├── dbquerycore.d.ts
│ │ ├── dexie-constructor.d.ts
│ │ ├── dexie-dom-dependencies.d.ts
│ │ ├── dexie-event-set.d.ts
│ │ ├── dexie-event.d.ts
│ │ ├── dexie.d.ts
│ │ ├── entity-table.d.ts
│ │ ├── entity.d.ts
│ │ ├── errors.d.ts
│ │ ├── global.d.ts
│ │ ├── index-spec.d.ts
│ │ ├── indexable-type.d.ts
│ │ ├── insert-type.d.ts
│ │ ├── is-strictly-any.d.ts
│ │ ├── keypaths.d.ts
│ │ ├── middleware.d.ts
│ │ ├── observable.d.ts
│ │ ├── promise-extended.d.ts
│ │ ├── prop-modification.d.ts
│ │ ├── rangeset.d.ts
│ │ ├── table-hooks.d.ts
│ │ ├── table-schema.d.ts
│ │ ├── table.d.ts
│ │ ├── then-shortcut.d.ts
│ │ ├── transaction-events.d.ts
│ │ ├── transaction-mode.d.ts
│ │ ├── transaction.d.ts
│ │ ├── update-spec.d.ts
│ │ ├── version.d.ts
│ │ └── where-clause.d.ts
│ ├── support-bfcache.ts
│ └── tsconfig.json
├── test/
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── data.json
│ ├── deepEqual.js
│ ├── dexie-unittest-utils.js
│ ├── gh-actions.sh
│ ├── integrations/
│ │ └── test-dexie-relationships/
│ │ ├── basic-tests.js
│ │ ├── gh-actions.sh
│ │ ├── index.js
│ │ ├── karma.conf.js
│ │ ├── package.json
│ │ └── webpack.config.js
│ ├── is-idb-and-promise-compatible.js
│ ├── karma-env.js
│ ├── karma.browsers.matrix.d.ts
│ ├── karma.browsers.matrix.js
│ ├── karma.common.d.ts
│ ├── karma.common.js
│ ├── karma.conf.js
│ ├── karma.lambdatest.d.ts
│ ├── karma.lambdatest.js
│ ├── lt-local.js
│ ├── rebalance.md
│ ├── run-unit-tests.html
│ ├── tests-all.js
│ ├── tests-asyncawait.js
│ ├── tests-binarykeys.js
│ ├── tests-blobs.js
│ ├── tests-chrome-transaction-durability.js
│ ├── tests-cmp.js
│ ├── tests-collection.js
│ ├── tests-crud-hooks.js
│ ├── tests-exception-handling.js
│ ├── tests-extendability.js
│ ├── tests-idb30.js
│ ├── tests-live-query.js
│ ├── tests-max-connections.js
│ ├── tests-misc.js
│ ├── tests-open.js
│ ├── tests-performance.js
│ ├── tests-promise.js
│ ├── tests-rangeset.js
│ ├── tests-table.js
│ ├── tests-transaction.js
│ ├── tests-upgrading.js
│ ├── tests-whereclause.js
│ ├── tests-yield.js
│ ├── tsconfig.json
│ ├── typings-test/
│ │ ├── test-extend-dexie.ts
│ │ ├── test-misc.ts
│ │ ├── test-typings.ts
│ │ ├── test-updatespec.ts
│ │ └── tsconfig.json
│ └── worker.js
├── tools/
│ ├── .eslintrc.json
│ ├── build-configs/
│ │ ├── banner.txt
│ │ ├── rollup.config.js
│ │ ├── rollup.config.mjs
│ │ ├── rollup.modern.config.js
│ │ ├── rollup.modern.config.mjs
│ │ ├── rollup.tests.config.js
│ │ ├── rollup.tests.config.mjs
│ │ ├── rollup.umd.config.js
│ │ └── rollup.umd.config.mjs
│ ├── fix-dts-duplicates.js
│ ├── prepend.js
│ ├── release.sh
│ └── replaceVersionAndDate.js
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
github: dfahlander
================================================
FILE: .github/workflows/dexie-cloud-common.yml
================================================
name: dexie-cloud-common Tests
on:
workflow_dispatch:
push:
branches:
- master
paths:
- 'libs/dexie-cloud-common/**'
- '.github/workflows/dexie-cloud-common.yml'
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'libs/dexie-cloud-common/**'
- '.github/workflows/dexie-cloud-common.yml'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: "pnpm"
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build dexie-cloud-common
run: pnpm --filter dexie-cloud-common run build
- name: Run tests
run: pnpm --filter dexie-cloud-common test
================================================
FILE: .github/workflows/main.yml
================================================
name: Build and Test
on:
workflow_dispatch:
push:
branches:
- master # Add also master-4 when dexie@4 is stable and master will represent dexie@5. dexie@3 has its own workflow.
pull_request:
types: [opened, synchronize, reopened]
env:
LAMBDATEST: "true"
GH_ACTIONS: "true"
LT_USERNAME: ${{ secrets.LT_USERNAME }}
LT_ACCESS_KEY: ${{ secrets.LT_ACCESS_KEY }}
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
TF:
- test
- addons/Dexie.Observable/test
- addons/Dexie.Syncable/test
- addons/dexie-export-import/test
- addons/y-dexie/test
- libs/dexie-react-hooks/test
fail-fast: true # If one test fails, abort the rest of the tests
max-parallel: 6 # At least for browserstack, this seems to be needed to avoid timeouts
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: "pnpm"
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build
run: pnpm run build
- name: Set LT_TUNNEL_NAME
run: echo "LT_TUNNEL_NAME=${{ matrix.TF }}-${{ github.run_id }}-${{ github.run_attempt }}" >> $GITHUB_ENV
- name: Run headless test
uses: coactions/setup-xvfb@v1
with:
run: bash -e ./gh-actions.sh
working-directory: ${{ matrix.TF }}
================================================
FILE: .gitignore
================================================
# Ignore node_modules
node_modules/
# Ignore all build output
dist/*.js
dist/*.map
dist/*.ts
dist/*.gz
dist/*.mjs
dist/**/*.js
dist/**/*.map
dist/**/*.ts
dist/**/*.gz
dist/**/*.mjs
# Other ignores
tmp/
.idea/
.eslintcache
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
[Bb]in/
[Oo]bj/
# Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets
!packages/*/build/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*_i.c
*_p.c
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.log
*.scc
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# NCrunch
*.ncrunch*
.*crunch*.local.xml
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.Publish.xml
*.pubxml
# NuGet Packages Directory
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
#packages/
# Windows Azure Build Output
csx
*.build.csdef
# Windows Store app package directory
AppPackages/
# Others
sql/
*.Cache
ClientBin/
[Ss]tyle[Cc]op.*
~$*
*~
*.dbmdl
*.[Pp]ublish.xml
*.pfx
*.publishsettings
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
App_Data/*.mdf
App_Data/*.ldf
# =========================
# Windows detritus
# =========================
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Mac crap
.DS_Store
*.csproj
Dexie.sln
samples/typescript/appdb.js
samples/typescript/app.js
samples/typescript/app.js.map
samples/typescript/appdb.js.map
samples/typescript/console.js
samples/typescript/console.js.map
samples/typescript/utils.js
samples/typescript/utils.js.map
/.vscode
/jsconfig.json
/.ntvs_analysis.dat
/*.njsproj
libs/dexie-cloud-common/tsconfig.tsbuildinfo
#lambdatest tunnel binary
.lambdatest
tunnel.pid
================================================
FILE: .npmignore
================================================
**/tmp/
samples/
addons/
libs/
*.njsproj
.*
*.log
test/
tools/
bower.json
src/
.lambdatest
tunnel.pid
tsconfig.json
pnpm-workspace.yaml
================================================
FILE: .npmrc
================================================
auto-install-peers=true
================================================
FILE: .prettierrc
================================================
{
"trailingComma": "es5",
"tabWidth": 2,
"singleQuote": true
}
================================================
FILE: .prettierrc.yml
================================================
trailingComma: 'none'
tabWidth: 2
semi: true
singleQuote: true
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at code@dexie.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
================================================
FILE: CONTRIBUTING.md
================================================
HOW TO CONTRIBUTE
=================
We appreciate contributions in forms of:
* issues
* help answering questions in [issues](https://github.com/dexie/Dexie.js/issues) and on [stackoverflow](https://stackexchange.com/filters/233583/dexie-stackoverflow)
* fixing bugs via pull-requests
* developing addons or other [derived work](https://dexie.org/docs/DerivedWork)
* promoting Dexie.js
* sharing ideas
Contribute while developing your own app
========================================
Dexie uses pnpm package manager. Refer to [pnpm.io/installation](https://pnpm.io/installation) for how to install pnpm.
Here is a little cheat-sheet for how to symlink your app's `node_modules/dexie` to a place where you can edit the source, version control your changes and create pull requests back to Dexie. Assuming you've already ran `npm install dexie` for the app your are developing.
1. Fork Dexie.js from the web gui on github
2. Clone your fork locally by launching a shell/command window and cd to a neutral place (like `~repos/`, `c:\repos` or whatever)
3. Run the following commands:
```
git clone https://github.com/YOUR-USERNAME/Dexie.js.git dexie
cd dexie
pnpm install
pnpm run build
npm link # Or yarn link or pnpm link --global depending on what package manager you are using.
```
3. cd to your app directory and write:
```
npm link dexie # Or yarn link dexie / pnpm link dexie depending on your package manager.
```
Your app's `node_modules/dexie/` is now sym-linked to the Dexie.js clone on your hard drive so any change you do there will propagate to your app. Build dexie.js using `pnpm run build` or `pnpm run watch`. The latter will react on any source file change and rebuild the dist files.
That's it. Now you're up and running to test and commit changes to files under dexie/src/* or dexie/test/* and the changes will instantly affect the app you are developing.
If you're on yarn or pnpm, do the same procedures using yarn link / pnpm link.
Pull requests are more than welcome. Some advices are:
* Run pnpm test before making a pull request.
* If you find an issue, a unit test that reproduces it is lovely ;). If you don't know where to put it, put it in `test/tests-misc.js`. We use qunit. Just look at existing tests in `tests-misc.js` to see how they should be written. Tests are transpiled in the build script so you can use ES6 if you like.
Build
-----
```
# To install pnpm, see https://pnpm.io/installation
pnpm install
pnpm run build
```
Test
----
```
pnpm test
```
Watch
-----
```
pnpm run watch
```
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: NOTICE
================================================
Dexie.js
Copyright (c) 2014-2017 David Fahlander
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: README.md
================================================
# Dexie.js
[![NPM Version][npm-image]][npm-url]  [](https://discord.gg/huhre7MHBF)
Dexie.js is a wrapper library for indexedDB - the standard database in the browser. https://dexie.org.
#### Why Dexie.js?
IndexedDB is the portable database for all browser engines. Dexie.js makes it fun and easy to work with.
But also:
* Dexie.js is widely used by 100,000 of web sites, apps and other projects and supports all browsers, Electron for Desktop apps, Capacitor for iOS / Android apps and of course pure PWAs.
* Dexie.js works around bugs in the IndexedDB implementations, giving a more stable user experience.
* Need sync? [Dexie Cloud](https://dexie.org/cloud/) adds real-time sync, auth, and collaboration on top of Dexie.js — no backend needed.
#### Hello World (vanilla JS)
```html
```
Yes, it's that simple. Read [the docs](https://dexie.org/docs/) to get into the details.
#### Hello World (legacy script tags)
```html
```
#### Hello World (React + Typescript)
Real-world apps are often built using components in various frameworks. Here's a version of Hello World written for React and Typescript. There are also links below this sample to more tutorials for different frameworks...
```tsx
import React from 'react';
import { Dexie, type EntityTable } from 'dexie';
import { useLiveQuery } from 'dexie-react-hooks';
// Typing for your entities (hint is to move this to its own module)
export interface Friend {
id: number;
name: string;
age: number;
}
// Database declaration (move this to its own module also)
export const db = new Dexie('FriendDatabase') as Dexie & {
friends: EntityTable;
};
db.version(1).stores({
friends: '++id, age',
});
// Component:
export function MyDexieReactComponent() {
const youngFriends = useLiveQuery(() =>
db.friends
.where('age')
.below(30)
.toArray()
);
return (
<>
My young friends
{youngFriends?.map((f) => (
Name: {f.name}, Age: {f.age}
))}
>
);
}
```
[Tutorials for React, Svelte, Vue, Angular and vanilla JS](https://dexie.org/docs/Tutorial/Getting-started)
[API Reference](https://dexie.org/docs/API-Reference)
[Samples](https://dexie.org/docs/Samples)
### Performance
Dexie has kick-ass performance. Its [bulk methods]() take advantage of a lesser-known feature in IndexedDB that makes it possible to store stuff without listening to every onsuccess event. This speeds up the performance to a maximum.
#### Supported operations
```js
above(key): Collection;
aboveOrEqual(key): Collection;
add(item, key?): Promise;
and(filter: (x) => boolean): Collection;
anyOf(keys[]): Collection;
anyOfIgnoreCase(keys: string[]): Collection;
below(key): Collection;
belowOrEqual(key): Collection;
between(lower, upper, includeLower?, includeUpper?): Collection;
bulkAdd(items: Array): Promise;
bulkDelete(keys: Array): Promise;
bulkPut(items: Array): Promise;
clear(): Promise;
count(): Promise;
delete(key): Promise;
distinct(): Collection;
each(callback: (obj) => any): Promise;
eachKey(callback: (key) => any): Promise;
eachPrimaryKey(callback: (key) => any): Promise;
eachUniqueKey(callback: (key) => any): Promise;
equals(key): Collection;
equalsIgnoreCase(key): Collection;
filter(fn: (obj) => boolean): Collection;
first(): Promise;
get(key): Promise;
inAnyRange(ranges): Collection;
keys(): Promise;
last(): Promise;
limit(n: number): Collection;
modify(changeCallback: (obj: T, ctx:{value: T}) => void): Promise;
modify(changes: { [keyPath: string]: any } ): Promise;
noneOf(keys: Array): Collection;
notEqual(key): Collection;
offset(n: number): Collection;
or(indexOrPrimayKey: string): WhereClause;
orderBy(index: string): Collection;
primaryKeys(): Promise;
put(item: T, key?: Key): Promise;
reverse(): Collection;
sortBy(keyPath: string): Promise;
startsWith(key: string): Collection;
startsWithAnyOf(prefixes: string[]): Collection;
startsWithAnyOfIgnoreCase(prefixes: string[]): Collection;
startsWithIgnoreCase(key: string): Collection;
toArray(): Promise;
toCollection(): Collection;
uniqueKeys(): Promise;
until(filter: (value) => boolean, includeStopEntry?: boolean): Collection;
update(key: Key, changes: { [keyPath: string]: any }): Promise;
```
This is a mix of methods from [WhereClause](https://dexie.org/docs/WhereClause/WhereClause), [Table](https://dexie.org/docs/Table/Table) and [Collection](https://dexie.org/docs/Collection/Collection). Dive into the [API reference](https://dexie.org/docs/API-Reference) to see the details.
## Dexie Cloud
[Dexie Cloud](https://dexie.org/cloud/) is the easiest way to add sync, authentication, and real-time collaboration to your Dexie app. You keep writing frontend code with Dexie.js — Dexie Cloud handles the rest.
**What you get:**
- 🔄 **Sync across devices** — changes propagate in real time, no polling needed
- 🔐 **Authentication** — built-in user auth, no identity provider required
- 🛡️ **Access control** — share data between users with fine-grained permissions
- 📁 **File & blob storage** — store attachments alongside your structured data
- ✈️ **Offline-first** — works fully offline, syncs when back online
**Getting started is just a few lines:**
```bash
npm install dexie-cloud-addon
```
```ts
import Dexie from 'dexie';
import dexieCloud from 'dexie-cloud-addon';
const db = new Dexie('MyDatabase', { addons: [dexieCloud] });
db.version(1).stores({ items: '@id, title' });
db.cloud.configure({ databaseUrl: 'https://.dexie.cloud' });
```
That's it. Your existing Dexie app now syncs. Hosted cloud or self-hosted on your own infrastructure. 👋
→ [Quickstart guide](https://dexie.org/cloud/docs/quickstart)
**Sample app:**
Source: [Dexie Cloud To-do app](https://github.com/dexie/Dexie.js/tree/master/samples/dexie-cloud-todo-app)
Live demo: https://dexie.github.io/Dexie.js/dexie-cloud-todo-app/
## Samples
https://dexie.org/docs/Samples
https://github.com/dexie/Dexie.js/tree/master/samples
## Knowledge Base
[https://dexie.org/docs/Questions-and-Answers](https://dexie.org/docs/Questions-and-Answers)
## Website
[https://dexie.org](https://dexie.org)
## Install via npm
```
npm install dexie
```
## Download
For those who don't like package managers, here's the download links:
### UMD (for legacy script includes as well as commonjs require):
https://unpkg.com/dexie@latest/dist/dexie.min.js
https://unpkg.com/dexie@latest/dist/dexie.min.js.map
### Modern (ES module):
https://unpkg.com/dexie@latest/dist/modern/dexie.min.mjs
https://unpkg.com/dexie@latest/dist/modern/dexie.min.mjs.map
### Typings:
https://unpkg.com/dexie@latest/dist/dexie.d.ts
# Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md)
## Build
```
pnpm install
pnpm run build
```
## Test
```
pnpm test
```
## Watch
```
pnpm run watch
```
[](https://www.lambdatest.com/)
[npm-image]: https://img.shields.io/npm/v/dexie.svg?style=flat
[npm-url]: https://npmjs.org/package/dexie
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Supported Versions
| Version | Supported | Branch
| ------- | ------------------ | --------
| 4.x | :white_check_mark: | master
| 3.x | :white_check_mark: | master-3
| 2.0.x | :x: | master-2
| 1.5.x | :x: | master-1
| < 1.5.1 | :x: |
## Reporting a Vulnerability
To report a security vulnerability in Dexie.js, please send an email to code@dexie.org describing the vulnerability and how to reproduce it.
If we find an issue to be regarded as a security vulnerability, we will patch and release a new version in all the supported versions as soon as possible.
Keep in mind though that this is an uncommercial open source project which means that sometimes you might have to be the one that
*fixes* the issue and not just report it.
## Fixing a Vulnerability
Fix the issue in the corresponding branch for the major version according to the table above where it applies and
create pull requests. Make sure that you have the words "security" or "vulnerability" in the title of the Pull Request
in order to get the correct attention for it to be merged and released as soon as possible.
================================================
FILE: addons/Dexie.Observable/.gitignore
================================================
dist/*.js
dist/*.map
dist/*.ts
dist/*.gz
**/tmp/
================================================
FILE: addons/Dexie.Observable/.npmignore
================================================
tools/
src/
.*
tmp/
**/tmp/
test
*.log
================================================
FILE: addons/Dexie.Observable/README.md
================================================
# Dexie.Observable.js
*NOTE! Dexie's liveQuery feature is NOT dependent on this old package. This package has been unmaintained for a looong time and might be retired*
Observe changes to database - even when they happen in another browser window.
### Install
```
npm install dexie --save
npm install dexie-observable --save
```
### Use
```js
import Dexie from 'dexie';
import 'dexie-observable';
// Use Dexie as normally - but you can also subscribe to db.on('changes').
```
#### Usage with existing DB
In case you want to use Dexie.Observable with your existing database, you will have to do a schema upgrade. Without it Dexie.Observable will not be able to properly work.
```javascript
import Dexie from 'dexie';
import 'dexie-observable';
var db = new Dexie('myExistingDb');
db.version(1).stores(... existing schema ...);
// Now, add another version, just to trigger an upgrade for Dexie.Observable
db.version(2).stores({}); // No need to add / remove tables. This is just to allow the addon to install its tables.
```
### Dependency Tree
* [Dexie.Syncable.js](https://dexie.org/docs/Syncable/Dexie.Syncable.js)
* **Dexie.Observable.js**
* [Dexie.js](https://dexie.org/docs/Dexie/Dexie.js)
* [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
### Source
[Dexie.Observable.js](https://github.com/dexie/Dexie.js/blob/master/addons/Dexie.Observable/src/Dexie.Observable.js)
### Description
Dexie.Observable is an add-on to Dexie.js makes it possible to listen for changes on the database even if the changes are made in a foreign window. The addon provides a "storage" event for IndexedDB, much like the storage event (onstorage) for localStorage.
In contrary to the [Dexie CRUD hooks](https://dexie.org/docs/Tutorial/Design#the-crud-hooks-create-read-update-delete), this event reacts not only on changes made on the current db instance but also on changes occurring on db instances in other browser windows. This enables a Web Apps to react to database changes and update their views accordingly.
Dexie.Observable is also the base of [Dexie.Syncable.js](https://dexie.org/docs/Syncable//Dexie.Syncable.js) - an add-on that enables two-way replication with a remote server.
### Extended Methods, Properties and Events
#### UUID key generator
When defining your stores in [Version.stores()](https://dexie.org/docs/Version/Version.stores()) you may use the $$ (double dollar) prefix to your primary key. This will make it auto-generated to a UUID string. See sample below.
#### Dexie.Observable.createUUID()
A static method added to Dexie that creates a UUID. This method is used internally when using the $$ prefix to primary keys. To change the format of $$ primary keys, just override Dexie.createUUID by setting it to your desired function instead.
#### db.on('changes') event
Subscribe to any database changes no matter if they occur locally or in other browser window.
Parameters to your callback:
Array of changes that have occured in database (locally or in other window) since last time event was triggered, or the time of starting subscribing to changes.
partial: Boolean
True in case the array does not contain all changes. In this case, your callback will soon be called again with the additional changes and partial=false when all changes are delivered.
#### Example (here we're using plain ES6 script tags):
```html
```
================================================
FILE: addons/Dexie.Observable/api.d.ts
================================================
/**
* API for Dexie.Observable.
*
* Contains interfaces used by dexie-observable.
*
* By separating module 'dexie-observable' from 'dexie-observable/api' we
* distinguish that:
*
* import {...} from 'dexie-observable/api' is only for getting access to its
* interfaces and has no side-effects.
* Typescript-only import.
*
* import 'dexie-observable' is only for side effects - to extend Dexie with
* functionality of dexie-observable.
* Javascript / Typescript import.
*
*/
export const enum DatabaseChangeType {
Create = 1,
Update = 2,
Delete = 3,
}
export interface ICreateChange {
type: DatabaseChangeType.Create;
table: string;
key: any;
obj: any;
source?: string;
}
export interface IUpdateChange {
type: DatabaseChangeType.Update;
table: string;
key: any;
mods: { [keyPath: string]: any | undefined };
obj: any;
oldObj: any;
source?: string;
}
export interface IDeleteChange {
type: DatabaseChangeType.Delete;
table: string;
key: any;
oldObj: any;
source?: string;
}
export type IDatabaseChange = ICreateChange | IUpdateChange | IDeleteChange;
================================================
FILE: addons/Dexie.Observable/api.js
================================================
// This file is deliberatly left empty to allow the api.d.ts to contain the definitions for Dexie.Observable without generating an error on webpack
================================================
FILE: addons/Dexie.Observable/dist/README.md
================================================
## Can't find dexie-observable.js?
Transpiled code (dist version) IS ONLY checked in to
the [releases](https://github.com/dexie/Dexie.js/tree/releases/addons/Dexie.Observable/dist)
branch.
## Download
[unpkg.com/dexie-observable/dist/dexie-observable.js](https://unpkg.com/dexie-observable/dist/dexie-observable.js)
[unpkg.com/dexie-observable/dist/dexie-observable.min.js](https://unpkg.com/dexie-observable/dist/dexie-observable.min.js)
[unpkg.com/dexie-observable/dist/dexie-observable.js.map](https://unpkg.com/dexie-observable/dist/dexie-observable.js.map)
[unpkg.com/dexie-observable/dist/dexie-observable.min.js.map](https://unpkg.com/dexie-observable/dist/dexie-observable.min.js.map)
## npm
```
npm install dexie-observable --save
```
## bower
Since Dexie v1.3.4, addons are included in the dexie bower package.
```
$ bower install dexie --save
$ ls bower_components/dexie/addons/Dexie.Observable/dist
dexie-observable.js dexie-observable.js.map dexie-observable.min.js dexie-observable.min.js.map
```
## Or build them yourself...
Fork Dexie.js, then:
```
git clone https://github.com/YOUR-USERNAME/Dexie.js.git
cd Dexie.js
npm install
cd addons/Dexie.Observable
npm run build # or npm run watch
```
If you're on windows, you need to use an elevated command prompt of some reason to get `npm install` to work.
================================================
FILE: addons/Dexie.Observable/package.json
================================================
{
"name": "dexie-observable",
"version": "4.0.1-beta.13",
"description": "Addon to Dexie that makes it possible to observe database changes no matter if they occur on other db instance or other window.",
"main": "dist/dexie-observable.js",
"module": "dist/dexie-observable.es.js",
"jsnext:main": "dist/dexie-observable.es.js",
"typings": "dist/dexie-observable.d.ts",
"jspm": {
"format": "cjs",
"ignore": [
"src/"
]
},
"repository": {
"type": "git",
"url": "https://github.com/dexie/Dexie.js.git"
},
"keywords": [
"indexeddb",
"browser",
"dexie",
"addon"
],
"author": "David Fahlander",
"contributors": [
"Nikolas Poniros ",
"Yury Solovyov ",
"Martin Diphoorn ",
"Corbin Crutchley "
],
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/dexie/Dexie.js/issues"
},
"scripts": {
"build": "just-build",
"watch": "just-build --watch",
"test": "pnpm run build && pnpm run test:typings && pnpm run test:unit && pnpm run test:integration",
"test:unit": "karma start test/unit/karma.conf.js --single-run",
"test:integration": "karma start test/integration/karma.conf.js --single-run",
"test:typings": "just-build test-typings",
"test:unit:debug": "karma start test/unit/karma.conf.js --log-level debug",
"test:integration:debug": "karma start test/integrations/karma.conf.js --log-level debug",
"test:ltcloud": "cross-env LAMBDATEST=true pnpm run test:ltTunnel & sleep 10 && pnpm run test:unit; UNIT_STATUS=$?; exit $UNIT_STATUS",
"test:ltTunnel": "node ../../test/lt-local",
"test:ltcloud:integration": "cross-env LAMBDATEST=true pnpm run test:integration; UNIT_STATUS=$?; kill $(cat tunnel.pid); exit $UNIT_STATUS"
},
"just-build": {
"default": [
"just-build release test"
],
"dev": [
"just-build dexie-observable test"
],
"dexie-observable": [
"# Build UMD module",
"tsc --allowJs -t es5 -m es2015 --outDir tools/tmp/es5/src/ --sourceMap --skipLibCheck src/Dexie.Observable.js [--watch 'Compilation complete.']",
"rollup -c tools/build-configs/rollup.config.mjs",
"node tools/replaceVersionAndDate.js dist/dexie-observable.js",
"# eslint ",
"eslint src --cache"
],
"release": [
"just-build dexie-observable",
"# Copy Dexie.Observable.d.ts to dist and replace version in it",
"node -e \"fs.writeFileSync('dist/dexie-observable.d.ts', fs.readFileSync('src/Dexie.Observable.d.ts'))\"",
"node tools/replaceVersionAndDate.js dist/dexie-observable.d.ts",
"# Minify the default ES5 UMD module",
"cd dist",
"uglifyjs dexie-observable.js -m -c negate_iife=0 -o dexie-observable.min.js --source-map"
],
"test": [
"# Build the unit tests (integration tests need no build)",
"tsc --allowJs --moduleResolution node --lib es2018,dom -t es5 -m es2015 --outDir tools/tmp/es5/test --rootDir ../.. --sourceMap --skipLibCheck test/unit/unit-tests-all.js [--watch 'Compilation complete.']",
"rollup -c tools/build-configs/rollup.tests.config.mjs"
],
"test-typings": [
"tsc -p test/typings/"
]
},
"homepage": "https://dexie.org",
"peerDependencies": {
"dexie": "workspace:^"
},
"devDependencies": {
"@types/node": "^18.11.18",
"dexie": "workspace:^",
"eslint": "^7.27.0",
"just-build": "^0.9.24",
"qunit": "2.10.0",
"qunitjs": "1.23.1",
"typescript": "^5.3.3",
"uglify-js": "^3.5.6",
"undici-types": "^6.21.0"
}
}
================================================
FILE: addons/Dexie.Observable/src/.eslintrc.json
================================================
{
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"ecmaFeatures": {
}
},
"rules": {
"no-undef": ["error"],
"no-unused-vars": 1,
"no-console": 0,
"no-empty": 0
},
"globals": {
"indexedDB": false,
"IDBKeyRange": false,
"setTimeout": false,
"clearTimeout": false,
"Symbol": false,
"setImmediate": false,
"console": false,
"self": false,
"window": false,
"global": false,
"navigator": false,
"location": false,
"chrome": false,
"document": false,
"MutationObserver": false,
"CustomEvent": false,
"dispatchEvent": false,
"localStorage": false
}
}
================================================
FILE: addons/Dexie.Observable/src/Dexie.Observable.d.ts
================================================
// Type definitions for dexie-observable v{version}
// Project: https://github.com/dexie/Dexie.js/tree/master/addons/Dexie.Observable
// Definitions by: David Fahlander
import Dexie, { DexieEventSet } from 'dexie';
import { IDatabaseChange } from '../api';
export interface SyncNodeConstructor {
new() : SyncNode;
}
//
// Interfaces of Dexie.Observable
//
/**
* A SyncNode represents a local database instance that subscribes
* to changes made on the database.
* SyncNodes are stored in the _syncNodes table.
*
* Dexie.Syncable extends this interface and allows 'remote' nodes to be stored
* as well.
*/
export interface SyncNode {
id?: number,
myRevision: number,
type: 'local' | 'remote',
lastHeartBeat: number,
deleteTimeStamp: number, // In case lastHeartBeat is too old, a value of now + HIBERNATE_GRACE_PERIOD will be set here. If reached before node wakes up, node will be deleted.
isMaster: number // 1 if true. Not using Boolean because it's not possible to index Booleans.
}
export interface ObservableEventSet extends DexieEventSet {
(eventName: 'latestRevisionIncremented', subscriber: (dbName: string, latestRevision: number) => void): void;
(eventName: 'suicideNurseCall', subscriber: (dbName: string, nodeID: number) => void): void;
(eventName: 'intercomm', subscriber: (dbName: string) => void): void;
(eventName: 'beforeunload', subscriber: () => void): void;
}
// Object received by on('message') after sendMessage() or broadcastMessage()
interface MessageEvent {
id: number;
type: string;
message: any;
destinationNode: number;
wantReply?: boolean;
resolve(result: any): void;
reject(error: any): void;
}
//
// Extend Dexie interface
//
declare module 'dexie' {
// Extend methods on db (db.sendMessage(), ...)
interface Dexie {
// Placeholder where to access the SyncNode class constructor.
// (makes it valid to do new db.observable.SyncNode())
observable: {
version: string;
SyncNode: SyncNodeConstructor;
sendMessage(
type: string, // Don't use 'response' as it is used internally by the framework
message: any, // anything that can be saved by IndexedDB
destinationNode: number,
options: {
wantReply?: boolean;
}
): Promise | void; // When wantReply is undefined or false return is void
broadcastMessage(
type: string,
message: any, // anything that can be saved by IndexedDB
bIncludeSelf: boolean
): void;
}
readonly _localSyncNode: SyncNode;
_changes: Dexie.Table;
_syncNodes: Dexie.Table;
_intercomm: Dexie.Table;
}
// Extended events db.on('changes', subscriber), ...
interface DbEvents {
(eventName: 'changes', subscriber: (changes: IDatabaseChange[], partial: boolean)=>void): void;
(eventName: 'cleanup', subscriber: ()=>any): void;
(eventName: 'message', subscriber: (msg: MessageEvent)=>any): void;
}
// Extended IndexSpec with uuid boolean for primary key.
interface IndexSpec {
uuid: boolean;
}
interface DexieConstructor {
Observable: {
(db: Dexie) : void;
version: string;
createUUID: () => string;
on: ObservableEventSet;
localStorageImpl: {
setItem(key: string, value: string): void,
getItem(key: string): string,
removeItem(key: string): void;
};
_onStorage: (event: StorageEvent) => void;
}
}
}
export default Dexie.Observable;
================================================
FILE: addons/Dexie.Observable/src/Dexie.Observable.js
================================================
/* ==========================================================================
* dexie-observable.js
* ==========================================================================
*
* Dexie addon for observing database changes not just on local db instance
* but also on other instances, tabs and windows.
*
* Comprises a base framework for dexie-syncable.js
*
* By David Fahlander, david.fahlander@gmail.com,
* Nikolas Poniros, https://github.com/nponiros
*
* ==========================================================================
*
* Version {version}, {date}
*
* https://dexie.org
*
* Apache License Version 2.0, January 2004, http://www.apache.org/licenses/
*
*/
import Dexie from 'dexie';
import { nop, promisableChain, createUUID } from './utils';
import initOverrideCreateTransaction from './override-create-transaction';
import initWakeupObservers from './wakeup-observers';
import initCrudMonitor from './hooks/crud-monitor';
import initOnStorage from './on-storage';
import initOverrideOpen from './override-open';
import initIntercomm from './intercomm';
import overrideParseStoresSpec from './override-parse-stores-spec';
import deleteOldChanges from './delete-old-changes';
var global = self;
/** class DatabaseChange
*
* Object contained by the _changes table.
*/
var DatabaseChange = Dexie.defineClass({
rev: Number, // Auto-incremented primary key
source: String, // Optional source creating the change. Set if transaction.source was set when doing the operation.
table: String, // Table name
key: Object, // Primary key. Any type.
type: Number, // 1 = CREATE, 2 = UPDATE, 3 = DELETE
obj: Object, // CREATE: obj contains the object created.
mods: Object, // UPDATE: mods contains the modifications made to the object.
oldObj: Object // DELETE: oldObj contains the object deleted. UPDATE: oldObj contains the old object before updates applied.
});
// Import some usable helper functions
var override = Dexie.override;
var Promise = Dexie.Promise;
var browserIsShuttingDown = false;
/** Dexie addon for change tracking and real-time observation.
*
* @param {Dexie} db
*/
function Observable(db) {
if (!/^(3|4)\./.test(Dexie.version))
throw new Error(`Missing dexie version 3.x or 4.x`);
if (db.observable) {
if (db.observable.version !== "{version}") throw new Error(`Mixed versions of dexie-observable`);
return; // Addon already active.
}
var NODE_TIMEOUT = 20000, // 20 seconds before local db instances are timed out. This is so that old changes can be deleted when not needed and to garbage collect old _syncNodes objects.
HIBERNATE_GRACE_PERIOD = 20000, // 20 seconds
// LOCAL_POLL: The time to wait before polling local db for changes and cleaning up old nodes.
// Polling for changes is a fallback only needed in certain circomstances (when the onstorage event doesnt reach all listeners - when different browser windows doesnt share the same process)
LOCAL_POLL = 500, // 500 ms. In real-world there will be this value + the time it takes to poll(). A small value is needed in Workers where we cannot rely on storage event.
HEARTBEAT_INTERVAL = NODE_TIMEOUT - 5000;
var localStorage = Observable.localStorageImpl;
/** class SyncNode
*
* Object contained in the _syncNodes table.
*/
var SyncNode = Dexie.defineClass({
//id: Number,
myRevision: Number,
type: String, // "local" or "remote"
lastHeartBeat: Number,
deleteTimeStamp: Number, // In case lastHeartBeat is too old, a value of now + HIBERNATE_GRACE_PERIOD will be set here. If reached before node wakes up, node will be deleted.
url: String, // Only applicable for "remote" nodes. Only used in Dexie.Syncable.
isMaster: Number, // 1 if true. Not using Boolean because it's not possible to index Booleans in IE implementation of IDB.
// Below properties should be extended in Dexie.Syncable. Not here. They apply to remote nodes only (type == "remote"):
syncProtocol: String, // Tells which implementation of ISyncProtocol to use for remote syncing.
syncContext: null,
syncOptions: Object,
connected: false, // FIXTHIS: Remove! Replace with status.
status: Number,
appliedRemoteRevision: null,
remoteBaseRevisions: [{ local: Number, remote: null }],
dbUploadState: {
tablesToUpload: [String],
currentTable: String,
currentKey: null,
localBaseRevision: Number
}
});
db.observable = {version: "{version}"};
db.observable.SyncNode = SyncNode;
const wakeupObservers = initWakeupObservers(db, Observable, localStorage);
const overrideCreateTransaction = initOverrideCreateTransaction(db, wakeupObservers);
const crudMonitor = initCrudMonitor(db);
const overrideOpen = initOverrideOpen(db, SyncNode, crudMonitor);
var mySyncNode = {node: null};
const intercomm = initIntercomm(db, Observable, SyncNode, mySyncNode, localStorage);
const onIntercomm = intercomm.onIntercomm;
const consumeIntercommMessages = intercomm.consumeIntercommMessages;
// Allow other addons to access the local sync node. May be needed by Dexie.Syncable.
Object.defineProperty(db, "_localSyncNode", {
get: function() { return mySyncNode.node; }
});
var pollHandle = null,
heartbeatHandle = null;
if (Dexie.fake) {
// This code will never run.
// It's here just to enable auto-complete in visual studio - helps a lot when writing code.
db.version(1).stores({
_syncNodes: "++id,myRevision,lastHeartBeat",
_changes: "++rev",
_intercomm: "++id,destinationNode",
_uncommittedChanges: "++id,node"
});
db._syncNodes.mapToClass(SyncNode);
db._changes.mapToClass(DatabaseChange);
mySyncNode.node = new SyncNode({
myRevision: 0,
type: "local",
lastHeartBeat: Date.now(),
deleteTimeStamp: null
});
}
//
// Override parsing the stores to add "_changes" and "_syncNodes" tables.
// It also adds UUID support for the primary key and sets tables as observable tables.
//
db.Version.prototype._parseStoresSpec = override(db.Version.prototype._parseStoresSpec, overrideParseStoresSpec);
// changes event on db:
db.on.addEventType({
changes: 'asap',
cleanup: [promisableChain, nop], // fire (nodesTable, changesTable, trans). Hook called when cleaning up nodes. Subscribers may return a Promise to to more stuff. May do additional stuff if local sync node is master.
message: 'asap'
});
//
// Override transaction creation to always include the "_changes" store when any observable store is involved.
//
db._createTransaction = override(db._createTransaction, overrideCreateTransaction);
// If Observable.latestRevsion[db.name] is undefined, set it to 0 so that comparing against it always works.
// You might think that it will always be undefined before this call, but in case another Dexie instance in the same
// window with the same database name has been created already, this static property will already be set correctly.
Observable.latestRevision[db.name] = Observable.latestRevision[db.name] || 0;
//
// Override open to setup hooks for db changes and map the _syncNodes table to class
//
db.open = override(db.open, overrideOpen);
db.close = override(db.close, function(origClose) {
return function () {
if (db.dynamicallyOpened()) return origClose.apply(this, arguments); // Don't observe dynamically opened databases.
// Teardown our framework.
if (wakeupObservers.timeoutHandle) {
clearTimeout(wakeupObservers.timeoutHandle);
delete wakeupObservers.timeoutHandle;
}
Observable.on('latestRevisionIncremented').unsubscribe(onLatestRevisionIncremented);
Observable.on('suicideNurseCall').unsubscribe(onSuicide);
Observable.on('intercomm').unsubscribe(onIntercomm);
Observable.on('beforeunload').unsubscribe(onBeforeUnload);
// Inform other db instances in same window that we are dying:
if (mySyncNode.node && mySyncNode.node.id) {
Observable.on.suicideNurseCall.fire(db.name, mySyncNode.node.id);
// Inform other windows as well:
if (localStorage) {
localStorage.setItem('Dexie.Observable/deadnode:' + mySyncNode.node.id.toString() + '/' + db.name, "dead"); // In IE, this will also wakeup our own window. cleanup() may trigger twice per other db instance. But that doesnt to anything.
}
mySyncNode.node.deleteTimeStamp = 1; // One millisecond after 1970. Makes it occur in the past but still keeps it truthy.
mySyncNode.node.lastHeartBeat = 0;
db._syncNodes.put(mySyncNode.node); // This async operation may be cancelled since the browser is closing down now.
mySyncNode.node = null;
}
if (pollHandle) clearTimeout(pollHandle);
pollHandle = null;
if (heartbeatHandle) clearTimeout(heartbeatHandle);
heartbeatHandle = null;
return origClose.apply(this, arguments);
};
});
// Override Dexie.delete() in order to delete Observable.latestRevision[db.name].
db.delete = override(db.delete, function(origDelete) {
return function() {
return origDelete.apply(this, arguments).then(function(result) {
// Reset Observable.latestRevision[db.name]
Observable.latestRevision[db.name] = 0;
return result;
});
};
});
// When db opens, make sure to start monitor any changes before other db operations will start.
db.on("ready", function startObserving() {
if (db.dynamicallyOpened()) return db; // Don't observe dynamically opened databases.
return db.table("_changes").orderBy("rev").last(function(lastChange) {
// Since startObserving() is called before database open() method, this will be the first database operation enqueued to db.
// Therefore we know that the retrieved value will be This query will
var latestRevision = (lastChange ? lastChange.rev : 0);
mySyncNode.node = new SyncNode({
myRevision: latestRevision,
type: "local",
lastHeartBeat: Date.now(),
deleteTimeStamp: null,
isMaster: 0
});
if (Observable.latestRevision[db.name] < latestRevision) {
// Side track . For correctness whenever setting Observable.latestRevision[db.name] we must make sure the event is fired if increased:
// There are other db instances in same window that hasnt yet been informed about a new revision
Observable.latestRevision[db.name] = latestRevision;
Dexie.ignoreTransaction(function() {
Observable.on.latestRevisionIncremented.fire(latestRevision);
});
}
// Add new sync node or if this is a reopening of the database after a close() call, update it.
return db._syncNodes.put(mySyncNode.node).then(Dexie.ignoreTransaction(() => {
// By default, this node will become master unless we discover an existing, up-to-date master
var mySyncNodeShouldBecomeMaster = 1;
return db._syncNodes.orderBy('isMaster').reverse().modify(existingNode => {
if (existingNode.isMaster) {
if (existingNode.lastHeartBeat < Date.now() - NODE_TIMEOUT) {
// Existing master record is out-of-date; demote it
existingNode.isMaster = 0;
} else {
// An existing up-to-date master record exists, so it will remain master
mySyncNodeShouldBecomeMaster = 0;
}
}
// The local node reference may be unassigned at any point by a database close() operation
if (!mySyncNode.node) return;
// Assign the local node state
// This is guaranteed to apply *after* any existing master records have been inspected, due to the orderBy clause
if (existingNode.id === mySyncNode.node.id) {
existingNode.isMaster = mySyncNode.node.isMaster = mySyncNodeShouldBecomeMaster;
}
});
})).then(() => {
Observable.on('latestRevisionIncremented', onLatestRevisionIncremented); // Wakeup when a new revision is available.
Observable.on('beforeunload', onBeforeUnload);
Observable.on('suicideNurseCall', onSuicide);
Observable.on('intercomm', onIntercomm);
// Start polling for changes and do cleanups:
pollHandle = setTimeout(poll, LOCAL_POLL);
// Start heartbeat
heartbeatHandle = setTimeout(heartbeat, HEARTBEAT_INTERVAL);
}).then(function () {
cleanup();
});
});
}, true); // True means the on(ready) event will survive a db reopening (db.close() / db.open()).
var handledRevision = 0;
function onLatestRevisionIncremented(dbname, latestRevision) {
if (dbname === db.name) {
if (handledRevision >= latestRevision) return; // Make sure to only run once per revision. (Workaround for IE triggering storage event on same window)
handledRevision = latestRevision;
Dexie.vip(function() {
readChanges(latestRevision).catch('DatabaseClosedError', ()=>{
// Handle database closed error gracefully while reading changes.
// Don't trigger 'unhandledrejection'.
// Even though we intercept the close() method, it might be called when in the middle of
// reading changes and then that flow will cancel with DatabaseClosedError.
});
});
}
}
function readChanges(latestRevision, recursion, wasPartial) {
// Whenever changes are read, fire db.on("changes") with the array of changes. Eventually, limit the array to 1000 entries or so (an entire database is
// downloaded from server AFTER we are initiated. For example, if first sync call fails, then after a while we get reconnected. However, that scenario
// should be handled in case database is totally empty we should fail if sync is not available)
if (!recursion && readChanges.ongoingOperation) {
// We are already reading changes. Prohibit a parallell execution of this which would lead to duplicate trigging of 'changes' event.
// Instead, the callback in toArray() will always check Observable.latestRevision[db.name] to see if it has changed and if so, re-launch readChanges().
// The caller should get the Promise instance from the ongoing operation so that the then() method will resolve when operation is finished.
return readChanges.ongoingOperation;
}
var partial = false;
var ourSyncNode = mySyncNode.node; // Because mySyncNode can suddenly be set to null on database close, and worse, can be set to a new value if database is reopened.
if (!ourSyncNode) {
return Promise.reject(new Dexie.DatabaseClosedError());
}
var LIMIT = 1000;
var promise = db._changes.where("rev").above(ourSyncNode.myRevision).limit(LIMIT).toArray(function (changes) {
if (changes.length > 0) {
var lastChange = changes[changes.length - 1];
partial = (changes.length === LIMIT);
db.on('changes').fire(changes, partial);
ourSyncNode.myRevision = lastChange.rev;
} else if (wasPartial) {
// No more changes, BUT since we have triggered on('changes') with partial = true,
// we HAVE TO trigger changes again with empty list and partial = false
db.on('changes').fire([], false);
}
let ourNodeStillExists = false;
return db._syncNodes.where(':id').equals(ourSyncNode.id).modify(syncNode => {
ourNodeStillExists = true;
syncNode.lastHeartBeat = Date.now(); // Update heart beat (not nescessary, but why not!)
syncNode.deleteTimeStamp = null; // Reset "deleteTimeStamp" flag if it was there.
syncNode.myRevision = Math.max(syncNode.myRevision, ourSyncNode.myRevision);
}).then(()=>ourNodeStillExists);
}).then(ourNodeStillExists =>{
if (!ourNodeStillExists) {
// My node has been deleted. We must have been lazy and got removed by another node.
if (browserIsShuttingDown) {
throw new Error("Browser is shutting down");
} else {
db.close();
console.error("Out of sync"); // TODO: What to do? Reload the page?
if (global.location) global.location.reload(true);
throw new Error("Out of sync"); // Will make current promise reject
}
}
// Check if more changes have come since we started reading changes in the first place. If so, relaunch readChanges and let the ongoing promise not
// resolve until all changes have been read.
if (partial || Observable.latestRevision[db.name] > ourSyncNode.myRevision) {
// Either there were more than 1000 changes or additional changes where added while we were reading these changes,
// In either case, call readChanges() again until we're done.
return readChanges(Observable.latestRevision[db.name], (recursion || 0) + 1, partial);
}
}).finally(function() {
delete readChanges.ongoingOperation;
});
if (!recursion) {
readChanges.ongoingOperation = promise;
}
return promise;
}
/**
* The reason we need heartbeat in parallell with poll() is due to the risk of long-running
* transactions while syncing changes from server to client in Dexie.Syncable. That transaction will
* include _changes (which will block readChanges()) but not _syncNodes. So this heartbeat will go on
* during that changes are being applied and update our lastHeartBeat property while poll() is waiting.
* When cleanup() (who also is blocked by the sync) wakes up, it won't kill the master node because this
* heartbeat job will have updated the master node's heartbeat during the long-running sync transaction.
*
* If we did not have this heartbeat, and a server send lots of changes that took more than NODE_TIMEOUT
* (20 seconds), another node waking up after the sync would kill the master node and take over because
* it would believe it was dead.
*/
function heartbeat() {
heartbeatHandle = null;
var currentInstance = mySyncNode.node && mySyncNode.node.id;
if (!currentInstance) return;
db.transaction('rw!', db._syncNodes, ()=>{
db._syncNodes.where({id: currentInstance}).first(ourSyncNode => {
if (!ourSyncNode) {
// We do not exist anymore. Call db.close() to teardown polls etc.
if (db.isOpen()) db.close();
return;
}
ourSyncNode.lastHeartBeat = Date.now();
ourSyncNode.deleteTimeStamp = null; // Reset "deleteTimeStamp" flag if it was there.
return db._syncNodes.put(ourSyncNode);
});
}).catch('DatabaseClosedError', () => {
// Ignore silently
}).finally(() => {
if (mySyncNode.node && mySyncNode.node.id === currentInstance && db.isOpen()) {
heartbeatHandle = setTimeout(heartbeat, HEARTBEAT_INTERVAL);
}
});
}
function poll() {
pollHandle = null;
var currentInstance = mySyncNode.node && mySyncNode.node.id;
if (!currentInstance) return;
Dexie.vip(function() { // VIP ourselves. Otherwise we might not be able to consume intercomm messages from master node before database has finished opening. This would make DB stall forever. Cannot rely on storage-event since it may not always work in some browsers of different processes.
readChanges(Observable.latestRevision[db.name]).then(cleanup).then(consumeIntercommMessages)
.catch('DatabaseClosedError', ()=>{
// Handle database closed error gracefully while reading changes.
// Don't trigger 'unhandledrejection'.
// Even though we intercept the close() method, it might be called when in the middle of
// reading changes and then that flow will cancel with DatabaseClosedError.
})
.finally(function() {
// Poll again in given interval:
if (mySyncNode.node && mySyncNode.node.id === currentInstance && db.isOpen()) {
pollHandle = setTimeout(poll, LOCAL_POLL);
}
});
});
}
function cleanup() {
var ourSyncNode = mySyncNode.node;
if (!ourSyncNode) return Promise.reject(new Dexie.DatabaseClosedError());
return db.transaction('rw', '_syncNodes', '_changes', '_intercomm', function() {
// Cleanup dead local nodes that has no heartbeat for over a minute
// Dont do the following:
//nodes.where("lastHeartBeat").below(Date.now() - NODE_TIMEOUT).and(function (node) { return node.type == "local"; }).delete();
// Because client may have been in hybernate mode and recently woken up. That would lead to deletion of all nodes.
// Instead, we should mark any old nodes for deletion in a minute or so. If they still dont wakeup after that minute we could consider them dead.
var weBecameMaster = false;
db._syncNodes.where("lastHeartBeat").below(Date.now() - NODE_TIMEOUT).filter(node => node.type === 'local').modify(function(node) {
if (node.deleteTimeStamp && node.deleteTimeStamp < Date.now()) {
// Delete the node.
delete this.value;
// Cleanup localStorage "deadnode:" entry for this node (localStorage API was used to wakeup other windows (onstorage event) - an event type missing in indexedDB.)
if (localStorage) {
localStorage.removeItem('Dexie.Observable/deadnode:' + node.id + '/' + db.name);
}
// Check if we are deleting a master node
if (node.isMaster) {
// The node we are deleting is master. We must take over that role.
// OK to call nodes.update(). No need to call Dexie.vip() because nodes is opened in existing transaction!
db._syncNodes.update(ourSyncNode, { isMaster: 1 });
weBecameMaster = true;
}
// Cleanup intercomm messages destinated to the node being deleted.
// Those that waits for reply should be redirected to us.
db._intercomm.where({destinationNode: node.id}).modify(function(msg) {
if (msg.wantReply)
msg.destinationNode = ourSyncNode.id;
else
// Delete the message from DB and if someone is waiting for reply, let ourselved answer the request.
delete this.value;
});
} else if (!node.deleteTimeStamp) {
// Mark the node for deletion
node.deleteTimeStamp = Date.now() + HIBERNATE_GRACE_PERIOD;
}
}).then(function() {
// Cleanup old revisions that no node is interested of.
Observable.deleteOldChanges(db);
return db.on("cleanup").fire(weBecameMaster);
});
});
}
function onBeforeUnload() {
// Mark our own sync node for deletion.
if (!mySyncNode.node) return;
browserIsShuttingDown = true;
mySyncNode.node.deleteTimeStamp = 1; // One millisecond after 1970. Makes it occur in the past but still keeps it truthy.
mySyncNode.node.lastHeartBeat = 0;
db._syncNodes.put(mySyncNode.node); // This async operation may be cancelled since the browser is closing down now.
Observable.wereTheOneDying = true; // If other nodes in same window wakes up by this call, make sure they dont start taking over mastership and stuff...
// Inform other windows that we're gone, so that they may take over our role if needed. Setting localStorage item below will trigger Observable.onStorage, which will trigger onSuicie() below:
if (localStorage) {
localStorage.setItem('Dexie.Observable/deadnode:' + mySyncNode.node.id.toString() + '/' + db.name, "dead"); // In IE, this will also wakeup our own window. However, that is doublechecked in nursecall subscriber below.
}
}
function onSuicide(dbname, nodeID) {
if (dbname === db.name && !Observable.wereTheOneDying) {
// Make sure it's dead indeed. Second bullet. Why? Because it has marked itself for deletion in the onbeforeunload event, which is fired just before window dies.
// It's own call to put() may have been cancelled.
// Note also that in IE, this event may be called twice, but that doesnt harm!
Dexie.vip(function() {
db._syncNodes.update(nodeID, { deleteTimeStamp: 1, lastHeartBeat: 0 }).then(cleanup);
});
}
}
}
//
// Static properties and methods
//
Observable.version = "{version}";
Observable.latestRevision = {}; // Latest revision PER DATABASE. Example: Observable.latestRevision.FriendsDB = 37;
Observable.on = Dexie.Events(null, "latestRevisionIncremented", "suicideNurseCall", "intercomm", "beforeunload"); // fire(dbname, value);
Observable.createUUID = createUUID;
Observable.deleteOldChanges = deleteOldChanges;
Observable._onStorage = initOnStorage(Observable);
Observable._onBeforeUnload = function() {
Observable.on.beforeunload.fire();
};
try {
Observable.localStorageImpl = global.localStorage;
} catch (ex){}
//
// Map window events to static events in Dexie.Observable:
//
if (global?.addEventListener) {
global.addEventListener("storage", Observable._onStorage);
global.addEventListener("beforeunload", Observable._onBeforeUnload);
}
if (Dexie.Observable) {
if (Dexie.Observable.version !== "{version}") {
throw new Error (`Mixed versions of dexie-observable`);
}
} else {
// Register addon:
Dexie.Observable = Observable;
Dexie.addons.push(Observable);
}
export default Dexie.Observable;
================================================
FILE: addons/Dexie.Observable/src/change_types.js
================================================
// Change Types
export const CREATE = 1;
export const UPDATE = 2;
export const DELETE = 3;
================================================
FILE: addons/Dexie.Observable/src/delete-old-changes.js
================================================
import Dexie from 'dexie';
export default function deleteOldChanges(db) {
// This is a background job and should never be done within
// a caller's transaction. Use Dexie.ignoreTransaction() to ensure that.
// We should not return the Promise but catch it ourselves instead.
// To prohibit starving the database we want to lock transactions as short as possible
// and since we're not in a hurry, we could do this job in chunks and reschedule a
// continuation every 500 ms.
const CHUNK_SIZE = 100;
Dexie.ignoreTransaction(()=>{
return db._syncNodes.orderBy("myRevision").first(oldestNode => {
return db._changes
.where("rev").below(oldestNode.myRevision)
.limit(CHUNK_SIZE)
.primaryKeys();
}).then(keysToDelete => {
if (keysToDelete.length === 0) return; // Done.
return db._changes.bulkDelete(keysToDelete).then(()=> {
// If not done garbage collecting, reschedule a continuation of it until done.
if (keysToDelete.length === CHUNK_SIZE) {
// Limit reached. Changes are there are more job to do. Schedule again:
setTimeout(() => db.isOpen() && deleteOldChanges(db), 500);
}
});
});
}).catch(()=>{
// The operation is not crucial. A failure could almost only be due to that database has been closed.
// No need to log this.
});
}
================================================
FILE: addons/Dexie.Observable/src/hooks/creating.js
================================================
import Dexie from 'dexie';
import {CREATE} from '../change_types';
export default function initCreatingHook(db, table) {
return function creatingHook(primKey, obj, trans) {
///
var rv = undefined;
if (primKey === undefined && table.schema.primKey.uuid) {
primKey = rv = Dexie.Observable.createUUID();
if (table.schema.primKey.keyPath) {
Dexie.setByKeyPath(obj, table.schema.primKey.keyPath, primKey);
}
}
var change = {
source: trans.source || null, // If a "source" is marked on the transaction, store it. Useful for observers that want to ignore their own changes.
table: table.name,
key: primKey === undefined ? null : primKey,
type: CREATE,
obj: obj
};
var promise = db._changes.add(change).then(function (rev) {
trans._lastWrittenRevision = Math.max(trans._lastWrittenRevision, rev);
return rev;
});
// Wait for onsuccess so that we have the primKey if it is auto-incremented and update the change item if so.
this.onsuccess = function (resultKey) {
if (primKey != resultKey)
promise._then(function () {
change.key = resultKey;
db._changes.put(change);
});
};
this.onerror = function () {
// If the main operation fails, make sure to regret the change
promise._then(function (rev) {
// Will only happen if app code catches the main operation error to prohibit transaction from aborting.
db._changes.delete(rev);
});
};
return rv;
};
}
================================================
FILE: addons/Dexie.Observable/src/hooks/crud-monitor.js
================================================
import initCreatingHook from './creating';
import initUpdatingHook from './updating';
import initDeletingHook from './deleting';
export default function initCrudMonitor(db) {
//
// The Creating/Updating/Deleting hook will make sure any change is stored to the changes table
//
return function crudMonitor(table) {
///
if (table.hook._observing) return;
table.hook._observing = true;
const tableName = table.name;
table.hook('creating').subscribe(initCreatingHook(db, table));
table.hook('updating').subscribe(initUpdatingHook(db, tableName));
table.hook('deleting').subscribe(initDeletingHook(db, tableName));
};
}
================================================
FILE: addons/Dexie.Observable/src/hooks/deleting.js
================================================
import {DELETE} from '../change_types';
export default function initDeletingHook(db, tableName) {
return function deletingHook(primKey, obj, trans) {
///
var promise = db._changes.add({
source: trans.source || null, // If a "source" is marked on the transaction, store it. Useful for observers that want to ignore their own changes.
table: tableName,
key: primKey,
type: DELETE,
oldObj: obj
}).then(function (rev) {
trans._lastWrittenRevision = Math.max(trans._lastWrittenRevision, rev);
return rev;
})
.catch((e) => {
console.log(obj)
console.log(e.stack)
})
this.onerror = function () {
// If the main operation fails, make sure to regret the change.
// Using _then because if promise is already fullfilled, the standard then() would
// do setTimeout() and we would loose the transaction.
promise._then(function (rev) {
// Will only happen if app code catches the main operation error to prohibit transaction from aborting.
db._changes.delete(rev);
});
};
};
}
================================================
FILE: addons/Dexie.Observable/src/hooks/updating.js
================================================
import Dexie from 'dexie';
import {UPDATE} from '../change_types';
export default function initUpdatingHook(db, tableName) {
return function updatingHook(mods, primKey, oldObj, trans) {
///
// mods may contain property paths with undefined as value if the property
// is being deleted. Since we cannot persist undefined we need to act
// like those changes is setting the value to null instead.
var modsWithoutUndefined = {};
// As of current Dexie version (1.0.3) hook may be called even if it wouldn't really change.
// Therefore we may do that kind of optimization here - to not add change entries if
// there's nothing to change.
var anythingChanged = false;
var newObj = Dexie.deepClone(oldObj);
for (var propPath in mods) {
var mod = mods[propPath];
if (typeof mod === 'undefined') {
Dexie.delByKeyPath(newObj, propPath);
modsWithoutUndefined[propPath] = null; // Null is as close we could come to deleting a property when not allowing undefined.
anythingChanged = true;
} else {
var currentValue = Dexie.getByKeyPath(oldObj, propPath);
if (mod !== currentValue && JSON.stringify(mod) !== JSON.stringify(currentValue)) {
Dexie.setByKeyPath(newObj, propPath, mod);
modsWithoutUndefined[propPath] = mod;
anythingChanged = true;
}
}
}
if (anythingChanged) {
var change = {
source: trans.source || null, // If a "source" is marked on the transaction, store it. Useful for observers that want to ignore their own changes.
table: tableName,
key: primKey,
type: UPDATE,
mods: modsWithoutUndefined,
oldObj: oldObj,
obj: newObj
};
var promise = db._changes.add(change); // Just so we get the correct revision order of the update...
this.onsuccess = function () {
promise._then(function (rev) {
trans._lastWrittenRevision = Math.max(trans._lastWrittenRevision, rev);
});
};
this.onerror = function () {
// If the main operation fails, make sure to regret the change.
promise._then(function (rev) {
// Will only happen if app code catches the main operation error to prohibit transaction from aborting.
db._changes.delete(rev);
});
};
}
};
}
================================================
FILE: addons/Dexie.Observable/src/intercomm.js
================================================
import Dexie from 'dexie';
const Promise = Dexie.Promise;
export default function initIntercomm(db, Observable, SyncNode, mySyncNode, localStorage) {
//
// Intercommunication between nodes
//
// Enable inter-process communication between browser windows using localStorage storage event (is registered in Dexie.Observable)
var requestsWaitingForReply = {};
/**
* @param {string} type Type of message
* @param message Message to send
* @param {number} destinationNode ID of destination node
* @param {{wantReply: boolean, isFailure: boolean, requestId: number}} options If {wantReply: true}, the returned promise will complete with the reply from remote. Otherwise it will complete when message has been successfully sent.
*/
db.observable.sendMessage = function (type, message, destinationNode, options) {
/// Type of message
/// Message to send
/// ID of destination node
/// {wantReply: Boolean, isFailure: Boolean, requestId: Number}. If wantReply, the returned promise will complete with the reply from remote. Otherwise it will complete when message has been successfully sent.
options = options || {};
if (!mySyncNode.node)
return options.wantReply ?
Promise.reject(new Dexie.DatabaseClosedError()) :
Promise.resolve(); // If caller doesn't want a reply, it won't catch errors either.
var msg = {message: message, destinationNode: destinationNode, sender: mySyncNode.node.id, type: type};
Dexie.extend(msg, options); // wantReply: wantReply, success: !isFailure, requestId: ...
return Dexie.ignoreTransaction(()=> {
var tables = ["_intercomm"];
if (options.wantReply) tables.push("_syncNodes"); // If caller wants a reply, include "_syncNodes" in transaction to check that there's a receiver there. Otherwise, new master will get it.
var promise = db.transaction('rw', tables, () => {
if (options.wantReply) {
// Check that there is a receiver there to take the request.
return db._syncNodes.where('id').equals(destinationNode).count(receiverAlive => {
if (receiverAlive)
return db._intercomm.add(msg);
else // If we couldn't find a node -> send to master
return db._syncNodes.where('isMaster').above(0).first(function (masterNode) {
msg.destinationNode = masterNode.id;
return db._intercomm.add(msg)
});
});
} else {
// If caller doesn't need a response, we don't have to make sure that it gets one.
return db._intercomm.add(msg);
}
}).then(messageId => {
var rv = null;
if (options.wantReply) {
rv = new Promise(function (resolve, reject) {
requestsWaitingForReply[messageId.toString()] = {resolve: resolve, reject: reject};
});
}
if (localStorage) {
localStorage.setItem("Dexie.Observable/intercomm/" + db.name, messageId.toString());
}
Observable.on.intercomm.fire(db.name);
return rv;
});
if (!options.wantReply) {
promise.catch(()=> {
});
return;
} else {
// Forward rejection to caller if it waits for reply.
return promise;
}
});
};
// Send a message to all local _syncNodes
db.observable.broadcastMessage = function (type, message, bIncludeSelf) {
if (!mySyncNode.node) return;
var mySyncNodeId = mySyncNode.node.id;
Dexie.ignoreTransaction(()=> {
db._syncNodes.toArray(nodes => {
return Promise.all(nodes
.filter(node => node.type === 'local' && (bIncludeSelf || node.id !== mySyncNodeId))
.map(node => db.observable.sendMessage(type, message, node.id)));
}).catch(()=> {
});
});
};
function consumeIntercommMessages() {
// Check if we got messages:
if (!mySyncNode.node) return Promise.reject(new Dexie.DatabaseClosedError());
return Dexie.ignoreTransaction(()=> {
return db.transaction('rw', '_intercomm', function() {
return db._intercomm.where({destinationNode: mySyncNode.node.id}).toArray(messages => {
messages.forEach(msg => consumeMessage(msg));
return db._intercomm.where('id').anyOf(messages.map(msg => msg.id)).delete();
});
});
});
}
function consumeMessage(msg) {
if (msg.type === 'response') {
// This is a response. Lookup pending request and fulfill its promise.
var request = requestsWaitingForReply[msg.requestId.toString()];
if (request) {
if (msg.isFailure) {
request.reject(msg.message.error);
} else {
request.resolve(msg.message.result);
}
delete requestsWaitingForReply[msg.requestId.toString()];
}
} else {
// This is a message or request. Fire the event and add an API for the subscriber to use if reply is requested
msg.resolve = function (result) {
db.observable.sendMessage('response', {result: result}, msg.sender, {requestId: msg.id});
};
msg.reject = function (error) {
db.observable.sendMessage('response', {error: error.toString()}, msg.sender, {isFailure: true, requestId: msg.id});
};
db.on.message.fire(msg);
}
}
// Listener for 'intercomm' events
// Gets fired when we get a 'storage' event from local storage or when sendMessage is called
// 'storage' is used to communicate between tabs (sendMessage changes the localStorage to trigger the event)
// sendMessage is used to communicate in the same tab and to trigger a storage event
function onIntercomm(dbname) {
// When storage event trigger us to check
if (dbname === db.name) {
consumeIntercommMessages().catch('DatabaseClosedError', ()=> {});
}
}
return {
onIntercomm,
consumeIntercommMessages
};
}
================================================
FILE: addons/Dexie.Observable/src/on-storage.js
================================================
import Dexie from 'dexie';
export default function initOnStorage(Observable) {
return function onStorage(event) {
// We use the onstorage event to trigger onLatestRevisionIncremented since we will wake up when other windows modify the DB as well!
if (event.key && event.key.indexOf("Dexie.Observable/") === 0) { // For example "Dexie.Observable/latestRevision/FriendsDB"
var parts = event.key.split('/');
var prop = parts[1];
var dbname = parts[2];
if (prop === 'latestRevision') {
var rev = parseInt(event.newValue, 10);
if (!isNaN(rev) && rev > Observable.latestRevision[dbname]) {
Observable.latestRevision[dbname] = rev;
Dexie.ignoreTransaction(function () {
Observable.on('latestRevisionIncremented').fire(dbname, rev);
});
}
} else if (prop.indexOf("deadnode:") === 0) {
var nodeID = parseInt(prop.split(':')[1], 10);
if (event.newValue) {
Observable.on.suicideNurseCall.fire(dbname, nodeID);
}
} else if (prop === 'intercomm') {
if (event.newValue) {
Observable.on.intercomm.fire(dbname);
}
}
}
};
}
================================================
FILE: addons/Dexie.Observable/src/override-create-transaction.js
================================================
export default function initOverrideCreateTransaction(db, wakeupObservers) {
return function overrideCreateTransaction(origFunc) {
return function (mode, storenames, dbschema, parent) {
if (db.dynamicallyOpened()) return origFunc.apply(this, arguments); // Don't observe dynamically opened databases.
var addChanges = false;
if (mode === 'readwrite' && storenames.some(function (storeName) {
return dbschema[storeName] && dbschema[storeName].observable;
})) {
// At least one included store is a observable store. Make sure to also include the _changes store.
addChanges = true;
storenames = storenames.slice(0); // Clone
if (storenames.indexOf("_changes") === -1)
storenames.push("_changes"); // Otherwise, firefox will hang... (I've reported the bug to Mozilla@Bugzilla)
}
// Call original db._createTransaction()
var trans = origFunc.call(this, mode, storenames, dbschema, parent);
// If this transaction is bound to any observable table, make sure to add changes when transaction completes.
if (addChanges) {
trans._lastWrittenRevision = 0;
trans.on('complete', function () {
if (trans._lastWrittenRevision) {
// Changes were written in this transaction.
if (!parent) {
// This is root-level transaction, i.e. a physical commit has happened.
// Delay-trigger a wakeup call:
if (wakeupObservers.timeoutHandle) clearTimeout(wakeupObservers.timeoutHandle);
wakeupObservers.timeoutHandle = setTimeout(function () {
delete wakeupObservers.timeoutHandle;
wakeupObservers(trans._lastWrittenRevision);
}, 25);
} else {
// This is just a virtual commit of a sub transaction.
// Wait with waking up observers until root transaction has committed.
// Make sure to mark root transaction so that it will wakeup observers upon commit.
var rootTransaction = (function findRootTransaction(trans) {
return trans.parent ? findRootTransaction(trans.parent) : trans;
})(parent);
rootTransaction._lastWrittenRevision = Math.max(
trans._lastWrittenRevision,
rootTransaction.lastWrittenRevision || 0);
}
}
});
// Derive "source" property from parent transaction by default
if (trans.parent && trans.parent.source) trans.source = trans.parent.source;
}
return trans;
};
};
}
================================================
FILE: addons/Dexie.Observable/src/override-open.js
================================================
export default function initOverrideOpen(db, SyncNode, crudMonitor) {
return function overrideOpen(origOpen) {
return function () {
//
// Make sure to subscribe to "creating", "updating" and "deleting" hooks for all observable tables that were created in the stores() method.
//
Object.keys(db._allTables).forEach(tableName => {
let table = db._allTables[tableName];
if (table.schema.observable) {
crudMonitor(table);
}
if (table.name === "_syncNodes") {
table.mapToClass(SyncNode);
}
});
return origOpen.apply(this, arguments);
}
};
}
================================================
FILE: addons/Dexie.Observable/src/override-parse-stores-spec.js
================================================
export default function overrideParseStoresSpec(origFunc) {
return function(stores, dbSchema) {
// Create the _changes and _syncNodes tables
stores["_changes"] = "++rev";
stores["_syncNodes"] = "++id,myRevision,lastHeartBeat,&url,isMaster,type,status";
stores["_intercomm"] = "++id,destinationNode";
stores["_uncommittedChanges"] = "++id,node"; // For remote syncing when server returns a partial result.
// Call default implementation. Will populate the dbSchema structures.
origFunc.call(this, stores, dbSchema);
// Allow UUID primary keys using $$ prefix on primary key or indexes
Object.keys(dbSchema).forEach(function(tableName) {
var schema = dbSchema[tableName];
if (schema.primKey.name.indexOf('$$') === 0) {
schema.primKey.uuid = true;
schema.primKey.name = schema.primKey.name.substr(2);
schema.primKey.keyPath = schema.primKey.keyPath.substr(2);
}
});
// Now mark all observable tables
Object.keys(dbSchema).forEach(function(tableName) {
// Marked observable tables with "observable" in their TableSchema.
if (tableName.indexOf('_') !== 0 && tableName.indexOf('$') !== 0) {
dbSchema[tableName].observable = true;
}
});
};
}
================================================
FILE: addons/Dexie.Observable/src/utils.js
================================================
export function nop() {}
export function promisableChain(f1, f2) {
if (f1 === nop) return f2;
return function() {
var res = f1.apply(this, arguments);
if (res && typeof res.then === 'function') {
var thiz = this, args = arguments;
return res.then(function() {
return f2.apply(thiz, args);
});
}
return f2.apply(this, arguments);
};
}
export function createUUID() {
// Decent solution from http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript
var d = Date.now();
var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x7 | 0x8)).toString(16);
});
return uuid;
}
================================================
FILE: addons/Dexie.Observable/src/wakeup-observers.js
================================================
import Dexie from 'dexie';
export default function initWakeupObservers(db, Observable, localStorage) {
return function wakeupObservers(lastWrittenRevision) {
// Make sure Observable.latestRevision[db.name] is still below our value, now when some time has elapsed and other db instances in same window possibly could have made changes too.
if (Observable.latestRevision[db.name] < lastWrittenRevision) {
// Set the static property lastRevision[db.name] to the revision of the last written change.
Observable.latestRevision[db.name] = lastWrittenRevision;
// Wakeup ourselves, and any other db instances on this window:
Dexie.ignoreTransaction(function () {
Observable.on('latestRevisionIncremented').fire(db.name, lastWrittenRevision);
});
// Observable.on.latestRevisionIncremented will only wakeup db's in current window.
// We need a storage event to wakeup other windwos.
// Since indexedDB lacks storage events, let's use the storage event from WebStorage just for
// the purpose to wakeup db instances in other windows.
if (localStorage) localStorage.setItem('Dexie.Observable/latestRevision/' + db.name, lastWrittenRevision); // In IE, this will also wakeup our own window. However, onLatestRevisionIncremented will work around this by only running once per revision id.
}
};
}
================================================
FILE: addons/Dexie.Observable/test/gh-actions.sh
================================================
#!/bin/bash -e
echo "Installing dependencies for dexie-observable"
pnpm install >/dev/null
pnpm run build
pnpm run test:typings
pnpm run test:ltcloud
pnpm run test:ltcloud:integration
================================================
FILE: addons/Dexie.Observable/test/integration/karma-env.js
================================================
// workerImports will be used by tests-open.js in the dexie test suite when
// launching a Worker. This line will instruct the worker to import dexie-observable.
window.workerImports.push("../addons/Dexie.Observable/dist/dexie-observable.js");
================================================
FILE: addons/Dexie.Observable/test/integration/karma.conf.js
================================================
// Include common configuration
const {karmaCommon, getKarmaConfig, defaultBrowserMatrix} = require('../../../../test/karma.common');
module.exports = function (config) {
const browserMatrixOverrides = {
// Be fine with testing on local travis firefox + browserstack chrome, latest supported.
ci: ["remote_chrome"],
// Safari fails to reply on browserstack. Need to not have it here.
// Just complement with old chrome browser that is not part of CI test suite.
pre_npm_publish: [
"Chrome",
]
};
const cfg = getKarmaConfig(browserMatrixOverrides, {
// Base path should point at the root
basePath: '../../../../',
// The files needed to apply dexie-observable to the standard dexie unit tests.
files: karmaCommon.files.concat([
'dist/dexie.js',
'addons/Dexie.Observable/test/integration/karma-env.js',
'addons/Dexie.Observable/dist/dexie-observable.js', // Apply observable addon
'test/bundle.js', // The dexie standard test suite
{ pattern: 'addons/Dexie.Observable/dist/*.map', watched: false, included: false }
])
});
config.set(cfg);
}
================================================
FILE: addons/Dexie.Observable/test/integration/test-observable-dexie-tests.html
================================================
Dexie Unit tests with Dexie.Observable applied
================================================
FILE: addons/Dexie.Observable/test/typings/test-typings.ts
================================================
import Dexie from 'dexie';
import '../../src/Dexie.Observable';
import dexieObservable from '../../src/Dexie.Observable';
import { IDatabaseChange, DatabaseChangeType } from '../../api';
interface Foo {
id: string;
}
class MyDb extends Dexie {
foos: Dexie.Table;
constructor() {
super('testdb', {addons: [dexieObservable, Dexie.Observable]});
this.version(1).stores({foos: '$$id'});
}
}
let db = new MyDb();
let syncNode = new db.observable.SyncNode();
syncNode.isMaster;
syncNode.deleteTimeStamp.toExponential();
syncNode.lastHeartBeat.toExponential();
syncNode.myRevision.toFixed();
db.on('message', msg => {
msg.type;
msg.message;
msg.destinationNode * 1;
msg.wantReply;
msg.resolve('foo');
msg.reject(new Error('Foo'));
});
db.observable.sendMessage('myMsgType', {foo: 'bar'}, 13, {wantReply: true});
db.observable.sendMessage('myMsgType', 'foobar', 13, {wantReply: false});
db.observable.broadcastMessage('myBroadcastMsgType', {foo2: 'bar2'}, false);
db.on('changes', changes => {
changes.forEach(change => {
switch (change.type) {
case DatabaseChangeType.Create:
change.table.toLowerCase();
change.key;
change.obj;
break;
case DatabaseChangeType.Update:
change.table.toLowerCase();
change.key;
change.mods;
break;
case DatabaseChangeType.Delete:
change.table.toLowerCase();
change.key;
break;
}
})
});
Dexie.Observable.createUUID().toLowerCase();
Dexie.Observable.on('latestRevisionIncremented', (dbName, rev) => {dbName.toLowerCase(); rev.toFixed()});
Dexie.Observable.on('latestRevisionIncremented').subscribe(()=>{});
Dexie.Observable.on('latestRevisionIncremented').fire(()=>{});
Dexie.Observable.on('latestRevisionIncremented').unsubscribe(()=>{});
Dexie.Observable.on('suicideNurseCall', (dbName, nodeId)=>{dbName.toLowerCase(); nodeId.toExponential()});
Dexie.Observable.on('intercomm', dbName=>{dbName.toLowerCase()});
Dexie.Observable.on('beforeunload', ()=>{});
Dexie.Observable.on('latestRevisionIncremented').unsubscribe(()=>{});
var x: IDatabaseChange = {key: 1, table: "", type: DatabaseChangeType.Delete, oldObj: {}};
x.key;
x.type;
x.oldObj;
================================================
FILE: addons/Dexie.Observable/test/typings/tsconfig.json
================================================
{
"compilerOptions": {
"module": "es6",
"target": "es5",
"noImplicitAny": true,
"strictNullChecks": true,
"outDir": "../../tools/tmp/test-typings",
"moduleResolution": "node",
"lib": ["dom", "es2020"]
},
"files": [
"test-typings.ts"
]
}
================================================
FILE: addons/Dexie.Observable/test/unit/.eslintrc.json
================================================
{
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
}
},
"env": {
"browser": true,
"node": true
},
"rules": {
"no-undef": ["error"]
},
"globals": {
"Promise": true
}
}
================================================
FILE: addons/Dexie.Observable/test/unit/.gitignore
================================================
/bundle.js
/bundle.js.map
================================================
FILE: addons/Dexie.Observable/test/unit/deep-equal.js
================================================
import {equal} from 'QUnit';
// Must use this rather than QUnit's deepEqual() because that one fails on Safari when run via karma-browserstack-launcher
export function deepEqual(a, b, description) {
if (typeof a === 'object' && typeof b === 'object' && a != null && b != null) {
equal(JSON.stringify(sortMembers(a), null, 2), JSON.stringify(sortMembers(b), null, 2), description);
} else {
equal(JSON.stringify(a, null, 2), JSON.stringify(b, null, 2), description);
}
}
/**
*
* @param {object} obj
*/
function sortMembers (obj) {
return Object.keys(obj).sort().reduce((result, key) => {
result[key] = obj[key];
return result;
}, {});
}
================================================
FILE: addons/Dexie.Observable/test/unit/hooks/tests-creating.js
================================================
import Dexie from 'dexie';
import {module, asyncTest, start, stop, strictEqual, ok} from 'QUnit';
import {deepEqual} from '../deep-equal';
import {resetDatabase} from '../../../../../test/dexie-unittest-utils';
import initCreatingHook from '../../../src/hooks/creating';
import initWakeupObservers from '../../../src/wakeup-observers';
import initOverrideCreateTransaction from '../../../src/override-create-transaction';
import {CREATE} from '../../../src/change_types';
const db = new Dexie('TestDBTable', {addons: []});
db.version(1).stores({
foo: "id",
bar: "$$id",
baz: "++id",
_changes: "++rev"
});
const wakeupObservers = initWakeupObservers(db, {latestRevision: {}}, self.localStorage);
const overrideCreateTransaction = initOverrideCreateTransaction(db, wakeupObservers);
db._createTransaction = Dexie.override(db._createTransaction, overrideCreateTransaction);
module('creating Hook', {
setup: () => {
stop();
// Do a full DB reset to clean _changes table
db._hasBeenCreated = false;
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should create a UUID key if $$ was given', () => {
db.bar.schema.primKey.uuid = true;
db.bar.schema.observable = true;
const creatingHook = initCreatingHook(db, db.bar);
db.bar.hook('creating', function(primKey, obj, trans) {
const ctx = {onsuccess: null, onerror: null};
const res = creatingHook.call(ctx, primKey, obj, trans);
// Note that this regex is not spec compliant but should be good enough for this test
const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/;
ok(regex.test(res), 'We got a UUID');
this.onsuccess = function(resKey) {
try {
strictEqual(res, resKey, 'Key from success and hook should match');
db._changes.toArray((changes) => {
strictEqual(changes.length, 1, 'We have one change');
strictEqual(changes[0].key, resKey, 'Key should match');
strictEqual(changes[0].type, CREATE, 'CREATE type');
strictEqual(changes[0].table, 'bar', 'bar table');
// Normally $$ would be removed from the id in overrideParseStoresSpec
deepEqual(changes[0].obj, {foo: 'bar', $$id: resKey}, 'obj should match');
strictEqual(changes[0].source, null, 'We have no source');
ok(typeof trans._lastWrittenRevision !== 'undefined', '_lastWrittenRevision should be defined');
})
.catch((e) => {
ok(false, 'Error: ' + e);
})
} catch (e) {
ok(false, 'Error: ' + e);
}
};
this.onerror = function(e) {
ok(false, 'Error: ' + e);
}
});
db.bar.add({foo: 'bar'})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should not create a key if one was given', () => {
db.foo.schema.observable = true;
const creatingHook = initCreatingHook(db, db.foo);
const ID = 1;
const source = 10;
db.foo.hook('creating', function(primKey, obj, trans) {
trans.source = source;
const ctx = {onsuccess: null, onerror: null};
const res = creatingHook.call(ctx, primKey, obj, trans);
strictEqual(res, undefined, 'ID was given return undefined');
this.onsuccess = function(resKey) {
try {
strictEqual(resKey, ID, 'We got the given ID');
db._changes.toArray((changes) => {
strictEqual(changes.length, 1, 'We have one change');
strictEqual(changes[0].key, ID, 'Key should match');
strictEqual(changes[0].type, CREATE, 'CREATE type');
strictEqual(changes[0].table, 'foo', 'foo table');
deepEqual(changes[0].obj, {foo: 'bar', id: ID}, 'obj should match');
strictEqual(changes[0].source, source, 'We have source');
ok(typeof trans._lastWrittenRevision !== 'undefined', '_lastWrittenRevision should be defined');
})
.catch((e) => {
ok(false, 'Error: ' + e);
})
} catch (e) {
ok(false, 'Error: ' + e);
}
};
this.onerror = function(e) {
ok(false, 'Error: ' + e);
}
});
db.foo.add({id: ID, foo: 'bar'})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should use the auto-incremented key if ++ was given', () => {
db.baz.schema.observable = true;
const creatingHook = initCreatingHook(db, db.baz);
const ID = 1;
db.baz.hook('creating', function(primKey, obj, trans) {
const ctx = {onsuccess: null, onerror: null};
const res = creatingHook.call(ctx, primKey, obj, trans);
strictEqual(res, undefined, 'ID was auto-increment return undefined');
this.onsuccess = function(resKey) {
try {
strictEqual(resKey, ID, 'We got the given ID');
db._changes.toArray((changes) => {
strictEqual(changes.length, 1, 'We have one change');
strictEqual(changes[0].key, null, 'Key should be null');
strictEqual(changes[0].type, CREATE, 'CREATE type');
strictEqual(changes[0].table, 'baz', 'baz table');
deepEqual(changes[0].obj, {foo: 'bar'}, 'obj should match');
strictEqual(changes[0].source, null, 'We have no source');
ok(typeof trans._lastWrittenRevision !== 'undefined', '_lastWrittenRevision should be defined');
})
.catch((e) => {
ok(false, 'Error: ' + e);
})
} catch (e) {
ok(false, 'Error: ' + e);
}
};
this.onerror = function(e) {
ok(false, 'Error: ' + e);
}
});
db.baz.add({foo: 'bar'})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Observable/test/unit/hooks/tests-deleting.js
================================================
import Dexie from 'dexie';
import {module, asyncTest, start, stop, strictEqual, ok} from 'QUnit';
import {deepEqual} from '../deep-equal';
import {resetDatabase} from '../../../../../test/dexie-unittest-utils';
import initDeletingHook from '../../../src/hooks/deleting';
import initWakeupObservers from '../../../src/wakeup-observers';
import initOverrideCreateTransaction from '../../../src/override-create-transaction';
import {DELETE} from '../../../src/change_types';
const db = new Dexie('TestDBTable', {addons: []});
db.version(1).stores({
foo: "id",
_changes: "++rev"
});
const wakeupObservers = initWakeupObservers(db, {latestRevision: {}}, self.localStorage);
const overrideCreateTransaction = initOverrideCreateTransaction(db, wakeupObservers);
db._createTransaction = Dexie.override(db._createTransaction, overrideCreateTransaction);
module('deleting Hook', {
setup: () => {
stop();
// Do a full DB reset to clean _changes table
db._hasBeenCreated = false;
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should create a DELETE change', () => {
db.foo.schema.observable = true;
const deletingHook = initDeletingHook(db, db.foo.name);
const ID = 10;
const source = 10;
db.foo.hook('deleting', function(primKey, obj, trans) {
trans.source = 10;
const ctx = {onsuccess: null, onerror: null};
deletingHook.call(ctx, primKey, obj, trans);
this.onsuccess = function() {
try {
db._changes.toArray((changes) => {
strictEqual(changes.length, 1, 'We have one change');
strictEqual(changes[0].key, ID, 'Key should match');
strictEqual(changes[0].type, DELETE, 'DELETE type');
strictEqual(changes[0].table, 'foo', 'foo table');
deepEqual(changes[0].oldObj, {foo: 'bar', id: ID}, 'oldObj should match');
strictEqual(changes[0].source, source, 'We have source');
ok(typeof trans._lastWrittenRevision !== 'undefined', '_lastWrittenRevision should be defined');
})
.catch((e) => {
ok(false, 'Error: ' + e);
})
} catch (e) {
ok(false, 'Error: ' + e);
}
};
this.onerror = function(e) {
ok(false, 'Error: ' + e);
}
});
db.foo.add({id: ID, foo: 'bar'})
.then(() => {
return db.foo.delete(ID)
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Observable/test/unit/hooks/tests-updating.js
================================================
import Dexie from 'dexie';
import {module, asyncTest, start, stop, strictEqual, ok} from 'QUnit';
import {deepEqual} from '../deep-equal';
import {resetDatabase} from '../../../../../test/dexie-unittest-utils';
import initUpdatingHook from '../../../src/hooks/updating';
import initWakeupObservers from '../../../src/wakeup-observers';
import initOverrideCreateTransaction from '../../../src/override-create-transaction';
import {UPDATE} from '../../../src/change_types';
const db = new Dexie('TestDBTable', {addons: []});
db.version(1).stores({
foo: "id",
_changes: "++rev"
});
const wakeupObservers = initWakeupObservers(db, {latestRevision: {}}, self.localStorage);
const overrideCreateTransaction = initOverrideCreateTransaction(db, wakeupObservers);
db._createTransaction = Dexie.override(db._createTransaction, overrideCreateTransaction);
module('updating Hook', {
setup: () => {
stop();
// Do a full DB reset to clean _changes table
db._hasBeenCreated = false;
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should create an UPDATE change', () => {
db.foo.schema.observable = true;
const updatingHook = initUpdatingHook(db, db.foo.name);
const ID = 1;
const source = 10;
db.foo.hook('updating', function hook(mods, primKey, obj, trans) {
// Remove this hook now otherwise other tests might call it
db.foo.hook('updating').unsubscribe(hook);
trans.source = source;
const ctx = {onsuccess: null, onerror: null};
updatingHook.call(ctx, mods, primKey, obj, trans);
this.onsuccess = function() {
try {
db._changes.toArray((changes) => {
strictEqual(changes.length, 1, 'We have one change');
strictEqual(changes[0].key, ID, 'Key should match');
strictEqual(changes[0].type, UPDATE, 'UPDATE type');
strictEqual(changes[0].table, 'foo', 'foo table');
deepEqual(changes[0].obj, {foo: 'baz', id: ID}, 'obj should match');
strictEqual(changes[0].source, source, 'We have source');
ok(typeof trans._lastWrittenRevision !== 'undefined', '_lastWrittenRevision should be defined');
})
.catch((e) => {
ok(false, 'Error: ' + e);
})
} catch (e) {
ok(false, 'Error: ' + e);
}
};
this.onerror = function(e) {
ok(false, 'Error: ' + e);
}
});
db.foo.add({id: ID, foo: 'bar'})
.then(() => {
return db.foo.update(ID, {foo: 'baz'});
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should not create an UPDATE change if the mods are already in the old object', () => {
db.foo.schema.observable = true;
const updatingHook = initUpdatingHook(db, db.foo.name);
const ID = 2;
const source = 10;
db.foo.hook('updating', function hook(mods, primKey, obj, trans) {
// Remove this hook now otherwise other tests might call it
db.foo.hook('updating').unsubscribe(hook);
trans.source = source;
const ctx = {onsuccess: null, onerror: null};
updatingHook.call(ctx, mods, primKey, obj, trans);
this.onsuccess = function() {
try {
db._changes.toArray((changes) => {
strictEqual(changes.length, 0, 'We have no change');
})
.catch((e) => {
ok(false, 'Error: ' + e);
})
} catch (e) {
ok(false, 'Error: ' + e);
}
};
this.onerror = function(e) {
ok(false, 'Error: ' + e);
}
});
db.foo.add({id: ID, foo: 'bar'})
.then(() => {
return db.foo.update(ID, {foo: 'bar'});
}).then(()=>{
return db._changes.toArray((changes) => {
strictEqual(changes.length, 0, 'We have no change');
});
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Observable/test/unit/karma.conf.js
================================================
// Include common configuration
const {karmaCommon, getKarmaConfig, defaultBrowserMatrix} = require('../../../../test/karma.common');
module.exports = function (config) {
const browserMatrixOverrides = {
// Be fine with testing on local travis firefox + browserstack chrome, latest supported.
ci: ["remote_chrome"],
// Safari fails to reply on browserstack. Need to not have it here.
// Just complement with old chrome browser that is not part of CI test suite.
pre_npm_publish: [
"Chrome",
]
};
const cfg = getKarmaConfig(browserMatrixOverrides, {
// Base path should point at the root
basePath: '../../../../',
files: karmaCommon.files.concat([
'dist/dexie.js',
'addons/Dexie.Observable/dist/dexie-observable.js',
'addons/Dexie.Observable/test/unit/bundle.js',
{ pattern: 'addons/Dexie.Observable/test/unit/*.map', watched: false, included: false },
{ pattern: 'addons/Dexie.Observable/dist/*.map', watched: false, included: false }
])
});
config.set(cfg);
}
================================================
FILE: addons/Dexie.Observable/test/unit/run-unit-tests.html
================================================
Dexie.Observable Unit tests
================================================
FILE: addons/Dexie.Observable/test/unit/tests-observable-misc.js
================================================
import {module, asyncTest, equal, strictEqual, ok, start} from 'QUnit';
import {deepEqual} from './deep-equal';
import Dexie from 'dexie';
import 'dexie-observable';
module("tests-observable-misc", {
setup: function () {
stop();
Dexie.delete("ObservableTest").then(function () {
start();
}).catch(function (e) {
ok(false, "Could not delete database");
});
},
teardown: function () {
stop();
Dexie.delete("ObservableTest").then(start);
}
});
function createDB() {
var db = new Dexie("ObservableTest");
db.version(1).stores({
friends: "++id,name,shoeSize",
CapitalIdTest: "$$Id,name"
//pets: "++id,name,kind",
//$emailWords: "",
});
return db;
}
// Basically check if Dexie.Observable adds a 'changes' event
// Test works because of the second parameter to asyncTest which expects 4 assertions
asyncTest("changes in on DB instance should trigger a change event in an other instance", 4, function () {
var db1 = createDB();
var db2 = createDB();
db2.on('changes', function (changes, partitial) {
changes.forEach(function (change) {
switch (change.type) {
case 1:
ok(true, "obj created: " + JSON.stringify(change.obj));
break;
case 2:
ok(true, "obj updated: " + JSON.stringify(change.mods));
equal(JSON.stringify(change.mods), JSON.stringify({name: "David"}), "Only modifying the name property");
break;
case 3:
ok(true, "obj deleted: " + JSON.stringify(change.oldObj));
db1.close();
db2.close();
start();
break;
}
});
});
db1.open();
db2.open();
db1.friends.put({name: "Dave", shoeSize: 43}).then(function (id) {
// Update object:
return db1.friends.put({id: id, name: "David", shoeSize: 43});
}).then(function (id) {
// Delete object:
return db1.friends.delete(id);
}).catch(function (e) {
ok(false, "Error: " + e.stack || e);
start();
});
});
// Test UUID primary key
asyncTest("Capital$$Id-test", function () {
var db = createDB();
db.open();
db.CapitalIdTest.put({name: "Hilda"}).then(function () {
return db.CapitalIdTest.toCollection().first();
}).then(function (firstItem) {
ok(firstItem.name == "Hilda", "Got first item");
ok(firstItem.Id, "First item has a primary key set: " + firstItem.Id);
// Note that this regex is not spec compliant but should be good enough for this test
const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/;
ok(regex.test(firstItem.Id), 'We got a UUID');
}).catch(function (e) {
ok(false, "Error: " + e);
}).finally(function () {
db.close();
start();
});
});
//
// Test intercomm in same window
//
asyncTest('should receive a message from the first sync node', 4, () => {
const db1 = createDB();
const db2 = createDB();
let senderID;
let receiverID;
db2.on('message', (msg) => {
strictEqual(msg.message, 'foobar', 'We got the correct message');
strictEqual(msg.sender, senderID, 'We got the right sender ID');
strictEqual(msg.type, 'request', 'We got the correct type');
strictEqual(msg.destinationNode, receiverID, 'We got the correct destination node');
start();
});
db1.on('message', () => {
ok(false, 'We should not receive a message');
});
db1._syncNodes.toArray()
.then((arr) => {
senderID = arr[0].id;
return db2._syncNodes.toArray();
})
.then((arr) => {
receiverID = arr.filter((node) => node.id !== senderID)[0].id;
db1.observable.sendMessage('request', 'foobar', receiverID, {});
})
.catch((e) => {
ok(false, 'Error: ' + e);
});
});
asyncTest('master node should receive the message if the destination is not present and we want a reply', 5, () => {
const db1 = createDB();
let senderID;
db1.on('message', (msg) => {
deepEqual(msg.message, {foo: 'foobar'}, 'We got the correct message');
strictEqual(msg.sender, senderID, 'We got the right sender ID');
strictEqual(msg.type, 'request', 'We got the correct type');
strictEqual(msg.destinationNode, senderID, 'We got the correct destination node');
start();
});
db1._syncNodes.toArray()
.then((arr) => {
senderID = arr[0].id;
strictEqual(arr[0].isMaster, 1, 'We are master');
db1.observable.sendMessage('request', {foo: 'foobar'}, 10, {wantReply: true});
})
.catch((e) => {
ok(false, 'Error: ' + e);
});
});
asyncTest('sender should react on successful reply', 1, () => {
const db1 = createDB();
const db2 = createDB();
let senderID;
let receiverID;
db2.on('message', (msg) => {
msg.resolve('reply msg');
});
db1._syncNodes.toArray()
.then((arr) => {
senderID = arr[0].id;
return db2._syncNodes.toArray();
})
.then((arr) => {
receiverID = arr.filter((node) => node.id !== senderID)[0].id;
return db1.observable.sendMessage('request', {foo: 'foobar'}, receiverID, {wantReply: true});
})
.then((result) => {
strictEqual(result, 'reply msg', 'We got the correct result msg');
})
.catch((e) => {
ok(false, 'Error: ' + e);
})
.finally(start);
});
asyncTest('sender should react on failure reply', 1, () => {
const db1 = createDB();
const db2 = createDB();
let senderID;
let receiverID;
db2.on('message', (msg) => {
msg.reject('error msg');
});
db1._syncNodes.toArray()
.then((arr) => {
senderID = arr[0].id;
return db2._syncNodes.toArray();
})
.then((arr) => {
receiverID = arr.filter((node) => node.id !== senderID)[0].id;
return db1.observable.sendMessage('request', {foo: 'foobar'}, receiverID, {wantReply: true});
})
.catch((msg) => {
strictEqual(msg, 'error msg');
})
.finally(start);
});
asyncTest('sync nodes should react on broadcast', 4, () => {
const db1 = createDB();
const db2 = createDB();
let senderID;
let receiverID;
db2.on('message', (msg) => {
deepEqual(msg.message, {foo: 'foobar'}, 'We got the correct message');
strictEqual(msg.sender, senderID, 'We got the right sender ID');
strictEqual(msg.type, 'request', 'We got the correct type');
strictEqual(msg.destinationNode, receiverID, 'We got the correct destination node');
start();
});
db1.on('message', () => {
ok(false, 'We should not receive a message');
});
db1._syncNodes.toArray()
.then((arr) => {
senderID = arr[0].id;
return db2._syncNodes.toArray();
})
.then((arr) => {
receiverID = arr.filter((node) => node.id !== senderID)[0].id;
db1.observable.broadcastMessage('request', {foo: 'foobar'});
})
.catch((e) => {
ok(false, 'Error: ' + e);
});
});
asyncTest('sender should be able to react on broadcast if bIncludeSelf is true', 4, () => {
const db1 = createDB();
const db2 = createDB();
let senderID;
let receiverID;
db1.on('message', (msg) => {
deepEqual(msg.message, {foo: 'foobar'}, 'We got the correct message');
strictEqual(msg.sender, senderID, 'We got the right sender ID');
strictEqual(msg.type, 'broadcast', 'We got the correct type');
strictEqual(msg.destinationNode, senderID, 'We got the correct destination node');
start();
});
db1._syncNodes.toArray()
.then((arr) => {
senderID = arr[0].id;
return db2._syncNodes.toArray();
})
.then((arr) => {
receiverID = arr.filter((node) => node.id !== senderID)[0].id;
const bIncludeSelf = true;
db1.observable.broadcastMessage('broadcast', {foo: 'foobar'}, bIncludeSelf);
})
.catch((e) => {
ok(false, 'Error: ' + e);
});
});
// This tests relates to https://github.com/dexie/Dexie.js/issues/429#issuecomment-269793599
// If db2 receives its message multiple times qunit will error as start() is called multiple times
asyncTest('no matter how many times sendMessage is called a receiver should receive its message only once', 4, () => {
const db1 = createDB();
const db2 = createDB();
let senderID;
let receiverID;
db2.on('message', (msg) => {
deepEqual(msg.message, {foo: 'foobar'}, 'We got the correct message');
strictEqual(msg.sender, senderID, 'We got the right sender ID');
strictEqual(msg.type, 'request', 'We got the correct type');
strictEqual(msg.destinationNode, receiverID, 'We got the correct destination node');
start();
});
db1._syncNodes.toArray()
.then((arr) => {
senderID = arr[0].id;
return db2._syncNodes.toArray();
})
.then((arr) => {
receiverID = arr.filter((node) => node.id !== senderID)[0].id;
db1.observable.sendMessage('request', {foo: 'foobar'}, receiverID, {});
// Send messages for receivers that don't exist
db1.observable.sendMessage('request', {foo: 'foobar'}, 'foobar', {});
db1.observable.sendMessage('request', {foo: 'foobar'}, 'barbaz', {});
})
.catch((e) => {
ok(false, 'Error: ' + e);
});
});
================================================
FILE: addons/Dexie.Observable/test/unit/tests-override-open.js
================================================
import {module, test, strictEqual, deepEqual, ok} from 'QUnit';
import initOverrideOpen from '../../src/override-open';
module('override-open', {
setup: () => {
},
teardown: () => {
}
});
test('should call the given original function', () => {
let wasCalled = false;
function origFn() {
wasCalled = true;
}
const db = {
_allTables: {}
};
initOverrideOpen(db, function SyncNode() {}, function crudMonitor() {})(origFn)();
ok(wasCalled);
});
test('should call the crudMonitor function for every observable table', () => {
const tables = [];
function crudMonitor(table) {
tables.push(table);
}
const db = {
_allTables: {
foo: {
// TableSchema: for more info see https://github.com/dfahlander/Dexie.js/wiki/TableSchema
schema: {
observable: true
}
},
_bar: {
schema: {}
}
}
};
initOverrideOpen(db, function SyncNode() {}, crudMonitor)(() => {})();
deepEqual(tables, [db._allTables.foo]);
});
test('should call mapToClass for the _syncNodes table', () => {
function SyncNode() {}
let calledWithClass;
const db = {
_allTables: {
_syncNodes: {
name: '_syncNodes',
mapToClass(cls) {
calledWithClass = cls;
},
schema: {}
}
}
};
initOverrideOpen(db, SyncNode, function crudMonitor(){})(() => {})();
strictEqual(calledWithClass, SyncNode);
});
================================================
FILE: addons/Dexie.Observable/test/unit/tests-override-parse-stores-spec.js
================================================
import {module, test, strictEqual, deepEqual, ok} from 'QUnit';
import overrideParseStoresSpec from '../../src/override-parse-stores-spec';
module('override-parse-stores', {
setup: () => {
},
teardown: () => {
}
});
test('should create stores for Dexie.Observable and Dexie.Syncable', () => {
function origFunc(stores/*, dbSchema*/) {
ok(typeof stores._changes === 'string', 'Should have _changes store');
ok(typeof stores._syncNodes === 'string', 'Should have _syncNodes store');
ok(typeof stores._intercomm === 'string', 'Should have _intercomm store');
ok(typeof stores._uncommittedChanges === 'string', 'Should have _uncommittedChanges store');
}
const stores = {};
const dbSchema = {};
overrideParseStoresSpec(origFunc)(stores, dbSchema);
});
test('should add UUID keys to the schema', () => {
function origFunc(){}
const stores = {
foo: '$$id',
};
const dbSchema = {
// TableSchema: for more info see https://github.com/dfahlander/Dexie.js/wiki/TableSchema
foo: {
name: 'foo',
// IndexSpec: for more info see https://github.com/dfahlander/Dexie.js/wiki/IndexSpec
primKey: {
name: '$$id',
keyPath: '$$id'
}
}
};
overrideParseStoresSpec(origFunc)(stores, dbSchema);
strictEqual(dbSchema.foo.primKey.name, 'id', 'Should remove $$ from the name');
strictEqual(dbSchema.foo.primKey.keyPath, 'id', 'Should remove $$ from keyPath');
strictEqual(dbSchema.foo.primKey.uuid, true, 'uuid should be set to true');
});
test('should observe tables without _ and $', () => {
function origFunc(){}
const stores = {
foo: 'id',
_foo: 'id',
$bar: 'id'
};
const dbSchema = {
foo: {primKey: {name: 'id'}},
_foo: {primKey: {name: 'id'}},
$bar: {primKey: {name: 'id'}}
};
overrideParseStoresSpec(origFunc)(stores, dbSchema);
strictEqual(dbSchema.foo.observable, true, 'foo should be observed');
strictEqual(dbSchema._foo.observable, undefined, '_foo should not be observed');
strictEqual(dbSchema.$bar.observable, undefined, '$bar should not be observed');
});
================================================
FILE: addons/Dexie.Observable/test/unit/unit-tests-all.js
================================================
import './hooks/tests-creating.js';
import './hooks/tests-deleting.js';
import './hooks/tests-updating.js';
import './tests-override-open.js';
import './tests-override-parse-stores-spec.js';
import './tests-observable-misc';
================================================
FILE: addons/Dexie.Observable/tools/build-configs/banner.txt
================================================
/* ==========================================================================
* dexie-observable.js
* ==========================================================================
*
* Dexie addon for observing database changes not just on local db instance
* but also on other instances, tabs and windows.
*
* Comprises a base framework for dexie-syncable.js
*
* By David Fahlander, david.fahlander@gmail.com,
* Nikolas Poniros, https://github.com/nponiros
*
* ==========================================================================
*
* Version {version}, {date}
*
* https://dexie.org
*
* Apache License Version 2.0, January 2004, http://www.apache.org/licenses/
*
*/
================================================
FILE: addons/Dexie.Observable/tools/build-configs/rollup.config.mjs
================================================
import sourcemaps from 'rollup-plugin-sourcemaps';
import {readFileSync} from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf-8'));
const version = packageJson.version;
export default {
input: 'tools/tmp/es5/src/Dexie.Observable.js',
output: [{
file: 'dist/dexie-observable.js',
format: 'umd',
banner: readFileSync(path.resolve(__dirname, 'banner.txt')),
globals: {dexie: "Dexie"},
name: 'Dexie.Observable',
sourcemap: true
},{
file: 'dist/dexie-observable.es.js',
format: 'es',
banner: readFileSync(path.resolve(__dirname, 'banner.txt')),
globals: {dexie: "Dexie"},
name: 'Dexie.Observable',
sourcemap: true
}],
external: ['dexie'],
plugins: [ sourcemaps() ]
};
================================================
FILE: addons/Dexie.Observable/tools/build-configs/rollup.tests.config.js
================================================
import sourcemaps from 'rollup-plugin-sourcemaps';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
const ERRORS_TO_IGNORE = [
"THIS_IS_UNDEFINED"
];
export default {
input: 'tools/tmp/es5/test/addons/Dexie.Observable/test/unit/unit-tests-all.js',
output: {
file: 'test/unit/bundle.js',
format: 'umd',
globals: {dexie: "Dexie", "dexie-observable": "Dexie.Observable", QUnit: "QUnit"},
sourcemap: true,
name: 'dexieTests'
},
external: ['dexie', 'dexie-observable', 'QUnit'],
plugins: [
sourcemaps(),
nodeResolve({browser: true}),
commonjs()
],
onwarn ({loc, frame, code, message}) {
if (ERRORS_TO_IGNORE.includes(code)) return;
if ( loc ) {
console.warn( `${loc.file} (${loc.line}:${loc.column}) ${message}` );
if ( frame ) console.warn( frame );
} else {
console.warn(`${code} ${message}`);
}
}
};
================================================
FILE: addons/Dexie.Observable/tools/build-configs/rollup.tests.config.mjs
================================================
import sourcemaps from 'rollup-plugin-sourcemaps';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
const ERRORS_TO_IGNORE = [
"THIS_IS_UNDEFINED"
];
export default {
input: 'tools/tmp/es5/test/addons/Dexie.Observable/test/unit/unit-tests-all.js',
output: {
file: 'test/unit/bundle.js',
format: 'umd',
globals: {dexie: "Dexie", "dexie-observable": "Dexie.Observable", QUnit: "QUnit"},
sourcemap: true,
name: 'dexieTests'
},
external: ['dexie', 'dexie-observable', 'QUnit'],
plugins: [
sourcemaps(),
nodeResolve({browser: true}),
commonjs()
],
onwarn ({loc, frame, code, message}) {
if (ERRORS_TO_IGNORE.includes(code)) return;
if ( loc ) {
console.warn( `${loc.file} (${loc.line}:${loc.column}) ${message}` );
if ( frame ) console.warn( frame );
} else {
console.warn(`${code} ${message}`);
}
}
};
================================================
FILE: addons/Dexie.Observable/tools/build-configs/rollup.tests.unit.config.js
================================================
import sourcemaps from 'rollup-plugin-sourcemaps';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
const ERRORS_TO_IGNORE = [
"THIS_IS_UNDEFINED"
];
export default {
input: 'tools/tmp/es5/test/addons/Dexie.Observable/test/unit/unit-tests-all.js',
output: {
file: 'test/unit/bundle.js',
format: 'umd',
globals: {dexie: "Dexie", "dexie-observable": "Dexie.Observable", QUnit: "QUnit"},
sourcemap: true,
name: 'dexieTests'
},
external: ['dexie', 'dexie-observable', 'QUnit'],
plugins: [
sourcemaps(),
nodeResolve({browser: true}),
commonjs()
],
onwarn ({loc, frame, code, message}) {
if (ERRORS_TO_IGNORE.includes(code)) return;
if ( loc ) {
console.warn( `${loc.file} (${loc.line}:${loc.column}) ${message}` );
if ( frame ) console.warn( frame );
} else {
console.warn(`${code} ${message}`);
}
}
};
================================================
FILE: addons/Dexie.Observable/tools/build-configs/rollup.tests.unit.config.mjs
================================================
import sourcemaps from 'rollup-plugin-sourcemaps';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
const ERRORS_TO_IGNORE = [
"THIS_IS_UNDEFINED"
];
export default {
input: 'tools/tmp/es5/test/addons/Dexie.Observable/test/unit/unit-tests-all.js',
output: {
file: 'test/unit/bundle.js',
format: 'umd',
globals: {dexie: "Dexie", "dexie-observable": "Dexie.Observable", QUnit: "QUnit"},
sourcemap: true,
name: 'dexieTests'
},
external: ['dexie', 'dexie-observable', 'QUnit'],
plugins: [
sourcemaps(),
nodeResolve({browser: true}),
commonjs()
],
onwarn ({loc, frame, code, message}) {
if (ERRORS_TO_IGNORE.includes(code)) return;
if ( loc ) {
console.warn( `${loc.file} (${loc.line}:${loc.column}) ${message}` );
if ( frame ) console.warn( frame );
} else {
console.warn(`${code} ${message}`);
}
}
};
================================================
FILE: addons/Dexie.Observable/tools/replaceVersionAndDate.js
================================================
const fs = require('fs');
const files = process.argv.slice(2);
const version = require('../package.json').version;
files.forEach(file => {
let fileContent = fs.readFileSync(file, "utf-8");
fileContent = fileContent
.replace(/{version}/g, version)
.replace(/{date}/g, new Date().toDateString());
fs.writeFileSync(file, fileContent, "utf-8");
});
================================================
FILE: addons/Dexie.Syncable/.gitignore
================================================
dist/*.js
dist/*.map
dist/*.ts
dist/*.gz
.eslintcache
**/tmp/
================================================
FILE: addons/Dexie.Syncable/.npmignore
================================================
tools/
src/
.*
tmp/
**/tmp/
test
*.log
================================================
FILE: addons/Dexie.Syncable/README.md
================================================
# Dexie.Syncable.js
Enables two-way synchronization with remote database.
*NOTE! This package has been unmaintained for a looong time and might be retired. Some people go under the impression that Dexie Cloud would be based on this package, but it's not - it has its own sync system based on middlewares - all source for it is in addons/dexie-cloud.*
### Install
```
npm install dexie --save
npm install dexie-observable --save
npm install dexie-syncable --save
```
### Use
```js
import Dexie from 'dexie';
import 'dexie-syncable'; // will import dexie-observable as well.
// Use Dexie as normally - but you can also register your sync protocols though
// Dexie.Syncable.registerSyncProtocol() api as well as using the db.syncable api
// as documented here.
```
### Dependency Tree
* **Dexie.Syncable.js**
* [Dexie.Observable.js](https://dexie.org/docs/Observable/Dexie.Observable.js)
* [Dexie.js](https://dexie.org/docs/Dexie/Dexie.js)
* [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
* _An implementation of [ISyncProtocol](https://dexie.org/docs/Syncable/Dexie.Syncable.ISyncProtocol)_
### Tutorial
#### 1. Include Required Sources
In your HTML, make sure to include Dexie.js, Dexie.Observable.js, Dexie.Syncable.js and an implementation of [ISyncProtocol](https://dexie.org/docs/Syncable/Dexie.Syncable.ISyncProtocol).
...
##### Usage with existing DB
In case you want to use Dexie.Syncable with your existing database, but do not want to use UUID based Primary Keys as described below, you will have to do a schema upgrade. Without it Dexie.Syncable will not be able to properly work.
```javascript
import Dexie from 'dexie';
import 'dexie-observable';
import 'dexie-syncable';
import 'your-sync-protocol-implementation';
var db = new Dexie('myExistingDb');
db.version(1).stores(... existing schema ...);
// Now, add another version, just to trigger an upgrade for Dexie.Syncable
db.version(2).stores({}); // No need to add / remove tables. This is just to allow the addon to install its tables.
```
#### 2. Use UUID based Primary Keys ($$)
Two way replication cannot use auto-incremented keys if any sync node should be able to create objects no matter if it is offline or online. Dexie.Syncable comes with a new syntax when defining your store schemas: the double-dollar prefix ($$). Similarly to the ++ prefix in Dexie (meaning auto-incremented primary key), the double-dollar prefix means that the key will be given a universally unique identifier (UUID), in string format (For example "9cc6768c-358b-4d21-ac4d-58cc0fddd2d6").
var db = new Dexie("MySyncedDB");
db.version(1).stores({
friends: "$$oid,name,shoeSize",
pets: "$$oid,name,kind"
});
#### 3. Connect to Server
You must specify the URL of the server you want to keep in-sync with. This has to be done once in the entire database life-time, but doing it on every startup is ok as well, since it won't affect already connected URLs.
// This example uses the WebSocketSyncProtocol included in earlier steps.
db.syncable.connect ("websocket", "https://syncserver.com/sync");
db.syncable.on('statusChanged', function (newStatus, url) {
console.log ("Sync Status changed: " + Dexie.Syncable.StatusTexts[newStatus]);
});
#### 4. Use Your Database
Query and modify your database as if it was a simple Dexie instance. Any changes will be replicated to the server and changes on the server or an other window will replicate back to you.
db.transaction('rw', db.friends, function (friends) {
friends.add({name: "Arne", shoeSize: 47});
friends.where('shoeSize').above(40).each(function (friend) {
console.log("Friend with shoeSize over 40: " + friend.name);
});
});
_NOTE: Transactions only provide the Atomicity part of the [ACID](http://en.wikipedia.org/wiki/ACID) properties when using 2-way synchronization. This is due to the fact that the syncronization phase may result in another change overwriting the changes. However, it's still meaningful to use the transaction() method for atomicity. Atomicity is guaranteed not only locally but also when synced to the server, meaning that a part of the changes will never commit on the server until all changes from the transaction have been synced. In practice, you cannot increment a counter in the database (for example) and expect it to be consistent, but you can have a guaranteed that if you add a sequence of objects, all or none of them will replicate._
### API Reference
#### Static Members
[Dexie.Syncable.registerSyncProtocol (name, protocolInstance)](https://dexie.org/docs/Syncable/Dexie.Syncable.registerSyncProtocol())
Define how to replicate changes with your type of server.
[Dexie.Syncable.Statuses](https://dexie.org/docs/Syncable/Dexie.Syncable.Statuses)
Enum of possible sync statuses, such as OFFLINE, CONNECTING, ONLINE and ERROR.
[Dexie.Syncable.StatusTexts](https://dexie.org/docs/Syncable/Dexie.Syncable.StatusTexts)
Text lookup for status numbers
#### Non-Static Methods and Events
[db.syncable.connect (protocol, url, options)](https://dexie.org/docs/Syncable/db.syncable.connect())
Create a persistend two-way sync connection with the given URL.
[db.syncable.disconnect (url)](https://dexie.org/docs/Syncable/db.syncable.disconnect())
Stop syncing with the given URL but keep revision states until next connect.
[db.syncable.delete(url)](https://dexie.org/docs/Syncable/db.syncable.delete())
Delete all states and change queue for given URL.
[db.syncable.list()](https://dexie.org/docs/Syncable/db.syncable.list())
List the URLs of each remote node we have a state saved for.
[db.syncable.on('statusChanged')](https://dexie.org/docs/Syncable/db.syncable.on('statusChanged'))
Event triggered when sync status changes.
[db.syncable.setFilter ([criteria], filter)](https://dexie.org/docs/Syncable/db.syncable.setFilter())
Ignore certain objects from being synced defined by the given filter.
[db.syncable.getStatus (url)](https://dexie.org/docs/Syncable/db.syncable.getStatus())
Get sync status for the given URL.
[db.syncable.getOptions (url)](https://dexie.org/docs/Syncable/db.syncable.getOptions())
Get the options object for the given URL.
### Source
[Dexie.Syncable.js](https://github.com/dexie/Dexie.js/blob/master/addons/Dexie.Syncable/src/Dexie.Syncable.js)
### Description
Dexie.Syncable enables synchronization with a remote database (of almost any kind). It has its own API [ISyncProtocol](https://dexie.org/docs/Syncable/Dexie.Syncable.ISyncProtocol).
The [ISyncProtocol](https://dexie.org/docs/Syncable/Dexie.Syncable.ISyncProtocol) is pretty straight-forward to implement.
The implementation of that API defines how client- and server- changes are transported between local and remote nodes. The API support both poll-patterns
(such as ajax calls) and direct reaction pattern (such as WebSocket or long-polling methods). See samples below for each pattern.
### Sample [ISyncProtocol](https://dexie.org/docs/Syncable/Dexie.Syncable.ISyncProtocol) Implementations
* [https://github.com/nponiros/sync_client](https://github.com/nponiros/sync_client)
* [AjaxSyncProtocol.js](https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/ajax/AjaxSyncProtocol.js)
* [WebSocketSyncProtocol.js](https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncProtocol.js)
### Sample Sync Servers
* [https://github.com/nponiros/sync_server](https://github.com/nponiros/sync_server)
* [WebSocketSyncServer.js](https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js)
================================================
FILE: addons/Dexie.Syncable/api.d.ts
================================================
/* dexie-syncable API - an independant syncronization API used by 'dexie-syncable'.
* Version: 1.1
* Date: December 2016
*
* Some assumptions are made upon how the database is structured though. We assume that:
* * Databases has 1..N tables. (For NOSQL databases without tables, this also works since it could be considered a db with a single table.)
* * Each table has a primary key.
* * The primary key is a UUID of some kind since auto-incremented primary keys are not suitable for syncronization
* (auto-incremented key would work but changes of conflicts would increase on create).
* * A database record is a JSON compatible object.
* * Always assume that the client may send the same set of changes twice. For example if client sent changes that server stored, but network went down before
* client got the ack from server, the client may try resending same set of changes again. This means that the same Object Create change may be sent twice etc.
* The implementation must not fail if trying to create an object with the same key twice, or delete an object with a key that does not exist.
* * Client and server must resolve conflicts in such way that the result on both sides are equal.
* * Since a server is the point of the most up-to-date database, conflicts should be resolved by prefering server changes over client changes.
* This makes it predestinable for client that the more often the client syncs, the more chance to prohibit conflicts.
*
* By separating module 'dexie-syncable' from 'dexie-syncable/api' we
* distinguish that:
*
* import {...} from 'dexie-syncable/api' is only for getting access to its
* interfaces and has no side-effects.
* Typescript-only import.
*
* import 'dexie-syncable' is only for side effects - to extend Dexie with
* functionality of dexie-syncable.
* Javascript / Typescript import.
*
*/
import {IDatabaseChange} from 'dexie-observable/api';
export {DatabaseChangeType} from 'dexie-observable/api';
/* ISyncProtocol
Interface to implement for enabling syncronization with a remote database server. The remote database server may be SQL- or NOSQL based
as long as it is capable of storing JSON compliant objects into some kind of object stores and reference them by a primary key.
The server must also be revision- and changes aware. This is something that for many databases needs to be implemented by a REST- or
WebSocket gateway between the client and the backend database. The gateway can act as a controller and make sure any changes
are registered in the 'changes' table and that the API provides a sync() method to interchange changes between client and server.
Two examples of a ISyncProtocol instances are found in:
https://github.com/dfahlander/Dexie.js/tree/master/samples/remote-sync/ajax/AjaxSyncProtocol.js
https://github.com/dfahlander/Dexie.js/tree/master/samples/remote-sync/websocket/WebSocketSyncProtocol.js
*/
/**
* The interface to implement to provide sync towards a remote server.
*
* Documentation for this interface: https://github.com/dfahlander/Dexie.js/wiki/Dexie.Syncable.ISyncProtocol
*
*/
export interface ISyncProtocol {
partialsThreshold?: number;
sync (
context: IPersistedContext,
url: string,
options: any,
baseRevision: any,
syncedRevision: any,
changes: IDatabaseChange[],
partial: boolean,
applyRemoteChanges: ApplyRemoteChangesFunction,
onChangesAccepted: ()=>void,
onSuccess: (continuation: PollContinuation | ReactiveContinuation)=>void,
onError: (error: any, again?: number) => void) : void;
}
/**
* Documentation for this interface: https://github.com/dfahlander/Dexie.js/wiki/Dexie.Syncable.IPersistedContext
*/
export interface IPersistedContext {
save() : Promise;
[customProp: string] : any;
}
/**
* Documentation for this function: https://github.com/dfahlander/Dexie.js/wiki/Dexie.Syncable.ISyncProtocol
*/
export type ApplyRemoteChangesFunction = (
changes: IDatabaseChange[],
lastRevision: any,
partial?: boolean,
clear?: boolean)
=> Promise;
/**
* Provide a poll continuation if your backend is a reqest/response service, such as a REST API.
*/
export interface PollContinuation {
/** Implementation should return number of milliseconds until you want the framework to call sync() again. */
again: number
}
/**
* Provide a reactive continuation if your backend is connected to over WebSocket, socket-io, signalR or such,
* and may push changes back to the client as they occur.
*/
export interface ReactiveContinuation {
react (
/** List of local changes to send to server. */
changes: IDatabaseChange[],
/** Server revision that server needs to know in order to apply the changes correcly. */
baseRevision: any,
/** If true, it means that reach() will be called upon again with additional changes once you'version
* called onChangesAccepted(). An implementation may handle this transactionally, i.e. wait with applying
* these changes and instead buffer them in a temporary table and the apply everything once reac() is called
* with partial=false.
*/
partial: boolean,
/** Callback to call when the given changes has been acknowledged and persisted at the server side.
* This will mark the change-set as delivered and the framework wont try resending these changes anymore.
*/
onChangesAccepted: ()=>void): void;
/** Implementation should disconned the underlying transport and stop calling applyRemoteChanges(). */
disconnect(): void;
}
export enum SyncStatus {
/** An irrepairable error occurred and the sync provider is dead. */
ERROR = -1,
/** The sync provider hasnt yet become online, or it has been disconnected. */
OFFLINE = 0,
/** Trying to connect to server */
CONNECTING = 1,
/** Connected to server and currently in sync with server */
ONLINE = 2,
/** Syncing with server. For poll pattern, this is every poll call.
* For react pattern, this is when local changes are being sent to server. */
SYNCING = 3,
/** An error occured such as net down but the sync provider will retry to connect. */
ERROR_WILL_RETRY = 4
}
================================================
FILE: addons/Dexie.Syncable/api.js
================================================
// This file is deliberatly left empty to allow the api.d.ts to contain the definitions for Dexie.Syncable without generating an error on webpack
================================================
FILE: addons/Dexie.Syncable/dist/README.md
================================================
## Can't find dexie-syncable.js?
Transpiled code (dist version) IS ONLY checked in to
the [releases](https://github.com/dexie/Dexie.js/tree/releases/addons/Dexie.Syncable/dist).
branch.
## Download
[unpkg.com/dexie-syncable/dist/dexie-syncable.js](https://unpkg.com/dexie-syncable/dist/dexie-syncable.js)
[unpkg.com/dexie-syncable/dist/dexie-syncable.min.js](https://unpkg.com/dexie-syncable/dist/dexie-syncable.min.js)
[unpkg.com/dexie-syncable/dist/dexie-syncable.js.map](https://unpkg.com/dexie-syncable/dist/dexie-syncable.js.map)
[unpkg.com/dexie-syncable/dist/dexie-syncable.min.js.map](https://unpkg.com/dexie-syncable/dist/dexie-syncable.min.js.map)
## npm
```
npm install dexie-syncable --save
```
## bower
Since Dexie v1.3.4, addons are included in the dexie bower package.
```
$ bower install dexie --save
$ ls bower_components/dexie/addons/Dexie.Syncable/dist
dexie-syncable.js dexie-syncable.js.map dexie-syncable.min.js dexie-syncable.min.js.map
```
## Or build them yourself...
Fork Dexie.js, then:
```
git clone https://github.com/YOUR-USERNAME/Dexie.js.git
cd Dexie.js
npm install
cd addons/Dexie.Syncable
npm run build # or npm run watch
```
If you're on windows, you need to use an elevated command prompt of some reason to get `npm install` to work.
================================================
FILE: addons/Dexie.Syncable/package.json
================================================
{
"name": "dexie-syncable",
"version": "4.0.1-beta.13",
"description": "Addon to Dexie that makes it possible to sync indexeDB with remote databases.",
"main": "dist/dexie-syncable.js",
"module": "dist/dexie-syncable.es.js",
"jsnext:main": "dist/dexie-syncable.es.js",
"typings": "dist/dexie-syncable.d.ts",
"jspm": {
"format": "cjs",
"ignore": [
"src/"
]
},
"repository": {
"type": "git",
"url": "https://github.com/dexie/Dexie.js.git"
},
"keywords": [
"indexeddb",
"dexie",
"addon",
"database",
"sync"
],
"author": "David Fahlander ",
"contributors": [
"Nikolas Poniros ",
"Martin Diphoorn "
],
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/dexie/Dexie.js/issues"
},
"scripts": {
"build": "just-build",
"watch": "just-build --watch",
"test": "pnpm run build && pnpm run test:typings && pnpm run test:unit && pnpm run test:integration",
"test:unit": "karma start test/unit/karma.conf.js --single-run",
"test:integration": "karma start test/integration/karma.conf.js --single-run",
"test:typings": "tsc -p test/test-typings/",
"test:unit:debug": "karma start test/unit/karma.conf.js --log-level debug",
"test:integration:debug": "karma start test/integrations/karma.conf.js --log-level debug",
"test:ltcloud": "cross-env LAMBDATEST=true pnpm run test:ltTunnel & sleep 10 && pnpm run test:unit; UNIT_STATUS=$?; exit $UNIT_STATUS",
"test:ltTunnel": "node ../../test/lt-local",
"test:ltcloud:integration": "cross-env LAMBDATEST=true pnpm run test:integration; UNIT_STATUS=$?; kill $(cat tunnel.pid); exit $UNIT_STATUS"
},
"just-build": {
"default": [
"just-build release"
],
"dev": [
"just-build dexie-syncable"
],
"dexie-syncable": [
"# Build UMD module and the tests (two bundles)",
"tsc --allowJs --moduleResolution node --lib es2020,dom -t es5 -m es2015 --outDir tools/tmp/es5 --rootDir ../.. --sourceMap src/Dexie.Syncable.js test/unit/unit-tests-all.js [--watch 'Compilation complete.']",
"rollup -c tools/build-configs/rollup.config.mjs",
"rollup -c tools/build-configs/rollup.tests.config.mjs",
"node tools/replaceVersionAndDate.js dist/dexie-syncable.js",
"node tools/replaceVersionAndDate.js test/unit/bundle.js",
"# eslint ",
"eslint src --cache"
],
"release": [
"just-build dexie-syncable",
"# Copy Dexie.Syncable.d.ts to dist and replace version in it",
"node -e \"fs.writeFileSync('dist/dexie-syncable.d.ts', fs.readFileSync('src/Dexie.Syncable.d.ts'))\"",
"node tools/replaceVersionAndDate.js dist/dexie-syncable.d.ts",
"# Minify the default ES5 UMD module",
"cd dist",
"uglifyjs dexie-syncable.js -m -c negate_iife=0 -o dexie-syncable.min.js --source-map"
]
},
"homepage": "https://dexie.org",
"peerDependencies": {
"dexie": "workspace:^",
"dexie-observable": "workspace:^"
},
"devDependencies": {
"dexie": "workspace:^",
"dexie-observable": "workspace:^",
"eslint": "^5.16.0",
"just-build": "^0.9.24",
"qunit": "2.10.0",
"qunitjs": "1.23.1",
"typescript": "^5.3.3",
"uglify-js": "^3.5.6"
}
}
================================================
FILE: addons/Dexie.Syncable/src/.eslintrc.json
================================================
{
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
}
},
"rules": {
"no-undef": ["error"],
"no-unused-vars": 1,
"no-console": 0,
"no-empty": 0
},
"globals": {
"indexedDB": false,
"IDBKeyRange": false,
"setTimeout": false,
"clearTimeout": false,
"Symbol": false,
"setImmediate": false,
"console": false,
"self": false,
"window": false,
"global": false,
"navigator": false,
"location": false,
"chrome": false,
"document": false,
"MutationObserver": false,
"CustomEvent": false,
"dispatchEvent": false,
"localStorage": false
}
}
================================================
FILE: addons/Dexie.Syncable/src/Dexie.Syncable.d.ts
================================================
// Type definitions for dexie-syncable v{version}
// Project: https://github.com/dfahlander/Dexie.js/tree/master/addons/Dexie.Syncable
// Definitions by: David Fahlander
import Dexie, { DexieEventSet } from 'dexie';
import 'dexie-observable';
import { ISyncProtocol, SyncStatus } from '../api';
import {IDatabaseChange} from 'dexie-observable/api';
export interface SyncableEventSet extends DexieEventSet {
(eventName: 'statusChanged', subscriber: (status: number, url: string) => void): void;
}
//
// Extend Dexie interface
//
declare module 'dexie' {
interface Dexie {
syncable: {
version: string;
/**
* Connect to given URL using given protocol and options. See documentation at:
* https://github.com/dfahlander/Dexie.js/wiki/db.syncable.connect()
*/
connect(protocol: string, url: string, options?: any): Dexie.Promise;
/**
* Stop syncing with given url.. See docs at:
* https://github.com/dfahlander/Dexie.js/wiki/db.syncable.disconnect()
*/
disconnect(url: string): Dexie.Promise;
/**
* Stop syncing and delete all sync state for given URL. See docs at:
* https://github.com/dfahlander/Dexie.js/wiki/db.syncable.delete()
*/
delete(url: string): Dexie.Promise;
/**
* List remote URLs. See docs at:
* https://github.com/dfahlander/Dexie.js/wiki/db.syncable.list()
*/
list (): Dexie.Promise;
/**
* Get sync status for given URL. See docs at:
* https://github.com/dfahlander/Dexie.js/wiki/db.syncable.getStatus()
*/
getStatus(url: string): Dexie.Promise;
/**
* Syncable events. See docs at:
* https://github.com/dfahlander/Dexie.js/wiki/db.syncable.on('statusChanged')
*/
on: SyncableEventSet;
}
/**
* Table used for storing uncommitted changes when downloading partial change sets from
* a sync server.
*
* Each change is bound to a node id (represents the remote server that the change was
* downloaded from)
*/
_uncommittedChanges: Dexie.Table;
}
interface DexieConstructor {
Syncable: {
(db: Dexie) : void;
version: string;
/**
* See documentation at:
* https://dexie.org/docs/Syncable/Dexie.Syncable.registerSyncProtocol()
*/
registerSyncProtocol: (name: string, prototocolInstance: ISyncProtocol) => void;
/** Translates a sync status number into a string "ERROR_WILL_RETRY", "ERROR", etc */
StatusTexts: {[syncStatus:number]: string};
}
}
}
//
// Extend dexie-observable interfaces
//
declare module "dexie-observable" {
// Extend SyncNode interface from Dexie.Observable to
// allow storing remote nodes in table _syncNodes.
interface SyncNode {
url: string, // Only applicable for "remote" nodes. Only used in Dexie.Syncable.
syncProtocol: string, // Tells which implementation of ISyncProtocol to use for remote syncing.
syncContext: any,
syncOptions: any,
status: number,
appliedRemoteRevision: any,
remoteBaseRevisions: { local: number, remote: any }[],
dbUploadState: {
tablesToUpload: string[],
currentTable: string,
currentKey: any,
localBaseRevision: number
}
}
}
export default Dexie.Syncable;
================================================
FILE: addons/Dexie.Syncable/src/Dexie.Syncable.js
================================================
/* ==========================================================================
* dexie-syncable.js
* ==========================================================================
*
* Dexie addon for syncing indexedDB with remote endpoints.
*
* By David Fahlander, david.fahlander@gmail.com,
* Nikolas Poniros, https://github.com/nponiros
*
* ==========================================================================
*
* Version {version}, {date}
*
* https://dexie.org
*
* Apache License Version 2.0, January 2004, http://www.apache.org/licenses/
*
*/
import Dexie from "dexie";
// Depend on 'dexie-observable'
// To support both ES6,AMD,CJS and UMD (plain script), we just import it and then access it as "Dexie.Observable".
// That way, our plugin works in all UMD cases.
// If target platform would only be module based (ES6/AMD/CJS), we could have done 'import Observable from "dexie-observable"'.
import "dexie-observable";
import initSyncableConnect from './syncable-connect';
import initConnectFn from './connect-fn';
import {Statuses, StatusTexts} from './statuses';
var override = Dexie.override,
Promise = Dexie.Promise,
Observable = Dexie.Observable;
/** Dexie addon for remote database sync.
*
* @param {Dexie} db
*/
function Syncable (db) {
if (!/^(3|4)\./.test(Dexie.version))
throw new Error(`Missing dexie version 3.x or 4.x`);
if (!db.observable || (db.observable.version !== "{version}" && !/^(3|4)\./.test(db.observable.version)))
throw new Error(`Missing dexie-observable version 3.x or 4.x`);
if (db.syncable) {
if (db.syncable.version !== "{version}") throw new Error(`Mixed versions of dexie-syncable: "{version}" vs "${db.syncable.version}"`);
return; // Addon already active.
}
var activePeers = [];
const connectFn = initConnectFn(db, activePeers);
const syncableConnect = initSyncableConnect(db, connectFn);
db.on('message', function(msg) {
// Message from other local node arrives...
Dexie.vip(function() {
if (msg.type === 'connect') {
// We are master node and another non-master node wants us to do the connect.
db.syncable.connect(msg.message.protocolName, msg.message.url, msg.message.options).then(msg.resolve, msg.reject);
} else if (msg.type === 'disconnect') {
db.syncable.disconnect(msg.message.url).then(msg.resolve, msg.reject);
} else if (msg.type === 'syncStatusChanged') {
// We are client and a master node informs us about syncStatus change.
// Lookup the connectedProvider and call its event
db.syncable.on.statusChanged.fire(msg.message.newStatus, msg.message.url);
}
});
});
db.on('cleanup', function(weBecameMaster) {
// A cleanup (done in Dexie.Observable) may result in that a master node is removed and we become master.
if (weBecameMaster) {
// We took over the master role in Observable's cleanup method.
// We should connect to remote servers now.
// At this point, also reconnect servers with status ERROR_WILL_RETRY as well as plain ERROR.
// Reason to reconnect to those with plain "ERROR" is that the ERROR state may occur when a database
// connection has been closed. The new master would then be expected to reconnect.
// Also, this is not an infinite poll(). This is rare event that a new browser tab takes over from
// an old closed one.
Dexie.ignoreTransaction(()=>Dexie.vip(()=>{
return db._syncNodes.where({type: 'remote'})
.filter(node => node.status !== Statuses.OFFLINE)
.toArray(connectedRemoteNodes => Promise.all(connectedRemoteNodes.map(node =>
db.syncable.connect(node.syncProtocol, node.url, node.syncOptions).catch(e => {
console.warn(`Dexie.Syncable: Could not connect to ${node.url}. ${e.stack || e}`);
})
)));
})).catch('DatabaseClosedError', ()=>{});
}
});
// "ready" subscriber for the master node that makes sure it will always connect to sync server
// when the database opens. It will not wait for the connection to complete, just initiate the
// connection so that it will continue syncing as long as the database is open.
// Dexie.Observable's 'ready' subscriber will have been invoked prior to this, making sure
// that db._localSyncNode exists and persisted before this subscriber kicks in.
db.on('ready', function onReady() {
// Again, in onReady: If we ARE master, make sure to connect to remote servers that is in a connected state.
if (db._localSyncNode && db._localSyncNode.isMaster) {
// Make sure to connect to remote servers that is in a connected state (NOT OFFLINE or ERROR!)
// This "ready" subscriber will never be the one performing the initial sync request, because
// even after calling db.syncable.connect(), there won't exist any "remote" sync node yet.
// Instead, db.syncable.connect() will subscribe to "ready" also, and that subscriber will be
// called after this one. There, in that subscriber, the initial sync request will take place
// and the "remote" node will be created so that this "ready" subscriber can auto-connect the
// next time this database is opened.
// CONCLUSION: We can always assume that the local DB has been in sync with the server at least
// once in the past for each "connectedRemoteNode" we find in query below.
// Don't halt db.ready while connecting (i.e. we do not return a promise here!)
db._syncNodes
.where('type').equals('remote')
.and(node => node.status !== Statuses.OFFLINE)
.toArray(connectedRemoteNodes => {
// There are connected remote nodes that we must manage (or take over to manage)
connectedRemoteNodes.forEach(
node => db.syncable.connect(
node.syncProtocol,
node.url,
node.syncOptions)
.catch (()=>{}) // A failure will be triggered in on('statusChanged'). We can ignore.
);
}).catch('DatabaseClosedError', ()=>{});
}
}, true); // True means the ready event will survive a db reopen - db.close()/db.open()
db.syncable = {
version: "{version}"
};
db.syncable.getStatus = function(url, cb) {
if (db.isOpen()) {
return Dexie.vip(function() {
return db._syncNodes.where('url').equals(url).first(function(node) {
return node ? node.status : Statuses.OFFLINE;
});
}).then(cb);
} else {
return Promise.resolve(Syncable.Statuses.OFFLINE).then(cb);
}
};
db.syncable.getOptions = function(url, cb) {
return db.transaction('r?', db._syncNodes, () => {
return db._syncNodes.where('url').equals(url).first(function(node) {
return node.syncOptions;
}).then(cb);
});
};
db.syncable.list = function() {
return db.transaction('r?', db._syncNodes, ()=>{
return db._syncNodes.where('type').equals('remote').toArray(function(a) {
return a.map(function(node) { return node.url; });
});
});
};
db.syncable.on = Dexie.Events(db, { statusChanged: "asap" });
db.syncable.disconnect = function(url) {
return Dexie.ignoreTransaction(()=>{
return Promise.resolve().then(()=>{
if (db._localSyncNode && db._localSyncNode.isMaster) {
return Promise.all(activePeers.filter(peer => peer.url === url).map(peer => {
return peer.disconnect(Statuses.OFFLINE);
}));
} else {
return db._syncNodes.where('isMaster').above(0).first(masterNode => {
return db.observable.sendMessage('disconnect', { url: url }, masterNode.id, {wantReply: true});
});
}
}).then(()=>{
return db._syncNodes.where("url").equals(url).modify(node => {
node.status = Statuses.OFFLINE;
});
});
});
};
db.syncable.connect = function(protocolName, url, options) {
options = options || {}; // Make sure options is always an object because 1) Provider expects it to be. 2) We'll be persisting it and you cannot persist undefined.
var protocolInstance = Syncable.registeredProtocols[protocolName];
if (protocolInstance) {
return syncableConnect(protocolInstance, protocolName, url, options);
} else {
return Promise.reject(
new Error("ISyncProtocol '" + protocolName + "' is not registered in Dexie.Syncable.registerSyncProtocol()")
);
}
};
db.syncable.delete = function(url) {
return db.syncable.disconnect(url).then(()=>{
return db.transaction('rw!', db._syncNodes, db._changes, db._uncommittedChanges, ()=>{
// Find the node(s)
// Several can be found, as detected by @martindiphoorn,
// let's delete them and cleanup _uncommittedChanges and _changes
// accordingly.
let nodeIDsToDelete;
return db._syncNodes
.where("url").equals(url)
.toArray(nodes => nodes.map(node => node.id))
.then(nodeIDs => {
nodeIDsToDelete = nodeIDs;
// Delete the syncNode that represents the remote endpoint.
return db._syncNodes.where('id').anyOf(nodeIDs).delete()
})
.then (() =>
// In case there were uncommittedChanges belonging to this, delete them as well
db._uncommittedChanges.where('node').anyOf(nodeIDsToDelete).delete());
}).then(()=> {
// Spawn background job to delete old changes, now that a node has been deleted,
// there might be changes in _changes table that is not needed to keep anymore.
// This is done in its own transaction, or possible several transaction to prohibit
// starvation
Observable.deleteOldChanges(db);
});
});
};
db.syncable.unsyncedChanges = function(url) {
return db._syncNodes.where("url").equals(url).first(function(node) {
return db._changes.where('rev').above(node.myRevision).toArray();
});
};
db.close = override(db.close, function(origClose) {
return function() {
activePeers.forEach(function(peer) {
peer.disconnect();
});
return origClose.apply(this, arguments);
};
});
Object.defineProperty(
db.observable.SyncNode.prototype,
'save', {
enumerable: false,
configurable: true,
writable: true,
value() {
return db.transaction('rw?', db._syncNodes, () => {
return db._syncNodes.put(this);
});
}
});
}
Syncable.version = "{version}";
Syncable.Statuses = Statuses;
Syncable.StatusTexts = StatusTexts;
Syncable.registeredProtocols = {}; // Map when key is the provider name.
Syncable.registerSyncProtocol = function(name, protocolInstance) {
///
/// Register a synchronization protocol that can synchronize databases with remote servers.
///
/// Provider name
/// Implementation of ISyncProtocol
const partialsThreshold = protocolInstance.partialsThreshold;
if (typeof partialsThreshold === 'number') {
// Don't allow NaN or negative threshold
if (isNaN(partialsThreshold) || partialsThreshold < 0) {
throw new Error('The given number for the threshold is not supported');
}
// If the threshold is 0 we will not send any client changes but will get server changes
} else {
// Use Infinity as the default so simple protocols don't have to care about partial synchronization
protocolInstance.partialsThreshold = Infinity;
}
Syncable.registeredProtocols[name] = protocolInstance;
};
if (Dexie.Syncable) {
if (Dexie.Syncable.version !== "{version}") {
throw new Error (`Mixed versions of dexie-syncable: "{version}" vs "${Dexie.Syncable.version}"`);
}
} else {
// Register addon in Dexie:
Dexie.Syncable = Syncable;
Dexie.addons.push(Syncable);
}
export default Dexie.Syncable;
================================================
FILE: addons/Dexie.Syncable/src/PersistedContext.js
================================================
import Dexie from 'dexie';
export default function initPersistedContext(node) {
//
// PersistedContext : IPersistedContext
//
return class PersistedContext {
constructor(nodeID, otherProps) {
this.nodeID = nodeID;
if (otherProps) Dexie.extend(this, otherProps);
}
save() {
// Store this instance in the syncContext property of the node it belongs to.
return Dexie.vip(() => {
return node.save();
});
}
}
}
================================================
FILE: addons/Dexie.Syncable/src/apply-changes.js
================================================
import { CREATE, DELETE, UPDATE } from './change_types';
import bulkUpdate from './bulk-update';
export default function initApplyChanges(db) {
return function applyChanges(changes) {
let collectedChanges = {};
changes.forEach((change) => {
if (!collectedChanges.hasOwnProperty(change.table)) {
collectedChanges[change.table] = { [CREATE]: [], [DELETE]: [], [UPDATE]: [] };
}
collectedChanges[change.table][change.type].push(change);
});
let table_names = Object.keys(collectedChanges);
let tables = table_names.map((table) => db.table(table));
return db.transaction("rw", tables, () => {
table_names.forEach((table_name) => {
const table = db.table(table_name);
const specifyKeys = !table.schema.primKey.keyPath;
const createChangesToApply = collectedChanges[table_name][CREATE];
const deleteChangesToApply = collectedChanges[table_name][DELETE];
const updateChangesToApply = collectedChanges[table_name][UPDATE];
if (createChangesToApply.length > 0)
table.bulkPut(createChangesToApply.map(c => c.obj), specifyKeys ?
createChangesToApply.map(c => c.key) : undefined);
if (updateChangesToApply.length > 0)
bulkUpdate(table, updateChangesToApply);
if (deleteChangesToApply.length > 0)
table.bulkDelete(deleteChangesToApply.map(c => c.key));
});
});
};
}
================================================
FILE: addons/Dexie.Syncable/src/bulk-update.js
================================================
import Dexie from 'dexie';
export default function bulkUpdate(table, changes) {
let keys = changes.map(c => c.key);
let map = {};
// Retrieve current object of each change to update and map each
// found object's primary key to the existing object:
return table.where(':id').anyOf(keys).raw().each((obj, cursor) => {
map[cursor.primaryKey+''] = obj;
}).then(()=>{
// Filter away changes whose key wasn't found in the local database
// (we can't update them if we do not know the existing values)
let updatesThatApply = changes.filter(c => map.hasOwnProperty(c.key+''));
// Apply modifications onto each existing object (in memory)
// and generate array of resulting objects to put using bulkPut():
let objsToPut = updatesThatApply.map (c => {
let curr = map[c.key+''];
Object.keys(c.mods).forEach(keyPath => {
Dexie.setByKeyPath(curr, keyPath, c.mods[keyPath]);
});
return curr;
});
return table.bulkPut(objsToPut);
});
}
================================================
FILE: addons/Dexie.Syncable/src/change_types.js
================================================
// Change Types
export const CREATE = 1;
export const UPDATE = 2;
export const DELETE = 3;
================================================
FILE: addons/Dexie.Syncable/src/combine-create-and-update.js
================================================
import Dexie from 'dexie';
export default function combineCreateAndUpdate(prevChange, nextChange) {
var clonedChange = Dexie.deepClone(prevChange); // Clone object before modifying since the earlier change in db.changes[] would otherwise be altered.
Object.keys(nextChange.mods).forEach(function (keyPath) {
Dexie.setByKeyPath(clonedChange.obj, keyPath, nextChange.mods[keyPath]);
});
return clonedChange;
}
================================================
FILE: addons/Dexie.Syncable/src/combine-update-and-update.js
================================================
import Dexie from 'dexie';
export default function combineUpdateAndUpdate(prevChange, nextChange) {
var clonedChange = Dexie.deepClone(prevChange); // Clone object before modifying since the earlier change in db.changes[] would otherwise be altered.
Object.keys(nextChange.mods).forEach(function (keyPath) {
// If prev-change was changing a parent path of this keyPath, we must update the parent path rather than adding this keyPath
var hadParentPath = false;
Object.keys(prevChange.mods).filter(function (parentPath) { return keyPath.indexOf(parentPath + '.') === 0; }).forEach(function (parentPath) {
Dexie.setByKeyPath(clonedChange.mods[parentPath], keyPath.substr(parentPath.length + 1), nextChange.mods[keyPath]);
hadParentPath = true;
});
if (!hadParentPath) {
// Add or replace this keyPath and its new value
clonedChange.mods[keyPath] = nextChange.mods[keyPath];
}
// In case prevChange contained sub-paths to the new keyPath, we must make sure that those sub-paths are removed since
// we must mimic what would happen if applying the two changes after each other:
Object.keys(prevChange.mods).filter(function (subPath) { return subPath.indexOf(keyPath + '.') === 0; }).forEach(function (subPath) {
delete clonedChange.mods[subPath];
});
});
return clonedChange;
}
================================================
FILE: addons/Dexie.Syncable/src/connect-fn.js
================================================
import Dexie from 'dexie';
import initGetOrCreateSyncNode from './get-or-create-sync-node';
import initConnectProtocol from './connect-protocol';
import {Statuses} from './statuses';
export default function initConnectFn(db, activePeers) {
return function connect(protocolInstance, protocolName, url, options, dbAliveID) {
///
var existingPeer = activePeers.filter(function (peer) {
return peer.url === url;
});
if (existingPeer.length > 0) {
const activePeer = existingPeer[0];
const diffObject = {};
Dexie.getObjectDiff(activePeer.syncOptions, options, diffObject);
// Options have been changed
// We need to disconnect and reconnect
if (Object.keys(diffObject).length !== 0) {
return db.syncable.disconnect(url)
.then(() => {
return execConnect();
})
} else {
// Never create multiple syncNodes with same protocolName and url. Instead, let the next call to connect() return the same promise that
// have already been started and eventually also resolved. If promise has already resolved (node connected), calling existing promise.then() will give a callback directly.
return existingPeer[0].connectPromise;
}
}
function execConnect() {
// Use an object otherwise we wouldn't be able to get the reject promise from
// connectProtocol
var rejectConnectPromise = {p: null};
const connectProtocol = initConnectProtocol(db, protocolInstance, dbAliveID, options, rejectConnectPromise);
const getOrCreateSyncNode = initGetOrCreateSyncNode(db, protocolName, url);
var connectPromise = getOrCreateSyncNode(options).then(function (node) {
return connectProtocol(node, activePeer);
});
var disconnected = false;
var activePeer = {
url: url,
status: Statuses.OFFLINE,
connectPromise: connectPromise,
syncOptions: options,
on: Dexie.Events(null, "disconnect"),
disconnect: function (newStatus, error) {
var pos = activePeers.indexOf(activePeer);
if (pos >= 0) activePeers.splice(pos, 1);
if (error && rejectConnectPromise.p) rejectConnectPromise.p(error);
if (!disconnected) {
activePeer.on.disconnect.fire(newStatus, error);
}
disconnected = true;
}
};
activePeers.push(activePeer);
return connectPromise;
}
return execConnect();
};
}
================================================
FILE: addons/Dexie.Syncable/src/connect-protocol.js
================================================
import Dexie from 'dexie';
import initEnqueue from './enqueue';
import initSaveToUncommittedChanges from './save-to-uncommitted-changes';
import initFinallyCommitAllChanges from './finally-commit-all-changes';
import initGetLocalChangesForNode from './get-local-changes-for-node/get-local-changes-for-node';
import {Statuses} from './statuses';
const Promise = Dexie.Promise;
export default function initConnectProtocol(db, protocolInstance, dbAliveID, options, rejectConnectPromise) {
const enqueue = initEnqueue(db);
var hasMoreToGive = {hasMoreToGive: true};
function stillAlive() {
// A better method than doing db.isOpen() because the same db instance may have been reopened, but then this sync call should be dead
// because the new instance should be considered a fresh instance and will have another local node.
return db._localSyncNode && db._localSyncNode.id === dbAliveID;
}
return function connectProtocol(node, activePeer) {
///
const getLocalChangesForNode = initGetLocalChangesForNode(db, hasMoreToGive, protocolInstance.partialsThreshold);
const url = activePeer.url;
function changeStatusTo(newStatus) {
if (node.status !== newStatus) {
node.status = newStatus;
node.save().then(()=> {
db.syncable.on.statusChanged.fire(newStatus, url);
// Also broadcast message to other nodes about the status
db.observable.broadcastMessage("syncStatusChanged", {newStatus: newStatus, url: url}, false);
}).catch('DatabaseClosedError', ()=> {
});
}
}
activePeer.on('disconnect', function (newStatus) {
if (!isNaN(newStatus)) changeStatusTo(newStatus);
});
var connectedContinuation;
changeStatusTo(Statuses.CONNECTING);
return doSync();
function doSync() {
// Use enqueue() to ensure only a single promise execution at a time.
return enqueue(doSync, function () {
// By returning the Promise returned by getLocalChangesForNode() a final catch() on the sync() method will also catch error occurring in entire sequence.
return getLocalChangesForNode_autoAckIfEmpty(node, sendChangesToProvider);
}, dbAliveID);
}
function sendChangesToProvider(changes, remoteBaseRevision, partial, nodeModificationsOnAck) {
// Create a final Promise for the entire sync() operation that will resolve when provider calls onSuccess().
// By creating finalPromise before calling protocolInstance.sync() it is possible for provider to call onError() immediately if it wants.
var finalSyncPromise = new Promise(function (resolve, reject) {
rejectConnectPromise.p = function (err) {
reject(err);
};
Dexie.asap(function () {
try {
protocolInstance.sync(
node.syncContext,
url,
options,
remoteBaseRevision,
node.appliedRemoteRevision,
changes,
partial,
applyRemoteChanges,
onChangesAccepted,
function (continuation) {
resolve(continuation);
},
onError);
} catch (ex) {
onError(ex, Infinity);
}
function onError(error, again) {
reject(error);
if (stillAlive()) {
if (!isNaN(again) && again < Infinity) {
setTimeout(function () {
if (stillAlive()) {
changeStatusTo(Statuses.SYNCING);
doSync().catch('DatabaseClosedError', abortTheProvider);
}
}, again);
changeStatusTo(Statuses.ERROR_WILL_RETRY, error);
if (connectedContinuation && connectedContinuation.disconnect) connectedContinuation.disconnect();
connectedContinuation = null;
} else {
abortTheProvider(error); // Will fire ERROR on statusChanged event.
}
}
}
});
});
return finalSyncPromise.then(function () {
// Resolve caller of db.syncable.connect() with undefined. Not with continuation!
return undefined;
}).finally(()=> {
// In case error happens after connect, don't try reject the connect promise anymore.
// This is important. A Dexie unit test that verifies unhandled rejections will fail when Dexie.Syncable addon
// is active and this happens. It would fire unhandledrejection but that we do not want.
rejectConnectPromise.p = null;
});
function onChangesAccepted() {
Object.keys(nodeModificationsOnAck).forEach(function (keyPath) {
Dexie.setByKeyPath(node, keyPath, nodeModificationsOnAck[keyPath]);
});
// We dont know if onSuccess() was called by provider yet. If it's already called, finalPromise.then() will execute immediately,
// otherwise it will execute when finalSyncPromise resolves.
finalSyncPromise.then(continueSendingChanges);
return node.save();
}
}
function abortTheProvider(error) {
activePeer.disconnect(Statuses.ERROR, error);
}
function getLocalChangesForNode_autoAckIfEmpty(node, cb) {
return getLocalChangesForNode(node, function autoAck(changes, remoteBaseRevision, partial, nodeModificationsOnAck) {
if (changes.length === 0 && 'myRevision' in nodeModificationsOnAck && nodeModificationsOnAck.myRevision !== node.myRevision) {
Object.keys(nodeModificationsOnAck).forEach(function (keyPath) {
Dexie.setByKeyPath(node, keyPath, nodeModificationsOnAck[keyPath]);
});
node.save().catch('DatabaseClosedError', ()=> {
});
return getLocalChangesForNode(node, autoAck);
} else {
return cb(changes, remoteBaseRevision, partial, nodeModificationsOnAck);
}
});
}
function applyRemoteChanges(remoteChanges, remoteRevision, partial/*, clear*/) {
const saveToUncommittedChanges = initSaveToUncommittedChanges(db, node);
const finallyCommitAllChanges = initFinallyCommitAllChanges(db, node);
return enqueue(applyRemoteChanges, function () {
if (!stillAlive()) return Promise.reject(new Dexie.DatabaseClosedError());
// FIXTHIS: Check what to do if clear() is true!
return (partial ? saveToUncommittedChanges(remoteChanges, remoteRevision) : finallyCommitAllChanges(remoteChanges, remoteRevision))
.catch(function (error) {
abortTheProvider(error);
return Promise.reject(error);
});
}, dbAliveID);
}
//
//
// Continuation Patterns Follows
//
//
function continueSendingChanges(continuation) {
if (!stillAlive()) { // Database was closed.
if (continuation.disconnect)
continuation.disconnect();
return;
}
connectedContinuation = continuation;
activePeer.on('disconnect', function () {
if (connectedContinuation) {
if (connectedContinuation.react) {
try {
// react pattern must provide a disconnect function.
connectedContinuation.disconnect();
} catch (e) {
}
}
connectedContinuation = null; // Stop poll() pattern from polling again and abortTheProvider() from being called twice.
}
});
if (continuation.react) {
continueUsingReactPattern(continuation);
} else {
continueUsingPollPattern(continuation);
}
}
// React Pattern (eager)
function continueUsingReactPattern(continuation) {
var changesWaiting, // Boolean
isWaitingForServer; // Boolean
function onChanges() {
if (connectedContinuation) {
changeStatusTo(Statuses.SYNCING);
if (isWaitingForServer)
changesWaiting = true;
else {
reactToChanges();
}
}
}
db.on('changes', onChanges);
activePeer.on('disconnect', function () {
db.on.changes.unsubscribe(onChanges);
});
function reactToChanges() {
if (!connectedContinuation) return;
changesWaiting = false;
isWaitingForServer = true;
getLocalChangesForNode_autoAckIfEmpty(node, function (changes, remoteBaseRevision, partial, nodeModificationsOnAck) {
if (!connectedContinuation) return;
if (changes.length > 0) {
continuation.react(changes, remoteBaseRevision, partial, function onChangesAccepted() {
Object.keys(nodeModificationsOnAck).forEach(function (keyPath) {
Dexie.setByKeyPath(node, keyPath, nodeModificationsOnAck[keyPath]);
});
node.save().catch('DatabaseClosedError', ()=> {
});
// More changes may be waiting:
reactToChanges();
});
} else {
isWaitingForServer = false;
if (changesWaiting) {
// A change jumped in between the time-spot of quering _changes and getting called back with zero changes.
// This is an expreemely rare scenario, and eventually impossible. But need to be here because it could happen in theory.
reactToChanges();
} else {
changeStatusTo(Statuses.ONLINE);
}
}
}).catch(ex => {
console.error(`Got ${ex.message} caught by reactToChanges`);
abortTheProvider(ex);
});
}
reactToChanges();
}
// Poll Pattern
function continueUsingPollPattern() {
function syncAgain() {
getLocalChangesForNode_autoAckIfEmpty(node, function (changes, remoteBaseRevision, partial, nodeModificationsOnAck) {
protocolInstance.sync(node.syncContext, url, options, remoteBaseRevision, node.appliedRemoteRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError);
function onChangesAccepted() {
Object.keys(nodeModificationsOnAck).forEach(function (keyPath) {
Dexie.setByKeyPath(node, keyPath, nodeModificationsOnAck[keyPath]);
});
node.save().catch('DatabaseClosedError', ()=> {
});
}
function onSuccess(continuation) {
if (!connectedContinuation) {
// Got disconnected before succeeding. Quit.
return;
}
connectedContinuation = continuation;
if (partial) {
// We only sent partial changes. Need to do another round asap.
syncAgain();
} else {
// We've sent all changes now (in sync!)
if (!isNaN(continuation.again) && continuation.again < Infinity) {
// Provider wants to keep polling. Set Status to ONLINE.
changeStatusTo(Statuses.ONLINE);
setTimeout(function () {
if (connectedContinuation) {
changeStatusTo(Statuses.SYNCING);
syncAgain();
}
}, continuation.again);
} else {
// Provider seems finished polling. Since we are never going to poll again,
// disconnect provider and set status to OFFLINE until another call to db.syncable.connect().
activePeer.disconnect(Statuses.OFFLINE);
}
}
}
function onError(error, again) {
if (!isNaN(again) && again < Infinity) {
if (connectedContinuation) {
setTimeout(function () {
if (connectedContinuation) {
changeStatusTo(Statuses.SYNCING);
syncAgain();
}
}, again);
changeStatusTo(Statuses.ERROR_WILL_RETRY);
} // else status is already changed since we got disconnected.
} else {
abortTheProvider(error); // Will fire ERROR on onStatusChanged.
}
}
}).catch(abortTheProvider);
}
if (hasMoreToGive.hasMoreToGive) {
syncAgain();
} else if (connectedContinuation && !isNaN(connectedContinuation.again) && connectedContinuation.again < Infinity) {
changeStatusTo(Statuses.ONLINE);
setTimeout(function () {
if (connectedContinuation) {
changeStatusTo(Statuses.SYNCING);
syncAgain();
}
}, connectedContinuation.again);
} else {
// Provider seems finished polling. Since we are never going to poll again,
// disconnect provider and set status to OFFLINE until another call to db.syncable.connect().
activePeer.disconnect(Statuses.OFFLINE);
}
}
};
}
================================================
FILE: addons/Dexie.Syncable/src/enqueue.js
================================================
import Dexie from 'dexie';
export default function initEnqueue(db) {
return function enqueue(context, fn, instanceID) {
function _enqueue() {
if (!context.ongoingOperation) {
context.ongoingOperation = Dexie.ignoreTransaction(function () {
return Dexie.vip(function () {
return fn();
});
}).finally(()=> {
delete context.ongoingOperation;
});
} else {
context.ongoingOperation = context.ongoingOperation.then(function () {
return enqueue(context, fn, instanceID);
});
}
return context.ongoingOperation;
}
if (!instanceID) {
// Caller wants to enqueue it until database becomes open.
if (db.isOpen()) {
return _enqueue();
} else {
return Dexie.Promise.reject(new Dexie.DatabaseClosedError());
}
} else if (db._localSyncNode && instanceID === db._localSyncNode.id) {
// DB is already open but queue doesn't want it to be queued if database has been closed (request bound to current instance of DB)
return _enqueue();
} else {
return Dexie.Promise.reject(new Dexie.DatabaseClosedError());
}
};
}
================================================
FILE: addons/Dexie.Syncable/src/finally-commit-all-changes.js
================================================
import Dexie from 'dexie';
import initApplyChanges from './apply-changes';
export default function initFinallyCommitAllChanges(db, node) {
const applyChanges = initApplyChanges(db);
return function finallyCommitAllChanges(changes, remoteRevision) {
// 1. Open a write transaction on all tables in DB
const tablesToIncludeInTrans = db.tables.filter(table => table.name === '_changes' ||
table.name === '_uncommittedChanges' ||
table.schema.observable);
return db.transaction('rw!', tablesToIncludeInTrans, () => {
var trans = Dexie.currentTransaction;
var localRevisionBeforeChanges = 0;
return db._changes.orderBy('rev').last(function (lastChange) {
// Store what revision we were at before committing the changes
localRevisionBeforeChanges = (lastChange && lastChange.rev) || 0;
}).then(() => {
// Specify the source. Important for the change consumer to ignore changes originated from self!
trans.source = node.id;
// 2. Apply uncommitted changes and delete each uncommitted change
return db._uncommittedChanges.where('node').equals(node.id).toArray();
}).then(function (uncommittedChanges) {
return applyChanges(uncommittedChanges);
}).then(function () {
return db._uncommittedChanges.where('node').equals(node.id).delete();
}).then(function () {
// 3. Apply last chunk of changes
return applyChanges(changes);
}).then(function () {
// Get what revision we are at now:
return db._changes.orderBy('rev').last();
}).then(function (lastChange) {
var currentLocalRevision = (lastChange && lastChange.rev) || 0;
// 4. Update node states (appliedRemoteRevision, remoteBaseRevisions and eventually myRevision)
node.appliedRemoteRevision = remoteRevision;
node.remoteBaseRevisions.push({remote: remoteRevision, local: currentLocalRevision});
if (node.myRevision === localRevisionBeforeChanges) {
// If server was up-to-date before we added new changes from the server, update myRevision to last change
// because server is still up-to-date! This is also important in order to prohibit getLocalChangesForNode() from
// ever sending an empty change list to server, which would otherwise be done every second time it would send changes.
node.myRevision = currentLocalRevision;
}
// Garbage collect remoteBaseRevisions not in use anymore:
if (node.remoteBaseRevisions.length > 1) {
for (var i = node.remoteBaseRevisions.length - 1; i > 0; --i) {
if (node.myRevision >= node.remoteBaseRevisions[i].local) {
node.remoteBaseRevisions.splice(0, i);
break;
}
}
}
// We are not including _syncNodes in transaction, so this save() call will execute in its own transaction.
node.save().catch(err=> {
console.warn("Dexie.Syncable: Unable to save SyncNode after applying remote changes: " + (err.stack || err));
});
});
});
};
}
================================================
FILE: addons/Dexie.Syncable/src/get-local-changes-for-node/get-base-revision-and-max-client-revision.js
================================================
export default function getBaseRevisionAndMaxClientRevision(node) {
///
if (node.remoteBaseRevisions.length === 0)
return {
// No remoteBaseRevisions have arrived yet. No limit on clientRevision and provide null as remoteBaseRevision:
maxClientRevision: Infinity,
remoteBaseRevision: null
};
for (var i = node.remoteBaseRevisions.length - 1; i >= 0; --i) {
if (node.myRevision >= node.remoteBaseRevisions[i].local) {
// Found a remoteBaseRevision that fits node.myRevision. Return remoteBaseRevision and eventually a roof maxClientRevision pointing out where next remoteBaseRevision bases its changes on.
return {
maxClientRevision: i === node.remoteBaseRevisions.length - 1 ? Infinity : node.remoteBaseRevisions[i + 1].local,
remoteBaseRevision: node.remoteBaseRevisions[i].remote
};
}
}
// There are at least one item in the list but the server hasn't yet become up-to-date with the 0 revision from client.
return {
maxClientRevision: node.remoteBaseRevisions[0].local,
remoteBaseRevision: null
};
}
================================================
FILE: addons/Dexie.Syncable/src/get-local-changes-for-node/get-changes-since-revision.js
================================================
import {CREATE, UPDATE} from '../change_types';
import mergeChange from '../merge-change';
export default function initGetChangesSinceRevision(db, node, hasMoreToGive) {
return function getChangesSinceRevision(revision, maxChanges, maxRevision, cb) {
/// Callback that will retrieve next chunk of changes and a boolean telling if it's a partial result or not. If truthy, result is partial and there are more changes to come. If falsy, these changes are the final result.
var changeSet = {};
var numChanges = 0;
var partial = false;
var ignoreSource = node.id;
var nextRevision = revision;
return db.transaction('r', db._changes, function () {
var query = db._changes.where('rev').between(revision, maxRevision, false, true);
return query.until(() => {
if (numChanges === maxChanges) {
partial = true;
return true;
}
}).each(function (change) {
// Note the revision in nextRevision:
nextRevision = change.rev;
// change.source is set based on currentTransaction.source
if (change.source === ignoreSource) return;
// Our _changes table contains more info than required (old objs, source etc). Just make sure to include the necessary info:
var changeToSend = {
type: change.type,
table: change.table,
key: change.key
};
if (change.type === CREATE)
changeToSend.obj = change.obj;
else if (change.type === UPDATE)
changeToSend.mods = change.mods;
var id = change.table + ":" + change.key;
var prevChange = changeSet[id];
if (!prevChange) {
// This is the first change on this key. Add it unless it comes from the source that we are working against
changeSet[id] = changeToSend;
++numChanges;
} else {
// Merge the oldchange with the new change
var nextChange = changeToSend;
var mergedChange = mergeChange(prevChange, nextChange);
changeSet[id] = mergedChange;
}
});
}).then(function () {
var changes = Object.keys(changeSet).map(function (key) {
return changeSet[key];
});
hasMoreToGive.hasMoreToGive = partial;
return cb(changes, partial, {myRevision: nextRevision});
});
};
}
================================================
FILE: addons/Dexie.Syncable/src/get-local-changes-for-node/get-local-changes-for-node.js
================================================
import Dexie from 'dexie';
import getBaseRevisionAndMaxClientRevision from './get-base-revision-and-max-client-revision';
import initGetChangesSinceRevision from './get-changes-since-revision';
import initGetTableObjectsAsChanges from './get-table-objects-as-changes';
export default function initGetLocalChangesForNode(db, hasMoreToGive, partialsThreshold) {
var MAX_CHANGES_PER_CHUNK = partialsThreshold;
return function getLocalChangesForNode(node, cb) {
///
/// Based on given node's current revision and state, this function makes sure to retrieve next chunk of changes
/// for that node.
///
///
/// Callback that will retrieve next chunk of changes and a boolean telling if it's a partial result or not. If truthy, result is partial and there are more changes to come. If falsy, these changes are the final result.
const getChangesSinceRevision = initGetChangesSinceRevision(db, node, hasMoreToGive);
const getTableObjectsAsChanges = initGetTableObjectsAsChanges(db, node, MAX_CHANGES_PER_CHUNK, getChangesSinceRevision, hasMoreToGive, cb);
// Only a "remote" SyncNode created by Dexie.Syncable
// could not pass this test (remote nodes have myRevision: -1 on instantiation)
if (node.myRevision >= 0) {
// Node is based on a revision in our local database and will just need to get the changes that have occurred since that revision.
var brmcr = getBaseRevisionAndMaxClientRevision(node);
return getChangesSinceRevision(node.myRevision, MAX_CHANGES_PER_CHUNK, brmcr.maxClientRevision, function (changes, partial, nodeModificationsOnAck) {
return cb(changes, brmcr.remoteBaseRevision, partial, nodeModificationsOnAck);
});
} else {
// Node hasn't got anything from our local database yet. We will need to upload the entire DB to the node in the form of CREATE changes.
// Check if we're in the middle of already doing that:
if (node.dbUploadState === null) {
// Initialize dbUploadState
var tablesToUpload = db.tables.filter(function (table) {
return table.schema.observable;
}).map(function (table) {
return table.name;
});
if (tablesToUpload.length === 0) return Dexie.Promise.resolve(cb([], null, false, {})); // There are no synced tables at all.
var dbUploadState = {
tablesToUpload: tablesToUpload,
currentTable: tablesToUpload.shift(),
currentKey: null
};
return db._changes.orderBy('rev').last(function (lastChange) {
dbUploadState.localBaseRevision = (lastChange && lastChange.rev) || 0;
var collection = db.table(dbUploadState.currentTable).orderBy(':id');
return getTableObjectsAsChanges(dbUploadState, [], collection);
});
} else if (node.dbUploadState.currentKey) {
const collection = db.table(node.dbUploadState.currentTable).where(':id').above(node.dbUploadState.currentKey);
return getTableObjectsAsChanges(Dexie.deepClone(node.dbUploadState), [], collection);
} else {
const collection = db.table(dbUploadState.currentTable).orderBy(':id');
return getTableObjectsAsChanges(Dexie.deepClone(node.dbUploadState), [], collection);
}
}
};
}
================================================
FILE: addons/Dexie.Syncable/src/get-local-changes-for-node/get-table-objects-as-changes.js
================================================
import {CREATE} from '../change_types';
import getBaseRevisionAndMaxClientRevision from './get-base-revision-and-max-client-revision';
export default function initGetTableObjectsAsChanges(db, node, MAX_CHANGES_PER_CHUNK, getChangesSinceRevision, hasMoreToGive, cb) {
return function getTableObjectsAsChanges(state, changes, collection) {
///
///
///
var limitReached = false;
return collection.until(function () {
if (changes.length === MAX_CHANGES_PER_CHUNK) {
limitReached = true;
return true;
}
}).each(function (item, cursor) {
changes.push({
type: CREATE,
table: state.currentTable,
key: cursor.key,
obj: cursor.value
});
state.currentKey = cursor.key;
}).then(function () {
if (limitReached) {
// Limit reached. Send partial result.
hasMoreToGive.hasMoreToGive = true;
return cb(changes, null, true, {dbUploadState: state});
} else {
// Done iterating this table. Check if there are more tables to go through:
if (state.tablesToUpload.length === 0) {
// Done iterating all tables
// Now append changes occurred during our dbUpload:
var brmcr = getBaseRevisionAndMaxClientRevision(node);
return getChangesSinceRevision(state.localBaseRevision, MAX_CHANGES_PER_CHUNK - changes.length, brmcr.maxClientRevision, function (additionalChanges, partial, nodeModificationsOnAck) {
changes = changes.concat(additionalChanges);
nodeModificationsOnAck.dbUploadState = null;
return cb(changes, brmcr.remoteBaseRevision, partial, nodeModificationsOnAck);
});
} else {
// Not done iterating all tables. Continue on next table:
state.currentTable = state.tablesToUpload.shift();
return getTableObjectsAsChanges(state, changes, db.table(state.currentTable).orderBy(':id'));
}
}
});
};
}
================================================
FILE: addons/Dexie.Syncable/src/get-or-create-sync-node.js
================================================
import Dexie from 'dexie';
import initPersistedContext from './PersistedContext';
export default function initGetOrCreateSyncNode(db, protocolName, url) {
return function getOrCreateSyncNode(options) {
return db.transaction('rw', db._syncNodes, db._changes, function () {
if (!url) throw new Error("Url cannot be empty");
// Returning a promise from transaction scope will make the transaction promise resolve with the value of that promise.
return db._syncNodes.where("url").equalsIgnoreCase(url).first(function (node) {
// If we found a node it will be instanceof SyncNode as Dexie.Observable
// maps to class
if (node) {
const PersistedContext = initPersistedContext(node);
// Node already there. Make syncContext become an instance of PersistedContext:
node.syncContext = new PersistedContext(node.id, node.syncContext);
node.syncProtocol = protocolName; // In case it was changed (would be very strange but...) could happen...
node.syncOptions = options; // Options could have been changed
db._syncNodes.put(node);
} else {
// Create new node and sync everything
node = new db.observable.SyncNode();
node.myRevision = -1;
node.appliedRemoteRevision = null;
node.remoteBaseRevisions = [];
node.type = "remote";
node.syncProtocol = protocolName;
node.url = url;
node.syncOptions = options;
node.lastHeartBeat = Date.now();
node.dbUploadState = null;
const PersistedContext = initPersistedContext(node);
Dexie.Promise.resolve(function () {
// If options.initialUpload is explicitely false, set myRevision to currentRevision.
if (options.initialUpload === false)
return db._changes.toCollection().lastKey(function (currentRevision) {
node.myRevision = currentRevision;
});
}()).then(function () {
db._syncNodes.add(node).then(function (nodeID) {
node.syncContext = new PersistedContext(nodeID); // Update syncContext in db with correct nodeId.
db._syncNodes.put(node);
});
});
}
return node; // returning node will make the db.transaction()-promise resolve with this value.
});
});
};
}
================================================
FILE: addons/Dexie.Syncable/src/merge-change.js
================================================
import { CREATE, UPDATE, DELETE } from './change_types';
import combineCreateAndUpdate from './combine-create-and-update.js';
import combineUpdateAndUpdate from './combine-update-and-update.js';
export default function mergeChange(prevChange, nextChange) {
switch (prevChange.type) {
case CREATE:
switch (nextChange.type) {
case CREATE:
return nextChange; // Another CREATE replaces previous CREATE.
case UPDATE:
return combineCreateAndUpdate(prevChange, nextChange); // Apply nextChange.mods into prevChange.obj
case DELETE:
return nextChange; // Object created and then deleted. If it wasnt for that we MUST handle resent changes, we would skip entire change here. But what if the CREATE was sent earlier, and then CREATE/DELETE at later stage? It would become a ghost object in DB. Therefore, we MUST keep the delete change! If object doesnt exist, it wont harm!
}
break;
case UPDATE:
switch (nextChange.type) {
case CREATE:
return nextChange; // Another CREATE replaces previous update.
case UPDATE:
return combineUpdateAndUpdate(prevChange, nextChange); // Add the additional modifications to existing modification set.
case DELETE:
return nextChange; // Only send the delete change. What was updated earlier is no longer of interest.
}
break;
case DELETE:
switch (nextChange.type) {
case CREATE:
return nextChange; // A resurection occurred. Only create change is of interest.
case UPDATE:
return prevChange; // Nothing to do. We cannot update an object that doesnt exist. Leave the delete change there.
case DELETE:
return prevChange; // Still a delete change. Leave as is.
}
break;
}
}
================================================
FILE: addons/Dexie.Syncable/src/save-to-uncommitted-changes.js
================================================
export default function initSaveToUncommittedChanges(db, node) {
return function saveToUncommittedChanges(changes, remoteRevision) {
return db.transaction('rw!', db._uncommittedChanges, () => {
return db._uncommittedChanges.bulkAdd(changes.map(change => {
let changeWithNodeId = {
node: node.id,
type: change.type,
table: change.table,
key: change.key
};
if (change.obj) changeWithNodeId.obj = change.obj;
if (change.mods) changeWithNodeId.mods = change.mods;
return changeWithNodeId;
}));
}).then(() => {
node.appliedRemoteRevision = remoteRevision;
return node.save();
});
};
}
================================================
FILE: addons/Dexie.Syncable/src/statuses.js
================================================
export const Statuses = {
ERROR: -1, // An irreparable error occurred and the sync provider is dead.
OFFLINE: 0, // The sync provider hasn't yet become online, or it has been disconnected.
CONNECTING: 1, // Trying to connect to server
ONLINE: 2, // Connected to server and currently in sync with server
SYNCING: 3, // Syncing with server. For poll pattern, this is every poll call. For react pattern, this is when local changes are being sent to server.
ERROR_WILL_RETRY: 4 // An error occurred such as net down but the sync provider will retry to connect.
};
export const StatusTexts = {
"-1": "ERROR",
"0": "OFFLINE",
"1": "CONNECTING",
"2": "ONLINE",
"3": "SYNCING",
"4": "ERROR_WILL_RETRY"
};
================================================
FILE: addons/Dexie.Syncable/src/syncable-connect.js
================================================
import Dexie from 'dexie';
const Promise = Dexie.Promise;
export default function initSyncableConnect(db, connect) {
return function syncableConnect(protocolInstance, protocolName, url, options) {
if (db.isOpen()) {
// Database is open
if (!db._localSyncNode)
throw new Error("Precondition failed: local sync node is missing. Make sure Dexie.Observable is active!");
if (db._localSyncNode.isMaster) {
// We are master node
return connect(protocolInstance, protocolName, url, options, db._localSyncNode.id);
} else {
// We are not master node
// Request master node to do the connect:
return db.table('_syncNodes').where('isMaster').above(0).first(function (masterNode) {
// There will always be a master node. In theory we may self have become master node when we come here. But that's ok. We'll request ourselves.
return db.observable.sendMessage('connect', {
protocolName: protocolName,
url: url,
options: options
}, masterNode.id, {wantReply: true});
});
}
} else if (db.hasBeenClosed()) {
// Database has been closed.
return Promise.reject(new Dexie.DatabaseClosedError());
} else if (db.hasFailed()) {
// Database has failed to open
return Promise.reject(new Dexie.InvalidStateError(
"Dexie.Syncable: Cannot connect. Database has failed to open"));
} else {
// Database not yet open. It may be on its way to open, or open() hasn't yet been called.
// Wait for it to open, then connect.
var promise = new Promise(function (resolve, reject) {
db.on("ready", () => {
// First, check if this is the very first time we connect to given URL.
// Need to know, because if it is, we should stall the promise returned to
// db.on('ready') to not be fulfilled until the initial sync has succeeded.
return db._syncNodes.get({url}, node => {
// Ok, now we know whether we should await the connect promise or not.
// No matter, we should now connect (will maybe create the SyncNode instance
// representing the given URL)
let connectPromise = db.syncable.connect(protocolName, url, options);
connectPromise.then(resolve, reject);// Resolve the returned promise when connected.
// Ok, so let's see if we should suspend DB queries until connected or not:
if (node && node.appliedRemoteRevision) {
// The very first initial sync has been done so we need not wait
// for the connect promise to complete. It can continue in background.
// Returning here will resume db.on('ready') and resume all queries that
// the application has put to the database.
return;
}
// This was the very first time we connect to the remote server,
// we must make sure that the initial sync request succeeeds before resuming
// database queries that the application code puts onto the database.
// If OFFLINE or other error, don't allow the application to proceed.
// We are assuming that an initial sync is essential for the application to
// function correctly.
return connectPromise;
});
});
// Force open() to happen. Otherwise connect() may stall forever.
db.open().catch(ex => {
// If open fails, db.on('ready') may not have been called and we must
// reject promise with InvalidStateError
reject(new Dexie.InvalidStateError(
`Dexie.Syncable: Couldn't connect. Database failed to open`,
ex
));
});
});
return promise;
}
};
}
================================================
FILE: addons/Dexie.Syncable/test/gh-actions.sh
================================================
#!/bin/bash -e
cd ../../Dexie.Observable
echo "Installing dependencies for dexie-observable"
pnpm install >/dev/null
echo "Building dexie-observable"
pnpm run build
cd -
echo "Installing dependencies for dexie-syncable"
pnpm install >/dev/null
echo "Building dexie-syncable"
pnpm run build
pnpm run test:typings
pnpm run test:ltcloud
pnpm run test:ltcloud:integration
================================================
FILE: addons/Dexie.Syncable/test/integration/dummy-sync-protocol.js
================================================
Dexie.Syncable.registerSyncProtocol("logger", {
sync: function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
///
///
///
console.log("sync(changes.length: "+ changes.length + ", baseRevision:" + baseRevision + ", " + (partial ? "partial" : "full") + ", syncedRevision:" + syncedRevision + ")");
changes.forEach(function (change) {
console.log(JSON.stringify(change, null, true));
});
setTimeout(function () {
var dummyRev = 1,
CREATE = 1,
UPDATE = 2,
DELETE = 3;
onChangesAccepted().catch('DatabaseClosedError', function () {
console.log("Got DatabaseClosedError while calling onChangesAccepted()");
});
applyRemoteChanges([], "ServerRevision" + dummyRev++, true, false).catch('DatabaseClosedError', function() {
console.log("Got DatabaseClosedError while calling applyRemoteChanges()");
});
applyRemoteChanges([], "ServerRevision" + dummyRev++, false, false).catch('DatabaseClosedError', function(){
console.log("Got DatabaseClosedError while calling applyRemoteChanges()");
})
/*var dummyPoller = setInterval(function(){
applyRemoteChanges([], "ServerRevision" + dummyRev++, true, false);
applyRemoteChanges([], "ServerRevision" + dummyRev++, false, false);
}, 10);*/
onSuccess({
react: function (changes, baseRevision, partial, onChangesAccepted) {
console.log("react(changes.length: " + changes.length + ", baseRevision:" + baseRevision + ", " + (partial ? "partial" : "full") + ")");
changes.forEach(function (change) {
console.log(JSON.stringify(change, null, true));
});
setTimeout(onChangesAccepted, 0);
},
disconnect: function () {
console.log("disconned()");
//clearInterval(dummyPoller);
}
});
}, 0);
}
});
// Make sure to always call sync() before any call to open().
Dexie.addons.push(function (db) {
db.open = Dexie.override(db.open, function (origFunc) {
return function () {
if (!db.dynamicallyOpened() && !db.connectAlreadyCalled) {
db.connectAlreadyCalled = true;
console.log("Calling db.sync() on " + db.name);
db.syncable.connect("logger", "logger").then(function() {
console.log("connect() promise was resolved (database=" + db.name + ")");
}).catch('DatabaseClosedError', function(){
}).catch(function(err) {
console.error("Error from connect() (database=" + db.name + "): " + err.stack || err);
});
}
return origFunc.apply(this, arguments);
}
});
db.close = Dexie.override(db.close, function (origFunc) {
return function() {
db.connectAlreadyCalled = false;
return origFunc.apply(this, arguments);
}
});
});
================================================
FILE: addons/Dexie.Syncable/test/integration/karma-env.js
================================================
// workerImports will be used by tests-open.js in the dexie test suite when
// launching a Worker. This line will instruct the worker to import dexie-observable
// and dexie-syncable.
window.workerImports.push("../addons/Dexie.Observable/dist/dexie-observable.js");
window.workerImports.push("../addons/Dexie.Syncable/dist/dexie-syncable.js");
================================================
FILE: addons/Dexie.Syncable/test/integration/karma.conf.js
================================================
// Include common configuration
const {karmaCommon, getKarmaConfig, defaultBrowserMatrix} = require('../../../../test/karma.common');
module.exports = function (config) {
const browserMatrixOverrides = {
// Be fine with testing on local travis firefox + browserstack chrome, latest supported.
ci: ["remote_chrome"],
// Safari fails to reply on browserstack. Need to not have it here.
// Just complement with old chrome browser that is not part of CI test suite.
pre_npm_publish: [
"Chrome",
]
};
const cfg = getKarmaConfig(browserMatrixOverrides, {
// Base path should point at the root
basePath: '../../../../',
// The files needed to apply dexie-observable to the standard dexie unit tests.
files: karmaCommon.files.concat([
'dist/dexie.js',
'addons/Dexie.Syncable/test/integration/karma-env.js',
'addons/Dexie.Observable/dist/dexie-observable.js', // Apply observable addon
'addons/Dexie.Syncable/dist/dexie-syncable.js', // Apply syncable addon
'addons/Dexie.Syncable/test/integration/dummy-sync-protocol.js',
'test/bundle.js', // The dexie standard test suite
{ pattern: 'addons/Dexie.Observable/dist/*.map', watched: false, included: false },
{ pattern: 'addons/Dexie.Syncable/dist/*.map', watched: false, included: false }
])
});
config.set(cfg);
}
================================================
FILE: addons/Dexie.Syncable/test/integration/test-syncable-dexie-tests.html
================================================
Dexie Unit tests with Dexie.Syncable applied and dummy ISyncProtocol
================================================
FILE: addons/Dexie.Syncable/test/test-typings/test-typings.ts
================================================
import Dexie from 'dexie';
import 'dexie-observable';
import '../../src/Dexie.Syncable';
import dexieSyncable from '../../src/Dexie.Syncable';
import {DatabaseChangeType} from '../../api';
import {IDatabaseChange} from 'dexie-observable/api';
//
// Typings for registerSyncProtocol():
//
Dexie.Syncable.registerSyncProtocol("myProtocol", {
sync: (context,
url,
options,
baseRevision,
syncedRevision,
changes,
partial,
applyRemoteChanges,
onChangesAccepted,
onSuccess,
onError) => {
context["customProp"] = 3;
context.save().catch(ex=>{});
url.toLowerCase();
changes.forEach(change => {
if (change.type === DatabaseChangeType.Create) {
change.obj.hello; // Should be able to access custom props on change.obj.
change.key;
change.table.toLowerCase();
change.type.toExponential();
} else if (change.type === DatabaseChangeType.Update) {
Object.keys(change.mods).forEach(keyPath => {
change.mods[keyPath];
});
} else if (change.type === DatabaseChangeType.Delete) {
change.key;
change.table;
}
});
partial.valueOf(); // boolean
applyRemoteChanges(changes, {anyType:'anyValue'}, true);
applyRemoteChanges(changes, {anyType:'anyValue'});
onChangesAccepted();
onError(new Error("hoohoo"), 5000);
// Poll pattern typings:
onSuccess({ again: 5000 });
// React pattern typings:
onSuccess({
react (changes, baseRevision, partial, onChangesAccepted) {
changes.forEach(change => change.key && change.table.toUpperCase() && change.type.toExponential());
baseRevision;
partial.valueOf();
onChangesAccepted();
},
disconnect(){}
});
}
});
//
// 2. Declare Your Database.
//
class Foo {
id: Date;
bar() {};
age: number;
address: {
city: string;
}
}
class MyDb extends Dexie {
foo: Dexie.Table;
constructor() {
super('mydb', {addons: [dexieSyncable, Dexie.Syncable]});
this.version(1).stores({foo: 'id'});
//
// Connect
//
this.syncable.connect(
"myProtocol",
"https://remote-server/...",
{anyOption: 'anyValue'})
.catch(err => {
console.error (`Failed to connect: ${err.stack || err}`);
});
}
}
var db = new MyDb();
// Start using the database as normal.
db.foo.where('x').notEqual(1).toArray(foos => {
foos.forEach(foo => foo.bar());
}).catch(err => {
});
db.foo.get(new Date()).then(foo => foo && foo.bar());
db.syncable.disconnect("myUrl");
db.syncable.delete("myUrl");
db.syncable.getStatus("myUrl").finally(()=>{}).catch('DatabaseClosedError', ex => ex.name);
/* BUGBUG: Fails! See issue #537. Need to fix db.syncable.list().then(urls => Promise.all(
urls.map(url => db.syncable.getStatus(url).then (status => ({url: url, status: status})))
)).then(urlsAndStatuses => {
urlsAndStatuses.forEach(urlAndStatus => {
urlAndStatus.url.toLowerCase();
urlAndStatus.status.toExponential();
});
});*/
// With async/await
async function getUrlsAndStatuses() {
let urls = await db.syncable.list();
let statuses = await Dexie.Promise.all(urls.map(url => db.syncable.getStatus(url)));
}
function statusChanged(status: number, url: string) {
status.toExponential();
url.toLowerCase();
}
db.syncable.on('statusChanged', statusChanged);
db.syncable.on('statusChanged').unsubscribe(statusChanged);
================================================
FILE: addons/Dexie.Syncable/test/test-typings/tsconfig.json
================================================
{
"compilerOptions": {
"module": "es6",
"target": "es5",
"noImplicitAny": true,
"strictNullChecks": true,
"outDir": "../../tools/tmp/test-typings",
"moduleResolution": "node",
"lib": ["dom", "es2020"]
},
"files": [
"test-typings.ts"
]
}
================================================
FILE: addons/Dexie.Syncable/test/unit/.eslintrc.json
================================================
{
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
}
},
"env": {
"browser": true,
"node": true
},
"rules": {
"no-undef": ["error"]
},
"globals": {
"Promise": true
}
}
================================================
FILE: addons/Dexie.Syncable/test/unit/.gitignore
================================================
/bundle.js
/bundle.js.map
================================================
FILE: addons/Dexie.Syncable/test/unit/get-local-changes-for-node/tests-get-base-revision-and-max-client-revision.js
================================================
import {module, test, deepEqual, equal, ok} from 'QUnit';
import getBaseRevisionAndMaxClientRevision from '../../../src/get-local-changes-for-node/get-base-revision-and-max-client-revision';
module('getBaseRevisionAndMaxClientRevision', {
setup: () => {
},
teardown: () => {
}
});
test('maxClientRevision should be Infinity and remoteBaseRevision null if we haven\'t gotten any server changes yet', () => {
const syncNode = {
remoteBaseRevisions: []
};
const res = getBaseRevisionAndMaxClientRevision(syncNode);
deepEqual(res, { maxClientRevision: Infinity, remoteBaseRevision: null});
});
test('remoteBaseRevision should be null and maxClientRevision should match the first server change if the sync node\'s revision is not bigger that at least one remoteBaseRevisions', () => {
const syncNode = {
remoteBaseRevisions: [{ local: 2, remote: 1 }, { local: 3, remote: 1 }],
myRevision: 1
};
const res = getBaseRevisionAndMaxClientRevision(syncNode);
deepEqual(res, { maxClientRevision: 2, remoteBaseRevision: null});
});
test('remoteBaseRevision should have the value of "remote" for a remoteBaseRevision\'s "local" matching "myRevision"', () => {
const syncNode = {
remoteBaseRevisions: [{ local: 2, remote: 1 }, { local: 3, remote: 2 }],
myRevision: 2
};
const res = getBaseRevisionAndMaxClientRevision(syncNode);
equal(res.remoteBaseRevision, 1);
});
test('maxClientRevision should have the value of the next "local" for a remoteBaseRevision\'s "local" matching "myRevision"', () => {
const syncNode = {
remoteBaseRevisions: [{ local: 2, remote: 1 }, { local: 3, remote: 2 }],
myRevision: 2
};
const res = getBaseRevisionAndMaxClientRevision(syncNode);
equal(res.maxClientRevision, 3);
});
test('maxClientRevision should be Infinity if the last remoteBaseRevision\'s "local" matches "myRevision"', () => {
const syncNode = {
remoteBaseRevisions: [{ local: 2, remote: 1 }, { local: 3, remote: 2 }],
myRevision: 3
};
const res = getBaseRevisionAndMaxClientRevision(syncNode);
equal(res.maxClientRevision, Infinity);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/get-local-changes-for-node/tests-get-changes-since-revision.js
================================================
import Dexie from 'dexie';
import 'dexie-observable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
import {resetDatabase} from '../../../../../test/dexie-unittest-utils';
import initGetChangesSinceRevision from '../../../src/get-local-changes-for-node/get-changes-since-revision';
import {CREATE, UPDATE} from '../../../src/change_types';
const db = new Dexie('TestDBTable');
db.version(1).stores({
foo: "id"
});
const syncNode = new db.observable.SyncNode();
const nodeID = 1;
syncNode.id = nodeID;
const hasMoreToGive = {hasMoreToGive: false};
const getChangesSinceRevision = initGetChangesSinceRevision(db, syncNode, hasMoreToGive);
module('getChangesSinceRevision', {
setup: () => {
stop();
// Do a full DB reset to clean _changes table
db._hasBeenCreated = false;
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should return relevant (between revision and maxRevision) changes', () => {
const createChange1 = {
rev: 1,
key: 1,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange2 = {
rev: 2,
key: 2,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange3 = {
rev: 3,
key: 3,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange4 = {
rev: 4,
key: 4,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const revision = 1;
const maxChanges = 10;
const maxRevision = 3;
function cb(changes, partial, revisionObject) {
strictEqual(revisionObject.myRevision, createChange3.rev, 'myRevision');
strictEqual(partial, false, 'is not a partial change');
strictEqual(changes.length, 2, 'get only 2 changes');
deepEqual(changes, [{
key: 2,
table: 'foo',
type: CREATE,
obj: {foo: 'bar'}
}, {
key: 3,
table: 'foo',
type: CREATE,
obj: {foo: 'bar'}
}], 'changes');
deepEqual(hasMoreToGive, {hasMoreToGive: false}, 'hasMoreToGive remains false');
}
const changesToAdd = [createChange1, createChange2, createChange3, createChange4];
db._changes.bulkAdd(changesToAdd)
.then(() => {
return getChangesSinceRevision(revision, maxChanges, maxRevision, cb)
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should ignore a change if it was set by this node', () => {
const createChange1 = {
rev: 1,
key: 1,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange2 = {
rev: 2,
key: 2,
type: CREATE,
source: 1,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange3 = {
rev: 3,
key: 3,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange4 = {
rev: 4,
key: 4,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const revision = 1;
const maxChanges = 10;
const maxRevision = 3;
function cb(changes/*, partial, revisionObject*/) {
strictEqual(changes.length, 1, 'get only 1 changes');
deepEqual(changes, [{
key: 3,
table: 'foo',
type: CREATE,
obj: {foo: 'bar'}
}], 'changes');
}
const changesToAdd = [createChange1, createChange2, createChange3, createChange4];
db._changes.bulkAdd(changesToAdd)
.then(() => {
return getChangesSinceRevision(revision, maxChanges, maxRevision, cb)
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should merge changes', () => {
const createChange1 = {
rev: 2,
key: 2,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange2 = {
rev: 3,
key: 2,
type: UPDATE,
source: 2,
table: 'foo',
mods: {foo: 'baz'}
};
const revision = 1;
const maxChanges = 10;
const maxRevision = 3;
function cb(changes/*, partial, revisionObject*/) {
strictEqual(changes.length, 1, 'get only 1 changes');
deepEqual(changes, [{
key: 2,
table: 'foo',
type: CREATE,
obj: {foo: 'baz'}
}], 'changes');
}
const changesToAdd = [createChange1, createChange2];
db._changes.bulkAdd(changesToAdd)
.then(() => {
return getChangesSinceRevision(revision, maxChanges, maxRevision, cb)
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should set hasMoreToGive to true if we have more changes than maxChanges', () => {
const createChange1 = {
rev: 1,
key: 1,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange2 = {
rev: 2,
key: 2,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange3 = {
rev: 3,
key: 3,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange4 = {
rev: 4,
key: 4,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const revision = 1;
const maxChanges = 2;
const maxRevision = 4;
hasMoreToGive.hasMoreToGive = false;
function cb(changes, partial, revisionObject) {
strictEqual(revisionObject.myRevision, createChange3.rev, 'myRevision');
strictEqual(partial, true, 'is a partial change');
strictEqual(changes.length, 2, 'get only 2 changes');
deepEqual(hasMoreToGive, {hasMoreToGive: true}, 'hasMoreToGive is true');
}
const changesToAdd = [createChange1, createChange2, createChange3, createChange4];
db._changes.bulkAdd(changesToAdd)
.then(() => {
return getChangesSinceRevision(revision, maxChanges, maxRevision, cb)
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should set hasMoreToGive to true but give no changes if maxChanges is 0', () => {
const createChange1 = {
rev: 1,
key: 1,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange2 = {
rev: 2,
key: 2,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange3 = {
rev: 3,
key: 3,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const createChange4 = {
rev: 4,
key: 4,
type: CREATE,
source: 2,
table: 'foo',
obj: {foo: 'bar'}
};
const revision = 1;
const maxChanges = 0;
const maxRevision = 4;
hasMoreToGive.hasMoreToGive = false;
function cb(changes, partial, revisionObject) {
strictEqual(revisionObject.myRevision, revision, 'revision should not change');
strictEqual(partial, true, 'is a partial change');
strictEqual(changes.length, 0, 'get no changes');
deepEqual(hasMoreToGive, {hasMoreToGive: true}, 'hasMoreToGive is true');
}
const changesToAdd = [createChange1, createChange2, createChange3, createChange4];
db._changes.bulkAdd(changesToAdd)
.then(() => {
return getChangesSinceRevision(revision, maxChanges, maxRevision, cb)
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/get-local-changes-for-node/tests-get-local-changes-for-node.js
================================================
import Dexie from 'dexie';
import 'dexie-observable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
import {resetDatabase} from '../../../../../test/dexie-unittest-utils';
import initGetLocalChangesForNode from '../../../src/get-local-changes-for-node/get-local-changes-for-node';
import {CREATE} from '../../../src/change_types';
const db = new Dexie('TestDBTable');
db.version(1).stores({
foo: "id",
bar: "id"
});
const syncNode = new db.observable.SyncNode();
const nodeID = 1;
syncNode.id = nodeID;
const hasMoreToGive = {hasMoreToGive: false};
module('getLocalChangesForNode', {
setup: () => {
stop();
// Do a full DB reset to clean _changes table
db._hasBeenCreated = false;
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should get the contents of our tables and create CREATE changes if node.myRevision is -1', () => {
syncNode.myRevision = -1;
syncNode.dbUploadState = null;
syncNode.remoteBaseRevisions = [];
const getLocalChangesForNode = initGetLocalChangesForNode(db, hasMoreToGive, 10);
const fooTableObject = {
id: 1,
foo: 'bar'
};
const barTableObject = {
id: 1,
bar: 'foo'
};
function cb(changes/*, remoteBaseRevision, partial, nodeModificationsOnAck*/) {
strictEqual(changes.length, 2, 'We have 2 changes');
deepEqual(changes, [{
key: 1,
obj: fooTableObject,
type: CREATE,
table: 'foo'
}, {
key: 1,
obj: barTableObject,
type: CREATE,
table: 'bar'
}], 'Changes match the objects in the tables');
deepEqual(hasMoreToGive, {hasMoreToGive: false}, 'it should\'t change hasMoreToGive');
}
db.foo.add(fooTableObject)
.then(() => {
return db.bar.add(barTableObject);
})
.then(() => {
return getLocalChangesForNode(syncNode, cb);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should return changes in _changes if myRevision >= 0', () => {
syncNode.myRevision = -1;
syncNode.dbUploadState = null;
syncNode.remoteBaseRevisions = [];
const getLocalChangesForNode = initGetLocalChangesForNode(db, hasMoreToGive, 10);
const fooTableObject1 = {
id: 1,
foo: 'bar'
};
const fooTableObject2 = {
id: 2,
foo: 'foobar'
};
function cb(changes/*, remoteBaseRevision, partial, nodeModificationsOnAck*/) {
strictEqual(changes.length, 2, 'We have 2 changes');
deepEqual(changes, [{
key: 1,
obj: fooTableObject1,
type: CREATE,
table: 'foo'
}, {
key: 2,
obj: fooTableObject2,
type: CREATE,
table: 'foo'
}], 'Changes match the objects in the tables');
deepEqual(hasMoreToGive, {hasMoreToGive: false}, 'it should\'t change hasMoreToGive');
}
// This also adds changes to _changes
db.foo.bulkAdd([fooTableObject1, fooTableObject2])
.then(() => {
return getLocalChangesForNode(syncNode, cb);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should return partial changes in _changes if myRevision >= 0 and threshold was reached', () => {
syncNode.myRevision = -1;
syncNode.dbUploadState = null;
syncNode.remoteBaseRevisions = [];
hasMoreToGive.hasMoreToGive = false;
const getLocalChangesForNode = initGetLocalChangesForNode(db, hasMoreToGive, 1);
const fooTableObject1 = {
id: 1,
foo: 'bar'
};
const fooTableObject2 = {
id: 2,
foo: 'foobar'
};
function cb(changes/*, remoteBaseRevision, partial, nodeModificationsOnAck*/) {
strictEqual(changes.length, 1, 'We have 1 change');
deepEqual(changes, [{
key: 1,
obj: fooTableObject1,
type: CREATE,
table: 'foo'
}], 'Changes match the objects in the tables');
deepEqual(hasMoreToGive, {hasMoreToGive: true}, 'it should change hasMoreToGive');
}
// This also adds changes to _changes
db.foo.bulkAdd([fooTableObject1, fooTableObject2])
.then(() => {
return getLocalChangesForNode(syncNode, cb);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should not return any changed if the threshold is 0 but should set hasMoreToGive to true', () => {
syncNode.myRevision = -1;
syncNode.dbUploadState = null;
syncNode.remoteBaseRevisions = [];
hasMoreToGive.hasMoreToGive = false;
const getLocalChangesForNode = initGetLocalChangesForNode(db, hasMoreToGive, 0);
const fooTableObject1 = {
id: 1,
foo: 'bar'
};
const fooTableObject2 = {
id: 2,
foo: 'foobar'
};
function cb(changes/*, remoteBaseRevision, partial, nodeModificationsOnAck*/) {
strictEqual(changes.length, 0, 'We have 0 changes');
deepEqual(hasMoreToGive, {hasMoreToGive: true}, 'it should change hasMoreToGive');
}
// This also adds changes to _changes
db.foo.bulkAdd([fooTableObject1, fooTableObject2])
.then(() => {
return getLocalChangesForNode(syncNode, cb);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/karma-env.js
================================================
QUnit.config.autostart = false;
================================================
FILE: addons/Dexie.Syncable/test/unit/karma.conf.js
================================================
// Include common configuration
const {karmaCommon, getKarmaConfig, defaultBrowserMatrix} = require('../../../../test/karma.common');
module.exports = function (config) {
const browserMatrixOverrides = {
// Be fine with testing on local travis firefox + browserstack chrome, latest supported.
ci: ["remote_chrome"],
// Safari fails to reply on browserstack. Need to not have it here.
// Just complement with old chrome browser that is not part of CI test suite.
pre_npm_publish: [
"Chrome",
]
};
const cfg = getKarmaConfig(browserMatrixOverrides, {
// Base path should point at the root
basePath: '../../../../',
files: karmaCommon.files.concat([
'dist/dexie.js',
'addons/Dexie.Observable/dist/dexie-observable.js',
'samples/remote-sync/websocket/websocketserver-shim.js',
'samples/remote-sync/websocket/WebSocketSyncServer.js',// With shim applied, we can run the server in the browser
'addons/Dexie.Syncable/test/unit/bundle.js',
{ pattern: 'addons/Dexie.Observable/dist/*.map', watched: false, included: false },
{ pattern: 'addons/Dexie.Syncable/dist/*.map', watched: false, included: false },
{ pattern: 'addons/Dexie.Syncable/test/unit/*.map', watched: false, included: false },
])
});
config.set(cfg);
}
================================================
FILE: addons/Dexie.Syncable/test/unit/run-unit-tests.html
================================================
Dexie.Syncable Unit tests
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-PersistedContext.js
================================================
import Dexie from 'dexie';
import observable from 'dexie-observable';
// Add this so we have the SyncNode.prototype.save method
import syncable from '../../src/Dexie.Syncable';
import {module, asyncTest, test, start, stop, propEqual, deepEqual, ok} from 'QUnit';
import {resetDatabase} from '../../../../test/dexie-unittest-utils';
import initPersistedContext from '../../src/PersistedContext';
const db = new Dexie('TestDBTable', {addons: [observable, syncable]});
db.version(1).stores({foo: '++id'});
module('PersistedContext', {
setup: () => {
stop();
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should save any properties we add to the context into the DB', () => {
const syncNode = new db.observable.SyncNode();
const PersistedContext = initPersistedContext(syncNode);
let addedNodeID;
db._syncNodes.add(syncNode)
.then((nodeID) => {
addedNodeID = nodeID;
const persistedContext = new PersistedContext(syncNode.id);
syncNode.syncContext = persistedContext;
persistedContext.foobar = 'foobar';
return persistedContext.save()
})
.then(() => {
return db._syncNodes.get(addedNodeID);
})
.then((node) => {
deepEqual(node.syncContext, {foobar: 'foobar', nodeID: addedNodeID});
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
test('should extend the instance with the given options object', () => {
const syncNode = new db.observable.SyncNode();
const PersistedContext = initPersistedContext(syncNode);
const persistedContext = new PersistedContext(1, {foo: 'bar'});
propEqual(persistedContext, {nodeID: 1, foo: 'bar'});
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-WebSocketSyncServer.js
================================================
import 'dexie-observable';
import '../../src/Dexie.Syncable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
module("tests-WebSocketSyncServer");
asyncTest("testWebSocketSyncServer", function () {
var server = new SyncServer(1234);
server.start();
var ws = new WebSocket("http://dummy:1234");
ws.onopen = function () {
ok(true, "WebSocket opened");
ws.send(JSON.stringify({
type: "clientIdentity",
clientIdentity: null
}));
ws.send(JSON.stringify({
type: "subscribe",
syncedRevision: null
}));
}
ws.onclose = function (reason) {
ok(true, "WebSocket closed. Reason: " + reason);
start();
}
ws.onerror = function (event) {
ok(false, "Error: " + event.reason);
start();
}
ws.onmessage = function (event) {
var requestFromServer = JSON.parse(event.data);
if (requestFromServer.type === "clientIdentity") {
ok(true, "Got client identity: " + requestFromServer.clientIdentity);
// Now send changes to server
ws.send(JSON.stringify({
type: "changes",
changes: [],
partial: false,
baseRevision: null,
requestId: 1
}));
} else if (requestFromServer.type == "ack") {
ok(true, "Got ack from server: " + requestFromServer.requestId);
equal(requestFromServer.requestId, 1, "The request ID 1 was acked");
// Now connect another WebSocket and send its changes to server so that server will react and send us the changes:
var ws2 = new WebSocket("http://dummy:1234");
ws2.onopen = function () {
ws2.send(JSON.stringify({
type: "clientIdentity",
clientIdentity: null
}));
ws2.send(JSON.stringify({
type: "changes",
changes: [{type: 1, table: "UllaBella", key: "apa", obj: {name: "Apansson"}}],
partial: false,
baseRevision: null,
requestId: 1
}));
}
} else if (requestFromServer.type == "changes") {
if (requestFromServer.currentRevision == 0) {
ok(true, "Got initial changes sent to us with current revision 0");
} else {
ok(true, "Got changes from server: " + JSON.stringify(requestFromServer.changes));
equal(JSON.stringify(requestFromServer.changes), JSON.stringify([
{
rev: 1,
source: 2, // WebSocket2 was the source of the changes.
type: 1,
table: "UllaBella",
key: "apa",
obj: {name: "Apansson"}
}
]), "Changes where the same as the ones sent by WebSocket2");
start();
}
}
}
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-apply-changes.js
================================================
import Dexie from 'dexie';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
import {resetDatabase} from '../../../../test/dexie-unittest-utils';
import initApplyChanges from '../../src/apply-changes';
import {CREATE, DELETE, UPDATE} from '../../src/change_types';
const db = new Dexie('TestDBTable', {addons: []});
db.version(1).stores({
foo: "id",
bar: "++id"
});
const applyChanges = initApplyChanges(db);
module('applyChanges', {
setup: () => {
stop();
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should be able to handle changes belonging to different tables', () => {
const fooCreateChange = {
key: 1,
table: 'foo',
obj: { foo: 'bar', id: 1 },
type: CREATE
};
const barCreateChange = {
table: 'bar',
obj: { foo: 'baz' },
type: CREATE
};
const changes = [fooCreateChange, barCreateChange];
applyChanges(changes, 0)
.then(() => {
return db.table('foo').get(fooCreateChange.key);
})
.then((obj) => {
// Works when key is given
deepEqual(obj, fooCreateChange.obj, 'fooCreateChange found in table');
return db.table('bar').toArray();
})
.then((objects) => {
// Works with auto-incremented key
strictEqual(objects[0].foo, barCreateChange.obj.foo, 'barCreateChange found in table');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should be able to handle large number of changes', () => {
let changes = [];
for( let i = 0; i < 10000; i++ ) {
changes.push({key : i, table: "foo", obj: { id: i, foo: "bar" }, type: CREATE});
}
applyChanges(changes)
.then(() => {
ok(true, "Tests passed!");
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should be able to handle different types of changes', () => {
const tableData = [{ id: 2, foo: 'foobar' }, { id: 3, foo: 'bar' }];
const createChange = {
key: 1,
table: 'foo',
obj: { foo: 'bar', id: 1 },
type: CREATE
};
const updateChange = {
key: 2,
table: 'foo',
mods: { foo: 'baz' },
type: UPDATE
};
const deleteChange = {
key: 3,
table: 'foo',
type: DELETE
};
const changes = [createChange, updateChange, deleteChange];
db.table('foo').bulkPut(tableData)
.then(() => {
return applyChanges(changes, 0);
})
.then(() => {
return db.table('foo').get(createChange.key);
})
.then((obj) => {
deepEqual(obj, createChange.obj, 'createChange found in table');
return db.table('foo').get(updateChange.key);
})
.then((obj) => {
strictEqual(obj.foo, 'baz', 'updateChange found in table');
return db.table('foo').get(deleteChange.key);
})
.then((obj) => {
strictEqual(obj, undefined, 'deleteChange was executed on table');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-bulk-update.js
================================================
import Dexie from 'dexie';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
import {resetDatabase} from '../../../../test/dexie-unittest-utils';
import bulkUpdate from '../../src/bulk-update';
import {UPDATE} from '../../src/change_types';
const db = new Dexie('TestDBTable', {addons: []});
db.version(1).stores({
foo: "id"
});
db.on("populate", function () {
db.table('foo').add({foo: 'bar', id: 1});
db.table('foo').add({bar: 'baz', foo: { bar: 'foobar' }, id: 2});
});
module('bulkUpdate', {
setup: () => {
stop();
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should ignore any changes for which we didn\'t find an object in the table', () => {
const changes = [{
key: 3,
mods: {id: 3, foo: 'bar'},
table: 'foo',
type: UPDATE
}];
bulkUpdate(db.table('foo'), changes)
.then(() => {
return db.table('foo').count();
})
.then((count) => {
strictEqual(count, 2, 'No changes made to the table');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should update the object in the table according to the changes', () => {
const updateKey1 = {
key: 1,
mods: {id: 1, foo: 'bar'},
table: 'foo',
type: UPDATE
};
const updateKey2 = {
key: 2,
mods: {id: 2, bar: 'bar', 'foo.bar': 'bazzz'},
table: 'foo',
type: UPDATE
};
const changes = [updateKey1, updateKey2];
bulkUpdate(db.table('foo'), changes)
.then(() => {
return db.table('foo').get(updateKey1.key);
})
.then((obj) => {
deepEqual(obj, updateKey1.mods, 'Key 1 updated');
return db.table('foo').get(updateKey2.key);
})
.then((obj) => {
deepEqual(obj, {id: 2, bar: 'bar', foo: {bar: 'bazzz'}}, 'Key 2 updated');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-changing-options.js
================================================
import Dexie from 'dexie';
import 'dexie-observable';
import '../../src/Dexie.Syncable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
/* The following is being tested:
1. getOptions method
2. changing options on an existing connected node by using connect() with a different options object than before
3. changing options on an existing disconnected node by using connect() with a different options object than before
*/
const db = new Dexie("optionsTestDB");
const deletePromise = Dexie.delete("optionsTestDB");
module("tests-changing-options", {
setup: function () {
db.close();
stop();
deletePromise.then(function () {
start()
});
},
teardown: function () {
}
});
asyncTest('Change options on an existing node', function () {
const protocolName = 'testProtocolChanges';
const serverUrl = 'http://dummy.local';
const syncProtocol = {
sync: undefined,
partialsThreshold: 1000
};
Dexie.Syncable.registerSyncProtocol(protocolName, syncProtocol);
db.version(1).stores({objects: "$$"});
db.open();
syncProtocol.sync = function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess) {
propEqual(options, {option1: 'option1'}, 'sync got the correct options');
onSuccess({again: 1000});
};
db.syncable.connect(protocolName, serverUrl, {option1: "option1"})
.then(() => {
return db.syncable.getOptions(serverUrl);
})
.then((options) => {
propEqual(options, {option1: 'option1'}, 'getOptions got the correct options');
syncProtocol.sync = function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess) {
propEqual(options, {newOptions: 'other options'}, 'sync got the new options');
onSuccess({again: 1000});
};
// Test changing options on an already connected node
// We are already connected but are changing options
// We expect that the next getOptions/sync call has the new options
return db.syncable.connect(protocolName, serverUrl, {newOptions: 'other options'});
})
.then(() => {
return db.syncable.getOptions(serverUrl);
})
.then((options) => {
propEqual(options, {newOptions: 'other options'}, 'getOptions got the new options');
// Test changing options on a disconnected existing node
return db.syncable.disconnect(serverUrl);
})
.then(() => {
syncProtocol.sync = function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess) {
propEqual(options, {evenNewerOptions: 'super new options'}, 'sync got the even newer options');
onSuccess({again: 1000});
};
return db.syncable.connect(protocolName, serverUrl, {evenNewerOptions: 'super new options'});
})
.then(() => {
return db.syncable.getOptions(serverUrl);
})
.then((options) => {
propEqual(options, {evenNewerOptions: 'super new options'}, 'getOptions got the even newer options');
})
.catch(function (err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-combine-create-and-update.js
================================================
import {module, test, deepEqual, ok} from 'QUnit';
import combineCreateAndUpdate from '../../src/combine-create-and-update';
module('combineCreateAndUpdate', {
setup: () => {
},
teardown: () => {
}
});
test('should get a create change and update change and return a combined object', () => {
const createChange = {
obj: {
foo: 'value',
},
};
const updateChange = {
mods: {
foo: 'value2',
bar: 'new Value',
},
};
const res = combineCreateAndUpdate(createChange, updateChange);
deepEqual(res.obj, { foo: 'value2', bar: 'new Value' });
});
test('should not change the original createObject', () => {
const createChange = {
obj: {
foo: 'value',
},
};
const updateChange = {
mods: {
foo: 'value2',
bar: 'new Value',
},
};
combineCreateAndUpdate(createChange, updateChange);
deepEqual(createChange.obj, { foo: 'value' });
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-combine-update-and-update.js
================================================
import {module, test, deepEqual, ok} from 'QUnit';
import combineUpdateAndUpdate from '../../src/combine-update-and-update';
module('combineUpdateAndUpdate', {
setup: () => {
},
teardown: () => {
}
});
test('should combine the keys of nextChange.mods and prevChange.mods', () => {
const prevChange = {
mods: {
foo: 'bar',
},
};
const nextChange = {
mods: {
bar: 'baz',
},
};
const res = combineUpdateAndUpdate(prevChange, nextChange);
deepEqual(res.mods, {foo: 'bar', bar: 'baz'});
});
test('should leave the original object untouched', () => {
const prevChange = {
mods: {
foo: 'bar',
},
};
const nextChange = {
mods: {
bar: 'baz',
},
};
combineUpdateAndUpdate(prevChange, nextChange);
deepEqual(prevChange, {mods: {foo: 'bar'}});
});
test('should overwrite a previous change with the same key', () => {
const prevChange = {
mods: {
foo: 'bar',
},
};
const nextChange = {
mods: {
foo: 'baz',
},
};
const res = combineUpdateAndUpdate(prevChange, nextChange);
deepEqual(res.mods, {foo: 'baz'});
});
test('should ignore a previous change which changed a parent object of the next change', () => {
const prevChange = {
mods: {
'foo': {bar: 'baz', baz: 'bar'},
},
};
const nextChange = {
mods: {
'foo.bar': 'foobar',
},
};
const res = combineUpdateAndUpdate(prevChange, nextChange);
deepEqual(res, {mods: {foo: {bar: 'foobar', baz: 'bar'}}});
});
test('should ignore a previous change which changed a sub value of the nextChange', () => {
const prevChange = {
mods: {
'foo.bar': 'foobar',
},
};
const nextChange = {
mods: {
'foo': {bar: 'baz'},
},
};
const res = combineUpdateAndUpdate(prevChange, nextChange);
deepEqual(res, {mods: {'foo': {bar: 'baz'}}});
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-finally-commit-all-changes.js
================================================
import Dexie from 'dexie';
import observable from 'dexie-observable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
import {resetDatabase} from '../../../../test/dexie-unittest-utils';
import initFinallyCommitAllChanges from '../../src/finally-commit-all-changes';
import {CREATE, DELETE, UPDATE} from '../../src/change_types';
const db = new Dexie('TestDBTable', {addons: [observable]});
db.version(1).stores({
foo: "id"
});
const nodeID = 1;
let syncNode;
let finallyCommitAllChanges;
module('finallyCommitAllChanges', {
setup: () => {
stop();
db.observable.SyncNode.prototype.save = function() { return { catch() {} }; };
syncNode = new db.observable.SyncNode();
syncNode.id = nodeID;
syncNode.remoteBaseRevisions = [];
finallyCommitAllChanges = initFinallyCommitAllChanges(db, syncNode);
// Do a full DB reset to clean _changes table
db._hasBeenCreated = false;
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should call node.save()', () => {
let wasCalled = false;
db.observable.SyncNode.prototype.save = function() {
wasCalled = true;
return { catch() {} };
};
finallyCommitAllChanges([], 1)
.then(() => {
ok(wasCalled);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should apply _uncommittedChanges and remove them from that table', () => {
const createChange = {
key: 1,
node: nodeID,
type: CREATE,
obj: { id: 1, foo: 'bar' },
table: 'foo'
};
db._uncommittedChanges
.add(createChange)
.then(() => {
return finallyCommitAllChanges([], 1);
})
.then(() => {
return db.foo.get(createChange.key);
})
.then((obj) => {
deepEqual(obj, createChange.obj, 'Change was found in table "foo"');
return db._uncommittedChanges.where('node').equals(nodeID).count();
})
.then((count) => {
strictEqual(count, 0, 'No more entries in _uncommittedChanges for the given nodeID');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should put the changes into the given table', () => {
const createChange = {
key: 2,
node: nodeID,
type: CREATE,
obj: { id: 2, foo: 'barbaz' },
table: 'foo'
};
return finallyCommitAllChanges([createChange], 1)
.then(() => {
return db.foo.get(createChange.key);
})
.then((obj) => {
deepEqual(obj, createChange.obj, 'Change was found in table "foo"');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should set the nodeID as source for the currentTransaction', () => {
db.observable.SyncNode.prototype.save = function() {
strictEqual(Dexie.currentTransaction.source, nodeID);
return { catch() {} };
};
finallyCommitAllChanges([], 1)
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should update remoteBaseRevisions and remove any old revisions', () => {
const createChange = {
key: 1,
node: nodeID,
type: CREATE,
obj: { id: 1, foo: 'bar' },
table: 'foo'
};
syncNode.remoteBaseRevisions = [{local: 1, remote: 2}];
syncNode.myRevision = 2;
const remoteRevision = 3;
finallyCommitAllChanges([createChange], remoteRevision)
.then(() => {
strictEqual(syncNode.remoteBaseRevisions.length, 1, 'Only one remoteBaseRevision');
// We had no changes yet so the next local revision is 1
deepEqual(syncNode.remoteBaseRevisions, [{local: 1, remote: remoteRevision}], 'Make sure remoteBaseRevisions contains the correct object');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should update node.appliedRemoteRevision', () => {
const remoteRevision = 3;
finallyCommitAllChanges([], remoteRevision)
.then(() => {
strictEqual(syncNode.appliedRemoteRevision, remoteRevision);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should update myRevision to the latest rev we got', () => {
const createChange = {
rev: 1,
key: 1,
node: nodeID,
type: CREATE,
obj: { id: 1, foo: 'bar' },
table: 'foo'
};
syncNode.remoteBaseRevisions = [{local: 1, remote: 2}];
// We had no revision before and no changes
syncNode.myRevision = 0;
const remoteRevision = 3;
finallyCommitAllChanges([createChange], remoteRevision)
.then(() => {
strictEqual(syncNode.myRevision, createChange.rev);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-get-or-create-sync-node.js
================================================
import Dexie from 'dexie';
import observable from 'dexie-observable';
// Add this so we have the SyncNode.prototype.save method
import syncable from '../../src/Dexie.Syncable';
import {module, asyncTest, test, start, stop, propEqual, deepEqual, strictEqual, ok} from 'QUnit';
import {resetDatabase} from '../../../../test/dexie-unittest-utils';
import initGetOrCreateSyncNode from '../../src/get-or-create-sync-node';
const db = new Dexie('TestDBTable', {addons: [observable, syncable]});
db.version(1).stores({foo: '++id'});
const protocolName = 'protocolName';
const url = 'http://foo.invalid';
const getOrCreateSyncNode = initGetOrCreateSyncNode(db, protocolName, url);
module('getOrCreateSyncNode', {
setup: () => {
stop();
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should throw an error if no URL was passed', () => {
const getOrCreateSyncNode = initGetOrCreateSyncNode(db, protocolName);
getOrCreateSyncNode({})
.catch((e) => {
strictEqual(e.message, 'Url cannot be empty');
})
.finally(start);
});
asyncTest('should return a new node if none exists for the given URL', () => {
const nodeOpts = {foo: 'bar'};
let nodeID;
getOrCreateSyncNode(nodeOpts)
.then((node) => {
nodeID = node.id;
ok(node instanceof db.observable.SyncNode, 'returned node is instance of SyncNode');
strictEqual(node.syncProtocol, protocolName, 'syncProtocol is protocol name');
strictEqual(node.url, url, 'url is the url we passed to init');
deepEqual(node.syncOptions, nodeOpts, 'syncOptions are the same as the options we passed');
strictEqual(node.myRevision, -1, 'myRevision is -1');
propEqual(node.syncContext, {nodeID}, 'syncContext contains the correct nodeID');
return db._syncNodes.get(nodeID);
})
.then((node) => {
strictEqual(node.id, nodeID, 'Node was saved in the DB');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should return an existing node if one exists for the given URL', () => {
const nodeOpts = {foo: 'bar'};
let nodeID;
// Don't reuse the save URL, it would cause an error because the index is not unique
const otherUrl = 'http://bar.invalid';
const getOrCreateSyncNode = initGetOrCreateSyncNode(db, protocolName, otherUrl);
const syncNode = new db.observable.SyncNode();
syncNode.url = otherUrl;
let addedNodeID;
db._syncNodes.add(syncNode)
.then((nodeID) => {
addedNodeID = nodeID;
return getOrCreateSyncNode(nodeOpts)
})
.then((node) => {
ok(node instanceof db.observable.SyncNode, 'returned node is instance of SyncNode');
strictEqual(node.id, addedNodeID, 'We got the correct node back');
propEqual(node.syncContext, {nodeID: addedNodeID}, 'syncContext contains the correct nodeID');
propEqual(node.syncOptions, nodeOpts, 'node contains the given options');
return db._syncNodes.get(addedNodeID);
})
.then((node) => {
strictEqual(node.syncContext.nodeID, addedNodeID, 'Node was saved in the DB with the correct context');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should set myRevision to the last _changes if initialUpload is false', () => {
const nodeOpts = {initialUpload: false};
// Don't reuse the save URL, it would cause an error because the index is not unique
const otherUrl = 'http://baz.invalid';
const getOrCreateSyncNode = initGetOrCreateSyncNode(db, protocolName, otherUrl);
db._changes.add({key: 1, obj: {foo: 'bar'}, table: 'foo', type: 1})
.then(() => {
return getOrCreateSyncNode(nodeOpts);
})
.then((node) => {
strictEqual(node.myRevision, 1);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-merge-change.js
================================================
import {module, test, deepEqual} from 'QUnit';
import mergeChange from '../../src/merge-change';
import {CREATE, UPDATE, DELETE} from '../../src/change_types';
// Tests for if a key exists multiple times in a table
module('mergeChange: prev change was CREATE', {
setup: () => {
},
teardown: () => {
}
});
test('should just return the nextChange if it is CREATE', () => {
const prevChange = {
key: 1,
table: 'foo',
obj: {},
type: CREATE,
};
const nextChange = {
key: 1,
table: 'foo',
obj: {foo: 'bar'},
type: CREATE,
};
const res = mergeChange(prevChange, nextChange);
deepEqual(res, nextChange);
});
test('should just return the nextChange if it is DELETE', () => {
const prevChange = {
key: 1,
table: 'foo',
obj: {},
type: CREATE,
};
const nextChange = {
rev: 1,
key: 1,
table: 'foo',
type: DELETE,
};
const res = mergeChange(prevChange, nextChange);
deepEqual(res, nextChange);
});
test('should combine the CREATE and UPDATE change if nextChange is UPATE', () => {
const prevChange = {
key: 1,
table: 'foo',
obj: {
foo: 'baz',
},
type: CREATE,
};
const nextChange = {
key: 1,
table: 'foo',
mods: {
title: 'bar',
},
type: UPDATE,
};
const res = mergeChange(prevChange, nextChange);
const expectedResult = {
key: 1,
table: 'foo',
obj: {
title: 'bar',
foo: 'baz'
},
type: CREATE
};
deepEqual(res, expectedResult);
});
module('mergeChange: prev change was UPDATE', {
setup: () => {
},
teardown: () => {
}
});
test('should return the nextChange if it is CREATE', () => {
const prevChange = {
key: 1,
table: 'foo',
mods: {foo: 'bar'},
type: UPDATE,
};
const nextChange = {
key: 1,
table: 'foo',
obj: {foo: 'bar baz'},
type: CREATE,
};
const res = mergeChange(prevChange, nextChange);
deepEqual(res, nextChange);
});
test('should return the nextChange if it is DELETE', () => {
const prevChange = {
key: 1,
table: 'foo',
mods: {foo: 'bar'},
type: UPDATE,
};
const nextChange = {
rev: 1,
key: 1,
table: 'foo',
type: DELETE,
};
const res = mergeChange(prevChange, nextChange);
deepEqual(res, nextChange);
});
test('should the changes if the nextChange is UPDATE', () => {
const prevChange = {
key: 1,
table: 'foo',
mods: {
foo: 'baz',
},
type: UPDATE,
};
const nextChange = {
key: 1,
table: 'foo',
mods: {
title: 'bar',
},
type: UPDATE,
};
const res = mergeChange(prevChange, nextChange);
const expectedResult = {
key: 1,
table: 'foo',
mods: {
title: 'bar',
foo: 'baz'
},
type: UPDATE
};
deepEqual(res, expectedResult);
});
module('mergeChange: prev change was DELETE', {
setup: () => {
},
teardown: () => {
}
});
test('should return nextChange if it is CREATE', () => {
const prevChange = {
key: 1,
table: 'foo',
type: DELETE,
};
const nextChange = {
key: 1,
table: 'foo',
obj: {foo: 'bar'},
type: CREATE,
};
const res = mergeChange(prevChange, nextChange);
deepEqual(res, nextChange);
});
test('should return the prevChange if nextChange is DELETE', () => {
const prevChange = {
key: 1,
table: 'foo',
type: DELETE,
};
const nextChange = {
key: 1,
table: 'foo',
type: DELETE,
};
const res = mergeChange(prevChange, nextChange);
deepEqual(res, prevChange);
});
test('should return prevChange if nextChange is UPDATE', () => {
const prevChange = {
rev: 0,
key: 1,
table: 'foo',
type: DELETE,
};
const nextChange = {
rev: 1,
key: 1,
table: 'foo',
mods: {foo: 'bar'},
type: UPDATE,
};
const res = mergeChange(prevChange, nextChange);
deepEqual(res, prevChange);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-register-sync-protocol.js
================================================
import Dexie from 'dexie';
import '../../src/Dexie.Syncable';
import {module, test, strictEqual, raises} from 'QUnit';
module('registerSyncProtocol', {
setup: () => {
},
teardown: () => {
}
});
test('should set partialsThreshold to Infinity if no threshold was given', () => {
const protocolName = 'foo';
Dexie.Syncable.registerSyncProtocol(protocolName, {
sync() {},
});
strictEqual(Dexie.Syncable.registeredProtocols[protocolName].partialsThreshold, Infinity);
});
test('should save the given partialsThreshold', () => {
const protocolName = 'foo';
Dexie.Syncable.registerSyncProtocol(protocolName, {
sync() {},
partialsThreshold: 1000
});
strictEqual(Dexie.Syncable.registeredProtocols[protocolName].partialsThreshold, 1000);
});
test('should throw an error if the partialsThreshold is NaN or smaller 0', () => {
const protocolName = 'foo';
function fn1() {
Dexie.Syncable.registerSyncProtocol(protocolName, {
sync() {},
partialsThreshold: NaN
});
}
raises(fn1, Error, 'NaN test');
function fn2() {
Dexie.Syncable.registerSyncProtocol(protocolName, {
sync() {},
partialsThreshold: -10
});
}
raises(fn2, Error, 'Negative number test');
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-save-to-uncommitted-changes.js
================================================
import Dexie from 'dexie';
import observable from 'dexie-observable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
import {resetDatabase} from '../../../../test/dexie-unittest-utils';
import initSaveToUncommittedChanges from '../../src/save-to-uncommitted-changes';
import {CREATE, DELETE, UPDATE} from '../../src/change_types';
const db = new Dexie('TestDBTable', {addons: [observable]});
db.version(1).stores({
foo: "id"
});
const nodeID = 1;
db.observable.SyncNode.prototype.save = function() {
return {
then(cb){ cb(); }
};
};
let syncNode;
let saveToUncommittedChanges;
module('saveToUncommittedChanges', {
setup: () => {
stop();
syncNode = new db.observable.SyncNode();
syncNode.id = nodeID;
saveToUncommittedChanges = initSaveToUncommittedChanges(db, syncNode);
resetDatabase(db).catch(function (e) {
ok(false, "Error resetting database: " + e.stack);
}).finally(start);
},
teardown: () => {
}
});
asyncTest('should save the given changes in the _uncommittedChanges table', () => {
const create = {
key: 1,
table: 'foo',
type: CREATE,
obj: {foo: 'bar'}
};
const update = {
key: 2,
table: 'foo',
type: UPDATE,
mods: {bar: 'baz'}
};
const remove = {
key: 3,
table: 'foo',
type: DELETE
};
const changes = [create, update, remove];
saveToUncommittedChanges(changes, 10)
.then(() => {
return db._uncommittedChanges.toArray();
})
.then((changes) => {
strictEqual(changes.length, 3, 'Number of changes matches');
deepEqual(changes[0], {
id: 1,
key: 1,
type: CREATE,
node: nodeID,
table: 'foo',
obj: {foo: 'bar'}
}, 'Create change');
deepEqual(changes[1], {
id: 2,
key: 2,
type: UPDATE,
node: nodeID,
table: 'foo',
mods: {bar: 'baz'}
}, 'Update change');
deepEqual(changes[2], {
id: 3,
key: 3,
type: DELETE,
node: nodeID,
table: 'foo'
}, 'Delete change');
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
asyncTest('should add the remoteRevision to the given node', () => {
const remoteRevision = 20;
saveToUncommittedChanges([], remoteRevision)
.then(() => {
strictEqual(syncNode.appliedRemoteRevision, remoteRevision);
})
.catch(function(err) {
ok(false, "Error: " + err);
})
.finally(start);
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-syncable-partials.js
================================================
import Dexie from 'dexie';
import 'dexie-observable';
import '../../src/Dexie.Syncable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
/* The following is being tested:
1. Client partials
2. Receiving partials from the server
3. What happens when the partialsThreshold is 0
*/
var db1 = new Dexie("db1");
var deletePromise = Dexie.delete("db1");
module("tests-syncable-partials", {
setup: function () {
db1.close();
stop();
deletePromise.then(function () {
start()
});
},
teardown: function () {
}
});
const partialsThreshold = 100;
asyncTest("client/server partials", function () {
var testNo = 0;
var callbacks = [];
Dexie.Syncable.registerSyncProtocol("testProtocol", {
sync: function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
var thiz = this, args = arguments;
Dexie.vip(function () {
try {
callbacks[testNo++].apply(thiz, args);
} catch (err) {
db1.close();
ok(false, err);
start();
}
});
},
partialsThreshold: partialsThreshold
});
db1.version(1).stores({objects: "$$"});
db1.on('populate', function () {
db1.objects.add({name: "one"});
db1.objects.add({name: "two"});
db1.objects.add({name: "three"});
});
db1.open();
db1.syncable.connect("testProtocol", "http://dummy.local");
// Prepare tests
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// applyRemoteChanges
applyRemoteChanges([], "revision one", false, false).then(function () {
// onChangesAccepted
onChangesAccepted();
}).then(function () {
// onSuccess
onSuccess({again: 1});
});
});
// Bulk add changes to pass the threshold
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// baseRevision
equal(baseRevision, "revision one", "Now we get changes based on revision two");
// syncedRevision
equal(syncedRevision, "revision one", "Sync revision is 'revision two' because client has got it");
// partial
equal(partial, false, 'partial should be false');
onChangesAccepted();
db1.transaction('rw', db1.objects, function () {
for (var i = 0; i < partialsThreshold + 1; ++i) {
db1.objects.add({name: "bulk"});
}
}).then(function () {
onSuccess({again: 1});
});
});
// Make sure that we didn't receive more changes than the threshold
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// baseRevision
equal(baseRevision, "revision one", "Now we get changes based on revision two");
// syncedRevision
equal(syncedRevision, "revision one", "Sync revision is 'revision two' because client has got it");
// changes
equal(changes.length, partialsThreshold, `Got ${partialsThreshold} changes`);
equal(changes[0].obj.name, "bulk", "change is bulk");
equal(partial, true, `More than ${partialsThreshold} changes gives partial=true`);
onChangesAccepted();
onSuccess({again: 1});
});
// Make sure we now get the rest of the changes
// Revisions shouldn't change
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// baseRevision
equal(baseRevision, "revision one", "Now we get changes based on revision two");
// syncedRevision
equal(syncedRevision, "revision one", "Sync revision is 'revision two' because client has got it");
// changes
equal(changes.length, 1, "Got 1 change");
equal(changes[0].obj.name, "bulk", "change is bulk");
equal(partial, false, "Last chunk with 1 change");
onSuccess({again: 1});
});
// Test that a server partial is added to _uncommittedChanges and not to _changes
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
applyRemoteChanges([{
type: 1,
table: "objects",
key: "apa",
obj: {name: "uncommittedChange"}
}], "revision with uncommitted", true, false)
.then(() => {
return db1._uncommittedChanges.toArray().then((changes) => {
strictEqual(changes.length, 1, 'Should have one uncommitted change');
strictEqual(changes[0].key, 'apa', 'Key should match');
deepEqual(changes[0].obj, {name: 'uncommittedChange'}, 'Saved obj should match');
strictEqual(changes[0].table, 'objects', 'Table should match');
strictEqual(changes[0].type, 1, 'Type should match');
return db1._changes.toArray();
});
})
.then((changes) => {
ok(changes.every(function (change) {
return change.obj.name !== 'uncommittedChange'
}), 'The uncommitted change should not be in _changes');
return db1._syncNodes.where('url').equals('http://dummy.local').toArray();
})
.then((nodes) => {
strictEqual(nodes[0].appliedRemoteRevision, 'revision with uncommitted', "The node's appliedRemoteRevision should be updated");
onSuccess({again: 1});
});
});
// Test that if we don't have partial anymore -> move _uncommittedChanges to _changes
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
applyRemoteChanges([{
type: 1,
table: "objects",
key: "abba",
obj: {name: "committedChange"}
}], "revision one", false, false)
.then(() => {
return db1._uncommittedChanges.toArray().then((changes) => {
strictEqual(changes.length, 0, 'Should have no uncommitted change');
return db1._changes.toArray();
});
})
.then((changes) => {
ok(changes.some(function (change) {
return change.obj.name === 'uncommittedChange'
}), 'The uncommitted change should now be in _changes');
ok(changes.some(function (change) {
return change.obj.name === 'committedChange'
}), 'The committedChange should also be in _changes');
})
.then(function () {
return db1.delete();
}).catch(function (err) {
ok(false, "Got error: " + err);
}).finally(start);
});
});
asyncTest('partialsThreshold is zero', () => {
var testNo = 0;
var callbacks = [];
Dexie.Syncable.registerSyncProtocol("testProtocol", {
sync: function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
var thiz = this, args = arguments;
Dexie.vip(function () {
try {
callbacks[testNo++].apply(thiz, args);
} catch (err) {
db1.close();
ok(false, err);
start();
}
});
},
partialsThreshold: 0
});
db1.version(1).stores({objects: "$$"});
db1.on('populate', function () {
db1.objects.add({name: "one"});
db1.objects.add({name: "two"});
});
db1.open();
db1.syncable.connect("testProtocol", "http://dummy.local", {option1: "option1"});
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial) {
// changes
equal(changes.length, 0, "No changes");
equal(partial, true, "Partial since threshold is 0");
start();
});
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-syncable.js
================================================
import Dexie from 'dexie';
import 'dexie-observable';
import '../../src/Dexie.Syncable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
/* The following is being tested:
1. A dummy implementation of ISyncProtocol is registered so that the unit test can interact with the database correctly.
2. Test status changes
3. Test disconnect/reconnect and that we get changes which happened as we were disconnected
*/
var db1 = new Dexie("db1");
var db2 = new Dexie("db1");
var deletePromise = Dexie.delete("db1");
module("tests-syncable", {
setup: function () {
db1.close();
db2.close();
stop();
deletePromise.then(function () {
start()
});
},
teardown: function () {
}
});
asyncTest("connect(), disconnect()", function () {
var testNo = 0;
var callbacks = [];
Dexie.Syncable.registerSyncProtocol("testProtocol", {
sync: function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
var thiz = this, args = arguments;
Dexie.vip(function () {
try {
callbacks[testNo++].apply(thiz, args);
} catch (err) {
db1.close();
ok(false, err);
start();
}
});
},
partialsThreshold: 1000
});
db1.version(1).stores({objects: "$$"});
db2.version(1).stores({objects: "$$"});
db1.on('populate', function () {
db1.objects.add({name: "one"});
db1.objects.add({name: "two"});
db1.objects.add({name: "three"}).then(function (key) {
db1.objects.update(key, {name: "four"});
});
});
db1.syncable.on('statusChanged', function (newStatus) {
ok(true, "Status changed to " + Dexie.Syncable.StatusTexts[newStatus]);
});
db2.syncable.on('statusChanged', function (newStatus) {
ok(true, "Status changed to " + Dexie.Syncable.StatusTexts[newStatus]);
});
db1.open();
var connectPromise = db1.syncable.connect("testProtocol", "http://dummy.local", {option1: "option1"});
// Test first sync call
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// url
equal(url, "http://dummy.local", "URL got through");
// options
equal(options.option1, "option1", "Options got through");
// baseRevision
equal(baseRevision, null, "Base revision is null");
// syncedRevision
equal(syncedRevision, null, "Sync revision is null");
// changes
equal(changes.length, 3, "Three changes (change number four should be reduced into change no 3");
ok(changes.every(function (change) {
return change.type == 1
}), "All three changes are create changes");
ok(changes.some(function (change) {
return change.obj.name == "one"
}), "'one' is among changes");
ok(changes.some(function (change) {
return change.obj.name == "two"
}), "'two' is among changes");
ok(changes.some(function (change) {
return change.obj.name == "four"
}), "'four' is among changes");
// partial
equal(partial, false, "Not partial since number of changes are below 1000");
// applyRemoteChanges
applyRemoteChanges([{
type: 1,
table: "objects",
key: "apa",
obj: {name: "five"}
}], "revision one", false, false).then(function () {
// Create a local change between remoteChanges application
return db1.objects.add({name: "six"});
}).then(function () {
return applyRemoteChanges([{
type: 1,
table: "objects",
key: "apa2",
obj: {name: "seven"}
}], "revision two", false, false);
}).then(function () {
// onChangesAccepted
onChangesAccepted();
return db1.objects.add({name: "eight"});
}).then(function () {
// onSuccess
onSuccess({again: 1});
});
});
connectPromise.then(function () {
db1.objects.count(function (count) {
equal(count, 7, "There should be seven objects in db after sync");
// From populate:
// 1: one
// 2: two
// 3: four
// 4: applyRemoteChanges: "five" ("apa")
// 5: db.objects.add("six")
// 6: applyRemoteChanges: "seven" ("apa2")
// 7: db.objects.add("eight");
});
db1.objects.get("apa2", function (seven) {
equal(seven.name, "seven", "Have got the change from the server. If not, check that promise does not fire until all changes have committed.");
});
}).catch('DatabaseClosedError', function () {
console.warn("DatabaseClosedError");
}).catch(function (ex) {
ok(false, "Could not connect. Error: " + (ex.stack || ex));
});
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// url
equal(url, "http://dummy.local", "URL still there");
// options
equal(options.option1, "option1", "Options still there");
// baseRevision
equal(baseRevision, "revision one", "First chunk of changes is based on revision one because 'six' was created based on revision one");
// syncedRevision
equal(syncedRevision, "revision two", "Sync revision is 'revision two' because client has got it");
// changes
equal(changes.length, 1, "Even though there's two changes, we should only get the first one because they are based on different revisions");
equal(changes[0].obj.name, "six", "First change is six");
equal(partial, false);
onChangesAccepted();
onSuccess({again: 1});
});
// Prepare disconnect test
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// baseRevision
equal(baseRevision, "revision two", "Now we get changes based on revision two");
// syncedRevision
equal(syncedRevision, "revision two", "Sync revision is 'revision two' because client has got it");
// changes
equal(changes.length, 1, "Got another change");
equal(changes[0].obj.name, "eight", "change is eight");
equal(partial, false);
onChangesAccepted();
// Test disconnect()
db1.syncable.disconnect("http://dummy.local");
onSuccess({again: 1}); // Framework should ignore again: 1 since it's disconnected.
setTimeout(reconnect, 500);
db1.objects.add({name: "changeAfterDisconnect"});
});
function reconnect() {
db1.close();
db1 = db2;
db1.open().then(function () {
return db1.objects.add({name: "changeBeforeReconnect"});
}).then(function () {
return db1.syncable.getStatus("http://dummy.local", function (status) {
equal(status, Dexie.Syncable.Statuses.OFFLINE, "Status is OFFLINE");
});
}).then(function () {
db1.syncable.connect("testProtocol", "http://dummy.local");
});
}
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// baseRevision
equal(baseRevision, "revision two", "baseRevision Still revision two");
// syncedRevision
equal(syncedRevision, "revision two", "syncedRevision Still revision two");
// changes
equal(changes.length, 2, "Got 2 changes after reconnect.");
equal(changes[0].obj.name, "changeAfterDisconnect", "change one is changeAfterDisconnect");
equal(changes[1].obj.name, "changeBeforeReconnect", "change two is changeBeforeReconnect");
onChangesAccepted();
onSuccess({again: 10000}); // Wait a looong time for calling us again (so that we have the time to close and reopen and then force a sync sooner)
setTimeout(function () {
db1.syncable.getStatus("http://dummy.local", function (status) {
// Close and open again and it will be status connected at once
equal(status, Dexie.Syncable.Statuses.ONLINE, "Status is ONLINE");
}).then(function () {
// Close and open again and it will be status connected at once
return db1.delete();
}).catch(function (err) {
ok(false, "Got error: " + err);
}).finally(start);
}, 100);
});
});
asyncTest('delete()', () => {
var testNo = 0;
var callbacks = [];
Dexie.Syncable.registerSyncProtocol("testProtocol", {
sync: function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
var thiz = this, args = arguments;
Dexie.vip(function () {
try {
callbacks[testNo++].apply(thiz, args);
} catch (err) {
db1.close();
ok(false, err);
start();
}
});
},
partialsThreshold: 10
});
db1.version(1).stores({objects: "$$"});
db1.on('populate', function () {
db1.objects.add({name: "one"});
db1.objects.add({name: "two"});
});
db1.open();
const url = "http://urlToDelete.local";
db1.syncable.connect("testProtocol", url);
db1.syncable.on('statusChanged', function (newStatus) {
ok(true, "Status changed to " + Dexie.Syncable.StatusTexts[newStatus]);
});
const originalDisconnect = db1.syncable.disconnect;
let disconnectWasCalled = false;
db1.syncable.disconnect = function (url) {
disconnectWasCalled = true;
return originalDisconnect(url);
};
const originalDeleteOldChanges = Dexie.Observable.deleteOldChanges;
let deleteOldChangesWasCalled = false;
Dexie.Observable.deleteOldChanges = function (db) {
deleteOldChangesWasCalled = true;
return originalDeleteOldChanges(db);
};
callbacks.push(function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
// Add some uncommitted changes
applyRemoteChanges([{
type: 1,
table: "objects",
key: "apa",
obj: {name: "uncommittedChangeBeforeDelete"}
}], "revision with uncommitted", true, false)
.then(() => {
return db1.syncable.delete(url);
})
.then(() => {
ok(disconnectWasCalled, 'We got disconnected');
return db1._uncommittedChanges.toArray();
})
.then((uncommittedChanges) => {
ok(uncommittedChanges.every(function (change) {
return change.obj.name !== 'uncommittedChangeBeforeDelete'
}), 'The uncommitted change should not be in _uncommittedChanges anymore');
return db1._syncNodes.where('url').equals(url).toArray();
})
.then((nodes) => {
strictEqual(nodes.length, 0, 'All nodes with this url should be deleted');
ok(deleteOldChangesWasCalled, 'Observable.deleteOldChanges was called');
})
.catch(function (err) {
ok(false, "Got error: " + err);
}).finally(start);
onSuccess(); // Stop syncing
});
});
================================================
FILE: addons/Dexie.Syncable/test/unit/tests-syncprovider.js
================================================
import Dexie from 'dexie';
import 'dexie-observable';
import '../../src/Dexie.Syncable';
import {module, asyncTest, start, stop, strictEqual, deepEqual, ok} from 'QUnit';
/* To Test:
* db.sync() using WebSocketSyncProtocol and WebSocketSyncServer with two db's of different names in same window.
* Add object to db1
* Make sure it appears in db2
* Add 1100 objects to db2
* Make sure they all appear in db1 - ALSO DEBUG THAT:
* db2 sends it's changes as two chunks where first one is partial and second final
* SyncServer gets partial and applies to uncommittedChanges
* SyncServer gets final and commits changes, as well as triggers db1 provider, who
* calls applyRemoteChanges on all objects.
* db1.on('changes') gets triggered.
* Add 1000 objects the same way and debug that:
* db2 sents it's changes as one or two chunks depending on how I implemented it.
* Add another db with same name as one of the two dbs.
* Make sure that the third DB is not master
* Make sure that sync() on third db will call master and make it sync, or connect to existing syncing.
* Make sure that the third DB gets changes added to other remote db.
* Take down master and make sure third DB becomes master and can continue syncing.
*/
/* WebSocketSyncProtocol
* Was copied from /samples/remote-sync/websocket/WebSocketSyncProtocol.js
* The tests would hang with the original file. Probably because of different instances of
* Dexie and Dexie.Syncable (there were no registered protocols in the test when using directly the file from samples)
*/
// Constants:
var RECONNECT_DELAY = 5000;
Dexie.Syncable.registerSyncProtocol("websocket", {
sync: function (context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
var requestId = 0;
var acceptCallbacks = {};
var ws = new WebSocket(url);
function sendChanges(changes, baseRevision, partial, onChangesAccepted) {
++requestId;
acceptCallbacks[requestId.toString()] = onChangesAccepted;
ws.send(JSON.stringify({
type: 'changes',
changes: changes,
partial: partial,
baseRevision: baseRevision,
requestId: requestId
}));
}
ws.onopen = function (event) {
ws.send(JSON.stringify({
type: "clientIdentity",
clientIdentity: context.clientIdentity || null
}));
sendChanges(changes, baseRevision, partial, onChangesAccepted);
ws.send(JSON.stringify({
type: "subscribe",
syncedRevision: syncedRevision
}));
}
ws.onerror = function (event) {
ws.close();
onError(event.message, RECONNECT_DELAY);
}
ws.onclose = function (event) {
onError("Socket closed: " + event.reason, RECONNECT_DELAY);
}
var isFirstRound = true;
ws.onmessage = function (event) {
try {
var requestFromServer = JSON.parse(event.data);
if (requestFromServer.type == "changes") {
applyRemoteChanges(requestFromServer.changes, requestFromServer.currentRevision, requestFromServer.partial);
if (isFirstRound && !requestFromServer.partial) {
onSuccess({
react: function (changes, baseRevision, partial, onChangesAccepted) {
sendChanges(changes, baseRevision, partial, onChangesAccepted);
},
disconnect: function () {
ws.close();
}
});
isFirstRound = false;
}
} else if (requestFromServer.type == "ack") {
var requestId = requestFromServer.requestId;
var acceptCallback = acceptCallbacks[requestId.toString()];
acceptCallback(); // Tell framework that server has acknowledged the changes sent.
delete acceptCallbacks[requestId.toString()];
} else if (requestFromServer.type == "clientIdentity") {
context.clientIdentity = requestFromServer.clientIdentity;
context.save();
} else if (requestFromServer.type == "error") {
var requestId = requestFromServer.requestId;
ws.close();
onError(requestFromServer.message, Infinity); // Don't reconnect - an error in application level means we have done something wrong.
}
} catch (e) {
ws.close();
onError(e, Infinity); // Something went crazy. Server sends invalid format or our code is buggy. Dont reconnect - it would continue failing.
}
}
}
});
module("tests-syncprovider", {
setup: function () {
stop();
Dexie.Promise.all(Dexie.delete("SyncProviderTest"), Dexie.delete("OtherClientDB")).then(function () {
start();
}).catch(function (e) {
ok(false, "Could not delete database");
});
},
teardown: function () {
stop();
Dexie.Promise.all(Dexie.delete("SyncProviderTest"), Dexie.delete("OtherClientDB")
).catch('DatabaseClosedError', function () {
}).then(function () {
start();
});
}
});
asyncTest("testSyncProvider", function () {
var CREATE = 1,
UPDATE = 2,
DELETE = 3;
var db = new Dexie("SyncProviderTest");
db.version(1).stores({
friends: "$$id,name",
pets: "$$id,kind,name"
});
// Setup the sync server
var server = new SyncServer(5000);
server.start();
// Connect our db client to it
db.syncable.connect("websocket", "http://dummy:5000");
db.syncable.on('statusChanged', function (newStatus, url) {
console.log("Sync State Changed: " + Dexie.Syncable.StatusTexts[newStatus]);
});
// Open database
db.open();
// Create another database to sync with:
var db2 = new Dexie("OtherClientDB");
db2.version(1).stores({
friends: "$$id",
pets: "$$id"
});
db2.syncable.connect("websocket", "http://dummy:5000");
db2.open().then(function () {
console.log("db2 opened");
});
db2.on('changes', function (changes, partial) {
console.log("db2.on('changes'): changes.length: " + changes.length + "\tpartial: " + (partial ? "true" : "false"));
changes.forEach(function (change) {
//console.log(JSON.stringify(change));
db2.checkChange(change);
});
});
db.on('changes', function (changes, partial) {
console.log("db.on('changes'): changes.length: " + changes.length + "\tpartial: " + (partial ? "true" : "false"));
changes.forEach(function (change) {
if (db.checkChange) db.checkChange(change);
});
});
function waitFor(db, params) {
return new Dexie.Promise(function (resolve, reject) {
db.checkChange = function (change) {
var checker = {};
Dexie.extend(checker, change);
if (change.type == CREATE) Dexie.extend(checker, change.obj);
if (change.type == UPDATE) Dexie.extend(checker, change.mods);
var found = true;
Object.keys(params).forEach(function (param) {
if (!(param in checker)) found = false;
if (params[param] != checker[param]) found = false;
});
if (found) resolve();
}
});
}
db.friends.add({name: "David"});
waitFor(db2, {type: CREATE, name: "David"}).then(function () {
ok(true, "The CREATE of friend 'David' was sent all the way to server and then back again to db2.");
db.friends.where('name').equals('David').modify({name: "Ylva"});
return waitFor(db2, {type: UPDATE, name: "Ylva"});
}).then(function () {
ok(true, "The UPDATE of friend 'David' to 'Ylva' was sent all the way around as well");
return db.friends.where('name').equals('Ylva').first(function (friend) {
return friend.id;
})
}).then(function (id) {
db.friends.delete(id);
return waitFor(db2, {type: DELETE, key: id});
}).then(function () {
ok(true, "The DELETE of friend 'Ylva' was sent all the way around as well");
// Now send 1100 create requests
var petsToAdd = new Array(1100);
for (var i = 0; i < petsToAdd.length; ++i) {
petsToAdd[i] = {name: "Josephina" + (i + 1), kind: "Dog"};
}
return db2.pets.bulkAdd(petsToAdd);
}).then(function () {
ok(true, "Successfully added 1100 pets into db2. Now wait for the last change to be synced into db1.");
return waitFor(db, {type: CREATE, name: "Josephina1100"});
}).then(function () {
ok(true, "All 1100 dogs where sent all the way around (db2-->db this time)");
// Now check that db2 contains all dogs and that its _uncommittedChanges is emptied
return db.pets.count(function (count) {
equal(count, 1100, "DB2 has 1100 pets now");
});
}).then(function () {
return db._uncommittedChanges.count(function (count) {
equal(count, 0, "DB2 has no uncommitted changes anymore");
});
}).then(function () {
ok(true, "Now send 1000 create this time (exact number of max changes per chunk)");
var petsToAdd = new Array(1000);
for (var i = 0; i < petsToAdd.length; ++i) {
petsToAdd[i] = {name: "Tito" + (i + 1), kind: "Cat"};
}
return db.pets.bulkAdd(petsToAdd);
}).then(function () {
ok(true, "Successfully added 1000 cats. Now wait for them to arrive in db2.");
return waitFor(db2, {type: CREATE, name: "Tito1000"});
}).then(function () {
ok(true, "All 1000 cats where sent all the way around (db-->db2 this time)");
}).finally(function () {
console.log("Closing down");
db.close();
db2.close();
start();
});
});
================================================
FILE: addons/Dexie.Syncable/test/unit/unit-tests-all.js
================================================
import './get-local-changes-for-node/tests-get-base-revision-and-max-client-revision.js';
import './get-local-changes-for-node/tests-get-changes-since-revision.js';
import './get-local-changes-for-node/tests-get-local-changes-for-node.js';
import './tests-apply-changes.js';
import './tests-bulk-update.js';
import './tests-changing-options.js';
import './tests-combine-create-and-update.js';
import './tests-combine-update-and-update.js';
import './tests-finally-commit-all-changes.js';
import './tests-get-or-create-sync-node.js';
import './tests-merge-change.js';
import './tests-PersistedContext.js';
import './tests-register-sync-protocol.js';
import './tests-save-to-uncommitted-changes.js';
import './tests-syncable.js';
import './tests-syncable-partials.js';
import './tests-syncprovider.js';
import './tests-WebSocketSyncServer.js';
================================================
FILE: addons/Dexie.Syncable/tools/build-configs/banner.txt
================================================
/* ==========================================================================
* dexie-syncable.js
* ==========================================================================
*
* Dexie addon for syncing indexedDB with remote endpoints.
*
* By David Fahlander, david.fahlander@gmail.com,
* Nikolas Poniros, https://github.com/nponiros
*
* ==========================================================================
*
* Version {version}, {date}
*
* https://dexie.org
*
* Apache License Version 2.0, January 2004, http://www.apache.org/licenses/
*
*/
================================================
FILE: addons/Dexie.Syncable/tools/build-configs/rollup.config.mjs
================================================
import sourcemaps from 'rollup-plugin-sourcemaps';
import {readFileSync} from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf-8'));
const version = packageJson.version;
export default {
input: 'tools/tmp/es5/addons/Dexie.Syncable/src/Dexie.Syncable.js',
output: [{
file: 'dist/dexie-syncable.js',
format: 'umd',
banner: readFileSync(path.resolve(__dirname, 'banner.txt')),
globals: {dexie: "Dexie", "dexie-observable": "Dexie.Observable"},
name: "Dexie.Syncable",
},{
file: 'dist/dexie-syncable.es.js',
format: 'es',
banner: readFileSync(path.resolve(__dirname, 'banner.txt')),
globals: {dexie: "Dexie", "dexie-observable": "Dexie.Observable"},
name: "Dexie.Syncable",
}],
external: ['dexie', 'dexie-observable'],
plugins: [ sourcemaps() ]
};
================================================
FILE: addons/Dexie.Syncable/tools/build-configs/rollup.tests.config.js
================================================
import sourcemaps from 'rollup-plugin-sourcemaps';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
const ERRORS_TO_IGNORE = [
"THIS_IS_UNDEFINED"
];
export default {
input: 'tools/tmp/es5/addons/Dexie.Syncable/test/unit/unit-tests-all.js',
output: [{
file: 'test/unit/bundle.js',
format: 'umd',
name: 'dexieSyncableTests',
globals: {dexie: "Dexie", "dexie-observable": "Dexie.Observable", QUnit: "QUnit"},
}],
external: ['dexie', 'dexie-observable', 'QUnit'],
plugins: [
sourcemaps(),
nodeResolve({browser: true}),
commonjs()
],
onwarn ({loc, frame, code, message}) {
if (ERRORS_TO_IGNORE.includes(code)) return;
if ( loc ) {
console.warn( `${loc.file} (${loc.line}:${loc.column}) ${message}` );
if ( frame ) console.warn( frame );
} else {
console.warn(`${code} ${message}`);
}
}
};
================================================
FILE: addons/Dexie.Syncable/tools/build-configs/rollup.tests.config.mjs
================================================
import sourcemaps from 'rollup-plugin-sourcemaps';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
const ERRORS_TO_IGNORE = [
"THIS_IS_UNDEFINED"
];
export default {
input: 'tools/tmp/es5/addons/Dexie.Syncable/test/unit/unit-tests-all.js',
output: [{
file: 'test/unit/bundle.js',
format: 'umd',
name: 'dexieSyncableTests',
globals: {dexie: "Dexie", "dexie-observable": "Dexie.Observable", QUnit: "QUnit"},
}],
external: ['dexie', 'dexie-observable', 'QUnit'],
plugins: [
sourcemaps(),
nodeResolve({browser: true}),
commonjs()
],
onwarn ({loc, frame, code, message}) {
if (ERRORS_TO_IGNORE.includes(code)) return;
if ( loc ) {
console.warn( `${loc.file} (${loc.line}:${loc.column}) ${message}` );
if ( frame ) console.warn( frame );
} else {
console.warn(`${code} ${message}`);
}
}
};
================================================
FILE: addons/Dexie.Syncable/tools/replaceVersionAndDate.js
================================================
const fs = require('fs');
const files = process.argv.slice(2);
const version = require('../package.json').version;
files.forEach(file => {
let fileContent = fs.readFileSync(file, "utf-8");
fileContent = fileContent
.replace(/{version}/g, version)
.replace(/{date}/g, new Date().toDateString());
fs.writeFileSync(file, fileContent, "utf-8");
});
================================================
FILE: addons/dexie-cloud/.gitignore
================================================
dist/
esnext/
.eslintcache
**/tmp/
test/**/bundle.*
dexie-cloud.key
dexie-cloud.json
================================================
FILE: addons/dexie-cloud/.npmignore
================================================
.DS_Store
tools/
src/
bin-src/
.*
tmp/
**/tmp/
test
*.log
dexie-cloud.key
dexie-cloud.json
================================================
FILE: addons/dexie-cloud/.vscode/settings.json
================================================
{
"editor.formatOnSave": true
}
================================================
FILE: addons/dexie-cloud/README.md
================================================
The web client for [Dexie Cloud](https://dexie.org/cloud/).
## Getting started
```
npm install dexie@latest
npm install dexie-cloud-addon@latest
```
```ts
import { Dexie } from 'dexie';
import dexieCloud from 'dexie-cloud-addon';
const db = new Dexie('dbname', { addons: [dexieCloud]});
db.version(1).stores({
yourTable: '@primKeyProp, indexedProp1, indexedProp2, ...'
});
db.cloud.configure({
databaseUrl: 'https://.dexie.cloud'
})
```
## See also
https://dexie.org/cloud/docs/dexie-cloud-addon#api
## Obtaining a database URL
Run the following command in a console / terminal:
```
npx dexie-cloud create
```
See also https://dexie.org/cloud/#getting-started
*Having problems getting started, please [file an issue](https://github.com/dexie/Dexie.js/issues/new)*
# The Cloud Service
[](https://www.dexie-cloud-status.com)
The official production service for Dexie Cloud Server is forever free of charge so you do not need to install any server to get started. The free service has some limits, see https://dexie.org/cloud/pricing
# On-Prem version
Dexie Cloud Server is a closed source software that can be purchased and installed on own hardware, see [On-Prem Silver / On-Prem Gold](https://dexie.org/cloud/pricing)
# CLI
See https://dexie.org/cloud/docs/cli
# APIs
See https://dexie.org/cloud/docs/dexie-cloud-api
================================================
FILE: addons/dexie-cloud/TODO-SOCIALAUTH.md
================================================
# Social Authentication for Dexie Cloud
## Overview
This feature adds support for OAuth 2.0 social login providers (Google, GitHub, Microsoft, Apple, and custom OAuth2) as an alternative to the existing OTP (One-Time Password) email authentication in Dexie Cloud.
**Key Design Principle**: The Dexie Cloud server acts as an OAuth broker, handling all provider interactions including the OAuth callback. The client library (dexie-cloud-addon) never receives provider tokens - only Dexie Cloud authorization codes which are exchanged for Dexie Cloud tokens.
### Related Files
- **Detailed flow diagram**: [oauth_flow.md](oauth_flow.md) - Sequence diagrams and detailed protocol description
- **Server implementation**: See `dexie-cloud-server` repository
- `src/api/oauth/registerOAuthEndpoints.ts` - OAuth endpoints
- `src/api/oauth/oauth-helpers.ts` - Provider exchange logic
- `src/api/registerTokenEndpoint.ts` - Token endpoint (authorization_code grant)
### Flow Summary
1. **Client** fetches available auth providers from `GET /auth-providers`
2. **Client** redirects to `GET /oauth/login/:provider` (full page redirect)
3. **Dexie Cloud Server** redirects to OAuth provider and handles callback at `/oauth/callback/:provider`
4. **Server** exchanges provider code for tokens, verifies email, generates single-use Dexie auth code
5. **Server** redirects back to client with `dxc-auth` query parameter (base64url-encoded JSON)
6. **Client** detects `dxc-auth` in `db.cloud.configure()`, exchanges code for tokens via `POST /token`
### Supported Providers
- **Google** - OpenID Connect with PKCE
- **GitHub** - OAuth 2.0 (client secret only)
- **Microsoft** - OpenID Connect with PKCE
- **Apple** - Sign in with Apple (form_post response mode)
- **Custom OAuth2** - Configurable endpoints for self-hosted identity providers
### Client Delivery Methods
| Method | Use Case | Delivery Mechanism |
|--------|----------|-------------------|
| **Full Page Redirect** | Web SPAs (recommended) | HTTP redirect with `dxc-auth` query param |
| **Custom URL Scheme** | Capacitor/Native apps | Deep link redirect (e.g., `myapp://`) |
---
## Implementation Status
### ✅ Server-Side (dexie-cloud-server) - COMPLETE
- [x] **OAuth provider configuration type** (`OAuthProviderConfig`)
- [x] **`GET /auth-providers` endpoint** - Returns enabled providers and OTP status
- [x] **`GET /oauth/login/:provider` endpoint** - Initiates OAuth flow with PKCE
- [x] **`GET /oauth/callback/:provider` endpoint** - Handles provider callback, redirects with `dxc-auth`
- [x] **`POST /token` with `grant_type: "authorization_code"`** - Exchanges Dexie auth code for tokens
- [x] **OAuth helper functions** (`oauth-helpers.ts`)
- [x] **Configuration GUI in dexie-cloud-manager**
### ✅ Client-Side (dexie-cloud-addon) - COMPLETE
#### Types in dexie-cloud-common
- [x] **`OAuthProviderInfo` type** - Provider metadata
- [x] **`AuthProvidersResponse` type** - Response from `/auth-providers`
- [x] **`AuthorizationCodeTokenRequest` type** - Token request for OAuth codes
#### Types and Interfaces in dexie-cloud-addon
- [x] **Extended `LoginHints` interface** - Added `provider`, `oauthCode`
- [x] **`DXCProviderSelection` interaction type** - For provider selection UI
- [x] **`DexieCloudOptions` extension** - Added `socialAuth`, `oauthRedirectUri`
#### Core Authentication Flow
- [x] **`fetchAuthProviders()`** - Fetches available providers from server
- [x] **`startOAuthRedirect()`** - Initiates OAuth via full page redirect
- [x] **`parseOAuthCallback()`** - Parses `dxc-auth` query parameter
- [x] **`cleanupOAuthUrl()`** - Removes `dxc-auth` from URL via `history.replaceState()`
- [x] **`exchangeOAuthCode()`** - Exchanges Dexie auth code for tokens
- [x] **OAuth detection in `configure()`** - Auto-detects `dxc-auth` on page load
- [x] **OAuth processing in `onDbReady`** - Completes login when database is ready
- [x] **Updated `login()` function** - Supports `provider` and `oauthCode` hints
#### Default UI Components
- [x] **`ProviderSelectionDialog`** - Renders provider selection screen
- [x] **`AuthProviderButton`** - Renders individual provider buttons with icons
- [x] **`OtpButton`** - "Continue with email" option
- [x] **Updated `LoginDialog.tsx`** - Handles OAuth redirect flow
- [x] **Updated `Styles.ts`** - Provider button styles
#### Error Handling
- [x] **`OAuthError` class** - Error codes: `access_denied`, `invalid_state`, `email_not_verified`, `expired_code`, `provider_error`, `network_error`
---
## 🔲 Remaining TODO
### Testing
- [ ] **Unit tests for OAuth flow**
- Test `parseOAuthCallback()` with various `dxc-auth` payloads
- Test error scenarios
- Test URL cleanup
- [ ] **Integration tests**
- Test full redirect flow with mock server
- Test token exchange
- [ ] **Manual testing**
- Test with `samples/dexie-cloud-todo-app`
- Test with Capacitor app (deep links)
### Documentation
- [ ] **Update README.md**
- Document OAuth login: `db.cloud.login({ provider: 'google' })`
- Show Capacitor integration pattern
- Explain redirect flow
- [ ] **Update dexie.org docs**
- Add OAuth configuration guide
- Document `socialAuth` and `oauthRedirectUri` options
---
## Client Integration Patterns
### Web SPA (Redirect Flow)
```typescript
// Configure database
db.cloud.configure({
databaseUrl: 'https://mydb.dexie.cloud'
});
// OAuth callback is handled automatically!
// When page loads with ?dxc-auth=..., the addon:
// 1. Detects the parameter in configure()
// 2. Cleans up the URL immediately
// 3. Completes login in db.on('ready')
// To manually initiate OAuth (e.g., from custom UI):
await db.cloud.login({ provider: 'google' });
// Page redirects to OAuth provider, then back with auth code
```
### Capacitor / Native App
```typescript
// Configure with custom URL scheme
db.cloud.configure({
databaseUrl: 'https://mydb.dexie.cloud',
oauthRedirectUri: 'myapp://'
});
// Handle deep link in app
App.addListener('appUrlOpen', async ({ url }) => {
const callback = handleOAuthCallback(url);
if (callback) {
await db.cloud.login({
oauthCode: callback.code,
provider: callback.provider
});
}
});
// Initiate login (opens system browser)
await db.cloud.login({ provider: 'google' });
```
---
## Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ CLIENT (dexie-cloud-addon) │
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ LoginDialog │───▶│ startOAuthRedirect│──▶ window.location.href = │
│ │ (default UI) │ │ () │ /oauth/login/:provider │
│ └─────────────────┘ └───────────────────┘ │
│ │
│ ... page navigates away, user authenticates ... │
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ Page loads with │───▶│ db.cloud. │──▶ Detects dxc-auth param │
│ │ ?dxc-auth=... │ │ configure() │ Cleans URL immediately │
│ └─────────────────┘ └───────────────────┘ Stores pending code │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ db.on('ready') │───▶│ POST /token │ │
│ │ │ │ grant_type: │ │
│ │ │◀───│ authorization_code │ │
│ └─────────────────┘ └─────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ User logged in! │ │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ DEXIE CLOUD SERVER │
│ │
│ /oauth/login/:provider │
│ ├── Generate state, PKCE │
│ ├── Store in challenges table │
│ └── Redirect to OAuth provider │
│ │
│ /oauth/callback/:provider ◀── OAuth provider redirects here │
│ ├── Verify state │
│ ├── Exchange code for provider tokens (server-side!) │
│ ├── Fetch user info, verify email │
│ ├── Generate Dexie auth code (single-use, 5 min TTL) │
│ └── HTTP 302 redirect with ?dxc-auth= │
│ │
│ POST /token (grant_type: authorization_code) │
│ ├── Validate Dexie auth code │
│ ├── Extract stored user claims │
│ └── Return Dexie Cloud access + refresh tokens │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## The `dxc-auth` Query Parameter
The OAuth callback uses a single `dxc-auth` query parameter containing base64url-encoded JSON to avoid collisions with app query parameters:
**Success:**
```json
{ "code": "DEXIE_AUTH_CODE", "provider": "google", "state": "..." }
```
**Error:**
```json
{ "error": "Error message", "provider": "google", "state": "..." }
```
Example URL:
```
https://myapp.com/?dxc-auth=eyJjb2RlIjoiLi4uIiwicHJvdmlkZXIiOiJnb29nbGUiLCJzdGF0ZSI6Ii4uLiJ9
```
---
## Security Properties
- 🛡 **No provider tokens reach client** - All provider exchange happens server-side
- 🛡 **Single-use Dexie auth codes** - 5 minute TTL, deleted after use
- 🛡 **PKCE protection** - Prevents code interception (where supported)
- 🛡 **State parameter** - CSRF protection, stored server-side
- 🛡 **Origin validation** - redirect_uri validated and whitelisted
- 🛡 **Email verification enforced** - Server rejects unverified emails
- 🛡 **No tokens in URL fragments** - Auth code in query param, not fragment
---
## Key Files
**dexie-cloud-common:**
- `src/OAuthProviderInfo.ts`
- `src/AuthProvidersResponse.ts`
- `src/AuthorizationCodeTokenRequest.ts`
**dexie-cloud-addon:**
- `src/authentication/oauthLogin.ts` - `startOAuthRedirect()`, `mapOAuthError()`
- `src/authentication/handleOAuthCallback.ts` - `parseOAuthCallback()`, `cleanupOAuthUrl()`
- `src/authentication/exchangeOAuthCode.ts` - Token exchange
- `src/authentication/fetchAuthProviders.ts` - Fetch available providers
- `src/errors/OAuthError.ts` - OAuth-specific errors
- `src/default-ui/ProviderSelectionDialog.tsx` - Provider selection UI
- `src/default-ui/AuthProviderButton.tsx` - Provider button component
- `src/dexie-cloud-client.ts` - OAuth detection in `configure()`, processing in `onDbReady`
- `src/DexieCloudOptions.ts` - `socialAuth`, `oauthRedirectUri` options
================================================
FILE: addons/dexie-cloud/dexie-cloud-import.json
================================================
{
"demoUsers": {
"foo@demo.local": {},
"bar@demo.local": {},
"issue2228@demo.local": {}
}
}
================================================
FILE: addons/dexie-cloud/oauth_flow.md
================================================
# OAuth Authorization Code Flow for Dexie Cloud SPA Integration
## Actors
- **SPA** – Customer's frontend application
- **Dexie Cloud** – Auth broker + database access control
- **OAuth Provider** – Google, GitHub, Apple, Microsoft, etc.
## Preconditions
The SPA:
- Generates a persistent public/private keypair
- Private key stored securely in IndexedDB
- Public key sent later during token exchange
- Needs two JWTs after login:
- Access Token (short-lived)
- Refresh Token (long-lived)
Dexie Cloud acts as OAuth broker and manages tenant + identity linkage.
---
## Flow Overview
### 1. User Initiates Login
User clicks "Login", SPA displays list of providers:
```
Google | GitHub | Apple | Microsoft
```
No nonce or PKCE is created yet.
---
### 2. User Selects Provider
Example: User selects **Google**
The client initiates the OAuth flow. There are two ways to do this:
#### 2a. Full Page Redirect (Recommended for Web SPAs)
```js
window.location.href = `https://.dexie.cloud/oauth/login/google?redirect_uri=${encodeURIComponent(location.href)}`;
```
The `redirect_uri` parameter specifies where Dexie Cloud should redirect after authentication.
This can be any page in your app - no dedicated callback route is needed.
#### 2b. Custom URL Scheme (Capacitor / Native Apps)
```js
// Open in system browser or in-app browser
Browser.open({
url: `https://.dexie.cloud/oauth/login/google?redirect_uri=${encodeURIComponent('myapp://')}`
});
```
The custom scheme `myapp://` tells Dexie Cloud to redirect back via deep link.
---
### 3. Dexie Cloud Prepares OAuth
Dexie Cloud receives `/oauth/login/google` and generates:
- `state` (anti-CSRF)
- `code_verifier` (PKCE)
- `code_challenge` (PKCE)
Stores these in the challenges table, then redirects the browser to provider:
```
https://accounts.google.com/o/oauth2/v2/auth?
client_id=...
redirect_uri=https://.dexie.cloud/oauth/callback/google
state=STATE
code_challenge=CHALLENGE
code_challenge_method=S256
response_type=code
scope=openid email profile
```
Note: `redirect_uri` points to the **Dexie Cloud server** callback endpoint.
---
### 4. Provider Authenticates User
Provider authenticates the user and requests consent if needed.
---
### 5. Provider Callback to Dexie Cloud
Provider redirects back to Dexie Cloud:
```
https://.dexie.cloud/oauth/callback/google?code=CODE&state=STATE
```
Dexie Cloud:
1. Verifies `state`
2. Performs token exchange with provider using PKCE
3. Extracts identity claims (email/id/name/…)
4. Verifies email is verified
5. Links identity to tenant/database
6. Generates a **single-use Dexie Cloud authorization code**
7. Deletes the OAuth state (one-time use)
---
### 6. Dexie Cloud Delivers Auth Code to Client
Dexie Cloud issues an HTTP 302 redirect back to the client with the authorization code.
The auth data is encapsulated in a single `dxc-auth` query parameter containing base64url-encoded JSON.
This avoids collisions with the app's own query parameters.
#### 6a. Full Page Redirect (Web SPAs)
If the client passed an http/https `redirect_uri`, Dexie Cloud redirects:
```
HTTP/1.1 302 Found
Location: https://myapp.com/?dxc-auth=eyJjb2RlIjoiLi4uIiwicHJvdmlkZXIiOiJnb29nbGUiLCJzdGF0ZSI6Ii4uLiJ9
```
The `dxc-auth` parameter contains base64url-encoded JSON:
```json
{ "code": "DEXIE_AUTH_CODE", "provider": "google", "state": "STATE" }
```
Or in case of error:
```json
{ "error": "Error message", "provider": "google", "state": "STATE" }
```
The app doesn't need a dedicated OAuth callback route - the dexie-cloud client library
detects and processes the `dxc-auth` parameter on any page load.
#### 6b. Custom URL Scheme (Capacitor / Native Apps)
If the client passed a `redirect_uri` with a custom scheme (e.g., `myapp://`),
Dexie Cloud redirects to that URL with the same `dxc-auth` parameter:
```
HTTP/1.1 302 Found
Location: myapp://?dxc-auth=eyJjb2RlIjoiLi4uIiwicHJvdmlkZXIiOiJnb29nbGUiLCJzdGF0ZSI6Ii4uLiJ9
```
The native app intercepts this deep link and decodes the parameter.
#### 6c. Error Case
If no valid `redirect_uri` was provided, an error page is displayed
explaining that the auth flow cannot complete.
---
### 7. Client Receives Authorization Code
**For Full Page Redirect (6a):**
The `dexie-cloud-addon` library handles OAuth callback detection automatically:
1. When `db.cloud.configure()` is called, the addon checks for the `dxc-auth` query parameter
2. This check only runs in DOM environments (not in Web Workers)
3. If the parameter is present:
- The URL is immediately cleaned up using `history.replaceState()` to remove `dxc-auth`
- A `setTimeout(cb, 0)` is scheduled to initiate the token exchange
- The token exchange fetches from the configured `databaseUrl`
- The response is processed in the existing `db.on('ready')` callback when Dexie is ready
```js
// Pseudocode for dexie-cloud-addon implementation
function configure(options) {
// Only check in DOM environment, not workers
if (typeof window !== 'undefined' && window.location) {
const encoded = new URLSearchParams(location.search).get('dxc-auth');
if (encoded) {
// Decode base64url (unpadded) to JSON
const padded = encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
const { code, provider, state, error } = payload;
// Clean up URL immediately (remove dxc-auth param)
const url = new URL(location.href);
url.searchParams.delete('dxc-auth');
history.replaceState(null, '', url.toString());
if (!error) {
// Schedule token exchange (processed in db.on('ready'))
setTimeout(() => {
// Perform token exchange with options.databaseUrl
}, 0);
}
}
}
}
```
**For Capacitor/Native Apps (6b):**
App registers a deep link handler and decodes the same parameter:
```js
// Capacitor example
App.addListener('appUrlOpen', ({ url }) => {
const parsedUrl = new URL(url);
const encoded = parsedUrl.searchParams.get('dxc-auth');
if (encoded) {
const padded = encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
const { code, provider, state, error } = payload;
// Proceed to token exchange
}
});
```
Upon success, client proceeds to token exchange.
---
### 8. Client Performs Token Exchange
Client sends:
```http
POST /token
Content-Type: application/json
```
Payload:
```json
{
"grant_type": "authorization_code",
"code": "",
"public_key": "",
"scopes": ["ACCESS_DB"]
}
```
Dexie Cloud validates:
- Dexie authorization code integrity
- TTL (5 minutes)
- Single-use constraint
- Database context
- User identity and claims from stored data
- Subscription/license status
---
### 9. Dexie Cloud Issues Tokens
Dexie Cloud responds with:
```json
{
"access_token": "...",
"refresh_token": "...",
"expires_in": 3600,
"token_type": "Bearer"
}
```
This completes authentication.
---
## Security Properties Achieved
- 🛑 No JWTs exposed via URL fragments
- 🛑 Provider tokens never reach SPA (only Dexie tokens)
- 🛡 Single-use Dexie authorization code (5 min TTL)
- 🛡 PKCE prevents provider code interception
- 🛡 State stored server-side with TTL (30 min)
- 🛡 CSRF protection via `state` parameter
- 🛡 OAuth state deleted after use
- 🛡 Dexie auth code deleted after use
- 🛡 Email verification enforced by server
- 🛡 All provider exchanges happen server-side
- 🛡 CORS + origin protections during `/token` exchange
- 🛡 Future PoP (Proof-of-Possession) enabled via SPA public key
- 🛡 Works with Apple, Google, Microsoft, GitHub
---
## Resulting Benefits
- Works for SPA / PWA / Capacitor / WebViews
- Supports multi-tenant architectures
- Supports native account linking
- Enables refresh token rotation
- Supports offline-first/local-first model
This aligns with modern OIDC/OAuth best practices (2023+) and matches architectures used by:
Auth0, Firebase, Supabase, Okta, MSAL, Google Identity Services, Clerk, etc.
================================================
FILE: addons/dexie-cloud/package.json
================================================
{
"name": "dexie-cloud-addon",
"version": "4.4.1",
"description": "Dexie addon that syncs with to Dexie Cloud",
"type": "module",
"module": "dist/modern/dexie-cloud-addon.js",
"homepage": "https://dexie.org/cloud/docs/dexie-cloud-addon",
"exports": {
".": {
"development": {
"import": "./dist/modern/dexie-cloud-addon.js",
"require": null,
"types": "./dist/modern/dexie-cloud-addon.d.ts"
},
"production": {
"import": "./dist/modern/dexie-cloud-addon.min.js",
"require": null,
"types": "./dist/modern/dexie-cloud-addon.d.ts"
},
"default": {
"import": "./dist/modern/dexie-cloud-addon.min.js",
"require": null,
"types": "./dist/modern/dexie-cloud-addon.d.ts"
}
},
"./service-worker": {
"development": {
"import": "./dist/modern/service-worker.js",
"require": "./dist/umd/service-worker.js",
"default": "./dist/umd/service-worker.js",
"types": "./dist/modern/service-worker.d.ts"
},
"production": {
"import": "./dist/modern/service-worker.min.js",
"require": "./dist/umd/service-worker.min.js",
"default": "./dist/umd/service-worker.min.js",
"types": "./dist/modern/service-worker.d.ts"
},
"default": {
"import": "./dist/modern/service-worker.min.js",
"require": "./dist/umd/service-worker.min.js",
"default": "./dist/umd/service-worker.min.js",
"types": "./dist/modern/service-worker.d.ts"
}
}
},
"types": "dist/modern/dexie-cloud-addon.d.ts",
"engines": {
"node": ">=14"
},
"repository": {
"type": "git",
"url": "https://github.com/dexie/Dexie.js.git"
},
"scripts": {
"test": "just-build test && pnpm run test-unit",
"test-unit": "karma start test/unit/karma.conf.cjs --single-run",
"build": "rollup -c tools/build-configs/rollup.config.mjs",
"watch": "rollup -c tools/build-configs/rollup.config.mjs --watch",
"clean": "rm -rf tools/tmp dist test/unit/bundle.*",
"prepack": "pnpm run build"
},
"just-build": {
"default": [
"rollup -c tools/build-configs/rollup.config.mjs"
],
"test": [
"just-build test-unit"
],
"test-unit": [
"tsc -p test [--watch 'Watching for file changes.']",
"rollup -c tools/build-configs/rollup.test.unit.config.js"
]
},
"author": "david.fahlander@gmail.com",
"license": "Apache-2.0",
"devDependencies": {
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^5.0.4",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.3.0",
"@types/node": "^18.11.18",
"just-build": "*",
"karma": "*",
"karma-chrome-launcher": "*",
"karma-firefox-launcher": "*",
"karma-qunit": "*",
"karma-webdriver-launcher": "*",
"qunit": "2.10.0",
"qunitjs": "1.23.1",
"lib0": "^0.2.97",
"preact": "*",
"rollup": "^4.53.3",
"terser": "^5.20.0",
"tslib": "*",
"typescript": "^5.6.3",
"y-dexie": "workspace:>=4.2.0-alpha.1 <5.0.0"
},
"dependencies": {
"dexie-cloud-common": "workspace:^",
"y-dexie": "workspace:^",
"rxjs": "^7.x",
"yjs": "^13.6.27",
"y-protocols": "^1.0.6"
},
"peerDependencies": {
"dexie": "workspace:^"
}
}
================================================
FILE: addons/dexie-cloud/src/DISABLE_SERVICEWORKER_STRATEGY.ts
================================================
import { isFirefox } from './isFirefox';
import { isSafari, safariVersion } from './isSafari';
// What we know: Safari 14.1 (version 605) crashes when using dexie-cloud's service worker.
// We don't know what exact call is causing this. Have tried safari-14-idb-fix with no luck.
// Something we do in the service worker is triggering the crash.
// When next Safari version (606) is out we will start enabling SW again, hoping that the bug is solved.
// If not, we might increment 605 to 606.
export const DISABLE_SERVICEWORKER_STRATEGY =
(isSafari && safariVersion <= 605) || // Disable for Safari for now.
isFirefox; // Disable for Firefox for now. Seems to have a bug in reading CryptoKeys from IDB from service workers
================================================
FILE: addons/dexie-cloud/src/DXCWebSocketStatus.ts
================================================
export type DXCWebSocketStatus = "not-started" | "connecting" | "connected" | "disconnected" | "error";
================================================
FILE: addons/dexie-cloud/src/DexieCloudAPI.ts
================================================
import { DexieCloudOptions } from './DexieCloudOptions';
import { DBRealmRole, DexieCloudSchema, AuthProvidersResponse } from 'dexie-cloud-common';
import { UserLogin } from './db/entities/UserLogin';
import { PersistedSyncState } from './db/entities/PersistedSyncState';
import { SyncState } from './types/SyncState';
import { DXCUserInteraction } from './types/DXCUserInteraction';
import { DXCWebSocketStatus } from './DXCWebSocketStatus';
import { PermissionChecker } from './PermissionChecker';
import { DexieCloudSyncOptions } from "./DexieCloudSyncOptions";
import { Invite } from './Invite';
import { BehaviorSubject, Observable } from 'rxjs';
/** Progress state for blob downloads */
export interface BlobProgress {
/** Whether blob downloads are currently in progress */
isDownloading: boolean;
/** Number of blobs remaining to download */
blobsRemaining: number;
/** Total bytes remaining to download (estimated from BlobRef.$size) */
bytesRemaining: number;
}
/** The API of db.cloud, where `db` is an instance of Dexie with dexie-cloud-addon active.
*/
export interface LoginHints {
email?: string;
userId?: string;
grant_type?: 'demo' | 'otp';
otpId?: string;
otp?: string;
/** OAuth provider name to initiate OAuth flow (e.g., 'google', 'github') */
provider?: string;
/** Dexie Cloud authorization code received from OAuth callback */
oauthCode?: string;
/** Optional redirect path (relative or absolute) to use for OAuth redirect URI. */
redirectPath?: string;
}
export interface DexieCloudAPI {
// Version of dexie-cloud-addon
version: string;
// Options configured using db.cloud.configure()
options: DexieCloudOptions | null;
// Dexie-Cloud specific schema (complementary to dexie schema)
schema: DexieCloudSchema | null;
// UserID of the currently logged in user. If not logged in,
// this string will be "unauthorized"
currentUserId: string;
// Observable of currently logged in user
currentUser: BehaviorSubject;
// Observable of current WebSocket status
webSocketStatus: BehaviorSubject;
// Observable of current Sync State
syncState: BehaviorSubject;
// Observable of persisted sync state
persistedSyncState: BehaviorSubject;
/** Observable of blob download progress.
*
* Shows the current state of background blob downloads (when blobMode='eager')
* or provides insight into unresolved blobs (when blobMode='lazy').
*
* Use this to show progress indicators or "downloading for offline" status.
*/
blobProgress: Observable;
events: {
syncComplete: Observable;
}
// Observable reflecting the GUI data that Dexie Cloud wants you to render if using
// db.cloud.configure({customLoginGui: true}).
// The information it wants you to render is login dialogs and error alerts.
// The information also contains action callbacks to call from Submit / Cancel buttons.
userInteraction: BehaviorSubject;
// Observable of invites for the user to accept or reject
invites: Observable;
// Observable of global application roles - a liveQuery() of the 'roles' table
roles: Observable<{[roleName: string]: DBRealmRole}>;
// Boolean whether service worker is used or not
usingServiceWorker?: boolean;
// Boolean whether this Dexie instance is a private Dexie instance owned by
// the built-in Dexie Cloud service worker.
isServiceWorkerDB?: boolean;
/** Login using Dexie Cloud OTP or Demo user.
*
* @param email Email to authenticate
* @param userId Optional userId to authenticate
* @param grant_type requested grant type
*/
login(hint?: LoginHints): Promise;
logout(options?: {force?: boolean}): Promise;
/**
* Connect to given URL
*/
configure(options: DexieCloudOptions): void;
/** Trigger a sync
*
*/
sync(options?: DexieCloudSyncOptions): Promise;
/** Method that returns an observable of the available permissions of given
* entity.
*
* @param entity Entity to check permission for
*/
permissions string; }>(entity: T): Observable>;
/** Method that returns an observable of the available permissions of given
* object and table name.
*
* @param obj Object retrieved from a dexie query
* @param table Table name that the object was retrieved from
*/
permissions(obj: T, table: string): Observable>;
/** Query available authentication providers from the server.
*
* Returns information about which OAuth providers are configured
* and whether OTP (email) authentication is enabled.
*
* Useful for apps that want to build their own login UI and show
* provider-specific buttons.
*
* @returns Promise resolving to available auth providers
*/
getAuthProviders(): Promise;
}
================================================
FILE: addons/dexie-cloud/src/DexieCloudOptions.ts
================================================
import type { TokenFinalResponse } from 'dexie-cloud-common';
import type { LoginHints } from './DexieCloudAPI';
export interface PeriodicSyncOptions {
// The minimum interval time, in milliseconds, at which the service-worker's
// periodic sync should occur.
minInterval?: number;
}
export interface DexieCloudOptions {
// URL to a database created with `npx dexie-cloud create`
databaseUrl: string;
// Whether to require authentication or opt-in to it using db.cloud.login()
requireAuth?: boolean | LoginHints
// Whether to use service worker. Combine with registering your own service
// worker and import "dexie-cloud-addon/dist/modern/service-worker.min.js" from it.
tryUseServiceWorker?: boolean;
// Optional customization of periodic sync.
// See https://developer.mozilla.org/en-US/docs/Web/API/PeriodicSyncManager/register
periodicSync?: PeriodicSyncOptions;
// Disable default login GUI and replace it with your own by
// subscribing to the `db.cloud.userInteraction` observable and render its emitted data.
customLoginGui?: boolean;
// Array of table names that should be considered local-only and
// not be synced with Dexie Cloud
unsyncedTables?: string[];
unsyncedProperties?: {
[tableName: string]: string[];
}
// By default Dexie Cloud will suffix the cloud DB ID to your IndexedDB database name
// in order to ensure that the local database is uniquely tied to the remote one and
// will use another local database if databaseURL is changed or if dexieCloud addon
// is not being used anymore.
//
// By setting this value to `false`, no suffix will be added to the database name and
// instead, it will use the exact name that is specified in the Dexie constructor,
// without a suffix.
nameSuffix?: boolean;
// Disable websocket connection
disableWebSocket?: boolean;
// Disable automatic sync on changes
disableEagerSync?: boolean;
// Provides a custom way of fetching the JWT tokens. This option
// can be used when integrating with custom authentication.
// See https://dexie.org/cloud/docs/db.cloud.configure()#fetchtoken
fetchTokens?: (tokenParams: {
public_key: string;
hints?: { userId?: string; email?: string };
}) => Promise;
awarenessProtocol?: typeof import('y-protocols/awareness');
/** Enable social/OAuth authentication.
* - true (default): Fetch providers from server, show if available
* - false: Disable OAuth, always use OTP flow
*
* Use `false` for backward compatibility if your custom login UI
* doesn't handle the `DXCSelect` interaction type yet.
*/
socialAuth?: boolean;
/** Redirect URI for OAuth callback.
* Defaults to window.location.href for web SPAs.
*
* For Capacitor/native apps, set this to a custom URL scheme:
* ```
* oauthRedirectUri: 'myapp://'
* ```
*/
oauthRedirectUri?: string;
/** How to handle blob downloads from cloud storage.
*
* - 'eager' (default): Download blobs in background immediately after sync.
* Best for offline-first apps that need all data available offline ASAP.
*
* - 'lazy': Download blobs on-demand when accessed.
* Best for apps with large media that may not all be needed offline.
*/
blobMode?: 'eager' | 'lazy';
/** Maximum string length (in characters) before offloading to blob storage during sync.
*
* Strings longer than this threshold are uploaded as blobs during sync,
* reducing sync payload size. The original string is kept intact in IndexedDB.
*
* Set to `Infinity` to disable string offloading.
* Minimum value is 100 to prevent accidental offloading of primary keys.
* Maximum value is 32768 (server limit).
*
* @default 32768
*/
maxStringLength?: number;
}
================================================
FILE: addons/dexie-cloud/src/DexieCloudSyncOptions.ts
================================================
export interface DexieCloudSyncOptions {
wait: boolean;
purpose: 'push' | 'pull';
}
================================================
FILE: addons/dexie-cloud/src/DexieCloudTable.ts
================================================
import { EntityTable, InsertType } from 'dexie';
export interface DexieCloudEntity {
owner: string;
realmId: string;
}
/** Don't force the declaration of owner and realmId on every entity (some
* types may not be interested of these props if they are never going to be shared)
* Let the type system behave the same as the runtime and merge these props in automatically
* when declaring the table where the props aren't explicitely declared.
* User may also explicitely declare these props in order to manually set them when
* they are interested of taking control over access.
*/
type WithDexieCloudProps = T extends DexieCloudEntity ? T : T & DexieCloudEntity;
/** Syntactic sugar for declaring a synced table of arbritary entity.
*
*/
export type DexieCloudTable =
EntityTable<
WithDexieCloudProps,
TKeyPropName,
InsertType, TKeyPropName | 'owner' | 'realmId'>
>;
================================================
FILE: addons/dexie-cloud/src/InvalidLicenseError.ts
================================================
export class InvalidLicenseError extends Error {
name = 'InvalidLicenseError';
license?: 'expired' | 'deactivated';
constructor(license?: 'expired' | 'deactivated') {
super(
license === 'expired'
? `License expired`
: license === 'deactivated'
? `User deactivated`
: 'Invalid license'
);
if (license) {
this.license = license;
}
}
}
================================================
FILE: addons/dexie-cloud/src/Invite.ts
================================================
import { DBPermissionSet, DBRealm, DBRealmMember } from 'dexie-cloud-common';
export interface Invite extends DBRealmMember {
realm?: DBRealm & { permissions: DBPermissionSet };
accept(): Promise;
reject(): Promise;
}
================================================
FILE: addons/dexie-cloud/src/PermissionChecker.ts
================================================
import { KeyPaths } from 'dexie';
import { DBPermissionSet } from 'dexie-cloud-common';
type TableName = T extends {table: ()=>infer TABLE} ? TABLE extends string ? TABLE : string : string;
export class PermissionChecker> {
private permissions: DBPermissionSet;
private tableName: TableNames;
private isOwner: boolean;
constructor(
permissions: DBPermissionSet,
tableName: TableNames,
isOwner: boolean
) {
this.permissions = permissions || {};
this.tableName = tableName;
this.isOwner = isOwner;
}
add(...tableNames: TableNames[]): boolean {
// If user can manage the whole realm, return true.
if (this.permissions.manage === '*') return true;
// If user can manage given table in realm, return true
if (this.permissions.manage?.includes(this.tableName)) return true;
// If user can add any type, return true
if (this.permissions.add === '*') return true;
// If user can add objects into given table names in the realm, return true
if (
tableNames.every((tableName) => this.permissions.add?.includes(tableName))
) {
return true;
}
return false;
}
update(...props: KeyPaths[]): boolean {
// If user is owner of this object, or if user can manage the whole realm, return true.
if (this.isOwner || this.permissions.manage === '*') return true;
// If user can manage given table in realm, return true
if (this.permissions.manage?.includes(this.tableName)) return true;
// If user can update any prop in any table in this realm, return true unless
// it regards to ownership change:
if (this.permissions.update === '*') {
// @ts-ignore
return props.every((prop) => prop !== 'owner');
}
const tablePermissions = this.permissions.update?.[this.tableName];
// If user can update any prop in table and realm, return true unless
// accessing special props owner or realmId
if (tablePermissions === '*')
return props.every((prop) => prop !== 'owner');
// Explicitely listed properties to allow updates on:
return props.every((prop) =>
tablePermissions?.some(
(permittedProp) =>
permittedProp === prop || (permittedProp === '*' && prop !== 'owner')
)
);
}
delete(): boolean {
// If user is owner of this object, or if user can manage the whole realm, return true.
if (this.isOwner || this.permissions.manage === '*') return true;
// If user can manage given table in realm, return true
if (this.permissions.manage?.includes(this.tableName)) return true;
return false;
}
}
================================================
FILE: addons/dexie-cloud/src/TSON.ts
================================================
import {
TypesonSimplified,
undefinedTypeDef,
blobTypeDef,
typedArrayTypeDefs,
arrayBufferTypeDef,
fileTypeDef,
dateTypeDef,
setTypeDef,
mapTypeDef,
numberTypeDef,
} from 'dexie-cloud-common';
import { TypeDefSet } from 'dexie-cloud-common';
import { PropModSpec, PropModification } from 'dexie';
// Since server revisions are stored in bigints, we need to handle clients without
// bigint support to not fail when serverRevision is passed over to client.
// We need to not fail when reviving it and we need to somehow store the information.
// Since the revived version will later on be put into indexedDB we have another
// issue: When reading it back from indexedDB we will get a poco object that we
// cannot replace correctly when sending it to server. So we will also need
// to do an explicit workaround in the protocol where a bigint is supported.
// The workaround should be there regardless if browser supports BigInt or not, because
// the serverRev might have been stored in IDB before the browser was upgraded to support bigint.
//
// if (typeof serverRev.rev !== "bigint")
// if (hasBigIntSupport)
// serverRev.rev = bigIntDef.bigint.revive(server.rev)
// else
// serverRev.rev = new FakeBigInt(server.rev)
export const hasBigIntSupport =
typeof BigInt === 'function' && typeof BigInt(0) === 'bigint';
function getValueOfBigInt(x: bigint | FakeBigInt | string) {
if (typeof x === 'bigint') {
return x;
}
if (hasBigIntSupport) {
return typeof x === 'string' ? BigInt(x) : BigInt(x.v);
} else {
return typeof x === 'string' ? Number(x) : Number(x.v);
}
}
export function compareBigInts(
a: bigint | FakeBigInt | string,
b: bigint | FakeBigInt | string
) {
const valA = getValueOfBigInt(a);
const valB = getValueOfBigInt(b);
return valA < valB ? -1 : valA > valB ? 1 : 0;
}
export class FakeBigInt {
v: string;
toString() {
return this.v;
}
constructor(value: string) {
this.v = value;
}
}
const bigIntDef = hasBigIntSupport
? {}
: {
bigint: {
test: (val: any) => val instanceof FakeBigInt,
replace: (fakeBigInt: any) => {
return {
$t: 'bigint',
...fakeBigInt,
};
},
revive: ({ v }: { $t: 'bigint'; v: string }) =>
new FakeBigInt(v) as any as bigint,
},
};
const defs: TypeDefSet = {
...undefinedTypeDef,
...bigIntDef,
...fileTypeDef,
PropModification: {
test: (val: any) => val instanceof PropModification,
replace: (propModification: any) => {
return {
$t: 'PropModification',
...propModification['@@propmod'],
};
},
revive: ({
$t, // strip '$t'
...propModSpec // keep the rest
}: {
$t: 'PropModification';
} & PropModSpec) => new PropModification(propModSpec),
},
};
export const TSON = TypesonSimplified(
// Standard type definitions - TSON is transparent to BlobRefs
// BlobRefs use _bt convention and are handled by blobResolveMiddleware, not TSON
typedArrayTypeDefs,
arrayBufferTypeDef,
blobTypeDef,
// Non-binary built-in types
numberTypeDef,
dateTypeDef,
setTypeDef,
mapTypeDef,
// Custom type definitions
defs
);
================================================
FILE: addons/dexie-cloud/src/WSObservable.ts
================================================
import { DBOperationsSet } from 'dexie-cloud-common';
import { BehaviorSubject, Observable, Subscriber, Subscription, tap } from 'rxjs';
import { TokenExpiredError } from './authentication/TokenExpiredError';
import { DXCWebSocketStatus } from './DXCWebSocketStatus';
import { TSON } from './TSON';
import type { YClientMessage, YServerMessage } from 'dexie-cloud-common';
import { DexieCloudDB } from './db/DexieCloudDB';
import { createYClientUpdateObservable } from './yjs/createYClientUpdateObservable';
import { applyYServerMessages } from './yjs/applyYMessages';
import { Table } from 'dexie';
import { getDocAwareness } from './yjs/awareness';
import * as awap from 'y-protocols/awareness';
import { encodeYMessage, decodeYMessage } from 'dexie-cloud-common';
import { UserLogin } from './dexie-cloud-client';
import { isEagerSyncDisabled } from './isEagerSyncDisabled';
import { getOpenDocSignal } from './yjs/reopenDocSignal';
import { getUpdatesTable } from './yjs/getUpdatesTable';
import { DEXIE_CLOUD_SYNCER_ID } from './sync/DEXIE_CLOUD_SYNCER_ID';
import { DexieYProvider, YSyncState } from 'y-dexie';
const SERVER_PING_TIMEOUT = 20000;
const CLIENT_PING_INTERVAL = 30000;
const FAIL_RETRY_WAIT_TIME = 60000;
export type WSClientToServerMsg = ReadyForChangesMessage | YClientMessage;
export interface ReadyForChangesMessage {
type: 'ready';
realmSetHash: string;
rev: string;
}
export type WSConnectionMsg =
| RevisionChangedMessage
| RealmAddedMessage
| RealmAcceptedMessage
| RealmRemovedMessage
| RealmsChangedMessage
| ChangesFromServerMessage
| TokenExpiredMessage;
interface PingMessage {
type: 'ping';
}
interface PongMessage {
type: 'pong';
}
interface ErrorMessage {
type: 'error';
error: string;
}
export interface ChangesFromServerMessage {
type: 'changes';
baseRev: string;
realmSetHash: string;
newRev: string;
changes: DBOperationsSet;
}
export interface RevisionChangedMessage {
type: 'rev';
rev: string;
}
export interface RealmAddedMessage {
type: 'realm-added';
realm: string;
}
export interface RealmAcceptedMessage {
type: 'realm-accepted';
realm: string;
}
export interface RealmRemovedMessage {
type: 'realm-removed';
realm: string;
}
export interface RealmsChangedMessage {
type: 'realms-changed';
realmsHash: string;
}
export interface TokenExpiredMessage {
type: 'token-expired';
}
export class WSObservable extends Observable {
constructor(
db: DexieCloudDB,
rev: string | undefined,
yrev: string | undefined,
realmSetHash: string,
clientIdentity: string,
messageProducer: Observable,
webSocketStatus: BehaviorSubject,
user: UserLogin
) {
super(
(subscriber) =>
new WSConnection(
db,
rev,
yrev,
realmSetHash,
clientIdentity,
user,
subscriber,
messageProducer,
webSocketStatus
)
);
}
}
let counter = 0;
export class WSConnection extends Subscription {
db: DexieCloudDB;
ws: WebSocket | null;
lastServerActivity: Date;
lastUserActivity: Date;
lastPing: Date;
databaseUrl: string;
rev: string | undefined;
yrev: string | undefined;
realmSetHash: string;
clientIdentity: string;
user: UserLogin;
subscriber: Subscriber;
pauseUntil?: Date;
messageProducer: Observable;
webSocketStatus: BehaviorSubject;
id = ++counter;
private pinger: any;
private subscriptions: Set = new Set();
constructor(
db: DexieCloudDB,
rev: string | undefined,
yrev: string | undefined,
realmSetHash: string,
clientIdentity: string,
user: UserLogin,
subscriber: Subscriber,
messageProducer: Observable,
webSocketStatus: BehaviorSubject
) {
super(() => this.teardown());
console.debug(
'New WebSocket Connection',
this.id,
user.accessToken ? 'authorized' : 'unauthorized'
);
this.db = db;
this.databaseUrl = db.cloud.options!.databaseUrl;
this.rev = rev;
this.yrev = yrev;
this.realmSetHash = realmSetHash;
this.clientIdentity = clientIdentity;
this.user = user;
this.subscriber = subscriber;
this.lastUserActivity = new Date();
this.messageProducer = messageProducer;
this.webSocketStatus = webSocketStatus;
this.connect();
}
private teardown() {
console.debug('Teardown WebSocket Connection', this.id);
this.disconnect();
}
private disconnect() {
this.webSocketStatus.next('disconnected');
if (this.pinger) {
clearInterval(this.pinger);
this.pinger = null;
}
if (this.ws) {
try {
this.ws.close();
} catch {}
}
this.ws = null;
for (const sub of this.subscriptions) {
sub.unsubscribe();
}
this.subscriptions.clear();
}
reconnecting = false;
reconnect() {
if (this.reconnecting) return;
this.reconnecting = true;
try {
this.disconnect();
} catch {}
this.connect()
.catch(() => {})
.then(() => (this.reconnecting = false)); // finally()
}
async connect() {
this.lastServerActivity = new Date();
if (this.pauseUntil && this.pauseUntil > new Date()) {
console.debug('WS not reconnecting just yet', {
id: this.id,
pauseUntil: this.pauseUntil,
});
return;
}
if (this.ws) {
throw new Error(`Called connect() when a connection is already open`);
}
if (!this.databaseUrl)
throw new Error(`Cannot connect without a database URL`);
if (this.closed) {
//console.debug('SyncStatus: DUBB: Ooops it was closed!');
return;
}
const tokenExpiration = this.user.accessTokenExpiration;
if (tokenExpiration && tokenExpiration < new Date()) {
this.subscriber.error(new TokenExpiredError()); // Will be handled in connectWebSocket.ts.
return;
}
this.webSocketStatus.next('connecting');
this.pinger = setInterval(async () => {
// setInterval here causes unnecessary pings when server is proved active anyway.
// TODO: Use setTimout() here instead. When triggered, check if we really need to ping.
// In case we've had server activity, we don't need to ping. Then schedule then next ping
// to the time when we should ping next time (based on lastServerActivity + CLIENT_PING_INTERVAL).
// Else, ping now and schedule next ping to CLIENT_PING_INTERVAL from now.
if (this.closed) {
console.debug('pinger check', this.id, 'CLOSED.');
this.teardown();
return;
}
if (this.ws) {
try {
this.ws.send(JSON.stringify({ type: 'ping' } as PingMessage));
setTimeout(() => {
console.debug(
'pinger setTimeout',
this.id,
this.pinger ? `alive` : 'dead'
);
if (!this.pinger) return;
if (this.closed) {
console.debug(
'pinger setTimeout',
this.id,
'subscription is closed'
);
this.teardown();
return;
}
if (
this.lastServerActivity <
new Date(Date.now() - SERVER_PING_TIMEOUT)
) {
// Server inactive. Reconnect if user is active.
console.debug('pinger: server is inactive');
console.debug('pinger reconnecting');
this.reconnect();
} else {
console.debug('pinger: server still active');
}
}, SERVER_PING_TIMEOUT);
} catch {
console.debug('pinger catch error', this.id, 'reconnecting');
this.reconnect();
}
} else {
console.debug('pinger', this.id, 'reconnecting');
this.reconnect();
}
}, CLIENT_PING_INTERVAL);
// The following vars are needed because we must know which callback to ack when server sends it's ack to us.
const wsUrl = new URL(this.databaseUrl);
wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws' : 'wss';
const searchParams = new URLSearchParams();
if (this.subscriber.closed) return;
searchParams.set('v', '2');
if (this.rev) searchParams.set('rev', this.rev);
if (this.yrev) searchParams.set('yrev', this.yrev);
searchParams.set('realmsHash', this.realmSetHash);
searchParams.set('clientId', this.clientIdentity);
searchParams.set('dxcv', this.db.cloud.version);
if (this.user.accessToken) {
searchParams.set('token', this.user.accessToken);
}
// Connect the WebSocket to given url:
console.debug('dexie-cloud WebSocket create');
const ws = (this.ws = new WebSocket(`${wsUrl}/changes?${searchParams}`));
ws.binaryType = "arraybuffer";
ws.onclose = (event: Event) => {
if (!this.pinger) return;
console.debug('dexie-cloud WebSocket onclosed', this.id);
this.reconnect();
};
ws.onmessage = (event: MessageEvent) => {
if (!this.pinger) return;
this.lastServerActivity = new Date();
try {
const msg = typeof event.data === 'string'
? TSON.parse(event.data) as
| WSConnectionMsg
| PongMessage
| ErrorMessage
| YServerMessage
: decodeYMessage(new Uint8Array(event.data)) as
| YServerMessage;
console.debug('dexie-cloud WebSocket onmessage', msg.type, msg);
if (msg.type === 'error') {
throw new Error(`Error message from dexie-cloud: ${msg.error}`);
} else if (msg.type === 'aware') {
const docCache = DexieYProvider.getDocCache(this.db.dx);
const doc = docCache.find(msg.table, msg.k, msg.prop);
if (doc) {
const awareness = getDocAwareness(doc);
if (awareness) {
awap.applyAwarenessUpdate(
awareness,
msg.u,
'server',
);
}
}
} else if (msg.type === 'pong') {
// Do nothing
} else if (msg.type === 'doc-open') {
const docCache = DexieYProvider.getDocCache(this.db.dx);
const doc = docCache.find(msg.table, msg.k, msg.prop);
if (doc) {
getOpenDocSignal(doc).next(); // Make yHandler reopen the document on server.
}
} else if (msg.type === 'u-ack' || msg.type === 'u-reject' || msg.type === 'u-s' || msg.type === 'in-sync' || msg.type === 'outdated-server-rev' || msg.type === 'y-complete-sync-done') {
applyYServerMessages([msg], this.db).then(async ({resyncNeeded, yServerRevision, receivedUntils}) => {
if (yServerRevision) {
await this.db.$syncState.update('syncState', { yServerRevision: yServerRevision });
}
if (msg.type === 'u-s' && receivedUntils) {
const utbl = getUpdatesTable(this.db, msg.table, msg.prop) as any as Table;
if (utbl) {
const receivedUntil = receivedUntils[utbl.name];
if (receivedUntil) {
await utbl.update(DEXIE_CLOUD_SYNCER_ID, { receivedUntil });
}
}
}
if (resyncNeeded) {
await this.db.cloud.sync({ purpose: 'pull', wait: true });
}
})
} else {
// Forward the request to our subscriber, wich is in messageFromServerQueue.ts (via connectWebSocket's subscribe() at the end!)
this.subscriber.next(msg);
}
} catch (e) {
this.subscriber.error(e);
}
};
try {
let everConnected = false;
await new Promise((resolve, reject) => {
ws.onopen = (event) => {
console.debug('dexie-cloud WebSocket onopen');
everConnected = true;
resolve(null);
};
ws.onerror = (event: ErrorEvent) => {
if (!everConnected) {
const error = event.error || new Error('WebSocket Error');
this.subscriber.error(error);
this.webSocketStatus.next('error');
reject(error);
} else {
this.reconnect();
}
};
});
this.subscriptions.add(this.messageProducer.subscribe(
(msg) => {
if (!this.closed) {
if (
msg.type === 'ready' &&
this.webSocketStatus.value !== 'connected'
) {
this.webSocketStatus.next('connected');
}
console.debug('dexie-cloud WebSocket send', msg.type, msg);
if (msg.type === 'ready') {
// Ok, we are certain to have stored everything up until revision msg.rev.
// Update this.rev in case of reconnect - remember where we were and don't just start over!
this.rev = msg.rev;
// ... and then send along the request to the server so it would also be updated!
this.ws?.send(TSON.stringify(msg));
} else {
// If it's not a "ready" message, it's an YMessage.
// YMessages can be sent binary encoded.
this.ws?.send(encodeYMessage(msg));
}
}
}
));
if (this.user.isLoggedIn && !isEagerSyncDisabled(this.db)) {
this.subscriptions.add(
createYClientUpdateObservable(this.db).subscribe(
this.db.messageProducer
)
);
}
} catch (error) {
this.pauseUntil = new Date(Date.now() + FAIL_RETRY_WAIT_TIME);
}
}
}
================================================
FILE: addons/dexie-cloud/src/associate.ts
================================================
export function associate(factory: (x: T)=>M): (x: T) => M {
const wm = new WeakMap();
return (x: T) => {
let rv = wm.get(x);
if (!rv) {
rv = factory(x);
wm.set(x, rv);
}
return rv;
}
}
================================================
FILE: addons/dexie-cloud/src/authentication/AuthPersistedContext.ts
================================================
import { DexieCloudDB } from "../db/DexieCloudDB";
import { UserLogin } from "../db/entities/UserLogin";
export interface AuthPersistedContext extends UserLogin {
save(): Promise;
}
// Emulate true-private property db. Why? So it's not stored in DB.
const wm = new WeakMap();
export class AuthPersistedContext {
constructor(db: DexieCloudDB, userLogin: UserLogin) {
wm.set(this, db);
Object.assign(this, userLogin);
}
static load(db: DexieCloudDB, userId: string) {
return db
.table("$logins")
.get(userId)
.then(
(userLogin) => new AuthPersistedContext(db, userLogin || {
userId,
claims: {
sub: userId
},
lastLogin: new Date(0)
})
);
}
async save() {
const db = wm.get(this)!;
db.table("$logins").put(this);
}
}
================================================
FILE: addons/dexie-cloud/src/authentication/TokenErrorResponseError.ts
================================================
import { TokenErrorResponse } from 'dexie-cloud-common';
export class TokenErrorResponseError extends Error {
title: string;
messageCode:
| 'INVALID_OTP'
| 'INVALID_EMAIL'
| 'LICENSE_LIMIT_REACHED'
| 'GENERIC_ERROR';
message: string;
messageParams?: { [param: string]: string };
constructor({
title,
message,
messageCode,
messageParams,
}: TokenErrorResponse) {
super(message);
this.name = 'TokenErrorResponseError';
this.title = title;
this.messageCode = messageCode;
this.messageParams = messageParams;
}
}
================================================
FILE: addons/dexie-cloud/src/authentication/TokenExpiredError.ts
================================================
export class TokenExpiredError extends Error {
name = "TokenExpiredError";
}
================================================
FILE: addons/dexie-cloud/src/authentication/UNAUTHORIZED_USER.ts
================================================
import { UserLogin } from '../db/entities/UserLogin';
export const UNAUTHORIZED_USER: UserLogin = {
userId: "unauthorized",
name: "Unauthorized",
claims: {
sub: "unauthorized",
},
lastLogin: new Date(0)
}
try {
Object.freeze(UNAUTHORIZED_USER);
Object.freeze(UNAUTHORIZED_USER.claims);
} catch {}
================================================
FILE: addons/dexie-cloud/src/authentication/authenticate.ts
================================================
import Dexie from 'dexie';
import type {
RefreshTokenRequest,
TokenErrorResponse,
TokenFinalResponse,
} from 'dexie-cloud-common';
import { b64encode } from 'dexie-cloud-common';
import { BehaviorSubject } from 'rxjs';
import { DexieCloudDB } from '../db/DexieCloudDB';
import { UserLogin } from '../db/entities/UserLogin';
import { DXCAlert } from '../types/DXCAlert';
import {
DXCMessageAlert,
DXCUserInteraction,
} from '../types/DXCUserInteraction';
import { TokenErrorResponseError } from './TokenErrorResponseError';
import { alertUser, interactWithUser } from './interactWithUser';
import { InvalidLicenseError } from '../InvalidLicenseError';
import { LoginHints } from '../DexieCloudAPI';
import { OAuthRedirectError } from '../errors/OAuthRedirectError';
import { MINUTES } from '../helpers/date-constants';
export type FetchTokenCallback = (tokenParams: {
public_key: string;
hints?: LoginHints;
}) => Promise;
export async function loadAccessToken(
db: DexieCloudDB
): Promise {
const currentUser = await db.getCurrentUser();
const {
accessToken,
accessTokenExpiration,
refreshToken,
refreshTokenExpiration,
claims,
} = currentUser;
if (!accessToken) return null;
const expTime = accessTokenExpiration?.getTime() ?? Infinity;
if (expTime > (Date.now() + 5 * MINUTES) && (currentUser.license?.status || 'ok') === 'ok') {
return currentUser;
}
if (!refreshToken) {
throw new Error(`Refresh token missing`);
}
const refreshExpTime = refreshTokenExpiration?.getTime() ?? Infinity;
if (refreshExpTime <= Date.now()) {
throw new Error(`Refresh token has expired`);
}
const refreshedLogin = await refreshAccessToken(
db.cloud.options!.databaseUrl,
currentUser
);
await db.table('$logins').update(claims.sub, {
accessToken: refreshedLogin.accessToken,
accessTokenExpiration: refreshedLogin.accessTokenExpiration,
claims: refreshedLogin.claims,
license: refreshedLogin.license,
data: refreshedLogin.data,
});
return refreshedLogin;
}
export async function authenticate(
url: string,
context: UserLogin,
fetchToken: FetchTokenCallback,
userInteraction: BehaviorSubject,
hints?: LoginHints
): Promise {
if (
context.accessToken &&
context.accessTokenExpiration!.getTime() > Date.now()
) {
return context;
} else if (
context.refreshToken &&
(!context.refreshTokenExpiration ||
context.refreshTokenExpiration.getTime() > Date.now())
) {
return await refreshAccessToken(url, context);
} else {
return await userAuthenticate(context, fetchToken, userInteraction, hints);
}
}
export async function refreshAccessToken(
url: string,
login: UserLogin
): Promise {
if (!login.refreshToken)
throw new Error(`Cannot refresh token - refresh token is missing.`);
if (!login.nonExportablePrivateKey)
throw new Error(
`login.nonExportablePrivateKey is missing - cannot sign refresh token without a private key.`
);
const time_stamp = Date.now();
const signing_algorithm = 'RSASSA-PKCS1-v1_5';
const textEncoder = new TextEncoder();
const data = textEncoder.encode(login.refreshToken + time_stamp);
const binarySignature = await crypto.subtle.sign(
signing_algorithm,
login.nonExportablePrivateKey,
data
);
const signature = b64encode(binarySignature);
const tokenRequest: RefreshTokenRequest = {
grant_type: 'refresh_token',
refresh_token: login.refreshToken,
scopes: ['ACCESS_DB'],
signature,
signing_algorithm,
time_stamp,
};
const res = await fetch(`${url}/token`, {
body: JSON.stringify(tokenRequest),
method: 'post',
headers: { 'Content-Type': 'application/json' },
mode: 'cors',
});
if (res.status !== 200)
throw new Error(`RefreshToken: Status ${res.status} from ${url}/token`);
const response: TokenFinalResponse | TokenErrorResponse = await res.json();
if (response.type === 'error') {
throw new TokenErrorResponseError(response);
}
login.accessToken = response.accessToken;
login.accessTokenExpiration = response.accessTokenExpiration
? new Date(response.accessTokenExpiration)
: undefined;
login.claims = response.claims;
login.license = {
type: response.userType,
status: response.claims.license || 'ok',
}
if (response.evalDaysLeft != null) {
login.license.evalDaysLeft = response.evalDaysLeft;
}
if (response.userValidUntil != null) {
login.license.validUntil = new Date(response.userValidUntil);
}
if (response.data) {
login.data = response.data;
}
return login;
}
async function userAuthenticate(
context: UserLogin,
fetchToken: FetchTokenCallback,
userInteraction: BehaviorSubject,
hints?: LoginHints
) {
if (!crypto.subtle) {
if (typeof location !== 'undefined' && location.protocol === 'http:') {
throw new Error(`Dexie Cloud Addon needs to use WebCrypto, but your browser has disabled it due to being served from an insecure location. Please serve it from https or http://localhost: (See https://stackoverflow.com/questions/46670556/how-to-enable-crypto-subtle-for-unsecure-origins-in-chrome/46671627#46671627)`);
} else {
throw new Error(`This browser does not support WebCrypto.`);
}
}
const { privateKey, publicKey } = await crypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: { name: 'SHA-256' },
},
false, // Non-exportable...
['sign', 'verify']
);
if (!privateKey || !publicKey)
throw new Error(`Could not generate RSA keypair`); // Typings suggest these can be undefined...
context.nonExportablePrivateKey = privateKey; //...but storable!
const publicKeySPKI = await crypto.subtle.exportKey('spki', publicKey);
const publicKeyPEM = spkiToPEM(publicKeySPKI);
context.publicKey = publicKey;
try {
const response2 = await fetchToken({
public_key: publicKeyPEM,
hints,
});
if (response2.type === 'error') {
throw new TokenErrorResponseError(response2);
}
if (response2.type !== 'tokens')
throw new Error(
`Unexpected response type from token endpoint: ${(response2 as any).type}`
);
/*const licenseStatus = response2.claims.license || 'ok';
if (licenseStatus !== 'ok') {
throw new InvalidLicenseError(licenseStatus);
}*/
context.accessToken = response2.accessToken;
context.accessTokenExpiration = new Date(response2.accessTokenExpiration);
context.refreshToken = response2.refreshToken;
if (response2.refreshTokenExpiration) {
context.refreshTokenExpiration = new Date(
response2.refreshTokenExpiration
);
}
context.userId = response2.claims.sub;
context.email = response2.claims.email;
context.name = response2.claims.name;
context.claims = response2.claims;
context.license = {
type: response2.userType,
status: response2.claims.license || 'ok',
}
context.data = response2.data;
if (response2.evalDaysLeft != null) {
context.license.evalDaysLeft = response2.evalDaysLeft;
}
if (response2.userValidUntil != null) {
context.license.validUntil = new Date(response2.userValidUntil);
}
if (response2.alerts && response2.alerts.length > 0) {
await interactWithUser(userInteraction, {
type: 'message-alert',
title: 'Authentication Alert',
fields: {},
alerts: response2.alerts as DXCAlert[],
});
}
return context;
} catch (error: any) {
// OAuth redirect is not an error - page is navigating away
if (error instanceof OAuthRedirectError || error?.name === 'OAuthRedirectError') {
throw error; // Re-throw without logging
}
if (error instanceof TokenErrorResponseError) {
await alertUser(userInteraction, error.title, {
type: 'error',
messageCode: error.messageCode,
message: error.message,
messageParams: {},
});
throw error;
}
let message = `We're having a problem authenticating right now.`;
console.error (`Error authenticating`, error);
if (error instanceof TypeError) {
const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
if (isOffline) {
message = `You seem to be offline. Please connect to the internet and try again.`;
} else if (typeof location !== 'undefined' && (Dexie.debug || location.hostname === 'localhost' || location.hostname === '127.0.0.1')) {
// The audience is most likely the developer. Suggest to whitelist the localhost origin:
const whitelistCommand = `npx dexie-cloud whitelist ${location.origin}`;
message = `Could not connect to server. Please verify that your origin '${location.origin}' is whitelisted using \`npx dexie-cloud whitelist\``;
await alertUser(userInteraction, 'Authentication Failed', {
type: 'error',
messageCode: 'GENERIC_ERROR',
message,
messageParams: {},
copyText: whitelistCommand,
}).catch(() => {});
} else {
message = `Could not connect to server. Please verify the connection.`;
await alertUser(userInteraction, 'Authentication Failed', {
type: 'error',
messageCode: 'GENERIC_ERROR',
message,
messageParams: {},
}).catch(() => {});
}
}
throw error;
}
}
function spkiToPEM(keydata: ArrayBuffer) {
const keydataB64 = b64encode(keydata);
const keydataB64Pem = formatAsPem(keydataB64);
return keydataB64Pem;
}
function formatAsPem(str: string) {
let finalString = '-----BEGIN PUBLIC KEY-----\n';
while (str.length > 0) {
finalString += str.substring(0, 64) + '\n';
str = str.substring(64);
}
finalString = finalString + '-----END PUBLIC KEY-----';
return finalString;
}
================================================
FILE: addons/dexie-cloud/src/authentication/currentUserObservable.ts
================================================
import { Dexie } from "dexie";
import { BehaviorSubject } from 'rxjs';
import { UserLogin } from '../db/entities/UserLogin';
//export const currentUserWeakMap = new WeakMap>();
================================================
FILE: addons/dexie-cloud/src/authentication/exchangeOAuthCode.ts
================================================
import type {
AuthorizationCodeTokenRequest,
TokenFinalResponse,
TokenErrorResponse,
} from 'dexie-cloud-common';
import { OAuthError } from '../errors/OAuthError';
import { TokenErrorResponseError } from './TokenErrorResponseError';
/** Options for exchanging an OAuth code */
export interface ExchangeOAuthCodeOptions {
/** The Dexie Cloud database URL */
databaseUrl: string;
/** The Dexie Cloud authorization code from OAuth callback */
code: string;
/** The client's public key in PEM format */
publicKey: string;
/** Requested scopes (defaults to ['ACCESS_DB']) */
scopes?: string[];
}
/**
* Exchanges a Dexie Cloud authorization code for access and refresh tokens.
*
* This is called after the OAuth callback delivers the authorization code
* via postMessage (popup flow) or redirect.
*
* @param options - Exchange options
* @returns Promise resolving to TokenFinalResponse
* @throws OAuthError or TokenErrorResponseError on failure
*/
export async function exchangeOAuthCode(
options: ExchangeOAuthCodeOptions
): Promise {
const { databaseUrl, code, publicKey, scopes = ['ACCESS_DB'] } = options;
const tokenRequest: AuthorizationCodeTokenRequest = {
grant_type: 'authorization_code',
code,
public_key: publicKey,
scopes,
};
try {
const res = await fetch(`${databaseUrl}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tokenRequest),
mode: 'cors',
});
if (!res.ok) {
// Read body once as text to avoid stream consumption issues
const bodyText = await res.text().catch(() => res.statusText);
if (res.status === 400 || res.status === 401) {
// Try to parse error response as JSON
try {
const errorResponse: TokenErrorResponse = JSON.parse(bodyText);
if (errorResponse.type === 'error') {
// Check for specific error codes
if (errorResponse.messageCode === 'INVALID_OTP') {
// In the context of OAuth, this likely means expired code
throw new OAuthError('expired_code', undefined, errorResponse.message);
}
throw new TokenErrorResponseError(errorResponse);
}
} catch (e) {
if (e instanceof OAuthError || e instanceof TokenErrorResponseError) {
throw e;
}
// Fall through to generic error
}
}
throw new OAuthError('provider_error', undefined, `Token exchange failed: ${res.status} ${bodyText}`);
}
const response: TokenFinalResponse | TokenErrorResponse = await res.json();
if (response.type === 'error') {
throw new TokenErrorResponseError(response);
}
if (response.type !== 'tokens') {
throw new OAuthError('provider_error', undefined, `Unexpected response type: ${(response as any).type}`);
}
return response;
} catch (error) {
if (error instanceof OAuthError || error instanceof TokenErrorResponseError) {
throw error;
}
if (error instanceof TypeError) {
// Network error
throw new OAuthError('network_error');
}
throw error;
}
}
================================================
FILE: addons/dexie-cloud/src/authentication/fetchAuthProviders.ts
================================================
import type { AuthProvidersResponse } from 'dexie-cloud-common';
/** Default response when OAuth is disabled or unavailable */
const OTP_ONLY_RESPONSE: AuthProvidersResponse = {
providers: [],
otpEnabled: true,
};
/**
* Fetches available authentication providers from the Dexie Cloud server.
*
* @param databaseUrl - The Dexie Cloud database URL
* @param socialAuthEnabled - Whether social auth is enabled in client config (default: true)
* @returns Promise resolving to AuthProvidersResponse
*
* Handles failures gracefully:
* - 404 → Returns OTP-only (old server version)
* - Network error → Returns OTP-only
* - socialAuthEnabled: false → Returns OTP-only without fetching
*/
export async function fetchAuthProviders(
databaseUrl: string,
socialAuthEnabled: boolean = true
): Promise {
// If social auth is disabled, return OTP-only without fetching
if (!socialAuthEnabled) {
return OTP_ONLY_RESPONSE;
}
try {
const res = await fetch(`${databaseUrl}/auth-providers`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
mode: 'cors',
});
if (res.status === 404) {
// Old server version without OAuth support
console.debug('[dexie-cloud] Server does not support /auth-providers endpoint. Using OTP-only authentication.');
return OTP_ONLY_RESPONSE;
}
if (!res.ok) {
console.warn(`[dexie-cloud] Failed to fetch auth providers: ${res.status} ${res.statusText}`);
return OTP_ONLY_RESPONSE;
}
return await res.json();
} catch (error) {
// Network error or other failure - fall back to OTP
console.debug('[dexie-cloud] Could not fetch auth providers, falling back to OTP:', error);
return OTP_ONLY_RESPONSE;
}
}
================================================
FILE: addons/dexie-cloud/src/authentication/handleOAuthCallback.ts
================================================
import { OAuthError } from '../errors/OAuthError';
/** Parsed OAuth callback parameters from dxc-auth query parameter */
export interface OAuthCallbackParams {
/** The Dexie Cloud authorization code */
code: string;
/** The OAuth provider that was used */
provider: string;
/** The state parameter */
state: string;
}
/** Decoded dxc-auth payload structure */
interface DxcAuthPayload {
code?: string;
provider: string;
state: string;
error?: string;
}
/**
* Decodes a base64url-encoded string to a regular string.
* Base64url uses - instead of + and _ instead of /, and may omit padding.
*/
function decodeBase64Url(encoded: string): string {
// Add padding if needed
const padded = encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
// Convert base64url to base64
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
return atob(base64);
}
/**
* Parses OAuth callback parameters from the dxc-auth query parameter.
*
* The dxc-auth parameter contains base64url-encoded JSON with the following structure:
* - On success: { "code": "...", "provider": "...", "state": "..." }
* - On error: { "error": "...", "provider": "...", "state": "..." }
*
* @param url - The URL to parse (defaults to window.location.href)
* @returns OAuthCallbackParams if valid callback, null otherwise
* @throws OAuthError if there's an error in the callback
*/
export function parseOAuthCallback(url?: string): OAuthCallbackParams | null {
const targetUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
if (!targetUrl) {
return null;
}
const parsed = new URL(targetUrl);
const encoded = parsed.searchParams.get('dxc-auth');
if (!encoded) {
return null; // Not an OAuth callback URL
}
let payload: DxcAuthPayload;
try {
const json = decodeBase64Url(encoded);
payload = JSON.parse(json);
} catch (e) {
console.warn('[dexie-cloud] Failed to parse dxc-auth parameter:', e);
return null;
}
const { code, provider, state, error } = payload;
// Check for error first
if (error) {
if (error.toLowerCase().includes('access_denied') || error.toLowerCase().includes('access denied')) {
throw new OAuthError('access_denied', provider, error);
}
if (error.toLowerCase().includes('email') && error.toLowerCase().includes('verif')) {
throw new OAuthError('email_not_verified', provider, error);
}
throw new OAuthError('provider_error', provider, error);
}
// Validate required fields for success case
if (!code || !provider || !state) {
console.warn('[dexie-cloud] Invalid dxc-auth payload: missing required fields');
return null;
}
return { code, provider, state };
}
/**
* Validates the OAuth state parameter against the stored state.
*
* @param receivedState - The state from the callback URL
* @returns true if valid, false otherwise
*/
export function validateOAuthState(receivedState: string): boolean {
if (typeof sessionStorage === 'undefined') {
console.warn('[dexie-cloud] sessionStorage not available, cannot validate OAuth state');
return false; // Fail closed - reject if we cannot validate CSRF protection
}
const storedState = sessionStorage.getItem('dexie-cloud-oauth-state');
if (!storedState) {
console.warn('[dexie-cloud] No stored OAuth state found');
return false;
}
// Clear the stored state after validation attempt
sessionStorage.removeItem('dexie-cloud-oauth-state');
return storedState === receivedState;
}
/**
* Cleans up the dxc-auth query parameter from the URL.
* Call this after successfully handling the callback to clean up the browser URL.
*/
export function cleanupOAuthUrl(): void {
if (typeof window === 'undefined' || !window.history?.replaceState) {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has('dxc-auth')) {
return;
}
url.searchParams.delete('dxc-auth');
const cleanUrl = url.pathname + (url.searchParams.toString() ? `?${url.searchParams.toString()}` : '') + url.hash;
window.history.replaceState(null, '', cleanUrl);
}
/**
* Complete handler for OAuth callback.
*
* Parses the dxc-auth query parameter, validates state, and returns the parameters
* needed to complete the login flow.
*
* Note: For web SPAs using full page redirect, the dexie-cloud-addon automatically
* detects and processes the dxc-auth parameter when db.cloud.configure() is called.
* This function is primarily useful for Capacitor/native apps handling deep links.
*
* @param url - The callback URL (defaults to window.location.href)
* @returns OAuthCallbackParams if valid callback, null otherwise
* @throws OAuthError on validation failure or if callback contains an error
*
* @example
* ```typescript
* // Capacitor deep link handler:
* App.addListener('appUrlOpen', async ({ url }) => {
* const callback = handleOAuthCallback(url);
* if (callback) {
* await db.cloud.login({ oauthCode: callback.code, provider: callback.provider });
* }
* });
* ```
*/
export function handleOAuthCallback(url?: string): OAuthCallbackParams | null {
const params = parseOAuthCallback(url);
if (!params) {
return null;
}
// Validate state for CSRF protection
if (!validateOAuthState(params.state)) {
throw new OAuthError('invalid_state', params.provider);
}
return params;
}
================================================
FILE: addons/dexie-cloud/src/authentication/interactWithUser.ts
================================================
import Dexie from 'dexie';
import type { OAuthProviderInfo } from 'dexie-cloud-common';
import { BehaviorSubject } from 'rxjs';
import { take } from 'rxjs/operators';
import { DexieCloudDB } from '../db/DexieCloudDB';
import { DXCAlert } from '../types/DXCAlert';
import { DXCInputField } from '../types/DXCInputField';
import { DXCUserInteraction, DXCGenericUserInteraction, DXCOption } from '../types/DXCUserInteraction';
/** Email/envelope icon data URL for OTP option */
const EmailIcon = `data:image/svg+xml;base64,${btoa('')}`;
/**
* Converts an OAuthProviderInfo to a generic DXCOption.
*/
function providerToOption(provider: OAuthProviderInfo): DXCOption {
return {
name: 'provider',
value: provider.name,
displayName: `Continue with ${provider.displayName}`,
iconUrl: provider.iconUrl,
styleHint: provider.type,
};
}
export interface DXCUserInteractionRequest {
type: DXCUserInteraction['type'];
title: string;
alerts: DXCAlert[];
submitLabel?: string;
cancelLabel?: string | null;
fields: { [name: string]: DXCInputField };
}
export function interactWithUser(
userInteraction: BehaviorSubject,
req: T
): Promise<{
[P in keyof T['fields']]: string;
}> {
let done = false;
return new Promise<{
[P in keyof T['fields']]: string;
}>((resolve, reject) => {
const interactionProps = {
submitLabel: 'Submit',
cancelLabel: 'Cancel',
...req,
onSubmit: (res: {
[P in keyof T['fields']]: string;
}) => {
userInteraction.next(undefined);
done = true;
resolve(res);
},
onCancel: () => {
userInteraction.next(undefined);
done = true;
reject(new Dexie.AbortError('User cancelled'));
},
} as DXCUserInteraction;
userInteraction.next(interactionProps);
// Start subscribing for external updates to db.cloud.userInteraction, and if so, cancel this request.
/*const subscription = userInteraction.subscribe((currentInteractionProps) => {
if (currentInteractionProps !== interactionProps) {
if (subscription) subscription.unsubscribe();
if (!done) {
reject(new Dexie.AbortError("User cancelled"));
}
}
});*/
});
}
export function alertUser(
userInteraction: BehaviorSubject,
title: string,
...alerts: DXCAlert[]
) {
return interactWithUser(userInteraction, {
type: 'message-alert',
title,
alerts,
fields: {},
submitLabel: 'OK',
cancelLabel: null,
});
}
export async function promptForEmail(
userInteraction: BehaviorSubject,
title: string,
emailHint?: string
) {
let email = emailHint || '';
// Regular expression for email validation
// ^[\w-+.]+@([\w-]+\.)+[\w-]{2,10}(\sas\s[\w-+.]+@([\w-]+\.)+[\w-]{2,10})?$
//
// ^[\w-+.]+ : Matches the start of the string. Allows one or more word characters
// (a-z, A-Z, 0-9, and underscore), hyphen, plus, or dot.
//
// @ : Matches the @ symbol.
// ([\w-]+\.)+ : Matches one or more word characters or hyphens followed by a dot.
// The plus sign outside the parentheses means this pattern can repeat one or more times,
// allowing for subdomains.
// [\w-]{2,10} : Matches between 2 and 10 word characters or hyphens. This is typically for
// the domain extension like .com, .net, etc.
// (\sas\s[\w-+.]+@([\w-]+\.)+[\w-]{2,10})?$ : This part is optional (due to the ? at the end).
// If present, it matches " as " followed by another valid email address. This allows for the
// input to be either a single email address or two email addresses separated by " as ".
//
// The use case for " as "" is for when a database owner with full access to the
// database needs to impersonate another user in the database in order to troubleshoot. This
// format will only be possible to use when email1 is the owner of an API client with GLOBAL_READ
// and GLOBAL_WRITE permissions on the database. The email will be checked on the server before
// allowing it and giving out a token for email2, using the OTP sent to email1.
while (!email || !/^[\w-+.]+@([\w-]+\.)+[\w-]{2,10}(\sas\s[\w-+.]+@([\w-]+\.)+[\w-]{2,10})?$/.test(email)) {
email = (
await interactWithUser(userInteraction, {
type: 'email',
title,
alerts: email
? [
{
type: 'error',
messageCode: 'INVALID_EMAIL',
message: 'Please enter a valid email address',
messageParams: {},
},
]
: [],
fields: {
email: {
type: 'email',
placeholder: 'you@somedomain.com',
},
},
})
).email;
}
return email;
}
export async function promptForOTP(
userInteraction: BehaviorSubject,
email: string,
alert?: DXCAlert
) {
const alerts: DXCAlert[] = [
{
type: 'info',
messageCode: 'OTP_SENT',
message: `A One-Time password has been sent to {email}`,
messageParams: { email },
},
];
if (alert) {
alerts.push(alert);
}
const { otp } = await interactWithUser(userInteraction, {
type: 'otp',
title: 'Enter OTP',
alerts,
fields: {
otp: {
type: 'otp',
label: 'OTP',
placeholder: 'Paste OTP here',
},
},
});
return otp;
}
export async function confirmLogout(
userInteraction: BehaviorSubject,
currentUserId: string,
numUnsyncedChanges: number
) {
const alerts: DXCAlert[] = [
{
type: 'warning',
messageCode: 'LOGOUT_CONFIRMATION',
message: `{numUnsyncedChanges} unsynced changes will get lost!
Logout anyway?`,
messageParams: {
currentUserId,
numUnsyncedChanges: numUnsyncedChanges.toString(),
}
},
];
return await interactWithUser(userInteraction, {
type: 'logout-confirmation',
title: 'Confirm Logout',
alerts,
fields: {},
submitLabel: 'Confirm logout',
cancelLabel: 'Cancel'
})
.then(() => true)
.catch(() => false);
}
/** Result from provider selection prompt */
export type ProviderSelectionResult =
| { type: 'provider'; provider: string }
| { type: 'otp' };
/**
* Prompts the user to select an authentication method (OAuth provider or OTP).
*
* This function converts OAuth providers and OTP option into generic DXCOption[]
* for the DXCSelect interaction, handling icon fetching and style hints.
*
* @param userInteraction - The user interaction BehaviorSubject
* @param providers - Available OAuth providers
* @param otpEnabled - Whether OTP is available
* @param title - Dialog title
* @param alerts - Optional alerts to display
* @returns Promise resolving to the user's selection
*/
export async function promptForProvider(
userInteraction: BehaviorSubject,
providers: OAuthProviderInfo[],
otpEnabled: boolean,
title: string = 'Choose login method',
alerts: DXCAlert[] = []
): Promise {
// Convert providers to generic options
const providerOptions = providers.map(providerToOption);
// Build the options array
const options: DXCOption[] = [...providerOptions];
// Add OTP option if enabled
if (otpEnabled) {
options.push({
name: 'otp',
value: 'email',
displayName: 'Continue with email',
iconUrl: EmailIcon,
styleHint: 'otp',
});
}
return new Promise((resolve, reject) => {
const interactionProps: DXCGenericUserInteraction = {
type: 'generic',
title,
alerts,
options,
fields: {},
submitLabel: '', // No submit button - just options
cancelLabel: 'Cancel',
onSubmit: (params: { [key: string]: string }) => {
userInteraction.next(undefined);
// Check which option was selected
if ('otp' in params) {
resolve({ type: 'otp' });
} else if ('provider' in params) {
resolve({ type: 'provider', provider: params.provider });
} else {
// Unknown - default to OTP
resolve({ type: 'otp' });
}
},
onCancel: () => {
userInteraction.next(undefined);
reject(new Dexie.AbortError('User cancelled'));
},
};
userInteraction.next(interactionProps);
});
}
================================================
FILE: addons/dexie-cloud/src/authentication/login.ts
================================================
import { DexieCloudDB } from '../db/DexieCloudDB';
import { LoginHints } from '../DexieCloudAPI';
import { triggerSync } from '../sync/triggerSync';
import { authenticate, loadAccessToken } from './authenticate';
import { AuthPersistedContext } from './AuthPersistedContext';
import { logout } from './logout';
import { otpFetchTokenCallback } from './otpFetchTokenCallback';
import { setCurrentUser } from './setCurrentUser';
import { UNAUTHORIZED_USER } from './UNAUTHORIZED_USER';
export async function login(
db: DexieCloudDB,
hints?: LoginHints
) {
const currentUser = await db.getCurrentUser();
const origUserId = currentUser.userId;
if (currentUser.isLoggedIn && (!hints || (!hints.email && !hints.userId))) {
const licenseStatus = currentUser.license?.status || 'ok';
if (
licenseStatus === 'ok' &&
currentUser.accessToken &&
(!currentUser.accessTokenExpiration ||
currentUser.accessTokenExpiration.getTime() > Date.now())
) {
// Already authenticated according to given hints. And license is valid.
return false;
}
if (
currentUser.refreshToken &&
(!currentUser.refreshTokenExpiration ||
currentUser.refreshTokenExpiration.getTime() > Date.now())
) {
// Refresh the token
await loadAccessToken(db);
return false;
}
// No refresh token - must re-authenticate:
}
const context = new AuthPersistedContext(db, {
claims: {},
lastLogin: new Date(0),
});
try {
await authenticate(
db.cloud.options!.databaseUrl,
context,
db.cloud.options!.fetchTokens || otpFetchTokenCallback(db),
db.cloud.userInteraction,
hints
);
} catch (err) {
if (err.name === 'OAuthRedirectError') {
return false; // Page is redirecting for OAuth login
}
throw err;
}
if (
origUserId !== UNAUTHORIZED_USER.userId &&
context.userId !== origUserId
) {
// User was logged in before, but now logged in as another user.
await logout(db);
}
/*try {
await context.save();
} catch (e) {
try {
if (e.name === 'DataCloneError') {
console.debug(`Login context property names:`, Object.keys(context));
console.debug(`Login context:`, context);
console.debug(`Login context JSON:`, JSON.stringify(context));
}
} catch {}
throw e;
}*/
await setCurrentUser(db, context);
// Make sure to resync as the new login will be authorized
// for new realms.
triggerSync(db, 'pull');
return context.userId !== origUserId;
}
================================================
FILE: addons/dexie-cloud/src/authentication/logout.ts
================================================
import { DexieCloudDB } from '../db/DexieCloudDB';
import { TXExpandos } from '../types/TXExpandos';
import { confirmLogout } from './interactWithUser';
import { UNAUTHORIZED_USER } from './UNAUTHORIZED_USER';
import { waitUntil } from './waitUntil';
export async function logout(db: DexieCloudDB) {
const numUnsyncedChanges = await _logout(db);
if (numUnsyncedChanges) {
if (
await confirmLogout(
db.cloud.userInteraction,
db.cloud.currentUserId,
numUnsyncedChanges
)
) {
await _logout(db, { deleteUnsyncedData: true });
} else {
throw new Error(`User cancelled logout due to unsynced changes`);
}
}
}
export async function _logout(db: DexieCloudDB, { deleteUnsyncedData = false } = {}) {
// Clear the database without emptying configuration options.
const [numUnsynced, loggedOut] = await db.dx.transaction('rw', db.dx.tables, async (tx) => {
// @ts-ignore
const idbtrans: IDBTransaction & TXExpandos = tx.idbtrans;
idbtrans.disableChangeTracking = true;
idbtrans.disableAccessControl = true;
const mutationTables = tx.storeNames.filter((tableName) =>
tableName.endsWith('_mutations')
);
// Count unsynced changes
const unsyncCounts = await Promise.all(
mutationTables.map((mutationTable) => tx.table(mutationTable).count())
);
const sumUnSynced = unsyncCounts.reduce((a, b) => a + b, 0);
if (sumUnSynced > 0 && !deleteUnsyncedData) {
// Let caller ask user if they want to delete unsynced data.
return [sumUnSynced, false];
}
// Either there are no unsynched changes, or caller provided flag deleteUnsynchedData = true.
// Clear all tables except $jobs and $syncState (except the persisted sync state which is
// also cleared because we're going to rebuild it using a fresh sync).
db.$syncState.delete('syncState');
for (const table of db.dx.tables) {
if (table.name !== '$jobs' && table.name !== '$syncState') {
table.clear();
}
}
return [sumUnSynced, true];
});
if (loggedOut) {
// Wait for currentUser observable to emit UNAUTHORIZED_USER
await waitUntil(db.cloud.currentUser, (user) => user.userId === UNAUTHORIZED_USER.userId);
// Then perform an initial sync
await db.cloud.sync({purpose: 'pull', wait: true});
}
return numUnsynced;
}
================================================
FILE: addons/dexie-cloud/src/authentication/oauthLogin.ts
================================================
import { OAuthError } from '../errors/OAuthError';
/** Options for initiating OAuth redirect */
export interface OAuthRedirectOptions {
/** The Dexie Cloud database URL */
databaseUrl: string;
/** The OAuth provider name */
provider: string;
/** Optional redirect URI override.
* Defaults to window.location.href for web apps.
* For Capacitor/native apps, use a custom URL scheme like 'myapp://'
*/
redirectUri?: string;
}
/** Build the OAuth login URL */
function buildOAuthLoginUrl(options: OAuthRedirectOptions): string {
const url = new URL(`${options.databaseUrl}/oauth/login/${options.provider}`);
// Set the redirect URI - defaults to current page URL for web SPAs
const redirectUri = options.redirectUri ||
(typeof window !== 'undefined' ? window.location.href : '');
if (redirectUri) {
url.searchParams.set('redirect_uri', redirectUri);
}
return url.toString();
}
/**
* Initiates OAuth login via full page redirect.
*
* The page will navigate to the OAuth provider. After authentication,
* the user is redirected back to the app with a `dxc-auth` query parameter
* containing base64url-encoded JSON with the authorization code.
*
* The dexie-cloud-addon automatically detects and processes this parameter
* when db.cloud.configure() is called on page load.
*
* @param options - OAuth redirect options
*
* @example
* ```typescript
* // Initiate OAuth login
* startOAuthRedirect({
* databaseUrl: 'https://mydb.dexie.cloud',
* provider: 'google'
* });
* // Page navigates away, user authenticates, then returns with auth code
* ```
*/
export function startOAuthRedirect(options: OAuthRedirectOptions): void {
if (typeof window === 'undefined') {
throw new Error('OAuth redirect requires a browser environment');
}
const loginUrl = buildOAuthLoginUrl(options);
window.location.href = loginUrl;
}
/** Map OAuth error strings to error codes */
export function mapOAuthError(error: string): OAuthError['code'] {
const lowerError = error.toLowerCase();
if (lowerError.includes('access_denied') || lowerError.includes('access denied')) {
return 'access_denied';
}
if (lowerError.includes('email') && lowerError.includes('verif')) {
return 'email_not_verified';
}
if (lowerError.includes('expired')) {
return 'expired_code';
}
if (lowerError.includes('state')) {
return 'invalid_state';
}
return 'provider_error';
}
================================================
FILE: addons/dexie-cloud/src/authentication/otpFetchTokenCallback.ts
================================================
import {
AuthorizationCodeTokenRequest,
DemoTokenRequest,
OTPTokenRequest1,
OTPTokenRequest2,
TokenErrorResponse,
TokenFinalResponse,
TokenRequest,
TokenResponse,
} from 'dexie-cloud-common';
import { DexieCloudDB } from '../db/DexieCloudDB';
import { HttpError } from '../errors/HttpError';
import { FetchTokenCallback } from './authenticate';
import { exchangeOAuthCode } from './exchangeOAuthCode';
import { fetchAuthProviders } from './fetchAuthProviders';
import { alertUser, promptForEmail, promptForOTP, promptForProvider } from './interactWithUser';
import { startOAuthRedirect } from './oauthLogin';
import { OAuthRedirectError } from '../errors/OAuthRedirectError';
export function otpFetchTokenCallback(db: DexieCloudDB): FetchTokenCallback {
const { userInteraction } = db.cloud;
return async function otpAuthenticate({ public_key, hints }) {
let tokenRequest: TokenRequest;
const url = db.cloud.options?.databaseUrl;
if (!url) throw new Error(`No database URL given.`);
// Handle OAuth code exchange (from redirect/deep link flows)
if (hints?.oauthCode && hints.provider) {
return await exchangeOAuthCode({
databaseUrl: url,
code: hints.oauthCode,
publicKey: public_key,
scopes: ['ACCESS_DB'],
});
}
// Handle OAuth provider login via redirect
if (hints?.provider) {
let resolvedRedirectUri: string | undefined = undefined;
if (hints.redirectPath) {
// If redirectPath is absolute, use as is. If relative, resolve against current location
if (/^https?:\/\//i.test(hints.redirectPath)) {
resolvedRedirectUri = hints.redirectPath;
} else if (typeof window !== 'undefined' && window.location) {
// Use URL constructor to resolve relative path
resolvedRedirectUri = new URL(hints.redirectPath, window.location.href).toString();
} else if (typeof location !== 'undefined' && location.href) {
resolvedRedirectUri = new URL(hints.redirectPath, location.href).toString();
}
}
initiateOAuthRedirect(db, hints.provider, resolvedRedirectUri);
// This function never returns - page navigates away
throw new OAuthRedirectError(hints.provider);
}
if (hints?.grant_type === 'demo') {
const demo_user = await promptForEmail(
userInteraction,
'Enter a demo user email',
hints?.email || hints?.userId
);
tokenRequest = {
demo_user,
grant_type: 'demo',
scopes: ['ACCESS_DB'],
public_key
} satisfies DemoTokenRequest;
} else if (hints?.otpId && hints.otp) {
// User provided OTP ID and OTP code. This means that the OTP email
// has already gone out and the user may have clicked a magic link
// in the email with otp and otpId in query and the app has picked
// up those values and passed them to db.cloud.login().
tokenRequest = {
grant_type: 'otp',
otp_id: hints.otpId,
otp: hints.otp,
scopes: ['ACCESS_DB'],
public_key,
} satisfies OTPTokenRequest2;
} else if (hints?.grant_type === 'otp' || hints?.email) {
// User explicitly requested OTP flow - skip provider selection
const email = hints?.email || await promptForEmail(
userInteraction,
'Enter email address'
);
if (/@demo.local$/.test(email)) {
tokenRequest = {
demo_user: email,
grant_type: 'demo',
scopes: ['ACCESS_DB'],
public_key
} satisfies DemoTokenRequest;
} else {
tokenRequest = {
email,
grant_type: 'otp',
scopes: ['ACCESS_DB'],
} satisfies OTPTokenRequest1;
}
} else {
// Check for available auth providers (OAuth + OTP)
const socialAuthEnabled = db.cloud.options?.socialAuth !== false;
const authProviders = await fetchAuthProviders(url, socialAuthEnabled);
// If we have OAuth providers available, prompt for selection
if (authProviders.providers.length > 0) {
const selection = await promptForProvider(
userInteraction,
authProviders.providers,
authProviders.otpEnabled,
'Sign in',
);
if (selection.type === 'provider') {
// User selected an OAuth provider - initiate redirect
initiateOAuthRedirect(db, selection.provider);
// This function never returns - page navigates away
throw new OAuthRedirectError(selection.provider);
}
// User chose OTP - continue with email prompt below
}
const email = await promptForEmail(
userInteraction,
'Enter email address',
hints?.email
);
if (/@demo.local$/.test(email)) {
tokenRequest = {
demo_user: email,
grant_type: 'demo',
scopes: ['ACCESS_DB'],
public_key
} satisfies DemoTokenRequest;
} else {
tokenRequest = {
email,
grant_type: 'otp',
scopes: ['ACCESS_DB'],
} satisfies OTPTokenRequest1;
}
}
const res1 = await fetch(`${url}/token`, {
body: JSON.stringify(tokenRequest),
method: 'post',
headers: { 'Content-Type': 'application/json' },
mode: 'cors',
});
if (res1.status !== 200) {
const errMsg = await res1.text();
await alertUser(userInteraction, "Token request failed", {
type: 'error',
messageCode: 'GENERIC_ERROR',
message: errMsg,
messageParams: {}
}).catch(()=>{});
throw new HttpError(res1, errMsg);
}
const response: TokenResponse = await res1.json();
if (response.type === 'tokens' || response.type === 'error') {
// Demo user request can get a "tokens" response right away
// Error can also be returned right away.
return response;
} else if (tokenRequest.grant_type === 'otp' && 'email' in tokenRequest) {
if (response.type !== 'otp-sent')
throw new Error(`Unexpected response from ${url}/token`);
const otp = await promptForOTP(userInteraction, tokenRequest.email);
const tokenRequest2 = {
...tokenRequest,
otp: otp || '',
otp_id: response.otp_id,
public_key
} satisfies OTPTokenRequest2;
let res2 = await fetch(`${url}/token`, {
body: JSON.stringify(tokenRequest2),
method: 'post',
headers: { 'Content-Type': 'application/json' },
mode: 'cors',
});
while (res2.status === 401) {
const errorText = await res2.text();
tokenRequest2.otp = await promptForOTP(userInteraction, tokenRequest.email, {
type: 'error',
messageCode: 'INVALID_OTP',
message: errorText,
messageParams: {}
});
res2 = await fetch(`${url}/token`, {
body: JSON.stringify(tokenRequest2),
method: 'post',
headers: { 'Content-Type': 'application/json' },
mode: 'cors',
});
}
if (res2.status !== 200) {
const errMsg = await res2.text();
throw new HttpError(res2, errMsg);
}
const response2: TokenFinalResponse | TokenErrorResponse = await res2.json();
return response2;
} else {
throw new Error(`Unexpected response from ${url}/token`);
}
};
}
/**
* Initiates OAuth login via full page redirect.
*
* The page will navigate away to the OAuth provider. After authentication,
* the user is redirected back with a dxc-auth query parameter that is
* automatically detected by db.cloud.configure().
*/
function initiateOAuthRedirect(
db: DexieCloudDB,
provider: string,
redirectUriOverride?: string
): void {
const url = db.cloud.options?.databaseUrl;
if (!url) throw new Error(`No database URL given.`);
const redirectUri =
redirectUriOverride ||
db.cloud.options?.oauthRedirectUri ||
(typeof location !== 'undefined' ? location.href : undefined);
// CodeRabbit suggested to fail fast here, but the only situation where
// redirectUri would be undefined is in non-browser environments, and
// in those environments OAuth redirect does not make sense anyway
// and will fail fast in startOAuthRedirect().
// Start OAuth redirect flow - page navigates away
startOAuthRedirect({
databaseUrl: url,
provider,
redirectUri,
});
}
================================================
FILE: addons/dexie-cloud/src/authentication/setCurrentUser.ts
================================================
import { DexieCloudDB } from '../db/DexieCloudDB';
import { prodLog } from '../prodLog';
import { AuthPersistedContext } from './AuthPersistedContext';
import { waitUntil } from './waitUntil';
/** This function changes or sets the current user as requested.
*
* Use cases:
* * Initially on db.ready after reading the current user from db.$logins.
* This will make sure that any unsynced operations from the previous user is synced before
* changing the user.
* * Upon user request
*
* @param db
* @param newUser
*/
export async function setCurrentUser(
db: DexieCloudDB,
user: AuthPersistedContext
) {
const $logins = db.table('$logins');
await db.transaction('rw', $logins, async (tx) => {
const existingLogins = await $logins.toArray();
await Promise.all(
existingLogins
.filter((login) => login.userId !== user.userId && login.isLoggedIn)
.map((login) => {
login.isLoggedIn = false;
return $logins.put(login);
})
);
user.isLoggedIn = true;
user.lastLogin = new Date();
try {
await user.save();
} catch (e) {
try {
if (e.name === 'DataCloneError') {
// We've seen this buggy behavior in some browsers and in case it happens
// again we really need to collect the details to understand what's going on.
prodLog('debug', `Login context property names:`, Object.keys(user));
prodLog('debug', `Login context property names:`, Object.keys(user));
prodLog('debug', `Login context:`, user);
prodLog('debug', `Login context JSON:`, JSON.stringify(user));
}
} catch {}
throw e;
}
console.debug('Saved new user', user.email);
});
await waitUntil(
db.cloud.currentUser,
(currentUser) => currentUser.userId === user.userId
);
}
================================================
FILE: addons/dexie-cloud/src/authentication/waitUntil.ts
================================================
import { filter, firstValueFrom, from, InteropObservable, Observable } from 'rxjs';
export function waitUntil(
o: Observable | InteropObservable, // Works with Dexie's liveQuery observables if we'd need that
predicate: (value: T) => boolean
) {
return firstValueFrom(from(o).pipe(
filter(predicate),
));
}
================================================
FILE: addons/dexie-cloud/src/computeSyncState.ts
================================================
import { combineLatest, Observable, of } from 'rxjs';
import { debounceTime, map, startWith, switchMap } from 'rxjs/operators';
import { getCurrentUserEmitter } from './currentUserEmitter';
import { DexieCloudDB, SyncStateChangedEventData } from './db/DexieCloudDB';
import { isOnline } from './sync/isOnline';
import { SyncState } from './types/SyncState';
import { userIsActive, userIsReallyActive } from './userIsActive';
export function computeSyncState(db: DexieCloudDB): Observable {
let _prevStatus = db.cloud.webSocketStatus.value;
const lazyWebSocketStatus = db.cloud.webSocketStatus.pipe(
switchMap((status) => {
const prevStatus = _prevStatus;
_prevStatus = status;
const rv = of(status);
switch (status) {
// A normal scenario is that the WS reconnects and falls shortly in disconnected-->connection-->connected.
// Don't distract user with this unless these things take more time than normal:
// Only show disconnected if disconnected more than 500ms, or if we can
// see that the user is indeed not active.
case 'disconnected':
return userIsActive.value ? rv.pipe(debounceTime(500)) : rv;
// Only show connecting if previous state was 'not-started' or 'error', or if
// the time it takes to connect goes beyond 4 seconds.
case 'connecting':
return prevStatus === 'not-started' || prevStatus === 'error'
? rv
: rv.pipe(debounceTime(4000));
default:
return rv;
}
})
);
return combineLatest([
lazyWebSocketStatus,
db.syncStateChangedEvent.pipe(startWith({ phase: 'initial' } as SyncStateChangedEventData)),
getCurrentUserEmitter(db.dx._novip),
userIsReallyActive
]).pipe(
map(([status, syncState, user, userIsActive]) => {
if (user.license?.status && user.license.status !== 'ok') {
return {
phase: 'offline',
status: 'offline',
license: user.license.status
} satisfies SyncState;
}
let { phase, error, progress } = syncState;
let adjustedStatus = status;
if (phase === 'error') {
// Let users only rely on the status property to display an icon.
// If there's an error in the sync phase, let it show on that
// status icon also.
adjustedStatus = 'error';
}
if (status === 'not-started') {
// If websocket isn't yet connected becase we're doing
// the startup sync, let the icon show the symbol for connecting.
if (phase === 'pushing' || phase === 'pulling') {
adjustedStatus = 'connecting';
}
}
const previousPhase = db.cloud.syncState.value.phase;
//const previousStatus = db.cloud.syncState.value.status;
if (previousPhase === 'error' && (syncState.phase === 'pushing' || syncState.phase === 'pulling')) {
// We were in an errored state but is now doing sync. Show "connecting" icon.
adjustedStatus = 'connecting';
}
/*if (syncState.phase === 'in-sync' && adjustedStatus === 'connecting') {
adjustedStatus = 'connected';
}*/
if (!userIsActive) {
adjustedStatus = 'disconnected';
}
const retState: SyncState = {
phase,
error,
progress,
status: isOnline ? adjustedStatus : 'offline',
license: 'ok'
};
return retState;
})
);
}
================================================
FILE: addons/dexie-cloud/src/createSharedValueObservable.ts
================================================
import {
concat,
from,
InteropObservable,
map,
Observable,
ObservableInput,
share,
timer,
} from 'rxjs';
import { ObservableWithCurrentValue } from './mapValueObservable';
export function createSharedValueObservable(
o: ObservableInput,
defaultValue: T
): ObservableWithCurrentValue {
let currentValue = defaultValue;
let shared = from(o).pipe(
map((x) => (currentValue = x)),
share({ resetOnRefCountZero: () => timer(1000) })
) as ObservableWithCurrentValue;
const rv = new Observable((observer) => {
let didEmit = false;
const subscription = shared.subscribe({
next(value) {
didEmit = true;
observer.next(value);
},
error(error) {
observer.error(error);
},
complete() {
observer.complete();
}
});
if (!didEmit && !subscription.closed) {
observer.next(currentValue);
}
return subscription;
}) as ObservableWithCurrentValue;
rv.getValue = () => currentValue;
return rv;
}
================================================
FILE: addons/dexie-cloud/src/currentUserEmitter.ts
================================================
import Dexie from "dexie";
import { BehaviorSubject } from "rxjs";
import { associate } from "./associate";
import { UNAUTHORIZED_USER } from "./authentication/UNAUTHORIZED_USER";
import { UserLogin } from "./dexie-cloud-client";
export const getCurrentUserEmitter = associate((db: Dexie) => new BehaviorSubject(
{...UNAUTHORIZED_USER, isLoading: true}
));
================================================
FILE: addons/dexie-cloud/src/db/DexieCloudDB.ts
================================================
import Dexie, { Table } from 'dexie';
import { GuardedJob } from './entities/GuardedJob';
import { UserLogin } from './entities/UserLogin';
import { PersistedSyncState } from './entities/PersistedSyncState';
import { UNAUTHORIZED_USER } from '../authentication/UNAUTHORIZED_USER';
import { DexieCloudOptions } from '../DexieCloudOptions';
import { BehaviorSubject, Subject } from 'rxjs';
import { BaseRevisionMapEntry } from './entities/BaseRevisionMapEntry';
import {
DBRealm,
DBRealmMember,
DBRealmRole,
DexieCloudSchema,
} from 'dexie-cloud-common';
import { BroadcastedAndLocalEvent } from '../helpers/BroadcastedAndLocalEvent';
import { SyncState, SyncStatePhase } from '../types/SyncState';
import { MessagesFromServerConsumer } from '../sync/messagesFromServerQueue';
import { YClientMessage } from 'dexie-cloud-common';
import { BlobDownloadTracker } from '../sync/BlobDownloadTracker';
/*export interface DexieCloudDB extends Dexie {
table(name: string): Table;
table(name: "$jobs"): Table;
table(name: "$logins"): Table;
table(name: "$syncState"): Table;
//table(name: "$pendingChangesFromServer"): Table;
}
*/
export interface SyncStateChangedEventData {
phase: SyncStatePhase;
error?: Error;
progress?: number;
}
type SyncStateTable = Table<
PersistedSyncState | DexieCloudSchema | DexieCloudOptions,
'syncState' | 'options' | 'schema'
>;
export interface DexieCloudDBBase {
readonly name: Dexie['name'];
readonly close: Dexie['close'];
transaction: Dexie['transaction'];
table: Dexie['table'];
readonly tables: Dexie['tables'];
readonly cloud: Dexie['cloud'];
readonly $jobs: Table;
readonly $logins: Table;
readonly $syncState: SyncStateTable;
readonly $baseRevs: Table;
readonly realms: Table;
readonly members: Table;
readonly roles: Table;
readonly localSyncEvent: Subject<{ purpose?: 'pull' | 'push' }>;
readonly syncStateChangedEvent: BroadcastedAndLocalEvent;
readonly syncCompleteEvent: BroadcastedAndLocalEvent;
readonly dx: Dexie;
readonly initiallySynced: boolean;
}
export interface DexieCloudDB extends DexieCloudDBBase {
getCurrentUser(): Promise;
getSchema(): Promise;
getOptions(): Promise;
getPersistedSyncState(): Promise;
setInitiallySynced(initiallySynced: boolean): void;
reconfigure(): void;
messageConsumer: MessagesFromServerConsumer;
messageProducer: Subject;
blobDownloadTracker: BlobDownloadTracker;
}
const wm = new WeakMap