Files
pad/internal/server/bug3825_import_unique_values_test.go
T
xarmian 02806238d6 fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) (#2194)
* fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825)

Workspace import stored each archive item's fields as carried, and nothing
checked unique_scope: workspace_collection. An import creates a new
workspace, so a duplicate can only come from inside the archive (an export
taken after the BUG-3812 race stored one, or a hand-edited bundle). The new
workspace then held two live items with one unique value, and every later
write to either that carried it was refused 409. A duplicate invocation_slug
failed the whole import instead, on idx_items_invocation_slug_per_collection,
as a 500 on the JSON door and a 400 carrying the raw constraint on the bundle
door.

As ruled: drop and report, the BUG-2367 rule for carried values. The first
item in archive order (export order: created_at, id) keeps the value; each
later item loses only that key. The unique keys are every unique_scope:
workspace_collection field plus invocation_slug, whose index covers every
collection whatever its schema declares. invocation_slug is compared as both
dialects' indexes compare it: Postgres by its ->> text, SQLite by
json_extract's typed value. A value collides if either would, so an archive
that imports on one dialect imports on the other. A blob with nothing dropped
is stored byte for byte.

Each drop is reported as (item, field, value, holder), refs in the new
workspace, in a new additive dropped_unique_values member of both import
doors' 201. pad workspace import prints each one in move's "dropped:" shape.

Import checks no required fields, before or after this change, so a dropped
value on a required field leaves the item without it, reported like any
other drop.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(store): import compares invocation_slug numbers exactly, as each index reads them (BUG-3825 r2)

Codex r2 M: float64 rounding made 2^53 and 2^53+1 one value, so a value
neither index equates was dropped. Hunter r2: Postgres's ->> prints a
jsonb number with the scale written, so 1.0 equals the string "1.0" there,
and the normalised slot missed it. Numbers now claim their exact value
(SQLite) and their numeric output text (Postgres), with no float.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(store): import compares invocation_slug numbers as SQLite's REAL does, and bounds the exponent (BUG-3825 r3)

Codex r3 H / hunter r3 M1: SQLite's json_extract returns an int64 literal
as INTEGER and anything else as a binary64 REAL, so 0.1 and
0.10000000000000000001 are one value there; the exact big.Rat slot kept
both and the index failed the import. The SQLite slot is now the int64, or
the float64 (as an integer when it is exactly one).

Codex r3 M / hunter r3 M2: big.Rat expanded 1e999999 into a million digits
per item. Postgres's numeric text is now built from the literal's digits,
and an exponent beyond +-1000 stands for itself.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(store): import keys a Postgres number by value and scale, not expanded text, and checks the slot model against both databases (BUG-3825 r4)

Codex r4 M: the +-1000 expansion cap missed 1e1001 against its expanded
digits on Postgres. Two jsonb numbers print the same ->> text exactly when
value and scale match, so the Postgres slot is that pair, which does not
grow with the exponent; a string in numeric's output form shares it.

Hunter r4 asked whether SQLite's own decimal parser and Postgres's numeric
limits match the model. TestBUG3825_SlotsMatchTheDatabase asks the database
under test, for every pair of 58 values, whether its index would see them
equal, and requires that dialect's slots to collide exactly then. It caught
one mismatch on Postgres: jsonb renders an object as {"a": 1}, so objects
and arrays are compared on Postgres only with each other.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(store): bound the numeric key's exponent; the slot oracle skips only Postgres's out-of-range refusal (BUG-3825 r5)

Codex r5: an exponent near MaxInt overflowed len(intPart)+exp; beyond 2^24 the
literal now stands for itself (Postgres refuses such a number). Hunter r5: the
oracle skipped every Postgres error; it now skips only SQLSTATE 22003, counts
them, and covers negative decimals and zero with an exponent. The composite
residual is documented for both dialects.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 11:24:31 -04:00

79 lines
3.0 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3825, both import doors: an archive carrying one invocation_slug on two
// items of a collection used to fail the whole import on the unique index
// (idx_items_invocation_slug_per_collection) with a 500 import_failed. It now
// imports, the first item in archive order keeps the slug, and the 201 names
// the drop as (item, field, value, holder). The store tests pin what is
// stored; this pins the status and the response.
func TestBUG3825_ImportDropsADuplicateInvocationSlugAndSaysSo(t *testing.T) {
const ts = "2026-10-15T00:00:00Z"
bundle := models.WorkspaceExport{
Version: 1,
Workspace: models.WorkspaceExportMeta{Name: "Dup Slug", Slug: "dup-slug", Settings: "{}"},
Collections: []models.CollectionExport{{
ID: "c", Name: "Playbooks", Slug: "playbooks", Prefix: "PLAYB", Settings: "{}", CreatedAt: ts, UpdatedAt: ts,
Schema: `{"fields":[{"key":"invocation_slug","type":"text"}]}`,
}},
Items: []models.ItemExport{
{ID: "11111111-1111-4111-8111-111111111111", CollectionID: "c", Title: "One", Slug: "one",
Fields: `{"invocation_slug":"ship"}`, Tags: "[]", ItemNumber: 1, CreatedAt: ts, UpdatedAt: ts},
{ID: "22222222-2222-4222-8222-222222222222", CollectionID: "c", Title: "Two", Slug: "two",
Fields: `{"invocation_slug":"ship"}`, Tags: "[]", ItemNumber: 2, CreatedAt: ts, UpdatedAt: ts},
},
}
export, err := json.Marshal(bundle)
if err != nil {
t.Fatal(err)
}
want := []models.ImportUniqueDrop{{Item: "PLAYB-2", Key: "invocation_slug", Value: "ship", Holder: "PLAYB-1"}}
for _, door := range []struct {
name string
server func(*testing.T) *Server
post func(*Server) *httptest.ResponseRecorder
}{
{"json", testServer, func(srv *Server) *httptest.ResponseRecorder {
return rawJSONRequest(srv, "POST", "/api/v1/workspaces/import", string(export))
}},
// The bundle door needs attachment storage configured.
{"bundle", func(t *testing.T) *Server {
srv, _ := testServerWithAttachments(t)
return srv
}, func(srv *Server) *httptest.ResponseRecorder {
return postBundle(srv, "DupSlugBundle", gzipTar(t, []bundleEntry{{"pad-export.json", export}}))
}},
} {
t.Run(door.name, func(t *testing.T) {
srv := door.server(t)
rr := door.post(srv)
if rr.Code != http.StatusCreated {
t.Fatalf("import: %d %s, want 201", rr.Code, rr.Body.String())
}
var got struct {
Slug string `json:"slug"`
DroppedUniqueValues []models.ImportUniqueDrop `json:"dropped_unique_values"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v (%s)", err, rr.Body.String())
}
if got.Slug == "" {
t.Errorf("the workspace is no longer in the 201: %s", rr.Body.String())
}
if !reflect.DeepEqual(got.DroppedUniqueValues, want) {
t.Errorf("dropped_unique_values = %+v, want %+v", got.DroppedUniqueValues, want)
}
})
}
}