@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 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,3 @@
1
+ export type { PostgresInstance } from "./postgres.instance.ts"
2
+ export type { DatabaseConfig, PostgresOptions } from "./postgres.options.ts"
3
+ export { postgres } from "./postgres.service.ts"
@@ -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,5 @@
1
+ export type PostgresState = {
2
+ version: string
3
+ password: string | undefined
4
+ primaryDatabase: string | undefined
5
+ }
@@ -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
+ }