letopis 0.20.3 → 1.0.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/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# letopis 1.0 — agent cheatsheet
|
|
2
|
+
|
|
3
|
+
One page instead of [README.en.md](README.en.md), for an AI agent that writes code on top of the package. It ships in
|
|
4
|
+
the package next to the README; in Russian: [Русская версия](AGENT-CHEATSHEET.md). Migration from 0.21:
|
|
5
|
+
[MIGRATION.md](MIGRATION.md) (in Russian); the machine-readable catalog of the API and errors is `docs/api-contract.json`
|
|
6
|
+
in the repository. The examples below go in order and form one program; the `docs` test runs it against PostgreSQL 18,
|
|
7
|
+
so the code is checked against the package.
|
|
8
|
+
|
|
9
|
+
## The model in a minute
|
|
10
|
+
|
|
11
|
+
- PostgreSQL 18+, Node 22+. Three tables in the installation schema `v2.<name>`: `entity` (the current version of each
|
|
12
|
+
entity), `log` (the journal of all versions), `secret` (credentials and sessions). Classes, accounts and access rules
|
|
13
|
+
are `entity` rows too.
|
|
14
|
+
- Checks are done by **the database**: triggers (data against JSON Schema, references, keys, the journal) and RLS
|
|
15
|
+
policies (tenant and access rules). Raw SQL from the application role behaves the same way as the library.
|
|
16
|
+
- A hub (`hub`) is an entity; a link (`link`) is an entity with ends. Ends are in `row.links`, fields are in `row.data`.
|
|
17
|
+
- id: for a class with a key it is a UUID v5 of (class, key values) within the tenant, and it can be computed in
|
|
18
|
+
advance (`db.idOf`); for a class without a key the database assigns the id. Only the key gives uniqueness.
|
|
19
|
+
- Tenant = account. Rows belong to the **session** tenant; rows of other tenants are not visible. Access rules are
|
|
20
|
+
always on and **deny** by default.
|
|
21
|
+
|
|
22
|
+
## Working cycle
|
|
23
|
+
|
|
24
|
+
### 1. Installation: `up()` and the application role
|
|
25
|
+
|
|
26
|
+
<!-- run -->
|
|
27
|
+
```ts
|
|
28
|
+
import { up, connect, syncModels, inc, gt, or, cursorOf } from 'letopis'
|
|
29
|
+
|
|
30
|
+
const ADMIN = 'postgres://postgres:pass@localhost:5432/app' // superuser or a role with CREATEROLE
|
|
31
|
+
const r = await up({ dsn: ADMIN, schema: 'myapp', // schema 'v2.myapp'
|
|
32
|
+
tenant: { name: 'Salon', user: { name: 'Olga', login: 'olga@salon.ru', password: 'Lilac-2026!' } } })
|
|
33
|
+
// r: { schema, created, upgraded, revision, serviceKey, migrations, analyzed, tenant, … }
|
|
34
|
+
// r.serviceKey ('lts_…') is the service key; it is issued at the first installation (again only if the installation has
|
|
35
|
+
// no valid API keys left). Save it.
|
|
36
|
+
// r.tenant: { tenant, user, token, created } — the tenant "Salon" and its first user Olga
|
|
37
|
+
```
|
|
38
|
+
- `up()` waits for the database, creates it, checks for PostgreSQL 18+ and UTF8, and installs the roles `letopis_owner`
|
|
39
|
+
and `letopis_app`. Options: `fresh` (recreate the schema), `upgrade` (apply migrations), `seeds` (the name of a
|
|
40
|
+
package seed, for example `'booking'` for the demo domain, or a path to your own `.sql`), `history` (retention
|
|
41
|
+
policy), `allowReset`, `serviceKey`, `quiet`, `pglite: true` (a database for development without PostgreSQL; then
|
|
42
|
+
`connect({ dsn: r.pglite.dsn, listen: false, max: 1, allowBypassRls: true, … })` — a superuser connection, RLS
|
|
43
|
+
does not apply on it).
|
|
44
|
+
- `tenant` — the first tenant and user without a seed of your own: the tenant gets the rule "authenticated users may
|
|
45
|
+
do everything" (`grantAll`, on by default, weight 10); the user gets a membership with the roles `user.roles`
|
|
46
|
+
(`['owner']` by default), a password for signing in with the login `login`, and a session in the tenant. Repeating
|
|
47
|
+
the call with the same name and login duplicates nothing and does not change the roles of an existing membership (the
|
|
48
|
+
ids are v5 of `tenant:<name>` and `user:<login>`). Another tenant: `createTenant(postgres(ADMIN), 'v2.myapp', { name,
|
|
49
|
+
user })` (needs an admin connection).
|
|
50
|
+
- The application connects with an **application LOGIN role** that is a member of `letopis_app` only:
|
|
51
|
+
`create role myapp login password '…'; grant letopis_app to myapp;`. Connecting as a superuser is the error
|
|
52
|
+
`bypass_rls` (RLS would not apply); an admin connection is possible only with an explicit `allowBypassRls: true`.
|
|
53
|
+
- Accounts live in the System tenant; the system administrator (a member of System with the `admin` role; your own
|
|
54
|
+
installation seed or the loader on an admin connection appoints one) creates them with the chain `admin.Account().create({ name, categories })`. Your own seed (`seeds: ['./my.sql']`, the owner functions
|
|
55
|
+
`sys_create`, `sys_grant_all`, the markers `@SCHEMA@`, `@SYSTEM@`) remains for special cases.
|
|
56
|
+
|
|
57
|
+
### 2. Sign-in: service, password, session
|
|
58
|
+
|
|
59
|
+
<!-- run -->
|
|
60
|
+
```ts
|
|
61
|
+
const APP = 'postgres://myapp:…@localhost:5432/app'
|
|
62
|
+
const svc = await connect({ dsn: APP, schema: 'v2.myapp', apiKey: r.serviceKey! }) // service: key → session
|
|
63
|
+
const SALON = r.tenant!.tenant, OLGA = r.tenant!.user!
|
|
64
|
+
await svc.auth.setPassword({ account: SALON, identifier: 'salon@example.com', password: 'Lilac-2026!', confirmed: true })
|
|
65
|
+
const login = await svc.auth.verifyPassword({ identifier: 'salon@example.com', password: 'Lilac-2026!' })
|
|
66
|
+
// login: { account, token } | null (a wrong password or an unconfirmed credential gives null, not an error)
|
|
67
|
+
const db = await connect({ dsn: APP, schema: 'v2.myapp', token: login!.token })
|
|
68
|
+
db.tenant // the session tenant: by default the account's own tenant (here SALON)
|
|
69
|
+
```
|
|
70
|
+
- `connect({ dsn | sql, schema: 'v2.<name>', token | apiKey, max, listen, cache, onQuery, slowMs, maintain,
|
|
71
|
+
allowBypassRls, models })`. `schema` is the **full** name with `v2.`.
|
|
72
|
+
- `db.auth`: `setPassword`, `verifyPassword`, `issueApiKey`, `verifyApiKey`, `issueKeySecret`, `verifyKeySecret`,
|
|
73
|
+
`enrollTotp`, `verifyTotp`, `totpEnabled`, `issueOtp`, `verifyOtp`, `link`, `lookup`, `credentials`, `revokeCredential`,
|
|
74
|
+
`setFlags`, `sessionFor(account, { ttl })`, `refresh`, `revoke`, `revokeAll`, `switch(tenant)`, `tenants()`,
|
|
75
|
+
`purgeAccount`. Password, OTP and TOTP checks, issuing sessions and confirming credentials are called by the
|
|
76
|
+
**service** (the `auth.*` permissions); a regular user gets `acl_denied`. Signatures:
|
|
77
|
+
`issueApiKey({ account, name?, ttl? })` → `{ key, id }`; `verifyApiKey(key)` → `{ token, account } | null`;
|
|
78
|
+
`sessionFor(account, { ttl })` → a token string; then `connect({ dsn, schema, token })`, and to work in another
|
|
79
|
+
tenant through a membership, `db.auth.switch(tenant)` (without it, the session is in the account's own tenant). The
|
|
80
|
+
token for the CLI (`--token`) is the same `token` from `verifyPassword`/`verifyApiKey`/`sessionFor`. An admin
|
|
81
|
+
connection (`allowBypassRls`) prints the warning "RLS не действует" (RLS is not in effect); this is expected.
|
|
82
|
+
- Second factor: `verifyPassword({ identifier, password, totp })` and `lookup({ kind, identifier, totp })` check the
|
|
83
|
+
TOTP code after the first check (the `auth.totp` permission; the failure counter and lockout are shared with
|
|
84
|
+
`verifyTotp`) → a session with the methods `[kind, 'TOTP']`, with which a user with TOTP changes their own
|
|
85
|
+
credentials. A wrong or repeated code and a code for an account without TOTP give `null`; without `totp`, the code
|
|
86
|
+
is not checked.
|
|
87
|
+
- Service keys: with a session of its own key, the service issues and revokes its own keys itself (`issueApiKey`,
|
|
88
|
+
`issueKeySecret` with `account` set to the service id; `revokeCredential(id)`). Revoking a key revokes only the
|
|
89
|
+
sessions of that key, so rotation has no downtime: new key → processes onto it → revoke the old one. The last active
|
|
90
|
+
key of a service cannot be revoked (`acl_denied`); to disable a service, set `enabled: false` on its account.
|
|
91
|
+
- Working in another tenant through a membership: `await user.auth.tenants()` → `[{ tenant, roles }]`, then
|
|
92
|
+
`await user.auth.switch(tenant)` (only from the connection root). Impersonation by a service:
|
|
93
|
+
`const u = await svc.as(account, { tenant, owner })` — the database checks it; in the journal, `author` is the user
|
|
94
|
+
and `agent` is the service. `db.as()` is **asynchronous**.
|
|
95
|
+
|
|
96
|
+
### 3. Models in Zod and `letopis sync`
|
|
97
|
+
|
|
98
|
+
<!-- run -->
|
|
99
|
+
```ts
|
|
100
|
+
// models.ts
|
|
101
|
+
import { z } from 'zod'
|
|
102
|
+
import { hub, link, one, many } from 'letopis/model'
|
|
103
|
+
export const Shop = hub('Shop', { name: z.string(), city: z.string().optional() }, { key: ['name'] })
|
|
104
|
+
export const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0).default(0) },
|
|
105
|
+
{ key: ['number'], ends: { Shop } }) // a bare model in ends = one(Shop), a required end
|
|
106
|
+
export const Payment = link('Payment', { opId: z.string(), amount: z.number().int() },
|
|
107
|
+
{ key: ['Wallet', 'opId'], ends: { Wallet } }) // key from an end and a field: idempotency by opId
|
|
108
|
+
export const Note = hub('Note', { text: z.string() }, {}) // no key: the database assigns the id
|
|
109
|
+
```
|
|
110
|
+
- Full form: `hub(name, { alias, extends, abstract, ends, fields: z.object(…) | { fields }, key, onDelete, history,
|
|
111
|
+
ownerDefault: 'tenant' | 'actor', cache: { ttl }, meta })`; `link` works the same way. `extends` also works in
|
|
112
|
+
the short form, but TypeScript infers the data types of a descendant only in the full form.
|
|
113
|
+
- Ends: `one(A)` (required), `one(A, B)` (union), `one('Name')` (target by name, for a class that refers to itself),
|
|
114
|
+
`.optional()`, `.onDelete('restrict' | 'cascade' | 'unset')` (default `restrict`; `unset` only for an optional
|
|
115
|
+
end), `many(A, { min, max, onDelete })`.
|
|
116
|
+
- A key is made of fields (required strings) and single required ends. The key is inherited from `extends`.
|
|
117
|
+
|
|
118
|
+
Models into the database (classes are written to the session tenant; a tightening that existing rows block gives `tightening_conflict`):
|
|
119
|
+
<!-- run -->
|
|
120
|
+
```ts
|
|
121
|
+
const synced = await syncModels(db, [Shop, Wallet, Payment, Note], { apply: true })
|
|
122
|
+
// synced: { applied, created, updated, unchanged, skipped, lost } (lost: refine and anything else JSON Schema cannot express)
|
|
123
|
+
```
|
|
124
|
+
```sh
|
|
125
|
+
npx letopis sync ./models.js --dsn "$APP" --schema v2.myapp --token "$TOKEN" # dry run (.ts needs Node 22.18+)
|
|
126
|
+
npx letopis sync ./models.js --dsn "$APP" --schema v2.myapp --token "$TOKEN" --apply # apply
|
|
127
|
+
npx letopis types --dsn "$APP" --schema v2.myapp --token "$TOKEN" --out types.ts # class types from the database
|
|
128
|
+
```
|
|
129
|
+
Chain types: `connect({ …, models: [Shop, Wallet, Payment, Note] })` — the models are used only for types.
|
|
130
|
+
|
|
131
|
+
### 4. Writing with chains
|
|
132
|
+
|
|
133
|
+
A verb is part of a plan; the plan is executed by the **terminal** (`rows`, `first`, `ids`, `count` …). The whole plan is one transaction.
|
|
134
|
+
<!-- run -->
|
|
135
|
+
```ts
|
|
136
|
+
const [shop] = await db.Shop().create({ name: 'Center', city: 'Kazan' }).rows()
|
|
137
|
+
const [w] = await db.Shop(shop).Wallet().create({ number: 'W-1' }).rows() // the Shop end comes from the path
|
|
138
|
+
const [n] = await db.Note().create({ text: 'hello' }).rows()
|
|
139
|
+
await db.Note(n.id).update({ text: 'bye' }).rows() // patch merge, new version
|
|
140
|
+
const [up1] = await db.Shop().upsert({ name: 'Center', city: 'Ufa' }).rows() // full replacement; up1.$upsert:
|
|
141
|
+
// 'created' | 'updated' | 'unchanged'
|
|
142
|
+
// money: an increment in the database, without a read; Payment with the key [Wallet, opId]: a repeat gives exists
|
|
143
|
+
await db.Wallet(w).Payment().create({ opId: 'op-1', amount: 500 }).Wallet().update({ balance: inc(500) }).rows()
|
|
144
|
+
await db.Wallet(w).update({ balance: inc(-300) }).first() // balance 200; a result below minimum (0) is invalid_data
|
|
145
|
+
const cur = (await db.Wallet(w).first())!
|
|
146
|
+
await db.Wallet(w).update({ balance: 0 }, { rev: cur.rev }).first() // strict mode: someone else's edit gives conflict
|
|
147
|
+
// end slots after create / update / upsert: .Role.set(x) .unset(), and for many also .add(x) .remove(x)
|
|
148
|
+
const [west] = await db.Shop().create({ name: 'West' }).rows()
|
|
149
|
+
await db.Wallet(w).update({}).Shop.set(west).rows()
|
|
150
|
+
const [w2] = await db.Shop(shop).Wallet().create({ number: 'W-2' }).rows()
|
|
151
|
+
const [w200] = await db.Wallet(w2).rekey({ number: 'W-200' }).rows() // new key → new id, references are moved
|
|
152
|
+
await db.Note(n.id).anonymize(['text']).rows()
|
|
153
|
+
const b = db.batch('init'); b.Shop().create({ name: 'A' }); b.Shop().create({ name: 'B' })
|
|
154
|
+
const batchRows = await b.run() // Row[][] in one transaction; b.size(), b.discard()
|
|
155
|
+
```
|
|
156
|
+
- `inc(n, { start })` — only in `update`; a missing field is an error (or `start` is used). The marker cannot come
|
|
157
|
+
from JSON.
|
|
158
|
+
- `{ rev }` — only when the step has one target (`rev_ambiguous`). If fewer rows are affected than the step found, the
|
|
159
|
+
plan is rolled back with `target_not_found` (not visible), `conflict` (`rev` differs) or `acl_denied`.
|
|
160
|
+
|
|
161
|
+
### 5. Reading with a chain
|
|
162
|
+
|
|
163
|
+
<!-- run -->
|
|
164
|
+
```ts
|
|
165
|
+
await db.Shop({ city: 'Ufa' }).Wallet().rows() // Row[]: { id, class, rev, tenant, owner, links, data, tags, at, author, op, … }
|
|
166
|
+
const rich = await db.Wallet({ balance: gt(100) }).count() // the number of ENTITIES; for paths, count({ paths: true })
|
|
167
|
+
const paid = await db.Wallet(w).Payment().sum('data.amount') // sum | avg | min | max (null when empty), countBy
|
|
168
|
+
await db.Shop(west).Wallet().Payment().paths() // [{ Shop: Row, Wallet: Row, Payment: Row }]
|
|
169
|
+
await db.Wallet(w).first() // Row | null
|
|
170
|
+
const page = await db.Wallet().sort('data.number', 'desc').limit(20).rows()
|
|
171
|
+
const next = await db.Wallet().sort('data.number', 'desc').after(cursorOf(page.at(-1)!, 'data.number')).limit(20).rows() // keyset
|
|
172
|
+
await db.Note().withDeleted().rows() // including deleted ones ($deleted: true)
|
|
173
|
+
await db.Wallet(or({ number: 'W-1' }, { number: 'W-200' })).ids()
|
|
174
|
+
```
|
|
175
|
+
- Step filter: an id, a list of ids, a result row, an object of fields nested to any depth, `or(…)`, the operators
|
|
176
|
+
`ne gt gte lt lte between inList like ilike starts ends has hasAny hasAll exists isNull not`, end roles
|
|
177
|
+
`{ Shop: id }`, `{ Shop: null }`. Fields are in `row.data`, not at the root of the row.
|
|
178
|
+
- A step is a class name, an alias or an end role; a step by a parent class also returns descendants (`.exact()` —
|
|
179
|
+
only its own class); `.deep(max)` — a tree (`$depth`); `.tags()`, `.owner()`, `.alias(name)`, `db.entity(row)`.
|
|
180
|
+
|
|
181
|
+
### 6. Deletion with a preview
|
|
182
|
+
|
|
183
|
+
<!-- run -->
|
|
184
|
+
```ts
|
|
185
|
+
const preview = await db.Shop(shop).delete().rows() // deletes nothing: the whole cascade closure,
|
|
186
|
+
// each row has $action: 'delete' | 'unset' | 'restrict' and $depth (0 is the target; unset and restrict do not have it). Wallet refers to Shop
|
|
187
|
+
// with the default onDelete 'restrict': in the preview it has $action 'restrict', and delete({ confirm: true }) would give delete_restricted
|
|
188
|
+
await db.Note(n.id).delete({ confirm: true }).rows() // tombstones (row.$deleted); the cascade follows the onDelete of the ends
|
|
189
|
+
await db.Note(n.id).restore().rows() // bring back a deleted entity (same id); a live one gives not_deleted
|
|
190
|
+
await db.Note(n.id).delete({ confirm: true }).rows()
|
|
191
|
+
await db.Note(n.id).purge({ confirm: true }).rows() // erase the history of a deleted entity; without confirm, a preview
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 7. Loader
|
|
195
|
+
|
|
196
|
+
Only an **admin** connection (`allowBypassRls` and the right to become the owner), otherwise `admin_required`:
|
|
197
|
+
<!-- run -->
|
|
198
|
+
```ts
|
|
199
|
+
const adm = await connect({ dsn: ADMIN, schema: 'v2.myapp', token: login!.token, allowBypassRls: true })
|
|
200
|
+
const rep = await adm.load({
|
|
201
|
+
Shop: [{ name: 'North' }],
|
|
202
|
+
Wallet: [{ number: 'W-9', Shop: { $key: ['North'] } }], // reference: id | { $key: [...] } | { $ext }
|
|
203
|
+
}, { existing: 'skip' }) // duplicates 'error'|'first'|'last', existing 'error'|'skip'|'update', commitEvery, withHistory, verify, tenant, as
|
|
204
|
+
// rep: { rows, loaded, updated, skipped, duplicates, byClass, external, portions, ms }
|
|
205
|
+
```
|
|
206
|
+
NDJSON files (one object per line; all files form one load):
|
|
207
|
+
```sh
|
|
208
|
+
npx letopis load shops.ndjson wallets.ndjson --dsn "$ADMIN" --schema v2.myapp --token "$TOKEN" [--class Wallet] \
|
|
209
|
+
[--duplicates first] [--existing skip] [--commit-every 100000] [--verify]
|
|
210
|
+
# {"$class":"Shop","name":"South"}
|
|
211
|
+
# {"$class":"Wallet","number":"W-10","Shop":{"$key":["South"]}}
|
|
212
|
+
# {"$class":"Note","text":"x","$ext":"n1"} ← $ext is the row's external key for references { "$ext": "n1" }
|
|
213
|
+
```
|
|
214
|
+
An error carries the code of the first problem; `err.detail.rows` is the first 100 violations (line number, code,
|
|
215
|
+
details), `err.detail.total` is how many there are in all, `err.detail.truncated` means the list is cut. A polymorphic
|
|
216
|
+
reference needs `$class`. `$tags` are the row's tags, `$owner` is its owner.
|
|
217
|
+
|
|
218
|
+
### 8. Access rules: granting a permission
|
|
219
|
+
|
|
220
|
+
A rule = a group (a `Resource` of category `ACCOUNT`) → an object (`API` — an endpoint, `READ`/`WRITE`/`DELETE` — rows
|
|
221
|
+
of a class with a condition) with `allow`/`deny` and a weight. One rule decides: the highest weight; on a tie, deny.
|
|
222
|
+
A tenant rule weight above 999 is cut down to 999. Rows of access rules in a tenant can be written by whoever has
|
|
223
|
+
`WRITE` on them (here, the tenant itself, through the "may do everything" rule).
|
|
224
|
+
<!-- run -->
|
|
225
|
+
```ts
|
|
226
|
+
// Olga's membership in the salon was created by up({ tenant }) with the role owner; we make her an employee with the role staff
|
|
227
|
+
await db.member(db.idOf('member', { User: OLGA, Tenant: SALON })).update({ roles: ['staff'] }).rows()
|
|
228
|
+
const [staff] = await db.Resource().create({ alias: 'staff:ACCOUNT', category: 'ACCOUNT', pattern: { roles: '{staff}' } }).rows()
|
|
229
|
+
const [api] = await db.Resource().create({ alias: 'reports:API', category: 'API', pattern: { endpoint: 'shop.reports.*' } }).rows()
|
|
230
|
+
await db.rule().create({ permission: 'allow', weight: 50 }).Group.set(staff).Object.set(api).rows()
|
|
231
|
+
const [del] = await db.Resource().create({ alias: 'wallet:DELETE', category: 'DELETE', pattern: { class: 'Wallet' } }).rows()
|
|
232
|
+
await db.rule().create({ permission: 'deny', weight: 50 }).Group.set(staff).Object.set(del).rows() // stronger than "may do everything" (10)
|
|
233
|
+
// check on behalf of Olga (the service acts on her behalf in the salon)
|
|
234
|
+
const olga = await svc.as(OLGA, { tenant: SALON })
|
|
235
|
+
const canReport = await olga.acl.check('shop.reports.view') // { allow: true, rule: { weight: 50, … }, code?, message? }
|
|
236
|
+
const canDelete = await olga.acl.checkData('Wallet', 'DELETE') // { allow: false } | { allow: true, filter? }
|
|
237
|
+
```
|
|
238
|
+
- A membership is a `member` row with the ends `User` and `Tenant` and the key `[User, Tenant]`: a new membership is
|
|
239
|
+
`db.member().create({ roles }).User.set(account).Tenant.set(tenant)` if it does not exist yet (otherwise `exists`).
|
|
240
|
+
- Group: `{ categories: '{Staff}' }` (account categories) or `{ roles: '{staff}' }` (membership roles). A mask
|
|
241
|
+
(`categories`, `roles`, `endpoint`, `class`) is **a string only**: the database rejects an array (`['staff']`), a
|
|
242
|
+
number or `null` with `invalid_data`.
|
|
243
|
+
Object condition: `{ class: 'Wallet', owner: '$actor' }`, `{ class: 'Wallet', data: { … } }`; a class mask includes
|
|
244
|
+
descendants, `'{rule,Resource,member}'` means several classes. `deny` with `{ code, message }` is a custom endpoint
|
|
245
|
+
refusal.
|
|
246
|
+
- The database recomputes access rights by itself; to remove a permission, use
|
|
247
|
+
`db.rule(id).delete({ confirm: true }).rows()`.
|
|
248
|
+
|
|
249
|
+
### 9. Subscription and cache
|
|
250
|
+
|
|
251
|
+
<!-- run -->
|
|
252
|
+
```ts
|
|
253
|
+
const sub = db.watch({ from: 'start', classes: ['Wallet'] }) // from: 'now' (default) | 'start' | a cursor; pollMs
|
|
254
|
+
const lagMs = await sub.lag() // lag, ms
|
|
255
|
+
await db.Wallet(w).update({ balance: inc(1) }).rows()
|
|
256
|
+
let cursor = ''
|
|
257
|
+
for await (const ev of sub) { // ev: { cursor, row, id, class, op, rev, at }
|
|
258
|
+
cursor = ev.cursor // to resume: db.watch({ from: cursor })
|
|
259
|
+
if (ev.id === w.id && ev.row.data.balance === 1) break // break or sub.close() stops it
|
|
260
|
+
}
|
|
261
|
+
const shops = await db.Shop().cache({ ttl: 300 }).rows() // result cache; reset by your own writes and by signals
|
|
262
|
+
db.cache.stats(); db.cache.clear()
|
|
263
|
+
```
|
|
264
|
+
Events arrive without losses or duplicates, under RLS; a cursor older than the `all` tier of the retention policy gives
|
|
265
|
+
`cursor_expired`. With `from: 'now'` the subscription sees only what was committed after it started.
|
|
266
|
+
|
|
267
|
+
### 10. As-of snapshot and history
|
|
268
|
+
|
|
269
|
+
<!-- run -->
|
|
270
|
+
```ts
|
|
271
|
+
const was = await db.Wallet(w).asOf('2026-10-01T00:00:00Z').first() // as it was at that moment (an ISO string or a Date)
|
|
272
|
+
await db.Shop(shop).Wallet().asOf(new Date('2026-10-01')).rows()
|
|
273
|
+
const history = await db.Wallet(w).versions() // all versions; the deletion version has $deleted
|
|
274
|
+
await db.Wallet(w200).versions({ follow: true }) // also across id changes (reclass, rekey)
|
|
275
|
+
```
|
|
276
|
+
A class with `history: false` does not provide `versions`, `asOf`, `restore` or subscription events.
|
|
277
|
+
|
|
278
|
+
### 11. Import from 0.21
|
|
279
|
+
|
|
280
|
+
The 1.0 schema is installed in advance (`up()` without the demo seed); the connections are admin connections. The
|
|
281
|
+
source is the 0.21 tables (`Schema`, `Entity`, `Account`, `Credential`, `Resource`, `Rule`) in a `v1.<name>` schema of
|
|
282
|
+
any database. The repository has the fixture `lib/test/fixtures/v021.sql`, a dump of these tables with the marker
|
|
283
|
+
`<SCHEMA>`: replace the marker and run the file.
|
|
284
|
+
```ts
|
|
285
|
+
import postgres from 'postgres'; import { readFile } from 'node:fs/promises'
|
|
286
|
+
const sql = postgres(ADMIN)
|
|
287
|
+
await sql.unsafe((await readFile('lib/test/fixtures/v021.sql', 'utf8')).replaceAll('<SCHEMA>', 'v1.fixture'))
|
|
288
|
+
await up({ dsn: ADMIN, schema: 'imported' }) // v2.imported
|
|
289
|
+
```
|
|
290
|
+
```sh
|
|
291
|
+
npx letopis import --from "$ADMIN" --from-schema v1.fixture --dsn "$ADMIN" --schema v2.imported \
|
|
292
|
+
--tenant Import --keyless Service
|
|
293
|
+
```
|
|
294
|
+
- `--tenant <name>` — the tenant for the System data of 0.21; `--keyless <Class>` — a class whose data violates the
|
|
295
|
+
key (without it, importing the fixture stops: two 0.21 objects give one 1.0 id); `--rename Old=New` — names taken by
|
|
296
|
+
1.0 methods; `--update` — append new versions; `--commit-every N` — commit in portions; `--partition <name>` — the
|
|
297
|
+
0.21 source partition (default `entity`).
|
|
298
|
+
- The report is JSON at the end of the output (before it come the progress lines `letopis.import: …`): `classes`,
|
|
299
|
+
`notes`, `source`/`target` (objects, versions and live ones by class in 0.21 and 1.0), `tenants`, `accounts`,
|
|
300
|
+
`credentials`, `resources`, `rules`, `objects`, `uuidFields`, `droppedFields`, `ownerLost`, `verify`, `ms`; the exit
|
|
301
|
+
code is 1 if `verify` failed. Repeating the import adds what is missing, without duplicates.
|
|
302
|
+
|
|
303
|
+
### Operations
|
|
304
|
+
|
|
305
|
+
`db.verify({ data: true })` and `letopis verify` — hash chains, the anchor; `db.maintain()` and
|
|
306
|
+
`connect({ maintain: '1h' })` — sessions, codes, journal thinning; `db.trimHistory(target, moment)`;
|
|
307
|
+
`db.reset({ level, confirm, tenant })` — with `up({ allowReset: true })`; `db.describe()` /
|
|
308
|
+
`letopis describe --format text`. Close each connection once; connections from `svc.as()` share the pool with `svc`:
|
|
309
|
+
<!-- run -->
|
|
310
|
+
```ts
|
|
311
|
+
await adm.close(); await db.close(); await svc.close()
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Pitfalls in 1.0
|
|
315
|
+
|
|
316
|
+
1. **`create` with a taken key is the error `exists`** (`err.detail.id`), not an overwrite. To replace the whole
|
|
317
|
+
object, use `upsert`, and only for a class with a key (otherwise `no_key`). For a partial edit, use `update`.
|
|
318
|
+
2. **`update` never creates.** If the step found nothing, the result is empty with no error, so check its length;
|
|
319
|
+
without a filter and without `data.id`, all rows of the step are edited. `target_not_found` comes with `{ rev }` or
|
|
320
|
+
if the row was deleted between the lookup and the edit.
|
|
321
|
+
3. **Verbs return a chain.** Without a terminal (`.rows()`, `.first()` …) nothing runs. `delete()` without
|
|
322
|
+
`{ confirm: true }` is only a preview; `purge` works only on deleted entities (`not_deleted`).
|
|
323
|
+
4. **Money and counters**: `update({ balance: inc(n) })` — the database adds inside the statement, without a read and
|
|
324
|
+
without lost updates; the `minimum` check in the schema catches a drop below zero. For a total computed from what
|
|
325
|
+
you read, use `{ rev }` (re-read and retry on `conflict`) or `forUpdate()` in `db.begin()`. Idempotency comes from
|
|
326
|
+
an operation key (`[Wallet, opId]`): a repeat gives `exists`.
|
|
327
|
+
5. **`db.begin()` is `read committed`.** Two reads in one transaction can see different data. To lock what you read:
|
|
328
|
+
`await tr.Wallet(w).forUpdate().first()` (only in `db.begin()`, otherwise `tx_required`; only with `rows`, `first`,
|
|
329
|
+
`ids`, otherwise `lock_unsupported`). `tr.lock(...keys)` takes an advisory lock until the end of the transaction.
|
|
330
|
+
6. **An error breaks an explicit transaction.** After any error (including `exists` and `conflict`), every call and
|
|
331
|
+
`commit()` throw the original error, and `commit()` rolls back in that case; `rollback()` succeeds. Pattern:
|
|
332
|
+
`const tr = await db.begin(); try { …; await tr.commit() } catch (e) { await tr.rollback(); throw e }`.
|
|
333
|
+
`db.begin()` has no retries: on `40P01`, repeat the whole block. Operations from the pool retry `40001`/`40P01`
|
|
334
|
+
by themselves.
|
|
335
|
+
7. **`commit_unknown`** — the connection dropped during `COMMIT`: the transaction may have been committed. Do not
|
|
336
|
+
retry blindly; repeat the same operation with the same key (`exists` means "already written").
|
|
337
|
+
8. **The loader needs an admin connection** (`allowBypassRls: true`, the owner role), otherwise `admin_required`;
|
|
338
|
+
inside `db.begin()` it gives `invalid_query`. A regular application uses the application role, without bypassing
|
|
339
|
+
RLS.
|
|
340
|
+
9. **Data is under RLS.** Without a session (`token`/`apiKey`) a write gives `no_session`; a row of another tenant or
|
|
341
|
+
an invisible row cannot be told apart from a missing one: the step does not find it, and a reference to it gives
|
|
342
|
+
`target_not_found`; without a permission you get an empty result or `acl_denied`. `` db.sql`…` `` and raw SQL also run under the session and RLS.
|
|
343
|
+
10. **The key is immutable.** Editing a key field, the id, the tenant or the owner gives `immutable_key`; a new key is
|
|
344
|
+
`rekey(patch)` (or `db.rekeyClass(class, key)`), and that is a new id. Changing the class is
|
|
345
|
+
`reclass(class, data)`, not `update` (`use_reclass`). Deletion, `reclass` and `rekey` run only in
|
|
346
|
+
`read committed` (`isolation_level`).
|
|
347
|
+
11. **Classes live in the session tenant.** `letopis sync` with a tenant's token writes the classes to that tenant;
|
|
348
|
+
other tenants do not see them. Classes shared by all tenants live in System (the installation seed or an import).
|
|
349
|
+
12. **Do not pass the id of a class with a key**: it is computed (`db.idOf('Shop', { name: 'Center' })`); a different
|
|
350
|
+
id gives `id_mismatch`, and an id for a class without a key gives `id_from_db`.
|
|
351
|
+
13. **Method names are reserved**: a class named `count`, `sort`, `set`, `run`, `account`, `sql`, `auth` … cannot be
|
|
352
|
+
reached as a step (the list is `RESERVED_CLASS_NAMES`). The removed 0.21 API (`run()`, `account()`, `watch(cb)`,
|
|
353
|
+
`reloadSchema()`, the facades `db.accounts` …) throws `removed` with the replacement — see
|
|
354
|
+
[MIGRATION.md](MIGRATION.md) (in Russian).
|
|
355
|
+
14. **Access rules deny by default.** A new tenant without rules sees nothing; `up({ tenant })` and `createTenant`
|
|
356
|
+
(`grantAll`) are the quick start. The `auth.*` permissions (passwords, sessions, impersonation) belong to the
|
|
357
|
+
service and the system administrator.
|
|
358
|
+
15. **Errors:** `err.code` is a lowercase code (`exists`, `conflict`, `invalid_data` …), the text is
|
|
359
|
+
`letopis: <code>: …`; data errors (`ValidationError`) have `err.issues: [{ path, keyword, message }]`.
|
|
360
|
+
16. **A subscription with `'now'` starts when the database answers.** `db.watch()` returns immediately, and the
|
|
361
|
+
subscription takes the "now" moment with its first query to the database. A write made right after `db.watch()`
|
|
362
|
+
can be committed earlier and miss the stream. If you need a guarantee, subscribe from a saved cursor or from
|
|
363
|
+
`'start'`.
|
|
364
|
+
17. **The terminal returns the rows of the last step.** `…Payment().create(…).Wallet().update(…).rows()` returns the
|
|
365
|
+
`Wallet` rows, not the created payment; if you need the payment, end the chain on it.
|
|
366
|
+
18. **A batch queue belongs to its facade.** `db.batch('x')` on the root, on each `await db.as(…)` and on each
|
|
367
|
+
`db.begin()` are different queues; a plan runs on behalf of whoever queued it. Collect plans through one facade
|
|
368
|
+
object.
|