@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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { pgErrorToResourceError, PostgresConnectionError, quoteIdent } from '@owlmeans/postgres-resource'
|
|
2
|
+
import type { PostgresMeta } from '@owlmeans/postgres-resource'
|
|
3
|
+
import type { Pool } from 'pg'
|
|
4
|
+
|
|
5
|
+
import { DEF_RETRIES, DEF_RETRY_DELAY, TERMINAL_CONNECT_CODES } from '../consts.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Retryable means "the server isn't up yet". A driver level failure carries no `code` at
|
|
9
|
+
* all (`ECONNREFUSED`, DNS) and is exactly the case worth waiting on; a Postgres error
|
|
10
|
+
* code that names a credential or catalog problem will say the same thing in a minute.
|
|
11
|
+
*/
|
|
12
|
+
const isTransient = (error: unknown): boolean => {
|
|
13
|
+
const code = (error as { code?: string } | null)?.code
|
|
14
|
+
if (code == null) {
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return !TERMINAL_CONNECT_CODES.includes(code)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wait for the server to answer a query, not merely to accept a socket.
|
|
23
|
+
*
|
|
24
|
+
* Postgres binds its port before it finishes recovery, so a sidecar or an operator managed
|
|
25
|
+
* instance routinely accepts a connection and then refuses to run anything. Every OwlMeans
|
|
26
|
+
* deployment grew its own copy of this loop; this is the one.
|
|
27
|
+
*
|
|
28
|
+
* @throws {PostgresConnectionError}
|
|
29
|
+
*/
|
|
30
|
+
export const probe = async (pool: Pool, meta: PostgresMeta, location: string): Promise<void> => {
|
|
31
|
+
const retries = Math.max(1, meta.retries ?? DEF_RETRIES)
|
|
32
|
+
const delay = meta.retryDelayMillis ?? DEF_RETRY_DELAY
|
|
33
|
+
let last: unknown
|
|
34
|
+
|
|
35
|
+
for (let attempt = 1; attempt <= retries; ++attempt) {
|
|
36
|
+
try {
|
|
37
|
+
await pool.query('SELECT 1')
|
|
38
|
+
|
|
39
|
+
return
|
|
40
|
+
} catch (error) {
|
|
41
|
+
last = error
|
|
42
|
+
if (attempt === retries || !isTransient(error)) {
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
console.log(`${location}: not ready (${attempt}/${retries}), retrying in ${delay}ms…`)
|
|
46
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const translated = pgErrorToResourceError(last)
|
|
51
|
+
const failure = new PostgresConnectionError(`unreachable:${location}:${translated.message}`)
|
|
52
|
+
failure.cause = last
|
|
53
|
+
|
|
54
|
+
throw failure
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `CREATE SCHEMA IF NOT EXISTS` on a pooled connection. */
|
|
58
|
+
export const ensureSchema = async (pool: Pool, schema: string): Promise<void> => {
|
|
59
|
+
try {
|
|
60
|
+
await pool.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw pgErrorToResourceError(error)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { PostgresBootstrapError } from '@owlmeans/postgres-resource'
|
|
3
|
+
import { randomNamespace } from '@owlmeans/test-integration'
|
|
4
|
+
import { Pool } from 'pg'
|
|
5
|
+
|
|
6
|
+
import { DEF_ADMIN_ALIAS } from '@owlmeans/postgres'
|
|
7
|
+
import { capabilities, gate, makeSuite, raw } from './context.js'
|
|
8
|
+
import type { PostgresService } from './context.js'
|
|
9
|
+
|
|
10
|
+
const suite = makeSuite('bootstrap')
|
|
11
|
+
const closed = gate.skip || !capabilities.bootstrap
|
|
12
|
+
const it = closed ? test.skip : test
|
|
13
|
+
|
|
14
|
+
const role = randomNamespace('omt_role')
|
|
15
|
+
const database = randomNamespace('omt_db')
|
|
16
|
+
/** Never printed, never persisted — generated per run and dropped with the role. */
|
|
17
|
+
const password = randomNamespace('pw', 24)
|
|
18
|
+
const schema = 'app'
|
|
19
|
+
|
|
20
|
+
/** The gate's URL is the only statement of where Postgres is; nothing here assumes 5432. */
|
|
21
|
+
const target = (): { host: string, port: number } => {
|
|
22
|
+
const parsed = new URL(gate.env.POSTGRES_URL ?? 'postgres://127.0.0.1:5432/postgres')
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
host: decodeURIComponent(parsed.hostname).replace(/^\[|\]$/g, ''),
|
|
26
|
+
port: parsed.port !== '' ? parseInt(parsed.port, 10) : 5432
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('@owlmeans/postgres — admin bootstrap path', () => {
|
|
31
|
+
if (closed) {
|
|
32
|
+
test.skip(
|
|
33
|
+
gate.skip
|
|
34
|
+
? gate.reason ?? 'postgres gate closed'
|
|
35
|
+
: 'connection role has neither CREATEROLE+CREATEDB nor superuser',
|
|
36
|
+
() => {}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let pg: PostgresService
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
pg = (await suite.boot({ admin: true })).pg
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
/** The admin alias connects lazily, so its pool only exists once bootstrap has run. */
|
|
50
|
+
suite.collect(pg)
|
|
51
|
+
await suite.teardown()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A database and a role outlive the schema the suite owns, so they are dropped here
|
|
55
|
+
* rather than by the shared teardown — and the role only after everything it owns is
|
|
56
|
+
* gone, which is what `DROP OWNED BY` guarantees.
|
|
57
|
+
*/
|
|
58
|
+
await raw(async pool => {
|
|
59
|
+
await pool.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`)
|
|
60
|
+
await pool.query(`DROP OWNED BY "${role}"`).catch(() => undefined)
|
|
61
|
+
await pool.query(`DROP ROLE IF EXISTS "${role}"`)
|
|
62
|
+
}).catch(error => console.error(`postgres bootstrap teardown (${role}@${database}):`, error))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('creates the role, its database and its schema', async () => {
|
|
66
|
+
const report = await pg.bootstrap(DEF_ADMIN_ALIAS, { role, password, database, schema })
|
|
67
|
+
|
|
68
|
+
expect(report).toEqual({
|
|
69
|
+
roleCreated: true,
|
|
70
|
+
passwordRotated: false,
|
|
71
|
+
databaseCreated: true,
|
|
72
|
+
schemaCreated: true,
|
|
73
|
+
grantsApplied: true
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('lets the new role connect and own its schema', async () => {
|
|
78
|
+
const pool = new Pool({ ...target(), user: role, password, database, max: 1 })
|
|
79
|
+
try {
|
|
80
|
+
/** `ALTER ROLE ... SET search_path` is what puts the role in its own schema on login. */
|
|
81
|
+
const { rows } = await pool.query<{ schema: string, owner: string, pub: boolean }>(
|
|
82
|
+
`SELECT current_schema() AS schema, pg_get_userbyid(nspowner) AS owner,
|
|
83
|
+
has_schema_privilege('public', 'public', 'CREATE') AS pub
|
|
84
|
+
FROM pg_namespace WHERE nspname = $1`, [schema]
|
|
85
|
+
)
|
|
86
|
+
expect(rows[0].schema).toBe(schema)
|
|
87
|
+
expect(rows[0].owner).toBe(role)
|
|
88
|
+
/** `REVOKE CREATE ON SCHEMA public FROM PUBLIC` — nothing lands in `public` by accident. */
|
|
89
|
+
expect(rows[0].pub).toBe(false)
|
|
90
|
+
|
|
91
|
+
/** An unqualified table goes to the role's own schema, which it owns outright. */
|
|
92
|
+
await pool.query('CREATE TABLE probe (id text)')
|
|
93
|
+
const [{ nspname }] = (await pool.query<{ nspname: string }>(
|
|
94
|
+
`SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
95
|
+
WHERE c.relname = 'probe'`
|
|
96
|
+
)).rows
|
|
97
|
+
expect(nspname).toBe(schema)
|
|
98
|
+
await pool.query('DROP TABLE probe')
|
|
99
|
+
} finally {
|
|
100
|
+
await pool.end().catch(() => undefined)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('revokes PUBLIC and grants the role instead', async () => {
|
|
105
|
+
const [row] = await raw(async pool => (await pool.query<{ pub: boolean, own: boolean }>(
|
|
106
|
+
`SELECT has_database_privilege('public', $1, 'CONNECT') AS pub,
|
|
107
|
+
has_database_privilege($2, $1, 'CONNECT') AS own`, [database, role]
|
|
108
|
+
)).rows)
|
|
109
|
+
|
|
110
|
+
expect(row.pub).toBe(false)
|
|
111
|
+
expect(row.own).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('is idempotent — a second run rotates the password and creates nothing', async () => {
|
|
115
|
+
const report = await pg.bootstrap(DEF_ADMIN_ALIAS, { role, password, database, schema })
|
|
116
|
+
|
|
117
|
+
expect(report.roleCreated).toBe(false)
|
|
118
|
+
expect(report.databaseCreated).toBe(false)
|
|
119
|
+
expect(report.passwordRotated).toBe(true)
|
|
120
|
+
/** `CREATE SCHEMA IF NOT EXISTS` is a no-op, but the grants are re-asserted every time. */
|
|
121
|
+
expect(report.schemaCreated).toBe(true)
|
|
122
|
+
expect(report.grantsApplied).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('leaves an existing password alone when asked to', async () => {
|
|
126
|
+
const report = await pg.bootstrap(
|
|
127
|
+
DEF_ADMIN_ALIAS, { role, password, database, schema, rotatePassword: false }
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
expect(report.passwordRotated).toBe(false)
|
|
131
|
+
expect(report.roleCreated).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('refuses an identifier it cannot prove safe, before it connects to anything', async () => {
|
|
135
|
+
await expect(pg.bootstrap(DEF_ADMIN_ALIAS, { role: 'role"; DROP DATABASE x; --', password }))
|
|
136
|
+
.rejects.toThrow(SyntaxError)
|
|
137
|
+
await expect(pg.bootstrap(DEF_ADMIN_ALIAS, { role, password, database: 'has space' }))
|
|
138
|
+
.rejects.toThrow(SyntaxError)
|
|
139
|
+
await expect(pg.bootstrap(DEF_ADMIN_ALIAS, { role, password: '' }))
|
|
140
|
+
.rejects.toThrow(PostgresBootstrapError)
|
|
141
|
+
})
|
|
142
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { postgresGate, randomNamespace } from '@owlmeans/test-integration'
|
|
2
|
+
import type { IntegrationGate, PostgresEnv } from '@owlmeans/test-integration'
|
|
3
|
+
import { PgAutoSync, resetPlaceholderCache } from '@owlmeans/postgres-resource'
|
|
4
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
5
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
6
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
7
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
8
|
+
import { Pool } from 'pg'
|
|
9
|
+
|
|
10
|
+
import { appendPostgres, DEF_ADMIN_ALIAS } from '@owlmeans/postgres'
|
|
11
|
+
import type { PostgresService } from '@owlmeans/postgres'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Integration harness for the Postgres pair.
|
|
15
|
+
*
|
|
16
|
+
* Unlike the `mongo-resource` pilot, which only proves the gate/namespace/cleanup pattern
|
|
17
|
+
* against the raw driver, these suites build a real `ServerContext` and drive
|
|
18
|
+
* `makePostgresResource` end to end — a resource is meaningless without the db service
|
|
19
|
+
* that backs it, which is why the specs live in this package rather than the resource one
|
|
20
|
+
* (the reverse would make `@owlmeans/postgres-resource` dev-depend on its own dependent).
|
|
21
|
+
*/
|
|
22
|
+
export const gate: IntegrationGate<PostgresEnv> = postgresGate()
|
|
23
|
+
|
|
24
|
+
const url = (): string => gate.env.POSTGRES_URL as string
|
|
25
|
+
|
|
26
|
+
/** Short lived, single connection pool for setup and assertions outside any context. */
|
|
27
|
+
export const raw = async <R>(fn: (pool: Pool) => Promise<R>): Promise<R> => {
|
|
28
|
+
const pool = new Pool({ connectionString: url(), max: 1 })
|
|
29
|
+
try {
|
|
30
|
+
return await fn(pool)
|
|
31
|
+
} finally {
|
|
32
|
+
await pool.end().catch(() => undefined)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PgCapabilities {
|
|
37
|
+
/** The connection role can CREATE ROLE and CREATE DATABASE — what the admin path needs. */
|
|
38
|
+
bootstrap: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Probed once, at module load, because Bun decides `test` vs `test.skip` synchronously and
|
|
43
|
+
* a suite cannot await its own gate. A role without `CREATEROLE`/`CREATEDB` still runs
|
|
44
|
+
* everything else — only the bootstrap suite self-skips.
|
|
45
|
+
*/
|
|
46
|
+
export const capabilities: PgCapabilities = gate.skip
|
|
47
|
+
? { bootstrap: false }
|
|
48
|
+
: await raw(async pool => {
|
|
49
|
+
const { rows } = await pool.query<{ super: boolean, role: boolean, db: boolean }>(
|
|
50
|
+
`SELECT rolsuper AS "super", rolcreaterole AS role, rolcreatedb AS db
|
|
51
|
+
FROM pg_roles WHERE rolname = current_user`
|
|
52
|
+
)
|
|
53
|
+
const row = rows[0]
|
|
54
|
+
|
|
55
|
+
return { bootstrap: row != null && (row.super || (row.role && row.db)) }
|
|
56
|
+
}).catch(() => ({ bootstrap: false }))
|
|
57
|
+
|
|
58
|
+
export interface BootOptions {
|
|
59
|
+
/** Registered in the order given — which is *not* dependency order, deliberately. */
|
|
60
|
+
resources?: Array<PostgresResource<any>>
|
|
61
|
+
autoSync?: PgAutoSync
|
|
62
|
+
/** Also configure a second alias pointing at the same server, for the admin path. */
|
|
63
|
+
admin?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type { PostgresResource, PostgresService }
|
|
67
|
+
|
|
68
|
+
export interface Booted {
|
|
69
|
+
context: ServerContext<ServerConfig>
|
|
70
|
+
pg: PostgresService
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PgSuite {
|
|
74
|
+
/** Postgres SCHEMA this suite owns end to end. Dropped by {@link teardown}. */
|
|
75
|
+
schema: string
|
|
76
|
+
boot: (opts?: BootOptions) => Promise<Booted>
|
|
77
|
+
/**
|
|
78
|
+
* Track pools a service opened *after* `boot()` returned. Config aliases connect lazily,
|
|
79
|
+
* so an alias only reached later — the admin one — has no pool at init time.
|
|
80
|
+
*/
|
|
81
|
+
collect: (pg: PostgresService) => void
|
|
82
|
+
teardown: () => Promise<void>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One namespace per suite, torn down by that suite's own `afterAll`.
|
|
87
|
+
*
|
|
88
|
+
* The shared `registerCleanup`/`runCleanups` queue is process global, and Bun runs every
|
|
89
|
+
* spec file of a package in one process — so the first file to finish would drop the
|
|
90
|
+
* namespaces of the files still to come.
|
|
91
|
+
*/
|
|
92
|
+
export const makeSuite = (label: string): PgSuite => {
|
|
93
|
+
const prefix = process.env.POSTGRES_TEST_DB_PREFIX ?? 'omt'
|
|
94
|
+
const schema = randomNamespace(`${prefix}_${label}`)
|
|
95
|
+
const pools: Pool[] = []
|
|
96
|
+
|
|
97
|
+
const boot = async (opts: BootOptions = {}): Promise<Booted> => {
|
|
98
|
+
/**
|
|
99
|
+
* The cache is keyed by context and a context is never rebooted, so entries from a
|
|
100
|
+
* previous `boot()` are dead weight rather than a hazard — but a suite that runs ten
|
|
101
|
+
* boots would otherwise carry all ten.
|
|
102
|
+
*
|
|
103
|
+
* Declarations are deliberately *not* reset here. They survive a context switch on
|
|
104
|
+
* purpose (that is the whole point of keying them by alias), and a `.migration()`
|
|
105
|
+
* registered before `boot()` would be silently dropped. A spec that wants a clean
|
|
106
|
+
* slate calls `resetDeclarations(alias)` itself, which is exactly what a spec
|
|
107
|
+
* simulating a restarted process should have to say out loud.
|
|
108
|
+
*/
|
|
109
|
+
resetPlaceholderCache()
|
|
110
|
+
|
|
111
|
+
const meta = { url: url(), max: 2, retries: 3, retryDelayMillis: 200 }
|
|
112
|
+
const cfg: ServerConfig = config('pg-test', {
|
|
113
|
+
dbs: [
|
|
114
|
+
{
|
|
115
|
+
service: 'postgres',
|
|
116
|
+
alias: 'postgres',
|
|
117
|
+
host: '127.0.0.1',
|
|
118
|
+
schema,
|
|
119
|
+
meta: { ...meta, autoSync: opts.autoSync ?? PgAutoSync.Full }
|
|
120
|
+
},
|
|
121
|
+
...(opts.admin === true
|
|
122
|
+
? [{ service: 'postgres', alias: DEF_ADMIN_ALIAS, host: '127.0.0.1', meta }]
|
|
123
|
+
: [])
|
|
124
|
+
]
|
|
125
|
+
} as Partial<ServerConfig>)
|
|
126
|
+
|
|
127
|
+
const context = makeServerContext(cfg) as ServerContext<ServerConfig>
|
|
128
|
+
appendPostgres(context)
|
|
129
|
+
|
|
130
|
+
for (const resource of opts.resources ?? []) {
|
|
131
|
+
context.registerResource(resource as never)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
context.configure()
|
|
135
|
+
const pg = context.service<PostgresService>('postgres')
|
|
136
|
+
try {
|
|
137
|
+
await context.init()
|
|
138
|
+
} finally {
|
|
139
|
+
/**
|
|
140
|
+
* Captured in `finally` because the suites that assert a *failed* boot — a refused
|
|
141
|
+
* cast, a throwing migration — open a pool before the failure and would otherwise
|
|
142
|
+
* leak it for the rest of the run.
|
|
143
|
+
*/
|
|
144
|
+
collect(pg)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { context, pg }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const collect = (pg: PostgresService): void => {
|
|
151
|
+
for (const pool of Object.values(pg.clients ?? {})) {
|
|
152
|
+
if (!pools.includes(pool)) {
|
|
153
|
+
pools.push(pool)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const teardown = async (): Promise<void> => {
|
|
159
|
+
if (gate.skip) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
await raw(async pool => { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`) })
|
|
163
|
+
.catch(error => console.error(`postgres test teardown (${schema}):`, error))
|
|
164
|
+
/** After the drop — a pool still holding the schema open makes CASCADE wait. */
|
|
165
|
+
while (pools.length > 0) {
|
|
166
|
+
await pools.pop()?.end().catch(() => undefined)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { schema, boot, collect, teardown }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Columns of a table as Postgres currently reports them, `name → "type[ NOT NULL]"`. */
|
|
174
|
+
export const shapeOf = async (
|
|
175
|
+
pg: PostgresService, schema: string, table: string
|
|
176
|
+
): Promise<Record<string, string>> => {
|
|
177
|
+
const rows = await pg.query<{ name: string, type: string, not_null: boolean }>(
|
|
178
|
+
`SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS not_null
|
|
179
|
+
FROM pg_attribute a
|
|
180
|
+
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
|
|
181
|
+
ORDER BY a.attnum`,
|
|
182
|
+
[`"${schema}"."${table}"`]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return Object.fromEntries(rows.map(row => [row.name, `${row.type}${row.not_null ? ' NOT NULL' : ''}`]))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface LedgerRow {
|
|
189
|
+
alias: string
|
|
190
|
+
name: string
|
|
191
|
+
stage: string
|
|
192
|
+
baseline: boolean
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const ledgerOf = async (pg: PostgresService, schema: string): Promise<LedgerRow[]> =>
|
|
196
|
+
await pg.query<LedgerRow & Record<string, unknown>>(
|
|
197
|
+
`SELECT alias, name, stage, baseline FROM "${schema}"."_owlmeans_migrations" ORDER BY name`
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
export interface Note extends ResourceRecord {
|
|
201
|
+
id?: string
|
|
202
|
+
title: string
|
|
203
|
+
slug?: string
|
|
204
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { makePostgresResource } from '@owlmeans/postgres-resource'
|
|
3
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
4
|
+
import { RecordExists, UnknownRecordError } from '@owlmeans/resource'
|
|
5
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
6
|
+
|
|
7
|
+
import { gate, makeSuite, shapeOf } from './context.js'
|
|
8
|
+
import type { PostgresService } from '@owlmeans/postgres'
|
|
9
|
+
|
|
10
|
+
interface User extends ResourceRecord {
|
|
11
|
+
id?: string
|
|
12
|
+
email: string
|
|
13
|
+
status?: string
|
|
14
|
+
age?: number
|
|
15
|
+
tags?: string[]
|
|
16
|
+
profile?: Record<string, unknown>
|
|
17
|
+
createdAt?: Date
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Post extends ResourceRecord {
|
|
21
|
+
id?: string
|
|
22
|
+
ownerId: string
|
|
23
|
+
title: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const userSchema = {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
id: { type: 'string', format: 'uuid' },
|
|
30
|
+
email: { type: 'string', pg: { type: 'varchar', length: 320, unique: true } },
|
|
31
|
+
status: { type: 'string', enum: ['active', 'banned'], nullable: true, default: 'active' },
|
|
32
|
+
age: { type: 'integer', nullable: true },
|
|
33
|
+
tags: { type: 'array', items: { type: 'string' }, nullable: true },
|
|
34
|
+
profile: { type: 'object', nullable: true },
|
|
35
|
+
createdAt: { type: 'object', format: 'date-time', nullable: true, pg: { defaultRaw: 'now()' } }
|
|
36
|
+
},
|
|
37
|
+
required: ['id', 'email'],
|
|
38
|
+
pg: { indexes: [{ name: 'idx_crud_users_status', columns: ['status'] }] }
|
|
39
|
+
} as never
|
|
40
|
+
|
|
41
|
+
const postSchema = {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
id: { type: 'string', format: 'uuid' },
|
|
45
|
+
ownerId: { type: 'string', format: 'uuid', pg: { references: { resource: 'crud-users', onDelete: 'cascade' } } },
|
|
46
|
+
title: { type: 'string' }
|
|
47
|
+
},
|
|
48
|
+
required: ['id', 'ownerId', 'title']
|
|
49
|
+
} as never
|
|
50
|
+
|
|
51
|
+
const suite = makeSuite('crud')
|
|
52
|
+
const it = gate.skip ? test.skip : test
|
|
53
|
+
|
|
54
|
+
describe('@owlmeans/postgres — resource CRUD against a real context', () => {
|
|
55
|
+
if (gate.skip) {
|
|
56
|
+
test.skip(gate.reason ?? 'postgres gate closed', () => {})
|
|
57
|
+
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let pg: PostgresService
|
|
62
|
+
let users: PostgresResource<User>
|
|
63
|
+
let posts: PostgresResource<Post>
|
|
64
|
+
let seeded: User
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
const user = makePostgresResource<User, PostgresResource<User>>('crud-users')
|
|
68
|
+
user.schema = userSchema
|
|
69
|
+
const post = makePostgresResource<Post, PostgresResource<Post>>('crud-posts')
|
|
70
|
+
post.schema = postSchema
|
|
71
|
+
|
|
72
|
+
/** Posts first: registration order is not dependency order, and must not have to be. */
|
|
73
|
+
const booted = await suite.boot({ resources: [post, user] })
|
|
74
|
+
pg = booted.pg
|
|
75
|
+
users = booted.context.resource<PostgresResource<User>>('crud-users')
|
|
76
|
+
posts = booted.context.resource<PostgresResource<Post>>('crud-posts')
|
|
77
|
+
|
|
78
|
+
seeded = await users.create({ email: 'a@b.c', age: 21, tags: ['x', 'y'], profile: { city: 'Kyiv' } })
|
|
79
|
+
await posts.create({ ownerId: seeded.id as string, title: 'hello' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await suite.teardown()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('creates the table the AJV schema describes', async () => {
|
|
87
|
+
expect(users.table.qualified).toBe(`"${suite.schema}"."crud_users"`)
|
|
88
|
+
|
|
89
|
+
expect(await shapeOf(pg, suite.schema, 'crud_users')).toEqual({
|
|
90
|
+
id: 'uuid NOT NULL',
|
|
91
|
+
email: 'character varying(320) NOT NULL',
|
|
92
|
+
status: 'text',
|
|
93
|
+
age: 'integer',
|
|
94
|
+
tags: 'text[]',
|
|
95
|
+
profile: 'jsonb',
|
|
96
|
+
createdAt: 'timestamp with time zone'
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('creates the constraints and indexes the schema implies', async () => {
|
|
101
|
+
const constraints = await pg.query<{ conname: string, def: string }>(
|
|
102
|
+
`SELECT conname, pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conrelid = $1::regclass`,
|
|
103
|
+
[`"${suite.schema}".crud_users`]
|
|
104
|
+
)
|
|
105
|
+
const definitions = constraints.map(row => row.def)
|
|
106
|
+
|
|
107
|
+
expect(definitions).toContain('PRIMARY KEY (id)')
|
|
108
|
+
expect(definitions).toContain('UNIQUE (email)')
|
|
109
|
+
/** A string enum becomes a CHECK — a native enum can't drop values or be altered in a transaction. */
|
|
110
|
+
expect(definitions.some(def => def.includes('CHECK') && def.includes("'banned'"))).toBe(true)
|
|
111
|
+
|
|
112
|
+
const indexes = await pg.query<{ indexname: string }>(
|
|
113
|
+
`SELECT indexname FROM pg_indexes WHERE schemaname = $1 AND tablename = $2`,
|
|
114
|
+
[suite.schema, 'crud_users']
|
|
115
|
+
)
|
|
116
|
+
expect(indexes.map(row => row.indexname)).toContain('idx_crud_users_status')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The key targets a table another resource owns, and resource `init()` runs in
|
|
121
|
+
* registration order. It is applied by the Loading middleware instead — the first moment
|
|
122
|
+
* every table is known to exist.
|
|
123
|
+
*/
|
|
124
|
+
it('applies a deferred foreign key after every resource has initialized', async () => {
|
|
125
|
+
const keys = await pg.query<{ def: string }>(
|
|
126
|
+
`SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
|
|
127
|
+
WHERE conrelid = $1::regclass AND contype = 'f'`,
|
|
128
|
+
[`"${suite.schema}".crud_posts`]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
expect(keys).toHaveLength(1)
|
|
132
|
+
expect(keys[0].def).toContain(`REFERENCES ${suite.schema}.crud_users(id)`)
|
|
133
|
+
expect(keys[0].def).toContain('ON DELETE CASCADE')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('round-trips a record through create and get', async () => {
|
|
137
|
+
const loaded = await users.get(seeded.id as string)
|
|
138
|
+
|
|
139
|
+
expect(loaded.email).toBe('a@b.c')
|
|
140
|
+
expect(loaded.age).toBe(21)
|
|
141
|
+
expect(loaded.tags).toEqual(['x', 'y'])
|
|
142
|
+
expect(loaded.profile).toEqual({ city: 'Kyiv' })
|
|
143
|
+
/** Schema defaults are applied on the way in, not only in DDL. */
|
|
144
|
+
expect(loaded.status).toBe('active')
|
|
145
|
+
/** Drizzle's node-postgres session replaces the driver's date parsers; the marshaller restores them. */
|
|
146
|
+
expect(loaded.createdAt).toBeInstanceOf(Date)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('loads by a field other than the primary key, and refuses an unknown record', async () => {
|
|
150
|
+
const byEmail = await users.load('a@b.c', 'email')
|
|
151
|
+
expect(byEmail?.id).toBe(seeded.id as string)
|
|
152
|
+
|
|
153
|
+
expect(await users.load('nobody@nowhere', 'email')).toBeNull()
|
|
154
|
+
await expect(users.get('nobody@nowhere', 'email')).rejects.toThrow(UnknownRecordError)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('patches by merge where update replaces the whole record', async () => {
|
|
158
|
+
const patched = await users.patch<User>({ id: seeded.id, age: 22 })
|
|
159
|
+
expect(patched.age).toBe(22)
|
|
160
|
+
expect(patched.email).toBe('a@b.c')
|
|
161
|
+
|
|
162
|
+
const replaced = await users.update({ id: seeded.id, email: 'a@b.c', status: 'banned' })
|
|
163
|
+
expect(replaced.status).toBe('banned')
|
|
164
|
+
/** Mongo's `replaceOne` semantics: a field absent from the record is absent afterwards. */
|
|
165
|
+
expect(replaced.age ?? null).toBeNull()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('lists by criteria and reports a total independent of the page', async () => {
|
|
169
|
+
await users.create({ email: 'second@b.c', status: 'banned' })
|
|
170
|
+
await users.create({ email: 'third@b.c', status: 'active' })
|
|
171
|
+
|
|
172
|
+
const banned = await users.list({ status: 'banned' })
|
|
173
|
+
expect(banned.pager?.total).toBe(2)
|
|
174
|
+
expect(banned.items.every(item => item.status === 'banned')).toBe(true)
|
|
175
|
+
|
|
176
|
+
const paged = await users.list({}, { pager: { page: 0, size: 2 } })
|
|
177
|
+
expect(paged.items).toHaveLength(2)
|
|
178
|
+
expect(paged.pager?.total).toBe(3)
|
|
179
|
+
|
|
180
|
+
const sorted = await users.list({}, { pager: { sort: [['email', false]] } })
|
|
181
|
+
expect(sorted.items.map(item => item.email)).toEqual(['a@b.c', 'second@b.c', 'third@b.c'])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('counts and purges by criteria', async () => {
|
|
185
|
+
expect(await users.count()).toBe(3)
|
|
186
|
+
expect(await users.count({ status: 'banned' })).toBe(2)
|
|
187
|
+
|
|
188
|
+
expect(await users.purge({ email: 'third@b.c' })).toBe(1)
|
|
189
|
+
expect(await users.count()).toBe(2)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('refuses a caller supplied id in create and accepts one in insert', async () => {
|
|
193
|
+
await expect(users.create({ id: '00000000-0000-4000-8000-000000000001', email: 'no@b.c' }))
|
|
194
|
+
.rejects.toThrow(RecordExists)
|
|
195
|
+
|
|
196
|
+
const inserted = await users.insert<User>({
|
|
197
|
+
id: '00000000-0000-4000-8000-000000000002', email: 'explicit@b.c'
|
|
198
|
+
})
|
|
199
|
+
expect(inserted.id).toBe('00000000-0000-4000-8000-000000000002')
|
|
200
|
+
await users.delete(inserted.id as string)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('upserts on an arbiter that is not the primary key', async () => {
|
|
204
|
+
const upserted = await users.upsert<User>({ email: 'a@b.c', age: 44 }, ['email'])
|
|
205
|
+
|
|
206
|
+
expect(upserted.id).toBe(seeded.id as string)
|
|
207
|
+
expect(upserted.age).toBe(44)
|
|
208
|
+
expect(await users.count()).toBe(2)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('reports a unique violation as RecordExists', async () => {
|
|
212
|
+
await expect(users.create({ email: 'a@b.c' })).rejects.toThrow(RecordExists)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('picks a record by deleting it, atomically', async () => {
|
|
216
|
+
const before = await users.count()
|
|
217
|
+
expect(await posts.count()).toBe(1)
|
|
218
|
+
|
|
219
|
+
const picked = await users.pick(seeded.id as string)
|
|
220
|
+
|
|
221
|
+
expect(picked.email).toBe('a@b.c')
|
|
222
|
+
expect(await users.count()).toBe(before - 1)
|
|
223
|
+
/** The cascade is the database's, not the resource's — the post goes with its owner. */
|
|
224
|
+
expect(await posts.count()).toBe(0)
|
|
225
|
+
await expect(users.pick(seeded.id as string)).rejects.toThrow(UnknownRecordError)
|
|
226
|
+
})
|
|
227
|
+
})
|