@defrex/autobuild-postgres-store 0.11.0 → 0.13.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 CHANGED
@@ -44,10 +44,27 @@ release checkout. The database identity needs permission to create tables,
44
44
  constraints, and migration markers during migration and to select, insert,
45
45
  update, and delete the resulting tables at runtime. The ticket schema has its
46
46
  own version/checksum marker, so migrating an existing BuildStore v1 database
47
- adds team-scoped tickets, comments, and blockers without replacing the
47
+ adds project-scoped tickets, comments, and blockers without replacing the
48
48
  established BuildStore marker. Opening against a missing, older/newer, or
49
49
  checksum-mismatched schema fails; schema creation is never implicit.
50
50
 
51
+ Populated legacy ticket-v1 upgrades also require the running service's blob
52
+ backend, credentials, access mode and prefix. The environment-aware migration
53
+ CLI opens blobs lazily to read each registered project's selected effective
54
+ configuration when ownership must be inferred. Registered projects without a
55
+ run/config reference make no claim. Unreadable selected configs stop unchanged;
56
+ restoring their bytes or assigning every remaining legacy team unblocks migration.
57
+ Complete registered owner assignments avoid unrelated configuration reads.
58
+ Fresh installs, empty-v1 upgrades and v2 reruns remain SQL-only.
59
+ Released main-v9 upgrades import `AB_WEB_REPOSITORIES` once inside that same
60
+ transaction. On failure, main promotion and registry import roll back with
61
+ tickets. Owner `projects add` and `tickets assign-team` independently bootstrap
62
+ their registry/recovery journals on v9, so they remain usable after rollback.
63
+ Ambiguous teams stop unchanged; the owner resolves them with
64
+ `ab-hosted-store tickets assign-team TEAM REPOSITORY --user IDENTITY` before
65
+ rerunning migration. See the hosted-service migration procedure.
66
+
67
+
51
68
  The current DDL of every marker — build store, ticket, and auth — is immutable
52
69
  once a database has deployed it: deployed databases carry that exact DDL's
53
70
  checksum, and editing the DDL in place leaves them all incompatible. Every
@@ -102,9 +119,9 @@ Only an object-store 404 is treated as absent; authorization and service errors
102
119
  are propagated. Vercel supports either a Blob read-write token or a Vercel OIDC
103
120
  token paired with its Blob store ID.
104
121
 
105
- `PostgresTicketDatabase.source({ teamKey, claimedState?, createState? })` creates
106
- a request-bound TicketSource view. The deployment supplies four distinct
107
- lifecycle names; the defaults are Triage, Ready, Doing, and Done. Ticket bodies
122
+ `PostgresTicketDatabase.source({ repo, teamKey, lifecycle, claimedState?, createState? })`
123
+ creates a request-bound TicketSource view. The deployment supplies the project
124
+ identity, readable-id prefix, and lifecycle names. Ticket bodies
108
125
  are stored as PostgreSQL `text` and returned unchanged.
109
126
 
110
127
  For an authenticated HTTP deployment of this adapter, see the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild-postgres-store",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -22,6 +22,26 @@
22
22
  "./store": {
23
23
  "types": "./src/store.ts",
24
24
  "import": "./src/store.ts"
25
+ },
26
+ "./projects": {
27
+ "types": "./src/projects.ts",
28
+ "import": "./src/projects.ts"
29
+ },
30
+ "./credentials": {
31
+ "types": "./src/credentials.ts",
32
+ "import": "./src/credentials.ts"
33
+ },
34
+ "./connections": {
35
+ "types": "./src/connections.ts",
36
+ "import": "./src/connections.ts"
37
+ },
38
+ "./ticket-policy": {
39
+ "types": "./src/ticket-policy.ts",
40
+ "import": "./src/ticket-policy.ts"
41
+ },
42
+ "./ticket-grants": {
43
+ "types": "./src/ticket-grants.ts",
44
+ "import": "./src/ticket-grants.ts"
25
45
  }
26
46
  },
27
47
  "bin": {
package/src/bin.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  import { describePostgresTarget, resolvePostgresUrl } from './env'
3
- import { migratePostgres } from './schema'
3
+ import { migratePostgresFromEnv } from './config'
4
4
 
5
5
  async function main(): Promise<void> {
6
6
  if (process.argv.length !== 3 || process.argv[2] !== 'migrate') {
7
7
  throw new Error('usage: ab-postgres-store migrate')
8
8
  }
9
9
  const url = resolvePostgresUrl(process.env)
10
- await migratePostgres(url)
10
+ await migratePostgresFromEnv(process.env)
11
11
  console.log(`Autobuild PostgreSQL schema is ready on ${describePostgresTarget(url)}`)
12
12
  }
13
13
 
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { migratePostgres } from './schema'
1
2
  import { S3Client } from '@aws-sdk/client-s3'
2
- import type { Clock } from '@defrex/autobuild/plugin-sdk'
3
+ import type { BlobStore, Clock } from '@defrex/autobuild/plugin-sdk'
3
4
  import { resolvePostgresUrl } from './env'
4
5
  import { S3BlobStore } from './s3'
5
6
  import { openPostgresBuildStore, type PostgresBuildStore } from './store'
@@ -117,6 +118,14 @@ export async function openPostgresBuildStoreFromEnv(
117
118
  ...options,
118
119
  ...(config.retention ? { retention: config.retention } : {}),
119
120
  }
121
+ return openPostgresBuildStore(config.url, openPostgresBlobs(config), storeOptions)
122
+ }
123
+
124
+ export function openPostgresBlobsFromEnv(env: Env): BlobStore {
125
+ return openPostgresBlobs(parsePostgresStoreEnv(env))
126
+ }
127
+
128
+ function openPostgresBlobs(config: PostgresStoreConfig): BlobStore {
120
129
  if (config.backend === 's3') {
121
130
  const client = new S3Client({
122
131
  region: config.region,
@@ -128,25 +137,24 @@ export async function openPostgresBuildStoreFromEnv(
128
137
  ...(config.sessionToken ? { sessionToken: config.sessionToken } : {}),
129
138
  },
130
139
  })
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,
140
+ return new S3BlobStore({
141
+ client,
142
+ bucket: config.bucket,
145
143
  ...(config.prefix ? { prefix: config.prefix } : {}),
146
- ...(config.token
147
- ? { token: config.token }
148
- : { oidcToken: config.oidcToken!, storeId: config.storeId! }),
149
- }),
150
- storeOptions,
151
- )
144
+ })
145
+ }
146
+ return new VercelBlobStore({
147
+ access: config.access,
148
+ ...(config.prefix ? { prefix: config.prefix } : {}),
149
+ ...(config.token
150
+ ? { token: config.token }
151
+ : { oidcToken: config.oidcToken!, storeId: config.storeId! }),
152
+ })
153
+ }
154
+
155
+ export async function migratePostgresFromEnv(env: Env): Promise<void> {
156
+ await migratePostgres(resolvePostgresUrl(env), {
157
+ openBlobs: () => openPostgresBlobsFromEnv(env),
158
+ legacyProjectEnv: env,
159
+ })
152
160
  }
@@ -0,0 +1,160 @@
1
+ import { insertPreparedCredential } from './credentials'
2
+ import { SQL } from 'bun'
3
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
4
+ import type { ConnectionProposal, ConnectionOutcome } from '@defrex/autobuild/connection'
5
+ import { normalizeProjectRepository, PostgresProjectRegistry } from './projects'
6
+ import { resolvePostgresUrl, type PostgresEnv } from './env'
7
+ import { assertSchema } from './schema'
8
+
9
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex')
10
+ export class ConnectionError extends Error {
11
+ constructor(
12
+ readonly status: number,
13
+ message: string,
14
+ ) {
15
+ super(message)
16
+ }
17
+ }
18
+ export interface ConnectionRecord extends ConnectionOutcome {
19
+ id: string
20
+ code: string
21
+ expiresAt: string
22
+ }
23
+ export interface ConnectionStore {
24
+ start(proposal: ConnectionProposal): Promise<ConnectionRecord & { capability: string }>
25
+ poll(id: string, capability: string, cancel?: boolean): Promise<ConnectionOutcome>
26
+ lookup(code: string, actor: string): Promise<ConnectionRecord | null>
27
+ decide(code: string, actor: string, approve: boolean): Promise<ConnectionRecord>
28
+ }
29
+ function record(row: Record<string, unknown>): ConnectionRecord {
30
+ return {
31
+ id: String(row.id),
32
+ repo: String(row.repo),
33
+ credentialId: String(row.credential_id),
34
+ code: String(row.code),
35
+ status: row.status as ConnectionOutcome['status'],
36
+ expiresAt: new Date(row.expires_at as string).toISOString(),
37
+ }
38
+ }
39
+ export class PostgresConnectionStore implements ConnectionStore {
40
+ constructor(
41
+ private readonly sql: SQL,
42
+ private readonly now = () => new Date(),
43
+ ) {}
44
+ async start(input: ConnectionProposal) {
45
+ if (
46
+ typeof input.repo !== 'string' ||
47
+ input.repo.length > 2048 ||
48
+ !/^[a-f0-9]{32}$/.test(input.credentialId) ||
49
+ !/^[a-f0-9]{64}$/.test(input.digest)
50
+ )
51
+ throw new ConnectionError(400, 'invalid connection proposal')
52
+ let repo: string
53
+ try {
54
+ repo = normalizeProjectRepository(input.repo)
55
+ } catch {
56
+ throw new ConnectionError(400, 'invalid repository identity')
57
+ }
58
+ const now = this.now()
59
+ // Bounded expiry retention; never deletes an active capability.
60
+ await this
61
+ .sql`DELETE FROM project_connections WHERE id IN (SELECT id FROM project_connections WHERE expires_at < ${new Date(now.getTime() - 86400000).toISOString()} LIMIT 100)`
62
+ const capability = randomBytes(32).toString('hex')
63
+ for (let attempt = 0; attempt < 5; attempt++) {
64
+ const id = randomBytes(16).toString('hex')
65
+ const code = randomBytes(5).toString('hex').toUpperCase()
66
+ const [row] = await this
67
+ .sql`INSERT INTO project_connections (id, repo, credential_id, digest, capability_hash, code, created_at, expires_at, status)
68
+ VALUES (${id}, ${repo}, ${input.credentialId}, ${input.digest}, ${hash(capability)}, ${code}, ${now.toISOString()}, ${new Date(now.getTime() + 600000).toISOString()}, 'pending') ON CONFLICT DO NOTHING RETURNING *`
69
+ if (row) return { ...record(row), capability }
70
+ }
71
+ throw new ConnectionError(503, 'connection creation unavailable; retry')
72
+ }
73
+ async poll(id: string, capability: string, cancel = false): Promise<ConnectionOutcome> {
74
+ if (!/^[a-f0-9]{32}$/.test(id) || !/^[a-f0-9]{64}$/.test(capability))
75
+ throw new ConnectionError(404, 'unknown connection')
76
+ return this.sql.begin(async (tx) => {
77
+ const [row] = await tx`SELECT * FROM project_connections WHERE id = ${id} FOR UPDATE`
78
+ if (
79
+ !row ||
80
+ !timingSafeEqual(
81
+ Buffer.from(row.capability_hash, 'hex'),
82
+ Buffer.from(hash(capability), 'hex'),
83
+ )
84
+ )
85
+ throw new ConnectionError(404, 'unknown connection')
86
+ const now = this.now()
87
+ if (!cancel && row.polled_at && now.getTime() - new Date(row.polled_at).getTime() < 4500)
88
+ throw new ConnectionError(429, 'poll again in five seconds')
89
+ if (!cancel && row.poll_attempts >= 130)
90
+ throw new ConnectionError(429, 'connection polling limit reached')
91
+ let status = row.status
92
+ if (cancel) {
93
+ if (status === 'approved')
94
+ await tx`UPDATE project_credentials SET revoked_at = COALESCE(revoked_at, ${now.toISOString()}) WHERE id = ${row.credential_id} AND repo = ${row.repo}`
95
+ status = 'denied'
96
+ } else if (status === 'pending' && new Date(row.expires_at) <= now) status = 'expired'
97
+ await tx`UPDATE project_connections SET status = ${status}, polled_at = ${now.toISOString()}, poll_attempts = poll_attempts + 1 WHERE id = ${id}`
98
+ return { ...record(row), status }
99
+ }) as Promise<ConnectionOutcome>
100
+ }
101
+ async lookup(code: string, actor: string): Promise<ConnectionRecord | null> {
102
+ if (!actor || actor.length > 320 || !/^[A-F0-9]{10}$/.test(code))
103
+ throw new ConnectionError(400, 'enter the ten-character connection code')
104
+ const now = this.now()
105
+ const [limit] = await this
106
+ .sql`INSERT INTO connection_lookup_limits (actor, started_at, attempts) VALUES (${actor}, ${now.toISOString()}, 1)
107
+ ON CONFLICT (actor) DO UPDATE SET
108
+ attempts = CASE WHEN connection_lookup_limits.started_at < ${new Date(now.getTime() - 60000).toISOString()} THEN 1 ELSE connection_lookup_limits.attempts + 1 END,
109
+ started_at = CASE WHEN connection_lookup_limits.started_at < ${new Date(now.getTime() - 60000).toISOString()} THEN ${now.toISOString()} ELSE connection_lookup_limits.started_at END RETURNING attempts`
110
+ if (limit.attempts > 20)
111
+ throw new ConnectionError(429, 'too many code lookups; retry in a minute')
112
+ const [row] = await this.sql`SELECT * FROM project_connections WHERE code = ${code}`
113
+ if (!row) return null
114
+ const result = record(row)
115
+ if (result.status === 'pending' && new Date(result.expiresAt) <= now) result.status = 'expired'
116
+ return result
117
+ }
118
+ async decide(code: string, actor: string, approve: boolean): Promise<ConnectionRecord> {
119
+ if (!(await this.lookup(code, actor))) throw new ConnectionError(404, 'unknown connection')
120
+ return this.sql.begin(async (tx) => {
121
+ const [row] = await tx`SELECT * FROM project_connections WHERE code = ${code} FOR UPDATE`
122
+ const now = this.now()
123
+ if (row.status !== 'pending') return record(row)
124
+ const status = new Date(row.expires_at) <= now ? 'expired' : approve ? 'approved' : 'denied'
125
+ if (status === 'approved') {
126
+ // Same lock order as registry mutations, with the request locked first.
127
+ await tx`INSERT INTO project_registry_state (singleton, legacy_imported) VALUES (true, false) ON CONFLICT DO NOTHING`
128
+ await tx`SELECT singleton FROM project_registry_state WHERE singleton = true FOR UPDATE`
129
+ const [previous] =
130
+ await tx`SELECT action FROM project_journal WHERE repo = ${row.repo} ORDER BY seq DESC LIMIT 1`
131
+ if (previous?.action !== 'added')
132
+ await tx`INSERT INTO project_journal (seq, repo, action, actor, ts) VALUES (nextval('project_journal_seq'), ${row.repo}, 'added', ${actor}, ${now.toISOString()})`
133
+ // A collision aborts the whole transaction, including project registration.
134
+ await insertPreparedCredential(tx as SQL, {
135
+ id: row.credential_id,
136
+ digest: row.digest,
137
+ repo: row.repo,
138
+ now,
139
+ user: actor,
140
+ })
141
+ }
142
+ const [updated] =
143
+ await tx`UPDATE project_connections SET status = ${status}, approved_by = ${status === 'approved' ? actor : null} WHERE id = ${row.id} RETURNING *`
144
+ return record(updated)
145
+ }) as Promise<ConnectionRecord>
146
+ }
147
+ }
148
+ export async function openPostgresConnectionStoreFromEnv(
149
+ env: PostgresEnv,
150
+ ): Promise<PostgresConnectionStore> {
151
+ const sql = new SQL(resolvePostgresUrl(env))
152
+ try {
153
+ await assertSchema(sql)
154
+ await new PostgresProjectRegistry(sql).initializeFromEnv(env)
155
+ return new PostgresConnectionStore(sql)
156
+ } catch (error) {
157
+ await sql.close()
158
+ throw error
159
+ }
160
+ }
@@ -0,0 +1,157 @@
1
+ import { SQL } from 'bun'
2
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
3
+ import {
4
+ normalizeProjectRepository,
5
+ PostgresProjectRegistry,
6
+ type ProjectRegistryReader,
7
+ } from './projects'
8
+ import { resolvePostgresUrl, type PostgresEnv } from './env'
9
+ import { assertSchema } from './schema'
10
+
11
+ export interface CredentialMetadata {
12
+ id: string
13
+ repo: string
14
+ createdAt: string
15
+ createdBy: string
16
+ lastUsedAt: string | null
17
+ revokedAt: string | null
18
+ expiresAt: string | null
19
+ purpose: 'project-owner' | 'dispatcher'
20
+ }
21
+ export interface CredentialReader {
22
+ authenticate(token: string, now: Date): Promise<CredentialMetadata | null>
23
+ }
24
+ export interface CredentialStore extends CredentialReader {
25
+ issue(
26
+ repo: string,
27
+ user: string,
28
+ options?: { expiresAt?: Date; purpose?: CredentialMetadata['purpose'] },
29
+ ): Promise<{ token: string; credential: CredentialMetadata }>
30
+ list(repo: string): Promise<CredentialMetadata[]>
31
+ revoke(repo: string, id: string, user: string): Promise<void>
32
+ close(): Promise<void>
33
+ }
34
+ export class ProjectNotRegisteredError extends Error {
35
+ constructor() {
36
+ super('project not registered')
37
+ }
38
+ }
39
+ const hash = (secret: string) => createHash('sha256').update(secret).digest()
40
+ const stamp = (value: unknown): string | null =>
41
+ value == null ? null : new Date(value as string).toISOString()
42
+ function metadata(row: Record<string, unknown>): CredentialMetadata {
43
+ return {
44
+ id: String(row.id),
45
+ repo: String(row.repo),
46
+ createdAt: stamp(row.created_at)!,
47
+ createdBy: String(row.created_by),
48
+ lastUsedAt: stamp(row.last_used_at),
49
+ revokedAt: stamp(row.revoked_at),
50
+ expiresAt: stamp(row.expires_at),
51
+ purpose: row.purpose as CredentialMetadata['purpose'],
52
+ }
53
+ }
54
+ function actor(user: string): string {
55
+ if (!user.trim()) throw new Error('credential creator must be nonblank')
56
+ return user.trim()
57
+ }
58
+ /** Insert a CLI-prepared digest using the caller's transaction, never a raw token. */
59
+ export async function insertPreparedCredential(
60
+ sql: SQL,
61
+ input: {
62
+ id: string
63
+ digest: string
64
+ repo: string
65
+ user: string
66
+ now: Date
67
+ purpose?: CredentialMetadata['purpose']
68
+ expiresAt?: Date
69
+ },
70
+ ): Promise<CredentialMetadata> {
71
+ if (!/^[a-f0-9]{32}$/.test(input.id) || !/^[a-f0-9]{64}$/.test(input.digest))
72
+ throw new Error('invalid prepared credential')
73
+ const repo = normalizeProjectRepository(input.repo),
74
+ by = actor(input.user)
75
+ const [row] =
76
+ await sql`INSERT INTO project_credentials (id, digest, repo, created_at, created_by, purpose, expires_at)
77
+ VALUES (${input.id}, ${input.digest}, ${repo}, ${input.now.toISOString()}, ${by}, ${input.purpose ?? 'project-owner'}, ${input.expiresAt?.toISOString() ?? null}) RETURNING *`
78
+ return metadata(row)
79
+ }
80
+ export class PostgresCredentialStore implements CredentialStore {
81
+ constructor(
82
+ private readonly sql: SQL,
83
+ private readonly projects: ProjectRegistryReader,
84
+ private readonly ownsPool = false,
85
+ ) {}
86
+ async issue(
87
+ raw: string,
88
+ user: string,
89
+ options: { expiresAt?: Date; purpose?: CredentialMetadata['purpose'] } = {},
90
+ ): Promise<{ token: string; credential: CredentialMetadata }> {
91
+ const repo = normalizeProjectRepository(raw)
92
+ const by = actor(user)
93
+ if (!(await this.projects.has(repo))) throw new ProjectNotRegisteredError()
94
+ const now = new Date()
95
+ if (
96
+ options.expiresAt &&
97
+ (!Number.isFinite(options.expiresAt.getTime()) || options.expiresAt <= now)
98
+ )
99
+ throw new Error('credential expiry must be in the future')
100
+ const id = randomBytes(16).toString('hex')
101
+ const secret = randomBytes(32).toString('hex')
102
+ const credential = await insertPreparedCredential(this.sql, {
103
+ id,
104
+ digest: hash(secret).toString('hex'),
105
+ repo,
106
+ user: by,
107
+ now,
108
+ ...options,
109
+ })
110
+ return { token: `abp_1_${id}_${secret}`, credential }
111
+ }
112
+
113
+ async authenticate(token: string, now: Date): Promise<CredentialMetadata | null> {
114
+ const parts = /^abp_1_([a-f0-9]{32})_([a-f0-9]{64})$/.exec(token)
115
+ if (!parts) return null
116
+ const [row] = await this.sql`SELECT * FROM project_credentials WHERE id = ${parts[1]!}`
117
+ if (
118
+ !row ||
119
+ !timingSafeEqual(hash(parts[2]!), Buffer.from(row.digest, 'hex')) ||
120
+ row.revoked_at ||
121
+ (row.expires_at && new Date(row.expires_at) <= now)
122
+ )
123
+ return null
124
+ if (!(await this.projects.has(row.repo))) throw new ProjectNotRegisteredError()
125
+ // The conditional update also closes a revoke racing the initial read.
126
+ const [used] = await this.sql`UPDATE project_credentials SET last_used_at = ${now.toISOString()}
127
+ WHERE id = ${row.id} AND revoked_at IS NULL RETURNING *`
128
+ return used ? metadata(used) : null
129
+ }
130
+ async list(raw: string): Promise<CredentialMetadata[]> {
131
+ const rows = await this
132
+ .sql`SELECT * FROM project_credentials WHERE repo = ${normalizeProjectRepository(raw)} ORDER BY created_at, id`
133
+ return rows.map(metadata)
134
+ }
135
+ async revoke(raw: string, id: string, user: string): Promise<void> {
136
+ actor(user)
137
+ await this
138
+ .sql`UPDATE project_credentials SET revoked_at = COALESCE(revoked_at, ${new Date().toISOString()}) WHERE repo = ${normalizeProjectRepository(raw)} AND id = ${id}`
139
+ }
140
+ async close(): Promise<void> {
141
+ if (this.ownsPool) await this.sql.close()
142
+ }
143
+ }
144
+ export async function openPostgresCredentialStoreFromEnv(
145
+ env: PostgresEnv,
146
+ ): Promise<CredentialStore> {
147
+ const sql = new SQL(resolvePostgresUrl(env))
148
+ try {
149
+ await assertSchema(sql)
150
+ const projects = new PostgresProjectRegistry(sql)
151
+ await projects.initializeFromEnv(env)
152
+ return new PostgresCredentialStore(sql, projects, true)
153
+ } catch (error) {
154
+ await sql.close()
155
+ throw error
156
+ }
157
+ }
package/src/index.ts CHANGED
@@ -40,3 +40,8 @@ export {
40
40
  export type { PostgresTicketContext, PostgresTicketLifecycle } from './tickets'
41
41
  export { VercelBlobStore } from './vercel'
42
42
  export type { VercelBlobStoreOptions } from './vercel'
43
+ export * from './projects'
44
+ export * from './credentials'
45
+
46
+ export { openPostgresBlobsFromEnv, migratePostgresFromEnv } from './config'
47
+ export * from './ticket-assignments'
@@ -0,0 +1,171 @@
1
+ import { SQL } from 'bun'
2
+ import { normalizeConnectionRepository as normalizeProjectRepository } from '@defrex/autobuild/connection'
3
+ import { resolvePostgresUrl, type PostgresEnv } from './env'
4
+ import { assertSchema, PROJECT_SCHEMA_DDL } from './schema'
5
+
6
+ export interface ProjectEntry {
7
+ repo: string
8
+ addedAt: string
9
+ addedBy: string
10
+ }
11
+ export interface ProjectRegistryReader {
12
+ list(): Promise<ProjectEntry[]>
13
+ has(repo: string): Promise<boolean>
14
+ }
15
+ export interface ProjectRegistry extends ProjectRegistryReader {
16
+ add(repo: string, user: string): Promise<ProjectEntry>
17
+ remove(repo: string, user: string): Promise<void>
18
+ close(): Promise<void>
19
+ }
20
+
21
+ /** Repository identities use the same normalization as checkout origins. */
22
+ export { normalizeConnectionRepository as normalizeProjectRepository } from '@defrex/autobuild/connection'
23
+
24
+ export function legacyProjectRepositories(env: PostgresEnv): string[] {
25
+ const raw = env.AB_WEB_REPOSITORIES?.trim()
26
+ if (!raw) return []
27
+ return [...new Set(raw.split(',').map(normalizeProjectRepository))]
28
+ }
29
+
30
+ function actor(user: string): string {
31
+ const value = user.trim()
32
+ if (!value) throw new Error('project administrator identity must be nonblank')
33
+ return value
34
+ }
35
+ function entry(row: { repo: string; ts: Date | string; actor: string }): ProjectEntry {
36
+ return { repo: row.repo, addedAt: new Date(row.ts).toISOString(), addedBy: row.actor }
37
+ }
38
+
39
+ export class PostgresProjectRegistry implements ProjectRegistry {
40
+ constructor(
41
+ private readonly sql: SQL,
42
+ private readonly ownsPool = false,
43
+ ) {}
44
+
45
+ private async locked<T>(work: (tx: SQL) => Promise<T>): Promise<T> {
46
+ return this.sql.begin(async (tx) => {
47
+ await tx`INSERT INTO project_registry_state (singleton, legacy_imported)
48
+ VALUES (true, false) ON CONFLICT (singleton) DO NOTHING`
49
+ await tx`SELECT singleton FROM project_registry_state WHERE singleton = true FOR UPDATE`
50
+ return work(tx as SQL)
51
+ }) as Promise<T>
52
+ }
53
+
54
+ async initialize(repositories: readonly string[]): Promise<void> {
55
+ if (repositories.length === 0) return
56
+ await this.importOnce(() => repositories)
57
+ }
58
+
59
+ async initializeFromEnv(env: PostgresEnv): Promise<void> {
60
+ await this.importOnce(() => legacyProjectRepositories(env))
61
+ }
62
+
63
+ /** The migration owns this transaction; do not open a nested connection. */
64
+ async initializeFromEnvInTransaction(env: PostgresEnv): Promise<void> {
65
+ await this.sql`INSERT INTO project_registry_state (singleton, legacy_imported)
66
+ VALUES (true, false) ON CONFLICT (singleton) DO NOTHING`
67
+ await this.sql`SELECT singleton FROM project_registry_state WHERE singleton = true FOR UPDATE`
68
+ await this.importIn(this.sql, () => legacyProjectRepositories(env))
69
+ }
70
+
71
+ private async importOnce(source: () => readonly string[]): Promise<void> {
72
+ await this.locked((tx) => this.importIn(tx, source))
73
+ }
74
+
75
+ private async importIn(tx: SQL, source: () => readonly string[]): Promise<void> {
76
+ const [state] =
77
+ await tx`SELECT legacy_imported FROM project_registry_state WHERE singleton = true`
78
+ if (state.legacy_imported) return
79
+ // Read the retired environment only until the one-time import succeeds.
80
+ const normalized = source().map(normalizeProjectRepository)
81
+ if (normalized.length === 0) return
82
+ for (const repo of normalized) await this.addIn(tx, repo, 'system:AB_WEB_REPOSITORIES')
83
+ await tx`UPDATE project_registry_state SET legacy_imported = true WHERE singleton = true`
84
+ }
85
+
86
+ async list(): Promise<ProjectEntry[]> {
87
+ const rows = await this.sql`SELECT repo, actor, ts FROM (
88
+ SELECT DISTINCT ON (repo) repo, action, actor, ts FROM project_journal ORDER BY repo, seq DESC
89
+ ) latest WHERE action = 'added' ORDER BY repo`
90
+ return rows.map(entry)
91
+ }
92
+
93
+ async has(repo: string): Promise<boolean> {
94
+ const [row] = await this
95
+ .sql`SELECT action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
96
+ return row?.action === 'added'
97
+ }
98
+
99
+ private async addIn(tx: SQL, repo: string, user: string): Promise<ProjectEntry> {
100
+ const [previous] =
101
+ await tx`SELECT repo, action, actor, ts FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
102
+ if (previous?.action === 'added') return entry(previous)
103
+ const [row] = await tx`INSERT INTO project_journal (seq, repo, action, actor, ts)
104
+ VALUES (nextval('project_journal_seq'), ${repo}, 'added', ${user}, ${new Date().toISOString()})
105
+ RETURNING repo, actor, ts`
106
+ return entry(row)
107
+ }
108
+
109
+ async add(raw: string, user: string): Promise<ProjectEntry> {
110
+ const repo = normalizeProjectRepository(raw)
111
+ const by = actor(user)
112
+ return this.locked((tx) => this.addIn(tx, repo, by))
113
+ }
114
+
115
+ async remove(raw: string, user: string): Promise<void> {
116
+ const repo = normalizeProjectRepository(raw)
117
+ const by = actor(user)
118
+ await this.locked(async (tx) => {
119
+ const [previous] =
120
+ await tx`SELECT action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
121
+ if (previous?.action !== 'added') return
122
+ await tx`INSERT INTO project_journal (seq, repo, action, actor, ts)
123
+ VALUES (nextval('project_journal_seq'), ${repo}, 'removed', ${by}, ${new Date().toISOString()})`
124
+ })
125
+ }
126
+
127
+ async close(): Promise<void> {
128
+ if (this.ownsPool) await this.sql.close()
129
+ }
130
+ }
131
+
132
+ export async function openPostgresProjectRegistryFromEnv(
133
+ env: PostgresEnv,
134
+ ): Promise<ProjectRegistry> {
135
+ const sql = new SQL(resolvePostgresUrl(env))
136
+ try {
137
+ await assertSchema(sql)
138
+ const registry = new PostgresProjectRegistry(sql, true)
139
+ await registry.initializeFromEnv(env)
140
+ return registry
141
+ } catch (error) {
142
+ await sql.close()
143
+ throw error
144
+ }
145
+ }
146
+
147
+ /** Owner recovery must work while the main-schema promotion is rolled back by
148
+ * a stopped ticket migration. Bootstrap only the independent registry tables. */
149
+ export async function initializeProjectRegistryForRecovery(
150
+ sql: SQL,
151
+ env: PostgresEnv,
152
+ ): Promise<void> {
153
+ await sql.begin(async (tx) => {
154
+ await tx`SELECT pg_advisory_xact_lock(470281941)`
155
+ await tx.unsafe(PROJECT_SCHEMA_DDL)
156
+ await new PostgresProjectRegistry(tx as SQL).initializeFromEnvInTransaction(env)
157
+ })
158
+ }
159
+
160
+ export async function openPostgresProjectRegistryForRecoveryFromEnv(
161
+ env: PostgresEnv,
162
+ ): Promise<ProjectRegistry> {
163
+ const sql = new SQL(resolvePostgresUrl(env))
164
+ try {
165
+ await initializeProjectRegistryForRecovery(sql, env)
166
+ return new PostgresProjectRegistry(sql, true)
167
+ } catch (error) {
168
+ await sql.close()
169
+ throw error
170
+ }
171
+ }