@owlmeans/postgres 0.1.18-rc.2 → 0.1.18-rc.21
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/README.md +4 -4
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/postgres/SKILL.md +67 -16
- package/build/service.d.ts.map +1 -1
- package/build/service.js +5 -32
- package/build/service.js.map +1 -1
- package/package.json +8 -8
- package/src/service.ts +9 -42
- package/tests/crud.spec.ts +68 -18
- package/tests/custom-sql.spec.ts +3 -2
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ provisioning, and an opt-in least-privilege bootstrap path.
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
bun add @owlmeans/postgres @owlmeans/postgres-resource
|
|
17
|
+
bun add @owlmeans/postgres@^0.1.18-rc.13 @owlmeans/postgres-resource@^0.1.18-rc.12
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
## Usage
|
|
@@ -46,8 +46,8 @@ A whole connection string works too, and wins over `host`/`user`/`secret`:
|
|
|
46
46
|
cfg.dbs = [{ service: 'postgres', alias: 'postgres', schema: 'app', meta: { url: process.env.DATABASE_URL } }]
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
`schema` is the Postgres **schema**, not the database — `dbName()`
|
|
50
|
-
|
|
49
|
+
`schema` is the Postgres **schema**, not the database — `dbName()` resolves it as
|
|
50
|
+
`config.schema ?? config.alias ?? service.alias`. The database comes from `meta.database`.
|
|
51
51
|
|
|
52
52
|
At `init()` the service opens a pool, runs a `SELECT 1` readiness probe (30 attempts, 2s apart by
|
|
53
53
|
default — a Postgres sidecar routinely accepts TCP before it accepts queries), issues
|
|
@@ -129,7 +129,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
129
129
|
your project's skill store (`.agents/skills/`):
|
|
130
130
|
|
|
131
131
|
```sh
|
|
132
|
-
npx @owlmeans/agent-skills
|
|
132
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.19
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/postgres",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
4
|
+
"version": "0.1.18-rc.21",
|
|
5
|
+
"generatedAt": "2026-09-12T13:51:53.545Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: postgres
|
|
3
|
-
description: How to use @owlmeans/postgres — PostgreSQL connection service (makePostgresDbService / appendPostgres) registered on a server context, plus the least-privilege bootstrap admin path. Auto-invoked when wiring PostgreSQL into a server app.
|
|
3
|
+
description: How to use @owlmeans/postgres — PostgreSQL connection service (makePostgresDbService / appendPostgres) registered on a server context, its health checks, plus the least-privilege bootstrap admin path. Auto-invoked when wiring PostgreSQL into a server app.
|
|
4
4
|
user-invocable: false
|
|
5
5
|
---
|
|
6
6
|
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
@@ -8,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/postgres
|
|
9
9
|
|
|
10
10
|
**Layer:** Infra
|
|
11
|
-
**Install:** `"@owlmeans/postgres": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/postgres": "^0.1.18-rc.21"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
Pooled `pg` connections for a server context, plus the admin path that provisions the role, database
|
|
14
14
|
and schema an app connects with. All DDL for application tables belongs to [[postgres-resource]] —
|
|
@@ -21,7 +21,9 @@ this package only creates the schema those tables live in.
|
|
|
21
21
|
| `makePostgresDbService(alias?)` | The connection service. `alias` defaults to `DEFAULT_ALIAS` (`'postgres'`). |
|
|
22
22
|
| `appendPostgres(context, alias?)` | Registers the service **and** its drain middleware. Use this, not a bare `registerService`. |
|
|
23
23
|
| `PostgresService` | `PostgresDbService` + `bootstrap(configAlias, opts)`. |
|
|
24
|
-
| `BootstrapOptions`, `BootstrapReport`, `bootstrapDb` | The admin path
|
|
24
|
+
| `BootstrapOptions`, `BootstrapReport`, `bootstrapDb` | The admin path. `bootstrapDb(service, configAlias, opts)` is what `service.bootstrap(configAlias, opts)` calls — both read the config through a **context-bound** service, so neither runs outside a context. |
|
|
25
|
+
| `pingDb(context, alias?, configAlias?)` / `checkDbHealth(...)` | Health checks through the registered service; `DbHealth` = `{ ok, summary, error? }`. |
|
|
26
|
+
| `getLastDbHealth(alias?, configAlias?)` / `formatDbError(error)` | The cached verdict, and the cause-chain error formatter. |
|
|
25
27
|
| `drainMiddleware(alias?)` | Drains work deferred until every resource has initialized. |
|
|
26
28
|
| `parseUrl`, `prepareConfig`, `poolDatabase`, `probe`, `ensureSchema` | Config and connection helpers. |
|
|
27
29
|
| `DEFAULT_ALIAS`, `DEF_ADMIN_ALIAS`, `DEF_MAINTENANCE_DB`, `DEF_PORT`, `DEF_POOL_SIZE`, `DEF_RETRIES`, `DEF_RETRY_DELAY`, `TERMINAL_CONNECT_CODES` | Constants. |
|
|
@@ -50,20 +52,36 @@ cfg.dbs = [{
|
|
|
50
52
|
for a `DATABASE_URL` env var. Because a leading `/` is auto-read, moving to file-mounted secrets
|
|
51
53
|
later is a config change, not a code change.
|
|
52
54
|
|
|
53
|
-
**`schema` is the SCHEMA, not the database.** `
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
**`schema` is the SCHEMA, not the database.** The service's `name(alias?)` returns it as given
|
|
56
|
+
(`config.schema ?? config.alias ?? service.alias`), so a separate namespace costs a `CREATE SCHEMA`
|
|
57
|
+
inside a shared database rather than a `CREATE DATABASE`. The database comes from `meta.database`.
|
|
56
58
|
|
|
57
59
|
Other `meta` keys: `autoSync` (see [[postgres-resource]]), `ssl`, `max`, `idleTimeoutMillis`,
|
|
58
60
|
`connectionTimeoutMillis`, `statementTimeoutMillis`, `retries`, `retryDelayMillis`.
|
|
59
61
|
|
|
60
62
|
## What `init()` does
|
|
61
63
|
|
|
62
|
-
Opens a `pg.Pool` →
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
Opens a `pg.Pool` → attaches the pool's `error` handler → `SELECT 1` readiness probe
|
|
65
|
+
(`DEF_RETRIES` 30 attempts, `DEF_RETRY_DELAY` 2s apart, both overridable through
|
|
66
|
+
`meta.retries`/`meta.retryDelayMillis`) → `SIGTERM` handler that drains the pool → registers the
|
|
67
|
+
client → `CREATE SCHEMA IF NOT EXISTS`.
|
|
68
|
+
|
|
69
|
+
Do **not** hand-roll the retry loop around it: a Postgres sidecar routinely accepts TCP before it
|
|
70
|
+
accepts queries, which is precisely what the probe is for. Credential, missing-database and
|
|
71
|
+
permission failures (`TERMINAL_CONNECT_CODES`) fail immediately instead of burning the full retry
|
|
72
|
+
budget on an error that will never clear. A probe that gives up ends the pool before raising, so a
|
|
73
|
+
failed boot leaves no sockets behind.
|
|
74
|
+
|
|
75
|
+
**The schema is created only when the config entry names one.** `config.schema` absent means no
|
|
76
|
+
`CREATE SCHEMA` at all — an entry that identifies its database through `meta.database` or
|
|
77
|
+
`meta.url` and omits `schema` gets none, deliberately: resources create the schema they need
|
|
78
|
+
anyway, and inventing one from the service alias would leave an empty `postgres` schema behind on
|
|
79
|
+
every boot of the admin connection.
|
|
80
|
+
|
|
81
|
+
**The pool's `error` handler is not optional.** `node-postgres` emits `error` on *idle* clients
|
|
82
|
+
whenever the server closes a connection — routine behind a load balancer — and an unhandled
|
|
83
|
+
`error` on an EventEmitter terminates the process. The service attaches one that logs; a pool you
|
|
84
|
+
open yourself needs the same.
|
|
67
85
|
|
|
68
86
|
Keep the pool small. Postgres caps connections cluster-wide (`max_connections`, 100 by default), so
|
|
69
87
|
an oversized per-process pool starves every other client of the same server — the opposite of the
|
|
@@ -73,7 +91,8 @@ Mongo driver's tuning instinct.
|
|
|
73
91
|
|
|
74
92
|
`db(alias?)` → `{ drizzle, pool, schema, database }` · `client(alias?)` / `clients` ·
|
|
75
93
|
`qualify(resourceAlias, configAlias?)` · `query(text, params?, configAlias?)` ·
|
|
76
|
-
`transaction(fn, configAlias?)` · `lock`/`unlock` (
|
|
94
|
+
`transaction(fn, configAlias?)` · `lock`/`unlock` (field encryption through
|
|
95
|
+
`makeKeyPairModel(config.encryptionKey)` from `@owlmeans/basic-keys`) ·
|
|
77
96
|
`defer(configAlias, task)` / `drain(configAlias?)`.
|
|
78
97
|
|
|
79
98
|
`defer` exists for work that can't run during one resource's `init()` because it points at another
|
|
@@ -81,6 +100,35 @@ resource that hasn't initialized yet — foreign keys, above all. Queue it; the
|
|
|
81
100
|
`appendPostgres` registers drains it once the context is up. Do not reorder resource registrations
|
|
82
101
|
to work around this.
|
|
83
102
|
|
|
103
|
+
## Health
|
|
104
|
+
|
|
105
|
+
Do not hand-roll a health check, and never open a connection of your own for one: `pingDb` and
|
|
106
|
+
`checkDbHealth` resolve the service by alias exactly as any other consumer does, so a probe can
|
|
107
|
+
never report on a connection the app does not use. Both return `{ ok, summary, error? }` and never
|
|
108
|
+
throw.
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
const health = await checkDbHealth(context) // boot gate, after init()
|
|
112
|
+
if (!health.ok) throw new Error(health.error ?? health.summary)
|
|
113
|
+
|
|
114
|
+
const live = await pingDb(ctx) // request time, in a health handler
|
|
115
|
+
const boot = getLastDbHealth()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`checkDbHealth` awaits `service.ready()`, runs `SELECT 1` and caches its verdict for
|
|
119
|
+
`getLastDbHealth`; `pingDb` is the bare `SELECT 1` and caches nothing. Neither inspects the schema
|
|
120
|
+
— structure is reconciled by the resources during `init()`, so a process that reached the check has
|
|
121
|
+
the tables it was built with and there is nothing left to assert beyond "the connection works".
|
|
122
|
+
|
|
123
|
+
The context is a parameter, not a module import: a handler is handed the context that actually
|
|
124
|
+
served the request (possibly an entity-scoped derivative), and passing it keeps these functions out
|
|
125
|
+
of the boot import cycle. Errors go through `formatDbError`, which walks the `cause` chain and
|
|
126
|
+
surfaces the `severity`/`code`/`detail`/`hint` a bare `.message` hides — that is where the real
|
|
127
|
+
FATAL/permission reason lives.
|
|
128
|
+
|
|
129
|
+
Pair it with the bind-first boot in [[server-app]]: `checkDbHealth` is the boot gate, and a failure
|
|
130
|
+
becomes `setBootPhase('failed', …)` on a port that is already bound.
|
|
131
|
+
|
|
84
132
|
## Bootstrap — the admin path
|
|
85
133
|
|
|
86
134
|
Superuser credentials belong to a **separate config alias**. The application's own entry connects as
|
|
@@ -94,10 +142,13 @@ await context.service<PostgresService>('postgres').bootstrap('pg-admin', {
|
|
|
94
142
|
|
|
95
143
|
Idempotent by design — every OwlMeans deployment calls it on each start and each rebuild. It probes
|
|
96
144
|
`pg_roles` / `pg_database` before creating, rotates the password of an existing role (the caller
|
|
97
|
-
generated the password it just passed in, so the role has to accept it)
|
|
98
|
-
applies `REVOKE CONNECT … FROM PUBLIC`,
|
|
99
|
-
`GRANT CONNECT … TO <role>`,
|
|
100
|
-
|
|
145
|
+
generated the password it just passed in, so the role has to accept it). The two grant branches are
|
|
146
|
+
independent: `leastPrivilege` applies `REVOKE CONNECT … FROM PUBLIC`,
|
|
147
|
+
`REVOKE CREATE ON SCHEMA public FROM PUBLIC` and `GRANT CONNECT … TO <role>`, while `schema`
|
|
148
|
+
applies `CREATE SCHEMA … AUTHORIZATION <role>`, `GRANT ALL ON SCHEMA … TO <role>` and
|
|
149
|
+
`ALTER ROLE … SET search_path`. **`search_path` comes from `schema`** — a call with
|
|
150
|
+
`leastPrivilege` and no `schema` sets none. The returned `BootstrapReport` says what actually
|
|
151
|
+
changed. Identifiers are validated and quoted; the password is the only literal.
|
|
101
152
|
|
|
102
153
|
**This replaces hand-written bootstrap SQL.** If you find `CREATE ROLE` / `CREATE DATABASE` /
|
|
103
154
|
`GRANT` strings in a deployment script, a provisioner, or a `DEPLOYMENT.md`, they are a duplicate of
|
package/build/service.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAS3E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAIjD,KAAK,MAAM,GAAG,YAAY,CAAA;AAC1B,UAAU,OAAO,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,aAAa,CAAC,CAAC,CAAC;CAAI;AAEzE,eAAO,MAAM,qBAAqB,WAAW,MAAM,KAAmB,eA2NrE,CAAA;AAED,eAAO,MAAM,cAAc,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,WACvE,CAAC,UAAS,MAAM,KACxB,CAOF,CAAA"}
|
package/build/service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { makeKeyPairModel } from '@owlmeans/basic-keys';
|
|
2
|
-
import { assertContext
|
|
2
|
+
import { assertContext } from '@owlmeans/context';
|
|
3
3
|
import { makeTx, pgErrorToResourceError, pgIdentifier, refOf, resolvePlaceholders } from '@owlmeans/postgres-resource';
|
|
4
4
|
import { createDbService } from '@owlmeans/resource';
|
|
5
5
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
@@ -14,7 +14,7 @@ export const makePostgresDbService = (alias = DEFAULT_ALIAS) => {
|
|
|
14
14
|
/**
|
|
15
15
|
* Handles are cached per config alias rather than rebuilt per call: `db()` runs on every
|
|
16
16
|
* single resource operation, and both the Drizzle binding and the schema name are fixed
|
|
17
|
-
* for the lifetime of
|
|
17
|
+
* for the lifetime of the service — one instance per alias, for the whole process.
|
|
18
18
|
*/
|
|
19
19
|
const handles = new Map();
|
|
20
20
|
const deferred = new Map();
|
|
@@ -28,9 +28,9 @@ export const makePostgresDbService = (alias = DEFAULT_ALIAS) => {
|
|
|
28
28
|
drizzle: drizzle(pool),
|
|
29
29
|
pool,
|
|
30
30
|
/**
|
|
31
|
-
* `name()` is the
|
|
32
|
-
* reads the same value as a database name.
|
|
33
|
-
*
|
|
31
|
+
* `name()` is the configured namespace — the Postgres SCHEMA here, where Mongo
|
|
32
|
+
* reads the same value as a database name. `pgIdentifier` holds it inside the
|
|
33
|
+
* 63 byte limit Postgres would otherwise truncate silently.
|
|
34
34
|
*/
|
|
35
35
|
schema: pgIdentifier(service.name(configAlias)),
|
|
36
36
|
database: poolDatabase(pool)
|
|
@@ -45,20 +45,6 @@ export const makePostgresDbService = (alias = DEFAULT_ALIAS) => {
|
|
|
45
45
|
if (service.clients[configAlias] != null) {
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
|
-
if (service.layers == null) {
|
|
49
|
-
service.layers = [Layer.Global];
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Sensitivity *adds* a layer the service is willing to serve. The Mongo copy of this
|
|
53
|
-
* block tests `includes` where it means `!includes`, so it only ever appends a layer
|
|
54
|
-
* that is already there — a latent no-op, corrected here.
|
|
55
|
-
*/
|
|
56
|
-
if (config.serviceSensitive === true && !service.layers.includes(Layer.Service)) {
|
|
57
|
-
service.layers.push(Layer.Service);
|
|
58
|
-
}
|
|
59
|
-
if (config.entitySensitive === true && !service.layers.includes(Layer.Entity)) {
|
|
60
|
-
service.layers.push(Layer.Entity);
|
|
61
|
-
}
|
|
62
48
|
const meta = (config.meta ?? {});
|
|
63
49
|
const pool = new Pool(prepareConfig(config));
|
|
64
50
|
/**
|
|
@@ -188,19 +174,6 @@ export const makePostgresDbService = (alias = DEFAULT_ALIAS) => {
|
|
|
188
174
|
/** A value that was never locked decrypts to itself rather than failing. */
|
|
189
175
|
fields.includes(field) ? await key.decrypt(value).catch(() => value) : value
|
|
190
176
|
])));
|
|
191
|
-
},
|
|
192
|
-
reinitializeContext: (context) => {
|
|
193
|
-
const _service = makePostgresDbService(alias);
|
|
194
|
-
_service.ctx = context;
|
|
195
|
-
_service.layers = service.layers;
|
|
196
|
-
/**
|
|
197
|
-
* Pools carry over instead of being reopened. A layer switch changes the Postgres
|
|
198
|
-
* *schema*, not the server, and `max_connections` is a cluster wide budget — a pool
|
|
199
|
-
* per tenant exhausts it long before the tenants run out. The handle cache is
|
|
200
|
-
* deliberately *not* carried: its schema name is what the switch just changed.
|
|
201
|
-
*/
|
|
202
|
-
Object.assign(_service.clients, service.clients);
|
|
203
|
-
return _service;
|
|
204
177
|
}
|
|
205
178
|
}, service => async () => {
|
|
206
179
|
const context = assertContext(service.ctx, location);
|
package/build/service.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,aAAa,EAAE,
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/postgres",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.21",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -22,18 +22,18 @@
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@owlmeans/basic-keys": "^0.1.18-rc.
|
|
26
|
-
"@owlmeans/context": "^0.1.18-rc.
|
|
27
|
-
"@owlmeans/postgres-resource": "^0.1.18-rc.
|
|
28
|
-
"@owlmeans/resource": "^0.1.18-rc.
|
|
29
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
25
|
+
"@owlmeans/basic-keys": "^0.1.18-rc.19",
|
|
26
|
+
"@owlmeans/context": "^0.1.18-rc.15",
|
|
27
|
+
"@owlmeans/postgres-resource": "^0.1.18-rc.20",
|
|
28
|
+
"@owlmeans/resource": "^0.1.18-rc.16",
|
|
29
|
+
"@owlmeans/server-context": "^0.1.18-rc.19",
|
|
30
30
|
"drizzle-orm": "~0.45.2",
|
|
31
31
|
"pg": "^8.22.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@owlmeans/dep-config": "workspace:*",
|
|
35
|
-
"@owlmeans/test-integration": "^0.1.18-rc.
|
|
36
|
-
"@types/bun": "^1.
|
|
35
|
+
"@owlmeans/test-integration": "^0.1.18-rc.15",
|
|
36
|
+
"@types/bun": "^1.4.0",
|
|
37
37
|
"@types/node": "^26.1.0",
|
|
38
38
|
"@types/pg": "^8.20.4",
|
|
39
39
|
"nodemon": "^3.1.14",
|
package/src/service.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { makeKeyPairModel } from '@owlmeans/basic-keys'
|
|
2
|
-
import { assertContext
|
|
3
|
-
import type { BasicContext } from '@owlmeans/context'
|
|
2
|
+
import { assertContext } from '@owlmeans/context'
|
|
4
3
|
import {
|
|
5
4
|
makeTx, pgErrorToResourceError, pgIdentifier, refOf, resolvePlaceholders
|
|
6
5
|
} from '@owlmeans/postgres-resource'
|
|
@@ -28,7 +27,7 @@ export const makePostgresDbService = (alias: string = DEFAULT_ALIAS): PostgresSe
|
|
|
28
27
|
/**
|
|
29
28
|
* Handles are cached per config alias rather than rebuilt per call: `db()` runs on every
|
|
30
29
|
* single resource operation, and both the Drizzle binding and the schema name are fixed
|
|
31
|
-
* for the lifetime of
|
|
30
|
+
* for the lifetime of the service — one instance per alias, for the whole process.
|
|
32
31
|
*/
|
|
33
32
|
const handles: Map<string, PostgresDb> = new Map()
|
|
34
33
|
const deferred: Map<string, Array<() => Promise<void>>> = new Map()
|
|
@@ -44,9 +43,9 @@ export const makePostgresDbService = (alias: string = DEFAULT_ALIAS): PostgresSe
|
|
|
44
43
|
drizzle: drizzle(pool) as NodePgDatabase<Record<string, never>>,
|
|
45
44
|
pool,
|
|
46
45
|
/**
|
|
47
|
-
* `name()` is the
|
|
48
|
-
* reads the same value as a database name.
|
|
49
|
-
*
|
|
46
|
+
* `name()` is the configured namespace — the Postgres SCHEMA here, where Mongo
|
|
47
|
+
* reads the same value as a database name. `pgIdentifier` holds it inside the
|
|
48
|
+
* 63 byte limit Postgres would otherwise truncate silently.
|
|
50
49
|
*/
|
|
51
50
|
schema: pgIdentifier(service.name(configAlias)),
|
|
52
51
|
database: poolDatabase(pool)
|
|
@@ -65,21 +64,6 @@ export const makePostgresDbService = (alias: string = DEFAULT_ALIAS): PostgresSe
|
|
|
65
64
|
return
|
|
66
65
|
}
|
|
67
66
|
|
|
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
67
|
const meta = (config.meta ?? {}) as PostgresMeta
|
|
84
68
|
const pool = new Pool(prepareConfig(config))
|
|
85
69
|
|
|
@@ -125,7 +109,7 @@ export const makePostgresDbService = (alias: string = DEFAULT_ALIAS): PostgresSe
|
|
|
125
109
|
qualify: resourceAlias => {
|
|
126
110
|
const context = assertContext<Config, Context>(service.ctx as Context, location)
|
|
127
111
|
|
|
128
|
-
return refOf(context
|
|
112
|
+
return refOf(context, null, resourceAlias)
|
|
129
113
|
},
|
|
130
114
|
|
|
131
115
|
query: async <Row extends QueryResultRow = QueryResultRow>(
|
|
@@ -135,7 +119,7 @@ export const makePostgresDbService = (alias: string = DEFAULT_ALIAS): PostgresSe
|
|
|
135
119
|
const context = assertContext<Config, Context>(service.ctx as Context, location)
|
|
136
120
|
try {
|
|
137
121
|
const result = await db.pool.query<Row>(
|
|
138
|
-
resolvePlaceholders(text, context
|
|
122
|
+
resolvePlaceholders(text, context, null), params as never[]
|
|
139
123
|
)
|
|
140
124
|
|
|
141
125
|
return result.rows
|
|
@@ -152,8 +136,8 @@ export const makePostgresDbService = (alias: string = DEFAULT_ALIAS): PostgresSe
|
|
|
152
136
|
await client.query('BEGIN')
|
|
153
137
|
const result = await fn(makeTx(
|
|
154
138
|
client,
|
|
155
|
-
text => resolvePlaceholders(text, context
|
|
156
|
-
resourceAlias => refOf(context
|
|
139
|
+
text => resolvePlaceholders(text, context, null),
|
|
140
|
+
resourceAlias => refOf(context, null, resourceAlias)
|
|
157
141
|
))
|
|
158
142
|
await client.query('COMMIT')
|
|
159
143
|
|
|
@@ -243,23 +227,6 @@ export const makePostgresDbService = (alias: string = DEFAULT_ALIAS): PostgresSe
|
|
|
243
227
|
]
|
|
244
228
|
))
|
|
245
229
|
)
|
|
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
230
|
}
|
|
264
231
|
}, service => async () => {
|
|
265
232
|
const context = assertContext<Config, Context>(service.ctx as Context, location)
|
package/tests/crud.spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
-
import { makePostgresResource } from '@owlmeans/postgres-resource'
|
|
2
|
+
import { DEFAULT_PAGE_SIZE, makePostgresResource } from '@owlmeans/postgres-resource'
|
|
3
3
|
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
4
|
-
import { RecordExists, UnknownRecordError } from '@owlmeans/resource'
|
|
4
|
+
import { RecordExists, UnknownRecordError, UnsupportedArgumentError } from '@owlmeans/resource'
|
|
5
5
|
import type { ResourceRecord } from '@owlmeans/resource'
|
|
6
6
|
|
|
7
7
|
import { gate, makeSuite, shapeOf } from './context.js'
|
|
@@ -146,16 +146,23 @@ describe('@owlmeans/postgres — resource CRUD against a real context', () => {
|
|
|
146
146
|
expect(loaded.createdAt).toBeInstanceOf(Date)
|
|
147
147
|
})
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
/**
|
|
150
|
+
* One call, several fields — the shape a list-then-take-the-first was standing in for.
|
|
151
|
+
* `load` answers with a record or `null`; `get` answers or raises.
|
|
152
|
+
*/
|
|
153
|
+
it('reads one record by criteria rather than by id, and refuses an unknown one', async () => {
|
|
154
|
+
const byEmail = await users.load({ email: 'a@b.c' })
|
|
151
155
|
expect(byEmail?.id).toBe(seeded.id as string)
|
|
152
156
|
|
|
153
|
-
expect(await users.load('
|
|
154
|
-
await
|
|
157
|
+
expect(await users.load({ email: 'a@b.c', status: 'active', age: 21 })).not.toBeNull()
|
|
158
|
+
expect(await users.load({ email: 'a@b.c', status: 'banned' })).toBeNull()
|
|
159
|
+
|
|
160
|
+
expect(await users.load({ email: 'nobody@nowhere' })).toBeNull()
|
|
161
|
+
await expect(users.get({ email: 'nobody@nowhere' })).rejects.toThrow(UnknownRecordError)
|
|
155
162
|
})
|
|
156
163
|
|
|
157
164
|
it('patches by merge where update replaces the whole record', async () => {
|
|
158
|
-
const patched = await users.patch
|
|
165
|
+
const patched = await users.patch({ id: seeded.id, age: 22 })
|
|
159
166
|
expect(patched.age).toBe(22)
|
|
160
167
|
expect(patched.email).toBe('a@b.c')
|
|
161
168
|
|
|
@@ -170,21 +177,55 @@ describe('@owlmeans/postgres — resource CRUD against a real context', () => {
|
|
|
170
177
|
await users.create({ email: 'third@b.c', status: 'active' })
|
|
171
178
|
|
|
172
179
|
const banned = await users.list({ status: 'banned' })
|
|
173
|
-
expect(banned.
|
|
180
|
+
expect(banned.total).toBe(2)
|
|
174
181
|
expect(banned.items.every(item => item.status === 'banned')).toBe(true)
|
|
175
182
|
|
|
176
|
-
const paged = await users.list({}, {
|
|
183
|
+
const paged = await users.list({}, { page: 0, size: 2 })
|
|
177
184
|
expect(paged.items).toHaveLength(2)
|
|
178
|
-
expect(paged.
|
|
185
|
+
expect(paged.total).toBe(3)
|
|
186
|
+
expect(paged.page).toBe(0)
|
|
187
|
+
expect(paged.size).toBe(2)
|
|
188
|
+
|
|
189
|
+
/** A page past the end is empty, and the total still describes the whole match. */
|
|
190
|
+
const beyond = await users.list({}, { page: 5, size: 2 })
|
|
191
|
+
expect(beyond.items).toHaveLength(0)
|
|
192
|
+
expect(beyond.total).toBe(3)
|
|
179
193
|
|
|
180
|
-
const sorted = await users.list({}, {
|
|
194
|
+
const sorted = await users.list({}, { sort: ['email'] })
|
|
181
195
|
expect(sorted.items.map(item => item.email)).toEqual(['a@b.c', 'second@b.c', 'third@b.c'])
|
|
196
|
+
|
|
197
|
+
const descending = await users.list({}, { sort: [{ field: 'email', order: 'desc' }] })
|
|
198
|
+
expect(descending.items.map(item => item.email)).toEqual(['third@b.c', 'second@b.c', 'a@b.c'])
|
|
199
|
+
|
|
200
|
+
/** The same sort narrows a multi-record criteria down to one record in a single read. */
|
|
201
|
+
const last = await users.get({ status: 'banned' }, { sort: [{ field: 'email', order: 'desc' }] })
|
|
202
|
+
expect(last.email).toBe('second@b.c')
|
|
182
203
|
})
|
|
183
204
|
|
|
184
|
-
|
|
205
|
+
/**
|
|
206
|
+
* A relational table is unbounded, so an unasked-for read is capped. Reading everything
|
|
207
|
+
* stays possible, but only by saying so.
|
|
208
|
+
*/
|
|
209
|
+
it('caps an unpaged list at the default size and lifts the cap on size 0', async () => {
|
|
210
|
+
const capped = await users.list()
|
|
211
|
+
expect(capped.size).toBe(DEFAULT_PAGE_SIZE)
|
|
212
|
+
expect(capped.page).toBe(0)
|
|
213
|
+
expect(capped.items).toHaveLength(3)
|
|
214
|
+
|
|
215
|
+
const everything = await users.list({}, { size: 0 })
|
|
216
|
+
expect(everything.items).toHaveLength(3)
|
|
217
|
+
expect(everything.total).toBe(3)
|
|
218
|
+
expect(everything.size).toBeUndefined()
|
|
219
|
+
expect(everything.page).toBeUndefined()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('counts and purges by criteria, and refuses to purge everything', async () => {
|
|
185
223
|
expect(await users.count()).toBe(3)
|
|
186
224
|
expect(await users.count({ status: 'banned' })).toBe(2)
|
|
187
225
|
|
|
226
|
+
/** An empty criteria object would truncate the table — it has to be said in SQL instead. */
|
|
227
|
+
await expect(users.purge({})).rejects.toThrow(UnsupportedArgumentError)
|
|
228
|
+
|
|
188
229
|
expect(await users.purge({ email: 'third@b.c' })).toBe(1)
|
|
189
230
|
expect(await users.count()).toBe(2)
|
|
190
231
|
})
|
|
@@ -193,15 +234,22 @@ describe('@owlmeans/postgres — resource CRUD against a real context', () => {
|
|
|
193
234
|
await expect(users.create({ id: '00000000-0000-4000-8000-000000000001', email: 'no@b.c' }))
|
|
194
235
|
.rejects.toThrow(RecordExists)
|
|
195
236
|
|
|
196
|
-
const inserted = await users.insert
|
|
237
|
+
const inserted = await users.insert({
|
|
197
238
|
id: '00000000-0000-4000-8000-000000000002', email: 'explicit@b.c'
|
|
198
239
|
})
|
|
199
240
|
expect(inserted.id).toBe('00000000-0000-4000-8000-000000000002')
|
|
200
241
|
await users.delete(inserted.id as string)
|
|
201
242
|
})
|
|
202
243
|
|
|
244
|
+
it('refuses a TTL rather than dropping it — Postgres has no row expiry', async () => {
|
|
245
|
+
await expect(users.create({ email: 'ttl@b.c' }, { ttl: 60 }))
|
|
246
|
+
.rejects.toThrow(UnsupportedArgumentError)
|
|
247
|
+
await expect(users.save({ id: seeded.id, email: 'a@b.c' }, { ttl: 60 }))
|
|
248
|
+
.rejects.toThrow(UnsupportedArgumentError)
|
|
249
|
+
})
|
|
250
|
+
|
|
203
251
|
it('upserts on an arbiter that is not the primary key', async () => {
|
|
204
|
-
const upserted = await users.upsert
|
|
252
|
+
const upserted = await users.upsert({ email: 'a@b.c', age: 44 }, ['email'])
|
|
205
253
|
|
|
206
254
|
expect(upserted.id).toBe(seeded.id as string)
|
|
207
255
|
expect(upserted.age).toBe(44)
|
|
@@ -212,16 +260,18 @@ describe('@owlmeans/postgres — resource CRUD against a real context', () => {
|
|
|
212
260
|
await expect(users.create({ email: 'a@b.c' })).rejects.toThrow(RecordExists)
|
|
213
261
|
})
|
|
214
262
|
|
|
215
|
-
it('
|
|
263
|
+
it('takes a record by deleting it, atomically', async () => {
|
|
216
264
|
const before = await users.count()
|
|
217
265
|
expect(await posts.count()).toBe(1)
|
|
218
266
|
|
|
219
|
-
const
|
|
267
|
+
const taken = await users.take(seeded.id as string)
|
|
220
268
|
|
|
221
|
-
expect(
|
|
269
|
+
expect(taken.email).toBe('a@b.c')
|
|
222
270
|
expect(await users.count()).toBe(before - 1)
|
|
223
271
|
/** The cascade is the database's, not the resource's — the post goes with its owner. */
|
|
224
272
|
expect(await posts.count()).toBe(0)
|
|
225
|
-
|
|
273
|
+
/** `delete` reports absence, `take` raises on it. */
|
|
274
|
+
expect(await users.delete(seeded.id as string)).toBeNull()
|
|
275
|
+
await expect(users.take(seeded.id as string)).rejects.toThrow(UnknownRecordError)
|
|
226
276
|
})
|
|
227
277
|
})
|
package/tests/custom-sql.spec.ts
CHANGED
|
@@ -140,12 +140,13 @@ describe('@owlmeans/postgres — custom SQL with alias placeholders', () => {
|
|
|
140
140
|
expect(raw?.email_address).toBe('jrr@shire.me')
|
|
141
141
|
expect(raw?.email).toBeUndefined()
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
/** The resource is typed once, at the call to `resource()` — never per method. */
|
|
144
|
+
const record = await authors.selectOne('SELECT * FROM {{}} WHERE "name" = $1', ['Tolkien'])
|
|
144
145
|
expect(record?.email).toBe('jrr@shire.me')
|
|
145
146
|
expect(record?.id).toBe(tolkien.id as string)
|
|
146
147
|
|
|
147
148
|
/** jsonb comes back parsed, not as a string, on both routes. */
|
|
148
|
-
const [book] = await books.select
|
|
149
|
+
const [book] = await books.select(`SELECT * FROM {{}} WHERE "title" = $1`, ['The Silmarillion'])
|
|
149
150
|
expect(book.meta).toEqual({ posthumous: true })
|
|
150
151
|
})
|
|
151
152
|
|