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

+1
View File
@@ -31,6 +31,7 @@ type Collections struct {
Priority pgtype.Int4 `db:"priority" json:"priority"`
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
}
type DeviceCatalogs struct {
+11
View File
@@ -30,6 +30,7 @@ type Querier interface {
// Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
@@ -305,6 +306,12 @@ type Querier interface {
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error)
// System collections are permanent: identity is (user_id, query_type).
// Restore/reset operate IN PLACE (the row id — and everything referencing
// it: shelf mappings, prefs order, links — never churns). Deleted sections
// are TOMBSTONED (deleted_at) and stay invisible until restored.
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error)
// Dashboard collections queries
GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
// SYSTEM CONFIG QUERIES
@@ -425,6 +432,9 @@ type Querier interface {
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
// Full reset, in place: canonical metadata, back on the dashboard,
// un-tombstoned. Item assignments are cleared by the caller ("full").
RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error)
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
RevokeDevice(ctx context.Context, id pgtype.UUID) error
// Revoke OPDS token
@@ -449,6 +459,7 @@ type Querier interface {
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
+133 -9
View File
@@ -206,6 +206,15 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 floa
return err
}
const ClearCollectionItems = `-- name: ClearCollectionItems :exec
DELETE FROM collection_items WHERE collection_id = $1
`
func (q *Queries) ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error {
_, err := q.db.Exec(ctx, ClearCollectionItems, collectionID)
return err
}
const ClearDeviceSyncQueue = `-- name: ClearDeviceSyncQueue :exec
DELETE FROM sync_queue WHERE device_id = $1
`
@@ -351,7 +360,7 @@ const CreateCollection = `-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type CreateCollectionParams struct {
@@ -391,6 +400,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
@@ -1724,7 +1734,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
const CreateSystemCollection = `-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type CreateSystemCollectionParams struct {
@@ -1764,6 +1774,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
@@ -2593,7 +2604,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
}
const GetCollection = `-- name: GetCollection :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections WHERE id = $1
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
`
// Get collection
@@ -2614,6 +2625,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
@@ -2846,7 +2858,7 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
SELECT
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at,
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
COUNT(ci.id) as book_count
FROM collections c
LEFT JOIN collection_items ci ON c.id = ci.collection_id
@@ -2868,6 +2880,7 @@ type GetCollectionWithBookCountRow struct {
Priority pgtype.Int4 `db:"priority" json:"priority"`
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
BookCount int64 `db:"book_count" json:"book_count"`
}
@@ -2889,13 +2902,14 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
&i.BookCount,
)
return i, err
}
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections WHERE user_id = $1 ORDER BY created_at DESC
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
`
// Get collections by user
@@ -2922,6 +2936,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@@ -2934,10 +2949,11 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
}
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1
AND c.deleted_at IS NULL
`
// Get collections for book
@@ -2964,6 +2980,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@@ -7073,11 +7090,52 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
return i, err
}
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1
AND query_type = $2
AND is_system_collection = true
`
type GetSystemCollectionByQueryTypeParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
QueryType pgtype.Text `db:"query_type" json:"query_type"`
}
// System collections are permanent: identity is (user_id, query_type).
// Restore/reset operate IN PLACE (the row id — and everything referencing
// it: shelf mappings, prefs order, links — never churns). Deleted sections
// are TOMBSTONED (deleted_at) and stay invisible until restored.
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error) {
row := q.db.QueryRow(ctx, GetSystemCollectionByQueryType, arg.UserID, arg.QueryType)
var i Collections
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1
AND is_system_collection = true
AND show_on_dashboard = true
AND deleted_at IS NULL
ORDER BY priority ASC
`
@@ -7105,6 +7163,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@@ -7657,10 +7716,11 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
}
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at FROM collections c
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
AND c.is_system_collection = false
AND c.deleted_at IS NULL
ORDER BY priority ASC
`
@@ -7687,6 +7747,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@@ -10823,6 +10884,59 @@ func (q *Queries) RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNote
return result.RowsAffected(), nil
}
const RestoreSystemCollectionRow = `-- name: RestoreSystemCollectionRow :one
UPDATE collections
SET name = $2,
description = $3,
icon = $4,
color = $5,
priority = $6,
show_on_dashboard = true,
deleted_at = NULL
WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type RestoreSystemCollectionRowParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Description pgtype.Text `db:"description" json:"description"`
Icon pgtype.Text `db:"icon" json:"icon"`
Color pgtype.Text `db:"color" json:"color"`
Priority pgtype.Int4 `db:"priority" json:"priority"`
}
// Full reset, in place: canonical metadata, back on the dashboard,
// un-tombstoned. Item assignments are cleared by the caller ("full").
func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error) {
row := q.db.QueryRow(ctx, RestoreSystemCollectionRow,
arg.ID,
arg.Name,
arg.Description,
arg.Icon,
arg.Color,
arg.Priority,
)
var i Collections
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL
`
@@ -11869,6 +11983,15 @@ func (q *Queries) TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) er
return err
}
const TombstoneSystemCollection = `-- name: TombstoneSystemCollection :exec
UPDATE collections SET deleted_at = NOW() WHERE id = $1
`
func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, TombstoneSystemCollection, id)
return err
}
const UpdateCollection = `-- name: UpdateCollection :one
UPDATE collections
SET
@@ -11879,7 +12002,7 @@ SET
auto_assign_rules = $6,
view_settings = $7
WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type UpdateCollectionParams struct {
@@ -11918,6 +12041,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
+37 -2
View File
@@ -2151,7 +2151,7 @@ SELECT * FROM collections WHERE id = $1;
-- Get collections by user
-- name: GetCollectionsByUser :many
SELECT * FROM collections WHERE user_id = $1 ORDER BY created_at DESC;
SELECT * FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC;
-- Get collection with book count
-- name: GetCollectionWithBookCount :one
@@ -2208,7 +2208,8 @@ ORDER BY ci.added_at DESC;
SELECT c.*
FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1;
WHERE ci.media_item_id = $1
AND c.deleted_at IS NULL;
-- Check if book is in collection
-- name: IsBookInCollection :one
@@ -2485,6 +2486,7 @@ SELECT * FROM collections
WHERE user_id = $1
AND is_system_collection = true
AND show_on_dashboard = true
AND deleted_at IS NULL
ORDER BY priority ASC;
-- name: GetUserCollectionsForDashboard :many
@@ -2492,6 +2494,7 @@ SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
AND c.is_system_collection = false
AND c.deleted_at IS NULL
ORDER BY priority ASC;
-- name: DeleteUserSystemCollection :exec
@@ -2500,6 +2503,38 @@ WHERE user_id = $1
AND name = $2
AND is_system_collection = true;
-- System collections are permanent: identity is (user_id, query_type).
-- Restore/reset operate IN PLACE (the row id — and everything referencing
-- it: shelf mappings, prefs order, links — never churns). Deleted sections
-- are TOMBSTONED (deleted_at) and stay invisible until restored.
-- name: GetSystemCollectionByQueryType :one
-- Unfiltered by design: restore/seed must resolve tombstoned rows too.
SELECT * FROM collections
WHERE user_id = $1
AND query_type = $2
AND is_system_collection = true;
-- name: TombstoneSystemCollection :exec
UPDATE collections SET deleted_at = NOW() WHERE id = $1;
-- name: RestoreSystemCollectionRow :one
-- Full reset, in place: canonical metadata, back on the dashboard,
-- un-tombstoned. Item assignments are cleared by the caller ("full").
UPDATE collections
SET name = $2,
description = $3,
icon = $4,
color = $5,
priority = $6,
show_on_dashboard = true,
deleted_at = NULL
WHERE id = $1
RETURNING *;
-- name: ClearCollectionItems :exec
DELETE FROM collection_items WHERE collection_id = $1;
-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)