mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
d75db30515
* fix(server,store): unique_scope values are checked again inside the write, under its lock (BUG-3812) The server checked a unique_scope value on the pool before the write, with nothing held between the check and the write, and only invocation_slug has a database index behind it. Concurrent writers carrying the same value all passed and all wrote. The race tests show it at origin/main on both backends: 8 of 8 concurrent creates stored one value on SQLite, and up to 7 concurrent PATCHes did. ItemCreate and ItemUpdate carry an internal UniqueCheck (schema and fields). The store runs it inside the write transaction, after the workspace seq lock on Postgres (SQLite's BEGIN IMMEDIATE holds the write lock already). Every item insert, update, move and copy in the workspace takes that lock. A writer that loses the race gets UniqueFieldHeldError, the same 409 sentence the check before the write answers. Wired doors: create (createItemChecked, which also serves artifact import and library activate), PATCH with fields and with fields_patch, and the bulk field update. Bulk move now re-checks under the move's lock, as single move has since BUG-3665. Copy and the app installer already checked inside their transactions. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(server): the BUG-3812 race legs assert each loser's answer, and cover fields_patch (codex r1 Low) - PATCH: one 200 and seven 409s per round, for both `fields` and `fields_patch`. The items are addressed by ref. A slug like item-7 is read as a ref and named CODE-7, a different item, so two writers hit one item. - Bulk move: one row kept, and every other row either refused with "conflict" under the lock or moved with the value dropped and reported in not_unique (BUG-2367 drops carried collisions). Mutants: without the update check, both PATCH shapes fail. Without the bulk-move re-check, the bulk leg fails (8 holders on SQLite, 5 on Postgres). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
235 lines
10 KiB
Go
235 lines
10 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// UniqueFieldConflictsQ returns the keys of fields declaring
|
|
// `unique_scope: workspace_collection` whose value in fields is already held by
|
|
// another live item in collectionID. excludeItemID is the item being written,
|
|
// so an item never conflicts with itself.
|
|
//
|
|
// It exists for the MIGRATE doors — single move, bulk move, the cross-workspace
|
|
// copy and its preflight (BUG-2367). Create and update have enforced the rule
|
|
// through the server's checkUniqueFields all along; a move or copy carried the
|
|
// value through MigrateFields and never asked, so a carried duplicate was
|
|
// stored outright on a field with no index behind it, and on invocation_slug,
|
|
// which has one (migration 054), single move answered 500 and bulk move
|
|
// reported the raw SQL error. The copy runs this inside its own transaction,
|
|
// which is why it is a Queryer function in `store` rather than a server helper:
|
|
// one implementation for four doors that sit in two packages.
|
|
//
|
|
// It returns KEYS only, never the conflicting item. The copy's existing 409
|
|
// stays generic for the reason recorded there — naming the holder would report
|
|
// on a row the caller may not be able to see — and a door that names nothing
|
|
// cannot become an existence oracle.
|
|
//
|
|
// Same value rule as checkUniqueFields: only a non-empty string is checked, and
|
|
// soft-deleted items are ignored, matching the partial index's
|
|
// `deleted_at IS NULL`. Returned keys are sorted, for stable messages.
|
|
func (s *Store) UniqueFieldConflictsQ(q Queryer, collectionID, excludeItemID string, schema []models.FieldDef, fields map[string]any) ([]string, error) {
|
|
conflicts, err := s.uniqueFieldConflictsQ(q, collectionID, excludeItemID, schema, fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0, len(conflicts))
|
|
for _, c := range conflicts {
|
|
out = append(out, c.key)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// uniqueConflict is one collision: the key, the value, and the live item that
|
|
// already holds it. The holder never leaves this package unfiltered — see
|
|
// DropCarriedUniqueCollisionsQ.
|
|
type uniqueConflict struct {
|
|
key, value, holderID string
|
|
}
|
|
|
|
func (s *Store) uniqueFieldConflictsQ(q Queryer, collectionID, excludeItemID string, schema []models.FieldDef, fields map[string]any) ([]uniqueConflict, error) {
|
|
var out []uniqueConflict
|
|
for _, def := range schema {
|
|
if def.UniqueScope != "workspace_collection" {
|
|
continue
|
|
}
|
|
val, ok := fields[def.Key].(string)
|
|
if !ok || val == "" {
|
|
continue
|
|
}
|
|
// The key is interpolated into the JSON path, so it must be a key the
|
|
// field-filter paths would also accept (search.go).
|
|
if !validFieldKey.MatchString(def.Key) {
|
|
return nil, fmt.Errorf("unique field check: unsupported field key %q", def.Key)
|
|
}
|
|
query, args := s.uniqueHolderQuery(collectionID, def.Key, val, excludeItemID)
|
|
var id string
|
|
err := q.QueryRow(s.q(query), args...).Scan(&id)
|
|
if err == nil {
|
|
out = append(out, uniqueConflict{key: def.Key, value: val, holderID: id})
|
|
continue
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("unique field check %q: %w", def.Key, err)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].key < out[j].key })
|
|
return out, nil
|
|
}
|
|
|
|
// DropCarriedUniqueCollisionsQ removes from fields every CARRIED value that
|
|
// collides on a destination unique_scope field, and describes each removal
|
|
// (BUG-2367, lead ruling day 78).
|
|
//
|
|
// Provenance decides, as it does for relation referents (TASK-2878): a value
|
|
// the move or copy only CARRIED was asserted by nobody in this request, so it
|
|
// is dropped and reported rather than failing the whole write; a value the
|
|
// caller SUPPLIED (supplied(key) true) is left for the final check to refuse.
|
|
// Run BEFORE validation, so a required field left empty is refused as required
|
|
// — which is what routes it to the needs_value picker on the clients that
|
|
// have one.
|
|
//
|
|
// The report names the value, and names the item holding it ONLY when canSee
|
|
// says the caller may see that item. A drop already says the value is taken,
|
|
// so the value discloses nothing new; the holder's ref would, for an item
|
|
// behind a grant the caller lacks. A nil canSee means the caller sees
|
|
// everything (the same convention the relation passes use).
|
|
func (s *Store) DropCarriedUniqueCollisionsQ(q Queryer, canSee RelationVisibilityFunc, workspaceID string, coll *models.Collection, schema []models.FieldDef, fields map[string]any, supplied func(key string) bool) ([]models.NotUniqueDrop, error) {
|
|
conflicts, err := s.uniqueFieldConflictsQ(q, coll.ID, "", schema, fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []models.NotUniqueDrop
|
|
for _, c := range conflicts {
|
|
if supplied != nil && supplied(c.key) {
|
|
continue
|
|
}
|
|
delete(fields, c.key)
|
|
d := models.NotUniqueDrop{Key: c.key, Value: c.value}
|
|
holder, herr := s.GetItemQ(q, c.holderID)
|
|
if herr != nil && !errors.Is(herr, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("unique field check %q: read holder: %w", c.key, herr)
|
|
}
|
|
if holder != nil {
|
|
visible := true
|
|
if canSee != nil {
|
|
if visible, err = canSee(q, workspaceID, holder); err != nil {
|
|
return nil, fmt.Errorf("unique field check %q: holder visibility: %w", c.key, err)
|
|
}
|
|
}
|
|
if visible {
|
|
d.Holder = holder.Ref
|
|
}
|
|
}
|
|
if d.Holder != "" {
|
|
d.Message = fmt.Sprintf("%s %q is taken by %s", c.key, c.value, d.Holder)
|
|
} else {
|
|
d.Message = NotUniqueAnonymousMessage(c.key, c.value, coll.Name)
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// NotUniqueAnonymousMessage is the not_unique sentence that names no holder.
|
|
// It is what a caller who may not see the holder is told, and it is the ONLY
|
|
// form written anywhere another reader will see it — an activity row is read
|
|
// by everyone who can see the moved item, not only by the actor whose
|
|
// visibility decided the response's sentence.
|
|
func NotUniqueAnonymousMessage(key, value, collectionName string) string {
|
|
return fmt.Sprintf("%s %q is already used by another item in %s", key, value, collectionName)
|
|
}
|
|
|
|
// UniqueFieldConflictsMessage is the one sentence every migrate door refuses
|
|
// with, so a move, a bulk move, a copy and its preview say the same thing.
|
|
func UniqueFieldConflictsMessage(keys []string) string {
|
|
if len(keys) == 1 {
|
|
return fmt.Sprintf("field %q must be unique in the destination collection, and another item there already has this value; set a different value with a field override", keys[0])
|
|
}
|
|
return fmt.Sprintf("fields %q must be unique in the destination collection, and other items there already have these values; set different values with field overrides", keys)
|
|
}
|
|
|
|
// UniqueFieldHeldError is a create or update refused under the store's lock
|
|
// because a unique_scope value it writes is already held by another live item
|
|
// of the collection (BUG-3812). Its sentence is the one the server's check
|
|
// before the write answers, holder slug included, so a write that loses the
|
|
// race reads the same as one refused up front.
|
|
type UniqueFieldHeldError struct {
|
|
Key, Value, HolderSlug string
|
|
}
|
|
|
|
func (e *UniqueFieldHeldError) Error() string {
|
|
return fmt.Sprintf("field %q value %q is already used by item %s", e.Key, e.Value, e.HolderSlug)
|
|
}
|
|
|
|
// AsUniqueFieldHeldError unwraps a UniqueFieldHeldError.
|
|
func AsUniqueFieldHeldError(err error) (*UniqueFieldHeldError, bool) {
|
|
var e *UniqueFieldHeldError
|
|
if errors.As(err, &e) {
|
|
return e, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// checkUniqueTx runs uc inside tx (BUG-3812). The caller holds the workspace
|
|
// seq lock (Postgres) or the database write lock (SQLite's BEGIN IMMEDIATE),
|
|
// which every item insert, update, move and copy in the workspace also takes,
|
|
// so no other writer can store the value between this read and the write.
|
|
func (s *Store) checkUniqueTx(tx Queryer, collectionID, excludeItemID string, uc *models.UniqueCheck) error {
|
|
if uc == nil {
|
|
return nil
|
|
}
|
|
conflicts, err := s.uniqueFieldConflictsQ(tx, collectionID, excludeItemID, uc.Schema, uc.Fields)
|
|
if err != nil || len(conflicts) == 0 {
|
|
return err
|
|
}
|
|
c := conflicts[0]
|
|
var slug string
|
|
if err := tx.QueryRow(s.q(`SELECT slug FROM items WHERE id = ?`), c.holderID).Scan(&slug); err != nil {
|
|
return fmt.Errorf("unique field holder %s: %w", c.holderID, err)
|
|
}
|
|
return &UniqueFieldHeldError{Key: c.key, Value: c.value, HolderSlug: slug}
|
|
}
|
|
|
|
// UniqueFieldConflictError is a copy refused because a destination
|
|
// unique_scope field already holds the value (BUG-2367). Typed so the HTTP
|
|
// layer answers the same 409 sentence the move doors do.
|
|
type UniqueFieldConflictError struct {
|
|
Keys []string
|
|
}
|
|
|
|
func (e *UniqueFieldConflictError) Error() string {
|
|
return UniqueFieldConflictsMessage(e.Keys)
|
|
}
|
|
|
|
// uniqueHolderQuery finds one live item in the collection other than
|
|
// excludeItemID whose key holds val, by JSON type, identically on both
|
|
// dialects (BUG-3221). The string arm restates the invocation_slug partial
|
|
// index's own predicate (non-NULL, non-empty, live), which is what lets SQLite
|
|
// use that index at all: without it SQLite scanned the collection, before
|
|
// BUG-3221 too. The number/boolean arm exists only for an argument that can
|
|
// match one, as its own UNION ALL arm so it never takes the index away from
|
|
// the string arm; it restates the same predicate, which every number or
|
|
// boolean holder satisfies. Plans pinned by TestUniqueHolderQueryUsesTheIndex.
|
|
func (s *Store) uniqueHolderQuery(collectionID, key, val, excludeItemID string) (string, []any) {
|
|
ext := s.dialect.JSONExtractText("fields", key)
|
|
live := fmt.Sprintf("%s IS NOT NULL AND %s != ''", ext, ext)
|
|
str, strArgs, other, otherArgs := s.dialect.JSONFieldEqualsParts("fields", key, val)
|
|
arm := `SELECT id FROM items
|
|
WHERE collection_id = ?
|
|
AND %s
|
|
AND id != ?
|
|
AND deleted_at IS NULL`
|
|
query := fmt.Sprintf(arm, str+" AND "+live)
|
|
args := append(append([]any{collectionID}, strArgs...), excludeItemID)
|
|
if other != "" {
|
|
query += " UNION ALL " + fmt.Sprintf(arm, "("+other+") AND "+live)
|
|
args = append(append(append(args, collectionID), otherArgs...), excludeItemID)
|
|
}
|
|
return query + " LIMIT 1", args
|
|
}
|