mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
02806238d6
* 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
79 lines
3.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|