letopis 1.4.0 → 1.4.2
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 +15 -7
- package/AGENT-CHEATSHEET.md +19 -11
- package/CHANGELOG.md +299 -1
- package/README.en.md +180 -62
- package/README.md +165 -55
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +53 -18
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +92 -15
- package/dist/chain.js +56 -7
- package/dist/cli.js +16 -2
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +73 -45
- package/dist/index.js +69 -14
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.d.ts +10 -3
- package/dist/load.js +39 -11
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +8 -6
- package/dist/model.d.ts +4 -1
- package/dist/model.js +7 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +39 -11
- package/dist/project.js +75 -15
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +8 -4
- package/dist/sql.js +110 -25
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +22 -4
- package/dist/types.d.ts +21 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +48 -25
- package/dist/up.js +94 -41
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +29 -5
- package/dist/watch.js +62 -13
- package/dist/write.d.ts +3 -2
- package/dist/write.js +52 -19
- package/package.json +8 -4
- package/sql/10-core.sql +6 -1
- package/sql/60-write.sql +47 -18
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +41 -3
- package/sql/88-derived.sql +3 -1
- package/sql/90-time.sql +118 -30
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +1 -1
- package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/README.en.md
CHANGED
|
@@ -130,7 +130,7 @@ What happened here:
|
|
|
130
130
|
from the journal.
|
|
131
131
|
|
|
132
132
|
Calling `up()` again is safe: it re-applies the same revision and does not touch the data, and it does not issue the
|
|
133
|
-
service key again while the installation still has
|
|
133
|
+
service key again while the installation still has at least one API key that is not revoked. Next, section 2 explains the design, and sections
|
|
134
134
|
3–13 go through the tasks in order.
|
|
135
135
|
|
|
136
136
|
## 2. How letopis works
|
|
@@ -243,11 +243,16 @@ functions and the system seed. On the first installation, it issues a service ke
|
|
|
243
243
|
| `owner`, `app` | the names of the owner and application roles (`letopis_owner`, `letopis_app` by default) |
|
|
244
244
|
|
|
245
245
|
Result: `schema` (the full name), `serviceKey` (on the first installation, and on a re-run when the installation has no
|
|
246
|
-
|
|
246
|
+
API key left except revoked ones: an expired or disabled key counts too; otherwise `null`), `created`, `upgraded`, `revision`, `migrations`, `analyzed`, `owner`, `app`,
|
|
247
247
|
`signals` (the signal mode of the installation), `warnings` (without `quiet` they are printed), and also `tenant` and
|
|
248
248
|
`pglite` if they were requested. On PostgreSQL below 18.2, `up()` warns: such a server could truncate CLOG that unread
|
|
249
249
|
signals still need — upgrade it.
|
|
250
250
|
|
|
251
|
+
Several instances of a service may call `up()` for the same schema at the same time: the installation runs under a lock
|
|
252
|
+
on the schema. The first `up()` installs or upgrades it, the others wait and re-apply the ready schema
|
|
253
|
+
(`created: false`, `upgraded: false`); one of them creates the first `tenant`, the others find it by name. `up()` calls
|
|
254
|
+
for different schemas of one database do not wait for each other.
|
|
255
|
+
|
|
251
256
|
To hide class names from signals, use the `opaque` mode (or `tenant` if the names are not secret but you want the
|
|
252
257
|
cache to be precise per tenant); after changing the mode, restart the application processes — they read the mode on
|
|
253
258
|
connect:
|
|
@@ -291,7 +296,9 @@ A repeat with the same name and login duplicates nothing and does not change the
|
|
|
291
296
|
session. The login goes into the id as is: `Anna@shop.ru` and `anna@shop.ru` would give two accounts, and the second one
|
|
292
297
|
would have no password, so write logins in one case. The `created` field in the result tells whether this call created
|
|
293
298
|
the tenant: for an existing tenant with a new user it is `false`. The roles are set by `user.roles`, a non-empty list
|
|
294
|
-
of names (not masks), otherwise `invalid_data`.
|
|
299
|
+
of names (not masks), otherwise `invalid_data`. The password kind `user.kind` (`PASSWORD` by default) follows the rule
|
|
300
|
+
for a credential kind, as in `auth.setPassword` ([API reference](#dbauth)), otherwise it is `invalid_data` too. A
|
|
301
|
+
repeat does not change the roles of an existing membership: they are
|
|
295
302
|
changed by editing the membership ([Granting a permission](#granting-a-permission)). A repeat does not bring back
|
|
296
303
|
what was deleted: if the tenant, the user account or the membership is deleted (the row is gone but its history is in
|
|
297
304
|
the journal), `createTenant` answers `invalid_data` and writes nothing. The error text names what was deleted and
|
|
@@ -363,13 +370,16 @@ const adm = await connect({ dsn: ADMIN, schema: r.schema, token: r.tenant!.token
|
|
|
363
370
|
|
|
364
371
|
### Upgrading the package and schema
|
|
365
372
|
|
|
366
|
-
The schema has an engine revision (currently
|
|
373
|
+
The schema has an engine revision (currently 7). If a new package version brings a new revision, `connect()` only warns
|
|
367
374
|
and names the command. It is `up()` that refuses: a lagging schema without `upgrade: true`, and a schema newer than the
|
|
368
|
-
package.
|
|
375
|
+
package. To a schema newer than the package (a newer package has already upgraded it, and old application instances
|
|
376
|
+
are still running) `connect()` connects anyway and warns that the letopis package should be updated. So a startup script that calls `up()` on every start will stop after a package update until it gets
|
|
369
377
|
`upgrade: true`. `up({ dsn, schema, upgrade: true })` applies the migrations from `sql/migrate/` and re-applies the
|
|
370
|
-
functions; the data is kept.
|
|
378
|
+
functions; the data is kept. Revision 7 (1.4.1) adds the foreign key `secret.account`: migration `0007` first erases
|
|
379
|
+
the `secret` rows whose accounts are already gone (the credentials of accounts deleted earlier were kept with the
|
|
380
|
+
`deleted_at` mark), and then sets the key.
|
|
371
381
|
|
|
372
|
-
The revision does not change with every version: 1.1–1.4 share one, while their functions differ. So `up()` also writes
|
|
382
|
+
The revision does not change with every version: 1.1–1.4.0 share one (6), while their functions differ. So `up()` also writes
|
|
373
383
|
an engine stamp into the schema comment: the package version and a hash of its SQL files. If the stamp does not match
|
|
374
384
|
the package files, `connect()` warns. When the schema was applied by an older version (or by a version before 1.4,
|
|
375
385
|
which has no stamp), it suggests `up({ schema })`. When a newer version has already upgraded the schema, it suggests
|
|
@@ -382,10 +392,30 @@ class name. While the index is being built, writes to the journal wait. On a lar
|
|
|
382
392
|
without stopping writes: `create index concurrently log_class_tenant_at on "v2.<schema>".log (class, tenant, at)` as
|
|
383
393
|
the owner role — then `up()` only drops the old one.
|
|
384
394
|
|
|
385
|
-
The `upgrade` test checks upgrading 1.1.0 and 1.0.1 schemas
|
|
395
|
+
The `upgrade` test checks upgrading 1.4.1, 1.1.0 and 1.0.1 schemas; every release checks the upgrade from the previous
|
|
396
|
+
release. The old package installs the schema and the old engine
|
|
386
397
|
writes the data, then the current version's `up()` runs. After it, the versions and hashes written by the old engine
|
|
387
|
-
are in place, `verify({ data: true })` is clean, and the schema catalog matches a clean installation. A 1.1
|
|
388
|
-
upgraded with a plain `up()`,
|
|
398
|
+
are in place, `verify({ data: true })` is clean, and the schema catalog matches a clean installation. A 1.4.1 schema of the
|
|
399
|
+
same revision 7 is upgraded with a plain `up()`, without migrations; 1.1.0 (revision 6) and 1.0.1 (revision 5) — with
|
|
400
|
+
`up({ upgrade: true })`: with migration `0007` and with migrations `0006` and `0007`.
|
|
401
|
+
|
|
402
|
+
### Versions and compatibility
|
|
403
|
+
|
|
404
|
+
letopis versions follow semver. What each version step promises:
|
|
405
|
+
|
|
406
|
+
- **Patch** (1.4.1 → 1.4.2) — fixes only. The engine revision stays the same: a plain `up({ schema })` re-applies the
|
|
407
|
+
fixed functions, there are no migrations; until it runs, `connect()` warns about the stamp mismatch. Working code gets
|
|
408
|
+
no new refusals, and no methods, options, exports or error codes disappear.
|
|
409
|
+
- **Minor** (1.4 → 1.5) — new features: methods, options, error codes; behavior changes that do not break working code;
|
|
410
|
+
a new engine revision. `up({ schema, upgrade: true })` applies the new revision. Nothing is removed or renamed.
|
|
411
|
+
- **Major** (1.x → 2.0) — removed and renamed methods, options, exports and error codes, changed signatures and types,
|
|
412
|
+
new refusals for everyone. `MIGRATION.md` describes what to change in your code.
|
|
413
|
+
|
|
414
|
+
The text of an error message is not part of the API: a patch may make it more precise. Rely on `err.code`.
|
|
415
|
+
|
|
416
|
+
Before every release the `check:semver` script checks the version step: it compares the API contract
|
|
417
|
+
(`docs/api-contract.json`) and the engine revision with the previous release and requires at least the matching step.
|
|
418
|
+
Every release checks the schema upgrade from the previous release (the `upgrade` test).
|
|
389
419
|
|
|
390
420
|
### Development without PostgreSQL: PGlite
|
|
391
421
|
|
|
@@ -623,9 +653,10 @@ ends type is `InferLinks<typeof Product>`. Your own `meta` shape is typed by ext
|
|
|
623
653
|
|
|
624
654
|
### Class names
|
|
625
655
|
|
|
626
|
-
A chain step is named after a class, its alias or a role name.
|
|
627
|
-
(`count`, `sort`, `set`, `sql`, `auth` and others) before classes
|
|
628
|
-
step
|
|
656
|
+
A chain step is named after a class, its alias or a role name. Where a chain or root method exists, the library parses
|
|
657
|
+
its name (`count`, `sort`, `set`, `sql`, `auth` and others) before classes: a class `count` is reachable as the first
|
|
658
|
+
step `db.count(…)`, but not after another step. The database does not accept a link with such a name. Do not give
|
|
659
|
+
classes these names; the full list is in the
|
|
629
660
|
["Reserved names"](#reserved-names) section.
|
|
630
661
|
|
|
631
662
|
## 5. Writing
|
|
@@ -679,8 +710,9 @@ fields are not lost. If the step has no filter of its own, the target is taken f
|
|
|
679
710
|
`data.id` all entities of the step are edited: `db.Product().update(…)` edits all products of the tenant. If the step
|
|
680
711
|
found nothing, the result is empty and there is no error, so check the length of the result. If `update` affected fewer
|
|
681
712
|
rows than the step found, the whole plan is rolled back: a row deleted in the meantime gives `target_not_found`, and a
|
|
682
|
-
row that is visible but may not be edited gives `acl_denied`. The `.tags(…)` modifier before `update`
|
|
683
|
-
|
|
713
|
+
row that is visible but may not be edited gives `acl_denied`. The `.tags(…)` modifier before `update` selects the rows;
|
|
714
|
+
a string or a list also replaces their tags entirely, while an operator (`has`, `hasAny`, `hasAll`) only selects — the
|
|
715
|
+
tags stay as they are.
|
|
684
716
|
|
|
685
717
|
### Create or replace: `upsert`
|
|
686
718
|
|
|
@@ -815,8 +847,15 @@ await db.Order(order).line().create({ qty: 1, price: 900 }).Product.set(sencha).
|
|
|
815
847
|
journal conflict, the library retries the whole plan, up to three times.
|
|
816
848
|
- An error in any part rolls back the whole plan. `exists` and `conflict` are not retried: they are answers for the
|
|
817
849
|
application.
|
|
818
|
-
- Modifiers before a verb (`limit`, `sort`) narrow its targets.
|
|
850
|
+
- Modifiers before a verb (`limit`, `offset`, `sort`) narrow its targets.
|
|
851
|
+
- A modifier after a verb applies to what follows it: a class step, `versions()`, `diff()`, `blame()` and aggregates,
|
|
852
|
+
or the targets of the next verb. If a `rows`, `first`, `ids`, `count` or `paths` terminal follows right away, there is
|
|
853
|
+
nothing left to narrow — the verb has gone through all targets of the step — so
|
|
854
|
+
`db.Product().update(…).limit(1).rows()` gives `invalid_query` and writes nothing. In a batch, `run()` refuses the
|
|
855
|
+
same way.
|
|
819
856
|
- A verb right after a verb writes to the same entities.
|
|
857
|
+
- The result rows of a verb come in the order of its targets: by `sort()` before the verb, otherwise by id; `first()`
|
|
858
|
+
after a verb is the first one in that order.
|
|
820
859
|
|
|
821
860
|
## 6. Reading
|
|
822
861
|
|
|
@@ -861,7 +900,7 @@ const roots = await db.Category({ Parent: null }).rows() // root categorie
|
|
|
861
900
|
| `ne(v)`, `gt(v)`, `gte(v)`, `lt(v)`, `lte(v)` | ≠ (taking `null` into account), >, ≥, <, ≤ |
|
|
862
901
|
| `between(a, b)`, `inList([…])` | a ≤ x ≤ b; a value from the list |
|
|
863
902
|
| `like(s)`, `ilike(s)`, `starts(s)`, `ends(s)` | a pattern with `%` and `_`; case-insensitive; starts with; ends with |
|
|
864
|
-
| `has(v)`, `hasAny([…])`, `hasAll([…])` | an array field contains the value; at least one; all |
|
|
903
|
+
| `has(v)`, `hasAny([…])`, `hasAll([…])` | an array field contains the value; at least one; all (`hasAll([])` matches every row, `hasAny([])` matches none) |
|
|
865
904
|
| `exists(true \| false)`, `isNull()` | the field is present or not; the field is `null` or missing |
|
|
866
905
|
| `not(v)` | negation of an operator; for a scalar, "not equal" |
|
|
867
906
|
| `or(f1, f2, …)` | any of the step filters |
|
|
@@ -897,8 +936,8 @@ const page2 = await db.Product().sort('data.price', 'desc')
|
|
|
897
936
|
|
|
898
937
|
`sort(field, 'asc' | 'desc')` sorts by `'at'`, `'rev'`, `'id'` or `'data.<path>'`. `after(cursorOf(row, field))` is
|
|
899
938
|
pagination "after this row"; it requires `sort()`. Such pagination withstands inserts and deletes between pages, but a
|
|
900
|
-
row whose sort field has changed may drop out or repeat. A cursor is an object `{ v, id }` (the field value
|
|
901
|
-
id): it survives JSON, so you can hand it to a client. The field in `cursorOf` must match the `sort` field. The cursor
|
|
939
|
+
row whose sort field has changed may drop out or repeat. A cursor is an object `{ v, id, rev }` (the field value, the
|
|
940
|
+
row id and its version number): it survives JSON, so you can hand it to a client. The field in `cursorOf` must match the `sort` field. The cursor
|
|
902
941
|
value is checked against the type of the `sort` field before the query, and a wrong one is `invalid_query`: a numeric
|
|
903
942
|
field needs a finite number, `rev` an integer, a boolean field `true` or `false`, a text field a string, uuid fields
|
|
904
943
|
and `id` a uuid, time fields and `at` an ISO string with a time zone, a date field `YYYY-MM-DD`, a field without a
|
|
@@ -906,6 +945,12 @@ description a string, a number or `true`/`false`. `null` fits only data fields,
|
|
|
906
945
|
The check cannot tell apart a cursor of the same type taken from another field. `limit` and `offset` are available
|
|
907
946
|
too.
|
|
908
947
|
|
|
948
|
+
`after()` also pages `versions()`: the cursor of a version carries its `rev`, so versions of one object with an equal
|
|
949
|
+
sort field are not lost. In `paths()` the cursor comes from `cursorOf(path, field)`: the field and the id are those of
|
|
950
|
+
the last node of the path, and `path` holds the ids of all nodes, so paths to the same node are not lost. A cursor
|
|
951
|
+
`cursorOf(path.Node, field)` taken from a single node skips the other paths to that node. `count()`, aggregates,
|
|
952
|
+
`diff()` and `blame()` ignore the cursor.
|
|
953
|
+
|
|
909
954
|
A data field may be `null` or missing. `sort` places such rows the way `order by` in PostgreSQL does: last when
|
|
910
955
|
ascending, first when descending, and `after()` treats `null` as a value after all others (ascending) or before them
|
|
911
956
|
(descending). So pages go through such rows without gaps or repeats, and a cursor with the value `null` gives the next
|
|
@@ -932,7 +977,9 @@ const byStatus = await db.Order().countBy('data.status') // { new: 1 }
|
|
|
932
977
|
```
|
|
933
978
|
|
|
934
979
|
The database computes aggregates over the entities of the last step. `limit(n)` and `offset(n)` before `countBy()`
|
|
935
|
-
apply to the groups: `db.Order().limit(3).countBy('data.status')` gives the three most frequent values.
|
|
980
|
+
apply to the groups: `db.Order().limit(3).countBy('data.status')` gives the three most frequent values. For a boolean
|
|
981
|
+
field, `min()` is `false` if there is at least one `false` (whether all values are `true`), and `max()` is `true` if
|
|
982
|
+
there is at least one `true`; `sum()` and `avg()` do not work on a boolean field — PostgreSQL returns an error.
|
|
936
983
|
|
|
937
984
|
### Paths
|
|
938
985
|
|
|
@@ -942,7 +989,8 @@ const orderPaths = await db.Order(order).line().Product().paths() // [{ Order:
|
|
|
942
989
|
```
|
|
943
990
|
|
|
944
991
|
`paths()` returns the nodes of every variant of the path; the key of a node is the step name or the name from
|
|
945
|
-
`.alias(name)`.
|
|
992
|
+
`.alias(name)`. The keys follow the order of the steps; if the chain ends by returning to an earlier node, that node
|
|
993
|
+
comes last. Repeating a link in the chain returns to its node: this way you can go from one link in several
|
|
946
994
|
directions. `db.entity(row | chain)` inserts a ready node into the path.
|
|
947
995
|
|
|
948
996
|
## 7. Deleting and restoring
|
|
@@ -965,9 +1013,10 @@ try {
|
|
|
965
1013
|
`delete()` without `{ confirm: true }` is only a preview: the whole cascade closure. Every preview row has `$action`
|
|
966
1014
|
(`delete`, `unset` or `restrict`) and `$depth`, the cascade level (0 is the target itself; `unset` and `restrict` rows
|
|
967
1015
|
do not have it). With the confirmation, all targets of the step are deleted in one statement, and the cascade applies
|
|
968
|
-
the rules of the ends. The
|
|
969
|
-
|
|
970
|
-
|
|
1016
|
+
the rules of the ends. The result is the tombstones level by level, by id within a level (the journal has them in the
|
|
1017
|
+
same order); targets of classes without a journal (`history: false`) come last. The preview does not take permissions
|
|
1018
|
+
into account: a row with `$action: 'delete'` may fail to be deleted for a user without the permission (`acl_denied`),
|
|
1019
|
+
so a user interface should ask `acl.checkData(class, 'DELETE')`. The rules of the ends:
|
|
971
1020
|
|
|
972
1021
|
- `restrict`: deletion is forbidden while the target is referenced (`delete_restricted` with a list of the visible rows
|
|
973
1022
|
and the number of the others);
|
|
@@ -1254,7 +1303,7 @@ const changed = await adm.load({ Shop: [{ name: 'Cornflower', city: 'Samara' }]
|
|
|
1254
1303
|
| Option | Values |
|
|
1255
1304
|
|---|---|
|
|
1256
1305
|
| `duplicates`: a repeated id within the load | `'error'` (default): cancel; `'first'` or `'last'`: keep the first or the last row |
|
|
1257
|
-
| `existing`: the object already exists in the database | `'error'` (default): cancel; `'skip'`: skip, do not resurrect deleted objects; `'update'`: a new version if the content differs (unchanged objects are counted in `skipped`), deleted objects are created again |
|
|
1306
|
+
| `existing`: the object already exists in the database | `'error'` (default): cancel; `'skip'`: skip, do not resurrect deleted objects; `'update'`: a new version if the content differs (unchanged objects are counted in `skipped`), deleted objects are created again; an object of another class with the same id (a class without a key after `reclass`) is `exists` |
|
|
1258
1307
|
|
|
1259
1308
|
### Portions, history and verification
|
|
1260
1309
|
|
|
@@ -1431,8 +1480,10 @@ denial that the identifier is taken ([section 18](#18-limitations)).
|
|
|
1431
1480
|
<!-- run: guide -->
|
|
1432
1481
|
```ts
|
|
1433
1482
|
const shortToken = await svc.auth.sessionFor(BORIS, { ttl: 600 }) // a session for 10 minutes of idle time for another process
|
|
1483
|
+
const whose = await svc.auth.session(shortToken) // { account, tenant, member, expiresAt, loginAt, methods }
|
|
1434
1484
|
const boris = await connect({ dsn: APP, schema: r.schema, token: shortToken })
|
|
1435
1485
|
await boris.auth.revoke() // sign-out: the session is revoked
|
|
1486
|
+
const ended = await svc.auth.session(shortToken) // null
|
|
1436
1487
|
await boris.close()
|
|
1437
1488
|
```
|
|
1438
1489
|
|
|
@@ -1445,14 +1496,25 @@ account is deleted. Deleting a credential revokes the other sessions of the acco
|
|
|
1445
1496
|
"key + secret" pair revokes only the sessions opened by that key (and old sessions of the same sign-in method without
|
|
1446
1497
|
a key mark); the current session stays unless you pass `keepCurrent: false`.
|
|
1447
1498
|
|
|
1499
|
+
A service that holds a user's token (for example, in a cookie) learns about the session with `svc.auth.session(token)`:
|
|
1500
|
+
no connection is opened with that token, and the session is not extended. The answer tells whose session it is
|
|
1501
|
+
(`account`), which tenant it works in (`tenant`: the one chosen by `auth.switch()` or the account's own tenant),
|
|
1502
|
+
whether the membership there is intact (`member`; always `true` in the own tenant), when it expires (`expiresAt`), and
|
|
1503
|
+
when and how the sign-in happened (`loginAt`, `methods`: `PASSWORD`, `APIKEY`, `SERVICE`, `TOTP` …). A wrong, revoked
|
|
1504
|
+
or expired token and a disabled account give `null`; the reason is not told apart. This is how a web service checks the
|
|
1505
|
+
session at the start of every request: reading under a revoked session gives no error, the answers are simply empty.
|
|
1506
|
+
The method is called by the service or the system administrator (the `auth.session` permission); a user gets
|
|
1507
|
+
`acl_denied`.
|
|
1508
|
+
|
|
1448
1509
|
### Working in another tenant
|
|
1449
1510
|
|
|
1450
1511
|
A service acts on behalf of a user through `svc.as(account, { tenant, owner })`. The database checks the service's
|
|
1451
1512
|
`auth.impersonate` permission (by System rules), that the user exists and is enabled, and that the tenant is available
|
|
1452
1513
|
to the user, either their own or through membership. You cannot act on behalf of System, services, system
|
|
1453
1514
|
administrators or disabled accounts. The user's visibility under the access rules is not checked. In the journal,
|
|
1454
|
-
`author` is the user and `agent` is the service.
|
|
1455
|
-
|
|
1515
|
+
`author` is the user and `agent` is the service. The class registry of `db.as()` is one per tenant, and a connection
|
|
1516
|
+
keeps at most 1000 of them: a long unused one is evicted, and a facade whose registry was evicted keeps working — the
|
|
1517
|
+
registry is re-read. A user's connection by their own token switches between tenants like this:
|
|
1456
1518
|
|
|
1457
1519
|
<!-- run: guide -->
|
|
1458
1520
|
```ts
|
|
@@ -1557,7 +1619,15 @@ Fields of the system permission classes:
|
|
|
1557
1619
|
|
|
1558
1620
|
`db.auth.purgeAccount(account)` deletes an account and the data of its tenant. The system administrator or the owner of
|
|
1559
1621
|
its tenant can call it; you cannot delete yourself or the last owner of a tenant. Ownership of rows in other tenants
|
|
1560
|
-
passes to those tenants, sessions are revoked, credentials are deleted.
|
|
1622
|
+
passes to those tenants, sessions are revoked, credentials are deleted. An owner can also delete the tenant they are
|
|
1623
|
+
working in right now: their session returns to their personal tenant.
|
|
1624
|
+
|
|
1625
|
+
The system administrator can also delete the account row itself — `db.Account(id).delete({ confirm: true })` in the
|
|
1626
|
+
System tenant; the data of the account's tenant stays then. Either way, the account's rows in `secret` are erased at
|
|
1627
|
+
once: sessions, password and key hashes, TOTP secrets, external accounts (the foreign key `secret.account` with a
|
|
1628
|
+
cascade, revision 7). Before that, the sessions are revoked with a signal, and processes drop the cached answers of
|
|
1629
|
+
these sessions. The System account cannot be deleted (`acl_denied`): the service rows of the installation in `secret`
|
|
1630
|
+
belong to it.
|
|
1561
1631
|
|
|
1562
1632
|
## 11. History and point-in-time reads
|
|
1563
1633
|
|
|
@@ -1579,6 +1649,12 @@ Without `sort()`, versions go by id and version number (with `follow`, by time).
|
|
|
1579
1649
|
too, and `limit()` limits them in the query itself: `db.Product(kettle).sort('at', 'desc').limit(5).versions()` gives
|
|
1580
1650
|
the five latest versions, newest first.
|
|
1581
1651
|
|
|
1652
|
+
`follow: true` gives the history of the object across all its ids: before a move, the versions of the former id;
|
|
1653
|
+
after it, of the new one. An object created later on the former id (the same key after `rekey`) is a different one,
|
|
1654
|
+
and its versions are not in this history; its own history with `follow` starts at its creation. `diff({ follow: true
|
|
1655
|
+
})` and `blame()` work the same way. Without `follow`, `versions()` gives every version of the id, whoever it
|
|
1656
|
+
belonged to.
|
|
1657
|
+
|
|
1582
1658
|
### Point-in-time read
|
|
1583
1659
|
|
|
1584
1660
|
<!-- run: guide -->
|
|
@@ -1681,7 +1757,8 @@ none.
|
|
|
1681
1757
|
single-valued end, every id of a multi-valued end (`['links', role, id]`), every tag (`['tags', tag]`), `owner` and
|
|
1682
1758
|
`class`. `rev`, `at`, `author`, `agent`, `op` and `reason` come from the version that changed the leaf last. Empty
|
|
1683
1759
|
`agent` and `reason` are omitted; `id` is present only if it is a version of the former id (the history went
|
|
1684
|
-
through `reclass` or `rekey`).
|
|
1760
|
+
through `reclass` or `rekey`). `blame()` does not look at versions of another object on the same id — one that
|
|
1761
|
+
lived there before a move or was created after it.
|
|
1685
1762
|
- `exact: false` means there are gaps between the current version and the found one (the history is thinned,
|
|
1686
1763
|
trimmed or partly invisible): a missing version may have changed the leaf too. `complete: true` means the whole
|
|
1687
1764
|
history was scanned without gaps; by default at most the 1000 latest versions are scanned.
|
|
@@ -1727,7 +1804,7 @@ const sub = db.watch({ from: 'start', classes: ['Product'] }) // the whole jou
|
|
|
1727
1804
|
await db.Product(kettle).update({ price: 2100 }).rows()
|
|
1728
1805
|
let cursor = ''
|
|
1729
1806
|
for await (const ev of sub) { // ev: { cursor, row, id, class, op, rev, at }
|
|
1730
|
-
cursor =
|
|
1807
|
+
cursor = sub.cursor! // the subscription cursor: watch({ from: cursor }) continues after this event
|
|
1731
1808
|
if (ev.id === kettle.id && ev.row.data.price === 2100) break // break (or sub.close()) stops the subscription
|
|
1732
1809
|
}
|
|
1733
1810
|
```
|
|
@@ -1746,10 +1823,14 @@ for await (const ev of sub) { // ev: { cursor, row, id, class, op, r
|
|
|
1746
1823
|
within a transaction in the order of writing. This is not the commit order: compare versions of one object by `rev`.
|
|
1747
1824
|
- A long write transaction in any database of the cluster delays the stream: `await sub.lag()` shows the lag in ms.
|
|
1748
1825
|
- A cursor older than the `all` tier of the retention policy gives `cursor_expired`: read the current state again and
|
|
1749
|
-
subscribe with `'now'`.
|
|
1826
|
+
subscribe with `'now'`. The time in the subscription cursor `sub.cursor` is the time of the last event, and once the
|
|
1827
|
+
subscription has caught up with the journal, the time of the poll, so a subscription that got no events for a day
|
|
1828
|
+
does not expire. The event cursor `ev.cursor` keeps the time of the event itself.
|
|
1750
1829
|
- Without `LISTEN` (`connect({ listen: false })`), the subscription polls the journal.
|
|
1751
|
-
- A subscription without losses is a saved cursor: store `
|
|
1752
|
-
after a restart.
|
|
1830
|
+
- A subscription without losses is a saved cursor: after processing an event, store `sub.cursor` (the position after
|
|
1831
|
+
the last delivered event) and resume from it after a restart. `ev.cursor` also continues the stream, but after a
|
|
1832
|
+
quiet period longer than the `all` tier such a cursor is older than the tier, and resuming from it gives
|
|
1833
|
+
`cursor_expired`.
|
|
1753
1834
|
|
|
1754
1835
|
### Projectors
|
|
1755
1836
|
|
|
@@ -1836,6 +1917,9 @@ sales.close() // stop() after the current bat
|
|
|
1836
1917
|
`out.inc(class, row, deltas, { tenant })` creates a row or completes it and adds the numbers without reading the row
|
|
1837
1918
|
(missing fields count from 0). `out.delete(class, key or id, { tenant })` deletes a row and does nothing if there is
|
|
1838
1919
|
none. `await out.get(class, key or id, { tenant })` reads the current row together with the writes of the batch.
|
|
1920
|
+
`delete` and `get` find a row of a class with a key by the key fields (`{ day: '2026-10-29' }`), and by id (a string
|
|
1921
|
+
or `{ $id }`) only a row of a class without a key; an id alone for a class with a key is `invalid_data`, and the
|
|
1922
|
+
batch is rolled back.
|
|
1839
1923
|
`out` writes only to the `into` classes and only to the tenant of the changes passed to `handle`; otherwise you get
|
|
1840
1924
|
`derived_class` or `tenant_denied`, and the batch is rolled back.
|
|
1841
1925
|
- `out` adds up the increments of a row given by key fields only (or by `$id` alone) before sending them to the
|
|
@@ -1888,11 +1972,15 @@ sales.close() // stop() after the current bat
|
|
|
1888
1972
|
at all — `derived_class`. Only an administrator may mark the classes of a tenant, so it is the administrator who
|
|
1889
1973
|
chooses the participants: a tenant user cannot join a projector on their own. The option `tenants: [id, …]`
|
|
1890
1974
|
is an explicit list of participants: the projector works only with these tenants (their `into` classes must be
|
|
1891
|
-
marked too) and ignores the marks of the others. The sources are the `from` families of these tenants;
|
|
1892
|
-
|
|
1893
|
-
`history: false`: it has no journal. So a tenant with such a class among the
|
|
1894
|
-
`invalid_class`, and the projector does not build its derived rows. Turn the journal
|
|
1895
|
-
tenant with `rebuild({ tenant })`.
|
|
1975
|
+
marked too) and ignores the marks of the others. The sources are the `from` families of these tenants; an `into`
|
|
1976
|
+
class is never a source, even if it falls into `from`: a projector does not read its own results. A projector does
|
|
1977
|
+
not see the changes of a class with `history: false`: it has no journal. So a tenant with such a class among the
|
|
1978
|
+
sources drops out with the code `invalid_class`, and the projector does not build its derived rows. Turn the journal
|
|
1979
|
+
of the class on and rebuild the tenant with `rebuild({ tenant })`.
|
|
1980
|
+
- A tenant that became a participant after the first projection (the administrator marked its classes later) gets an
|
|
1981
|
+
initial batch rebuild: the rows created before it joined get into the projection too. A projector re-reads its
|
|
1982
|
+
participants on a class signal, and on a connection without LISTEN (`listen: false`) at the start of `run()` and at
|
|
1983
|
+
least every 5 seconds.
|
|
1896
1984
|
- `batch` is how many changes go into a batch (500), `pollMs` is fallback polling (1000 ms): source signals wake the
|
|
1897
1985
|
projector earlier.
|
|
1898
1986
|
- `lockTimeout` is how long a projector transaction waits for another transaction's row lock (5000 ms; 0 — no limit).
|
|
@@ -1986,8 +2074,10 @@ stays correct:
|
|
|
1986
2074
|
- changes of permissions and classes, writes through `db.sql`, a revoked session and a listener reconnect clear the
|
|
1987
2075
|
whole cache or the results of the session;
|
|
1988
2076
|
- the default term is 60 seconds; the cache does not work inside `db.begin()`;
|
|
1989
|
-
- without `LISTEN` and in the `none` mode the cache is off, and `connect({ cache: { ttlOnly: true } })` enables it
|
|
1990
|
-
term
|
|
2077
|
+
- without `LISTEN` and in the `none` mode the cache is off, and `connect({ cache: { ttlOnly: true } })` enables it: an
|
|
2078
|
+
answer lives until its term ends unless a write of this connection resets it;
|
|
2079
|
+
- `db.load()`, the projector and `db.rebuildAggregates()` reset the cache, even of this connection, only by a database
|
|
2080
|
+
signal.
|
|
1991
2081
|
|
|
1992
2082
|
A process re-reads the class registry right away on a class change signal (one re-read at a time), and the registry
|
|
1993
2083
|
and the signal key on `*`, on an unknown token and on a payload of another mode (and without the key in the `opaque`
|
|
@@ -2008,10 +2098,18 @@ Between processes, the cache goes stale for the signal sending interval (`notify
|
|
|
2008
2098
|
const report = await adm.maintain() // { sessions, codes, thinned, batches, more, queue, skipped }
|
|
2009
2099
|
```
|
|
2010
2100
|
|
|
2011
|
-
`maintain()` deletes
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2101
|
+
`maintain()` deletes sessions and one-time codes of all kinds (`OTP` and custom `OTP_<NAME>` from `issueOtp({ kind })`)
|
|
2102
|
+
from the `secret` table — expired ones, as well as
|
|
2103
|
+
revoked sessions and codes that were used, replaced by a new one or ran out of attempts, — and the lock rows of deleted
|
|
2104
|
+
classes (two per class; a row held by a transaction goes on the next call), thins the journal by the
|
|
2105
|
+
retention policy and checks the PostgreSQL signal queue: if it is more than half full, you get a warning. You do not
|
|
2106
|
+
need `maintain()` for the rows of a deleted account: the deletion itself erases them ([Deleting an account](#deleting-an-account)).
|
|
2107
|
+
The system administrator, a role with the `letopis.maintain` permission or an admin connection can call it. An external scheduler
|
|
2108
|
+
runs it (`letopis maintain`), or the library itself does: `connect({ maintain: '1h' })`. The interval is a number of
|
|
2109
|
+
milliseconds or a string (`'500ms'`, `'30s'`, `'15m'`, `'1h'`, `'1d'`) from 1 ms to 2³¹−1 ms (about 24 days):
|
|
2110
|
+
`connect()` rejects any other value, including zero, a negative number or `NaN`, with `invalid_data` without opening
|
|
2111
|
+
connections. `close()` waits for a scheduled run in progress: it finishes the current batch and does not start the
|
|
2112
|
+
next one. Of several processes, only one works: each batch is its own
|
|
2015
2113
|
transaction, and the first thing it does is take the maintenance lock — a row of the `secret` table that neither the
|
|
2016
2114
|
application role nor any other database role can take; the lock is released when the batch commits. If another
|
|
2017
2115
|
process holds the lock, the call answers `skipped: true` before the first batch, and between batches it stops (that
|
|
@@ -2222,6 +2320,10 @@ letopis mcp --dsn <dsn> --schema <v2.name> --token <user token> [--write]
|
|
|
2222
2320
|
(or LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; the service key --api-key is not accepted)
|
|
2223
2321
|
```
|
|
2224
2322
|
|
|
2323
|
+
`letopis types` and `letopis describe` build their output from the session's classes, so without a valid session (the
|
|
2324
|
+
token expired, was revoked, is wrong or not given, or the API key is wrong) they exit with code 1 and the `no_session`
|
|
2325
|
+
error and leave the `--out` file untouched.
|
|
2326
|
+
|
|
2225
2327
|
### Monitoring
|
|
2226
2328
|
|
|
2227
2329
|
- `connect({ onQuery, slowMs })`: for every chain query, the hook gets the mode, the classes, the duration, the row
|
|
@@ -2341,17 +2443,19 @@ Removed 0.21 names (`uuidv7`, `LETOPIS_NS` and the methods listed below) throw `
|
|
|
2341
2443
|
| `link`, `lookup` | `{ account, kind, identifier, meta?, confirmed? }` → id; `{ kind, identifier, requireConfirmed?, ttl?, totp? }` → sign-in or `null` |
|
|
2342
2444
|
| `credentials`, `revokeCredential`, `setFlags` | `(account)` → credentials without secrets; `(id, { keepCurrent? })`; `(id, { confirmed?, enabled? })` |
|
|
2343
2445
|
| `sessionFor`, `refresh`, `revoke`, `revokeAll` | `(account, { ttl? })` → token; `({ ttl? })` → the new expiration time (the token stays the same); `()`; `(account?)` → number of sessions |
|
|
2446
|
+
| `session` | `(token)` → `{ account, tenant, member, expiresAt, loginAt, methods }` or `null` |
|
|
2344
2447
|
| `tenants`, `switch` | `()` → `[{ tenant, roles }]`; `(tenant)` |
|
|
2345
2448
|
| `purgeAccount` | `(account)` → `{ account, rows, versions, memberships, owned }` |
|
|
2346
2449
|
|
|
2347
|
-
Password, code and TOTP checks, session issuing and credential confirmation are called by the
|
|
2348
|
-
permissions); a regular user gets `acl_denied`. The credentials of System, services and system
|
|
2349
|
-
changed only by the account itself after a recent interactive sign-in, or by the system
|
|
2350
|
-
the service's keys: the service issues and revokes its own API keys and "key + secret"
|
|
2351
|
-
its own key, but not its other credentials; nobody can revoke the last active key of a
|
|
2352
|
-
`totp`, `verifyPassword` and `lookup` also check the TOTP code (the `auth.totp` permission)
|
|
2353
|
-
`TOTP` method. The credential kind `kind` is `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`,
|
|
2354
|
-
`TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`, `OTP`, `TOTP` and `
|
|
2450
|
+
Password, code and TOTP checks, session issuing, session details by token and credential confirmation are called by the
|
|
2451
|
+
service (`auth.*` permissions); a regular user gets `acl_denied`. The credentials of System, services and system
|
|
2452
|
+
administrators can be changed only by the account itself after a recent interactive sign-in, or by the system
|
|
2453
|
+
administrator. The exception is the service's keys: the service issues and revokes its own API keys and "key + secret"
|
|
2454
|
+
pairs itself with a session of its own key, but not its other credentials; nobody can revoke the last active key of a
|
|
2455
|
+
service (`acl_denied`). With `totp`, `verifyPassword` and `lookup` also check the TOTP code (the `auth.totp` permission)
|
|
2456
|
+
and issue a session with the `TOTP` method. The credential kind `kind` is `^[A-Z][A-Z0-9_]{1,31}$` (`PASSWORD`, `EMAIL`,
|
|
2457
|
+
`TELEGRAM` …); `SESSION`, `APIKEY`, `KEYSECRET`, `OTP`, `TOTP`, `KEY`, `PROJECTOR` and `LOCK` are taken. The same rule
|
|
2458
|
+
applies to the password kind `user.kind` in `createTenant` and `up({ tenant })`.
|
|
2355
2459
|
|
|
2356
2460
|
### Projector
|
|
2357
2461
|
|
|
@@ -2368,7 +2472,7 @@ give `invalid_data`.
|
|
|
2368
2472
|
| `stop()`, `close()` | stop `run()` after the current batch; the same plus unsubscribing from signals |
|
|
2369
2473
|
| `ProjectorReport` | `{ batches, changes, created, updated, deleted, unchanged, skipped, rebuilds, locked }` |
|
|
2370
2474
|
| `ProjectorChange` | `{ cursor, id, class, tenant, op, rev, at, author, row }` and, when present, `agent`, `reason`, `moved`, `before`, `rebuild` |
|
|
2371
|
-
| `ProjectorOut` | `upsert(class, row, { tenant })`, `inc(class, row, deltas, { tenant })`, `delete(class, key or id, { tenant })`, `get(class, key or id, { tenant })` → `Row \| null`; a row is a `ProjectorRow` in the loader format |
|
|
2475
|
+
| `ProjectorOut` | `upsert(class, row, { tenant })`, `inc(class, row, deltas, { tenant })`, `delete(class, key or id, { tenant })`, `get(class, key or id, { tenant })` → `Row \| null`; a row is a `ProjectorRow` in the loader format; for a class with a key `delete` and `get` go by the key, an id only for a class without a key |
|
|
2372
2476
|
|
|
2373
2477
|
### Result row
|
|
2374
2478
|
|
|
@@ -2385,9 +2489,11 @@ give `invalid_data`.
|
|
|
2385
2489
|
|
|
2386
2490
|
### Reserved names
|
|
2387
2491
|
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
`
|
|
2492
|
+
A method name hides a class only where that method exists: after a step — chain methods, at the root `db` — its
|
|
2493
|
+
methods, in a batch — its own. So a hub named like a chain-only method (for example, `count`) is reachable as the first
|
|
2494
|
+
step `db.count(…)` but not after another step, and a hub named like a root method cannot be the first step. The
|
|
2495
|
+
database does not accept a link with any of these names (`reserved_names()`; the installer fills in the list from
|
|
2496
|
+
`RESERVED_CLASS_NAMES`). Do not give classes these names.
|
|
2391
2497
|
|
|
2392
2498
|
- chain methods: `then`, `entity`, `paths`, `rows`, `first`, `ids`, `count`, `limit`, `offset`, `sort`, `asOf`,
|
|
2393
2499
|
`withDeleted`, `deep`, `exact`, `sum`, `avg`, `min`, `max`, `countBy`, `after`, `versions`, `create`, `update`,
|
|
@@ -2444,7 +2550,7 @@ Library codes:
|
|
|
2444
2550
|
| `tx_rolled_back` | PostgreSQL answered `ROLLBACK` to `COMMIT`: the error was caught inside `tr.transaction(fn)` |
|
|
2445
2551
|
| `no_key` | `upsert`, `rekey` or `idOf` on a class without a key |
|
|
2446
2552
|
| `no_path` | there is no hop between the classes of neighboring steps |
|
|
2447
|
-
| `invalid_query` | the chain or the API is used incorrectly (a step modifier `deep`, `exact`, `alias`, `tags` or `owner` after a verb, a slot not after a verb, an `after()` cursor with a value of a different type than the `sort` field, without `v` or with an `id` that is not a uuid, and so on) |
|
|
2553
|
+
| `invalid_query` | the chain or the API is used incorrectly (a step modifier `deep`, `exact`, `alias`, `tags` or `owner` after a verb, a selection modifier between a verb and a `rows`, `first`, `ids`, `count` or `paths` terminal, a slot not after a verb, an `after()` cursor with a value of a different type than the `sort` field, without `v` or with an `id` that is not a uuid, and so on) |
|
|
2448
2554
|
| `admin_required` | the loader, a projector and `rebuildAggregates` need an admin connection |
|
|
2449
2555
|
| `rebuild_required` | the projection was built by another version of the projector (`version`): a rebuild with `rebuild()` is needed; `rebuild({ tenant })` while there is no projection or a batch rebuild is in progress |
|
|
2450
2556
|
| `projector_busy` | a rebuild cannot start: the projector is running in another process right now |
|
|
@@ -2520,7 +2626,7 @@ behavior.
|
|
|
2520
2626
|
**Platform**
|
|
2521
2627
|
|
|
2522
2628
|
- PostgreSQL 18 or newer is required; there are no workarounds for 15–17. AWS RDS, Google Cloud SQL, Azure Flexible
|
|
2523
|
-
Server and Neon support 18, while Supabase, as of October 2026, offers only 15 and 17, so
|
|
2629
|
+
Server and Neon support 18, while Supabase, as of October 2026, offers only 15 and 17, so letopis will not work there.
|
|
2524
2630
|
- The database must be in UTF8 encoding (`casefold()` and the `pg_unicode_fast` collation). If a new major PostgreSQL
|
|
2525
2631
|
version updates the Unicode tables, the unique index of credential identifiers is rebuilt with the `reindex` command.
|
|
2526
2632
|
- PGlite has one superuser connection: no concurrent writers and no `LISTEN`, and RLS does not apply
|
|
@@ -2579,8 +2685,8 @@ behavior.
|
|
|
2579
2685
|
- Hot reference targets (an organization that almost everything references) accumulate `for key share` locks and
|
|
2580
2686
|
MultiXact, as with regular foreign keys; this needs monitoring. Operations on one wallet go one after another under a
|
|
2581
2687
|
lock of its row ([section 8](#8-transactions-batches-and-money), "Recipe: wallet").
|
|
2582
|
-
- A link cannot have a reserved name, and a hub with such a name
|
|
2583
|
-
(["Reserved names"](#reserved-names)).
|
|
2688
|
+
- A link cannot have a reserved name, and a hub with such a name is not reachable everywhere: for example, `count` —
|
|
2689
|
+
only as the first step (["Reserved names"](#reserved-names)).
|
|
2584
2690
|
|
|
2585
2691
|
**Access**
|
|
2586
2692
|
|
|
@@ -2731,6 +2837,12 @@ behavior.
|
|
|
2731
2837
|
This section is for those who develop letopis itself. Run: `cd lib && npm test`. The harness starts the
|
|
2732
2838
|
`letopis-pg18` container (`postgres:18`, port 15433) by itself if the database does not respond; each test file works in its own schema `v2.t_<name>`.
|
|
2733
2839
|
|
|
2840
|
+
CI runs no tests: before a push and a release, everything that checks behavior runs as one command,
|
|
2841
|
+
`cd lib && npm run prerelease` — typecheck, the documentation check, all tests, the site examples against snapshots, the
|
|
2842
|
+
sandbox, the service scenarios, a quick soak run (15 minutes of mixed load) and the package check with a live run, in
|
|
2843
|
+
order; the first red step stops the run. The same load for a day: `cd lib && npm run soak` (duration — `SOAK_HOURS`,
|
|
2844
|
+
24 hours by default). A test for a fix is named after its test-plan item ("plan 2.0, 7.3 No. 1") and fails without the fix.
|
|
2845
|
+
|
|
2734
2846
|
```
|
|
2735
2847
|
# connect — connecting under a session, refusing roles that bypass RLS, the registry, the deferred signal, error translation (stage 2)
|
|
2736
2848
|
# context — sessions, service sign-in by key, the RLS policies of stage 1 (stage 1)
|
|
@@ -2777,9 +2889,9 @@ This section is for those who develop letopis itself. Run: `cd lib && npm test`.
|
|
|
2777
2889
|
# retention — retention policy: tiers, a finite term, special versions, deleted rows, asOf, cursor_expired, time zone, catch-up run, inheritance and default — versus a reference in TypeScript (stage 6)
|
|
2778
2890
|
# watch — cursor subscription: commit order, a long transaction, resuming, listener drop, two tenants (stage 6)
|
|
2779
2891
|
# cache — result cache: hits, own and others' writes, the key with the user, permissions, revoked sessions, listener drop, ttl, class marking, without LISTEN, a race, db.sql, the limit of signal counters (stage 6; plan 1.1, checkpoint A)
|
|
2780
|
-
# maintain — maintenance: expired sessions and codes, permissions, one process under a lock, schedule (stage 6)
|
|
2892
|
+
# maintain — maintenance: expired sessions and codes, permissions, one process under a lock, schedule (stage 6), lock rows of deleted classes
|
|
2781
2893
|
# up — the full installer: fresh installation, re-apply, custom seeds, automatic database creation, UTF8, version 17, upgrade with migrations, PGlite, the first tenant and user (up({ tenant }), createTenant) (stages 6, 8)
|
|
2782
|
-
# upgrade — schema upgrade from 1.1.0 (
|
|
2894
|
+
# upgrade — schema upgrade from 1.4.1 (a plain up() without migrations, plan 2.0, 7.8 No. 2), from 1.1.0 (upgrade: true, migration 0007) and from 1.0.1 (upgrade: true, migrations 0006 and 0007) with the letopis-141, letopis-11 and letopis-1 packages: versions and hashes written by the old engine, verify(), the catalog against a clean installation, lock rows, secret rows without an account erased, the application, a subscription and a projector over old data, a repeated up() (work after 1.2.0, stage 1; revision 7)
|
|
2783
2895
|
# scenario-salon — end-to-end salon scenario: 21 acts from 0.21 (salon) with a coverage matrix of the 1.0 API (stage 6)
|
|
2784
2896
|
# telemetry — onQuery and slowMs, describe() and class metadata with inheritance, describeText (stage 6)
|
|
2785
2897
|
# history — diff and blame over the log, the reason of a change for verbs, db.begin() and batches; a version hidden by an ACL condition, the changed column, a missing version, row_changes over 20 thousand targets (plan 1.1, stage 1 and checkpoint A)
|
|
@@ -2789,4 +2901,10 @@ This section is for those who develop letopis itself. Run: `cd lib && npm test`.
|
|
|
2789
2901
|
# mcp — MCP tools: the model and meta only in results, tenant rights, the JSON chain and hints of similar names, row and byte limits with a cursor, the untrusted-data wrapper, writing only with write, update only with rev, deletion with a human confirmation (elicitation in revisions 2025 and 2026-07-28), the reason in blame, credentials, tool schemas, letopis without the SDK; letopis mcp over stdio, the HTTP handler; the confirmation window without the alias and meta, countBy and versions limited in the query itself, limits of steps and $or conditions; the permission classes only with allowAclWrite, a session in the System tenant only with allowSystemAdmin; structuredContent with a note, a requestState signature of the server user with an expiry (plan 1.1, stage 5; security review No. 2; checkpoint B and the work after it)
|
|
2790
2902
|
# import — import from 0.21 with a fixture of a real 0.21: key violation, --keyless, per-class counters, verify, self-reference, repeat, ids without a key, a dangling reference, $$strict and fields starting with $, update, credentials, CLI (stage 7)
|
|
2791
2903
|
# security-final — the final security review (stage 8, §7.10 No. 8.4): a family lock by the tenant of the root, account_purge without permission and restore/purge of a foreign id give neither a leak nor a lock; the functions of stage 6, log_refs_at, journal-based find_* reads and watch_read stay within their own tenant; NOTIFY carries only names or mode tokens; the cache key distinguishes the token, the actor and the tenant (stage 8)
|
|
2904
|
+
# semver — version rules: check:semver requires at least the matching version step — a major for a removed method or error code, a minor for a new method and a new engine revision, a patch for texts only (plan 2.0, 7.8 No. 1)
|
|
2905
|
+
# prerelease — the pre-release check: steps run strictly in order, the first red one stops the run and the following ones do not start (plan 2.0, 7.8 No. 3)
|
|
2906
|
+
# write-contract — README promises about writes (plan 2.0, 7.2): a repeated key in a batch gives exists and a rollback, the row order of reclass, restore and rekey responses, a chain with a verb as a slot value, the loader with existing: update does not edit a row of another class, a cached answer is not spoiled by editing an earlier answer
|
|
2907
|
+
# watch-contract — README promises about subscriptions and connections (plan 2.0, 7.4): an idle subscription polls the journal once per pollMs, close() of the connection closes live subscriptions, a reclass without a key resets the cache of the old class, a new projector participant gets an initial rebuild, a projector without LISTEN re-reads its participants, at most a limit of db.as() registries
|
|
2908
|
+
# model-contract — README promises about models, types, the CLI and errors (plan 2.0, 7.5): letopis types gives valid TypeScript for Cyrillic names, a class without fields and anyOf, the full form of hub() with z.object, clear errors instead of raw ones, CLI exit codes
|
|
2909
|
+
# read-contract — README promises about reads and history (plan 2.0, 7.1): an asOf slice by class and by ends after a class change without an id change, versions, diff and blame with follow after rekey, the after() cursor in versions() and paths(), a field name with @S@
|
|
2792
2910
|
```
|