From 7bd3a29393be1897e80cb14fc276d43113300d0f Mon Sep 17 00:00:00 2001 From: cballevre Date: Fri, 27 Oct 2023 18:21:01 +0200 Subject: [PATCH] feat: Apply data enhancement to realtime results for specific doctypes This enrichment closes the gap between the results of queries from the cozy-stack and realtime. In fact, realtime returns the document directly from the database, unlike the stack, which can add computed fields. Without them, it can lead to instable behaviour in applications. Some applications fix some issue by reimplementing the RealTimeQueries component. This commit aims to provide unifed api to avoid duplicated code. It can also be used as a reference when we want to close the gap. --- docs/api/cozy-client/README.md | 21 +- packages/cozy-client/src/RealTimeQueries.jsx | 34 ++- .../cozy-client/src/RealTimeQueries.spec.jsx | 221 ++++++++++++------ packages/cozy-client/src/helpers/realtime.js | 39 ++++ packages/cozy-client/src/store/realtime.js | 93 ++++++-- .../cozy-client/types/helpers/realtime.d.ts | 5 + .../cozy-client/types/store/realtime.d.ts | 12 +- 7 files changed, 306 insertions(+), 119 deletions(-) create mode 100644 packages/cozy-client/src/helpers/realtime.js create mode 100644 packages/cozy-client/types/helpers/realtime.d.ts diff --git a/docs/api/cozy-client/README.md b/docs/api/cozy-client/README.md index e426106802..93ceffd7cb 100644 --- a/docs/api/cozy-client/README.md +++ b/docs/api/cozy-client/README.md @@ -336,7 +336,7 @@ Deconstructed link ### dispatchCreate -▸ **dispatchCreate**(`client`, `doctype`, `couchDBDoc`): `void` +▸ **dispatchCreate**(`client`, `doctype`, `couchDBDoc`, `options?`): `Promise`<`void`> Dispatches a create action for a document to update CozyClient store from realtime callbacks. @@ -347,20 +347,21 @@ Dispatches a create action for a document to update CozyClient store from realti | `client` | `any` | CozyClient instance | | `doctype` | `string` | Doctype of the document to create | | `couchDBDoc` | `CouchDBDocument` | Document to create | +| `options` | `DispatchOptions` | - | *Returns* -`void` +`Promise`<`void`> *Defined in* -[packages/cozy-client/src/store/realtime.js:58](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/store/realtime.js#L58) +[packages/cozy-client/src/store/realtime.js:76](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/store/realtime.js#L76) *** ### dispatchDelete -▸ **dispatchDelete**(`client`, `doctype`, `couchDBDoc`): `void` +▸ **dispatchDelete**(`client`, `doctype`, `couchDBDoc`, `options?`): `Promise`<`void`> Dispatches a delete action for a document to update CozyClient store from realtime callbacks. @@ -371,20 +372,21 @@ Dispatches a delete action for a document to update CozyClient store from realti | `client` | `any` | CozyClient instance | | `doctype` | `string` | Doctype of the document to create | | `couchDBDoc` | `CouchDBDocument` | Document to create | +| `options` | `DispatchOptions` | - | *Returns* -`void` +`Promise`<`void`> *Defined in* -[packages/cozy-client/src/store/realtime.js:80](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/store/realtime.js#L80) +[packages/cozy-client/src/store/realtime.js:120](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/store/realtime.js#L120) *** ### dispatchUpdate -▸ **dispatchUpdate**(`client`, `doctype`, `couchDBDoc`): `void` +▸ **dispatchUpdate**(`client`, `doctype`, `couchDBDoc`, `options?`): `Promise`<`void`> Dispatches a update action for a document to update CozyClient store from realtime callbacks. @@ -395,14 +397,15 @@ Dispatches a update action for a document to update CozyClient store from realti | `client` | `any` | CozyClient instance | | `doctype` | `string` | Doctype of the document to create | | `couchDBDoc` | `CouchDBDocument` | Document to create | +| `options` | `DispatchOptions` | - | *Returns* -`void` +`Promise`<`void`> *Defined in* -[packages/cozy-client/src/store/realtime.js:69](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/store/realtime.js#L69) +[packages/cozy-client/src/store/realtime.js:98](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/store/realtime.js#L98) *** diff --git a/packages/cozy-client/src/RealTimeQueries.jsx b/packages/cozy-client/src/RealTimeQueries.jsx index 5356bd2acb..a253bdbc04 100644 --- a/packages/cozy-client/src/RealTimeQueries.jsx +++ b/packages/cozy-client/src/RealTimeQueries.jsx @@ -5,13 +5,14 @@ import { dispatchDelete, dispatchUpdate } from './store/realtime' +import { ensureFilePath } from './helpers/realtime' /** * Component that subscribes to a doctype changes and keep the * internal store updated. * - * @param {object} options - Options - * @param {import("./types").Doctype} options.doctype - The doctype to watch + * @param {object} options - Options + * @param {import("./types").Doctype} options.doctype - The doctype to watch * @returns {null} The component does not display anything. */ const RealTimeQueries = ({ doctype }) => { @@ -26,16 +27,27 @@ const RealTimeQueries = ({ doctype }) => { ) } + let options = {} + if (doctype === 'io.cozy.files') { + options.enhanceDocFn = ensureFilePath + } + + const handleCreated = data => { + dispatchCreate(client, doctype, data, options) + } + + const handleUpdated = data => { + dispatchUpdate(client, doctype, data, options) + } + + const handleDeleted = data => { + dispatchDelete(client, doctype, data, options) + } + const subscribe = async () => { - await realtime.subscribe('created', doctype, data => - dispatchCreate(client, doctype, data) - ) - await realtime.subscribe('updated', doctype, data => - dispatchUpdate(client, doctype, data) - ) - await realtime.subscribe('deleted', doctype, data => - dispatchDelete(client, doctype, data) - ) + await realtime.subscribe('created', doctype, handleCreated) + await realtime.subscribe('updated', doctype, handleUpdated) + await realtime.subscribe('deleted', doctype, handleDeleted) } subscribe() diff --git a/packages/cozy-client/src/RealTimeQueries.spec.jsx b/packages/cozy-client/src/RealTimeQueries.spec.jsx index bfde14087d..fdbd9e0ea9 100644 --- a/packages/cozy-client/src/RealTimeQueries.spec.jsx +++ b/packages/cozy-client/src/RealTimeQueries.spec.jsx @@ -6,7 +6,26 @@ import CozyProvider from './Provider' const setup = async doctype => { const realtimeCallbacks = {} - const client = new createMockClient({}) + const client = new createMockClient({ + queries: { + 'io.cozy.files/parent': { + doctype: 'io.cozy.files', + definition: { + doctype: 'io.cozy.files', + id: 'parent' + }, + data: [ + { + _id: 'parent', + _type: 'io.cozy.files', + name: 'Parent folder', + path: '/Parent' + } + ] + } + } + }) + client.plugins.realtime = { subscribe: jest.fn((event, doctype, callback) => { realtimeCallbacks[event] = callback @@ -29,17 +48,26 @@ const setup = async doctype => { } describe('RealTimeQueries', () => { - it('notifies the cozy-client store', async () => { + it('should dispatch CREATE_DOCUMENT mutation for created io.cozy.files', async () => { const { client, realtimeCallbacks, unmount } = await setup('io.cozy.files') - realtimeCallbacks['created']({ _id: 'mock-created', type: 'file' }) - expect(client.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ + realtimeCallbacks['created']({ + _id: 'mock-created', + type: 'file', + name: 'mock-created', + dir_id: 'parent' + }) + + await waitFor(() => { + expect(client.dispatch).toHaveBeenCalledWith({ definition: { document: { _id: 'mock-created', - id: 'mock-created', _type: 'io.cozy.files', + dir_id: 'parent', + id: 'mock-created', + name: 'mock-created', + path: '/Parent/mock-created', type: 'file' }, mutationType: 'CREATE_DOCUMENT' @@ -48,103 +76,152 @@ describe('RealTimeQueries', () => { response: { data: { _id: 'mock-created', - id: 'mock-created', _type: 'io.cozy.files', + dir_id: 'parent', + id: 'mock-created', + name: 'mock-created', + path: '/Parent/mock-created', type: 'file' } }, type: 'RECEIVE_MUTATION_RESULT' }) - ) + }) - realtimeCallbacks['updated']({ _id: 'mock-updated', type: 'file' }) - expect(client.dispatch).toHaveBeenCalledWith({ - definition: { - document: { - _id: 'mock-updated', - id: 'mock-updated', - _type: 'io.cozy.files', - type: 'file' - }, - mutationType: 'UPDATE_DOCUMENT' - }, - mutationId: '2', - response: { - data: { - _id: 'mock-updated', - id: 'mock-updated', - _type: 'io.cozy.files', - type: 'file' - } - }, - type: 'RECEIVE_MUTATION_RESULT' + unmount() + await waitFor(() => { + expect(client.plugins.realtime.unsubscribe).toHaveBeenCalledTimes(3) + }) + }) + + it('should dispatch UPDATE_DOCUMENT mutation for updated io.cozy.files', async () => { + const { client, realtimeCallbacks, unmount } = await setup('io.cozy.files') + + realtimeCallbacks['updated']({ + _id: 'mock-updated', + type: 'file', + name: 'mock-updated', + dir_id: 'parent' }) - realtimeCallbacks['deleted']({ _id: 'mock-deleted', type: 'file' }) - expect(client.dispatch).toHaveBeenCalledWith({ - definition: { - document: { - _id: 'mock-deleted', - id: 'mock-deleted', - _type: 'io.cozy.files', - type: 'file', - _deleted: true + await waitFor(() => { + expect(client.dispatch).toHaveBeenCalledWith({ + definition: { + document: { + _id: 'mock-updated', + _type: 'io.cozy.files', + dir_id: 'parent', + id: 'mock-updated', + name: 'mock-updated', + path: '/Parent/mock-updated', + type: 'file' + }, + mutationType: 'UPDATE_DOCUMENT' }, - mutationType: 'DELETE_DOCUMENT' - }, - mutationId: '3', - response: { - data: { - _id: 'mock-deleted', - id: 'mock-deleted', - _type: 'io.cozy.files', - type: 'file', - _deleted: true - } - }, - type: 'RECEIVE_MUTATION_RESULT' + mutationId: '1', + response: { + data: { + _id: 'mock-updated', + _type: 'io.cozy.files', + dir_id: 'parent', + id: 'mock-updated', + name: 'mock-updated', + path: '/Parent/mock-updated', + type: 'file' + } + }, + type: 'RECEIVE_MUTATION_RESULT' + }) }) unmount() expect(client.plugins.realtime.unsubscribe).toHaveBeenCalledTimes(3) }) - it('deals with other doctypes than io.cozy.files', async () => { - const { client, realtimeCallbacks, unmount } = await setup( - 'io.cozy.oauth.clients' - ) + it('should dispatch DELETE_DOCUMENT mutation for deleted io.cozy.files', async () => { + const { client, realtimeCallbacks, unmount } = await setup('io.cozy.files') - realtimeCallbacks['created']({ - _id: 'mock-created', - client_kind: 'desktop', - client_name: 'Cozy Drive (hostname)' + realtimeCallbacks['deleted']({ + _id: 'mock-deleted', + type: 'file', + name: 'mock-deleted', + dir_id: 'parent' }) - expect(client.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ + + await waitFor(() => { + expect(client.dispatch).toHaveBeenCalledWith({ definition: { document: { - _id: 'mock-created', - id: 'mock-created', - _type: 'io.cozy.oauth.clients', - client_kind: 'desktop', - client_name: 'Cozy Drive (hostname)' + _deleted: true, + _type: 'io.cozy.files', + _id: 'mock-deleted', + dir_id: 'parent', + id: 'mock-deleted', + name: 'mock-deleted', + path: '/Parent/mock-deleted', + type: 'file' }, - mutationType: 'CREATE_DOCUMENT' + mutationType: 'DELETE_DOCUMENT' }, mutationId: '1', response: { data: { - _id: 'mock-created', - id: 'mock-created', - _type: 'io.cozy.oauth.clients', - client_kind: 'desktop', - client_name: 'Cozy Drive (hostname)' + _deleted: true, + _type: 'io.cozy.files', + _id: 'mock-deleted', + dir_id: 'parent', + id: 'mock-deleted', + name: 'mock-deleted', + path: '/Parent/mock-deleted', + type: 'file' } }, type: 'RECEIVE_MUTATION_RESULT' }) + }) + + unmount() + expect(client.plugins.realtime.unsubscribe).toHaveBeenCalledTimes(3) + }) + + it('should handle realtime events for other doctypes than io.cozy.files', async () => { + const { client, realtimeCallbacks, unmount } = await setup( + 'io.cozy.oauth.clients' ) + realtimeCallbacks['created']({ + _id: 'mock-created', + client_kind: 'desktop', + client_name: 'Cozy Drive (hostname)' + }) + await waitFor(() => { + expect(client.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + definition: { + document: { + _id: 'mock-created', + id: 'mock-created', + _type: 'io.cozy.oauth.clients', + client_kind: 'desktop', + client_name: 'Cozy Drive (hostname)' + }, + mutationType: 'CREATE_DOCUMENT' + }, + mutationId: '1', + response: { + data: { + _id: 'mock-created', + id: 'mock-created', + _type: 'io.cozy.oauth.clients', + client_kind: 'desktop', + client_name: 'Cozy Drive (hostname)' + } + }, + type: 'RECEIVE_MUTATION_RESULT' + }) + ) + }) + unmount() }) }) diff --git a/packages/cozy-client/src/helpers/realtime.js b/packages/cozy-client/src/helpers/realtime.js new file mode 100644 index 0000000000..672f1bac00 --- /dev/null +++ b/packages/cozy-client/src/helpers/realtime.js @@ -0,0 +1,39 @@ +import { Q } from '../queries/dsl' +import CozyClient from '../CozyClient' + +const buildFileByIdQuery = id => ({ + definition: () => Q('io.cozy.files').getById(id), + options: { + as: `io.cozy.files/${id}`, + singleDocData: true, + fetchPolicy: CozyClient.fetchPolicies.olderThan(30 * 1000) + } +}) + +/** + * Ensures existence of `path` inside the io.cozy.files document + * + * @public + * @param {import("../types").IOCozyFile} couchDBDoc - object representing the document + * @param {object} options Options + * @param {string} [options.doctype] - Doctype of the document + * @param {CozyClient} [options.client] - CozyClient instance + * + * @returns {Promise} full normalized document + */ +export const ensureFilePath = async (couchDBDoc, options = {}) => { + if (couchDBDoc.path) return couchDBDoc + + const parentQuery = buildFileByIdQuery(couchDBDoc.dir_id) + const parentResult = await options.client.fetchQueryAndGetFromState({ + definition: parentQuery.definition(), + options: parentQuery.options + }) + + if (!parentResult.data || !parentResult.data.path) + throw new Error( + `Could not define a file path for ${couchDBDoc._id || couchDBDoc.id}` + ) + const path = parentResult.data.path + '/' + couchDBDoc.name + return { path, ...couchDBDoc } +} diff --git a/packages/cozy-client/src/store/realtime.js b/packages/cozy-client/src/store/realtime.js index 20616233b1..ff219cacbe 100644 --- a/packages/cozy-client/src/store/realtime.js +++ b/packages/cozy-client/src/store/realtime.js @@ -8,6 +8,7 @@ import { receiveMutationResult } from './mutations' * * @public * @param {import("../types").CouchDBDocument} couchDBDoc - object representing the document + * @param {string} doctype - Doctype of the document * @returns {import("../types").CozyClientDocument} full normalized document */ const normalizeDoc = (couchDBDoc, doctype) => { @@ -18,24 +19,35 @@ const normalizeDoc = (couchDBDoc, doctype) => { } } +/** + * Enhances a document with additional attributes + * + * @async + * @param {import("../types").CozyClientDocument} doc - The document to enhance + * @param {Object} options - Options for enhancing the document + * @param {Function} [options.enhanceDocFn] - Function to enhance document attributes + * @param {object} options.client - CozyClient instance + * @returns {Promise} Enhanced document + */ +const enhanceDoc = async (doc, options) => { + if (typeof options.enhanceDocFn === 'function') { + return await options.enhanceDocFn(doc, { + client: options.client + }) + } + return doc +} + /** * DispatchChange * - * @param {object} client CozyClient instane - * @param {import("../types").Doctype} doctype Doctype of the document to update - * @param {import("../types").CouchDBDocument} couchDBDoc Document to update + * @param {object} client CozyClient instance + * @param {import("../types").CozyClientDocument} document Document to update * @param {import("../types").Mutation} mutationDefinitionCreator Mutation to apply + * */ -const dispatchChange = ( - client, - doctype, - couchDBDoc, - mutationDefinitionCreator -) => { - const data = normalizeDoc(couchDBDoc, doctype) - const response = { - data - } +const dispatchChange = (client, document, mutationDefinitionCreator) => { + const response = { data: document } const options = {} client.dispatch( @@ -43,20 +55,36 @@ const dispatchChange = ( client.generateRandomId(), response, options, - mutationDefinitionCreator(data) + mutationDefinitionCreator(document) ) ) } +/** + * @typedef {Object} DispatchOptions + * @property {function} [enhanceDocFn] Optional function to enhance the document attributes before dispatch + */ + /** * Dispatches a create action for a document to update CozyClient store from realtime callbacks. * * @param {object} client - CozyClient instance * @param {import("../types").Doctype} doctype - Doctype of the document to create * @param {import("../types").CouchDBDocument} couchDBDoc - Document to create + * @param {DispatchOptions} [options] Options */ -export const dispatchCreate = (client, doctype, couchDBDoc) => { - dispatchChange(client, doctype, couchDBDoc, Mutations.createDocument) +export const dispatchCreate = async ( + client, + doctype, + couchDBDoc, + options = {} +) => { + const normalizedDoc = normalizeDoc(couchDBDoc, doctype) + const enhancedDoc = await enhanceDoc(normalizedDoc, { + client, + enhanceDocFn: options?.enhanceDocFn + }) + dispatchChange(client, enhancedDoc, Mutations.createDocument) } /** @@ -65,9 +93,20 @@ export const dispatchCreate = (client, doctype, couchDBDoc) => { * @param {object} client - CozyClient instance * @param {import("../types").Doctype} doctype - Doctype of the document to create * @param {import("../types").CouchDBDocument} couchDBDoc - Document to create + * @param {DispatchOptions} [options] Options */ -export const dispatchUpdate = (client, doctype, couchDBDoc) => { - dispatchChange(client, doctype, couchDBDoc, Mutations.updateDocument) +export const dispatchUpdate = async ( + client, + doctype, + couchDBDoc, + options = {} +) => { + const normalizedDoc = normalizeDoc(couchDBDoc, doctype) + const enhancedDoc = await enhanceDoc(normalizedDoc, { + client, + enhanceDocFn: options?.enhanceDocFn + }) + dispatchChange(client, enhancedDoc, Mutations.updateDocument) } /** @@ -76,12 +115,18 @@ export const dispatchUpdate = (client, doctype, couchDBDoc) => { * @param {object} client - CozyClient instance * @param {import("../types").Doctype} doctype - Doctype of the document to create * @param {import("../types").CouchDBDocument} couchDBDoc - Document to create + * @param {DispatchOptions} [options] Options */ -export const dispatchDelete = (client, doctype, couchDBDoc) => { - dispatchChange( +export const dispatchDelete = async ( + client, + doctype, + couchDBDoc, + options = {} +) => { + const normalizedDoc = normalizeDoc({ ...couchDBDoc, _deleted: true }, doctype) + const enhancedDoc = await enhanceDoc(normalizedDoc, { client, - doctype, - { ...couchDBDoc, _deleted: true }, - Mutations.deleteDocument - ) + enhanceDocFn: options?.enhanceDocFn + }) + dispatchChange(client, enhancedDoc, Mutations.deleteDocument) } diff --git a/packages/cozy-client/types/helpers/realtime.d.ts b/packages/cozy-client/types/helpers/realtime.d.ts new file mode 100644 index 0000000000..805d70af7b --- /dev/null +++ b/packages/cozy-client/types/helpers/realtime.d.ts @@ -0,0 +1,5 @@ +export function ensureFilePath(couchDBDoc: import("../types").IOCozyFile, options?: { + doctype: string; + client: CozyClient; +}): Promise; +import CozyClient from "../CozyClient"; diff --git a/packages/cozy-client/types/store/realtime.d.ts b/packages/cozy-client/types/store/realtime.d.ts index 21a4bd8672..fe6cf5c5b6 100644 --- a/packages/cozy-client/types/store/realtime.d.ts +++ b/packages/cozy-client/types/store/realtime.d.ts @@ -1,3 +1,9 @@ -export function dispatchCreate(client: object, doctype: import("../types").Doctype, couchDBDoc: import("../types").CouchDBDocument): void; -export function dispatchUpdate(client: object, doctype: import("../types").Doctype, couchDBDoc: import("../types").CouchDBDocument): void; -export function dispatchDelete(client: object, doctype: import("../types").Doctype, couchDBDoc: import("../types").CouchDBDocument): void; +export function dispatchCreate(client: object, doctype: import("../types").Doctype, couchDBDoc: import("../types").CouchDBDocument, options?: DispatchOptions): Promise; +export function dispatchUpdate(client: object, doctype: import("../types").Doctype, couchDBDoc: import("../types").CouchDBDocument, options?: DispatchOptions): Promise; +export function dispatchDelete(client: object, doctype: import("../types").Doctype, couchDBDoc: import("../types").CouchDBDocument, options?: DispatchOptions): Promise; +export type DispatchOptions = { + /** + * Optional function to enhance the document attributes before dispatch + */ + enhanceDocFn?: Function; +};