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.
- package/AGENT-CHEATSHEET.md +12 -5
- package/CHANGELOG.md +40 -0
- package/README.md +202 -46
- package/api-contract.json +4 -0
- package/dist/acl.d.ts +4 -2
- package/dist/admin.d.ts +5 -0
- package/dist/admin.js +10 -5
- package/dist/auth.d.ts +29 -8
- package/dist/auth.js +54 -9
- package/dist/chain.d.ts +19 -13
- package/dist/chain.js +17 -7
- package/dist/cli.js +46 -16
- package/dist/errors.d.ts +4 -5
- package/dist/errors.js +6 -7
- package/dist/index.d.ts +24 -13
- package/dist/index.js +75 -24
- package/dist/load.d.ts +1 -1
- package/dist/log.js +23 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +8 -5
- package/dist/messages/access.d.ts +10 -0
- package/dist/messages/access.js +13 -0
- package/dist/messages/api.d.ts +10 -0
- package/dist/messages/api.js +23 -10
- package/dist/messages/boot.d.ts +5 -0
- package/dist/messages/boot.js +7 -0
- package/dist/messages/codes.js +2 -2
- package/dist/messages/log.d.ts +8 -5
- package/dist/messages/log.js +8 -5
- package/dist/messages/mcp.js +7 -7
- package/dist/messages/model.d.ts +16 -0
- package/dist/messages/model.js +20 -2
- package/dist/messages/write.d.ts +5 -0
- package/dist/messages/write.js +22 -14
- package/dist/model.d.ts +6 -2
- package/dist/model.js +5 -1
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +6 -0
- package/dist/sync.d.ts +3 -4
- package/dist/sync.js +3 -4
- package/dist/typed.d.ts +6 -6
- package/dist/types.d.ts +14 -9
- package/dist/types.js +2 -2
- package/dist/up.d.ts +5 -2
- package/dist/up.js +14 -6
- package/dist/write.d.ts +13 -9
- package/dist/write.js +81 -12
- package/llms.txt +2 -1
- package/package.json +1 -1
- 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
|
-
/**
|
|
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) =>
|
|
58
|
-
|
|
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
|
-
/**
|
|
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
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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.
|
|
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
|
-
--
|
|
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, ни классом.
|
|
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
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|