@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.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # `@defrex/autobuild-postgres-store`
2
+
3
+ Serverless-safe Autobuild persistence using PostgreSQL and either an
4
+ S3-compatible object store or Vercel Blob. This is a separate package: installing
5
+ the `@defrex/autobuild` CLI does not install its database/blob provider
6
+ dependencies. A project that embeds the adapter adds it directly:
7
+
8
+ ```sh
9
+ bun add @defrex/autobuild-postgres-store
10
+ ```
11
+
12
+ ## Setup
13
+
14
+ To run the migration from the hosted deployment's own checkout, choose the
15
+ adapter-compatible tag shown in [GitHub Releases](https://github.com/defrex/autobuild/releases),
16
+ then obtain and install that exact repository revision in a dedicated checkout:
17
+
18
+ ```sh
19
+ git clone --depth 1 --branch v0.6.0 --single-branch https://github.com/defrex/autobuild.git autobuild-v0.6.0
20
+ cd autobuild-v0.6.0
21
+ bun install --frozen-lockfile
22
+ DATABASE_URL=postgres://… bun run postgres:migrate
23
+ ```
24
+
25
+ Replace `v0.6.0` with the selected release tag. The migration is idempotent.
26
+ The adapter reads the conventional `DATABASE_URL`, which Vercel's Neon and
27
+ Postgres storage integrations inject; set `AB_POSTGRES_URL` to override it
28
+ explicitly.
29
+ Schema diagnostics refer to the root `postgres:migrate` script in this pinned
30
+ release checkout. The database identity needs permission to create tables,
31
+ constraints, and migration markers during migration and to select, insert,
32
+ update, and delete the resulting tables at runtime. The ticket schema has its
33
+ own version/checksum marker, so migrating an existing BuildStore v1 database
34
+ adds team-scoped tickets, comments, and blockers without replacing the
35
+ established BuildStore marker. Opening against a missing, older/newer, or
36
+ checksum-mismatched schema fails; schema creation is never implicit.
37
+
38
+ ## Concurrency
39
+
40
+ Identity rows are created conflict-safely before being locked for subsequent
41
+ work. Concurrent repository ensures are idempotent and all return the single
42
+ stored record. Concurrent attempts to create the same build slug produce one
43
+ winner; every other caller receives `build "<slug>" already exists` rather than
44
+ a raw PostgreSQL uniqueness error.
45
+
46
+ ```ts
47
+ import { openPostgresBuildStoreFromEnv } from '@defrex/autobuild-postgres-store'
48
+
49
+ const store = await openPostgresBuildStoreFromEnv(process.env)
50
+ ```
51
+
52
+ S3 credentials need `GetObject` and `PutObject` on the configured bucket/prefix.
53
+ Only an object-store 404 is treated as absent; authorization and service errors
54
+ are propagated. Vercel supports either a Blob read-write token or a Vercel OIDC
55
+ token paired with its Blob store ID.
56
+
57
+ `PostgresTicketDatabase.source({ teamKey, claimedState?, createState? })` creates
58
+ a request-bound TicketSource view. The deployment supplies four distinct
59
+ lifecycle names; the defaults are Triage, Ready, Doing, and Done. Ticket bodies
60
+ are stored as PostgreSQL `text` and returned unchanged.
61
+
62
+ For an authenticated HTTP deployment of this adapter, see the
63
+ [`@defrex/autobuild-hosted-store-service` guide](../hosted-store-service/README.md).
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@defrex/autobuild-postgres-store",
3
+ "version": "0.8.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "engines": {
7
+ "bun": ">=1.4.0"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "import": "./src/index.ts"
13
+ },
14
+ "./env": {
15
+ "types": "./src/env.ts",
16
+ "import": "./src/env.ts"
17
+ }
18
+ },
19
+ "bin": {
20
+ "ab-postgres-store": "./src/bin.ts"
21
+ },
22
+ "peerDependencies": {
23
+ "@defrex/autobuild": ">=0.7.0"
24
+ },
25
+ "dependencies": {
26
+ "@aws-sdk/client-s3": "^3.0.0",
27
+ "@vercel/blob": "^2.0.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@defrex/autobuild": {
34
+ "optional": true
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,238 @@
1
+ import type { SQL } from 'bun'
2
+
3
+ /** Better Auth 1.4.18 core + MCP/OIDC/JWT plugin PostgreSQL schema. Changes to
4
+ * the pinned auth package must deliberately update this marker and DDL.
5
+ *
6
+ * v3 notes the plugin's declared-schema drift: the pinned 1.4.18 MCP plugin's
7
+ * DCR endpoint writes `authenticationScheme`
8
+ * (node_modules/better-auth/dist/plugins/mcp/index.mjs:595) but its declared
9
+ * `oauthApplication` schema (dist/plugins/oidc-provider/schema.mjs) omits
10
+ * the field, so the adapter factory's transformInput drops it before either
11
+ * adapter persists it. Verified dropped, not rejected: the Postgres-gated
12
+ * MCP e2e passed 9/9 pre-fix and a live memory-adapter DCR probe stored a
13
+ * row with no `authenticationScheme`. The field declaration in
14
+ * hosted-store-service's createWebAuth restores it; this column persists it.
15
+ * Patching the plugin upstream is out of scope. */
16
+ export const AUTH_SCHEMA_VERSION = 3
17
+
18
+ /** The frozen v1 DDL, kept verbatim so a deployed v1 marker's checksum can be
19
+ * recognized and upgraded in place (see migratePostgres). */
20
+ export const AUTH_SCHEMA_V1_DDL = `
21
+ CREATE TABLE IF NOT EXISTS ab_auth_schema_migrations (
22
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
23
+ version integer NOT NULL, checksum text NOT NULL, applied_at timestamptz NOT NULL
24
+ );
25
+ CREATE TABLE IF NOT EXISTS "user" (
26
+ id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE,
27
+ "emailVerified" boolean NOT NULL, image text,
28
+ "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL
29
+ );
30
+ CREATE TABLE IF NOT EXISTS session (
31
+ id text PRIMARY KEY, "expiresAt" timestamp NOT NULL, token text NOT NULL UNIQUE,
32
+ "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL,
33
+ "ipAddress" text, "userAgent" text, "userId" text NOT NULL
34
+ REFERENCES "user"(id) ON DELETE CASCADE
35
+ );
36
+ CREATE TABLE IF NOT EXISTS account (
37
+ id text PRIMARY KEY, "accountId" text NOT NULL, "providerId" text NOT NULL,
38
+ "userId" text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
39
+ "accessToken" text, "refreshToken" text, "idToken" text,
40
+ "accessTokenExpiresAt" timestamp, "refreshTokenExpiresAt" timestamp,
41
+ scope text, password text, "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL
42
+ );
43
+ CREATE TABLE IF NOT EXISTS verification (
44
+ id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL,
45
+ "expiresAt" timestamp NOT NULL, "createdAt" timestamp, "updatedAt" timestamp
46
+ );
47
+ CREATE INDEX IF NOT EXISTS session_user_id_idx ON session ("userId");
48
+ CREATE INDEX IF NOT EXISTS account_user_id_idx ON account ("userId");
49
+ CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification (identifier);`.trim()
50
+
51
+ export const AUTH_SCHEMA_V1_CHECKSUM = new Bun.CryptoHasher('sha256')
52
+ .update(AUTH_SCHEMA_V1_DDL)
53
+ .digest('hex')
54
+
55
+ /** The frozen v2 DDL, kept verbatim so a deployed v2 marker's checksum can be
56
+ * recognized and upgraded in place (see migratePostgres). Byte-identical to
57
+ * the v2 AUTH_SCHEMA_DDL via the shared v1 interpolation above. */
58
+ export const AUTH_SCHEMA_V2_DDL = `
59
+ ${AUTH_SCHEMA_V1_DDL}
60
+ CREATE TABLE IF NOT EXISTS jwks (
61
+ id text PRIMARY KEY, "publicKey" text NOT NULL, "privateKey" text NOT NULL,
62
+ "createdAt" timestamp NOT NULL, "expiresAt" timestamp
63
+ );
64
+ CREATE TABLE IF NOT EXISTS "oauthApplication" (
65
+ id text PRIMARY KEY, name text NOT NULL, icon text, metadata text,
66
+ "clientId" text NOT NULL UNIQUE, "clientSecret" text,
67
+ "redirectUrls" text NOT NULL, type text NOT NULL,
68
+ disabled boolean NOT NULL DEFAULT false,
69
+ "userId" text REFERENCES "user"(id) ON DELETE CASCADE,
70
+ "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL
71
+ );
72
+ CREATE INDEX IF NOT EXISTS oauthApplication_user_id_idx ON "oauthApplication" ("userId");
73
+ CREATE TABLE IF NOT EXISTS "oauthAccessToken" (
74
+ id text PRIMARY KEY, "accessToken" text NOT NULL UNIQUE,
75
+ "refreshToken" text NOT NULL UNIQUE, "accessTokenExpiresAt" timestamp NOT NULL,
76
+ "refreshTokenExpiresAt" timestamp NOT NULL,
77
+ "clientId" text NOT NULL REFERENCES "oauthApplication"("clientId") ON DELETE CASCADE,
78
+ "userId" text REFERENCES "user"(id) ON DELETE CASCADE,
79
+ scopes text NOT NULL, "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL
80
+ );
81
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_client_id_idx ON "oauthAccessToken" ("clientId");
82
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_user_id_idx ON "oauthAccessToken" ("userId");
83
+ CREATE TABLE IF NOT EXISTS "oauthConsent" (
84
+ id text PRIMARY KEY, "clientId" text NOT NULL
85
+ REFERENCES "oauthApplication"("clientId") ON DELETE CASCADE,
86
+ "userId" text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
87
+ scopes text NOT NULL, "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL,
88
+ "consentGiven" boolean NOT NULL
89
+ );
90
+ CREATE INDEX IF NOT EXISTS oauthConsent_user_id_idx ON "oauthConsent" ("userId");`.trim()
91
+
92
+ export const AUTH_SCHEMA_V2_CHECKSUM = new Bun.CryptoHasher('sha256')
93
+ .update(AUTH_SCHEMA_V2_DDL)
94
+ .digest('hex')
95
+
96
+ /** v2 adds the four MCP-plugin tables (jwks from the jwt companion): column
97
+ * names are exactly the camelCase quoted identifiers Better Auth's pg adapter
98
+ * writes.
99
+ *
100
+ * v3 adds the nullable `authenticationScheme` column to `oauthApplication`
101
+ * (see the header marker): legacy rows legitimately lack the field, so NULL
102
+ * means "field absent" (the builds.repo_origin precedent) — NOT NULL DEFAULT
103
+ * would invent scheme values for existing clients. The column rides last so
104
+ * fresh databases assert identically to v2 databases upgraded by the guarded
105
+ * ALTER in migratePostgres (the streams.creation_seq precedent). */
106
+ export const AUTH_SCHEMA_DDL = `
107
+ ${AUTH_SCHEMA_V1_DDL}
108
+ CREATE TABLE IF NOT EXISTS jwks (
109
+ id text PRIMARY KEY, "publicKey" text NOT NULL, "privateKey" text NOT NULL,
110
+ "createdAt" timestamp NOT NULL, "expiresAt" timestamp
111
+ );
112
+ CREATE TABLE IF NOT EXISTS "oauthApplication" (
113
+ id text PRIMARY KEY, name text NOT NULL, icon text, metadata text,
114
+ "clientId" text NOT NULL UNIQUE, "clientSecret" text,
115
+ "redirectUrls" text NOT NULL, type text NOT NULL,
116
+ disabled boolean NOT NULL DEFAULT false,
117
+ "userId" text REFERENCES "user"(id) ON DELETE CASCADE,
118
+ "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL,
119
+ "authenticationScheme" text
120
+ );
121
+ CREATE INDEX IF NOT EXISTS oauthApplication_user_id_idx ON "oauthApplication" ("userId");
122
+ CREATE TABLE IF NOT EXISTS "oauthAccessToken" (
123
+ id text PRIMARY KEY, "accessToken" text NOT NULL UNIQUE,
124
+ "refreshToken" text NOT NULL UNIQUE, "accessTokenExpiresAt" timestamp NOT NULL,
125
+ "refreshTokenExpiresAt" timestamp NOT NULL,
126
+ "clientId" text NOT NULL REFERENCES "oauthApplication"("clientId") ON DELETE CASCADE,
127
+ "userId" text REFERENCES "user"(id) ON DELETE CASCADE,
128
+ scopes text NOT NULL, "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL
129
+ );
130
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_client_id_idx ON "oauthAccessToken" ("clientId");
131
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_user_id_idx ON "oauthAccessToken" ("userId");
132
+ CREATE TABLE IF NOT EXISTS "oauthConsent" (
133
+ id text PRIMARY KEY, "clientId" text NOT NULL
134
+ REFERENCES "oauthApplication"("clientId") ON DELETE CASCADE,
135
+ "userId" text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
136
+ scopes text NOT NULL, "createdAt" timestamp NOT NULL, "updatedAt" timestamp NOT NULL,
137
+ "consentGiven" boolean NOT NULL
138
+ );
139
+ CREATE INDEX IF NOT EXISTS oauthConsent_user_id_idx ON "oauthConsent" ("userId");`.trim()
140
+
141
+ export const AUTH_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
142
+ .update(AUTH_SCHEMA_DDL)
143
+ .digest('hex')
144
+
145
+ export async function assertAuthSchema(sql: SQL): Promise<void> {
146
+ let rows: { version: number; checksum: string }[]
147
+ try {
148
+ rows = await sql`SELECT version, checksum FROM ab_auth_schema_migrations WHERE singleton = true`
149
+ } catch (error) {
150
+ const code =
151
+ (error as { code?: string; errno?: string }).errno ?? (error as { code?: string }).code
152
+ if (code === '42P01')
153
+ throw new Error('PostgreSQL auth schema is missing; run: bun run postgres:migrate')
154
+ throw error
155
+ }
156
+ const marker = rows[0]
157
+ if (
158
+ !marker ||
159
+ Number(marker.version) !== AUTH_SCHEMA_VERSION ||
160
+ marker.checksum !== AUTH_SCHEMA_CHECKSUM
161
+ ) {
162
+ throw new Error(
163
+ 'PostgreSQL auth schema marker is incompatible; run the migration from this release',
164
+ )
165
+ }
166
+ const columns: { table_name: string; column_name: string }[] = await sql`
167
+ SELECT table_name, column_name FROM information_schema.columns
168
+ WHERE table_schema = current_schema()
169
+ AND table_name = ANY(ARRAY['user', 'session', 'account', 'verification', 'jwks',
170
+ 'oauthApplication', 'oauthAccessToken', 'oauthConsent'])
171
+ ORDER BY table_name, ordinal_position`
172
+ const expected = {
173
+ account: [
174
+ 'id',
175
+ 'accountId',
176
+ 'providerId',
177
+ 'userId',
178
+ 'accessToken',
179
+ 'refreshToken',
180
+ 'idToken',
181
+ 'accessTokenExpiresAt',
182
+ 'refreshTokenExpiresAt',
183
+ 'scope',
184
+ 'password',
185
+ 'createdAt',
186
+ 'updatedAt',
187
+ ],
188
+ jwks: ['id', 'publicKey', 'privateKey', 'createdAt', 'expiresAt'],
189
+ oauthApplication: [
190
+ 'id',
191
+ 'name',
192
+ 'icon',
193
+ 'metadata',
194
+ 'clientId',
195
+ 'clientSecret',
196
+ 'redirectUrls',
197
+ 'type',
198
+ 'disabled',
199
+ 'userId',
200
+ 'createdAt',
201
+ 'updatedAt',
202
+ // Rides last: the guarded v2→v3 ALTER appends it at the end, so
203
+ // migrated and fresh databases assert identically.
204
+ 'authenticationScheme',
205
+ ],
206
+ oauthAccessToken: [
207
+ 'id',
208
+ 'accessToken',
209
+ 'refreshToken',
210
+ 'accessTokenExpiresAt',
211
+ 'refreshTokenExpiresAt',
212
+ 'clientId',
213
+ 'userId',
214
+ 'scopes',
215
+ 'createdAt',
216
+ 'updatedAt',
217
+ ],
218
+ oauthConsent: ['id', 'clientId', 'userId', 'scopes', 'createdAt', 'updatedAt', 'consentGiven'],
219
+ session: [
220
+ 'id',
221
+ 'expiresAt',
222
+ 'token',
223
+ 'createdAt',
224
+ 'updatedAt',
225
+ 'ipAddress',
226
+ 'userAgent',
227
+ 'userId',
228
+ ],
229
+ user: ['id', 'name', 'email', 'emailVerified', 'image', 'createdAt', 'updatedAt'],
230
+ verification: ['id', 'identifier', 'value', 'expiresAt', 'createdAt', 'updatedAt'],
231
+ }
232
+ for (const [table, names] of Object.entries(expected)) {
233
+ const actual = columns.filter((row) => row.table_name === table).map((row) => row.column_name)
234
+ if (JSON.stringify(actual) !== JSON.stringify(names)) {
235
+ throw new Error(`PostgreSQL auth schema table ${table} is missing or mismatched`)
236
+ }
237
+ }
238
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bun
2
+ import { describePostgresTarget, resolvePostgresUrl } from './env'
3
+ import { migratePostgres } from './schema'
4
+
5
+ async function main(): Promise<void> {
6
+ if (process.argv.length !== 3 || process.argv[2] !== 'migrate') {
7
+ throw new Error('usage: ab-postgres-store migrate')
8
+ }
9
+ const url = resolvePostgresUrl(process.env)
10
+ await migratePostgres(url)
11
+ console.log(`Autobuild PostgreSQL schema is ready on ${describePostgresTarget(url)}`)
12
+ }
13
+
14
+ main().catch((error: unknown) => {
15
+ console.error(error instanceof Error ? error.message : String(error))
16
+ process.exitCode = 1
17
+ })
@@ -0,0 +1,121 @@
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
+ })
@@ -0,0 +1,174 @@
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
+ })