letopis 1.1.0 → 1.2.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 +110 -15
- package/AGENT-CHEATSHEET.md +103 -13
- package/CHANGELOG.md +577 -0
- package/README.en.md +769 -73
- package/README.md +732 -67
- package/dist/admin.d.ts +20 -4
- package/dist/admin.js +33 -9
- package/dist/cli.js +53 -4
- package/dist/errors.js +4 -2
- package/dist/index.d.ts +16 -2
- package/dist/index.js +75 -17
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.js +42 -11
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/sql.js +9 -5
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +20 -1
- package/dist/write.d.ts +1 -1
- package/dist/write.js +25 -5
- package/llms.txt +6 -2
- package/package.json +12 -2
- package/sql/10-core.sql +5 -1
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +515 -48
- package/sql/70-read.sql +33 -8
- package/sql/80-auth.sql +4 -4
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +89 -12
- package/sql/95-seed.system.sql +12 -2
- package/sql/README.md +4 -1
package/AGENT-CHEATSHEET.en.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
1
|
+
# letopis 1.2 — agent cheatsheet
|
|
2
2
|
|
|
3
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
4
|
the package next to the README; in Russian: [Русская версия](AGENT-CHEATSHEET.md). Migration from 0.21:
|
|
@@ -41,12 +41,14 @@ const r = await up({ dsn: ADMIN, schema: 'myapp', // schema 'v2
|
|
|
41
41
|
policy), `allowReset`, `serviceKey`, `quiet`, `pglite: true` (a database for development without PostgreSQL; then
|
|
42
42
|
`connect({ dsn: r.pglite.dsn, listen: false, max: 1, allowBypassRls: true, … })` — a superuser connection, RLS
|
|
43
43
|
does not apply on it).
|
|
44
|
-
- `signals` — what `NOTIFY` signals show to any role that can connect to the database
|
|
45
|
-
|
|
46
|
-
`
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
`
|
|
44
|
+
- `signals` — what `NOTIFY` signals show to any role that can connect to the database. The channel is the full
|
|
45
|
+
schema name (`LISTEN "v2.myapp"`); the write signal payload: `'class'` (default) — `Class`; `'tenant'` —
|
|
46
|
+
`Class@tenant token` (the cache is cleared only in the tenant of the write); `'opaque'` — `class token@tenant
|
|
47
|
+
token`; `'none'` — no signals (the cache works only with `ttlOnly`, subscriptions poll the log). Service signals:
|
|
48
|
+
`session:<16 hex>` is a revoked session, `session:*` and `*` are resets (`reset`). The mode is stored in the
|
|
49
|
+
schema: `up()` without the option keeps it; after a change, restart the processes. The signal key (precise cache
|
|
50
|
+
clearing) goes to services — the `letopis.signals` permission. The `up()` result also has `signals` and `warnings`
|
|
51
|
+
(PostgreSQL below 18.2 — upgrade).
|
|
50
52
|
- `tenant` — the first tenant and user without a seed of your own: the tenant gets the rule "authenticated users may
|
|
51
53
|
do everything" (`grantAll`, on by default, weight 10); the user gets a membership with the roles `user.roles`
|
|
52
54
|
(`['owner']` by default), a password for signing in with the login `login`, and a session in the tenant. Repeating
|
|
@@ -114,8 +116,9 @@ export const Payment = link('Payment', { opId: z.string(), amount: z.number().in
|
|
|
114
116
|
export const Note = hub('Note', { text: z.string() }, {}) // no key: the database assigns the id
|
|
115
117
|
```
|
|
116
118
|
- Full form: `hub(name, { alias, extends, abstract, ends, fields: z.object(…) | { fields }, key, onDelete, history,
|
|
117
|
-
ownerDefault: 'tenant' | 'actor', cache: { ttl }, meta })`; `link` works the same way.
|
|
118
|
-
|
|
119
|
+
ownerDefault: 'tenant' | 'actor', cache: { ttl }, meta, derivedBy, aggregate })`; `link` works the same way.
|
|
120
|
+
`derivedBy` and `aggregate` are in sections 12 and 13 below. `extends` also works in the short form, but TypeScript
|
|
121
|
+
infers the data types of a descendant only in the full form.
|
|
119
122
|
- Ends: `one(A)` (required), `one(A, B)` (union), `one('Name')` (target by name, for a class that refers to itself),
|
|
120
123
|
`.optional()`, `.onDelete('restrict' | 'cascade' | 'unset')` (default `restrict`; `unset` only for an optional
|
|
121
124
|
end), `many(A, { min, max, onDelete })`.
|
|
@@ -285,6 +288,12 @@ await db.Wallet(w200).versions({ follow: true }) // also ac
|
|
|
285
288
|
await db.Wallet(w).diff() // what changed: previous version → latest
|
|
286
289
|
await db.Wallet(w).blame() // who, when and why (verb option reason) changed fields
|
|
287
290
|
```
|
|
291
|
+
`diff()` is an array of `DiffResult`: `{ id, class, from, to, changes }`; `from` and `to` are `{ rev, at, id? }`, `to`
|
|
292
|
+
also has `deleted?` (then `changes` is empty), `from: null` means the object did not exist then. A change, `DiffChange`,
|
|
293
|
+
is `{ path, kind: 'added' | 'removed' | 'changed', before?, after? }`. `blame()` is an array of `BlameResult`:
|
|
294
|
+
`{ id, class, rev, complete, fields }`; `fields` are the `BlameField` leaves in path order:
|
|
295
|
+
`{ path, rev, at, author, op, exact, agent?, reason?, id? }`. `path` is an array of segments: `['data', 'price']`;
|
|
296
|
+
empty `agent`, `reason`, `before`, `after` are omitted. The types come from `letopis`.
|
|
288
297
|
A class with `history: false` does not provide `versions`, `asOf`, `restore` or subscription events.
|
|
289
298
|
|
|
290
299
|
### 11. Import from 0.21
|
|
@@ -312,6 +321,87 @@ npx letopis import --from "$ADMIN" --from-schema v1.fixture --dsn "$ADMIN" --sch
|
|
|
312
321
|
`credentials`, `resources`, `rules`, `objects`, `uuidFields`, `droppedFields`, `ownerLost`, `verify`, `ms`; the exit
|
|
313
322
|
code is 1 if `verify` failed. Repeating the import adds what is missing, without duplicates.
|
|
314
323
|
|
|
324
|
+
### 12. Projectors
|
|
325
|
+
|
|
326
|
+
A projector builds rows of derived classes from the journal: summaries, cards, anything that may be computed with a
|
|
327
|
+
lag. A derived class is marked with `derivedBy: 'projector-name'`, and only this projector writes its rows. The
|
|
328
|
+
derived class itself (own or inherited) is created, edited and deleted only by an administrator: `syncModels` on an
|
|
329
|
+
admin connection holding a tenant session (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) or an
|
|
330
|
+
account with the System permission `letopis.projector`; for an application session it is `acl_denied`. Everyone whom
|
|
331
|
+
the rules give `READ` on a derived class sees its totals, even without permissions on the source rows.
|
|
332
|
+
```ts
|
|
333
|
+
const p = adm.projector('daily', { from: ['Payment'], into: ['DayTotal'], version: 1, withBefore: true,
|
|
334
|
+
handle(changes, out) { /* out.inc, out.upsert, out.delete, await out.get: into classes, { tenant: c.tenant } */ } })
|
|
335
|
+
await p.run({ once: true }) // catch up with the journal; run() follows it until stop(); rebuild(), status(), close()
|
|
336
|
+
p.run().catch(report) // run() without once needs a catch: after a projector error it stops
|
|
337
|
+
```
|
|
338
|
+
Only an admin connection (otherwise `admin_required`). A batch is one transaction, every change is applied exactly
|
|
339
|
+
once, there is one writer (`rebuild()` while another process writes a batch gives `projector_busy`). The first
|
|
340
|
+
start rebuilds the projection from the current state, in batches. `purge`, `trim` and `reset` come as changes with `rev` 1, so check `op` first.
|
|
341
|
+
Another `version` gives `rebuild_required`; a cursor older than the `all` tier gives `cursor_expired`
|
|
342
|
+
(`onExpired: 'rebuild'` rebuilds by itself, in batches); the tier is per tenant: if it has expired for some participants, only
|
|
343
|
+
they drop out. A tenant with a source with `history: false` drops out (`invalid_class`). `c.before` (with
|
|
344
|
+
`withBefore`) is the previous version of the same id or `null`: `null` for a creation, for the first version of a new
|
|
345
|
+
id after `reclass` and `rekey` (the tombstone of the former id subtracts the former data), during a rebuild and when
|
|
346
|
+
the previous version is a deletion (after `restore`) or lies outside the `from` family; `purge`, `trim` and `reset`
|
|
347
|
+
have no such field, so subtract `before` only after checking it for `null`. A class change that keeps the id (a class
|
|
348
|
+
without a key) and takes the row out of the `from` family comes as a deletion (`op` `delete`, `row` is the last
|
|
349
|
+
version in the family with `$deleted`). `handle` gets the changes of one tenant. An error of its data (the
|
|
350
|
+
database rejected its results, or `out` rejected its row or delta) rolls back only this tenant: it drops out
|
|
351
|
+
(`status().failed`: `{ id: { cursor, code, message } }`) until `rebuild({ tenant: id })` (`{ tenant, chunked: true }` — in batches while the tail goes on for all;
|
|
352
|
+
`status().rebuildingTenants`), and the others go on; with `onExpired: 'rebuild'` a tenant dropped by the tier rebuilds
|
|
353
|
+
itself in batches, in any process;
|
|
354
|
+
`tenants: [id, …]` is an explicit list of participants.
|
|
355
|
+
`status().state`: `'new'` means there is no projection yet, `'running'` means the projection is
|
|
356
|
+
built and kept up (not "a process is running now"), `'rebuilding'` means a batch rebuild is in progress or was
|
|
357
|
+
interrupted.
|
|
358
|
+
A daily total is a fold: subtract the previous version (`c.before`), add the new one (`c.row` unless it is `$deleted`)
|
|
359
|
+
with `out.inc('DayTotal', { day }, { total: ±amount }, { tenant: c.tenant })`; `DayTotal` has the key `['day']`,
|
|
360
|
+
`derivedBy: 'daily'` and `history: false`. Take the day from the data of the row, not from `at`: every edit changes the
|
|
361
|
+
version time. `out` itself folds the increments of one row by key into one edit per batch (as long as there is no
|
|
362
|
+
`upsert`, `delete` or `get` between them) and checks the class rule on their total. The full recipe is in
|
|
363
|
+
[the README, "Recipe: daily revenue"](README.en.md#recipe-daily-revenue).
|
|
364
|
+
|
|
365
|
+
### 13. Aggregates in the database
|
|
366
|
+
|
|
367
|
+
The database itself keeps a sum or a count of source rows — for totals that must match even for raw SQL:
|
|
368
|
+
```ts
|
|
369
|
+
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0) }, {
|
|
370
|
+
key: ['number'], aggregate: { balance: { sum: 'amount', of: 'Payment', via: 'Wallet' } } }) // or count: true; where is a JSON Schema
|
|
371
|
+
await db.Wallet(w).Payment().create({ opId: 'op-9', amount: 500 }).rows() // the balance grew by itself, no inc
|
|
372
|
+
```
|
|
373
|
+
A trigger recalculates the field after every statement (the library, raw SQL, a cascade, `restore`, `reclass`,
|
|
374
|
+
`rekey`, the loader); going below `minimum` is `invalid_data`, every change is a version of the target. Live rows of
|
|
375
|
+
the same tenant are counted (a System row is not). Repair: `adm.rebuildAggregates('Wallet')` on an admin connection;
|
|
376
|
+
discrepancies: `verify({ data: true })`. Only someone who reads all rows of the source and its descendants without
|
|
377
|
+
conditions can describe or change an aggregate, otherwise `acl_denied`. A hot counter (more than ~150 writes per
|
|
378
|
+
second per object) is a projector that folds a batch, not an aggregate.
|
|
379
|
+
|
|
380
|
+
### 14. Agents and MCP
|
|
381
|
+
|
|
382
|
+
MCP tools for an agent, under a user session (needs `npm i @modelcontextprotocol/server`):
|
|
383
|
+
```ts
|
|
384
|
+
import { createMcpServer } from 'letopis/mcp'
|
|
385
|
+
const server = await createMcpServer(await svc.as(userId)) // letopis_model, letopis_query, letopis_history; { write: true } adds letopis_write
|
|
386
|
+
// stdio: letopis mcp --dsn … --schema v2.x --token <user token> [--write]; HTTP: createMcpHandler(() => createMcpServer(…)) from the SDK
|
|
387
|
+
```
|
|
388
|
+
Only a user session or `svc.as(user)`: a connection that bypasses RLS gets `bypass_rls`, the service root without
|
|
389
|
+
`as()` and a session in the System tenant (without `allowSystemAdmin`) get `user_session_required`; `letopis_write`
|
|
390
|
+
does not write the permission classes without `allowAclWrite` (`acl_denied`); the command line does not accept
|
|
391
|
+
`--api-key` but also takes `--dsn`,
|
|
392
|
+
`--schema` and `--token` from `LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`. Reading is a JSON chain
|
|
393
|
+
(`class`, `filter` with `$gt`, `$in`, `$or` and so on, `steps`, `sort: { field, dir: 'asc' | 'desc' }`, `cursor`),
|
|
394
|
+
with no SQL; an answer holds up to `maxRows` rows (100) and `maxBytes` bytes (50,000) from the `createMcpServer`
|
|
395
|
+
options, then comes a `cursor`. `structuredContent` is `{ notice, data }` (`notice` is a constant note that this is
|
|
396
|
+
data, not instructions); `data` by terminal: `rows` — `{ rows, count, more, cursor? }`, `ids` —
|
|
397
|
+
`{ ids, count, more, cursor? }` (`count` is the rows on the page), `first` — `{ row }`, `count` — `{ count }`,
|
|
398
|
+
`countBy` — `{ counts, truncated? }`, `sum`, `avg`, `min`, `max` —
|
|
399
|
+
`{ value }`. A refusal is `isError: true` with a `structuredContent` of the form `{ notice, error }` (`error` is
|
|
400
|
+
`{ code, message, similar? }`). Writing only
|
|
401
|
+
with `write: true` (without it `letopis_write` is not in the list, and a call is an SDK protocol error): `update` with
|
|
402
|
+
`rev`; `delete` without `confirm` is a preview, with `confirm` a human confirms it (elicitation), and without that it
|
|
403
|
+
is `confirmation_required`. Data from the database in an answer sits inside `<untrusted-data-…>`.
|
|
404
|
+
|
|
315
405
|
### Operations
|
|
316
406
|
|
|
317
407
|
`db.verify({ data: true })` and `letopis verify` — hash chains, the anchor; `db.maintain()` and
|
|
@@ -331,7 +421,8 @@ await adm.close(); await db.close(); await svc.close()
|
|
|
331
421
|
without a filter and without `data.id`, all rows of the step are edited. `target_not_found` comes with `{ rev }` or
|
|
332
422
|
if the row was deleted between the lookup and the edit.
|
|
333
423
|
3. **Verbs return a chain.** Without a terminal (`.rows()`, `.first()` …) nothing runs. `delete()` without
|
|
334
|
-
`{ confirm: true }` is only a preview; `purge` works only on deleted entities (`not_deleted`)
|
|
424
|
+
`{ confirm: true }` is only a preview; `purge` works only on deleted entities (`not_deleted`) and once a projector
|
|
425
|
+
of the class has read the deletion (`projector_pending`).
|
|
335
426
|
4. **Money and counters**: `update({ balance: inc(n) })` — the database adds inside the statement, without a read and
|
|
336
427
|
without lost updates; the `minimum` check in the schema catches a drop below zero. For a total computed from what
|
|
337
428
|
you read, use `{ rev }` (re-read and retry on `conflict`) or `forUpdate()` in `db.begin()`. Idempotency comes from
|
|
@@ -346,9 +437,9 @@ await adm.close(); await db.close(); await svc.close()
|
|
|
346
437
|
by themselves.
|
|
347
438
|
7. **`commit_unknown`** — the connection dropped during `COMMIT`: the transaction may have been committed. Do not
|
|
348
439
|
retry blindly; repeat the same operation with the same key (`exists` means "already written").
|
|
349
|
-
8. **The loader
|
|
350
|
-
inside `db.begin()`
|
|
351
|
-
RLS.
|
|
440
|
+
8. **The loader, a projector and `rebuildAggregates` need an admin connection** (`allowBypassRls: true`, the owner
|
|
441
|
+
role), otherwise `admin_required`; inside `db.begin()` they give `invalid_query`. A regular application uses the
|
|
442
|
+
application role, without bypassing RLS.
|
|
352
443
|
9. **Data is under RLS.** Without a session (`token`/`apiKey`) a write gives `no_session`; a row of another tenant or
|
|
353
444
|
an invisible row cannot be told apart from a missing one: the step does not find it, and a reference to it gives
|
|
354
445
|
`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.
|
|
@@ -371,10 +462,14 @@ await adm.close(); await db.close(); await svc.close()
|
|
|
371
462
|
`letopis: <code>: …`; data errors (`ValidationError`) have `err.issues: [{ path, keyword, message }]`.
|
|
372
463
|
16. **A subscription with `'now'` starts when the database answers.** `db.watch()` returns immediately, and the
|
|
373
464
|
subscription takes the "now" moment with its first query to the database. A write made right after `db.watch()`
|
|
374
|
-
can be committed earlier and miss the stream. If you need a guarantee
|
|
375
|
-
`'start'`.
|
|
465
|
+
can be committed earlier and miss the stream. If you need a guarantee — `await sub.ready()` before the write, a
|
|
466
|
+
saved cursor or `'start'`. Parallel `next()` calls go in turn and get different events.
|
|
376
467
|
17. **The terminal returns the rows of the last step.** `…Payment().create(…).Wallet().update(…).rows()` returns the
|
|
377
468
|
`Wallet` rows, not the created payment; if you need the payment, end the chain on it.
|
|
378
469
|
18. **A batch queue belongs to its facade.** `db.batch('x')` on the root, on each `await db.as(…)` and on each
|
|
379
470
|
`db.begin()` are different queues; a plan runs on behalf of whoever queued it. Collect plans through one facade
|
|
380
471
|
object.
|
|
472
|
+
19. **Only its projector writes the rows of a derived class.** The application, raw SQL and the loader get
|
|
473
|
+
`derived_class`; ends may not refer to such rows either. To recompute them, use the projector's `rebuild()`.
|
|
474
|
+
20. **Only the database writes an aggregate field.** A value in `create`, `update`, `upsert`, `rekey`, `reclass` and raw
|
|
475
|
+
SQL is `aggregate_field`; do not pass it. A raw upsert of a target: `set data = excluded.data - array['balance']`.
|
package/AGENT-CHEATSHEET.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# letopis 1.
|
|
1
|
+
# letopis 1.2 — шпаргалка для агента
|
|
2
2
|
|
|
3
3
|
Одна страница вместо [README.md](README.md) — для ИИ-агента, который пишет код поверх пакета. Лежит в пакете рядом с
|
|
4
4
|
README; английская версия — [AGENT-CHEATSHEET.en.md](AGENT-CHEATSHEET.en.md). Переход с 0.21 — [MIGRATION.md](MIGRATION.md);
|
|
@@ -38,12 +38,13 @@ const r = await up({ dsn: ADMIN, schema: 'myapp', // схема
|
|
|
38
38
|
`'booking'` — демо-домен, или путь к своему `.sql`), `history` (политика хранения), `allowReset`, `serviceKey`,
|
|
39
39
|
`quiet`, `pglite: true` (база для разработки без PostgreSQL; затем `connect({ dsn: r.pglite.dsn, listen: false,
|
|
40
40
|
max: 1, allowBypassRls: true, … })` — подключение суперпользователя, RLS на нём не действует).
|
|
41
|
-
- `signals` — что видно в сигналах `NOTIFY` любой роли, способной подключиться к
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
- `signals` — что видно в сигналах `NOTIFY` любой роли, способной подключиться к базе. Канал — полное имя схемы
|
|
42
|
+
(`LISTEN "v2.myapp"`), нагрузка сигнала записи: `'class'` (по умолчанию) — `Класс`; `'tenant'` —
|
|
43
|
+
`Класс@токен арендатора` (кэш сбрасывается только в арендаторе записи); `'opaque'` — `токен класса@токен
|
|
44
|
+
арендатора`; `'none'` — сигналов нет (кэш — только `ttlOnly`, подписка опрашивает журнал). Служебные сигналы:
|
|
45
|
+
`session:<16 hex>` — погашенная сессия, `session:*` и `*` — сбросы (`reset`). Режим хранится в схеме: `up()` без
|
|
46
|
+
опции его не меняет; после смены — перезапуск процессов. Ключ сигналов (точный сброс кэша) получают сервисы — право
|
|
47
|
+
`letopis.signals`. Результат `up()`: ещё `signals` и `warnings` (PostgreSQL ниже 18.2 — обновить).
|
|
47
48
|
- `tenant` — первый арендатор и пользователь без своего сида: арендатор получает правило «аутентифицированным можно
|
|
48
49
|
всё» (`grantAll`, по умолчанию да, вес 10), пользователь — членство с ролями `user.roles` (по умолчанию `['owner']`),
|
|
49
50
|
пароль для входа по логину `login` и сессию в арендаторе. Повтор с тем же именем и логином ничего не дублирует и роли
|
|
@@ -109,8 +110,9 @@ export const Payment = link('Payment', { opId: z.string(), amount: z.number().in
|
|
|
109
110
|
export const Note = hub('Note', { text: z.string() }, {}) // без ключа: id выдаёт база
|
|
110
111
|
```
|
|
111
112
|
- Полная форма: `hub(name, { alias, extends, abstract, ends, fields: z.object(…) | { поля }, key, onDelete, history,
|
|
112
|
-
ownerDefault: 'tenant' | 'actor', cache: { ttl }, meta })`; `link` — так же. `
|
|
113
|
-
форме, но типы данных потомка TypeScript
|
|
113
|
+
ownerDefault: 'tenant' | 'actor', cache: { ttl }, meta, derivedBy, aggregate })`; `link` — так же. `derivedBy` и
|
|
114
|
+
`aggregate` — разделы 12 и 13 ниже. `extends` работает и в короткой форме, но типы данных потомка TypeScript
|
|
115
|
+
выводит только в полной.
|
|
114
116
|
- Концы: `one(A)` (обязательный), `one(A, B)` (союз), `one('Имя')` (цель по имени — ссылка класса на себя),
|
|
115
117
|
`.optional()`, `.onDelete('restrict' | 'cascade' | 'unset')` (по умолчанию `restrict`; `unset` — только для
|
|
116
118
|
необязательного), `many(A, { min, max, onDelete })`.
|
|
@@ -277,6 +279,12 @@ await db.Wallet(w200).versions({ follow: true }) // и че
|
|
|
277
279
|
await db.Wallet(w).diff() // что изменилось: предыдущая версия → последняя
|
|
278
280
|
await db.Wallet(w).blame() // кто, когда и почему (опция reason глагола) менял поля
|
|
279
281
|
```
|
|
282
|
+
`diff()` — массив `DiffResult`: `{ id, class, from, to, changes }`; `from` и `to` — `{ rev, at, id? }`, у `to` ещё
|
|
283
|
+
`deleted?` (тогда `changes` пуст), `from: null` — объекта тогда не было. Изменение `DiffChange` —
|
|
284
|
+
`{ path, kind: 'added' | 'removed' | 'changed', before?, after? }`. `blame()` — массив `BlameResult`:
|
|
285
|
+
`{ id, class, rev, complete, fields }`; `fields` — листья `BlameField` по порядку пути:
|
|
286
|
+
`{ path, rev, at, author, op, exact, agent?, reason?, id? }`. `path` — массив сегментов: `['data', 'price']`; пустые
|
|
287
|
+
`agent`, `reason`, `before`, `after` опускаются. Типы — из `letopis`.
|
|
280
288
|
Класс с `history: false` не даёт `versions`, `asOf`, `restore` и событий подписки.
|
|
281
289
|
|
|
282
290
|
### 11. Импорт из 0.21
|
|
@@ -304,6 +312,81 @@ npx letopis import --from "$ADMIN" --from-schema v1.fixture --dsn "$ADMIN" --sch
|
|
|
304
312
|
`objects`, `uuidFields`, `droppedFields`, `ownerLost`, `verify`, `ms`; код выхода 1, если `verify` не прошёл.
|
|
305
313
|
Повтор импорта дописывает недостающее без дублей.
|
|
306
314
|
|
|
315
|
+
### 12. Проекторы
|
|
316
|
+
|
|
317
|
+
Проектор строит строки производных классов из журнала: сводки, карточки, всё, что можно считать с отставанием.
|
|
318
|
+
Производный класс помечен `derivedBy: 'имя-проектора'`, и его строки пишет только этот проектор. Сам производный
|
|
319
|
+
класс (свой или унаследованный) заводит, меняет и удаляет только администратор: `syncModels` администраторским
|
|
320
|
+
подключением с сессией арендатора (`connect({ dsn: ADMIN, schema, token, allowBypassRls: true })`) или аккаунт с
|
|
321
|
+
правом System `letopis.projector`; у сессии приложения — `acl_denied`. Итоги производного класса видит каждый, кому
|
|
322
|
+
правила дают `READ` на него, даже без прав на строки источников.
|
|
323
|
+
```ts
|
|
324
|
+
const p = adm.projector('daily', { from: ['Payment'], into: ['DayTotal'], version: 1, withBefore: true,
|
|
325
|
+
handle(changes, out) { /* out.inc, out.upsert, out.delete, await out.get — классы into, { tenant: c.tenant } */ } })
|
|
326
|
+
await p.run({ once: true }) // догнать журнал; run() — следовать за ним до stop(); rebuild(), status(), close()
|
|
327
|
+
p.run().catch(report) // run() без once — с catch: после ошибки проектора он остановлен
|
|
328
|
+
```
|
|
329
|
+
Только администраторское подключение (иначе `admin_required`). Пачка — одна транзакция, каждое изменение
|
|
330
|
+
применяется ровно один раз, писатель один (`rebuild()`, пока другой процесс пишет пачку, — `projector_busy`).
|
|
331
|
+
Первый запуск перестраивает проекцию с текущего состояния, пачками. `purge`, `trim` и `reset` приходят изменениями с `rev` 1 — сначала
|
|
332
|
+
разберите `op`. Другая `version` — `rebuild_required`; курсор старше яруса `all` — `cursor_expired`
|
|
333
|
+
(`onExpired: 'rebuild'` перестраивает сам, пачками); ярус — по арендатору: истёк у части участников — выбывают только они.
|
|
334
|
+
Арендатор с источником `history: false` выбывает (`invalid_class`). `c.before` (с `withBefore`) —
|
|
335
|
+
прошлая версия того же id или `null`: `null` при создании, у первой версии нового id после `reclass` и `rekey`
|
|
336
|
+
(прежние данные вычитает надгробие прежнего id), при перестройке и когда прошлая версия — удаление (после `restore`)
|
|
337
|
+
или вне семейства `from`; у `purge`, `trim` и `reset` поля нет — вычитайте `before`, только проверив его на `null`.
|
|
338
|
+
Смена класса без смены id (класс без ключа), уводящая строку из семейства `from`, приходит удалением (`op` `delete`,
|
|
339
|
+
`row` — последняя версия в семействе с `$deleted`). `handle` получает изменения
|
|
340
|
+
одного арендатора. Ошибку его данных (база отклонила его результаты или `out` — его строку или приращение)
|
|
341
|
+
откатывает только он: арендатор выбывает (`status().failed`: `{ id: { cursor, code, message } }`) до
|
|
342
|
+
`rebuild({ tenant: id })` (`{ tenant, chunked: true }` — пачками, пока хвост идёт для всех; `status().rebuildingTenants`),
|
|
343
|
+
остальные идут дальше; с `onExpired: 'rebuild'` выбывший по ярусу перестраивается пачками сам, в любом процессе; `tenants: [id, …]` — явный список участников. `status().state`: `'new'` —
|
|
344
|
+
проекции ещё нет, `'running'` — проекция построена и ведётся (а не «процесс сейчас работает»), `'rebuilding'` — идёт
|
|
345
|
+
или прервана перестройка пачками.
|
|
346
|
+
Дневной итог — свёртка: прошлую версию (`c.before`) вычесть, новую (`c.row`, если не `$deleted`) прибавить через
|
|
347
|
+
`out.inc('DayTotal', { day }, { total: ±amount }, { tenant: c.tenant })`; у `DayTotal` ключ `['day']`,
|
|
348
|
+
`derivedBy: 'daily'` и `history: false`. День берите из данных строки, а не из `at`: время версии меняет каждая правка.
|
|
349
|
+
Приращения одной строки по ключу `out` складывает сам в одну правку за пачку (пока между ними нет `upsert`, `delete`
|
|
350
|
+
или `get`), а правило класса проверяет над их итогом. Полный рецепт —
|
|
351
|
+
[README, «Рецепт: дневная выручка»](README.md#рецепт-дневная-выручка).
|
|
352
|
+
|
|
353
|
+
### 13. Агрегаты в базе
|
|
354
|
+
|
|
355
|
+
Сумму или счётчик строк источника держит сама база — для итогов, которые обязаны совпадать и для сырого SQL:
|
|
356
|
+
```ts
|
|
357
|
+
const Wallet = hub('Wallet', { number: z.string(), balance: z.number().int().min(0) }, {
|
|
358
|
+
key: ['number'], aggregate: { balance: { sum: 'amount', of: 'Payment', via: 'Wallet' } } }) // или count: true; where — JSON Schema
|
|
359
|
+
await db.Wallet(w).Payment().create({ opId: 'op-9', amount: 500 }).rows() // баланс вырос сам, без inc
|
|
360
|
+
```
|
|
361
|
+
Поле пересчитывает триггер после каждого оператора (библиотека, сырой SQL, каскад, `restore`, `reclass`, `rekey`,
|
|
362
|
+
загрузчик); уход ниже `minimum` — `invalid_data`, каждое изменение — версия цели. Считаются живые строки того же
|
|
363
|
+
арендатора (строка System — нет). Ремонт — `adm.rebuildAggregates('Wallet')` на администраторском подключении,
|
|
364
|
+
расхождения — `verify({ data: true })`. Описать или изменить агрегат может только тот, кто без условий читает все строки
|
|
365
|
+
источника и его потомков, иначе `acl_denied`. Горячий счётчик (больше ~150 записей в секунду на объект) —
|
|
366
|
+
проектор со свёрткой пачкой, а не агрегат.
|
|
367
|
+
|
|
368
|
+
### 14. Агенты и MCP
|
|
369
|
+
|
|
370
|
+
Агенту — инструменты MCP под сессией пользователя (нужен `npm i @modelcontextprotocol/server`):
|
|
371
|
+
```ts
|
|
372
|
+
import { createMcpServer } from 'letopis/mcp'
|
|
373
|
+
const server = await createMcpServer(await svc.as(userId)) // letopis_model, letopis_query, letopis_history; { write: true } — letopis_write
|
|
374
|
+
// stdio: letopis mcp --dsn … --schema v2.x --token <токен пользователя> [--write]; HTTP — createMcpHandler(() => createMcpServer(…)) из SDK
|
|
375
|
+
```
|
|
376
|
+
Только сессия пользователя или `svc.as(пользователь)`: подключение в обход RLS — `bypass_rls`, корень сервиса без `as()`
|
|
377
|
+
и сессия в арендаторе System (без `allowSystemAdmin`) — `user_session_required`; классы прав `letopis_write` не пишет без
|
|
378
|
+
`allowAclWrite` (`acl_denied`); `--api-key` командная строка не принимает, а `--dsn`, `--schema` и `--token` берёт и из
|
|
379
|
+
`LETOPIS_DSN`, `LETOPIS_SCHEMA`, `LETOPIS_TOKEN`. Чтение — JSON-цепочкой (`class`, `filter` с `$gt`, `$in`, `$or` и
|
|
380
|
+
т. п., `steps`, `sort: { field, dir: 'asc' | 'desc' }`, `cursor`), без SQL; ответ — до `maxRows` строк (100) и
|
|
381
|
+
`maxBytes` байт (50 000) из опций `createMcpServer`, дальше `cursor`. `structuredContent` — `{ notice, data }`
|
|
382
|
+
(`notice` — постоянное пояснение, что это данные, а не инструкции); `data` по терминалам: `rows` —
|
|
383
|
+
`{ rows, count, more, cursor? }`, `ids` — `{ ids, count, more, cursor? }` (`count` — строк на странице), `first` —
|
|
384
|
+
`{ row }`, `count` — `{ count }`, `countBy` — `{ counts, truncated? }`, `sum`, `avg`, `min`, `max` — `{ value }`.
|
|
385
|
+
Отказ — `isError: true` и `structuredContent` вида `{ notice, error }` (`error` — `{ code, message, similar? }`). Запись — только `write: true`
|
|
386
|
+
(без него `letopis_write` нет в списке, а вызов — ошибка протокола SDK): `update` с `rev`; `delete` без `confirm` —
|
|
387
|
+
превью, с `confirm` — подтверждение человека (elicitation), без него — `confirmation_required`. Данные из базы в
|
|
388
|
+
ответе — внутри `<untrusted-data-…>`.
|
|
389
|
+
|
|
307
390
|
### Эксплуатация
|
|
308
391
|
|
|
309
392
|
`db.verify({ data: true })` и `letopis verify` — цепочки хэшей, якорь; `db.maintain()` и `connect({ maintain: '1h' })` —
|
|
@@ -323,7 +406,8 @@ await adm.close(); await db.close(); await svc.close()
|
|
|
323
406
|
без `data.id` правятся все строки шага. `target_not_found` — при `{ rev }` или если строку удалили между поиском
|
|
324
407
|
и правкой.
|
|
325
408
|
3. **Глаголы возвращают цепочку.** Без терминала (`.rows()`, `.first()` …) ничего не исполнится. `delete()` без
|
|
326
|
-
`{ confirm: true }` — только превью; `purge` — только для удалённых (`not_deleted`)
|
|
409
|
+
`{ confirm: true }` — только превью; `purge` — только для удалённых (`not_deleted`) и после того, как проектор
|
|
410
|
+
класса прочёл удаление (`projector_pending`).
|
|
327
411
|
4. **Деньги и счётчики** — `update({ balance: inc(n) })`: база прибавляет внутри оператора, без чтения и потерянных
|
|
328
412
|
обновлений; проверка `minimum` в схеме ловит уход в минус. Итог, вычисленный из прочитанного, — `{ rev }`
|
|
329
413
|
(перечитать и повторить при `conflict`) или `forUpdate()` в `db.begin()`. Идемпотентность — ключом операции
|
|
@@ -337,8 +421,9 @@ await adm.close(); await db.close(); await svc.close()
|
|
|
337
421
|
В `db.begin()` повторов нет: на `40P01` повторите весь блок. Операции из пула сами повторяют `40001`/`40P01`.
|
|
338
422
|
7. **`commit_unknown`** — связь оборвалась во время `COMMIT`: транзакция могла зафиксироваться. Не повторяйте
|
|
339
423
|
вслепую — повторите ту же операцию с тем же ключом (`exists` значит «уже записано»).
|
|
340
|
-
8.
|
|
341
|
-
`admin_required`; внутри `db.begin()` — `invalid_query`. Обычное приложение — прикладной
|
|
424
|
+
8. **Загрузчику, проектору и `rebuildAggregates` нужно администраторское подключение** (`allowBypassRls: true`,
|
|
425
|
+
роль-владелец), иначе `admin_required`; внутри `db.begin()` — `invalid_query`. Обычное приложение — прикладной
|
|
426
|
+
ролью, без обхода RLS.
|
|
342
427
|
9. **Данные под RLS.** Без сессии (`token`/`apiKey`) запись — `no_session`; чужая или невидимая строка неотличима от
|
|
343
428
|
несуществующей: шаг её не находит, а ссылка на неё — `target_not_found`; без права — пусто или `acl_denied`. `` db.sql`…` `` и сырой SQL — тоже под
|
|
344
429
|
сессией и RLS.
|
|
@@ -359,8 +444,13 @@ await adm.close(); await db.close(); await svc.close()
|
|
|
359
444
|
у ошибок данных (`ValidationError`) — `err.issues: [{ path, keyword, message }]`.
|
|
360
445
|
16. **Подписка с `'now'` — с момента ответа базы.** `db.watch()` возвращается сразу, а момент «now» подписка берёт
|
|
361
446
|
первым запросом к базе. Запись, сделанная сразу после `db.watch()`, может зафиксироваться раньше и в поток не
|
|
362
|
-
попасть. Нужна гарантия —
|
|
447
|
+
попасть. Нужна гарантия — `await sub.ready()` перед записью, сохранённый курсор или `'start'`. Параллельные
|
|
448
|
+
`next()` идут по очереди и получают разные события.
|
|
363
449
|
17. **Терминал отдаёт строки последнего шага.** `…Payment().create(…).Wallet().update(…).rows()` вернёт строки
|
|
364
450
|
`Wallet`, а не созданной операции; нужна операция — заканчивайте цепочку на ней.
|
|
365
451
|
18. **Очередь батча — своя у фасада.** `db.batch('x')` у корня, у каждого `await db.as(…)` и у каждой `db.begin()` —
|
|
366
452
|
разные очереди; план исполняется от имени того, кто его поставил. Копите планы через один объект фасада.
|
|
453
|
+
19. **Строки производного класса пишет только его проектор.** Приложение, сырой SQL и загрузчик получат
|
|
454
|
+
`derived_class`; ссылаться на такие строки концами тоже нельзя. Пересчитать их — `rebuild()` проектора.
|
|
455
|
+
20. **Агрегатное поле пишет только база.** Значение в `create`, `update`, `upsert`, `rekey`, `reclass` и сыром SQL —
|
|
456
|
+
`aggregate_field`; не передавайте его. Сырой upsert цели — `set data = excluded.data - array['balance']`.
|