refactor(pages): page-scoped asset staging, delete the generic file upload (SUPER-2080)
Summary
- Deletes the generic
file.*router — a domain-less write path (1 GiB ceiling, no content-type policy, orphan-by-default) whose only consumer in the repo was the pages CLI. - Replaces it with a page-scoped surface.
fileIdno longer appears in any input or output, CLI JSON included. - Publishing a thirty-asset directory went from 21.5s to 5.2s.
Why / Context
The files layer landed in #7019 with a read path that was domain-aware (list takes parentKind + parentId and checks readability) and a write path that was not. Anyone in an organization could upload a gigabyte of any content type and get an org-owned orphan file, because the domain only appeared at attach time and read time, never at upload.
That asymmetry is what boxes in product decisions: quota and retention have no key to hang on at upload time, one ceiling covers a favicon and a screen recording alike, and sweep.ts exists precisely because uploads have no parent.
How It Works
page.create({ workspaceId?, entryPath?, title? })
page.assets.upload({ pageId, path, name, contentType, sizeBytes, sha256 })
-> { uploadUrl, headers } | { reused: true }
page.assets.remove({ pageId, path })
page.publish({ pageId, content })
Assets stage against the page as attachments(parentKind:'page', parentId:pageId, path), reusing the unique index that already enforced one file per path. Publish verifies every staged asset, snapshots them onto the version it mints, and clears staging, so a version is never served missing its own assets.
There is deliberately no complete procedure. A presigned PUT lands without the API seeing it, so the bytes have to be checked somewhere; publish is already the gate that must be correct, so it does the HEAD and the 8 KiB ranged sniff there. That also means a failed asset cannot burn a version number.
Reuse moved server-side: the API answers by sha256 within the page's own lineage, so the CLI no longer enumerates files to deduplicate.
Shared plumbing stays domain-neutral in packages/trpc/src/lib/files/ (sniff, tickets, sweep, constants). Only policy is domained: page assets cap at 100 MiB against the plumbing's 1 GiB.
Design decisions
| Decision | Choice | Why |
|---|---|---|
| Reference form | by path, not by id | An id-addressed asset (Claude's _blob/{id}) works without a version, but the HTML can only be written after publishing and never previews locally. Paths keep open index.html working. |
| Where assets attach | version, via page-scoped staging | Pinning v1 has to render v1's stylesheet. A page-scoped store with by-name references silently rewrites history. |
| Staging key | a page row with zero versions | Assets need something to attach to before the first version exists. page.create is one procedure against a new table or a second key shape. |
stageAsset | one upsert, no transaction | It runs once per asset; a pooled transaction each made a 30-asset publish take 21.5s. The index is partial, so ON CONFLICT repeats its path is not null predicate. |
Manual QA
Ran end-to-end against a real API, a Neon branch of production migrated to 0103, a real better-auth API key, and the real CLI. Storage was a local S3-compatible stub, because .env.local.example states publishing needs real R2 credentials.
- Directory publish, 2 assets: v1,
uploaded: 2, reused: 0 - Storage trace matches the design: 2x
PUT files/<id>/original-> 2xHEAD-> 2x rangedGET 0-N->PUT versions/1/index.html->PUT manifest.json - Republish unchanged: v2,
uploaded: 0, reused: 2, zero asset bytes moved - One asset edited: v3,
uploaded: 1, reused: 1 - Version pinning holds —
chart.pngshares one file across v1/v2/v3 whilestyle.cssdiverges at v3 - Staging cleared after publish (0 rows), no duplicate
(version, path)rows, all filesready -
page.assets.removeunstages - Stage without sending bytes, then publish: refused with
Asset "ghost.txt" was never uploaded — send the bytes first, and no version burned - Single-file publish still mints its own page without
page.create -
pages list/get/versions/pull --version 1 - 30-asset publish: 21.5s -> 5.2s, reuse still 0/30 then 1/29
Testing
bun run test(21/21 packages)bun run typecheckbun run lint
Known Limitations
- The Worker was not exercised in this run: objects went to the S3 stub rather than wrangler's local R2, so asset serving is verified through the manifest that drives it, not end to end. Production's origin serves the document today and 404s assets because main's Worker has no
serveAsset; that is what this stack adds. - Untested: concurrency between two racing publishes, the 100 MiB and 200-asset caps, thumbnails (disabled locally).
Follow-ups
page.assets.uploadstill costs ~7 database round trips per asset. A batch form taking an array would collapse them; the CLI running 8 wide is the cheaper fix taken here.- Republishing a directory without
--titleresets the page title to the directory name. Pre-existing, not introduced here.
Risks / Rollout
- Migration 0103 is
ALTER TYPE ... ADD VALUE 'page'— additive, same single-statement form as the shipped 0083, applied cleanly to a fresh branch of production during QA. publishPageSchemais now.strict(). Zod strips unknown keys by default, which is how a Canary CLI publishing a directory to production today gets a successful publish whose page is missing every image, with no error anywhere. Both in-repo callers were checked: MCP forwards only declared fields after its destructure.- Rollback: revert the merge. The enum value is additive and harmless if left behind.
https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
Summary by cubic
Replaces the generic file.* upload router — a domain-less write path with a 1 GiB ceiling and no content-type policy — with page-scoped asset staging. Assets now upload against a page by path, publish verifies and snapshots them onto the version it mints, and fileId disappears from every input and output.
Behavior changes
- Assets stage as
attachments(parentKind: 'page'); publish clears only the staged rows it snapshotted, so a concurrent upload mid-publish survives. - Reuse is the server's call by sha256 within the page's lineage; the CLI no longer enumerates files and there is no
completestep — publish HEADs and sniffs the staged bytes. - Page assets cap at 100 MiB and 200 per page, down from the generic 1 GiB; the path and caps are enforced on reuse too.
publishPageSchemais now strict, so a newer CLI against an older API fails loudly instead of publishing a page with itsassetssilently stripped.- A 30-asset directory publish dropped from 21.5s to 5.2s via 8-wide uploads and single-upsert staging.
Migration
- Run migration
0103, which adds'page'toattachment_parent_kind— additive, no data changes; rollback is a plain revert.
Summary by CodeRabbit
-
New Features
- Directory publishing now stages assets directly against the target page.
- Existing assets can be reused automatically based on content, with upload and reuse totals reported.
- Pages can be resolved or created automatically when publishing directories.
- Added page asset management with upload limits and removal support.
-
Bug Fixes
- Publish now verifies staged files before attaching them, improving content-type and size validation.
- Invalid publish request fields are rejected instead of silently ignored.
-
Documentation
- CLI help now explains asset reuse and the default
orgvisibility.
- CLI help now explains asset reuse and the default