Loading...
+ }
+
return (
-
Using Ditto with path “{path}“
-
Number of tasks {tasks.length}
+ <>
+
+ Using Ditto with path “{persistenceDirectory}“ and query
+ “{storeObserver?.queryString}“
+
+
Number of tasks {tasks?.length}
+
+ {error &&
Error: {String(error)}
}
+ >
{
value: {
body: newBodyText,
isCompleted: false,
+ isDeleted: false,
},
})
setNewBodyText('')
@@ -52,8 +88,8 @@ const App = ({ path }: Props) => {
{tasks.map((task) => {
return (
-
- DocumentId: {task.id.value}
+
+ DocumentId: {task.value._id}
Body: {task.value.body}
Is Completed:{' '}
@@ -61,20 +97,16 @@ const App = ({ path }: Props) => {
{
- removeByID({ _id: task.id })
+ removeByID({ id: task.value._id })
}}
>
Remove
{
- updateByID({
- _id: task.id,
- updateClosure: (mutableDoc) => {
- mutableDoc
- .at('isCompleted')
- .set(!mutableDoc.value.isCompleted)
- },
+ setCompletedByID({
+ _id: task.value._id,
+ isCompleted: !task.value.isCompleted,
})
}}
>
diff --git a/examples/vite-typescript-example/src/AppContainer.tsx b/examples/vite-typescript-example/src/AppContainer.tsx
index 59b81b9..60adad2 100644
--- a/examples/vite-typescript-example/src/AppContainer.tsx
+++ b/examples/vite-typescript-example/src/AppContainer.tsx
@@ -1,53 +1,83 @@
-import { Ditto } from '@dittolive/ditto'
-import {
- DittoProvider,
- useOfflinePlaygroundIdentity,
- useOnlineIdentity,
-} from '@dittolive/react-ditto'
+import { Authenticator, Ditto, DittoConfig } from '@dittolive/ditto'
+import { DittoProvider } from '@dittolive/react-ditto'
import React, { useState } from 'react'
import { default as ReactSelect, SingleValue } from 'react-select'
-import { v4 as uuidv4 } from 'uuid'
import App from './App'
-import AuthenticationPanel from './AuthenticationPanel'
-interface IdentityOption {
+interface InstanceOption {
name: string
path: string
}
-const options: IdentityOption[] = [
- { path: '/path-development', name: 'Development' },
- { path: '/path-online', name: 'Online' },
+
+// Online (server) connection details. Copy these from your database's settings
+// page in the Ditto portal. Copy the whole connection URL verbatim from
+// "Connect via SDK → URL"; do not build it from the database ID.
+const DITTO_DATABASE_ID = 'REPLACE_ME_WITH_YOUR_DATABASE_ID'
+const DITTO_SERVER_URL = 'REPLACE_ME_WITH_YOUR_URL'
+const DITTO_PLAYGROUND_TOKEN = 'REPLACE_ME_WITH_YOUR_PLAYGROUND_TOKEN'
+
+const DEVELOPMENT_PATH = '/path-development'
+const ONLINE_PATH = '/path-online'
+
+// The online (server) instance is only opened once the credentials above are
+// filled in. Until then the example runs fully offline and out of the box.
+const isOnlineConfigured =
+ DITTO_DATABASE_ID !== 'REPLACE_ME_WITH_YOUR_DATABASE_ID' &&
+ DITTO_SERVER_URL !== 'REPLACE_ME_WITH_YOUR_URL'
+
+const options: InstanceOption[] = [
+ { path: DEVELOPMENT_PATH, name: 'Development (offline)' },
+ ...(isOnlineConfigured
+ ? [{ path: ONLINE_PATH, name: 'Online (server)' }]
+ : []),
]
/**
- * Container component that shows how to initialize the DittoProvider component.
+ * Container component that shows how to initialize the DittoProvider with the
+ * Ditto v5 API (`Ditto.open` + `DittoConfig`).
* */
const AppContainer: React.FC = () => {
- const { create: createDevelopment } = useOfflinePlaygroundIdentity()
- const { create: createOnline, getAuthenticationRequired } =
- useOnlineIdentity()
- const [currentPath, setCurrentPath] = useState('/path-development')
+ const [currentPath, setCurrentPath] = useState(DEVELOPMENT_PATH)
- const handleCreateDittoInstances = () => {
- // Example of how to create a development instance
- const dittoDevelopment = new Ditto(
- createDevelopment({ appID: 'live.ditto.example', siteID: 1234 }),
- '/path-development',
+ const handleCreateDittoInstances = async (): Promise => {
+ // An offline, peer-to-peer ("small peers only") instance. No authentication
+ // is required, so sync can be started right away.
+ const dittoDevelopment = await Ditto.open(
+ new DittoConfig(
+ 'live.ditto.example',
+ { mode: 'smallPeersOnly' },
+ DEVELOPMENT_PATH,
+ ),
)
+ dittoDevelopment.sync.start()
+
+ if (!isOnlineConfigured) {
+ return [dittoDevelopment]
+ }
- // Example of how to create an online instance with authentication enabled
- const dittoOnline = new Ditto(
- createOnline(
- {
- // If you're using the Ditto cloud this ID should be the app ID shown on your app settings page, on the portal.
- appID: uuidv4(),
- // enableDittoCloudSync: true,
- },
- '/path-online',
+ // An instance connected to a Ditto server (Big Peer). Server connections
+ // require an authentication expiration handler to be set before starting
+ // sync — `sync.start()` throws otherwise.
+ const dittoOnline = await Ditto.open(
+ new DittoConfig(
+ DITTO_DATABASE_ID,
+ { mode: 'server', url: DITTO_SERVER_URL },
+ ONLINE_PATH,
),
- '/path-online',
)
+ await dittoOnline.auth.setExpirationHandler(async (ditto) => {
+ try {
+ await ditto.auth.login(
+ DITTO_PLAYGROUND_TOKEN,
+ Authenticator.DEVELOPMENT_PROVIDER,
+ )
+ } catch (error) {
+ console.error('Ditto authentication failed:', error)
+ }
+ })
+ dittoOnline.sync.start()
+
return [dittoDevelopment, dittoOnline]
}
@@ -60,15 +90,13 @@ const AppContainer: React.FC = () => {
padding: '4px',
}}
>
-
- Identity type
-
-
- getOptionLabel={(animal: IdentityOption) => animal.name}
- getOptionValue={(animal: IdentityOption) => animal.path}
+ Instance
+
+ getOptionLabel={(option: InstanceOption) => option.name}
+ getOptionValue={(option: InstanceOption) => option.path}
options={options}
value={options.find((opt) => opt.path === currentPath)}
- onChange={(nextOption: SingleValue) =>
+ onChange={(nextOption: SingleValue) =>
setCurrentPath(nextOption!.path)
}
/>
@@ -79,18 +107,11 @@ const AppContainer: React.FC = () => {
return Loading
}
if (error) {
+ console.error('Error creating Ditto instances:', error)
return Error: {JSON.stringify(error)}
}
- return (
- <>
-
-
- >
- )
+ return
}}
>
diff --git a/examples/vite-typescript-example/src/AuthenticationPanel.tsx b/examples/vite-typescript-example/src/AuthenticationPanel.tsx
deleted file mode 100644
index 8e3b20b..0000000
--- a/examples/vite-typescript-example/src/AuthenticationPanel.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { useDitto } from '@dittolive/react-ditto'
-import React, { useState } from 'react'
-
-type Props = {
- /** True if authentication is required */
- isAuthRequired: boolean
- /** Current active path */
- path: string
-}
-
-/** Simple authenticate panel for the user to input a token and a token provider.
- */
-const AuthenticationPanel: React.FC = ({ path, isAuthRequired }) => {
- const [token, setToken] = useState('')
- const [provider, setProvider] = useState('')
- const [authError, setAuthError] = useState()
- const { ditto } = useDitto(path)
- const [isAuthenticated, setIsAuthenticated] = useState(
- !!ditto?.auth.status.isAuthenticated,
- )
-
- if (!ditto || !isAuthRequired || isAuthenticated) {
- return null
- }
-
- return (
-
-
-
-
- {authError?.message}
-
-
- )
-}
-
-export default AuthenticationPanel
diff --git a/examples/vite-typescript-example/tsconfig.json b/examples/vite-typescript-example/tsconfig.json
index 37f9b87..20f51ab 100644
--- a/examples/vite-typescript-example/tsconfig.json
+++ b/examples/vite-typescript-example/tsconfig.json
@@ -15,7 +15,7 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
- "types": ["vite/client", "node", "jest", "@testing-library/jest-dom"]
+ "types": ["vite/client", "node", "vitest/globals", "@testing-library/jest-dom"]
},
- "include": ["src", "setupTests.ts"]
+ "include": ["src", "vitest.setup.ts"]
}
diff --git a/examples/vite-typescript-example/vite.config.ts b/examples/vite-typescript-example/vite.config.ts
index 984eab1..d6d449a 100644
--- a/examples/vite-typescript-example/vite.config.ts
+++ b/examples/vite-typescript-example/vite.config.ts
@@ -1,3 +1,4 @@
+///
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
@@ -7,4 +8,14 @@ export default defineConfig({
server: {
port: 3000,
},
+ // Ensure a single copy of React is used, even when `@dittolive/react-ditto`
+ // is resolved from a local `file:` link that carries its own dependency tree.
+ resolve: {
+ dedupe: ['react', 'react-dom'],
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./vitest.setup.ts'],
+ },
})
diff --git a/examples/vite-typescript-example/vitest.setup.ts b/examples/vite-typescript-example/vitest.setup.ts
new file mode 100644
index 0000000..a9d0dd3
--- /dev/null
+++ b/examples/vite-typescript-example/vitest.setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest'
diff --git a/karma.conf.js b/karma.conf.js
index 802aa3c..655cdbc 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -17,7 +17,15 @@ module.exports = function (config) {
port: 9876, // karma web server port
colors: true,
logLevel: config.LOG_INFO,
- browsers: ['ChromeHeadless'],
+ // CI runners (and most container images) disable the Chrome sandbox, so
+ // run headless Chrome with it turned off.
+ customLaunchers: {
+ ChromeHeadlessNoSandbox: {
+ base: 'ChromeHeadless',
+ flags: ['--no-sandbox'],
+ },
+ },
+ browsers: ['ChromeHeadlessNoSandbox'],
autoWatch: false,
// singleRun: false, // Karma captures browsers, runs the tests and exits
concurrency: Infinity,
diff --git a/package-lock.json b/package-lock.json
index 0aa653a..4ba11c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,20 +1,21 @@
{
"name": "@dittolive/react-ditto",
- "version": "0.11.2",
+ "version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@dittolive/react-ditto",
- "version": "0.11.2",
+ "version": "1.0.0",
"license": "ISC",
"dependencies": {
"lodash.isequal": "^4.5.0"
},
"devDependencies": {
- "@dittolive/ditto": "^4.0.0",
+ "@dittolive/ditto": "^5.0.0",
"@testing-library/react": "^14.3.1",
"@types/chai": "^4.2.21",
+ "@types/karma": "^6.3.8",
"@types/lodash": "^4.14.172",
"@types/lodash.isequal": "^4.5.6",
"@types/mocha": "^10.0.0",
@@ -63,7 +64,7 @@
"node": ">=20"
},
"peerDependencies": {
- "@dittolive/ditto": "^4.0.0",
+ "@dittolive/ditto": "^5.0.0",
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
@@ -1694,29 +1695,12 @@
"node": ">=0.1.90"
}
},
- "node_modules/@deno/shim-deno": {
- "version": "0.16.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@deno/shim-deno-test": "^0.4.0",
- "which": "^2.0.2"
- }
- },
- "node_modules/@deno/shim-deno-test": {
- "version": "0.4.0",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@dittolive/ditto": {
- "version": "4.7.0",
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@dittolive/ditto/-/ditto-5.0.1.tgz",
+ "integrity": "sha512-xkADT6aAyyXTiG8NUbHOw3tuYlOIlo1TxhodaFxpGC0PADHMWechj8fiHsLYkbqCJpIVD8ZbB+8iMeApDA2Bhg==",
"dev": true,
"license": "SEE LICENSE IN LICENSE.md",
- "dependencies": {
- "@ungap/weakrefs": "^0.2.0",
- "cbor-redux": "^1.0.0",
- "fastestsmallesttextencoderdecoder": "^1.0.22"
- },
"engines": {
"node": ">=14"
}
@@ -2316,6 +2300,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/karma": {
+ "version": "6.3.9",
+ "resolved": "https://registry.npmjs.org/@types/karma/-/karma-6.3.9.tgz",
+ "integrity": "sha512-sjE/MHnoAZAQYAKRXAbjTOiBKyGGErEM725bruRcmDdMa2vp1bjWPhApI7/i564PTyHlzc3vIGXLL6TFIpAxFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "log4js": "^6.4.1"
+ }
+ },
"node_modules/@types/lodash": {
"version": "4.17.0",
"dev": true,
@@ -2628,11 +2623,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/@ungap/weakrefs": {
- "version": "0.2.0",
- "dev": true,
- "license": "ISC"
- },
"node_modules/accepts": {
"version": "1.3.8",
"dev": true,
@@ -3524,14 +3514,6 @@
],
"license": "CC-BY-4.0"
},
- "node_modules/cbor-redux": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@deno/shim-deno": "~0.16.1"
- }
- },
"node_modules/chai": {
"version": "4.4.1",
"dev": true,
@@ -5033,11 +5015,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fastestsmallesttextencoderdecoder": {
- "version": "1.0.22",
- "dev": true,
- "license": "CC0-1.0"
- },
"node_modules/fastq": {
"version": "1.17.1",
"dev": true,
diff --git a/package.json b/package.json
index 029920f..179a15e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@dittolive/react-ditto",
- "version": "0.11.2",
+ "version": "1.0.0",
"description": "React wrappers for Ditto",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
@@ -18,6 +18,7 @@
"build:cjs": "tsc -p tsconfig.json",
"build:static-files": "cp package.json CHANGELOG.md README.md dist/",
"test": "karma start",
+ "test:watch": "karma start --auto-watch --no-single-run",
"types": "tsc -p tsconfig.esm.json",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
@@ -35,7 +36,7 @@
"lodash.isequal": "^4.5.0"
},
"peerDependencies": {
- "@dittolive/ditto": "^4.0.0",
+ "@dittolive/ditto": "^5.0.0",
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
},
@@ -50,9 +51,10 @@
"basic-ftp": "5.3.1"
},
"devDependencies": {
- "@dittolive/ditto": "^4.0.0",
+ "@dittolive/ditto": "^5.0.0",
"@testing-library/react": "^14.3.1",
"@types/chai": "^4.2.21",
+ "@types/karma": "^6.3.8",
"@types/lodash": "^4.14.172",
"@types/lodash.isequal": "^4.5.6",
"@types/mocha": "^10.0.0",
diff --git a/src/DittoContext.tsx b/src/DittoContext.tsx
index 1243167..8520c50 100644
--- a/src/DittoContext.tsx
+++ b/src/DittoContext.tsx
@@ -8,6 +8,17 @@ export interface DittoHash {
export type RegisterDitto = (ditto: Ditto) => void
export type UnregisterDitto = (persistenceDirectory: string) => void
+/**
+ * Computes the key under which a Ditto instance is registered in the provider's
+ * {@link DittoHash}.
+ *
+ * Uses the persistence directory configured on the instance — matching the
+ * value a consumer passes to {@link useDitto} or a hook's `persistenceDirectory`
+ * option — and falls back to the resolved absolute directory when none was set.
+ */
+export const dittoInstanceKey = (ditto: Ditto): string =>
+ ditto.config.persistenceDirectory ?? ditto.absolutePersistenceDirectory
+
export interface DittoContextProps {
dittoHash: DittoHash
registerDitto?: RegisterDitto
diff --git a/src/DittoLazyProvider.spec.tsx b/src/DittoLazyProvider.spec.tsx
index d0cd851..2e9d29a 100644
--- a/src/DittoLazyProvider.spec.tsx
+++ b/src/DittoLazyProvider.spec.tsx
@@ -1,7 +1,4 @@
-import dittoPackage, {
- Ditto,
- IdentityOfflinePlayground,
-} from '@dittolive/ditto'
+import dittoPackage from '@dittolive/ditto'
import { expect } from 'chai'
import React from 'react'
import { createRoot, Root } from 'react-dom/client'
@@ -10,17 +7,13 @@ import { v4 as uuidv4 } from 'uuid'
import { useDittoContext } from './DittoContext'
import { DittoLazyProvider } from './DittoLazyProvider'
-import { waitFor } from './utils.spec'
+import { openOfflineDitto, waitFor } from './utils.spec'
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
+const testConfig: () => {
+ databaseID: string
path: string
} = () => ({
- identity: {
- appID: 'dittoLazyProviderSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
+ databaseID: 'dittoLazyProviderSpec',
path: uuidv4(),
})
@@ -46,13 +39,11 @@ describe('Ditto Lazy Provider Tests', () => {
it('should load ditto wasm from the CDN', async function () {
this.timeout(10_000)
- const config = testIdentity()
+ const config = testConfig()
root.render(
{
- return Promise.resolve(new Ditto(config.identity, config.path))
- }}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{({ loading, error }) => {
return (
@@ -77,14 +68,12 @@ describe('Ditto Lazy Provider Tests', () => {
})
it('should load ditto wasm from a locally served ditto.wasm file', async function () {
- const config = testIdentity()
+ const config = testConfig()
root.render(
{
- return Promise.resolve(new Ditto(config.identity, config.path))
- }}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{({ loading, error }) => {
return (
@@ -108,7 +97,7 @@ describe('Ditto Lazy Provider Tests', () => {
})
it('should fail to load ditto from web assembly file that does not exist', async function () {
- const config = testIdentity()
+ const config = testConfig()
const initOptions = {
webAssemblyModule:
'/base/node_modules/@dittolive/ditto/web/ditto-that-does-not-exist.wasm',
@@ -117,9 +106,7 @@ describe('Ditto Lazy Provider Tests', () => {
root.render(
{
- return Promise.resolve(new Ditto(config.identity, config.path))
- }}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{({ loading, error }) => {
return (
@@ -144,7 +131,7 @@ describe('Ditto Lazy Provider Tests', () => {
})
it('should mount the provider with an empty set of Ditto instances.', async () => {
- const config = testIdentity()
+ const config = testConfig()
const TesterChildComponent = () => {
const { dittoHash } = useDittoContext()
@@ -158,9 +145,7 @@ describe('Ditto Lazy Provider Tests', () => {
root.render(
{
- return Promise.resolve(new Ditto(config.identity, config.path))
- }}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{() => }
,
diff --git a/src/DittoLazyProvider.tsx b/src/DittoLazyProvider.tsx
index a665e06..c4176c2 100644
--- a/src/DittoLazyProvider.tsx
+++ b/src/DittoLazyProvider.tsx
@@ -4,6 +4,7 @@ import React, { ReactNode, useEffect, useRef, useState } from 'react'
import {
DittoContext,
DittoHash,
+ dittoInstanceKey,
RegisterDitto,
UnregisterDitto,
} from './DittoContext.js'
@@ -15,9 +16,13 @@ export interface DittoLazyProviderProps {
* This function is called whenever a child component uses a Ditto instance through the useDitto hook
* and the instance needs to be created.
*
- * @param props Path on which the app is being created. Should be used as a discriminator to determine how the Ditto instance
- * should be created.
- * @returns A Ditto instance initialized on the given path
+ * The returned instance is cached under `appPath`, so configure it with
+ * `appPath` as its persistence directory. That keeps the lazily loaded
+ * instance resolvable by the same path through `useDitto` and the query hooks.
+ *
+ * @param appPath Path the instance is created for. Used both as a discriminator
+ * for how to create the instance and as its cache key.
+ * @returns A Ditto instance initialized on the given path, or `null` to skip creation.
*/
setup: (appPath: string) => Promise
render?: RenderFunction
@@ -103,7 +108,7 @@ export const DittoLazyProvider: React.FunctionComponent<
}
const registerDitto: RegisterDitto = (ditto) => {
- if (ditto.persistenceDirectory in dittoHash) {
+ if (dittoInstanceKey(ditto) in dittoHash) {
throw new Error(
'The instance path is already being used by a Ditto instance.',
)
@@ -111,7 +116,7 @@ export const DittoLazyProvider: React.FunctionComponent<
setDittoHash((currentHash) => ({
...currentHash,
- [ditto.persistenceDirectory]: ditto,
+ [dittoInstanceKey(ditto)]: ditto,
}))
}
diff --git a/src/DittoProvider.spec.tsx b/src/DittoProvider.spec.tsx
index 9003cff..6a107be 100644
--- a/src/DittoProvider.spec.tsx
+++ b/src/DittoProvider.spec.tsx
@@ -1,7 +1,4 @@
-import dittoPackage, {
- Ditto,
- IdentityOfflinePlayground,
-} from '@dittolive/ditto'
+import dittoPackage from '@dittolive/ditto'
import { expect } from 'chai'
import React from 'react'
import { createRoot, Root } from 'react-dom/client'
@@ -10,17 +7,13 @@ import { v4 as uuidv4 } from 'uuid'
import { useDittoContext } from './DittoContext'
import { DittoProvider } from './DittoProvider'
-import { waitFor } from './utils.spec'
+import { openOfflineDitto, waitFor } from './utils.spec'
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
+const testConfig: () => {
+ databaseID: string
path: string
} = () => ({
- identity: {
- appID: 'dittoProviderSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
+ databaseID: 'dittoProviderSpec',
path: uuidv4(),
})
@@ -46,14 +39,11 @@ describe('Ditto Provider Tests', () => {
it('should load ditto wasm from the CDN', async function () {
this.timeout(10_000)
- const config = testIdentity()
+ const config = testConfig()
root.render(
{
- const ditto = new Ditto(config.identity, config.path)
- return ditto
- }}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{({ loading, error }) => {
return (
@@ -78,15 +68,12 @@ describe('Ditto Provider Tests', () => {
})
it('should load ditto wasm from a locally served ditto.wasm file', async function () {
- const config = testIdentity()
+ const config = testConfig()
root.render(
{
- const ditto = new Ditto(config.identity, config.path)
- return ditto
- }}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{({ loading, error }) => {
return (
@@ -111,7 +98,7 @@ describe('Ditto Provider Tests', () => {
})
it('should fail to load ditto from web assembly file that does not exist', async function () {
- const config = testIdentity()
+ const config = testConfig()
const initOptions = {
webAssemblyModule:
@@ -121,10 +108,7 @@ describe('Ditto Provider Tests', () => {
root.render(
{
- const ditto = new Ditto(config.identity, config.path)
- return ditto
- }}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{({ loading, error }) => {
return (
@@ -149,7 +133,7 @@ describe('Ditto Provider Tests', () => {
})
it('should mount the provider with the initialized Ditto instance.', async () => {
- const config = testIdentity()
+ const config = testConfig()
const TesterChildComponent = () => {
const { dittoHash } = useDittoContext()
@@ -163,9 +147,7 @@ describe('Ditto Provider Tests', () => {
root.render(
{
- return new Ditto(config.identity, config.path)
- }}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
initOptions={initOptions}
>
{() => }
@@ -181,13 +163,13 @@ describe('Ditto Provider Tests', () => {
})
it('should pass the loading state to the child component when the provider is initialized as a single instance', async () => {
- const config = testIdentity()
+ const config = testConfig()
const renderFn = sinon.stub()
renderFn.withArgs(sinon.match({ loading: false })).returns('loaded')
root.render(
new Ditto(config.identity, config.path)}
+ setup={() => openOfflineDitto(config.databaseID, config.path)}
initOptions={initOptions}
>
{renderFn}
@@ -201,17 +183,19 @@ describe('Ditto Provider Tests', () => {
})
it('should pass the loading state to the child component when the provider is initialized as an array of instances', async () => {
- const config = testIdentity()
- const config2 = testIdentity()
+ const config = testConfig()
+ const config2 = testConfig()
const renderFn = sinon.stub()
renderFn.withArgs(sinon.match({ loading: false })).returns('loaded')
root.render(
[
- new Ditto(config.identity, config.path),
- new Ditto(config2.identity, config2.path),
- ]}
+ setup={() =>
+ Promise.all([
+ openOfflineDitto(config.databaseID, config.path),
+ openOfflineDitto(config2.databaseID, config2.path),
+ ])
+ }
initOptions={initOptions}
>
{renderFn}
@@ -243,14 +227,14 @@ describe('Ditto Provider Tests', () => {
})
it('should work with an async setup function', async () => {
- const config = testIdentity()
+ const config = testConfig()
const renderFn = sinon.stub()
renderFn.withArgs(sinon.match({ loading: false })).returns('loaded')
root.render(
{
- const ditto = new Ditto(config.identity, config.path)
+ const ditto = await openOfflineDitto(config.databaseID, config.path)
await new Promise((resolve) => setTimeout(resolve, 10))
return ditto
}}
diff --git a/src/DittoProvider.tsx b/src/DittoProvider.tsx
index a3e3eb7..dd689ee 100644
--- a/src/DittoProvider.tsx
+++ b/src/DittoProvider.tsx
@@ -7,7 +7,7 @@ import React, {
useState,
} from 'react'
-import { DittoContext } from './DittoContext.js'
+import { DittoContext, dittoInstanceKey } from './DittoContext.js'
import { DittoHash, RegisterDitto, UnregisterDitto } from './index.js'
export type RenderFunction = (providerState: ProviderState) => ReactNode
@@ -77,7 +77,7 @@ export const DittoProvider: React.FunctionComponent = (
`expected an array of Ditto instances to be returned by the setup function, but at least one element is not a Ditto instance (got ${ditto})`,
)
}
- dittoHash[ditto.persistenceDirectory] = ditto
+ dittoHash[dittoInstanceKey(ditto)] = ditto
}
setDittoHash(dittoHash)
} else {
@@ -90,7 +90,7 @@ export const DittoProvider: React.FunctionComponent = (
)
}
const dittoHash: DittoHash = {}
- dittoHash[ditto.persistenceDirectory] = ditto
+ dittoHash[dittoInstanceKey(ditto)] = ditto
setDittoHash(dittoHash)
}
setProviderState({
@@ -117,7 +117,7 @@ export const DittoProvider: React.FunctionComponent = (
const registerDitto: RegisterDitto = (ditto) => {
const hash = { ...dittoHash }
- hash[ditto.persistenceDirectory] = ditto
+ hash[dittoInstanceKey(ditto)] = ditto
setDittoHash(hash)
}
diff --git a/src/identity/index.ts b/src/identity/index.ts
deleted file mode 100644
index 7b20cba..0000000
--- a/src/identity/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './useOfflinePlaygroundIdentity.js'
-export * from './useOnlineIdentity.js'
-export * from './useOnlinePlaygroundIdentity.js'
diff --git a/src/identity/useOfflinePlaygroundIdentity.spec.ts b/src/identity/useOfflinePlaygroundIdentity.spec.ts
deleted file mode 100644
index fd44644..0000000
--- a/src/identity/useOfflinePlaygroundIdentity.spec.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import { expect } from 'chai'
-
-import { useOfflinePlaygroundIdentity } from './useOfflinePlaygroundIdentity'
-
-describe('Ditto useDevelopmentIdentity hook tests', () => {
- it('should correctly create a development identity', () => {
- const { result } = renderHook(() => useOfflinePlaygroundIdentity())
-
- expect(result.current.create).to.exist
-
- const identity = result.current.create({ appID: 'my-app', siteID: 1234 })
-
- expect(identity).to.eql({
- type: 'offlinePlayground',
- appID: 'my-app',
- siteID: 1234,
- })
- })
-})
diff --git a/src/identity/useOfflinePlaygroundIdentity.ts b/src/identity/useOfflinePlaygroundIdentity.ts
deleted file mode 100644
index 997d22c..0000000
--- a/src/identity/useOfflinePlaygroundIdentity.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { IdentityOfflinePlayground } from '@dittolive/ditto'
-
-export interface CreateOfflinePlaygroundIdentityParams {
- appID: string
- siteID: number | bigint
-}
-
-/**
- * @example
-
- * ```js
- * const { create } = useOfflinePlaygroundIdentity();
- *
- * const myIdentity = create({appId: uuid(), siteID: 1234});
- * const ditto = new Ditto(myIdentity, '/path');
- * ```
- *
- * A hook for creating Development Ditto identity objects.
- */
-export const useOfflinePlaygroundIdentity = (): {
- create: (
- params: CreateOfflinePlaygroundIdentityParams,
- ) => IdentityOfflinePlayground
-} => {
- return {
- create: ({
- appID,
- siteID,
- }: CreateOfflinePlaygroundIdentityParams): IdentityOfflinePlayground => {
- return {
- appID,
- siteID,
- type: 'offlinePlayground',
- }
- },
- }
-}
diff --git a/src/identity/useOnlineIdentity.spec.tsx b/src/identity/useOnlineIdentity.spec.tsx
deleted file mode 100644
index 743a878..0000000
--- a/src/identity/useOnlineIdentity.spec.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Ditto } from '@dittolive/ditto'
-import { render, renderHook, screen, waitFor } from '@testing-library/react'
-import { expect } from 'chai'
-import { v4 as uuidv4 } from 'uuid'
-
-import { DittoProvider } from '../DittoProvider'
-import { useOnlineIdentity } from './useOnlineIdentity'
-
-describe('Ditto useOnlineIdentity hook tests', () => {
- it('should correctly create an online identity', () => {
- const appID = uuidv4()
- const { result } = renderHook(() => useOnlineIdentity())
-
- expect(result.current.create).to.exist
- expect(result.current.getAuthenticationRequired).to.exist
- expect(result.current.getTokenExpiresInSeconds).to.exist
-
- const identity = result.current.create({ appID }, 'app')
-
- expect(identity.type).to.eql('onlineWithAuthentication')
- expect(identity.appID).to.eql(appID)
- expect(identity.authHandler).to.exist
- expect(identity.authHandler.authenticationRequired).to.exist
- expect(identity.authHandler.authenticationExpiringSoon).to.exist
- })
-
- it('should return true when the getAuthenticationRequired function is called and authentication is required to connect to an app', async () => {
- const appID = uuidv4()
- const { result } = renderHook(() => useOnlineIdentity())
-
- expect(result.current.create).to.exist
- const identity = result.current.create({ appID }, 'app')
-
- const { unmount } = render(
- {
- const ditto = new Ditto(identity, '/path')
- return ditto
- }}
- >
- {({ loading, error }) => {
- if (loading || error) {
- return null
- }
- return
- }}
- ,
- )
-
- await waitFor(
- () => expect(screen.queryAllByTestId('loaded')).not.to.be.empty,
- )
- await waitFor(
- () => expect(result.current.getAuthenticationRequired('app')).to.be.true,
- )
- await waitFor(unmount)
- })
-})
diff --git a/src/identity/useOnlineIdentity.ts b/src/identity/useOnlineIdentity.ts
deleted file mode 100644
index 20cd655..0000000
--- a/src/identity/useOnlineIdentity.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { IdentityOnlineWithAuthentication } from '@dittolive/ditto'
-import { useState } from 'react'
-
-export interface CreateOnlineIdentityParams {
- appID: string
- enableDittoCloudSync?: boolean
- customAuthURL?: string
-}
-
-export interface useOnlineIdentityProps {
- /**
- * Function used for creating a new online identity. Will save an internal reference to the identity inside of the
- * hook in order to manage the authentication state for the identity.
- * */
- create: (
- params: CreateOnlineIdentityParams,
- path: string,
- ) => IdentityOnlineWithAuthentication
- /**
- * Retrieves the authentication required state for any of the identities created with the hook.
- * */
- getAuthenticationRequired: (forPath: string) => boolean
- /**
- * Retrieves the token expiration time for an identity if one has been notified by the SDK, for any identity created with
- * the hook.
- * */
- getTokenExpiresInSeconds: (forPath: string) => number | null
-}
-
-/**
- * @example
-
- * ```js
- * const { create, isAuthenticationRequired, tokenExpiresInSeconds } = useOnlineIdentity();
- *
- * const onlineIdentity = create({appID: uuid(), enableDittoCloudSync: true});
- * const ditto = new Ditto(onlineIdentity, '/path');
- *
- * ...
- * ...
- *
- * return ditto.auth.loginWithToken('my-token', 'my-provider')}>Authenticate
- * ```
- *
- * A hook for creating OnlineDitto identity objects. For creating OnlinePlayground identities,
- * use `{@link useOnlinePlaygroundIdentity}` instead.
- * @returns useOnlineIdentityProps
- */
-export const useOnlineIdentity = (): useOnlineIdentityProps => {
- // Auth required booleans, indexed by the instance paths
- const [authenticationRequired, setAuthenticationRequired] = useState<{
- [path: string]: boolean
- }>({})
- // Auth expiring booleans, indexed by the instance paths
- const [authenticationExpiringSoon, setAuthenticationExpiringSoon] = useState<{
- [path: string]: number
- }>({})
-
- const create = (
- { appID, enableDittoCloudSync, customAuthURL }: CreateOnlineIdentityParams,
- path: string,
- ) => {
- return {
- type: 'onlineWithAuthentication',
- appID,
- enableDittoCloudSync,
- customAuthURL,
- authHandler: {
- authenticationRequired: () => {
- setAuthenticationRequired((currentAuthRequired) => ({
- ...currentAuthRequired,
- [path]: true,
- }))
- },
- authenticationExpiringSoon: (authenticator, tokenExpiresInSeconds) => {
- setAuthenticationExpiringSoon((currentAuthExpiringSoon) => ({
- ...currentAuthExpiringSoon,
- [path]: tokenExpiresInSeconds,
- }))
- },
- },
- } as IdentityOnlineWithAuthentication
- }
-
- const getAuthenticationRequired = (forPath: string) => {
- return forPath in authenticationRequired
- }
-
- const getTokenExpiresInSeconds = (forPath: string) => {
- return forPath in authenticationExpiringSoon
- ? authenticationExpiringSoon[forPath]
- : null
- }
-
- return {
- create,
- getAuthenticationRequired,
- getTokenExpiresInSeconds,
- }
-}
diff --git a/src/identity/useOnlinePlaygroundIdentity.spec.ts b/src/identity/useOnlinePlaygroundIdentity.spec.ts
deleted file mode 100644
index 92431b1..0000000
--- a/src/identity/useOnlinePlaygroundIdentity.spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import { expect } from 'chai'
-import { v4 as uuidv4 } from 'uuid'
-
-import { useOnlinePlaygroundIdentity } from './useOnlinePlaygroundIdentity'
-
-describe('Ditto useOnlinePlaygroundIdentity hook tests', () => {
- it('should correctly create an onlinePlayground identity', () => {
- const appID = uuidv4()
-
- const { result } = renderHook(() => useOnlinePlaygroundIdentity())
-
- expect(result.current.create).to.exist
-
- const identity = result.current.create({
- appID: appID,
- token: 'my-token',
- })
-
- expect(identity).to.eql({
- type: 'onlinePlayground',
- appID: appID,
- token: 'my-token',
- customAuthURL: undefined,
- enableDittoCloudSync: undefined,
- })
- })
-})
diff --git a/src/identity/useOnlinePlaygroundIdentity.ts b/src/identity/useOnlinePlaygroundIdentity.ts
deleted file mode 100644
index 1be69fd..0000000
--- a/src/identity/useOnlinePlaygroundIdentity.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { IdentityOnlinePlayground } from '@dittolive/ditto'
-
-export interface CreateOnlinePlaygroundIdentityParams {
- appID: string
- token: string
- enableDittoCloudSync?: boolean
- customAuthURL?: string
-}
-
-export interface useOnlinePlaygroundIdentityProps {
- /**
- * Function used for creating a new online playground identity.
- * */
- create: (
- params: CreateOnlinePlaygroundIdentityParams,
- ) => IdentityOnlinePlayground
-}
-
-/**
- * @example
- * ```js
- * const { create } = useOnlinePlaygroundIdentity();
- *
- * const myIdentity = create({appId: uuid(), token: 'my-token'});
- * const ditto = new Ditto(myIdentity, '/path');
- * ```
- *
- * A hook for creating OnlinePlayground Ditto identity objects.
- * @returns useOnlinePlaygroundIdentityProps
- */
-export const useOnlinePlaygroundIdentity =
- (): useOnlinePlaygroundIdentityProps => {
- return {
- create: ({
- appID,
- token,
- enableDittoCloudSync,
- customAuthURL,
- }: CreateOnlinePlaygroundIdentityParams): IdentityOnlinePlayground => {
- return {
- type: 'onlinePlayground',
- appID,
- token,
- enableDittoCloudSync,
- customAuthURL,
- }
- },
- }
- }
diff --git a/src/index.tsx b/src/index.tsx
index e5a5cd5..c85300b 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,8 +1,6 @@
export * from './DittoContext.js'
export * from './DittoLazyProvider.js'
export * from './DittoProvider.js'
-export * from './identity/index.js'
-export * from './mutations/index.js'
export * from './presence/index.js'
export * from './queries/index.js'
export * from './useDitto.js'
diff --git a/src/mutations/index.tsx b/src/mutations/index.tsx
deleted file mode 100644
index 494cfa4..0000000
--- a/src/mutations/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export * from './useMutations.js'
diff --git a/src/mutations/useMutations.spec.tsx b/src/mutations/useMutations.spec.tsx
deleted file mode 100644
index 051a868..0000000
--- a/src/mutations/useMutations.spec.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import { Ditto, IdentityOfflinePlayground } from '@dittolive/ditto'
-import { renderHook, waitFor } from '@testing-library/react'
-import { expect } from 'chai'
-import { ReactNode } from 'react'
-import { v4 as uuidv4 } from 'uuid'
-
-import { DittoProvider } from '../DittoProvider'
-import { useMutations } from './useMutations'
-
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
- path: string
-} = () => ({
- identity: {
- appID: 'useMutationsSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
- path: uuidv4(),
-})
-
-describe('useMutations tests', function () {
- const collection = 'collection'
-
- it('should correctly create a new entity inside of a collection and update it by ID', async () => {
- const testConfiguration = testIdentity()
- const initOptions = {
- webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
- }
-
- const wrapper = ({ children }: { children: ReactNode }) => (
- {
- const ditto = new Ditto(
- testConfiguration.identity,
- testConfiguration.path,
- )
- return ditto
- }}
- initOptions={initOptions}
- >
- {() => {
- return children
- }}
-
- )
-
- const params = { path: testConfiguration.path, collection }
- const { result: mutations } = renderHook(() => useMutations(params), {
- wrapper,
- })
- await waitFor(() => expect(mutations.current.ditto).to.exist)
-
- const upsertResult = await mutations.current.upsert({
- value: { _id: 'some_id', foo: 'bar' },
- })
-
- expect(upsertResult.value).to.eql('some_id')
-
- const updateResult = await mutations.current.updateByID({
- _id: 'some_id',
- updateClosure: (doc) => doc.at('foo').set('updated'),
- })
-
- expect(updateResult.length).to.eq(1)
- expect(updateResult[0].type).to.eq('set')
- expect(updateResult[0].path).to.eql('foo')
- expect(updateResult[0].value).to.eql('updated')
- })
-
- it('should correctly create multiple documents inside of a collection and update them using a query', async () => {
- const testConfiguration = testIdentity()
-
- const wrapper = ({ children }: { children: ReactNode }) => (
- {
- const ditto = new Ditto(
- testConfiguration.identity,
- testConfiguration.path,
- )
- return ditto
- }}
- >
- {() => {
- return children
- }}
-
- )
-
- const params = { path: testConfiguration.path, collection }
- const { result: mutations } = renderHook(() => useMutations(params), {
- wrapper,
- })
-
- await waitFor(() => expect(mutations.current.ditto).to.exist)
-
- await mutations.current.upsert({
- value: { _id: 'car', type: 'car', wheels: 4 },
- })
- await mutations.current.upsert({
- value: { _id: 'skate', type: 'skate', wheels: 4 },
- })
- await mutations.current.upsert({
- value: { _id: 'bike', type: 'bike', wheels: 2 },
- })
-
- const updateResult = await mutations.current.update({
- query: 'wheels > 2',
- updateClosure: (docs) => docs[0].at('wheels').set(0),
- })
-
- expect(updateResult.keys().length).to.eq(2)
-
- updateResult.keys().forEach((key) => {
- expect(key).not.to.eq('bike')
- // Comment these back in once https://github.com/getditto/ditto/issues/4242 is fixed
- // expect(updateResult.get(key).length).to.eq(1)
- // expect(updateResult.get(key)[0].type).to.eq('set')
- // expect(updateResult.get(key)[0].path).to.eql('wheels')
- // expect(updateResult.get(key)[0].value).to.eql(0)
- })
- })
-})
diff --git a/src/mutations/useMutations.ts b/src/mutations/useMutations.ts
deleted file mode 100644
index 2b75ed4..0000000
--- a/src/mutations/useMutations.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import {
- Ditto,
- DocumentID,
- DocumentIDValue,
- DocumentValue,
- MutableDocument,
- PendingCursorOperation,
- PendingIDSpecificOperation,
- QueryArguments,
- UpdateResult,
- UpdateResultsMap,
- UpsertOptions,
-} from '@dittolive/ditto'
-import { useCallback } from 'react'
-
-import { useDitto } from '../useDitto.js'
-
-export interface UpdateParams {
- /**
- * A Ditto query that specifies the documents to update. If this is omitted, then the `updateClosure` will
- * apply to _all documents_.
- */
- query?: string
- /**
- * Arguments to use with the `query`
- */
- args?: QueryArguments
- /**
- * A function used to update all the documents
- */
- updateClosure: (mutableDocuments: MutableDocument[]) => void
-}
-
-export type UpdateFunction = (params: UpdateParams) => Promise
-
-export interface UpdateByIDParams {
- /**
- * The _id of the document to remove
- */
- // The `DocumentIDValue` type needs to be narrowed in @dittolive/ditto
-
- _id: DocumentID | DocumentIDValue
- /**
- * The update function to perform on the specified document
- */
- updateClosure: (mutableDocument: MutableDocument) => void
-}
-
-export type UpdateByIDFunction = (
- params: UpdateByIDParams,
-) => Promise
-
-export interface UpsertParams {
- value: DocumentValue
- upsertOptions?: UpsertOptions
-}
-
-export type UpsertFunction = (params: UpsertParams) => Promise
-
-export interface RemoveParams {
- query?: string
- args?: QueryArguments
-}
-
-export type RemoveFunction = (params: RemoveParams) => Promise
-
-export interface RemoveByIDParams {
- _id?: DocumentID
-}
-
-export type RemoveByIDFunction = (params: RemoveByIDParams) => Promise
-
-export interface UseMutationParams {
- /**
- * An optional path of the ditto instance that you'd like to use
- */
- path?: string
- /**
- * The name of the collection
- */
- collection: string
-}
-
-export function useMutations(useMutationParams: UseMutationParams): {
- ditto: Ditto
- update: UpdateFunction
- updateByID: UpdateByIDFunction
- upsert: UpsertFunction
- remove: RemoveFunction
- removeByID: RemoveByIDFunction
-} {
- const { ditto } = useDitto(useMutationParams.path)
-
- const update: UpdateFunction = useCallback(
- (params) => {
- let cursor: PendingCursorOperation
- if (params.query) {
- if (params.args) {
- cursor = ditto.store
- .collection(useMutationParams.collection)
- .find(params.query, params.args)
- } else {
- cursor = ditto.store
- .collection(useMutationParams.collection)
- .find(params.query)
- }
- } else {
- cursor = ditto.store.collection(useMutationParams.collection).findAll()
- }
- return cursor.update(params.updateClosure)
- },
- [ditto, useMutationParams.collection],
- )
-
- const updateByID: UpdateByIDFunction = useCallback(
- (params) => {
- const pendingIDSpecificOperation: PendingIDSpecificOperation = ditto.store
- .collection(useMutationParams.collection)
- .findByID(params._id)
- return pendingIDSpecificOperation.update((mutableDoc) => {
- params.updateClosure(mutableDoc)
- })
- },
- [ditto, useMutationParams.collection],
- )
-
- const upsert: UpsertFunction = useCallback(
- (params) => {
- return ditto.store
- .collection(useMutationParams.collection)
- .upsert(params.value, params.upsertOptions)
- },
- [ditto, useMutationParams.collection],
- )
-
- const remove: RemoveFunction = useCallback(
- (params: RemoveParams): Promise => {
- let cursor: PendingCursorOperation
- if (params.query) {
- if (params.args) {
- cursor = ditto.store
- .collection(useMutationParams.collection)
- .find(params.query, params.args)
- } else {
- cursor = ditto.store
- .collection(useMutationParams.collection)
- .find(params.query)
- }
- } else {
- cursor = ditto.store.collection(useMutationParams.collection).findAll()
- }
- return cursor.remove()
- },
- [ditto, useMutationParams.collection],
- )
-
- const removeByID: RemoveByIDFunction = useCallback(
- (params: RemoveByIDParams): Promise => {
- return ditto.store
- .collection(useMutationParams.collection)
- .findByID(params._id)
- .remove()
- },
- [ditto, useMutationParams.collection],
- )
-
- return {
- ditto,
- update,
- updateByID,
- upsert,
- remove,
- removeByID,
- }
-}
diff --git a/src/queries/index.tsx b/src/queries/index.tsx
index af1829c..9cfb531 100644
--- a/src/queries/index.tsx
+++ b/src/queries/index.tsx
@@ -1,6 +1,3 @@
-export * from './useCollections.js'
-export * from './useLazyPendingCursorOperation.js'
-export * from './useLazyPendingIDSpecificOperation.js'
-export * from './usePendingCursorOperation.js'
-export * from './usePendingIDSpecificOperation.js'
+export * from './useExecuteQuery.js'
+export * from './useQuery.js'
export * from './useRemotePeers.js'
diff --git a/src/queries/useCollections.spec.tsx b/src/queries/useCollections.spec.tsx
deleted file mode 100644
index cb603a6..0000000
--- a/src/queries/useCollections.spec.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { Ditto, IdentityOfflinePlayground } from '@dittolive/ditto'
-import { renderHook, waitFor } from '@testing-library/react'
-import { expect } from 'chai'
-import { ReactNode, useEffect } from 'react'
-import { v4 as uuidv4 } from 'uuid'
-
-import { DittoProvider } from '../DittoProvider'
-import { useMutations } from '../mutations'
-import { useCollections } from './useCollections'
-
-const initOptions = {
- webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
-}
-
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
- path: string
-} = () => ({
- identity: {
- appID: 'useCollectionsSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
- path: uuidv4(),
-})
-
-const TestComponent = ({ path }: { path: string }) => {
- const { ditto, upsert } = useMutations({ path: path, collection: 'foo' })
-
- useEffect(() => {
- if (ditto) {
- upsert({ value: { document: 1 } })
- }
- }, [ditto, upsert])
-
- return <>>
-}
-
-const wrapper =
- (identity: IdentityOfflinePlayground, path: string) =>
- // eslint-disable-next-line react/display-name
- ({ children }: { children: ReactNode }) => (
- new Ditto(identity, path)}
- initOptions={initOptions}
- >
- {() => {
- return (
- <>
-
- {children}
- >
- )
- }}
-
- )
-
-describe('useCollections tests', function () {
- it('should load all collections correctly', async () => {
- const testConfiguration = testIdentity()
- const params = { path: testConfiguration.path }
- const { result } = renderHook(() => useCollections(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eq(1)
-
- expect(
- result.current.documents.map((collection) => collection.name),
- ).to.eql(['foo'])
-
- expect(
- result.current.collectionsEvent.collections.map(
- (collection) => collection.name,
- ),
- ).to.eql(['foo'])
- })
-
- it('should cancel the subscription on unmount', async () => {
- const testConfiguration = testIdentity()
- const params = { path: testConfiguration.path }
- const { result, unmount } = renderHook(() => useCollections(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.documents).not.to.be.empty)
-
- unmount()
-
- await waitFor(
- () => expect(result.current.subscription.isCancelled).to.be.true,
- )
- })
-})
diff --git a/src/queries/useCollections.ts b/src/queries/useCollections.ts
deleted file mode 100644
index b4089f0..0000000
--- a/src/queries/useCollections.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import {
- Collection,
- CollectionsEvent,
- Ditto,
- LiveQuery,
- SortDirection,
- Subscription,
-} from '@dittolive/ditto'
-import { useEffect, useRef, useState } from 'react'
-
-import { useDitto } from '../useDitto.js'
-
-export interface CollectionsQueryParams {
- /**
- * The path of the Ditto string. If you omit this, it will fetch the first registered Ditto value.
- */
- path?: string
- sort?: {
- /**
- * An optional sort parameter for your collections query. Allows sorting collections using the _id and names fields, in ascending
- * or descending order:
- *
- * ```js
- * {
- * propertyPath: "name",
- * direction: "ascending"
- * }
- * ```
- *
- * For descending values use:
- *
- * ```js
- * {
- * propertyPath: "name",
- * direction: "descending"
- * }
- * ```
- * For more information on the query string syntax refer to https://docs.ditto.live/concepts/querying
- */
- propertyPath: '_id' | 'name'
- direction?: SortDirection
- }
- /**
- * An optional number to limit the results of the collections query. If you omit this value, the query will return all values
- */
- limit?: number
-}
-
-/**
- * Runs a ditto live query on the collections collection. Eg:
- *
- * const { documents } = useCollections({
- * path: myPath,
- * sort: {
- * propertyPath: 'name',
- * direction: 'descending' as SortDirection,
- * },
- * limit: 2
- * })
- *
- * @param params collections query parameters.
- * @returns
- */
-export function useCollections(params: CollectionsQueryParams): {
- ditto: Ditto
- documents: Collection[]
- collectionsEvent: CollectionsEvent | undefined // TODO use CollectionsEvent once it's exposed by the SDK
- liveQuery: LiveQuery | undefined
- subscription: Subscription | undefined
-} {
- const { ditto } = useDitto(params.path)
- const [documents, setDocuments] = useState([])
- const [collectionsEvent, setCollectionsEvent] = useState<
- CollectionsEvent | undefined
- >()
- const liveQueryRef = useRef()
- const subscriptionRef = useRef()
-
- useEffect(() => {
- if (ditto) {
- let cursor = ditto.store.collections()
-
- if (params.sort) {
- cursor = cursor.sort(params.sort.propertyPath, params.sort.direction)
- }
- if (params.limit) {
- cursor = cursor.limit(params.limit)
- }
-
- subscriptionRef.current = cursor.subscribe()
- liveQueryRef.current = cursor.observeLocal((event) => {
- setDocuments(event.collections || [])
- setCollectionsEvent(event)
- })
- } else {
- liveQueryRef.current?.stop()
- subscriptionRef.current?.cancel()
- }
-
- return () => {
- liveQueryRef.current?.stop()
- subscriptionRef.current?.cancel()
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [
- ditto,
- params.path,
- params.sort?.propertyPath,
- params.sort?.direction,
- params.limit,
- ])
-
- return {
- ditto,
- documents,
- collectionsEvent,
- liveQuery: liveQueryRef.current,
- subscription: subscriptionRef.current,
- }
-}
diff --git a/src/queries/useExecuteQuery.spec.tsx b/src/queries/useExecuteQuery.spec.tsx
new file mode 100644
index 0000000..d2ea19d
--- /dev/null
+++ b/src/queries/useExecuteQuery.spec.tsx
@@ -0,0 +1,376 @@
+import { Ditto, DittoError } from '@dittolive/ditto'
+import { renderHook, waitFor } from '@testing-library/react'
+import { AssertionError, expect } from 'chai'
+import { ReactNode } from 'react'
+import { v4 as uuidv4 } from 'uuid'
+
+import { DittoLazyProvider } from '../DittoLazyProvider'
+import { DittoProvider } from '../DittoProvider'
+import {
+ openSeededDitto,
+ waitForNextUpdate,
+ wasmInitOptions,
+} from '../utils.spec'
+import { useExecuteQuery } from './useExecuteQuery'
+
+const testConfig: () => {
+ databaseID: string
+ persistenceDirectory: string
+} = () => ({
+ databaseID: 'useExecuteQuery',
+ persistenceDirectory: uuidv4(),
+})
+
+type Data = {
+ document: number
+ category?: number
+}
+
+// Creates a wrapper component for each test
+const wrapper =
+ (databaseID: string, persistenceDirectory: string, isLazy: boolean = false) =>
+ // eslint-disable-next-line react/display-name
+ ({ children }: { children: ReactNode }) => {
+ const Provider = isLazy ? DittoLazyProvider : DittoProvider
+ return (
+
+ openSeededDitto(
+ databaseID,
+ lazyPersistenceDirectory ?? persistenceDirectory,
+ )
+ }
+ initOptions={wasmInitOptions}
+ >
+ {({ loading, error }) => {
+ return (
+ <>
+ {`${loading}`}
+ {error?.message}
+ {children}
+ >
+ )
+ }}
+
+ )
+ }
+
+describe('useExecuteQuery', function () {
+ it('should only load items once the execution function is called', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('select * from foo', {
+ persistenceDirectory: config.persistenceDirectory,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ // Wait for the Ditto instance to load
+ await waitForNextUpdate(result)
+
+ const [execute, { items, mutatedDocumentIDs, ditto, error, isLoading }] =
+ result.current
+
+ expect(items).to.be.undefined
+ expect(mutatedDocumentIDs).to.be.undefined
+ expect(ditto).to.be.undefined
+ expect(error).to.be.null
+ expect(isLoading).to.be.false
+
+ await execute()
+ await waitForNextUpdate(result)
+
+ expect(result.current[1].items).to.have.length(5)
+ expect(result.current[1].mutatedDocumentIDs).to.have.length(0)
+ expect(result.current[1].error).to.be.null
+ expect(result.current[1].isLoading).to.be.false
+ expect(result.current[1].ditto).to.be.an.instanceOf(Ditto)
+ })
+
+ it('should load all documents correctly', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('select * from foo order by document asc', {
+ persistenceDirectory: config.persistenceDirectory,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ await waitForNextUpdate(result)
+ const [execute] = result.current
+ await execute()
+ await waitForNextUpdate(result)
+
+ expect(result.current[1].items).to.have.length(5)
+
+ for (let i = 1; i < 6; i++) {
+ expect(result.current[1].items[i - 1].value.document).to.eq(i)
+ }
+ })
+
+ it('should report errors correctly', async () => {
+ const config = testConfig()
+
+ const errorHandler = (error: Error) => {
+ expect(error).to.be.an.instanceOf(DittoError)
+ }
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('not a query', {
+ persistenceDirectory: config.persistenceDirectory,
+ onError: errorHandler,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ const localErrorHandler = (error: Error) => {
+ expect(error).to.be.an.instanceOf(DittoError)
+ }
+
+ await waitForNextUpdate(result)
+ const [execute] = result.current
+ await execute(undefined, localErrorHandler)
+ await waitForNextUpdate(result)
+
+ // items stay undefined because the query failed; the error is surfaced
+ // through `error` instead.
+ expect(result.current[1].items).to.be.undefined
+ expect(result.current[1].error).to.be.an.instanceOf(DittoError)
+ expect(result.current[1].isLoading).to.be.false
+ })
+
+ it('should use query arguments configured in the hook setup', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('select * from foo where document = :document', {
+ persistenceDirectory: config.persistenceDirectory,
+ queryArguments: {
+ document: 1,
+ },
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ await waitForNextUpdate(result)
+ const [execute] = result.current
+ await execute()
+ await waitForNextUpdate(result)
+
+ expect(result.current[1].items).to.have.length(1)
+ expect(result.current[1].items[0].value.document).to.eq(1)
+ })
+
+ it('should use query arguments configured in the execution function', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('select * from foo where document = :document', {
+ persistenceDirectory: config.persistenceDirectory,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ await waitForNextUpdate(result)
+ const [execute] = result.current
+ await execute({ document: 2 })
+ await waitForNextUpdate(result)
+
+ expect(result.current[1].items).to.have.length(1)
+ expect(result.current[1].items[0].value.document).to.eq(2)
+ })
+
+ it('should merge query arguments from the setup and the execution function', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery>(
+ 'select * from foo where document = :document and category = :category',
+ {
+ persistenceDirectory: config.persistenceDirectory,
+ queryArguments: {
+ document: 1,
+ category: 2,
+ },
+ },
+ ),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ await waitForNextUpdate(result)
+ const [selectByDocument] = result.current
+ await selectByDocument({ document: 2 })
+ await waitForNextUpdate(result)
+
+ expect(result.current[1].items).to.have.length(1)
+ expect(result.current[1].items[0].value.category).to.eq(2)
+ })
+
+ it('type generics should allow declaring the query arguments type while only providing query arguments to the execution function', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery(
+ 'select * from foo where document = :document',
+ {
+ persistenceDirectory: config.persistenceDirectory,
+ },
+ ),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ await waitForNextUpdate(result)
+ const [execute] = result.current
+ await execute({ document: 1 })
+ })
+
+ it('should reject the execution function if an invalid Ditto instance is requested', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('select * from foo', {
+ persistenceDirectory: 'non-existent',
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ await waitForNextUpdate(result)
+ const [execute] = result.current
+ try {
+ await execute()
+ expect.fail('execute() should reject when the instance does not exist')
+ } catch (e) {
+ if (e instanceof AssertionError) throw e
+ expect(e).to.be.an.instanceOf(Error)
+ expect((e as Error).message).to.eq(
+ 'Provider does not have a loaded Ditto instance with persistence ' +
+ 'directory non-existent. Make sure your provider finished ' +
+ 'loading the instance before you call the execution function.',
+ )
+ }
+ })
+
+ it('should reject the execution function if the Ditto instance is not loaded yet', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('select * from foo', {
+ persistenceDirectory: config.persistenceDirectory,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+ // Here we don't wait for the Ditto instance to load.
+ const [execute] = result.current
+ try {
+ await execute()
+ expect.fail('execute() should reject before the instance has loaded')
+ } catch (e) {
+ if (e instanceof AssertionError) throw e
+ expect(e).to.be.an.instanceOf(Error)
+ expect((e as Error).message).to.eq(
+ 'Provider does not have a loaded Ditto instance with persistence ' +
+ 'directory ' +
+ config.persistenceDirectory +
+ '. Make sure your provider finished loading the instance before ' +
+ 'you call the execution function.',
+ )
+ }
+ })
+
+ describe('using a lazy provider', function () {
+ it('should load all documents correctly', async () => {
+ const config = testConfig()
+
+ const container = document.createElement('div')
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('select * from foo', {
+ persistenceDirectory: config.persistenceDirectory,
+ }),
+ {
+ wrapper: wrapper(
+ config.databaseID,
+ config.persistenceDirectory,
+ true, // isLazy
+ ),
+ baseElement: container,
+ },
+ )
+
+ await waitFor(() =>
+ expect(
+ container.querySelector("div[data-testid='loading']").innerHTML,
+ ).to.eq('false'),
+ )
+
+ const [execute] = result.current
+ await execute()
+ await waitForNextUpdate(result)
+
+ expect(result.current[1].items).to.have.length(5)
+ })
+
+ it('should reject the execution function if an invalid Ditto instance is requested', async () => {
+ // A lazy provider whose setup declines to create an instance for the
+ // requested path (returns null), so the execution function rejects.
+ const nullWrapper = ({ children }: { children: ReactNode }) => (
+ Promise.resolve(null)}
+ initOptions={wasmInitOptions}
+ >
+ {() => <>{children}>}
+
+ )
+
+ const { result } = renderHook(
+ () =>
+ useExecuteQuery('select * from foo', {
+ persistenceDirectory: 'non-existent',
+ }),
+ { wrapper: nullWrapper },
+ )
+
+ const [execute] = result.current
+ try {
+ await execute()
+ expect.fail('execute() should reject when setup returns no instance')
+ } catch (e) {
+ if (e instanceof AssertionError) throw e
+ expect(e).to.be.an.instanceOf(Error)
+ expect((e as Error).message).to.eq(
+ 'Provider does not have a loaded Ditto instance with persistence ' +
+ 'directory non-existent.',
+ )
+ }
+ })
+ })
+})
diff --git a/src/queries/useExecuteQuery.ts b/src/queries/useExecuteQuery.ts
new file mode 100644
index 0000000..57e57f6
--- /dev/null
+++ b/src/queries/useExecuteQuery.ts
@@ -0,0 +1,307 @@
+// some of these types are used in API documentation but eslint does not
+// recognize that, so we disable the rule for these imports
+import {
+ Ditto,
+ DQLQueryArguments,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ type QueryResult,
+ QueryResultItem,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ type SyncSubscription,
+} from '@dittolive/ditto'
+import { useCallback, useState } from 'react'
+
+import { useDittoContext } from '../DittoContext.js'
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import type { DittoProvider } from '../DittoProvider.js'
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import type { useQuery } from './useQuery.js'
+import { useVersion } from './useVersion.js'
+
+/**
+ * Parameters for {@link useExecuteQuery}.
+ *
+ * @template T - The type of query arguments.
+ */
+export interface UseExecuteQueryParams<
+ T extends DQLQueryArguments = DQLQueryArguments,
+> {
+ /**
+ * The arguments to pass to the query.
+ */
+ queryArguments?: T
+ /**
+ * A callback to run when an error occurs.
+ *
+ * @param error The error that occurred during query execution.
+ */
+ onError?: (error: unknown) => void
+ /**
+ * Identifies the Ditto instance to use when multiple instances are registered
+ * in the {@link DittoProvider}. Defaults to the first registered instance.
+ *
+ * See {@link Ditto.absolutePersistenceDirectory}.
+ */
+ persistenceDirectory?: string
+}
+
+/**
+ * Execute the query with the given arguments.
+ *
+ * This function is returned by {@link useExecuteQuery}. Optionally takes an
+ * `onError` callback to handle errors specifically for this query execution.
+ *
+ * To avoid overly complex types, query arguments are allowed to be partial.
+ * However, if you pass partial query arguments when setting up the hook, you
+ * must pass complete query arguments when calling the execution function.
+ *
+ * @param queryArguments Any arguments to pass to the query. Will be merged with
+ * parameters set up when the hook was created. See
+ * {@link UseExecuteQueryParams.queryArguments}.
+ * @param onError A callback to run when an error occurs.
+ * @template T - The type of query arguments.
+ * @returns A promise that resolves when the query has been executed. The
+ * results of the query can be accessed through the hook's return value.
+ */
+export type ExecutionFunction = (
+ queryArguments?: Partial,
+ onError?: (error: unknown) => void,
+) => Promise
+
+/**
+ * Return value of {@link useExecuteQuery}.
+ *
+ * @template T - The type of the items returned by the query. Be aware that this
+ * is a convenience type that is not checked against the query being run.
+ * @template U - The type of query arguments.
+ */
+export type UseExecuteQueryReturn<
+ T,
+ U extends DQLQueryArguments = DQLQueryArguments,
+> = [
+ ExecutionFunction,
+ {
+ /**
+ * The Ditto instance used by this hook. `undefined` until an `execute()`
+ * call resolves an instance — set once the instance is found (before the
+ * query runs) and retained afterwards, including across {@link reset}. Note
+ * this stays `undefined` even with the non-lazy provider until the first
+ * `execute()` call.
+ */
+ ditto?: Ditto
+ /**
+ * The most recent error that resulted from query execution
+ *
+ * Use the {@link UseExecuteQueryParams.onError | `onError`} callback
+ * parameter when setting up the hook or the `onError` parameter of
+ * {@link UseExecuteQueryReturn.0 | the execution function} to handle errors
+ * as they occur.
+ */
+ error: unknown
+ /**
+ * The items returned by the most recent successful execution.
+ *
+ * `undefined` until an execution succeeds, and again while a call is in
+ * flight or after one fails (use {@link isLoading} and {@link error} to
+ * distinguish those cases).
+ */
+ items?: QueryResultItem[]
+
+ /**
+ * `true` while a call to the execution function is pending. Resetting the
+ * query with {@link reset} will not set this back to `true` to avoid
+ * flickering when used in UIs.
+ */
+ isLoading: boolean
+ /**
+ * Reset the state of this hook.
+ *
+ * This resets `error`, `items`, and `mutatedDocumentIDs` to their initial
+ * state.
+ *
+ * This does not set `isLoading` to `true` during the reset process.
+ */
+ reset: () => void
+ /**
+ * IDs of documents that were mutated _locally_ by the most recent
+ * successful execution. An empty array when nothing was mutated, and
+ * `undefined` before the first successful execution.
+ *
+ * See {@link QueryResult.mutatedDocumentIDsV2}.
+ */
+ mutatedDocumentIDs?: string[]
+ },
+]
+
+/**
+ * Provides an _execution function_ that can be used to run the given query.
+ *
+ * This hook does not run the query immediately and does not set up a sync
+ * subscription. Use this hook for running mutating queries and ad-hoc queries
+ * in response to user actions. Be aware that mutations will not be synced to
+ * other peers unless you also set up a {@link SyncSubscription} for the same
+ * query, which can be done with a {@link useQuery} hook.
+ *
+ * Query arguments can be supplied when setting up the hook and when calling the
+ * execution function. When query arguments contain the same key in both places,
+ * a shallow merge is performed, with the arguments passed to the execution
+ * function taking precedence. Below is an example of how to use this feature.
+ *
+ * To avoid overly complex types, partial query arguments are only allowed on
+ * the execution function. This means that the query arguments passed when
+ * setting up the hook must be complete.
+ *
+ * Errors that occur during query execution are stored in the `error` property
+ * of the return value and are reset on each subsequent execution. You can also
+ * provide an `onError` callback in the hook parameters or when calling the
+ * execution function to handle errors as they occur.
+ *
+ * @example Basic Usage
+ * ```tsx
+ * const [insertTask] = useExecuteQuery(
+ * 'insert into tasks documents (:task)',
+ * {
+ * queryArguments: { task: { description: 'Buy milk' } },
+ * },
+ * )
+ * ```
+ * @example Merging query arguments
+ * ```tsx
+ * const [executeQuery] = useExecuteQuery(
+ * 'insert into tasks documents (:task)',
+ * {
+ * queryArguments: { task: { author: 'Alice' } },
+ * },
+ * )
+ *
+ * // Clicking the button will execute the query with query arguments
+ * // `{ task: { author: 'Alice', description: 'Buy milk' } }`.
+ * return executeQuery({ task: { description: 'Buy milk' } })}
+ * >Add Task
+ * ```
+ *
+ * @param query - The query to run. Must be a non-mutating query.
+ * @param params - Additional parameters to configure how the query is run.
+ * @template T - The type of the items returned by the query. Be aware that this
+ * is a convenience type that is not checked against the query being run.
+ * @template U - The type of query arguments.
+ */
+export function useExecuteQuery<
+ // We want this to allow for any query arguments.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ T = any,
+ U extends DQLQueryArguments = DQLQueryArguments,
+>(
+ query: string,
+ params?: UseExecuteQueryParams,
+): UseExecuteQueryReturn {
+ const { queryArguments, onError, persistenceDirectory } = params ?? {}
+ const { dittoHash, isLazy, load } = useDittoContext()
+ const queryArgumentsVersion = useVersion(params?.queryArguments)
+
+ const [ditto, setDitto] = useState()
+ const [error, setError] = useState(null)
+ const [items, setItems] = useState[]>()
+ const [isLoading, setIsLoading] = useState(false)
+ const [mutatedDocumentIDs, setMutatedDocumentIDs] = useState()
+
+ const execute = useCallback(
+ async (
+ localQueryArguments?: Partial,
+ localOnError?: (error: unknown) => void,
+ ) => {
+ setItems(undefined)
+ setMutatedDocumentIDs(undefined)
+ setIsLoading(true)
+ setError(null)
+
+ let nextDitto: Ditto
+ if (isLazy) {
+ nextDitto = (await load(persistenceDirectory)) as Ditto
+ } else {
+ nextDitto = dittoHash[persistenceDirectory ?? Object.keys(dittoHash)[0]]
+ }
+
+ if (nextDitto == null) {
+ setIsLoading(false)
+ throw new Error(
+ `Provider does not have a loaded Ditto instance${
+ persistenceDirectory
+ ? ` with persistence directory ${persistenceDirectory}.`
+ : '.'
+ }${
+ !isLazy
+ ? ' Make sure your provider finished loading the instance before ' +
+ 'you call the execution function.'
+ : ''
+ }`,
+ )
+ }
+ setDitto(nextDitto)
+
+ let finalQueryArguments: U = {} as U
+ if (queryArguments) {
+ finalQueryArguments = Object.assign(finalQueryArguments, queryArguments)
+ }
+
+ if (localQueryArguments) {
+ finalQueryArguments = Object.assign(
+ finalQueryArguments,
+ localQueryArguments,
+ )
+ }
+
+ try {
+ const result = await nextDitto.store.execute(
+ query,
+ finalQueryArguments,
+ )
+ setItems(result.items)
+ setMutatedDocumentIDs(result.mutatedDocumentIDsV2())
+ } catch (e: unknown) {
+ setError(e)
+ params?.onError?.(e)
+ localOnError?.(e)
+ if (!params?.onError && !localOnError) {
+ console.error(e)
+ }
+ } finally {
+ setIsLoading(false)
+ }
+ },
+
+ // ESlint does not recognize `queryArgumentsVersion` as a dependency but it
+ // should be as it ensures that deep changes in `queryArguments` trigger a
+ // re-run of the hook.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ dittoHash,
+ isLazy,
+ load,
+ onError,
+ queryArgumentsVersion,
+ persistenceDirectory,
+ query,
+ queryArguments,
+ ],
+ )
+
+ const reset = useCallback(() => {
+ setError(null)
+ setItems(undefined)
+ setMutatedDocumentIDs(undefined)
+ }, [])
+
+ return [
+ execute,
+ {
+ ditto,
+ error,
+ isLoading,
+ items,
+ mutatedDocumentIDs,
+ reset,
+ },
+ ]
+}
diff --git a/src/queries/useLazyPendingCursorOperation.spec.tsx b/src/queries/useLazyPendingCursorOperation.spec.tsx
deleted file mode 100644
index 69a6d26..0000000
--- a/src/queries/useLazyPendingCursorOperation.spec.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-import { Ditto, IdentityOfflinePlayground } from '@dittolive/ditto'
-import { renderHook, waitFor } from '@testing-library/react'
-import { expect } from 'chai'
-import { ReactNode } from 'react'
-import { v4 as uuidv4 } from 'uuid'
-
-import { DittoProvider } from '../DittoProvider'
-import { waitForNextUpdate } from '../utils.spec'
-import { useLazyPendingCursorOperation } from './useLazyPendingCursorOperation'
-import { LiveQueryParams } from './usePendingCursorOperation'
-import { DocumentUpserter } from './usePendingCursorOperation.spec'
-
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
- path: string
-} = () => ({
- identity: {
- appID: 'useLazyPendingCursorOperationSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
- path: uuidv4(),
-})
-
-const initOptions = {
- webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
-}
-
-// Creates a wrapper component for each test
-const wrapper =
- (identity: IdentityOfflinePlayground, path: string) =>
- // eslint-disable-next-line react/display-name
- ({ children }: { children: ReactNode }) => (
- {
- const ditto = new Ditto(identity, path)
- return ditto
- }}
- initOptions={initOptions}
- >
- {() => {
- return (
- <>
-
- {children}
- >
- )
- }}
-
- )
-
-describe('useLazyPendingCursorOperation tests', function () {
- it('should not load any values until a query is executed with the exec function', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- }
- const { result } = renderHook(() => useLazyPendingCursorOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
-
- expect(result.current.documents).to.eql([])
- expect(result.current.liveQuery).to.eq(undefined)
- expect(result.current.subscription).to.eq(undefined)
- expect(result.current.liveQueryEvent).to.eq(undefined)
- expect(result.current.ditto).to.eq(undefined)
-
- await result.current.exec(params)
-
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eql(5)
- expect(result.current.liveQuery).not.to.eq(undefined)
- expect(result.current.subscription).not.to.eq(undefined)
- expect(result.current.liveQueryEvent).not.to.eq(undefined)
- expect(result.current.ditto).not.to.eq(undefined)
- })
-
- it('should load all documents correctly', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- }
- const { result } = renderHook(() => useLazyPendingCursorOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
- await result.current.exec(params)
-
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eq(5)
-
- for (let i = 1; i < 6; i++) {
- expect(
- !!result.current.documents.find((doc) => doc.value.document === i),
- ).to.eq(true)
- }
- })
-
- it('should load all documents correctly observing only for local data', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- localOnly: true,
- }
- const { result } = renderHook(() => useLazyPendingCursorOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
- await result.current.exec(params)
-
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.subscription).to.be.undefined
- expect(result.current.documents.length).to.eq(5)
-
- for (let i = 1; i < 6; i++) {
- expect(
- !!result.current.documents.find((doc) => doc.value.document === i),
- ).to.eq(true)
- }
- })
-
- it('should load documents correctly using a query', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- query: 'document > 3',
- }
-
- const { result } = renderHook(() => useLazyPendingCursorOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
- await result.current.exec(params)
-
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eq(2)
-
- for (let i = 4; i < 6; i++) {
- expect(
- !!result.current.documents.find((doc) => doc.value.document === i),
- ).to.eq(true)
- }
- })
-
- it('should correctly reset the current live query and create a new one when the exec function is called a second time.', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- query: 'document > 3',
- }
-
- const { result } = renderHook(() => useLazyPendingCursorOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
- await result.current.exec(params)
-
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eq(2)
- const liveQuery = result.current.liveQuery
-
- await result.current.exec({
- path: testConfiguration.path,
- collection: 'foo',
- })
-
- await waitFor(() => expect(result.current.documents).to.have.lengthOf(5), {
- timeout: 5000,
- })
-
- expect(result.current.liveQuery).not.to.eq(liveQuery)
- })
-
- it('should return the Ditto collection as an alternative way for developers to query the collection', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- }
- const { result } = renderHook(() => useLazyPendingCursorOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
- await result.current.exec(params)
-
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eq(5)
-
- const collectionDocuments = await result.current.collection.findAll().exec()
- expect(collectionDocuments.length).to.eq(5)
- })
-
- it('should cancel the subscription on unmount', async () => {
- const testConfiguration = testIdentity()
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- }
- const { result, unmount } = renderHook(
- () => useLazyPendingCursorOperation(),
- {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- },
- )
- await waitForNextUpdate(result)
- await result.current.exec(params)
- await waitFor(() => expect(result.current.documents).not.to.be.empty)
-
- unmount()
-
- await waitFor(
- () => expect(result.current.subscription.isCancelled).to.be.true,
- )
- })
-})
diff --git a/src/queries/useLazyPendingCursorOperation.ts b/src/queries/useLazyPendingCursorOperation.ts
deleted file mode 100644
index 9192b52..0000000
--- a/src/queries/useLazyPendingCursorOperation.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import {
- Collection,
- Ditto,
- Document,
- LiveQuery,
- LiveQueryEvent,
- PendingCursorOperation,
- Subscription,
-} from '@dittolive/ditto'
-import { useEffect, useRef, useState } from 'react'
-
-import { useDittoContext } from '../DittoContext.js'
-import { LiveQueryParams } from './usePendingCursorOperation.js'
-
-export interface LazyPendingCursorOperationReturn {
- /** The initialized Ditto instance if one could be found for the provided path. */
- ditto: Ditto | null
- /** The set of documents found for the current query. */
- documents: Document[]
- /** The last LiveQueryEvent received by the query observer. */
- liveQueryEvent: LiveQueryEvent | undefined
- /** Currently active live query. */
- liveQuery: LiveQuery | undefined
- /** Currently active subscription. */
- subscription: Subscription | undefined
- /** Function used to trigger a query on a collection */
- exec: (params: LiveQueryParams) => Promise
- /** Current Ditto collection instance. */
- collection: Collection | undefined
-}
-
-/**
- * Runs a ditto live query lazily, once the exec function is called with the passed in query params, Eg:
- *
- * @example
- * ```tsx
- * const { documents, exec } = useLazyPendingCursorOperation()
- *
- * const handleSomeEvent = () => {
- * exec({
- * path: myPath,
- * offset: 0,
- * collection: 'collection'
- * sort: {
- * propertyPath: 'createdAt',
- * direction: 'descending' as SortDirection,
- * },
- * })
- * }
- *
- * ```
- * @param params live query parameters.
- * @returns LazyPendingCursorOperationReturn
- */
-export function useLazyPendingCursorOperation(): LazyPendingCursorOperationReturn {
- const { dittoHash, isLazy, load } = useDittoContext()
- const [documents, setDocuments] = useState([])
- const [liveQueryEvent, setLiveQueryEvent] = useState<
- LiveQueryEvent | undefined
- >()
- const liveQueryRef = useRef()
- const subscriptionRef = useRef()
- const [ditto, setDitto] = useState()
- const [collection, setCollection] = useState()
-
- const createLiveQuery = async (params: LiveQueryParams) => {
- let nextDitto
-
- if (isLazy) {
- nextDitto = await load(params.path)
- } else {
- nextDitto = params.path
- ? dittoHash[params.path]
- : Object.values(dittoHash)[0]
- }
-
- if (nextDitto) {
- const nextCollection = nextDitto.store.collection(params.collection)
- let cursor: PendingCursorOperation
- if (params.query) {
- cursor = nextCollection.find(params.query, params.args)
- } else {
- cursor = nextCollection.findAll()
- }
- if (params.sort) {
- cursor = cursor.sort(params.sort.propertyPath, params.sort.direction)
- }
- if (params.limit) {
- cursor = cursor.limit(params.limit)
- }
- if (params.offset) {
- cursor = cursor.offset(params.offset)
- }
- if (!params.localOnly) {
- subscriptionRef.current = cursor.subscribe()
- }
-
- liveQueryRef.current = cursor.observeLocal((docs, event) => {
- setDocuments(docs)
- setLiveQueryEvent(event)
- })
- setCollection(nextCollection)
- setDitto(nextDitto)
- } else {
- return Promise.reject(
- new Error(
- `Could not load a Ditto instance${
- params.path ? ' for path ' + params.path : ''
- }.${
- !isLazy
- ? ' Make sure your provider finished loading the instance before you call exec'
- : ''
- }.`,
- ),
- )
- }
- }
-
- const exec = async (params: LiveQueryParams) => {
- liveQueryRef.current?.stop()
- liveQueryRef.current = undefined
- subscriptionRef.current?.cancel()
- subscriptionRef.current = undefined
-
- setDocuments([])
- setDitto(undefined)
- setLiveQueryEvent(undefined)
- setCollection(undefined)
-
- return createLiveQuery(params)
- }
-
- useEffect(() => {
- return () => {
- liveQueryRef.current?.stop()
- subscriptionRef.current?.cancel()
- }
- }, [])
-
- return {
- collection,
- ditto,
- documents,
- liveQueryEvent,
- exec,
- liveQuery: liveQueryRef.current,
- subscription: subscriptionRef.current,
- }
-}
diff --git a/src/queries/useLazyPendingIDSpecificOperation.spec.tsx b/src/queries/useLazyPendingIDSpecificOperation.spec.tsx
deleted file mode 100644
index 8eaf00f..0000000
--- a/src/queries/useLazyPendingIDSpecificOperation.spec.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import { Ditto, IdentityOfflinePlayground } from '@dittolive/ditto'
-import { renderHook, waitFor } from '@testing-library/react'
-import { expect } from 'chai'
-import React, { ReactNode, useEffect } from 'react'
-import { v4 as uuidv4 } from 'uuid'
-
-import { DittoProvider } from '../DittoProvider'
-import { useMutations } from '../mutations'
-import { waitForNextUpdate } from '../utils.spec'
-import { useLazyPendingIDSpecificOperation } from './useLazyPendingIDSpecificOperation'
-import { UsePendingIDSpecificOperationParams } from './usePendingIDSpecificOperation'
-
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
- path: string
-} = () => ({
- identity: {
- appID: 'usePendingIDSpecificOperationSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
- path: uuidv4(),
-})
-
-const DocumentUpserter: React.FC<{ path: string }> = ({ path }) => {
- const { ditto, upsert } = useMutations({
- path,
- collection: 'foo',
- })
-
- useEffect(() => {
- if (ditto) {
- upsert({ value: { _id: 'someId', document: 1 } })
- upsert({ value: { document: 2 } })
- upsert({ value: { document: 3 } })
- upsert({ value: { document: 4 } })
- upsert({ value: { document: 5 } })
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [ditto])
-
- return <>>
-}
-
-const initOptions = {
- webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
-}
-
-const wrapper =
- (identity: IdentityOfflinePlayground, path: string) =>
- // eslint-disable-next-line react/display-name
- ({ children }: { children: ReactNode }) => (
- {
- const ditto = new Ditto(identity, path)
- return ditto
- }}
- initOptions={initOptions}
- >
- {() => {
- return (
- <>
-
- {children}
- >
- )
- }}
-
- )
-
-describe('useLazyPendingIDSpecificOperation tests', function () {
- it('should load a document by ID correctly when the exec function is called', async () => {
- const testConfiguration = testIdentity()
-
- const params: UsePendingIDSpecificOperationParams = {
- path: testConfiguration.path,
- collection: 'foo',
- _id: 'someId',
- }
- const { result } = renderHook(() => useLazyPendingIDSpecificOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
-
- expect(result.current.document).to.eq(undefined)
- expect(result.current.ditto).to.eq(undefined)
- expect(result.current.liveQuery).to.eq(undefined)
- expect(result.current.subscription).to.eq(undefined)
- expect(result.current.event).to.eq(undefined)
-
- await result.current.exec(params)
- await waitFor(() => expect(result.current.document).to.exist, {
- timeout: 5000,
- })
-
- expect(result.current.document.id.value).to.eq('someId')
- expect(result.current.document.value.document).to.eq(1)
-
- expect(result.current.ditto).not.to.eq(undefined)
- expect(result.current.liveQuery).not.to.eq(undefined)
- expect(result.current.subscription).not.to.eq(undefined)
- expect(result.current.event).not.to.eq(undefined)
- })
-
- it('should load a document by ID correctly when the exec function is called, observing only the local store', async () => {
- const testConfiguration = testIdentity()
-
- const params: UsePendingIDSpecificOperationParams = {
- path: testConfiguration.path,
- collection: 'foo',
- _id: 'someId',
- localOnly: true,
- }
- const { result } = renderHook(() => useLazyPendingIDSpecificOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
-
- await result.current.exec(params)
- await waitFor(() => expect(result.current.document).to.exist, {
- timeout: 5000,
- })
-
- expect(result.current.document.id.value).to.eq('someId')
- expect(result.current.document.value.document).to.eq(1)
-
- expect(result.current.subscription).to.eq(undefined)
- expect(result.current.ditto).not.to.eq(undefined)
- expect(result.current.liveQuery).not.to.eq(undefined)
- expect(result.current.event).not.to.eq(undefined)
- })
-
- it('should return the loaded Ditto collection so developers can launch queries on the store with it, once the exec function is called', async function () {
- const testConfiguration = testIdentity()
-
- const params: UsePendingIDSpecificOperationParams = {
- path: testConfiguration.path,
- collection: 'foo',
- _id: 'someId',
- }
- const { result } = renderHook(() => useLazyPendingIDSpecificOperation(), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
-
- // we wait for the Ditto instance to load.
- await waitForNextUpdate(result)
-
- await result.current.exec(params)
- await waitFor(() => expect(result.current.document).to.exist, {
- timeout: 5000,
- })
-
- const allDocs = await result.current.collection.findAll().exec()
-
- expect(allDocs.length).to.eq(5)
- })
-
- it('should cancel the subscription on unmount', async () => {
- const testConfiguration = testIdentity()
- const params: UsePendingIDSpecificOperationParams = {
- path: testConfiguration.path,
- collection: 'foo',
- _id: 'someId',
- }
- const { result, unmount } = renderHook(
- () => useLazyPendingIDSpecificOperation(),
- {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- },
- )
- await waitForNextUpdate(result)
- await result.current.exec(params)
- await waitFor(() => expect(result.current.document).to.exist)
-
- unmount()
-
- await waitFor(
- () => expect(result.current.subscription.isCancelled).to.be.true,
- )
- })
-})
diff --git a/src/queries/useLazyPendingIDSpecificOperation.ts b/src/queries/useLazyPendingIDSpecificOperation.ts
deleted file mode 100644
index 3affaef..0000000
--- a/src/queries/useLazyPendingIDSpecificOperation.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import {
- Collection,
- Ditto,
- Document,
- LiveQuery,
- SingleDocumentLiveQueryEvent,
- Subscription,
-} from '@dittolive/ditto'
-import { useEffect, useRef, useState } from 'react'
-
-import { useDittoContext } from '../DittoContext.js'
-import { UsePendingIDSpecificOperationParams } from './usePendingIDSpecificOperation.js'
-
-export interface LazyPendingIDSpecificOperationReturn {
- /** The initialized Ditto instance if one could be found for the provided path. */
- ditto: Ditto | null
- /** The document found for the current query. */
- document: Document | undefined
- /** The last SingleDocumentLiveQueryEvent received by the query observer. */
- event?: SingleDocumentLiveQueryEvent
- /** Currently active live query. */
- liveQuery: LiveQuery | undefined
- /** Currently active live query. */
- subscription: Subscription | undefined
- /** Current Ditto collection instance. */
- collection: Collection | undefined
- /** Function used to trigger a query on a collection */
- exec: (params: UsePendingIDSpecificOperationParams) => Promise
-}
-
-/**
- * Runs a ditto live query lazily, once the exec function is called with the passed in query params, over a know document ID.
- * As a result of this the live query may return a document with the ID passed in as a parameter, if it exists.
- *
- * @example
- * ```tsx
- * const { document, exec } = useLazyPendingIDSpecificOperation()
- *
- *
- * const handleSomeEvent = () => {
- * exec({
- * path: myPath,
- * collection: 'collection'
- * _id: new DocumentID("some_id")
- * })
- * }
- * ```
- * @param params live query parameters.
- * @returns LazyPendingIDSpecificOperationReturn
- */
-export function useLazyPendingIDSpecificOperation(): LazyPendingIDSpecificOperationReturn {
- const { dittoHash, isLazy, load } = useDittoContext()
- const liveQueryRef = useRef()
- const subscriptionRef = useRef()
- const [ditto, setDitto] = useState()
- const [document, setDocument] = useState()
- const [collection, setCollection] = useState()
- const [event, setEvent] = useState()
-
- const createLiveQuery = async (
- params: UsePendingIDSpecificOperationParams,
- ) => {
- let nextDitto
-
- if (isLazy) {
- nextDitto = await load(params.path)
- } else {
- nextDitto = params.path
- ? dittoHash[params.path]
- : Object.values(dittoHash)[0]
- }
-
- if (nextDitto) {
- const nextCollection = nextDitto.store.collection(params.collection)
- const pendingOperation = nextCollection.findByID(params._id)
-
- if (!params.localOnly) {
- subscriptionRef.current = pendingOperation.subscribe()
- }
-
- liveQueryRef.current = pendingOperation.observeLocal((doc, e) => {
- setEvent(e)
- setDocument(doc)
- })
- setDitto(nextDitto)
- setCollection(nextCollection)
- } else {
- return Promise.reject(
- new Error(
- `Could not load a Ditto instance${
- params.path ? ' for path ' + params.path : ''
- }.${
- !isLazy
- ? ' Make sure your provider finished loading the instance before you call exec'
- : ''
- }.`,
- ),
- )
- }
- }
-
- const exec = async (params: UsePendingIDSpecificOperationParams) => {
- liveQueryRef.current?.stop()
- liveQueryRef.current = undefined
- subscriptionRef.current?.cancel()
- subscriptionRef.current = undefined
-
- setDocument(undefined)
- setDitto(undefined)
- setEvent(undefined)
- setCollection(undefined)
-
- return createLiveQuery(params)
- }
-
- useEffect(() => {
- return () => {
- liveQueryRef.current?.stop()
- subscriptionRef.current?.cancel()
- }
- }, [])
-
- return {
- collection,
- ditto,
- document,
- event,
- exec,
- liveQuery: liveQueryRef.current,
- subscription: subscriptionRef.current,
- }
-}
diff --git a/src/queries/usePendingCursorOperation.spec.tsx b/src/queries/usePendingCursorOperation.spec.tsx
deleted file mode 100644
index a46440c..0000000
--- a/src/queries/usePendingCursorOperation.spec.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import { Ditto, IdentityOfflinePlayground } from '@dittolive/ditto'
-import { renderHook, waitFor } from '@testing-library/react'
-import { expect } from 'chai'
-import React, { ReactNode, useEffect } from 'react'
-import { v4 as uuidv4 } from 'uuid'
-
-import { DittoProvider } from '../DittoProvider'
-import { useMutations } from '../mutations'
-import {
- LiveQueryParams,
- usePendingCursorOperation,
-} from './usePendingCursorOperation'
-
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
- path: string
-} = () => ({
- identity: {
- appID: 'usePendingCursorOperationSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
- path: uuidv4(),
-})
-
-export const DocumentUpserter: React.FC<{ path: string }> = ({ path }) => {
- const { ditto, upsert } = useMutations({
- path,
- collection: 'foo',
- })
-
- useEffect(() => {
- if (ditto) {
- upsert({ value: { document: 1 } })
- upsert({ value: { document: 2 } })
- upsert({ value: { document: 3 } })
- upsert({ value: { document: 4 } })
- upsert({ value: { document: 5 } })
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [ditto])
-
- return <>>
-}
-
-const initOptions = {
- webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
-}
-
-// Creates a wrapper component for each test
-const wrapper =
- (identity: IdentityOfflinePlayground, path: string) =>
- // eslint-disable-next-line react/display-name
- ({ children }: { children: ReactNode }) => (
- {
- const ditto = new Ditto(identity, path)
- return ditto
- }}
- initOptions={initOptions}
- >
- {() => {
- return (
- <>
-
- {children}
- >
- )
- }}
-
- )
-
-describe('usePendingCursorOperation tests', function () {
- it('should load all documents correctly', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- }
- const { result } = renderHook(() => usePendingCursorOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.subscription).to.exist
- expect(result.current.documents.length).to.eq(5)
-
- for (let i = 1; i < 6; i++) {
- expect(
- !!result.current.documents.find((doc) => doc.value.document === i),
- ).to.eq(true)
- }
- })
-
- it('should load all documents correctly observing only for local data', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- localOnly: true,
- }
- const { result } = renderHook(() => usePendingCursorOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.subscription).to.be.undefined
- expect(result.current.documents.length).to.eq(5)
-
- for (let i = 1; i < 6; i++) {
- expect(
- !!result.current.documents.find((doc) => doc.value.document === i),
- ).to.eq(true)
- }
- })
-
- it('should load documents correctly using a query', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- query: 'document > 3',
- }
- const { result } = renderHook(() => usePendingCursorOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eq(2)
-
- for (let i = 4; i < 6; i++) {
- expect(
- !!result.current.documents.find((doc) => doc.value.document === i),
- ).to.eq(true)
- }
- })
-
- it('should correctly reset the current live query and create a new one when the reset function is called.', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- query: 'document > 3',
- }
- const { result } = renderHook(() => usePendingCursorOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eq(2)
-
- const liveQueryBeforeReset = result.current.liveQuery
-
- result.current.reset()
-
- await waitFor(
- () => {
- expect(result.current.liveQuery).not.to.eq(liveQueryBeforeReset)
- expect(result.current.documents).to.have.lengthOf(2)
- },
- { timeout: 5000 },
- )
- })
-
- it('should cancel the current subscription when the reset function is called.', async () => {
- const testConfiguration = testIdentity()
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- query: 'document > 3',
- }
- const { result } = renderHook(() => usePendingCursorOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.documents).to.have.lengthOf(2))
- const subscriptionBeforeReset = result.current.subscription
-
- result.current.reset()
-
- await waitFor(() => expect(subscriptionBeforeReset.isCancelled).to.be.true)
- })
-
- it('should return the Ditto collection as an alternative way for developers to query the collection', async () => {
- const testConfiguration = testIdentity()
-
- const params: LiveQueryParams = {
- path: testConfiguration.path,
- collection: 'foo',
- }
- const { result } = renderHook(() => usePendingCursorOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.documents).not.to.be.empty, {
- timeout: 5000,
- })
-
- expect(result.current.documents.length).to.eq(5)
-
- const collectionDocuments = await result.current.collection.findAll().exec()
- expect(collectionDocuments.length).to.eq(5)
- })
-})
diff --git a/src/queries/usePendingCursorOperation.ts b/src/queries/usePendingCursorOperation.ts
deleted file mode 100644
index 0754126..0000000
--- a/src/queries/usePendingCursorOperation.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-import {
- Collection,
- Ditto,
- Document,
- LiveQuery,
- LiveQueryEvent,
- PendingCursorOperation,
- QueryArguments,
- SortDirection,
- Subscription,
-} from '@dittolive/ditto'
-import { useEffect, useRef, useState } from 'react'
-
-import { useDitto } from '../useDitto.js'
-import { useVersion } from './useVersion.js'
-
-export interface LiveQueryParams {
- collection: string
- /**
- * The path of the Ditto string. If you omit this, it will fetch the first registered Ditto value.
- */
- path?: string
- /**
- * A Ditto query string. For more information on the query string syntax refer to https://docs.ditto.live/concepts/querying
- * For example to query for a color property equal to red use:
- * `color == 'red'`
- */
- query?: string
- /**
- * Optional arguments that will interpolate the values into the `query` string. For example, if your query string is
- * ```
- * "color == $args.color && mileage >= $args.mileage"
- * ```. You can provide an args dictionary like:
- * ```js
- * { color: "red", mileage: "1200" }
- * ```
- */
- args?: QueryArguments
- sort?: {
- /**
- * An optional sort parameter for your query. For example, if you want to sort with ascending values on a specific field like `"createdOn"` use:
- *
- * ```js
- * {
- * propertyPath: "createdOn",
- * direction: "ascending"
- * }
- * ```
- *
- * For descending values use:
- *
- * ```js
- * {
- * propertyPath: "createdOn",
- * direction: "ascending"
- * }
- * ```
- * For more information on the query string syntax refer to https://docs.ditto.live/concepts/querying
- */
- propertyPath: string
- direction?: SortDirection
- }
- /**
- * An optional number to limit the results of the query. If you omit this value, the query will return all values
- */
- limit?: number
- /**
- * An optional number to use as an offset of the results of the query. If you omit this value, an offset of 0 is assumed.
- */
- offset?: number
- /** When true the query will only on local data mutations and will not rely on replication. */
- localOnly?: boolean
-}
-
-export interface PendingCursorOperationReturn {
- /** The initialized Ditto instance if one could be found for the provided path. */
- ditto: Ditto | null
- /** The set of documents found for the current query. */
- documents: Document[]
- /** The last LiveQueryEvent received by the query observer. */
- liveQueryEvent: LiveQueryEvent | undefined
- /** Currently active live query. */
- liveQuery: LiveQuery | undefined
- /** Currently active subscription. */
- subscription: Subscription | undefined
- /** A function used to stop the currect live query and create a new one using the current input params.*/
- reset: () => void
- /** Current Ditto collection instance. */
- collection: Collection | undefined
-}
-
-/**
- * Runs a ditto live query immediately with the passed in query params. Eg:
- *
- * @example
- * ```tsx
- * const { documents } = usePendingCursorOperation({
- * path: myPath,
- * offset: 0,
- * collection: 'collection'
- * sort: {
- * propertyPath: 'createdAt',
- * direction: 'descending' as SortDirection,
- * },
- * })
- * ```
- * @param params live query parameters.
- * @returns
- */
-export function usePendingCursorOperation(
- params: LiveQueryParams,
-): PendingCursorOperationReturn {
- const { ditto } = useDitto(params.path)
- const [documents, setDocuments] = useState([])
- const [liveQueryEvent, setLiveQueryEvent] = useState<
- LiveQueryEvent | undefined
- >()
- const [collection, setCollection] = useState()
- const liveQueryRef = useRef()
- const subscriptionRef = useRef()
- const paramsVersion = useVersion(params)
-
- const createLiveQuery = () => {
- if (ditto && !liveQueryRef.current) {
- const nextCollection = ditto.store.collection(params.collection)
- let cursor: PendingCursorOperation
- if (params.query) {
- cursor = nextCollection.find(params.query, params.args)
- } else {
- cursor = nextCollection.findAll()
- }
- if (params.sort) {
- cursor = cursor.sort(params.sort.propertyPath, params.sort.direction)
- }
- if (params.limit) {
- cursor = cursor.limit(params.limit)
- }
- if (params.offset) {
- cursor = cursor.offset(params.offset)
- }
- if (!params.localOnly) {
- subscriptionRef.current = cursor.subscribe()
- }
-
- liveQueryRef.current = cursor.observeLocal((docs, event) => {
- setDocuments(docs)
- setLiveQueryEvent(event)
- })
- setCollection(nextCollection)
- }
- }
-
- const handleResetLiveQuery = () => {
- liveQueryRef.current?.stop()
- liveQueryRef.current = undefined
- subscriptionRef.current?.cancel()
- subscriptionRef.current = undefined
-
- setCollection(null)
- setLiveQueryEvent(null)
- setDocuments([])
-
- createLiveQuery()
- }
-
- useEffect(() => {
- createLiveQuery()
-
- return () => {
- liveQueryRef.current?.stop()
- liveQueryRef.current = undefined
- subscriptionRef.current?.cancel()
- subscriptionRef.current = undefined
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [ditto, paramsVersion])
-
- return {
- collection,
- ditto,
- documents,
- liveQueryEvent,
- liveQuery: liveQueryRef.current,
- subscription: subscriptionRef.current,
- reset: handleResetLiveQuery,
- }
-}
diff --git a/src/queries/usePendingIDSpecificOperation.spec.tsx b/src/queries/usePendingIDSpecificOperation.spec.tsx
deleted file mode 100644
index df91274..0000000
--- a/src/queries/usePendingIDSpecificOperation.spec.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import { Ditto, IdentityOfflinePlayground } from '@dittolive/ditto'
-import { renderHook, waitFor } from '@testing-library/react'
-import { expect } from 'chai'
-import React, { ReactNode, useEffect } from 'react'
-import { v4 as uuidv4 } from 'uuid'
-
-import { DittoProvider } from '../DittoProvider'
-import { useMutations } from '../mutations'
-import {
- usePendingIDSpecificOperation,
- UsePendingIDSpecificOperationParams,
-} from './usePendingIDSpecificOperation'
-
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
- path: string
-} = () => ({
- identity: {
- appID: 'usePendingIDSpecificOperationSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
- path: uuidv4(),
-})
-
-const DocumentUpserter: React.FC<{ path: string }> = ({ path }) => {
- const { ditto, upsert } = useMutations({
- path,
- collection: 'foo',
- })
-
- useEffect(() => {
- if (ditto) {
- upsert({ value: { _id: 'someId', document: 1 } })
- upsert({ value: { document: 2 } })
- upsert({ value: { document: 3 } })
- upsert({ value: { document: 4 } })
- upsert({ value: { document: 5 } })
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [ditto])
-
- return <>>
-}
-
-const initOptions = {
- webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
-}
-
-const wrapper =
- (identity: IdentityOfflinePlayground, path: string) =>
- // eslint-disable-next-line react/display-name
- ({ children }: { children: ReactNode }) => (
- {
- const ditto = new Ditto(identity, path)
- return ditto
- }}
- initOptions={initOptions}
- >
- {() => {
- return (
- <>
-
- {children}
- >
- )
- }}
-
- )
-
-describe('usePendingIDSpecificOperation tests', function () {
- it('should load a document by ID correctly', async () => {
- const testConfiguration = testIdentity()
-
- const params: UsePendingIDSpecificOperationParams = {
- path: testConfiguration.path,
- collection: 'foo',
- _id: 'someId',
- }
- const { result } = renderHook(() => usePendingIDSpecificOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.document).to.exist, {
- timeout: 5000,
- })
-
- expect(result.current.subscription).to.exist
- expect(result.current.document.id.value).to.eq('someId')
- expect(result.current.document.value.document).to.eq(1)
- })
-
- it('should load a document by ID correctly observing only the local store', async () => {
- const testConfiguration = testIdentity()
-
- const params: UsePendingIDSpecificOperationParams = {
- path: testConfiguration.path,
- collection: 'foo',
- _id: 'someId',
- localOnly: true,
- }
- const { result } = renderHook(() => usePendingIDSpecificOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.document).to.exist, {
- timeout: 5000,
- })
-
- expect(result.current.subscription).to.be.undefined
- expect(result.current.document.id.value).to.eq('someId')
- expect(result.current.document.value.document).to.eq(1)
- })
-
- it('should return the loaded Ditto collection so developers can launch queries on the store with it', async function () {
- const testConfiguration = testIdentity()
-
- const params: UsePendingIDSpecificOperationParams = {
- path: testConfiguration.path,
- collection: 'foo',
- _id: 'someId',
- }
- const { result } = renderHook(() => usePendingIDSpecificOperation(params), {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- })
- await waitFor(() => expect(result.current.document).to.exist, {
- timeout: 5000,
- })
-
- expect(result.current.collection).not.to.eq(undefined)
-
- const allDocs = await result.current.collection.findAll().exec()
-
- expect(allDocs.length).to.eq(5)
- })
-
- it('should cancel the subscription on unmount', async () => {
- const testConfiguration = testIdentity()
- const params: UsePendingIDSpecificOperationParams = {
- path: testConfiguration.path,
- collection: 'foo',
- _id: 'someId',
- }
- const { result, unmount } = renderHook(
- () => usePendingIDSpecificOperation(params),
- {
- wrapper: wrapper(testConfiguration.identity, testConfiguration.path),
- },
- )
- await waitFor(() => expect(result.current.document).to.exist)
-
- unmount()
-
- await waitFor(
- () => expect(result.current.subscription.isCancelled).to.be.true,
- )
- })
-})
diff --git a/src/queries/usePendingIDSpecificOperation.ts b/src/queries/usePendingIDSpecificOperation.ts
deleted file mode 100644
index a471d8f..0000000
--- a/src/queries/usePendingIDSpecificOperation.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import {
- Collection,
- Ditto,
- Document,
- DocumentIDValue,
- LiveQuery,
- SingleDocumentLiveQueryEvent,
- Subscription,
-} from '@dittolive/ditto'
-import { useEffect, useRef, useState } from 'react'
-
-import { useDitto } from '../useDitto.js'
-
-export interface UsePendingIDSpecificOperationParams {
- /**
- * The path
- */
- path?: string
- /**
- * The name of the collection to query
- */
- collection: string
- /**
- * The _id of the document to query
- */
- _id: DocumentIDValue
- /**
- * When true the query will only on local data mutations and will not rely on replication.
- * */
- localOnly?: boolean
-}
-
-export interface PendingIDSpecificOperationReturn {
- /** The initialized Ditto instance if one could be found for the provided path. */
- ditto: Ditto | null
- /** The document found for the current query. */
- document: Document | undefined
- /** The last SingleDocumentLiveQueryEvent received by the query observer. */
- event?: SingleDocumentLiveQueryEvent
- /** Currently active live query. */
- liveQuery: LiveQuery | undefined
- /** Currently active subscription. */
- subscription: Subscription | undefined
- /** Current Ditto collection instance. */
- collection: Collection | undefined
-}
-
-/**
- * Runs a ditto live query immediately with the passed in query params over a know document ID. As a result of
- * this the live query may return a the document with the ID passed in as a parameter, if it exists.
- *
- * @example
- * ```tsx
- * const { document } = usePendingIDSpecificOperation({
- * path: myPath,
- * collection: 'collection'
- * _id: new DocumentID("some_id")
- * })
- * ```
- * @param params live query parameters.
- * @returns PendingIDSpecificOperationReturn
- */
-export function usePendingIDSpecificOperation(
- params: UsePendingIDSpecificOperationParams,
-): PendingIDSpecificOperationReturn {
- const liveQueryRef = useRef()
- const subscriptionRef = useRef()
- const { ditto } = useDitto(params.path)
- const [document, setDocument] = useState()
- const [collection, setCollection] = useState()
- const [event, setEvent] = useState()
-
- useEffect(() => {
- if (params._id && params.collection && ditto) {
- const nextCollection = ditto.store.collection(params.collection)
- const pendingOperation = nextCollection.findByID(params._id)
-
- if (!params.localOnly) {
- subscriptionRef.current = pendingOperation.subscribe()
- }
-
- liveQueryRef.current = pendingOperation.observeLocal((doc, e) => {
- setEvent(e)
- setDocument(doc)
- })
- setCollection(nextCollection)
- } else {
- setDocument(undefined)
- setCollection(undefined)
- setEvent(undefined)
- }
-
- return () => {
- liveQueryRef.current?.stop()
- subscriptionRef.current?.cancel()
- }
- /** We need to serialize the _id in order for React's dependency array comparison to work. */
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [params.path, params.collection, params._id || '', ditto])
-
- return {
- collection,
- ditto,
- document,
- event,
- liveQuery: liveQueryRef.current,
- subscription: subscriptionRef.current,
- }
-}
diff --git a/src/queries/useQuery.spec.tsx b/src/queries/useQuery.spec.tsx
new file mode 100644
index 0000000..7bc81da
--- /dev/null
+++ b/src/queries/useQuery.spec.tsx
@@ -0,0 +1,215 @@
+import { DittoError } from '@dittolive/ditto'
+import { renderHook, waitFor } from '@testing-library/react'
+import { expect } from 'chai'
+import { ReactNode } from 'react'
+import { v4 as uuidv4 } from 'uuid'
+
+import { DittoProvider } from '../DittoProvider'
+import { openSeededDitto, wasmInitOptions } from '../utils.spec'
+import { useQuery, UseQueryParams } from './useQuery'
+
+const testConfig: () => {
+ databaseID: string
+ persistenceDirectory: string
+} = () => ({
+ databaseID: 'useQuerySpec',
+ persistenceDirectory: uuidv4(),
+})
+
+// Creates a wrapper component for each test
+const wrapper =
+ (databaseID: string, persistenceDirectory: string) =>
+ // eslint-disable-next-line react/display-name
+ ({ children }: { children: ReactNode }) => (
+ openSeededDitto(databaseID, persistenceDirectory)}
+ initOptions={wasmInitOptions}
+ >
+ {() => {
+ return <>{children}>
+ }}
+
+ )
+
+describe('useQuery', function () {
+ it('should load all documents correctly', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useQuery('select * from foo', {
+ persistenceDirectory: config.persistenceDirectory,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+ await waitFor(() => expect(result.current.items).not.to.be.empty, {
+ timeout: 5000,
+ })
+
+ expect(result.current.syncSubscription).to.exist
+ expect(result.current.items.length).to.eq(5)
+
+ for (let i = 1; i < 6; i++) {
+ expect(
+ !!result.current.items.find((item) => item.value.document === i),
+ ).to.eq(true)
+ }
+ })
+
+ it('should load all documents correctly observing only for local data', async () => {
+ const config = testConfig()
+
+ const params: UseQueryParams = {
+ persistenceDirectory: config.persistenceDirectory,
+ localOnly: true,
+ }
+ const { result } = renderHook(() => useQuery('select * from foo', params), {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ })
+ await waitFor(() => expect(result.current.items).not.to.be.empty, {
+ timeout: 5000,
+ })
+
+ expect(result.current.syncSubscription).to.be.undefined
+ expect(result.current.items.length).to.eq(5)
+
+ for (let i = 1; i < 6; i++) {
+ expect(
+ !!result.current.items.find((doc) => doc.value.document === i),
+ ).to.eq(true)
+ }
+ })
+
+ it('should load documents correctly using a query', async () => {
+ const config = testConfig()
+
+ const params: UseQueryParams = {
+ persistenceDirectory: config.persistenceDirectory,
+ }
+ const { result } = renderHook(
+ () => useQuery('select * from foo where document > 3', params),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+ await waitFor(() => expect(result.current.items).not.to.be.empty, {
+ timeout: 5000,
+ })
+
+ expect(result.current.items.length).to.eq(2)
+
+ for (let i = 4; i < 6; i++) {
+ expect(
+ !!result.current.items.find((doc) => doc.value.document === i),
+ ).to.eq(true)
+ }
+ })
+
+ it('should correctly reset the current store observer and create a new one when the reset function is called.', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useQuery('select * from foo where document > 3', {
+ persistenceDirectory: config.persistenceDirectory,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+ await waitFor(() => expect(result.current.items).not.to.be.empty, {
+ timeout: 5000,
+ })
+
+ expect(result.current.items.length).to.eq(2)
+
+ const storeObserverBeforeReset = result.current.storeObserver
+
+ const promisedReset = result.current.reset()
+
+ expect(result.current.isLoading).to.be.false
+ await promisedReset
+
+ await waitFor(
+ () =>
+ expect(result.current.storeObserver).not.to.eq(
+ storeObserverBeforeReset,
+ ),
+ { timeout: 5000 },
+ )
+ expect(storeObserverBeforeReset.isCancelled).to.be.true
+ await waitFor(
+ () => {
+ expect(result.current.items).to.have.lengthOf(2)
+ },
+ { timeout: 5000 },
+ )
+ })
+
+ it('should cancel the current sync subscription when the reset function is called.', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useQuery('select * from foo where document > 3', {
+ persistenceDirectory: config.persistenceDirectory,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+ await waitFor(() => expect(result.current.items).to.have.lengthOf(2))
+ const subscriptionBeforeReset = result.current.syncSubscription
+
+ await result.current.reset()
+
+ expect(subscriptionBeforeReset.isCancelled).to.be.true
+ })
+
+ it('should provide errors from invalid queries on the return value and via the error callback', async () => {
+ const config = testConfig()
+
+ const handleErrors = (error: Error) => {
+ expect(error).to.exist
+ }
+
+ const { result } = renderHook(
+ () =>
+ useQuery('not a query', {
+ persistenceDirectory: config.persistenceDirectory,
+ onError: handleErrors,
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ await waitFor(() => expect(result.current.error).to.exist)
+ })
+
+ it('has the expected failure mode when used with a mutating query', async () => {
+ const config = testConfig()
+
+ const { result } = renderHook(
+ () =>
+ useQuery('insert into foo documents (:value)', {
+ persistenceDirectory: config.persistenceDirectory,
+ queryArguments: { value: { document: 10 } },
+ // The mutating query is expected to fail; handle the error so the
+ // hook does not log it to the console during the test run.
+ onError: () => {},
+ }),
+ {
+ wrapper: wrapper(config.databaseID, config.persistenceDirectory),
+ },
+ )
+
+ await waitFor(() =>
+ expect((result.current.error as DittoError).code).to.equal(
+ 'query/unsupported',
+ ),
+ )
+ })
+})
diff --git a/src/queries/useQuery.ts b/src/queries/useQuery.ts
new file mode 100644
index 0000000..1317c5a
--- /dev/null
+++ b/src/queries/useQuery.ts
@@ -0,0 +1,215 @@
+import {
+ Ditto,
+ DQLQueryArguments,
+ QueryResult,
+ QueryResultItem,
+ StoreObserver,
+ SyncSubscription,
+} from '@dittolive/ditto'
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import { type DittoProvider } from '../DittoProvider.js'
+import { useDitto } from '../useDitto.js'
+import { useVersion } from './useVersion.js'
+
+/**
+ * Parameters for {@link useQuery}.
+ *
+ * @template T - The type of query arguments.
+ */
+export interface UseQueryParams<
+ T extends DQLQueryArguments = DQLQueryArguments,
+> {
+ /**
+ * The arguments to pass to the query.
+ */
+ queryArguments?: T
+ /**
+ * Whether to run the query locally only.
+ *
+ * Setting this to `true` will skip setting up a {@link SyncSubscription} that
+ * syncs documents with remote peers. Consequently, the `syncSubscription`
+ * property of the return value will be `undefined`.
+ */
+ localOnly?: boolean
+ /**
+ * A callback to run when an error occurs.
+ *
+ * @param error
+ * @returns
+ */
+ onError?: (error: unknown) => void
+ /**
+ * Identifies the Ditto instance to use when multiple instances are registered
+ * in the {@link DittoProvider}. Defaults to the first registered instance.
+ *
+ * See {@link Ditto.absolutePersistenceDirectory}.
+ */
+ persistenceDirectory?: string
+}
+
+/**
+ * The return value of {@link useQuery}.
+ *
+ * @template T - The type of query result items.
+ */
+export interface UseQueryReturn {
+ /**
+ * The Ditto instance used by this hook. `null` until the provider has loaded
+ * an instance for the requested persistence directory.
+ */
+ ditto: Ditto | null
+ /**
+ * The most recent error that occurred while setting up the query.
+ *
+ * Use the {@link UseQueryParams.onError | `onError`} callback parameter
+ * to handle errors as they occur.
+ */
+ error: unknown
+ /**
+ * The items returned by the query.
+ *
+ * An empty array while `isLoading` is `true`.
+ */
+ items: QueryResultItem[]
+ /**
+ * `true` during the initial setup of the query. Resetting the query with
+ * {@link UseQueryReturn.reset | `reset`} will not set this back to `true` to
+ * avoid flickering when used in UIs.
+ */
+ isLoading: boolean
+ /**
+ * Reset the state of this hook.
+ *
+ * This will cancel and reconfigure the {@link StoreObserver} and
+ * {@link SyncSubscription}, and return `error` and `items` to their initial
+ * `null` state.
+ *
+ * This does not set {@link UseQueryReturn.isLoading | `isLoading`} to `true`
+ * during the reset process. However, the promise returned by this function
+ * will resolve once the reset is complete.
+ */
+ reset: () => Promise
+ /**
+ * The underlying Ditto {@link StoreObserver}. `undefined` until the query has
+ * been configured.
+ */
+ storeObserver?: StoreObserver
+ /**
+ * The underlying Ditto {@link SyncSubscription}. This is `undefined` when the
+ * {@link UseQueryParams.localOnly | `localOnly`} parameter is set to `true`.
+ */
+ syncSubscription?: SyncSubscription
+}
+
+/**
+ * Continuously fetch results for the provided query.
+ *
+ * Configures both a {@link StoreObserver} and a {@link SyncSubscription} to
+ * keep results up-to-date with local and remote changes.
+ *
+ *
+ * @example
+ * ```tsx
+ * const { items } = useStoreObserver(
+ * 'select * from tasks where _id = :id limit 10', {
+ * queryArguments: { id: '123' },
+ * }
+ * )
+ * ```
+ *
+ * @param query - The query to run. Must be a non-mutating query.
+ * @param params - Additional parameters to configure how the query is run.
+ * @template T - The type of query result items. Be aware that this is a
+ * convenience type that is not checked against the query being run.
+ * @template U - The type of query arguments.
+ */
+export function useQuery<
+ // We default to any here and let the user specify the type if they want.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ T = any,
+ U extends DQLQueryArguments | undefined | null = DQLQueryArguments,
+>(query: string, params?: UseQueryParams): UseQueryReturn {
+ const { ditto } = useDitto(params?.persistenceDirectory)
+ const [queryResult, setQueryResult] = useState>()
+ const [error, setError] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const storeObserverRef = useRef()
+ const syncSubscriptionRef = useRef()
+ const queryArgumentsVersion = useVersion(params?.queryArguments)
+
+ const reset = useCallback(async () => {
+ const configureQuery = (onCompletion: () => void) => {
+ if (!ditto) {
+ onCompletion()
+ return
+ }
+
+ storeObserverRef.current?.cancel()
+ syncSubscriptionRef.current?.cancel()
+
+ try {
+ storeObserverRef.current = ditto.store.registerObserver(
+ query,
+ (result) => {
+ setQueryResult(result)
+ onCompletion()
+ },
+ params?.queryArguments,
+ )
+ } catch (e: unknown) {
+ setError(e)
+ if (params?.onError) params.onError(e)
+ else console.error(e)
+ // Resolve even on failure so callers awaiting the returned promise (and
+ // the loading state) are not left hanging.
+ onCompletion()
+ }
+
+ if (!params?.localOnly) {
+ try {
+ syncSubscriptionRef.current = ditto.sync.registerSubscription(
+ query,
+ params?.queryArguments,
+ )
+ } catch (e: unknown) {
+ setError(e)
+ if (params?.onError) params.onError(e)
+ else console.error(e)
+ }
+ }
+ }
+
+ setQueryResult(null)
+ setError(null)
+ return new Promise((resolve) => {
+ configureQuery(resolve)
+ })
+
+ // `queryArgumentsVersion` is not recognized by eslint as a required
+ // dependency but ensures that the hook is reset when deep changes occur in
+ // `queryArguments`.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ditto, queryArgumentsVersion, query, params?.localOnly])
+
+ useEffect(() => {
+ reset().then(() => setIsLoading(false))
+ return () => {
+ storeObserverRef.current?.cancel()
+ syncSubscriptionRef.current?.cancel()
+ }
+ // `reset` already changes identity whenever `ditto`, the query, or the
+ // query arguments change, so it is the only dependency needed here.
+ }, [reset])
+
+ return {
+ ditto,
+ error,
+ items: queryResult?.items || [],
+ isLoading,
+ reset,
+ storeObserver: storeObserverRef.current,
+ syncSubscription: syncSubscriptionRef.current,
+ }
+}
diff --git a/src/queries/useRemotePeers.tsx b/src/queries/useRemotePeers.tsx
index a4b4445..013089e 100644
--- a/src/queries/useRemotePeers.tsx
+++ b/src/queries/useRemotePeers.tsx
@@ -16,7 +16,7 @@ export interface UsePeersParams {
* @returns
*/
export function useRemotePeers(params: UsePeersParams = {}): {
- ditto: Ditto
+ ditto: Ditto | null
remotePeers: Peer[]
} {
const { ditto } = useDitto(params.path)
diff --git a/src/useDitto.spec.tsx b/src/useDitto.spec.tsx
index f9faa65..e066e30 100644
--- a/src/useDitto.spec.tsx
+++ b/src/useDitto.spec.tsx
@@ -1,40 +1,31 @@
-import { Ditto, IdentityOfflinePlayground } from '@dittolive/ditto'
+import { Ditto } from '@dittolive/ditto'
import { renderHook, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import { ReactNode } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { DittoLazyProvider, DittoProvider, useDitto } from './'
+import { openOfflineDitto, wasmInitOptions } from './utils.spec'
-const testIdentity: () => {
- identity: IdentityOfflinePlayground
+const testConfig: () => {
+ databaseID: string
persistenceDirectory: string
} = () => ({
- identity: {
- appID: 'useDittoSpec',
- siteID: 100,
- type: 'offlinePlayground',
- },
+ databaseID: 'useDittoSpec',
persistenceDirectory: uuidv4(),
})
describe('useDittoSpec tests', function () {
it('should return a ditto instance with a matching persistence directory when a non-lazy provider is used.', async function () {
- const testConfiguration = testIdentity()
- const setup = (): Ditto => {
- const ditto = new Ditto(
- testConfiguration.identity,
+ const testConfiguration = testConfig()
+ const setup = (): Promise =>
+ openOfflineDitto(
+ testConfiguration.databaseID,
testConfiguration.persistenceDirectory,
)
- return ditto
- }
-
- const initOptions = {
- webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
- }
const wrapper = ({ children }: { children: ReactNode }) => (
-
+
{() => {
return children
}}
@@ -50,28 +41,21 @@ describe('useDittoSpec tests', function () {
await waitFor(() => expect(result.current.ditto).to.exist, {
timeout: 5000,
})
- expect(result.current.ditto.persistenceDirectory).to.eq(
+ expect(result.current.ditto.config.persistenceDirectory).to.eq(
testConfiguration.persistenceDirectory,
)
})
it('should return a ditto instance with a matching persistenceDirectory, and a loading state, when a lazy provider is used.', async function () {
- const testConfiguration = testIdentity()
- const setup = (): Promise => {
- return Promise.resolve(
- new Ditto(
- testConfiguration.identity,
- testConfiguration.persistenceDirectory,
- ),
+ const testConfiguration = testConfig()
+ const setup = (): Promise =>
+ openOfflineDitto(
+ testConfiguration.databaseID,
+ testConfiguration.persistenceDirectory,
)
- }
-
- const initOptions = {
- webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
- }
const wrapper = ({ children }: { children: ReactNode }) => (
-
+
{({ loading }) => {
if (loading) {
return null
@@ -96,7 +80,7 @@ describe('useDittoSpec tests', function () {
{ timeout: 5000 },
)
- expect(result.current?.ditto.persistenceDirectory).to.eq(
+ expect(result.current?.ditto.config.persistenceDirectory).to.eq(
testConfiguration.persistenceDirectory,
)
expect(result.current?.loading).to.eq(false)
diff --git a/src/utils.spec.ts b/src/utils.spec.ts
index 5ca46f9..dc6b3cf 100644
--- a/src/utils.spec.ts
+++ b/src/utils.spec.ts
@@ -1,6 +1,48 @@
+import { Ditto, DittoConfig, Logger } from '@dittolive/ditto'
import { waitFor as libraryWaitFor } from '@testing-library/react'
import { expect } from 'chai'
+/** Tells the provider where to load the locally served `ditto.wasm` file. */
+export const wasmInitOptions = {
+ webAssemblyModule: '/base/node_modules/@dittolive/ditto/web/ditto.wasm',
+}
+
+/** Opens an offline (peer-to-peer only) Ditto instance for use in tests. */
+export const openOfflineDitto = (
+ databaseID: string,
+ persistenceDirectory: string,
+): Promise => {
+ // The provider has already called `init()` by the time this runs, so it is
+ // safe to configure the logger here to keep test output quiet.
+ Logger.minimumLogLevel = 'Warning'
+ return Ditto.open(
+ new DittoConfig(
+ databaseID,
+ { mode: 'smallPeersOnly' },
+ persistenceDirectory,
+ ),
+ )
+}
+
+/** Opens an offline Ditto instance seeded with five `foo` documents. */
+export const openSeededDitto = async (
+ databaseID: string,
+ persistenceDirectory: string,
+): Promise => {
+ const ditto = await openOfflineDitto(databaseID, persistenceDirectory)
+ await ditto.store.execute(
+ 'INSERT INTO foo DOCUMENTS (:d1), (:d2), (:d3), (:d4), (:d5)',
+ {
+ d1: { document: 1, category: 1 },
+ d2: { document: 2, category: 2 },
+ d3: { document: 3 },
+ d4: { document: 4 },
+ d5: { document: 5 },
+ },
+ )
+ return ditto
+}
+
/** Helper function used to wait for events to sink to the DOM before assertions can be made. */
export const waitFor = (cb: () => boolean, waitMs = 300): Promise => {
return new Promise((resolve) => {