@owlmeans/postgres 0.1.15
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/LICENSE +21 -0
- package/README.md +139 -0
- package/agent-meta/instructions/postgres.instructions.md +57 -0
- package/agent-meta/manifest.json +23 -0
- package/agent-meta/skills/postgres/SKILL.md +121 -0
- package/build/bootstrap.d.ts +16 -0
- package/build/bootstrap.d.ts.map +1 -0
- package/build/bootstrap.js +106 -0
- package/build/bootstrap.js.map +1 -0
- package/build/consts.d.ts +29 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +30 -0
- package/build/consts.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/build/middleware.d.ts +11 -0
- package/build/middleware.d.ts.map +1 -0
- package/build/middleware.js +21 -0
- package/build/middleware.js.map +1 -0
- package/build/service.d.ts +9 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +221 -0
- package/build/service.js.map +1 -0
- package/build/types.d.ts +40 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/config.d.ts +27 -0
- package/build/utils/config.d.ts.map +1 -0
- package/build/utils/config.js +96 -0
- package/build/utils/config.js.map +1 -0
- package/build/utils/connection.d.ts +15 -0
- package/build/utils/connection.d.ts.map +1 -0
- package/build/utils/connection.js +56 -0
- package/build/utils/connection.js.map +1 -0
- package/package.json +45 -0
- package/src/bootstrap.ts +126 -0
- package/src/consts.ts +36 -0
- package/src/index.ts +7 -0
- package/src/middleware.ts +26 -0
- package/src/service.ts +287 -0
- package/src/types.ts +42 -0
- package/src/utils/config.ts +109 -0
- package/src/utils/connection.ts +64 -0
- package/tests/bootstrap.spec.ts +142 -0
- package/tests/context.ts +204 -0
- package/tests/crud.spec.ts +227 -0
- package/tests/custom-sql.spec.ts +196 -0
- package/tests/migration.spec.ts +260 -0
- package/tests/sync.spec.ts +156 -0
- package/tsconfig.json +16 -0
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertSqlIdentifier, PostgresBootstrapError, quoteIdent, quoteLiteral
|
|
3
|
+
} from '@owlmeans/postgres-resource'
|
|
4
|
+
import { Pool } from 'pg'
|
|
5
|
+
import type { PoolClient } from 'pg'
|
|
6
|
+
|
|
7
|
+
import type { BootstrapOptions, BootstrapReport, PostgresService } from './types.js'
|
|
8
|
+
import { prepareConfig } from './utils/config.js'
|
|
9
|
+
|
|
10
|
+
const exists = async (client: PoolClient, text: string, value: string): Promise<boolean> => {
|
|
11
|
+
const result = await client.query(text, [value])
|
|
12
|
+
|
|
13
|
+
return result.rowCount != null && result.rowCount > 0
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Provision a least privileged role, its database and its schema.
|
|
18
|
+
*
|
|
19
|
+
* This is the one copy of a script every OwlMeans deployment used to carry inline. It runs
|
|
20
|
+
* on every start and every rebuild, so each step probes before it acts — "already exists"
|
|
21
|
+
* is the expected outcome, not an error.
|
|
22
|
+
*
|
|
23
|
+
* Two connections are unavoidable. `CREATE DATABASE` cannot run from inside the database
|
|
24
|
+
* it creates, so roles and databases are made over the maintenance connection the admin
|
|
25
|
+
* config points at, and the grants are applied over a short lived connection to the target.
|
|
26
|
+
*
|
|
27
|
+
* @throws {PostgresBootstrapError}
|
|
28
|
+
*/
|
|
29
|
+
export const bootstrapDb = async (
|
|
30
|
+
service: PostgresService, configAlias: string, opts: BootstrapOptions
|
|
31
|
+
): Promise<BootstrapReport> => {
|
|
32
|
+
const role = assertSqlIdentifier(opts.role, 'role')
|
|
33
|
+
const database = assertSqlIdentifier(opts.database ?? opts.role, 'database')
|
|
34
|
+
const schema = opts.schema != null ? assertSqlIdentifier(opts.schema, 'schema') : null
|
|
35
|
+
const leastPrivilege = opts.leastPrivilege ?? true
|
|
36
|
+
const rotate = opts.rotatePassword ?? true
|
|
37
|
+
|
|
38
|
+
if (opts.password === '') {
|
|
39
|
+
throw new PostgresBootstrapError(`empty-password:${role}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const report: BootstrapReport = {
|
|
43
|
+
roleCreated: false,
|
|
44
|
+
passwordRotated: false,
|
|
45
|
+
databaseCreated: false,
|
|
46
|
+
schemaCreated: false,
|
|
47
|
+
grantsApplied: false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const config = service.config(configAlias)
|
|
51
|
+
const admin = await service.client(configAlias)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The password is the only value here that can't be bound: `CREATE ROLE` takes no
|
|
55
|
+
* parameters, so it reaches the server inside the statement text. Identifiers have
|
|
56
|
+
* already been asserted safe; the literal is escaped.
|
|
57
|
+
*/
|
|
58
|
+
const secret = quoteLiteral(opts.password)
|
|
59
|
+
|
|
60
|
+
const client = await admin.connect()
|
|
61
|
+
try {
|
|
62
|
+
if (await exists(client, 'SELECT 1 FROM pg_roles WHERE rolname = $1', role)) {
|
|
63
|
+
if (rotate) {
|
|
64
|
+
await client.query(`ALTER ROLE ${quoteIdent(role)} WITH LOGIN PASSWORD ${secret}`)
|
|
65
|
+
report.passwordRotated = true
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
await client.query(`CREATE ROLE ${quoteIdent(role)} WITH LOGIN PASSWORD ${secret}`)
|
|
69
|
+
report.roleCreated = true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!await exists(client, 'SELECT 1 FROM pg_database WHERE datname = $1', database)) {
|
|
73
|
+
/** Never inside a transaction — Postgres refuses `CREATE DATABASE` in one. */
|
|
74
|
+
await client.query(`CREATE DATABASE ${quoteIdent(database)} OWNER ${quoteIdent(role)}`)
|
|
75
|
+
report.databaseCreated = true
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw wrap(error, `${role}@${database}`)
|
|
79
|
+
} finally {
|
|
80
|
+
client.release()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const target = new Pool(prepareConfig(config, { database, max: 1 }))
|
|
84
|
+
try {
|
|
85
|
+
if (schema != null) {
|
|
86
|
+
/**
|
|
87
|
+
* The resource layer creates its own schema too, but only once an application with
|
|
88
|
+
* resources boots. Creating it here — owned by the role — is what lets that
|
|
89
|
+
* application connect as a role with no `CREATE` right on the database at all.
|
|
90
|
+
*/
|
|
91
|
+
await target.query(
|
|
92
|
+
`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)} AUTHORIZATION ${quoteIdent(role)}`
|
|
93
|
+
)
|
|
94
|
+
await target.query(`GRANT ALL ON SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(role)}`)
|
|
95
|
+
await target.query(`ALTER ROLE ${quoteIdent(role)} SET search_path = ${quoteIdent(schema)}`)
|
|
96
|
+
report.schemaCreated = true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (leastPrivilege) {
|
|
100
|
+
await target.query(`REVOKE CONNECT ON DATABASE ${quoteIdent(database)} FROM PUBLIC`)
|
|
101
|
+
await target.query('REVOKE CREATE ON SCHEMA public FROM PUBLIC')
|
|
102
|
+
await target.query(`GRANT CONNECT ON DATABASE ${quoteIdent(database)} TO ${quoteIdent(role)}`)
|
|
103
|
+
report.grantsApplied = true
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw wrap(error, `${role}@${database}`)
|
|
107
|
+
} finally {
|
|
108
|
+
await target.end().catch(() => undefined)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(
|
|
112
|
+
`@owlmeans/postgres: bootstrap ${role}@${database} —`
|
|
113
|
+
+ ` role ${report.roleCreated ? 'created' : report.passwordRotated ? 'rotated' : 'present'},`
|
|
114
|
+
+ ` database ${report.databaseCreated ? 'created' : 'present'}`
|
|
115
|
+
+ (schema != null ? `, schema ${schema} ready` : '')
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return report
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const wrap = (error: unknown, subject: string): Error => {
|
|
122
|
+
const failure = new PostgresBootstrapError(`${subject}:${(error as Error)?.message ?? error}`)
|
|
123
|
+
failure.cause = error
|
|
124
|
+
|
|
125
|
+
return failure
|
|
126
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DEFAULT_DB_ALIAS } from '@owlmeans/postgres-resource'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_ALIAS = DEFAULT_DB_ALIAS
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Config alias conventionally holding the superuser connection the admin path needs.
|
|
7
|
+
* Never the same as {@link DEFAULT_ALIAS} — the application connects as a least
|
|
8
|
+
* privileged role and must not carry superuser credentials in the same config entry.
|
|
9
|
+
*/
|
|
10
|
+
export const DEF_ADMIN_ALIAS = 'pg-admin'
|
|
11
|
+
|
|
12
|
+
/** The database `CREATE DATABASE` has to be issued from, since it can't create the one it's in. */
|
|
13
|
+
export const DEF_MAINTENANCE_DB = 'postgres'
|
|
14
|
+
|
|
15
|
+
export const DEF_PORT = 5432
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `SELECT 1` readiness probe, generalized from the boot loop every OwlMeans deployment
|
|
19
|
+
* hand-rolled: a Postgres sidecar routinely accepts TCP before it accepts queries.
|
|
20
|
+
*/
|
|
21
|
+
export const DEF_RETRIES = 30
|
|
22
|
+
export const DEF_RETRY_DELAY = 2000
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Postgres caps connections cluster wide (`max_connections`, 100 by default), so the
|
|
26
|
+
* per-process pool stays deliberately small — unlike a Mongo driver pool, an oversized
|
|
27
|
+
* one here starves every other client of the same server.
|
|
28
|
+
*/
|
|
29
|
+
export const DEF_POOL_SIZE = 10
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Failures the readiness probe treats as final rather than as "not up yet":
|
|
33
|
+
* `28P01`/`28000` wrong credentials, `3D000` no such database, `42501` no rights.
|
|
34
|
+
* Retrying any of them just delays the same error by a minute.
|
|
35
|
+
*/
|
|
36
|
+
export const TERMINAL_CONNECT_CODES = ['28P01', '28000', '3D000', '42501']
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MiddlewareStage, MiddlewareType } from '@owlmeans/context'
|
|
2
|
+
import type { Middleware } from '@owlmeans/context'
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_ALIAS } from './consts.js'
|
|
5
|
+
import type { PostgresService } from './types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run the work resources held back until every one of them had initialized.
|
|
9
|
+
*
|
|
10
|
+
* Foreign keys are the reason this exists: a key points at a table another resource owns,
|
|
11
|
+
* and resource initialization order is registration order, not dependency order. The
|
|
12
|
+
* Loading stage of the Context middleware type runs immediately after the last resource's
|
|
13
|
+
* `init()` — the first moment at which every table is known to exist.
|
|
14
|
+
*/
|
|
15
|
+
export const drainMiddleware = (alias: string = DEFAULT_ALIAS): Middleware => ({
|
|
16
|
+
type: MiddlewareType.Context,
|
|
17
|
+
stage: MiddlewareStage.Loading,
|
|
18
|
+
|
|
19
|
+
apply: async context => {
|
|
20
|
+
if (!context.hasService(alias)) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await context.service<PostgresService>(alias).drain()
|
|
25
|
+
}
|
|
26
|
+
})
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { makeKeyPairModel } from '@owlmeans/basic-keys'
|
|
2
|
+
import { assertContext, Layer } from '@owlmeans/context'
|
|
3
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
4
|
+
import {
|
|
5
|
+
makeTx, pgErrorToResourceError, pgIdentifier, refOf, resolvePlaceholders
|
|
6
|
+
} from '@owlmeans/postgres-resource'
|
|
7
|
+
import type { PostgresDb, PostgresMeta } from '@owlmeans/postgres-resource'
|
|
8
|
+
import { createDbService } from '@owlmeans/resource'
|
|
9
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
10
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
11
|
+
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
|
12
|
+
import { Pool } from 'pg'
|
|
13
|
+
import type { QueryResultRow } from 'pg'
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_ALIAS } from './consts.js'
|
|
16
|
+
import { bootstrapDb } from './bootstrap.js'
|
|
17
|
+
import { drainMiddleware } from './middleware.js'
|
|
18
|
+
import type { PostgresService } from './types.js'
|
|
19
|
+
import { poolDatabase, prepareConfig } from './utils/config.js'
|
|
20
|
+
import { ensureSchema, probe } from './utils/connection.js'
|
|
21
|
+
|
|
22
|
+
type Config = ServerConfig
|
|
23
|
+
interface Context<C extends Config = Config> extends ServerContext<C> { }
|
|
24
|
+
|
|
25
|
+
export const makePostgresDbService = (alias: string = DEFAULT_ALIAS): PostgresService => {
|
|
26
|
+
const location = `postgres:${alias}`
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Handles are cached per config alias rather than rebuilt per call: `db()` runs on every
|
|
30
|
+
* single resource operation, and both the Drizzle binding and the schema name are fixed
|
|
31
|
+
* for the lifetime of a service instance — a layer switch produces a *new* instance.
|
|
32
|
+
*/
|
|
33
|
+
const handles: Map<string, PostgresDb> = new Map()
|
|
34
|
+
const deferred: Map<string, Array<() => Promise<void>>> = new Map()
|
|
35
|
+
|
|
36
|
+
const service: PostgresService = createDbService<PostgresDb, Pool, PostgresService>(alias, {
|
|
37
|
+
db: async configAlias => {
|
|
38
|
+
const pool = await service.client(configAlias)
|
|
39
|
+
configAlias = service.ensureConfigAlias(configAlias)
|
|
40
|
+
|
|
41
|
+
let handle = handles.get(configAlias)
|
|
42
|
+
if (handle == null) {
|
|
43
|
+
handle = {
|
|
44
|
+
drizzle: drizzle(pool) as NodePgDatabase<Record<string, never>>,
|
|
45
|
+
pool,
|
|
46
|
+
/**
|
|
47
|
+
* `name()` is the layer aware namespace — the Postgres SCHEMA here, where Mongo
|
|
48
|
+
* reads the same value as a database name. It can outgrow 63 bytes once a layer
|
|
49
|
+
* suffix is appended, which Postgres would truncate silently.
|
|
50
|
+
*/
|
|
51
|
+
schema: pgIdentifier(service.name(configAlias)),
|
|
52
|
+
database: poolDatabase(pool)
|
|
53
|
+
}
|
|
54
|
+
handles.set(configAlias, handle)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return handle
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
initialize: async configAlias => {
|
|
61
|
+
configAlias = service.ensureConfigAlias(configAlias)
|
|
62
|
+
const config = service.config(configAlias)
|
|
63
|
+
|
|
64
|
+
if (service.clients[configAlias] != null) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (service.layers == null) {
|
|
69
|
+
service.layers = [Layer.Global]
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Sensitivity *adds* a layer the service is willing to serve. The Mongo copy of this
|
|
73
|
+
* block tests `includes` where it means `!includes`, so it only ever appends a layer
|
|
74
|
+
* that is already there — a latent no-op, corrected here.
|
|
75
|
+
*/
|
|
76
|
+
if (config.serviceSensitive === true && !service.layers.includes(Layer.Service)) {
|
|
77
|
+
service.layers.push(Layer.Service)
|
|
78
|
+
}
|
|
79
|
+
if (config.entitySensitive === true && !service.layers.includes(Layer.Entity)) {
|
|
80
|
+
service.layers.push(Layer.Entity)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const meta = (config.meta ?? {}) as PostgresMeta
|
|
84
|
+
const pool = new Pool(prepareConfig(config))
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Not optional: node-postgres emits `error` on *idle* clients when the server closes
|
|
88
|
+
* a connection — a routine event behind a load balancer — and an unhandled `error`
|
|
89
|
+
* on an EventEmitter terminates the process.
|
|
90
|
+
*/
|
|
91
|
+
pool.on('error', error => {
|
|
92
|
+
console.error(`${location}: idle client error — ${(error as Error).message}`)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await probe(pool, meta, location)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await pool.end().catch(() => undefined)
|
|
99
|
+
throw error
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
process.on('SIGTERM', () => { void pool.end().catch(() => undefined) })
|
|
103
|
+
|
|
104
|
+
if (service.clients[configAlias] != null) {
|
|
105
|
+
await pool.end().catch(() => undefined)
|
|
106
|
+
throw new SyntaxError(`Cannot replace existing postgres client: ${configAlias} - ${service.alias}`)
|
|
107
|
+
}
|
|
108
|
+
service.clients[configAlias] = pool
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Only when the config names one. Resources create their own schema anyway, and the
|
|
112
|
+
* admin connection points at the maintenance database — where inventing a namespace
|
|
113
|
+
* from the service alias would leave an empty `postgres` schema behind on every boot.
|
|
114
|
+
*/
|
|
115
|
+
if (config.schema != null) {
|
|
116
|
+
await ensureSchema(pool, pgIdentifier(service.name(configAlias)))
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `configAlias` is part of the contract for symmetry only — a resource's qualified
|
|
122
|
+
* name comes from the table spec it compiled at init, which already resolved against
|
|
123
|
+
* whichever config that resource was bound to.
|
|
124
|
+
*/
|
|
125
|
+
qualify: resourceAlias => {
|
|
126
|
+
const context = assertContext<Config, Context>(service.ctx as Context, location)
|
|
127
|
+
|
|
128
|
+
return refOf(context as unknown as BasicContext<any>, null, resourceAlias)
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
query: async <Row extends QueryResultRow = QueryResultRow>(
|
|
132
|
+
text: string, params?: unknown[], configAlias?: string
|
|
133
|
+
) => {
|
|
134
|
+
const db = await service.db(configAlias)
|
|
135
|
+
const context = assertContext<Config, Context>(service.ctx as Context, location)
|
|
136
|
+
try {
|
|
137
|
+
const result = await db.pool.query<Row>(
|
|
138
|
+
resolvePlaceholders(text, context as unknown as BasicContext<any>, null), params as never[]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return result.rows
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw pgErrorToResourceError(error)
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
transaction: async (fn, configAlias) => {
|
|
148
|
+
const db = await service.db(configAlias)
|
|
149
|
+
const context = assertContext<Config, Context>(service.ctx as Context, location)
|
|
150
|
+
const client = await db.pool.connect()
|
|
151
|
+
try {
|
|
152
|
+
await client.query('BEGIN')
|
|
153
|
+
const result = await fn(makeTx(
|
|
154
|
+
client,
|
|
155
|
+
text => resolvePlaceholders(text, context as unknown as BasicContext<any>, null),
|
|
156
|
+
resourceAlias => refOf(context as unknown as BasicContext<any>, null, resourceAlias)
|
|
157
|
+
))
|
|
158
|
+
await client.query('COMMIT')
|
|
159
|
+
|
|
160
|
+
return result
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await client.query('ROLLBACK').catch(() => undefined)
|
|
163
|
+
throw pgErrorToResourceError(error)
|
|
164
|
+
} finally {
|
|
165
|
+
client.release()
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
defer: (configAlias, task) => {
|
|
170
|
+
configAlias = service.ensureConfigAlias(configAlias)
|
|
171
|
+
const tasks = deferred.get(configAlias)
|
|
172
|
+
if (tasks == null) {
|
|
173
|
+
deferred.set(configAlias, [task])
|
|
174
|
+
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
tasks.push(task)
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
drain: async configAlias => {
|
|
181
|
+
const aliases = configAlias != null
|
|
182
|
+
? [service.ensureConfigAlias(configAlias)]
|
|
183
|
+
: [...deferred.keys()]
|
|
184
|
+
|
|
185
|
+
for (const key of aliases) {
|
|
186
|
+
const tasks = deferred.get(key)
|
|
187
|
+
if (tasks == null) {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Cleared before running, not after: a task that throws has still had its side
|
|
192
|
+
* effects, and a later drain replaying it would apply them twice.
|
|
193
|
+
*/
|
|
194
|
+
deferred.delete(key)
|
|
195
|
+
for (const task of tasks) {
|
|
196
|
+
await task()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
bootstrap: async (configAlias, opts) => bootstrapDb(service, configAlias, opts),
|
|
202
|
+
|
|
203
|
+
lock: async (configAlias, record, fields) => {
|
|
204
|
+
configAlias = service.ensureConfigAlias(configAlias)
|
|
205
|
+
const config = service.config(configAlias)
|
|
206
|
+
if (config.encryptionKey == null) {
|
|
207
|
+
throw new SyntaxError(`No encryption key for locking: ${configAlias}`)
|
|
208
|
+
}
|
|
209
|
+
if ((fields.length ?? 0) < 1) {
|
|
210
|
+
throw new SyntaxError(`No fields to lock: ${JSON.stringify(record)}`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const key = makeKeyPairModel(config.encryptionKey)
|
|
214
|
+
|
|
215
|
+
return Object.fromEntries(
|
|
216
|
+
await Promise.all(Object.entries(record).map(
|
|
217
|
+
async ([field, value]) => [
|
|
218
|
+
field,
|
|
219
|
+
fields.includes(field) ? await key.encrypt(value) : value
|
|
220
|
+
]
|
|
221
|
+
))
|
|
222
|
+
)
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
unlock: async (configAlias, record, fields) => {
|
|
226
|
+
configAlias = service.ensureConfigAlias(configAlias)
|
|
227
|
+
const config = service.config(configAlias)
|
|
228
|
+
if (config.encryptionKey == null) {
|
|
229
|
+
throw new SyntaxError(`No encryption key for unlocking: ${configAlias}`)
|
|
230
|
+
}
|
|
231
|
+
if ((fields.length ?? 0) < 1) {
|
|
232
|
+
throw new SyntaxError(`No fields to unlock: ${JSON.stringify(record)}`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const key = makeKeyPairModel(config.encryptionKey)
|
|
236
|
+
|
|
237
|
+
return Object.fromEntries(
|
|
238
|
+
await Promise.all(Object.entries(record).map(
|
|
239
|
+
async ([field, value]) => [
|
|
240
|
+
field,
|
|
241
|
+
/** A value that was never locked decrypts to itself rather than failing. */
|
|
242
|
+
fields.includes(field) ? await key.decrypt(value).catch(() => value) : value
|
|
243
|
+
]
|
|
244
|
+
))
|
|
245
|
+
)
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
reinitializeContext: <T>(context: BasicContext<ServerConfig>) => {
|
|
249
|
+
const _service = makePostgresDbService(alias)
|
|
250
|
+
|
|
251
|
+
_service.ctx = context
|
|
252
|
+
_service.layers = service.layers
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Pools carry over instead of being reopened. A layer switch changes the Postgres
|
|
256
|
+
* *schema*, not the server, and `max_connections` is a cluster wide budget — a pool
|
|
257
|
+
* per tenant exhausts it long before the tenants run out. The handle cache is
|
|
258
|
+
* deliberately *not* carried: its schema name is what the switch just changed.
|
|
259
|
+
*/
|
|
260
|
+
Object.assign(_service.clients, service.clients)
|
|
261
|
+
|
|
262
|
+
return _service as T
|
|
263
|
+
}
|
|
264
|
+
}, service => async () => {
|
|
265
|
+
const context = assertContext<Config, Context>(service.ctx as Context, location)
|
|
266
|
+
|
|
267
|
+
await context.cfg.dbs?.filter(dbConfig => dbConfig.service === alias).reduce(async (prev, dbConfig) => {
|
|
268
|
+
await prev
|
|
269
|
+
service.config(dbConfig.alias)
|
|
270
|
+
}, Promise.resolve())
|
|
271
|
+
|
|
272
|
+
service.initialized = true
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
return service
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const appendPostgres = <C extends Config, T extends Context<C> = Context<C>>(
|
|
279
|
+
context: T, alias: string = DEFAULT_ALIAS
|
|
280
|
+
): T => {
|
|
281
|
+
const service = makePostgresDbService(alias)
|
|
282
|
+
|
|
283
|
+
context.registerService(service)
|
|
284
|
+
context.registerMiddleware(drainMiddleware(alias))
|
|
285
|
+
|
|
286
|
+
return context
|
|
287
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PostgresDbService } from '@owlmeans/postgres-resource'
|
|
2
|
+
|
|
3
|
+
export interface PostgresService extends PostgresDbService {
|
|
4
|
+
/**
|
|
5
|
+
* Provision a least privileged role, its database and its schema, using a superuser
|
|
6
|
+
* connection held under a *separate* config alias.
|
|
7
|
+
*
|
|
8
|
+
* Idempotent by design — every OwlMeans deployment calls it on each start and each
|
|
9
|
+
* rebuild, so it probes before it creates and never fails on "already exists".
|
|
10
|
+
*
|
|
11
|
+
* @throws {PostgresBootstrapError}
|
|
12
|
+
*/
|
|
13
|
+
bootstrap: (configAlias: string, opts: BootstrapOptions) => Promise<BootstrapReport>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BootstrapOptions {
|
|
17
|
+
/** Login role to create, or whose password to rotate. */
|
|
18
|
+
role: string
|
|
19
|
+
password: string
|
|
20
|
+
/** Database to create, owned by `role`. Defaults to the role name. */
|
|
21
|
+
database?: string
|
|
22
|
+
/** Schema to create inside it, owned by `role` and set as its `search_path`. */
|
|
23
|
+
schema?: string
|
|
24
|
+
/**
|
|
25
|
+
* Revoke `PUBLIC`'s rights on the database and on `public`, granting them to `role`
|
|
26
|
+
* alone. Defaults to `true` — the point of the admin path is least privilege.
|
|
27
|
+
*/
|
|
28
|
+
leastPrivilege?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Reset the password of a role that already exists. Defaults to `true`, because the
|
|
31
|
+
* caller generated the password it just passed in and the role has to accept it.
|
|
32
|
+
*/
|
|
33
|
+
rotatePassword?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BootstrapReport {
|
|
37
|
+
roleCreated: boolean
|
|
38
|
+
passwordRotated: boolean
|
|
39
|
+
databaseCreated: boolean
|
|
40
|
+
schemaCreated: boolean
|
|
41
|
+
grantsApplied: boolean
|
|
42
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { PostgresConnectionError } from '@owlmeans/postgres-resource'
|
|
2
|
+
import type { PostgresMeta } from '@owlmeans/postgres-resource'
|
|
3
|
+
import type { DbConfig } from '@owlmeans/resource'
|
|
4
|
+
import type { PoolConfig } from 'pg'
|
|
5
|
+
|
|
6
|
+
import { DEF_POOL_SIZE, DEF_PORT } from '../consts.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Split a `postgres://` URL into pool fields.
|
|
10
|
+
*
|
|
11
|
+
* node-postgres accepts a `connectionString` directly, but the parsed values *override*
|
|
12
|
+
* every sibling field — so `{ connectionString, database }` silently ignores the
|
|
13
|
+
* database. Parsing here instead keeps overriding possible, which the admin path needs
|
|
14
|
+
* to reach both the maintenance database and the target one over the same credentials.
|
|
15
|
+
*
|
|
16
|
+
* @throws {PostgresConnectionError}
|
|
17
|
+
*/
|
|
18
|
+
export const parseUrl = (url: string): PoolConfig => {
|
|
19
|
+
let parsed: URL
|
|
20
|
+
try {
|
|
21
|
+
parsed = new URL(url)
|
|
22
|
+
} catch {
|
|
23
|
+
throw new PostgresConnectionError('malformed-url')
|
|
24
|
+
}
|
|
25
|
+
if (!/^postgres(ql)?:$/.test(parsed.protocol)) {
|
|
26
|
+
throw new PostgresConnectionError(`unexpected-protocol:${parsed.protocol.replace(':', '')}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const database = decodeURIComponent(parsed.pathname.replace(/^\//, ''))
|
|
30
|
+
/** IPv6 literals arrive bracketed from `URL`; `pg` wants the bare address. */
|
|
31
|
+
const host = decodeURIComponent(parsed.hostname).replace(/^\[|\]$/g, '')
|
|
32
|
+
const mode = parsed.searchParams.get('sslmode')
|
|
33
|
+
|
|
34
|
+
const config: PoolConfig = {
|
|
35
|
+
host,
|
|
36
|
+
port: parsed.port !== '' ? parseInt(parsed.port, 10) : DEF_PORT
|
|
37
|
+
}
|
|
38
|
+
if (parsed.username !== '') {
|
|
39
|
+
config.user = decodeURIComponent(parsed.username)
|
|
40
|
+
}
|
|
41
|
+
if (parsed.password !== '') {
|
|
42
|
+
config.password = decodeURIComponent(parsed.password)
|
|
43
|
+
}
|
|
44
|
+
if (database !== '') {
|
|
45
|
+
config.database = database
|
|
46
|
+
}
|
|
47
|
+
if (mode != null && mode !== 'disable') {
|
|
48
|
+
/**
|
|
49
|
+
* `no-verify` is what a self signed in-cluster certificate needs, and is the only
|
|
50
|
+
* mode that may skip verification — anything else keeps the chain checked.
|
|
51
|
+
*/
|
|
52
|
+
config.ssl = mode === 'no-verify' ? { rejectUnauthorized: false } : true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return config
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Turn a {@link DbConfig} into a `pg` pool configuration.
|
|
60
|
+
*
|
|
61
|
+
* Values that start with `/` have already been read from disk by the `fileConfigReader`
|
|
62
|
+
* middleware, so a file mounted secret and a literal one are indistinguishable here —
|
|
63
|
+
* which is what lets a deployment move from env vars to mounted files without a code
|
|
64
|
+
* change.
|
|
65
|
+
*/
|
|
66
|
+
export const prepareConfig = (config: DbConfig, overrides?: PoolConfig): PoolConfig => {
|
|
67
|
+
const meta = (config.meta ?? {}) as PostgresMeta
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `DbConfig.host` is an array for Mongo's replica sets. Postgres has no client side
|
|
71
|
+
* equivalent — multi-host failover is the pooler's job — so only the first is used.
|
|
72
|
+
*/
|
|
73
|
+
const host = Array.isArray(config.host) ? config.host[0] : config.host
|
|
74
|
+
|
|
75
|
+
const base: PoolConfig = meta.url != null
|
|
76
|
+
? parseUrl(meta.url)
|
|
77
|
+
: {
|
|
78
|
+
host,
|
|
79
|
+
port: config.port ?? DEF_PORT,
|
|
80
|
+
...(config.user != null ? { user: config.user } : {}),
|
|
81
|
+
...(config.secret != null ? { password: config.secret } : {}),
|
|
82
|
+
/** Absent, libpq falls back to the connection user's name. */
|
|
83
|
+
...(meta.database != null ? { database: meta.database } : {})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const prepared: PoolConfig = {
|
|
87
|
+
...base,
|
|
88
|
+
max: meta.max ?? DEF_POOL_SIZE,
|
|
89
|
+
...(meta.ssl != null ? { ssl: meta.ssl as PoolConfig['ssl'] } : {}),
|
|
90
|
+
...(meta.idleTimeoutMillis != null ? { idleTimeoutMillis: meta.idleTimeoutMillis } : {}),
|
|
91
|
+
...(meta.connectionTimeoutMillis != null
|
|
92
|
+
? { connectionTimeoutMillis: meta.connectionTimeoutMillis }
|
|
93
|
+
: {}),
|
|
94
|
+
/**
|
|
95
|
+
* A server side cap, so a query that outlives its caller stops holding a connection
|
|
96
|
+
* and its locks — the failure mode that turns one slow statement into a stuck pool.
|
|
97
|
+
*/
|
|
98
|
+
...(meta.statementTimeoutMillis != null
|
|
99
|
+
? { statement_timeout: meta.statementTimeoutMillis }
|
|
100
|
+
: {}),
|
|
101
|
+
...overrides
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return prepared
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Effective database of an open pool, as `pg` resolved it. */
|
|
108
|
+
export const poolDatabase = (pool: { options?: PoolConfig }): string =>
|
|
109
|
+
pool.options?.database ?? pool.options?.user ?? ''
|