@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/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: SQL, id: string): Promise<string[]> {
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: SQL, row: Row): Promise<Ticket> {
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: SQL, id: string, operation: string, lock = false): Promise<Row> {
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
- 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
- }
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
- 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
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
- 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
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.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
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 tx`INSERT INTO ab_ticket_comments (team, ticket_id, seq, body, created_at)
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
- 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}"`)
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.sql.begin(async (tx) => {
216
+ return this.retry.tx(this.sql, async (q) => {
197
217
  if (options.idempotencyKey !== undefined) {
198
- const adopted: Row[] = await tx`SELECT * FROM ab_tickets
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(tx, adopted[0])
220
+ if (adopted[0]) return this.record(q, adopted[0])
201
221
  }
202
222
  for (const blocker of draft.blockedBy ?? []) {
203
- await this.require(tx, blocker, 'create blocker')
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 tx`INSERT INTO ab_tickets
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}, ${tx.array(draft.labels ?? [], 'text')}, ${now}, ${now})
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 tx`SELECT * FROM ab_tickets
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 tx`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
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(tx, row)
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.sql.begin(async (tx) => {
234
- const row = await this.require(tx, id, 'update', true)
235
- await tx`UPDATE ab_tickets SET
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 = ${tx.array(validated.labels ?? strings(row.labels), 'text')},
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.sql.begin(async (tx) => {
246
- await this.require(tx, id, 'addBlocker', true)
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(tx, blockerId, 'addBlocker')
250
- await tx`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
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
- 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}`
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(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
- }),
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
- })
@@ -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
- })