@defrex/autobuild-postgres-store 0.8.0 → 0.10.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.
- package/README.md +48 -0
- package/package.json +18 -1
- package/src/auth-schema.ts +21 -6
- package/src/index.ts +1 -0
- package/src/retry.ts +260 -0
- package/src/schema.ts +453 -31
- package/src/store.ts +618 -197
- package/src/tickets.ts +88 -59
- package/src/blobs.test.ts +0 -121
- package/src/config.test.ts +0 -174
- package/src/env.test.ts +0 -48
- package/src/event-wait-poll-ms.test.ts +0 -23
- package/src/migrate.test.ts +0 -640
- package/src/store.live.test.ts +0 -453
- package/src/testing/concurrent-worker.ts +0 -42
- package/src/tickets.live.test.ts +0 -49
package/src/tickets.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
} from '@defrex/autobuild/plugin-sdk'
|
|
11
11
|
import { validateTicketUpdate } from '@defrex/autobuild/ticket-update'
|
|
12
12
|
import { assertTicketSchema } from './schema'
|
|
13
|
+
import { PlanRetryRunner, type Exec } from './retry'
|
|
13
14
|
|
|
14
15
|
type Row = Record<string, unknown>
|
|
15
16
|
|
|
@@ -39,6 +40,12 @@ function strings(value: unknown): string[] {
|
|
|
39
40
|
* repository's team or lifecycle overrides. */
|
|
40
41
|
export class PostgresTicketDatabase {
|
|
41
42
|
readonly lifecycle: PostgresTicketLifecycle
|
|
43
|
+
/** The shared plan-retry mechanics (`./retry`): one runner per pool owner,
|
|
44
|
+
* so every source view this database hands out shares one memo — healing
|
|
45
|
+
* done by one source benefits all sources on the same pool, and the
|
|
46
|
+
* per-runner marker namespace keeps sources of different databases from
|
|
47
|
+
* colliding on prepared-statement names. */
|
|
48
|
+
private readonly retry = new PlanRetryRunner()
|
|
42
49
|
|
|
43
50
|
constructor(
|
|
44
51
|
readonly sql: SQL,
|
|
@@ -56,7 +63,7 @@ export class PostgresTicketDatabase {
|
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
source(context: PostgresTicketContext): PostgresTicketSource {
|
|
59
|
-
return new PostgresTicketSource(this.sql, context, this.lifecycle)
|
|
66
|
+
return new PostgresTicketSource(this.sql, context, this.lifecycle, this.retry)
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
close(): Promise<void> {
|
|
@@ -94,6 +101,10 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
94
101
|
private readonly sql: SQL,
|
|
95
102
|
context: PostgresTicketContext,
|
|
96
103
|
private readonly lifecycle: PostgresTicketLifecycle,
|
|
104
|
+
/** Optional trailing parameter so the exported constructor stays
|
|
105
|
+
* signature-compatible; a fresh runner keeps the per-instance marker
|
|
106
|
+
* namespace when no pool owner supplies one. */
|
|
107
|
+
private readonly retry: PlanRetryRunner = new PlanRetryRunner(),
|
|
97
108
|
) {
|
|
98
109
|
if (!context.teamKey.trim()) throw new Error('ticket teamKey must be nonblank')
|
|
99
110
|
this.team = context.teamKey
|
|
@@ -112,13 +123,13 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
112
123
|
}
|
|
113
124
|
}
|
|
114
125
|
|
|
115
|
-
private async blockers(executor:
|
|
126
|
+
private async blockers(executor: Exec, id: string): Promise<string[]> {
|
|
116
127
|
const rows: Row[] = await executor`SELECT blocker_id FROM ab_ticket_blockers
|
|
117
128
|
WHERE team = ${this.team} AND ticket_id = ${id} ORDER BY blocker_id`
|
|
118
129
|
return rows.map((row) => String(row.blocker_id))
|
|
119
130
|
}
|
|
120
131
|
|
|
121
|
-
private async record(executor:
|
|
132
|
+
private async record(executor: Exec, row: Row): Promise<Ticket> {
|
|
122
133
|
const id = String(row.id)
|
|
123
134
|
const title = String(row.title)
|
|
124
135
|
const blockedBy = await this.blockers(executor, id)
|
|
@@ -135,7 +146,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
135
146
|
}
|
|
136
147
|
}
|
|
137
148
|
|
|
138
|
-
private async require(executor:
|
|
149
|
+
private async require(executor: Exec, id: string, operation: string, lock = false): Promise<Row> {
|
|
139
150
|
const rows: Row[] = lock
|
|
140
151
|
? await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id} FOR UPDATE`
|
|
141
152
|
: await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
|
|
@@ -146,46 +157,55 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
146
157
|
|
|
147
158
|
async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
|
|
148
159
|
const labels = criteria.labels ?? []
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
return this.retry.run(this.sql, async (q) => {
|
|
161
|
+
const rows: Row[] = await q`SELECT * FROM ab_tickets
|
|
162
|
+
WHERE team = ${this.team}
|
|
163
|
+
AND (${criteria.state ?? null}::text IS NULL OR state = ${criteria.state ?? null})
|
|
164
|
+
AND labels @> ${q.array(labels, 'text')}
|
|
165
|
+
ORDER BY created_at, id`
|
|
166
|
+
// Sequential, not `Promise.all`: every statement of a body runs on one
|
|
167
|
+
// pinned connection, and failure attribution (`Attempt.inFlight` in
|
|
168
|
+
// `./retry`) requires the statements to be ordered.
|
|
169
|
+
const tickets: Ticket[] = []
|
|
170
|
+
for (const row of rows) tickets.push(await this.record(q, row))
|
|
171
|
+
return { tickets, diagnostics: [] }
|
|
172
|
+
})
|
|
158
173
|
}
|
|
159
174
|
|
|
160
175
|
async get(id: string): Promise<Ticket | null> {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
return this.retry.run(this.sql, async (q) => {
|
|
177
|
+
const rows: Row[] = await q`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
|
|
178
|
+
return rows[0] ? this.record(q, rows[0]) : null
|
|
179
|
+
})
|
|
164
180
|
}
|
|
165
181
|
|
|
166
182
|
async claim(id: string): Promise<boolean> {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
return this.retry.run(this.sql, async (q) => {
|
|
184
|
+
const rows: Row[] = await q`UPDATE ab_tickets SET state = ${this.claimedState},
|
|
185
|
+
updated_at = ${new Date().toISOString()}
|
|
186
|
+
WHERE team = ${this.team} AND id = ${id}
|
|
187
|
+
AND state <> ${this.claimedState} AND state <> ${this.lifecycle.done} RETURNING id`
|
|
188
|
+
return rows.length === 1
|
|
189
|
+
})
|
|
172
190
|
}
|
|
173
191
|
|
|
174
192
|
async comment(id: string, body: string): Promise<void> {
|
|
175
|
-
await this.
|
|
176
|
-
await this.require(
|
|
177
|
-
const tails: Row[] = await
|
|
193
|
+
await this.retry.tx(this.sql, async (q) => {
|
|
194
|
+
await this.require(q, id, 'comment', true)
|
|
195
|
+
const tails: Row[] = await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM ab_ticket_comments
|
|
178
196
|
WHERE team = ${this.team} AND ticket_id = ${id}`
|
|
179
|
-
await
|
|
197
|
+
await q`INSERT INTO ab_ticket_comments (team, ticket_id, seq, body, created_at)
|
|
180
198
|
VALUES (${this.team}, ${id}, ${Number(tails[0]?.seq ?? 0) + 1}, ${body}, ${new Date().toISOString()})`
|
|
181
199
|
})
|
|
182
200
|
}
|
|
183
201
|
|
|
184
202
|
async transition(id: string, state: string): Promise<void> {
|
|
185
203
|
this.assertState(state)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
204
|
+
await this.retry.run(this.sql, async (q) => {
|
|
205
|
+
const rows: Row[] = await q`UPDATE ab_tickets SET state = ${state},
|
|
206
|
+
updated_at = ${new Date().toISOString()} WHERE team = ${this.team} AND id = ${id} RETURNING id`
|
|
207
|
+
if (!rows[0]) throw new Error(`database ticket source: transition on unknown ticket "${id}"`)
|
|
208
|
+
})
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
async create(draft: TicketDraft, options: TicketCreateOptions = {}): Promise<Ticket> {
|
|
@@ -193,25 +213,25 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
193
213
|
if (!draft.body.trim()) throw new Error('database ticket create: body must be nonblank')
|
|
194
214
|
const state = options.state ?? this.createState
|
|
195
215
|
this.assertState(state)
|
|
196
|
-
return this.
|
|
216
|
+
return this.retry.tx(this.sql, async (q) => {
|
|
197
217
|
if (options.idempotencyKey !== undefined) {
|
|
198
|
-
const adopted: Row[] = await
|
|
218
|
+
const adopted: Row[] = await q`SELECT * FROM ab_tickets
|
|
199
219
|
WHERE team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
200
|
-
if (adopted[0]) return this.record(
|
|
220
|
+
if (adopted[0]) return this.record(q, adopted[0])
|
|
201
221
|
}
|
|
202
222
|
for (const blocker of draft.blockedBy ?? []) {
|
|
203
|
-
await this.require(
|
|
223
|
+
await this.require(q, blocker, 'create blocker')
|
|
204
224
|
}
|
|
205
225
|
const id = `ticket-${crypto.randomUUID()}`
|
|
206
226
|
const now = new Date().toISOString()
|
|
207
|
-
const inserted: Row[] = await
|
|
227
|
+
const inserted: Row[] = await q`INSERT INTO ab_tickets
|
|
208
228
|
(team, id, creation_key, title, body, state, labels, created_at, updated_at)
|
|
209
229
|
VALUES (${this.team}, ${id}, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
|
|
210
|
-
${state}, ${
|
|
230
|
+
${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now})
|
|
211
231
|
ON CONFLICT (team, creation_key) DO NOTHING RETURNING *`
|
|
212
232
|
let row = inserted[0]
|
|
213
233
|
if (!row && options.idempotencyKey !== undefined) {
|
|
214
|
-
const adopted: Row[] = await
|
|
234
|
+
const adopted: Row[] = await q`SELECT * FROM ab_tickets
|
|
215
235
|
WHERE team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
216
236
|
row = adopted[0]
|
|
217
237
|
}
|
|
@@ -220,57 +240,66 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
220
240
|
for (const blocker of [...new Set(draft.blockedBy ?? [])]) {
|
|
221
241
|
if (blocker === id)
|
|
222
242
|
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
223
|
-
await
|
|
243
|
+
await q`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
|
|
224
244
|
VALUES (${this.team}, ${id}, ${blocker}) ON CONFLICT DO NOTHING`
|
|
225
245
|
}
|
|
226
246
|
}
|
|
227
|
-
return this.record(
|
|
247
|
+
return this.record(q, row)
|
|
228
248
|
})
|
|
229
249
|
}
|
|
230
250
|
|
|
231
251
|
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
232
252
|
const validated = validateTicketUpdate(patch)
|
|
233
|
-
await this.
|
|
234
|
-
const row = await this.require(
|
|
235
|
-
await
|
|
253
|
+
await this.retry.tx(this.sql, async (q) => {
|
|
254
|
+
const row = await this.require(q, id, 'update', true)
|
|
255
|
+
await q`UPDATE ab_tickets SET
|
|
236
256
|
title = ${validated.title ?? String(row.title)},
|
|
237
257
|
body = ${validated.body ?? String(row.body)},
|
|
238
|
-
labels = ${
|
|
258
|
+
labels = ${q.array(validated.labels ?? strings(row.labels), 'text')},
|
|
239
259
|
updated_at = ${new Date().toISOString()}
|
|
240
260
|
WHERE team = ${this.team} AND id = ${id}`
|
|
241
261
|
})
|
|
242
262
|
}
|
|
243
263
|
|
|
244
264
|
async addBlocker(id: string, blockerId: string): Promise<void> {
|
|
245
|
-
await this.
|
|
246
|
-
await this.require(
|
|
265
|
+
await this.retry.tx(this.sql, async (q) => {
|
|
266
|
+
await this.require(q, id, 'addBlocker', true)
|
|
247
267
|
if (id === blockerId)
|
|
248
268
|
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
249
|
-
await this.require(
|
|
250
|
-
await
|
|
269
|
+
await this.require(q, blockerId, 'addBlocker')
|
|
270
|
+
await q`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
|
|
251
271
|
VALUES (${this.team}, ${id}, ${blockerId}) ON CONFLICT DO NOTHING`
|
|
252
272
|
})
|
|
253
273
|
}
|
|
254
274
|
|
|
255
275
|
async removeBlocker(id: string, blockerId: string): Promise<void> {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
276
|
+
// Require + DELETE are one coherent body on a pinned connection: the
|
|
277
|
+
// require's `SELECT *` is a migration-poisonable plan, and the pair must
|
|
278
|
+
// re-run together on the retry.
|
|
279
|
+
await this.retry.run(this.sql, async (q) => {
|
|
280
|
+
await this.require(q, id, 'removeBlocker')
|
|
281
|
+
await q`DELETE FROM ab_ticket_blockers
|
|
282
|
+
WHERE team = ${this.team} AND ticket_id = ${id} AND blocker_id = ${blockerId}`
|
|
283
|
+
})
|
|
259
284
|
}
|
|
260
285
|
|
|
261
286
|
async dependencyStates(ids: string[]): Promise<DependencyState[]> {
|
|
262
287
|
return Promise.all(
|
|
263
|
-
ids.map(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
id
|
|
269
|
-
exists:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
ids.map((id) =>
|
|
289
|
+
// One run per id keeps each id's state read + blockers query
|
|
290
|
+
// sequential on one pinned connection; the ids stay concurrent.
|
|
291
|
+
this.retry.run(this.sql, async (q) => {
|
|
292
|
+
const rows: Row[] = await q`SELECT state FROM ab_tickets
|
|
293
|
+
WHERE team = ${this.team} AND id = ${id}`
|
|
294
|
+
if (!rows[0]) return { id, exists: false, resolved: false, blockedBy: [] }
|
|
295
|
+
return {
|
|
296
|
+
id,
|
|
297
|
+
exists: true,
|
|
298
|
+
resolved: String(rows[0].state) === this.lifecycle.done,
|
|
299
|
+
blockedBy: await this.blockers(q, id),
|
|
300
|
+
}
|
|
301
|
+
}),
|
|
302
|
+
),
|
|
274
303
|
)
|
|
275
304
|
}
|
|
276
305
|
}
|
package/src/blobs.test.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
|
|
3
|
-
import { describeBlobStoreContract } from '@defrex/autobuild/plugin-sdk'
|
|
4
|
-
import { Buffer } from 'node:buffer'
|
|
5
|
-
import { S3BlobStore } from './s3'
|
|
6
|
-
import { VercelBlobStore } from './vercel'
|
|
7
|
-
|
|
8
|
-
function s3Harness(): S3BlobStore {
|
|
9
|
-
const data = new Map<string, Uint8Array>()
|
|
10
|
-
const client = {
|
|
11
|
-
async send(command: unknown): Promise<unknown> {
|
|
12
|
-
if (command instanceof PutObjectCommand) {
|
|
13
|
-
data.set(String(command.input.Key), new Uint8Array(command.input.Body as Uint8Array))
|
|
14
|
-
return {}
|
|
15
|
-
}
|
|
16
|
-
if (command instanceof GetObjectCommand) {
|
|
17
|
-
const value = data.get(String(command.input.Key))
|
|
18
|
-
if (!value) {
|
|
19
|
-
const error = new Error('not found') as Error & { $metadata: { httpStatusCode: number } }
|
|
20
|
-
error.$metadata = { httpStatusCode: 404 }
|
|
21
|
-
throw error
|
|
22
|
-
}
|
|
23
|
-
return { Body: { transformToByteArray: async () => value.slice() } }
|
|
24
|
-
}
|
|
25
|
-
throw new Error('unexpected command')
|
|
26
|
-
},
|
|
27
|
-
}
|
|
28
|
-
return new S3BlobStore({ client: client as never, bucket: 'builds', prefix: '/ab//blobs/' })
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function vercelHarness(): VercelBlobStore {
|
|
32
|
-
const data = new Map<string, Uint8Array>()
|
|
33
|
-
return new VercelBlobStore({
|
|
34
|
-
access: 'private',
|
|
35
|
-
prefix: 'ab/blobs',
|
|
36
|
-
put: (async (pathname: string, body: unknown) => {
|
|
37
|
-
if (!Buffer.isBuffer(body)) throw new TypeError('Vercel PutBody must be a Buffer')
|
|
38
|
-
data.set(pathname, Uint8Array.from(body))
|
|
39
|
-
return {
|
|
40
|
-
url: pathname,
|
|
41
|
-
downloadUrl: pathname,
|
|
42
|
-
pathname,
|
|
43
|
-
contentType: 'application/octet-stream',
|
|
44
|
-
contentDisposition: '',
|
|
45
|
-
size: 0,
|
|
46
|
-
uploadedAt: new Date(),
|
|
47
|
-
}
|
|
48
|
-
}) as never,
|
|
49
|
-
get: (async (pathname: string) => {
|
|
50
|
-
const value = data.get(pathname)
|
|
51
|
-
if (!value) return null
|
|
52
|
-
return {
|
|
53
|
-
statusCode: 200,
|
|
54
|
-
stream: new Blob([Uint8Array.from(value)]).stream(),
|
|
55
|
-
headers: new Headers(),
|
|
56
|
-
blob: {
|
|
57
|
-
url: pathname,
|
|
58
|
-
downloadUrl: pathname,
|
|
59
|
-
pathname,
|
|
60
|
-
contentType: 'application/octet-stream',
|
|
61
|
-
contentDisposition: '',
|
|
62
|
-
size: value.length,
|
|
63
|
-
uploadedAt: new Date(),
|
|
64
|
-
etag: 'x',
|
|
65
|
-
cacheControl: 'public',
|
|
66
|
-
},
|
|
67
|
-
}
|
|
68
|
-
}) as never,
|
|
69
|
-
})
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
describeBlobStoreContract('S3-compatible', async () => ({ blobs: s3Harness() }))
|
|
73
|
-
describeBlobStoreContract('Vercel Blob', async () => ({ blobs: vercelHarness() }))
|
|
74
|
-
|
|
75
|
-
describe('provider request mapping', () => {
|
|
76
|
-
test('S3 uses the configured bucket and normalized deterministic key', async () => {
|
|
77
|
-
let seen: PutObjectCommand | undefined
|
|
78
|
-
const blobs = new S3BlobStore({
|
|
79
|
-
bucket: 'bucket',
|
|
80
|
-
prefix: '//prefix//',
|
|
81
|
-
client: {
|
|
82
|
-
send: async (command: unknown) => {
|
|
83
|
-
seen = command as PutObjectCommand
|
|
84
|
-
return {}
|
|
85
|
-
},
|
|
86
|
-
} as never,
|
|
87
|
-
})
|
|
88
|
-
await blobs.put('abc', new Uint8Array([1]))
|
|
89
|
-
expect(seen?.input).toMatchObject({ Bucket: 'bucket', Key: 'prefix/abc' })
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('Vercel uploads binary bytes as a supported Buffer with deterministic options', async () => {
|
|
93
|
-
let pathname: string | undefined
|
|
94
|
-
let body: Buffer | undefined
|
|
95
|
-
let options: Record<string, unknown> | undefined
|
|
96
|
-
const blobs = new VercelBlobStore({
|
|
97
|
-
access: 'public',
|
|
98
|
-
prefix: '//prefix//',
|
|
99
|
-
token: 'secret',
|
|
100
|
-
put: (async (seenPath: string, seenBody: unknown, opts: Record<string, unknown>) => {
|
|
101
|
-
if (!Buffer.isBuffer(seenBody)) throw new TypeError('Vercel PutBody must be a Buffer')
|
|
102
|
-
pathname = seenPath
|
|
103
|
-
body = seenBody
|
|
104
|
-
options = opts
|
|
105
|
-
return {}
|
|
106
|
-
}) as never,
|
|
107
|
-
get: (async () => null) as never,
|
|
108
|
-
})
|
|
109
|
-
const bytes = new Uint8Array([0x00, 0x80, 0xff, 0x41])
|
|
110
|
-
await blobs.put('abc', bytes)
|
|
111
|
-
expect(pathname).toBe('prefix/abc')
|
|
112
|
-
expect(Buffer.isBuffer(body)).toBe(true)
|
|
113
|
-
expect([...body!]).toEqual([...bytes])
|
|
114
|
-
expect(options).toMatchObject({
|
|
115
|
-
access: 'public',
|
|
116
|
-
token: 'secret',
|
|
117
|
-
addRandomSuffix: false,
|
|
118
|
-
allowOverwrite: true,
|
|
119
|
-
})
|
|
120
|
-
})
|
|
121
|
-
})
|
package/src/config.test.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { parsePostgresStoreEnv } from './config'
|
|
3
|
-
|
|
4
|
-
const base = { AB_POSTGRES_URL: 'postgres://db/app' }
|
|
5
|
-
|
|
6
|
-
describe('PostgreSQL store environment', () => {
|
|
7
|
-
test('parses explicit S3-compatible configuration', () => {
|
|
8
|
-
expect(
|
|
9
|
-
parsePostgresStoreEnv({
|
|
10
|
-
...base,
|
|
11
|
-
AB_BLOB_BACKEND: 's3',
|
|
12
|
-
AB_S3_BUCKET: 'bucket',
|
|
13
|
-
AB_S3_REGION: 'region',
|
|
14
|
-
AB_S3_ENDPOINT: 'https://objects.example',
|
|
15
|
-
AB_S3_ACCESS_KEY_ID: 'key',
|
|
16
|
-
AB_S3_SECRET_ACCESS_KEY: 'secret',
|
|
17
|
-
AB_S3_SESSION_TOKEN: 'session',
|
|
18
|
-
AB_S3_FORCE_PATH_STYLE: 'true',
|
|
19
|
-
AB_BLOB_PREFIX: '/builds/',
|
|
20
|
-
}),
|
|
21
|
-
).toEqual({
|
|
22
|
-
url: 'postgres://db/app',
|
|
23
|
-
backend: 's3',
|
|
24
|
-
bucket: 'bucket',
|
|
25
|
-
region: 'region',
|
|
26
|
-
endpoint: 'https://objects.example',
|
|
27
|
-
accessKeyId: 'key',
|
|
28
|
-
secretAccessKey: 'secret',
|
|
29
|
-
sessionToken: 'session',
|
|
30
|
-
forcePathStyle: true,
|
|
31
|
-
prefix: '/builds/',
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('supports token and complete OIDC Vercel authentication', () => {
|
|
36
|
-
expect(
|
|
37
|
-
parsePostgresStoreEnv({
|
|
38
|
-
...base,
|
|
39
|
-
AB_BLOB_BACKEND: 'vercel',
|
|
40
|
-
AB_VERCEL_BLOB_ACCESS: 'private',
|
|
41
|
-
BLOB_READ_WRITE_TOKEN: 'rw',
|
|
42
|
-
}),
|
|
43
|
-
).toMatchObject({ backend: 'vercel', access: 'private', token: 'rw' })
|
|
44
|
-
expect(
|
|
45
|
-
parsePostgresStoreEnv({
|
|
46
|
-
...base,
|
|
47
|
-
AB_BLOB_BACKEND: 'vercel',
|
|
48
|
-
AB_VERCEL_BLOB_ACCESS: 'public',
|
|
49
|
-
VERCEL_OIDC_TOKEN: 'oidc',
|
|
50
|
-
BLOB_STORE_ID: 'store',
|
|
51
|
-
}),
|
|
52
|
-
).toMatchObject({ backend: 'vercel', access: 'public', oidcToken: 'oidc', storeId: 'store' })
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('rejects missing, malformed, and incomplete values', () => {
|
|
56
|
-
expect(() => parsePostgresStoreEnv({ AB_BLOB_BACKEND: 's3' })).toThrow('AB_POSTGRES_URL')
|
|
57
|
-
expect(() => parsePostgresStoreEnv({ ...base, AB_BLOB_BACKEND: 'other' })).toThrow('s3')
|
|
58
|
-
expect(() =>
|
|
59
|
-
parsePostgresStoreEnv({
|
|
60
|
-
...base,
|
|
61
|
-
AB_BLOB_BACKEND: 's3',
|
|
62
|
-
AB_S3_BUCKET: 'b',
|
|
63
|
-
AB_S3_REGION: 'r',
|
|
64
|
-
AB_S3_ACCESS_KEY_ID: 'k',
|
|
65
|
-
AB_S3_SECRET_ACCESS_KEY: 's',
|
|
66
|
-
AB_S3_FORCE_PATH_STYLE: 'yes',
|
|
67
|
-
}),
|
|
68
|
-
).toThrow('true')
|
|
69
|
-
expect(() =>
|
|
70
|
-
parsePostgresStoreEnv({
|
|
71
|
-
...base,
|
|
72
|
-
AB_BLOB_BACKEND: 'vercel',
|
|
73
|
-
AB_VERCEL_BLOB_ACCESS: 'private',
|
|
74
|
-
VERCEL_OIDC_TOKEN: 'only',
|
|
75
|
-
}),
|
|
76
|
-
).toThrow('BLOB_STORE_ID')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
test('accepts the conventional DATABASE_URL but never ambient AWS credentials', () => {
|
|
80
|
-
expect(
|
|
81
|
-
parsePostgresStoreEnv({
|
|
82
|
-
DATABASE_URL: 'postgres://ambient',
|
|
83
|
-
AB_BLOB_BACKEND: 'vercel',
|
|
84
|
-
AB_VERCEL_BLOB_ACCESS: 'private',
|
|
85
|
-
BLOB_READ_WRITE_TOKEN: 'token',
|
|
86
|
-
}).url,
|
|
87
|
-
).toBe('postgres://ambient')
|
|
88
|
-
expect(
|
|
89
|
-
parsePostgresStoreEnv({
|
|
90
|
-
...base,
|
|
91
|
-
DATABASE_URL: 'postgres://ambient',
|
|
92
|
-
AB_BLOB_BACKEND: 'vercel',
|
|
93
|
-
AB_VERCEL_BLOB_ACCESS: 'private',
|
|
94
|
-
BLOB_READ_WRITE_TOKEN: 'token',
|
|
95
|
-
}).url,
|
|
96
|
-
).toBe('postgres://db/app')
|
|
97
|
-
expect(() =>
|
|
98
|
-
parsePostgresStoreEnv({
|
|
99
|
-
DATABASE_URL: 'postgres://ambient',
|
|
100
|
-
AB_BLOB_BACKEND: 's3',
|
|
101
|
-
AB_S3_BUCKET: 'b',
|
|
102
|
-
AB_S3_REGION: 'r',
|
|
103
|
-
AWS_ACCESS_KEY_ID: 'ambient',
|
|
104
|
-
AWS_SECRET_ACCESS_KEY: 'ambient',
|
|
105
|
-
}),
|
|
106
|
-
).toThrow('AB_S3_ACCESS_KEY_ID')
|
|
107
|
-
expect(() => parsePostgresStoreEnv({ AB_BLOB_BACKEND: 's3' })).toThrow(
|
|
108
|
-
'AB_POSTGRES_URL or DATABASE_URL',
|
|
109
|
-
)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('AB_ARTIFACT_RETENTION_MAX_REVISIONS: absent means default, valid flows through, invalid rejected', () => {
|
|
113
|
-
// Absent ⇒ no retention override; the store's documented default applies.
|
|
114
|
-
expect(
|
|
115
|
-
parsePostgresStoreEnv({
|
|
116
|
-
...base,
|
|
117
|
-
AB_BLOB_BACKEND: 'vercel',
|
|
118
|
-
AB_VERCEL_BLOB_ACCESS: 'private',
|
|
119
|
-
BLOB_READ_WRITE_TOKEN: 'rw',
|
|
120
|
-
}).retention,
|
|
121
|
-
).toBeUndefined()
|
|
122
|
-
|
|
123
|
-
// A valid value flows through to the retention option on either backend.
|
|
124
|
-
expect(
|
|
125
|
-
parsePostgresStoreEnv({
|
|
126
|
-
...base,
|
|
127
|
-
AB_BLOB_BACKEND: 's3',
|
|
128
|
-
AB_S3_BUCKET: 'bucket',
|
|
129
|
-
AB_S3_REGION: 'region',
|
|
130
|
-
AB_S3_ACCESS_KEY_ID: 'key',
|
|
131
|
-
AB_S3_SECRET_ACCESS_KEY: 'secret',
|
|
132
|
-
AB_ARTIFACT_RETENTION_MAX_REVISIONS: ' 50 ',
|
|
133
|
-
}).retention,
|
|
134
|
-
).toEqual({ maxRevisions: 50 })
|
|
135
|
-
expect(
|
|
136
|
-
parsePostgresStoreEnv({
|
|
137
|
-
...base,
|
|
138
|
-
AB_BLOB_BACKEND: 'vercel',
|
|
139
|
-
AB_VERCEL_BLOB_ACCESS: 'private',
|
|
140
|
-
BLOB_READ_WRITE_TOKEN: 'rw',
|
|
141
|
-
AB_ARTIFACT_RETENTION_MAX_REVISIONS: '1',
|
|
142
|
-
}).retention,
|
|
143
|
-
).toEqual({ maxRevisions: 1 })
|
|
144
|
-
|
|
145
|
-
// Non-integer, zero, and negative values are rejected with the variable named.
|
|
146
|
-
// (A blank value is treated as absent, matching this file's optional() convention.)
|
|
147
|
-
for (const bad of ['zero', '0', '-5', '2.5']) {
|
|
148
|
-
expect(() =>
|
|
149
|
-
parsePostgresStoreEnv({
|
|
150
|
-
...base,
|
|
151
|
-
AB_BLOB_BACKEND: 'vercel',
|
|
152
|
-
AB_VERCEL_BLOB_ACCESS: 'private',
|
|
153
|
-
BLOB_READ_WRITE_TOKEN: 'rw',
|
|
154
|
-
AB_ARTIFACT_RETENTION_MAX_REVISIONS: bad,
|
|
155
|
-
}),
|
|
156
|
-
).toThrow('AB_ARTIFACT_RETENTION_MAX_REVISIONS')
|
|
157
|
-
}
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
test('root full-stack manifest has no blob provider SDK dependency', async () => {
|
|
161
|
-
const manifest = (await Bun.file('package.json').json()) as {
|
|
162
|
-
dependencies?: Record<string, string>
|
|
163
|
-
}
|
|
164
|
-
const names = Object.keys(manifest.dependencies ?? {})
|
|
165
|
-
expect(names).not.toContain('@aws-sdk/client-s3')
|
|
166
|
-
expect(names).not.toContain('@vercel/blob')
|
|
167
|
-
expect(names).not.toContain('pg')
|
|
168
|
-
expect(names).not.toContain('postgres')
|
|
169
|
-
const hosted = (await Bun.file('packages/hosted-store-service/package.json').json()) as {
|
|
170
|
-
dependencies?: Record<string, string>
|
|
171
|
-
}
|
|
172
|
-
expect(hosted.dependencies?.pg).toBe('8.18.0')
|
|
173
|
-
})
|
|
174
|
-
})
|
package/src/env.test.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { describePostgresTarget, MISSING_POSTGRES_URL_MESSAGE, resolvePostgresUrl } from './env'
|
|
3
|
-
|
|
4
|
-
describe('PostgreSQL URL resolution', () => {
|
|
5
|
-
test('prefers the explicit Autobuild variable', () => {
|
|
6
|
-
expect(
|
|
7
|
-
resolvePostgresUrl({
|
|
8
|
-
AB_POSTGRES_URL: ' postgres://explicit ',
|
|
9
|
-
DATABASE_URL: 'postgres://ambient',
|
|
10
|
-
}),
|
|
11
|
-
).toBe('postgres://explicit')
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
test('falls back to the conventional DATABASE_URL', () => {
|
|
15
|
-
expect(resolvePostgresUrl({ DATABASE_URL: 'postgres://ambient' })).toBe('postgres://ambient')
|
|
16
|
-
expect(resolvePostgresUrl({ AB_POSTGRES_URL: ' ', DATABASE_URL: 'postgres://ambient' })).toBe(
|
|
17
|
-
'postgres://ambient',
|
|
18
|
-
)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
test('names both variables when neither is set', () => {
|
|
22
|
-
expect(() => resolvePostgresUrl({})).toThrow(MISSING_POSTGRES_URL_MESSAGE)
|
|
23
|
-
expect(() =>
|
|
24
|
-
resolvePostgresUrl({
|
|
25
|
-
AB_POSTGRES_URL: '',
|
|
26
|
-
DATABASE_URL_UNPOOLED: 'postgres://direct',
|
|
27
|
-
POSTGRES_URL: 'postgres://legacy',
|
|
28
|
-
}),
|
|
29
|
-
).toThrow(MISSING_POSTGRES_URL_MESSAGE)
|
|
30
|
-
})
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
describe('PostgreSQL target description', () => {
|
|
34
|
-
test('names the host and database without credentials', () => {
|
|
35
|
-
expect(
|
|
36
|
-
describePostgresTarget(
|
|
37
|
-
'postgresql://neondb_owner:hunter2@ep-example-pooler.neon.tech/neondb?sslmode=require',
|
|
38
|
-
),
|
|
39
|
-
).toBe('ep-example-pooler.neon.tech/neondb')
|
|
40
|
-
expect(describePostgresTarget('postgres://user:secret@db.internal:6432')).toBe(
|
|
41
|
-
'db.internal:6432',
|
|
42
|
-
)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
test('never echoes an unparsable value', () => {
|
|
46
|
-
expect(describePostgresTarget('not a url with secret')).toBe('the configured database')
|
|
47
|
-
})
|
|
48
|
-
})
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { readFileSync } from 'node:fs'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
import { EVENT_WAIT_POLL_MS as fromAdapter } from '@defrex/autobuild/store-adapter'
|
|
5
|
-
import { EVENT_WAIT_POLL_MS as fromStore } from './store'
|
|
6
|
-
|
|
7
|
-
describe('EVENT_WAIT_POLL_MS unification (AUT-388)', () => {
|
|
8
|
-
const source = readFileSync(join(import.meta.dir, 'store.ts'), 'utf8')
|
|
9
|
-
|
|
10
|
-
test('store.ts does not redefine the constant (AUT-388)', () => {
|
|
11
|
-
expect(source).not.toMatch(/const\s+EVENT_WAIT_POLL_MS\s*=/)
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
test('store.ts imports the core constant via @defrex/autobuild/store-adapter', () => {
|
|
15
|
-
expect(source).toMatch(
|
|
16
|
-
/import\s*\{[^}]*\bEVENT_WAIT_POLL_MS\b[^}]*\}\s*from\s*'@defrex\/autobuild\/store-adapter'/s,
|
|
17
|
-
)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
test('the postgres-store export is the core binding, not a copy', () => {
|
|
21
|
-
expect(fromStore).toBe(fromAdapter)
|
|
22
|
-
})
|
|
23
|
-
})
|