@geonosis/db 0.2.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 +151 -0
- package/README.md +177 -8
- package/dist/index.cjs +1152 -171
- package/dist/index.d.cts +490 -78
- package/dist/index.d.ts +490 -78
- package/dist/index.js +1132 -167
- package/package.json +11 -3
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
|
@@ -42,6 +42,13 @@ const listInvoices = seam.scoped(async (executor, params: { tenantId: string })
|
|
|
42
42
|
const sweep = seam.scopedAsOps(async (executor, params: { since: number }) => …)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
`scoped` reads the tenant out of a query's own parameters, under `DEFAULT_TENANT_KEY` — `tenantId`
|
|
46
|
+
— or under the `tenantKey` the seam was built with, so a repo whose queries carry `{ orgId }` names
|
|
47
|
+
the key once instead of wrapping every call site in `inTenant` by hand. The key is part of the
|
|
48
|
+
type: parameters that do not carry it do not compile, and parameters that carry it as nothing at
|
|
49
|
+
runtime are a `DbRefusal` naming the key rather than a query that quietly runs outside every
|
|
50
|
+
session.
|
|
51
|
+
|
|
45
52
|
`tenantSetting` and `opsSetting` have NO defaults. The name of a session setting is the one thing
|
|
46
53
|
this package cannot pick for you: the seam and the policies must agree on it exactly, and a default
|
|
47
54
|
here would be one repo's name compiled into every other repo's wall. `opsValue` defaults to
|
|
@@ -114,7 +121,10 @@ const records = createConnections({
|
|
|
114
121
|
|
|
115
122
|
export default {
|
|
116
123
|
fetch: (request, env, ctx) =>
|
|
117
|
-
records.withConnection(
|
|
124
|
+
records.withConnection(
|
|
125
|
+
() => handle(request),
|
|
126
|
+
(closing) => ctx.waitUntil(closing),
|
|
127
|
+
),
|
|
118
128
|
}
|
|
119
129
|
```
|
|
120
130
|
|
|
@@ -125,6 +135,17 @@ instead of something every caller threads through. `sessionDb()` resolves the in
|
|
|
125
135
|
and REFUSES outside a frame, where a handle it opened would be closed by nobody — the repo this
|
|
126
136
|
came from returns one there and says so in a comment. `open()` is for a caller who will close it.
|
|
127
137
|
|
|
138
|
+
A handle does not outlive its invocation: used after the frame that opened it has ended, it answers
|
|
139
|
+
with a `DbRefusal` naming the unit of work it came from. Without that, the answer is the driver's,
|
|
140
|
+
at whatever line the query sits on — `Cannot use a pool after calling end on the pool` from
|
|
141
|
+
node-postgres, and on workerd a refusal to do I/O on behalf of another request — which reads as a
|
|
142
|
+
bug in the query rather than as a lifetime that was crossed.
|
|
143
|
+
|
|
144
|
+
A nested `withConnection` JOINS the invocation it is inside, so a request that reaches two
|
|
145
|
+
databases still has one lifetime. A caller that needs its own frame regardless passes
|
|
146
|
+
`InvocationOptions`: `withConnection(run, release, { named: 'charge', own: true })`. `named` is what
|
|
147
|
+
a leaked handle is named with; `own` is what a durable step needs.
|
|
148
|
+
|
|
128
149
|
`onStatement` reports every statement a handle sends, the transaction's own `begin` and `commit`
|
|
129
150
|
included — a count of round trips that leaves those out is not a count of round trips. It is what
|
|
130
151
|
the statement census reads.
|
|
@@ -151,6 +172,12 @@ outside on the runner's connection.
|
|
|
151
172
|
createRuntime({ journal, scope: perStepConnection(records) })
|
|
152
173
|
```
|
|
153
174
|
|
|
175
|
+
Each step opens its OWN frame, even when the entrypoint has already wrapped the whole run in one.
|
|
176
|
+
A Cloudflare Workflows entrypoint that does that would otherwise hand every step the RUN's handle,
|
|
177
|
+
and workerd refuses I/O on a socket opened for another request. `perStep(name)` takes the step's
|
|
178
|
+
name where the caller has one — `RunScope` calls it with none — so a handle that leaked out of a
|
|
179
|
+
step is refused naming the step it came from.
|
|
180
|
+
|
|
154
181
|
The type is not imported in either direction. `@geonosis/db` and a workflow engine are sibling
|
|
155
182
|
foundations, and what crosses between them is the shape.
|
|
156
183
|
|
|
@@ -163,24 +190,160 @@ its own transaction, each recorded in `migrationsTable` so the next run skips it
|
|
|
163
190
|
thing that can apply a folder. A consumer with a migrator of their own implements `Driver` around
|
|
164
191
|
it, which is two methods.
|
|
165
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
|
+
|
|
166
270
|
Connection POOLING and caching belong to the deployment, not to this package. A managed pooler in
|
|
167
271
|
front of Postgres is reached the same way any other connection string is; if that pooler caches
|
|
168
272
|
query results, turn the caching OFF — this seam's correctness rests on `set_config` being local to
|
|
169
273
|
the transaction that a cached result never re-enters.
|
|
170
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
|
+
|
|
171
332
|
## The exam
|
|
172
333
|
|
|
173
334
|
The four proofs that made this worth extracting ship as runner-agnostic functions a consumer runs
|
|
174
335
|
against THEIR session — no test framework rides along; a case fails by throwing a
|
|
175
336
|
`ConformanceFailure` from [`@geonosis/conformance`](../conformance):
|
|
176
337
|
|
|
177
|
-
| Export
|
|
178
|
-
|
|
|
179
|
-
| `tenantIsolationConformance`
|
|
180
|
-
| `statementCensusConformance`
|
|
181
|
-
| `concurrentTenantsConformance`
|
|
182
|
-
| `forcedRowLevelSecurityConformance` | every guarded table forces RLS, so the owner is behind the wall too
|
|
183
|
-
| `
|
|
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 |
|
|
184
347
|
|
|
185
348
|
```ts
|
|
186
349
|
for (const { name, run } of sessionConformance(subject)) {
|
|
@@ -188,4 +351,10 @@ for (const { name, run } of sessionConformance(subject)) {
|
|
|
188
351
|
}
|
|
189
352
|
```
|
|
190
353
|
|
|
354
|
+
`SeamUnderTest` carries `scoped` and the `tenantKey` those queries use — a seam with no `scoped` is
|
|
355
|
+
REFUSED by name rather than passing a claim nothing was asked of. A `ConnectionsSubject` is
|
|
356
|
+
`{ connections, perStep?, probe? }`: the `ConnectionsUnderTest` is `sessionDb` and `withConnection`,
|
|
357
|
+
`perStep` is the per-step scope BY NAME, and `probe` is any statement the role may run (`select 1`
|
|
358
|
+
when nothing is named).
|
|
359
|
+
|
|
191
360
|
This package sits its own exam over `nodePostgresDriver` against a real Postgres, on both majors.
|