From 7f9580431587cb30b48f086858d5d6de1a01cdfb Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Thu, 26 Sep 2024 17:54:11 +0200 Subject: [PATCH] feat(core): use indexedDB POC for editState --- .../document/document-pair/editState.ts | 81 +++++--- .../document-pair/utils/indexedDbPOC.ts | 194 ++++++++++++++++++ 2 files changed, 247 insertions(+), 28 deletions(-) create mode 100644 packages/sanity/src/core/store/_legacy/document/document-pair/utils/indexedDbPOC.ts diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts index d2fc4c7d246a..430ac8632747 100644 --- a/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/editState.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import {type SanityClient} from '@sanity/client' import {type SanityDocument, type Schema} from '@sanity/types' import {combineLatest, defer, merge, type Observable, of} from 'rxjs' @@ -7,8 +8,8 @@ import {type IdPair, type PendingMutationsEvent} from '../types' import {memoize} from '../utils/createMemoizer' import {memoizeKeyGen} from './memoizeKeyGen' import {snapshotPair} from './snapshotPair' +import {getPairFromIndexedDB, savePairToIndexedDB} from './utils/indexedDbPOC' import {isLiveEditEnabled} from './utils/isLiveEditEnabled' -import {getPairFromLocalStorage, savePairToLocalStorage} from './utils/localStoragePOC' interface TransactionSyncLockState { enabled: boolean @@ -57,42 +58,62 @@ export const editState = memoize( } | null = null function getCachedPair() { - // try first read it from memory - // if we haven't got it in memory, see if it's in localstorage + // read the memoized value, if we don't have we will search it in the indexedDB if (cachedDocumentPair) { return cachedDocumentPair } - return getPairFromLocalStorage(idPair) + return null } - return snapshotPair(ctx.client, idPair, typeName, ctx.serverActionsEnabled).pipe( - switchMap((versions) => - combineLatest([ - versions.draft.snapshots$, - versions.published.snapshots$, - versions.transactionsPendingEvents$.pipe( - // eslint-disable-next-line max-nested-callbacks - map((ev: PendingMutationsEvent) => (ev.phase === 'begin' ? LOCKED : NOT_LOCKED)), - startWith(NOT_LOCKED), + return defer(() => { + const cachedPair = getCachedPair() + if (cachedPair) { + console.log('using cachedPair, no need to check the indexedDB') + return of(cachedPair) + } + return getPairFromIndexedDB(idPair) + }).pipe( + switchMap((initialPair) => { + console.log('cached pair', initialPair) + return snapshotPair(ctx.client, idPair, typeName, ctx.serverActionsEnabled).pipe( + switchMap((versions) => + combineLatest([ + versions.draft.snapshots$, + versions.published.snapshots$, + versions.transactionsPendingEvents$.pipe( + // eslint-disable-next-line max-nested-callbacks + map((ev: PendingMutationsEvent) => (ev.phase === 'begin' ? LOCKED : NOT_LOCKED)), + startWith(NOT_LOCKED), + ), + ]), ), - ]), - ), - tap(([draft, published]) => { - cachedDocumentPair = {draft, published} + tap(([draft, published]) => { + cachedDocumentPair = {draft, published} + }), + map(([draftSnapshot, publishedSnapshot, transactionSyncLock]) => ({ + id: idPair.publishedId, + type: typeName, + draft: draftSnapshot, + published: publishedSnapshot, + liveEdit, + ready: true, + transactionSyncLock, + })), + startWith({ + id: idPair.publishedId, + type: typeName, + draft: initialPair.draft, + published: initialPair.published, + liveEdit, + ready: false, + transactionSyncLock: null, + }), + ) }), - map(([draftSnapshot, publishedSnapshot, transactionSyncLock]) => ({ - id: idPair.publishedId, - type: typeName, - draft: draftSnapshot, - published: publishedSnapshot, - liveEdit, - ready: true, - transactionSyncLock, - })), - // todo: turn this into a proper operator function - It's like startWith only that it takes a function that will be invoked upon subscription (input$) => { return defer(() => { const cachedPair = getCachedPair() + console.log('creating initial value for editState observable, cachedPair:', cachedPair) return merge( cachedPair ? of({ @@ -110,7 +131,11 @@ export const editState = memoize( }) }, finalize(() => { - savePairToLocalStorage(cachedDocumentPair) + console.log( + 'Closing subscription for: ', + cachedDocumentPair?.published?._id || cachedDocumentPair?.draft?._id, + ) + savePairToIndexedDB(cachedDocumentPair) }), publishReplay(1), refCount(), diff --git a/packages/sanity/src/core/store/_legacy/document/document-pair/utils/indexedDbPOC.ts b/packages/sanity/src/core/store/_legacy/document/document-pair/utils/indexedDbPOC.ts new file mode 100644 index 000000000000..1a9fb84517d2 --- /dev/null +++ b/packages/sanity/src/core/store/_legacy/document/document-pair/utils/indexedDbPOC.ts @@ -0,0 +1,194 @@ +/* eslint-disable no-console */ +import {isSanityDocument, type SanityDocument} from '@sanity/types' + +import {type IdPair} from '../../types' + +const DB_NAME = 'sanityDocumentsDB' +const DB_VERSION = 1 +const STORE_NAME = 'documents' + +let idb: IDBDatabase | null = null + +function openDatabase() { + if (idb) { + return Promise.resolve(idb) + } + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, {keyPath: '_id'}) + } + } + + request.onsuccess = () => { + idb = request.result + resolve(request.result) + } + + request.onerror = () => { + reject(request.error) + } + }) +} + +async function getDocumentFromIndexedDB(id: string): Promise { + try { + const db = await openDatabase() + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.get(id) + + request.onsuccess = () => { + const result = request.result + resolve(isSanityDocument(result) ? result : null) + } + + transaction.onerror = () => { + console.error(`Error retrieving document with ID ${id} from IndexedDB:`, request.error) + reject(transaction.error) + } + }) + } catch (error) { + console.error(`Error opening IndexedDB:`, error) + return null + } +} + +async function saveDocumentToIndexedDB(document: SanityDocument): Promise { + const db = await openDatabase() + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const request = store.put(document) + + request.onsuccess = () => { + resolve() + } + + transaction.onerror = () => { + console.error(`Error saving document with ID ${document._id} to IndexedDB:`, request.error) + reject(transaction.error) + } + }) +} + +interface DocumentPair { + draft: SanityDocument | null + published: SanityDocument | null +} + +/** + * returns the pair in one transaction + */ +async function getDocumentPairIndexedDB(idPair: IdPair): Promise { + try { + const db = await openDatabase() + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + + let draft: SanityDocument | null = null + let published: SanityDocument | null = null + + transaction.oncomplete = () => { + resolve({draft, published}) + } + + // Handle transaction errors + transaction.onerror = () => { + console.error('Transaction error:', transaction.error) + reject(transaction.error) + } + + // Initiate the get request for the draft document + const draftRequest = store.get(idPair.draftId) + draftRequest.onsuccess = () => { + const result = draftRequest.result + draft = isSanityDocument(result) ? result : null + } + // Initiate the get request for the published document + const publishedRequest = store.get(idPair.publishedId) + publishedRequest.onsuccess = () => { + const result = publishedRequest.result + published = isSanityDocument(result) ? result : null + } + }) + } catch (error) { + console.error(`Error opening IndexedDB:`, error) + return null + } +} + +async function saveDocumentPairIndexedDB(documentPair: DocumentPair): Promise { + try { + const db = await openDatabase() + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + + transaction.oncomplete = () => { + resolve() + } + + transaction.onerror = () => { + console.error('Transaction error:', transaction.error) + reject(transaction.error) + } + + // Save the draft document if it exists + if (documentPair.draft) { + store.put(documentPair.draft) + } + + // Save the published document if it exists + if (documentPair.published) { + store.put(documentPair.published) + } + }) + } catch (error) { + console.error(`Error opening IndexedDB:`, error) + // Optionally, rethrow the error or handle it + throw error + } +} + +export const supportsIndexedDB = (() => { + try { + return 'indexedDB' in window && window.indexedDB !== null + } catch (e) { + return false + } +})() + +export async function getPairFromIndexedDB(idPair: IdPair): Promise { + console.log('Getting idbPair', idPair) + if (!supportsIndexedDB) { + console.info("IndexedDB isn't supported, returning null") + return { + draft: null, + published: null, + } + } + console.time('getPairFromIndexedDB') + const pair = await getDocumentPairIndexedDB(idPair) + console.timeEnd('getPairFromIndexedDB') + if (!pair) { + return { + draft: null, + published: null, + } + } + return pair +} + +export async function savePairToIndexedDB(documentPair: DocumentPair | null) { + console.log('Saving pair to indexedDB', documentPair?.published?._id || documentPair?.draft?._id) + if (!supportsIndexedDB || !documentPair) return + console.time('savePairToIndexedDB') + await saveDocumentPairIndexedDB(documentPair) + console.timeEnd('savePairToIndexedDB') +}