Skip to content

Commit c94dd5d

Browse files
committed
feat(write-locks): record/consume/cleanup helpers
1 parent 09da9bc commit c94dd5d

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// apps/dashboard/server/lib/github-write-locks.test.ts
2+
// Integration tests — these hit the real Postgres instance.
3+
// Run with: bun test apps/dashboard/server/lib/github-write-locks.test.ts
4+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
5+
import { sql } from "drizzle-orm"
6+
import { db } from "../db"
7+
import { githubWriteLocks, projects, reports } from "../db/schema"
8+
import {
9+
cleanupExpiredLocks,
10+
consumeWriteLock,
11+
recordWriteLock,
12+
WRITE_LOCK_TTL_MS,
13+
} from "./github-write-locks"
14+
15+
// We need a real report row to satisfy the FK
16+
let testProjectId: string
17+
let testReportId: string
18+
19+
async function truncate() {
20+
await db.execute(sql`TRUNCATE github_write_locks RESTART IDENTITY CASCADE`)
21+
}
22+
23+
beforeEach(async () => {
24+
await db.execute(
25+
sql`TRUNCATE project_invitations, project_members, projects, "account", "session", "verification", "user" RESTART IDENTITY CASCADE`,
26+
)
27+
await db.execute(sql`TRUNCATE report_attachments, reports RESTART IDENTITY CASCADE`)
28+
await truncate()
29+
30+
const [p] = await db
31+
.insert(projects)
32+
.values({
33+
name: "test",
34+
createdBy: "user-test",
35+
publicKey: "rp_pk_writelocktest",
36+
allowedOrigins: [],
37+
})
38+
.returning()
39+
testProjectId = p.id
40+
41+
const [r] = await db
42+
.insert(reports)
43+
.values({
44+
projectId: testProjectId,
45+
title: "test report",
46+
description: "test",
47+
context: {
48+
pageUrl: "http://example.com",
49+
userAgent: "UA",
50+
viewport: { w: 1, h: 1 },
51+
timestamp: new Date().toISOString(),
52+
},
53+
})
54+
.returning()
55+
testReportId = r.id
56+
})
57+
58+
afterEach(async () => {
59+
await truncate()
60+
await db.execute(sql`TRUNCATE report_attachments, reports RESTART IDENTITY CASCADE`)
61+
await db.execute(
62+
sql`TRUNCATE project_invitations, project_members, projects, "account", "session", "verification", "user" RESTART IDENTITY CASCADE`,
63+
)
64+
})
65+
66+
describe("recordWriteLock", () => {
67+
test("inserts a row with correct fields", async () => {
68+
await recordWriteLock(db, {
69+
reportId: testReportId,
70+
kind: "title",
71+
signature: "abc123",
72+
})
73+
74+
const rows = await db.select().from(githubWriteLocks)
75+
expect(rows.length).toBe(1)
76+
expect(rows[0]?.reportId).toBe(testReportId)
77+
expect(rows[0]?.kind).toBe("title")
78+
expect(rows[0]?.signature).toBe("abc123")
79+
expect(rows[0]?.expiresAt.getTime()).toBeGreaterThan(Date.now())
80+
expect(rows[0]?.expiresAt.getTime()).toBeLessThanOrEqual(Date.now() + WRITE_LOCK_TTL_MS + 1000)
81+
})
82+
})
83+
84+
describe("consumeWriteLock", () => {
85+
test("returns true and deletes when matching live row exists", async () => {
86+
await recordWriteLock(db, {
87+
reportId: testReportId,
88+
kind: "state",
89+
signature: "sig-xyz",
90+
})
91+
92+
const result = await consumeWriteLock(db, {
93+
reportId: testReportId,
94+
kind: "state",
95+
signature: "sig-xyz",
96+
})
97+
98+
expect(result).toBe(true)
99+
const rows = await db.select().from(githubWriteLocks)
100+
expect(rows.length).toBe(0)
101+
})
102+
103+
test("returns false when no matching row", async () => {
104+
const result = await consumeWriteLock(db, {
105+
reportId: testReportId,
106+
kind: "labels",
107+
signature: "nonexistent",
108+
})
109+
expect(result).toBe(false)
110+
})
111+
112+
test("returns false when signature differs", async () => {
113+
await recordWriteLock(db, {
114+
reportId: testReportId,
115+
kind: "labels",
116+
signature: "correct-sig",
117+
})
118+
119+
const result = await consumeWriteLock(db, {
120+
reportId: testReportId,
121+
kind: "labels",
122+
signature: "wrong-sig",
123+
})
124+
expect(result).toBe(false)
125+
})
126+
127+
test("returns false for expired rows", async () => {
128+
// Insert an already-expired lock directly
129+
const expiresAt = new Date(Date.now() - 1000)
130+
await db.insert(githubWriteLocks).values({
131+
reportId: testReportId,
132+
kind: "title",
133+
signature: "expired-sig",
134+
expiresAt,
135+
})
136+
137+
const result = await consumeWriteLock(db, {
138+
reportId: testReportId,
139+
kind: "title",
140+
signature: "expired-sig",
141+
})
142+
expect(result).toBe(false)
143+
})
144+
})
145+
146+
describe("cleanupExpiredLocks", () => {
147+
test("removes only expired rows, leaves live rows", async () => {
148+
// Insert one live and one expired lock
149+
const expired = new Date(Date.now() - 1000)
150+
await db.insert(githubWriteLocks).values([
151+
{
152+
reportId: testReportId,
153+
kind: "labels",
154+
signature: "live-sig",
155+
expiresAt: new Date(Date.now() + WRITE_LOCK_TTL_MS),
156+
},
157+
{
158+
reportId: testReportId,
159+
kind: "state",
160+
signature: "expired-sig",
161+
expiresAt: expired,
162+
},
163+
])
164+
165+
const count = await cleanupExpiredLocks(db)
166+
expect(count).toBe(1)
167+
168+
const remaining = await db.select().from(githubWriteLocks)
169+
expect(remaining.length).toBe(1)
170+
expect(remaining[0]?.signature).toBe("live-sig")
171+
})
172+
173+
test("returns 0 when no expired rows", async () => {
174+
await recordWriteLock(db, {
175+
reportId: testReportId,
176+
kind: "title",
177+
signature: "live",
178+
})
179+
180+
const count = await cleanupExpiredLocks(db)
181+
expect(count).toBe(0)
182+
})
183+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// apps/dashboard/server/lib/github-write-locks.ts
2+
// Helpers for recording and consuming write-locks. Write-locks prevent echo
3+
// loops: before we push an outbound change to GitHub we record a short-lived
4+
// lock; when the matching webhook arrives back we consume it and skip the
5+
// inbound application (it's our own echo).
6+
import { and, eq, gt, lt } from "drizzle-orm"
7+
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
8+
import type * as schema from "../db/schema"
9+
import { githubWriteLocks } from "../db/schema"
10+
import type { githubWriteLockKinds } from "../db/schema"
11+
12+
export const WRITE_LOCK_TTL_MS = 30_000 // 30 seconds
13+
14+
type DB = NodePgDatabase<typeof schema>
15+
type LockKind = (typeof githubWriteLockKinds.enumValues)[number]
16+
17+
export interface WriteLockInput {
18+
reportId: string
19+
kind: LockKind
20+
signature: string
21+
}
22+
23+
/** Record a write-lock row with a TTL of WRITE_LOCK_TTL_MS. */
24+
export async function recordWriteLock(db: DB, input: WriteLockInput): Promise<void> {
25+
const expiresAt = new Date(Date.now() + WRITE_LOCK_TTL_MS)
26+
await db.insert(githubWriteLocks).values({
27+
reportId: input.reportId,
28+
kind: input.kind,
29+
signature: input.signature,
30+
expiresAt,
31+
})
32+
}
33+
34+
/**
35+
* Consume a write-lock. Returns true if a matching live row was deleted,
36+
* false otherwise (no lock found, wrong signature, or already expired).
37+
*/
38+
export async function consumeWriteLock(db: DB, input: WriteLockInput): Promise<boolean> {
39+
const now = new Date()
40+
const deleted = await db
41+
.delete(githubWriteLocks)
42+
.where(
43+
and(
44+
eq(githubWriteLocks.reportId, input.reportId),
45+
eq(githubWriteLocks.kind, input.kind),
46+
eq(githubWriteLocks.signature, input.signature),
47+
gt(githubWriteLocks.expiresAt, now),
48+
),
49+
)
50+
.returning({ id: githubWriteLocks.id })
51+
52+
return deleted.length > 0
53+
}
54+
55+
/**
56+
* Delete all expired write-lock rows. Returns the number of rows deleted.
57+
* Suitable for a scheduled cleanup task.
58+
*/
59+
export async function cleanupExpiredLocks(db: DB): Promise<number> {
60+
const now = new Date()
61+
const deleted = await db
62+
.delete(githubWriteLocks)
63+
.where(lt(githubWriteLocks.expiresAt, now))
64+
.returning({ id: githubWriteLocks.id })
65+
66+
return deleted.length
67+
}

0 commit comments

Comments
 (0)