feat(server): system collections become permanent — soft-delete + in-place restore + query_type-keyed provisioning

The old model hard-deleted system collections and re-created them at
the tail, resurrecting stale dedups and breaking ordering. Redesign
(user-directed B-final):

- schema.sql: collections.deleted_at TIMESTAMPTZ (idempotent ALTER —
  the whole schema is re-applied on every boot via executeSchema).
- queries.sql: every collection list/read query filters
  deleted_at IS NULL; new GetSystemCollectionByQueryType :one
  (unfiltered, sees tombstones), TombstoneSystemCollection :exec,
  ClearCollectionItems :exec, RestoreSystemCollectionRow :one
  (in-place metadata reset + un-tombstone; collections have no
  updated_at column — ResetUpdatedAt was a 500 caught by tests).
  sqlc regenerated.
- collection_service.DeleteCollection: system collections are
  tombstoned (preserving position/ownership); user collections keep
  the hard delete. No ErrNoRows to the client (delete is idempotent).
- dashboard_service.RestoreSystemCollection: resolve by query_type;
  found → in-place restore (ClearCollectionItems + metadata reset,
  un-tombstoned); missing → provision fresh. "Want to Read"/etc.
  stay at their home positions forever.
- handlers/collections.CreateCollection: name-taken is 409, not 500.
- handlers/auth.CreateDefaultCollectionsForUser: idempotent by
  (user_id, query_type) — update-in-place when a row for that query
  type exists (heals renames/tombstones), insert when missing; this
  also fixes the duplicate-system-collections regression at source.
This commit is contained in:
John O'Keefe committed 2026-10-09 11:04:24 -04:00
1 parent 31f939002f
commit 7ce73213ae
9 files changed
+284 -39

No files matched your search

+30 -3
View File
@@ -4,13 +4,19 @@ import (
"bookhoard/internal/database"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
)
// ErrCollectionNameTaken: the user already has a collection with this name.
var ErrCollectionNameTaken = errors.New("collection name already exists")
// Rule represents an auto-assign rule for collections
type Rule struct {
ID string `json:"id"`
@@ -72,7 +78,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to create collection: %v", err)
// Duplicate name (UNIQUE(user_id, name)) is a client error, not a
// server failure: surface it as 409 so callers can recover.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return database.Collections{}, ErrCollectionNameTaken
}
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
}
return collection, nil
@@ -160,9 +172,24 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
// DeleteCollection deletes a collection
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
err := s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
// System collections (the 5 dashboard sections) are PERMANENT: a
// delete tombstones the row (invisible everywhere, restorable with
// the same id) instead of destroying it and every reference to it.
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete collection: %v", err)
if errors.Is(err, pgx.ErrNoRows) {
return nil // already gone: keep delete idempotent
}
return fmt.Errorf("failed to fetch collection: %w", err)
}
if row.IsSystemCollection.Bool {
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
}
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete collection: %w", err)
}
return nil