letopis 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENT-CHEATSHEET.md +12 -5
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +202 -46
  4. package/api-contract.json +4 -0
  5. package/dist/acl.d.ts +4 -2
  6. package/dist/admin.d.ts +5 -0
  7. package/dist/admin.js +10 -5
  8. package/dist/auth.d.ts +29 -8
  9. package/dist/auth.js +54 -9
  10. package/dist/chain.d.ts +19 -13
  11. package/dist/chain.js +17 -7
  12. package/dist/cli.js +46 -16
  13. package/dist/errors.d.ts +4 -5
  14. package/dist/errors.js +6 -7
  15. package/dist/index.d.ts +24 -13
  16. package/dist/index.js +75 -24
  17. package/dist/load.d.ts +1 -1
  18. package/dist/log.js +23 -2
  19. package/dist/mcp.d.ts +1 -1
  20. package/dist/mcp.js +8 -5
  21. package/dist/messages/access.d.ts +10 -0
  22. package/dist/messages/access.js +13 -0
  23. package/dist/messages/api.d.ts +10 -0
  24. package/dist/messages/api.js +23 -10
  25. package/dist/messages/boot.d.ts +5 -0
  26. package/dist/messages/boot.js +7 -0
  27. package/dist/messages/codes.js +2 -2
  28. package/dist/messages/log.d.ts +8 -5
  29. package/dist/messages/log.js +8 -5
  30. package/dist/messages/mcp.js +7 -7
  31. package/dist/messages/model.d.ts +16 -0
  32. package/dist/messages/model.js +20 -2
  33. package/dist/messages/write.d.ts +5 -0
  34. package/dist/messages/write.js +22 -14
  35. package/dist/model.d.ts +6 -2
  36. package/dist/model.js +5 -1
  37. package/dist/registry.d.ts +6 -0
  38. package/dist/registry.js +6 -0
  39. package/dist/sync.d.ts +3 -4
  40. package/dist/sync.js +3 -4
  41. package/dist/typed.d.ts +6 -6
  42. package/dist/types.d.ts +14 -9
  43. package/dist/types.js +2 -2
  44. package/dist/up.d.ts +5 -2
  45. package/dist/up.js +14 -6
  46. package/dist/write.d.ts +13 -9
  47. package/dist/write.js +81 -12
  48. package/llms.txt +2 -1
  49. package/package.json +1 -1
  50. package/sql/60-write.sql +18 -6
package/dist/write.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * await db.Org(org).Staff().create({ name: 'Вася', phone: '+7' }).rows() // концы из пути: { Org }
7
7
  * await db.Customer(c).booking({ notes: 'x' }).update({ notes: 'y' }).rows()
8
8
  * await db.Wallet(w).update({ balance: inc(-300) }).first() // приращение в базе
9
- * await db.Staff(s).booking().create({ … }).Customer.set(c).Item.set(svc).rows() // слоты концов
9
+ * await db.Staff(s).booking().create({ … }).Customer.set(c).Item.set(svc).rows() // шаги ролей
10
10
  *
11
11
  * Библиотека пишет обычными операторами SQL — теми же, что выполнил бы человек в консоли:
12
12
  * insert, update … set data = merge(data, $patch), insert … on conflict do update, delete; всё
@@ -36,6 +36,18 @@ export function inc(by, opts = {}) {
36
36
  return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
37
37
  }
38
38
  export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
39
+ const HIDDEN_ACTIONS = ['delete', 'unset', 'restrict'];
40
+ const HIDDEN_PREFIX = 'hidden:';
41
+ /**
42
+ * Действие строки-сводки предпросмотра удаления (3.0.1): `$hidden` или хвост id `hidden:<действие>`; не сводка — null.
43
+ * Сводку узнаёт и по одному id: expect, собранный из ответа как `{ id, rev }`, её не теряет.
44
+ */
45
+ function hiddenOf(x) {
46
+ if (x.$action !== 'hidden' && !(typeof x.id === 'string' && x.id.startsWith(HIDDEN_PREFIX)))
47
+ return null;
48
+ const a = x.$hidden ?? (typeof x.id === 'string' ? x.id.slice(HIDDEN_PREFIX.length) : undefined);
49
+ return HIDDEN_ACTIONS.includes(a) ? a : null;
50
+ }
39
51
  /**
40
52
  * Данные create() и upsert() без ключей со значением undefined — как у патча update() (splitPatch): ключ undefined JSON
41
53
  * не передаёт, а валидатор на TypeScript принимал его за поле не того типа (план 2.1, A12). Вложенные объекты — тоже;
@@ -52,10 +64,21 @@ export function dropUndefined(data) {
52
64
  }
53
65
  return out;
54
66
  }
55
- /** expect — список строк { id, rev } (и $action у превью удаления): иначе invalid_query до записи. */
67
+ /**
68
+ * expect — список строк { id, rev } (и $action у предпросмотра удаления): иначе invalid_query до записи. Строка-сводка
69
+ * скрытых строк предпросмотра удаления (`$action: 'hidden'`, id `hidden:<действие>`, 3.0.1) — без rev, `$count` (если
70
+ * задан) — целое ≥ 1.
71
+ */
56
72
  function checkExpect(expect, verb) {
57
- const ok = Array.isArray(expect) && expect.every((x) => x !== null && typeof x === 'object' && typeof x.id === 'string'
58
- && Number.isInteger(x.rev) && x.rev >= 1);
73
+ const ok = Array.isArray(expect) && expect.every((x) => {
74
+ if (x === null || typeof x !== 'object' || typeof x.id !== 'string')
75
+ return false;
76
+ const e = x;
77
+ if (verb === 'delete' && (e.$action === 'hidden' || e.id.startsWith(HIDDEN_PREFIX))) {
78
+ return hiddenOf(e) !== null && (e.$count === undefined || (Number.isInteger(e.$count) && e.$count >= 1));
79
+ }
80
+ return Number.isInteger(e.rev) && e.rev >= 1;
81
+ });
59
82
  if (!ok)
60
83
  throw lerr('write.bad_expect', { verb });
61
84
  }
@@ -666,29 +689,74 @@ async function updateOp(w, steps, opStep) {
666
689
  }
667
690
  return inTargetOrder(ids, res.map((r) => toRow(r.row)));
668
691
  }
669
- /** Превью удаления целей: то же замыкание, что у триггера удаления (ref_action), база не меняется. */
692
+ /** Строка-сводка скрытых правами строк предпросмотра (3.0.1): ни класса, ни id, ни данных — только действие и число. */
693
+ const hiddenRow = (action, count) => ({
694
+ id: `${HIDDEN_PREFIX}${action}`, class: '', rev: 0, tenant: '', owner: '', links: {}, data: {}, tags: [], at: '', author: '',
695
+ op: 'hidden', $action: 'hidden', $hidden: action, $count: count,
696
+ });
697
+ /**
698
+ * Предпросмотр удаления целей: то же замыкание, что у триггера удаления (ref_action), база не меняется. Видимые строки —
699
+ * как есть, скрытые правами — в конце сводками: delete_preview отдаёт их строками с id null, action
700
+ * 'hidden:<действие>' и числом в depth; здесь — по строке на действие delete, unset, restrict, только непустые.
701
+ */
670
702
  async function previewOf(w, ids) {
671
703
  const S = quoteId(w.schema);
672
704
  const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
673
- + `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
674
- return res.map((r) => {
705
+ + `left join ${S}.entity e on e.id = p.id where p.id is null or e.id is not null `
706
+ + `order by p.id is null, p.depth nulls last, p.action, e.id`, [ids]);
707
+ const out = [];
708
+ const hidden = new Map();
709
+ for (const r of res) {
710
+ if (r.action.startsWith(HIDDEN_PREFIX)) {
711
+ hidden.set(r.action.slice(HIDDEN_PREFIX.length), r.depth ?? 0);
712
+ continue;
713
+ }
675
714
  const row = toRow(r.row);
676
715
  row.$action = r.action;
677
716
  if (r.depth !== null)
678
717
  row.$depth = r.depth;
679
- return row;
680
- });
718
+ out.push(row);
719
+ }
720
+ for (const a of HIDDEN_ACTIONS) {
721
+ const n = hidden.get(a) ?? 0;
722
+ if (n > 0)
723
+ out.push(hiddenRow(a, n));
724
+ }
725
+ return out;
681
726
  }
682
- /** Превью совпадает с показанным: те же строки по id, с теми же rev и $action (порядок не важен). */
727
+ /**
728
+ * Превью совпадает с показанным: те же видимые строки по id, с теми же rev и $action (порядок не важен), и те же
729
+ * сводки скрытых строк (3.0.1) по действию и числу; у сводки без $count сверяется только её наличие.
730
+ */
683
731
  function samePreview(now, expect) {
684
732
  if (now.length !== expect.length)
685
733
  return false;
686
- const want = new Map(expect.map((x) => [x.id.toLowerCase(), x]));
734
+ const want = new Map();
735
+ for (const x of expect) {
736
+ const h = hiddenOf(x);
737
+ want.set(h ? `${HIDDEN_PREFIX}${h}` : x.id.toLowerCase(), x);
738
+ }
739
+ if (want.size !== expect.length)
740
+ return false;
687
741
  return now.every((r) => {
742
+ if (r.$action === 'hidden') {
743
+ const x = want.get(r.id);
744
+ return x !== undefined && (x.$count === undefined || x.$count === r.$count);
745
+ }
688
746
  const x = want.get(r.id.toLowerCase());
689
- return x !== undefined && x.rev === r.rev && (x.$action === undefined || x.$action === r.$action);
747
+ return x !== undefined && hiddenOf(x) === null && x.rev === r.rev && (x.$action === undefined || x.$action === r.$action);
690
748
  });
691
749
  }
750
+ /**
751
+ * Сверенный с expect предпросмотр, а в нём мешающие строки (restrict) — видимые или скрытые правами (3.0.1): отказ
752
+ * delete_restricted до удаления; detail — как у отказа триггера: видимые { id, class } и число остальных.
753
+ */
754
+ function checkRestrict(now) {
755
+ const rows = now.filter((r) => r.$action === 'restrict').map((r) => ({ id: r.id, class: r.class }));
756
+ const others = now.filter((r) => r.$action === 'hidden' && r.$hidden === 'restrict').reduce((n, r) => n + (r.$count ?? 0), 0);
757
+ if (rows.length || others)
758
+ throw lerr('write.delete_restricted', { visible: rows.length, others }, { detail: { rows, others } });
759
+ }
692
760
  async function deleteOp(w, steps, opStep) {
693
761
  const op = opStep.op;
694
762
  const S = quoteId(w.schema);
@@ -710,6 +778,7 @@ async function deleteOp(w, steps, opStep) {
710
778
  }
711
779
  if (!samePreview(now, op.expect))
712
780
  throw lerr('write.preview_changed', undefined, { detail: { preview: now } });
781
+ checkRestrict(now);
713
782
  }
714
783
  if (!ids.length)
715
784
  return [];
package/llms.txt CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  Главное для кода на letopis:
18
18
 
19
- - Установка: `npm pkg set type=module && npm i letopis zod postgres@3 && npm i -D tsx` (код приложения — ESM; `postgres@3` — та же мажорная версия, что у letopis, иначе `createTenant` не проходит проверку типов). Node 22+, PostgreSQL 18+ с pgcrypto, база UTF8. Zod 4 нужен только `letopis/model`, `@modelcontextprotocol/server` — только `letopis/mcp`.
19
+ - Установка: `npm pkg set type=module && npm i letopis@3 zod postgres@3 && npm i -D tsx` (код приложения — ESM; `@3` — мажорная линия этой документации, без версии npm поставит последний выпуск; `postgres@3` — та же мажорная версия, что у letopis, иначе `createTenant` не проходит проверку типов). Node 22+, PostgreSQL 18+ с pgcrypto, база UTF8. Zod 4 нужен только `letopis/model`, `@modelcontextprotocol/server` — только `letopis/mcp`.
20
20
  - Схему ставит `up({ dsn, schema, login, tenant })` от администратора (суперпользователь или роль с `CREATEROLE`, `CREATE` в базе и `CREATEDB`, если базы нет); роль приложения заводит `login`, её строка подключения — `r.login.dsn`. Ключ сервиса выдаёт только первый `up()` — храните его сами или передавайте свой `up({ serviceKey })`; `connect({ apiKey: null | '' })` — сразу `invalid_data` (`api.connect_empty_credential`), а `undefined` — подключение без сессии, поэтому ключ проверяйте до `connect()`; `up({ upgrade: true })` обновляет схему отставшей ревизии, `up({ fresh: true })` пересоздаёт её с потерей данных.
21
21
  - Вход пользователя: одно подключение сервиса на процесс (`connect({ apiKey })`), `svc.auth.verifyPassword()` → `{ account, token }`, затем `svc.as(account, { tenant })` на запрос. Без `{ tenant }` сессия — в **личном** арендаторе пользователя (классов там нет, `invalid_class`); проверка — `db.tenant`. По погашенной сессии чтение отдаёт пустые ответы, запись — `no_session`, поэтому `svc.auth.session(token)` — в начале каждого запроса.
22
22
  - Классы — хабы (объекты) и связи (отношения с концами): `hub()` и `link()` из `letopis/model`, в базу — `syncModels()` или `letopis sync` (в арендатора сессии). Имя роли конца одно на арендатора; роли `Group`, `Object`, `User`, `Tenant` заняты системными классами `rule` и `member` — конец с таким именем на другой класс — `invalid_class` (`model.end_role_system`).
@@ -32,6 +32,7 @@
32
32
  - Логирование: `connect({ log, logLevel, redact })` и `up({ … })` — функция или объект-логгер (`console`, pino), порог `debug|info|warn|error|off` (по умолчанию `info`); без кода — `LETOPIS_LOG=info` (JSON-строки в stderr), CLI — `--log[=уровень]`. События `delete` и `restore` — что удалено и что не вернулось; внутри `db.begin()` событие значит «выполнено в транзакции», фиксацию доказывает `tx.commit` с тем же `tx`. Штатные отказы — событие `error` уровня `warn`, ошибки кода — уровня `error`.
33
33
  - Обслуживание — `maintain()` (администраторское подключение, системный администратор или право `letopis.maintain`, иначе `acl_denied`); загрузка — `db.load(rows)` с администраторского подключения; подписка — `db.watch({ from, classes })` и `await sub.ready()` перед записью.
34
34
  - MCP: `createMcpServer(await svc.as(пользователь, { tenant }))` из `letopis/mcp` и `letopis mcp` дают ИИ-агенту `letopis_model`, `letopis_query`, `letopis_history` и, с `write: true`, `letopis_write`; удаление подтверждает человек, удаляется ровно показанное превью.
35
+ - Системный администратор — член System с ролью `admin`, отдельный аккаунт: через сервис не входит (`verifyPassword`, `sessionFor`, `svc.as()` — `acl_denied`), входит ключом API и `auth.switch(System)`; `purgeAccount` стирает только собственного арендатора аккаунта — стираемые персональные данные держите в классе с `history: false` (README, «Аккаунты, арендаторы и членство», «Удалить аккаунт»).
35
36
  - Лицензия — PolyForm Noncommercial 1.0.0 (SPDX `PolyForm-Noncommercial-1.0.0`): исходный код доступен, некоммерческое использование бесплатно, коммерческое — только по отдельной лицензии владельца (запрос — issue на https://github.com/alepri51/letopis/issues).
36
37
 
37
38
  ## Документы
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Letopis (летопись): хранилище версионированных сущностей на PostgreSQL 18 — целостность, история, арендаторы и права проверяет сама база, поверх — цепочки через точку. ИИ-агенту — AGENT-CHEATSHEET.md и llms.txt в пакете; исходный код доступен (PolyForm Noncommercial 1.0.0)",
5
5
  "keywords": [
6
6
  "postgres",
package/sql/60-write.sql CHANGED
@@ -1378,12 +1378,15 @@ begin
1378
1378
  return null;
1379
1379
  end $$;
1380
1380
 
1381
- -- Превью удаления (этап 4, п. 6): что удалит delete этих строк — те же уровни и то же правило
1381
+ -- Предпросмотр удаления (этап 4, п. 6): что удалит delete этих строк — те же уровни и то же правило
1382
1382
  -- ref_action(), что у триггера, но без изменений. Строки: action delete (цели — depth 0, каскад —
1383
1383
  -- уровень), unset (конец будет снят) и restrict (мешает удалению). Функция владельца ищет по
1384
1384
  -- индексу ends без RLS (§3 «Индексы под RLS»), поэтому и цели, и выдачу пропускает через
1385
1385
  -- row_visible() — то же условие, что у политики чтения (как find_*): невидимых строк не раскрывает
1386
- -- ни id, ни классом. О невидимых мешающих строках скажет само удаление (visible_detail: число).
1386
+ -- ни id, ни классом. Невидимые строки замыкания (3.0.1) — в конце сводкой, по строке на действие (delete,
1387
+ -- unset, restrict; только непустые): id и class — null, action — 'hidden:<действие>', depth — их число. Вид
1388
+ -- результата прежний: тело ставит create or replace повторным накатом той же ревизии, а клиент, который
1389
+ -- соединяет выдачу с entity по id, сводок не видит.
1387
1390
  create or replace function @SCHEMA@.delete_preview(p_ids uuid[]) returns table(id uuid, class text, action text, depth int)
1388
1391
  language plpgsql stable security definer
1389
1392
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -1432,10 +1435,19 @@ begin
1432
1435
  from (select x.id, @SCHEMA@.ref_action((@SCHEMA@.class_row(x.class, x.tenant)).data->'resolved'->'ends', x.links, all_ids) as a
1433
1436
  from @SCHEMA@.entity x where x.ends && all_ids and not (x.id = any (all_ids))) r);
1434
1437
  return query
1435
- select (o->>0)::uuid, e.class, o->>1, (o->>2)::int
1436
- from jsonb_array_elements(out_rows) o
1437
- join @SCHEMA@.entity e on e.id = (o->>0)::uuid
1438
- where @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
1438
+ with o as (
1439
+ select (j.v->>0)::uuid as id, e.class, j.v->>1 as action, (j.v->>2)::int as depth, j.ord,
1440
+ @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan) as vis
1441
+ from jsonb_array_elements(out_rows) with ordinality j(v, ord)
1442
+ join @SCHEMA@.entity e on e.id = (j.v->>0)::uuid
1443
+ )
1444
+ select x.id, x.class, x.action, x.depth
1445
+ from (select o.id, o.class, o.action, o.depth, 0 as part, o.ord as k from o where o.vis
1446
+ union all
1447
+ select null::uuid, null::text, 'hidden:' || o.action, count(*)::int, 1,
1448
+ array_position(array['delete', 'unset', 'restrict'], o.action)::bigint
1449
+ from o where not o.vis group by o.action) x
1450
+ order by x.part, x.k;
1439
1451
  end $$;
1440
1452
 
1441
1453
  create or replace trigger entity_bi before insert on @SCHEMA@.entity