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] ![Build Status](https://github.com/dexie/Dexie.js/actions/workflows/main.yml/badge.svg) [![Join our Discord](https://img.shields.io/discord/1328303736363421747?label=Discord&logo=discord&style=badge)](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

); } ``` [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 ```
[![Browser testing via LAMDBATEST](https://dexie.org/assets/images/lambdatest2.png)](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:
changes : Array<DatabaseChange>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: BooleanTrue 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 [![Better Stack Badge](https://uptime.betterstack.com/status-badges/v2/monitor/jist.svg)](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(); export const DEXIE_CLOUD_SCHEMA = { members: '@id, [userId+realmId], [email+realmId], realmId', roles: '[realmId+name]', realms: '@realmId', $jobs: '', $syncState: '', $baseRevs: '[tableName+clientRev]', $logins: 'claims.sub, lastLogin', }; let static_counter = 0; export function DexieCloudDB(dx: Dexie): DexieCloudDB { if ('vip' in dx) dx = dx['vip']; // Avoid race condition. Always map to a vipped dexie that don't block during db.on.ready(). let db = wm.get(dx); if (!db) { const localSyncEvent = new Subject<{ purpose: 'push' | 'pull' }>(); let syncStateChangedEvent = new BroadcastedAndLocalEvent( `syncstatechanged-${dx.name}` ); let syncCompleteEvent = new BroadcastedAndLocalEvent( `synccomplete-${dx.name}` ); localSyncEvent['id'] = ++static_counter; let initiallySynced = false; db = { get name() { return dx.name; }, close() { return dx.close(); }, transaction: dx.transaction.bind(dx), table: dx.table.bind(dx), get tables() { return dx.tables; }, get cloud() { return dx.cloud; }, get $jobs() { return dx.table('$jobs') as Table; }, get $syncState() { return dx.table('$syncState') as SyncStateTable; }, get $baseRevs() { return dx.table('$baseRevs') as Table< BaseRevisionMapEntry, [string, number] >; }, get $logins() { return dx.table('$logins') as Table; }, get realms() { return dx.realms; }, get members() { return dx.members; }, get roles() { return dx.roles; }, get initiallySynced() { return initiallySynced; }, localSyncEvent, get syncStateChangedEvent() { return syncStateChangedEvent; }, get syncCompleteEvent() { return syncCompleteEvent; }, dx, } as DexieCloudDB; const helperMethods: Partial = { getCurrentUser() { return db!.$logins .toArray() .then( (logins) => logins.find((l) => l.isLoggedIn) || UNAUTHORIZED_USER ); }, getPersistedSyncState() { return db!.$syncState.get('syncState') as Promise< PersistedSyncState | undefined >; }, getSchema() { return db!.$syncState.get('schema').then((schema: DexieCloudSchema) => { if (schema) { for (const table of db!.tables) { if (table.schema.primKey && table.schema.primKey.keyPath && schema[table.name]) { schema[table.name].primaryKey = nameFromKeyPath( table.schema.primKey.keyPath ); } } } return schema; }) as Promise; }, getOptions() { return db!.$syncState.get('options') as Promise< DexieCloudOptions | undefined >; }, setInitiallySynced(value) { initiallySynced = value; }, reconfigure() { syncStateChangedEvent = new BroadcastedAndLocalEvent( `syncstatechanged-${dx.name}` ); syncCompleteEvent = new BroadcastedAndLocalEvent( `synccomplete-${dx.name}` ); }, }; Object.assign(db, helperMethods); db.messageConsumer = MessagesFromServerConsumer(db); db.messageProducer = new Subject(); db.blobDownloadTracker = new BlobDownloadTracker(db); wm.set(dx, db); } return db; } function nameFromKeyPath (keyPath?: string | string[]): string { return typeof keyPath === 'string' ? keyPath : keyPath ? ('[' + [].join.call(keyPath, '+') + ']') : ""; } ================================================ FILE: addons/dexie-cloud/src/db/entities/BaseRevisionMapEntry.ts ================================================ // This interface has been moved to dexie-cloud-common. TODO: Remove file and update imports. export interface BaseRevisionMapEntry { tableName: string; clientRev: number; serverRev: any; } ================================================ FILE: addons/dexie-cloud/src/db/entities/EntityCommon.ts ================================================ export interface EntityCommon { realmId?: string; owner?: string; $ts?: string; _hasBlobRefs?: 1; // Indicates that the entity has unresolved BlobRefs } ================================================ FILE: addons/dexie-cloud/src/db/entities/GuardedJob.ts ================================================ export interface GuardedJob { nodeId: string; started: Date; heartbeat: Date; } ================================================ FILE: addons/dexie-cloud/src/db/entities/Member.ts ================================================ export interface Member { id?: string; // Auto-generated universal primary key realmId: string; userId?: string; // User identity. Set by the system when user accepts invite. email?: string; // The email of the requested user (for invites). name?: string; // The name of the requested user (for invites). invite?: boolean; invited?: Date; accepted?: Date; rejected?: Date; roles?: string[]; // Array of role names for this user. permissions?: { add?: string[] | "*"; // array of tables or "*" (all). update?: { [tableName: string]: string[] | "*"; // array of properties or "*" (all). }; manage?: string[] | "*"; // array of tables or "*" (all). }; } ================================================ FILE: addons/dexie-cloud/src/db/entities/PersistedSyncState.ts ================================================ export interface PersistedSyncState { serverRevision?: any; yServerRevision?: string; latestRevisions: { [tableName: string]: number }; realms: string[]; inviteRealms: string[]; clientIdentity: string; initiallySynced?: boolean; remoteDbId?: string; syncedTables: string[]; timestamp?: Date; error?: string; yDownloadedRealms?: { [realmId: string]: "*" | { tbl: string; prop: string; key: any; } } } ================================================ FILE: addons/dexie-cloud/src/db/entities/Realm.ts ================================================ export interface Realm { /** Primary key of the realm. */ realmId: string; /** The name of the realm. * * This property is optional but it can be a good practice to name a realm for what it represents. */ name?: string; /** Contains the user-ID of the owner. An owner has implicit full write-access to the realm * and all obejcts connected to it. */ owner?: string; } ================================================ FILE: addons/dexie-cloud/src/db/entities/Role.ts ================================================ export interface Role { realmId: string; name: string; permissions: { add?: string[] | "*"; // array of tables or "*" (all). update?: { [tableName: string]: string[] | "*"; // array of properties or "*" (all). }; manage?: string[] | "*"; // array of tables or "*" (all). }; } ================================================ FILE: addons/dexie-cloud/src/db/entities/UserLogin.ts ================================================ import { DXCUserInteraction } from "../../types/DXCUserInteraction"; export interface UserLogin { userId?: string; name?: string; email?: string; claims: { [claimName: string]: any; } license?: { type: 'demo' | 'eval' | 'prod' | 'client'; status: 'ok' | 'expired' | 'deactivated'; validUntil?: Date; evalDaysLeft?: number; } lastLogin: Date; accessToken?: string; accessTokenExpiration?: Date; refreshToken?: string; refreshTokenExpiration?: Date; nonExportablePrivateKey?: CryptoKey; publicKey?: CryptoKey; isLoggedIn?: boolean; data?: any; // From user data isLoading?: boolean; // true while we still are loading user info } ================================================ FILE: addons/dexie-cloud/src/default-ui/Dialog.tsx ================================================ import { Styles } from './Styles'; import { ComponentChildren, h } from 'preact'; export function Dialog({ children, className }: { children?: ComponentChildren, className?: string }) { return (
{children}
); } ================================================ FILE: addons/dexie-cloud/src/default-ui/LoginDialog.tsx ================================================ import { Dialog } from './Dialog'; import { Styles } from './Styles'; import { h, Fragment } from 'preact'; import { useLayoutEffect, useRef, useState } from 'preact/hooks'; import { DXCUserInteraction, DXCOption } from '../types/DXCUserInteraction'; import { resolveText } from '../helpers/resolveText'; import { DXCInputField } from '../types/DXCInputField'; import { OptionButton, Divider } from './OptionButton'; import { DXCAlert } from '../types/DXCAlert'; const OTP_LENGTH = 8; /** Props for LoginDialog - accepts any user interaction */ interface LoginDialogProps { title: string; alerts: DXCAlert[]; fields: { [name: string]: DXCInputField }; options?: DXCOption[]; submitLabel?: string; cancelLabel?: string | null; onSubmit: (params: { [paramName: string]: string }) => void; onCancel: () => void; } /** * Generic dialog that can render: * - Form fields (text inputs) * - Selectable options (buttons) * - Or both together * * When an option is clicked, calls onSubmit({ [option.name]: option.value }). * This unified approach means the same callback handles both form submission * and option selection. */ export function LoginDialog({ title, alerts, fields, options, submitLabel, cancelLabel, onCancel, onSubmit, }: LoginDialogProps) { const [params, setParams] = useState<{ [param: string]: string }>({}); const firstFieldRef = useRef(null); useLayoutEffect(() => firstFieldRef.current?.focus(), []); const fieldEntries = Object.entries(fields || {}) as [string, DXCInputField][]; const hasFields = fieldEntries.length > 0; const hasOptions = options && options.length > 0; // Group options by name to detect if we have multiple groups const optionGroups = new Map(); if (options) { for (const option of options) { const group = optionGroups.get(option.name) || []; group.push(option); optionGroups.set(option.name, group); } } const hasMultipleGroups = optionGroups.size > 1; // Handler for option clicks - calls onSubmit with { [option.name]: option.value } const handleOptionClick = (option: DXCOption) => { onSubmit({ [option.name]: option.value }); }; return ( <>

{title}

{alerts.map((alert, idx) => (

{resolveText(alert)}

{alert.copyText && }
))} {/* Render options if present */} {hasOptions && (
{hasMultipleGroups ? ( // Render with dividers between groups Array.from(optionGroups.entries()).map(([groupName, groupOptions], groupIdx) => ( {groupIdx > 0 && } {groupOptions!.map((option) => ( handleOptionClick(option)} /> ))} )) ) : ( // Simple case: all options in one group options!.map((option) => ( handleOptionClick(option)} /> )) )}
)} {/* Divider between options and fields if both are present */} {hasOptions && hasFields && } {/* Render form fields if present */} {hasFields && (
{ ev.preventDefault(); onSubmit(params); }} > {fieldEntries.map( ([fieldName, { type, label, placeholder }], idx) => ( ) )}
)}
<> {/* Show submit button if there are fields, OR if there are no options and no fields (e.g., message alert) */} {submitLabel && (hasFields || (!hasOptions && !hasFields)) && ( )} {cancelLabel && ( )}
); } function valueTransformer(type: string, value: string) { switch (type) { case 'email': return value.toLowerCase(); case 'otp': return value.toUpperCase(); default: return value; } } function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const timeoutRef = useRef | null>(null); // Cleanup timeout on unmount useLayoutEffect(() => { return () => { if (timeoutRef.current !== null) clearTimeout(timeoutRef.current); }; }, []); const scheduleCopiedReset = () => { if (timeoutRef.current !== null) clearTimeout(timeoutRef.current); setCopied(true); timeoutRef.current = setTimeout(() => { timeoutRef.current = null; setCopied(false); }, 2000); }; const handleClick = () => { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { navigator.clipboard.writeText(text).then(scheduleCopiedReset).catch(() => { fallbackCopy(text, scheduleCopiedReset); }); } else { fallbackCopy(text, scheduleCopiedReset); } }; return ( ); } function fallbackCopy(text: string, onSuccess: () => void) { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); const success = document.execCommand('copy'); document.body.removeChild(textarea); if (success) onSuccess(); } ================================================ FILE: addons/dexie-cloud/src/default-ui/OptionButton.tsx ================================================ import { h } from 'preact'; import { DXCOption } from '../types/DXCUserInteraction'; import { Styles } from './Styles'; export interface OptionButtonProps { option: DXCOption; onClick: () => void; } /** Get style based on styleHint (for provider branding, etc.) */ function getOptionStyle(styleHint?: string): Record { const baseStyle = { ...Styles.ProviderButton }; if (!styleHint) { return baseStyle; } switch (styleHint) { case 'google': return { ...baseStyle, ...Styles.ProviderGoogle }; case 'github': return { ...baseStyle, ...Styles.ProviderGitHub }; case 'microsoft': return { ...baseStyle, ...Styles.ProviderMicrosoft }; case 'apple': return { ...baseStyle, ...Styles.ProviderApple }; case 'otp': return { ...Styles.OtpButton }; case 'custom-oauth2': return { ...baseStyle, ...Styles.ProviderCustom }; default: return baseStyle; } } /** * Generic button component for selectable options. * Displays the option's icon and display name. * * Style is determined by the styleHint property for branding purposes. */ export function OptionButton({ option, onClick }: OptionButtonProps) { const { displayName, iconUrl, styleHint } = option; const style = getOptionStyle(styleHint); return ( ); } /** * Visual divider with "or" text. */ export function Divider() { return (
or
); } ================================================ FILE: addons/dexie-cloud/src/default-ui/Styles.ts ================================================ export const Styles: { [styleAlias: string]: Partial | any} = { Error: { color: "red", }, Alert: { error: { color: "red", fontWeight: "bold" }, warning: { color: "#f80", fontWeight: "bold" }, info: { color: "black" } }, Darken: { position: "fixed", top: 0, left: 0, opacity: 0.5, backgroundColor: "#000", width: "100vw", height: "100vh", zIndex: 150, webkitBackdropFilter: "blur(2px)", backdropFilter: "blur(2px)", }, DialogOuter: { position: "fixed", top: 0, left: 0, width: "100vw", height: "100vh", zIndex: 150, alignItems: "center", display: "flex", justifyContent: "center", padding: "16px", boxSizing: "border-box" }, DialogInner: { position: "relative", color: "#222", backgroundColor: "#fff", padding: "24px", marginBottom: "2em", maxWidth: "400px", width: "100%", maxHeight: "90%", overflowY: "auto", border: "3px solid #3d3d5d", borderRadius: "8px", boxShadow: "0 0 80px 10px #666", fontFamily: "sans-serif", boxSizing: "border-box" }, Input: { height: "35px", width: "100%", maxWidth: "100%", borderColor: "#ccf4", outline: "none", fontSize: "16px", padding: "8px", boxSizing: "border-box", backgroundColor: "#f9f9f9", borderRadius: "4px", border: "1px solid #ccc", marginTop: "6px", fontFamily: "inherit" }, Button: { padding: "10px 20px", margin: "0 4px", border: "1px solid #d1d5db", borderRadius: "6px", backgroundColor: "#ffffff", cursor: "pointer", fontSize: "14px", fontWeight: "500", color: "#374151", transition: "all 0.2s ease" }, PrimaryButton: { padding: "10px 20px", margin: "0 4px", border: "1px solid #3b82f6", borderRadius: "6px", backgroundColor: "#3b82f6", color: "white", cursor: "pointer", fontSize: "14px", fontWeight: "500", transition: "all 0.2s ease" }, ButtonsDiv: { display: "flex", justifyContent: "flex-end", gap: "12px", marginTop: "24px", paddingTop: "20px" }, Label: { display: "block", marginBottom: "12px", fontSize: "14px", fontWeight: "500", color: "#333" }, WindowHeader: { margin: "0 0 20px 0", fontSize: "18px", fontWeight: "600", color: "#333", borderBottom: "1px solid #eee", paddingBottom: "10px" }, // OAuth Provider Button Styles ProviderButton: { display: "flex", alignItems: "center", justifyContent: "center", width: "100%", padding: "12px 16px", marginBottom: "10px", border: "1px solid #d1d5db", borderRadius: "6px", backgroundColor: "#ffffff", cursor: "pointer", fontSize: "14px", fontWeight: "500", color: "#374151", transition: "all 0.2s ease", gap: "12px" }, ProviderButtonIcon: { width: "20px", height: "20px", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }, ProviderButtonText: { flex: 1, textAlign: "left" as const }, // Provider-specific colors ProviderGoogle: { backgroundColor: "#ffffff", border: "1px solid #dadce0", color: "#3c4043" }, ProviderGitHub: { backgroundColor: "#ffffff", border: "1px solid #dadce0", color: "#181717" }, ProviderMicrosoft: { backgroundColor: "#ffffff", border: "1px solid #dadce0", color: "#5e5e5e" }, ProviderApple: { backgroundColor: "#000000", border: "1px solid #000000", color: "#ffffff" }, ProviderCustom: { backgroundColor: "#ffffff", border: "1px solid #dadce0", color: "#181717" }, // Divider styles Divider: { display: "flex", alignItems: "center", margin: "20px 0", color: "#6b7280", fontSize: "13px" }, DividerLine: { flex: 1, height: "1px", backgroundColor: "#e5e7eb" }, DividerText: { padding: "0 12px", color: "#9ca3af" }, // OTP Button (Continue with email) OtpButton: { display: "flex", alignItems: "center", justifyContent: "center", width: "100%", padding: "12px 16px", border: "1px solid #d1d5db", borderRadius: "6px", backgroundColor: "#f9fafb", cursor: "pointer", fontSize: "14px", fontWeight: "500", color: "#374151", transition: "all 0.2s ease", gap: "12px" }, // Copy button for alerts with copyText CopyButton: { display: "inline-flex", alignItems: "center", gap: "4px", padding: "4px 10px", marginTop: "8px", border: "1px solid #d1d5db", borderRadius: "4px", backgroundColor: "#f9fafb", cursor: "pointer", fontSize: "12px", fontWeight: "500", color: "#374151", transition: "all 0.15s ease", fontFamily: "monospace" }, CopyButtonCopied: { display: "inline-flex", alignItems: "center", gap: "4px", padding: "4px 10px", marginTop: "8px", border: "1px solid #22c55e", borderRadius: "4px", backgroundColor: "#f0fdf4", cursor: "default", fontSize: "12px", fontWeight: "500", color: "#16a34a", fontFamily: "monospace" }, // Cancel button for provider selection CancelButtonRow: { display: "flex", justifyContent: "center", marginTop: "16px" } }; ================================================ FILE: addons/dexie-cloud/src/default-ui/index.tsx ================================================ import Dexie from "dexie"; import "../extend-dexie-interface"; import { h, Component } from "preact"; import { from, Subscription } from "rxjs"; import { LoginDialog } from './LoginDialog'; import { DXCUserInteraction } from "../types/DXCUserInteraction"; import * as preact from "preact"; export interface Props { db: Dexie; } interface State { userInteraction: DXCUserInteraction | undefined; } export default class LoginGui extends Component { subscription?: Subscription; observer = (userInteraction: DXCUserInteraction | undefined) => this.setState({userInteraction}); constructor(props: Props) { super(props); this.state = { userInteraction: undefined }; } componentDidMount() { this.subscription = from(this.props.db.cloud.userInteraction).subscribe(this.observer); } componentWillUnmount() { if (this.subscription) { this.subscription.unsubscribe(); delete this.subscription; } } render(props: Props, {userInteraction}: State) { if (!userInteraction) return null; // LoginDialog handles all interaction types uniformly // (forms with fields, options, or both) return ; } } export function setupDefaultGUI(db: Dexie) { let closed = false; const el = document.createElement('div'); if (document.body) { document.body.appendChild(el); preact.render(, el); } else { addEventListener('DOMContentLoaded', ()=>{ if (!closed) { document.body.appendChild(el); preact.render(, el); } }); } return { unsubscribe() { try { el.remove(); } catch {} closed = true; }, get closed() { return closed; } } } ================================================ FILE: addons/dexie-cloud/src/define-ydoc-trigger.ts ================================================ import Dexie, { RangeSet, type DBCore, type Middleware, type Table, } from 'dexie'; import { DexieYProvider, YUpdateRow } from 'y-dexie'; import type { Doc as YjsDoc } from 'yjs'; const ydocTriggers: { [ydocTable: string]: { trigger: (ydoc: YjsDoc, parentId: any) => any; parentTable: string; prop: string; }; } = {}; const middlewares = new WeakMap>(); let subscribedToProviderBeforeUnload = false; const txRunner = TriggerRunner("tx"); // Trigger registry for transaction completion. Avoids open docs. const unloadRunner = TriggerRunner("unload"); // Trigger registry for unload. Runs when a document is closed. type TriggerRegistry = Map< string, { db: Dexie; parentTable: string; parentId: any; prop: string; triggers: Set<(ydoc: YjsDoc, parentId: any) => any>; } >; function TriggerRunner(name: string) { let triggerExecPromise: Promise | null = null; let triggerScheduled = false; let registry: TriggerRegistry = new Map< string, { db: Dexie; parentTable: string; parentId: any; prop: string; triggers: Set<(ydoc: YjsDoc, parentId: any) => any>; } >(); async function execute(registryCopy: TriggerRegistry) { for (const { db, parentId, triggers, parentTable, prop, } of registryCopy.values()) { const yDoc = DexieYProvider.getOrCreateDocument( db, parentTable, prop, parentId ); try { const provider = DexieYProvider.load(yDoc); // If doc is open, this would just be a ++refount await provider.whenLoaded; // If doc is loaded, this would resolve immediately for (const trigger of triggers) { await trigger(yDoc, parentId); } } catch (error) { if (error?.name === 'AbortError') { // Ignore abort errors. They are expected when the document is closed. } else { console.error(`Error in YDocTrigger ${error}`); } } finally { DexieYProvider.release(yDoc); } } } return { name, async run() { console.log(`Running trigger (${name})?`, triggerScheduled, registry.size, !!triggerExecPromise); if (!triggerScheduled && registry.size > 0) { triggerScheduled = true; if (triggerExecPromise) await triggerExecPromise.catch(() => {}); setTimeout(() => { // setTimeout() is to escape from Promise.PSD zones and never run within liveQueries or transaction scopes console.log("Running trigger really!", name); triggerScheduled = false; const registryCopy = registry; registry = new Map(); triggerExecPromise = execute(registryCopy).finally( () => { triggerExecPromise = null; } ); }, 0); } }, enqueue( db: Dexie, parentTable: string, parentId: any, prop: string, trigger: (ydoc: YjsDoc, parentId: any) => any ) { const key = `${db.name}:${parentTable}:${parentId}:${prop}`; let entry = registry.get(key); if (!entry) { entry = { db, parentTable, parentId, prop, triggers: new Set(), }; console.log(`Adding trigger ${key}`); registry.set(key, entry); } entry.triggers.add(trigger); }, }; } const createMiddleware: (db: Dexie) => Middleware = (db) => ({ stack: 'dbcore', level: 10, name: 'yTriggerMiddleware', create: (down) => { return { ...down, transaction: (stores, mode, options) => { const idbtrans = down.transaction(stores, mode, options); if (mode === 'readonly') return idbtrans; if (!stores.some((store) => ydocTriggers[store])) return idbtrans; (idbtrans as IDBTransaction).addEventListener( 'complete', onTransactionCommitted ); return idbtrans; }, table: (updatesTable) => { const coreTable = down.table(updatesTable); const triggerSpec = ydocTriggers[updatesTable]; if (!triggerSpec) return coreTable; const { trigger, parentTable, prop } = triggerSpec; return { ...coreTable, mutate(req) { switch (req.type) { case 'add': { for (const yUpdateRow of req.values) { if (yUpdateRow.k == undefined) continue; // A syncer or garbage collection state does not point to a key const primaryKey = (yUpdateRow as YUpdateRow).k; const doc = DexieYProvider.getDocCache(db).find( parentTable, primaryKey, prop ); const runner = doc && DexieYProvider.for(doc)?.refCount ? unloadRunner // Document is open. Wait with trigger until it's closed. : txRunner; // Document is closed. Run trigger immediately after transaction commits. runner.enqueue(db, parentTable, primaryKey, prop, trigger); } break; } case 'delete': // @ts-ignore if (req.trans._rejecting_y_ypdate) { // The deletion came from a rejection, not garbage collection. // When that happens, let the triggers run to compute new values // based on the deleted updates. coreTable .getMany({ keys: req.keys, trans: req.trans, cache: 'immutable', }) .then((updates) => { const keySet = new RangeSet(); for (const { k } of updates as YUpdateRow[]) { if (k != undefined) keySet.addKey(k); } for (const interval of keySet) { txRunner.enqueue( db, parentTable, interval.from, prop, trigger ); } }); } break; } return coreTable.mutate(req); }, }; }, }; }, }); function onTransactionCommitted() { txRunner.run(); } function beforeProviderUnload() { unloadRunner.run(); } export function defineYDocTrigger( table: Table, prop: keyof T & string, trigger: (ydoc: YjsDoc, parentId: TKey) => any ) { const updatesTable = table.schema.yProps?.find( (p) => p.prop === prop )?.updatesTable; if (!updatesTable) throw new Error( `Table ${table.name} does not have a Yjs property named ${prop}` ); ydocTriggers[updatesTable] = { trigger, parentTable: table.name, prop, }; const db = table.db._novip; let mw = middlewares.get(db); if (!mw) { mw = createMiddleware(db); middlewares.set(db, mw); } db.use(mw); if (!subscribedToProviderBeforeUnload) { DexieYProvider.on('beforeunload', beforeProviderUnload); } } ================================================ FILE: addons/dexie-cloud/src/dexie-cloud-addon.ts ================================================ import dexieCloudAddon from './dexie-cloud-client'; export * from './dexie-cloud-client'; export default dexieCloudAddon; ================================================ FILE: addons/dexie-cloud/src/dexie-cloud-client.ts ================================================ import Dexie, { liveQuery, Subscription, Table } from 'dexie'; import { DBPermissionSet, DBRealmMember, getDbNameFromDbUrl, } from 'dexie-cloud-common'; import { BehaviorSubject, combineLatest, firstValueFrom, from, fromEvent, Subject } from 'rxjs'; import { createDownloadingState, observeBlobProgress } from './sync/blobProgress'; import { downloadUnresolvedBlobs } from './sync/eagerBlobDownloader'; import { filter, map, skip, startWith, switchMap, take } from 'rxjs/operators'; import { login } from './authentication/login'; import { UNAUTHORIZED_USER } from './authentication/UNAUTHORIZED_USER'; import { DexieCloudDB } from './db/DexieCloudDB'; import { PersistedSyncState } from './db/entities/PersistedSyncState'; import { DexieCloudOptions } from './DexieCloudOptions'; import { DISABLE_SERVICEWORKER_STRATEGY } from './DISABLE_SERVICEWORKER_STRATEGY'; import './extend-dexie-interface'; import { DexieCloudSyncOptions } from './DexieCloudSyncOptions'; import { IS_SERVICE_WORKER } from './helpers/IS_SERVICE_WORKER'; import { throwVersionIncrementNeeded } from './helpers/throwVersionIncrementNeeded'; import { createIdGenerationMiddleware } from './middlewares/createIdGenerationMiddleware'; import { createImplicitPropSetterMiddleware } from './middlewares/createImplicitPropSetterMiddleware'; import { createMutationTrackingMiddleware } from './middlewares/createMutationTrackingMiddleware'; import { createBlobResolveMiddleware } from './middlewares/blobResolveMiddleware'; //import { dexieCloudSyncProtocol } from "./dexieCloudSyncProtocol"; import { overrideParseStoresSpec } from './overrideParseStoresSpec'; import { performInitialSync } from './performInitialSync'; import { connectWebSocket } from './sync/connectWebSocket'; import { isSyncNeeded } from './sync/isSyncNeeded'; import { LocalSyncWorker } from './sync/LocalSyncWorker'; import { registerPeriodicSyncEvent, registerSyncEvent, } from './sync/registerSyncEvent'; import { triggerSync } from './sync/triggerSync'; import { DXCUserInteraction } from './types/DXCUserInteraction'; import { SyncState } from './types/SyncState'; import { updateSchemaFromOptions } from './updateSchemaFromOptions'; import { verifySchema } from './verifySchema'; import { setupDefaultGUI } from './default-ui'; import { DXCWebSocketStatus } from './DXCWebSocketStatus'; import { computeSyncState } from './computeSyncState'; import { generateKey } from './middleware-helpers/idGenerationHelpers'; import { permissions } from './permissions'; import { getCurrentUserEmitter } from './currentUserEmitter'; import { NewIdOptions } from './types/NewIdOptions'; import { getInvitesObservable } from './getInvitesObservable'; import { getGlobalRolesObservable } from './getGlobalRolesObservable'; import { UserLogin } from './db/entities/UserLogin'; import { InvalidLicenseError } from './InvalidLicenseError'; import { logout, _logout } from './authentication/logout'; import { loadAccessToken } from './authentication/authenticate'; import { isEagerSyncDisabled } from './isEagerSyncDisabled'; import { createYHandler } from "./yjs/createYHandler"; import { DexieYProvider } from 'y-dexie'; import { parseOAuthCallback, cleanupOAuthUrl } from './authentication/handleOAuthCallback'; import { OAuthError } from './errors/OAuthError'; import { alertUser } from './authentication/interactWithUser'; import { fetchAuthProviders } from './authentication/fetchAuthProviders'; export { DexieCloudTable } from './DexieCloudTable'; export * from './getTiedRealmId'; export { DBRealm, DBRealmMember, DBRealmRole, DBSyncedObject, DBPermissionSet, AuthProvidersResponse, OAuthProviderInfo, } from 'dexie-cloud-common'; export { resolveText } from './helpers/resolveText'; export { Invite } from './Invite'; export type { UserLogin, DXCWebSocketStatus, SyncState }; export type { DexieCloudSyncOptions }; export type { DexieCloudOptions, PeriodicSyncOptions } from './DexieCloudOptions'; export * from './types/DXCAlert'; export * from './types/DXCInputField'; export * from './types/DXCUserInteraction'; export { defineYDocTrigger } from './define-ydoc-trigger'; const DEFAULT_OPTIONS: Partial = { nameSuffix: true, }; export function dexieCloud(dexie: Dexie) { const origIdbName = dexie.name; // // // const currentUserEmitter = getCurrentUserEmitter(dexie); const subscriptions: Subscription[] = []; let configuredProgramatically = false; // Pending OAuth auth code from dxc-auth redirect (detected in configure()) let pendingOAuthCode: { code: string; provider: string } | null = null; // Pending OAuth error from dxc-auth redirect (detected in configure()) let pendingOAuthError: OAuthError | null = null; // local sync worker - used when there's no service worker. let localSyncWorker: { start: () => void; stop: () => void } | null = null; dexie.on( 'ready', async (dexie: Dexie) => { try { await onDbReady(dexie); } catch (error) { console.error(error); // Make sure to succeed with database open even if network is down. } }, true // true = sticky ); /** Void starting subscribers after a close has happened. */ let closed = false; function throwIfClosed() { if (closed) throw new Dexie.DatabaseClosedError(); } dexie.once('close', () => { subscriptions.forEach((subscription) => subscription.unsubscribe()); subscriptions.splice(0, subscriptions.length); closed = true; localSyncWorker && localSyncWorker.stop(); localSyncWorker = null; currentUserEmitter.next(UNAUTHORIZED_USER); }); const syncComplete = new Subject(); const downloading$ = createDownloadingState(); dexie.cloud = { // @ts-ignore version: __VERSION__, options: { ...DEFAULT_OPTIONS } as DexieCloudOptions, schema: null, get currentUserId() { return currentUserEmitter.value.userId || UNAUTHORIZED_USER.userId!; }, currentUser: currentUserEmitter, syncState: new BehaviorSubject({ phase: 'initial', status: 'not-started', }), events: { syncComplete, }, persistedSyncState: new BehaviorSubject( undefined ), blobProgress: observeBlobProgress(DexieCloudDB(dexie), downloading$), userInteraction: new BehaviorSubject( undefined ), webSocketStatus: new BehaviorSubject('not-started'), async login(hint) { const db = DexieCloudDB(dexie); await db.cloud.sync(); await login(db, hint); }, invites: getInvitesObservable(dexie), roles: getGlobalRolesObservable(dexie), configure(options: DexieCloudOptions) { // Validate maxStringLength — Infinity disables offloading, otherwise must be // a finite number between 100 and the server limit (32768). // Minimum 100 prevents accidental offloading of primary keys and short strings // that would break sync. const MIN_STRING_LENGTH = 100; const MAX_SERVER_STRING_LENGTH = 32768; if ( options.maxStringLength !== undefined && options.maxStringLength !== Infinity && (!Number.isFinite(options.maxStringLength) || options.maxStringLength < MIN_STRING_LENGTH || options.maxStringLength > MAX_SERVER_STRING_LENGTH) ) { throw new Error( `maxStringLength must be Infinity or a finite number in [${MIN_STRING_LENGTH}, ${MAX_SERVER_STRING_LENGTH}]. Got: ${options.maxStringLength}` ); } options = dexie.cloud.options = { ...dexie.cloud.options, ...options }; configuredProgramatically = true; if (options.databaseUrl && options.nameSuffix) { // @ts-ignore dexie.name = `${origIdbName}-${getDbNameFromDbUrl( options.databaseUrl )}`; DexieCloudDB(dexie).reconfigure(); // Update observable from new dexie.name } updateSchemaFromOptions(dexie.cloud.schema, dexie.cloud.options); // Check for OAuth callback (dxc-auth query parameter) // Only check in DOM environment, not workers if (typeof window !== 'undefined' && window.location) { try { const callback = parseOAuthCallback(); if (callback) { // Store the pending auth code for processing when db is ready pendingOAuthCode = { code: callback.code, provider: callback.provider }; console.debug('[dexie-cloud] OAuth callback detected, auth code stored for processing'); } } catch (error) { // parseOAuthCallback throws OAuthError on error callbacks if (error instanceof OAuthError) { pendingOAuthError = error; console.error('[dexie-cloud] OAuth callback error:', error.message); } else { console.error('[dexie-cloud] OAuth callback error:', error); } } } }, async logout({ force } = {}) { force ? await _logout(DexieCloudDB(dexie), { deleteUnsyncedData: true }) : await logout(DexieCloudDB(dexie)); }, async getAuthProviders() { const options = dexie.cloud.options; if (!options?.databaseUrl) { throw new Error('Dexie Cloud not configured. Call db.cloud.configure() first.'); } const socialAuthEnabled = options.socialAuth !== false; return fetchAuthProviders(options.databaseUrl, socialAuthEnabled); }, async sync( { wait, purpose }: DexieCloudSyncOptions = { wait: true, purpose: 'push' } ) { if (wait === undefined) wait = true; const db = DexieCloudDB(dexie); const licenseStatus = db.cloud.currentUser.value.license?.status || 'ok'; if (licenseStatus !== 'ok') { // Refresh access token to check for updated license await loadAccessToken(db); } if (purpose === 'pull') { const syncState = db.cloud.persistedSyncState.value; triggerSync(db, purpose); if (wait) { const newSyncState = await firstValueFrom( db.cloud.persistedSyncState.pipe( filter( (newSyncState) => newSyncState?.timestamp != null && (!syncState || newSyncState.timestamp > syncState.timestamp!) ) ) ); if (newSyncState?.error) { throw new Error(`Sync error: ` + newSyncState.error); } } } else if (await isSyncNeeded(db)) { const syncState = db.cloud.persistedSyncState.value; triggerSync(db, purpose); if (wait) { console.debug('db.cloud.login() is waiting for sync completion...'); await firstValueFrom( from( liveQuery(async () => { const syncNeeded = await isSyncNeeded(db); const newSyncState = await db.getPersistedSyncState(); if ( newSyncState?.timestamp !== syncState?.timestamp && newSyncState?.error ) throw new Error(`Sync error: ` + newSyncState.error); return syncNeeded; }) ).pipe(filter((isNeeded) => !isNeeded)) ); console.debug( 'Done waiting for sync completion because we have nothing to push anymore' ); } } }, permissions( obj: { owner: string; realmId: string; table?: () => string }, tableName?: string ) { return permissions(dexie._novip, obj, tableName); }, }; dexie.Version.prototype['_parseStoresSpec'] = Dexie.override( dexie.Version.prototype['_parseStoresSpec'], (origFunc) => overrideParseStoresSpec(origFunc, dexie) ); dexie.Table.prototype.newId = function ( this: Table, { colocateWith }: NewIdOptions = {} ) { const shardKey = colocateWith && colocateWith.substr(colocateWith.length - 3); return generateKey(dexie.cloud.schema![this.name].idPrefix || '', shardKey); }; dexie.Table.prototype.idPrefix = function (this: Table) { return this.db.cloud.schema?.[this.name]?.idPrefix || ''; }; dexie.use(createBlobResolveMiddleware(DexieCloudDB(dexie))); dexie.use( createMutationTrackingMiddleware({ currentUserObservable: dexie.cloud.currentUser, db: DexieCloudDB(dexie), }) ); dexie.use(createImplicitPropSetterMiddleware(DexieCloudDB(dexie))); dexie.use(createIdGenerationMiddleware(DexieCloudDB(dexie))); async function onDbReady(dexie: Dexie) { closed = false; // As Dexie calls us, we are not closed anymore. Maybe reopened? Remember db.ready event is registered with sticky flag! const db = DexieCloudDB(dexie); // Setup default GUI: if (typeof window !== 'undefined' && typeof document !== 'undefined') { if (!db.cloud.options?.customLoginGui) { subscriptions.push(setupDefaultGUI(dexie)); } } if (!db.cloud.isServiceWorkerDB) { subscriptions.push(computeSyncState(db).subscribe(dexie.cloud.syncState)); } // Forward db.syncCompleteEvent to be publicly consumable via db.cloud.events.syncComplete: subscriptions.push(db.syncCompleteEvent.subscribe(syncComplete)); // Eager blob download: When blobMode='eager' (default), download unresolved blobs after sync const blobMode = db.cloud.options?.blobMode ?? 'eager'; if (blobMode === 'eager') { let eagerBlobDownloadInFlight: Promise | null = null; const downloadBlobs = () => { if (eagerBlobDownloadInFlight) return; eagerBlobDownloadInFlight = Dexie.ignoreTransaction( () => downloadUnresolvedBlobs(db, downloading$) ) .catch(err => { console.error('[dexie-cloud] Eager blob download failed:', err); }) .finally(() => { eagerBlobDownloadInFlight = null; }); }; setTimeout(downloadBlobs, 0); // Don't block ready event. Start downloading blobs in the background right after. // And also after every sync completes: subscriptions.push(db.syncCompleteEvent.subscribe(downloadBlobs)); } //verifyConfig(db.cloud.options); Not needed (yet at least!) // Verify the user has allowed version increment. if (!db.tables.every((table) => table.core)) { throwVersionIncrementNeeded(); } const swRegistrations = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistrations() : []; const [initiallySynced, lastSyncedRealms] = await db.transaction( 'rw', db.$syncState, async () => { const { options, schema } = db.cloud; const [persistedOptions, persistedSchema, persistedSyncState] = await Promise.all([ db.getOptions(), db.getSchema(), db.getPersistedSyncState(), ]); if (!configuredProgramatically) { // Options not specified programatically (use case for SW!) // Take persisted options: db.cloud.options = persistedOptions || null; } else if ( !persistedOptions || JSON.stringify(persistedOptions) !== JSON.stringify(options) ) { // Update persisted options: if (!options) throw new Error(`Internal error`); // options cannot be null if configuredProgramatically is set. const newPersistedOptions: DexieCloudOptions = { ...options, }; delete newPersistedOptions.fetchTokens; delete newPersistedOptions.awarenessProtocol; await db.$syncState.put(newPersistedOptions, 'options'); } if ( db.cloud.options?.tryUseServiceWorker && 'serviceWorker' in navigator && swRegistrations.length > 0 && !DISABLE_SERVICEWORKER_STRATEGY ) { // * Configured for using service worker if available. // * Browser supports service workers // * There are at least one service worker registration console.debug('Dexie Cloud Addon: Using service worker'); db.cloud.usingServiceWorker = true; } else { // Not configured for using service worker or no service worker // registration exists. Don't rely on service worker to do any job. // Use LocalSyncWorker instead. if ( db.cloud.options?.tryUseServiceWorker && !db.cloud.isServiceWorkerDB ) { console.debug( 'dexie-cloud-addon: Not using service worker.', swRegistrations.length === 0 ? 'No SW registrations found.' : 'serviceWorker' in navigator && DISABLE_SERVICEWORKER_STRATEGY ? 'Avoiding SW background sync and SW periodic bg sync for this browser due to browser bugs.' : 'navigator.serviceWorker not present' ); } db.cloud.usingServiceWorker = false; } updateSchemaFromOptions(schema, db.cloud.options); updateSchemaFromOptions(persistedSchema, db.cloud.options); if (!schema) { // Database opened dynamically (use case for SW!) // Take persisted schema: db.cloud.schema = persistedSchema || null; } else if ( !persistedSchema || JSON.stringify(persistedSchema) !== JSON.stringify(schema) ) { // Update persisted schema (but don't overwrite table prefixes) const newPersistedSchema = persistedSchema || {}; for (const [table, tblSchema] of Object.entries(schema)) { const newTblSchema = newPersistedSchema[table]; if (!newTblSchema) { newPersistedSchema[table] = { ...tblSchema }; } else { newTblSchema.markedForSync = tblSchema.markedForSync; tblSchema.deleted = newTblSchema.deleted; newTblSchema.generatedGlobalId = tblSchema.generatedGlobalId; } } await db.$syncState.put(newPersistedSchema, 'schema'); // Make sure persisted table prefixes are being used instead of computed ones: // Let's assign all props as the newPersistedSchems should be what we should be working with. Object.assign(schema, newPersistedSchema); } return [persistedSyncState?.initiallySynced, persistedSyncState?.realms]; } ); if (initiallySynced) { db.setInitiallySynced(true); } verifySchema(db); // Manage CurrentUser observable: throwIfClosed(); if (!db.cloud.isServiceWorkerDB) { subscriptions.push( liveQuery(() => db.getCurrentUser().then(user => { if (!user.isLoggedIn && typeof location !== 'undefined' && /dxc-auth\=/.test(location.search)) { // Still loading user because OAuth redirect just happened. // Keep isLoading true. return { ...user, isLoading: true }; } return user; })).subscribe(currentUserEmitter) ); // Manage PersistendSyncState observable: subscriptions.push( liveQuery(() => db.getPersistedSyncState()).subscribe( db.cloud.persistedSyncState ) ); // Wait till currentUser and persistedSyncState gets populated // with things from the database and not just the default values. // This is so that when db.open() completes, user should be safe // to subscribe to these observables and get actual data. await firstValueFrom(combineLatest([ currentUserEmitter.pipe(skip(1), take(1)), db.cloud.persistedSyncState.pipe(skip(1), take(1)), ])); const yHandler = createYHandler(db); DexieYProvider.on.new.subscribe(yHandler); db.dx.once('close', () => { DexieYProvider.on.new.unsubscribe(yHandler); }); } // HERE: If requireAuth, do athentication now. let changedUser = false; let user = await db.getCurrentUser(); // Show pending OAuth error if present (from dxc-auth redirect) if (pendingOAuthError && !db.cloud.isServiceWorkerDB) { const error = pendingOAuthError; pendingOAuthError = null; // Clear pending error console.debug('[dexie-cloud] Showing OAuth error:', error.message); // Show alert to user about the OAuth error // Guard so UI errors don't abort initialization try { await alertUser(db.cloud.userInteraction, 'Authentication Error', { type: 'error', messageCode: 'GENERIC_ERROR', message: error.message, messageParams: { provider: error.provider || 'unknown' } }); // Clean up URL (remove dxc-auth param) cleanupOAuthUrl(); } catch (uiError) { console.error('[dexie-cloud] Failed to show OAuth error alert:', uiError); } } // Process pending OAuth callback if present (from dxc-auth redirect) if (pendingOAuthCode && !db.cloud.isServiceWorkerDB) { const { code, provider } = pendingOAuthCode; pendingOAuthCode = null; // Clear pending code console.debug('[dexie-cloud] Processing OAuth callback, provider:', provider); try { changedUser = await login(db, { oauthCode: code, provider }); user = await db.getCurrentUser(); // Clean up URL (remove dxc-auth param) cleanupOAuthUrl(); } catch (error) { console.error('[dexie-cloud] OAuth login failed:', error); // Continue with normal flow - user can try again } } const requireAuth = db.cloud.options?.requireAuth; if (requireAuth) { if (db.cloud.isServiceWorkerDB) { // If this is a service worker DB, we can't do authentication here, // we just wait until the application has done it. console.debug('Dexie Cloud Service worker. Waiting for application to authenticate.'); await firstValueFrom(currentUserEmitter.pipe(filter((user) => !!user.isLoggedIn), take(1))); console.debug('Dexie Cloud Service worker. Application has authenticated.'); } else { if (typeof requireAuth === 'object') { // requireAuth contains login hints. Check if we already fulfil it: if ( !user.isLoggedIn || (requireAuth.userId && user.userId !== requireAuth.userId) || (requireAuth.email && user.email !== requireAuth.email) ) { // If not, login the configured user: changedUser = await login(db, requireAuth); } } else if (!user.isLoggedIn) { // requireAuth is true and user is not logged in changedUser = await login(db); } } } if (user.isLoggedIn && (!lastSyncedRealms || !lastSyncedRealms.includes(user.userId!))) { // User has been logged in but this is not reflected in the sync state. // This can happen if page is reloaded after login but before the sync call following // the login was complete. // The user is to be viewed as changed becuase current syncState does not reflect the presence // of the logged-in user. changedUser = true; // Set changedUser to true to trigger a pull-sync later down. } if (localSyncWorker) localSyncWorker.stop(); localSyncWorker = null; throwIfClosed(); const doInitialSync = db.cloud.options?.databaseUrl && (!initiallySynced || changedUser); if (doInitialSync) { // Do the initial sync directly in the browser thread no matter if we are using service worker or not. await performInitialSync(db, db.cloud.options!, db.cloud.schema!); db.setInitiallySynced(true); } throwIfClosed(); if (db.cloud.usingServiceWorker && db.cloud.options?.databaseUrl) { if (!doInitialSync) { registerSyncEvent(db, 'push').catch(() => {}); } registerPeriodicSyncEvent(db).catch(() => {}); } else if ( db.cloud.options?.databaseUrl && db.cloud.schema && !db.cloud.isServiceWorkerDB ) { // There's no SW. Start SyncWorker instead. localSyncWorker = LocalSyncWorker(db, db.cloud.options, db.cloud.schema!); localSyncWorker.start(); if (!doInitialSync) { triggerSync(db, 'push'); } } // Listen to online event and do sync. throwIfClosed(); if (!db.cloud.isServiceWorkerDB) { subscriptions.push( fromEvent(self, 'online').subscribe(() => { console.debug('online!'); db.syncStateChangedEvent.next({ phase: 'not-in-sync', }); if (!isEagerSyncDisabled(db)) { triggerSync(db, 'push'); } }), fromEvent(self, 'offline').subscribe(() => { console.debug('offline!'); db.syncStateChangedEvent.next({ phase: 'offline', }); }) ); } // Connect WebSocket unless we are in a service worker or websocket is disabled. if ( db.cloud.options?.databaseUrl && !db.cloud.options?.disableWebSocket && !IS_SERVICE_WORKER ) { subscriptions.push(connectWebSocket(db)); } } } // @ts-ignore dexieCloud.version = __VERSION__; Dexie.Cloud = dexieCloud; export default dexieCloud; ================================================ FILE: addons/dexie-cloud/src/errors/HttpError.ts ================================================ export class HttpError extends Error { httpStatus: number; constructor( res: Response, message?: string) { super(message || `${res.status} ${res.statusText}`); this.httpStatus = res.status; } get name() { return "HttpError"; } } ================================================ FILE: addons/dexie-cloud/src/errors/OAuthError.ts ================================================ /** OAuth-specific error codes */ export type OAuthErrorCode = | 'access_denied' | 'invalid_state' | 'email_not_verified' | 'expired_code' | 'provider_error' | 'network_error'; /** User-friendly messages for OAuth error codes */ const ERROR_MESSAGES: Record = { access_denied: 'Access was denied by the authentication provider.', invalid_state: 'The authentication response could not be verified. Please try again.', email_not_verified: 'Your email address must be verified before you can log in.', expired_code: 'The authentication code has expired. Please try again.', provider_error: 'An error occurred with the authentication provider.', network_error: 'A network error occurred during authentication. Please check your connection and try again.', }; /** Error class for OAuth-specific errors */ export class OAuthError extends Error { readonly code: OAuthErrorCode; readonly provider?: string; constructor(code: OAuthErrorCode, provider?: string, customMessage?: string) { super(customMessage || ERROR_MESSAGES[code]); this.name = 'OAuthError'; this.code = code; this.provider = provider; } /** Get user-friendly message for this error */ get userMessage(): string { return ERROR_MESSAGES[this.code] || this.message; } } ================================================ FILE: addons/dexie-cloud/src/errors/OAuthRedirectError.ts ================================================ /** * Error thrown when initiating an OAuth redirect. * * This is not a real error - it's used to signal that the page is * navigating away to an OAuth provider. It should be caught and * silently ignored at the appropriate level. */ export class OAuthRedirectError extends Error { readonly provider: string; constructor(provider: string) { super(`OAuth redirect initiated for provider: ${provider}`); this.name = 'OAuthRedirectError'; this.provider = provider; } } ================================================ FILE: addons/dexie-cloud/src/extend-dexie-interface.ts ================================================ import { IndexableType, TableProp } from 'dexie'; import { DBRealm, DBRealmMember, DBRealmRole, } from 'dexie-cloud-common'; import { Member } from './db/entities/Member'; import { Role } from './db/entities/Role'; import { EntityCommon } from './db/entities/EntityCommon'; import { DexieCloudAPI } from './DexieCloudAPI'; import { DexieCloudTable } from './DexieCloudTable'; import { NewIdOptions } from './types/NewIdOptions'; type Optional = Omit & Partial; // // Extend Dexie interface // declare module 'dexie' { interface Dexie { cloud: DexieCloudAPI; realms: Table>; members: Table>; roles: Table>; } interface Table { newId(options: NewIdOptions): string; idPrefix(): string; } interface DexieConstructor { Cloud: { (db: Dexie): void; version: string; }; } } ================================================ FILE: addons/dexie-cloud/src/getGlobalRolesObservable.ts ================================================ import Dexie, { liveQuery } from 'dexie'; import { DBRealmRole } from 'dexie-cloud-common'; import { associate } from './associate'; import { createSharedValueObservable } from './createSharedValueObservable'; export const getGlobalRolesObservable = associate((db: Dexie) => { return createSharedValueObservable( liveQuery(() => db.roles .where({ realmId: 'rlm-public' }) .toArray() .then((roles) => { const rv: { [roleName: string]: DBRealmRole } = {}; for (const role of roles .slice() .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))) { rv[role.name] = role; } return rv; }) ), {} ); }); ================================================ FILE: addons/dexie-cloud/src/getInternalAccessControlObservable.ts ================================================ import Dexie, { liveQuery } from 'dexie'; import { DBRealm, DBRealmMember } from 'dexie-cloud-common'; import { concat, Observable, timer } from 'rxjs'; import { share, switchMap } from 'rxjs/operators'; import { associate } from './associate'; import { createSharedValueObservable } from './createSharedValueObservable'; import { getCurrentUserEmitter } from './currentUserEmitter'; export type InternalAccessControlData = { readonly selfMembers: DBRealmMember[]; readonly realms: DBRealm[]; readonly userId: string; }; export const getInternalAccessControlObservable = associate((db: Dexie) => { return createSharedValueObservable( getCurrentUserEmitter(db._novip).pipe( switchMap((currentUser) => liveQuery(() => db.transaction('r', 'realms', 'members', () => Promise.all([ db.members.where({ userId: currentUser.userId }).toArray(), db.realms.toArray(), currentUser.userId!, ] as const).then(([selfMembers, realms, userId]) => { //console.debug(`PERMS: Result from liveQUery():`, JSON.stringify({selfMembers, realms, userId}, null, 2)) return { selfMembers, realms, userId }; }) ) ) ) ), { selfMembers: [], realms: [], get userId() { return db.cloud.currentUserId; }, } ); /* let refCount = 0; return new Observable(observer => { const subscription = o.subscribe(observer); console.debug ('PERMS subscribe', ++refCount); return { unsubscribe() { console.debug ('PERMS unsubscribe', --refCount); subscription.unsubscribe(); } } })*/ }); ================================================ FILE: addons/dexie-cloud/src/getInvitesObservable.ts ================================================ import { Dexie, liveQuery } from 'dexie'; import { DBRealmMember } from 'dexie-cloud-common'; import { combineLatest } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { associate } from './associate'; import { createSharedValueObservable } from './createSharedValueObservable'; import { getCurrentUserEmitter } from './currentUserEmitter'; import { getInternalAccessControlObservable } from './getInternalAccessControlObservable'; import { getPermissionsLookupObservable } from './getPermissionsLookupObservable'; import { Invite } from './Invite'; import { mapValueObservable } from './mapValueObservable'; export const getInvitesObservable = associate((db: Dexie) => { const membersByEmail = getCurrentUserEmitter(db._novip).pipe( switchMap((currentUser) => liveQuery(() => db.members.where({ email: currentUser.email || '' }).toArray() ) ) ); const permissions = getPermissionsLookupObservable(db._novip); const accessControl = getInternalAccessControlObservable(db._novip); return createSharedValueObservable( combineLatest([membersByEmail, accessControl, permissions]).pipe( map(([membersByEmail, accessControl, realmLookup]) => { const reducer = ( result: { [id: string]: Invite }, m: DBRealmMember ) => ({ ...result, [m.id!]: { ...m, realm: realmLookup[m.realmId] } }); const emailMembersById = membersByEmail.reduce(reducer, {}); const membersById = accessControl.selfMembers.reduce( reducer, emailMembersById ); return Object.values(membersById) .filter((invite: DBRealmMember) => !invite.accepted) .map( (invite: DBRealmMember) => ({ ...invite, async accept() { await db.members.update(invite.id!, { accepted: new Date() }); }, async reject() { await db.members.update(invite.id!, { rejected: new Date() }); }, } satisfies Invite) ); }) ), [] ); }); ================================================ FILE: addons/dexie-cloud/src/getPermissionsLookupObservable.ts ================================================ import Dexie from 'dexie'; import { DBPermissionSet, DBRealm, DBRealmMember } from 'dexie-cloud-common'; import { combineLatest, Observable } from 'rxjs'; import { map, startWith, tap } from 'rxjs/operators'; import { associate } from './associate'; import { UNAUTHORIZED_USER } from './authentication/UNAUTHORIZED_USER'; import { createSharedValueObservable } from './createSharedValueObservable'; import { getGlobalRolesObservable } from './getGlobalRolesObservable'; import { getInternalAccessControlObservable } from './getInternalAccessControlObservable'; import { flatten } from './helpers/flatten'; import { mapValueObservable } from './mapValueObservable'; import { mergePermissions } from './mergePermissions'; export type PermissionsLookup = { [realmId: string]: DBRealm & { permissions: DBPermissionSet }; }; export type PermissionsLookupObservable = Observable & { getValue(): PermissionsLookup; }; export const getPermissionsLookupObservable = associate((db: Dexie) => { const o = createSharedValueObservable( combineLatest([ getInternalAccessControlObservable(db._novip), getGlobalRolesObservable(db._novip), ]).pipe( map(([{ selfMembers, realms, userId }, globalRoles]) => ({ selfMembers, realms, userId, globalRoles, })) ), { selfMembers: [], realms: [], userId: UNAUTHORIZED_USER.userId!, globalRoles: {}, } ); return mapValueObservable( o, ({ selfMembers, realms, userId, globalRoles }) => { const rv = realms .map((realm) => { const selfRealmMembers = selfMembers.filter( (m) => m.realmId === realm.realmId ); const directPermissionSets = selfRealmMembers .map((m) => m.permissions!) .filter((p) => p); const rolePermissionSets = flatten( selfRealmMembers.map((m) => m.roles!).filter((roleName) => roleName) ) .map((role) => globalRoles[role]!) .filter((role) => role) .map((role) => role.permissions); return { ...realm, permissions: realm.owner === userId ? ({ manage: '*' } as DBPermissionSet) : mergePermissions( ...directPermissionSets, ...rolePermissionSets ), }; }) .reduce((p, c) => ({ ...p, [c.realmId]: c }), { [userId!]: { realmId: userId, owner: userId, name: userId, permissions: { manage: '*' }, } as DBRealm & { permissions: DBPermissionSet }, }); return rv; } ); }); ================================================ FILE: addons/dexie-cloud/src/getTiedRealmId.ts ================================================ export function getTiedRealmId(objectId: string) { return 'rlm~' + objectId; } export function getTiedObjectId(realmId: string) { return realmId.startsWith('rlm~') ? realmId.substr(4) : null; } ================================================ FILE: addons/dexie-cloud/src/helpers/BroadcastedAndLocalEvent.ts ================================================ import { Observable } from "rxjs"; import { SWBroadcastChannel } from "./SWBroadcastChannel"; const events: Mapvoid>> = globalThis['lbc-events'] || (globalThis['lbc-events'] = new Mapvoid>>()); function addListener(name: string, listener: (ev: CustomEvent)=>void) { if (events.has(name)) { events.get(name)!.push(listener); } else { events.set(name, [listener]); } } function removeListener(name: string, listener: (ev: CustomEvent)=>void) { const listeners = events.get(name); if (listeners) { const idx = listeners.indexOf(listener); if (idx !== -1) { listeners.splice(idx, 1); } } } function dispatch(ev: CustomEvent) { const listeners = events.get(ev.type); if (listeners) { listeners.forEach(listener => { try { listener(ev); } catch { } }); } } export class BroadcastedAndLocalEvent extends Observable{ name: string; bc: BroadcastChannel | SWBroadcastChannel constructor(name: string) { const bc = typeof BroadcastChannel === "undefined" ? new SWBroadcastChannel(name) : new BroadcastChannel(name); super(subscriber => { function onCustomEvent(ev: CustomEvent) { subscriber.next(ev.detail); } function onMessageEvent(ev: MessageEvent) { console.debug("BroadcastedAndLocalEvent: onMessageEvent", ev); subscriber.next(ev.data); } let unsubscribe: ()=>void; //self.addEventListener(`lbc-${name}`, onCustomEvent); // Fails in service workers addListener(`lbc-${name}`, onCustomEvent); // Works better in service worker try { if (bc instanceof SWBroadcastChannel) { unsubscribe = bc.subscribe(message => subscriber.next(message)); } else { console.debug("BroadcastedAndLocalEvent: bc.addEventListener()", name, "bc is a", bc); bc.addEventListener("message", onMessageEvent); } } catch (err) { // Service workers might fail to subscribe outside its initial script. console.warn('Failed to subscribe to broadcast channel', err); } return () => { //self.removeEventListener(`lbc-${name}`, onCustomEvent); removeListener(`lbc-${name}`, onCustomEvent); if (bc instanceof SWBroadcastChannel) { unsubscribe!(); } else { bc.removeEventListener("message", onMessageEvent); } } }); this.name = name; this.bc = bc; } next(message: T) { console.debug("BroadcastedAndLocalEvent: bc.postMessage()", {...message}, "bc is a", this.bc); this.bc.postMessage(message); const ev = new CustomEvent(`lbc-${this.name}`, { detail: message }); //self.dispatchEvent(ev); dispatch(ev); } } ================================================ FILE: addons/dexie-cloud/src/helpers/CancelToken.ts ================================================ import Dexie from "dexie"; export interface CancelToken { cancelled: boolean; } export function throwIfCancelled(cancelToken?: CancelToken) { if (cancelToken?.cancelled) throw new Dexie.AbortError(`Operation was cancelled`); } ================================================ FILE: addons/dexie-cloud/src/helpers/IS_SERVICE_WORKER.ts ================================================ export const IS_SERVICE_WORKER = typeof self !== "undefined" && "clients" in self && !self.document; ================================================ FILE: addons/dexie-cloud/src/helpers/SWBroadcastChannel.ts ================================================ const swHolder: { registration?: ServiceWorkerRegistration } = {}; const swContainer = typeof self !== 'undefined' && self.document && // self.document is to verify we're not the SW ourself typeof navigator !== 'undefined' && navigator.serviceWorker; if (swContainer) swContainer.ready.then( (registration) => (swHolder.registration = registration) ); if (typeof self !== 'undefined' && 'clients' in self && !self.document) { // We are the service worker. Propagate messages to all our clients. addEventListener('message', (ev: any) => { if (ev.data?.type?.startsWith('sw-broadcast-')) { [...self['clients'].matchAll({ includeUncontrolled: true })].forEach( (client) => client.id !== ev.source?.id && client.postMessage(ev.data) ); } }); } /** This class is a fallback for browsers that lacks BroadcastChannel but have * service workers (which is Safari versions 11.1 through 15.3). * Safari 15.4 with BroadcastChannel was released on 2022-03-14. * We might be able to remove this class in a near future as Safari < 15.4 is * already very low in market share as of 2023-03-10. */ export class SWBroadcastChannel { name: string; constructor(name: string) { this.name = name; } subscribe(listener: (message: any) => void) { if (!swContainer) return () => {}; const forwarder = (ev: MessageEvent) => { if (ev.data?.type === `sw-broadcast-${this.name}`) { listener(ev.data.message); } }; swContainer.addEventListener('message', forwarder); return () => swContainer.removeEventListener('message', forwarder); } postMessage(message: any) { if (typeof self['clients'] === 'object') { // We're a service worker. Propagate to our browser clients. [...self['clients'].matchAll({ includeUncontrolled: true })].forEach( (client) => client.postMessage({ type: `sw-broadcast-${this.name}`, message, }) ); } else if (swHolder.registration) { // We're a client (browser window or other worker) // Post to SW so it can repost to all its clients and to itself swHolder.registration.active?.postMessage({ type: `sw-broadcast-${this.name}`, message, }); } } } ================================================ FILE: addons/dexie-cloud/src/helpers/allSettled.ts ================================================ export function allSettled(possiblePromises: any[]) { return new Promise(resolve => { if (possiblePromises.length === 0) resolve([]); let remaining = possiblePromises.length; const results = new Array(remaining); possiblePromises.forEach((p, i) => Promise.resolve(p).then( value => results[i] = {status: "fulfilled", value}, reason => results[i] = {status: "rejected", reason}) .then(()=>--remaining || resolve(results))); }); } ================================================ FILE: addons/dexie-cloud/src/helpers/bulkUpdate.ts ================================================ import Dexie, { Table, cmp } from 'dexie'; export async function bulkUpdate( table: Table, keys: any[], changeSpecs: { [keyPath: string]: any }[] ) { const objs = await table.bulkGet(keys); const resultKeys: any[] = []; const resultObjs: any[] = []; keys.forEach((key, idx) => { const obj = objs[idx]; if (obj) { for (const [keyPath, value] of Object.entries(changeSpecs[idx])) { if (keyPath === table.schema.primKey.keyPath) { if (cmp(value, key) !== 0) { throw new Error(`Cannot change primary key`); } } else { Dexie.setByKeyPath(obj, keyPath, value); } } resultKeys.push(key); resultObjs.push(obj); } }); await (table.schema.primKey.keyPath == null ? table.bulkPut(resultObjs, resultKeys) : table.bulkPut(resultObjs)); } ================================================ FILE: addons/dexie-cloud/src/helpers/computeRealmSetHash.ts ================================================ import { PersistedSyncState } from '../db/entities/PersistedSyncState'; import { b64encode } from 'dexie-cloud-common'; export async function computeRealmSetHash({ realms, inviteRealms, }: PersistedSyncState) { const data = JSON.stringify( [ ...realms.map((realmId) => ({ realmId, accepted: true })), ...inviteRealms.map((realmId) => ({ realmId, accepted: false })), ].sort((a, b) => a.realmId < b.realmId ? -1 : a.realmId > b.realmId ? 1 : 0 ) ); const byteArray = new TextEncoder().encode(data); const digestBytes = await crypto.subtle.digest('SHA-1', byteArray); const base64 = b64encode(digestBytes); return base64; } ================================================ FILE: addons/dexie-cloud/src/helpers/date-constants.ts ================================================ export const SECONDS = 1000; export const MINUTES = 60 * SECONDS; export const HOURS = 60 * MINUTES; export const DAYS = 24 * HOURS; export const WEEKS = 7 * DAYS; ================================================ FILE: addons/dexie-cloud/src/helpers/flatten.ts ================================================ const concat = [].concat; export function flatten(a: (T | T[])[]): T[] { return concat.apply([], a); } ================================================ FILE: addons/dexie-cloud/src/helpers/getMutationTable.ts ================================================ export function getMutationTable(tableName: string) { return `$${tableName}_mutations`; } ================================================ FILE: addons/dexie-cloud/src/helpers/getSyncableTables.ts ================================================ import { IndexableType, Table } from "dexie"; import { DexieCloudDB } from "../db/DexieCloudDB"; import { EntityCommon } from "../db/entities/EntityCommon"; export function getSyncableTables(db: DexieCloudDB): Table[] { return Object.entries(db.cloud.schema || {}) .filter(([, { markedForSync }]) => markedForSync) .map(([tbl]) => db.tables.find(({name}) => name === tbl)) .filter((syncableTable): syncableTable is Table => !!syncableTable); } ================================================ FILE: addons/dexie-cloud/src/helpers/getTableFromMutationTable.ts ================================================ export function getTableFromMutationTable(mutationTable: string) { const tableName = /^\$(.*)_mutations$/.exec(mutationTable)?.[1]; if (!tableName) throw new Error(`Given mutationTable ${mutationTable} is not correct`); return tableName; } ================================================ FILE: addons/dexie-cloud/src/helpers/makeArray.ts ================================================ export function makeArray(iterable: Iterable): T[] { return [].slice.call(iterable); } ================================================ FILE: addons/dexie-cloud/src/helpers/randomString.ts ================================================ export function randomString(bytes: number) { const buf = new Uint8Array(bytes); if (typeof crypto !== 'undefined') { crypto.getRandomValues(buf); } else { for (let i = 0; i < bytes; i++) buf[i] = Math.floor(Math.random() * 256); } if (typeof Buffer !== 'undefined' && Buffer.from) { return Buffer.from(buf).toString('base64'); } else if (typeof btoa !== 'undefined') { return btoa(String.fromCharCode.apply(null, buf)); } else { throw new Error('No btoa or Buffer available'); } } ================================================ FILE: addons/dexie-cloud/src/helpers/resolveText.ts ================================================ import { DXCAlert } from "../types/DXCAlert"; /** Resolve a message template with parameters. * * Example: * resolveText({ * message: "Hello {name}!", * messageCode: "HELLO", * messageParams: {name: "David"} * }) => "Hello David!" * * @param message Template message with {vars} in it. * @param messageCode Unique code for the message. Can be used for translation. * @param messageParams Parameters to be used in the message. * @returns A final message where parameters have been replaced with values. */ export function resolveText({message, messageCode, messageParams}: DXCAlert) { return message.replace(/\{\w+\}/ig, n => messageParams[n.substring(1, n.length-1)]); } ================================================ FILE: addons/dexie-cloud/src/helpers/throwVersionIncrementNeeded.ts ================================================ import Dexie from "dexie"; export function throwVersionIncrementNeeded() { throw new Dexie.SchemaError( `Version increment needed to allow dexie-cloud change tracking` ); } ================================================ FILE: addons/dexie-cloud/src/helpers/visibilityState.ts ================================================ import { BehaviorSubject, from, fromEvent } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; export function createVisibilityStateObservable() { return fromEvent(document, 'visibilitychange').pipe( map(() => document.visibilityState), startWith(document.visibilityState) ); } ================================================ FILE: addons/dexie-cloud/src/isEagerSyncDisabled.ts ================================================ import { DexieCloudDB } from './db/DexieCloudDB'; export function isEagerSyncDisabled(db: DexieCloudDB) { return ( db.cloud.options?.disableEagerSync || db.cloud.currentUser.value?.license?.status !== 'ok' || !db.cloud.options?.databaseUrl ); } ================================================ FILE: addons/dexie-cloud/src/isFirefox.ts ================================================ // @ts-ignore export const isFirefox = typeof InstallTrigger !== 'undefined'; ================================================ FILE: addons/dexie-cloud/src/isSafari.ts ================================================ export const isSafari = typeof navigator !== 'undefined' && /Safari\//.test(navigator.userAgent) && !/Chrom(e|ium)\/|Edge\//.test(navigator.userAgent); export const safariVersion = isSafari ? // @ts-ignore [].concat(navigator.userAgent.match(/Safari\/(\d*)/))[1] : NaN; ================================================ FILE: addons/dexie-cloud/src/mapValueObservable.ts ================================================ import { map, Observable } from 'rxjs'; export interface ObservableWithCurrentValue extends Observable { getValue(): T; } export function mapValueObservable( o: ObservableWithCurrentValue, mapper: (x: T) => R ): ObservableWithCurrentValue { let currentValue: R | undefined; const rv = o.pipe( map((x) => (currentValue = mapper(x))) ) as ObservableWithCurrentValue; rv.getValue = () => currentValue !== undefined ? currentValue : (currentValue = mapper(o.getValue())); return rv; } ================================================ FILE: addons/dexie-cloud/src/mergePermissions.ts ================================================ // TODO: Move to dexie-cloud-common import { DBPermissionSet } from 'dexie-cloud-common'; export function mergePermissions( ...permissions: DBPermissionSet[] ): DBPermissionSet { if (permissions.length === 0) return {}; const reduced = permissions.reduce((result, next) => { const ret = { ...result } as DBPermissionSet; for (const [verb, rights] of Object.entries(next) as [ keyof DBPermissionSet, DBPermissionSet[keyof DBPermissionSet] ][]) { if (verb in ret && ret[verb]) { if (ret[verb] === '*') continue; if (rights === '*') { ret[verb] = '*'; } else if (Array.isArray(rights) && Array.isArray(ret[verb])) { // Both are arrays (verb is 'add' or 'manage') const r = ret as { [v in typeof verb]?: string[] }; const retVerb = r[verb]!; // "!" because Array.isArray(ret[verb]) r[verb] = [...new Set([...retVerb, ...rights])]; } else if ( typeof rights === 'object' && rights && typeof ret[verb] === 'object' ) { // Both are objects (verb is 'update') const mergedRights = ret[verb] as { [tableName: string]: '*' | string[]; }; // because we've checked that typeof ret[verb] === 'object' and earlier that not ret[verb] === '*'. for (const [tableName, tableRights] of Object.entries(rights) as [ string, string[] | '*' ][]) { if (mergedRights[tableName] === '*') continue; if (tableRights === '*') { mergedRights[tableName] = '*'; } else if ( Array.isArray(mergedRights[tableName]) && Array.isArray(tableRights) ) { mergedRights[tableName] = [ ...new Set([...mergedRights[tableName], ...tableRights]), ]; } } } } else { /* This compiles without type assertions. Keeping the comment to explain why we do tsignore on the next statement. if (verb === "add") { ret[verb] = next[verb]; } else if (verb === "update") { ret[verb] = next[verb]; } else if (verb === "manage") { ret[verb] = next[verb]; } else { ret[verb] = next[verb]; } */ //@ts-ignore ret[verb] = next[verb]; } } return ret; }); return reduced; } ================================================ FILE: addons/dexie-cloud/src/middleware-helpers/guardedTable.ts ================================================ import { DBCoreTable, DBCoreTransaction } from "dexie"; import { allSettled } from "../helpers/allSettled"; let counter = 0; export function guardedTable(table: DBCoreTable) { const prop = "$lock"+ (++counter); return { ...table, count: readLock(table.count, prop), get: readLock(table.get, prop), getMany: readLock(table.getMany, prop), openCursor: readLock(table.openCursor, prop), query: readLock(table.query, prop), mutate: writeLock(table.mutate, prop), }; } function readLock( fn: (req: TReq) => Promise, prop: string ): (req: TReq) => Promise { return function readLocker(req): Promise { const { readers, writers, }: { writers: Promise[]; readers: Promise[] } = req.trans[prop] || (req.trans[prop] = { writers: [], readers: [] }); const numWriters = writers.length; const promise = (numWriters > 0 ? writers[numWriters - 1].then(() => fn(req), () => fn(req)) : fn(req) ).finally(() => {readers.splice(readers.indexOf(promise))}); readers.push(promise); return promise; }; } function writeLock( fn: (req: TReq) => Promise, prop: string ): (req: TReq) => Promise { return function writeLocker(req): Promise { const { readers, writers, }: { writers: Promise[]; readers: Promise[] } = req.trans[prop] || (req.trans[prop] = { writers: [], readers: [] }); let promise = (writers.length > 0 ? writers[writers.length - 1].then(() => fn(req), () => fn(req)) : readers.length > 0 ? allSettled(readers).then(() => fn(req)) : fn(req) ).finally(() => {writers.shift();}); writers.push(promise); return promise; }; } ================================================ FILE: addons/dexie-cloud/src/middleware-helpers/idGenerationHelpers.ts ================================================ import { DBCoreAddRequest, DBCoreDeleteRequest, DBCoreIndex, DBCorePutRequest } from 'dexie'; import { b64LexEncode } from 'dexie-cloud-common'; const { toString } = {}; export function toStringTag(o: Object) { return toString.call(o).slice(8, -1); } export function getEffectiveKeys( primaryKey: DBCoreIndex, req: (Pick & { keys?: any[]; }) | Pick ) { if (req.type === 'delete') return req.keys; return req.keys?.slice() || req.values.map(primaryKey.extractKey!); } function applyToUpperBitFix(orig: string, bits: number) { return ( (bits & 1 ? orig[0].toUpperCase() : orig[0].toLowerCase()) + (bits & 2 ? orig[1].toUpperCase() : orig[1].toLowerCase()) + (bits & 4 ? orig[2].toUpperCase() : orig[2].toLowerCase()) ); } const consonants = /b|c|d|f|g|h|j|k|l|m|n|p|q|r|s|t|v|x|y|z/i; function isUpperCase(ch: string) { return ch >= 'A' && ch <= 'Z'; } export function generateTablePrefix( tableName: string, allPrefixes: Set ) { let rv = tableName[0].toLocaleLowerCase(); // "users" = "usr", "friends" = "frn", "realms" = "rlm", etc. for (let i = 1, l = tableName.length; i < l && rv.length < 3; ++i) { if (consonants.test(tableName[i]) || isUpperCase(tableName[i])) rv += tableName[i].toLowerCase(); } while (allPrefixes.has(rv)) { if (/\d/g.test(rv)) { rv = rv.substr(0, rv.length - 1) + (rv[rv.length - 1] + 1); if (rv.length > 3) rv = rv.substr(0, 3); else continue; } else if (rv.length < 3) { rv = rv + '2'; continue; } let bitFix = 1; let upperFixed = rv; while (allPrefixes.has(upperFixed) && bitFix < 8) { upperFixed = applyToUpperBitFix(rv, bitFix); ++bitFix; } if (bitFix < 8) rv = upperFixed; else { let nextChar = (rv.charCodeAt(2) + 1) & 127; rv = rv.substr(0, 2) + String.fromCharCode(nextChar); // Here, in theory we could get an infinite loop if having 127*8 table names with identical 3 first consonants. } } return rv; } let time = 0; /** * * @param prefix A unique 3-letter short-name of the table. * @param shardKey 3 last letters from another ID if colocation is requested. Verified on server on inserts - guarantees unique IDs across shards. * The shardKey part of the key represent the shardId where it was first created. An object with this * primary key can later on be moved to another shard without being altered. The reason for having * the origin shardKey as part of the key, is that the server will not need to check uniqueness constraint * across all shards on every insert. Updates / moves across shards are already controlled by the server * in the sense that the objects needs to be there already - we only need this part for inserts. * @returns */ export function generateKey(prefix: string, shardKey?: string) { const a = new Uint8Array(18); const timePart = new Uint8Array(a.buffer, 0, 6); const now = Date.now(); // Will fit into 6 bytes until year 10 895. if (time >= now) { // User is bulk-creating objects the same millisecond. // Increment the time part by one millisecond for each item. // If bulk-creating 1,000,000 rows client-side in 10 seconds, // the last time-stamp will be 990 seconds in future, which is no biggie at all. // The point is to create a nice order of the generated IDs instead of // using random ids. ++time; } else { time = now; } timePart[0] = time / 1099511627776; // Normal division (no bitwise operator) --> works with >= 32 bits. timePart[1] = time / 4294967296; timePart[2] = time / 16777216; timePart[3] = time / 65536; timePart[4] = time / 256; timePart[5] = time; const randomPart = new Uint8Array(a.buffer, 6); crypto.getRandomValues(randomPart); const id = new Uint8Array(a.buffer); return prefix + b64LexEncode(id) + (shardKey || ''); } ================================================ FILE: addons/dexie-cloud/src/middlewares/blobResolveMiddleware.ts ================================================ /** * DBCore Middleware for resolving BlobRefs on read * * This middleware intercepts read operations and resolves any BlobRefs * found in objects marked with _hasBlobRefs. * * Important: Avoids async/await to preserve Dexie's Promise.PSD context. * Uses Dexie.waitFor() only for explicit rw transactions to keep them alive. * For readonly or implicit transactions, resolves directly (no waitFor needed). * * Resolved blobs are queued for saving via BlobSavingQueue, which uses * setTimeout(fn, 0) to completely isolate from Dexie's transaction context. * Each blob is saved atomically using Table.update() with its keyPath to * avoid race conditions with other property changes. * * Blob downloads use Authorization header (same as sync) via the server * proxy endpoint: GET /blob/{ref} */ import Dexie, { DBCore, DBCoreGetManyRequest, DBCoreGetRequest, DBCoreQueryRequest, DBCoreOpenCursorRequest, DBCoreCursor, DBCoreTable, DBCoreTransaction, Middleware } from 'dexie'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { hasUnresolvedBlobRefs, resolveAllBlobRefs, ResolvedBlob } from '../sync/blobResolve'; import { BlobSavingQueue } from '../sync/BlobSavingQueue'; import { TXExpandos } from '../types/TXExpandos'; import { UserLogin } from '../dexie-cloud-client'; export function createBlobResolveMiddleware(db: DexieCloudDB): Middleware { return { stack: 'dbcore' as const, name: 'blobResolve', level: 2, // Run above cache (0) and other middlewares (1) to resolve BlobRefs from cached data create(downlevelDatabase: DBCore): DBCore { // Create a single queue instance for this database const blobSavingQueue = new BlobSavingQueue(db); return { ...downlevelDatabase, table(tableName: string): DBCoreTable { if (!db.cloud) { // db.cloud not yet initialized - skip blob resolution // Fall through to downlevel table to avoid crash return downlevelDatabase.table(tableName); } const dbUrl = db.cloud.options?.databaseUrl; const downlevelTable = downlevelDatabase.table(tableName); // Skip internal tables if (tableName.startsWith('$')) { return downlevelTable; } return { ...downlevelTable, get(req: DBCoreGetRequest) { if ((req.trans as DBCoreTransaction & TXExpandos)?.disableBlobResolve) { return downlevelTable.get(req); } return downlevelTable.get(req).then(result => { if (result && hasUnresolvedBlobRefs(result)) { return resolveAndSave(downlevelTable, req.trans, req.key, result, blobSavingQueue, db); } return result; }); }, getMany(req: DBCoreGetManyRequest) { if ((req.trans as DBCoreTransaction & TXExpandos)?.disableBlobResolve) { return downlevelTable.getMany(req); } return downlevelTable.getMany(req).then(results => { // Check if any results need resolution const needsResolution = results.some(r => r && hasUnresolvedBlobRefs(r)); if (!needsResolution) return results; return Dexie.Promise.all( results.map((result, index) => { if (result && hasUnresolvedBlobRefs(result)) { return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, blobSavingQueue, db); } return result; }) ); }); }, query(req: DBCoreQueryRequest) { if ((req.trans as DBCoreTransaction & TXExpandos)?.disableBlobResolve) { return downlevelTable.query(req); } return downlevelTable.query(req).then(result => { if (!result.result || !Array.isArray(result.result)) return result; // Check if any results need resolution const needsResolution = result.result.some(r => r && hasUnresolvedBlobRefs(r)); if (!needsResolution) return result; return Dexie.Promise.all( result.result.map(item => { if (item && hasUnresolvedBlobRefs(item)) { return resolveAndSave(downlevelTable, req.trans, undefined, item, blobSavingQueue, db); } return item; }) ).then(resolved => ({ ...result, result: resolved })); }); }, openCursor(req: DBCoreOpenCursorRequest) { if ((req.trans as DBCoreTransaction & TXExpandos)?.disableBlobResolve) { return downlevelTable.openCursor(req); } return downlevelTable.openCursor(req).then(cursor => { if (!cursor) return cursor; // No results, so no resolution needed if (!req.values) return cursor; // No values requested, so no resolution needed if (!dbUrl) return cursor; // No database URL configured, can't resolve blobs return createBlobResolvingCursor(cursor, downlevelTable, blobSavingQueue, db); }); }, }; }, }; }, }; } /** * Create a cursor wrapper that resolves BlobRefs in values synchronously. * * Uses Object.create() to inherit all cursor methods, only overriding: * - start(): Resolves BlobRefs before calling the callback * - value: Getter that returns the resolved value * * Returns the cursor synchronously. Resolution happens in start() before * each onNext callback, ensuring cursor.value is always available. */ function createBlobResolvingCursor( cursor: DBCoreCursor, table: DBCoreTable, blobSavingQueue: BlobSavingQueue, db: DexieCloudDB ): DBCoreCursor { // Create wrapped cursor using Object.create() - inherits everything const wrappedCursor = Object.create(cursor, { value: { value: cursor.value, enumerable: true, writable: true }, start: { value(onNext: () => void): Promise { // Override start to resolve BlobRefs before each callback return cursor.start(() => { const rawValue = cursor.value; if (!rawValue || !hasUnresolvedBlobRefs(rawValue)) { onNext(); return; } resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, blobSavingQueue, db, true).then(resolved => { wrappedCursor.value = resolved; onNext(); }, err => { console.error('Failed to resolve BlobRefs for cursor value:', err); onNext(); }); }); } } }); return wrappedCursor; } /** * Resolve BlobRefs in an object and queue each blob for atomic saving. * * Uses Dexie.waitFor() only when needed: * - Skip waitFor for readonly ('r') transactions * - Skip waitFor for implicit transactions (most common in liveQuery) * - Use waitFor only for explicit rw transactions that need to stay alive * * Each resolved blob is queued individually with its keyPath for atomic * update using downCore transaction with the specific keyPath - this avoids race conditions. * * Returns Dexie.Promise to preserve PSD context. */ function resolveAndSave( table: DBCoreTable, trans: DBCoreTransaction, pKey: any | undefined, // optional. If missing, tries to extract from object using primary key path obj: any, blobSavingQueue: BlobSavingQueue, db: DexieCloudDB, isCursorValue: boolean = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key) ): Promise { try { // Determine if we need waitFor: // Skip waitFor ONLY if BOTH conditions are met: // 1. readonly transaction // 2. implicit (non-explicit) transaction // // Transaction.explicit is true when user called db.transaction() explicitly. // For implicit transactions (auto-created for single operations), // Dexie handles async automatically so no waitFor needed. const currentTx = Dexie.currentTransaction; const isReadonly = currentTx?.mode === 'readonly'; const isExplicit = currentTx?.explicit === true; // Skip waitFor only for implicit readonly (most common case: liveQuery) const skipWaitFor = isReadonly && !isExplicit && !isCursorValue; const needsWaitFor = currentTx && !skipWaitFor; const dbUrl = db.cloud.options?.databaseUrl || ''; // Collect resolved blobs with their keyPaths const resolvedBlobs: ResolvedBlob[] = []; // Create the resolution promise with auth info const resolutionPromise = resolveAllBlobRefs(obj, dbUrl, resolvedBlobs, '', new WeakMap(), db.blobDownloadTracker) // Wrap with waitFor to keep transaction alive during fetch const resolvePromise = needsWaitFor ? Dexie.waitFor(resolutionPromise) : Dexie.Promise.resolve(resolutionPromise); return resolvePromise.then(resolved => { // Get primary key from the object const primaryKey = table.schema.primaryKey; const key = pKey !== undefined ? pKey : primaryKey.keyPath ? Dexie.getByKeyPath(obj, primaryKey.keyPath as string) : undefined; if (key !== undefined) { // Queue each resolved blob individually for atomic update // This uses setTimeout(fn, 0) to completely isolate from // Dexie's transaction context (avoids inheriting PSD) if (isReadonly) { blobSavingQueue.saveBlobs(table.name, key, resolvedBlobs); } else { // For rw transactions, we can save directly without queueing // since we're still in the same transaction context table.mutate({ type: 'put', keys: [key], values: [resolved], trans }).catch(err => { console.error(`Failed to save resolved blob on ${table.name}:${key}:`, err); }); } } return resolved; }).catch(err => { console.error(`[dexie-cloud:blobResolve] Failed to resolve BlobRefs on ${table.name}:`, err); return obj; // Return original object on error - never block the read pipeline }); } catch (err) { console.error(`[dexie-cloud:blobResolve] Sync error in resolveAndSave on ${table.name}:`, err); return Dexie.Promise.resolve(obj); // Never block reads } } ================================================ FILE: addons/dexie-cloud/src/middlewares/createIdGenerationMiddleware.ts ================================================ import Dexie, { DBCore, DBCoreAddRequest, DBCoreMutateRequest, DBCorePutRequest, DBCoreTransaction, Middleware, } from 'dexie'; import { isValidSyncableID } from 'dexie-cloud-common'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { getEffectiveKeys, generateKey, toStringTag, } from '../middleware-helpers/idGenerationHelpers'; import { TXExpandos } from '../types/TXExpandos'; export function createIdGenerationMiddleware( db: DexieCloudDB ): Middleware { return { stack: 'dbcore', name: 'idGenerationMiddleware', level: 1, create: (core) => { return { ...core, table: (tableName) => { const table = core.table(tableName); function generateOrVerifyAtKeys( req: DBCoreAddRequest | DBCorePutRequest, idPrefix: string ) { let valueClones: null | object[] = null; const keys = getEffectiveKeys(table.schema.primaryKey, req); keys.forEach((key, idx) => { if (key === undefined) { // Generate the key const colocatedId = req.values[idx].realmId || db.cloud.currentUserId; const shardKey = colocatedId.substr(colocatedId.length - 3); keys[idx] = generateKey(idPrefix, shardKey); if (!table.schema.primaryKey.outbound) { if (!valueClones) valueClones = req.values.slice(); valueClones[idx] = Dexie.deepClone(valueClones[idx]); Dexie.setByKeyPath( valueClones[idx], table.schema.primaryKey.keyPath!, keys[idx] ); } } else if ( typeof key !== 'string' || (!key.startsWith(idPrefix) && !key.startsWith('#' + idPrefix)) ) { // Key was specified by caller. Verify it complies with id prefix. throw new Dexie.ConstraintError( `The ID "${key}" is not valid for table "${tableName}". ` + `Primary '@' keys requires the key to be prefixed with "${idPrefix}" (or "#${idPrefix}).\n` + `If you want to generate IDs programmatically, remove '@' from the schema to get rid of this constraint. Dexie Cloud supports custom IDs as long as they are random and globally unique.` ); } }); return table.mutate({ ...req, keys, values: valueClones || req.values, }); } return { ...table, mutate: (req) => { const idbtrans = req.trans as DBCoreTransaction & IDBTransaction & TXExpandos; if (idbtrans.mode === 'versionchange') { // Tell all the other middlewares to skip bothering. We're in versionchange mode. // dexie-cloud is not initialized yet. idbtrans.disableChangeTracking = true; idbtrans.disableAccessControl = true; } if (idbtrans.disableChangeTracking) { // Disable ID policy checks and ID generation return table.mutate(req); } if (req.type === 'add' || req.type === 'put') { const cloudTableSchema = db.cloud.schema?.[tableName]; if (!cloudTableSchema?.generatedGlobalId) { if (cloudTableSchema?.markedForSync) { // Just make sure primary key is of a supported type: const keys = getEffectiveKeys(table.schema.primaryKey, req); keys.forEach((key, idx) => { if (!isValidSyncableID(key)) { const type = Array.isArray(key) ? key.map(toStringTag).join(',') : toStringTag(key); throw new Dexie.ConstraintError( `Invalid primary key type ${type} for table ${tableName}. Tables marked for sync has primary keys of type string or Array of string (and optional numbers)` ); } }); } } else { if (db.cloud.options?.databaseUrl && !db.initiallySynced) { // A database URL is configured but no initial sync has been performed. const keys = getEffectiveKeys(table.schema.primaryKey, req); // Check if the operation would yield any INSERT. If so, complain! We never want wrong ID prefixes stored. return table .getMany({ keys, trans: req.trans, cache: 'immutable' }) .then((results) => { if (results.length < keys.length) { // At least one of the given objects would be created. Complain since // the generated ID would be based on a locally computed ID prefix only - we wouldn't // know if the server would give the same ID prefix until an initial sync has been // performed. throw new Error( `Unable to create new objects without an initial sync having been performed.` ); } return table.mutate(req); }); } return generateOrVerifyAtKeys( req, cloudTableSchema.idPrefix! ); } } return table.mutate(req); }, }; }, }; }, }; } ================================================ FILE: addons/dexie-cloud/src/middlewares/createImplicitPropSetterMiddleware.ts ================================================ import Dexie, { DBCore, DBCoreTransaction, Middleware } from 'dexie'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { TXExpandos } from '../types/TXExpandos'; import { UNAUTHORIZED_USER } from '../authentication/UNAUTHORIZED_USER'; export function createImplicitPropSetterMiddleware( db: DexieCloudDB ): Middleware { return { stack: 'dbcore', name: 'implicitPropSetterMiddleware', level: 1, create: (core) => { return { ...core, table: (tableName) => { const table = core.table(tableName); return { ...table, mutate: (req) => { const trans = req.trans as DBCoreTransaction & TXExpandos & IDBTransaction; if (trans.disableChangeTracking) { return table.mutate(req); } const currentUserId = trans.currentUser?.userId ?? UNAUTHORIZED_USER.userId; if (db.cloud.schema?.[tableName]?.markedForSync) { if (req.type === 'add' || req.type === 'put') { if (tableName === 'members') { for (const member of req.values) { if (typeof member.email === 'string') { // Resolve https://github.com/dexie/dexie-cloud/issues/4 // If adding a member, make sure email is lowercase and trimmed. // This is to avoid issues where the APP does not check this // and just allows the user to enter an email address that might // have been pasted by the user from a source that had a trailing // space or was in uppercase. We want to avoid that the user // creates a new member with a different email address than // the one he/she intended to create. member.email = member.email.trim().toLowerCase(); } } } // No matter if user is logged in or not, make sure "owner" and "realmId" props are set properly. // If not logged in, this will be changed upon syncification of the tables (next sync after login), // however, application code will work better if we can always rely on that the properties realmId // and owner are set. Application code may index them and query them based on db.cloud.currentUserId, // and expect them to be returned. That scenario must work also when db.cloud.currentUserId === 'unauthorized'. for (const obj of req.values) { if (!obj.owner) { obj.owner = currentUserId; } if (!obj.realmId) { obj.realmId = currentUserId; } const key = table.schema.primaryKey.extractKey?.(obj); if (typeof key === 'string' && key[0] === '#') { // Add $ts prop for put operations and // disable update operations as well as consistent // modify operations. Reason: Server may not have // the object. Object should be created on server only // if is being updated. An update operation won't create it // so we must delete req.changeSpec to degrade operation to // an upsert operation with timestamp so that it will be created. // We must also degrade from consistent modify operations for the // same reason - object might be there on server. Must but put up instead. // FUTURE: This clumpsy behavior of private IDs could be refined later. // Suggestion is to in future, treat private IDs as we treat all objects // and sync operations normally. Only that deletions should become soft deletes // for them - so that server knows when a private ID has been deleted on server // not accept insert/upserts on them. if (req.type === 'put') { delete req.criteria; delete req.changeSpec; if (!req.upsert) delete req.updates; obj.$ts = Date.now(); } } } } } return table.mutate(req); }, }; }, }; }, }; } ================================================ FILE: addons/dexie-cloud/src/middlewares/createMutationTrackingMiddleware.ts ================================================ import { DBCore, DBCoreAddRequest, DBCoreDeleteRequest, DBCoreMutateResponse, DBCorePutRequest, DBCoreTable, DBCoreTransaction, Middleware, RangeSet, } from 'dexie'; import { DBOperation, DBUpdateOperation } from 'dexie-cloud-common'; import { BehaviorSubject } from 'rxjs'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { UserLogin } from '../db/entities/UserLogin'; import { getMutationTable } from '../helpers/getMutationTable'; import { randomString } from '../helpers/randomString'; import { throwVersionIncrementNeeded } from '../helpers/throwVersionIncrementNeeded'; import { guardedTable } from '../middleware-helpers/guardedTable'; import { registerSyncEvent } from '../sync/registerSyncEvent'; import { TXExpandos } from '../types/TXExpandos'; import { outstandingTransactions } from './outstandingTransaction'; import { isEagerSyncDisabled } from '../isEagerSyncDisabled'; import { triggerSync } from '../sync/triggerSync'; export interface MutationTrackingMiddlewareArgs { currentUserObservable: BehaviorSubject; db: DexieCloudDB; } /** Tracks all mutations in the same transaction as the mutations - * so it is guaranteed that no mutation goes untracked - and if transaction * aborts, the mutations won't be tracked. * * The sync job will use the tracked mutations as the source of truth when pushing * changes to server and cleanup the tracked mutations once the server has * ackowledged that it got them. */ export function createMutationTrackingMiddleware({ currentUserObservable, db, }: MutationTrackingMiddlewareArgs): Middleware { return { stack: 'dbcore', name: 'MutationTrackingMiddleware', level: 1, create: (core) => { const allTableNames = new Set(core.schema.tables.map((t) => t.name)); const ordinaryTables = core.schema.tables.filter( (t) => !/^\$/.test(t.name) ); const mutTableMap = new Map(); for (const tbl of ordinaryTables) { const mutationTableName = `$${tbl.name}_mutations`; if (allTableNames.has(mutationTableName)) { mutTableMap.set(tbl.name, core.table(mutationTableName)); } } return { ...core, transaction: (tables, mode) => { let tx: DBCoreTransaction & IDBTransaction & TXExpandos; if (mode === 'readwrite') { const mutationTables = tables .filter((tbl) => db.cloud.schema?.[tbl]?.markedForSync) .map((tbl) => getMutationTable(tbl)); tx = core.transaction( [...tables, ...mutationTables], mode ) as DBCoreTransaction & IDBTransaction & TXExpandos; } else { tx = core.transaction(tables, mode) as DBCoreTransaction & IDBTransaction & TXExpandos; } if (mode === 'readwrite') { // Give each transaction a globally unique id. tx.txid = randomString(16); tx.opCount = 0; // Introduce the concept of current user that lasts through the entire transaction. // This is important because the tracked mutations must be connected to the user. tx.currentUser = currentUserObservable.value; outstandingTransactions.value.add(tx); outstandingTransactions.next(outstandingTransactions.value); const removeTransaction = () => { tx.removeEventListener('complete', txComplete); tx.removeEventListener('error', removeTransaction); tx.removeEventListener('abort', removeTransaction); outstandingTransactions.value.delete(tx); outstandingTransactions.next(outstandingTransactions.value); }; const txComplete = () => { if (tx.mutationsAdded && !isEagerSyncDisabled(db)) { triggerSync(db, 'push'); } removeTransaction(); }; tx.addEventListener('complete', txComplete); tx.addEventListener('error', removeTransaction); tx.addEventListener('abort', removeTransaction); } return tx; }, table: (tableName) => { const table = core.table(tableName); if (/^\$/.test(tableName)) { if (tableName.endsWith('_mutations')) { // In case application code adds items to ..._mutations tables, // make sure to set the mutationsAdded flag on transaction. // This is also done in mutateAndLog() as that function talks to a // lower level DBCore and wouldn't be catched by this code. return { ...table, mutate: (req) => { if (req.type === 'add' || req.type === 'put') { ( req.trans as DBCoreTransaction & TXExpandos ).mutationsAdded = true; } return table.mutate(req); }, }; } else if (tableName === '$logins') { return { ...table, mutate: (req) => { //console.debug('Mutating $logins table', req); return table .mutate(req) .then((res) => { //console.debug('Mutating $logins'); ( req.trans as DBCoreTransaction & TXExpandos ).mutationsAdded = true; //console.debug('$logins mutated'); return res; }) .catch((err) => { console.debug('Failed mutation $logins', err); return Promise.reject(err); }); }, }; } else { return table; } } const { schema } = table; const mutsTable = mutTableMap.get(tableName)!; if (!mutsTable) { // We cannot track mutations on this table because there is no mutations table for it. // This might happen in upgraders that executes before cloud schema is applied. return table; } return guardedTable({ ...table, mutate: (req) => { const trans = req.trans as DBCoreTransaction & TXExpandos; if (!trans.txid) return table.mutate(req); // Upgrade transactions not guarded by us. if (trans.disableChangeTracking) return table.mutate(req); if (!db.cloud.schema?.[tableName]?.markedForSync) return table.mutate(req); if (!trans.currentUser?.isLoggedIn) { // Unauthorized user should not log mutations. // Instead, after login all local data should be logged at once. return table.mutate(req); } return req.type === 'deleteRange' ? table // Query the actual keys (needed for server sending correct rollback to us) .query({ query: { range: req.range, index: schema.primaryKey }, trans: req.trans, values: false, }) // Do a delete request instead, but keep the criteria info for the server to execute .then((res) => { return mutateAndLog({ type: 'delete', keys: res.result, trans: req.trans, criteria: { index: null, range: req.range }, }); }) : mutateAndLog(req); }, }); function mutateAndLog( req: DBCoreDeleteRequest | DBCoreAddRequest | DBCorePutRequest ): Promise { const trans = req.trans as DBCoreTransaction & TXExpandos; const unsyncedProps = db.cloud.options?.unsyncedProperties?.[tableName]; const { txid, currentUser: { userId }, } = trans; const { type } = req; const opNo = ++trans.opCount; function stripChangeSpec(changeSpec: { [keyPath: string]: any }) { if (!unsyncedProps) return changeSpec; let rv = changeSpec; for (const keyPath of Object.keys(changeSpec)) { if ( unsyncedProps.some( (p) => keyPath === p || keyPath.startsWith(p + '.') ) ) { if (rv === changeSpec) rv = { ...changeSpec }; // clone on demand delete rv[keyPath]; } } return rv; } return table.mutate(req).then((res) => { const { numFailures: hasFailures, failures } = res; let keys = type === 'delete' ? req.keys! : res.results!; let values = 'values' in req ? req.values : []; let changeSpec = 'changeSpec' in req ? req.changeSpec : undefined; let updates = 'updates' in req ? req.updates : undefined; let upsert = updates && 'upsert' in req ? req.upsert : false; if (hasFailures) { keys = keys.filter((_, idx) => !failures[idx]); values = values.filter((_, idx) => !failures[idx]); } if (unsyncedProps) { // Filter out unsynced properties values = values.map((value) => { const newValue = { ...value }; for (const prop of unsyncedProps) { delete newValue[prop]; } return newValue; }); if (changeSpec) { // modify operation with criteria and changeSpec. // We must strip out unsynced properties from changeSpec. // We deal with criteria later. changeSpec = stripChangeSpec(changeSpec); if (Object.keys(changeSpec).length === 0) { // Nothing to change on server return res; } } if (updates) { let strippedChangeSpecs = updates.changeSpecs.map(stripChangeSpec); let newUpdates: DBCorePutRequest['updates'] = { keys: [], changeSpecs: [], }; const validKeys = new RangeSet(); let anyChangeSpecBecameEmpty = false; if (!upsert) { for (let i = 0, l = strippedChangeSpecs.length; i < l; ++i) { if (Object.keys(strippedChangeSpecs[i]).length > 0) { newUpdates.keys.push(updates.keys[i]); newUpdates.changeSpecs.push(strippedChangeSpecs[i]); validKeys.addKey(updates.keys[i]); } else { anyChangeSpecBecameEmpty = true; } } updates = newUpdates; if (anyChangeSpecBecameEmpty) { // Some keys were stripped. We must also strip them from keys and values // unless this is an upsert operation in which case we want to send them all. let newKeys: any[] = []; let newValues: any[] = []; for (let i = 0, l = keys.length; i < l; ++i) { if (validKeys.hasKey(keys[i])) { newKeys.push(keys[i]); newValues.push(values[i]); } } keys = newKeys; values = newValues; } } } } const ts = Date.now(); // Canonicalize req.criteria.index to null if it's on the primary key. let criteria = 'criteria' in req && req.criteria ? { ...req.criteria, index: req.criteria.index === schema.primaryKey.keyPath // Use null to inform server that criteria is on primary key ? null // This will disable the server from trying to log consistent operations where it shouldnt. : req.criteria.index, } : undefined; if (unsyncedProps && criteria?.index) { const keyPaths = schema.indexes.find( (idx) => idx.name === criteria!.index )?.keyPath; const involvedProps = keyPaths ? typeof keyPaths === 'string' ? [keyPaths] : keyPaths : []; if (involvedProps.some((p) => unsyncedProps?.includes(p))) { // Don't log criteria on unsynced properties as the server could not test them. criteria = undefined; } } const mut: DBOperation = req.type === 'delete' ? { type: 'delete', ts, opNo, keys, criteria, txid, userId, } : req.type === 'add' ? { type: 'insert', ts, opNo, keys, txid, userId, values, } : upsert && updates ? { type: 'upsert', ts, opNo, keys, values, changeSpecs: updates.changeSpecs.filter((_, idx) => !failures[idx]), txid, userId, } : criteria && changeSpec ? { // Common changeSpec for all keys type: 'modify', ts, opNo, keys, criteria, changeSpec, txid, userId, } : changeSpec ? { // In case criteria involved an unsynced property, we go for keys instead. type: 'update', ts, opNo, keys, changeSpecs: keys.map(() => changeSpec!), txid, userId, } : updates ? { // One changeSpec per key type: 'update', ts, opNo, keys: updates.keys, changeSpecs: updates.changeSpecs, txid, userId, } : { type: 'upsert', ts, opNo, keys, values, txid, userId, }; if ('isAdditionalChunk' in req && req.isAdditionalChunk) { mut.isAdditionalChunk = true; } return keys.length > 0 || criteria ? mutsTable .mutate({ type: 'add', trans, values: [mut] }) // Log entry .then(() => { trans.mutationsAdded = true; // Mark transaction as having added mutations to trigger eager sync return res; // Return original response }) : res; }); } }, }; }, }; } ================================================ FILE: addons/dexie-cloud/src/middlewares/outstandingTransaction.ts ================================================ import { DBCoreTransaction } from 'dexie'; import { BehaviorSubject } from 'rxjs'; import { TXExpandos } from '../types/TXExpandos'; export const outstandingTransactions = new BehaviorSubject>(new Set()); ================================================ FILE: addons/dexie-cloud/src/overrideParseStoresSpec.ts ================================================ import Dexie, { DbSchema } from 'dexie'; import { DEXIE_CLOUD_SCHEMA } from './db/DexieCloudDB'; import { generateTablePrefix } from './middleware-helpers/idGenerationHelpers'; export function overrideParseStoresSpec(origFunc: Function, dexie: Dexie) { return function(stores: {[tableName: string]: string}, dbSchema: DbSchema) { const storesClone = { ...DEXIE_CLOUD_SCHEMA, ...stores, }; // Merge indexes of DEXIE_CLOUD_SCHEMA with stores Object.keys(DEXIE_CLOUD_SCHEMA).forEach((tableName: keyof typeof DEXIE_CLOUD_SCHEMA) => { const schemaSrc = storesClone[tableName]; // Verify that they don't try to delete a table that is needed for access control of Dexie Cloud if (schemaSrc == null) { // They try to delete one of the built-in schema tables. throw new Error(`Cannot delete table ${tableName} as it is needed for access control of Dexie Cloud`); } // If not trying to override a built-in table, then we can skip this and continue to next table. if (!stores[tableName]) { // They haven't tried to declare this table. No need to merge indexes. return; // Continue } // They have declared this table. Merge indexes in case they didn't declare all indexes we need. const requestedIndexes = schemaSrc.split(',').map(spec => spec.trim()); const builtInIndexes = DEXIE_CLOUD_SCHEMA[tableName].split(',').map(spec => spec.trim()); const requestedIndexSet = new Set(requestedIndexes.map(index => index.replace(/([&*]|\+\+)/g, ""))); // Verify that primary key is unchanged if (requestedIndexes[0] !== builtInIndexes[0]) { // Primary key must match exactly throw new Error(`Cannot override primary key of table ${tableName}. Please declare it as {${ tableName}: ${ JSON.stringify(DEXIE_CLOUD_SCHEMA[tableName]) }`); } // Merge indexes for (let i=1; i(); Object.keys(storesClone).forEach(tableName => { const schemaSrc = storesClone[tableName]?.trim(); const cloudTableSchema = cloudSchema[tableName] || (cloudSchema[tableName] = {}); if (schemaSrc != null) { if (/^\@/.test(schemaSrc)) { storesClone[tableName] = storesClone[tableName].substr(1); cloudTableSchema.generatedGlobalId = true; cloudTableSchema.idPrefix = generateTablePrefix(tableName, allPrefixes); allPrefixes.add(cloudTableSchema.idPrefix); } if (!/^\$/.test(tableName)) { storesClone[`$${tableName}_mutations`] = '++rev'; cloudTableSchema.markedForSync = true; // Add sparse index for _hasBlobRefs (for BlobRef resolution tracking) // IndexedDB sparse indexes have zero overhead when the property doesn't exist if (!storesClone[tableName].includes('_hasBlobRefs')) { storesClone[tableName] += ',_hasBlobRefs'; } } if (cloudTableSchema.deleted) { cloudTableSchema.deleted = false; } } else { cloudTableSchema.deleted = true; cloudTableSchema.markedForSync = false; storesClone[`$${tableName}_mutations`] = null; } }); const rv = origFunc.call(this, storesClone, dbSchema); for (const [tableName, spec] of Object.entries(dbSchema)) { if (spec.yProps?.length) { const cloudTableSchema = cloudSchema[tableName]; if (cloudTableSchema) { cloudTableSchema.yProps = spec.yProps.map((yProp) => yProp.prop); } } } return rv; } } ================================================ FILE: addons/dexie-cloud/src/performInitialSync.ts ================================================ import { DexieCloudSchema } from 'dexie-cloud-common'; import { DexieCloudDB } from './db/DexieCloudDB'; import { DexieCloudOptions } from './DexieCloudOptions'; import { CURRENT_SYNC_WORKER, sync } from './sync/sync'; import { performGuardedJob } from './sync/performGuardedJob'; export async function performInitialSync( db: DexieCloudDB, cloudOptions: DexieCloudOptions, cloudSchema: DexieCloudSchema ) { console.debug('Performing initial sync'); await performGuardedJob( db, CURRENT_SYNC_WORKER, () => sync(db, cloudOptions, cloudSchema, { isInitialSync: true }) ); console.debug('Done initial sync'); } ================================================ FILE: addons/dexie-cloud/src/permissions.ts ================================================ import Dexie, { liveQuery } from 'dexie'; import { DBRealmMember } from 'dexie-cloud-common'; import { from, Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; import { mergePermissions } from './mergePermissions'; import { getPermissionsLookupObservable, PermissionsLookup, } from './getPermissionsLookupObservable'; import { PermissionChecker } from './PermissionChecker'; import './extend-dexie-interface'; export function permissions( dexie: Dexie, obj: { owner?: string; realmId?: string; table?: () => string }, tableName?: string ): Observable> { if (!obj) throw new TypeError( `Cannot check permissions of undefined or null. A Dexie Cloud object with realmId and owner expected.` ); const { owner, realmId } = obj; if (!tableName) { if (typeof obj.table !== 'function') { throw new TypeError( `Missing 'table' argument to permissions and table could not be extracted from entity` ); } tableName = obj.table(); } const source = getPermissionsLookupObservable(dexie); const mapper = (permissionsLookup: PermissionsLookup) => { // If realmId is undefined, it can be due to that the object is not yet syncified - it exists // locally only as the user might not yet be authenticated. This is ok and we shall treat it // as if the realmId is dexie.cloud.currentUserId (which is "unauthorized" by the way) const realm = permissionsLookup[realmId || dexie.cloud.currentUserId]; if (!realm) return new PermissionChecker( {}, tableName!, !owner || owner === dexie.cloud.currentUserId ); return new PermissionChecker( realm.permissions, tableName!, realmId === undefined || realmId === dexie.cloud.currentUserId || owner === dexie.cloud.currentUserId ); }; const o = source.pipe(map(mapper)) as Observable> & { getValue: () => PermissionChecker; }; o.getValue = () => mapper(source.getValue()); return o; } ================================================ FILE: addons/dexie-cloud/src/prodLog.ts ================================================ /** A way to log to console in production without terser stripping out * it from the release bundle. * This should be used very rarely and only in places where it's * absolutely necessary to log something in production. * * @param level * @param args */ export function prodLog(level: 'log' | 'warn' | 'error' | 'debug', ...args: any[]) { globalThis["con"+"sole"][level](...args); } ================================================ FILE: addons/dexie-cloud/src/service-worker.ts ================================================ import Dexie from 'dexie'; import { DexieCloudDB } from './db/DexieCloudDB'; import dexieCloud from './dexie-cloud-client'; import { DISABLE_SERVICEWORKER_STRATEGY } from './DISABLE_SERVICEWORKER_STRATEGY'; import { isSafari, safariVersion } from './isSafari'; import { syncIfPossible } from './sync/syncIfPossible'; import { SWMessageEvent } from './types/SWMessageEvent'; import { SyncEvent } from './types/SWSyncEvent'; // In case the SW lives for a while, let it reuse already opened connections: const managedDBs = new Map(); function getDbNameFromTag(tag: string) { return tag.startsWith('dexie-cloud:') && tag.split(':')[1]; } const syncDBSemaphore = new Map>(); function syncDB(dbName: string, purpose: 'push' | 'pull') { // We're taking hight for being double-signalled both // via message event and sync event. // Which one comes first doesnt matter, just // that we return the existing promise if there is // an ongoing sync. let promise = syncDBSemaphore.get(dbName + '/' + purpose); if (!promise) { promise = _syncDB(dbName, purpose) .then(() => { // When legacy enough across browsers, use .finally() instead of then() and catch(): syncDBSemaphore.delete(dbName + '/' + purpose); }) .catch((error) => { syncDBSemaphore.delete(dbName + '/' + purpose); return Promise.reject(error); }); syncDBSemaphore.set(dbName + '/' + purpose, promise!); } return promise!; async function _syncDB(dbName: string, purpose: 'push' | 'pull') { let db = managedDBs.get(dbName); if (!db) { console.debug('Dexie Cloud SW: Creating new Dexie instance for', dbName); const dexie = new Dexie(dbName, { addons: [dexieCloud] }); db = DexieCloudDB(dexie); db.cloud.isServiceWorkerDB = true; dexie.on('versionchange', stopManagingDB); await db.dx.open(); // Makes sure db.cloud.options and db.cloud.schema are read from db, if (managedDBs.get(dbName)) { // Avoid race conditions. db.close(); return await _syncDB(dbName, purpose); } managedDBs.set(dbName, db); } if (!db.cloud.options?.databaseUrl) { console.error(`Dexie Cloud: No databaseUrl configured`); return; // Nothing to sync. } if (!db.cloud.schema) { console.error(`Dexie Cloud: No schema persisted`); return; // Nothing to sync. } function stopManagingDB() { db!.dx.on.versionchange.unsubscribe(stopManagingDB); if (managedDBs.get(db!.name) === db) { // Avoid race conditions. managedDBs.delete(db!.name); } console.debug(`Dexie Cloud SW: Closing Dexie instance for ${dbName}`); db!.dx.close(); return false; } try { console.debug('Dexie Cloud SW: Syncing'); await syncIfPossible(db, db.cloud.options, db.cloud.schema, { retryImmediatelyOnFetchError: true, purpose, }); console.debug('Dexie Cloud SW: Done Syncing'); } catch (e) { console.error(`Dexie Cloud SW Error`, e); // Error occured. Stop managing this DB until we wake up again by a sync event, // which will open a new Dexie and start trying to sync it. stopManagingDB(); if (e.name !== Dexie.errnames.NoSuchDatabase) { // Unless the error was that DB doesn't exist, rethrow to trigger sync retry. throw e; // Throw e to make syncEvent.waitUntil() receive a rejected promis, so it will retry. } } } } // Avoid taking care of events if browser bugs out by using dexie cloud from a service worker. if (!DISABLE_SERVICEWORKER_STRATEGY) { self.addEventListener('sync', (event: SyncEvent) => { console.debug('SW "sync" Event', event.tag); const dbName = getDbNameFromTag(event.tag); if (dbName) { event.waitUntil(syncDB(dbName, "push")); // The purpose of sync events are "push" } }); self.addEventListener('periodicsync', (event: SyncEvent) => { console.debug('SW "periodicsync" Event', event.tag); const dbName = getDbNameFromTag(event.tag); if (dbName) { event.waitUntil(syncDB(dbName, "pull")); // The purpose of periodic sync events are "pull" } }); self.addEventListener('message', (event: SWMessageEvent) => { console.debug('SW "message" Event', event.data); if (event.data.type === 'dexie-cloud-sync') { const { dbName } = event.data; // Mimic background sync behavior - retry in X minutes on failure. // But lesser timeout and more number of times. const syncAndRetry = (num = 1) => { return syncDB(dbName, event.data.purpose || "pull").catch(async (e) => { if (num === 3) throw e; await sleep(60_000); // 1 minute syncAndRetry(num + 1); }); }; if ('waitUntil' in event) { event.waitUntil(syncAndRetry().catch(error => console.error(error))); } else { syncAndRetry().catch(error => console.error(error)); } } }); } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } ================================================ FILE: addons/dexie-cloud/src/sync/BLOB_TODO.md ================================================ - [v] Bug: we're always resolving BlobRefs to Uint8Array but it could be a Blob, ArrayBuffer or other binary type. The exact type should have been stored in the TSON structure. Now it's $t: "Blob". Probably that is misleading and we should call it BlobRef for this. - Continue review the rest of the code # Beta Checklist - [x] ~~Remove `blobRefAwareTypeDefs` from TSON.ts~~ NOT NEEDED — these are required for lazy/eager blob resolution - [ ] Unskip lazy mode E2E test and verify it passes - [ ] Unskip blobProgress E2E test and verify it passes - [ ] David: review & merge PR #2255 (Dexie.js) + PR #75 (dexie-cloud) - [ ] Debug logging via `DEXIE_CLOUD_DEBUG` env var / `__DEBUG__` rollup guards (nice-to-have) # Reviewed files in the ongoing PR - [v] .github/workflows/publish-ci.yml - [v] addons/dexie-cloud/src/sync/BlobSavingQueue.ts - [v] addons/dexie-cloud/src/sync/blobResolve.ts - [v] addons/dexie-cloud/src/middlewares/blobResolveMiddleware.ts - [v] openCursor - [v] get - [v] getMany - [v] query - [v] addons/dexie-cloud/src/DexieCloudAPI.ts - [v] addons/dexie-cloud/src/DexieCloudOptions.ts - [v] addons/dexie-cloud/src/dexie-cloud-client.ts - [v] addons/dexie-cloud/src/overrideParseStoresSpec.ts - [v] addons/dexie-cloud/src/sync/eagerBlobDownloader.ts - [v] samples/dexie-cloud-todo-app/src/db/TodoDB.ts - [v] addons/dexie-cloud/src/sync/blobProgress.ts - [v] addons/dexie-cloud/src/sync/syncWithServer.ts () - [v] addons/dexie-cloud/src/sync/applyServerChanges.ts (light) - [v] addons/dexie-cloud/src/sync/blobOffloading.ts (extensive) - [v] addons/dexie-cloud/src/sync/sync.ts (medium) - [v] docs/CI-PUBLISHING.md # Error handling - [ ] eagerBlobDownloader: see line 114 TODO. Depending on error, retry or stop trying. Maybe only retry N times over a time period of T. Right now, it will continue with next object and retry next time over and over. # Optimize middleware to skip intercepting when not necessary: - [ ] In applyServerChanges transaction, let syncState reflect which tables that have unresolved blobs, also let this momentarily be reflected in a memory set. - [ ] In the end of eager blob downloader, do a transaction that verifies all blobs are resolved and then remove the tables from the sync state. Within that same transaction, reset memory set. - [ ] in dexie-cloud-client, on-ready event, populate the memory set from sync state. - [ ] In middleware, skip doing the blob dance if the requested table isn't listed in the memory set. # Refactoring To-dos - [v] Rename `$unresolved` to `$hasBlobRefs` as it is more explanatory - [x] Rename `$t` → `_bt` and `$hasBlobRefs` → `_hasBlobRefs` (done 2026-03-09) - [x] Remove BlobRef-aware TSON type defs — TSON is transparent to BlobRefs (done 2026-03-09) - [ ] Move `BlobRef.ts` from `dexie-cloud-common/src/tson/types/` to `dexie-cloud-common/src/blob/` — it has nothing to do with TSON - [ ] Consolidate `isBlobRef` into one place in `dexie-cloud-common/src/blob/` — currently duplicated in client (`blobResolve.ts`), server (`BlobRefManager.ts`, `getObjectDiff.ts`, `expandBlobRefsForExport.ts`, `validateImportData.ts`) and E2E tests - [ ] Move Blob-offloading code in dexie-cloud-addon into its own sub directory to collect this feature into a single place - [ ] Review: `BlobRefContext`/`createBlobRefContext` in dexie-cloud-common — currently unused by server code, verify if still needed or can be removed - [ ] Review: `TSONRef` class in dexie-cloud-common — is it still used anywhere now that TSON doesn't revive BlobRefs? If not, remove. # Beware-ofs - If exception happens during blob uploading phase in sync.ts, the entire flow will be forgotten even if some blobs were uploaded. Could there be a risk of eternal loop if some object causes a failure and the client keeps uploading blobs over and over with new IDs? If so, can could handle partial failures specifically? For example, to catch each operation in the loop in offloadBlobsInOperations() and if that specific situation occurs only (some succeed and then failure), update the "XXX_changes" with the BlobRef entries uploaded so far. Next sync would then continue to retry with the failed ones only. Don't know if this could be a real problem or not. Probably the common case is network failure during upload and a re-upload eternal loop might not even be a problem unless we have a bug in the code triggered by certain data. ================================================ FILE: addons/dexie-cloud/src/sync/BlobDownloadTracker.ts ================================================ import type { DexieCloudDB } from "../db/DexieCloudDB"; import { BlobRef } from "./blobResolve"; import { loadCachedAccessToken } from "./loadCachedAccessToken"; /** * Deduplicates in-flight blob downloads. * * Both the blob-resolve middleware and the eager blob downloader may * try to fetch the same blob concurrently. This tracker ensures each * unique blob ref is only downloaded once — subsequent requests for * the same ref piggyback on the existing promise. * * Instantiate once per DexieCloudDB. */ export class BlobDownloadTracker { private inFlight = new Map>(); private db: DexieCloudDB; constructor (db: DexieCloudDB) { this.db = db; } /** * Download a blob, deduplicating concurrent requests for the same ref. * * @param blobRef - The BlobRef to download * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud') */ download(blobRef: BlobRef, dbUrl: string): Promise { let promise = this.inFlight.get(blobRef.ref); if (!promise) { promise = loadCachedAccessToken(this.db).then(accessToken => { if (!accessToken) throw new Error("No access token available for blob download"); return downloadBlob(blobRef, dbUrl, accessToken); }).finally(() => this.inFlight.delete(blobRef.ref)); // When the promise settles (either fulfilled or rejected), remove it from the in-flight map this.inFlight.set(blobRef.ref, promise); } return promise; } } /** * Download blob data from server via proxy endpoint. * Uses auth header for authentication (same as sync). * * @param blobRef - The BlobRef to download * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud') * @param accessToken - Access token for authentication */ export async function downloadBlob( blobRef: BlobRef, dbUrl: string, accessToken: string ): Promise { const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`; const response = await fetch(downloadUrl, { headers: { 'Authorization': `Bearer ${accessToken}` } }); if (!response.ok) { throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); return new Uint8Array(arrayBuffer); } ================================================ FILE: addons/dexie-cloud/src/sync/BlobSavingQueue.ts ================================================ /** * BlobSavingQueue - Queues resolved blobs for saving back to IndexedDB * * Uses setTimeout(fn, 0) instead of queueMicrotask to completely isolate * from Dexie's Promise.PSD context. This prevents the save operation * from inheriting any ongoing transaction. * * Each blob is saved atomically using downCore transaction with the specific * keyPath to avoid race conditions with other property changes. */ import Dexie, { UpdateSpec } from 'dexie'; import { isBlobRef, ResolvedBlob } from './blobResolve'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { TXExpandos } from '../types/TXExpandos'; interface QueuedBlob { tableName: string; primaryKey: any; resolvedBlobs: ResolvedBlob[]; } export class BlobSavingQueue { private queue: QueuedBlob[] = []; private isProcessing = false; private db: DexieCloudDB; constructor(db: DexieCloudDB) { this.db = db; } /** * Queue a resolved blob for saving. * Only the specific blob property will be updated atomically. */ saveBlobs(tableName: string, primaryKey: any, resolvedBlobs: ResolvedBlob[]): void { this.queue.push({ tableName, primaryKey, resolvedBlobs }); this.startConsumer(); } /** * Start the consumer if not already processing. * Uses setTimeout(fn, 0) to completely break out of any * Dexie transaction context (Promise.PSD). */ private startConsumer(): void { if (this.isProcessing) return; this.isProcessing = true; // Use setTimeout to completely isolate from Dexie's PSD context // queueMicrotask would risk inheriting the current transaction setTimeout(() => { this.processQueue(); }, 0); } /** * Process all queued blobs. * Runs in a completely isolated context (no inherited transaction). * Uses atomic updates to avoid race conditions. */ private processQueue(): void { const item = this.queue.shift(); if (!item) { this.isProcessing = false; return; } // Atomic update of just the blob property this.db.transaction('rw', item.tableName, (tx) => { const trans = tx.idbtrans as IDBTransaction & TXExpandos; trans.disableChangeTracking = true; // Don't regard this as a change for sync purposes trans.disableAccessControl = true; // Bypass any access control checks since this is an internal operation trans.disableBlobResolve = true; // Custom flag to skip blob resolve middleware during this transaction const updateSpec: UpdateSpec = {}; for (const blob of item.resolvedBlobs) { updateSpec[blob.keyPath] = blob.data; } tx.table(item.tableName).update(item.primaryKey, obj => { // Check that object still has the same unresolved blob refs before applying update (i.e. it hasn't been modified since we read it) for (const blob of item.resolvedBlobs) { // Verify atomicity - none of the blob properties has been modified since we read it. If any of them was modified, skip updating this item to avoid overwriting user changes. const currentValue = Dexie.getByKeyPath(obj, blob.keyPath); if (currentValue === undefined) { // Blob property was removed - skip updating this blob continue; } if (!isBlobRef(currentValue)) { // Blob property was modified to a non-blob-ref value - skip updating this blob continue; } if (currentValue.ref !== blob.ref) { // Blob property was modified - skip updating this blob return; // Stop. Another items has been queued to fully fix the object. } Dexie.setByKeyPath(obj, blob.keyPath, blob.data); } delete obj._hasBlobRefs; // Clear the _hasBlobRefs marker if all refs was resolved. }); }).catch((error) => { console.error(`Error saving resolved blobs on ${item.tableName}:${item.primaryKey}:`, error); }).finally(() => { // Process next item in the queue return this.processQueue(); }); } } ================================================ FILE: addons/dexie-cloud/src/sync/DEXIE_CLOUD_SYNCER_ID.ts ================================================ export const DEXIE_CLOUD_SYNCER_ID = 'dexie-cloud-syncer'; ================================================ FILE: addons/dexie-cloud/src/sync/LocalSyncWorker.ts ================================================ import { Subscription } from 'rxjs'; import { syncIfPossible } from './syncIfPossible'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { SECONDS } from '../helpers/date-constants'; import { DexieCloudOptions } from '../DexieCloudOptions'; import { DexieCloudSchema } from 'dexie-cloud-common'; export function LocalSyncWorker( db: DexieCloudDB, cloudOptions: DexieCloudOptions, cloudSchema: DexieCloudSchema ) { let localSyncEventSubscription: Subscription | null = null; let cancelToken = { cancelled: false }; let nextRetryTime = 0; let syncStartTime = 0; function syncAndRetry(retryNum = 1) { // Use setTimeout() to get onto a clean stack and // break free from possible active transaction: setTimeout(() => { const purpose = pullSignalled ? 'pull' : 'push'; syncStartTime = Date.now(); syncIfPossible(db, cloudOptions, cloudSchema, { cancelToken, retryImmediatelyOnFetchError: true, // workaround for "net::ERR_NETWORK_CHANGED" in chrome. purpose, }).then(()=>{ if (cancelToken.cancelled) { stop(); } else { if (pullSignalled || pushSignalled) { // If we have signalled for more sync, do it now. pullSignalled = false; pushSignalled = false; return syncAndRetry(); } } ongoingSync = false; nextRetryTime = 0; syncStartTime = 0; }).catch((error: unknown) => { console.error('error in syncIfPossible()', error); if (cancelToken.cancelled) { stop(); ongoingSync = false; nextRetryTime = 0; syncStartTime = 0; } else if (retryNum < 5) { // Mimic service worker sync event but a bit more eager: retry 4 times // * first retry after 20 seconds // * second retry 40 seconds later // * third retry 5 minutes later // * last retry 15 minutes later const retryIn = [0, 20, 40, 300, 900][retryNum] * SECONDS nextRetryTime = Date.now() + retryIn; syncStartTime = 0; setTimeout( () => syncAndRetry(retryNum + 1), retryIn ); } else { ongoingSync = false; nextRetryTime = 0; syncStartTime = 0; } }); }, 0); } let pullSignalled = false; let pushSignalled = false; let ongoingSync = false; const consumer = (purpose: 'pull' | 'push') =>{ if (cancelToken.cancelled) return; if (purpose === 'pull') { pullSignalled = true; } if (purpose === 'push') { pushSignalled = true; } if (ongoingSync) { if (nextRetryTime) { console.debug(`Sync is paused until ${new Date(nextRetryTime).toISOString()} due to error in last sync attempt`); } else if (syncStartTime > 0 && Date.now() - syncStartTime > 20 * SECONDS) { console.debug(`An existing sync operation is taking more than 20 seconds. Will resync when done.`) } return; } ongoingSync = true; syncAndRetry(); }; const start = () => { // Sync eagerly whenever a change has happened (+ initially when there's no syncState yet) // This initial subscribe will also trigger an sync also now. console.debug('Starting LocalSyncWorker', db.localSyncEvent['id']); localSyncEventSubscription = db.localSyncEvent.subscribe(({ purpose }) => { consumer(purpose || 'pull'); }); }; const stop = () => { console.debug('Stopping LocalSyncWorker'); cancelToken.cancelled = true; if (localSyncEventSubscription) localSyncEventSubscription.unsubscribe(); }; return { start, stop, }; } ================================================ FILE: addons/dexie-cloud/src/sync/SyncRequiredError.ts ================================================ export class SyncRequiredError extends Error { name = "SyncRequiredError"; } ================================================ FILE: addons/dexie-cloud/src/sync/applyServerChanges.ts ================================================ import { DexieCloudDB } from '../db/DexieCloudDB'; import Dexie from 'dexie'; import { bulkUpdate } from '../helpers/bulkUpdate'; import { DBOperationsSet } from 'dexie-cloud-common'; import { hasBlobRefs } from './blobResolve'; /** * If the incoming value contains BlobRefs (e.g. offloaded strings or binaries), * mark it with _hasBlobRefs = 1 so the blobResolveMiddleware will resolve them * on the next read. */ function markIfHasBlobRefs(obj: unknown): void { if ( obj !== null && typeof obj === 'object' && (obj as any).constructor === Object && hasBlobRefs(obj) ) { (obj as any)._hasBlobRefs = 1; } } export async function applyServerChanges( changes: DBOperationsSet, db: DexieCloudDB ) { console.debug('Applying server changes', changes, Dexie.currentTransaction); for (const { table: tableName, muts } of changes) { if (!db.dx._allTables[tableName]) { console.debug( `Server sent changes for table ${tableName} that we don't have. Ignoring.` ); continue; } const table = db.table(tableName); const { primaryKey } = table.core.schema; const keyDecoder = (key: string) => { switch (key[0]) { case '[': // Decode JSON array if (key.endsWith(']')) try { // On server, array keys are transformed to JSON string representation return JSON.parse(key); } catch {} return key; case '#': // Decode private ID (do the opposite from what's done in encodeIdsForServer()) if (key.endsWith(':' + db.cloud.currentUserId)) { return key.substr( 0, key.length - db.cloud.currentUserId.length - 1 ); } return key; default: return key; } }; for (const mut of muts) { const keys = mut.keys.map(keyDecoder); switch (mut.type) { case 'insert': mut.values.forEach(markIfHasBlobRefs); if (primaryKey.outbound) { await table.bulkAdd(mut.values, keys); } else { keys.forEach((key, i) => { // Make sure inbound keys are consistent Dexie.setByKeyPath(mut.values[i], primaryKey.keyPath!, key); }); await table.bulkAdd(mut.values); } break; case 'upsert': mut.values.forEach(markIfHasBlobRefs); if (primaryKey.outbound) { await table.bulkPut(mut.values, keys); } else { keys.forEach((key, i) => { // Make sure inbound keys are consistent Dexie.setByKeyPath(mut.values[i], primaryKey.keyPath!, key); }); await table.bulkPut(mut.values); } break; case 'modify': if (keys.length === 1) { await table.update(keys[0], mut.changeSpec); } else { await table.where(':id').anyOf(keys).modify(mut.changeSpec); } break; case 'update': await bulkUpdate(table, keys, mut.changeSpecs); break; case 'delete': await table.bulkDelete(keys); break; } } } } ================================================ FILE: addons/dexie-cloud/src/sync/blobOffloading.test.ts ================================================ // Unit test for shouldOffloadBlob behavior // Verifies: Blob/File always offloaded; ArrayBuffer/TypedArray use 4KB threshold import { shouldOffloadBlob } from '../../src/sync/blobOffloading'; function assert(cond: boolean, msg: string) { if (!cond) throw new Error('FAIL: ' + msg); console.log('OK: ' + msg); } // Blob - always offloaded regardless of size assert(shouldOffloadBlob(new Blob([])), 'empty Blob (0 bytes) is offloaded'); assert(shouldOffloadBlob(new Blob(['x'])), '1-byte Blob is offloaded'); assert(shouldOffloadBlob(new Blob([new Uint8Array(1024)])), '1KB Blob is offloaded'); assert(shouldOffloadBlob(new Blob([new Uint8Array(8192)])), '8KB Blob is offloaded'); // File - always offloaded assert(shouldOffloadBlob(new File(['x'], 'test.txt')), '1-byte File is offloaded'); // ArrayBuffer - threshold at 4096 assert(!shouldOffloadBlob(new ArrayBuffer(1)), '1-byte ArrayBuffer is NOT offloaded'); assert(!shouldOffloadBlob(new ArrayBuffer(4095)), '4095-byte ArrayBuffer is NOT offloaded'); assert(shouldOffloadBlob(new ArrayBuffer(4096)), '4096-byte ArrayBuffer IS offloaded'); assert(shouldOffloadBlob(new ArrayBuffer(8192)), '8KB ArrayBuffer IS offloaded'); // Uint8Array - threshold at 4096 assert(!shouldOffloadBlob(new Uint8Array(1)), '1-byte Uint8Array is NOT offloaded'); assert(shouldOffloadBlob(new Uint8Array(4096)), '4096-byte Uint8Array IS offloaded'); // Primitives - never offloaded assert(!shouldOffloadBlob(null), 'null is not offloaded'); assert(!shouldOffloadBlob('string'), 'string is not offloaded'); assert(!shouldOffloadBlob(42), 'number is not offloaded'); assert(!shouldOffloadBlob({}), 'plain object is not offloaded'); console.log('\nAll tests passed!'); ================================================ FILE: addons/dexie-cloud/src/sync/blobOffloading.ts ================================================ /** * Blob Offloading for Dexie Cloud * * Handles uploading large blobs to blob storage before sync, * and resolving BlobRefs when reading from the database. */ import { newId, DBOperationsSet, DBOperation } from 'dexie-cloud-common'; import { BlobRef, BlobRefOrigType, isBlobRef as isBlobRefFromResolve } from './blobResolve'; // Blobs >= 4KB are offloaded to blob storage const BLOB_OFFLOAD_THRESHOLD = 4096; // Default max string length before offloading (32KB characters) export const DEFAULT_MAX_STRING_LENGTH = 32768; // Cache: once we know the server doesn't support blob storage, skip future uploads. // Maps databaseUrl → boolean (true = supported, false = not supported). const blobEndpointSupported = new Map(); // Re-export BlobRef type export type { BlobRef, BlobRefOrigType }; // Re-export isBlobRef from blobResolve export const isBlobRef = isBlobRefFromResolve; /** * Cross-realm type detection helpers (performance-optimized) * * When code runs in different JavaScript realms (e.g., Service Worker context), * `instanceof` checks can fail because each realm has its own global constructors. * We use Object.prototype.toString which works reliably across realms. * * Performance considerations (this is a hot path - every property is checked): * - Early return for primitives via typeof * - Static Set for O(1) TypedArray tag lookup * - Single typeTag call per check */ // TypedArray/DataView tags for size check const ARRAYBUFFER_VIEW_TAGS = new Set([ 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', 'BigInt64Array', 'BigUint64Array', 'DataView' ]); // Static Set for O(1) lookup of binary type tags const BINARY_TYPE_TAGS = new Set([ 'Blob', 'File', 'ArrayBuffer', ...ARRAYBUFFER_VIEW_TAGS, ]); /** * Get the [[Class]] internal property via Object.prototype.toString */ function getTypeTag(value: unknown): string { return Object.prototype.toString.call(value).slice(8, -1); } /** * Get the original type name for a value */ function getOrigType(value: Blob | ArrayBuffer | ArrayBufferView): BlobRefOrigType { const tag = getTypeTag(value); if (tag === 'Blob' || tag === 'File') return 'Blob'; if (tag === 'ArrayBuffer') return 'ArrayBuffer'; return tag as BlobRefOrigType; } /** * Check if a value should be offloaded to blob storage * Performance-optimized for hot path traversal. */ export function shouldOffloadBlob(value: unknown): value is Blob | ArrayBuffer | ArrayBufferView { // Fast path: primitives (most common case) // typeof returns: "string", "number", "boolean", "undefined", "symbol", "bigint", "function", "object" const t = typeof value; if (t !== 'object' || value === null) return false; // Get type tag once (cross-realm safe) const tag = getTypeTag(value); // Quick check: is this even a binary type? if (!BINARY_TYPE_TAGS.has(tag)) return false; // Blob/File: always offload regardless of size. // This ensures blobs are never stored inline in IndexedDB, which avoids // issues with synchronous blob reading (e.g. in service workers where // XMLHttpRequest is unavailable — see #2182). if (tag === 'Blob' || tag === 'File') { return true; } // ArrayBuffer/TypedArray/DataView: only offload above threshold if (tag === 'ArrayBuffer') { return (value as ArrayBuffer).byteLength >= BLOB_OFFLOAD_THRESHOLD; } // TypedArray or DataView return (value as ArrayBufferView).byteLength >= BLOB_OFFLOAD_THRESHOLD; } /** * Upload a blob to the blob storage endpoint */ export async function uploadBlob( databaseUrl: string, getCachedAccessToken: () => Promise, blob: Blob | ArrayBuffer | ArrayBufferView ): Promise { const accessToken = await getCachedAccessToken(); if (!accessToken) { throw new Error('Failed to load access token for blob upload'); } const blobId = newId(); // URL format: {databaseUrl}/blob/{blobId} const url = `${databaseUrl}/blob/${blobId}`; let body: Blob | ArrayBuffer; let contentType: string; let size: number; const origType = getOrigType(blob); // Use type tag for cross-realm compatible checks const tag = getTypeTag(blob); if (tag === 'Blob' || tag === 'File') { body = blob as Blob; contentType = (blob as Blob).type || 'application/octet-stream'; size = (blob as Blob).size; } else if (tag === 'ArrayBuffer') { body = blob as ArrayBuffer; contentType = 'application/octet-stream'; size = (blob as ArrayBuffer).byteLength; } else if (ARRAYBUFFER_VIEW_TAGS.has(tag)) { // ArrayBufferView (TypedArray or DataView) - create a proper ArrayBuffer copy const view = blob as ArrayBufferView; const arrayBuffer = new ArrayBuffer(view.byteLength); new Uint8Array(arrayBuffer).set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); body = arrayBuffer; contentType = 'application/octet-stream'; size = view.byteLength; } else { throw new Error(`Unsupported blob type: ${tag}`); } // Add content type as query param for the server to store const uploadUrl = `${url}?ct=${encodeURIComponent(contentType)}`; const response = await fetch(uploadUrl, { method: 'PUT', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': contentType, }, body, }); if (!response.ok) { if (response.status === 404 || response.status === 405) { // Server doesn't support blob storage endpoint — fall back to inline storage. // This happens when a new client connects to an older server (pre-3.0). return null; } throw new Error(`Failed to upload blob: ${response.status} ${response.statusText}`); } // The server returns the ref with version prefix (e.g., "1:blobId") const result = await response.json(); // Return BlobRef with server's ref (includes version) and original type preserved in _bt return { _bt: origType, ref: result.ref, size: size, ...(origType === 'Blob' ? { ct: contentType } : {}) // Only include content type for Blobs }; } export async function offloadBlobsAndMarkDirty( obj: unknown, databaseUrl: string, getCachedAccessToken: () => Promise, maxStringLength = DEFAULT_MAX_STRING_LENGTH ): Promise { const dirtyFlag = { dirty: false }; const result = await offloadBlobs(obj, databaseUrl, getCachedAccessToken, maxStringLength, dirtyFlag); // Mark the object as dirty for sync if any blobs were offloaded if (dirtyFlag.dirty && typeof result === 'object' && result !== null && result.constructor === Object) { (result as any)._hasBlobRefs = 1; } return result; } /** * Recursively scan an object for large blobs and upload them * Returns a new object with blobs replaced by BlobRefs */ export async function offloadBlobs( obj: unknown, databaseUrl: string, getCachedAccessToken: () => Promise, maxStringLength = DEFAULT_MAX_STRING_LENGTH, dirtyFlag = { dirty: false }, visited = new WeakSet() ): Promise { if (obj === null || obj === undefined) { return obj; } // Check if this is a long string that should be offloaded if (typeof obj === 'string' && obj.length > maxStringLength && maxStringLength !== Infinity) { if (blobEndpointSupported.get(databaseUrl) === false) { return obj; } const blob = new Blob([obj], { type: 'text/plain;charset=utf-8' }); const blobRef = await uploadBlob(databaseUrl, getCachedAccessToken, blob); if (blobRef === null) { blobEndpointSupported.set(databaseUrl, false); return obj; } blobEndpointSupported.set(databaseUrl, true); dirtyFlag.dirty = true; // Mark as string type so it's resolved back to string, not Blob return { ...blobRef, _bt: 'string', }; } // Check if this is a blob that should be offloaded if (shouldOffloadBlob(obj)) { if (blobEndpointSupported.get(databaseUrl) === false) { // Server known to not support blob storage — keep inline return obj; } const blobRef = await uploadBlob(databaseUrl, getCachedAccessToken, obj); if (blobRef === null) { // Server doesn't support blob storage — keep original inline blobEndpointSupported.set(databaseUrl, false); return obj; } blobEndpointSupported.set(databaseUrl, true); dirtyFlag.dirty = true; return blobRef; } if (typeof obj !== 'object') { return obj; } // Avoid circular references - check BEFORE processing if (visited.has(obj)) { return obj; } visited.add(obj); // Handle arrays if (Array.isArray(obj)) { const result: unknown[] = []; for (const item of obj) { result.push(await offloadBlobs(item, databaseUrl, getCachedAccessToken, maxStringLength, dirtyFlag, visited)); } return result; } // Traverse plain objects (POJO-like) - use prototype check since IndexedDB // may return objects where constructor !== Object const proto = Object.getPrototypeOf(obj); if (proto !== Object.prototype && proto !== null) { return obj; } const result: Record = {}; for (const [key, value] of Object.entries(obj)) { result[key] = await offloadBlobs(value, databaseUrl, getCachedAccessToken, maxStringLength, dirtyFlag, visited); } return result; } /** * Process a DBOperationsSet and offload any large blobs * Returns a new DBOperationsSet with blobs replaced by BlobRefs */ export async function offloadBlobsInOperations( operations: DBOperationsSet, databaseUrl: string, getCachedAccessToken: () => Promise, maxStringLength = DEFAULT_MAX_STRING_LENGTH ): Promise { const result: DBOperationsSet = []; for (const tableOps of operations) { const processedMuts: DBOperation[] = []; for (const mut of tableOps.muts) { const processedMut = await offloadBlobsInOperation(mut, databaseUrl, getCachedAccessToken, maxStringLength); processedMuts.push(processedMut); } result.push({ table: tableOps.table, muts: processedMuts, }); } return result; } async function offloadBlobsInOperation( op: DBOperation, databaseUrl: string, getCachedAccessToken: ()=> Promise, maxStringLength = DEFAULT_MAX_STRING_LENGTH ): Promise { switch (op.type) { case 'insert': case 'upsert': { const processedValues = await Promise.all( op.values.map(value => offloadBlobsAndMarkDirty(value, databaseUrl, getCachedAccessToken, maxStringLength)) ); return { ...op, values: processedValues, }; } case 'update': { const processedChangeSpecs = await Promise.all( op.changeSpecs.map(spec => offloadBlobsAndMarkDirty(spec, databaseUrl, getCachedAccessToken, maxStringLength)) ); return { ...op, changeSpecs: processedChangeSpecs as { [keyPath: string]: any }[], }; } case 'modify': { const processedChangeSpec = await offloadBlobsAndMarkDirty(op.changeSpec, databaseUrl, getCachedAccessToken, maxStringLength); return { ...op, changeSpec: processedChangeSpec as { [keyPath: string]: any }, }; } case 'delete': // No blobs in delete operations return op; default: return op; } } /** * Check if there are any large blobs in the operations that need offloading * This is a quick check to avoid unnecessary processing */ export function hasLargeBlobsInOperations(operations: DBOperationsSet, maxStringLength = DEFAULT_MAX_STRING_LENGTH): boolean { for (const tableOps of operations) { for (const mut of tableOps.muts) { if (hasLargeBlobsInOperation(mut, maxStringLength)) { return true; } } } return false; } function hasLargeBlobsInOperation(op: DBOperation, maxStringLength: number): boolean { switch (op.type) { case 'insert': case 'upsert': return op.values.some(value => hasLargeBlobs(value, maxStringLength)); case 'update': return op.changeSpecs.some(spec => hasLargeBlobs(spec, maxStringLength)); case 'modify': return hasLargeBlobs(op.changeSpec, maxStringLength); default: return false; } } function hasLargeBlobs(obj: unknown, maxStringLength: number, visited = new WeakSet()): boolean { if (obj === null || obj === undefined) { return false; } // Check long strings if (typeof obj === 'string' && obj.length > maxStringLength && maxStringLength !== Infinity) { return true; } if (shouldOffloadBlob(obj)) { return true; } if (typeof obj !== 'object') { return false; } // Avoid circular references - check BEFORE processing if (visited.has(obj)) { return false; } visited.add(obj); if (Array.isArray(obj)) { return obj.some(item => hasLargeBlobs(item, maxStringLength, visited)); } // Traverse plain objects (POJO-like) - use duck typing since IndexedDB // may return objects where constructor !== Object const proto = Object.getPrototypeOf(obj); if (proto === Object.prototype || proto === null) { return Object.values(obj).some(value => hasLargeBlobs(value, maxStringLength, visited)); } return false; } ================================================ FILE: addons/dexie-cloud/src/sync/blobProgress.ts ================================================ /** * Blob Progress Tracking * * Uses liveQuery to reactively track unresolved blob refs. * Any change to _hasBlobRefs in any syncable table automatically * triggers a re-scan — no manual updateBlobProgress() needed. */ import { BehaviorSubject, Observable, combineLatest, from, timer } from 'rxjs'; import { map, share } from 'rxjs/operators'; import { liveQuery } from 'dexie'; import { BlobProgress } from '../DexieCloudAPI'; import { isBlobRef, isSerializedTSONRef } from './blobResolve'; import { getSyncableTables } from '../helpers/getSyncableTables'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { TSONRef } from 'dexie-cloud-common'; /** * Unified reference info for both BlobRef and TSONRef */ interface RefInfo { ref: string; size: number; } /** * BehaviorSubject for the isDownloading flag, controlled by eagerBlobDownloader. */ export function createDownloadingState(): BehaviorSubject { return new BehaviorSubject(false); } /** * Set downloading state. */ export function setDownloadingState( downloading$: BehaviorSubject, isDownloading: boolean ): void { if (downloading$.value !== isDownloading) { downloading$.next(isDownloading); } } /** * Create a liveQuery-based Observable. * * Combines a liveQuery (blobsRemaining, bytesRemaining) with an external * isDownloading flag controlled by the eager downloader. */ export function observeBlobProgress( db: DexieCloudDB, downloading$: BehaviorSubject ): Observable { const blobStats$ = from(liveQuery(async () => { let blobsRemaining = 0; let bytesRemaining = 0; const syncedTables = getSyncableTables(db); await db.dx.transaction('r', syncedTables, async (tx) => { (tx.idbtrans as any).disableBlobResolve = true; for (const table of syncedTables) { const hasIndex = !!table.schema.idxByName['_hasBlobRefs']; if (!hasIndex) continue; const unresolvedObjects = await table .where('_hasBlobRefs') .equals(1) .toArray(); for (const obj of unresolvedObjects) { const blobs = findBlobRefs(obj); blobsRemaining += blobs.length; bytesRemaining += blobs.reduce( (sum, blob) => sum + (blob.size || 0), 0 ); } } }); return { blobsRemaining, bytesRemaining }; })); return combineLatest([blobStats$, downloading$]).pipe( map(([stats, isDownloading]) => ({ isDownloading: isDownloading && stats.blobsRemaining > 0, blobsRemaining: stats.blobsRemaining, bytesRemaining: stats.bytesRemaining, })), share({ resetOnRefCountZero: () => timer(2000) }) // Keep alive for 2s after last unsubscription to avoid rapid re-subscriptions during UI updates ); } /** * Find all unresolved refs (BlobRef or TSONRef) in an object (recursive). * Handles both live TSONRef instances and serialized TSONRefs (after IndexedDB). */ function findBlobRefs(obj: unknown): RefInfo[] { const refs: RefInfo[] = []; function scan(value: unknown): void { if (value === null || value === undefined) return; if (typeof value !== 'object') return; if (TSONRef.isTSONRef(value)) { refs.push({ ref: value.ref, size: value.size }); return; } if (isSerializedTSONRef(value)) { const obj = value as { type: string; ref: string; size: number }; refs.push({ ref: obj.ref, size: obj.size }); return; } if (isBlobRef(value)) { refs.push({ ref: value.ref, size: value.size || 0 }); return; } if (Array.isArray(value)) { value.forEach(scan); } else if (value.constructor === Object) { Object.values(value).forEach(scan); } } scan(obj); return refs; } ================================================ FILE: addons/dexie-cloud/src/sync/blobResolve.ts ================================================ import { BlobDownloadTracker } from './BlobDownloadTracker'; /** * BlobRef Resolution for Dexie Cloud * * Handles lazy resolution of BlobRefs when reading from the database. * BlobRefs are symbolic references to blobs stored in blob storage. * They get resolved on-demand when the object is read. * * The server sends offloaded binary data in the format: * { _bt: 'Uint8Array', ref: '1:blobId', size: 1234 } * { _bt: 'Blob', ref: '1:blobId', size: 1234, ct: 'image/png' } * * The _bt field preserves the original JavaScript type. * The ref format is '{version}:{blobId}' where version identifies * the storage backend configuration. */ /** * Original type that was offloaded to blob storage. * Matches the TSON type names. */ export type BlobRefOrigType = | 'Blob' | 'ArrayBuffer' | 'Uint8Array' | 'Int8Array' | 'Uint8ClampedArray' | 'Int16Array' | 'Uint16Array' | 'Int32Array' | 'Uint32Array' | 'Float32Array' | 'Float64Array' | 'BigInt64Array' | 'BigUint64Array' | 'DataView' | 'string'; /** * BlobRef represents a reference to binary data stored in blob storage. * The _bt field contains the original JavaScript type (Uint8Array, Blob, etc.) * The presence of 'ref' instead of 'v' indicates this is an offloaded blob. */ export interface BlobRef { _bt: BlobRefOrigType; ref: string; // Versioned ref: '{version}:{blobId}' size: number; // Size in bytes ct?: string; // Content-type (only for Blob type) } /** * Resolved blob with its keyPath for queueing */ export interface ResolvedBlob { keyPath: string; data: Blob | ArrayBuffer | ArrayBufferView | string; ref: string; } /** * Check if a value is a BlobRef (offloaded binary data) * A BlobRef has _bt (type), ref (blob ID), but no v (inline data) */ export function isBlobRef(value: unknown): value is BlobRef { if (typeof value !== 'object' || value === null) return false; const obj = value as any; return ( typeof obj._bt === 'string' && typeof obj.ref === 'string' && obj.v === undefined // No inline data = it's a reference ); } /** * Serialized TSONRef shape (after IndexedDB structured clone). * The Symbol is lost but properties remain. */ export interface SerializedTSONRef { type: string; // 'Uint8Array', 'Blob', etc. ref: string; // '1:blobId' size: number; contentType?: string; } /** * Check if a value is a serialized TSONRef (after IndexedDB storage) * Has 'type' instead of '$t', and no Symbol marker */ export function isSerializedTSONRef(value: unknown): value is SerializedTSONRef { if (typeof value !== 'object' || value === null) return false; const obj = value as any; return ( typeof obj.type === 'string' && typeof obj.ref === 'string' && typeof obj.size === 'number' && obj._bt === undefined // Not a raw BlobRef ); } /** * Recursively check if an object contains any BlobRefs */ export function hasBlobRefs(obj: unknown, visited = new WeakSet()): boolean { if (obj === null || obj === undefined) { return false; } if (isBlobRef(obj)) { return true; } if (typeof obj !== 'object') { return false; } // Avoid circular references - check BEFORE processing if (visited.has(obj)) { return false; } visited.add(obj); // Skip special objects that can't contain BlobRefs if (obj instanceof Date || obj instanceof RegExp || obj instanceof Blob) { return false; } if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { return false; } if (Array.isArray(obj)) { return obj.some(item => hasBlobRefs(item, visited)); } // Only traverse POJOs if (obj.constructor === Object) { return Object.values(obj).some(value => hasBlobRefs(value, visited)); } return false; } /** * Convert downloaded Uint8Array to the original type specified in BlobRef */ export function convertToOriginalType( data: Uint8Array, ref: BlobRef ): Blob | ArrayBuffer | ArrayBufferView | string { // String type: decode UTF-8 back to string if (ref._bt === 'string') { return new TextDecoder().decode(data); } // Get the underlying ArrayBuffer (handle shared buffer case) const buffer = data.buffer.byteLength === data.byteLength ? data.buffer as ArrayBuffer : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; switch (ref._bt) { case 'Blob': return new Blob([new Uint8Array(buffer)], { type: ref.ct || '' }); case 'ArrayBuffer': return buffer; case 'Uint8Array': return data; case 'Int8Array': return new Int8Array(buffer); case 'Uint8ClampedArray': return new Uint8ClampedArray(buffer); case 'Int16Array': return new Int16Array(buffer); case 'Uint16Array': return new Uint16Array(buffer); case 'Int32Array': return new Int32Array(buffer); case 'Uint32Array': return new Uint32Array(buffer); case 'Float32Array': return new Float32Array(buffer); case 'Float64Array': return new Float64Array(buffer); case 'BigInt64Array': return new BigInt64Array(buffer); case 'BigUint64Array': return new BigUint64Array(buffer); case 'DataView': return new DataView(buffer); default: // Fallback to Uint8Array for unknown types return data; } } /** * Recursively resolve all BlobRefs in an object and collect them for queueing. * Returns a new object with BlobRefs replaced by their original type data, * and populates the resolvedBlobs array with keyPath info for each blob. * * @param obj - Object to resolve * @param dbUrl - Base URL for the database * @param accessToken - Access token for blob downloads * @param resolvedBlobs - Array to collect resolved blob info * @param currentPath - Current property path (for tracking) * @param visited - WeakMap for circular reference detection */ export async function resolveAllBlobRefs( obj: unknown, dbUrl: string, resolvedBlobs: ResolvedBlob[] = [], currentPath: string = '', visited = new WeakMap(), tracker: BlobDownloadTracker ): Promise { if (obj == null) { // null or undefined return obj; } // Check if this is a BlobRef - resolve it and track it if (isBlobRef(obj)) { const rawData = await tracker.download(obj, dbUrl); const data = convertToOriginalType(rawData, obj); resolvedBlobs.push({ keyPath: currentPath, data, ref: obj.ref }); return data; } // Handle arrays if (Array.isArray(obj)) { // Avoid circular references - check and set BEFORE iterating if (visited.has(obj)) { return visited.get(obj); } const result: unknown[] = []; visited.set(obj, result); // Set before iterating to handle self-references for (let i = 0; i < obj.length; i++) { const itemPath = currentPath ? `${currentPath}.${i}` : `${i}`; result.push(await resolveAllBlobRefs(obj[i], dbUrl, resolvedBlobs, itemPath, visited, tracker)); } return result; } // Handle POJO objects only (not Date, RegExp, Blob, ArrayBuffer, etc.) if (typeof obj === 'object' && obj.constructor === Object) { // Avoid circular references if (visited.has(obj)) { return visited.get(obj); } const result: Record = {}; visited.set(obj, result); for (const [propName, value] of Object.entries(obj)) { // Skip the _hasBlobRefs marker itself if (propName === '_hasBlobRefs') { continue; } const propPath = currentPath ? `${currentPath}.${propName}` : propName; result[propName] = await resolveAllBlobRefs(value, dbUrl, resolvedBlobs, propPath, visited, tracker); } return result; } return obj; } /** * Check if an object has unresolved BlobRefs */ export function hasUnresolvedBlobRefs(obj: unknown): boolean { return ( typeof obj === 'object' && obj !== null && (obj as any)._hasBlobRefs === 1 ); } ================================================ FILE: addons/dexie-cloud/src/sync/connectWebSocket.ts ================================================ import { BehaviorSubject, firstValueFrom, from, Observable, of, throwError, merge } from 'rxjs'; import { catchError, combineLatestAll, debounceTime, delay, distinctUntilChanged, filter, map, mergeMap, switchMap, take, tap, } from 'rxjs/operators'; import { refreshAccessToken } from '../authentication/authenticate'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { PersistedSyncState } from '../db/entities/PersistedSyncState'; import { computeRealmSetHash } from '../helpers/computeRealmSetHash'; import { userDoesSomething, userIsActive, userIsReallyActive, } from '../userIsActive'; import { ReadyForChangesMessage, WSConnectionMsg, WSObservable, } from '../WSObservable'; import { InvalidLicenseError } from '../InvalidLicenseError'; import { read } from 'fs'; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function waitAndReconnectWhenUserDoesSomething(error: Error) { console.error( `WebSocket observable: error but revive when user does some active thing...`, error ); // Sleep some seconds... await sleep(3000); // Wait til user does something (move mouse, tap, scroll, click etc) console.debug('waiting for someone to do something'); await firstValueFrom(userDoesSomething); console.debug('someone did something!'); } export function connectWebSocket(db: DexieCloudDB) { if (!db.cloud.options?.databaseUrl) { throw new Error(`No database URL to connect WebSocket to`); } const readyForChangesMessage = db.messageConsumer.readyToServe.pipe( filter((isReady) => isReady), // When consumer is ready for new messages, produce such a message to inform server about it switchMap(() => db.getPersistedSyncState()), // We need the info on which server revision we are at: filter((syncState) => syncState && syncState.serverRevision), // We wont send anything to server before inital sync has taken place switchMap>(async (syncState) => ({ // Produce the message to trigger server to send us new messages to consume: type: 'ready', rev: syncState.serverRevision, realmSetHash: await computeRealmSetHash(syncState) } satisfies ReadyForChangesMessage)) ); const messageProducer = merge( readyForChangesMessage, db.messageProducer ); function createObservable(): Observable { return db.cloud.persistedSyncState.pipe( filter((syncState) => syncState?.serverRevision), // Don't connect before there's no initial sync performed. take(1), // Don't continue waking up whenever syncState change switchMap((syncState) => db.cloud.currentUser.pipe( map((userLogin) => [userLogin, syncState] as const) ) ), switchMap(([userLogin, syncState]) => { /*if (userLogin.license?.status && userLogin.license.status !== 'ok') { throw new InvalidLicenseError(); }*/ return userIsReallyActive.pipe( map((isActive) => [isActive ? userLogin : null, syncState] as const) ); }), switchMap(([userLogin, syncState]) => { if (userLogin?.isLoggedIn && !syncState?.realms.includes(userLogin.userId!)) { // We're in an in-between state when user is logged in but the user's realms are not yet synced. // Don't make this change reconnect the websocket just yet. Wait till syncState is updated // to iclude the user's realm. return db.cloud.persistedSyncState.pipe( filter((syncState) => syncState?.realms.includes(userLogin!.userId!) || false), take(1), map((syncState) => [userLogin, syncState] as const) ); } return new BehaviorSubject([userLogin, syncState] as const); }), switchMap( async ([userLogin, syncState]) => [userLogin, await computeRealmSetHash(syncState!)] as const ), distinctUntilChanged(([prevUser, prevHash], [currUser, currHash]) => prevUser === currUser && prevHash === currHash ), switchMap(([userLogin, realmSetHash]) => { if (!db.cloud.persistedSyncState?.value) { // Restart the flow if persistedSyncState is not yet available. return createObservable(); } // Let server end query changes from last entry of same client-ID and forward. // If no new entries, server won't bother the client. If new entries, server sends only those // and the baseRev of the last from same client-ID. if (userLogin) { return new WSObservable( db, db.cloud.persistedSyncState!.value!.serverRevision, db.cloud.persistedSyncState!.value!.yServerRevision, realmSetHash, db.cloud.persistedSyncState!.value!.clientIdentity, messageProducer, db.cloud.webSocketStatus, userLogin ); } else { return from([] as WSConnectionMsg[]); }}), catchError((error) => { if (error?.name === 'TokenExpiredError') { console.debug( 'WebSocket observable: Token expired. Refreshing token...' ); return of(true).pipe( switchMap(async () => { // Refresh access token const user = await db.getCurrentUser(); const refreshedLogin = await refreshAccessToken( db.cloud.options!.databaseUrl, user ); // Persist updated access token await db.table('$logins').update(user.userId, { accessToken: refreshedLogin.accessToken, accessTokenExpiration: refreshedLogin.accessTokenExpiration, claims: refreshedLogin.claims, license: refreshedLogin.license, data: refreshedLogin.data }); }), switchMap(() => createObservable()) ); } else { return throwError(()=>error); } }), catchError((error) => { db.cloud.webSocketStatus.next("error"); if (error instanceof InvalidLicenseError) { // Don't retry. Just throw and don't try connect again. return throwError(() => error); } return from(waitAndReconnectWhenUserDoesSomething(error)).pipe( switchMap(() => createObservable()) ); }) ) as Observable; } return createObservable().subscribe({ next: (msg) => { if (msg) { console.debug('WS got message', msg); db.messageConsumer.enqueue(msg); } }, error: (error) => { console.error('WS got error', error); }, complete: () => { console.debug('WS observable completed'); }, }); } ================================================ FILE: addons/dexie-cloud/src/sync/eagerBlobDownloader.ts ================================================ /** * Eager Blob Downloader * * Downloads unresolved blobs in the background when blobMode='eager'. * Called after sync completes to prefetch blobs for offline access. * * Progress is tracked automatically via liveQuery in blobProgress.ts — * no manual progress reporting needed here. */ import Dexie, { UpdateSpec } from 'dexie'; import { BehaviorSubject } from 'rxjs'; import { TSONRef, hasTSONRefs } from 'dexie-cloud-common'; import { BlobRef, isBlobRef, hasBlobRefs, hasUnresolvedBlobRefs, isSerializedTSONRef, resolveAllBlobRefs, ResolvedBlob, } from './blobResolve'; import { loadCachedAccessToken } from './loadCachedAccessToken'; import { setDownloadingState } from './blobProgress'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { getSyncableTables } from '../helpers/getSyncableTables'; /** * Download all unresolved blobs in the background. * * This is called when blobMode='eager' (default) after sync completes. * BlobRef URLs are signed (SAS tokens) so no auth header needed. * * Each blob is saved atomically using Table.update() to avoid race conditions. */ export async function downloadUnresolvedBlobs( db: DexieCloudDB, downloading$: BehaviorSubject, signal?: AbortSignal ): Promise { const debugLog = (msg: string) => console.debug(`[dexie-cloud] ${msg}`); debugLog('Eager download: Starting...'); // Scan for unresolved blobs const syncedTables = getSyncableTables(db); let hasWork = false; for (const table of syncedTables) { try { const hasIndex = !!table.schema.idxByName['_hasBlobRefs']; if (!hasIndex) continue; const count = await table.where('_hasBlobRefs').equals(1).count(); if (count > 0) { hasWork = true; break; } } catch { // skip } } if (!hasWork) { debugLog('Eager download: No blobs remaining, exiting'); return; } setDownloadingState(downloading$, true); try { debugLog(`Eager download: Found ${syncedTables.length} syncable tables: ${syncedTables.map(t => t.name).join(', ')}`); for (const table of syncedTables) { if (signal?.aborted) break; try { // Check if table has _hasBlobRefs index const hasIndex = table.schema.indexes.some(idx => idx.name === '_hasBlobRefs'); if (!hasIndex) continue; // Query objects with _hasBlobRefs marker const unresolvedObjects = await table .where('_hasBlobRefs') .equals(1) .toArray(); debugLog(`Eager download: Table ${table.name} has ${unresolvedObjects.length} unresolved objects`); const databaseUrl = db.cloud.options?.databaseUrl; if (!databaseUrl) throw new Error('Database URL is required to download blobs'); // Download up to MAX_CONCURRENT blobs in parallel const MAX_CONCURRENT = 6; const primaryKey = table.schema.primKey; // Filter to actionable objects first const pending = unresolvedObjects.filter(obj => { if (!hasUnresolvedBlobRefs(obj)) return false; const key = primaryKey.keyPath ? Dexie.getByKeyPath(obj, primaryKey.keyPath as string) : undefined; return key !== undefined; }); // Process in parallel with concurrency limit let i = 0; const runNext = async (): Promise => { while (i < pending.length) { if (signal?.aborted) return; const obj = pending[i++]; const key = Dexie.getByKeyPath(obj, primaryKey.keyPath as string); try { // Refresh token per object — cheap (returns cached) but ensures // we pick up renewed tokens during long download sessions. const resolvedBlobs: ResolvedBlob[] = []; await resolveAllBlobRefs(obj, databaseUrl, resolvedBlobs, '', new WeakMap(), db.blobDownloadTracker); const updateSpec: UpdateSpec = { _hasBlobRefs: undefined, }; for (const blob of resolvedBlobs) { updateSpec[blob.keyPath] = blob.data; } debugLog(`Eager download: Updating ${table.name}:${key} with ${resolvedBlobs.length} blobs`); await table.update(key, updateSpec); // liveQuery in blobProgress.ts auto-detects this change } catch (err) { console.error(`Failed to download blobs for ${table.name}:${key}:`, err); } } }; // Launch up to MAX_CONCURRENT workers const workers: Promise[] = []; for (let w = 0; w < Math.min(MAX_CONCURRENT, pending.length); w++) { workers.push(runNext()); } await Promise.all(workers); } catch (err) { // Table might not have _hasBlobRefs index or other issues - skip silently } } } finally { setDownloadingState(downloading$, false); } } ================================================ FILE: addons/dexie-cloud/src/sync/encodeIdsForServer.ts ================================================ import Dexie, { DBCoreSchema } from 'dexie'; import { DBInsertOperation, DBOperation, DBOperationsSet, DBOpPrimaryKey, } from 'dexie-cloud-common'; import { UserLogin } from '../db/entities/UserLogin'; export function encodeIdsForServer( schema: DBCoreSchema, currentUser: UserLogin, changes: DBOperationsSet ): DBOperationsSet { const rv: DBOperationsSet = []; for (let change of changes) { const { table, muts } = change; const tableSchema = schema.tables.find((t) => t.name === table); if (!tableSchema) throw new Error( `Internal error: table ${table} not found in DBCore schema` ); const { primaryKey } = tableSchema; let changeClone = change; muts.forEach((mut, mutIndex) => { const rewriteValues = !primaryKey.outbound && (mut.type === 'upsert' || mut.type === 'insert'); mut.keys.forEach((key, keyIndex) => { if (Array.isArray(key)) { // Server only support string keys. Dexie Cloud client support strings or array of strings. if (changeClone === change) changeClone = cloneChange(change, rewriteValues); const mutClone = changeClone.muts[mutIndex]; const rewrittenKey = JSON.stringify(key); mutClone.keys[keyIndex] = rewrittenKey; /* Bug (#1777) We should not rewrite values. It will fail because the key is array and the value is string. Only the keys should be rewritten and it's already done on the server. We should take another round of revieweing how key transformations are being done between client and server and let the server do the key transformations entirely instead now that we have the primary key schema on the server making it possible to do so. if (rewriteValues) { Dexie.setByKeyPath( (mutClone as DBInsertOperation).values[keyIndex], primaryKey.keyPath!, rewrittenKey ); }*/ } else if (key[0] === '#') { // Private ID - translate! if (changeClone === change) changeClone = cloneChange(change, rewriteValues); const mutClone = changeClone.muts[mutIndex]; if (!currentUser.isLoggedIn) throw new Error( `Internal error: Cannot sync private IDs before authenticated` ); const rewrittenKey = `${key}:${currentUser.userId}`; mutClone.keys[keyIndex] = rewrittenKey; if (rewriteValues) { Dexie.setByKeyPath( (mutClone as DBInsertOperation).values[keyIndex], primaryKey.keyPath!, rewrittenKey ); } } }); }); rv.push(changeClone); } return rv; } function cloneChange(change: DBOperationsSet[number], rewriteValues: boolean) { // clone on demand: return { ...change, muts: rewriteValues ? change.muts.map((m) => { return (m.type === 'insert' || m.type === 'upsert') && m.values ? { ...m, keys: m.keys.slice(), values: m.values.slice(), } : { ...m, keys: m.keys.slice(), }; }) : change.muts.map((m) => ({ ...m, keys: m.keys.slice() })), }; } ================================================ FILE: addons/dexie-cloud/src/sync/extractRealm.ts ================================================ import { EntityCommon } from "../db/entities/EntityCommon"; export function extractRealm(obj: EntityCommon) { return obj?.realmId; } ================================================ FILE: addons/dexie-cloud/src/sync/getLatestRevisionsPerTable.ts ================================================ import { DBOperationsSet } from 'dexie-cloud-common'; export function getLatestRevisionsPerTable( clientChangeSet: DBOperationsSet, lastRevisions = {} as { [table: string]: number; }) { for (const { table, muts } of clientChangeSet) { const lastRev = muts.length > 0 ? muts[muts.length - 1].rev : null; lastRevisions[table] = lastRev || lastRevisions[table] || 0; } return lastRevisions; } ================================================ FILE: addons/dexie-cloud/src/sync/getTablesToSyncify.ts ================================================ import { getSyncableTables } from "../helpers/getSyncableTables"; import { DexieCloudDB } from "../db/DexieCloudDB"; import { PersistedSyncState } from "../db/entities/PersistedSyncState"; export function getTablesToSyncify(db: DexieCloudDB, syncState: PersistedSyncState | undefined) { const syncedTables = syncState?.syncedTables || []; const syncableTables = getSyncableTables(db); const tablesToSyncify = syncableTables.filter( (tbl) => !syncedTables.includes(tbl.name) ); return tablesToSyncify; } ================================================ FILE: addons/dexie-cloud/src/sync/isOnline.ts ================================================ /* Need this because navigator.onLine seems to say "false" when it is actually online. This function relies initially on navigator.onLine but then uses online and offline events which seem to be more reliable. */ export let isOnline = false; if (typeof self !== 'undefined' && typeof navigator !== 'undefined') { isOnline = navigator.onLine; self.addEventListener('online', ()=>isOnline = true); self.addEventListener('offline', ()=>isOnline = false); } ================================================ FILE: addons/dexie-cloud/src/sync/isSyncNeeded.ts ================================================ import { DexieCloudDB } from "../db/DexieCloudDB"; import { sync } from "./sync"; export async function isSyncNeeded(db: DexieCloudDB) { return db.cloud.options?.databaseUrl && db.cloud.schema ? await sync(db, db.cloud.options, db.cloud.schema, {justCheckIfNeeded: true}) : false; } ================================================ FILE: addons/dexie-cloud/src/sync/listClientChanges.ts ================================================ import { PropModification, Table, UpdateSpec } from 'dexie'; import { getTableFromMutationTable } from '../helpers/getTableFromMutationTable'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { DBOperation, DBOperationsSet, DBUpdateOperation } from 'dexie-cloud-common'; import { flatten } from '../helpers/flatten'; export async function listClientChanges( mutationTables: Table[], db: DexieCloudDB, { since = {} as { [table: string]: number }, limit = Infinity } = {} ): Promise { const allMutsOnTables = await Promise.all( mutationTables.map(async (mutationTable) => { const tableName = getTableFromMutationTable(mutationTable.name); const lastRevision = since[tableName]; let query = lastRevision ? mutationTable.where('rev').above(lastRevision) : mutationTable; if (limit < Infinity) query = query.limit(limit); let muts: DBOperation[] = await query.toArray(); muts = canonicalizeToUpdateOps(muts); muts = removeRedundantUpdateOps(muts); const rv = muts.map((mut) => ({ table: tableName, mut, })); return rv; }) ); // Sort by time to get a true order of the operations (between tables) const sorted = flatten(allMutsOnTables).sort((a, b) => a.mut.txid === b.mut.txid ? a.mut.opNo! - b.mut.opNo! // Within same transaction, sort by opNo : a.mut.ts! - b.mut.ts! // Different transactions - sort by timestamp when mutation resolved ); const result: DBOperationsSet = []; let currentEntry: { table: string; muts: DBOperation[]; } | null = null; let currentTxid: string | null = null; for (const { table, mut } of sorted) { if ( currentEntry && currentEntry.table === table && currentTxid === mut.txid ) { currentEntry.muts.push(mut); } else { currentEntry = { table, muts: [mut], }; currentTxid = mut.txid!; result.push(currentEntry); } } // Filter out those tables that doesn't have any mutations: return result; } function removeRedundantUpdateOps(muts: DBOperation[]) { const updateCoverage = new Map; }>>(); for (const mut of muts) { if (mut.type === 'update') { if (mut.keys.length !== 1 || mut.changeSpecs.length !== 1) { continue; // Don't optimize multi-key updates } const strKey = '' + mut.keys[0]; const changeSpecs = mut.changeSpecs[0]; if (Object.values(changeSpecs).some(v => typeof v === "object" && v && "@@propmod" in v)) { continue; // Cannot optimize if any PropModification is present } let keyCoverage = updateCoverage.get(strKey); if (keyCoverage) { keyCoverage.push({ txid: mut.txid!, updateSpec: changeSpecs }); } else { updateCoverage.set(strKey, [{ txid: mut.txid!, updateSpec: changeSpecs }]); } } } muts = muts.filter(mut => { // Only apply optimization to update mutations that are single-key if (mut.type !== 'update') return true; if (mut.keys.length !== 1 || mut.changeSpecs.length !== 1) return true; // Check if this has PropModifications - if so, skip optimization const changeSpecs = mut.changeSpecs[0]; if (Object.values(changeSpecs).some(v => typeof v === "object" && v && "@@propmod" in v)) { return true; // Cannot optimize if any PropModification is present } // Keep track of properties that aren't overlapped by later transactions const unoverlappedProps = new Set(Object.keys(mut.changeSpecs[0])); const strKey = '' + mut.keys[0]; const keyCoverage = updateCoverage.get(strKey); if (!keyCoverage) return true; // No coverage info - cannot optimize for (let i = keyCoverage.length - 1; i >= 0; --i) { const { txid, updateSpec } = keyCoverage[i]; if (txid === mut.txid) break; // Stop when reaching own txid // If all changes in updateSpec are covered by all props on all mut.changeSpecs then // txid is redundant and can be removed. for (const keyPath of Object.keys(updateSpec)) { unoverlappedProps.delete(keyPath); } } if (unoverlappedProps.size === 0) { // This operation is completely overlapped by later operations. It can be removed. return false; } return true; }); return muts; } function canonicalizeToUpdateOps(muts: DBOperation[]) { muts = muts.map(mut => { if (mut.type === 'modify' && mut.criteria.index === null) { // The criteria is on primary key. Convert to an update operation instead. // It is simpler for the server to handle and also more efficient. const updateMut = { ...mut, criteria: undefined, changeSpec: undefined, type: 'update', keys: mut.keys, changeSpecs: [mut.changeSpec], }; delete updateMut.criteria; delete updateMut.changeSpec; return updateMut as DBUpdateOperation; } return mut; }); return muts; } ================================================ FILE: addons/dexie-cloud/src/sync/listSyncifiedChanges.ts ================================================ import { UserLogin } from '../db/entities/UserLogin'; import { randomString } from '../helpers/randomString'; import { EntityCommon } from '../db/entities/EntityCommon'; import { Table } from 'dexie'; import { DBOperationsSet, DBUpsertOperation, DexieCloudSchema, isValidAtID, isValidSyncableID, } from 'dexie-cloud-common'; export async function listSyncifiedChanges( tablesToSyncify: Table[], currentUser: UserLogin, schema: DexieCloudSchema, alreadySyncedRealms?: string[] ): Promise { const txid = `upload-${randomString(8)}`; if (currentUser.isLoggedIn) { if (tablesToSyncify.length > 0) { const ignoredRealms = new Set(alreadySyncedRealms || []); const upserts = await Promise.all( tablesToSyncify.map(async (table) => { const { extractKey } = table.core.schema.primaryKey; if (!extractKey) return { table: table.name, muts: [] }; // Outbound tables are not synced. const dexieCloudTableSchema = schema[table.name]; const query = dexieCloudTableSchema?.generatedGlobalId ? table.filter((item) => { const id = extractKey(item); return ( !ignoredRealms.has(item.realmId || '') && //(id[0] !== '#' || !!item.$ts) && // Private obj need no sync if not changed isValidAtID(extractKey(item), dexieCloudTableSchema?.idPrefix) ); }) : table.filter((item) => { const id = extractKey(item); return ( !ignoredRealms.has(item.realmId || '') && //(id[0] !== '#' || !!item.$ts) && // Private obj need no sync if not changed isValidSyncableID(id) ); }); const unsyncedObjects = await query.toArray(); if (unsyncedObjects.length > 0) { const mut: DBUpsertOperation = { type: 'upsert', values: unsyncedObjects, keys: unsyncedObjects.map(extractKey), userId: currentUser.userId, txid, }; return { table: table.name, muts: [mut], }; } else { return { table: table.name, muts: [], }; } }) ); return upserts.filter((op) => op.muts.length > 0); } } return []; } ================================================ FILE: addons/dexie-cloud/src/sync/loadCachedAccessToken.ts ================================================ import Dexie from 'dexie'; import { loadAccessToken } from '../authentication/authenticate'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { MINUTES } from '../helpers/date-constants'; const wm = new WeakMap(); export function loadCachedAccessToken(db: DexieCloudDB): Promise { let cached = wm.get(db); if (cached && cached.expiration > Date.now() + 5 * MINUTES) { return Promise.resolve(cached.accessToken); } const currentUser = db.cloud.currentUser.value; if (currentUser && currentUser.accessToken && (currentUser.accessTokenExpiration?.getTime() ?? Infinity) > Date.now() + 5 * MINUTES) { wm.set(db, { accessToken: currentUser.accessToken, expiration: currentUser.accessTokenExpiration?.getTime() ?? Infinity }); return Promise.resolve(currentUser.accessToken); } return Dexie.ignoreTransaction(() => loadAccessToken(db).then(user => { if (user?.accessToken) { wm.set(db, { accessToken: user.accessToken, expiration: user.accessTokenExpiration?.getTime() ?? Infinity }); } return user?.accessToken || null; })); } ================================================ FILE: addons/dexie-cloud/src/sync/messageConsumerIsReady.ts ================================================ import { BehaviorSubject } from "rxjs"; export const messageConsumerIsReady = new BehaviorSubject(false); ================================================ FILE: addons/dexie-cloud/src/sync/messagesFromServerQueue.ts ================================================ import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { filter, take } from 'rxjs/operators'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { WSConnectionMsg } from '../WSObservable'; import { triggerSync } from './triggerSync'; import Dexie from 'dexie'; import { computeRealmSetHash } from '../helpers/computeRealmSetHash'; import { DBOperationsSet } from 'dexie-cloud-common'; import { getSyncableTables } from '../helpers/getSyncableTables'; import { getMutationTable } from '../helpers/getMutationTable'; import { listClientChanges } from './listClientChanges'; import { filterServerChangesThroughAddedClientChanges } from './sync'; import { applyServerChanges } from './applyServerChanges'; import { updateBaseRevs } from './updateBaseRevs'; import { getLatestRevisionsPerTable } from './getLatestRevisionsPerTable'; import { refreshAccessToken } from '../authentication/authenticate'; const LIMIT_NUM_MESSAGES_PER_TIME = 10; // Allow a maximum of 10 messages per... const TIME_WINDOW = 10_000; // ...10 seconds. const PAUSE_PERIOD = 1_000; // Pause for 1 second if reached export type MessagesFromServerConsumer = ReturnType< typeof MessagesFromServerConsumer >; export function MessagesFromServerConsumer(db: DexieCloudDB) { const queue: WSConnectionMsg[] = []; const readyToServe = new BehaviorSubject(true); const event = new BehaviorSubject(null); let isWorking = false; let loopDetection = new Array(LIMIT_NUM_MESSAGES_PER_TIME).fill(0); event.subscribe(async () => { if (isWorking) return; if (queue.length > 0) { isWorking = true; loopDetection.shift(); loopDetection.push(Date.now()); readyToServe.next(false); try { await consumeQueue(); } finally { if ( loopDetection[loopDetection.length - 1] - loopDetection[0] < TIME_WINDOW ) { // Ten loops within 10 seconds. Slow down! // This is a one-time event. Just pause 10 seconds. console.warn(`Slowing down websocket loop for ${PAUSE_PERIOD} milliseconds`); await new Promise((resolve) => setTimeout(resolve, PAUSE_PERIOD)); } isWorking = false; readyToServe.next(true); } } }); function enqueue(msg: WSConnectionMsg) { queue.push(msg); event.next(null); } async function consumeQueue() { while (queue.length > 0) { const msg = queue.shift(); try { // If the sync worker or service worker is syncing, wait 'til thei're done. // It's no need to have two channels at the same time - even though it wouldnt // be a problem - this is an optimization. await firstValueFrom( db.cloud.syncState.pipe( filter(({ phase }) => phase === 'in-sync' || phase === 'error') ) ); console.debug('processing msg', msg); const persistedSyncState = db.cloud.persistedSyncState.value; //syncState. if (!msg) continue; switch (msg.type) { case 'token-expired': console.debug( 'WebSocket observable: Token expired. Refreshing token...' ); const user = db.cloud.currentUser.value; // Refresh access token const refreshedLogin = await refreshAccessToken( db.cloud.options!.databaseUrl, user ); // Persist updated access token await db.table('$logins').update(user.userId, { accessToken: refreshedLogin.accessToken, accessTokenExpiration: refreshedLogin.accessTokenExpiration, claims: refreshedLogin.claims, license: refreshedLogin.license, data: refreshedLogin.data, }); // Updating $logins will trigger emission of db.cloud.currentUser observable, which // in turn will lead to that connectWebSocket.ts will reconnect the socket with the // new token. So we don't need to do anything more here. break; case 'realm-added': if ( !persistedSyncState?.realms?.includes(msg.realm) && !persistedSyncState?.inviteRealms?.includes(msg.realm) ) { await db.cloud.sync({ purpose: 'pull', wait: true }); //triggerSync(db, 'pull'); } break; case 'realm-accepted': if (!persistedSyncState?.realms?.includes(msg.realm)) { await db.cloud.sync({ purpose: 'pull', wait: true }); //triggerSync(db, 'pull'); } break; case 'realm-removed': if ( persistedSyncState?.realms?.includes(msg.realm) || persistedSyncState?.inviteRealms?.includes(msg.realm) ) { await db.cloud.sync({ purpose: 'pull', wait: true }); //triggerSync(db, 'pull'); } break; case 'realms-changed': //triggerSync(db, 'pull'); await db.cloud.sync({ purpose: 'pull', wait: true }); break; case 'changes': { console.debug('changes'); if (db.cloud.syncState.value?.phase === 'error') { triggerSync(db, 'pull'); break; } const didApplyChanges = await db.transaction('rw', db.dx.tables, async (tx) => { // @ts-ignore tx.idbtrans.disableChangeTracking = true; // @ts-ignore tx.idbtrans.disableAccessControl = true; const [schema, syncState, currentUser] = await Promise.all([ db.getSchema(), db.getPersistedSyncState(), db.getCurrentUser(), ]); console.debug('ws message queue: in transaction'); if (!syncState || !schema || !currentUser) { console.debug('required vars not present', { syncState, schema, currentUser, }); return false; // Initial sync must have taken place - otherwise, ignore this. } // Verify again in ACID tx that we're on same server revision. if (msg.baseRev !== syncState.serverRevision) { console.debug( `baseRev (${msg.baseRev}) differs from our serverRevision in syncState (${syncState.serverRevision})` ); // Should we trigger a sync now? No. This is a normal case // when another local peer (such as the SW or a websocket channel on other tab) has // updated syncState from new server information but we are not aware yet. It would // be unnescessary to do a sync in that case. Instead, the caller of this consumeQueue() // function will do readyToServe.next(true) right after this return, which will lead // to a "ready" message being sent to server with the new accurate serverRev we have, // so that the next message indeed will be correct. if ( typeof msg.baseRev === 'string' && // v2 format (typeof syncState.serverRevision === 'bigint' || // v1 format typeof syncState.serverRevision === 'object') // v1 format old browser ) { // The reason for the diff seems to be that server has migrated the revision format. // Do a full sync to update revision format. // If we don't do a sync request now, we could stuck in an endless loop. triggerSync(db, 'pull'); } return false; // Ignore message } // Verify also that the message is based on the exact same set of realms const ourRealmSetHash = await Dexie.waitFor( // Keep TX in non-IDB work computeRealmSetHash(syncState) ); console.debug('ourRealmSetHash', ourRealmSetHash); if (ourRealmSetHash !== msg.realmSetHash) { console.debug('not same realmSetHash', msg.realmSetHash); triggerSync(db, 'pull'); // The message isn't based on the same realms. // Trigger a sync instead to resolve all things up. return false; } // Get clientChanges let clientChanges: DBOperationsSet = []; if (currentUser.isLoggedIn) { const mutationTables = getSyncableTables(db).map((tbl) => db.table(getMutationTable(tbl.name)) ); clientChanges = await listClientChanges(mutationTables, db); console.debug('msg queue: client changes', clientChanges); } if (msg.changes.length > 0) { const filteredChanges = filterServerChangesThroughAddedClientChanges( msg.changes, clientChanges ); // // apply server changes // console.debug( 'applying filtered server changes', filteredChanges ); await applyServerChanges(filteredChanges, db); } // Update latest revisions per table in case there are unsynced changes // This can be a real case in future when we allow non-eagery sync. // And it can actually be realistic now also, but very rare. syncState.latestRevisions = getLatestRevisionsPerTable( clientChanges, syncState.latestRevisions ); syncState.serverRevision = msg.newRev; // Update base revs console.debug('Updating baseRefs', syncState.latestRevisions); await updateBaseRevs( db, schema!, syncState.latestRevisions, msg.newRev ); // // Update syncState // console.debug('Updating syncState', syncState); await db.$syncState.put(syncState, 'syncState'); return true; }); console.debug('msg queue: done with rw transaction'); // Trigger eager blob download for any BlobRefs received via WebSocket. // This mirrors the behavior after normal HTTP sync (syncCompleteEvent). // Only emit if changes were actually applied (not on early returns). if (didApplyChanges && msg.changes.length > 0) { db.syncCompleteEvent.next(); } break; } } } catch (error) { console.error(`Error in msg queue`, error); } } } return { enqueue, readyToServe, }; } ================================================ FILE: addons/dexie-cloud/src/sync/modifyLocalObjectsWithNewUserId.ts ================================================ import { Table } from "dexie"; import { EntityCommon } from "../db/entities/EntityCommon"; import { UserLogin } from "../db/entities/UserLogin"; import { Member } from "../db/entities/Member"; import { UNAUTHORIZED_USER } from "../authentication/UNAUTHORIZED_USER"; import { Realm } from "../db/entities/Realm"; export async function modifyLocalObjectsWithNewUserId( syncifiedTables: Table[], currentUser: UserLogin, alreadySyncedRealms?: string[]) { const ignoredRealms = new Set(alreadySyncedRealms || []); for (const table of syncifiedTables) { if (table.name === "members") { // members await table.toCollection().modify((member: Member) => { if (!ignoredRealms.has(member.realmId) && (!member.userId || member.userId === UNAUTHORIZED_USER.userId)) { member.userId = currentUser.userId; } }); } else if (table.name === "roles") { // roles // No changes needed. } else if (table.name === "realms") { // realms await table.toCollection().modify((realm: Realm) => { if (!ignoredRealms.has(realm.realmId) && (realm.owner === undefined || realm.owner === UNAUTHORIZED_USER.userId)) { realm.owner = currentUser.userId; } }); } else { // application entities await table.toCollection().modify((obj) => { if (!obj.realmId || !ignoredRealms.has(obj.realmId)) { if (!obj.owner || obj.owner === UNAUTHORIZED_USER.userId) obj.owner = currentUser.userId; if (!obj.realmId || obj.realmId === UNAUTHORIZED_USER.userId) { obj.realmId = currentUser.userId; } } }); } } } ================================================ FILE: addons/dexie-cloud/src/sync/myId.ts ================================================ import { randomString } from "../helpers/randomString"; export const myId = randomString(16); ================================================ FILE: addons/dexie-cloud/src/sync/numUnsyncedMutations.ts ================================================ import Dexie, { liveQuery } from "dexie"; import { getMutationTable } from "../helpers/getMutationTable"; import { getSyncableTables } from "../helpers/getSyncableTables"; import { combineLatest, forkJoin, from } from "rxjs"; import { distinctUntilChanged, filter, map } from "rxjs/operators"; import { DexieCloudDB } from "../db/DexieCloudDB"; export function getNumUnsyncedMutationsObservable(db: DexieCloudDB) { const syncableTables = getSyncableTables(db); const mutationTables = syncableTables.map((table) => db.table(getMutationTable(table.name)) ); const queries = mutationTables.map((mt) => from(liveQuery(() => mt.count()))); return forkJoin(queries).pipe( // Compute the sum of all tables' unsynced changes: map((counts) => counts.reduce((x, y) => x + y)), // Swallow false positives - when the number was the same as before: distinctUntilChanged() ); } ================================================ FILE: addons/dexie-cloud/src/sync/old_startSyncingClientChanges.ts ================================================ /** TODO: * 1. Convert these unsynced changes into sync mutations by resolving values from ordinaryTables. * 2. Append a convertion of mutReqs[table].muts into sync mutations. * 3. Send all this using fetch * 4. if successful response, delete muts from mutTables by * 1. a new rw-transaction where we start by finding revs above those we generated. * 2. for tables where none such found, clear the table. if some of those found, deleteRange on the table. * * Tankar att följa upp: * * Varje DOM environmnet får en MutationSyncer * * Håller koll på huruvida den tror att en sync redan pågår eller inte. * * Bro * * Leader election * * IDB persisted value: leaderId * * Varje browsing-env får en nodeId (random string). * * db.open: Kolla om det finns en leader. Om inte, sätt dig själv som leader. * * Leaders: Ha sync consumer uppe. * * Service worker: Ta ledarskap. Spara även en prop som säger att detta är service worker (för att workaround BroadCastChannel props) * * Om det finns en non-service worker ledare, försök kontakta den (via BroadCastChannel) * * Om ledaren inte svarar inom 1 sekund, ta ledarskap. * * Ledare: Lyssna på ändring av ledarskap och sluta vara ledare i så fall. * * Följare: Lyssna på ändring av ledarskap så att du alltid kontaktar rätt ledare. * * När man postar något till non-SW ledaren så ska den ACKa inom 1 sek. Annars, ta ledarskapet. * * * Enklare: * * Vi har en som leker service worker per window. Två aktiva flikar: * * Båda vaknar upp och låser tabellen X för skrivning, om ingen annan tagit jobbet, skriver timestamp till X och * börjar sedan synka. Under tiden för sync, uppdaterar den timestamp kontinuerligt för att signallera att den * jobbar. När klar, clearar ut timestampen och avslutar jobbet. * - Om det inte fanns nåt att göra så clearas timestamp ut och man schedulerar inget mer uppvaknande. * * Den som kom sist ser att annan tagit jobbet inom rimlig timestamp och väljer då att polla igen ifall timestampet * skulle timat ut. * * Om jobbaren verkar tajma ut: gör nytt försök från början. Kan leda till att du blir ledare denna gång. * * Om jobbaren clearar ut timestampen pga avslutat jobb så kan väntaren ädnå för säkerhets-skull göra om'et från början. * * */ ================================================ FILE: addons/dexie-cloud/src/sync/performGuardedJob.ts ================================================ import { DexieCloudDB } from "../db/DexieCloudDB"; export function performGuardedJob( db: DexieCloudDB, jobName: string, job: () => Promise ): Promise { if (typeof navigator === 'undefined' || !navigator.locks) { // No support for guarding jobs. IE11, node.js, etc. return job(); } // @ts-expect-error - LockManager callback type inference issue with generics return navigator.locks.request(db.name + '|' + jobName, job); } ================================================ FILE: addons/dexie-cloud/src/sync/ratelimit.ts ================================================ import { DexieCloudDB } from '../db/DexieCloudDB'; // If we get Ratelimit-Limit and Ratelimit-Remaining where Ratelimit-Remaining is below // (Ratelimit-Limit / 2), we should delay the next sync by (Ratelimit-Reset / Ratelimit-Remaining) // seconds (given that there is a Ratelimit-Reset header). let syncRatelimitDelays = new WeakMap(); export async function checkSyncRateLimitDelay(db: DexieCloudDB) { const delatMilliseconds = (syncRatelimitDelays.get(db)?.getTime() ?? 0) - Date.now(); if (delatMilliseconds > 0) { console.debug(`Stalling sync request ${delatMilliseconds} ms to spare ratelimits`); await new Promise(resolve => setTimeout(resolve, delatMilliseconds)); } } export function updateSyncRateLimitDelays(db: DexieCloudDB, res: Response) { const limit = res.headers.get('Ratelimit-Limit'); const remaining = res.headers.get('Ratelimit-Remaining'); const reset = res.headers.get('Ratelimit-Reset'); if (limit && remaining && reset) { const limitNum = Number(limit); const remainingNum = Math.max(0, Number(remaining)); const willResetInSeconds = Number(reset); if (remainingNum < limitNum / 2) { const delay = Math.ceil(willResetInSeconds / (remainingNum + 1)); syncRatelimitDelays.set(db, new Date(Date.now() + delay * 1000)); console.debug(`Sync ratelimit delay set to ${delay} seconds`); } else { syncRatelimitDelays.delete(db); console.debug(`Sync ratelimit delay cleared`); } } } ================================================ FILE: addons/dexie-cloud/src/sync/registerSyncEvent.ts ================================================ import { DexieCloudDB } from '../db/DexieCloudDB'; //const hasSW = 'serviceWorker' in navigator; let hasComplainedAboutSyncEvent = false; export async function registerSyncEvent(db: DexieCloudDB, purpose: "push" | "pull") { try { // Send sync event to SW: const sw: ServiceWorkerRegistration & {sync?: any} = await navigator.serviceWorker.ready; if (purpose === "push" && sw.sync) { await sw.sync.register(`dexie-cloud:${db.name}`); } if (sw.active) { // Use postMessage for pull syncs and for browsers not supporting sync event (Firefox, Safari). // Also chromium based browsers with sw.sync as a fallback for sleepy sync events not taking action for a while. sw.active.postMessage({ type: 'dexie-cloud-sync', dbName: db.name, purpose }); } else { throw new Error(`Failed to trigger sync - there's no active service worker`); } return; } catch (e) { if (!hasComplainedAboutSyncEvent) { console.debug(`Dexie Cloud: Could not register sync event`, e); hasComplainedAboutSyncEvent = true; } } } export async function registerPeriodicSyncEvent(db: DexieCloudDB) { try { // Register periodicSync event to SW: // @ts-ignore const { periodicSync } = await navigator.serviceWorker.ready; if (periodicSync) { try { await periodicSync.register( `dexie-cloud:${db.name}`, db.cloud.options?.periodicSync ); console.debug( `Dexie Cloud: Successfully registered periodicsync event for ${db.name}` ); } catch (e) { console.debug(`Dexie Cloud: Failed to register periodic sync. Your PWA must be installed to allow background sync.`, e); } } else { console.debug(`Dexie Cloud: periodicSync not supported.`); } } catch (e) { console.debug( `Dexie Cloud: Could not register periodicSync for ${db.name}`, e ); } } ================================================ FILE: addons/dexie-cloud/src/sync/sync.ts ================================================ import { getMutationTable } from '../helpers/getMutationTable'; import { getSyncableTables } from '../helpers/getSyncableTables'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { listSyncifiedChanges } from './listSyncifiedChanges'; import { getTablesToSyncify } from './getTablesToSyncify'; import { listClientChanges } from './listClientChanges'; import { syncWithServer } from './syncWithServer'; import { modifyLocalObjectsWithNewUserId } from './modifyLocalObjectsWithNewUserId'; import { throwIfCancelled } from '../helpers/CancelToken'; import { DexieCloudOptions } from '../DexieCloudOptions'; import { BaseRevisionMapEntry } from '../db/entities/BaseRevisionMapEntry'; import { getTableFromMutationTable } from '../helpers/getTableFromMutationTable'; import { applyOperations, DBKeyMutationSet, DBOperationsSet, DexieCloudSchema, randomString, subtractChanges, SyncResponse, toDBOperationSet, } from 'dexie-cloud-common'; import { PersistedSyncState } from '../db/entities/PersistedSyncState'; import { isOnline } from './isOnline'; import { updateBaseRevs } from './updateBaseRevs'; import { getLatestRevisionsPerTable } from './getLatestRevisionsPerTable'; import { applyServerChanges } from './applyServerChanges'; import { checkSyncRateLimitDelay } from './ratelimit'; import { listYClientMessagesAndStateVector } from '../yjs/listYClientMessagesAndStateVector'; import { applyYServerMessages } from '../yjs/applyYMessages'; import { hasLargeBlobsInOperations, offloadBlobsInOperations } from './blobOffloading'; import { updateYSyncStates } from '../yjs/updateYSyncStates'; import { downloadYDocsFromServer } from '../yjs/downloadYDocsFromServer'; import { UpdateSpec } from 'dexie'; import { loadCachedAccessToken } from './loadCachedAccessToken'; export const CURRENT_SYNC_WORKER = 'currentSyncWorker'; export interface SyncOptions { isInitialSync?: boolean; cancelToken?: { cancelled: boolean }; justCheckIfNeeded?: boolean; retryImmediatelyOnFetchError?: boolean; purpose?: 'pull' | 'push'; } export function sync( db: DexieCloudDB, options: DexieCloudOptions, schema: DexieCloudSchema, syncOptions?: SyncOptions ): Promise { return _sync(db, options, schema, syncOptions) .then((result) => { if (!syncOptions?.justCheckIfNeeded) { // && syncOptions?.purpose !== 'push') { db.syncStateChangedEvent.next({ phase: 'in-sync', }); } return result; }) .catch(async (error: any) => { if (syncOptions?.justCheckIfNeeded) return Promise.reject(error); // Just rethrow. console.debug('Error from _sync', { isOnline, syncOptions, error, }); if ( isOnline && syncOptions?.retryImmediatelyOnFetchError && error?.name === 'TypeError' && /fetch/.test(error?.message) ) { db.syncStateChangedEvent.next({ phase: 'error', error, }); // Retry again in 500 ms but if it fails again, don't retry. await new Promise((resolve) => setTimeout(resolve, 500)); return await sync(db, options, schema, { ...syncOptions, retryImmediatelyOnFetchError: false, }); } // Make sure that no matter whether sync() explodes or not, // always update the timestamp. Also store the error. await db.$syncState.update('syncState', { timestamp: new Date(), error: '' + error, }); db.syncStateChangedEvent.next({ phase: isOnline ? 'error' : 'offline', error: new Error('' + error?.message || error), }); return Promise.reject(error); }); } async function _sync( db: DexieCloudDB, options: DexieCloudOptions, schema: DexieCloudSchema, { isInitialSync, cancelToken, justCheckIfNeeded, purpose }: SyncOptions = { isInitialSync: false, } ): Promise { if (!justCheckIfNeeded) { console.debug('SYNC STARTED', { isInitialSync, purpose }); } if (!db.cloud.options?.databaseUrl) throw new Error( `Internal error: sync must not be called when no databaseUrl is configured` ); const { databaseUrl } = options; const currentUser = await db.getCurrentUser(); // Keep same value across entire sync flow: const tablesToSync = currentUser.isLoggedIn ? getSyncableTables(db) : []; const mutationTables = tablesToSync.map((tbl) => db.table(getMutationTable(tbl.name)) ); // If this is not the initial sync, // go through tables that were previously not synced but should now be according to // logged in state and the sync table whitelist in db.cloud.options. // // Prepare for syncification by modifying locally unauthorized objects: // const persistedSyncState = await db.getPersistedSyncState(); const readyForSyncification = currentUser.isLoggedIn; const tablesToSyncify = readyForSyncification ? getTablesToSyncify(db, persistedSyncState) : []; throwIfCancelled(cancelToken); const doSyncify = tablesToSyncify.length > 0; if (doSyncify) { if (justCheckIfNeeded) return true; //console.debug('sync doSyncify is true'); await db.transaction('rw', tablesToSyncify, async (tx) => { // @ts-ignore tx.idbtrans.disableChangeTracking = true; // @ts-ignore tx.idbtrans.disableAccessControl = true; // TODO: Take care of this flag in access control middleware! await modifyLocalObjectsWithNewUserId( tablesToSyncify, currentUser, persistedSyncState?.realms ); }); throwIfCancelled(cancelToken); } // // List changes to sync // const [clientChangeSet, syncState, baseRevs, {yMessages, lastUpdateIds}] = await db.transaction( 'r', db.tables, async () => { const syncState = await db.getPersistedSyncState(); let baseRevs = await db.$baseRevs.toArray(); // Resolve #2168 baseRevs = baseRevs.filter(br => tablesToSync.some(tbl => tbl.name === br.tableName)); let clientChanges = await listClientChanges(mutationTables, db); const yResults = await listYClientMessagesAndStateVector(db, tablesToSync); throwIfCancelled(cancelToken); if (doSyncify) { const alreadySyncedRealms = [ ...(persistedSyncState?.realms || []), ...(persistedSyncState?.inviteRealms || []), ]; const syncificationInserts = await listSyncifiedChanges( tablesToSyncify, currentUser, schema!, alreadySyncedRealms ); throwIfCancelled(cancelToken); clientChanges = clientChanges.concat(syncificationInserts); return [clientChanges, syncState, baseRevs, yResults]; } return [clientChanges, syncState, baseRevs, yResults]; } ); const pushSyncIsNeeded = clientChangeSet.some((set) => set.muts.some((mut) => mut.keys.length > 0) ) || yMessages.some(m => m.type === 'u-c'); if (justCheckIfNeeded) { console.debug('Sync is needed:', pushSyncIsNeeded); return pushSyncIsNeeded; } if (purpose === 'push' && !pushSyncIsNeeded) { // The purpose of this request was to push changes return false; } const latestRevisions = getLatestRevisionsPerTable( clientChangeSet, syncState?.latestRevisions ); const clientIdentity = syncState?.clientIdentity || randomString(16); // // Offload large blobs to blob storage before sync // let processedChangeSet = clientChangeSet; const maxStringLength = db.cloud.options?.maxStringLength ?? 32768; const hasLargeBlobs = hasLargeBlobsInOperations(clientChangeSet, maxStringLength); if (hasLargeBlobs) { processedChangeSet = await offloadBlobsInOperations( clientChangeSet, databaseUrl, () => loadCachedAccessToken(db), maxStringLength ); } // // Push changes to server // throwIfCancelled(cancelToken); const res = await syncWithServer( processedChangeSet, yMessages, syncState, baseRevs, db, databaseUrl, schema, clientIdentity, currentUser ); console.debug('Sync response', res); // // Apply changes locally and clear old change entries: // const {done, newSyncState} = await db.transaction('rw', db.tables, async (tx) => { // @ts-ignore tx.idbtrans.disableChangeTracking = true; // @ts-ignore tx.idbtrans.disableAccessControl = true; // TODO: Take care of this flag in access control middleware! // Update db.cloud.schema from server response. // Local schema MAY include a subset of tables, so do not force all tables into local schema. for (const tableName of Object.keys(schema)) { if (res.schema[tableName]) { // Write directly into configured schema. This code can only be executed alone. schema[tableName] = res.schema[tableName]; } } await db.$syncState.put(schema, 'schema'); // List mutations that happened during our exchange with the server: const addedClientChanges = await listClientChanges(mutationTables, db, { since: latestRevisions, }); // // Delete changes now as server has return success // (but keep changes that haven't reached server yet) // for (const mutTable of mutationTables) { const tableName = getTableFromMutationTable(mutTable.name); if ( !addedClientChanges.some( (ch) => ch.table === tableName && ch.muts.length > 0 ) ) { // No added mutations for this table during the time we sent changes // to the server. // It is therefore safe to clear all changes (which is faster than // deleting a range) await Promise.all([ mutTable.clear(), db.$baseRevs.where({ tableName }).delete(), ]); } else if (latestRevisions[tableName]) { const latestRev = latestRevisions[tableName] || 0; await Promise.all([ mutTable.where('rev').belowOrEqual(latestRev).delete(), db.$baseRevs .where(':id') .between( [tableName, -Infinity], [tableName, latestRev + 1], true, true ) .reverse() .offset(1) // Keep one entry (the one mapping muts that came during fetch --> previous server revision) .delete(), ]); } else { // In this case, the mutation table only contains added items after sending empty changeset to server. // We should not clear out anything now. } } // Update latestRevisions object according to additional changes: getLatestRevisionsPerTable(addedClientChanges, latestRevisions); // Update/add new entries into baseRevs map. // * On tables without mutations since last serverRevision, // this will update existing entry. // * On tables where mutations have been recorded since last // serverRevision, this will create a new entry. // The purpose of this operation is to mark a start revision (per table) // so that all client-mutations that come after this, will be mapped to current // server revision. await updateBaseRevs(db, schema, latestRevisions, res.serverRevision); const syncState = await db.getPersistedSyncState(); // // Delete objects from removed realms // await deleteObjectsFromRemovedRealms(db, res, syncState); // // Update syncState // const newSyncState: PersistedSyncState = syncState || { syncedTables: [], latestRevisions: {}, realms: [], inviteRealms: [], clientIdentity, }; if (readyForSyncification) { newSyncState.syncedTables = tablesToSync .map((tbl) => tbl.name) .concat(tablesToSyncify.map((tbl) => tbl.name)); } newSyncState.latestRevisions = latestRevisions; newSyncState.remoteDbId = res.dbId; newSyncState.initiallySynced = true; newSyncState.realms = res.realms; newSyncState.inviteRealms = res.inviteRealms; newSyncState.serverRevision = res.serverRevision; newSyncState.yServerRevision = res.serverRevision; newSyncState.timestamp = new Date(); delete newSyncState.error; const filteredChanges = filterServerChangesThroughAddedClientChanges( res.changes, addedClientChanges ); // // apply server changes // await applyServerChanges(filteredChanges, db); if (res.yMessages) { // // apply yMessages // const {receivedUntils, resyncNeeded, yServerRevision} = await applyYServerMessages(res.yMessages, db); if (yServerRevision) { newSyncState.yServerRevision = yServerRevision; } // // update Y SyncStates // await updateYSyncStates(lastUpdateIds, receivedUntils, db); if (resyncNeeded) { newSyncState.yDownloadedRealms = {}; // Will trigger a full download of Y-documents below... } } // // Update regular syncState // db.$syncState.put(newSyncState, 'syncState'); return { done: addedClientChanges.length === 0, newSyncState }; }); if (!done) { console.debug('MORE SYNC NEEDED. Go for it again!'); await checkSyncRateLimitDelay(db); return await _sync(db, options, schema, { isInitialSync, cancelToken }); } const usingYProps = Object.values(schema).some(tbl => tbl.yProps?.length); const serverSupportsYprops = !!res.yMessages; if (usingYProps && serverSupportsYprops) { try { await downloadYDocsFromServer(db, databaseUrl, newSyncState); } catch (error) { console.error('Failed to download Yjs documents from server', error); } } console.debug('SYNC DONE', { isInitialSync }); db.syncCompleteEvent.next(); return false; // Not needed anymore } async function deleteObjectsFromRemovedRealms( db: DexieCloudDB, res: SyncResponse, syncState: PersistedSyncState | undefined ) { const deletedRealms = new Set(); const rejectedRealms = new Set(); const previousRealmSet = syncState ? syncState.realms : []; const previousInviteRealmSet = syncState ? syncState.inviteRealms : []; const updatedRealmSet = new Set(res.realms); const updatedTotalRealmSet = new Set(res.realms.concat(res.inviteRealms)); for (const realmId of previousRealmSet) { if (!updatedRealmSet.has(realmId)) { rejectedRealms.add(realmId); if (!updatedTotalRealmSet.has(realmId)) { deletedRealms.add(realmId); } } } for (const realmId of previousInviteRealmSet.concat(previousRealmSet)) { if (!updatedTotalRealmSet.has(realmId)) { deletedRealms.add(realmId); } } if (deletedRealms.size > 0 || rejectedRealms.size > 0) { const tables = getSyncableTables(db); for (const table of tables) { let realmsToDelete = ['realms', 'members', 'roles'].includes(table.name) ? deletedRealms // These tables should spare rejected ones. : rejectedRealms; // All other tables shoudl delete rejected+deleted ones if (realmsToDelete.size === 0) continue; if ( table.schema.indexes.some( (idx) => idx.keyPath === 'realmId' || (Array.isArray(idx.keyPath) && idx.keyPath[0] === 'realmId') ) ) { // There's an index to use: //console.debug(`REMOVAL: deleting all ${table.name} where realmId anyOf `, JSON.stringify([...realmsToDelete])); await table .where('realmId') .anyOf([...realmsToDelete]) .delete(); } else { // No index to use: //console.debug(`REMOVAL: deleting all ${table.name} where realmId is any of `, JSON.stringify([...realmsToDelete]), realmsToDelete.size); await table .filter((obj) => !!obj?.realmId && realmsToDelete.has(obj.realmId)) .delete(); } } } if (rejectedRealms.size > 0 && syncState?.yDownloadedRealms) { // Remove rejected/deleted realms from yDownloadedRealms because of the following use case: // 1. User becomes added to the realm // 2. User syncs and all documents of the realm is downloaded (downloadYDocsFromServer.ts) // 3. User leaves the realm and all docs are deleted locally (built-in-trigger of deleting their rows in this file) // 4. User is yet again added to the realm. At this point, we must make sure the docs are not considered already downloaded. const updateSpec: UpdateSpec = {}; for (const realmId of rejectedRealms) { delete syncState.yDownloadedRealms[realmId]; } } } export function filterServerChangesThroughAddedClientChanges( serverChanges: DBOperationsSet, addedClientChanges: DBOperationsSet ): DBOperationsSet { const changes: DBKeyMutationSet = {}; applyOperations(changes, serverChanges); const localPostChanges: DBKeyMutationSet = {}; applyOperations(localPostChanges, addedClientChanges); subtractChanges(changes, localPostChanges); return toDBOperationSet(changes); } ================================================ FILE: addons/dexie-cloud/src/sync/syncIfPossible.ts ================================================ import { IS_SERVICE_WORKER } from '../helpers/IS_SERVICE_WORKER'; import { performGuardedJob } from './performGuardedJob'; import { DexieCloudDB } from '../db/DexieCloudDB'; import { sync, CURRENT_SYNC_WORKER, SyncOptions } from './sync'; import { DexieCloudOptions } from '../DexieCloudOptions'; import { assert, DexieCloudSchema } from 'dexie-cloud-common'; import { checkSyncRateLimitDelay } from './ratelimit'; const ongoingSyncs = new WeakMap< DexieCloudDB, { promise: Promise; pull: boolean } >(); export function syncIfPossible( db: DexieCloudDB, cloudOptions: DexieCloudOptions, cloudSchema: DexieCloudSchema, options?: SyncOptions ) { const ongoing = ongoingSyncs.get(db); if (ongoing) { if (ongoing.pull || options?.purpose === 'push') { console.debug('syncIfPossible(): returning the ongoing sync promise.'); return ongoing.promise; } else { // Ongoing sync may never do anything in case there are no outstanding changes // to sync (because its purpose was "push" not "pull") // Now, however, we are asked to do a sync with the purpose of "pull" // We want to optimize here. We must wait for the ongoing to complete // and then, if the ongoing sync never resulted in a sync request, // we must redo the sync. // To inspect what is happening in the ongoing request, let's subscribe // to db.cloud.syncState and look for if it is doing any "pulling" phase: let hasPullTakenPlace = false; const subscription = db.cloud.syncState.subscribe((syncState) => { if (syncState.phase === 'pulling') { hasPullTakenPlace = true; } }); // Ok, so now we are watching. At the same time, wait for the ongoing to complete // and when it has completed, check if we're all set or if we need to redo // the call: return ( ongoing.promise // This is a finally block but we are still running tests on // browsers that don't support it, so need to do it like this: .then(() => { subscription.unsubscribe(); }) .catch((error) => { subscription.unsubscribe(); return Promise.reject(error); }) .then(() => { if (!hasPullTakenPlace) { // No pull took place in the ongoing sync but the caller had "pull" as // an explicit purpose of this call - so we need to redo the call! return syncIfPossible(db, cloudOptions, cloudSchema, options); } }) ); } } const promise = _syncIfPossible(); ongoingSyncs.set(db, { promise, pull: options?.purpose !== 'push' }); return promise; async function _syncIfPossible() { try { // Check if should delay sync due to ratelimit: await checkSyncRateLimitDelay(db); await performGuardedJob(db, CURRENT_SYNC_WORKER, () => sync(db, cloudOptions, cloudSchema, options) ); ongoingSyncs.delete(db); } catch (error) { ongoingSyncs.delete(db); console.error(`Failed to sync client changes`, error); throw error; // Make sure we rethrow error so that sync event is retried. // I don't think we should setTimout or so here. // Unless server tells us to in some response. // Then we could follow that advice but not by waiting here but by registering // Something that triggers an event listened to in startPushWorker() } } } ================================================ FILE: addons/dexie-cloud/src/sync/syncWithServer.ts ================================================ import { DexieCloudDB } from '../db/DexieCloudDB'; import { PersistedSyncState } from '../db/entities/PersistedSyncState'; import { loadAccessToken } from '../authentication/authenticate'; import { TSON } from '../TSON'; import { getSyncableTables } from '../helpers/getSyncableTables'; import { BaseRevisionMapEntry } from '../db/entities/BaseRevisionMapEntry'; import { HttpError } from '../errors/HttpError'; import { DBOperationsSet, DexieCloudSchema, SyncRequest, SyncResponse, YClientMessage, } from 'dexie-cloud-common'; import { encodeIdsForServer } from './encodeIdsForServer'; import { UserLogin } from '../db/entities/UserLogin'; import { updateSyncRateLimitDelays } from './ratelimit'; //import {BisonWebStreamReader} from "dreambase-library/dist/typeson-simplified/BisonWebStreamReader"; export async function syncWithServer( changes: DBOperationsSet, y: YClientMessage[], syncState: PersistedSyncState | undefined, baseRevs: BaseRevisionMapEntry[], db: DexieCloudDB, databaseUrl: string, schema: DexieCloudSchema | null, clientIdentity: string, currentUser: UserLogin ): Promise { // // Push changes to server using fetch // const headers: HeadersInit = { Accept: 'application/json', 'Content-Type': 'application/tson', }; const updatedUser = await loadAccessToken(db); /* if (updatedUser?.license && changes.length > 0) { if (updatedUser.license.status === 'expired') { throw new Error(`License has expired`); } if (updatedUser.license.status === 'deactivated') { throw new Error(`License deactivated`); } } */ const accessToken = updatedUser?.accessToken; if (accessToken) { headers.Authorization = `Bearer ${accessToken}`; } const syncRequest: SyncRequest = { v: 3, // v3 = supports BlobRef dbID: syncState?.remoteDbId, clientIdentity, schema: schema || {}, lastPull: syncState ? { serverRevision: syncState.serverRevision!, yServerRevision: syncState.yServerRevision, realms: syncState.realms, inviteRealms: syncState.inviteRealms, } : undefined, baseRevs, changes: encodeIdsForServer(db.dx.core.schema, currentUser, changes), y, dxcv: db.cloud.version }; console.debug('Sync request', syncRequest); db.syncStateChangedEvent.next({ phase: 'pushing', }); const body = TSON.stringify(syncRequest); const res = await fetch(`${databaseUrl}/sync`, { method: 'post', headers, credentials: 'include', // For Arr Affinity cookie only, for better Rate-Limit counting only. body, }); //const contentLength = Number(res.headers.get('content-length')); db.syncStateChangedEvent.next({ phase: 'pulling', }); updateSyncRateLimitDelays(db, res); if (!res.ok) { throw new HttpError(res); } switch (res.headers.get('content-type')) { case 'application/x-bison': case 'application/x-bison-stream': // BISON format deprecated - throw error if server sends it throw new Error('BISON format no longer supported. Server should send application/json.'); default: case 'application/json': { const text = await res.text(); const syncRes = TSON.parse(text); return syncRes; } } } ================================================ FILE: addons/dexie-cloud/src/sync/triggerSync.ts ================================================ import { DexieCloudDB } from "../db/DexieCloudDB"; import { registerSyncEvent } from "./registerSyncEvent"; export function triggerSync(db: DexieCloudDB, purpose: "push" | "pull") { if (db.cloud.usingServiceWorker) { console.debug('registering sync event'); registerSyncEvent(db, purpose); } else { db.localSyncEvent.next({purpose}); } } ================================================ FILE: addons/dexie-cloud/src/sync/updateBaseRevs.ts ================================================ import { DexieCloudDB } from '../db/DexieCloudDB'; import { DexieCloudSchema, SyncResponse } from 'dexie-cloud-common'; export async function updateBaseRevs(db: DexieCloudDB, schema: DexieCloudSchema, latestRevisions: { [table: string]: number; }, serverRev: any) { await db.$baseRevs.bulkPut( Object.keys(schema) .filter((table) => schema[table].markedForSync) .map((tableName) => { const lastClientRevOnPreviousServerRev = latestRevisions[tableName] || 0; return { tableName, clientRev: lastClientRevOnPreviousServerRev + 1, serverRev, }; }) ); // Clean up baseRevs for tables that do not exist anymore or are no longer marked for sync // Resolve #2168 by also cleaning up baseRevs for tables that are not marked for sync await db.$baseRevs.where('tableName').noneOf( Object.keys(schema).filter((table) => schema[table].markedForSync) ).delete(); } ================================================ FILE: addons/dexie-cloud/src/tsconfig.json ================================================ { "compilerOptions": { "module": "es2015", "target": "es2020", "declaration": true, "importHelpers": true, "strictNullChecks": true, "noImplicitAny": false, "noImplicitReturns": true, "moduleResolution": "node", "lib": ["ES2020", "DOM"], "forceConsistentCasingInFileNames": true, "sourceMap": true, "rootDir": ".", "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment", "downlevelIteration": true }, "include": ["**/*.ts"], "references": [{ "path": "../../../libs/dexie-cloud-common" }] } ================================================ FILE: addons/dexie-cloud/src/types/DXCAlert.ts ================================================ export type DXCAlert = DXCErrorAlert | DXCWarningAlert | DXCInfoAlert; export interface DXCErrorAlert { type: 'error'; messageCode: 'INVALID_OTP' | 'INVALID_EMAIL' | 'LICENSE_LIMIT_REACHED' | 'GENERIC_ERROR'; message: string; messageParams: { [paramName: string]: string; }; /** Optional text that users can copy to clipboard (e.g. a CLI command) */ copyText?: string; } export interface DXCWarningAlert { type: 'warning'; messageCode: 'GENERIC_WARNING' | 'LOGOUT_CONFIRMATION'; message: string; messageParams: { [paramName: string]: string; }; /** Optional text that users can copy to clipboard (e.g. a CLI command) */ copyText?: string; } export interface DXCInfoAlert { type: 'info'; messageCode: 'GENERIC_INFO' | 'OTP_SENT'; message: string; messageParams: { [paramName: string]: string; }; /** Optional text that users can copy to clipboard (e.g. a CLI command) */ copyText?: string; } ================================================ FILE: addons/dexie-cloud/src/types/DXCInputField.ts ================================================ export type DXCInputField = DXCTextField | DXCPasswordField; export interface DXCTextField { type: 'text' | 'email' | 'otp'; label?: string; placeholder?: string; } export interface DXCPasswordField { type: 'password'; label?: string; placeholder?: string; } ================================================ FILE: addons/dexie-cloud/src/types/DXCUserInteraction.ts ================================================ import { DXCAlert } from './DXCAlert'; import { DXCInputField } from './DXCInputField'; export type DXCUserInteraction = | DXCGenericUserInteraction | DXCEmailPrompt | DXCOTPPrompt | DXCMessageAlert | DXCLogoutConfirmation; /** A selectable option that can appear in any user interaction. * * Similar to an HTML `