From af2425453a1c4776eeb5aab6f1ce06e7e6099f09 Mon Sep 17 00:00:00 2001 From: Ben Merckx Date: Tue, 8 Oct 2024 11:50:52 +0200 Subject: [PATCH] Parse previews for local clients --- src/adapter/next/cms.tsx | 155 ++++++++------------------- src/backend/Database.ts | 4 + src/backend/Handler.ts | 70 ++---------- src/backend/resolver/ParsePreview.ts | 67 ++++++++++++ src/core/Client.ts | 6 +- src/core/Resolver.ts | 4 +- src/dashboard/atoms/DbAtoms.ts | 3 +- 7 files changed, 130 insertions(+), 179 deletions(-) create mode 100644 src/backend/resolver/ParsePreview.ts diff --git a/src/adapter/next/cms.tsx b/src/adapter/next/cms.tsx index 74c88bd2c..da944a3ab 100644 --- a/src/adapter/next/cms.tsx +++ b/src/adapter/next/cms.tsx @@ -1,86 +1,18 @@ import {Headers} from '@alinea/iso' import {Database} from 'alinea/backend/Database' -import {EntryResolver} from 'alinea/backend/resolver/EntryResolver' +import {createPreviewParser} from 'alinea/backend/resolver/ParsePreview' import {generatedStore} from 'alinea/backend/store/GeneratedStore' -import {AuthenticateRequest, Client, ClientOptions} from 'alinea/core/Client' +import {Client} from 'alinea/core/Client' import {CMS} from 'alinea/core/CMS' import {Config} from 'alinea/core/Config' -import {EntryRow} from 'alinea/core/EntryRow' import {outcome} from 'alinea/core/Outcome' -import { - PreviewPayload, - ResolveDefaults, - ResolveParams -} from 'alinea/core/Resolver' +import {PreviewRequest, ResolveParams} from 'alinea/core/Resolver' import {User} from 'alinea/core/User' +import {assign} from 'alinea/core/util/Objects' import {getPreviewPayloadFromCookies} from 'alinea/preview/PreviewCookies' -import {decodePreviewPayload} from 'alinea/preview/PreviewPayload' -import * as Y from 'yjs' +import PLazy from 'p-lazy' import {devUrl, requestContext} from './context.js' -class NextClient extends Client { - resolver: EntryResolver - - constructor(private db: Database, options: ClientOptions) { - super(options) - this.resolver = new EntryResolver(db, db.config.schema) - } - - async resolve(params: ResolveParams): Promise { - const {PHASE_PRODUCTION_BUILD} = await import('next/constants.js') - const isBuild = process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD - if (!params.preview && !isBuild) { - const syncInterval = params.syncInterval ?? 60 - const now = Date.now() - if (now - lastSync >= syncInterval * 1000) { - lastSync = now - const db = await database - await db.syncWith(client).catch(() => {}) - } - } - return this.resolver.resolve({...resolveDefaults, ...params}) - } - - async parsePreview(preview: PreviewPayload): Promise { - const update = await decodePreviewPayload(preview.payload) - let meta = await db.meta() - if (update.contentHash !== meta.contentHash) { - await syncPending(ctx) - meta = await db.meta() - } - const entry = await resolver.resolve({ - selection: createSelection(Entry({entryId: update.entryId}).maybeFirst()), - realm: Realm.PreferDraft - }) - if (!entry) return - const cachedDraft = await drafts.get(update.entryId) - let currentDraft: Draft | undefined - if (cachedDraft?.contentHash === meta.contentHash) { - currentDraft = cachedDraft.draft - } else { - try { - const pending = backend.drafts.get(ctx, update.entryId) - drafts.set( - update.entryId, - pending.then(draft => ({contentHash: meta.contentHash, draft})) - ) - currentDraft = await pending - } catch (error) { - console.warn('> could not fetch draft', error) - } - } - const apply = currentDraft - ? mergeUpdatesV2([currentDraft.draft, update.update]) - : update.update - const type = cms.config.schema[entry.type] - if (!type) return - const doc = new Y.Doc() - Y.applyUpdateV2(doc, apply) - const entryData = parseYDoc(type, doc) - return {...entry, ...entryData, path: entry.path} - } -} - export interface PreviewProps { widget?: boolean workspace?: string @@ -92,54 +24,57 @@ export class NextCMS< > extends CMS { constructor(config: Definition, public baseUrl?: string) { let lastSync = 0 - const database = generatedStore.then( - store => new Database(this.config, store) - ) - const dbResolver = database.then( - db => new EntryResolver(db, this.config.schema) + const database = PLazy.from(() => + generatedStore.then(store => new Database(this.config, store)) ) + const previewParser = PLazy.from(() => database.then(createPreviewParser)) super(config, async () => { const context = await requestContext(config) - const resolveDefaults: ResolveDefaults = {} - const {PHASE_PRODUCTION_BUILD} = await import('next/constants.js') - const isBuild = process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD - const {cookies, draftMode} = await import('next/headers.js') - const [isDraft] = outcome(() => draftMode().isEnabled) - const applyAuth: AuthenticateRequest = init => { - const headers = new Headers(init?.headers) - headers.set('Authorization', `Bearer ${context.apiKey}`) - return {...init, headers} - } - const url = context.handlerUrl.href - if (isDraft) { - const cookie = cookies() - const payload = getPreviewPayloadFromCookies(cookie.getAll()) - const update = payload && (await decodePreviewPayload(payload)) - if (payload) resolveDefaults.preview = {payload} - } - if (devUrl()) return new Client({url, applyAuth, resolveDefaults}) - const client = new NextClient( - { - async resolve(params) { - const resolver = await dbResolver - if (!resolveDefaults.preview && !isBuild) { + const client = new Client({ + url: context.handlerUrl.href, + applyAuth(init) { + const headers = new Headers(init?.headers) + headers.set('Authorization', `Bearer ${context.apiKey}`) + return {...init, headers} + } + }) + const clientResolve = client.resolve.bind(client) + const sync = () => database.then(db => db.syncWith(client)) + return assign(client, { + async resolve(params: ResolveParams) { + const isDev = Boolean(devUrl()) + let preview: PreviewRequest | undefined + const {cookies, draftMode} = await import('next/headers.js') + const [isDraft] = outcome(() => draftMode().isEnabled) + if (isDraft) { + const cookie = cookies() + const payload = getPreviewPayloadFromCookies(cookie.getAll()) + if (payload) preview = {payload} + } + if (isDev) return clientResolve({preview, ...params}) + const {PHASE_PRODUCTION_BUILD} = await import('next/constants.js') + const isBuild = process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD + const db = await database + if (!isBuild) { + if (preview) { + const previews = await previewParser + preview = await previews.parse( + preview, + sync, + client.getDraft.bind(client) + ) + } else { const syncInterval = params.syncInterval ?? 60 const now = Date.now() if (now - lastSync >= syncInterval * 1000) { lastSync = now - const db = await database - await db.syncWith(client).catch(() => {}) + await sync() } } - return resolver.resolve({...resolveDefaults, ...params}) } - }, - { - url, - applyAuth + return db.resolver.resolve({preview, ...params}) } - ) - return client + }) }) } diff --git a/src/backend/Database.ts b/src/backend/Database.ts index 0202a0c51..c655cf280 100644 --- a/src/backend/Database.ts +++ b/src/backend/Database.ts @@ -4,6 +4,7 @@ import {EntryRecord, createRecord, parseRecord} from 'alinea/core/EntryRecord' import {createId} from 'alinea/core/Id' import {Mutation, MutationType} from 'alinea/core/Mutation' import {PageSeed} from 'alinea/core/Page' +import {Resolver} from 'alinea/core/Resolver' import {Root} from 'alinea/core/Root' import {Schema} from 'alinea/core/Schema' import {EntryUrlMeta, Type} from 'alinea/core/Type' @@ -40,6 +41,7 @@ import {Change, ChangeType} from './data/ChangeSet.js' import {AlineaMeta} from './db/AlineaMeta.js' import {createEntrySearch} from './db/CreateEntrySearch.js' import {JsonLoader} from './loader/JsonLoader.js' +import {EntryResolver} from './resolver/EntryResolver.js' interface Seed { type: string @@ -55,9 +57,11 @@ function seedKey(workspace: string, root: string, filePath: string) { export class Database implements Syncable { seed: Map + resolver: Resolver constructor(public config: Config, public store: Store) { this.seed = this.seedData() + this.resolver = new EntryResolver(this, this.config.schema) } async syncRequired(contentHash: string): Promise { diff --git a/src/backend/Handler.ts b/src/backend/Handler.ts index fe774df77..387725c32 100644 --- a/src/backend/Handler.ts +++ b/src/backend/Handler.ts @@ -3,29 +3,22 @@ import {JWTPreviews} from 'alinea/backend/util/JWTPreviews' import {cloudBackend} from 'alinea/cloud/CloudBackend' import {CMS} from 'alinea/core/CMS' import {Connection} from 'alinea/core/Connection' -import {parseYDoc} from 'alinea/core/Doc' import {Draft} from 'alinea/core/Draft' -import {Entry} from 'alinea/core/Entry' -import {EntryRow} from 'alinea/core/EntryRow' import {Graph} from 'alinea/core/Graph' import {EditMutation, Mutation, MutationType} from 'alinea/core/Mutation' import { - PreviewPayload, PreviewUpdate, ResolveParams, ResolveRequest } from 'alinea/core/Resolver' -import {createSelection} from 'alinea/core/pages/CreateSelection' import {Realm} from 'alinea/core/pages/Realm' import {Selection} from 'alinea/core/pages/ResolveData' import {decode} from 'alinea/core/util/BufferToBase64' import {base64} from 'alinea/core/util/Encoding' import {assign} from 'alinea/core/util/Objects' -import {decodePreviewPayload} from 'alinea/preview/PreviewPayload' import {Type, array, enums, object, string} from 'cito' import PLazy from 'p-lazy' import pLimit from 'p-limit' -import * as Y from 'yjs' import {mergeUpdatesV2} from 'yjs' import { AuthedContext, @@ -34,7 +27,7 @@ import { RequestContext } from './Backend.js' import {ChangeSetCreator} from './data/ChangeSet.js' -import {EntryResolver} from './resolver/EntryResolver.js' +import {createPreviewParser} from './resolver/ParsePreview.js' import {generatedStore} from './store/GeneratedStore.js' const limit = pLimit(1) @@ -83,15 +76,12 @@ export function createHandler( ): HandlerWithConnect { const init = PLazy.from(async () => { const db = await database - const resolver = new EntryResolver(db, cms.schema) + const previews = createPreviewParser(db) + const resolver = db.resolver const changes = new ChangeSetCreator( cms.config, new Graph(cms.config, resolver) ) - const drafts = new Map< - string, - Promise<{contentHash: string; draft?: Draft}> - >() let lastSync = 0 return {db, mutate, resolve, periodicSync, syncPending} @@ -101,10 +91,14 @@ export function createHandler( await periodicSync(ctx, params.syncInterval) return resolver.resolve(params as ResolveRequest) } - const entry = params.preview && (await parsePreview(ctx, params.preview)) + const preview = await previews.parse( + params.preview, + () => syncPending(ctx), + entryId => backend.drafts.get(ctx, entryId) + ) return resolver.resolve({ ...params, - preview: entry && {entry} + preview: preview }) } @@ -181,51 +175,7 @@ export function createHandler( } await backend.drafts.store(ctx, draft) const {contentHash} = await db.meta() - drafts.set(mutation.entryId, Promise.resolve({contentHash, draft})) - } - - async function parsePreview( - ctx: RequestContext, - preview: PreviewPayload - ): Promise { - const update = await decodePreviewPayload(preview.payload) - let meta = await db.meta() - if (update.contentHash !== meta.contentHash) { - await syncPending(ctx) - meta = await db.meta() - } - const entry = await resolver.resolve({ - selection: createSelection( - Entry({entryId: update.entryId}).maybeFirst() - ), - realm: Realm.PreferDraft - }) - if (!entry) return - const cachedDraft = await drafts.get(update.entryId) - let currentDraft: Draft | undefined - if (cachedDraft?.contentHash === meta.contentHash) { - currentDraft = cachedDraft.draft - } else { - try { - const pending = backend.drafts.get(ctx, update.entryId) - drafts.set( - update.entryId, - pending.then(draft => ({contentHash: meta.contentHash, draft})) - ) - currentDraft = await pending - } catch (error) { - console.warn('> could not fetch draft', error) - } - } - const apply = currentDraft - ? mergeUpdatesV2([currentDraft.draft, update.update]) - : update.update - const type = cms.config.schema[entry.type] - if (!type) return - const doc = new Y.Doc() - Y.applyUpdateV2(doc, apply) - const entryData = parseYDoc(type, doc) - return {...entry, ...entryData, path: entry.path} + previews.setDraft(mutation.entryId, {contentHash, draft}) } }) diff --git a/src/backend/resolver/ParsePreview.ts b/src/backend/resolver/ParsePreview.ts new file mode 100644 index 000000000..b6d4ce0db --- /dev/null +++ b/src/backend/resolver/ParsePreview.ts @@ -0,0 +1,67 @@ +import {Entry} from 'alinea/core' +import {parseYDoc} from 'alinea/core/Doc' +import {Draft} from 'alinea/core/Draft' +import {EntryRow} from 'alinea/core/EntryRow' +import {createSelection} from 'alinea/core/pages/CreateSelection' +import {Realm} from 'alinea/core/pages/Realm' +import {PreviewRequest} from 'alinea/core/Resolver' +import {decodePreviewPayload} from 'alinea/preview/PreviewPayload' +import * as Y from 'yjs' +import {Database} from '../Database.js' + +export function createPreviewParser(db: Database) { + const drafts = new Map< + string, + Promise<{contentHash: string; draft?: Draft}> + >() + return { + async parse( + preview: PreviewRequest, + sync: () => Promise, + getDraft: (entryId: string) => Promise + ): Promise { + if (!(preview && 'payload' in preview)) return preview + const update = await decodePreviewPayload(preview.payload) + let meta = await db.meta() + if (update.contentHash !== meta.contentHash) { + await sync() + meta = await db.meta() + } + const entry = (await db.resolver.resolve({ + selection: createSelection( + Entry({entryId: update.entryId}).maybeFirst() + ), + realm: Realm.PreferDraft + })) as EntryRow | null + if (!entry) return + const cachedDraft = await drafts.get(update.entryId) + let currentDraft: Draft | undefined + if (cachedDraft?.contentHash === meta.contentHash) { + currentDraft = cachedDraft.draft + } else { + try { + const pending = getDraft(update.entryId) + drafts.set( + update.entryId, + pending.then(draft => ({contentHash: meta.contentHash, draft})) + ) + currentDraft = await pending + } catch (error) { + console.warn('> could not fetch draft', error) + } + } + const apply = currentDraft + ? Y.mergeUpdatesV2([currentDraft.draft, update.update]) + : update.update + const type = db.config.schema[entry.type] + if (!type) return + const doc = new Y.Doc() + Y.applyUpdateV2(doc, apply) + const entryData = parseYDoc(type, doc) + return {entry: {...entry, ...entryData, path: entry.path}} + }, + setDraft(entryId: string, input: {contentHash: string; draft?: Draft}) { + drafts.set(entryId, Promise.resolve(input)) + } + } +} diff --git a/src/core/Client.ts b/src/core/Client.ts index 9b9fcecc6..3361937de 100644 --- a/src/core/Client.ts +++ b/src/core/Client.ts @@ -7,7 +7,7 @@ import {Draft} from './Draft.js' import {EntryRecord} from './EntryRecord.js' import {HttpError} from './HttpError.js' import {Mutation} from './Mutation.js' -import {ResolveDefaults, ResolveParams} from './Resolver.js' +import {ResolveParams} from './Resolver.js' import {User} from './User.js' import {base64} from './util/Encoding.js' @@ -19,7 +19,6 @@ export interface ClientOptions { url: string applyAuth?: AuthenticateRequest unauthorized?: () => void - resolveDefaults?: ResolveDefaults } export class Client implements Connection { @@ -59,8 +58,7 @@ export class Client implements Connection { } resolve(params: ResolveParams): Promise { - const {resolveDefaults} = this.#options - const body = JSON.stringify({...resolveDefaults, ...params}) + const body = JSON.stringify(params) return this.#requestJson( {action: HandleAction.Resolve}, {method: 'POST', body} diff --git a/src/core/Resolver.ts b/src/core/Resolver.ts index d52800e17..fca7ef7c1 100644 --- a/src/core/Resolver.ts +++ b/src/core/Resolver.ts @@ -44,9 +44,7 @@ export interface ResolveRequest { preview?: {entry: EntryRow} | {payload: string} } -export interface ResolveParams extends ResolveRequest { - preview?: {payload: string} -} +export interface ResolveParams extends ResolveRequest {} export type ResolveDefaults = Partial diff --git a/src/dashboard/atoms/DbAtoms.ts b/src/dashboard/atoms/DbAtoms.ts index 98ea6db83..37f86ca68 100644 --- a/src/dashboard/atoms/DbAtoms.ts +++ b/src/dashboard/atoms/DbAtoms.ts @@ -1,5 +1,4 @@ import {Database} from 'alinea/backend/Database' -import {EntryResolver} from 'alinea/backend/resolver/EntryResolver' import {Config} from 'alinea/core/Config' import {Entry} from 'alinea/core/Entry' import {Graph} from 'alinea/core/Graph' @@ -45,7 +44,7 @@ const localDbAtom = atom(async (get, set) => { await clear() db = new Database(config, store) } - const resolver = new EntryResolver(db, config.schema) + const resolver = db.resolver const syncDb = async (force = false) => { const changed = await db.syncWith(client) if (changed.length > 0) await flush()