@atomservice/postgres 0.1.5
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/package.json +33 -0
- package/src/index.ts +3 -0
- package/src/postgres.compose.ts +45 -0
- package/src/postgres.consts.ts +1 -0
- package/src/postgres.instance.ts +39 -0
- package/src/postgres.options.ts +66 -0
- package/src/postgres.service.ts +164 -0
- package/src/postgres.types.ts +5 -0
- package/src/postgres.utils.ts +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atomservice/postgres",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "PostgreSQL 原子服务",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "openorson",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/openorson/atomservice.git",
|
|
10
|
+
"directory": "services/postgres"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/openorson/atomservice/issues",
|
|
13
|
+
"homepage": "https://github.com/openorson/atomservice/tree/main/services/postgres#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"atomservice",
|
|
16
|
+
"postgres",
|
|
17
|
+
"postgresql",
|
|
18
|
+
"self-hosted",
|
|
19
|
+
"podman"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.3.14"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@atomservice/core": "0.1.5"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { tmpl } from "@atomservice/core"
|
|
2
|
+
import { POSTGRES_IMAGE } from "./postgres.consts.ts"
|
|
3
|
+
import type { PostgresOptions } from "./postgres.options.ts"
|
|
4
|
+
|
|
5
|
+
export function postgresCompose(
|
|
6
|
+
containerName: string,
|
|
7
|
+
version: string,
|
|
8
|
+
dataDir: string,
|
|
9
|
+
opts: Pick<PostgresOptions, "password" | "hostPort" | "maxConnections" | "sharedBuffers">,
|
|
10
|
+
): string {
|
|
11
|
+
const { password, hostPort, maxConnections, sharedBuffers } = opts
|
|
12
|
+
|
|
13
|
+
const portsBlock = hostPort ? `\n ports:\n - "${hostPort}:5432"` : ""
|
|
14
|
+
|
|
15
|
+
const cmdArgs: string[] = []
|
|
16
|
+
if (maxConnections) cmdArgs.push(`-c max_connections=${maxConnections}`)
|
|
17
|
+
if (sharedBuffers) cmdArgs.push(`-c shared_buffers=${sharedBuffers}`)
|
|
18
|
+
const cmdBlock = cmdArgs.length > 0 ? `\n command: postgres ${cmdArgs.join(" ")}` : ""
|
|
19
|
+
|
|
20
|
+
const envPassword = password ? `\n POSTGRES_PASSWORD: ${password}` : `\n POSTGRES_HOST_AUTH_METHOD: trust`
|
|
21
|
+
|
|
22
|
+
return tmpl`\
|
|
23
|
+
services:
|
|
24
|
+
${containerName}:
|
|
25
|
+
image: ${POSTGRES_IMAGE}:${version}-alpine
|
|
26
|
+
container_name: ${containerName}${cmdBlock}
|
|
27
|
+
environment:${envPassword}
|
|
28
|
+
PGDATA: /var/lib/postgresql/pgdata
|
|
29
|
+
volumes:
|
|
30
|
+
- ${dataDir}:/var/lib/postgresql${portsBlock}
|
|
31
|
+
networks:
|
|
32
|
+
- atomservice
|
|
33
|
+
restart: unless-stopped
|
|
34
|
+
healthcheck:
|
|
35
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
36
|
+
interval: 10s
|
|
37
|
+
timeout: 5s
|
|
38
|
+
retries: 5
|
|
39
|
+
|
|
40
|
+
networks:
|
|
41
|
+
atomservice:
|
|
42
|
+
name: atomservice
|
|
43
|
+
external: true
|
|
44
|
+
`
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const POSTGRES_IMAGE = "postgres"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface PostgresInstance {
|
|
2
|
+
/**
|
|
3
|
+
* 容器 `hostname`
|
|
4
|
+
*/
|
|
5
|
+
readonly host: string
|
|
6
|
+
/**
|
|
7
|
+
* 容器内端口
|
|
8
|
+
*/
|
|
9
|
+
readonly port: 5432
|
|
10
|
+
/**
|
|
11
|
+
* 超级用户名
|
|
12
|
+
*/
|
|
13
|
+
readonly user: "postgres"
|
|
14
|
+
/**
|
|
15
|
+
* 超级用户密码
|
|
16
|
+
*
|
|
17
|
+
* - 未设置密码时为 `undefined`
|
|
18
|
+
*/
|
|
19
|
+
readonly password: string | undefined
|
|
20
|
+
/**
|
|
21
|
+
* 默认数据库名(主库,`options.database` 的第一个)
|
|
22
|
+
*/
|
|
23
|
+
readonly database: string
|
|
24
|
+
/**
|
|
25
|
+
* 获取指定数据库的连接字符串
|
|
26
|
+
*
|
|
27
|
+
* @param dbName 数据库名,默认为 `options.database ?? "postgres"`
|
|
28
|
+
*
|
|
29
|
+
* @example `"postgres://postgres:secret@postgres:5432/myapp"`
|
|
30
|
+
*/
|
|
31
|
+
connectionString(dbName?: string): string
|
|
32
|
+
/**
|
|
33
|
+
* 创建一个新数据库(幂等)
|
|
34
|
+
*
|
|
35
|
+
* @param name 数据库名
|
|
36
|
+
* @returns 该数据库的连接字符串
|
|
37
|
+
*/
|
|
38
|
+
createDatabase(name: string): Promise<string>
|
|
39
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface DatabaseConfig {
|
|
2
|
+
/**
|
|
3
|
+
* 数据库名
|
|
4
|
+
*/
|
|
5
|
+
name: string
|
|
6
|
+
/**
|
|
7
|
+
* 在此数据库中初始化的 PG 扩展列表(幂等,已存在则跳过)
|
|
8
|
+
*
|
|
9
|
+
* - 常用值:`"uuid-ossp"` / `"pgvector"` / `"pg_trgm"` / `"hstore"`
|
|
10
|
+
*/
|
|
11
|
+
extensions?: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PostgresOptions {
|
|
15
|
+
/**
|
|
16
|
+
* 实例标识
|
|
17
|
+
*
|
|
18
|
+
* - 用于区分不同实例
|
|
19
|
+
*
|
|
20
|
+
* @default "default"
|
|
21
|
+
*/
|
|
22
|
+
id?: string
|
|
23
|
+
/**
|
|
24
|
+
* 镜像版本
|
|
25
|
+
*
|
|
26
|
+
* @default "18"
|
|
27
|
+
*/
|
|
28
|
+
version?: string
|
|
29
|
+
/**
|
|
30
|
+
* 超级用户密码
|
|
31
|
+
*
|
|
32
|
+
* - 不填则不设置密码(仅限容器内网访问时可不设)
|
|
33
|
+
*/
|
|
34
|
+
password?: string
|
|
35
|
+
/**
|
|
36
|
+
* 映射到宿主机的端口
|
|
37
|
+
*
|
|
38
|
+
* - 不填则不对外暴露,仅容器网络内访问
|
|
39
|
+
*/
|
|
40
|
+
hostPort?: number
|
|
41
|
+
/**
|
|
42
|
+
* 初始化时自动创建的数据库,第一个为主库。
|
|
43
|
+
*
|
|
44
|
+
* @example "myapp" // 单数据库
|
|
45
|
+
* @example { name: "myapp", extensions: ["uuid-ossp"] } // 单数据库并安装扩展
|
|
46
|
+
* @example [{ name: "myapp" }, { name: "analytics", extensions: ["pg_trgm"] }] // 多数据库
|
|
47
|
+
*/
|
|
48
|
+
database?: string | DatabaseConfig | DatabaseConfig[]
|
|
49
|
+
/**
|
|
50
|
+
* 最大连接数
|
|
51
|
+
*
|
|
52
|
+
* - `Postgres` 默认 `100`,高并发场景建议调高
|
|
53
|
+
*
|
|
54
|
+
* @default 100
|
|
55
|
+
*/
|
|
56
|
+
maxConnections?: number
|
|
57
|
+
/**
|
|
58
|
+
* 共享内存大小
|
|
59
|
+
*
|
|
60
|
+
* - 对查询性能影响最大,建议配为机器 `RAM` 的 `25%`
|
|
61
|
+
* - 支持 `Postgres` 原生格式:`"128mb"` / `"512mb"` / `"1gb"` 等
|
|
62
|
+
*
|
|
63
|
+
* @default "128mb"
|
|
64
|
+
*/
|
|
65
|
+
sharedBuffers?: string
|
|
66
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import type { CallableService } from "@atomservice/core"
|
|
3
|
+
import {
|
|
4
|
+
defineService,
|
|
5
|
+
onDown,
|
|
6
|
+
onHealth,
|
|
7
|
+
onUp,
|
|
8
|
+
UserError,
|
|
9
|
+
useConfig,
|
|
10
|
+
useLogger,
|
|
11
|
+
useShell,
|
|
12
|
+
useState,
|
|
13
|
+
} from "@atomservice/core"
|
|
14
|
+
import { postgresCompose } from "./postgres.compose.ts"
|
|
15
|
+
import type { PostgresInstance } from "./postgres.instance.ts"
|
|
16
|
+
import type { PostgresOptions } from "./postgres.options.ts"
|
|
17
|
+
import type { PostgresState } from "./postgres.types.ts"
|
|
18
|
+
import { normalizeDatabases } from "./postgres.utils.ts"
|
|
19
|
+
|
|
20
|
+
export function postgres(opts: PostgresOptions = {}): CallableService<PostgresInstance> {
|
|
21
|
+
const serviceId = opts.id && opts.id !== "default" ? opts.id : "default"
|
|
22
|
+
const containerName = serviceId !== "default" ? `postgres-${serviceId}` : "postgres"
|
|
23
|
+
const version = opts.version ?? "18"
|
|
24
|
+
|
|
25
|
+
return defineService({
|
|
26
|
+
name: "postgres",
|
|
27
|
+
id: opts.id,
|
|
28
|
+
setup: () => {
|
|
29
|
+
const logger = useLogger()
|
|
30
|
+
const $ = useShell()
|
|
31
|
+
const config = useConfig()
|
|
32
|
+
const dir = path.join(config.root, containerName)
|
|
33
|
+
const composePath = path.join(dir, "compose.yml")
|
|
34
|
+
|
|
35
|
+
const { save, watch, checkChanges } = useState<PostgresState>()
|
|
36
|
+
|
|
37
|
+
watch("version", {
|
|
38
|
+
risk: "low",
|
|
39
|
+
changed: (previousValue, currentValue) =>
|
|
40
|
+
`Postgres 版本已变更 ${previousValue} → ${currentValue},将在下次启动时拉取新镜像`,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
watch("password", {
|
|
44
|
+
risk: "high",
|
|
45
|
+
added: (currentValue) =>
|
|
46
|
+
`已新增密码认证,已有数据目录不会自动更新,需手动执行 ALTER USER postgres WITH PASSWORD '${currentValue}'`,
|
|
47
|
+
removed: () => `已移除密码认证,已有数据目录没有自动切换为 trust 模式,需删除数据目录重新初始化`,
|
|
48
|
+
changed: (_, currentValue) => `密码已变更,需手动执行 ALTER USER postgres WITH PASSWORD '${currentValue}'`,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
watch("primaryDatabase", {
|
|
52
|
+
risk: "high",
|
|
53
|
+
changed: (previousValue, currentValue) =>
|
|
54
|
+
`主数据库名已变更 ${previousValue} → ${currentValue},旧库数据不会自动迁移`,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
onUp(async () => {
|
|
58
|
+
const databases = normalizeDatabases(opts.database)
|
|
59
|
+
const primaryDb = databases[0]
|
|
60
|
+
const currentState: PostgresState = {
|
|
61
|
+
version,
|
|
62
|
+
password: opts.password,
|
|
63
|
+
primaryDatabase: primaryDb?.name,
|
|
64
|
+
}
|
|
65
|
+
await checkChanges(currentState)
|
|
66
|
+
|
|
67
|
+
await $`mkdir -p ${dir}/data`
|
|
68
|
+
await Bun.write(composePath, postgresCompose(containerName, version, `${dir}/data`, opts))
|
|
69
|
+
|
|
70
|
+
logger.info(`正在启动 ${containerName}…`)
|
|
71
|
+
await $`podman compose -f ${composePath} up -d`
|
|
72
|
+
|
|
73
|
+
logger.info(`等待 ${containerName} 就绪`)
|
|
74
|
+
// 初始化阶段 Postgres 会先启动临时服务再重启正式服务,
|
|
75
|
+
// 因此用真实查询确认就绪,避免命中临时服务导致后续连接被中断
|
|
76
|
+
let ready = false
|
|
77
|
+
for (let i = 0; i < 60; i++) {
|
|
78
|
+
const probe = await $`podman exec ${containerName} psql -U postgres -tAc ${"SELECT 1"}`.quiet().nothrow()
|
|
79
|
+
if (probe.exitCode === 0 && probe.stdout.toString().trim() === "1") {
|
|
80
|
+
ready = true
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
await Bun.sleep(1000)
|
|
84
|
+
}
|
|
85
|
+
if (!ready) {
|
|
86
|
+
throw new UserError(`${containerName} 启动超时,未能在 60 秒内就绪`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const db of databases) {
|
|
90
|
+
const exists =
|
|
91
|
+
await $`podman exec ${containerName} psql -U postgres -tAc ${`SELECT 1 FROM pg_database WHERE datname='${db.name}'`}`.text()
|
|
92
|
+
if (exists.trim() !== "1") {
|
|
93
|
+
await $`podman exec ${containerName} psql -U postgres -c ${`CREATE DATABASE "${db.name}"`}`
|
|
94
|
+
logger.success(`数据库 "${db.name}" 已创建`)
|
|
95
|
+
}
|
|
96
|
+
for (const ext of db.extensions ?? []) {
|
|
97
|
+
await $`podman exec ${containerName} psql -U postgres -d ${db.name} -c ${`CREATE EXTENSION IF NOT EXISTS "${ext}"`}`
|
|
98
|
+
logger.success(`扩展 "${ext}" 已就绪`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
logger.success(`${containerName} 已启动`)
|
|
103
|
+
await save(currentState)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
onDown(async () => {
|
|
107
|
+
logger.info(`正在停止 ${containerName}`)
|
|
108
|
+
await $`podman compose -f ${composePath} down`
|
|
109
|
+
logger.success(`${containerName} 已停止`)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
onHealth(async () => {
|
|
113
|
+
try {
|
|
114
|
+
const result = await $`podman inspect --format {{.State.Health.Status}} ${containerName}`.text()
|
|
115
|
+
const status = result.trim()
|
|
116
|
+
if (status === "healthy") return { status: "healthy" }
|
|
117
|
+
return { status: "unhealthy", message: `容器健康状态:${status}` }
|
|
118
|
+
} catch {
|
|
119
|
+
return {
|
|
120
|
+
status: "unhealthy",
|
|
121
|
+
message: `容器 ${containerName} 不存在`,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const databases = normalizeDatabases(opts.database)
|
|
127
|
+
const primaryDb = databases[0]
|
|
128
|
+
const auth = opts.password ? `postgres:${opts.password}` : "postgres"
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
get host() {
|
|
132
|
+
return containerName
|
|
133
|
+
},
|
|
134
|
+
get port(): 5432 {
|
|
135
|
+
return 5432
|
|
136
|
+
},
|
|
137
|
+
get user(): "postgres" {
|
|
138
|
+
return "postgres"
|
|
139
|
+
},
|
|
140
|
+
get password() {
|
|
141
|
+
return opts.password
|
|
142
|
+
},
|
|
143
|
+
get database(): string {
|
|
144
|
+
if (!primaryDb) throw new Error(`[postgres] 未配置 database,请在选项中指定`)
|
|
145
|
+
return primaryDb.name
|
|
146
|
+
},
|
|
147
|
+
connectionString(dbName?: string): string {
|
|
148
|
+
const name = dbName ?? (primaryDb ? primaryDb.name : undefined)
|
|
149
|
+
if (!name) throw new Error(`[postgres] 未配置 database,请传入数据库名或在选项中指定`)
|
|
150
|
+
return `postgres://${auth}@${containerName}:5432/${name}`
|
|
151
|
+
},
|
|
152
|
+
async createDatabase(name: string): Promise<string> {
|
|
153
|
+
const exists =
|
|
154
|
+
await $`podman exec ${containerName} psql -U postgres -tAc ${`SELECT 1 FROM pg_database WHERE datname='${name}'`}`.text()
|
|
155
|
+
if (exists.trim() !== "1") {
|
|
156
|
+
await $`podman exec ${containerName} psql -U postgres -c ${`CREATE DATABASE "${name}"`}`
|
|
157
|
+
logger.success(`数据库 "${name}" 已创建`)
|
|
158
|
+
}
|
|
159
|
+
return `postgres://${auth}@${containerName}:5432/${name}`
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DatabaseConfig, PostgresOptions } from "./postgres.options.ts"
|
|
2
|
+
|
|
3
|
+
export function normalizeDatabases(database: PostgresOptions["database"]): DatabaseConfig[] {
|
|
4
|
+
if (!database) return []
|
|
5
|
+
if (typeof database === "string") return [{ name: database }]
|
|
6
|
+
if (Array.isArray(database)) return database
|
|
7
|
+
return [database]
|
|
8
|
+
}
|