Skip to content
File

Blob: src/worker/db/d1/dal/repositories.ts

typescript172 lines
1import { and, eq, exists, or, sql } from "drizzle-orm";
2 
3import type { Db } from "@/worker/db/d1/client";
4import { namespaceMemberships } from "@/worker/db/d1/schema/namespaceMemberships";
5import { namespaces } from "@/worker/db/d1/schema/namespaces";
6import {
7 type NewRepositoryRow,
8 type RepositoryRow,
9 type RepositoryVisibility,
10 repositories,
11} from "@/worker/db/d1/schema/repositories";
12 
13export async function findRepositoryById(
14 db: Db,
15 repositoryId: string
16): Promise<RepositoryRow | undefined> {
17 const rows = await db
18 .select()
19 .from(repositories)
20 .where(eq(repositories.id, repositoryId))
21 .limit(1);
22 return rows[0];
23}
24 
25export async function findRepositoryByDoName(
26 db: Db,
27 doName: string
28): Promise<RepositoryRow | undefined> {
29 const rows = await db.select().from(repositories).where(eq(repositories.doName, doName)).limit(1);
30 return rows[0];
31}
32 
33export async function findRepositoryByNamespaceAndSlug(
34 db: Db,
35 namespaceId: string,
36 slug: string
37): Promise<RepositoryRow | undefined> {
38 const rows = await db
39 .select()
40 .from(repositories)
41 .where(and(eq(repositories.namespaceId, namespaceId), eq(repositories.slug, slug)))
42 .limit(1);
43 return rows[0];
44}
45 
46// Used by repository creation and operator/test seeding. The conflict path
47// keeps existing rows untouched, so safe replays and re-runs are idempotent.
48export async function insertRepositoryIfNew(
49 db: Db,
50 row: NewRepositoryRow
51): Promise<RepositoryRow | undefined> {
52 const inserted = await db.insert(repositories).values(row).onConflictDoNothing().returning();
53 return inserted[0];
54}
55 
56export type RepositoryListing = {
57 repository: RepositoryRow;
58 namespace: { id: string; slug: string };
59};
60 
61// All repositories owned by a user via namespace memberships, ordered by the
62// user-facing full repository name (`namespace/repo`). This keeps account
63// listings stable even when pushes update repository activity timestamps.
64export async function listRepositoriesForUser(
65 db: Db,
66 userId: string
67): Promise<RepositoryListing[]> {
68 const rows = await db
69 .select({ repository: repositories, namespaceSlug: namespaces.slug })
70 .from(repositories)
71 .innerJoin(namespaces, eq(repositories.namespaceId, namespaces.id))
72 .innerJoin(namespaceMemberships, eq(repositories.namespaceId, namespaceMemberships.namespaceId))
73 .where(eq(namespaceMemberships.userId, userId))
74 .orderBy(namespaces.slug, repositories.slug);
75 return rows.map((row) => ({
76 repository: row.repository,
77 namespace: { id: row.repository.namespaceId, slug: row.namespaceSlug },
78 }));
79}
80 
81// Membership is owner-equivalent in this migration; an `EXISTS` subquery
82// lets the same owner page query include private rows only for members.
83export async function listRepositoriesForNamespace(
84 db: Db,
85 namespaceId: string,
86 viewerUserId: string | null
87): Promise<RepositoryRow[]> {
88 if (viewerUserId === null) {
89 return await db
90 .select()
91 .from(repositories)
92 .where(and(eq(repositories.namespaceId, namespaceId), eq(repositories.visibility, "public")))
93 .orderBy(repositories.slug);
94 }
95 const memberClause = exists(
96 db
97 .select({ one: sql`1` })
98 .from(namespaceMemberships)
99 .where(
100 and(
101 eq(namespaceMemberships.namespaceId, namespaceId),
102 eq(namespaceMemberships.userId, viewerUserId)
103 )
104 )
105 );
106 return await db
107 .select()
108 .from(repositories)
109 .where(
110 and(
111 eq(repositories.namespaceId, namespaceId),
112 or(eq(repositories.visibility, "public"), memberClause)
113 )
114 )
115 .orderBy(repositories.slug);
116}
117 
118export async function touchRepositoryUpdatedAt(
119 db: Db,
120 repositoryId: string,
121 now: number
122): Promise<void> {
123 await db.update(repositories).set({ updatedAt: now }).where(eq(repositories.id, repositoryId));
124}
125 
126// Deletes a repository row by id. Schema FK cascades remove `pat_repo_grants`
127// for that repo. Namespace-scoped grants in `pat_namespace_grants` are
128// intentionally untouched: they still cover any other or future repo in the
129// same namespace.
130//
131// Returns true on first run, false on replay (row already absent). Callers
132// must enforce membership before invoking; this DAL has no auth opinion so
133// the queue consumer can use it after the request-path gate has already
134// run.
135export async function deleteRepositoryById(db: Db, repositoryId: string): Promise<boolean> {
136 const result = await db
137 .delete(repositories)
138 .where(eq(repositories.id, repositoryId))
139 .returning({ id: repositories.id });
140 return result.length === 1;
141}
142 
143export type UpdateRepositoryVisibilityResult =
144 | { ok: true; previous: RepositoryVisibility; current: RepositoryVisibility }
145 | { ok: false; reason: "not-found" };
146 
147// Caller must verify membership before calling. Returns previous visibility
148// so the caller can decide whether to clear the route KV (privacy hygiene).
149export async function updateRepositoryVisibility(
150 db: Db,
151 repositoryId: string,
152 visibility: RepositoryVisibility,
153 now: number
154): Promise<UpdateRepositoryVisibilityResult> {
155 const rows = await db
156 .select({ visibility: repositories.visibility })
157 .from(repositories)
158 .where(eq(repositories.id, repositoryId))
159 .limit(1);
160 const existing = rows[0];
161 if (!existing) return { ok: false, reason: "not-found" };
162 await db
163 .update(repositories)
164 .set({ visibility, updatedAt: now })
165 .where(eq(repositories.id, repositoryId));
166 return {
167 ok: true,
168 previous: existing.visibility,
169 current: visibility,
170 };
171}