Files
TREK/client/src/components/Studio/useBookStore.ts
T
Maurice 30382dc366 feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read
- A book carries schemaRev. The reader that meets a newer one opens the book
  read-only with a reload banner and saves nothing, where it used to drop the
  elements it could not read on the next save.
- The server refuses a save, a restore or a tool edit that would write an older
  revision over a newer book or one newer than itself (409 book_schema_newer),
  on REST and through every book tool alike.
- normalizeBookDocument moves to book-normalize.ts.
- Print waits for photos eagerly and up to 15 s for an export of more than 40
  photos, and skips images without a source.
2026-10-23 14:15:01 +02:00

486 lines
21 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import type { BookDocument, BookRecord } from '@trek/shared'
import { BOOK_SCHEMA_NEWER_CODE, readBookDocument, stampBookSchemaRev } from '@trek/shared'
import { journeyApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
/**
* Saving the book.
*
* ── Why autosave rather than a save button ────────────────────────────────
*
* The document lived as long as the tab did, which made every session a race
* against a closed laptop. A save button would fix that and introduce a
* different failure: the one where you did press it, an hour ago, and cannot
* remember. Everyone on a journey can edit the same book, so an explicit save
* would also mean explicit merges.
*
* ── Why debounced rather than on every change ─────────────────────────────
*
* A drag is hundreds of frames and one edit. The store already models that —
* a gesture is one undo step — and this follows the same line: quiet for a
* moment, then write. The wait is short enough that closing the tab loses a
* sentence at most, and long enough that dragging a photograph across a spread
* is one request rather than four hundred.
*
* ── Why a version rather than a lock ─────────────────────────────────────
*
* See book-store.schema.ts. The short version: a save states which version it
* was made against, and one that has been overtaken is refused *with* the
* current record, so the client can say what happened instead of quietly
* discarding someone's afternoon.
*
* ── Why the other editors' saves arrive on their own ──────────────────────
*
* The server broadcasts `journey:book:saved` to everyone else on the journey,
* carrying the new version rather than the document — one integer instead of a
* few hundred kilobytes on every autosave. A client with nothing of its own
* outstanding fetches the new document and takes it: the book is shared, and
* sitting on a stale copy only means finding out at save time.
*
* A client that *does* have unsaved edits deliberately takes nothing. Its next
* save conflicts, and the conflict offers a choice, which is the whole point of
* the version. Quietly replacing someone's open document with somebody else's
* is exactly the failure this design exists to avoid.
*
* ── Why the refetch is coalesced ──────────────────────────────────────────
*
* The assistant can apply many page edits in one step, each a save and each a
* broadcast. Fetching the book once per event would be a burst of identical
* reads racing each other, and an older answer landing last would roll the
* editor back. So one read is open at a time, events that arrive during it ask
* for exactly one more, and a document no newer than the one held is dropped.
* An event that lands while this client's own write is open is not dropped
* either: it is remembered and read once that work has reached the server,
* because the save response and the broadcast travel on different channels and
* either can arrive first.
*
* ── Why a book from a newer Studio is never written ───────────────────────
*
* A document says which revision of the format wrote it (book-schema-rev.ts).
* This build reads a newer one as far as it can, and what it cannot read is
* already gone from the copy it holds: saving that copy would delete the rest
* for everyone. So such a book is shown and never written, the same way a
* viewer's is, until a reload brings the Studio that wrote it. The server
* refuses the save as well, which covers a tab that loaded the book before the
* newer Studio saved it.
*/
/** How long the document has to stay still before it is written. */
const AUTOSAVE_QUIET_MS = 1200
/** And how long it may go unwritten while someone keeps typing. */
const AUTOSAVE_MAX_MS = 15_000
export type SaveState =
| { status: 'idle' }
| { status: 'saving' }
| { status: 'saved'; at: number }
| { status: 'conflict'; current: BookRecord }
/** The journey is open to this user, but they may not write it (role 'viewer'). */
| { status: 'readonly' }
/** The book was saved by a newer Studio than this one: shown, never written, until a reload. */
| { status: 'newer' }
| { status: 'error' }
/** What the editor needs to know about a version it is handed from elsewhere. */
interface RemoteBookMeta {
/**
* Whether every save folded into it was this user's own (their assistant, or
* another tab of theirs), so taking it back with Ctrl+Z only undoes their own
* work. False as soon as somebody else's save is among them.
*/
undoable: boolean
}
export function useBookStore(
journeyId: number,
/** Called when another editor's version is taken, with their document. */
onRemote?: (document: BookDocument, meta: RemoteBookMeta) => void,
/** The signed-in user, to tell their own saves from everybody else's. */
selfId?: number | null,
) {
const [record, setRecord] = useState<BookRecord | null>(null)
const [loaded, setLoaded] = useState(false)
const [state, setState] = useState<SaveState>({ status: 'idle' })
/** The version every save is made against, and what a conflict compares to. */
const version = useRef<number | null>(null)
/**
* The id of the book that version belongs to. A book deleted and made again
* starts its versions over, so a lower version is only stale for the same book.
*/
const bookId = useRef<number | null>(null)
const timer = useRef<number | null>(null)
const firstDirtyAt = useRef<number | null>(null)
const pending = useRef<{ document: BookDocument; title: string } | null>(null)
const inFlight = useRef(false)
/** The title the last queued save carried, so a re-queue does not lose it. */
const lastTitle = useRef('')
/**
* The document the server and this client agree on, by identity.
*
* Loading one — at open, or when another editor's version arrives — puts that
* exact object into the editor store, so it comes straight back through
* queueSave on the next render. Without this it would be written back
* immediately, turning every open and every incoming change into a save and a
* version bump that changed nothing. Identity rather than a deep compare
* because queueSave runs on every commit, and stringifying a book per drag
* frame is not free.
*/
const synced = useRef<BookDocument | null>(null)
/** The document the editor currently holds, agreed on or not. */
const latest = useRef<BookDocument | null>(null)
/**
* Whether this client is holding work the server does not have.
*
* Broader than "a save is queued": after a conflict nothing is queued, and
* after keepMine nothing is queued either, yet in both cases the user is
* sitting on a document that is theirs. Comparing what the editor holds
* against what we last agreed on covers all three without a flag per case.
*/
const hasLocalWork = () =>
!!pending.current || inFlight.current || (!!latest.current && latest.current !== synced.current)
/**
* Set while a conflict is on screen, and nothing is written until it clears.
*
* Without it the editor keeps offering the same rejected document: every
* change queues a save, every save is refused, and the refusal re-renders the
* thing that queued it. The user sees a status that flickers and the server
* sees a client hammering it — over a question only the user can answer.
*/
const blocked = useRef(false)
/** The write that is open, so `flush` can wait for it rather than poll. */
const flight = useRef<Promise<void> | null>(null)
/** Kept in a ref so the socket listener is not re-subscribed per render. */
const onRemoteRef = useRef(onRemote)
onRemoteRef.current = onRemote
/**
* Read the book again if a save from elsewhere was passed over while this
* client had work of its own. Set by the listener below, called once that
* work has settled.
*/
const catchUp = useRef<() => void>(() => {})
useEffect(() => {
if (!Number.isFinite(journeyId)) return
let cancelled = false
journeyApi.getBook(journeyId)
.then(res => {
if (cancelled) return
/*
* Normalised here rather than by whoever renders it.
*
* A document written by an older build is missing whatever the contract
* has gained since, and the editor reads those fields without checking —
* `page.pageNumbers.show` on a book saved before page numbers existed is
* a white screen, not a missing feature.
*
* It has to happen at this seam and nowhere else: `synced` recognises the
* agreed document by identity, so a consumer that normalised on its own
* would hand back a different object and turn every open into a save.
*/
const read = res.book ? readBookDocument(res.book.document) : null
const book = res.book && read ? { ...res.book, document: read.document } : null
if (read?.newer) {
blocked.current = true
setState({ status: 'newer' })
}
setRecord(book)
version.current = book?.version ?? null
bookId.current = book?.id ?? null
synced.current = book?.document ?? null
latest.current = book?.document ?? null
})
.catch(() => { /* A book that will not load is a book that gets created. */ })
.finally(() => { if (!cancelled) setLoaded(true) })
return () => { cancelled = true }
}, [journeyId])
const write = useCallback(async () => {
const next = pending.current
if (!next || inFlight.current || blocked.current) return
inFlight.current = true
let settle = () => {}
flight.current = new Promise<void>(resolve => { settle = resolve })
pending.current = null
firstDirtyAt.current = null
setState({ status: 'saving' })
try {
const saved = await journeyApi.saveBook(journeyId, {
title: next.title,
// Stamped on the way out only: `synced` below keeps the editor's own object.
document: stampBookSchemaRev(next.document),
baseVersion: version.current ?? undefined,
})
setRecord(saved)
version.current = saved.version
bookId.current = saved.id
synced.current = next.document
setState({ status: 'saved', at: Date.now() })
} catch (err) {
/*
* A 409 is not a failure, it is the other person.
*
* The current record comes back in the body precisely so this moment does
* not need a second request: the editor can offer to take their version
* or keep going from here, and either way it can say whose work it is.
*/
const res = (err as { response?: { status?: number; data?: { current?: BookRecord; code?: string } } }).response
if (res?.status === 409 && res.data?.code === BOOK_SCHEMA_NEWER_CODE) {
// A newer Studio saved the book since this tab read it: nothing here may be written over it.
blocked.current = true
setState({ status: 'newer' })
} else if (res?.status === 409 && res.data?.current) {
blocked.current = true
setState({ status: 'conflict', current: res.data.current })
} else if (res?.status === 403) {
/*
* A viewer can open Studio - the book is part of the journey they were
* invited to - but the server will not take their writes. Stop trying
* on the first refusal and say so: retrying forever would let someone
* lay out an entire book before finding out none of it was ever saved.
*/
blocked.current = true
setState({ status: 'readonly' })
} else {
setState({ status: 'error' })
}
} finally {
inFlight.current = false
settle()
// Anything that arrived while the request was open goes next, so a save
// during a save is queued rather than dropped.
if (pending.current) void write()
else catchUp.current()
}
}, [journeyId])
/**
* Note that the document changed.
*
* Called on every commit. The write happens once the document has been still
* for a moment, or once it has been dirty long enough that waiting for a
* pause is no longer reasonable.
*/
const queueSave = useCallback((document: BookDocument, title: string) => {
latest.current = document
lastTitle.current = title
// The document we already agree on is not an edit — see `synced`.
if (document === synced.current) return
pending.current = { document, title }
if (firstDirtyAt.current == null) firstDirtyAt.current = Date.now()
if (timer.current != null) window.clearTimeout(timer.current)
const waited = Date.now() - firstDirtyAt.current
const delay = waited >= AUTOSAVE_MAX_MS ? 0 : AUTOSAVE_QUIET_MS
timer.current = window.setTimeout(() => { void write() }, delay)
}, [write])
/** Write immediately — for closing the editor, or a save the user asked for. */
const saveNow = useCallback((document?: BookDocument, title?: string) => {
if (document) {
if (title != null) lastTitle.current = title
pending.current = { document, title: title ?? lastTitle.current }
}
if (timer.current != null) window.clearTimeout(timer.current)
return write()
}, [write])
/**
* Hand everything this client holds to the server, and say whether it landed.
*
* For the moments where a write from somewhere else is about to happen on
* purpose: the assistant is sent a turn, or one of its cards is applied. Its
* write is checked against the version, so an edit still sitting in the
* debounce would turn the assistant's change into a conflict, or the other way
* round. Resolves false when the work could not be written (a conflict, a
* viewer, a failed request), so the caller can hold off instead of racing it.
*/
const flush = useCallback(async (): Promise<boolean> => {
if (timer.current != null) {
window.clearTimeout(timer.current)
timer.current = null
}
// Work that is local but not queued, as after a failed request.
if (!blocked.current && !pending.current && !inFlight.current && latest.current && latest.current !== synced.current) {
pending.current = { document: latest.current, title: lastTitle.current }
}
// Bounded: every round writes once or waits for one write to settle.
for (let round = 0; round < 8 && !blocked.current; round++) {
if (inFlight.current) await flight.current
else if (pending.current) await write()
else break
}
return !blocked.current && !hasLocalWork()
}, [write])
/** Take the other version, discarding the local one. */
const acceptTheirs = useCallback((current: BookRecord) => {
const { document, newer } = readBookDocument(current.document)
blocked.current = newer
setRecord({ ...current, document })
version.current = current.version
bookId.current = current.id
synced.current = document
latest.current = document
pending.current = null
setState(newer ? { status: 'newer' } : { status: 'idle' })
// A save from elsewhere passed over while the conflict was open may be
// newer than the record it carried.
catchUp.current()
return document
}, [])
/**
* Keep going from here, on top of their version.
*
* Not a merge: it rebases the local document onto their version number so the
* next save lands. The two documents are not reconciled, and pretending
* otherwise would be worse than saying so — which is why the editor offers
* this next to the option of taking theirs.
*/
const keepMine = useCallback((current: BookRecord) => {
version.current = current.version
blocked.current = false
/*
* The refused document has to be queued again here, because write() cleared
* the queue before it made the request that came back 409. Without this,
* choosing "keep mine" left the work sitting in the tab: the autosave only
* fires on the next change, and closing the editor writes whatever is
* queued, which is nothing.
*/
if (latest.current && latest.current !== synced.current) {
pending.current = { document: latest.current, title: lastTitle.current }
void write()
return
}
setState({ status: 'idle' })
}, [write])
/**
* Someone else saved.
*
* Only acted on when this client has nothing outstanding — no queued edit, no
* request open, and no conflict already on screen. Anything else and the user
* has work of their own that a silent replacement would throw away.
*/
useEffect(() => {
if (!Number.isFinite(journeyId)) return
let cancelled = false
/** A read is open; events that arrive meanwhile ask for one more after it. */
let fetching = false
let again = false
/** A save was passed over because this client had work of its own. */
let missed = false
/** The versions saved by somebody other than this user, not taken yet. */
let foreign: number[] = []
/** Whether a version is one this client does not hold yet. */
const isNewer = (v: unknown) =>
typeof v !== 'number' || version.current == null || v > version.current
const refetch = () => {
fetching = true
again = false
missed = false
void journeyApi.getBook(journeyId)
.then(res => {
if (cancelled || !res.book) return
// Re-checked after the round trip: the user may have started editing
// while it was in the air, and their work outranks the refresh. Kept
// for later, in case that work lands without a conflict.
if (hasLocalWork()) {
missed = true
return
}
// An answer overtaken by one already taken would roll the editor
// back. A different book (deleted and made again) is never stale.
const sameBook = bookId.current == null || res.book.id === bookId.current
if (sameBook && !isNewer(res.book.version)) return
// Normalised for the same reason the initial load is: a document
// written by an older build is missing whatever the contract has
// gained since. One written by a newer build is shown and held.
const { document, newer } = readBookDocument(res.book.document)
if (newer) {
blocked.current = true
setState({ status: 'newer' })
}
const held = version.current
const taken = res.book.version
// Undoable only when every save it folds in, past the version held,
// was this user's own.
const undoable = sameBook && !foreign.some(v => held == null || (v > held && v <= taken))
foreign = foreign.filter(v => v > taken)
setRecord({ ...res.book, document })
version.current = taken
bookId.current = res.book.id
synced.current = document
latest.current = document
onRemoteRef.current?.(document, { undoable })
})
.catch(() => { /* The next save will conflict and offer the choice. */ })
.finally(() => {
fetching = false
if (!again || cancelled) return
if (hasLocalWork()) missed = true
else refetch()
})
}
catchUp.current = () => {
if (cancelled || !missed || hasLocalWork()) return
if (fetching) again = true
else refetch()
}
const handler = (event: Record<string, unknown>) => {
if (event.type !== 'journey:book:saved') return
if (event.journeyId !== journeyId) return
// Already ours: the server excludes the saving socket, but a second tab
// on the same account is a different socket and the same person. Older
// than ours is a broadcast that arrived after a newer read.
if (!isNewer(event.version)) return
// Unknown who saved, or not this user: their change is not ours to undo.
if (selfId == null || event.savedBy !== selfId) {
// A version that did not come along counts against the next one taken.
foreign.push(typeof event.version === 'number' ? event.version : (version.current ?? 0) + 1)
}
if (hasLocalWork()) {
// Remembered rather than dropped: the user's own write may land first
// and leave this one unseen, with no conflict to bring it up.
missed = true
return
}
if (fetching) {
again = true
return
}
refetch()
}
addListener(handler)
return () => {
cancelled = true
catchUp.current = () => {}
removeListener(handler)
}
}, [journeyId, selfId])
useEffect(() => () => {
if (timer.current != null) window.clearTimeout(timer.current)
}, [])
return { record, loaded, state, queueSave, saveNow, flush, acceptTheirs, keepMine, version }
}