Files
pad/internal/store/export.go
T
xarmian 7e37dfc34e refactor(store): collections.settings boundary normalization + scan revert (IDEA-1484 follow-up) (#563)
* refactor(store): drop defensive sql.NullString scans on collections.settings (IDEA-1484 follow-up)

PR #562 (squash 0766d7e) hardened collections.settings to NOT NULL DEFAULT '{}'
at the schema level. The defensive sql.NullString scans introduced by PR #561
(BUG-1482, squash 714da48) and the paired import-side ""→"{}" coercion in
ImportWorkspace are no longer load-bearing — the database now enforces the
invariant the readers were defensively reconstructing.

Reverted sites:
- internal/store/collections.go: GetCollection, ListCollectionsMinimal,
  ListCollections — direct &c.Settings scans.
- internal/store/export.go: ExportWorkspace scan + ImportWorkspace coercion.
- internal/store/items.go: scanCollectionDoneFilters helper.
- internal/store/item_stars.go: buildCollectionDoneContextMap helper.

Test changes:
- Removed TestExportImportRoundTripWithEmptyStringSettings, whose purpose
  evaporated with the import-side coercion. The constraint-outcome tests
  (TestCollectionsSettingsNotNullEnforced, TestCollectionsSettingsDefaultsToEmptyObject)
  from PR #562 remain — they assert the load-bearing schema invariant.

* fix(store): restore import-side settings coercion (codex R1 P1)

Codex R1 caught that the prior commit reverted the import-side `""→"{}"`
coercion incorrectly. The NOT NULL DEFAULT '{}' schema constraint added by
PR #562 only fires when the INSERT omits the settings column — but
ImportWorkspace explicitly supplies the value. A legacy bundle or external
JSON workspace import whose `collections[].settings` is "" would therefore
bypass the default: Postgres rejects "" at JSONB type-validation; SQLite
silently stores invalid JSON.

The coercion was doing two jobs (BUG-1482 had folded them together):
  1. Defending against NULL-materialized-as-"" on read — obsolete now that
     the column cannot hold NULL.
  2. Defending against legacy/external "" settings on the import boundary —
     still required because schema constraints don't validate JSON.

Job #1's defense (the sql.NullString scans) stays reverted; the column
cannot hold NULL. Job #2's defense (the import-side coercion) is restored
and renamed in the comment to reflect that it's a boundary normalizer for
external data, not a transitional NULL-handler.

`TestExportImportRoundTripWithEmptyStringSettings` is restored with an
updated comment that makes the boundary-normalization framing explicit.
The constraint-outcome tests from PR #562 remain unchanged.

Tests pass on both drivers (SQLite full ./..., Postgres internal/store +
internal/server).

* fix(store): coerce empty-string settings in UpdateCollection (codex R2 P2)

Codex R2 surfaced UpdateCollection as the last writer path in the
collections.settings contract that didn't enforce the JSON-validity
invariant. A PATCH sending {"settings": ""} would write the empty string
verbatim, bypassing the NOT NULL DEFAULT '{}' constraint (which only fires
on column omission). Same failure mode as the ImportWorkspace bug R1
caught: Postgres rejects "" at JSONB type-validation, SQLite silently
stores invalid JSON.

Mirrors the boundary normalization restored in ImportWorkspace at 6f22f94.
Closes the contract loop for collections.settings — every writer path
(CreateCollection via json marshalling, ImportWorkspace, UpdateCollection)
now enforces JSON validity at the API boundary.

Added TestUpdateCollectionCoercesEmptyStringSettings to guard the
boundary. Also corrected a stale comment in
TestCollectionsSettingsDefaultsToEmptyObject that referenced
GetCollection's removed defensive scan.

The sibling-table UPDATE paths (UpdateItem at items.go:1442, UpdateView at
views.go:152) have the same defect class on items.fields/tags and
views.config; they are pre-existing, not introduced by this PR, and are
tracked in the sibling-table follow-up IDEA.
2026-05-15 19:57:22 -04:00

377 lines
13 KiB
Go

package store
import (
"fmt"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// ExportWorkspace exports all data for a workspace into a portable format.
func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
ws, err := s.GetWorkspaceBySlug(slug)
if err != nil {
return nil, fmt.Errorf("workspace lookup: %w", err)
}
if ws == nil {
return nil, fmt.Errorf("workspace not found: %s", slug)
}
export := &models.WorkspaceExport{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Workspace: models.WorkspaceExportMeta{
Name: ws.Name,
Slug: ws.Slug,
Description: ws.Description,
Settings: ws.Settings,
},
}
// Collections
rows, err := s.db.Query(s.q(`
SELECT id, name, slug, icon, description, schema, settings, prefix, sort_order, is_default, is_system, created_at, updated_at
FROM collections WHERE workspace_id = ? AND deleted_at IS NULL
ORDER BY sort_order, name`), ws.ID)
if err != nil {
return nil, fmt.Errorf("export collections: %w", err)
}
defer rows.Close()
for rows.Next() {
var c models.CollectionExport
var isDefault, isSystem bool
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Icon, &c.Description, &c.Schema, &c.Settings, &c.Prefix, &c.SortOrder, &isDefault, &isSystem, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan collection: %w", err)
}
c.IsDefault = isDefault
c.IsSystem = isSystem
export.Collections = append(export.Collections, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Items
itemRows, err := s.db.Query(s.q(`
SELECT id, collection_id, title, slug, content, fields, tags, pinned, sort_order,
COALESCE(parent_id, ''), created_by, last_modified_by, source, COALESCE(item_number, 0), created_at, updated_at
FROM items WHERE workspace_id = ? AND deleted_at IS NULL
ORDER BY created_at, id`), ws.ID)
if err != nil {
return nil, fmt.Errorf("export items: %w", err)
}
defer itemRows.Close()
for itemRows.Next() {
var it models.ItemExport
var pinned bool
if err := itemRows.Scan(&it.ID, &it.CollectionID, &it.Title, &it.Slug, &it.Content, &it.Fields, &it.Tags, &pinned, &it.SortOrder, &it.ParentID, &it.CreatedBy, &it.LastModifiedBy, &it.Source, &it.ItemNumber, &it.CreatedAt, &it.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan item: %w", err)
}
it.Pinned = pinned
export.Items = append(export.Items, it)
}
if err := itemRows.Err(); err != nil {
return nil, err
}
// Comments
commentRows, err := s.db.Query(s.q(`
SELECT c.id, c.item_id, c.author, c.body, c.created_by, c.source, c.created_at, c.updated_at
FROM comments c
JOIN items i ON c.item_id = i.id
WHERE c.workspace_id = ? AND i.deleted_at IS NULL
ORDER BY c.created_at`), ws.ID)
if err != nil {
return nil, fmt.Errorf("export comments: %w", err)
}
defer commentRows.Close()
for commentRows.Next() {
var cm models.CommentExport
if err := commentRows.Scan(&cm.ID, &cm.ItemID, &cm.Author, &cm.Body, &cm.CreatedBy, &cm.Source, &cm.CreatedAt, &cm.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan comment: %w", err)
}
export.Comments = append(export.Comments, cm)
}
if err := commentRows.Err(); err != nil {
return nil, err
}
// Item links — exported in full, including links whose source or target item
// is soft-deleted. This is intentional and differs from user-facing reads
// (GetItemLinks/GetParentForItem/GetParentMap, which all filter on
// items.deleted_at IS NULL — see BUG-734). Backups need to round-trip the
// raw graph so that re-importing into a workspace where the deleted items
// are restored preserves the original relationships. The import path
// already silently skips links whose endpoints are missing entirely.
linkRows, err := s.db.Query(s.q(`
SELECT id, source_id, target_id, link_type, created_by, created_at
FROM item_links WHERE workspace_id = ?
ORDER BY created_at`), ws.ID)
if err != nil {
return nil, fmt.Errorf("export item links: %w", err)
}
defer linkRows.Close()
for linkRows.Next() {
var lk models.ItemLinkExport
if err := linkRows.Scan(&lk.ID, &lk.SourceID, &lk.TargetID, &lk.LinkType, &lk.CreatedBy, &lk.CreatedAt); err != nil {
return nil, fmt.Errorf("scan item link: %w", err)
}
export.ItemLinks = append(export.ItemLinks, lk)
}
if err := linkRows.Err(); err != nil {
return nil, err
}
// Item versions
versionRows, err := s.db.Query(s.q(`
SELECT v.id, v.item_id, v.content, v.change_summary, v.created_by, v.source, v.is_diff, v.created_at
FROM item_versions v
JOIN items i ON v.item_id = i.id
WHERE i.workspace_id = ? AND i.deleted_at IS NULL
ORDER BY v.created_at`), ws.ID)
if err != nil {
return nil, fmt.Errorf("export item versions: %w", err)
}
defer versionRows.Close()
for versionRows.Next() {
var ver models.ItemVersionExport
var isDiff bool
if err := versionRows.Scan(&ver.ID, &ver.ItemID, &ver.Content, &ver.ChangeSummary, &ver.CreatedBy, &ver.Source, &isDiff, &ver.CreatedAt); err != nil {
return nil, fmt.Errorf("scan item version: %w", err)
}
ver.IsDiff = isDiff
export.ItemVersions = append(export.ItemVersions, ver)
}
if err := versionRows.Err(); err != nil {
return nil, err
}
return export, nil
}
// ImportWorkspace imports a workspace from an exported data structure.
// It creates a new workspace with regenerated IDs, remapping all references.
// If newName is non-empty, it overrides the workspace name and slug.
func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string, ownerID string) (*models.Workspace, error) {
if data.Version != 1 {
return nil, fmt.Errorf("unsupported export version: %d", data.Version)
}
// Determine workspace name/slug
wsName := data.Workspace.Name
wsSlug := data.Workspace.Slug
if newName != "" {
wsName = newName
wsSlug = newName
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{
Name: wsName,
Slug: wsSlug,
Description: data.Workspace.Description,
Settings: data.Workspace.Settings,
OwnerID: ownerID,
})
if err != nil {
return nil, fmt.Errorf("create workspace: %w", err)
}
// Run all data inserts in a single transaction for atomicity
tx, err := s.db.Begin()
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
// ID mapping: old ID -> new ID
collMap := make(map[string]string)
itemMap := make(map[string]string)
// Import collections
for _, c := range data.Collections {
newCollID := newID()
collMap[c.ID] = newCollID
// Coerce empty-string settings to a valid JSON object before insert.
// IDEA-1484 (PR #562) hardened collections.settings to NOT NULL
// DEFAULT '{}', but this INSERT explicitly supplies the settings
// column — so the DEFAULT clause does NOT fire when c.Settings is
// "". Without this coercion, Postgres rejects `""` at JSONB
// type-validation and SQLite silently stores invalid JSON. Legacy
// bundles and plain-JSON workspace imports (handlers_workspaces.go,
// handlers_import_bundle.go, cmd/pad/main.go's migrate command) can
// still carry "" settings, so normalization belongs at the import
// boundary rather than at the schema level.
settings := c.Settings
if settings == "" {
settings = "{}"
}
_, err := tx.Exec(s.q(`
INSERT INTO collections (id, workspace_id, name, slug, icon, description, schema, settings, prefix, sort_order, is_default, is_system, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, settings, c.Prefix, c.SortOrder, s.dialect.BoolToInt(c.IsDefault), s.dialect.BoolToInt(c.IsSystem),
c.CreatedAt, c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("import collection %s: %w", c.Name, err)
}
}
// Import items (first pass: create items, remap collection_id)
// Item numbers are assigned sequentially in created_at order to produce
// workspace-global numbering. Exported item_number values are ignored
// because old exports used per-collection numbering which can have
// duplicates within a workspace.
var nextItemNumber int
for _, it := range data.Items {
newItemID := newID()
itemMap[it.ID] = newItemID
newCollID := collMap[it.CollectionID]
if newCollID == "" {
continue // skip orphaned items
}
// On first pass, parent_id may refer to an item not yet created, so use empty
parentID := ""
if it.ParentID != "" {
if mapped, ok := itemMap[it.ParentID]; ok {
parentID = mapped
}
}
nextItemNumber++
// Stamp `seq` so workspace import populates the delta-sync cursor
// column (PLAN-1343 / TASK-1352). Each INSERT reads MAX(seq)+1
// within this transaction, so imported rows get sequential
// per-workspace seqs — clients post-import see them on the next
// /items-index fetch, and any subsequent mutation keeps bumping
// from a sensible floor instead of a flat MAX(seq)=0.
_, err := tx.Exec(s.q(`
INSERT INTO items (id, workspace_id, collection_id, title, slug, content, fields, tags, pinned, sort_order, parent_id, created_by, last_modified_by, source, item_number, created_at, updated_at, seq)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, `+nextWorkspaceSeqSubquery+`)`),
newItemID, ws.ID, newCollID, it.Title, it.Slug, it.Content, it.Fields, it.Tags, s.dialect.BoolToInt(it.Pinned), it.SortOrder,
parentID, it.CreatedBy, it.LastModifiedBy, it.Source, nextItemNumber,
it.CreatedAt, it.UpdatedAt, ws.ID)
if err != nil {
return nil, fmt.Errorf("import item %s: %w", it.Title, err)
}
}
// Second pass: remap parent_id and relation fields (now all items exist)
for _, it := range data.Items {
newItemID := itemMap[it.ID]
if newItemID == "" {
continue
}
// Remap relation fields now that ALL items are mapped
fields := remapFieldIDs(it.Fields, itemMap, collMap)
parentID := ""
if it.ParentID != "" {
if mapped, ok := itemMap[it.ParentID]; ok {
parentID = mapped
}
}
_, err := tx.Exec(s.q(`UPDATE items SET fields = ?, parent_id = NULLIF(?, '') WHERE id = ?`),
fields, parentID, newItemID)
if err != nil {
return nil, fmt.Errorf("remap item %s: %w", it.Title, err)
}
}
// Import comments
for _, cm := range data.Comments {
newItemID := itemMap[cm.ItemID]
if newItemID == "" {
continue
}
_, err := tx.Exec(s.q(`
INSERT INTO comments (id, item_id, workspace_id, author, body, created_by, source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
newID(), newItemID, ws.ID, cm.Author, cm.Body, cm.CreatedBy, cm.Source,
cm.CreatedAt, cm.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("import comment: %w", err)
}
}
// Import item links
for _, lk := range data.ItemLinks {
newSourceID := itemMap[lk.SourceID]
newTargetID := itemMap[lk.TargetID]
if newSourceID == "" || newTargetID == "" {
continue
}
_, err := tx.Exec(s.q(`
INSERT INTO item_links (id, workspace_id, source_id, target_id, link_type, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
newID(), ws.ID, newSourceID, newTargetID, lk.LinkType, lk.CreatedBy,
lk.CreatedAt)
if err != nil {
// Ignore duplicate links
continue
}
}
// Import item versions
for _, ver := range data.ItemVersions {
newItemID := itemMap[ver.ItemID]
if newItemID == "" {
continue
}
_, err := tx.Exec(s.q(`
INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
newID(), newItemID, ver.Content, ver.ChangeSummary, ver.CreatedBy, ver.Source, s.dialect.BoolToInt(ver.IsDiff),
ver.CreatedAt)
if err != nil {
// Log detail but skip — version history is non-critical
fmt.Printf("warning: skipped version for item %s: %v\n", ver.ItemID, err)
continue
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit import: %w", err)
}
// Rebuild FTS indexes for the new workspace (outside transaction)
s.rebuildFTSForWorkspace(ws.ID)
return ws, nil
}
// rebuildFTSForWorkspace rebuilds the FTS index for all items in a workspace.
// This is needed after import because direct INSERTs bypass the FTS triggers.
// Only applicable to SQLite (PostgreSQL uses trigger-maintained tsvector columns).
func (s *Store) rebuildFTSForWorkspace(wsID string) {
if s.dialect.Driver() != DriverSQLite {
return
}
rows, err := s.db.Query(s.q(`SELECT rowid, title, content, tags FROM items WHERE workspace_id = ? AND deleted_at IS NULL`), wsID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var rowid int64
var title, content, tags string
if err := rows.Scan(&rowid, &title, &content, &tags); err != nil {
continue
}
s.db.Exec(s.q(`INSERT INTO items_fts(rowid, title, content, tags) VALUES (?, ?, ?, ?)`), rowid, title, content, tags)
}
}
// remapFieldIDs replaces old UUIDs in a JSON fields string with their new IDs.
// This handles relation fields (e.g. parent: "uuid") without needing to parse the schema.
func remapFieldIDs(fieldsJSON string, itemMap, collMap map[string]string) string {
result := fieldsJSON
for oldID, newID := range itemMap {
if oldID != "" && newID != "" {
result = strings.ReplaceAll(result, oldID, newID)
}
}
return result
}