tenseleyflow/shithub / 1671b76

Browse files

S16: repo_redirects + repo_transfer_requests tables, lifecycle queries

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
1671b768cc0cae5c16fd6792d8285c29f4be4622
Parents
13a5114
Tree
88d0c84

9 changed files

StatusFile+-
M internal/auth/policy/sqlc/models.go 110 0
M internal/meta/sqlc/models.go 110 0
A internal/migrationsfs/migrations/0020_repo_lifecycle.sql 93 0
A internal/repos/queries/lifecycle.sql 162 0
A internal/repos/sqlc/lifecycle.sql.go 501 0
M internal/repos/sqlc/models.go 110 0
M internal/repos/sqlc/querier.go 60 0
M internal/users/sqlc/models.go 110 0
M internal/worker/sqlc/models.go 110 0
internal/auth/policy/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
9999
 	return string(ns.RepoVisibility), nil
100100
 }
101101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102189
 type AuthAuditLog struct {
103190
 	ID         int64
104191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199286
 	AddedByUserID pgtype.Int8
200287
 }
201288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202312
 type User struct {
203313
 	ID                int64
204314
 	Username          string
internal/meta/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
9999
 	return string(ns.RepoVisibility), nil
100100
 }
101101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102189
 type AuthAuditLog struct {
103190
 	ID         int64
104191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199286
 	AddedByUserID pgtype.Int8
200287
 }
201288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202312
 type User struct {
203313
 	ID                int64
204314
 	Username          string
internal/migrationsfs/migrations/0020_repo_lifecycle.sqladded
@@ -0,0 +1,93 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- S16 repo lifecycle.
4
+--
5
+-- Two tables, both auxiliary to repos:
6
+--
7
+--   repo_redirects          — one row per (old_owner, old_name) → repo_id
8
+--                             so /old/repo 301s to the current path.
9
+--                             Written on rename and on transfer-accept.
10
+--                             A single repo accumulates redirect rows
11
+--                             over its lifetime; the current name is
12
+--                             always on `repos.name` itself.
13
+--
14
+--   repo_transfer_requests  — pending transfer offers. The recipient
15
+--                             accepts or declines; expiration is 7
16
+--                             days; the sender can cancel.
17
+
18
+-- +goose Up
19
+
20
+CREATE TABLE repo_redirects (
21
+    old_owner_user_id  bigint,
22
+    old_owner_org_id   bigint,
23
+    old_name           citext      NOT NULL,
24
+    repo_id            bigint      NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
25
+    redirected_at      timestamptz NOT NULL DEFAULT now(),
26
+
27
+    -- Exactly one old-owner FK is set, mirroring repos.owner_xor.
28
+    CONSTRAINT repo_redirects_old_owner_xor CHECK (
29
+        (old_owner_user_id IS NOT NULL AND old_owner_org_id IS NULL)
30
+     OR (old_owner_user_id IS NULL     AND old_owner_org_id IS NOT NULL)
31
+    ),
32
+    CONSTRAINT repo_redirects_old_name_length CHECK (char_length(old_name::text) BETWEEN 1 AND 100)
33
+);
34
+
35
+-- Lookup index for /{old_owner}/{old_name} → repo_id. Two partial
36
+-- indexes mirror the unique index pattern on `repos`.
37
+CREATE UNIQUE INDEX repo_redirects_user_old_idx
38
+    ON repo_redirects (old_owner_user_id, old_name)
39
+    WHERE old_owner_user_id IS NOT NULL;
40
+
41
+CREATE UNIQUE INDEX repo_redirects_org_old_idx
42
+    ON repo_redirects (old_owner_org_id, old_name)
43
+    WHERE old_owner_org_id IS NOT NULL;
44
+
45
+
46
+CREATE TYPE transfer_principal_kind AS ENUM ('user', 'org');
47
+CREATE TYPE transfer_status         AS ENUM ('pending', 'accepted', 'declined', 'canceled', 'expired');
48
+
49
+CREATE TABLE repo_transfer_requests (
50
+    id                 bigserial   PRIMARY KEY,
51
+    repo_id            bigint      NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
52
+    from_user_id       bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
53
+    to_principal_kind  transfer_principal_kind NOT NULL,
54
+    to_principal_id    bigint      NOT NULL,
55
+    created_by         bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
56
+    created_at         timestamptz NOT NULL DEFAULT now(),
57
+    expires_at         timestamptz NOT NULL,
58
+    status             transfer_status NOT NULL DEFAULT 'pending',
59
+    accepted_at        timestamptz,
60
+    declined_at        timestamptz,
61
+    canceled_at        timestamptz,
62
+
63
+    -- Status invariants. We don't validate the (status, *_at) cross-
64
+    -- invariant here — the lifecycle orchestrator owns those updates
65
+    -- in transactions and is the single writer.
66
+    CONSTRAINT repo_transfer_requests_status_terminal CHECK (
67
+        (status = 'pending'  AND accepted_at IS NULL AND declined_at IS NULL AND canceled_at IS NULL) OR
68
+        (status = 'accepted' AND accepted_at IS NOT NULL) OR
69
+        (status = 'declined' AND declined_at IS NOT NULL) OR
70
+        (status = 'canceled' AND canceled_at IS NOT NULL) OR
71
+        (status = 'expired')
72
+    )
73
+);
74
+
75
+-- Recipient inbox lookup: "show me pending transfers offered to user X."
76
+CREATE INDEX repo_transfer_requests_recipient_idx
77
+    ON repo_transfer_requests (to_principal_kind, to_principal_id, status)
78
+    WHERE status = 'pending';
79
+
80
+-- Sender lookup + history.
81
+CREATE INDEX repo_transfer_requests_repo_idx
82
+    ON repo_transfer_requests (repo_id, created_at DESC);
83
+
84
+-- Expiry sweep (the worker that flips pending → expired runs over this).
85
+CREATE INDEX repo_transfer_requests_expiry_idx
86
+    ON repo_transfer_requests (expires_at)
87
+    WHERE status = 'pending';
88
+
89
+-- +goose Down
90
+DROP TABLE IF EXISTS repo_transfer_requests;
91
+DROP TYPE IF EXISTS transfer_status;
92
+DROP TYPE IF EXISTS transfer_principal_kind;
93
+DROP TABLE IF EXISTS repo_redirects;
internal/repos/queries/lifecycle.sqladded
@@ -0,0 +1,162 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- S16 lifecycle queries. Kept in a separate file from repos.sql so the
4
+-- mainline CRUD stays easy to read.
5
+
6
+-- ─── repo mutations ────────────────────────────────────────────────────
7
+
8
+-- name: RenameRepo :exec
9
+-- Same-owner rename. The handler validates the new name shape and the
10
+-- redirect row is INSERTed in the same tx.
11
+UPDATE repos SET name = $2, updated_at = now() WHERE id = $1;
12
+
13
+-- name: TransferRepoOwner :exec
14
+-- Sets owner_user_id (or owner_org_id post-S31) on accept. The xor
15
+-- check on the table enforces the shape. Also clears the other side
16
+-- so a user→org transfer flips both columns atomically.
17
+UPDATE repos
18
+SET owner_user_id = sqlc.narg(owner_user_id)::bigint,
19
+    owner_org_id  = sqlc.narg(owner_org_id)::bigint,
20
+    name          = $2,
21
+    updated_at    = now()
22
+WHERE id = $1;
23
+
24
+-- name: ArchiveRepo :exec
25
+UPDATE repos SET is_archived = true, archived_at = now(), updated_at = now() WHERE id = $1;
26
+
27
+-- name: UnarchiveRepo :exec
28
+UPDATE repos SET is_archived = false, archived_at = NULL, updated_at = now() WHERE id = $1;
29
+
30
+-- name: SetRepoVisibility :exec
31
+UPDATE repos SET visibility = $2, updated_at = now() WHERE id = $1;
32
+
33
+-- name: SoftDeleteRepoLifecycle :exec
34
+-- Distinct name from S11's SoftDeleteRepo so future code that wants to
35
+-- preserve the lifecycle audit-emission shape can find this one.
36
+UPDATE repos SET deleted_at = now(), updated_at = now() WHERE id = $1;
37
+
38
+-- name: RestoreRepo :exec
39
+UPDATE repos SET deleted_at = NULL, updated_at = now() WHERE id = $1;
40
+
41
+-- name: HardDeleteRepo :exec
42
+DELETE FROM repos WHERE id = $1;
43
+
44
+
45
+-- ─── redirects ─────────────────────────────────────────────────────────
46
+
47
+-- name: InsertRepoRedirect :exec
48
+-- Both old-owner FKs are nullable; pass exactly one. The CHECK
49
+-- constraint on the table enforces the xor shape.
50
+INSERT INTO repo_redirects (old_owner_user_id, old_owner_org_id, old_name, repo_id)
51
+VALUES (sqlc.narg(old_owner_user_id)::bigint, sqlc.narg(old_owner_org_id)::bigint, $1, $2)
52
+ON CONFLICT DO NOTHING;
53
+
54
+-- name: LookupRedirectByUserOwner :one
55
+-- Returns the current repo_id when (old_owner_user_id, old_name) hits
56
+-- a redirect row.
57
+SELECT repo_id FROM repo_redirects
58
+WHERE old_owner_user_id = $1 AND old_name = $2;
59
+
60
+-- name: DeleteRedirectsForRepo :exec
61
+-- Used by hard-delete: drop the redirect rows pointing at this repo
62
+-- (they would dangle once the repos row is gone; the FK ON DELETE
63
+-- CASCADE would handle it, but explicit is auditable).
64
+DELETE FROM repo_redirects WHERE repo_id = $1;
65
+
66
+
67
+-- ─── transfer requests ─────────────────────────────────────────────────
68
+
69
+-- name: InsertTransferRequest :one
70
+INSERT INTO repo_transfer_requests (
71
+    repo_id, from_user_id, to_principal_kind, to_principal_id,
72
+    created_by, expires_at
73
+) VALUES (
74
+    $1, $2, $3, $4, $5, $6
75
+)
76
+RETURNING id, repo_id, from_user_id, to_principal_kind, to_principal_id,
77
+          created_by, created_at, expires_at, status,
78
+          accepted_at, declined_at, canceled_at;
79
+
80
+-- name: GetTransferRequest :one
81
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
82
+       created_by, created_at, expires_at, status,
83
+       accepted_at, declined_at, canceled_at
84
+FROM repo_transfer_requests
85
+WHERE id = $1;
86
+
87
+-- name: ListPendingTransfersForUser :many
88
+-- Inbox view: pending offers a user can act on.
89
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
90
+       created_by, created_at, expires_at, status,
91
+       accepted_at, declined_at, canceled_at
92
+FROM repo_transfer_requests
93
+WHERE to_principal_kind = 'user' AND to_principal_id = $1 AND status = 'pending'
94
+ORDER BY created_at DESC;
95
+
96
+-- name: ListTransfersForRepo :many
97
+-- Sender / repo-settings view.
98
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
99
+       created_by, created_at, expires_at, status,
100
+       accepted_at, declined_at, canceled_at
101
+FROM repo_transfer_requests
102
+WHERE repo_id = $1
103
+ORDER BY created_at DESC;
104
+
105
+-- name: AcceptTransferRequest :exec
106
+UPDATE repo_transfer_requests
107
+SET status = 'accepted', accepted_at = now()
108
+WHERE id = $1 AND status = 'pending';
109
+
110
+-- name: DeclineTransferRequest :exec
111
+UPDATE repo_transfer_requests
112
+SET status = 'declined', declined_at = now()
113
+WHERE id = $1 AND status = 'pending';
114
+
115
+-- name: CancelTransferRequest :exec
116
+UPDATE repo_transfer_requests
117
+SET status = 'canceled', canceled_at = now()
118
+WHERE id = $1 AND status = 'pending';
119
+
120
+-- name: ExpirePendingTransfers :execrows
121
+-- Called by the periodic worker (transfers:expire) — flips pending
122
+-- offers past their expires_at to the expired terminal state.
123
+UPDATE repo_transfer_requests
124
+SET status = 'expired'
125
+WHERE status = 'pending' AND expires_at < now();
126
+
127
+
128
+-- ─── soft-delete sweep query ───────────────────────────────────────────
129
+
130
+-- name: ListRepoIDsPastSoftDeleteGrace :many
131
+-- The repo:hard_delete enqueuer queries this to find rows ready for
132
+-- destruction. The 7-day grace is hard-coded here; if we add a config
133
+-- knob later, change this to a parameter.
134
+SELECT id FROM repos
135
+WHERE deleted_at IS NOT NULL AND deleted_at < now() - interval '7 days'
136
+ORDER BY deleted_at ASC;
137
+
138
+-- name: ListSoftDeletedReposForOwner :many
139
+-- /settings/repositories/restore page lists these.
140
+SELECT id, owner_user_id, name, deleted_at
141
+FROM repos
142
+WHERE owner_user_id = $1 AND deleted_at IS NOT NULL
143
+ORDER BY deleted_at DESC;
144
+
145
+
146
+-- ─── rename rate limit support ─────────────────────────────────────────
147
+
148
+-- name: CountRecentRedirectsForRepo :one
149
+-- Used to enforce the 5-per-30-days rename rate limit. The redirect
150
+-- row is the audit trail for renames; counting them per repo gives a
151
+-- reliable cap.
152
+SELECT count(*)::int AS recent_count
153
+FROM repo_redirects
154
+WHERE repo_id = $1 AND redirected_at > now() - interval '30 days';
155
+
156
+
157
+-- ─── fork-anchor cleanup on hard delete ────────────────────────────────
158
+
159
+-- name: OrphanForksOf :execrows
160
+-- Children pointing at this repo lose their fork-of pointer. Mirrors
161
+-- GitHub's behavior when an upstream is deleted.
162
+UPDATE repos SET fork_of_repo_id = NULL WHERE fork_of_repo_id = $1;
internal/repos/sqlc/lifecycle.sql.goadded
@@ -0,0 +1,501 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: lifecycle.sql
5
+
6
+package reposdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const acceptTransferRequest = `-- name: AcceptTransferRequest :exec
15
+UPDATE repo_transfer_requests
16
+SET status = 'accepted', accepted_at = now()
17
+WHERE id = $1 AND status = 'pending'
18
+`
19
+
20
+func (q *Queries) AcceptTransferRequest(ctx context.Context, db DBTX, id int64) error {
21
+	_, err := db.Exec(ctx, acceptTransferRequest, id)
22
+	return err
23
+}
24
+
25
+const archiveRepo = `-- name: ArchiveRepo :exec
26
+UPDATE repos SET is_archived = true, archived_at = now(), updated_at = now() WHERE id = $1
27
+`
28
+
29
+func (q *Queries) ArchiveRepo(ctx context.Context, db DBTX, id int64) error {
30
+	_, err := db.Exec(ctx, archiveRepo, id)
31
+	return err
32
+}
33
+
34
+const cancelTransferRequest = `-- name: CancelTransferRequest :exec
35
+UPDATE repo_transfer_requests
36
+SET status = 'canceled', canceled_at = now()
37
+WHERE id = $1 AND status = 'pending'
38
+`
39
+
40
+func (q *Queries) CancelTransferRequest(ctx context.Context, db DBTX, id int64) error {
41
+	_, err := db.Exec(ctx, cancelTransferRequest, id)
42
+	return err
43
+}
44
+
45
+const countRecentRedirectsForRepo = `-- name: CountRecentRedirectsForRepo :one
46
+
47
+SELECT count(*)::int AS recent_count
48
+FROM repo_redirects
49
+WHERE repo_id = $1 AND redirected_at > now() - interval '30 days'
50
+`
51
+
52
+// ─── rename rate limit support ─────────────────────────────────────────
53
+// Used to enforce the 5-per-30-days rename rate limit. The redirect
54
+// row is the audit trail for renames; counting them per repo gives a
55
+// reliable cap.
56
+func (q *Queries) CountRecentRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) (int32, error) {
57
+	row := db.QueryRow(ctx, countRecentRedirectsForRepo, repoID)
58
+	var recent_count int32
59
+	err := row.Scan(&recent_count)
60
+	return recent_count, err
61
+}
62
+
63
+const declineTransferRequest = `-- name: DeclineTransferRequest :exec
64
+UPDATE repo_transfer_requests
65
+SET status = 'declined', declined_at = now()
66
+WHERE id = $1 AND status = 'pending'
67
+`
68
+
69
+func (q *Queries) DeclineTransferRequest(ctx context.Context, db DBTX, id int64) error {
70
+	_, err := db.Exec(ctx, declineTransferRequest, id)
71
+	return err
72
+}
73
+
74
+const deleteRedirectsForRepo = `-- name: DeleteRedirectsForRepo :exec
75
+DELETE FROM repo_redirects WHERE repo_id = $1
76
+`
77
+
78
+// Used by hard-delete: drop the redirect rows pointing at this repo
79
+// (they would dangle once the repos row is gone; the FK ON DELETE
80
+// CASCADE would handle it, but explicit is auditable).
81
+func (q *Queries) DeleteRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) error {
82
+	_, err := db.Exec(ctx, deleteRedirectsForRepo, repoID)
83
+	return err
84
+}
85
+
86
+const expirePendingTransfers = `-- name: ExpirePendingTransfers :execrows
87
+UPDATE repo_transfer_requests
88
+SET status = 'expired'
89
+WHERE status = 'pending' AND expires_at < now()
90
+`
91
+
92
+// Called by the periodic worker (transfers:expire) — flips pending
93
+// offers past their expires_at to the expired terminal state.
94
+func (q *Queries) ExpirePendingTransfers(ctx context.Context, db DBTX) (int64, error) {
95
+	result, err := db.Exec(ctx, expirePendingTransfers)
96
+	if err != nil {
97
+		return 0, err
98
+	}
99
+	return result.RowsAffected(), nil
100
+}
101
+
102
+const getTransferRequest = `-- name: GetTransferRequest :one
103
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
104
+       created_by, created_at, expires_at, status,
105
+       accepted_at, declined_at, canceled_at
106
+FROM repo_transfer_requests
107
+WHERE id = $1
108
+`
109
+
110
+func (q *Queries) GetTransferRequest(ctx context.Context, db DBTX, id int64) (RepoTransferRequest, error) {
111
+	row := db.QueryRow(ctx, getTransferRequest, id)
112
+	var i RepoTransferRequest
113
+	err := row.Scan(
114
+		&i.ID,
115
+		&i.RepoID,
116
+		&i.FromUserID,
117
+		&i.ToPrincipalKind,
118
+		&i.ToPrincipalID,
119
+		&i.CreatedBy,
120
+		&i.CreatedAt,
121
+		&i.ExpiresAt,
122
+		&i.Status,
123
+		&i.AcceptedAt,
124
+		&i.DeclinedAt,
125
+		&i.CanceledAt,
126
+	)
127
+	return i, err
128
+}
129
+
130
+const hardDeleteRepo = `-- name: HardDeleteRepo :exec
131
+DELETE FROM repos WHERE id = $1
132
+`
133
+
134
+func (q *Queries) HardDeleteRepo(ctx context.Context, db DBTX, id int64) error {
135
+	_, err := db.Exec(ctx, hardDeleteRepo, id)
136
+	return err
137
+}
138
+
139
+const insertRepoRedirect = `-- name: InsertRepoRedirect :exec
140
+
141
+INSERT INTO repo_redirects (old_owner_user_id, old_owner_org_id, old_name, repo_id)
142
+VALUES ($3::bigint, $4::bigint, $1, $2)
143
+ON CONFLICT DO NOTHING
144
+`
145
+
146
+type InsertRepoRedirectParams struct {
147
+	OldName        string
148
+	RepoID         int64
149
+	OldOwnerUserID pgtype.Int8
150
+	OldOwnerOrgID  pgtype.Int8
151
+}
152
+
153
+// ─── redirects ─────────────────────────────────────────────────────────
154
+// Both old-owner FKs are nullable; pass exactly one. The CHECK
155
+// constraint on the table enforces the xor shape.
156
+func (q *Queries) InsertRepoRedirect(ctx context.Context, db DBTX, arg InsertRepoRedirectParams) error {
157
+	_, err := db.Exec(ctx, insertRepoRedirect,
158
+		arg.OldName,
159
+		arg.RepoID,
160
+		arg.OldOwnerUserID,
161
+		arg.OldOwnerOrgID,
162
+	)
163
+	return err
164
+}
165
+
166
+const insertTransferRequest = `-- name: InsertTransferRequest :one
167
+
168
+INSERT INTO repo_transfer_requests (
169
+    repo_id, from_user_id, to_principal_kind, to_principal_id,
170
+    created_by, expires_at
171
+) VALUES (
172
+    $1, $2, $3, $4, $5, $6
173
+)
174
+RETURNING id, repo_id, from_user_id, to_principal_kind, to_principal_id,
175
+          created_by, created_at, expires_at, status,
176
+          accepted_at, declined_at, canceled_at
177
+`
178
+
179
+type InsertTransferRequestParams struct {
180
+	RepoID          int64
181
+	FromUserID      int64
182
+	ToPrincipalKind TransferPrincipalKind
183
+	ToPrincipalID   int64
184
+	CreatedBy       int64
185
+	ExpiresAt       pgtype.Timestamptz
186
+}
187
+
188
+// ─── transfer requests ─────────────────────────────────────────────────
189
+func (q *Queries) InsertTransferRequest(ctx context.Context, db DBTX, arg InsertTransferRequestParams) (RepoTransferRequest, error) {
190
+	row := db.QueryRow(ctx, insertTransferRequest,
191
+		arg.RepoID,
192
+		arg.FromUserID,
193
+		arg.ToPrincipalKind,
194
+		arg.ToPrincipalID,
195
+		arg.CreatedBy,
196
+		arg.ExpiresAt,
197
+	)
198
+	var i RepoTransferRequest
199
+	err := row.Scan(
200
+		&i.ID,
201
+		&i.RepoID,
202
+		&i.FromUserID,
203
+		&i.ToPrincipalKind,
204
+		&i.ToPrincipalID,
205
+		&i.CreatedBy,
206
+		&i.CreatedAt,
207
+		&i.ExpiresAt,
208
+		&i.Status,
209
+		&i.AcceptedAt,
210
+		&i.DeclinedAt,
211
+		&i.CanceledAt,
212
+	)
213
+	return i, err
214
+}
215
+
216
+const listPendingTransfersForUser = `-- name: ListPendingTransfersForUser :many
217
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
218
+       created_by, created_at, expires_at, status,
219
+       accepted_at, declined_at, canceled_at
220
+FROM repo_transfer_requests
221
+WHERE to_principal_kind = 'user' AND to_principal_id = $1 AND status = 'pending'
222
+ORDER BY created_at DESC
223
+`
224
+
225
+// Inbox view: pending offers a user can act on.
226
+func (q *Queries) ListPendingTransfersForUser(ctx context.Context, db DBTX, toPrincipalID int64) ([]RepoTransferRequest, error) {
227
+	rows, err := db.Query(ctx, listPendingTransfersForUser, toPrincipalID)
228
+	if err != nil {
229
+		return nil, err
230
+	}
231
+	defer rows.Close()
232
+	items := []RepoTransferRequest{}
233
+	for rows.Next() {
234
+		var i RepoTransferRequest
235
+		if err := rows.Scan(
236
+			&i.ID,
237
+			&i.RepoID,
238
+			&i.FromUserID,
239
+			&i.ToPrincipalKind,
240
+			&i.ToPrincipalID,
241
+			&i.CreatedBy,
242
+			&i.CreatedAt,
243
+			&i.ExpiresAt,
244
+			&i.Status,
245
+			&i.AcceptedAt,
246
+			&i.DeclinedAt,
247
+			&i.CanceledAt,
248
+		); err != nil {
249
+			return nil, err
250
+		}
251
+		items = append(items, i)
252
+	}
253
+	if err := rows.Err(); err != nil {
254
+		return nil, err
255
+	}
256
+	return items, nil
257
+}
258
+
259
+const listRepoIDsPastSoftDeleteGrace = `-- name: ListRepoIDsPastSoftDeleteGrace :many
260
+
261
+SELECT id FROM repos
262
+WHERE deleted_at IS NOT NULL AND deleted_at < now() - interval '7 days'
263
+ORDER BY deleted_at ASC
264
+`
265
+
266
+// ─── soft-delete sweep query ───────────────────────────────────────────
267
+// The repo:hard_delete enqueuer queries this to find rows ready for
268
+// destruction. The 7-day grace is hard-coded here; if we add a config
269
+// knob later, change this to a parameter.
270
+func (q *Queries) ListRepoIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error) {
271
+	rows, err := db.Query(ctx, listRepoIDsPastSoftDeleteGrace)
272
+	if err != nil {
273
+		return nil, err
274
+	}
275
+	defer rows.Close()
276
+	items := []int64{}
277
+	for rows.Next() {
278
+		var id int64
279
+		if err := rows.Scan(&id); err != nil {
280
+			return nil, err
281
+		}
282
+		items = append(items, id)
283
+	}
284
+	if err := rows.Err(); err != nil {
285
+		return nil, err
286
+	}
287
+	return items, nil
288
+}
289
+
290
+const listSoftDeletedReposForOwner = `-- name: ListSoftDeletedReposForOwner :many
291
+SELECT id, owner_user_id, name, deleted_at
292
+FROM repos
293
+WHERE owner_user_id = $1 AND deleted_at IS NOT NULL
294
+ORDER BY deleted_at DESC
295
+`
296
+
297
+type ListSoftDeletedReposForOwnerRow struct {
298
+	ID          int64
299
+	OwnerUserID pgtype.Int8
300
+	Name        string
301
+	DeletedAt   pgtype.Timestamptz
302
+}
303
+
304
+// /settings/repositories/restore page lists these.
305
+func (q *Queries) ListSoftDeletedReposForOwner(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]ListSoftDeletedReposForOwnerRow, error) {
306
+	rows, err := db.Query(ctx, listSoftDeletedReposForOwner, ownerUserID)
307
+	if err != nil {
308
+		return nil, err
309
+	}
310
+	defer rows.Close()
311
+	items := []ListSoftDeletedReposForOwnerRow{}
312
+	for rows.Next() {
313
+		var i ListSoftDeletedReposForOwnerRow
314
+		if err := rows.Scan(
315
+			&i.ID,
316
+			&i.OwnerUserID,
317
+			&i.Name,
318
+			&i.DeletedAt,
319
+		); err != nil {
320
+			return nil, err
321
+		}
322
+		items = append(items, i)
323
+	}
324
+	if err := rows.Err(); err != nil {
325
+		return nil, err
326
+	}
327
+	return items, nil
328
+}
329
+
330
+const listTransfersForRepo = `-- name: ListTransfersForRepo :many
331
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
332
+       created_by, created_at, expires_at, status,
333
+       accepted_at, declined_at, canceled_at
334
+FROM repo_transfer_requests
335
+WHERE repo_id = $1
336
+ORDER BY created_at DESC
337
+`
338
+
339
+// Sender / repo-settings view.
340
+func (q *Queries) ListTransfersForRepo(ctx context.Context, db DBTX, repoID int64) ([]RepoTransferRequest, error) {
341
+	rows, err := db.Query(ctx, listTransfersForRepo, repoID)
342
+	if err != nil {
343
+		return nil, err
344
+	}
345
+	defer rows.Close()
346
+	items := []RepoTransferRequest{}
347
+	for rows.Next() {
348
+		var i RepoTransferRequest
349
+		if err := rows.Scan(
350
+			&i.ID,
351
+			&i.RepoID,
352
+			&i.FromUserID,
353
+			&i.ToPrincipalKind,
354
+			&i.ToPrincipalID,
355
+			&i.CreatedBy,
356
+			&i.CreatedAt,
357
+			&i.ExpiresAt,
358
+			&i.Status,
359
+			&i.AcceptedAt,
360
+			&i.DeclinedAt,
361
+			&i.CanceledAt,
362
+		); err != nil {
363
+			return nil, err
364
+		}
365
+		items = append(items, i)
366
+	}
367
+	if err := rows.Err(); err != nil {
368
+		return nil, err
369
+	}
370
+	return items, nil
371
+}
372
+
373
+const lookupRedirectByUserOwner = `-- name: LookupRedirectByUserOwner :one
374
+SELECT repo_id FROM repo_redirects
375
+WHERE old_owner_user_id = $1 AND old_name = $2
376
+`
377
+
378
+type LookupRedirectByUserOwnerParams struct {
379
+	OldOwnerUserID pgtype.Int8
380
+	OldName        string
381
+}
382
+
383
+// Returns the current repo_id when (old_owner_user_id, old_name) hits
384
+// a redirect row.
385
+func (q *Queries) LookupRedirectByUserOwner(ctx context.Context, db DBTX, arg LookupRedirectByUserOwnerParams) (int64, error) {
386
+	row := db.QueryRow(ctx, lookupRedirectByUserOwner, arg.OldOwnerUserID, arg.OldName)
387
+	var repo_id int64
388
+	err := row.Scan(&repo_id)
389
+	return repo_id, err
390
+}
391
+
392
+const orphanForksOf = `-- name: OrphanForksOf :execrows
393
+
394
+UPDATE repos SET fork_of_repo_id = NULL WHERE fork_of_repo_id = $1
395
+`
396
+
397
+// ─── fork-anchor cleanup on hard delete ────────────────────────────────
398
+// Children pointing at this repo lose their fork-of pointer. Mirrors
399
+// GitHub's behavior when an upstream is deleted.
400
+func (q *Queries) OrphanForksOf(ctx context.Context, db DBTX, forkOfRepoID pgtype.Int8) (int64, error) {
401
+	result, err := db.Exec(ctx, orphanForksOf, forkOfRepoID)
402
+	if err != nil {
403
+		return 0, err
404
+	}
405
+	return result.RowsAffected(), nil
406
+}
407
+
408
+const renameRepo = `-- name: RenameRepo :exec
409
+
410
+
411
+UPDATE repos SET name = $2, updated_at = now() WHERE id = $1
412
+`
413
+
414
+type RenameRepoParams struct {
415
+	ID   int64
416
+	Name string
417
+}
418
+
419
+// SPDX-License-Identifier: AGPL-3.0-or-later
420
+//
421
+// S16 lifecycle queries. Kept in a separate file from repos.sql so the
422
+// mainline CRUD stays easy to read.
423
+// ─── repo mutations ────────────────────────────────────────────────────
424
+// Same-owner rename. The handler validates the new name shape and the
425
+// redirect row is INSERTed in the same tx.
426
+func (q *Queries) RenameRepo(ctx context.Context, db DBTX, arg RenameRepoParams) error {
427
+	_, err := db.Exec(ctx, renameRepo, arg.ID, arg.Name)
428
+	return err
429
+}
430
+
431
+const restoreRepo = `-- name: RestoreRepo :exec
432
+UPDATE repos SET deleted_at = NULL, updated_at = now() WHERE id = $1
433
+`
434
+
435
+func (q *Queries) RestoreRepo(ctx context.Context, db DBTX, id int64) error {
436
+	_, err := db.Exec(ctx, restoreRepo, id)
437
+	return err
438
+}
439
+
440
+const setRepoVisibility = `-- name: SetRepoVisibility :exec
441
+UPDATE repos SET visibility = $2, updated_at = now() WHERE id = $1
442
+`
443
+
444
+type SetRepoVisibilityParams struct {
445
+	ID         int64
446
+	Visibility RepoVisibility
447
+}
448
+
449
+func (q *Queries) SetRepoVisibility(ctx context.Context, db DBTX, arg SetRepoVisibilityParams) error {
450
+	_, err := db.Exec(ctx, setRepoVisibility, arg.ID, arg.Visibility)
451
+	return err
452
+}
453
+
454
+const softDeleteRepoLifecycle = `-- name: SoftDeleteRepoLifecycle :exec
455
+UPDATE repos SET deleted_at = now(), updated_at = now() WHERE id = $1
456
+`
457
+
458
+// Distinct name from S11's SoftDeleteRepo so future code that wants to
459
+// preserve the lifecycle audit-emission shape can find this one.
460
+func (q *Queries) SoftDeleteRepoLifecycle(ctx context.Context, db DBTX, id int64) error {
461
+	_, err := db.Exec(ctx, softDeleteRepoLifecycle, id)
462
+	return err
463
+}
464
+
465
+const transferRepoOwner = `-- name: TransferRepoOwner :exec
466
+UPDATE repos
467
+SET owner_user_id = $3::bigint,
468
+    owner_org_id  = $4::bigint,
469
+    name          = $2,
470
+    updated_at    = now()
471
+WHERE id = $1
472
+`
473
+
474
+type TransferRepoOwnerParams struct {
475
+	ID          int64
476
+	Name        string
477
+	OwnerUserID pgtype.Int8
478
+	OwnerOrgID  pgtype.Int8
479
+}
480
+
481
+// Sets owner_user_id (or owner_org_id post-S31) on accept. The xor
482
+// check on the table enforces the shape. Also clears the other side
483
+// so a user→org transfer flips both columns atomically.
484
+func (q *Queries) TransferRepoOwner(ctx context.Context, db DBTX, arg TransferRepoOwnerParams) error {
485
+	_, err := db.Exec(ctx, transferRepoOwner,
486
+		arg.ID,
487
+		arg.Name,
488
+		arg.OwnerUserID,
489
+		arg.OwnerOrgID,
490
+	)
491
+	return err
492
+}
493
+
494
+const unarchiveRepo = `-- name: UnarchiveRepo :exec
495
+UPDATE repos SET is_archived = false, archived_at = NULL, updated_at = now() WHERE id = $1
496
+`
497
+
498
+func (q *Queries) UnarchiveRepo(ctx context.Context, db DBTX, id int64) error {
499
+	_, err := db.Exec(ctx, unarchiveRepo, id)
500
+	return err
501
+}
internal/repos/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
9999
 	return string(ns.RepoVisibility), nil
100100
 }
101101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102189
 type AuthAuditLog struct {
103190
 	ID         int64
104191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199286
 	AddedByUserID pgtype.Int8
200287
 }
201288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202312
 type User struct {
203313
 	ID                int64
204314
 	Username          string
internal/repos/sqlc/querier.gomodified
@@ -11,21 +11,81 @@ import (
1111
 )
1212
 
1313
 type Querier interface {
14
+	AcceptTransferRequest(ctx context.Context, db DBTX, id int64) error
15
+	ArchiveRepo(ctx context.Context, db DBTX, id int64) error
16
+	CancelTransferRequest(ctx context.Context, db DBTX, id int64) error
17
+	// ─── rename rate limit support ─────────────────────────────────────────
18
+	// Used to enforce the 5-per-30-days rename rate limit. The redirect
19
+	// row is the audit trail for renames; counting them per repo gives a
20
+	// reliable cap.
21
+	CountRecentRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) (int32, error)
1422
 	CountReposForOwnerUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) (int64, error)
1523
 	// SPDX-License-Identifier: AGPL-3.0-or-later
1624
 	CreateRepo(ctx context.Context, db DBTX, arg CreateRepoParams) (Repo, error)
25
+	DeclineTransferRequest(ctx context.Context, db DBTX, id int64) error
26
+	// Used by hard-delete: drop the redirect rows pointing at this repo
27
+	// (they would dangle once the repos row is gone; the FK ON DELETE
28
+	// CASCADE would handle it, but explicit is auditable).
29
+	DeleteRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) error
1730
 	ExistsRepoForOwnerUser(ctx context.Context, db DBTX, arg ExistsRepoForOwnerUserParams) (bool, error)
31
+	// Called by the periodic worker (transfers:expire) — flips pending
32
+	// offers past their expires_at to the expired terminal state.
33
+	ExpirePendingTransfers(ctx context.Context, db DBTX) (int64, error)
1834
 	GetRepoByID(ctx context.Context, db DBTX, id int64) (Repo, error)
1935
 	GetRepoByOwnerUserAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerUserAndNameParams) (Repo, error)
2036
 	// Returns the owner_username for a repo. Used by size-recalc and other
2137
 	// jobs that need to derive the bare-repo on-disk path without round-
2238
 	// tripping through the full user row.
2339
 	GetRepoOwnerUsernameByID(ctx context.Context, db DBTX, id int64) (GetRepoOwnerUsernameByIDRow, error)
40
+	GetTransferRequest(ctx context.Context, db DBTX, id int64) (RepoTransferRequest, error)
41
+	HardDeleteRepo(ctx context.Context, db DBTX, id int64) error
42
+	// ─── redirects ─────────────────────────────────────────────────────────
43
+	// Both old-owner FKs are nullable; pass exactly one. The CHECK
44
+	// constraint on the table enforces the xor shape.
45
+	InsertRepoRedirect(ctx context.Context, db DBTX, arg InsertRepoRedirectParams) error
46
+	// ─── transfer requests ─────────────────────────────────────────────────
47
+	InsertTransferRequest(ctx context.Context, db DBTX, arg InsertTransferRequestParams) (RepoTransferRequest, error)
2448
 	// Used by `shithubd hooks reinstall --all` to enumerate every active
2549
 	// bare repo on disk and re-link its hooks.
2650
 	ListAllRepoFullNames(ctx context.Context, db DBTX) ([]ListAllRepoFullNamesRow, error)
51
+	// Inbox view: pending offers a user can act on.
52
+	ListPendingTransfersForUser(ctx context.Context, db DBTX, toPrincipalID int64) ([]RepoTransferRequest, error)
53
+	// ─── soft-delete sweep query ───────────────────────────────────────────
54
+	// The repo:hard_delete enqueuer queries this to find rows ready for
55
+	// destruction. The 7-day grace is hard-coded here; if we add a config
56
+	// knob later, change this to a parameter.
57
+	ListRepoIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error)
2758
 	ListReposForOwnerUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]Repo, error)
59
+	// /settings/repositories/restore page lists these.
60
+	ListSoftDeletedReposForOwner(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]ListSoftDeletedReposForOwnerRow, error)
61
+	// Sender / repo-settings view.
62
+	ListTransfersForRepo(ctx context.Context, db DBTX, repoID int64) ([]RepoTransferRequest, error)
63
+	// Returns the current repo_id when (old_owner_user_id, old_name) hits
64
+	// a redirect row.
65
+	LookupRedirectByUserOwner(ctx context.Context, db DBTX, arg LookupRedirectByUserOwnerParams) (int64, error)
66
+	// ─── fork-anchor cleanup on hard delete ────────────────────────────────
67
+	// Children pointing at this repo lose their fork-of pointer. Mirrors
68
+	// GitHub's behavior when an upstream is deleted.
69
+	OrphanForksOf(ctx context.Context, db DBTX, forkOfRepoID pgtype.Int8) (int64, error)
70
+	// SPDX-License-Identifier: AGPL-3.0-or-later
71
+	//
72
+	// S16 lifecycle queries. Kept in a separate file from repos.sql so the
73
+	// mainline CRUD stays easy to read.
74
+	// ─── repo mutations ────────────────────────────────────────────────────
75
+	// Same-owner rename. The handler validates the new name shape and the
76
+	// redirect row is INSERTed in the same tx.
77
+	RenameRepo(ctx context.Context, db DBTX, arg RenameRepoParams) error
78
+	RestoreRepo(ctx context.Context, db DBTX, id int64) error
79
+	SetRepoVisibility(ctx context.Context, db DBTX, arg SetRepoVisibilityParams) error
2880
 	SoftDeleteRepo(ctx context.Context, db DBTX, id int64) error
81
+	// Distinct name from S11's SoftDeleteRepo so future code that wants to
82
+	// preserve the lifecycle audit-emission shape can find this one.
83
+	SoftDeleteRepoLifecycle(ctx context.Context, db DBTX, id int64) error
84
+	// Sets owner_user_id (or owner_org_id post-S31) on accept. The xor
85
+	// check on the table enforces the shape. Also clears the other side
86
+	// so a user→org transfer flips both columns atomically.
87
+	TransferRepoOwner(ctx context.Context, db DBTX, arg TransferRepoOwnerParams) error
88
+	UnarchiveRepo(ctx context.Context, db DBTX, id int64) error
2989
 	// Set when push:process detects a commit on the repo's default branch.
3090
 	// Pass NULL to clear (e.g. when the branch is force-deleted in a future
3191
 	// sprint). The repo home view reads this to decide between empty and
internal/users/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
9999
 	return string(ns.RepoVisibility), nil
100100
 }
101101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102189
 type AuthAuditLog struct {
103190
 	ID         int64
104191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199286
 	AddedByUserID pgtype.Int8
200287
 }
201288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202312
 type User struct {
203313
 	ID                int64
204314
 	Username          string
internal/worker/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
9999
 	return string(ns.RepoVisibility), nil
100100
 }
101101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102189
 type AuthAuditLog struct {
103190
 	ID         int64
104191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199286
 	AddedByUserID pgtype.Int8
200287
 }
201288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202312
 type User struct {
203313
 	ID                int64
204314
 	Username          string