92 lines · 2.6 KB
1 /**
2 * Git ref storage service backed by D1.
3 * Handles branches and tags with atomic updates.
4 */
5
6 import { ConflictError } from '@gitfastr/shared/utils/errors.js';
7 import type { DbRef, RefUpdateCommand } from '@gitfastr/shared';
8
9 const ZERO_SHA = '0'.repeat(40);
10
11 export class RefStore {
12 constructor(
13 private db: D1Database,
14 private repoId: string
15 ) {}
16
17 /** Get all refs for the repo. */
18 async getAll(): Promise<DbRef[]> {
19 const result = await this.db
20 .prepare('SELECT * FROM refs WHERE repo_id = ? ORDER BY name')
21 .bind(this.repoId)
22 .all<DbRef>();
23 return result.results;
24 }
25
26 /** Get a single ref by name. */
27 async get(name: string): Promise<DbRef | null> {
28 return this.db
29 .prepare('SELECT * FROM refs WHERE repo_id = ? AND name = ?')
30 .bind(this.repoId, name)
31 .first<DbRef>();
32 }
33
34 /**
35 * Apply a batch of ref updates atomically.
36 * Validates old SHAs match (compare-and-swap).
37 */
38 async applyUpdates(commands: RefUpdateCommand[]): Promise<void> {
39 const stmts: D1PreparedStatement[] = [];
40
41 for (const cmd of commands) {
42 if (cmd.newSha === ZERO_SHA) {
43 // Delete ref
44 if (cmd.oldSha !== ZERO_SHA) {
45 // Verify current SHA matches
46 const current = await this.get(cmd.refName);
47 if (!current || current.sha !== cmd.oldSha) {
48 throw new ConflictError(
49 `Ref ${cmd.refName}: expected ${cmd.oldSha}, got ${current?.sha ?? 'none'}`
50 );
51 }
52 }
53 stmts.push(
54 this.db
55 .prepare('DELETE FROM refs WHERE repo_id = ? AND name = ?')
56 .bind(this.repoId, cmd.refName)
57 );
58 } else if (cmd.oldSha === ZERO_SHA) {
59 // Create new ref
60 stmts.push(
61 this.db
62 .prepare(
63 `INSERT INTO refs (repo_id, name, sha)
64 VALUES (?, ?, ?)`
65 )
66 .bind(this.repoId, cmd.refName, cmd.newSha)
67 );
68 } else {
69 // Update existing ref (compare-and-swap)
70 const current = await this.get(cmd.refName);
71 if (!current || current.sha !== cmd.oldSha) {
72 throw new ConflictError(
73 `Ref ${cmd.refName}: expected ${cmd.oldSha}, got ${current?.sha ?? 'none'}`
74 );
75 }
76 stmts.push(
77 this.db
78 .prepare(
79 `UPDATE refs SET sha = ?, updated_at = datetime('now')
80 WHERE repo_id = ? AND name = ?`
81 )
82 .bind(cmd.newSha, this.repoId, cmd.refName)
83 );
84 }
85 }
86
87 if (stmts.length > 0) {
88 await this.db.batch(stmts);
89 }
90 }
91 }
92