okmodel 0.1.1 → 0.3.0
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 +727 -37
- package/dist/adapters/capabilities.d.ts +34 -0
- package/dist/adapters/pg/bunsql-extra.d.ts +17 -0
- package/dist/adapters/pg/bunsql.d.ts +60 -0
- package/dist/adapters/pg/call.d.ts +2 -12
- package/dist/adapters/pg/nodepostgres-extra.d.ts +18 -0
- package/dist/adapters/pg/nodepostgres.d.ts +57 -0
- package/dist/adapters/pg/postgres-extra.d.ts +50 -1
- package/dist/adapters/pg/postgresjs.d.ts +0 -4
- package/dist/adapters/pg/session.d.ts +94 -0
- package/dist/adapters/pg/watch.d.ts +18 -0
- package/dist/contracts/catalog/enum.d.ts +31 -4
- package/dist/contracts/catalog/extension.d.ts +61 -0
- package/dist/contracts/catalog/object.d.ts +1 -0
- package/dist/contracts/catalog/privilege.d.ts +64 -0
- package/dist/contracts/catalog/routine.d.ts +56 -0
- package/dist/contracts/catalog/types.d.ts +179 -18
- package/dist/contracts/catalog/view.d.ts +73 -0
- package/dist/contracts/connection.d.ts +13 -0
- package/dist/contracts/driver.d.ts +18 -5
- package/dist/contracts/error.d.ts +17 -1
- package/dist/contracts/generator.d.ts +63 -0
- package/dist/contracts/index.d.ts +2 -2
- package/dist/contracts/index.js +1 -1
- package/dist/contracts/internal.js +11 -9
- package/dist/contracts/location.d.ts +24 -0
- package/dist/contracts/rows.d.ts +15 -0
- package/dist/dialects/pg/archive-bind.d.ts +22 -0
- package/dist/dialects/pg/column.d.ts +129 -10
- package/dist/dialects/pg/compile.d.ts +1 -0
- package/dist/dialects/pg/custom.d.ts +6 -0
- package/dist/dialects/pg/ddl.d.ts +65 -1
- package/dist/dialects/pg/enum.d.ts +10 -8
- package/dist/dialects/pg/ext/citext.d.ts +14 -0
- package/dist/dialects/pg/ext/citext.js +11 -0
- package/dist/dialects/pg/ext/index.d.ts +52 -0
- package/dist/dialects/pg/ext/index.js +6 -0
- package/dist/dialects/pg/ext/pg-trgm.d.ts +53 -0
- package/dist/dialects/pg/ext/pg-trgm.js +42 -0
- package/dist/dialects/pg/filter-parse.d.ts +19 -0
- package/dist/dialects/pg/filters.d.ts +19 -0
- package/dist/dialects/pg/fn/index.d.ts +107 -0
- package/dist/dialects/pg/fn/index.js +175 -0
- package/dist/dialects/pg/fn/touch.d.ts +14 -0
- package/dist/dialects/pg/index.d.ts +11 -2
- package/dist/dialects/pg/index.js +243 -1062
- package/dist/dialects/pg/introspect.d.ts +10 -1
- package/dist/dialects/pg/keys.d.ts +19 -3
- package/dist/dialects/pg/model.d.ts +223 -2
- package/dist/dialects/pg/operator-fit.d.ts +86 -0
- package/dist/dialects/pg/operators.d.ts +46 -1
- package/dist/dialects/pg/ops/arr-append.d.ts +9 -0
- package/dist/dialects/pg/ops/arr-ns.d.ts +7 -0
- package/dist/dialects/pg/ops/arr-remove.d.ts +9 -0
- package/dist/dialects/pg/ops/containedBy.d.ts +9 -0
- package/dist/dialects/pg/ops/contains.d.ts +8 -4
- package/dist/dialects/pg/ops/hasAnyKey.d.ts +8 -0
- package/dist/dialects/pg/ops/hasKey.d.ts +8 -0
- package/dist/dialects/pg/ops/json-ns.d.ts +6 -0
- package/dist/dialects/pg/ops/json-set.d.ts +12 -0
- package/dist/dialects/pg/ops/matches.d.ts +16 -0
- package/dist/dialects/pg/ops/overlaps.d.ts +9 -0
- package/dist/dialects/pg/ops/path.d.ts +13 -0
- package/dist/dialects/pg/preset.d.ts +76 -0
- package/dist/dialects/pg/relations.d.ts +59 -2
- package/dist/dialects/pg/role/check.d.ts +45 -0
- package/dist/dialects/pg/role/index.d.ts +50 -0
- package/dist/dialects/pg/role/sql.d.ts +44 -0
- package/dist/dialects/pg/schema.d.ts +109 -5
- package/dist/dialects/pg/table.d.ts +137 -11
- package/dist/dialects/pg/temporal.d.ts +9 -7
- package/dist/dialects/pg/tenancy.d.ts +103 -0
- package/dist/dialects/pg/through.d.ts +21 -0
- package/dist/dialects/pg/trait.d.ts +141 -0
- package/dist/dialects/pg/view/client.d.ts +30 -0
- package/dist/dialects/pg/view/depend.d.ts +35 -0
- package/dist/dialects/pg/view/index.d.ts +114 -0
- package/dist/dialects/pg/view/index.js +289 -0
- package/dist/dialects/pg/view/scratch.d.ts +21 -0
- package/dist/dialects/pg/where.d.ts +37 -0
- package/dist/runtime/aggregate.d.ts +25 -0
- package/dist/runtime/archive-handle.d.ts +18 -0
- package/dist/runtime/archive-rules.d.ts +12 -0
- package/dist/runtime/archive.d.ts +73 -0
- package/dist/runtime/batch.d.ts +20 -0
- package/dist/runtime/client.d.ts +114 -5
- package/dist/runtime/conflict.d.ts +2 -1
- package/dist/runtime/exposure.d.ts +27 -0
- package/dist/runtime/hookm.d.ts +39 -0
- package/dist/runtime/ids/index.d.ts +11 -0
- package/dist/runtime/ids/index.js +193 -0
- package/dist/runtime/ids/okid-extended.d.ts +19 -0
- package/dist/runtime/ids/okid-generator.d.ts +21 -0
- package/dist/runtime/ids/okid-shared.d.ts +89 -0
- package/dist/runtime/ids/okid.d.ts +24 -0
- package/dist/runtime/ids/uuid.d.ts +21 -0
- package/dist/runtime/lock.d.ts +22 -0
- package/dist/runtime/operator-sql.d.ts +8 -0
- package/dist/runtime/page.d.ts +25 -0
- package/dist/runtime/pg/bun.d.ts +30 -0
- package/dist/runtime/pg/bun.js +52 -0
- package/dist/runtime/pg/pg.d.ts +29 -0
- package/dist/runtime/pg/pg.js +52 -0
- package/dist/runtime/pg/pglite.js +16 -17
- package/dist/runtime/pg/postgresjs.js +14 -24
- package/dist/runtime/plan.d.ts +146 -7
- package/dist/runtime/preset-stack.d.ts +17 -0
- package/dist/runtime/presets.d.ts +48 -0
- package/dist/runtime/read-stream.d.ts +26 -0
- package/dist/runtime/safety/index.d.ts +159 -0
- package/dist/runtime/safety/index.js +294 -0
- package/dist/runtime/safety-hook.d.ts +42 -0
- package/dist/runtime/server-check.d.ts +34 -0
- package/dist/runtime/tenancy/index.d.ts +37 -0
- package/dist/runtime/tenancy/index.js +368 -0
- package/dist/runtime/trait-read.d.ts +30 -0
- package/dist/runtime/traits/index.d.ts +133 -0
- package/dist/runtime/traits/index.js +646 -0
- package/dist/runtime/transaction.d.ts +19 -0
- package/dist/runtime/tx.d.ts +22 -7
- package/dist/runtime/types.d.ts +376 -27
- package/dist/runtime/validate/bind.d.ts +11 -0
- package/dist/runtime/validate/closed.d.ts +16 -0
- package/dist/runtime/validate/engine.d.ts +41 -0
- package/dist/runtime/validate/hook.d.ts +17 -0
- package/dist/runtime/validate/index.d.ts +21 -0
- package/dist/runtime/validate/index.js +175 -0
- package/dist/runtime/validate/load.d.ts +13 -0
- package/dist/runtime/validate/places.d.ts +41 -0
- package/dist/runtime/validate/rules.d.ts +108 -0
- package/dist/runtime/validate/surface.d.ts +102 -0
- package/dist/runtime/write.d.ts +30 -5
- package/dist/shared/01yr20s5.js +149 -0
- package/dist/shared/0jc3ffzf.js +154 -0
- package/dist/shared/0v1g424p.js +26 -0
- package/dist/shared/1g0drakv.js +225 -0
- package/dist/shared/{kprcwaha.js → 1tfdfvhh.js} +5 -3
- package/dist/shared/{05w44d8g.js → 2ssqrv66.js} +767 -715
- package/dist/shared/3ekj9x92.js +210 -0
- package/dist/shared/49ypnfwt.js +1122 -0
- package/dist/shared/4e4c6vq2.js +62 -0
- package/dist/shared/4m9fn52z.js +70 -0
- package/dist/shared/{ycwz42az.js → 52szzakm.js} +6 -26
- package/dist/shared/5nmdenb6.js +1229 -0
- package/dist/shared/672t5vym.js +12 -0
- package/dist/shared/6bazvqwm.js +86 -0
- package/dist/shared/6w5j844a.js +201 -0
- package/dist/shared/{fgqgsb5z.js → 75xkjv0t.js} +2 -2
- package/dist/shared/7fvtrkwr.js +84 -0
- package/dist/shared/7m88x3e9.js +45 -0
- package/dist/shared/{mtkwv8wx.js → 7p2wbz7f.js} +1 -1
- package/dist/shared/83eaefwr.js +10 -0
- package/dist/shared/94ez8kzh.js +19 -0
- package/dist/shared/96ps4g1z.js +32 -0
- package/dist/shared/{xqbhg6cn.js → 999ade2q.js} +95 -10
- package/dist/shared/9m80mkac.js +158 -0
- package/dist/shared/9x25fxhc.js +150 -0
- package/dist/shared/a5sxz52y.js +13 -0
- package/dist/shared/axhk06q1.js +156 -0
- package/dist/shared/{dynq16vb.js → b066g80h.js} +8 -6
- package/dist/shared/bqha5cg6.js +229 -0
- package/dist/shared/bryqk9k9.js +31 -0
- package/dist/shared/c8aghh05.js +442 -0
- package/dist/shared/c9z5g0fm.js +309 -0
- package/dist/shared/{m3ypagh5.js → dbjej1tw.js} +124 -15
- package/dist/shared/dyj86cmj.js +69 -0
- package/dist/shared/e6wg00wd.js +164 -0
- package/dist/shared/{hadrfb9h.js → edwqqryd.js} +17 -14
- package/dist/shared/{qk3hxfja.js → fptpmvxp.js} +5 -5
- package/dist/shared/fs0ex00p.js +76 -0
- package/dist/shared/{jjb7yht9.js → g4f5kcx9.js} +3 -3
- package/dist/shared/gjthds9j.js +48 -0
- package/dist/shared/hcmdtwgh.js +102 -0
- package/dist/shared/{g5fhqmwe.js → jkc6ws1p.js} +6 -56
- package/dist/shared/{yd1kzwn2.js → jns2wx4t.js} +55 -12
- package/dist/shared/k34e0xf2.js +450 -0
- package/dist/shared/k3ted12y.js +33 -0
- package/dist/shared/k7dwkhvq.js +31 -0
- package/dist/shared/{8b04d28c.js → kgnc8grt.js} +2 -2
- package/dist/shared/{hd293j9g.js → kyxb62mc.js} +14 -2
- package/dist/shared/m78s7898.js +376 -0
- package/dist/shared/m9tv7xn7.js +404 -0
- package/dist/shared/meck53jv.js +99 -0
- package/dist/shared/mt5wswjz.js +90 -0
- package/dist/shared/n0dgs0pb.js +75 -0
- package/dist/shared/n442h54c.js +14 -0
- package/dist/shared/n4jqhpqz.js +27 -0
- package/dist/shared/n7mcc1gk.js +132 -0
- package/dist/shared/ne02ng5c.js +68 -0
- package/dist/shared/{mkjmqjv4.js → nfzc5z0w.js} +2 -104
- package/dist/shared/nsva2a6b.js +42 -0
- package/dist/shared/p24x2334.js +195 -0
- package/dist/shared/qbxzvwwc.js +88 -0
- package/dist/shared/{w8samvk1.js → qybh48nd.js} +1 -1
- package/dist/shared/r6nwkxnt.js +86 -0
- package/dist/shared/{xy79j5hq.js → rrstp3kb.js} +25 -36
- package/dist/shared/rwmrejjz.js +26 -0
- package/dist/shared/{zhc5vmgf.js → s6gqfn5j.js} +2 -2
- package/dist/shared/{523ft3e8.js → scwxrpx2.js} +7 -5
- package/dist/shared/{j5cn2963.js → t4tbsy9h.js} +13 -64
- package/dist/shared/{y9m15dfv.js → t616ynfn.js} +544 -32
- package/dist/shared/{ngk8gj61.js → tbszx8mz.js} +15 -1
- package/dist/shared/th14ygv2.js +69 -0
- package/dist/shared/tmbdywx0.js +178 -0
- package/dist/shared/ty3v3hfk.js +64 -0
- package/dist/shared/wekze34d.js +246 -0
- package/dist/shared/x6wysh83.js +1132 -0
- package/dist/shared/{rgs0st28.js → xrfnq5k8.js} +237 -434
- package/dist/shared/{x5x59srr.js → xva8v7qk.js} +416 -83
- package/dist/shared/{dkn3y60a.js → xw1d69vd.js} +76 -112
- package/dist/shared/y005b761.js +112 -0
- package/dist/shared/ydaz1sv3.js +55 -0
- package/dist/shared/{p0cbr3g2.js → zwmqxb0g.js} +1 -1
- package/dist/tooling/cli.js +40 -12
- package/dist/tooling/migrate/apply.d.ts +16 -0
- package/dist/tooling/migrate/commands.d.ts +12 -1
- package/dist/tooling/migrate/config.d.ts +8 -0
- package/dist/tooling/migrate/doctor.d.ts +25 -0
- package/dist/tooling/migrate/drift.d.ts +19 -0
- package/dist/tooling/migrate/engine.d.ts +36 -0
- package/dist/tooling/migrate/ext.d.ts +17 -0
- package/dist/tooling/migrate/extensions.d.ts +51 -0
- package/dist/tooling/migrate/managed.d.ts +17 -0
- package/dist/tooling/migrate/plan.d.ts +14 -1
- package/dist/tooling/migrate/project.d.ts +25 -1
- package/dist/tooling/migrate/type-name.d.ts +19 -0
- package/package.json +61 -4
- package/dist/shared/gk7cs7sp.js +0 -566
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# OKModel
|
|
2
2
|
|
|
3
|
-
okmodel is a catalog-first TypeScript ORM, PostgreSQL first. Other SQL databases are the direction.
|
|
3
|
+
okmodel is a catalog-first TypeScript ORM, PostgreSQL first. Other SQL databases are the direction. CI runs PostgreSQL 15 to 18. The schema is the single source. Migrations and queries come from it.
|
|
4
4
|
|
|
5
|
-
Version 0.
|
|
5
|
+
Version 0.3.0. Apache-2.0.
|
|
6
6
|
|
|
7
7
|
## Contents
|
|
8
8
|
|
|
@@ -13,10 +13,47 @@ Version 0.1.1. Apache-2.0.
|
|
|
13
13
|
- [Push](#push)
|
|
14
14
|
- [Reviewed migrations](#reviewed-migrations)
|
|
15
15
|
- [One client](#one-client)
|
|
16
|
+
- [Write and read](#write-and-read)
|
|
17
|
+
- [Core](#core)
|
|
18
|
+
- [Keys](#keys)
|
|
19
|
+
- [Identity](#identity)
|
|
20
|
+
- [Number](#number)
|
|
21
|
+
- [Uuid](#uuid)
|
|
22
|
+
- [Generated in the app](#generated-in-the-app)
|
|
23
|
+
- [Slug](#slug)
|
|
24
|
+
- [Two columns](#two-columns)
|
|
16
25
|
- [Insert](#insert)
|
|
26
|
+
- [On conflict](#on-conflict)
|
|
17
27
|
- [Find](#find)
|
|
28
|
+
- [One](#one)
|
|
29
|
+
- [Count](#count)
|
|
30
|
+
- [Exists](#exists)
|
|
31
|
+
- [Update](#update)
|
|
32
|
+
- [Delete](#delete)
|
|
18
33
|
- [Errors](#errors)
|
|
19
|
-
|
|
34
|
+
- [What is in the box](#what-is-in-the-box)
|
|
35
|
+
- [Columns](#columns)
|
|
36
|
+
- [Hidden](#hidden)
|
|
37
|
+
- [Guarded](#guarded)
|
|
38
|
+
- [Sensitive](#sensitive)
|
|
39
|
+
- [Relations](#relations)
|
|
40
|
+
- [Operators](#operators)
|
|
41
|
+
- [Page and aggregate](#page-and-aggregate)
|
|
42
|
+
- [Request filters](#request-filters)
|
|
43
|
+
- [Presets](#presets)
|
|
44
|
+
- [Validation](#validation)
|
|
45
|
+
- [Transactions](#transactions)
|
|
46
|
+
- [Batch and locks](#batch-and-locks)
|
|
47
|
+
- [Traits](#traits)
|
|
48
|
+
- [Archive](#archive)
|
|
49
|
+
- [Tenancy](#tenancy)
|
|
50
|
+
- [Extensions](#extensions)
|
|
51
|
+
- [Domains](#domains)
|
|
52
|
+
- [Functions](#functions)
|
|
53
|
+
- [Triggers](#triggers)
|
|
54
|
+
- [Views](#views)
|
|
55
|
+
- [Roles and grants](#roles-and-grants)
|
|
56
|
+
- [okm ext and okm doctor](#okm-ext-and-okm-doctor)
|
|
20
57
|
- [Commands](#commands)
|
|
21
58
|
- [Roadmap](#roadmap)
|
|
22
59
|
- [Size](#size)
|
|
@@ -24,23 +61,19 @@ Version 0.1.1. Apache-2.0.
|
|
|
24
61
|
|
|
25
62
|
## Install
|
|
26
63
|
|
|
27
|
-
|
|
64
|
+
Four Postgres drivers. Pick the one the runtime already has. postgres.js is the default, on Node and Bun. node-postgres is the other choice on Node and Bun when `pg` is already installed. Bun.sql is built into Bun and runs only there. PGlite is in-process, on Node and Bun.
|
|
28
65
|
|
|
29
66
|
```sh
|
|
30
67
|
bun add okmodel postgres
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
PGlite is the in-process driver.
|
|
34
|
-
|
|
35
|
-
```sh
|
|
68
|
+
bun add okmodel pg
|
|
36
69
|
bun add okmodel @electric-sql/pglite
|
|
37
70
|
```
|
|
38
71
|
|
|
39
|
-
okmodel has no runtime dependencies.
|
|
72
|
+
Bun.sql needs no extra package: `import { connect } from "okmodel/pg/bun"`. okmodel has no runtime dependencies. Each installed driver is an optional peer.
|
|
40
73
|
|
|
41
74
|
## Quickstart
|
|
42
75
|
|
|
43
|
-
|
|
76
|
+
This builds two tables, applies them, and runs one script. The script writes an author and a note, then reads the note with its author. `DATABASE_URL` is a direct Postgres URL, not a pooler.
|
|
44
77
|
|
|
45
78
|
### Configure
|
|
46
79
|
|
|
@@ -56,6 +89,7 @@ if (url === undefined || url.length === 0) {
|
|
|
56
89
|
throw new Error("DATABASE_URL is not set");
|
|
57
90
|
}
|
|
58
91
|
|
|
92
|
+
// schema is the file. database is DATABASE_URL.
|
|
59
93
|
export default defineConfig({
|
|
60
94
|
schema: "./schema.ts",
|
|
61
95
|
database: url,
|
|
@@ -64,7 +98,7 @@ export default defineConfig({
|
|
|
64
98
|
|
|
65
99
|
### Schema
|
|
66
100
|
|
|
67
|
-
`t.identity()` is the primary key. An insert omits the id, and the row that comes back carries it. Identity ids come back as strings by default. `t.identity({ as: "number" })` returns numbers. Push and reviewed migrations are two ways to do the same job: pick one per database.
|
|
101
|
+
`authors` has a unique name. `notes` stores a title and points at an author. `t.identity()` is the primary key. An insert omits the id, and the row that comes back carries it. Identity ids come back as strings by default. `t.identity({ as: "number" })` returns numbers. Push and reviewed migrations are two ways to do the same job: pick one per database.
|
|
68
102
|
|
|
69
103
|
Tested on PostgreSQL 15 to 18.
|
|
70
104
|
|
|
@@ -83,9 +117,13 @@ const notes = table(
|
|
|
83
117
|
{
|
|
84
118
|
id: t.identity(),
|
|
85
119
|
title: t.text(),
|
|
86
|
-
|
|
120
|
+
// Points at the authors primary key.
|
|
121
|
+
authorId: t.bigint().references("authors"),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
// find({ include: { author: true } }) loads this.
|
|
125
|
+
relations: { author: one("authors") },
|
|
87
126
|
},
|
|
88
|
-
{ relations: { author: one("authors") } },
|
|
89
127
|
);
|
|
90
128
|
|
|
91
129
|
const tables = [authors, notes];
|
|
@@ -114,7 +152,7 @@ bunx okm migrate apply
|
|
|
114
152
|
|
|
115
153
|
### One client
|
|
116
154
|
|
|
117
|
-
|
|
155
|
+
Import `db` from this file wherever the server handles a request. The process keeps one client.
|
|
118
156
|
|
|
119
157
|
`db.ts`:
|
|
120
158
|
|
|
@@ -126,9 +164,128 @@ import schema from "./schema.ts";
|
|
|
126
164
|
const url = process.env.DATABASE_URL;
|
|
127
165
|
if (url === undefined || url.length === 0) throw new Error("DATABASE_URL is not set");
|
|
128
166
|
|
|
167
|
+
// Import this from the server. The process keeps one client.
|
|
129
168
|
export const db = connect(url, { schema });
|
|
130
169
|
```
|
|
131
170
|
|
|
171
|
+
A server keeps this client for the life of the process. A script can use `await using`, which closes the pool at the end of the block. `db.close()` is that call on a line you choose. A pool opened with `ssl` still waits out the driver's 30 second idle timer.
|
|
172
|
+
|
|
173
|
+
### Write and read
|
|
174
|
+
|
|
175
|
+
The script writes one author and one note, then reads the note with the author attached. `await using` closes the pool when the block ends.
|
|
176
|
+
|
|
177
|
+
`script.ts`:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { connect } from "okmodel/pg/postgresjs";
|
|
181
|
+
|
|
182
|
+
import schema from "./schema.ts";
|
|
183
|
+
|
|
184
|
+
const url = process.env.DATABASE_URL;
|
|
185
|
+
if (url === undefined || url.length === 0) throw new Error("DATABASE_URL is not set");
|
|
186
|
+
|
|
187
|
+
await using db = connect(url, { schema });
|
|
188
|
+
const author = await db.authors.insert({ name: "Lin" });
|
|
189
|
+
const note = await db.notes.insert({ title: "hello", authorId: author.id });
|
|
190
|
+
// include loads the author named on the note.
|
|
191
|
+
const found = await db.notes.find({
|
|
192
|
+
where: { id: note.id },
|
|
193
|
+
limit: 1,
|
|
194
|
+
include: { author: true },
|
|
195
|
+
});
|
|
196
|
+
const row = found[0];
|
|
197
|
+
if (author.name !== "Lin" || row?.title !== "hello" || row.author?.name !== "Lin") {
|
|
198
|
+
throw new Error("insert did not return the row");
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Core
|
|
203
|
+
|
|
204
|
+
The calls below use the same schema and the same client. Read them in order. Each one uses the row the previous call wrote. The last one closes the client.
|
|
205
|
+
|
|
206
|
+
### Keys
|
|
207
|
+
|
|
208
|
+
Each kind of key is its own table.
|
|
209
|
+
|
|
210
|
+
#### Identity
|
|
211
|
+
|
|
212
|
+
The quickstart uses this. The database assigns a bigint, the insert leaves the id out, and the row comes back with that id as a string. It works on every supported Postgres version.
|
|
213
|
+
|
|
214
|
+
`identity.ts`:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { schema, table, t } from "okmodel/pg";
|
|
218
|
+
|
|
219
|
+
// The insert leaves id out. The row comes back with it as a string.
|
|
220
|
+
const authors = table("authors", { id: t.identity(), name: t.text() });
|
|
221
|
+
export const app = schema({ tables: [authors] });
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### Number
|
|
225
|
+
|
|
226
|
+
`t.identity({ as: "number" })` is the same key, and the id comes back as a number.
|
|
227
|
+
|
|
228
|
+
`number.ts`:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { schema, table, t } from "okmodel/pg";
|
|
232
|
+
|
|
233
|
+
// Same identity key. The id comes back as a number.
|
|
234
|
+
const counts = table("counts", { id: t.identity({ as: "number" }), name: t.text() });
|
|
235
|
+
export const app = schema({ tables: [counts] });
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### Uuid
|
|
239
|
+
|
|
240
|
+
`t.id()` is a uuid primary key. The default is `uuidv7()`, which needs Postgres 18. On an older server, `okm migrate apply` stops with OKM1812 before it runs any statement. `t.id({ default: "uuidv4" })` uses `gen_random_uuid()`, which Postgres has included since 13. `t.id({ default: "none" })` means the insert supplies the id.
|
|
241
|
+
|
|
242
|
+
#### Generated in the app
|
|
243
|
+
|
|
244
|
+
Import `uuidv4`, `uuidv7`, or `okid` from `okmodel/ids` and pass it to `.default()`. Or set `defaults.id` on `schema()` for every id column that does not choose its own. A choice on the column wins. A generator is not a database default, so changing it does not create a migration, and an insert that bypasses okmodel has to supply the value.
|
|
245
|
+
|
|
246
|
+
`generated.ts`:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { uuidv4 } from "okmodel/ids";
|
|
250
|
+
import { schema, table, t } from "okmodel/pg";
|
|
251
|
+
|
|
252
|
+
// The client fills id when the insert omits it. The database stores no default.
|
|
253
|
+
const sessions = table("sessions", { id: t.text().primaryKey().default(uuidv4) });
|
|
254
|
+
export const app = schema({ tables: [sessions] });
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
#### Slug
|
|
258
|
+
|
|
259
|
+
`.primaryKey()` on one column is a key you supply, such as a slug. The insert includes it, and an update cannot change it.
|
|
260
|
+
|
|
261
|
+
`slug.ts`:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { schema, table, t } from "okmodel/pg";
|
|
265
|
+
|
|
266
|
+
// The insert supplies slug. An update cannot change it.
|
|
267
|
+
const slugs = table("slugs", { slug: t.text().primaryKey() });
|
|
268
|
+
export const app = schema({ tables: [slugs] });
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
#### Two columns
|
|
272
|
+
|
|
273
|
+
A key of two columns is the `primaryKey` option on the table. The names are the columns, in order.
|
|
274
|
+
|
|
275
|
+
`members.ts`:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import { schema, table, t } from "okmodel/pg";
|
|
279
|
+
|
|
280
|
+
// Both columns together are the key. The insert supplies both.
|
|
281
|
+
const members = table(
|
|
282
|
+
"members",
|
|
283
|
+
{ userId: t.integer(), orgId: t.integer() },
|
|
284
|
+
{ primaryKey: ["userId", "orgId"] },
|
|
285
|
+
);
|
|
286
|
+
export const app = schema({ tables: [members] });
|
|
287
|
+
```
|
|
288
|
+
|
|
132
289
|
### Insert
|
|
133
290
|
|
|
134
291
|
Insert an author, then a note that points at the author's id.
|
|
@@ -137,6 +294,7 @@ Insert an author, then a note that points at the author's id.
|
|
|
137
294
|
import { db } from "./db.ts";
|
|
138
295
|
|
|
139
296
|
const author = await db.authors.insert({ name: "Ada" });
|
|
297
|
+
// authorId is the id the insert just returned.
|
|
140
298
|
const note = await db.notes.insert({
|
|
141
299
|
title: "hello",
|
|
142
300
|
authorId: author.id,
|
|
@@ -146,11 +304,21 @@ if (author.name !== "Ada" || note.title !== "hello" || author.id.length === 0) {
|
|
|
146
304
|
}
|
|
147
305
|
```
|
|
148
306
|
|
|
307
|
+
### On conflict
|
|
308
|
+
|
|
309
|
+
The name is unique, so inserting Ada again would fail. `onConflict: "ignore"` skips that second insert. The other form updates the named columns when the row is already there.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// Ada is already there. ignore skips this insert.
|
|
313
|
+
await db.authors.insert({ name: "Ada" }, { onConflict: "ignore" });
|
|
314
|
+
```
|
|
315
|
+
|
|
149
316
|
### Find
|
|
150
317
|
|
|
151
|
-
|
|
318
|
+
`find` returns a list, so it needs a `limit` (OKM1101). `include` loads the author on each note. When the relation is itself a list, that include needs its own limit (OKM1105).
|
|
152
319
|
|
|
153
320
|
```ts
|
|
321
|
+
// limit is required. include loads the author.
|
|
154
322
|
const found = await db.notes.find({
|
|
155
323
|
where: { id: note.id },
|
|
156
324
|
limit: 5,
|
|
@@ -162,42 +330,554 @@ if (row?.title !== "hello" || row.author?.name !== "Ada") {
|
|
|
162
330
|
}
|
|
163
331
|
```
|
|
164
332
|
|
|
333
|
+
### One
|
|
334
|
+
|
|
335
|
+
`one` returns the single note for that id. More than one match throws `not_unique`.
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
// One row. More than one match throws.
|
|
339
|
+
const single = await db.notes.one({ where: { id: note.id } });
|
|
340
|
+
if (single.title !== "hello") throw new Error("one did not return the note");
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Count
|
|
344
|
+
|
|
345
|
+
`count` returns how many notes match. It does not return the rows.
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
// How many notes, not the rows themselves.
|
|
349
|
+
if ((await db.notes.count()) < 1) throw new Error("count did not see the note");
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Exists
|
|
353
|
+
|
|
354
|
+
`exists` returns true when a note matches. It does not return the row.
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
// True or false. The row stays in the database.
|
|
358
|
+
if (!(await db.notes.exists({ where: { id: note.id } }))) {
|
|
359
|
+
throw new Error("exists did not see the note");
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Update
|
|
364
|
+
|
|
365
|
+
`update` changes the columns in `set` on the rows that match `where`. A call with no `where` is OKM1102. `.all(reason)` is the form that means every row.
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
// where picks the row. set names the new title.
|
|
369
|
+
await db.notes.update({ where: { id: note.id }, set: { title: "next" } });
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Delete
|
|
373
|
+
|
|
374
|
+
`delete` removes the rows that match `where`. A call with no `where` is OKM1102. `.all(reason)` is the form that means every row.
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// where picks the row. Without it the call is refused.
|
|
378
|
+
await db.notes.delete({ where: { id: note.id } });
|
|
379
|
+
```
|
|
380
|
+
|
|
165
381
|
### Errors
|
|
166
382
|
|
|
167
|
-
`safe` returns
|
|
383
|
+
Inserting Ada again fails because the name is unique. `safe` returns that `OkmError` instead of throwing. `kind` is `unique`.
|
|
168
384
|
|
|
169
385
|
```ts
|
|
170
386
|
import { OkmError, safe } from "okmodel";
|
|
171
387
|
|
|
388
|
+
// safe returns the unique error instead of throwing.
|
|
172
389
|
const duplicate = await safe(db.authors.insert({ name: "Ada" }));
|
|
173
390
|
if (duplicate.ok || !(duplicate.error instanceof OkmError) || duplicate.error.kind !== "unique") {
|
|
174
391
|
throw new Error("expected an OkmError of kind unique");
|
|
175
392
|
}
|
|
393
|
+
await db.close();
|
|
176
394
|
```
|
|
177
395
|
|
|
178
|
-
|
|
396
|
+
## What is in the box
|
|
179
397
|
|
|
180
|
-
|
|
398
|
+
Each section below is one feature you can add to the schema above.
|
|
181
399
|
|
|
182
|
-
|
|
400
|
+
### Columns
|
|
401
|
+
|
|
402
|
+
A column can allow only a fixed list of strings. `.picklist()` adds that check to a string column. `t.enum()` creates a Postgres enum, and every column that uses the same name shares the list. A value outside the list is OKM1210. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
403
|
+
|
|
404
|
+
`columns.ts`:
|
|
183
405
|
|
|
184
406
|
```ts
|
|
407
|
+
import { schema, table, t } from "okmodel/pg";
|
|
408
|
+
|
|
409
|
+
const tasks = table("tasks", {
|
|
410
|
+
id: t.identity(),
|
|
411
|
+
// Only these three strings. Anything else is rejected.
|
|
412
|
+
status: t.varchar(20).picklist(["draft", "active", "done"]),
|
|
413
|
+
// A Postgres enum. Other columns named color share this list.
|
|
414
|
+
color: t.enum("color", ["red", "blue"]),
|
|
415
|
+
});
|
|
416
|
+
export const app = schema({ tables: [tasks] });
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Hidden
|
|
420
|
+
|
|
421
|
+
A password hash should not come back on an ordinary read. `.hidden()` leaves the column out of `find` and out of `include`. Name it in `select` when that call needs it.
|
|
422
|
+
|
|
423
|
+
`hidden.ts`:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
import { schema, table, t } from "okmodel/pg";
|
|
427
|
+
|
|
428
|
+
const users = table("users", {
|
|
429
|
+
id: t.identity(),
|
|
430
|
+
email: t.text(),
|
|
431
|
+
// Left out of find and include. A named select still returns it.
|
|
432
|
+
passwordHash: t.text().hidden(),
|
|
433
|
+
});
|
|
434
|
+
export const app = schema({ tables: [users] });
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Guarded
|
|
438
|
+
|
|
439
|
+
A role should not be set by whatever the caller sent. `.guarded()` drops the column from insert and update. The call that is allowed to set it passes `{ allow: ["role"] }`. Any other call is OKM1190.
|
|
440
|
+
|
|
441
|
+
`guarded.ts`:
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
import { schema, table, t } from "okmodel/pg";
|
|
185
445
|
import { connect } from "okmodel/pg/postgresjs";
|
|
186
446
|
|
|
187
|
-
|
|
447
|
+
// role is dropped from insert and update unless the call names it in allow.
|
|
448
|
+
const users = table("users", { id: t.identity(), email: t.text(), role: t.text().guarded() });
|
|
449
|
+
const app = schema({ tables: [users] });
|
|
188
450
|
|
|
189
|
-
|
|
190
|
-
|
|
451
|
+
export async function grant(url: string): Promise<void> {
|
|
452
|
+
await using db = connect(url, { schema: app });
|
|
453
|
+
await db.users.insert({ email: "a@b.c", role: "admin" }, { allow: ["role"] });
|
|
454
|
+
}
|
|
455
|
+
```
|
|
191
456
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
457
|
+
### Sensitive
|
|
458
|
+
|
|
459
|
+
A token can be stored and returned, and it still must not appear in a log or an error. `.sensitive()` shows `[redacted]` in logs, errors, and `inspect()`. The value in the database stays the same.
|
|
460
|
+
|
|
461
|
+
`sensitive.ts`:
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
import { schema, table, t } from "okmodel/pg";
|
|
465
|
+
|
|
466
|
+
const sessions = table("sessions", {
|
|
467
|
+
id: t.identity(),
|
|
468
|
+
// Stored as written. Logs, errors, and inspect show [redacted].
|
|
469
|
+
token: t.text().sensitive(),
|
|
470
|
+
});
|
|
471
|
+
export const app = schema({ tables: [sessions] });
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Relations
|
|
475
|
+
|
|
476
|
+
The quickstart note points at one author. `one` is that author on the note. `many` is the list of notes on the author. `find({ include })` loads the related rows. `manyThrough` is the case with a third table in between, such as labels on a task through `taskLabels`.
|
|
477
|
+
|
|
478
|
+
`relations.ts`:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
import { many, one, schema, table, t } from "okmodel/pg";
|
|
482
|
+
|
|
483
|
+
// notes is the list of notes on this author.
|
|
484
|
+
const authors = table(
|
|
485
|
+
"authors",
|
|
486
|
+
{ id: t.identity() },
|
|
487
|
+
{ relations: { notes: many("notes", "authorId") } },
|
|
488
|
+
);
|
|
489
|
+
// author is the one author this note points at.
|
|
490
|
+
const notes = table(
|
|
491
|
+
"notes",
|
|
492
|
+
{ id: t.identity(), authorId: t.bigint().references("authors") },
|
|
493
|
+
{ relations: { author: one("authors", "authorId") } },
|
|
494
|
+
);
|
|
495
|
+
export const app = schema({ tables: [authors, notes] });
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### Operators
|
|
499
|
+
|
|
500
|
+
`where: { title: "hello" }` matches that exact title. A helper matches something else. `ilike` is a case-insensitive pattern, `contains` looks inside JSON, and `overlaps` looks for a shared array value. On update, `inc` adds to a number, `json.set` writes one JSON field, and `arr.append` adds one array value. The helper is a query parameter, so a JSON body cannot pretend to be one. `iStartsWith`, `iContains`, and `iEndsWith` are not in this version. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
501
|
+
|
|
502
|
+
`operators.ts`:
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
import { arr, contains, eq, ilike, inc, json, overlaps, schema, table, t } from "okmodel/pg";
|
|
506
|
+
|
|
507
|
+
const docs = table("docs", {
|
|
508
|
+
id: t.identity(),
|
|
509
|
+
title: t.text(),
|
|
510
|
+
n: t.integer(),
|
|
511
|
+
tags: t.text().array(),
|
|
512
|
+
meta: t.jsonb<{ readonly published: boolean }>(),
|
|
513
|
+
});
|
|
514
|
+
export const app = schema({ tables: [docs] });
|
|
515
|
+
// Read filters. A plain value would match exactly.
|
|
516
|
+
export const where = {
|
|
517
|
+
title: ilike("%hip%"),
|
|
518
|
+
n: eq(1),
|
|
519
|
+
meta: contains({ published: true }),
|
|
520
|
+
tags: overlaps(["news"]),
|
|
521
|
+
};
|
|
522
|
+
// Write helpers, passed inside update.
|
|
523
|
+
export const set = { n: inc(1), meta: json.set(["published"], true), tags: arr.append("news") };
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Page and aggregate
|
|
527
|
+
|
|
528
|
+
`page` returns one page of rows and a `next` cursor for the page after it. Pass that cursor back as `after`, with the same `orderBy`. A cursor from a different `orderBy` is OKM1130. `aggregate` counts or groups rows instead of returning them. A group needs a `limit`, or `.all(reason)`. There is no `having`.
|
|
529
|
+
|
|
530
|
+
`page.ts`:
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
import { schema, table, t } from "okmodel/pg";
|
|
534
|
+
import { connect } from "okmodel/pg/postgresjs";
|
|
535
|
+
|
|
536
|
+
const notes = table("notes", { id: t.identity(), title: t.text() });
|
|
537
|
+
const app = schema({ tables: [notes] });
|
|
538
|
+
|
|
539
|
+
export async function report(url: string): Promise<void> {
|
|
540
|
+
await using db = connect(url, { schema: app });
|
|
541
|
+
// next is the cursor for the following page. Pass it back as after.
|
|
542
|
+
const page = await db.notes.page({ orderBy: { title: "asc" }, limit: 20 });
|
|
543
|
+
// One row per title, with a count. groupBy needs a limit.
|
|
544
|
+
await db.notes.aggregate({ count: true, groupBy: ["title"], limit: 10 });
|
|
545
|
+
void page.next;
|
|
196
546
|
}
|
|
197
547
|
```
|
|
198
548
|
|
|
549
|
+
### Request filters
|
|
550
|
+
|
|
551
|
+
A request should not be able to filter on every column. `filters` names the columns and the comparisons you accept, and the columns a caller may sort by. `parse` turns that request into `where` and `orderBy`. Naming a hidden column there is OKM1123.
|
|
552
|
+
|
|
553
|
+
`filters.ts`:
|
|
554
|
+
|
|
199
555
|
```ts
|
|
200
|
-
|
|
556
|
+
import { schema, table, t } from "okmodel/pg";
|
|
557
|
+
|
|
558
|
+
const users = table("users", { id: t.identity(), email: t.text() });
|
|
559
|
+
export const app = schema({ tables: [users] });
|
|
560
|
+
// A request may filter email with eq, and may sort by email. Nothing else.
|
|
561
|
+
export const userFilters = users.filters({ allow: { email: ["eq"] }, sort: ["email"] });
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Presets
|
|
565
|
+
|
|
566
|
+
A filter you use often can live on the table under a name. Here `pending` means the status is `"pending"`. `db.tasks.pending().find({ limit: 20 })` applies it. It is added on top of the tenant and the archive filter, and it cannot remove either.
|
|
567
|
+
|
|
568
|
+
`presets.ts`:
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
import { schema, table, t } from "okmodel/pg";
|
|
572
|
+
|
|
573
|
+
const tasks = table(
|
|
574
|
+
"tasks",
|
|
575
|
+
{ id: t.identity(), title: t.text(), status: t.text() },
|
|
576
|
+
{
|
|
577
|
+
// db.tasks.pending().find({ limit: 20 }) adds this filter.
|
|
578
|
+
presets: { pending: (q) => q.where({ status: "pending" }) },
|
|
579
|
+
},
|
|
580
|
+
);
|
|
581
|
+
export const app = schema({ tables: [tasks] });
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### Validation
|
|
585
|
+
|
|
586
|
+
A title can be checked before it is written. `.validate()` stores the rules on the column. They run on insert and update when the schema sets `validation: true` and the file imports `okmodel/validate`. A failed check is OKM1200. `onRead` is stored and is not applied. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
587
|
+
|
|
588
|
+
`validation.ts`:
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
import { schema, table, t } from "okmodel/pg";
|
|
592
|
+
import { v } from "okmodel/validate";
|
|
593
|
+
|
|
594
|
+
const tasks = table("tasks", {
|
|
595
|
+
id: t.identity(),
|
|
596
|
+
// Trim, then require one character. A failure is OKM1200.
|
|
597
|
+
title: t.varchar(8).validate([v.trim(), v.min(1, "title_required")]),
|
|
598
|
+
});
|
|
599
|
+
export const app = schema({ tables: [tasks], validation: true });
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Transactions
|
|
603
|
+
|
|
604
|
+
Two writes that must succeed or fail together go in `tx()`. The callback gets its own client, and every call on it shares the transaction. A `tx()` inside that callback is a savepoint. `retry` runs the callback again after a serialization failure or a deadlock.
|
|
605
|
+
|
|
606
|
+
`transactions.ts`:
|
|
607
|
+
|
|
608
|
+
```ts
|
|
609
|
+
import { schema, table, t } from "okmodel/pg";
|
|
610
|
+
import { connect } from "okmodel/pg/postgresjs";
|
|
611
|
+
|
|
612
|
+
const notes = table("notes", { id: t.identity(), title: t.text() });
|
|
613
|
+
const app = schema({ tables: [notes] });
|
|
614
|
+
|
|
615
|
+
export async function save(url: string, title: string): Promise<void> {
|
|
616
|
+
await using db = connect(url, { schema: app });
|
|
617
|
+
// Both calls share one transaction. tx is the client inside it.
|
|
618
|
+
await db.tx(async (tx) => {
|
|
619
|
+
await tx.notes.insert({ title });
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### Batch and locks
|
|
625
|
+
|
|
626
|
+
`batch` sends several writes as one unit. It will not run `restore`, and it will not run a write that uses `expect` (OKM1121). Inside `tx()`, `lock: "update"` makes other transactions wait to change that row, and `advisoryLock` holds a named lock until the transaction ends. A row lock outside `tx()` is OKM1830. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
627
|
+
|
|
628
|
+
`batch.ts`:
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
import { schema, table, t } from "okmodel/pg";
|
|
632
|
+
import { connect } from "okmodel/pg/postgresjs";
|
|
633
|
+
|
|
634
|
+
const notes = table("notes", { id: t.identity(), title: t.text() });
|
|
635
|
+
const app = schema({ tables: [notes] });
|
|
636
|
+
|
|
637
|
+
export async function run(url: string, id: string): Promise<void> {
|
|
638
|
+
await using db = connect(url, { schema: app });
|
|
639
|
+
// One unit. The delete runs with the update.
|
|
640
|
+
await db.batch([
|
|
641
|
+
db.notes.update({ where: { id }, set: { title: "next" } }),
|
|
642
|
+
db.notes.delete({ where: { title: "old" } }),
|
|
643
|
+
]);
|
|
644
|
+
await db.tx(async (tx) => {
|
|
645
|
+
// lock waits inside the transaction only.
|
|
646
|
+
await tx.notes.find({ where: { id }, limit: 1, lock: "update", wait: "skip" });
|
|
647
|
+
await tx.advisoryLock("notes");
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### Traits
|
|
653
|
+
|
|
654
|
+
`timestamps()` adds `createdAt` and `updatedAt` for you. Insert fills both with the current time. Update changes `updatedAt` and leaves `createdAt`. `{ enforce: "trigger" }` does that in the database as well, so a write that skips the client still updates the time.
|
|
655
|
+
|
|
656
|
+
`traits.ts`:
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
import { schema, table, t } from "okmodel/pg";
|
|
660
|
+
import { timestamps } from "okmodel/traits";
|
|
661
|
+
|
|
662
|
+
// Adds createdAt and updatedAt. The insert does not set them.
|
|
663
|
+
const notes = table("notes", { id: t.identity(), title: t.text() }, { traits: [timestamps()] });
|
|
664
|
+
export const app = schema({ tables: [notes] });
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Archive
|
|
668
|
+
|
|
669
|
+
`archivable()` keeps the row and hides it from ordinary reads. `archive()` marks the matching rows and returns an `archiveId`. `restore({ archiveId })` brings that set back. `withArchived()` includes hidden rows. `onlyArchived()` reads only those rows. Put `cascade` on the parent and name the child tables that should archive with it. One level of children. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
670
|
+
|
|
671
|
+
`archive.ts`:
|
|
672
|
+
|
|
673
|
+
```ts
|
|
674
|
+
import { schema, table, t } from "okmodel/pg";
|
|
675
|
+
import { connect } from "okmodel/pg/postgresjs";
|
|
676
|
+
import { archivable } from "okmodel/traits";
|
|
677
|
+
|
|
678
|
+
// cascade is on the parent. tasks archive and restore with the list.
|
|
679
|
+
const lists = table(
|
|
680
|
+
"lists",
|
|
681
|
+
{ id: t.identity() },
|
|
682
|
+
{ traits: [archivable({ cascade: ["tasks"] })] },
|
|
683
|
+
);
|
|
684
|
+
const tasks = table(
|
|
685
|
+
"tasks",
|
|
686
|
+
{ id: t.identity(), listId: t.bigint().references("lists") },
|
|
687
|
+
{ traits: [archivable()] },
|
|
688
|
+
);
|
|
689
|
+
const app = schema({ tables: [lists, tasks] });
|
|
690
|
+
|
|
691
|
+
export async function hide(url: string, id: string): Promise<void> {
|
|
692
|
+
await using db = connect(url, { schema: app });
|
|
693
|
+
// archive hides the row and returns the id restore uses.
|
|
694
|
+
const archived = await db.lists.archive({ where: { id } });
|
|
695
|
+
await db.lists.onlyArchived().restore({ archiveId: archived.archiveId });
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
### Tenancy
|
|
700
|
+
|
|
701
|
+
Every note belongs to one tenant. Column tenancy adds `tenantId` and puts that tenant on every read and write. `db.for({ tenantId }).notes.find({ limit: 20 })` reads one tenant. `global("shared")` marks a table every tenant can read, such as a list of countries. Row-level security is not in this version. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
702
|
+
|
|
703
|
+
`tenancy.ts`:
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
import { schema, table, t } from "okmodel/pg";
|
|
707
|
+
import { columnTenancy, global } from "okmodel/tenancy";
|
|
708
|
+
|
|
709
|
+
// Every read and write adds tenantId.
|
|
710
|
+
const notes = table("notes", { id: t.identity(), title: t.text() });
|
|
711
|
+
// Shared by every tenant. The reason is recorded on the table.
|
|
712
|
+
const countries = table("countries", { id: t.identity() }, { tenancy: global("shared") });
|
|
713
|
+
export const app = schema({
|
|
714
|
+
tenancy: columnTenancy({ key: "tenantId", type: "uuid" }),
|
|
715
|
+
tables: [notes, countries],
|
|
716
|
+
});
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Extensions
|
|
720
|
+
|
|
721
|
+
`citext` is case-insensitive text. `pg_trgm` searches by similar chunks of text. Declare the extension on the schema and the migration creates it before the tables that use it. `gin` stores the trigram index. `okm ext check` compares the declaration with the server. A missing extension is OKM1811, and that check runs before any statement.
|
|
722
|
+
|
|
723
|
+
`extensions.ts`:
|
|
724
|
+
|
|
725
|
+
```ts
|
|
726
|
+
import { schema, table, t } from "okmodel/pg";
|
|
727
|
+
import { citext as citextExtension } from "okmodel/pg/citext";
|
|
728
|
+
import { pgTrgm } from "okmodel/pg/pg_trgm";
|
|
729
|
+
|
|
730
|
+
const trigram = pgTrgm();
|
|
731
|
+
const people = table(
|
|
732
|
+
"people",
|
|
733
|
+
{
|
|
734
|
+
email: t.citext(),
|
|
735
|
+
title: t.text(),
|
|
736
|
+
},
|
|
737
|
+
// gin index for similar-text search on title.
|
|
738
|
+
{ indexes: (column) => [trigram.gin(column.title.name)] },
|
|
739
|
+
);
|
|
740
|
+
export const app = schema({ extensions: [citextExtension(), trigram], tables: [people] });
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### Domains
|
|
744
|
+
|
|
745
|
+
A domain is a Postgres type with its own check. Here `pos` is an integer that must be greater than zero. TypeScript still sees a number. Changing the base type later is OKM1020. Write the check the way Postgres prints it, including the parentheses. A different spelling is a different check. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
746
|
+
|
|
747
|
+
`domains.ts`:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
import { schema, table, t } from "okmodel/pg";
|
|
751
|
+
|
|
752
|
+
export const app = schema({
|
|
753
|
+
// pos is an integer that must be greater than zero.
|
|
754
|
+
tables: [table("people", { n: t.domain("pos", t.integer(), "((VALUE > 0))") })],
|
|
755
|
+
});
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
### Functions
|
|
759
|
+
|
|
760
|
+
`fn()` is a function that lives in the database. This one takes a title and returns text. A plpgsql function names the tables it reads in `dependsOn`, or the declaration is OKM1824. `security: "definer"` also needs a `searchPath`, or it is OKM1823. A query cannot call it as `fn.slugify(col)` yet. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
761
|
+
|
|
762
|
+
`functions.ts`:
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
import { fn } from "okmodel/fn";
|
|
766
|
+
import { schema, table, t } from "okmodel/pg";
|
|
767
|
+
|
|
768
|
+
const tasks = table("tasks", { id: t.text().primaryKey(), title: t.text() });
|
|
769
|
+
export const app = schema({
|
|
770
|
+
tables: [tasks],
|
|
771
|
+
functions: [
|
|
772
|
+
// plpgsql must name the tables it uses.
|
|
773
|
+
fn("slug", {
|
|
774
|
+
arguments: [{ name: "title", type: "text" }],
|
|
775
|
+
returns: "text",
|
|
776
|
+
language: "plpgsql",
|
|
777
|
+
body: "begin return title; end",
|
|
778
|
+
dependsOn: [tasks],
|
|
779
|
+
}),
|
|
780
|
+
],
|
|
781
|
+
});
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
### Triggers
|
|
785
|
+
|
|
786
|
+
A trigger runs a function when a row changes. This one runs `touch` before each update of `tasks` and returns the new row. The function has to be listed on the schema. `timestamps({ enforce: "trigger" })` is the built-in form of this for `updatedAt`. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
787
|
+
|
|
788
|
+
`triggers.ts`:
|
|
789
|
+
|
|
790
|
+
```ts
|
|
791
|
+
import { fn, trigger } from "okmodel/fn";
|
|
792
|
+
import { schema, table, t } from "okmodel/pg";
|
|
793
|
+
|
|
794
|
+
const tasks = table("tasks", { id: t.text().primaryKey(), title: t.text() });
|
|
795
|
+
const touch = fn("touch", {
|
|
796
|
+
returns: "trigger",
|
|
797
|
+
language: "plpgsql",
|
|
798
|
+
volatility: "volatile",
|
|
799
|
+
body: "begin return new; end",
|
|
800
|
+
dependsOn: [tasks],
|
|
801
|
+
});
|
|
802
|
+
export const app = schema({
|
|
803
|
+
tables: [tasks],
|
|
804
|
+
functions: [touch],
|
|
805
|
+
triggers: [
|
|
806
|
+
// Runs touch before each row update and returns the new row.
|
|
807
|
+
trigger("tasks_touch", {
|
|
808
|
+
on: tasks,
|
|
809
|
+
timing: "before",
|
|
810
|
+
events: ["update"],
|
|
811
|
+
level: "row",
|
|
812
|
+
calls: touch,
|
|
813
|
+
}),
|
|
814
|
+
],
|
|
815
|
+
});
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
### Views
|
|
819
|
+
|
|
820
|
+
A view is a saved query. You give it the SQL and the columns that query returns. `db.views.active.find({ limit: 20 })` reads `active`. That handle has no insert or update. There is no query-builder form yet. `okm check` reprints the query on the connected server before it compares, so a difference that is only how that server prints the query is not drift. A materialized view that refreshes while people are reading it needs a unique index, or the declaration is OKM1822. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
821
|
+
|
|
822
|
+
`views.ts`:
|
|
823
|
+
|
|
824
|
+
```ts
|
|
825
|
+
import { schema, table, t } from "okmodel/pg";
|
|
826
|
+
import { materializedView, view } from "okmodel/view";
|
|
827
|
+
|
|
828
|
+
const tasks = table("tasks", { id: t.text().primaryKey(), title: t.text() });
|
|
829
|
+
const columns = [
|
|
830
|
+
{ name: "id", type: "text" },
|
|
831
|
+
{ name: "title", type: "text" },
|
|
832
|
+
];
|
|
833
|
+
export const app = schema({
|
|
834
|
+
tables: [tasks],
|
|
835
|
+
views: [
|
|
836
|
+
// A saved query. find reads it. The handle has no insert.
|
|
837
|
+
view("active", {
|
|
838
|
+
columns,
|
|
839
|
+
query: "SELECT id,\n title\n FROM tasks\n WHERE title IS NOT NULL",
|
|
840
|
+
}),
|
|
841
|
+
// Concurrent refresh needs the unique index.
|
|
842
|
+
materializedView("sums", {
|
|
843
|
+
columns,
|
|
844
|
+
query: "SELECT id,\n title\n FROM tasks",
|
|
845
|
+
refresh: "concurrently",
|
|
846
|
+
indexes: [{ columns: ["id"], unique: true }],
|
|
847
|
+
}),
|
|
848
|
+
],
|
|
849
|
+
});
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
### Roles and grants
|
|
853
|
+
|
|
854
|
+
The migration connects as one role and the app connects as another. `roles` names both. The app role receives select, insert, update, and delete on tables and views, select on materialized views, execute on functions, and usage plus select on sequences. Apply runs as the migration role, and switches to it with `SET ROLE` when the connection is someone else. The app role is a role you already created, unless you list it in `managed`. Grants on one column, and row-level security, are not in this version. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
855
|
+
|
|
856
|
+
`roles.ts`:
|
|
857
|
+
|
|
858
|
+
```ts
|
|
859
|
+
import { defineConfig } from "okmodel/migrate";
|
|
860
|
+
|
|
861
|
+
const url = process.env.DATABASE_URL;
|
|
862
|
+
if (url === undefined || url.length === 0) throw new Error("DATABASE_URL is not set");
|
|
863
|
+
|
|
864
|
+
export default defineConfig({
|
|
865
|
+
schema: "./schema.ts",
|
|
866
|
+
database: url,
|
|
867
|
+
// Apply uses the migration role. The app uses the app role.
|
|
868
|
+
roles: { migration: "okm_migrate", app: "okm_app", managed: [{ name: "okm_migrate" }] },
|
|
869
|
+
});
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
### okm ext and okm doctor
|
|
873
|
+
|
|
874
|
+
`okm ext list` prints the extensions this server can install, and the version that is already installed. `okm ext check` compares that list with the schema. `okm doctor` lists the triggers on each table. `okm doctor OKM1811` prints what that error code means. `okm ext test` and `okm ext scaffold` are not in this version. See [known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md).
|
|
875
|
+
|
|
876
|
+
```text
|
|
877
|
+
okm ext list
|
|
878
|
+
okm ext check
|
|
879
|
+
okm doctor
|
|
880
|
+
okm doctor OKM1811
|
|
201
881
|
```
|
|
202
882
|
|
|
203
883
|
## Commands
|
|
@@ -212,17 +892,22 @@ await db.close();
|
|
|
212
892
|
| `okm migrate plan <name>` | Prints the plan and its class. The name is required. |
|
|
213
893
|
| `okm migrate apply` | Replays migration files on the database. |
|
|
214
894
|
| `okm migrate status` | Prints version, catalog hash, and state for each target. |
|
|
895
|
+
| `okm ext list` | Prints the extensions the connected server can install and the version that is installed. |
|
|
896
|
+
| `okm ext check` | Compares those versions with the schema. A missing extension is OKM1811. A pin the server does not meet is OKM1812. |
|
|
897
|
+
| `okm doctor [code]` | Lists the triggers on each table. A code argument prints that code. |
|
|
215
898
|
| `okm --version` | Prints the package version. |
|
|
216
899
|
|
|
217
900
|
`okmodel` and `okm` are the same command.
|
|
218
901
|
|
|
219
902
|
## Roadmap
|
|
220
903
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
- [
|
|
224
|
-
- [
|
|
225
|
-
- [
|
|
904
|
+
Status is on the [board](https://github.com/users/omqkhafi/projects/1). Each release is a milestone.
|
|
905
|
+
|
|
906
|
+
- [x] [0.1](https://github.com/omqkhafi/okmodel/milestone/1) — Schema, queries, and migrations on PostgreSQL, with postgres.js and PGlite.
|
|
907
|
+
- [x] [0.2](https://github.com/omqkhafi/okmodel/milestone/2) — Hidden and sensitive fields, validation, traits, tenancy, archive and restore, richer relations, presets, transactions, and operators for JSON, arrays, ranges, and search.
|
|
908
|
+
- [x] [0.3](https://github.com/omqkhafi/okmodel/milestone/3) — Extensions, domains, functions, triggers, views, roles, and grants.
|
|
909
|
+
- [ ] [0.4](https://github.com/omqkhafi/okmodel/milestone/4) — Safer migration plans, backfill, drift checks, provisioning, reference data, and a testing package.
|
|
910
|
+
- [ ] [0.5](https://github.com/omqkhafi/okmodel/milestone/5) — A primary with replicas, read routing, and a reference app.
|
|
226
911
|
|
|
227
912
|
`okmodel/internal` has no stability promise. Names on that subpath can change or disappear in any release.
|
|
228
913
|
|
|
@@ -232,10 +917,13 @@ Each limit, with the version that lifts it, is in [known limits](https://github.
|
|
|
232
917
|
|
|
233
918
|
Measured on this release.
|
|
234
919
|
|
|
235
|
-
|
|
|
236
|
-
|
|
|
237
|
-
| Runtime entry
|
|
238
|
-
| App startup (10 tables, one find)
|
|
920
|
+
| | Minified | Gzip | Cold import |
|
|
921
|
+
| -------------------------------------------- | -------: | -----: | ----------: |
|
|
922
|
+
| Runtime entry | 5,288 | 2,026 | 1.794 ms |
|
|
923
|
+
| App startup (10 tables, one find) | 89,465 | 29,723 | 10.554 ms |
|
|
924
|
+
| App startup, every 0.2 feature in use (full) | 119,940 | 39,123 | 12.663 ms |
|
|
925
|
+
|
|
926
|
+
The full app has column tenancy, `archivable()`, `timestamps()`, validation rules, `one`, `many` and `manyThrough` relations, presets, and calls `include`, `page`, `aggregate`, `tx` and `batch`. A feature costs bytes only in an app that uses it: the full app is 30,475 minified and 9,400 gzip bytes above the plain one. The runtime entry and the plain app are gated. The full app is printed, not gated.
|
|
239
927
|
|
|
240
928
|
The rest of the measurements are in [size](https://github.com/omqkhafi/okmodel/blob/main/docs/size.md).
|
|
241
929
|
|
|
@@ -244,3 +932,5 @@ The rest of the measurements are in [size](https://github.com/omqkhafi/okmodel/b
|
|
|
244
932
|
- [Quickstart](https://github.com/omqkhafi/okmodel/blob/main/docs/quickstart.md)
|
|
245
933
|
- [Production checklist](https://github.com/omqkhafi/okmodel/blob/main/docs/production.md)
|
|
246
934
|
- [Known limits](https://github.com/omqkhafi/okmodel/blob/main/docs/known-limits.md)
|
|
935
|
+
- [Changelog](https://github.com/omqkhafi/okmodel/blob/main/changelog.md)
|
|
936
|
+
- [Design spec](https://github.com/omqkhafi/okmodel/blob/main/docs/okmodel-api-design.md)
|