@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/agent-meta/instructions/postgres.instructions.md +57 -0
  4. package/agent-meta/manifest.json +23 -0
  5. package/agent-meta/skills/postgres/SKILL.md +121 -0
  6. package/build/bootstrap.d.ts +16 -0
  7. package/build/bootstrap.d.ts.map +1 -0
  8. package/build/bootstrap.js +106 -0
  9. package/build/bootstrap.js.map +1 -0
  10. package/build/consts.d.ts +29 -0
  11. package/build/consts.d.ts.map +1 -0
  12. package/build/consts.js +30 -0
  13. package/build/consts.js.map +1 -0
  14. package/build/index.d.ts +8 -0
  15. package/build/index.d.ts.map +1 -0
  16. package/build/index.js +7 -0
  17. package/build/index.js.map +1 -0
  18. package/build/middleware.d.ts +11 -0
  19. package/build/middleware.d.ts.map +1 -0
  20. package/build/middleware.js +21 -0
  21. package/build/middleware.js.map +1 -0
  22. package/build/service.d.ts +9 -0
  23. package/build/service.d.ts.map +1 -0
  24. package/build/service.js +221 -0
  25. package/build/service.js.map +1 -0
  26. package/build/types.d.ts +40 -0
  27. package/build/types.d.ts.map +1 -0
  28. package/build/types.js +2 -0
  29. package/build/types.js.map +1 -0
  30. package/build/utils/config.d.ts +27 -0
  31. package/build/utils/config.d.ts.map +1 -0
  32. package/build/utils/config.js +96 -0
  33. package/build/utils/config.js.map +1 -0
  34. package/build/utils/connection.d.ts +15 -0
  35. package/build/utils/connection.d.ts.map +1 -0
  36. package/build/utils/connection.js +56 -0
  37. package/build/utils/connection.js.map +1 -0
  38. package/package.json +45 -0
  39. package/src/bootstrap.ts +126 -0
  40. package/src/consts.ts +36 -0
  41. package/src/index.ts +7 -0
  42. package/src/middleware.ts +26 -0
  43. package/src/service.ts +287 -0
  44. package/src/types.ts +42 -0
  45. package/src/utils/config.ts +109 -0
  46. package/src/utils/connection.ts +64 -0
  47. package/tests/bootstrap.spec.ts +142 -0
  48. package/tests/context.ts +204 -0
  49. package/tests/crud.spec.ts +227 -0
  50. package/tests/custom-sql.spec.ts +196 -0
  51. package/tests/migration.spec.ts +260 -0
  52. package/tests/sync.spec.ts +156 -0
  53. 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
+ })
@@ -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
+ })