Skip to content

feat(pages): version history with restore, trash with restore/purge, and Space export/import - #114

Open
Ayush7614 wants to merge 1 commit into
CopilotKit:mainfrom
Ayush7614:feat/page-history-trash-export
Open

Ayush7614 wants to merge 1 commit into
CopilotKit:mainfrom
Ayush7614:feat/page-history-trash-export

Conversation

@Ayush7614

Copy link
Copy Markdown

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:

  • Version history: every content-changing update snapshots title/content/revision (bounded to newest 50, auto-pruned). A History panel in the document view lists revisions, shows a 2,000-char preview, and restores as a new revision behind expectedRevision (stale restores 409 like stale saves).
  • Trash: delete becomes soft-delete (Move to Trash). Trashed rows stay hidden from list/get/exists so all existing contracts hold. Trash panel restores (reparenting under a live parent or Space root) or purges permanently with versions/threads.
  • Backup: Space JSON export download + import (1-200 pages, old-id and positional parent remapping, cycle validation, fresh ids) for migration and manual backup.

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_versions table + deletedAt migration, 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 — pass
  • tsc --noEmit — pass
  • npm test — 97 files / 610 tests passed, including 6 new history/trash/export cases
  • npm run build — pass (vite production build)
  • Manual UI exercised via component props: history open/preview/restore confirm path, trash restore/purge confirms, export download + import validation errors. Narrow-screen: history grid collapses to one column; trash rows wrap.

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/versions
  • POST /api/spaces/:spaceId/pages/:id/restore { versionId, expectedRevision }
  • GET /api/spaces/:spaceId/trash
  • POST /api/spaces/:spaceId/trash/:id/restore
  • DELETE /api/spaces/:spaceId/trash/:id
  • GET /api/spaces/:spaceId/export
  • POST /api/spaces/:spaceId/import { pages: [{ id?, title, content?, parentId? }] }

…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 paoValle left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tests is exactly main (48 / 302) plus this branch (49 / 308).
  • purge() removes page_threads and page_versions but leaves page_reviews rows behind. Deliberate on the soft-delete path (the comment explains why); just noting that those rows now outlive the page forever.
  • SpaceWorkspace.refresh() replaces pages outright and clears removed.current, bypassing mergePageSnapshot. Fine for a post-trash/import refresh, but #126 rewrites 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.

Comment thread src/server/pages.ts
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=?')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/server/pages.ts
>[];
} {
this.requireSpace(spaceId);
const pages = this.list(spaceId).map((page) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/client/SpaceTrash.tsx
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.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants