Files
pad/internal/models/export.go
T
xarmian 5e869b285e fix(export): mark bundle bodies that were stale when written, and refuse the migration that would abandon them (BUG-3032) (#1356)
* wip(items): mark bundle items whose body is behind the live doc (BUG-3032 doors 1-2)

* wip: doors 4 and 6 (BUG-3032)

* wip: store test for the gate predicate (BUG-3032)

* wip: door 3 import stale-body count + tests (BUG-3032)

* docs(backup): the migration refuses on unflushed collab edits; bundles mark stale bodies (BUG-3032)

* docs: discharge BUG-3032's open-decision conditional in the artifact door and the population guard

* fix(export): the migration gate's population cannot diverge from the bundle's (BUG-3032)

* docs(claude): Go build caches never go under /tmp — it is a tmpfs, so it is RAM

* wip: codex round 1 P1 + P2 fixes (BUG-3032)

* fix(db): refuse a migration whose bundle went stale after the pre-flight check (BUG-3032, codex r1 P1); print the import's stale-body count (P2)

* fix(db): refuse to migrate while a server can append, and parse the stale-body header (BUG-3032, codex r2 P1+P2)

* docs: narrow the running-server claim to what the probe measures (BUG-3032)

* wip: codex r3 fixes + web parity test (BUG-3032)

* fix(db): fail closed when the running-server check cannot run; match Go's header parsing on the web (BUG-3032, codex r3)

* fix(cli): the Go and web readers of the stale-body header accept exactly the same values (BUG-3032, codex r4)

* docs: correct two comment claims about integer representability and Atoi's leniency (BUG-3032, codex r5 nits)
2026-09-14 01:45:30 -04:00

207 lines
9.3 KiB
Go

package models
// WorkspaceExport is the complete portable representation of a workspace.
type WorkspaceExport struct {
Version int `json:"version"` // Export format version (1)
ExportedAt string `json:"exported_at"`
Workspace WorkspaceExportMeta `json:"workspace"`
Collections []CollectionExport `json:"collections"`
Items []ItemExport `json:"items"`
Comments []CommentExport `json:"comments,omitempty"`
ItemLinks []ItemLinkExport `json:"item_links,omitempty"`
ItemVersions []ItemVersionExport `json:"item_versions,omitempty"`
// Reminders round-trip with the workspace (IDEA-2641). They are
// item-scoped workspace CONTENT, like links and versions, not per-user
// state like stars and watches — which is the line this list has always
// drawn, and it puts reminders on the exported side of it. Without them a
// backup/restore or a SQLite→Postgres migration silently loses every
// pending reminder, and "silently" is the part that matters: nothing in
// the destination would show that anything was dropped.
Reminders []ReminderExport `json:"reminders,omitempty"`
}
// ReminderExport is one item reminder in a workspace bundle.
//
// The LIFECYCLE MARKS ARE CARRIED, not reset. A fired-and-unacknowledged
// reminder is still owed to whoever armed it, so it arrives pending on the
// destination; an armed one whose instant has passed fires once on the first
// tick there, which is the same thing that would have happened had the
// workspace never moved. Re-arming everything on import would be inventing a
// new schedule the user did not set.
type ReminderExport struct {
ItemID string `json:"item_id"`
RemindAt string `json:"remind_at"`
FiredAt string `json:"fired_at,omitempty"`
AckedAt string `json:"acked_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// AttachmentManifestEntry describes one attachment blob in the
// tar-bundle export's attachments/manifest.json. The bundle layout is:
//
// pad-export.json # the WorkspaceExport above
// attachments/manifest.json # uuid → AttachmentManifestEntry
// attachments/<uuid>.<ext> # the actual blob bytes
//
// Thumbnails are NOT bundled — they're re-derived on import via the
// existing thumbnail pipeline. ParentID and Variant therefore stay
// nil/empty for every entry shipped in a bundle, but the fields are
// kept here for forward compatibility (e.g. if a future format
// version starts shipping pre-derived variants).
type AttachmentManifestEntry struct {
ID string `json:"id"` // attachment UUID (the original)
Filename string `json:"filename"` // user-facing filename
MIME string `json:"mime"` // canonical MIME from upload time
SizeBytes int64 `json:"size_bytes"` // bytes on disk (matches the blob)
ContentHash string `json:"content_hash"` // sha256 hex, the dedupe key
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
ItemID string `json:"item_id,omitempty"` // exporter's item UUID; remapped on import
ParentID string `json:"parent_id,omitempty"`
Variant string `json:"variant,omitempty"`
UploadedBy string `json:"uploaded_by"`
CreatedAt string `json:"created_at"`
}
// AttachmentManifest is the top-level shape of attachments/manifest.json
// inside an export bundle. Wraps a list of entries plus a small
// "schema" version so the import path can validate / migrate.
type AttachmentManifest struct {
Version int `json:"version"` // manifest version, 1
Entries []AttachmentManifestEntry `json:"entries"`
}
// WorkspaceExportMeta holds workspace metadata for export.
type WorkspaceExportMeta struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Settings string `json:"settings"`
}
// CollectionExport holds a collection's data for export.
type CollectionExport struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Icon string `json:"icon"`
Description string `json:"description"`
Schema string `json:"schema"`
Settings string `json:"settings"`
// Traits carries the collection's kernel-trait declarations (SPEC-5).
// omitempty so an archive from a deployment where nothing declares traits
// omits the key entirely rather than carrying a noise "{}" on every
// collection, and so an import of an older archive is unambiguous (absent,
// not empty).
//
// This does NOT make a pre-TASK-2657 archive round-trip byte-identically:
// import defaults a missing value to "{}" in the column, and any collection
// the migration then backfills re-exports WITH declarations. Round-tripping
// an old archive through a current deployment is expected to gain traits —
// that is the backfill working, not export drift.
Traits string `json:"traits,omitempty"`
Prefix string `json:"prefix"`
SortOrder int `json:"sort_order"`
IsDefault bool `json:"is_default"`
IsSystem bool `json:"is_system"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// DeletedAt carries a collection's soft-delete mark, empty for a live
// collection (BUG-2884).
//
// The bundle used to skip soft-deleted collections while exporting every
// live ITEM, and DeleteCollection soft-deletes only the collection row —
// its items keep deleted_at IS NULL and stay reachable by ref, by id, and
// through search, because no item-bearing read joins collection liveness.
// So the two sections were filtered by different rules and the bundle
// named a collection it did not carry; ImportWorkspace then dropped those
// items on its orphan gate, silently. Since pad db migrate is
// ExportWorkspace piped into ImportWorkspace, that was live data lost on a
// SQLite→Postgres migration, not just a lossy backup.
//
// Filtering the items to live collections instead would have made the
// migration DELETE reachable rows, so the bundle carries the archived
// collection and the importer reproduces the archive.
//
// omitempty for the same reason as Traits: an archive from a workspace
// with nothing deleted omits the key rather than carrying a noise "" on
// every collection. Absent decodes to "" and MUST mean live — that is what
// keeps every archive written before this field importable.
DeletedAt string `json:"deleted_at,omitempty"`
}
// ItemExport holds an item's data for export.
type ItemExport struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"`
// ContentState records that CONTENT above was behind the item's live
// collaborative document when the bundle was written (BUG-3032 / BUG-3000).
// Same values as models.Item.ContentState, named the same, and empty for
// every item whose row is current — so a bundle from a workspace with
// nothing pending is byte-identical to one produced before this field
// existed.
//
// Additive with NO WorkspaceExport.Version bump, deliberately: no bundle
// decode path sets DisallowUnknownFields, so an older binary ignores this
// key, while ImportWorkspace compares `data.Version != 1` with EXACT
// equality — a bump would reject bundles in BOTH directions for an added
// optional key.
//
// Why the bundle needs its own marker rather than inheriting one: this is
// not a late read. WorkspaceExport carries no op-log, and ImportWorkspace
// writes Content back as the destination's canonical content, so on
// `pad db migrate-to-pg` — export piped into import across two databases,
// after which the source is abandoned — an unflushed body becomes the only
// copy. The marker is what lets that door refuse; it is not a repair.
ContentState string `json:"content_state,omitempty"`
Fields string `json:"fields"`
Tags string `json:"tags"`
Pinned bool `json:"pinned"`
SortOrder int `json:"sort_order"`
ParentID string `json:"parent_id,omitempty"`
CreatedBy string `json:"created_by"`
LastModifiedBy string `json:"last_modified_by"`
Source string `json:"source"`
ItemNumber int `json:"item_number"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CommentExport holds a comment's data for export.
type CommentExport struct {
ID string `json:"id"`
ItemID string `json:"item_id"`
Author string `json:"author"`
Body string `json:"body"`
CreatedBy string `json:"created_by"`
Source string `json:"source"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ItemLinkExport holds an item link's data for export.
type ItemLinkExport struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
LinkType string `json:"link_type"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
}
// ItemVersionExport holds an item version's data for export.
type ItemVersionExport struct {
ID string `json:"id"`
ItemID string `json:"item_id"`
Content string `json:"content"`
ChangeSummary string `json:"change_summary"`
CreatedBy string `json:"created_by"`
Source string `json:"source"`
IsDiff bool `json:"is_diff"`
CreatedAt string `json:"created_at"`
}