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:
1 parent
31f939002f
commit
7ce73213ae
9 files changed
+284
-39
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user