mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
c17865462f
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)
U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.
U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.
* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)
* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)
* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)
* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)
* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)
* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
243 lines
12 KiB
Go
243 lines
12 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
|
|
// FilenameSource is the exporter's provenance for Filename (BUG-2819).
|
|
// Omitted by bundles that predate it. On import it is the BUNDLE's
|
|
// claim, not an observation by the importing server.
|
|
FilenameSource string `json:"filename_source,omitempty"`
|
|
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"`
|
|
// Source is how the collection was created ('web' | 'cli' | 'mcp'),
|
|
// BUG-3447. omitempty: an older bundle has none, and imports it as ''
|
|
// (provenance unknown). A value outside that set imports as '' too.
|
|
Source string `json:"source,omitempty"`
|
|
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"`
|
|
// CollabSetAside carries the edits a collab schema-version rebuild set
|
|
// aside on this item (BUG-3244), which no body holds. Unlike the op-log,
|
|
// which a bundle never carries, these travel: they can only be recovered
|
|
// by a decoder, not by a tab, so a copy of the rows is the whole of what
|
|
// can be kept. Additive and omitempty, on the same no-version-bump grounds
|
|
// as ContentState.
|
|
CollabSetAside []YjsSetAsideExport `json:"collab_set_aside,omitempty"`
|
|
// BuiltinOrigin is the built-in convention or playbook this item was made
|
|
// from (TASK-3462), so a restored workspace can still be offered updates
|
|
// to Pad's text. Additive and omitempty on the same grounds as
|
|
// CollabSetAside; a content hash means the same thing on any instance.
|
|
BuiltinOrigin *BuiltinOrigin `json:"builtin_origin,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"`
|
|
}
|
|
|
|
// YjsSetAsideExport is one set-aside edit row in a bundle (BUG-3244). The item
|
|
// it belongs to is the enclosing ItemExport; update_data is base64 in JSON.
|
|
type YjsSetAsideExport struct {
|
|
OpLogID int64 `json:"op_log_id"`
|
|
SchemaVersion string `json:"schema_version"`
|
|
CreatedAt string `json:"created_at"`
|
|
SetAsideAt string `json:"set_aside_at"`
|
|
UpdateData []byte `json:"update_data"`
|
|
}
|
|
|
|
// 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"`
|
|
// PLAN-2348 U2. The line counts and the create marker travel; user_id does
|
|
// not, because an id from another instance names nobody on this one.
|
|
// Additive and omitempty: a bundle from before them imports as unknown.
|
|
LinesAdded *int `json:"lines_added,omitempty"`
|
|
LinesRemoved *int `json:"lines_removed,omitempty"`
|
|
IsCreate bool `json:"is_create,omitempty"`
|
|
}
|