From 3884f2857646f742a0c96f44e2f037cb1a67f82e Mon Sep 17 00:00:00 2001 From: Ben Merckx Date: Tue, 14 Nov 2023 15:38:29 +0100 Subject: [PATCH] Re-enable live previews --- src/backend/Handler.ts | 125 ++++++++++++++++-------- src/backend/resolver/EntryResolver.ts | 31 +++--- src/cli/Serve.ts | 3 +- src/cloud/server/CloudDebugHandler.ts | 5 +- src/cloud/server/CloudHandler.ts | 3 +- src/core/driver/NextDriver.server.tsx | 14 +-- src/core/driver/TestDriver.ts | 3 +- src/dashboard/atoms/Edits.ts | 3 + src/dashboard/atoms/EntryEditorAtoms.ts | 22 ++--- 9 files changed, 117 insertions(+), 92 deletions(-) diff --git a/src/backend/Handler.ts b/src/backend/Handler.ts index 93dbed993..dfd17a215 100644 --- a/src/backend/Handler.ts +++ b/src/backend/Handler.ts @@ -4,17 +4,23 @@ import { Config, Connection, Draft, + Entry, EntryPhase, + EntryRow, + PreviewUpdate, ResolveDefaults, - SyncResponse + SyncResponse, + parseYDoc } from 'alinea/core' import {EntryRecord} from 'alinea/core/EntryRecord' import {EditMutation, Mutation, MutationType} from 'alinea/core/Mutation' import {Realm} from 'alinea/core/pages/Realm' import {Selection} from 'alinea/core/pages/Selection' -import {base64} from 'alinea/core/util/Encoding' +import {base64, base64url} from 'alinea/core/util/Encoding' import {Logger, LoggerResult, Report} from 'alinea/core/util/Logger' +import * as Y from 'alinea/yjs' import {Type, enums, object, string} from 'cito' +import {unzlibSync} from 'fflate' import {mergeUpdatesV2} from 'yjs' import {Database} from './Database.js' import {DraftTransport, Drafts} from './Drafts.js' @@ -31,6 +37,7 @@ export interface HandlerOptions { config: Config db: Database previews: Previews + previewAuthToken: string auth?: Auth.Server target?: Target media?: Media @@ -43,46 +50,88 @@ export interface HandlerOptions { export class Handler { connect: (ctx: Connection.Context) => Connection router: Route + resolver: EntryResolver + changes: ChangeSetCreator + lastSync = 0 - constructor(private options: HandlerOptions) { - const context = { - resolver: new EntryResolver(options.db, options.config.schema), - changes: new ChangeSetCreator(options.config), - ...this.options - } + constructor(public options: HandlerOptions) { + this.resolver = new EntryResolver( + options.db, + options.config.schema, + this.parsePreview.bind(this) + ) + this.changes = new ChangeSetCreator(options.config) const auth = options.auth || Auth.anonymous() - this.connect = ctx => new HandlerConnection(context, ctx) + this.connect = ctx => new HandlerConnection(this, ctx) this.router = createRouter(auth, this.connect) } -} -interface HandlerContext extends HandlerOptions { - db: Database - resolver: EntryResolver - changes: ChangeSetCreator + previewAuth(): Connection.Context { + return { + logger: new Logger('parsePreview'), + token: this.options.previewAuthToken + } + } + + async parsePreview(preview: PreviewUpdate) { + const {config} = this.options + if (Date.now() - this.lastSync > 30_000) await this.syncPending() + const update = unzlibSync(base64url.parse(preview.update)) + const entry = await this.resolver.resolve({ + selection: Selection.create( + Entry({entryId: preview.entryId}).maybeFirst() + ), + realm: Realm.PreferDraft + }) + if (!entry) return + const currentDraft = await this.options.drafts?.getDraft( + preview.entryId, + this.previewAuth() + ) + const apply = currentDraft + ? mergeUpdatesV2([currentDraft.draft, update]) + : update + const type = 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} + } + + async syncPending() { + const {pending, db} = this.options + const meta = await db.meta() + if (!pending) return meta + const toApply = await pending.pendingSince( + meta.commitHash, + this.previewAuth() + ) + this.lastSync = Date.now() + if (!toApply) return meta + await db.applyMutations(toApply.mutations, toApply.toCommitHash) + return db.meta() + } } class HandlerConnection implements Connection { - constructor( - protected handler: HandlerContext, - protected ctx: Connection.Context - ) {} + constructor(protected handler: Handler, protected ctx: Connection.Context) {} // Resolver resolve = (params: Connection.ResolveParams) => { - const {resolveDefaults} = this.handler + const {resolveDefaults} = this.handler.options return this.handler.resolver.resolve({...resolveDefaults, ...params}) } // Target async mutate(mutations: Array): Promise<{commitHash: string}> { - const {target, media, changes, db} = this.handler + const {target, media, db} = this.handler.options if (!target) throw new Error('Target not available') if (!media) throw new Error('Media not available') - const changeSet = changes.create(mutations) - const {commitHash: fromCommitHash} = await this.syncPending() + const changeSet = this.handler.changes.create(mutations) + const {commitHash: fromCommitHash} = await this.handler.syncPending() const {commitHash: toCommitHash} = await target.mutate( {commitHash: fromCommitHash, mutations: changeSet}, this.ctx @@ -109,7 +158,7 @@ class HandlerConnection implements Connection { } previewToken(): Promise { - const {previews} = this.handler + const {previews} = this.handler.options const user = this.ctx.user if (!user) return previews.sign({anonymous: true}) return previews.sign({sub: user.sub}) @@ -118,7 +167,7 @@ class HandlerConnection implements Connection { // Media prepareUpload(file: string): Promise { - const {media} = this.handler + const {media} = this.handler.options if (!media) throw new Error('Media not available') return media.prepareUpload(file, this.ctx) } @@ -126,45 +175,35 @@ class HandlerConnection implements Connection { // History async revisions(file: string): Promise> { - const {history} = this.handler + const {history} = this.handler.options if (!history) return [] return history.revisions(file, this.ctx) } async revisionData(file: string, revisionId: string): Promise { - const {history} = this.handler + const {history} = this.handler.options if (!history) throw new Error('History not available') return history.revisionData(file, revisionId, this.ctx) } // Syncable - private async syncPending() { - const {pending, db} = this.handler - const meta = await db.meta() - if (!pending) return meta - const toApply = await pending.pendingSince(meta.commitHash, this.ctx) - if (!toApply) return meta - await db.applyMutations(toApply.mutations, toApply.toCommitHash) - return db.meta() - } - async syncRequired(contentHash: string): Promise { - const {db} = this.handler - await this.syncPending() + const {db} = this.handler.options + await this.handler.syncPending() return db.syncRequired(contentHash) } async sync(contentHashes: Array): Promise { - const {db} = this.handler - await this.syncPending() + const {db} = this.handler.options + await this.handler.syncPending() return db.sync(contentHashes) } // Drafts private async persistEdit(mutation: EditMutation) { - const {drafts} = this.handler + const {drafts} = this.handler.options if (!drafts || !mutation.update) return const update = base64.parse(mutation.update) const currentDraft = await this.getDraft(mutation.entryId) @@ -178,13 +217,13 @@ class HandlerConnection implements Connection { } getDraft(entryId: string): Promise { - const {drafts} = this.handler + const {drafts} = this.handler.options if (!drafts) throw new Error('Drafts not available') return drafts.getDraft(entryId, this.ctx) } storeDraft(draft: Draft): Promise { - const {drafts} = this.handler + const {drafts} = this.handler.options if (!drafts) throw new Error('Drafts not available') return drafts.storeDraft(draft, this.ctx) } diff --git a/src/backend/resolver/EntryResolver.ts b/src/backend/resolver/EntryResolver.ts index 52be2e108..d8be11a56 100644 --- a/src/backend/resolver/EntryResolver.ts +++ b/src/backend/resolver/EntryResolver.ts @@ -1,19 +1,15 @@ import { Connection, Field, + PreviewUpdate, ResolveDefaults, Schema, Type, - createYDoc, - parseYDoc, unreachable } from 'alinea/core' import {EntrySearch} from 'alinea/core/EntrySearch' import {Realm} from 'alinea/core/pages/Realm' -import {base64url} from 'alinea/core/util/Encoding' import {entries, fromEntries, keys} from 'alinea/core/util/Objects' -import * as Y from 'alinea/yjs' -import {unzlibSync} from 'fflate' import { BinOpType, Expr, @@ -169,6 +165,9 @@ export class EntryResolver { constructor( public db: Database, public schema: Schema, + public parsePreview?: ( + preview: PreviewUpdate + ) => Promise, public defaults?: ResolveDefaults ) { this.targets = Schema.targets(schema) @@ -702,25 +701,17 @@ export class EntryResolver { const queryData = this.query(ctx, selection) const query = new Query(queryData) if (preview) { - const current = EntryRow({ - entryId: preview.entryId, - active: true - }) - const entry = await this.db.store(current.maybeFirst()) - if (entry) + const updated = await this.parsePreview?.(preview) + if (updated) try { - // Create yjs doc - const type = this.schema[entry.type] - const yDoc = createYDoc(type, entry) - // Apply update - const update = unzlibSync(base64url.parse(preview.update)) - Y.applyUpdateV2(yDoc, update) - const entryData = parseYDoc(type, yDoc) - const previewEntry = {...entry, ...entryData} await this.db.store.transaction(async tx => { + const current = EntryRow({ + entryId: preview.entryId, + active: true + }) // Temporarily add preview entry await tx(current.delete()) - await tx(EntryRow().insert(previewEntry)) + await tx(EntryRow().insert(updated)) await Database.index(tx) const result = await tx(query) const linkResolver = new LinkResolver(this, tx, ctx.realm) diff --git a/src/cli/Serve.ts b/src/cli/Serve.ts index 134e6c7ea..99e93cb34 100644 --- a/src/cli/Serve.ts +++ b/src/cli/Serve.ts @@ -108,7 +108,8 @@ export async function serve(options: ServeOptions): Promise { media: fileData, drafts, history: new GitHistory(currentCMS, rootDir), - previews: new JWTPreviews('dev') + previews: new JWTPreviews('dev'), + previewAuthToken: 'dev' }) } } diff --git a/src/cloud/server/CloudDebugHandler.ts b/src/cloud/server/CloudDebugHandler.ts index dba713e4c..7f9c8aafb 100644 --- a/src/cloud/server/CloudDebugHandler.ts +++ b/src/cloud/server/CloudDebugHandler.ts @@ -6,7 +6,7 @@ import {Config, Connection, Draft, createId} from 'alinea/core' import {EntryRecord} from 'alinea/core/EntryRecord' import {Mutation} from 'alinea/core/Mutation' -const latency = 2000 +const latency = 0 const lag = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) @@ -93,6 +93,7 @@ export function createCloudDebugHandler(config: Config, db: Database) { history: api, drafts: api, pending: api, - previews: new JWTPreviews('dev') + previews: new JWTPreviews('dev'), + previewAuthToken: 'dev' }) } diff --git a/src/cloud/server/CloudHandler.ts b/src/cloud/server/CloudHandler.ts index 0040a9cbb..ab8a3e6e3 100644 --- a/src/cloud/server/CloudHandler.ts +++ b/src/cloud/server/CloudHandler.ts @@ -208,6 +208,7 @@ export function createCloudHandler( history: api, pending: api, drafts: api, - previews: new JWTPreviews(apiKey!) + previews: new JWTPreviews(apiKey!), + previewAuthToken: apiKey! }) } diff --git a/src/core/driver/NextDriver.server.tsx b/src/core/driver/NextDriver.server.tsx index 2c52d0624..9e513928a 100644 --- a/src/core/driver/NextDriver.server.tsx +++ b/src/core/driver/NextDriver.server.tsx @@ -1,5 +1,4 @@ import {JWTPreviews} from 'alinea/backend' -import {EntryResolver} from 'alinea/backend/resolver/EntryResolver' import {createCloudHandler} from 'alinea/cloud/server/CloudHandler' import {parseChunkedCookies} from 'alinea/preview/ChunkCookieValue' import { @@ -59,22 +58,19 @@ class NextDriver extends DefaultDriver implements NextApi { url: devUrl, resolveDefaults }) - const db = await this.db - return new EntryResolver(db, this.config.schema, resolveDefaults) + const handler = await this.cloudHandler + return handler.resolver } backendHandler = async (request: Request) => { const handler = await this.cloudHandler - return handler(request) + const response = await handler.router.handle(request) + return response ?? new Response('Not found', {status: 404}) } cloudHandler = PLazy.from(async () => { const db = await this.db - const handler = createCloudHandler(this, db, this.apiKey) - return async (request: Request) => { - const response = await handler.router.handle(request) - return response ?? new Response('Not found', {status: 404}) - } + return createCloudHandler(this, db, this.apiKey) }) previewHandler = async (request: Request) => { diff --git a/src/core/driver/TestDriver.ts b/src/core/driver/TestDriver.ts index 578616421..6692d5139 100644 --- a/src/core/driver/TestDriver.ts +++ b/src/core/driver/TestDriver.ts @@ -26,7 +26,8 @@ class TestDriver extends DefaultDriver implements TestApi { const handler = new Handler({ config: this, db, - previews: new JWTPreviews('test') + previews: new JWTPreviews('test'), + previewAuthToken: 'test' }) return handler.connect({logger: new Logger('test')}) }) diff --git a/src/dashboard/atoms/Edits.ts b/src/dashboard/atoms/Edits.ts index 2bcf34f3e..91951d8c4 100644 --- a/src/dashboard/atoms/Edits.ts +++ b/src/dashboard/atoms/Edits.ts @@ -24,6 +24,9 @@ export class Edits { isLoading = yAtom(this.root, () => { return !this.hasData() }) + yUpdate = yAtom(this.root, () => { + return this.getLocalUpdate() + }) constructor(private entryId: string) {} diff --git a/src/dashboard/atoms/EntryEditorAtoms.ts b/src/dashboard/atoms/EntryEditorAtoms.ts index ee0c0e121..fa694f742 100644 --- a/src/dashboard/atoms/EntryEditorAtoms.ts +++ b/src/dashboard/atoms/EntryEditorAtoms.ts @@ -264,6 +264,9 @@ export function createEntryEditor(entryData: EntryData) { const currentChanges = get(hasChanges) return options .action() + .then(() => { + if (options.clearChanges) set(hasChanges, false) + }) .catch(error => { if (options.clearChanges) set(hasChanges, currentChanges) set(errorAtom, options.errorMessage, error) @@ -552,16 +555,11 @@ export function createEntryEditor(entryData: EntryData) { const selectedState = atom(get => { const selected = get(selectedPhase) - const isLoading = get(edits.isLoading) - if (selected === activePhase && !isLoading) return edits.doc + if (selected === activePhase) return edits.doc return docs[selected] }) - const draftTitle = yAtom(edits.root, () => edits.root.get('title') as string) - const activeTitle = atom(get => { - const isLoading = get(edits.isLoading) - if (isLoading) return activeVersion.title - return get(draftTitle) - }) + const activeTitle = yAtom(edits.root, () => edits.root.get('title') as string) + const revisionState = atom(get => { const revision = get(previewRevision) return revision ? get(revisionDocState(revision)) : undefined @@ -582,13 +580,7 @@ export function createEntryEditor(entryData: EntryData) { // The debounce here prevents React warning us about a state change during // render for rich text fields. Some day that should be properly fixed. - const yUpdate = debounceAtom( - atom(get => { - get(currentDoc) - return edits.getLocalUpdate() - }), - 10 - ) + const yUpdate = debounceAtom(edits.yUpdate, 10) const discardEdits = edits.resetChanges const isLoading = edits.isLoading