@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/src/config.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { S3Client } from '@aws-sdk/client-s3'
2
+ import type { Clock } from '@defrex/autobuild/plugin-sdk'
3
+ import { resolvePostgresUrl } from './env'
4
+ import { S3BlobStore } from './s3'
5
+ import { openPostgresBuildStore, type PostgresBuildStore } from './store'
6
+ import { VercelBlobStore } from './vercel'
7
+
8
+ type Env = Record<string, string | undefined>
9
+ const required = (env: Env, name: string): string => {
10
+ const value = env[name]?.trim()
11
+ if (!value) throw new Error(`${name} is required and must be nonblank`)
12
+ return value
13
+ }
14
+ const optional = (env: Env, name: string): string | undefined => env[name]?.trim() || undefined
15
+ const bool = (env: Env, name: string): boolean | undefined => {
16
+ const value = optional(env, name)
17
+ if (value === undefined) return undefined
18
+ if (value === 'true') return true
19
+ if (value === 'false') return false
20
+ throw new Error(`${name} must be "true" or "false"`)
21
+ }
22
+ /** Optional positive integer. Absent ⇒ undefined (the caller's default applies). */
23
+ const positiveInt = (env: Env, name: string): number | undefined => {
24
+ const value = optional(env, name)
25
+ if (value === undefined) return undefined
26
+ const parsed = Number(value)
27
+ if (!Number.isInteger(parsed) || parsed < 1) {
28
+ throw new Error(`${name} must be a positive integer, got ${value}`)
29
+ }
30
+ return parsed
31
+ }
32
+
33
+ export type PostgresStoreConfig =
34
+ | {
35
+ url: string
36
+ backend: 's3'
37
+ bucket: string
38
+ region: string
39
+ accessKeyId: string
40
+ secretAccessKey: string
41
+ endpoint?: string
42
+ sessionToken?: string
43
+ forcePathStyle?: boolean
44
+ prefix?: string
45
+ /** Artifact retention bound (store/retention.ts); present only when
46
+ * AB_ARTIFACT_RETENTION_MAX_REVISIONS is set — absent means the store's
47
+ * documented default (200) applies. */
48
+ retention?: { maxRevisions: number }
49
+ }
50
+ | {
51
+ url: string
52
+ backend: 'vercel'
53
+ access: 'public' | 'private'
54
+ prefix?: string
55
+ token?: string
56
+ oidcToken?: string
57
+ storeId?: string
58
+ /** See the s3 branch. */
59
+ retention?: { maxRevisions: number }
60
+ }
61
+
62
+ export function parsePostgresStoreEnv(env: Env): PostgresStoreConfig {
63
+ const url = resolvePostgresUrl(env)
64
+ const backend = required(env, 'AB_BLOB_BACKEND')
65
+ const prefix = optional(env, 'AB_BLOB_PREFIX')
66
+ const maxRevisions = positiveInt(env, 'AB_ARTIFACT_RETENTION_MAX_REVISIONS')
67
+ const retention = maxRevisions === undefined ? undefined : { maxRevisions }
68
+ if (backend === 's3') {
69
+ const endpoint = optional(env, 'AB_S3_ENDPOINT')
70
+ const sessionToken = optional(env, 'AB_S3_SESSION_TOKEN')
71
+ const forcePathStyle = bool(env, 'AB_S3_FORCE_PATH_STYLE')
72
+ return {
73
+ url,
74
+ backend,
75
+ bucket: required(env, 'AB_S3_BUCKET'),
76
+ region: required(env, 'AB_S3_REGION'),
77
+ accessKeyId: required(env, 'AB_S3_ACCESS_KEY_ID'),
78
+ secretAccessKey: required(env, 'AB_S3_SECRET_ACCESS_KEY'),
79
+ ...(endpoint ? { endpoint } : {}),
80
+ ...(sessionToken ? { sessionToken } : {}),
81
+ ...(forcePathStyle !== undefined ? { forcePathStyle } : {}),
82
+ ...(prefix ? { prefix } : {}),
83
+ ...(retention ? { retention } : {}),
84
+ }
85
+ }
86
+ if (backend === 'vercel') {
87
+ const access = required(env, 'AB_VERCEL_BLOB_ACCESS')
88
+ if (access !== 'public' && access !== 'private') {
89
+ throw new Error('AB_VERCEL_BLOB_ACCESS must be "public" or "private"')
90
+ }
91
+ const token = optional(env, 'BLOB_READ_WRITE_TOKEN')
92
+ const oidcToken = optional(env, 'VERCEL_OIDC_TOKEN')
93
+ const storeId = optional(env, 'BLOB_STORE_ID')
94
+ if (!token && !(oidcToken && storeId)) {
95
+ throw new Error(
96
+ 'Vercel Blob requires BLOB_READ_WRITE_TOKEN or both VERCEL_OIDC_TOKEN and BLOB_STORE_ID',
97
+ )
98
+ }
99
+ return {
100
+ url,
101
+ backend,
102
+ access,
103
+ ...(prefix ? { prefix } : {}),
104
+ ...(token ? { token } : { oidcToken: oidcToken!, storeId: storeId! }),
105
+ ...(retention ? { retention } : {}),
106
+ }
107
+ }
108
+ throw new Error('AB_BLOB_BACKEND must be "s3" or "vercel"')
109
+ }
110
+
111
+ export async function openPostgresBuildStoreFromEnv(
112
+ env: Env,
113
+ options: { clock?: Clock } = {},
114
+ ): Promise<PostgresBuildStore> {
115
+ const config = parsePostgresStoreEnv(env)
116
+ const storeOptions = {
117
+ ...options,
118
+ ...(config.retention ? { retention: config.retention } : {}),
119
+ }
120
+ if (config.backend === 's3') {
121
+ const client = new S3Client({
122
+ region: config.region,
123
+ ...(config.endpoint ? { endpoint: config.endpoint } : {}),
124
+ ...(config.forcePathStyle !== undefined ? { forcePathStyle: config.forcePathStyle } : {}),
125
+ credentials: {
126
+ accessKeyId: config.accessKeyId,
127
+ secretAccessKey: config.secretAccessKey,
128
+ ...(config.sessionToken ? { sessionToken: config.sessionToken } : {}),
129
+ },
130
+ })
131
+ return openPostgresBuildStore(
132
+ config.url,
133
+ new S3BlobStore({
134
+ client,
135
+ bucket: config.bucket,
136
+ ...(config.prefix ? { prefix: config.prefix } : {}),
137
+ }),
138
+ storeOptions,
139
+ )
140
+ }
141
+ return openPostgresBuildStore(
142
+ config.url,
143
+ new VercelBlobStore({
144
+ access: config.access,
145
+ ...(config.prefix ? { prefix: config.prefix } : {}),
146
+ ...(config.token
147
+ ? { token: config.token }
148
+ : { oidcToken: config.oidcToken!, storeId: config.storeId! }),
149
+ }),
150
+ storeOptions,
151
+ )
152
+ }
@@ -0,0 +1,48 @@
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
+ })
package/src/env.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** PostgreSQL connection URL selection shared by the adapter, the migration
2
+ * CLI, and the hosted web session store. */
3
+ export type PostgresEnv = Record<string, string | undefined>
4
+
5
+ /** Variables consulted in precedence order: the explicit Autobuild name wins
6
+ * over the conventional `DATABASE_URL` that hosting integrations inject. */
7
+ export const POSTGRES_URL_VARIABLES = ['AB_POSTGRES_URL', 'DATABASE_URL'] as const
8
+
9
+ export const MISSING_POSTGRES_URL_MESSAGE =
10
+ 'AB_POSTGRES_URL or DATABASE_URL is required and must be nonblank'
11
+
12
+ /** Resolve the first nonblank connection URL. Blank values are skipped so an
13
+ * empty override cannot shadow the conventional variable. */
14
+ export function resolvePostgresUrl(env: PostgresEnv): string {
15
+ for (const name of POSTGRES_URL_VARIABLES) {
16
+ const value = env[name]?.trim()
17
+ if (value) return value
18
+ }
19
+ throw new Error(MISSING_POSTGRES_URL_MESSAGE)
20
+ }
21
+
22
+ /** Credential-free description of a connection URL for logs: host and database. */
23
+ export function describePostgresTarget(url: string): string {
24
+ try {
25
+ const parsed = new URL(url)
26
+ const database = parsed.pathname.replace(/^\/+/, '')
27
+ return database ? `${parsed.host}/${database}` : parsed.host
28
+ } catch {
29
+ return 'the configured database'
30
+ }
31
+ }
@@ -0,0 +1,23 @@
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
+ })
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export {
2
+ AUTH_SCHEMA_CHECKSUM,
3
+ AUTH_SCHEMA_DDL,
4
+ AUTH_SCHEMA_VERSION,
5
+ assertAuthSchema,
6
+ } from './auth-schema'
7
+ export { openPostgresBuildStoreFromEnv, parsePostgresStoreEnv } from './config'
8
+ export type { PostgresStoreConfig } from './config'
9
+ export {
10
+ describePostgresTarget,
11
+ MISSING_POSTGRES_URL_MESSAGE,
12
+ POSTGRES_URL_VARIABLES,
13
+ resolvePostgresUrl,
14
+ } from './env'
15
+ export type { PostgresEnv } from './env'
16
+ export { S3BlobStore, blobPath } from './s3'
17
+ export type { S3BlobStoreOptions } from './s3'
18
+ export {
19
+ MIGRATE_COMMAND,
20
+ SCHEMA_CHECKSUM,
21
+ SCHEMA_VERSION,
22
+ TICKET_SCHEMA_CHECKSUM,
23
+ TICKET_SCHEMA_VERSION,
24
+ assertSchema,
25
+ assertTicketSchema,
26
+ migratePostgres,
27
+ } from './schema'
28
+ export {
29
+ EVENT_WAIT_POLL_MS,
30
+ openPostgresBuildStore,
31
+ PostgresBuildStore,
32
+ } from './store'
33
+ export type { PostgresBuildStoreOptions } from './store'
34
+ export {
35
+ openPostgresTicketDatabase,
36
+ PostgresTicketDatabase,
37
+ PostgresTicketSource,
38
+ } from './tickets'
39
+ export type { PostgresTicketContext, PostgresTicketLifecycle } from './tickets'
40
+ export { VercelBlobStore } from './vercel'
41
+ export type { VercelBlobStoreOptions } from './vercel'