Repository navigation
Conversation
…and Space export/import Pages had optimistic-concurrency revisions but no recovery: every successful edit overwrote content forever and delete was permanent. This adds the missing safety net without changing existing defaults. Server (src/server/pages.ts, page-routes.ts): - page_versions table snapshots title/content/revision before every content-changing update (reason edit/restore), bounded to the newest 50 per page with automatic pruning. - POST /spaces/:spaceId/pages/:id/restore restores a version as a new revision behind expectedRevision, so concurrent edits still 409. - Soft-delete: DELETE sets deletedAt, reparents children, drops page_threads, keeps versions and review receipts. list/get/exists keep their old contracts by hiding trashed rows. - Trash APIs: GET /spaces/:spaceId/trash, POST .../trash/:id/restore (reparents under a live parent or Space root, bumps revision), DELETE .../trash/:id for permanent purge with versions/threads. - Backup APIs: GET /spaces/:spaceId/export and POST /spaces/:spaceId/import (1-200 pages, old-id and positional parent remapping, cycle validation, fresh ids). Client: - PageHistory panel in the document view: version list, 2000-char preview, one-click restore with 409 guidance. - Delete menu becomes Move to Trash with restore guidance. - SpaceTrash section under the library: trash list with restore/permanent-delete, JSON export download, JSON import with validation errors surfaced inline. - Styles for history/trash with narrow-screen fallback. Tests: - tests/page-history-trash.test.ts (6 cases): snapshot ordering, restore + stale-revision/version 404s, 50-entry bound, soft-delete/ restore/purge, reparent semantics, export/import round-trip with hierarchy remap, and full owner-API coverage. - Updated trash label expectation in page-document-delete test. Verification: prettier, eslint, tsc --noEmit, vitest (97 files / 610 tests passed), vite production build. Existing delete/history contracts preserved (soft-delete keeps list/get/404 behavior).
paoValle
left a comment
There was a problem hiding this comment.
I read the whole diff and ran the branch locally (Node 22.23.3): npm run typecheck, npm run lint, npm run check-format clean, npm test → 49 files / 308 tests pass. The shape is right: soft delete keeps the list/get/exists contracts, update() still checks expectedRevision before taking the snapshot, versions are pruned inside the same transaction, and the import remap + cycle check rolls back cleanly. Three things I would fix before merge.
1. The trash endpoint permanently deletes live pages. purge() only checks that the row exists, so DELETE /api/spaces/:spaceId/trash/:id destroys a page that was never trashed, together with its versions and thread bindings. Checked on this branch with a throwaway vitest case: purge(space, livePage.id) returns true, the page is gone, and trash(space) was still empty before the call. An AND deletedAt IS NOT NULL (and a 404/409) closes it and keeps the "delete is a safety net" promise honest for any other caller of this route.
2. The backup the purge dialog recommends does not contain what is being purged. SpaceTrash.tsx:52 says "Export the Space first if you need a backup", but exportSpace() goes through list(), which filters deletedAt IS NULL — the probe confirms a trashed page is absent from the export — and page_versions is not exported at all. Exporting before a purge therefore backs up everything except the page and the history about to be destroyed. Either include trashed rows (and versions) in the export, or drop that sentence from the dialog.
3. Restoring a version silently replaces an unsaved draft. PageHistory.tsx:59 promises "Your current draft is kept as a new history entry", but the snapshot that becomes the history entry is the last saved revision, and PageDocument.tsx:309-314 calls receive(next) followed by useLatest(), which resets draft to the remote page (autosave.ts:220-232). With unsaved edits in the editor, clicking Restore discards them with no conflict banner, unlike every other external update in this app. await controller.flush(true) before the restore request — the same idiom the file already uses as beforeChat — or an accurate dialog text would fix it.
Smaller notes:
- The body's test numbers look like two runs added together:
97 files / 610 testsis exactly main (48 / 302) plus this branch (49 / 308). purge()removespage_threadsandpage_versionsbut leavespage_reviewsrows behind. Deliberate on the soft-delete path (the comment explains why); just noting that those rows now outlive the page forever.SpaceWorkspace.refresh()replacespagesoutright and clearsremoved.current, bypassingmergePageSnapshot. Fine for a post-trash/import refresh, but#126rewrites that polling path, so the two will need a merge order.
Good: the deletedAt migration is additive and guarded by PRAGMA table_info, versions() refuses trashed pages through get(), and delete() still reparents children and keeps the review rows.
| this.db.prepare('DELETE FROM page_threads WHERE pageId=?').run(id); | ||
| this.db.prepare('DELETE FROM page_versions WHERE pageId=?').run(id); | ||
| this.db | ||
| .prepare('DELETE FROM pages WHERE id=? AND spaceId=?') |
There was a problem hiding this comment.
This runs for any page in the Space, trashed or not: purge() only checks that the row exists, so DELETE /api/spaces/:spaceId/trash/:id permanently destroys a live page with its versions and thread bindings. Checked on this branch with a throwaway vitest case — purge(space, livePage.id) returned true and the page was gone while trash(space) had never listed it. Adding AND deletedAt IS NOT NULL here (and a 404/409 in the route) closes the hole.
| >[]; | ||
| } { | ||
| this.requireSpace(spaceId); | ||
| const pages = this.list(spaceId).map((page) => ({ |
There was a problem hiding this comment.
list() filters deletedAt IS NULL, so the export omits every trashed page, and page_versions is not exported at all. That makes the advice in SpaceTrash.tsx:52 ("Export the Space first if you need a backup") unable to back up the page the user is about to purge. Either export trashed rows and versions, or drop that sentence.
| const purge = async (id: string, title: string) => { | ||
| if ( | ||
| !window.confirm( | ||
| `Permanently delete "${title}"? This cannot be undone. Export the Space first if you need a backup.`, |
There was a problem hiding this comment.
This points the user at an export that cannot contain title — the export reads the live page list only (pages.ts:491). Either make the export cover trashed pages, or say here that a purge cannot be recovered from an export.
| if (!selected) return; | ||
| if ( | ||
| !window.confirm( | ||
| `Restore "${selected.title}" from revision ${selected.revision}? Your current draft is kept as a new history entry.`, |
There was a problem hiding this comment.
"Your current draft is kept as a new history entry" is not what happens with unsaved edits: the history entry is the last saved revision, and PageDocument.tsx:309-314 runs receive(next) then useLatest(), which resets the draft to the remote page (autosave.ts:220). So Restore discards an unsaved draft with no conflict banner. Flushing first (controller.flush(true), as beforeChat already does) or wording the dialog honestly would fix it.
Workflow enabled
Pages had optimistic-concurrency revisions but no recovery: every successful edit overwrote content forever and delete was permanent, with only per-page Markdown download as a safety net. This PR adds the missing workspace safety net:
expectedRevision(stale restores 409 like stale saves).list/get/existsso all existing contracts hold. Trash panel restores (reparenting under a live parent or Space root) or purges permanently with versions/threads.No existing issue or PR covers this (checked open issues/PRs: no history/trash/export entry). Original work on a fresh branch from upstream main.
What changed
Server:
page_versionstable +deletedAtmigration, snapshot/prune/restore logic, trash restore/purge, export/import with remap validation, 7 new REST endpoints.Client:
PageHistory.tsx,SpaceTrash.tsx, PageDocument History integration + Move to Trash wording, SpaceWorkspace trash/export/import section, styles with narrow-screen fallback, README Features row.Tests: new
tests/page-history-trash.test.ts(6 cases) + trash-label update.Verification evidence
All run locally on Node 24, no cloud/service config needed (SQLite
:memory:+ Hono app fixtures):prettier --check— pass (formatter run on all touched files + README)npm run lint— passtsc --noEmit— passnpm test— 97 files / 610 tests passed, including 6 new history/trash/export casesnpm run build— pass (vite production build)Fixtures vs live: automated tests use service fixtures only. No Intelligence/model/Slack/voice path touched; live-integration behavior unchanged. No credentials, databases, or planning notes committed.
Endpoints
GET /api/spaces/:spaceId/pages/:id/versionsPOST /api/spaces/:spaceId/pages/:id/restore{ versionId, expectedRevision }GET /api/spaces/:spaceId/trashPOST /api/spaces/:spaceId/trash/:id/restoreDELETE /api/spaces/:spaceId/trash/:idGET /api/spaces/:spaceId/exportPOST /api/spaces/:spaceId/import{ pages: [{ id?, title, content?, parentId? }] }