@defrex/autobuild-postgres-store 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ import { MemoryBlobStore, sampleEventWrite } from '@defrex/autobuild/plugin-sdk'
2
+ import { openPostgresBuildStore } from '../store'
3
+
4
+ async function main(): Promise<void> {
5
+ const [url, mode, resource, countText] = process.argv.slice(2)
6
+ if (!url || !mode || !resource) throw new Error('missing worker arguments')
7
+ const count = Number(countText ?? '1')
8
+ const store = await openPostgresBuildStore(url, new MemoryBlobStore())
9
+ try {
10
+ if (mode === 'build') {
11
+ for (let index = 0; index < count; index++) {
12
+ await store.append(resource, sampleEventWrite(`${process.pid}-${index}`))
13
+ }
14
+ } else if (mode === 'repo') {
15
+ for (let index = 0; index < count; index++) {
16
+ await store.appendRepo(resource, {
17
+ actor: { kind: 'human', user: `worker-${process.pid}` },
18
+ type: 'dispatcher.pause-set',
19
+ payload: { enabled: index % 2 === 0 },
20
+ })
21
+ }
22
+ } else if (mode === 'conditional') {
23
+ const result = await store.appendIfCurrent(
24
+ resource,
25
+ count,
26
+ sampleEventWrite(String(process.pid)),
27
+ )
28
+ console.log(result ? 'winner' : 'stale')
29
+ } else {
30
+ throw new Error(`unknown worker mode ${mode}`)
31
+ }
32
+ } finally {
33
+ await store.close()
34
+ }
35
+ }
36
+
37
+ if (import.meta.main) {
38
+ main().catch((error: unknown) => {
39
+ console.error(error)
40
+ process.exitCode = 1
41
+ })
42
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, test } from 'bun:test'
2
+ import { SQL } from 'bun'
3
+ import { describeTicketSourceContract } from '@defrex/autobuild/plugin-sdk'
4
+ import { migratePostgres } from './schema'
5
+ import { openPostgresTicketDatabase } from './tickets'
6
+
7
+ const testUrl = process.env.AB_POSTGRES_TEST_URL?.trim()
8
+
9
+ async function isolatedDatabase(): Promise<{ url: string; cleanup(): Promise<void> }> {
10
+ if (!testUrl) throw new Error('AB_POSTGRES_TEST_URL is required')
11
+ const schema = `ab_tickets_${crypto.randomUUID().replaceAll('-', '')}`
12
+ const admin = new SQL(testUrl)
13
+ await admin.unsafe(`CREATE SCHEMA ${schema}`)
14
+ await admin.close()
15
+ const scoped = new URL(testUrl)
16
+ scoped.searchParams.set('options', `-csearch_path=${schema}`)
17
+ await migratePostgres(scoped.toString())
18
+ return {
19
+ url: scoped.toString(),
20
+ cleanup: async () => {
21
+ const sql = new SQL(testUrl)
22
+ try {
23
+ await sql.unsafe(`DROP SCHEMA ${schema} CASCADE`)
24
+ } finally {
25
+ await sql.close()
26
+ }
27
+ },
28
+ }
29
+ }
30
+
31
+ if (!testUrl) {
32
+ describe('PostgreSQL TicketSource live contract', () => {
33
+ test.skip('set AB_POSTGRES_TEST_URL to run TicketSource conformance', () => {})
34
+ })
35
+ } else {
36
+ describeTicketSourceContract('PostgresTicketSource', async () => {
37
+ const isolated = await isolatedDatabase()
38
+ const database = await openPostgresTicketDatabase(isolated.url)
39
+ return {
40
+ source: database.source({ teamKey: 'ENG', claimedState: 'Doing' }),
41
+ states: { ready: 'Ready', claimed: 'Doing', completed: 'Done' },
42
+ editableLabel: 'autobuild',
43
+ cleanup: async () => {
44
+ await database.close()
45
+ await isolated.cleanup()
46
+ },
47
+ }
48
+ })
49
+ }
package/src/tickets.ts ADDED
@@ -0,0 +1,276 @@
1
+ import { SQL } from 'bun'
2
+ import type {
3
+ DependencyState,
4
+ Ticket,
5
+ TicketCreateOptions,
6
+ TicketDraft,
7
+ TicketListing,
8
+ TicketSource,
9
+ TicketUpdate,
10
+ } from '@defrex/autobuild/plugin-sdk'
11
+ import { validateTicketUpdate } from '@defrex/autobuild/ticket-update'
12
+ import { assertTicketSchema } from './schema'
13
+
14
+ type Row = Record<string, unknown>
15
+
16
+ export interface PostgresTicketLifecycle {
17
+ triage: string
18
+ ready: string
19
+ doing: string
20
+ done: string
21
+ }
22
+
23
+ export interface PostgresTicketContext {
24
+ teamKey: string
25
+ claimedState?: string
26
+ createState?: string
27
+ }
28
+
29
+ function requiredState(value: string, label: string): string {
30
+ if (!value.trim()) throw new Error(`${label} must be nonblank`)
31
+ return value
32
+ }
33
+
34
+ function strings(value: unknown): string[] {
35
+ return Array.isArray(value) ? value.map(String) : []
36
+ }
37
+
38
+ /** Shared database handle; request-specific source views never retain another
39
+ * repository's team or lifecycle overrides. */
40
+ export class PostgresTicketDatabase {
41
+ readonly lifecycle: PostgresTicketLifecycle
42
+
43
+ constructor(
44
+ readonly sql: SQL,
45
+ lifecycle: PostgresTicketLifecycle,
46
+ ) {
47
+ this.lifecycle = {
48
+ triage: requiredState(lifecycle.triage, 'triage state'),
49
+ ready: requiredState(lifecycle.ready, 'ready state'),
50
+ doing: requiredState(lifecycle.doing, 'doing state'),
51
+ done: requiredState(lifecycle.done, 'done state'),
52
+ }
53
+ if (new Set(Object.values(this.lifecycle)).size !== 4) {
54
+ throw new Error('database ticket lifecycle states must be distinct')
55
+ }
56
+ }
57
+
58
+ source(context: PostgresTicketContext): PostgresTicketSource {
59
+ return new PostgresTicketSource(this.sql, context, this.lifecycle)
60
+ }
61
+
62
+ close(): Promise<void> {
63
+ return this.sql.close()
64
+ }
65
+ }
66
+
67
+ export async function openPostgresTicketDatabase(
68
+ url: string,
69
+ lifecycle: PostgresTicketLifecycle = {
70
+ triage: 'Triage',
71
+ ready: 'Ready',
72
+ doing: 'Doing',
73
+ done: 'Done',
74
+ },
75
+ ): Promise<PostgresTicketDatabase> {
76
+ const sql = new SQL(url)
77
+ try {
78
+ await assertTicketSchema(sql)
79
+ return new PostgresTicketDatabase(sql, lifecycle)
80
+ } catch (error) {
81
+ await sql.close()
82
+ throw error
83
+ }
84
+ }
85
+
86
+ export class PostgresTicketSource implements TicketSource {
87
+ readonly name = 'database'
88
+ private readonly team: string
89
+ private readonly claimedState: string
90
+ private readonly createState: string
91
+ private readonly allowed: Set<string>
92
+
93
+ constructor(
94
+ private readonly sql: SQL,
95
+ context: PostgresTicketContext,
96
+ private readonly lifecycle: PostgresTicketLifecycle,
97
+ ) {
98
+ if (!context.teamKey.trim()) throw new Error('ticket teamKey must be nonblank')
99
+ this.team = context.teamKey
100
+ this.allowed = new Set(Object.values(lifecycle))
101
+ this.claimedState = context.claimedState ?? lifecycle.doing
102
+ this.createState = context.createState ?? lifecycle.triage
103
+ this.assertState(this.claimedState)
104
+ this.assertState(this.createState)
105
+ }
106
+
107
+ private assertState(state: string): void {
108
+ if (!this.allowed.has(state)) {
109
+ throw new Error(
110
+ `database ticket state "${state}" is not configured; allowed states: ${[...this.allowed].join(', ')}`,
111
+ )
112
+ }
113
+ }
114
+
115
+ private async blockers(executor: SQL, id: string): Promise<string[]> {
116
+ const rows: Row[] = await executor`SELECT blocker_id FROM ab_ticket_blockers
117
+ WHERE team = ${this.team} AND ticket_id = ${id} ORDER BY blocker_id`
118
+ return rows.map((row) => String(row.blocker_id))
119
+ }
120
+
121
+ private async record(executor: SQL, row: Row): Promise<Ticket> {
122
+ const id = String(row.id)
123
+ const title = String(row.title)
124
+ const blockedBy = await this.blockers(executor, id)
125
+ return {
126
+ ref: { source: this.name, id, title },
127
+ ...(row.creation_key !== null && row.creation_key !== undefined
128
+ ? { creationKey: String(row.creation_key) }
129
+ : {}),
130
+ title,
131
+ body: String(row.body),
132
+ state: String(row.state),
133
+ labels: strings(row.labels),
134
+ ...(blockedBy.length > 0 ? { blockedBy } : {}),
135
+ }
136
+ }
137
+
138
+ private async require(executor: SQL, id: string, operation: string, lock = false): Promise<Row> {
139
+ const rows: Row[] = lock
140
+ ? await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id} FOR UPDATE`
141
+ : await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
142
+ const row = rows[0]
143
+ if (!row) throw new Error(`database ticket source: ${operation} on unknown ticket "${id}"`)
144
+ return row
145
+ }
146
+
147
+ async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
148
+ const labels = criteria.labels ?? []
149
+ const rows: Row[] = await this.sql`SELECT * FROM ab_tickets
150
+ WHERE team = ${this.team}
151
+ AND (${criteria.state ?? null}::text IS NULL OR state = ${criteria.state ?? null})
152
+ AND labels @> ${this.sql.array(labels, 'text')}
153
+ ORDER BY created_at, id`
154
+ return {
155
+ tickets: await Promise.all(rows.map((row) => this.record(this.sql, row))),
156
+ diagnostics: [],
157
+ }
158
+ }
159
+
160
+ async get(id: string): Promise<Ticket | null> {
161
+ const rows: Row[] = await this
162
+ .sql`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
163
+ return rows[0] ? this.record(this.sql, rows[0]) : null
164
+ }
165
+
166
+ async claim(id: string): Promise<boolean> {
167
+ const rows: Row[] = await this.sql`UPDATE ab_tickets SET state = ${this.claimedState},
168
+ updated_at = ${new Date().toISOString()}
169
+ WHERE team = ${this.team} AND id = ${id}
170
+ AND state <> ${this.claimedState} AND state <> ${this.lifecycle.done} RETURNING id`
171
+ return rows.length === 1
172
+ }
173
+
174
+ async comment(id: string, body: string): Promise<void> {
175
+ await this.sql.begin(async (tx) => {
176
+ await this.require(tx, id, 'comment', true)
177
+ const tails: Row[] = await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM ab_ticket_comments
178
+ WHERE team = ${this.team} AND ticket_id = ${id}`
179
+ await tx`INSERT INTO ab_ticket_comments (team, ticket_id, seq, body, created_at)
180
+ VALUES (${this.team}, ${id}, ${Number(tails[0]?.seq ?? 0) + 1}, ${body}, ${new Date().toISOString()})`
181
+ })
182
+ }
183
+
184
+ async transition(id: string, state: string): Promise<void> {
185
+ this.assertState(state)
186
+ const rows: Row[] = await this.sql`UPDATE ab_tickets SET state = ${state},
187
+ updated_at = ${new Date().toISOString()} WHERE team = ${this.team} AND id = ${id} RETURNING id`
188
+ if (!rows[0]) throw new Error(`database ticket source: transition on unknown ticket "${id}"`)
189
+ }
190
+
191
+ async create(draft: TicketDraft, options: TicketCreateOptions = {}): Promise<Ticket> {
192
+ if (!draft.title.trim()) throw new Error('database ticket create: title must be nonblank')
193
+ if (!draft.body.trim()) throw new Error('database ticket create: body must be nonblank')
194
+ const state = options.state ?? this.createState
195
+ this.assertState(state)
196
+ return this.sql.begin(async (tx) => {
197
+ if (options.idempotencyKey !== undefined) {
198
+ const adopted: Row[] = await tx`SELECT * FROM ab_tickets
199
+ WHERE team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
200
+ if (adopted[0]) return this.record(tx, adopted[0])
201
+ }
202
+ for (const blocker of draft.blockedBy ?? []) {
203
+ await this.require(tx, blocker, 'create blocker')
204
+ }
205
+ const id = `ticket-${crypto.randomUUID()}`
206
+ const now = new Date().toISOString()
207
+ const inserted: Row[] = await tx`INSERT INTO ab_tickets
208
+ (team, id, creation_key, title, body, state, labels, created_at, updated_at)
209
+ VALUES (${this.team}, ${id}, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
210
+ ${state}, ${tx.array(draft.labels ?? [], 'text')}, ${now}, ${now})
211
+ ON CONFLICT (team, creation_key) DO NOTHING RETURNING *`
212
+ let row = inserted[0]
213
+ if (!row && options.idempotencyKey !== undefined) {
214
+ const adopted: Row[] = await tx`SELECT * FROM ab_tickets
215
+ WHERE team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
216
+ row = adopted[0]
217
+ }
218
+ if (!row) throw new Error('database ticket create failed')
219
+ if (String(row.id) === id) {
220
+ for (const blocker of [...new Set(draft.blockedBy ?? [])]) {
221
+ if (blocker === id)
222
+ throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
223
+ await tx`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
224
+ VALUES (${this.team}, ${id}, ${blocker}) ON CONFLICT DO NOTHING`
225
+ }
226
+ }
227
+ return this.record(tx, row)
228
+ })
229
+ }
230
+
231
+ async update(id: string, patch: TicketUpdate): Promise<void> {
232
+ const validated = validateTicketUpdate(patch)
233
+ await this.sql.begin(async (tx) => {
234
+ const row = await this.require(tx, id, 'update', true)
235
+ await tx`UPDATE ab_tickets SET
236
+ title = ${validated.title ?? String(row.title)},
237
+ body = ${validated.body ?? String(row.body)},
238
+ labels = ${tx.array(validated.labels ?? strings(row.labels), 'text')},
239
+ updated_at = ${new Date().toISOString()}
240
+ WHERE team = ${this.team} AND id = ${id}`
241
+ })
242
+ }
243
+
244
+ async addBlocker(id: string, blockerId: string): Promise<void> {
245
+ await this.sql.begin(async (tx) => {
246
+ await this.require(tx, id, 'addBlocker', true)
247
+ if (id === blockerId)
248
+ throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
249
+ await this.require(tx, blockerId, 'addBlocker')
250
+ await tx`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
251
+ VALUES (${this.team}, ${id}, ${blockerId}) ON CONFLICT DO NOTHING`
252
+ })
253
+ }
254
+
255
+ async removeBlocker(id: string, blockerId: string): Promise<void> {
256
+ await this.require(this.sql, id, 'removeBlocker')
257
+ await this.sql`DELETE FROM ab_ticket_blockers
258
+ WHERE team = ${this.team} AND ticket_id = ${id} AND blocker_id = ${blockerId}`
259
+ }
260
+
261
+ async dependencyStates(ids: string[]): Promise<DependencyState[]> {
262
+ return Promise.all(
263
+ ids.map(async (id) => {
264
+ const rows: Row[] = await this.sql`SELECT state FROM ab_tickets
265
+ WHERE team = ${this.team} AND id = ${id}`
266
+ if (!rows[0]) return { id, exists: false, resolved: false, blockedBy: [] }
267
+ return {
268
+ id,
269
+ exists: true,
270
+ resolved: String(rows[0].state) === this.lifecycle.done,
271
+ blockedBy: await this.blockers(this.sql, id),
272
+ }
273
+ }),
274
+ )
275
+ }
276
+ }
package/src/vercel.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { get, put, type get as getType, type put as putType } from '@vercel/blob'
2
+ import { Buffer } from 'node:buffer'
3
+ import type { BlobStore } from '@defrex/autobuild/plugin-sdk'
4
+ import { blobPath } from './s3'
5
+
6
+ type Put = typeof putType
7
+ type Get = typeof getType
8
+
9
+ export interface VercelBlobStoreOptions {
10
+ access: 'public' | 'private'
11
+ prefix?: string
12
+ token?: string
13
+ oidcToken?: string
14
+ storeId?: string
15
+ put?: Put
16
+ get?: Get
17
+ }
18
+
19
+ export class VercelBlobStore implements BlobStore {
20
+ private readonly putFn: Put
21
+ private readonly getFn: Get
22
+
23
+ constructor(private readonly options: VercelBlobStoreOptions) {
24
+ this.putFn = options.put ?? put
25
+ this.getFn = options.get ?? get
26
+ }
27
+
28
+ private auth(): { token?: string; oidcToken?: string; storeId?: string } {
29
+ return {
30
+ ...(this.options.token ? { token: this.options.token } : {}),
31
+ ...(this.options.oidcToken ? { oidcToken: this.options.oidcToken } : {}),
32
+ ...(this.options.storeId ? { storeId: this.options.storeId } : {}),
33
+ }
34
+ }
35
+
36
+ async put(hash: string, bytes: Uint8Array): Promise<void> {
37
+ await this.putFn(blobPath(hash, this.options.prefix), Buffer.from(bytes), {
38
+ access: this.options.access,
39
+ addRandomSuffix: false,
40
+ allowOverwrite: true,
41
+ ...this.auth(),
42
+ })
43
+ }
44
+
45
+ async get(hash: string): Promise<Uint8Array | null> {
46
+ const result = await this.getFn(blobPath(hash, this.options.prefix), {
47
+ access: this.options.access,
48
+ ...this.auth(),
49
+ })
50
+ if (!result?.stream) return null
51
+ return new Uint8Array(await new Response(result.stream).arrayBuffer())
52
+ }
53
+ }