@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/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
|
+
}
|
package/src/env.test.ts
ADDED
|
@@ -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'
|