@geonosis/db 0.3.0 → 0.5.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,151 @@
1
+ # @geonosis/db
2
+
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d15a654: The tarball carries `CHANGELOG.md`, so `geonosis update` can read what a db bump changed (#268) instead of naming the floor as unreadable — the line the other four floors already had.
8
+ - Updated dependencies [d15a654]
9
+ - @geonosis/conformance@0.2.1
10
+
11
+ ## 0.5.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 5bc2eed: **`entityMigrations`: an entity's table and its wall, as the two files a consumer applies.** Four
16
+ exports added, none removed; the db door goes 78 → 82. `entityMigrations(options)` renders them;
17
+ `ENTITY_PLACEHOLDERS` is the pair a consumer whose migrator templates fills in, and is on the door
18
+ because passing it is how the token form is asked for; `EntityDdlOptions` and `EntityMigration` are
19
+ that call's argument and answer.
20
+
21
+ The tenant column DEFAULTS to the session setting, so a write that names no tenant still lands under
22
+ the one its transaction is open for — the column and the policy then say the same thing, and neither
23
+ depends on a caller remembering. RLS is enabled AND forced, and the policies are the same
24
+ `tenantPolicySet` the DDL and drizzle doors both render, so a table has one wall however it was
25
+ created. Rendered with `ENTITY_PLACEHOLDERS` it is the same DDL carrying `{{tenantSetting}}` and
26
+ `{{opsSetting}}`, for a migrator that templates: a `{{token}}` is a hole a migrator fills rather than
27
+ a name Postgres is ever handed, so policy DDL accepts one where `createSessionSeam` still refuses it.
28
+ Every finding squawk 2.63.0 has on the rendered DDL is accepted on the statement it belongs to, with
29
+ the reason beside the rule names — 0 issues over both files, and the findings come back the moment an
30
+ acceptance is taken off. `geonosis-release schema` reads an entity's migrations with no change to
31
+ the gate. The exam's own entity table is this renderer's output, so the DDL and the generated verbs
32
+ are proven against each other on both Postgres majors — under this suite's own setting names and
33
+ under a second pair belonging to nothing else, so a default leaking into the renderer, the policies
34
+ or the session shows up as a wall that lets nothing through.
35
+ - ccf04d8: **`defineEntity`: one payload table, one business key, every verb and both undos written once
36
+ (#251).** A payload table with a business key got the same ten things hand-written for it every
37
+ time — `get` / `list` / `listAll` / `upsert` / `retire`, the two compensations, a page envelope and a
38
+ keyset cursor — and every copy of that was a chance to forget the lock, the soft delete or the
39
+ tenant.
40
+
41
+ Sixteen exports added, none removed; the db door goes 62 → 78. Each of them, and why it is on the
42
+ door rather than inside:
43
+
44
+ - `defineEntity` — the declaration itself, and `undosOf(entity)`, the compensation registry as plain
45
+ data an engine reads by name (`@geonosis/db` and an engine are sibling foundations, so what
46
+ crosses between them is the shape).
47
+ - `EntityDefinition` — the argument, for a repo that builds one in a helper of its own; and
48
+ `EntityColumns`, `EntityOrdering`, `EntityRow` with it, because each is named inside it and a type
49
+ reachable from an exported one has to be reachable from the door.
50
+ - `Entity` — the return, for a signature that takes an entity; `EntityPage` — what `list` answers;
51
+ `EntityUndo`, `UpsertOutput`, `RetireOutput` — what an engine's registry names when it reads the
52
+ undos.
53
+ - `DEFAULT_ENTITY_PAGE`, `MAX_ENTITY_PAGE` — the bounds `list` clamps to, for an API layer that
54
+ validates its own page size against the same two numbers rather than restating them.
55
+ - `entityConformance`, `EntitySubject`, `EntityUnderTest` — the exam a consumer runs against their
56
+ own implementation.
57
+
58
+ `SessionSeam` gains `send`, so a generated query is written over the seam alone and runs on
59
+ whichever kind of handle that seam was built for.
60
+
61
+ Every verb goes through `scoped`, so a tenant is named before any of them runs and there is no door
62
+ on the entity that skips one (D-014). `list` is keyset on the listing index and reads one row more
63
+ than the page, because that extra row is the only honest way to know there is a next page;
64
+ `nextCursor` is absent on the last page rather than empty, and a cursor minted under another
65
+ ordering or another width is a `DbRefusal` rather than the first page served twice. The exam counts
66
+ what a WRITE costs, too — five statements, the row locked before the insert — so a round trip cannot
67
+ be added to an entity's write path silently, and it checks that the table's DDL, its policies and its
68
+ session all name the SAME two settings, which a table rendered with the defaults cannot show. `upsert` reads
69
+ the row it is about to replace under `for update` — at READ COMMITTED a plain select names the row
70
+ as it was before a concurrent commit, and the undo would then restore a version the write never
71
+ replaced — and calls a retired row's previous state nothing, so its undo retires again rather than
72
+ reviving. The exam runs over the executor port AND over a drizzle database, on Postgres 17.10 and
73
+ 18.4.
74
+
75
+ ## 0.4.0
76
+
77
+ ### Minor Changes
78
+
79
+ - 9d76504: **Drizzle-native: the seam, the driver, the handle and the wall (#250, #266, #267, #273).** A repo
80
+ whose queries are drizzle builders wrote an adapter for every one of those four, and now writes
81
+ none.
82
+
83
+ Sixteen exports added, none removed. `drizzleSession(options?)` — a `SessionHandles<Db>` whose
84
+ `send` hands each of a statement's chunks to `sql.raw` and each of its values to `sql.param`, and
85
+ whose `transaction` hands the query drizzle's own `tx`, so no text with placeholders in it is
86
+ written for something else to read back out; the `SessionHandles<Handle>` and `SqlFragment` types a
87
+ consumer implements that port with, and `fragment` / `asStatement`, which are what implementing it
88
+ for a third library takes and are exported for that alone (the port's own default and the
89
+ `set_config` builder are NOT: nothing outside this package calls either); `drizzleTenantPolicies(tools,
90
+ options)` and `tenantPolicySet(options)`, the one policy source rendered as `pgPolicy` declarations
91
+ drizzle-kit generates a migration from; `forceRowLevelSecurity(table, schema?)`, the statement no
92
+ drizzle declaration can say (neither drizzle-orm 0.45.2 nor drizzle-kit 0.31.10 contains `FORCE ROW
93
+ LEVEL SECURITY` anywhere, measured 2026-09-02); the `DrizzleDatabase`, `DrizzleModuleLike`,
94
+ `DrizzleSql`, `DrizzleSessionOptions`, `DrizzlePolicyTools`, `TenantPolicy` and `DrizzleFactory`
95
+ types.
96
+
97
+ CHANGED shapes, every one of them: `Driver<Handle = Connection>`, `OpenConnection<Handle>`,
98
+ `Connections<Handle>`, `ConnectionsConfig<Handle>` (new `over`) and `createConnections<Handle>` take
99
+ the handle a driver returns as a type parameter — a default-only change for the executor port, and
100
+ what lets `sessionDb()` answer with the database itself. `SessionSeam<Key, Handle>`,
101
+ `SessionSeamConfig<Key, Handle>` (new `over`) and `Query<Params, Result, Handle>` likewise; the seam
102
+ now also exposes `tenantKey`. `SessionSubject<Handle>`, `SeamUnderTest<Handle>`,
103
+ `RecordingConnection<Handle>`, `ConnectionsUnderTest<Handle>` and `ConnectionsSubject<Handle>` are
104
+ generic and take an optional `run` — the exam's own SQL now carries no parameters, so a handle of
105
+ any kind can be asked it as the text it is. `nodePostgresDriver({ drizzle })` returns
106
+ `Driver<Db>`. The ops lever's statement binds `statement_timeout` as a parameter rather than
107
+ inlining the name.
108
+
109
+ #266, held by a new `connectionsConformance` case: `sessionDb()` used to rebuild the handle out of
110
+ the port, so everything a driver attached — the ORM database, its schema, its logger — was
111
+ `undefined` inside every scoped query (17 names dropped, measured). A handle now keeps everything
112
+ `open()` returned; the drizzle guard is a Proxy, because a database's surface is its builders.
113
+ `sessionConformance`, `connectionsConformance` and `tenantIsolationConformance` run over a drizzle
114
+ subject on Postgres 17.10 and 18.4, and a table whose policies were DECLARED is walled there too.
115
+ drizzle-orm is an OPTIONAL peer; a bundle importing only the seam carries no byte of it or of `pg`.
116
+
117
+ ## 0.3.0
118
+
119
+ ### Minor Changes
120
+
121
+ - 91e1b2d: **`connectionsConformance`, and two handles that were nobody's (#246, #248).** The handle discipline
122
+ had no exam: a handle used after its invocation ended answered with the DRIVER's error at whatever
123
+ line the query sat on — `Cannot use a pool after calling end on the pool`, measured on Postgres 17
124
+ and 18 — which reads as a bug in the query. It is a `DbRefusal` now, naming the unit of work the
125
+ handle came from. And a step nested inside a run JOINED the run's invocation, so a Cloudflare
126
+ Workflows entrypoint that wrapped its run body in `withConnection` handed every step a socket opened
127
+ for another request, which workerd refuses: `perStepConnection` opens its own frame regardless of
128
+ nesting, and `perStep(name)` takes the step's name so a leak is named with the step that leaked it.
129
+ `withConnection` takes `InvocationOptions` (`named`, `own`) for a caller who needs the same; nested
130
+ reuse stays the default. The exam runs over `nodePostgresDriver` on both majors.
131
+ - cb21027: **`scoped` reads the tenant under the key your queries carry (#245).** It could only read
132
+ `tenantId`, so a repo whose 200 queries say `{ orgId }` reached the seam through `inTenant` with a
133
+ wrapper per call site — the hand-written scope the seam exists to remove. `createSessionSeam` takes
134
+ `tenantKey`, default `tenantId` and exported as `DEFAULT_TENANT_KEY`, and the key stays in the type:
135
+ `scoped` on a seam built with `ownerId` will not compile a query whose parameters carry `tenantId`.
136
+ A key name rather than a selector, measured: under `tenantOf: (params) => string` a query carrying
137
+ no tenant at all compiles clean. Parameters that carry the key as nothing are now a `DbRefusal`
138
+ naming it — before, the seam named `undefined`, matched the executor's own empty scope, and ran the
139
+ query outside every session with no transaction and no `set_config` at all. `sessionConformance`
140
+ gains `scopedQueryConformance`, run here under a non-default key on Postgres 17 and 18.
141
+
142
+ ## 0.2.0
143
+
144
+ ### Minor Changes
145
+
146
+ - 54fba77: **`DbRefusal`: everything this seam refuses, it refuses as a class (#211).** `session.ts` and `connections.ts` threw a bare `Error` — and the config validators threw `TypeError`, which is what a broken driver throws — so a caller could only recognise a refusal by matching its prose, and a conformance case could not tell one from a crash (#213). Every refusal in the package is now a `DbRefusal`, exported from the root, and the two conformance cases about the SEAM's own refusals assert the class.
147
+
148
+ ### Patch Changes
149
+
150
+ - Updated dependencies [bb5e84b]
151
+ - @geonosis/conformance@0.2.0
package/README.md CHANGED
@@ -121,7 +121,10 @@ const records = createConnections({
121
121
 
122
122
  export default {
123
123
  fetch: (request, env, ctx) =>
124
- records.withConnection(() => handle(request), (closing) => ctx.waitUntil(closing)),
124
+ records.withConnection(
125
+ () => handle(request),
126
+ (closing) => ctx.waitUntil(closing),
127
+ ),
125
128
  }
126
129
  ```
127
130
 
@@ -187,26 +190,160 @@ its own transaction, each recorded in `migrationsTable` so the next run skips it
187
190
  thing that can apply a folder. A consumer with a migrator of their own implements `Driver` around
188
191
  it, which is two methods.
189
192
 
193
+ ### On drizzle
194
+
195
+ A repo whose queries are drizzle builders keeps them, and writes no adapter. Three doors, one for
196
+ each thing it would otherwise have had to translate:
197
+
198
+ ```ts
199
+ import { createConnections, createSessionSeam, drizzleSession, nodePostgresDriver } from '@geonosis/db'
200
+ import * as drizzleOrm from 'drizzle-orm'
201
+ import { drizzle } from 'drizzle-orm/node-postgres'
202
+
203
+ const over = await drizzleSession<Database>({ drizzleOrm })
204
+ const seam = createSessionSeam({ settings, tenantKey: 'orgId', over })
205
+ const driver = await nodePostgresDriver({
206
+ drizzle: (pool, { onStatement }) =>
207
+ drizzle(pool, { schema, logger: { logQuery: (query) => onStatement?.(query) } }),
208
+ })
209
+ const records = createConnections({ connectionString, driver, over })
210
+
211
+ const listInvoices = seam.scoped((db, params: { orgId: string }) =>
212
+ db.select().from(invoices),
213
+ )
214
+ ```
215
+
216
+ `over` is a `SessionHandles<Handle>`: how a session is opened on ONE kind of handle — `send`,
217
+ `opens`, `transaction`, and the `guard` that holds a handle to its invocation. A seam given none
218
+ drives the executor port's own.
219
+
220
+ What `send` takes is a `SqlFragment`: the SQL between the values and the values themselves, one more
221
+ chunk than there are values — a template literal's own shape, because that is what every library
222
+ that places values already speaks. The executor port renders it to numbered placeholders with `asStatement`;
223
+ `drizzleSession` hands each chunk to `sql.raw` and each value to `sql.param`, which places it as ONE
224
+ parameter — drizzle's `sql` TAG spreads an array into a tuple, which is not the query anybody wrote.
225
+ Nothing in the middle writes text with placeholders in it for something else to read back out.
226
+ `fragment(chunks, values)` builds one; the two of them are what implementing `SessionHandles` for a
227
+ third library takes, and are exported for that and nothing else.
228
+
229
+ The driver's `drizzle` option is a factory rather than a flag, because a repo's database carries its
230
+ own schema and options and a flag could only hand back one with neither. `open()` returns the
231
+ DATABASE itself, unwrapped — so `sessionDb()` inside an invocation answers with what drizzle
232
+ returned, builders and all (#266: a keeper that rebuilt the handle out of the port left every ORM
233
+ handle `undefined` inside every scoped query, and `connectionsConformance` now holds it). The
234
+ factory is handed this `open`'s `onStatement`, because drizzle sends its own `begin` and `commit`
235
+ through the database where the driver cannot see them.
236
+
237
+ The lifetime guard for a database is a Proxy: its surface is its builders, and a keeper that copies
238
+ two methods off it hands the query something that cannot write a query.
239
+
240
+ The tenant wall has ONE source and two doors. `tenantPolicySet` is the policies themselves — name,
241
+ mode, predicate; `tenantPolicies` renders them as the DDL above, and `drizzleTenantPolicies` renders
242
+ the same set as declarations for a table's own definition, so drizzle-kit generates the migration
243
+ and a repo on drizzle applies the kit's wall rather than keeping a second one:
244
+
245
+ ```ts
246
+ import { drizzleTenantPolicies, forceRowLevelSecurity } from '@geonosis/db'
247
+ import { sql } from 'drizzle-orm'
248
+ import { pgPolicy, pgTable, text } from 'drizzle-orm/pg-core'
249
+
250
+ export const invoices = pgTable('invoices', { … }, () =>
251
+ drizzleTenantPolicies({ pgPolicy, sql }, { settings, tenantColumn: 'org_id' }),
252
+ ).enableRLS()
253
+ ```
254
+
255
+ `DrizzlePolicyTools` is handed in rather than imported, so the door costs nothing to a consumer who
256
+ never installed drizzle. FORCE is not among the declarations — neither drizzle-orm 0.45.2 nor
257
+ drizzle-kit 0.31.10 contains the string `FORCE ROW LEVEL SECURITY` anywhere (measured 2026-09-02) —
258
+ so `forceRowLevelSecurity(table)` is the one statement to apply beside the generated migration, and
259
+ without it ENABLE alone exempts the table's owner.
260
+
261
+ Hand drizzle-orm IN, as above: a repo reaching this door already holds the library, `drizzleOrm` is
262
+ the copy its own queries are built with, and passing it means no resolver decides which copy the
263
+ seam speaks through. `nodePostgresDriver({ pg })` takes the same form for the same reason. Leave
264
+ either out and the module is imported on demand — the fallback for a composition root that has none
265
+ in hand, and the reason both of these are `async` at all.
266
+
267
+ drizzle-orm is an OPTIONAL peer, and `pg` is another: a consumer who imports only the seam bundles
268
+ neither.
269
+
190
270
  Connection POOLING and caching belong to the deployment, not to this package. A managed pooler in
191
271
  front of Postgres is reached the same way any other connection string is; if that pooler caches
192
272
  query results, turn the caching OFF — this seam's correctness rests on `set_config` being local to
193
273
  the transaction that a cached result never re-enters.
194
274
 
275
+ ## An entity
276
+
277
+ A payload table with one business key gets the same ten things written for it every time: five verbs,
278
+ two undos, a page envelope and a cursor. `defineEntity` is that declaration, and every verb it
279
+ generates goes through the seam's `scoped`, so a tenant is named before any of them runs and there is
280
+ no door on the entity that skips one:
281
+
282
+ ```ts
283
+ import { defineEntity, undosOf } from '@geonosis/db'
284
+
285
+ export const products = defineEntity({
286
+ indexed: (body: Product) => ({ name: body.name, sku: body.sku }),
287
+ item: (row) => ({ ...row.body, id: row.key }),
288
+ key: { column: 'product_id', param: 'productId' },
289
+ order: [{ column: 'name' }, { column: 'product_id' }],
290
+ seam,
291
+ table: 'products',
292
+ })
293
+
294
+ await products.upsert(db, { orgId, productId, body })
295
+ const page = await products.list(db, { orgId, limit: 20 })
296
+ ```
297
+
298
+ `get` / `list` / `listAll` / `upsert` / `retire`, and both undos. `EntityColumns` names the columns
299
+ beside the key — `body`, `created_at`, `updated_at`, `retired_at`, `tenant_id` — and defaults to the
300
+ DDL this package renders. `indexed` reads the columns kept beside the body OFF the body on every
301
+ write: the body is the only source, and a column a caller could set separately is a second answer to
302
+ what the entity says. `item` is what a read answers with, from an `EntityRow<Body>`.
303
+
304
+ `list` answers an `EntityPage<Item>`: `{ items, nextCursor }`, keyset on the listing index, never an
305
+ offset — an offset re-counts rows somebody is still inserting. The page is read one row longer than
306
+ it was asked for, because that extra row is the only honest way to know there is a next page.
307
+ `nextCursor` is absent on the last page rather than empty. `DEFAULT_ENTITY_PAGE` is 50 and
308
+ `MAX_ENTITY_PAGE` caps it. A cursor minted under another ordering, or of another width, is a
309
+ `DbRefusal` — answering it with the first page again is a caller silently reading the list twice.
310
+
311
+ `undosOf(entity)` is the compensation registry as plain data: `upsert` and `retire`, each an
312
+ `EntityUndo` taking `{ output, tenantId }` — what the forward verb answered and the tenant it ran
313
+ for. A write is undone by writing what was there before it, which is exactly what the forward verb
314
+ returned, so nothing has to be remembered anywhere else. An upsert that created the row is undone by
315
+ retiring it; a retire that found nothing is undone by doing nothing. A retired row's previous state
316
+ is "retired", so its undo retires again rather than reviving.
317
+
318
+ `entityMigrations` renders the table and its wall as the two files a consumer applies:
319
+ `0001_<table>.sql` and `0002_<table>_policies.sql`. The tenant column DEFAULTS to the session
320
+ setting, so a write that names no tenant still lands under the one its transaction is open for — the
321
+ column and the policy then say the same thing, and neither depends on a caller remembering. The
322
+ listing index is the `order` the entity declares, under the tenant. RLS is enabled AND forced, and
323
+ the policies are the same `tenantPolicySet` both other doors render. Rendered with
324
+ `ENTITY_PLACEHOLDERS` it is the same DDL carrying `{{tenantSetting}}` and `{{opsSetting}}`, for a
325
+ migrator that templates; `geonosis-release schema` reads either. Every finding squawk has on it is
326
+ accepted on the statement it belongs to, with the reason beside the rule names.
327
+
328
+ `entityConformance` is the exam: the verbs, an upsert answering with what it replaced, a keyset walk
329
+ one row at a time that must visit every row exactly once, both undos, and one tenant reaching none of
330
+ another's rows through any verb. It runs here over both kinds of handle on both Postgres majors.
331
+
195
332
  ## The exam
196
333
 
197
334
  The four proofs that made this worth extracting ship as runner-agnostic functions a consumer runs
198
335
  against THEIR session — no test framework rides along; a case fails by throwing a
199
336
  `ConformanceFailure` from [`@geonosis/conformance`](../conformance):
200
337
 
201
- | Export | What it asks |
202
- | --- | --- |
203
- | `tenantIsolationConformance` | what a query with no predicate can still see, and who is a superuser |
204
- | `statementCensusConformance` | a read in a session is four statements: no savepoint, one `set_config`, and the tenant named before the read |
205
- | `concurrentTenantsConformance` | eight tenants at once on one pool, and the two refusals |
206
- | `forcedRowLevelSecurityConformance` | every guarded table forces RLS, so the owner is behind the wall too |
207
- | `scopedQueryConformance` | a query that names its own tenant lands under it, refuses a second one, and refuses parameters carrying no tenant at all |
208
- | `sessionConformance` | all five |
209
- | `connectionsConformance` | the handle discipline: alive inside its invocation, refused outside one, refused after it, a frame of its own per step, and the leak named with the step it came from |
338
+ | Export | What it asks |
339
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
340
+ | `tenantIsolationConformance` | what a query with no predicate can still see, and who is a superuser |
341
+ | `statementCensusConformance` | a read in a session is four statements: no savepoint, one `set_config`, and the tenant named before the read |
342
+ | `concurrentTenantsConformance` | eight tenants at once on one pool, and the two refusals |
343
+ | `forcedRowLevelSecurityConformance` | every guarded table forces RLS, so the owner is behind the wall too |
344
+ | `scopedQueryConformance` | a query that names its own tenant lands under it, refuses a second one, and refuses parameters carrying no tenant at all |
345
+ | `sessionConformance` | all five |
346
+ | `connectionsConformance` | the handle discipline: alive inside its invocation, refused outside one, refused after it, a frame of its own per step, and the leak named with the step it came from |
210
347
 
211
348
  ```ts
212
349
  for (const { name, run } of sessionConformance(subject)) {