mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 18:43:45 +00:00
714da48442
* fix(store): make ListCollectionsMinimal Postgres-safe (BUG-1482)
`COALESCE(settings, '')` failed at planner time on Postgres because
`collections.settings` is JSONB and `''` is not valid JSON
(SQLSTATE 22P02). The query failed regardless of row contents; SQLite
is type-loose and accepted it, leaving the bug latent in the two
production callers (`handlers_dashboard.go`, `handlers_items.go`).
Switch the query to a plain `SELECT ... settings ...` and scan into
`sql.NullString`, materializing NULL as the empty-string sentinel.
This preserves the existing contract that downstream consumers
(`buildDoneContextMap`, `ListCollections`'s own scan loop) gate on
via `if c.Settings != ""`, so no caller-side changes are needed.
Adds two regression tests in `collections_test.go` that exercise the
NULL-settings case (the planner-time failure mode) and the happy-path
JSON round-trip. Both run against SQLite and Postgres via the existing
PAD_TEST_POSTGRES_URL switch in `testStore`.
* test(store): tighten ListCollectionsMinimal happy-path assertion (BUG-1482)
Codex review round 1 flagged TestListCollectionsMinimalReturnsSettingsJSON
as too permissive: `Settings != ""` would pass for `{}` or any wrong JSON
payload. Postgres JSONB also normalizes formatting/key order, so a string
compare against the input literal would be brittle across drivers.
Switch to a semantic compare: unmarshal both sides into map[string]any
and reflect.DeepEqual. This actually verifies the JSON round-trips
through the (now fixed) ListCollectionsMinimal path on both drivers.
* fix(store): NULL-safe settings scan in GetCollection / ListCollections / ExportWorkspace (BUG-1482)
Round-2 extension of the same fix shape. Direct `Scan(... &c.Settings ...)`
into a Go string fails on Postgres for any row holding a real NULL with
"Scan error: converting NULL to string is unsupported". The column is
nullable on both drivers (TEXT DEFAULT '{}' / JSONB DEFAULT '{}'), so
legacy or manually-poisoned rows can 500 every handler that goes through
these readers — `GetCollection` is the hot reader on every item handler,
`ListCollections` powers dashboard + sidebar, `ExportWorkspace` crashes
the export pipeline before any data is emitted.
Same fix as ListCollectionsMinimal: scan into sql.NullString, materialize
NULL as "" to preserve the existing sentinel contract that downstream
consumers gate on via `if c.Settings != ""` (handlers_dashboard.go:247,
handlers_items.go:1626, collections.go:196 in ListCollections's own
post-scan loop). Audited; no caller depends on a non-empty default.
Adds TestGetCollectionHandlesNullSettings, TestListCollectionsHandlesNullSettings,
and TestExportWorkspaceHandlesNullSettings — each forces a NULL via direct
UPDATE (bypassing CreateCollection's empty→`{}` coercion) and asserts the
function returns without error and surfaces "" downstream. All pass on
SQLite and Postgres.
* fix(store): coerce empty-string settings to {} on workspace import (BUG-1482)
The earlier commits in this PR made ExportWorkspace, GetCollection, and
ListCollections all return `""` for a NULL `collections.settings` row,
preserving the in-process sentinel contract that downstream consumers
(buildDoneContextMap and friends) already gate on via `c.Settings != ""`.
That fix surfaced a paired contract gap: ImportWorkspace previously
inserted `c.Settings` verbatim into the collections table. After the
reader fixes, an exported NULL-settings row materializes as `""` in the
bundle, which Postgres's JSONB column rejects at INSERT time. Without
this commit, exporting a workspace with any NULL-settings row and
re-importing it would have crashed on Postgres — turning one half of a
symmetric contract green while leaving the other half broken.
Mirror the same coercion CreateCollection applies on the normal create
path: when the bundle's settings field is the empty-string sentinel,
write `"{}"` instead. Add a round-trip regression test
(TestExportImportRoundTripWithNullSettings) that NULL-poisons a workspace's
settings, exports, re-imports, and asserts the re-imported collections
hold valid JSON. Verified on both drivers.
* style(store): rewrite doc comment to avoid gofmt apostrophe-pair rewrite
Go 1.19+ gofmt's doc-comment formatter collapses `''` (two ASCII
apostrophes) inside backtick code spans into a single `”` (U+201D right
double quotation mark) — a typographic-pair heuristic that doesn't quite
fit when the literal pair is the load-bearing thing being described
(here: SQL's empty-string literal in COALESCE).
CI's golangci-lint flagged the file as gofmt-dirty for this reason.
Rewrite the prose to describe the bug without using `''` literally:
"coalesced settings against an empty SQL string literal" reads more
clearly than `COALESCE(settings, '')` becoming `COALESCE(settings, ”)`
after gofmt normalization. Functionally identical comment; lint-clean.
385 lines
13 KiB
Go
385 lines
13 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"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
|
|
// `collections.settings` is nullable on both drivers; a NULL row
|
|
// would error out a direct scan into Go string on Postgres
|
|
// ("converting NULL to string is unsupported"). Materialize NULL
|
|
// as "" to keep the export's existing sentinel — same fix shape
|
|
// as ListCollectionsMinimal / GetCollection / ListCollections.
|
|
// See BUG-1482.
|
|
var settings sql.NullString
|
|
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Icon, &c.Description, &c.Schema, &settings, &c.Prefix, &c.SortOrder, &isDefault, &isSystem, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("scan collection: %w", err)
|
|
}
|
|
if settings.Valid {
|
|
c.Settings = settings.String
|
|
}
|
|
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 the empty-string sentinel back to a valid JSON object before
|
|
// insert. Exports of NULL-settings rows surface as `""` per the
|
|
// store-layer sentinel contract (BUG-1482 round-2); Postgres's JSONB
|
|
// column rejects `""` at write time, and SQLite would accept it but
|
|
// then downstream JSON parsers would choke. CreateCollection does the
|
|
// same coercion on the normal create path; this loop bypasses that
|
|
// helper for transactional/verbatim import, so we mirror it here.
|
|
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
|
|
}
|