@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 +63 -0
- package/package.json +37 -0
- package/src/auth-schema.ts +238 -0
- package/src/bin.ts +17 -0
- package/src/blobs.test.ts +121 -0
- package/src/config.test.ts +174 -0
- package/src/config.ts +152 -0
- package/src/env.test.ts +48 -0
- package/src/env.ts +31 -0
- package/src/event-wait-poll-ms.test.ts +23 -0
- package/src/index.ts +41 -0
- package/src/migrate.test.ts +640 -0
- package/src/s3.ts +45 -0
- package/src/schema.ts +1099 -0
- package/src/store.live.test.ts +453 -0
- package/src/store.ts +1176 -0
- package/src/testing/concurrent-worker.ts +42 -0
- package/src/tickets.live.test.ts +49 -0
- package/src/tickets.ts +276 -0
- package/src/vercel.ts +53 -0
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
|
+
})
|