Files
pad/internal/items/migrate.go
T
xarmian fd95b79694 fix(BUG-2367): moves and copies never carry into a computed field, and a carried unique-field collision is dropped and named instead of 500ing or storing a duplicate (#1514)
MigrateFields matched fields by key and type only. On every migrate door
(single move, bulk move, cross-workspace copy and its preflight):

- a value was carried into a destination field marked `computed`. It is
  now dropped, with preflight reason `target_computed`.
- a value colliding on a destination `unique_scope` field answered 500
  on single move (invocation_slug, which has an index), leaked the SQL
  error through bulk move, and stored a duplicate on an unindexed unique
  field. Provenance decides now, as TASK-2878 did for relation referents:
  a CARRIED collision is dropped and reported as `not_unique`, and an
  override or schema default that collides is refused with 409 conflict.
  A required field emptied by the drop is refused as required.

One store check (UniqueFieldConflictsQ / DropCarriedUniqueCollisionsQ)
serves all four doors, including the tx-bound copy. The report names the
value, and names the item holding it only when the caller may see that
item. The activity row always uses the holder-free sentence.

Reporting, all additive and omitempty: warnings.not_unique on the move
response, the copy response and bulk updated[] rows; `detail` on the
preflight's dropped row; CLI copy dry run and result, plus `  dropped:`
lines on `item move` stdout; web copy dialog, move and copy toasts, and
the timeline move card. ToolSurfaceVersion 0.51 (behaviour bump).

The relation leg of the original report was already closed by TASK-2878.
Item 4 (terminal/abandoned state change) is PR 2. Out of scope and filed
separately: BUG-3200 (missing needs_value routes) and BUG-3202 (float64
precision; the migrateCopyFields comment now points there).

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 13:12:32 -04:00

283 lines
12 KiB
Go

package items
import (
"fmt"
"sort"
"strconv"
"github.com/PerpetualSoftware/pad/internal/models"
)
// MigrateResult holds the outcome of field migration between collection schemas.
type MigrateResult struct {
// Fields contains the migrated field values for the target schema.
Fields map[string]any
// Dropped lists field keys that were dropped during migration (no matching target field or incompatible types).
Dropped []string
// Errors lists required target fields that have no value after migration.
Errors []string
}
// MigrateScope says how far the item is travelling. It is a required argument
// rather than an option with a default because BOTH wrong answers lose
// something (BUG-2674): using SameWorkspace for a cross-workspace copy carries
// a github_pr into a workspace whose repository it does not describe, and using
// CrossWorkspace for an ordinary move DROPS that metadata from an item whose
// repo context never changed. A caller that has to name its scope cannot pick
// one by omission.
type MigrateScope int
const (
// SameWorkspace — a collection change within one workspace. The
// surrounding context (repository, members, conventions) is unchanged,
// so referential system metadata still describes something true.
SameWorkspace MigrateScope = iota
// CrossWorkspace — the item is landing in a different workspace, where
// a referent belonging to the source's context no longer holds.
CrossWorkspace
)
// ScopeFor answers the one question MigrateScope asks: is the item landing in a
// different workspace? It lives here, in the package that defines the type, so
// the copy (store) and its preflight (server) cannot answer it differently —
// a preview promising a carry the copy then drops is the DR-6 divergence the
// shared-endpoint design exists to prevent, and two inline comparisons in two
// packages is exactly how that drift starts.
func ScopeFor(sourceWorkspaceID, targetWorkspaceID string) MigrateScope {
if sourceWorkspaceID == targetWorkspaceID {
return SameWorkspace
}
return CrossWorkspace
}
// MigrateFields maps field values from a source schema to a target schema.
// Fields with matching keys and compatible types are transferred.
// Incompatible or missing fields are dropped. Required target fields without
// values after migration are reported as errors.
//
// Reserved system metadata (models.IsReservedItemField) bypasses schema
// matching entirely — it is declared by no schema, so matching it against one
// is what destroyed it before BUG-2674. Referential reserved keys additionally
// depend on scope; see MigrateScope.
func MigrateFields(
currentFields map[string]any,
sourceSchema []models.FieldDef,
targetSchema []models.FieldDef,
scope MigrateScope,
) MigrateResult {
result := MigrateResult{
Fields: make(map[string]any),
}
// Build lookup of target fields by key
targetDefs := make(map[string]models.FieldDef)
for _, f := range targetSchema {
targetDefs[f.Key] = f
}
// Build lookup of source fields by key
sourceDefs := make(map[string]models.FieldDef)
for _, f := range sourceSchema {
sourceDefs[f.Key] = f
}
// Migrate each current field value
for key, value := range currentFields {
// Reserved keys are system-written metadata that no collection
// schema declares (BUG-2674). Schema-matching them means they are
// absent from every targetDefs and are dropped on every move —
// which destroyed well-formed implementation notes, decision logs
// and linked-PR metadata outright, silently, on a routine
// operation. They carry through untouched instead.
//
// PLAN-2357 DR-17 already settled the principle for the analogous
// case: tags carry because "there is no workspace-scoped foreign
// key to break, so dropping them would lose information for no
// safety reason". These are the same shape — inert JSON on the
// row, nothing that could dangle in a destination collection or
// workspace — and the plan's carry list simply never considered
// them. There is no migration to attempt: they have no source or
// target FieldDef to migrate BETWEEN.
if models.IsReservedItemField(key) {
// Referential metadata travels only as far as its referent's
// context. Reported through the ordinary Dropped channel with
// no special casing — a user losing a PR link should learn it
// the same way they learn about any other dropped value
// (PLAN-2357 DR-17: "None of this may be silent").
if scope == CrossWorkspace && models.IsReferentialItemField(key) {
result.Dropped = append(result.Dropped, key)
continue
}
result.Fields[key] = value
continue
}
targetField, exists := targetDefs[key]
if !exists {
result.Dropped = append(result.Dropped, key)
continue
}
// A computed field is the server's to populate, never a carried
// literal's (BUG-2367): a source that happens to declare the same key
// as an ordinary field would otherwise plant a stale snapshot in it.
if targetField.Computed {
result.Dropped = append(result.Dropped, key)
continue
}
sourceField := sourceDefs[key]
migrated, ok := migrateValue(value, sourceField.Type, targetField)
if ok {
result.Fields[key] = migrated
} else {
result.Dropped = append(result.Dropped, key)
}
}
// Apply defaults for target fields not yet present
for _, f := range targetSchema {
// A GRANDFATHERED schema can still declare a reserved key (the gate
// added in BUG-2674 refuses new ones but keeps existing collections
// working). Such a FieldDef must not reach this loop: a Default would
// be injected into system metadata as though a user had authored it,
// and Required would produce a migration ERROR for a key the user
// cannot supply — which bulk move rejects on before it ever reaches
// the stripped-schema validation, so a legacy target requiring
// implementation_notes failed bulk move while single move and copy
// succeeded. Same key, same item, two answers (Codex round 4).
if models.IsReservedItemField(f.Key) {
continue
}
if _, exists := result.Fields[f.Key]; exists {
continue
}
if f.Default != nil && f.Default != "" {
result.Fields[f.Key] = f.Default
} else if f.Required {
result.Errors = append(result.Errors, fmt.Sprintf("required field %q has no value", f.Key))
}
}
return result
}
// migrateValue attempts to convert a value from sourceType to the targetField's type.
// Returns the migrated value and true if successful, or zero-value and false if incompatible.
func migrateValue(value any, sourceType string, target models.FieldDef) (any, bool) {
targetType := target.Type
// Same type — validate further for select fields
if sourceType == targetType {
if targetType == "select" && target.Options != nil {
strVal := fmt.Sprintf("%v", value)
for _, opt := range target.Options {
if opt == strVal {
return value, true
}
}
// Value not in target options — drop it
return nil, false
}
return value, true
}
// Compatible type conversions
strVal := fmt.Sprintf("%v", value)
switch {
case sourceType == "text" && targetType == "url":
return value, true
case sourceType == "url" && targetType == "text":
return value, true
case sourceType == "number" && targetType == "text":
return strVal, true
case sourceType == "select" && targetType == "text":
return strVal, true
case sourceType == "checkbox" && targetType == "text":
return strVal, true
case sourceType == "text" && targetType == "number":
if _, err := strconv.ParseFloat(strVal, 64); err == nil {
return value, true
}
return nil, false
default:
return nil, false
}
}
// StillDropped filters a MigrateResult.Dropped list down to the keys that are
// genuinely absent from the FINAL field map.
//
// Dropped is computed during migration, before field overrides are merged and
// before validation injects destination defaults. A key can therefore be listed
// as dropped and then supplied moments later — a number→select value migration
// rejected, say, that the caller replaced with a valid one in the same request.
// Reporting it anyway states something false about the item that is about to be
// written, and a report that cries loss over data the user can see on the item
// is worse than no report at all: it teaches the reader to distrust the channel.
//
// Returns a sorted slice so callers rendering it get stable output.
func StillDropped(dropped []string, finalFields map[string]any) []string {
if len(dropped) == 0 {
return nil
}
out := make([]string, 0, len(dropped))
seen := make(map[string]struct{}, len(dropped))
for _, key := range dropped {
// PRESENT-AND-NON-NIL is the test, not mere presence. The move path
// writes overrides straight into the map including a nil one, where
// the copy path deletes the key instead (see migrateCopyFields) — so
// on a move, `{"due_date": null}` leaves the key present with a nil
// value. Treating that as restored would suppress a REAL drop on the
// strength of a key that carries nothing, which is the silent loss
// this whole change exists to end (Codex round 3).
if v, restored := finalFields[key]; restored && v != nil {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
sort.Strings(out)
return out
}
// SchemaForMigratedFields returns schema with any reserved-key FieldDef
// removed, for validating the OUTPUT of MigrateFields.
//
// Only migration and copy need this, which is exactly why it is a separate
// function rather than a skip inside ValidateFieldsDetailed (Codex round 3).
// That validator is shared with create, full update and every bulk path, and a
// blanket skip there would stop enforcing a grandfathered declaration on the
// paths where the user really is authoring that key — letting arbitrary junk
// into implementation_notes through create while fields_patch, which uses
// ValidatePartialFields, kept rejecting it. Full and partial updates
// disagreeing about the same key is a worse bug than the one being fixed.
//
// The narrow problem it does solve: collection schemas may not declare a
// reserved key since BUG-2674, but that gate GRANDFATHERS existing ones, so a
// legacy `implementation_notes: text` FieldDef still exists in the wild.
// MigrateFields hands reserved keys through by identity — they are system-owned
// and schema-matching them is what destroyed them — so validating the migrated
// map against that FieldDef would meet the notes ARRAY and reject it, failing
// every move and copy for a collection whose only sin is a field name someone
// was once allowed to pick.
//
// The strip itself now lives in models.SchemaWithoutReservedFields, which
// BUG-2685 made the one predicate for every consumer that reasons about an
// ITEM's fields rather than only for migration's output. This name stays
// because its CALLERS are about migration and its doc is where the
// migration-specific reasoning above belongs; there is one implementation.
//
// Returns schema unchanged (no copy) when it declares no reserved key, which is
// every collection that has not been grandfathered — so the returned Fields
// slice may ALIAS the caller's. That is deliberate: this is called on a hot
// path for schemas that will never need stripping, and copying every one to
// serve a case that does not exist would be waste. The contract is therefore
// read-only — treat the result as immutable. Every current caller passes it
// straight to a validator or an enumeration and none mutates it.
func SchemaForMigratedFields(schema models.CollectionSchema) models.CollectionSchema {
return models.SchemaWithoutReservedFields(schema)
}