mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
eac92797f4
* fix(store): a workspace slug is slugified whichever door supplies it (BUG-3769)
An import with a name override used the raw name as the slug ("Interchange
Golden", answered 201), and the workspace's own export URL could not be
built. The same defect had three more doors: an import bundle's own slug
and a create body's slug landed verbatim, and a name slugify keeps nothing
of (all punctuation or all non-ASCII) minted an empty slug.
createWorkspaceQ, which every mint goes through, now slugifies the supplied
slug, falls back to the name's slug, then to "workspace", as roles, items
and collections fall back to theirs. The import override sets the name and
leaves the slug to be derived from it.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* test(server): import tests name their imports in slug form (BUG-3769)
They looked the workspace up by the raw import name, which was the slug
only because of the bug. Four were absence checks (Gone, Dup, Husk, the
interrupted ones) that the fix alone would have made vacuous, so the names
are lowercased rather than the lookups slugified.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* test(server): the import tests' URLs and quoted names use the slug form; the clean-import GET must be a 200 (BUG-3769)
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* docs(api): a workspace slug is canonical and the response's slug is the one to use; the fallback test is order-independent (BUG-3769)
Codex r1 Medium: a client that builds URLs from the slug it sent can miss
now that every door slugifies it, so docs/api.md states the rule and that
the 201's slug is authoritative, including the bundle re-canonicalization
the hunter's r1 L1 describes. Hunter r1 L2: the punctuation-only fallback
case no longer depends on the table's order.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* fix(store): pad db migrate-to-pg keeps each workspace's slug verbatim (BUG-3769)
Hunter r2 Medium: the migration copies existing workspaces through
ImportWorkspace with no name override, so the new canonicalization renamed a
legacy slug (my_ws, Foo) on the way to Postgres, breaking URLs, bookmarks and
.pad.toml links, and two slugs that canonicalize alike collided into -2.
A MintOption, WithPreservedSlug, keeps a supplied slug verbatim (still made
unique) in createWorkspaceQ. migrateImportWorkspace, which the command now
calls, passes it; every door that mints a new workspace leaves it off. The
docs bullet names the exception.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
572 lines
21 KiB
Go
572 lines
21 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// ListWorkspaces returns every non-deleted workspace, unordered by user.
|
|
// This is intended for admin-panel cross-tenant views and the
|
|
// pre-auth/fresh-install bootstrap. End-user workspace switchers should
|
|
// call GetUserWorkspaces instead, which scopes to the user's memberships.
|
|
func (s *Store) ListWorkspaces() ([]models.Workspace, error) {
|
|
return s.ListWorkspacesQ(s.db)
|
|
}
|
|
|
|
// ListWorkspacesQ is ListWorkspaces against a caller-supplied executor, so a
|
|
// migration's gate can read the workspace list inside the same snapshot
|
|
// transaction its exports run in (BUG-3072). Reading the pool here instead
|
|
// would answer from a different instant than the bundles it is gating, with
|
|
// nothing failing to show it.
|
|
func (s *Store) ListWorkspacesQ(q Queryer) ([]models.Workspace, error) {
|
|
// BUG-1481: workspaces.updated_at only moves when the workspace row
|
|
// itself changes (rename, settings, members) — it does NOT reflect
|
|
// item activity inside the workspace. We surface the latter via
|
|
// MAX(items.updated_at) and expose the later of the two as the
|
|
// workspace's effective UpdatedAt, so `pad workspace list` answers
|
|
// "where is work happening?" rather than "when was this row last
|
|
// renamed?". Done in two steps (scalar subquery + Go-side max) to
|
|
// stay portable across SQLite (no GREATEST) and Postgres.
|
|
rows, err := q.Query(s.q(`
|
|
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at,
|
|
(SELECT MAX(i.updated_at) FROM items i WHERE i.workspace_id = w.id AND i.deleted_at IS NULL)
|
|
FROM workspaces w
|
|
LEFT JOIN users ou ON ou.id = w.owner_id
|
|
WHERE w.deleted_at IS NULL
|
|
ORDER BY w.name ASC
|
|
`))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var workspaces []models.Workspace
|
|
for rows.Next() {
|
|
var w models.Workspace
|
|
var createdAt, updatedAt string
|
|
var lastItemActivity sql.NullString
|
|
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &lastItemActivity); err != nil {
|
|
return nil, err
|
|
}
|
|
w.CreatedAt = parseTime(createdAt)
|
|
w.UpdatedAt = effectiveWorkspaceUpdatedAt(updatedAt, lastItemActivity)
|
|
w.HydrateDerivedFields()
|
|
workspaces = append(workspaces, w)
|
|
}
|
|
return workspaces, rows.Err()
|
|
}
|
|
|
|
// effectiveWorkspaceUpdatedAt returns the later of the workspace's own
|
|
// updated_at and the most recent item activity inside it. See BUG-1481
|
|
// for why item activity is the meaningful freshness signal.
|
|
func effectiveWorkspaceUpdatedAt(workspaceUpdatedAt string, lastItemActivity sql.NullString) time.Time {
|
|
wsTS := parseTime(workspaceUpdatedAt)
|
|
if !lastItemActivity.Valid || lastItemActivity.String == "" {
|
|
return wsTS
|
|
}
|
|
itemTS := parseTime(lastItemActivity.String)
|
|
if itemTS.After(wsTS) {
|
|
return itemTS
|
|
}
|
|
return wsTS
|
|
}
|
|
|
|
// execQueryer is the subset of *sql.DB / *sql.Tx that createWorkspaceQ needs.
|
|
// Minting a workspace is one Exec between two reads — the slug probe before
|
|
// it and the read-back after — so it needs both halves, unlike the read-only
|
|
// Queryer and the write-only sqlExecer that already exist here.
|
|
type execQueryer interface {
|
|
sqlExecer
|
|
rowQueryer
|
|
}
|
|
|
|
// CreateWorkspace mints a workspace against the pool. Callers already inside a
|
|
// transaction want createWorkspaceQ instead.
|
|
//
|
|
// With WithPlanLimit() the mint runs in its own transaction and is refused with
|
|
// a *PlanLimitError when the owner is already at their workspaces limit,
|
|
// counted under the owner lock (enforceUserLimitTx, BUG-2808).
|
|
//
|
|
// INSERT FIRST, THEN LOCK AND COUNT, the same order ImportWorkspace uses. Both
|
|
// workspace mints must take their two locks in one order: the workspace row's
|
|
// UNIQUE slug entry, then the owner's users row. Review round 2 on PR A found
|
|
// the cycle the opposite order makes on Postgres: a create holding the owner
|
|
// lock while it waits on an import's uncommitted row for the same slug, and
|
|
// the import asking for the owner lock at its end. With one order, the create
|
|
// waits on the slug holding nothing, and the import commits.
|
|
func (s *Store) CreateWorkspace(input models.WorkspaceCreate, opts ...MintOption) (*models.Workspace, error) {
|
|
mo := resolveMintOptions(opts)
|
|
if !mo.planLimit {
|
|
return s.createWorkspaceQ(s.db, input, mo.preserveSlug)
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create workspace: begin: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
ws, err := s.createWorkspaceQ(tx, input, mo.preserveSlug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.enforceUserLimitTx(tx, input.OwnerID, "workspaces", 1); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("create workspace: commit: %w", err)
|
|
}
|
|
return ws, nil
|
|
}
|
|
|
|
// workspaceSlugProbedHook, when set by a test, runs between the slug probe and
|
|
// the INSERT: the gap a concurrent create of the same name lands in (BUG-3307).
|
|
var workspaceSlugProbedHook func(slug string)
|
|
|
|
// createWorkspaceQ is CreateWorkspace against a caller-supplied executor.
|
|
//
|
|
// It exists for ImportWorkspace (BUG-2892), which has to mint the workspace on
|
|
// the SAME transaction that carries the collections and items: minting it
|
|
// first as a committed write left the row behind on every one of the eight
|
|
// error paths between there and the commit, and the husk went on holding the
|
|
// slug, so the retry that finally worked landed on `name-2`.
|
|
//
|
|
// Both reads take the caller's executor rather than reaching for s.db, and
|
|
// that is load-bearing rather than tidy. A read routed through the POOL while
|
|
// the caller's transaction holds its own connection can wait for a free one,
|
|
// and under MaxOpenConns(1) there is none to wait for — the deadlock class
|
|
// BUG-2778 and BUG-2409 are both instances of.
|
|
func (s *Store) createWorkspaceQ(q execQueryer, input models.WorkspaceCreate, preserveSlug bool) (*models.Workspace, error) {
|
|
// A bot never owns a workspace (TASK-3392).
|
|
if input.OwnerID != "" {
|
|
if err := s.refuseAppPrincipalQ(q, input.OwnerID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
id := newID()
|
|
ts := now()
|
|
|
|
// Every door mints through here, so the slug is slugified here, whoever
|
|
// supplied it (BUG-3769): a create body's slug, an import bundle's, and
|
|
// an import name override all used to land verbatim, spaces and capitals
|
|
// included, in a value every URL carries. A name with nothing slugify
|
|
// keeps (all punctuation, or all non-ASCII) falls back to "workspace",
|
|
// as roles, items and collections fall back to theirs. A copy of an
|
|
// existing workspace (WithPreservedSlug) keeps the slug it already has.
|
|
slug := slugify(input.Slug)
|
|
if preserveSlug && input.Slug != "" {
|
|
slug = input.Slug
|
|
}
|
|
if slug == "" {
|
|
slug = slugify(input.Name)
|
|
}
|
|
if slug == "" {
|
|
slug = "workspace"
|
|
}
|
|
|
|
settings := input.Settings
|
|
if settings == "" {
|
|
settings = "{}"
|
|
}
|
|
settings, err := models.NormalizeWorkspaceSettings(settings)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("normalize workspace settings: %w", err)
|
|
}
|
|
if input.Context != nil {
|
|
settings, err = models.ApplyWorkspaceContext(settings, input.Context)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("apply workspace context: %w", err)
|
|
}
|
|
}
|
|
|
|
// Workspace slugs are globally unique (not scoped to a workspace
|
|
// like collection/item slugs), so we use a workspace-specific
|
|
// uniqueness check rather than the generic uniqueSlug helper.
|
|
//
|
|
// The probe is a READ, so two creates of one name can both see the
|
|
// slug free (BUG-3307). The INSERT therefore does not fail on the
|
|
// slug: ON CONFLICT DO NOTHING inserts no row when another create won,
|
|
// and this create probes again and takes the next free slug. Not a
|
|
// caught unique violation, because on Postgres a failed statement
|
|
// aborts the whole transaction, and ImportWorkspace mints inside the
|
|
// transaction that carries its collections and items (BUG-2892). Under
|
|
// READ COMMITTED the INSERT waits for a winner that has not committed
|
|
// yet, and the next probe, a new statement, sees its row.
|
|
var finalSlug string
|
|
for attempt := 0; ; attempt++ {
|
|
if attempt == maxWorkspaceSlugAttempts {
|
|
return nil, fmt.Errorf("insert workspace: %w", &WorkspaceSlugContendedError{Slug: slug, Attempts: attempt})
|
|
}
|
|
finalSlug, err = s.uniqueWorkspaceSlug(q, slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if workspaceSlugProbedHook != nil {
|
|
workspaceSlugProbedHook(finalSlug)
|
|
}
|
|
res, err := q.Exec(s.q(`
|
|
INSERT INTO workspaces (id, name, slug, owner_id, description, settings, source, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (slug) DO NOTHING
|
|
`), id, input.Name, finalSlug, input.OwnerID, input.Description, settings, input.Source, ts, ts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert workspace: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert workspace: rows affected: %w", err)
|
|
}
|
|
if n == 1 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return s.getWorkspaceBySlugQ(q, finalSlug)
|
|
}
|
|
|
|
// maxWorkspaceSlugAttempts bounds createWorkspaceQ's probe-and-insert loop.
|
|
// Each lost attempt means another create committed the probed slug, so the
|
|
// next probe moves past it; running out needs that many creates of one name
|
|
// to land inside one create's window.
|
|
const maxWorkspaceSlugAttempts = 10
|
|
|
|
// WorkspaceSlugContendedError is returned when every attempt to mint a
|
|
// workspace lost its slug to a concurrent create. It is contention, not a
|
|
// fault, and handlers answer it 409 (BUG-3307).
|
|
type WorkspaceSlugContendedError struct {
|
|
Slug string
|
|
Attempts int
|
|
}
|
|
|
|
func (e *WorkspaceSlugContendedError) Error() string {
|
|
return fmt.Sprintf("workspace slug %q: lost to a concurrent create %d times", e.Slug, e.Attempts)
|
|
}
|
|
|
|
// uniqueWorkspaceSlug probes on the caller's executor for the same reason
|
|
// createWorkspaceQ does: inside an import it must see that transaction's own
|
|
// uncommitted rows, and it must not reach for the pool from inside one.
|
|
//
|
|
// It counts SOFT-DELETED rows too, because the constraint does: slug is
|
|
// globally UNIQUE and soft delete keeps it, so skipping those rows handed a
|
|
// new workspace the slug of one deleted in the last 30 days, and the INSERT
|
|
// failed every time (BUG-3307). The new one takes the next suffix, and the
|
|
// deleted one stays restorable under its own slug.
|
|
func (s *Store) uniqueWorkspaceSlug(q rowQueryer, baseSlug string) (string, error) {
|
|
slug := baseSlug
|
|
for i := 2; ; i++ {
|
|
var count int
|
|
err := q.QueryRow(s.q("SELECT COUNT(*) FROM workspaces WHERE slug = ?"), slug).Scan(&count)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if count == 0 {
|
|
return slug, nil
|
|
}
|
|
slug = fmt.Sprintf("%s-%d", baseSlug, i)
|
|
}
|
|
}
|
|
|
|
func (s *Store) GetWorkspaceBySlug(slug string) (*models.Workspace, error) {
|
|
return s.getWorkspaceBySlugQ(s.db, slug)
|
|
}
|
|
|
|
// getWorkspaceBySlugQ is GetWorkspaceBySlug against a caller-supplied
|
|
// executor, for callers already inside a transaction (BUG-2778: a pool read
|
|
// from inside one needs a second connection while the first is held).
|
|
func (s *Store) getWorkspaceBySlugQ(q rowQueryer, slug string) (*models.Workspace, error) {
|
|
var w models.Workspace
|
|
var createdAt, updatedAt string
|
|
var deletedAt *string
|
|
|
|
err := q.QueryRow(s.q(`
|
|
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at
|
|
FROM workspaces w
|
|
LEFT JOIN users ou ON ou.id = w.owner_id
|
|
WHERE w.slug = ? AND w.deleted_at IS NULL
|
|
`), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
w.CreatedAt = parseTime(createdAt)
|
|
w.UpdatedAt = parseTime(updatedAt)
|
|
w.DeletedAt = parseTimePtr(deletedAt)
|
|
w.HydrateDerivedFields()
|
|
return &w, nil
|
|
}
|
|
|
|
func (s *Store) GetWorkspaceByID(id string) (*models.Workspace, error) {
|
|
var w models.Workspace
|
|
var createdAt, updatedAt string
|
|
var deletedAt *string
|
|
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at
|
|
FROM workspaces w
|
|
LEFT JOIN users ou ON ou.id = w.owner_id
|
|
WHERE w.id = ? AND w.deleted_at IS NULL
|
|
`), id).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
w.CreatedAt = parseTime(createdAt)
|
|
w.UpdatedAt = parseTime(updatedAt)
|
|
w.DeletedAt = parseTimePtr(deletedAt)
|
|
w.HydrateDerivedFields()
|
|
return &w, nil
|
|
}
|
|
|
|
// GetWorkspacesBySlugForUser finds workspaces matching a slug that are accessible
|
|
// to the given user (owned, member, or guest with grants).
|
|
func (s *Store) GetWorkspacesBySlugForUser(slug, userID string) ([]models.Workspace, error) {
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT DISTINCT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at
|
|
FROM workspaces w
|
|
LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ?
|
|
LEFT JOIN collection_grants cg ON cg.workspace_id = w.id AND cg.user_id = ?
|
|
LEFT JOIN item_grants ig ON ig.workspace_id = w.id AND ig.user_id = ?
|
|
LEFT JOIN users ou ON ou.id = w.owner_id
|
|
WHERE w.slug = ? AND w.deleted_at IS NULL
|
|
AND (w.owner_id = ? OR wm.user_id IS NOT NULL OR cg.user_id IS NOT NULL OR ig.user_id IS NOT NULL)
|
|
`), userID, userID, userID, slug, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get workspaces by slug for user: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.Workspace
|
|
for rows.Next() {
|
|
var w models.Workspace
|
|
var createdAt, updatedAt string
|
|
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
w.CreatedAt = parseTime(createdAt)
|
|
w.UpdatedAt = parseTime(updatedAt)
|
|
w.HydrateDerivedFields()
|
|
result = append(result, w)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *Store) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*models.Workspace, error) {
|
|
w, err := s.GetWorkspaceBySlug(slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if w == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
ts := now()
|
|
|
|
if input.Name != nil {
|
|
w.Name = *input.Name
|
|
}
|
|
if input.Description != nil {
|
|
w.Description = *input.Description
|
|
}
|
|
if input.Settings != nil {
|
|
w.Settings = *input.Settings
|
|
}
|
|
if input.Context != nil {
|
|
settings, err := models.ApplyWorkspaceContext(w.Settings, input.Context)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("apply workspace context: %w", err)
|
|
}
|
|
w.Settings = settings
|
|
}
|
|
if w.Settings != "" {
|
|
settings, err := models.NormalizeWorkspaceSettings(w.Settings)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("normalize workspace settings: %w", err)
|
|
}
|
|
w.Settings = settings
|
|
}
|
|
|
|
_, err = s.db.Exec(s.q(`
|
|
UPDATE workspaces SET name = ?, description = ?, settings = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`), w.Name, w.Description, w.Settings, ts, w.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.GetWorkspaceBySlug(slug)
|
|
}
|
|
|
|
// DeleteWorkspace soft-deletes a workspace. Every user's tab for it goes in
|
|
// the same transaction; a restore does not bring them back (TASK-3256,
|
|
// PLAN-3002 Q12).
|
|
func (s *Store) DeleteWorkspace(slug string) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
var id string
|
|
err = tx.QueryRow(s.q(`SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL`), slug).Scan(&id)
|
|
if err != nil {
|
|
return err // sql.ErrNoRows when no live workspace has the slug
|
|
}
|
|
// The tab holders' users rows before the workspaces row: the lock order
|
|
// account deletion takes (BUG-3285; see lockWorkspaceTabHoldersTx).
|
|
if err := s.lockWorkspaceTabHoldersTx(tx, id); err != nil {
|
|
return err
|
|
}
|
|
ts := now()
|
|
result, err := tx.Exec(s.q(`
|
|
UPDATE workspaces SET deleted_at = ?, updated_at = ?
|
|
WHERE id = ? AND deleted_at IS NULL
|
|
`), ts, ts, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
if err := s.deleteWorkspaceTabsForWorkspace(tx, id); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// RestoreWorkspace un-soft-deletes a workspace: it clears deleted_at so
|
|
// the workspace (and every item/collection/member/attachment that was
|
|
// only transitively hidden by the `w.deleted_at IS NULL` filters)
|
|
// re-surfaces intact. Nothing below the workspace was ever soft-deleted
|
|
// by DeleteWorkspace, so restoring the parent row is all it takes.
|
|
//
|
|
// The inverse of DeleteWorkspace and modeled on its shape. Idempotent-
|
|
// ish: returns sql.ErrNoRows (→ 404 at the handler) when no soft-deleted
|
|
// row matched the slug — i.e. the workspace is already live, or it was
|
|
// hard-purged by the retention sweeper (workspace_purge.go) and is gone
|
|
// for good.
|
|
func (s *Store) RestoreWorkspace(slug string) error {
|
|
ts := now()
|
|
result, err := s.db.Exec(s.q(`
|
|
UPDATE workspaces SET deleted_at = NULL, updated_at = ?
|
|
WHERE slug = ? AND deleted_at IS NOT NULL
|
|
`), ts, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetDeletedWorkspaceBySlug returns a SOFT-DELETED workspace by slug
|
|
// (deleted_at IS NOT NULL), including its owner_id, or nil when no such
|
|
// row exists (live workspace, unknown slug, or already hard-purged).
|
|
//
|
|
// The normal resolvers (GetWorkspaceBySlug / resolveWorkspace) filter
|
|
// `deleted_at IS NULL`, so a soft-deleted workspace is invisible to
|
|
// them — the restore handler uses this to look up the row it's about to
|
|
// restore and check ownership BEFORE mutating (so it can tell a
|
|
// non-owner's 403 apart from a genuine 404).
|
|
func (s *Store) GetDeletedWorkspaceBySlug(slug string) (*models.Workspace, error) {
|
|
var w models.Workspace
|
|
var createdAt, updatedAt string
|
|
var deletedAt *string
|
|
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at
|
|
FROM workspaces w
|
|
LEFT JOIN users ou ON ou.id = w.owner_id
|
|
WHERE w.slug = ? AND w.deleted_at IS NOT NULL
|
|
`), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
w.CreatedAt = parseTime(createdAt)
|
|
w.UpdatedAt = parseTime(updatedAt)
|
|
w.DeletedAt = parseTimePtr(deletedAt)
|
|
w.HydrateDerivedFields()
|
|
return &w, nil
|
|
}
|
|
|
|
// ListDeletedWorkspaces returns the soft-deleted workspaces the given
|
|
// user OWNS that are still inside the restore window — deleted_at set,
|
|
// newer than cutoff (which the caller derives from the purge retention
|
|
// constant so restore and purge share one horizon). Ordered
|
|
// most-recently-deleted first.
|
|
//
|
|
// Owner-scoped by owner_id, the canonical "workspaces you solely own"
|
|
// signal. Account-deleted workspaces have no live owner (the owner user
|
|
// row was removed with the account), so their owner_id can't match any
|
|
// live requester — they never leak into this list. That is the intended
|
|
// behavior: only manually-deleted workspaces owned by a live user are
|
|
// practically restorable.
|
|
//
|
|
// deleted_at is fixed-width RFC3339 UTC (store.now()), so the `> cutoff`
|
|
// string comparison is a valid chronological ordering — the same
|
|
// technique ListPurgeableWorkspaces uses for its `< cutoff` bound.
|
|
func (s *Store) ListDeletedWorkspaces(userID string, cutoff time.Time) ([]models.Workspace, error) {
|
|
cutoffStr := cutoff.UTC().Format(time.RFC3339)
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at
|
|
FROM workspaces w
|
|
LEFT JOIN users ou ON ou.id = w.owner_id
|
|
WHERE w.owner_id = ? AND w.deleted_at IS NOT NULL AND w.deleted_at > ?
|
|
ORDER BY w.deleted_at DESC
|
|
`), userID, cutoffStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list deleted workspaces: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.Workspace
|
|
for rows.Next() {
|
|
var w models.Workspace
|
|
var createdAt, updatedAt string
|
|
var deletedAt *string
|
|
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt); err != nil {
|
|
return nil, fmt.Errorf("scan deleted workspace: %w", err)
|
|
}
|
|
w.CreatedAt = parseTime(createdAt)
|
|
w.UpdatedAt = parseTime(updatedAt)
|
|
w.DeletedAt = parseTimePtr(deletedAt)
|
|
w.HydrateDerivedFields()
|
|
result = append(result, w)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ListOwnedLiveWorkspaceIDs returns the IDs of the live workspaces a user
|
|
// owns: exactly the set DeleteAccountAtomic soft-deletes, read with the same
|
|
// predicate so a caller can learn it before the delete (TASK-3272).
|
|
func (s *Store) ListOwnedLiveWorkspaceIDs(userID string) ([]string, error) {
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT id FROM workspaces WHERE owner_id = ? AND deleted_at IS NULL
|
|
`), userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list owned workspaces: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("scan owned workspace: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, rows.Err()
|
|
}
|