mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
1e9b87759e
* feat(store): comment_mentions index, parser, backfill (TASK-3917) Index of which humans a live comment @-mentions, written in the comment's transaction at every writer (create, edit, tombstone, fenced app doors, import), with a startup backfill. Migrations 154 (sqlite) and 123 (pg). Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(appstore): allow-list reindexCommentMentionsTx in the fence boundary (TASK-3917) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(appstore): the comment write census permits comment_mentions (TASK-3917) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * fix(store): comment mention index r1 fixes: skip imported, cap, parser rules, created_at, guard (TASK-3917 r1) F1 imported comments are never indexed (import, edit, backfill); F2 cap of 50 names; F3 parser "/" "\" "_" "." non-ASCII rules; F4 guard rewritten with per-decl statement counts over internal/ and cmd/; F5 created_at follows the comment (edit time for a newly added mention); F6 FOR KEY SHARE on PG; F8 backfill logs per-comment failures. Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(store): pin the backfill created_at against an aged comment (TASK-3917 r1) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * fix(links,store): follower and prefix rules, cap after resolution, guard ignores comment lines (TASK-3917 r2) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(links),docs(store): mid-word "_" prefix row; F6 skip comment names what reopens it (TASK-3917 r2) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * fix(links): linear URL check for "/" before "@"; store docs and guard test (TASK-3917 r3) r3 F1: the "/" prefix rule walked back to the previous whitespace for every match, so "/@a" repeated was quadratic (240 KB took ~10 s, in the comment write transaction). A forward scan now tracks the run start and the last "://" once per call. F2: the reindex doc no longer says ONE query. F3: the guard test legs are isolated. F4: callsReindex documents its fail-open cases. Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * fix(links): mention code-span check walks the spans once per call (TASK-3917 r4) isInRanges scanned every code range for every match, so many inline code spans followed by many mentions was quadratic (360 KB took ~1.1 s). A cursor over the sorted, disjoint ranges advances with the increasing match position. ExtractWikiLinks has the same shape and is left for its own item. Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * test(links): a row where the code-span cursor skips several spans at once (TASK-3917 r5) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * docs(links): CJK letter after a mention is a documented limit (TASK-3917 r3 F5, ruling 6250d6ec) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2056 lines
95 KiB
Go
2056 lines
95 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/collections"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// coerceJSONForImport normalizes an imported JSON column value to a
|
|
// well-formed default of the expected shape. Used at the import boundary
|
|
// (ImportWorkspace) where legacy / external workspace bundles may carry
|
|
// empty-string or malformed JSON that bypasses the column's NOT NULL
|
|
// DEFAULT (IDEA-1486) and that the handler-layer shape validators
|
|
// (IDEA-1488) never see. The lenient policy (coerce + log) matches the
|
|
// IDEA-1484 import-side precedent at export.go:206-209: don't break a
|
|
// workspace import because one row carries malformed data.
|
|
//
|
|
// - raw == "" → return defaultJSON (empty-string sentinel).
|
|
// - raw is well-formed JSON of the expected shape (non-nil object/array)
|
|
// → return raw verbatim.
|
|
// - raw is JSON `null`, unparseable, or wrong-shape → return defaultJSON
|
|
// AND log a structured warning naming the field + row. The raw
|
|
// value's LENGTH is logged (`raw_len`) but never its content, so
|
|
// user data does not leak into logs.
|
|
//
|
|
// expectObject==true requires the parsed value to be a JSON object
|
|
// (map[string]any); expectObject==false requires a JSON array
|
|
// ([]any) — used for items.tags.
|
|
//
|
|
// IDEA-1486 R1 codex P2: `json.Unmarshal("null", &m)` returns nil error
|
|
// and leaves the destination nil. Imported `fields: null` (or the
|
|
// string literal `"null"` at the JSON-encoded-string layer) would
|
|
// otherwise satisfy this validator and land as a JSONB null on
|
|
// Postgres (which technically satisfies NOT NULL — SQL NULL ≠ JSONB
|
|
// null) or the text "null" on SQLite. Downstream readers that expect
|
|
// an object would choke. The non-nil check below forces JSON null to
|
|
// the log-and-coerce path with the rest of the malformed shapes.
|
|
func coerceJSONForImport(raw, defaultJSON, field, rowID, workspaceID string, expectObject bool) string {
|
|
if raw == "" {
|
|
return defaultJSON
|
|
}
|
|
if expectObject {
|
|
var v map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &v); err == nil && v != nil {
|
|
return raw
|
|
}
|
|
} else {
|
|
var v []any
|
|
if err := json.Unmarshal([]byte(raw), &v); err == nil && v != nil {
|
|
return raw
|
|
}
|
|
}
|
|
slog.Warn("import_workspace coerced malformed json",
|
|
"field", field,
|
|
"row_id", rowID,
|
|
"workspace_id", workspaceID,
|
|
"raw_len", len(raw))
|
|
return defaultJSON
|
|
}
|
|
|
|
// importCoercedTitle is the item-title half of the same log-and-coerce policy
|
|
// (BUG-2833 / BUG-2831). It returns the title to write and, when it changed,
|
|
// the reason — so the caller can log one line carrying the row identity.
|
|
//
|
|
// COERCE, NOT REFUSE, and that is a ruling rather than a shortcut. The
|
|
// interactive doors (create, update, artifact import) REFUSE an empty or
|
|
// over-long title, because they are minting a new value from a live caller who
|
|
// can fix it. Import is restoring data this product already accepted: a
|
|
// 300-rune title has always been legal, so validating a bundle would turn
|
|
// "restore my archive" into a hard failure for rows we ourselves wrote, and
|
|
// `pad db migrate` — which is ExportWorkspace piped into this same INSERT —
|
|
// would refuse to migrate SQLite instances that work fine today.
|
|
//
|
|
// It also sits three lines from coerceJSONForImport, whose recorded IDEA-1488
|
|
// disposition is log-and-coerce "so a legacy bundle with one malformed item
|
|
// still imports". Refusing titles in the same loop would give one loop two
|
|
// dispositions for the same class of problem.
|
|
//
|
|
// The coercion does NOT weaken Dave's untitled-items ruling. The imported row
|
|
// lands WITH a title — the literal string "Untitled" — so the invariant that
|
|
// no door writes an empty title into the database holds here too, and the
|
|
// store's baseSlug == "" fallback stays defensive-only.
|
|
func importCoercedTitle(raw string) (string, string) {
|
|
title := models.NormalizeItemTitle(raw)
|
|
if title == "" {
|
|
return importUntitledTitle, "empty"
|
|
}
|
|
if n := utf8.RuneCountInString(title); n > models.MaxItemTitleRunes {
|
|
return string([]rune(title)[:models.MaxItemTitleRunes]), "too_long"
|
|
}
|
|
return title, ""
|
|
}
|
|
|
|
// importUntitledTitle is what an empty imported title becomes. Capitalized
|
|
// because it is a TITLE that a user will read in a list, not the lowercase
|
|
// "untitled" slug fallback in items.go — the two are deliberately different
|
|
// strings so a reader can tell which mechanism produced what they are looking
|
|
// at.
|
|
const importUntitledTitle = "Untitled"
|
|
|
|
// importCoercedSlug bounds an imported slug (BUG-2831). Coercing only the
|
|
// title would not close the import door: this loop writes `it.Slug` from the
|
|
// bundle VERBATIM rather than re-deriving it, and the slug — not the title —
|
|
// is what carries UNIQUE(workspace_id, slug) into a Postgres btree index tuple
|
|
// (capped at 2704 bytes in practice; see MaxItemTitleRunes for the
|
|
// measurement). A bundle from a SQLite instance holding a 2 MiB title carries a
|
|
// 2 MiB slug, and truncating the title alone would leave the INSERT failing
|
|
// exactly as before.
|
|
//
|
|
// Untouched unless it exceeds the bound: an in-range slug is written byte for
|
|
// byte, so nothing about ordinary round-tripping changes. Truncation can
|
|
// collide with another row in the same workspace, which is why the caller
|
|
// resolves the result through uniqueSlugQ inside the import transaction rather
|
|
// than hoping — but only for coerced slugs, so the uniqueness scan does not run
|
|
// for every row of a large import.
|
|
func importCoercedSlug(raw string) (string, bool) {
|
|
if utf8.RuneCountInString(raw) <= models.MaxItemTitleRunes {
|
|
return raw, false
|
|
}
|
|
return string([]rune(raw)[:models.MaxItemTitleRunes]), true
|
|
}
|
|
|
|
// ExportWorkspace exports all data for a workspace into a portable format,
|
|
// every section read from ONE snapshot (see ExportWorkspaceSnapshot).
|
|
func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
|
|
export, _, err := s.ExportWorkspaceSnapshot(context.Background(), slug, false)
|
|
return export, err
|
|
}
|
|
|
|
// ErrExportManifest marks an ExportWorkspaceSnapshot failure in the
|
|
// attachment-manifest read, after the export itself was read: the bundle door
|
|
// answers it 500 where it answers an export failure 404.
|
|
var ErrExportManifest = errors.New("export attachment manifest")
|
|
|
|
// ExportWorkspaceSnapshot reads a workspace export and, when withAttachments
|
|
// is set, the bundle's attachment rows, all inside one read-only transaction,
|
|
// so the archive is one instant of the database (BUG-3654).
|
|
//
|
|
// Read on the pool, each section took its own snapshot. A collection and its
|
|
// first item created between the collections scan and the items scan put the
|
|
// item in the bundle without its collection, and ImportWorkspace drops an item
|
|
// whose collection is missing. Links, comments and the attachment manifest had
|
|
// the same gap against the items they name.
|
|
//
|
|
// The transaction is READ ONLY, and on SQLite that is what keeps it from being
|
|
// a writer: the DSN's _txlock=immediate makes db.Begin() a BEGIN IMMEDIATE,
|
|
// which would hold the one write lock for the whole export, but modernc issues
|
|
// a plain BEGIN (deferred) for a ReadOnly transaction. A deferred transaction
|
|
// fixes its WAL snapshot at its first read and keeps it until it ends, and
|
|
// writers carry on beside it. On Postgres the default READ COMMITTED gives
|
|
// every statement a new snapshot, so the transaction asks for REPEATABLE READ,
|
|
// which keeps the first statement's snapshot for all of them.
|
|
func (s *Store) ExportWorkspaceSnapshot(ctx context.Context, slug string, withAttachments bool) (*models.WorkspaceExport, []models.Attachment, error) {
|
|
tx, err := s.beginReadSnapshot(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("begin export snapshot: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
export, err := s.ExportWorkspaceQ(tx, slug)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !withAttachments {
|
|
return export, nil, nil
|
|
}
|
|
ws, err := s.getWorkspaceBySlugQ(tx, slug)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("%w: workspace lookup: %w", ErrExportManifest, err)
|
|
}
|
|
if ws == nil {
|
|
return nil, nil, fmt.Errorf("%w: workspace not found: %s", ErrExportManifest, slug)
|
|
}
|
|
attachments, err := s.workspaceAttachmentsForExportQ(tx, ws.ID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("%w: %w", ErrExportManifest, err)
|
|
}
|
|
return export, attachments, nil
|
|
}
|
|
|
|
// BeginReadSnapshot is beginReadSnapshot for callers outside the store
|
|
// (BUG-3659, the account export). The caller rolls it back.
|
|
func (s *Store) BeginReadSnapshot(ctx context.Context) (*sql.Tx, error) {
|
|
return s.beginReadSnapshot(ctx)
|
|
}
|
|
|
|
// beginReadSnapshot opens a read-only transaction whose reads share one
|
|
// snapshot; ExportWorkspaceSnapshot says why each option is there.
|
|
func (s *Store) beginReadSnapshot(ctx context.Context) (*sql.Tx, error) {
|
|
opts := &sql.TxOptions{ReadOnly: true}
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
opts.Isolation = sql.LevelRepeatableRead
|
|
}
|
|
return s.db.BeginTx(ctx, opts)
|
|
}
|
|
|
|
// exportAfterCollectionsHook, when set by a test, runs between the collections
|
|
// section and the items section of ExportWorkspaceQ: the gap a concurrent
|
|
// writer would land in (BUG-3654).
|
|
var exportAfterCollectionsHook func(slug string)
|
|
|
|
// importedBodySHA256 is the body_sha256 an imported version row is stored
|
|
// with (BUG-3820): the bundle's value when it is a well-formed hex sha256,
|
|
// otherwise NULL, which the walk reads as "no hash" rather than as a body
|
|
// that mismatches.
|
|
func importedBodySHA256(h string) interface{} {
|
|
if len(h) != 64 {
|
|
return nil
|
|
}
|
|
for _, c := range h {
|
|
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
|
|
return nil
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// ExportWorkspaceQ is ExportWorkspace against a caller-supplied executor, so
|
|
// every section of a bundle can be read inside one transaction (BUG-3072).
|
|
//
|
|
// WHAT THE EXECUTOR ACTUALLY BUYS, corrected after measuring. The obvious
|
|
// story — that pooled reads take their own WAL snapshots, so the six sections
|
|
// below could disagree — is true of an export running with NO transaction held
|
|
// and FALSE of the migration, which is the caller this was written for. That
|
|
// caller holds a BEGIN IMMEDIATE across its whole run, and SQLite has one
|
|
// write lock, so nothing else can commit while it does: measured, a concurrent
|
|
// writer stays blocked for the duration and a pooled read sees exactly what
|
|
// the transaction sees.
|
|
//
|
|
// What passing a *sql.Tx buys is therefore not today's correctness but its
|
|
// GROUNDS. Consistency becomes a property of this transaction rather than of
|
|
// an exclusion argument that holds only while one transaction spans the entire
|
|
// command. Split that transaction per workspace, commit in the middle, or run
|
|
// this against a Postgres source where the pool has no single-writer property,
|
|
// and pooled reads begin to disagree with each other silently while
|
|
// transaction-scoped reads stay correct.
|
|
//
|
|
// The pool is still a legitimate executor, but its sections can disagree under
|
|
// concurrency. ExportWorkspace and the two HTTP export doors pass a read-only
|
|
// snapshot transaction instead (ExportWorkspaceSnapshot, BUG-3654).
|
|
func (s *Store) ExportWorkspaceQ(q Queryer, slug string) (*models.WorkspaceExport, error) {
|
|
ws, err := s.getWorkspaceBySlugQ(q, 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 — including SOFT-DELETED ones, which carry their deleted_at
|
|
// so the importer can reproduce the archive (BUG-2884).
|
|
//
|
|
// This section used to filter `deleted_at IS NULL` while the items section
|
|
// below filtered only on the ITEM's own deleted_at. DeleteCollection
|
|
// soft-deletes the collection row alone, so its items stay live and stay
|
|
// reachable — by ref, by id, and through search, since no item-bearing
|
|
// read in this package joins collection liveness. Two sections, two rules,
|
|
// and the bundle named a collection it did not contain: ImportWorkspace
|
|
// then dropped every such item on its orphan gate, with no log line for
|
|
// the item itself.
|
|
//
|
|
// The other repair — filtering items to live collections — was rejected on
|
|
// measurement rather than taste. `pad db migrate` is ExportWorkspace piped
|
|
// into ImportWorkspace (cmd/pad/cmd_db.go), so dropping those items would
|
|
// silently DELETE live, addressable rows during a SQLite→Postgres
|
|
// migration: a worse defect than the lossy backup it would fix.
|
|
rows, err := q.Query(s.q(`
|
|
SELECT id, name, slug, icon, description, schema, settings, traits, prefix, source, sort_order, is_default, is_system, created_at, updated_at, COALESCE(deleted_at, '')
|
|
FROM collections WHERE workspace_id = ?
|
|
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.Traits, &c.Prefix, &c.Source, &c.SortOrder, &isDefault, &isSystem, &c.CreatedAt, &c.UpdatedAt, &c.DeletedAt); 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
|
|
}
|
|
if exportAfterCollectionsHook != nil {
|
|
exportAfterCollectionsHook(slug)
|
|
}
|
|
|
|
// Items
|
|
//
|
|
// The `i` alias exists only so contentStateSQL can be spliced in (BUG-3032).
|
|
// The predicate marks a row whose body is BEHIND the item's live
|
|
// collaborative document; it rides in the SAME SELECT as `content` so the
|
|
// mark and the body it describes always come from one row — which is the
|
|
// only consistency this mark itself claims. Whether the bundle's separate
|
|
// SECTIONS agree with each other is a property of the EXECUTOR, not of this
|
|
// SELECT: on the pool they can disagree under concurrency (each connection
|
|
// takes its own WAL snapshot), and inside one transaction they cannot.
|
|
// BUG-3072 gave the migration the transaction for exactly that reason, and
|
|
// BUG-3654 gave the two HTTP export doors a read-only one.
|
|
itemRows, err := q.Query(s.q(`
|
|
SELECT i.id, i.collection_id, i.title, i.slug, i.content, `+contentStateSQL+`, i.fields, i.tags, i.pinned, i.sort_order,
|
|
COALESCE(i.parent_id, ''), i.created_by, i.last_modified_by, i.source, COALESCE(i.item_number, 0), i.created_at, i.updated_at
|
|
FROM items i WHERE i.workspace_id = ? AND i.deleted_at IS NULL
|
|
ORDER BY i.created_at, i.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.ContentState, &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
|
|
}
|
|
|
|
// Set-aside edits (BUG-3244), attached to their items. Read through the
|
|
// same executor as the items, so on the migration's transaction a row and
|
|
// the content_state that names it come from one snapshot.
|
|
itemIndex := make(map[string]int, len(export.Items))
|
|
for i := range export.Items {
|
|
itemIndex[export.Items[i].ID] = i
|
|
}
|
|
setAsideRows, err := q.Query(s.q(`
|
|
SELECT sa.item_id, sa.op_log_id, sa.update_data, sa.schema_version, sa.created_at, sa.set_aside_at
|
|
FROM item_yjs_updates_set_aside sa
|
|
JOIN items i ON i.id = sa.item_id
|
|
WHERE i.workspace_id = ? AND i.deleted_at IS NULL
|
|
ORDER BY sa.item_id, sa.op_log_id, sa.id`), ws.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export set-aside edits: %w", err)
|
|
}
|
|
defer setAsideRows.Close()
|
|
for setAsideRows.Next() {
|
|
var itemID string
|
|
var r models.YjsSetAsideExport
|
|
if err := setAsideRows.Scan(&itemID, &r.OpLogID, &r.UpdateData, &r.SchemaVersion, &r.CreatedAt, &r.SetAsideAt); err != nil {
|
|
return nil, fmt.Errorf("scan set-aside edit: %w", err)
|
|
}
|
|
if i, ok := itemIndex[itemID]; ok {
|
|
export.Items[i].CollabSetAside = append(export.Items[i].CollabSetAside, r)
|
|
}
|
|
}
|
|
if err := setAsideRows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Built-in origins (TASK-3462), attached to their items: one join, read
|
|
// through the same executor as the items.
|
|
originRows, err := q.Query(s.q(`
|
|
SELECT o.item_id, o.builtin_key, o.seed_hash, o.seed_content, o.seed_fields
|
|
FROM item_builtin_origin o
|
|
JOIN items i ON i.id = o.item_id
|
|
WHERE i.workspace_id = ? AND i.deleted_at IS NULL`), ws.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export built-in origins: %w", err)
|
|
}
|
|
defer originRows.Close()
|
|
for originRows.Next() {
|
|
var itemID string
|
|
var o models.BuiltinOrigin
|
|
var hash, content, fields sql.NullString
|
|
if err := originRows.Scan(&itemID, &o.Key, &hash, &content, &fields); err != nil {
|
|
return nil, fmt.Errorf("scan built-in origin: %w", err)
|
|
}
|
|
o.SeedHash, o.SeedContent, o.SeedFields = hash.String, content.String, fields.String
|
|
if i, ok := itemIndex[itemID]; ok {
|
|
export.Items[i].BuiltinOrigin = &o
|
|
}
|
|
}
|
|
if err := originRows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Comments
|
|
commentRows, err := q.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
|
|
-- A tombstone (BUG-3252) carries no words, and import does not
|
|
-- restore threading, so there is nothing left for it to hold.
|
|
AND c.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 := q.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
|
|
}
|
|
|
|
// Reminders — exported with their lifecycle marks intact, and ONLY for
|
|
// items this bundle actually carries.
|
|
//
|
|
// The comment that stood here said soft-deleted items' reminders were
|
|
// included so "a restore that brings the item back brings its reminder
|
|
// with it", copying the item_links rationale. That was false for this
|
|
// table: the items section filters on `deleted_at IS NULL`, so the item is
|
|
// NOT in the bundle, and there is no restore that could ever reunite them
|
|
// — the import simply drops the orphan on its itemMap lookup. Exporting
|
|
// them shipped rows that could only ever be discarded, under a comment
|
|
// asserting a benefit the bundle cannot deliver.
|
|
//
|
|
// item_links can carry soft-deleted endpoints because a link is a row
|
|
// ABOUT two items and the graph is worth round-tripping raw; a reminder
|
|
// whose item is absent is not a relationship, it is a dangling schedule.
|
|
reminderRows, err := q.Query(s.q(`
|
|
SELECT r.item_id, r.remind_at, COALESCE(r.fired_at, ''), COALESCE(r.acked_at, ''), r.created_at, r.updated_at
|
|
FROM item_reminders r
|
|
JOIN items i ON i.id = r.item_id AND i.workspace_id = r.workspace_id
|
|
WHERE r.workspace_id = ? AND i.deleted_at IS NULL
|
|
ORDER BY r.created_at, r.id`), ws.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export reminders: %w", err)
|
|
}
|
|
defer reminderRows.Close()
|
|
for reminderRows.Next() {
|
|
var rm models.ReminderExport
|
|
if err := reminderRows.Scan(&rm.ItemID, &rm.RemindAt, &rm.FiredAt, &rm.AckedAt, &rm.CreatedAt, &rm.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("scan reminder: %w", err)
|
|
}
|
|
export.Reminders = append(export.Reminders, rm)
|
|
}
|
|
if err := reminderRows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Verifications (TASK-3617 r4), for the items this bundle carries, on the
|
|
// same rule as reminders above: a verdict whose item is absent could only
|
|
// be discarded on import.
|
|
verRows, err := q.Query(s.q(`
|
|
SELECT v.item_id, v.criterion_id, v.criterion_hash, v.spec_seq, v.code_revision, v.verdict,
|
|
v.evidence, v.evidence_url, COALESCE(v.via_item_id, ''), v.run_id, v.created_by, v.agent_name, v.created_at
|
|
FROM item_verifications v
|
|
JOIN items i ON i.id = v.item_id AND i.workspace_id = v.workspace_id
|
|
WHERE v.workspace_id = ? AND i.deleted_at IS NULL
|
|
ORDER BY v.created_at, v.id`), ws.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export verifications: %w", err)
|
|
}
|
|
defer verRows.Close()
|
|
for verRows.Next() {
|
|
var v models.VerificationExport
|
|
if err := verRows.Scan(&v.ItemID, &v.CriterionID, &v.CriterionHash, &v.SpecSeq, &v.CodeRevision, &v.Verdict,
|
|
&v.Evidence, &v.EvidenceURL, &v.ViaItemID, &v.RunID, &v.CreatedBy, &v.AgentName, &v.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("scan verification: %w", err)
|
|
}
|
|
export.Verifications = append(export.Verifications, v)
|
|
}
|
|
if err := verRows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Item versions
|
|
versionRows, err := q.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,
|
|
v.lines_added, v.lines_removed, v.is_create, COALESCE(v.body_sha256, '')
|
|
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, v.version_seq`), 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, isCreate bool
|
|
var added, removed sql.NullInt64
|
|
if err := versionRows.Scan(&ver.ID, &ver.ItemID, &ver.Content, &ver.ChangeSummary, &ver.CreatedBy, &ver.Source, &isDiff, &ver.CreatedAt,
|
|
&added, &removed, &isCreate, &ver.BodySHA256); err != nil {
|
|
return nil, fmt.Errorf("scan item version: %w", err)
|
|
}
|
|
ver.IsDiff = isDiff
|
|
ver.IsCreate = isCreate
|
|
ver.LinesAdded = nullIntPtr(added)
|
|
ver.LinesRemoved = nullIntPtr(removed)
|
|
export.ItemVersions = append(export.ItemVersions, ver)
|
|
}
|
|
if err := versionRows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return export, nil
|
|
}
|
|
|
|
// resolveImportParent maps an exported parent_id onto the id its item received
|
|
// in this import, and returns "" — meaning no parent — unless that item
|
|
// actually landed.
|
|
//
|
|
// itemMap alone is not enough (BUG-2884, codex round 3). It holds an entry for
|
|
// every item in the bundle INCLUDING orphans, written before the orphan skip
|
|
// because parent resolution reads it for items the loop has not reached yet.
|
|
// So `mapped != ""` was satisfied by an id naming no row, and a live child of
|
|
// an archived-collection parent wrote that id into items.parent_id — a foreign
|
|
// key. It fails on both drivers (SQLite runs with foreign_keys ON), fatally on
|
|
// the first pass and by aborting the transaction on Postgres.
|
|
//
|
|
// Dropping the edge is the only thing the bundle can express: the parent is
|
|
// not in it, so there is nothing to point at. item_links already drops a link
|
|
// whose endpoint is missing, for the same reason.
|
|
//
|
|
// Both passes call this, and both need it: the first because an orphan earlier
|
|
// in the bundle's order is already mapped by the time a later child is
|
|
// inserted, the second because by then every item is mapped.
|
|
func resolveImportParent(exportedParentID string, itemMap map[string]string, insertedItems map[string]bool) string {
|
|
if exportedParentID == "" {
|
|
return ""
|
|
}
|
|
mapped, ok := itemMap[exportedParentID]
|
|
if !ok || !insertedItems[mapped] {
|
|
return ""
|
|
}
|
|
return mapped
|
|
}
|
|
|
|
// 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 the slug is
|
|
// derived from it.
|
|
// source is the creation-surface attribution, derived by the CALLER from
|
|
// the request's auth shape exactly as the create door derives it (BUG-1557,
|
|
// BUG-2809). It is a parameter rather than a field on the export because an
|
|
// export carries no source: a bundle says what the workspace WAS, and where
|
|
// this copy is being minted from is a fact about this request. Operator
|
|
// callers outside HTTP pass "".
|
|
// ImportReport describes what an import normalised on the way in, for the
|
|
// caller to surface (BUG-2896).
|
|
type ImportReport struct {
|
|
// CollapsedDuplicateKeys counts stored blobs (collection traits and item
|
|
// fields) that repeated a JSON member name and were stored collapsed, so
|
|
// SQLite's json_extract (first occurrence) and Go and Postgres (last) read
|
|
// the same value. Every other blob is stored verbatim.
|
|
CollapsedDuplicateKeys int
|
|
// ItemIDs maps each imported item's SOURCE id to its new id, set on a
|
|
// successful import (BUG-3357). The bundle door attaches rehydrated
|
|
// attachments through it: it used to go through the item SLUG, which the
|
|
// import renames when two items share one, so an attachment landed on
|
|
// the wrong item.
|
|
ItemIDs map[string]string
|
|
// DroppedRelationDefaults names each "collection.field" whose relation
|
|
// default named an item by an id the import has no item for (BUG-3658).
|
|
// The default is removed rather than left naming nothing; a value that is
|
|
// not an id (a ref like TASK-2) is kept, since it resolves late.
|
|
DroppedRelationDefaults []string
|
|
// DroppedUniqueValues lists each unique value the import removed from an
|
|
// item because an item earlier in the archive already held it (BUG-3825).
|
|
// An import creates a new workspace, so a duplicate can only come from
|
|
// inside the archive. The first item in archive order keeps the value.
|
|
DroppedUniqueValues []models.ImportUniqueDrop
|
|
}
|
|
|
|
// ImportWorkspace is ImportWorkspaceWithReport for callers with no use for the
|
|
// report.
|
|
func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string, ownerID string, source string, opts ...MintOption) (*models.Workspace, error) {
|
|
ws, _, err := s.ImportWorkspaceWithReport(data, newName, ownerID, source, opts...)
|
|
return ws, err
|
|
}
|
|
|
|
// ImportWorkspaceWithReport imports an archive as a new workspace and reports
|
|
// what it normalised on the way in.
|
|
func (s *Store) ImportWorkspaceWithReport(data *models.WorkspaceExport, newName string, ownerID string, source string, opts ...MintOption) (*models.Workspace, ImportReport, error) {
|
|
var report ImportReport
|
|
ws, err := s.importWorkspace(data, newName, ownerID, source, &report, opts...)
|
|
return ws, report, err
|
|
}
|
|
|
|
// checkImportSourceIDs refuses an export in which two collections, or two
|
|
// items, share a source id (BUG-3357). The import remaps every reference by
|
|
// that id (an item's collection, its parent, links, comments, versions,
|
|
// reminders, relation fields), so a duplicate collapses two rows into one
|
|
// mapping and every reference to the id lands silently on whichever row was
|
|
// written last. It runs before the transaction opens, so a refused bundle
|
|
// writes nothing. An EMPTY id counts like any other (codex r1): the maps key
|
|
// on "" too, so two rows without an id collide and a reference written as ""
|
|
// resolves to whichever came last. One row without an id stays importable,
|
|
// as it always was: a hand-built export may omit ids, and a lone "" is
|
|
// unambiguous.
|
|
//
|
|
// Comment, link and version ids are deliberately not checked: no other row
|
|
// refers to them, so a duplicate there cannot misdirect anything.
|
|
func checkImportSourceIDs(data *models.WorkspaceExport) error {
|
|
seen := make(map[string]bool, len(data.Collections))
|
|
for _, c := range data.Collections {
|
|
if seen[c.ID] {
|
|
if c.ID == "" {
|
|
return invalidf("the export has more than one collection with no id; every collection must have its own id")
|
|
}
|
|
return invalidf("the export has a duplicate collection id %q; every collection must have its own id", c.ID)
|
|
}
|
|
seen[c.ID] = true
|
|
}
|
|
seen = make(map[string]bool, len(data.Items))
|
|
for _, it := range data.Items {
|
|
if seen[it.ID] {
|
|
if it.ID == "" {
|
|
return invalidf("the export has more than one item with no id; every item must have its own id")
|
|
}
|
|
return invalidf("the export has a duplicate item id %q; every item must have its own id", it.ID)
|
|
}
|
|
seen[it.ID] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) importWorkspace(data *models.WorkspaceExport, newName string, ownerID string, source string, report *ImportReport, opts ...MintOption) (*models.Workspace, error) {
|
|
if data.Version != 1 {
|
|
return nil, fmt.Errorf("unsupported export version: %d", data.Version)
|
|
}
|
|
if err := checkImportSourceIDs(data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mintOpts := resolveMintOptions(opts)
|
|
|
|
// Determine workspace name/slug
|
|
wsName := data.Workspace.Name
|
|
wsSlug := data.Workspace.Slug
|
|
if newName != "" {
|
|
// The override names the copy; its slug derives from that name the
|
|
// way a create's does (BUG-3769), not from the raw name.
|
|
wsName = newName
|
|
wsSlug = ""
|
|
}
|
|
|
|
// Run all data inserts in a single transaction for atomicity — INCLUDING
|
|
// the workspace row itself (BUG-2892).
|
|
//
|
|
// The workspace used to be minted first, by a CreateWorkspace call that
|
|
// committed on its own. Eight error returns sit between that write and the
|
|
// commit below (begin, de-duplicate declarations, import collection, item
|
|
// slug-after-truncation, import item, remap item, import comment, and the
|
|
// commit itself), and every one of them returned an error while leaving the
|
|
// workspace row behind: named, slugged, owned by the caller, holding no
|
|
// collections and no items.
|
|
//
|
|
// The husk was not only clutter. A husk is a live row, so it kept the
|
|
// slug: an operator who fixed the bundle and retried landed
|
|
// on `name-2`, and that slug is in every URL for the workspace afterwards.
|
|
// The attempt that stored nothing took the name from the one that worked.
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// createWorkspaceQ rather than CreateWorkspace: the slug probe and the
|
|
// read-back must run on THIS transaction, both so they see its own
|
|
// uncommitted row and so neither reaches for the pool while the
|
|
// transaction holds its connection (BUG-2778's deadlock class).
|
|
ws, err := s.createWorkspaceQ(tx, models.WorkspaceCreate{
|
|
Name: wsName,
|
|
Slug: wsSlug,
|
|
Description: data.Workspace.Description,
|
|
Settings: data.Workspace.Settings,
|
|
OwnerID: ownerID,
|
|
Source: source,
|
|
}, mintOpts.preserveSlug)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create workspace: %w", err)
|
|
}
|
|
|
|
// ID mapping: old ID -> new ID
|
|
collMap := make(map[string]string)
|
|
itemMap := make(map[string]string)
|
|
|
|
// Import collections
|
|
// Workspace-level trait conflicts in the ARCHIVE. The API gate refuses a
|
|
// duplicate artifact_kind / invocation_field at create and update, but an
|
|
// archive is written elsewhere and arrives whole, so it can carry a pair
|
|
// the gate never saw. Import does not REFUSE it — an archive is often the
|
|
// only copy of a workspace, and rejecting the whole restore over an
|
|
// ambiguity the resolvers can still work through would be the wrong
|
|
// trade. What must not happen is that it lands silently: with two
|
|
// declarations live, which collection receives an imported artifact or
|
|
// answers an invocation slug depends on collection order. Warn so the
|
|
// operator can fix it. Codex round 8.
|
|
// DE-DUPLICATED ON THE WAY IN, not warned about and inserted (TASK-2710
|
|
// item 4, and codex round 2's P1 when it was still the latter).
|
|
//
|
|
// Import used to warn and insert both, which was right while nothing
|
|
// forbade the pair. With the partial unique indexes the second INSERT is
|
|
// refused and the whole transaction rolls back — so an archive carrying a
|
|
// duplicate would be unimportable, and archives carrying duplicates are
|
|
// exactly the ones this release repairs.
|
|
//
|
|
// That rollback now takes the workspace row with it (BUG-2892); when this
|
|
// paragraph was written the workspace was minted before the transaction
|
|
// and survived the failure as a husk, which made the same archive both
|
|
// unimportable AND a source of empty workspaces. The de-duplication is
|
|
// what keeps it importable; the transaction boundary only decides how
|
|
// cleanly the other failures fail.
|
|
//
|
|
// THE CHECK HAPPENS IN THE INSERT LOOP, ON THE FINAL BYTES, and that
|
|
// placement is the fix for codex round 3's P1 rather than a style choice.
|
|
// An earlier version pre-computed the strips from the traits the BUNDLE
|
|
// carries, which is not what gets written: coercion, validation-discard and
|
|
// canonical inference all run afterwards. A pre-traits archive carrying
|
|
// `conventions` with an EMPTY blob has its declaration INFERRED from the
|
|
// slug (BUG-2702), so a bundle pairing that with an explicit declarer
|
|
// showed the pre-pass one declaration and the database two — and the index
|
|
// aborted the import. Checking immediately before the INSERT makes the
|
|
// question "what am I about to write" instead of "what did the file say".
|
|
//
|
|
// The FIRST declaring collection in bundle order keeps the declaration and
|
|
// later ones are stripped, which is the disposition dedupeTraitDeclarations
|
|
// applies to an existing database. It cannot use that pass's primary rule —
|
|
// most user-written items — because items are inserted after collections
|
|
// and every count here is zero; bundle order IS the terminator, and the log
|
|
// says so rather than implying a considered choice. The loser keeps every
|
|
// item; only the declaration is dropped.
|
|
//
|
|
// ARCHIVED collections take no part in this (BUG-2884). Since the bundle
|
|
// started carrying soft-deleted collections, an archived collection can
|
|
// travel alongside the live one that replaced it, still declaring the same
|
|
// kind — and it arrives FIRST if it was created first, because the bundle
|
|
// is in creation order. It must neither CLAIM the kind, which would strip
|
|
// the live collection later in the bundle and leave the workspace with
|
|
// routing owned by a row every resolver filters out
|
|
// (ListTraitedCollections is deleted_at IS NULL), nor LOSE its own, which
|
|
// would edit data the operator archived rather than deleted. Both partial
|
|
// unique indexes carry `AND deleted_at IS NULL`, so an archived row is
|
|
// outside the constraint and can conflict with nothing.
|
|
seenKinds := map[string]string{}
|
|
invocationCollection := ""
|
|
// BUG-3825: per new collection, its ref prefix and the keys whose values
|
|
// must be unique among its live items. See importUniqueKeys.
|
|
collPrefix := map[string]string{}
|
|
collUniqueKeys := map[string][]string{}
|
|
|
|
for _, c := range data.Collections {
|
|
newCollID := newID()
|
|
collMap[c.ID] = newCollID
|
|
|
|
// Coerce empty-string / malformed 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 "" or
|
|
// malformed settings, so normalization belongs at the import
|
|
// boundary rather than at the schema level. IDEA-1488 extends
|
|
// this to log-and-coerce on non-empty malformed JSON.
|
|
settings := coerceJSONForImport(c.Settings, "{}", "collections.settings", c.ID, ws.ID, true)
|
|
// A bundle written before PLAN-3535 has no tracks_work. The migration
|
|
// marked every existing SYSTEM collection reference, so an imported
|
|
// one gets the same; any other collection keeps the absent key, which
|
|
// means work, as the migration left it. A value the bundle carries is
|
|
// kept.
|
|
if c.IsSystem {
|
|
settings = models.WithTracksWorkDefault(settings, c.Schema, true)
|
|
}
|
|
// Same coercion for traits, and for the same reason: this INSERT
|
|
// supplies the column explicitly, so the NOT NULL DEFAULT '{}' never
|
|
// fires. Archives written before TASK-2657 carry no traits key at
|
|
// all, which lands here as "" — a pre-traits archive imports as a
|
|
// collection that declares nothing, which is the honest reading.
|
|
traits := coerceJSONForImport(c.Traits, "{}", "collections.traits", c.ID, ws.ID, true)
|
|
// coerceJSONForImport only guarantees the blob is valid JSON. Traits
|
|
// are declarations that switch kernel behavior on, so an import is
|
|
// held to the same grammar the API enforces — otherwise a
|
|
// hand-edited or foreign archive can persist a declaration that
|
|
// parses as JSON, fails the trait parse, and degrades to "declares
|
|
// nothing", silently disabling bootstrap or invocation routing for
|
|
// the imported workspace.
|
|
//
|
|
// Degrade rather than reject: an archive is often the only copy of a
|
|
// workspace, and refusing the whole import over one bad declaration
|
|
// would be worse than importing it with that collection declaring
|
|
// nothing — which is exactly what a pre-traits archive does anyway.
|
|
// Logged so it isn't silent. TASK-2657.
|
|
// Reject declarations that don't parse or don't validate. The log
|
|
// deliberately does NOT claim what the collection ends up with —
|
|
// inference below may still give it the canonical set, and an earlier
|
|
// version of this said "importing with no declarations" and was then
|
|
// contradicted three lines later. Codex round 6.
|
|
discarded := false
|
|
if parsed, perr := models.ParseCollectionTraits(traits); perr != nil {
|
|
slog.Warn("import: collection traits could not be parsed; discarding them",
|
|
"collection", c.Slug, "workspace_id", ws.ID, "error", perr)
|
|
traits = "{}"
|
|
discarded = true
|
|
} else if verr := parsed.Validate(); verr != nil {
|
|
slog.Warn("import: collection traits failed validation; discarding them",
|
|
"collection", c.Slug, "workspace_id", ws.ID, "error", verr)
|
|
traits = "{}"
|
|
discarded = true
|
|
} else if key, dup := models.FirstDuplicateJSONKey([]byte(traits)); dup {
|
|
// BUG-2896: a repeated member means SQLite's unique index
|
|
// (json_extract, FIRST occurrence) and the resolver (Go, LAST,
|
|
// merging a repeated object into one struct) read different
|
|
// declarations. Store the resolver's own encoding of what it
|
|
// parsed, so every reader sees one declaration. Only here: a blob
|
|
// without a repeat keeps its bytes.
|
|
canon, jerr := parsed.JSON()
|
|
if jerr != nil {
|
|
return nil, fmt.Errorf("import collection %s: encode traits: %w", c.Slug, jerr)
|
|
}
|
|
slog.Warn("import: collection traits repeated a JSON member; stored collapsed",
|
|
"collection", c.Slug, "workspace_id", ws.ID, "member", key)
|
|
traits = canon
|
|
report.CollapsedDuplicateKeys++
|
|
}
|
|
|
|
// COMPATIBILITY INFERENCE for archives written before traits existed.
|
|
//
|
|
// Without this, importing a pre-TASK-2657 export silently reproduces
|
|
// exactly the defect traits were introduced to fix ([[BUG-2702]]): the
|
|
// migration backfill cannot help, because it ran long before these
|
|
// rows were inserted, so the imported conventions/playbooks
|
|
// collections would carry no declarations and the workspace would
|
|
// come up with no always-on rules, no playbook invocation routing and
|
|
// no artifact export. The archive looks fine and the workspace is
|
|
// quietly inert.
|
|
//
|
|
// Only applied when the collection declares NOTHING. A declaration
|
|
// that survived the round trip is authoritative, including the
|
|
// deliberate empty one a user can set — but an archive that predates
|
|
// the column cannot be distinguished from that case, and restoring a
|
|
// working workspace is worth more than honouring a deliberate clear
|
|
// that only round-trips through an export. Slug-keyed for the same
|
|
// reason the migration is: on a row with no declarations, the slug is
|
|
// the only evidence of intent that exists. Codex round 4.
|
|
//
|
|
// Not applied to an ARCHIVED collection (BUG-2884): inference exists so
|
|
// a restored workspace comes up ROUTING, and an archived collection
|
|
// never routes. Stamping it would only add a log line about a
|
|
// declaration nothing reads — and, where the archived collection is a
|
|
// renamed predecessor of the live one, would put the canonical set on
|
|
// two rows.
|
|
if inferred := collections.CanonicalTraitsForSlug(c.Slug); !inferred.IsZero() && c.DeletedAt == "" {
|
|
if current, err := models.ParseCollectionTraits(traits); err == nil && current.IsZero() {
|
|
if encoded, err := inferred.JSON(); err == nil {
|
|
// Two different situations reach here and the log must
|
|
// distinguish them: an archive that never had traits
|
|
// (expected, benign) versus one whose declarations were
|
|
// just thrown away (a data problem the operator should
|
|
// know about, even though the outcome is a working
|
|
// collection either way).
|
|
if discarded {
|
|
slog.Warn("import: collection's invalid declarations were discarded; substituting the canonical set for its slug",
|
|
"collection", c.Slug, "workspace_id", ws.ID)
|
|
} else {
|
|
slog.Info("import: collection carried no trait declarations; inferring the canonical set from its slug",
|
|
"collection", c.Slug, "workspace_id", ws.ID)
|
|
}
|
|
traits = encoded
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now that `traits` is final — coerced, validated, and inferred — drop
|
|
// any declaration another collection in this bundle already took.
|
|
var derr error
|
|
traits, derr = dropDuplicateImportDeclarations(traits, c.Slug, c.DeletedAt, ws.ID, seenKinds, &invocationCollection)
|
|
if derr != nil {
|
|
return nil, fmt.Errorf("de-duplicate declarations for %s: %w", c.Slug, derr)
|
|
}
|
|
|
|
// A PREFIX MUST BE RESOLVABLE, even on a restore (BUG-2943). Import
|
|
// is the one door where refusing has a real cost — a workspace that
|
|
// already carries a bad prefix would fail to come back — so it is the
|
|
// most permissive door that can still be honest: anything the ref
|
|
// parser resolves is accepted, which now includes digits after the
|
|
// first letter, and only a prefix NO surface could resolve is
|
|
// refused. Carrying that one verbatim would restore a workspace whose
|
|
// items print issue IDs the CLI answers "not found" to, which is the
|
|
// defect this item exists for rather than a compatibility we owe.
|
|
//
|
|
// The message names the collection and says the export is editable,
|
|
// because the operator holds the file and a one-character edit is the
|
|
// whole remedy.
|
|
//
|
|
// AN ABSENT prefix is not an unresolvable one. Exports written before
|
|
// the field existed — and every fixture in the suite — carry "", and
|
|
// the first version of this check refused them, which would have
|
|
// turned a fix for unresolvable IDs into a fix that cannot restore an
|
|
// old bundle at all. An empty prefix takes the same fallback
|
|
// CreateCollection applies (derive from the name, else ITEM), which
|
|
// also upgrades it: an empty prefix is itself an id-space nothing can
|
|
// resolve, since a ref would begin with "-".
|
|
prefix := c.Prefix
|
|
if prefix == "" {
|
|
prefix = collections.DerivePrefix(c.Name)
|
|
}
|
|
if prefix == "" {
|
|
prefix = "ITEM"
|
|
}
|
|
if !collections.IsValidPrefix(prefix) {
|
|
return nil, invalidf("import collection %q: prefix %q cannot be resolved — a collection prefix must start with an uppercase letter and contain only uppercase letters or digits; edit the prefix for this collection in the export and import again", c.Name, c.Prefix)
|
|
}
|
|
if hasDigit(prefix) {
|
|
// Accepted only because the parser widened for this fix. Logged so
|
|
// an operator can see an id-space that would have been rejected
|
|
// before, rather than discovering it from a resolve failure that
|
|
// no longer happens.
|
|
slog.Warn("imported collection with a digit-bearing prefix",
|
|
"workspace", ws.Slug, "collection", c.Slug, "prefix", prefix)
|
|
}
|
|
|
|
// abandoned_options must be a subset of terminal_options, as the
|
|
// create/update doors require (BUG-2347, codex round 1 on #1464): a
|
|
// declared list REPLACES the global fallback, so an invalid one would
|
|
// make a real abandon value count as shipped. Only a schema that
|
|
// parses is checked; the rest of the blob stays verbatim, as always.
|
|
var importedSchema models.CollectionSchema
|
|
if c.Schema != "" && models.UnmarshalItemFieldSchema([]byte(c.Schema), &importedSchema) == nil {
|
|
if err := models.ValidateAbandonedOptions(importedSchema); err != nil {
|
|
return nil, invalidf("import collection %q: %v; fix abandoned_options in the export and import again", c.Name, err)
|
|
}
|
|
}
|
|
collPrefix[newCollID] = prefix
|
|
collUniqueKeys[newCollID] = importUniqueKeys(importedSchema)
|
|
|
|
// NULLIF so an ABSENT deleted_at — every archive written before
|
|
// BUG-2884, which decodes the missing key as "" — imports the
|
|
// collection LIVE. That direction is what keeps old bundles working;
|
|
// only a non-empty mark reproduces an archive.
|
|
_, err := tx.Exec(s.q(`
|
|
INSERT INTO collections (id, workspace_id, name, slug, icon, description, schema, settings, traits, prefix, source, sort_order, is_default, is_system, created_at, updated_at, deleted_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''))`),
|
|
newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, settings, traits, prefix, models.ValidCollectionSource(c.Source), c.SortOrder, s.dialect.BoolToInt(c.IsDefault), s.dialect.BoolToInt(c.IsSystem),
|
|
c.CreatedAt, c.UpdatedAt, c.DeletedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("import collection %s: %w", c.Name, err)
|
|
}
|
|
}
|
|
|
|
// Preserve modern archives' reference numbers, including deletion gaps and
|
|
// out-of-order items: content and comments still refer to those numbers.
|
|
// Legacy archives used per-collection numbering (or had no numbers), so
|
|
// retain sequential allocation when the imported set is not workspace-unique.
|
|
// Orphans are skipped below and must not force valid items to be renumbered.
|
|
//
|
|
// The fallback is logged (TASK-2923): it silently moves every reference
|
|
// number, so an operator whose [[TASK-2]] now points elsewhere needs to
|
|
// find why. The scan runs to the end so the line can count each reason,
|
|
// as the coercions below do for theirs.
|
|
preserveItemNumbers := true
|
|
seenItemNumbers := make(map[int]bool, len(data.Items))
|
|
var missingNumbers, invalidNumbers, duplicateNumbers int
|
|
var firstRenumberRow string
|
|
for _, it := range data.Items {
|
|
if collMap[it.CollectionID] == "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case it.ItemNumber == 0:
|
|
missingNumbers++
|
|
case it.ItemNumber < 0:
|
|
invalidNumbers++
|
|
case seenItemNumbers[it.ItemNumber]:
|
|
duplicateNumbers++
|
|
default:
|
|
seenItemNumbers[it.ItemNumber] = true
|
|
continue
|
|
}
|
|
if preserveItemNumbers {
|
|
firstRenumberRow = it.ID
|
|
}
|
|
preserveItemNumbers = false
|
|
}
|
|
if !preserveItemNumbers {
|
|
slog.Warn("import_workspace renumbered items sequentially: the archive's item numbers are not positive and workspace-unique, so references to them (e.g. [[TASK-2]]) may now point at different items",
|
|
"workspace_id", ws.ID,
|
|
"missing", missingNumbers,
|
|
"invalid", invalidNumbers,
|
|
"duplicate", duplicateNumbers,
|
|
"first_row_id", firstRenumberRow)
|
|
}
|
|
|
|
// items_per_workspace, resolved here and compared after the insert loop
|
|
// (BUG-3103). Before this, ImportWorkspace enforced only the user-scoped
|
|
// `workspaces` cap, so a free-plan owner could import a workspace holding
|
|
// more items than the plan allows — every other door that raises the count
|
|
// (create, artifact import, cross-workspace copy, restore) refuses, and
|
|
// this one did not.
|
|
//
|
|
// THE LOCK GUARDS NOTHING HERE and is taken only so this door reads like
|
|
// #1393's. ImportWorkspace mints the workspace inside this same transaction
|
|
// (BUG-2892), so the destination does not exist to any other writer until
|
|
// commit: no concurrent insert can be racing this count, unlike the create /
|
|
// copy / restore doors where the workspace is already live and contended.
|
|
// The correctness content here is arithmetic, not serialization. Recorded so
|
|
// nobody later reads the lock's presence as evidence of a race.
|
|
//
|
|
// nil when the caller passed no WithPlanLimit — the CLI database import
|
|
// (cmd/pad/cmd_db.go) and migrations, which stay unlimited.
|
|
var itemLimit *LimitResult
|
|
if mintOpts.planLimit {
|
|
if err := s.acquireWorkspaceSeqLock(tx, ws.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
itemLimit, err = s.CheckLimitTx(tx, ws.ID, "items_per_workspace")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check item limit: %w", err)
|
|
}
|
|
}
|
|
|
|
// Import items (first pass: create items, remap collection_id).
|
|
//
|
|
// IDEA-1486 + IDEA-1488: precompute each item's coerced fields/tags so
|
|
// the second-pass UPDATE (which re-applies fields after the ID remap)
|
|
// uses the SAME normalized value as the first-pass INSERT. Without
|
|
// this, a malformed it.Fields gets coerced to "{}" on INSERT but the
|
|
// second pass would re-write the original malformed value verbatim,
|
|
// undoing the coercion. The map is keyed by the original (exported)
|
|
// item ID so the second-pass loop can look up the normalized value.
|
|
coercedFields := make(map[string]string, len(data.Items))
|
|
coercedTags := make(map[string]string, len(data.Items))
|
|
// Slugs this import has already written. See the collision note in the loop.
|
|
claimedSlugs := make(map[string]bool, len(data.Items))
|
|
// Unique values this import has already written (BUG-3825), keyed by
|
|
// collection, field key and slot (see importUniqueValue), to the ref of
|
|
// the item holding it. In
|
|
// memory, not by query: the new workspace holds only this import's rows.
|
|
claimedUnique := map[[3]string]string{}
|
|
// itemMap records the id an item WOULD get; insertedItems records the ones
|
|
// that actually landed. The two differ for an orphaned item — one whose
|
|
// collection is missing from the bundle — because the map entry is written
|
|
// before the skip below, and it has to be: parent resolution inside this
|
|
// same loop reads itemMap for items it has not reached yet.
|
|
//
|
|
// So a later section resolving an id through itemMap alone can get one
|
|
// that names no row, and inserting a foreign key to it fails (SQLite
|
|
// enforces FKs here — `_pragma=foreign_keys(on)` in the DSN — and Postgres
|
|
// always does).
|
|
//
|
|
// EVERY consumer therefore checks this set, not just a non-empty mapping
|
|
// (BUG-2884): the two parent-resolution sites via resolveImportParent, and
|
|
// the comment, link, version and reminder loops directly. The earlier
|
|
// design let links and versions "survive by skipping on error", which is
|
|
// true only on SQLite — on Postgres a failed statement aborts the
|
|
// transaction, so the skip never runs and COMMIT reports "commit
|
|
// unexpectedly resulted in rollback". A row that cannot be written has to
|
|
// be refused before the database sees it, which also refuses it for the
|
|
// right reason rather than letting the database refuse it for an
|
|
// incidental one.
|
|
insertedItems := make(map[string]bool, len(data.Items))
|
|
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 := resolveImportParent(it.ParentID, itemMap, insertedItems)
|
|
|
|
nextItemNumber++
|
|
itemNumber := nextItemNumber
|
|
if preserveItemNumbers {
|
|
itemNumber = it.ItemNumber
|
|
}
|
|
// IDEA-1486 + IDEA-1488: coerce empty-string / malformed
|
|
// fields/tags at the import boundary. After migration 056 /
|
|
// pgmigrations 035 hardened items.fields and items.tags to
|
|
// NOT NULL DEFAULT, an imported item with fields="" 500s on
|
|
// Postgres at JSONB type-validation and silently stores
|
|
// invalid JSON on SQLite. Mirror collections.settings'
|
|
// coercion above. The IDEA-1488 leg: log-and-coerce (not
|
|
// fail-stop) so a legacy bundle with one malformed item
|
|
// still imports.
|
|
// BUG-2833 / BUG-2831: title + slug coercion, same log-and-coerce
|
|
// policy as the fields/tags coercion below. See importCoercedTitle for
|
|
// why import coerces where the interactive doors refuse.
|
|
itemTitle, titleCoercion := importCoercedTitle(it.Title)
|
|
if titleCoercion != "" {
|
|
slog.Warn("import_workspace coerced item title",
|
|
"reason", titleCoercion,
|
|
"row_id", it.ID,
|
|
"workspace_id", ws.ID,
|
|
"raw_runes", utf8.RuneCountInString(it.Title))
|
|
}
|
|
itemSlug, slugCoerced := importCoercedSlug(it.Slug)
|
|
// Collision resolution is keyed on the CLAIMED SET, not on whether THIS
|
|
// row was truncated (codex round 1, P1). Truncation is the only source
|
|
// of duplicate slugs here — a bundle exported from a live workspace
|
|
// cannot contain two identical slugs, because UNIQUE(workspace_id, slug)
|
|
// held there — but the duplicate it creates can land in EITHER order:
|
|
// a long slug truncated to "x" can be inserted BEFORE a later row whose
|
|
// slug is already exactly "x". Resolving only the truncated row leaves
|
|
// that later, untouched row to hit the constraint and abort the entire
|
|
// import, which is the opposite of this loop's coerce-and-continue
|
|
// policy.
|
|
//
|
|
// Guarded by the map rather than by calling uniqueSlugQ unconditionally
|
|
// so an ordinary import of N items still issues no extra queries: the
|
|
// common case is a bundle with no truncation at all, where this costs
|
|
// one map lookup per row.
|
|
if claimedSlugs[itemSlug] {
|
|
unique, uerr := s.uniqueSlugQ(tx, "items", "workspace_id", ws.ID, itemSlug)
|
|
if uerr != nil {
|
|
return nil, fmt.Errorf("import item %s: unique slug after truncation: %w", it.ID, uerr)
|
|
}
|
|
slog.Warn("import_workspace resolved a colliding item slug",
|
|
"row_id", it.ID,
|
|
"workspace_id", ws.ID,
|
|
"requested", itemSlug,
|
|
"slug", unique,
|
|
"from_truncation", slugCoerced)
|
|
itemSlug = unique
|
|
}
|
|
if slugCoerced {
|
|
slog.Warn("import_workspace coerced item slug",
|
|
"reason", "too_long",
|
|
"row_id", it.ID,
|
|
"workspace_id", ws.ID,
|
|
"raw_runes", utf8.RuneCountInString(it.Slug),
|
|
"slug", itemSlug)
|
|
}
|
|
claimedSlugs[itemSlug] = true
|
|
fieldsJSON := coerceJSONForImport(it.Fields, "{}", "items.fields", it.ID, ws.ID, true)
|
|
// BUG-2896: a fields blob that repeats a member is stored collapsed
|
|
// (last wins, as Go and Postgres read it), so SQLite's json_extract,
|
|
// which reads the FIRST occurrence, cannot disagree with them about
|
|
// the status, a filter or a sort. Any other blob keeps its bytes.
|
|
if collapsed, key, did, cerr := models.CollapseDuplicateJSONKeys(fieldsJSON); cerr != nil {
|
|
return nil, fmt.Errorf("import item %s: %w", it.ID, cerr)
|
|
} else if did {
|
|
slog.Warn("import: item fields repeated a JSON member; stored collapsed",
|
|
"item", it.ID, "workspace_id", ws.ID, "member", key)
|
|
fieldsJSON = collapsed
|
|
report.CollapsedDuplicateKeys++
|
|
}
|
|
// Import is the one live door that brings in structured entries as
|
|
// written elsewhere, so it gives any without a usable, unique id a
|
|
// persisted one before the row exists (BUG-2788). A blob that needs no
|
|
// id is stored exactly as imported; one that does is re-encoded with
|
|
// its values unchanged.
|
|
if fixed, changed, err := models.EnsureStructuredEntryIDs(fieldsJSON); err != nil {
|
|
return nil, fmt.Errorf("import item %s: persist structured entry ids: %w", it.ID, err)
|
|
} else if changed {
|
|
fieldsJSON = fixed
|
|
}
|
|
// BUG-3825: a unique value an earlier item in the archive already
|
|
// holds is dropped and reported. Before the INSERT, so the
|
|
// invocation_slug index never sees the duplicate, and before
|
|
// coercedFields, which the second pass rewrites the row from.
|
|
itemRef := fmt.Sprintf("%s-%d", collPrefix[newCollID], itemNumber)
|
|
if keys := collUniqueKeys[newCollID]; len(keys) > 0 {
|
|
kept, drops, err := dropImportedUniqueDuplicates(fieldsJSON, newCollID, itemRef, keys, claimedUnique)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("import item %s: %w", it.ID, err)
|
|
}
|
|
for _, d := range drops {
|
|
slog.Warn("import: dropped a unique value an earlier item in the archive holds",
|
|
"workspace_id", ws.ID, "item", d.Item, "field", d.Key, "holder", d.Holder)
|
|
}
|
|
fieldsJSON = kept
|
|
report.DroppedUniqueValues = append(report.DroppedUniqueValues, drops...)
|
|
}
|
|
tagsJSON := coerceJSONForImport(it.Tags, "[]", "items.tags", it.ID, ws.ID, false)
|
|
coercedFields[it.ID] = fieldsJSON
|
|
coercedTags[it.ID] = tagsJSON
|
|
// 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.
|
|
// Stamp BEFORE the INSERT — see the ORDERING note on
|
|
// stampAttachmentRefsTx (BUG-2415).
|
|
if err := stampAttachmentRefsTx(tx, s, ws.ID, it.Content, fieldsJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
// NO EVENT IS EMITTED HERE, AND THAT IS A RULING, NOT AN OVERSIGHT.
|
|
// This is the second item-creation write path in the codebase — the
|
|
// other is insertItemTx, which owns the API paths and (from TASK-2658)
|
|
// the transactional event outbox. Import deliberately stays silent:
|
|
// SPEC-3 (DOC-2653) §Taxonomy records that events/1 cannot express an
|
|
// import, so restoring a 900-item archive fires no item.created fan-out
|
|
// and consumers resync out-of-band. If a future contract version wants
|
|
// import observable, the spec pre-names the shape — ONE additive
|
|
// workspace-level `workspace.imported` event, never per-item fan-out.
|
|
// Do not "fix" this by hanging an outbox write off this INSERT.
|
|
//
|
|
// The same reasoning keeps import OFF the typed-decision queue
|
|
// (TASK-3117): an enqueue per imported row would be one provider call
|
|
// per item of the archive — the fan-out ruling 3 declines for the
|
|
// title-rename cascade and field migrations. Unlike those, an imported
|
|
// item has NO answers rather than stale ones until its next direct
|
|
// write enqueues it. Whether import should enqueue is an open policy
|
|
// question on TASK-3117, not something to change silently here.
|
|
_, 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, itemTitle, itemSlug, it.Content, fieldsJSON, tagsJSON, s.dialect.BoolToInt(it.Pinned), it.SortOrder,
|
|
parentID, it.CreatedBy, it.LastModifiedBy, it.Source, itemNumber,
|
|
it.CreatedAt, it.UpdatedAt, ws.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("import item %s: %w", it.Title, err)
|
|
}
|
|
insertedItems[newItemID] = true
|
|
}
|
|
|
|
// items_per_workspace, compared here against the limit resolved before the
|
|
// loop (BUG-3103). Why the lock taken up there guards nothing is written at
|
|
// that site; it is not repeated.
|
|
//
|
|
// WHY AFTER THE INSERT LOOP rather than before it. The count that matters is
|
|
// how many items LAND, and that is not len(data.Items): the loop above skips
|
|
// orphaned items — ones whose collection is missing from the bundle — with a
|
|
// `continue`, and insertedItems is what records the survivors. Checking the
|
|
// bundle's length up front would refuse an import of 150 items at a 100 cap
|
|
// even when 50 of them are orphans and only 100 would land. Dave's ruling
|
|
// asks the refusal to name "how many would land vs the limit", so the check
|
|
// counts what landed. The whole thing sits inside the transaction that also
|
|
// minted the workspace, so refusing here rolls the workspace back with it.
|
|
//
|
|
// WHY THE LIMIT IS RESOLVED BEFORE THE LOOP and only compared here. The
|
|
// split is deliberate rather than tidy: CheckLimitTx counts on THIS
|
|
// transaction, so calling it after the inserts
|
|
// returns a count that already includes them. Resolving it afterwards and
|
|
// subtracting would work only by an identity ("every item this transaction
|
|
// inserted is in insertedItems") that nothing enforces, and getting it
|
|
// wrong refuses an import that exactly fills the cap — which is why the
|
|
// at-cap control leg exists.
|
|
if itemLimit != nil && itemLimit.Limit >= 0 {
|
|
landed := len(insertedItems)
|
|
if itemLimit.Current+landed > itemLimit.Limit {
|
|
refused := *itemLimit
|
|
refused.Allowed = false
|
|
// Current stays the count the destination held BEFORE this import
|
|
// (zero for the workspace this transaction just minted). Requested
|
|
// is what would land. Current is deliberately NOT overloaded to
|
|
// carry `landed` — it is published in the API error's `details` and
|
|
// means "how many this workspace holds now" at every other door.
|
|
// See LimitResult.Requested.
|
|
refused.Requested = landed
|
|
return nil, &PlanLimitError{Result: refused}
|
|
}
|
|
}
|
|
|
|
// The remap map for relation FIELD VALUES, filtered to items that actually
|
|
// landed (BUG-2895). itemMap holds an entry for every item in the bundle
|
|
// INCLUDING orphans — see the note where it is built, which explains why it
|
|
// has to — so remapping a field value through it rewrites a reference to an
|
|
// orphan into the id that orphan WOULD have received: an id that names no
|
|
// row here and has never identified anything anywhere.
|
|
//
|
|
// That is worse than leaving it alone. TASK-2878's carry rule imports an
|
|
// unresolvable relation value VERBATIM precisely so nothing is invented:
|
|
// the source id is evidence a human or a repair tool can act on, and
|
|
// ImportWorkspace runs no MigrateRelationReferents pass afterwards to
|
|
// re-examine what this writes.
|
|
//
|
|
// This is the same correction BUG-2884 made for parent_id, arriving late
|
|
// because every site that one fixed writes a FOREIGN KEY and the database
|
|
// objected. This one writes into a JSON blob, so nothing objected.
|
|
//
|
|
// KEY SPACES DIFFER and getting it backwards is silent in both directions:
|
|
// itemMap is oldID -> newID, insertedItems is keyed by NEW id. The test is
|
|
// on the VALUE.
|
|
insertedItemMap := make(map[string]string, len(insertedItems))
|
|
for oldID, newItemID := range itemMap {
|
|
if insertedItems[newItemID] {
|
|
insertedItemMap[oldID] = newItemID
|
|
}
|
|
}
|
|
|
|
// Relation DEFAULTS in the imported schemas name items by their SOURCE
|
|
// ids too (BUG-3658). The collection insert above stores the schema as
|
|
// the bundle carries it, and a create in the new workspace then resolved
|
|
// the source id to nothing and dropped the default (or refused, for a
|
|
// required field). Remapped here, once every item has its new id.
|
|
for _, c := range data.Collections {
|
|
newCollID := collMap[c.ID]
|
|
if newCollID == "" || c.Schema == "" {
|
|
continue
|
|
}
|
|
schema, dropped, changed := remapImportRelationDefaults(c.Schema, insertedItemMap)
|
|
if !changed {
|
|
continue
|
|
}
|
|
for _, d := range dropped {
|
|
slog.Warn("import: dropping a relation default that names an item not in the import",
|
|
"workspace_id", ws.ID, "collection", c.Slug, "field", d.field, "value", d.value)
|
|
report.DroppedRelationDefaults = append(report.DroppedRelationDefaults, c.Slug+"."+d.field)
|
|
}
|
|
if _, err := tx.Exec(s.q(`UPDATE collections SET schema = ? WHERE id = ?`), schema, newCollID); err != nil {
|
|
return nil, fmt.Errorf("remap relation defaults for %s: %w", c.Slug, err)
|
|
}
|
|
}
|
|
|
|
// Second pass: remap parent_id and relation fields (now all items exist).
|
|
// Use the coerced first-pass fields value as the remap input so a
|
|
// malformed-and-coerced row doesn't get its coercion clobbered by the
|
|
// raw export value (IDEA-1486 + IDEA-1488).
|
|
_ = coercedTags // tags don't carry ID relations; second-pass only touches fields
|
|
for _, it := range data.Items {
|
|
newItemID := itemMap[it.ID]
|
|
// Same correction as the comment loop, though this one was INERT
|
|
// rather than wrong: an orphan's UPDATE matched no row and returned no
|
|
// error. Left as `== ""` it reads like an orphan guard that works,
|
|
// which is the reading that let the comment loop ship unguarded.
|
|
if !insertedItems[newItemID] {
|
|
continue
|
|
}
|
|
fieldsInput, ok := coercedFields[it.ID]
|
|
if !ok {
|
|
fieldsInput = it.Fields // defensive — should always be populated by first pass
|
|
}
|
|
// Remap relation fields now that all INSERTED items are mapped
|
|
// (BUG-2895 — see insertedItemMap above for why it is not itemMap).
|
|
fields := remapFieldIDs(fieldsInput, insertedItemMap)
|
|
parentID := resolveImportParent(it.ParentID, itemMap, insertedItems)
|
|
_, 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)
|
|
}
|
|
|
|
// Index the relation edges (PLAN-2857 U5). HERE and not at the
|
|
// first-pass INSERT: this pass writes the FINAL blob, with
|
|
// source-workspace ids already remapped to their new ones, and it
|
|
// visits every inserted item (the guard above skips only items that
|
|
// were never inserted). Indexing at the insert would index ids
|
|
// belonging to the exporting workspace.
|
|
//
|
|
// A value the bundle carries that is not an id — a title, say — is
|
|
// indexed as written and simply matches no target. That row is inert
|
|
// for the reverse query, which is the same disposition migration 088
|
|
// gives every dangling reference. BUG-3014 tracks the separate
|
|
// question of how such a value should READ.
|
|
if _, err := s.replaceRelationLinks(tx, newItemID, ws.ID, collMap[it.CollectionID], fields); err != nil {
|
|
return nil, fmt.Errorf("index relation links for item %s: %w", it.Title, err)
|
|
}
|
|
|
|
// Index the [[wiki-links]] (BUG-3627). Import used to leave
|
|
// item_wiki_links empty, and only the server-start backfill filled it,
|
|
// so an imported workspace had no backlinks or graph edges until the
|
|
// next restart. HERE for the same reason as the relation edges above:
|
|
// every item now exists in this transaction, so a link to an item
|
|
// later in the bundle resolves. The content is what the first pass
|
|
// inserted.
|
|
if err := s.replaceWikiLinks(tx, newItemID, ws.ID, it.Content); err != nil {
|
|
return nil, fmt.Errorf("index wiki links for item %s: %w", it.Title, err)
|
|
}
|
|
}
|
|
|
|
// Import comments
|
|
for _, cm := range data.Comments {
|
|
newItemID := itemMap[cm.ItemID]
|
|
// insertedItems, NOT `newItemID == ""` (BUG-2884). That test cannot
|
|
// fire for an item the bundle CONTAINS: itemMap is written for every
|
|
// item before the orphan skip above, deliberately, because parent
|
|
// resolution reads it for items the loop has not reached yet. So an
|
|
// orphaned item's comment used to reach this INSERT with an item_id
|
|
// naming no row, and the foreign key aborted the ENTIRE workspace
|
|
// import — one skippable row taking the whole restore down.
|
|
//
|
|
// The reminder loop below carries a long note about being fixed for
|
|
// exactly this; the comment loop was left standing. Fixing the export
|
|
// half removes pad's own route here (a bundle this build writes has no
|
|
// orphans), but a hand-edited, foreign, or pre-fix archive can still
|
|
// carry one, and that is precisely the population that needs to
|
|
// import.
|
|
if !insertedItems[newItemID] {
|
|
continue
|
|
}
|
|
// Stamp BEFORE the INSERT (BUG-2415).
|
|
if err := stampAttachmentRefsTx(tx, s, ws.ID, cm.Body); err != nil {
|
|
return nil, err
|
|
}
|
|
_, err := tx.Exec(s.q(`
|
|
INSERT INTO comments (id, item_id, workspace_id, author, body, created_by, source, created_at, updated_at, imported)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`),
|
|
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)
|
|
}
|
|
// No comment_mentions rows for imported comments (TASK-3917 lead
|
|
// ruling bb0c9fc6): history would stay open for good, and a bundle's
|
|
// @handles name SOURCE-instance users that could bind to a different
|
|
// local user. Matches needsOthersComment (needs_snooze.go, #2231 r1).
|
|
}
|
|
|
|
// Import item links
|
|
seenLinks := make(map[string]bool, len(data.ItemLinks))
|
|
for _, lk := range data.ItemLinks {
|
|
newSourceID := itemMap[lk.SourceID]
|
|
newTargetID := itemMap[lk.TargetID]
|
|
// insertedItems on BOTH endpoints, not just a non-empty mapping
|
|
// (BUG-2884). An orphaned item still gets a map entry — written before
|
|
// the skip, because parent resolution needs it — so `!= ""` is
|
|
// satisfied by an id naming no row, and the INSERT below hits the
|
|
// foreign key. `continue` on that error is not a recovery on
|
|
// POSTGRES: a failed statement aborts the surrounding transaction, so
|
|
// every later statement fails and COMMIT reports "commit unexpectedly
|
|
// resulted in rollback". One skippable link took the whole restore
|
|
// down. The row has to be refused before the database sees it.
|
|
if !insertedItems[newSourceID] || !insertedItems[newTargetID] {
|
|
continue
|
|
}
|
|
// DEDUPE BEFORE THE WRITE, not after the error. The source enforces
|
|
// UNIQUE(source_id, target_id, link_type), but that bounds what pad
|
|
// WROTE, not what ARRIVES: a hand-edited, merged, or concatenated
|
|
// bundle can carry the same edge twice. On Postgres the unique
|
|
// violation aborts the transaction, so the `continue` below could
|
|
// never ignore it — COMMIT failed and the whole restore was lost over
|
|
// a row the loop meant to drop (codex round 3). Keyed on the remapped
|
|
// triple because that is what the index sees.
|
|
edge := newSourceID + "\x00" + newTargetID + "\x00" + lk.LinkType
|
|
if seenLinks[edge] {
|
|
continue
|
|
}
|
|
seenLinks[edge] = true
|
|
_, 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 {
|
|
// Defence in depth for a duplicate, nothing more — and on Postgres
|
|
// it is thin: the transaction is already poisoned by the time this
|
|
// runs, so the guard above is what actually keeps the import
|
|
// alive. A duplicate is not expected here anyway, since the source
|
|
// enforces UNIQUE(source_id, target_id, link_type) and the ids are
|
|
// freshly minted.
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Import reminders. NULL rather than empty string for the unset marks —
|
|
// the lifecycle is defined by NULL-ness (models.Reminder), and an empty
|
|
// string would make a never-fired reminder read as fired at "".
|
|
for _, rm := range data.Reminders {
|
|
newItemID := itemMap[rm.ItemID]
|
|
// TWO GUARDS, AND NEITHER ALONE IS OBSERVABLE ON SQLITE — measured,
|
|
// not assumed. Reverting either one on its own leaves the test green
|
|
// there: with the map gate restored, the skip-on-error below survives
|
|
// the FK failure; with the fatal return restored, this gate means the
|
|
// insert never fails. Removing BOTH is what fails it.
|
|
//
|
|
// The driver qualifier is not decoration (BUG-2884): on POSTGRES a
|
|
// failed statement aborts the transaction, so "the skip-on-error
|
|
// survives the FK failure" is false there — COMMIT reports "commit
|
|
// unexpectedly resulted in rollback" and the whole import is lost.
|
|
// This gate, which refuses the row before the database sees it, is the
|
|
// one that carries both drivers. They are kept as a pair because they
|
|
// defend the same failure at different depths — this one prevents the
|
|
// bad write, the one below survives a bad write that arrives some
|
|
// other way — and the pair is recorded here so a future reader does
|
|
// not delete one as dead code after watching its mutant survive.
|
|
//
|
|
// insertedItems, not just a non-empty mapping: an ORPHANED item — one
|
|
// whose collection is missing from the bundle — still gets a map entry
|
|
// (it is written before the skip, because parent resolution needs it),
|
|
// so `!= ""` is satisfied by an id that names no row. Inserting a
|
|
// foreign key to it fails, and this loop used to treat that as fatal,
|
|
// so ONE orphaned item with a reminder aborted the entire workspace
|
|
// restore. Codex round 10.
|
|
if !insertedItems[newItemID] {
|
|
continue
|
|
}
|
|
// NORMALIZE ON THE WAY IN. Import is a WRITER like any other, and a
|
|
// bundle is not necessarily one this server produced — it can be
|
|
// hand-edited, or come from another instance. Inserting a raw
|
|
// remind_at would let a bare date or a local offset into the one
|
|
// column every comparison downstream treats as a UTC instant, where
|
|
// it fires early, late, or never. Every other door normalizes; this
|
|
// one was writing underneath them.
|
|
//
|
|
// A value that will not parse is SKIPPED, not fatal: the import-side
|
|
// precedent here is lenient (coerce or drop, keep the import alive)
|
|
// rather than failing a whole workspace restore over one row.
|
|
remindAt, err := normalizeRemindAt(rm.RemindAt)
|
|
if err != nil {
|
|
slog.Warn("workspace import: skipping reminder with an unparseable remind_at",
|
|
"workspace_id", ws.ID, "item_id", newItemID, "raw_len", len(rm.RemindAt))
|
|
continue
|
|
}
|
|
var firedAt, ackedAt any
|
|
if rm.FiredAt != "" {
|
|
firedAt = rm.FiredAt
|
|
}
|
|
if rm.AckedAt != "" {
|
|
// ACKED WITHOUT FIRED IS NOT A STATE (codex round 11). The
|
|
// lifecycle has three: armed, fired-unacked, fired-acked. A bundle
|
|
// carrying an acknowledgement with no fire — which this server's
|
|
// export cannot produce, but a hand-edited or foreign one can —
|
|
// would import a reminder that fires, is excluded from the pending
|
|
// surface because it is already acked, and can never be
|
|
// acknowledged because AckReminder requires acked_at IS NULL. It
|
|
// would emit an event and then be invisible forever.
|
|
//
|
|
// The acknowledgement is dropped rather than the row: the user's
|
|
// SCHEDULE is the part worth keeping, and an ack of something that
|
|
// never fired means nothing. Lenient, matching the import-side
|
|
// precedent in this file.
|
|
if firedAt == nil {
|
|
slog.Warn("workspace import: dropping an acknowledgement on a reminder that never fired",
|
|
"workspace_id", ws.ID, "item_id", newItemID)
|
|
} else {
|
|
ackedAt = rm.AckedAt
|
|
}
|
|
}
|
|
if _, err := tx.Exec(s.q(`
|
|
INSERT INTO item_reminders (id, workspace_id, item_id, remind_at, fired_at, acked_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
newID(), ws.ID, newItemID, remindAt, firedAt, ackedAt, rm.CreatedAt, rm.UpdatedAt); err != nil {
|
|
// SKIP, not fatal — matching item_links and item_versions, whose
|
|
// loops both survive a bad row. A reminder is the least critical
|
|
// thing in a bundle, and failing a 900-item restore over one of
|
|
// them is the wrong trade; this was the aggravating half of the
|
|
// round-10 finding, and it was mine, not the pre-existing mapping.
|
|
slog.Warn("workspace import: skipping reminder that failed to insert",
|
|
"workspace_id", ws.ID, "item_id", newItemID, "error", err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Import verifications (TASK-3617 r4), keeping every value verbatim but
|
|
// remapping the item and via ids. Each row is CHECKED BEFORE its insert,
|
|
// never skipped on a failed one: on Postgres a failed statement aborts the
|
|
// transaction and loses the whole import (BUG-2884, recorded on the
|
|
// reminders loop above). A row that fails the check, or whose item did
|
|
// not import, is dropped and counted rather than failing the import.
|
|
verDropped := 0
|
|
for _, v := range data.Verifications {
|
|
newItemID := itemMap[v.ItemID]
|
|
if !insertedItems[newItemID] {
|
|
verDropped++
|
|
continue
|
|
}
|
|
createdAt, ok := verificationImportable(v)
|
|
if !ok {
|
|
verDropped++
|
|
continue
|
|
}
|
|
var via any
|
|
if v.ViaItemID != "" && insertedItems[itemMap[v.ViaItemID]] {
|
|
via = itemMap[v.ViaItemID]
|
|
}
|
|
if _, err := tx.Exec(s.q(`
|
|
INSERT INTO item_verifications
|
|
(id, workspace_id, item_id, criterion_id, criterion_hash, spec_seq, code_revision,
|
|
verdict, evidence, evidence_url, via_item_id, run_id, created_by, user_id, agent_name, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?)`),
|
|
newID(), ws.ID, newItemID, v.CriterionID, v.CriterionHash, v.SpecSeq, v.CodeRevision,
|
|
v.Verdict, v.Evidence, v.EvidenceURL, via, v.RunID, v.CreatedBy, v.AgentName, createdAt); err != nil {
|
|
return nil, fmt.Errorf("import verification: %w", err)
|
|
}
|
|
}
|
|
if verDropped > 0 {
|
|
slog.Warn("workspace import: dropped verifications", "workspace_id", ws.ID, "dropped", verDropped,
|
|
"of", len(data.Verifications))
|
|
}
|
|
|
|
// Import set-aside edits (BUG-3244), so the imported item keeps them and
|
|
// reads superseded_set_aside as its source did. Every value is checked
|
|
// BEFORE the insert rather than skipped on a failed one: on Postgres a
|
|
// failed statement poisons the transaction and loses the whole import (the
|
|
// BUG-2884 lesson recorded on the reminders loop above). A row that fails
|
|
// the check is skipped with a warning, matching this file's lenient
|
|
// precedent; a bundle this server wrote never fails it.
|
|
for _, it := range data.Items {
|
|
if len(it.CollabSetAside) == 0 {
|
|
continue
|
|
}
|
|
newItemID := itemMap[it.ID]
|
|
if !insertedItems[newItemID] {
|
|
continue
|
|
}
|
|
for _, sa := range it.CollabSetAside {
|
|
createdAt, cerr := normalizeImportedInstant(sa.CreatedAt)
|
|
setAsideAt, serr := normalizeImportedInstant(sa.SetAsideAt)
|
|
if len(sa.UpdateData) == 0 || !importSchemaVersionRe.MatchString(sa.SchemaVersion) || cerr != nil || serr != nil {
|
|
slog.Warn("workspace import: skipping a malformed set-aside edit row",
|
|
"workspace_id", ws.ID, "item_id", newItemID, "op_log_id", sa.OpLogID)
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(s.q(`
|
|
INSERT INTO item_yjs_updates_set_aside
|
|
(item_id, op_log_id, update_data, schema_version, created_at, set_aside_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`),
|
|
newItemID, sa.OpLogID, sa.UpdateData, sa.SchemaVersion, createdAt, setAsideAt); err != nil {
|
|
return nil, fmt.Errorf("import set-aside edit: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Import built-in origins (TASK-3462). Checked before the insert, for the
|
|
// set-aside loop's reason: a failed statement poisons a Postgres
|
|
// transaction. A malformed origin is skipped with a warning and the item
|
|
// imports without one, which only means it is offered no updates.
|
|
for _, it := range data.Items {
|
|
if it.BuiltinOrigin == nil {
|
|
continue
|
|
}
|
|
newItemID := itemMap[it.ID]
|
|
if !insertedItems[newItemID] {
|
|
continue
|
|
}
|
|
if err := validImportedBuiltinOrigin(*it.BuiltinOrigin); err != nil {
|
|
slog.Warn("workspace import: skipping a malformed built-in origin",
|
|
"workspace_id", ws.ID, "item_id", newItemID, "error", err)
|
|
continue
|
|
}
|
|
if err := s.insertBuiltinOriginTx(tx, newItemID, *it.BuiltinOrigin, now()); err != nil {
|
|
return nil, fmt.Errorf("import built-in origin: %w", err)
|
|
}
|
|
}
|
|
|
|
// Import item versions
|
|
for _, ver := range data.ItemVersions {
|
|
newItemID := itemMap[ver.ItemID]
|
|
// insertedItems, not just a non-empty mapping — same reason as the
|
|
// links loop above, and the same Postgres consequence (BUG-2884). The
|
|
// skip-on-error below is a log-and-continue for the unexpected, not a
|
|
// substitute for this gate: on Postgres the FK failure it used to
|
|
// "skip" had already aborted the transaction, so a single orphaned
|
|
// item's version history failed the entire workspace restore.
|
|
if !insertedItems[newItemID] {
|
|
continue
|
|
}
|
|
// version_seq (BUG-2270): re-derive a per-item monotonic seq at
|
|
// import time. data.ItemVersions is exported ORDER BY created_at,
|
|
// version_seq, so COALESCE(MAX,0)+1 reassigns 1,2,3… in that same
|
|
// deterministic order and imported same-second versions keep a
|
|
// stable tie-break instead of all defaulting to 0.
|
|
_, err := tx.Exec(s.q(`
|
|
INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at, version_seq,
|
|
lines_added, lines_removed, is_create, imported, body_sha256)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(version_seq), 0) + 1 FROM item_versions WHERE item_id = ?), ?, ?, ?, 1, ?)`),
|
|
newID(), newItemID, ver.Content, ver.ChangeSummary, ver.CreatedBy, ver.Source, s.dialect.BoolToInt(ver.IsDiff),
|
|
ver.CreatedAt, newItemID, ver.LinesAdded, ver.LinesRemoved, s.dialect.BoolToInt(ver.IsCreate), importedBodySHA256(ver.BodySHA256))
|
|
if err != nil {
|
|
// Log detail but skip — version history is non-critical.
|
|
// Migrated from fmt.Printf to slog.Warn alongside the
|
|
// IDEA-1488 log-and-coerce additions; same severity, same
|
|
// triage signal, but threads through the canonical logger.
|
|
slog.Warn("import_workspace skipped item version",
|
|
"item_id", ver.ItemID,
|
|
"workspace_id", ws.ID,
|
|
"err", err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// An import is a new workspace and counts against the owner's limit
|
|
// (BUG-2808). The check runs LAST, just before the commit, rather than
|
|
// at the top: the owner lock it takes is held until the commit, and an
|
|
// import of a large bundle would otherwise hold it for the whole import.
|
|
// The count therefore includes this transaction's own workspace row,
|
|
// which is what pendingOwn=1 accounts for. CreateWorkspace takes the same
|
|
// two locks in the same order (slug row, then owner), which is what keeps
|
|
// a concurrent create and import of one name from deadlocking.
|
|
if resolveMintOptions(opts).planLimit {
|
|
if err := s.enforceUserLimitTx(tx, ownerID, "workspaces", 1); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// No FTS pass follows the commit. On SQLite items_fts is an
|
|
// external-content table kept by the items_fts_* triggers, which fired for
|
|
// every row inserted above, inside this transaction. A second pass that
|
|
// re-inserted each row double-indexed it (FTS5 integrity-check reports the
|
|
// table malformed) and cost one write transaction per item after the
|
|
// commit, which stalled every other writer on the database (BUG-2758).
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit import: %w", err)
|
|
}
|
|
|
|
if report != nil {
|
|
// insertedItemMap, not itemMap: itemMap also names items the import
|
|
// skipped (their collection was missing), whose ids exist nowhere
|
|
// (codex r2).
|
|
report.ItemIDs = insertedItemMap
|
|
}
|
|
return ws, nil
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// itemMap must contain ONLY items the import actually inserted (BUG-2895). An id
|
|
// absent from it is left verbatim, which is the carry posture TASK-2878 defines
|
|
// for an unresolvable relation value; an id this function invents is not
|
|
// unresolvable, it is fabricated, and nothing downstream re-examines it.
|
|
//
|
|
// The collMap parameter this used to take was never read — a relation field's
|
|
// VALUE names an item, and the collection a relation targets is carried in the
|
|
// SCHEMA as a slug, not as an id in the blob (see retargetRelationFieldsTx).
|
|
// Dropped rather than left, because an unread parameter reads as a claim that
|
|
// collection ids are remapped here.
|
|
//
|
|
// IDEA-1486: empty-string input is normalized to "{}". Centralizing the
|
|
// contract here keeps future callers safe by default — any UPDATE that
|
|
// writes the result back into items.fields is guaranteed to satisfy the
|
|
// post-migration NOT NULL DEFAULT '{}' invariant. Without this guard,
|
|
// the second-pass UPDATE at ImportWorkspace would write "" verbatim on
|
|
// items whose original fields were already empty, which silently stores
|
|
// invalid JSON on SQLite and (post-migration) would have already 500'd
|
|
// on the first-pass INSERT on Postgres if not for the import-boundary
|
|
// coercion above.
|
|
func remapFieldIDs(fieldsJSON string, itemMap map[string]string) string {
|
|
if fieldsJSON == "" {
|
|
return "{}"
|
|
}
|
|
// WHOLE-VALUE matches only, never a substring (codex round 17).
|
|
//
|
|
// This was `strings.ReplaceAll` over the RAW id, which corrupts a value
|
|
// that merely CONTAINS one: with a bundle carrying ids `c-1` and a
|
|
// relation value `"c-10"`, the value came out as `<new-id-for-c-1>0` — a
|
|
// string that references nothing and existed on neither side. Bundle ids
|
|
// are whatever the exporting instance had and an import accepts a
|
|
// caller-supplied file, so this is not confined to well-formed UUIDs.
|
|
//
|
|
// It matters since the carry posture: an unresolvable relation value is
|
|
// deliberately imported VERBATIM rather than dropped, and verbatim is the
|
|
// whole promise. A partial rewrite breaks it silently.
|
|
//
|
|
// Matching the QUOTED JSON token is what makes it whole-value: `"c-1"`
|
|
// does not occur inside `"c-10"`, and the form still reaches ids inside
|
|
// arrays, which a multi-valued relation needs.
|
|
//
|
|
// DELIBERATELY NOT an unmarshal/marshal walk. That was this fix's first
|
|
// version and the full suite caught it: re-encoding rewrites the WHOLE
|
|
// blob, so a stored `\ufffd` escape came back as the literal replacement
|
|
// character and broke the NUL-repair import test, which asserts on the
|
|
// stored bytes. Every byte this function does not deliberately change has
|
|
// to survive it, and only a textual substitution guarantees that.
|
|
//
|
|
// I engineered a fixture AROUND this hazard in the import carry test
|
|
// rather than asking whether the product had it. It did.
|
|
result := fieldsJSON
|
|
for oldID, newID := range itemMap {
|
|
if oldID == "" || newID == "" {
|
|
continue
|
|
}
|
|
result = strings.ReplaceAll(result, `"`+oldID+`"`, `"`+newID+`"`)
|
|
}
|
|
return result
|
|
}
|
|
|
|
type droppedRelationDefault struct{ field, value string }
|
|
|
|
// remapImportRelationDefaults rewrites the default of every relation and
|
|
// multi_relation field in an imported schema through itemMap (source item id
|
|
// to imported item id). A value itemMap does not hold is dropped when it is
|
|
// an id, since every item in the new workspace has a fresh one and it can name
|
|
// nothing there; anything else is kept. A multi default left empty loses its
|
|
// default key. The schema is re-encoded only when something changed, through
|
|
// a generic decode so keys this build does not model survive.
|
|
func remapImportRelationDefaults(schemaJSON string, itemMap map[string]string) (string, []droppedRelationDefault, bool) {
|
|
dec := json.NewDecoder(strings.NewReader(schemaJSON))
|
|
dec.UseNumber()
|
|
var doc map[string]any
|
|
if err := dec.Decode(&doc); err != nil {
|
|
return schemaJSON, nil, false
|
|
}
|
|
fields, _ := doc["fields"].([]any)
|
|
var dropped []droppedRelationDefault
|
|
changed := false
|
|
remap := func(key string, v any) (any, bool) {
|
|
str, ok := v.(string)
|
|
if !ok {
|
|
return v, true
|
|
}
|
|
if n, ok := itemMap[str]; ok && n != "" {
|
|
if n != str {
|
|
changed = true
|
|
}
|
|
return n, true
|
|
}
|
|
if isUUID(str) {
|
|
dropped = append(dropped, droppedRelationDefault{field: key, value: str})
|
|
changed = true
|
|
return nil, false
|
|
}
|
|
return v, true
|
|
}
|
|
for _, f := range fields {
|
|
fm, ok := f.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
typ, _ := fm["type"].(string)
|
|
def, has := fm["default"]
|
|
if !has || (typ != "relation" && typ != "multi_relation") {
|
|
continue
|
|
}
|
|
key, _ := fm["key"].(string)
|
|
switch d := def.(type) {
|
|
case []any:
|
|
out := make([]any, 0, len(d))
|
|
for _, v := range d {
|
|
if nv, keep := remap(key, v); keep {
|
|
out = append(out, nv)
|
|
}
|
|
}
|
|
if len(out) == 0 && len(d) > 0 {
|
|
delete(fm, "default")
|
|
} else {
|
|
fm["default"] = out
|
|
}
|
|
default:
|
|
if nv, keep := remap(key, d); keep {
|
|
fm["default"] = nv
|
|
} else {
|
|
delete(fm, "default")
|
|
}
|
|
}
|
|
}
|
|
if !changed {
|
|
return schemaJSON, nil, false
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(doc); err != nil {
|
|
return schemaJSON, nil, false
|
|
}
|
|
return strings.TrimSuffix(buf.String(), "\n"), dropped, true
|
|
}
|
|
|
|
// dropDuplicateImportDeclarations returns traits with any declaration already
|
|
// claimed by an earlier collection in the same bundle removed, recording the
|
|
// claims it does allow. Called with the FINAL blob, immediately before the
|
|
// INSERT that writes it — see the note at the import loop for why that
|
|
// placement is load-bearing rather than incidental.
|
|
//
|
|
// A blob that does not parse is returned untouched: it declares nothing to
|
|
// every other reader, and it is outside the indexes' json_valid guard too.
|
|
//
|
|
// So is an ARCHIVED collection's, for the same reason one step further out: a
|
|
// soft-deleted row is outside both partial unique indexes (`AND deleted_at IS
|
|
// NULL`) and outside every trait resolver, so it can neither create the
|
|
// conflict this function exists to prevent nor be harmed by holding a stale
|
|
// declaration. Letting it take a claim would be the actual damage — the live
|
|
// collection later in the bundle would be stripped and the workspace would
|
|
// import with no routing for that kind at all (BUG-2884; the pre-pass this
|
|
// replaced grew the same condition).
|
|
func dropDuplicateImportDeclarations(traits, slug, deletedAt, workspaceID string, seenKinds map[string]string, invocationCollection *string) (string, error) {
|
|
if deletedAt != "" {
|
|
return traits, nil
|
|
}
|
|
t, err := models.ParseCollectionTraits(traits)
|
|
if err != nil {
|
|
return traits, nil
|
|
}
|
|
changed := false
|
|
if t.ArtifactKind != nil && t.ArtifactKind.Kind != "" {
|
|
if prev, dup := seenKinds[t.ArtifactKind.Kind]; dup {
|
|
slog.Warn("import: archive declares one artifact kind on two collections; the later one is imported WITHOUT the declaration and keeps its items",
|
|
"kind", t.ArtifactKind.Kind, "keeps_declaration", prev, "stripped", slug,
|
|
"decided_by", "bundle order (arbitrary — the archive carries no basis for preferring either)",
|
|
"workspace_id", workspaceID)
|
|
t.ArtifactKind = nil
|
|
changed = true
|
|
} else {
|
|
seenKinds[t.ArtifactKind.Kind] = slug
|
|
}
|
|
}
|
|
if t.InvocationField != "" {
|
|
if *invocationCollection != "" {
|
|
slog.Warn("import: archive declares invocation routing on two collections; the later one is imported WITHOUT the declaration and keeps its items",
|
|
"keeps_declaration", *invocationCollection, "stripped", slug,
|
|
"decided_by", "bundle order (arbitrary — the archive carries no basis for preferring either)",
|
|
"workspace_id", workspaceID)
|
|
t.InvocationField = ""
|
|
changed = true
|
|
} else {
|
|
*invocationCollection = slug
|
|
}
|
|
}
|
|
if !changed {
|
|
return traits, nil
|
|
}
|
|
encoded, eerr := t.JSON()
|
|
if eerr != nil {
|
|
return "", eerr
|
|
}
|
|
return encoded, nil
|
|
}
|
|
|
|
// hasDigit reports whether s contains an ASCII digit. Used at import to log a
|
|
// prefix that only became resolvable when parseItemRef widened (BUG-2943).
|
|
func hasDigit(s string) bool {
|
|
for _, r := range s {
|
|
if r >= '0' && r <= '9' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PendingFlushItem names one item whose stored body is BEHIND its live
|
|
// collaborative document, for the migration gate in `pad db migrate-to-pg`
|
|
// (BUG-3032).
|
|
type PendingFlushItem struct {
|
|
Ref string
|
|
Title string
|
|
}
|
|
|
|
// ListItemsPendingContentFlush returns the workspace's items whose op-log holds
|
|
// content-bearing updates above items.content_flushed_op_log_id (BUG-3124: rows
|
|
// that cannot change the document do not count, and this gate dropping them is
|
|
// sound because items.content already is the document) — the same predicate
|
|
// contentStateSQL evaluates, reached through the same helper so the gate and the
|
|
// bundle's own marker cannot disagree about which rows are stale. Two
|
|
// hand-written spellings of it would be two chances to drift, and a gate that
|
|
// refused a different set than the bundle marks would be worse than no gate.
|
|
//
|
|
// It asks about the OP-LOG half only (pendingFlushExistsSQLFor), not about
|
|
// set-aside rows (BUG-3244). Those travel in the bundle
|
|
// (ItemExport.CollabSetAside), so migrating an item that holds them loses
|
|
// nothing, and refusing it would send the operator to open a tab that cannot
|
|
// recover them. The op-log half is asked on its own because contentStateSQL
|
|
// reports set-aside FIRST: an item holding both would read superseded_set_aside
|
|
// and hide the pending rows this gate exists for. So the gate refuses a
|
|
// superset of the items the bundle marks applied_pending_flush, and it runs
|
|
// first, on the same snapshot.
|
|
//
|
|
// Why a query rather than a scan of an already-built bundle: the migration's
|
|
// refusal has to be able to say "nothing has been migrated", which means the
|
|
// check must cover EVERY workspace before the first import runs. Exporting them
|
|
// all up front to scan them would hold every workspace in memory at once; this
|
|
// is one indexed EXISTS per item (idx_yjs_updates_item_id covers it) and leaves
|
|
// the migration's export-then-import-per-workspace shape alone.
|
|
//
|
|
// The collection join is a LEFT join, and that is the load-bearing detail rather
|
|
// than defensiveness. This gate's population MUST equal the bundle's, or the
|
|
// migration passes while the bundle it is about to write carries a stale body.
|
|
// The export's items query does not join collections at all, so an INNER join
|
|
// here would silently drop any item whose collection row is missing — and that
|
|
// is reachable on precisely the databases this command runs against: items.collection_id
|
|
// is `NOT NULL REFERENCES collections(id)`, but store.New's note records that FK
|
|
// enforcement used to be applied to only ONE pool member, so a historically
|
|
// written SQLite file "may" carry latent integrity violations. A legacy SQLite
|
|
// file is what `migrate-to-pg` exists for. Rather than argue the orphan is
|
|
// unreachable, the two queries are made unable to disagree.
|
|
//
|
|
// Soft-deleted collections need no clause for the same reason: DeleteCollection
|
|
// soft-deletes the collection row alone and its items stay live and stay
|
|
// exported (BUG-2884), and nothing here filters on the collection's deleted_at,
|
|
// so those items are named too.
|
|
//
|
|
// An orphan therefore still gets NAMED, by slug rather than by a ref it cannot
|
|
// have — the refusal's whole job is to tell the operator what to open, and
|
|
// "PREFIX-0" would send them looking for something that does not exist.
|
|
//
|
|
// Ordered by ref so a refusal message is stable between runs.
|
|
func (s *Store) ListItemsPendingContentFlush(workspaceID string) ([]PendingFlushItem, error) {
|
|
return s.ListItemsPendingContentFlushQ(s.db, workspaceID)
|
|
}
|
|
|
|
// ListItemsPendingContentFlushQ is ListItemsPendingContentFlush against a
|
|
// caller-supplied executor, so the migration's unflushed-edits gate reads the
|
|
// same snapshot the bundles are exported from (BUG-3072). A gate that reads
|
|
// the pool answers about a different instant than the export it guards, which
|
|
// is the gap BUG-3032's post-export bundle check exists to cover and this
|
|
// removes rather than narrows.
|
|
func (s *Store) ListItemsPendingContentFlushQ(q Queryer, workspaceID string) ([]PendingFlushItem, error) {
|
|
rows, err := q.Query(s.q(`
|
|
SELECT COALESCE(c.prefix, ''), COALESCE(i.item_number, 0), i.title, i.slug
|
|
FROM items i
|
|
LEFT JOIN collections c ON c.id = i.collection_id
|
|
WHERE i.workspace_id = ? AND i.deleted_at IS NULL
|
|
AND `+pendingFlushExistsSQLFor("i")+`
|
|
ORDER BY COALESCE(c.prefix, ''), i.item_number`), workspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list items pending content flush: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []PendingFlushItem
|
|
for rows.Next() {
|
|
var prefix, title, slug string
|
|
var number int
|
|
if err := rows.Scan(&prefix, &number, &title, &slug); err != nil {
|
|
return nil, fmt.Errorf("scan item pending content flush: %w", err)
|
|
}
|
|
ref := slug
|
|
if prefix != "" {
|
|
ref = fmt.Sprintf("%s-%d", prefix, number)
|
|
}
|
|
out = append(out, PendingFlushItem{Ref: ref, Title: title})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|