A memo project lives in two places: a writable staging area the owner edits, and an immutable version prefix that links serve from. Publish takes a snapshot of staging, runs the TypeScript compiler over it, and atomically swaps current_version_id.
While editing, files live at:
r2://<R2_BUCKET>/staging/<projectId>/<path>
Two endpoints write here:
POST /api/owner/projects/<id>/upload — multipart form, accepts loose files and .zip archives. ZIPs are unzipped on the server (lazy unzipper import) and every entry is path-validated. Hard caps: 15 MB per file, 80 MB per upload. Audit-logged as project.upload.PUT /api/owner/projects/<id>/files/<path> — write or overwrite a single file. Used by the Monaco editor's Save button. Audit-logged as project.file.write.DELETE on the same per-path route deletes from staging only. Versions already published are unaffected.
GET on the per-path route streams the staged contents back — Monaco uses this to populate the buffer when a file is selected.
GET /api/owner/projects/<id>/files lists every staged path (R2 ListObjectsV2 filtered by prefix).
All file endpoints validate ownership via Drizzle (projects.owner_id == session.user.id) before any storage read or write.
When the owner clicks Publish, POST /api/owner/projects/<id>/publish runs:
listStagedFiles(projectId)
→ [path₁, path₂, ...]
await Promise.all(paths.map(p => readStagedFile(projectId, p)))
→ CompileFile[]
compileStagedFiles(sources)
→ { status, artifacts, errors, warnings, durationMs }
compileStagedFiles (in src/lib/publish/compile.ts) walks every staged file:
.ts / .tsx files are run through native esbuild's build() API (per-file, no bundling — stdin source, write: false):
format: 'esm'target: 'es2020'sourcemap: 'inline'bundle: falseloader: 'tsx' for .tsx, 'ts' otherwise.
Output is written as a sibling .js file. The original .ts source is dropped from the published version — clients only consume the compiled JS.If any file fails to compile, the response is 422 Unprocessable Entity with the full esbuild error array. Nothing is written to R2 and no versions row is created.
On a clean compile:
versionId (randomUUID).version_number (max + 1).r2://<bucket>/<userId>/<projectId>/<versionId>/<path> with the correct content-type.sha256 of the bytes for forensic comparison + later dedup.neon-serverless Pool):
versions row with file count, total bytes, compile_status='ok', and the warnings + duration in compile_log.files rows (path, mime, size, sha256, r2_key).projects.current_version_id to the new version and bump updated_at.audit_logs entry: action='project.publish', target_type='version', target_id=versionId, meta={ projectId, versionNumber, fileCount, totalBytes }.Because the version prefix is content-addressed by versionId, publish is atomic for visitors: until current_version_id flips, every existing link continues to serve the old version. The moment it flips, new visits to that link serve the new one.
Note: links reference a specific version_id, not current_version_id — so existing links keep serving the version they were created against. To bump a link to a newer version, an owner explicitly creates a new link (or in v2, an "edit link" endpoint will support PATCH version_id).
In v1 we deliberately compile only on publish, not on save. This is documented in the project plan as a UX/infra tradeoff:
A v2 plan adds compile-on-save via esbuild-wasm — additive, no schema change. The publish-time compile remains the canonical pipeline.
Typical publish cycles on a 20-file project:
The R2 PUTs are serialized in the current implementation — Promise.all would be a one-line change but risks pushing into R2's per-account rate ceiling if owners batch-publish in parallel. Punt until measurements demand it.
When changing the pipeline, the smoke test is:
index.html, app.ts.POST /publish — assert 200, status: "ok", versionNumber: 1, fileCount: 2 (because app.ts becomes app.js).<userId>/<projectId>/<versionId>/ prefix has index.html + app.js. The .ts is not present.SELECT * FROM memo.versions WHERE id = '<versionId>' — compile_status='ok', compile_log has warnings: [] and a numeric durationMs.SELECT current_version_id FROM memo.projects WHERE id = '<projectId>' — matches.view.astro streams index.html with app.js referenced as a sibling file at /<slug>/raw/app.js.