Commit Graph

4 Commits

Author SHA1 Message Date
8ae010218e Obsidian vault import: endpoint, job orchestration, rollback (#117)
Some checks failed
CD / Build and push images (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m21s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
POST /ponds/:pondId/import/vault (pond-admin-gated; a vault import
creates a subtree, uploads files, and creates labels — administration,
not everyday editing) takes the ZIP plus a JSON options field
{parentPageId?, labelIds?, frontmatterMode}. The archive is parsed at
enqueue for fast 400s; the job (new kind import_vault, riding the
existing isImportKind worker routing) re-parses and runs the #116
transform, then: containers top-down → notes (asset placeholders →
uploaded pond files; non-images become page attachments) → tags to
labels (nested tags build a label hierarchy via LabelsService, so
locking and cache invalidation apply) plus the dialog labels.

All-or-nothing: any failure hard-deletes the created pages (children
first) and removes the stored files (quota restored), then surfaces as
import_vault_invalid_zip / import_vault_too_large / quota_exceeded /
conversion_failed — and makes the worker's retry policy safe.

Supporting changes:
- conversion_jobs gains a nullable options jsonb column; enqueue takes
  kind-specific options and a maxInputBytes override (the 25 MiB
  default protects the pandoc sidecar, which a vault never touches —
  vaults use the 64 MiB upload limit).
- insertPage accepts a pre-reserved slug (the batch reserves all slugs
  up front against pond ∪ batch).
- NEW: pages born with content seed their outgoing page_links rows
  (deriveContent now returns wikilinkSlugs) — imported pages would
  otherwise stay invisible to backlinks and the graph until their
  first collab save. Collab still rewrites the rows on every save, and
  the existing phantom resolution heals batch creation order.

import-vault.e2e.db.test.ts (4 tests, real worker drained): gating +
input rejection, the full fixture import (tree under a mount page,
collision suffixes, link rows incl. phantom, nested tag labels, extra
label everywhere, frontmatter stripped, image embedded + PDF attached),
complete quota rollback, and a clean re-import with fresh suffixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:13:11 +02:00
546e8279ac Import .docx and .odt documents as new pages (#63)
All checks were successful
CD / Build and push images (push) Successful in 3m19s
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Uploading a Word/OpenOffice document to POST /ponds/:id/import enqueues a
conversion job (the #62 queue) that produces a new page in the pond; the
client polls GET /jobs/:id for the created resultPageId.

Pipeline (ImportService, ADR 0009): pandoc-server is stateless and hands
back a document's media no other way, so we convert in two passes —
docx/odt → html with embed-resources inlines every image as a data: URI,
then html → gfm produces clean structural Markdown with those data URIs
still inline. Embedded images are stored as pond files (with quota
accounting) and their references rewritten to file ids on the Markdown
text before parsing (the editor parser only admits png/jpeg/gif/webp data
URIs); an image whose bytes the upload pipeline rejects is dropped, not
fatal. The title comes from a leading top-level heading (removed from the
body) else the file name. The page is created from the resulting Yjs state.

The shared conversion worker routes import-kind jobs to the pipeline via a
token (breaking a module cycle), so import inherits the queue's locking,
retry, and restart-survival. Media stored during a failed attempt is rolled
back; a pond that runs out of storage fails the job with quota_exceeded.

- schema: ConversionJob gains pond_id / source_name / result_page_id
  (migration 20260710041215_import_pages_conversion); ConversionJobView
  gains resultPageId.
- PagesService.createWithState / yjs-content docToState build a page from a
  prepared document; FilesService.linkAttachmentsToPage links import media.
- fixtures/import/: representative .docx/.odt corpus (headings, lists,
  nested lists, tables, images, links, bold/italic) with expected-Markdown
  snapshots; scripts/gen-import-fixtures.mjs regenerates them.
- tests: import.service.db.test.ts drives the full pipeline with a fake
  converter (CI); import.fixtures.test.ts runs the real two-pass conversion
  over the corpus and a 50-page timing check against a reachable sidecar.
- i18n: import_unsupported_format (de+en). Limits documented (25 MiB input,
  60 s per pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 07:34:43 +02:00
a645763679 Add page trash: soft delete, restore, and purge job (#31)
All checks were successful
CD / Build and push images (push) Successful in 2m5s
CI / Lint, typecheck, test (push) Successful in 1m45s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s
Backend: a generic maintenance-job scheduler (SchedulerService, `jobs`
table) that any later maintenance job registers with instead of
growing its own timer loop. Due-ness and the run-mutex both live in
the DB row (`lastRunAt` survives a restart; claiming a due job is one
atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable
ClockService lets tests simulate retention elapsing without waiting or
faking the global clock.

Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore,
DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the
same purge logic as the scheduled daily job (default 30-day retention,
new trash.retentionDays instance setting). Purging deletes a page's
content cache, update log, and attachment files/quota; page_versions
is a placeholder until M3 exists. Direct navigation to a trashed page
now 404s with a distinguishable `page_trashed` code for editors (a
plain 404 for everyone else) instead of the generic not-found.

Attachment.pageId — added in #27 but never wired up — now gets set on
every page state save to whichever page's document currently embeds
the file, which is what lets purge find a page's files.

Frontend: a per-pond trash view (restore/purge), a "move to trash"
action with confirmation in the page menu, and a trash link in the
sidebar for pond owners. Also fixes react-query retrying 4xx responses
for several seconds by default, which was masking the trash-hint 404
in the UI (and would have affected any other not-found/permission
error the same way).

Closes #31
2026-07-08 12:48:17 +02:00
98e159ab50 Add page CRUD and Yjs state persistence (#23)
All checks were successful
CD / Build and push images (push) Successful in 1m52s
CI / Lint, typecheck, test (push) Successful in 1m34s
CI / Auth e2e pack (push) Successful in 1m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m7s
CD / Promote to Int (push) Successful in 10s
Prisma models `pages`/`page_updates`/`page_content_cache` per
data-model.md. Endpoints: POST /ponds/:id/pages (title -> empty Yjs doc
state, seeded via y-prosemirror), GET /pages/:id (meta + base64 state),
PUT /pages/:id/state (client-encoded Yjs state, rejected above the 5 MiB
operations.md limit or if it doesn't decode into a valid document for
the schema), PATCH /pages/:id (title/slug — explicit slug changes
validate uniqueness per pond, title-only renames keep the slug),
DELETE (soft). Access follows InterimAccessService via the page's pond,
same 404-not-403 interim rule as ponds.

State saves decode the Yjs update with yjs + y-prosemirror and run it
through the #24 shared derivation functions (docToPlainText/
docToMarkdown/docToHtml/extractOutline) to refresh page_content_cache.
The Yjs XmlFragment name ("default") and the derivation call are
factored so the collab server's persistence hooks (#35) can reuse both.

Raised the API's JSON body limit to 8 MiB (main.ts and the e2e test
app) to fit base64-encoded page state.

Closes #23
2026-07-05 22:25:41 +02:00