letopis
v0.19.0
Published
Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)
Maintainers
Readme
Letopis — руководство
Dot-цепочки над append-only Entity-хранилищем (TimescaleDB). Всё — точками: чтение, связи, запись, настройки выборки.
import { connect } from 'letopis'
const db = await connect({ dsn: 'postgres://…', schema: 'v1.booking' })
// чтение: пути по графу
const пути = await db.Мастер({ name: 'Вася' }).навык().Услуга().run()
// [ { Мастер: Row, навык: Row, Услуга: Row }, … ]
// запись: операции — звенья, исполняет терминал
await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()Возможности
Хранилище — темпоральное, append-only
- Каждое изменение — новая версия-строка; история — first-class (§2, §10).
- Единая
Entity-hypertable на TimescaleDB, партиции по времениupdated(§2.1). - Весь CRUD на уровне БД триггерами:
UPDATE= новая версия,DELETE= tombstone + рекурсивный каскад (§2.2). - FK-целостность с RESTRICT на append-only (§2.3); срез актуального через
DISTINCT ON(§2.4).
Схема и классы
- Реестр классов из таблицы
Schema:HUB/LINK, наследованиеattributesпо иерархии (§3, §3.3). - Строгая валидация данных — fastest-validator DSL, вложенные объекты любой глубины (§3, §6).
- Связи v2: союзы ролей (
{A|B|C}), optional-концы, жадный матчинг (§3.1). - Правило id в схеме: uuid v4 / v7 (time-ordered) / v5 (детерминированный → идемпотентный create) (§3.2).
Чтение — dot-цепочки (graph-query)
- Пути по графу HUB↔LINK, pivot-возврат, узел-переменная
entity()(§4). - Терминалы
run/rows/first/ids/count— ленивый план, SQL уходит на терминале (§4). - 18 операторов фильтров (
negtbetweeninListlikehasexistsnotor…), вложенные пути, касты по схеме (§5). - Модификаторы:
limit/offset/sort,asOf,after(keyset),deep,tags/account/owner,alias(§5).
Запись
create/update/delete/anonymize— звенья плана; весь план одной транзакцией с откатом (§6).- Слоты связей
.Класс.set()/.unset(), вложенная цепочка как значение слота (§6.1). - Deep-merge при
update; идемпотентныйcreateпо вычисленному v5-id (§6). - Транзакции
db.begin()+ advisory-lock (рецепт двойной брони, §7); батчи с multi-VALUES-склейкой (§8).
История и время
asOf()(«как было на T») +versions()(§10.1); keyset-пагинацияafter()/cursorOf()(§10.2).- Агрегации в БД
sum/avg/min/max/countBy(§10.3); деревьяdeep()recursive-CTE (§10.4). - Realtime
watch()черезpg_notify+onReconnect(§10.5). - Анонимизация (GDPR)
anonymize()(§10.7); политики сжатия / retention Timescale (§10.8).
Auth · ACL · сессии — встроенные
- Пароли (scrypt), api-ключи, key-secret, external identity (oauth/sso/telegram), TOTP, одноразовые коды (§9.1).
- Сессии во внешнем Redis (клиент инжектируется, §9.1).
- ACL:
Resource/Rule,enforceAcl— предикаты строк вливаются в SQL до сортировки/лимита; наследование прав по классам (§9.2). - Изоляция арендатора
enforceAccount(§10.6).
Эксплуатация
up()— dev-bootstrap одной функцией: Docker (TimescaleDB pg17 + Redis) + схема + сиды + connect (§1, §1.1).- Миграции классов
schema-syncс отчётом совместимости (§10.9); TS-типы из схемыgen-types(§10.11). - Наблюдаемость
onQuery/slowMs(§10.10); ретраи deadlock/serialization, самопереподключение LISTEN (§10.12). - Лёгкие зависимости: только
postgres+fastest-validator(Redis-клиент внешний,ioredisопционален).
Содержание: 1. Быстрый старт · 2. Хранилище · 3. Schema · 4. Чтение · 5. Фильтры и модификаторы · 6. Запись · 7. Транзакции · 8. Батчи · 9. Auth-таблицы · 10. Срез и история · 11. API Reference · 12. Ошибки · 13. Зона приложения · 14. Производительность · 15. E2E-пример · 16. Тесты
1. Быстрый старт
Одна функция — весь путь от нуля: dev-контейнер (TimescaleDB + Redis), готовность, версионная схема с сидами, подключение:
import { up } from 'letopis'
const db = await up({ schema: 'booking', version: 1 }) // → PG-схема "v1.booking"
// [letopis.up] building image letopis-db (first time pulls the timescaledb base — may take minutes)…
// [letopis.up] container letopis-timescale created (data: volume letopis-pgdata)
// [letopis.up] postgres ready in 5.0 s
// [letopis.up] redis ready on 16379
// [letopis.up] schema "v1.booking" applied (3 files)
// [letopis.up] connected (schema "v1.booking")
const [org] = await db.Организация().create({ name: 'BarberPro' }).rows()
const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
await db.close()Идемпотентно: живой postgres на dsn → docker пропускается; существующая схема не трогается.
Данные PG живут в named volume letopis-pgdata (кроссплатформенно, переживает пересоздание
контейнера); dataDir: '/path' — bind mount папки хоста. Полный контракт — §11.1a.
Вручную (то же самое по шагам) — из клона репозитория (db/apply.mjs — корневой скрипт репо, в npm-пакет не входит; в установленном пакете накат делает up() выше, а Docker-образ он собирает из вложенного docker/):
docker build -t letopis-db lib/docker
docker run -d --name letopis-timescale -e POSTGRES_PASSWORD=test -e POSTGRES_DB=clockz \
-p 15432:5432 -p 16379:6379 -v letopis-pgdata:/var/lib/postgresql/data letopis-db
node db/apply.mjs --dsn=postgres://postgres:test@localhost:15432/clockz --schema=booking --version=1
# SQL-шаблоны (lib/sql/) держат маркер <SCHEMA-NAME>; итоговая PG-схема — "v1.booking"
# = ddl.sql + seed.booking.sql + seed.auth.sql в указанную PG-схему (--no-seed — только ddl)const db = await connect({ dsn: 'postgres://postgres:test@localhost:15432/clockz', schema: 'v1.booking' })1.1 Своя схема с нуля + всё в контейнере одной командой
Три шага: (1) сид своей схемы → (2) up() поднимает контейнер и накатывает → (3) пишешь данные. Таблицы (Schema, Account, Entity, …) создаёт ddl.sql — своему сиду нужны только INSERT-ы; маркер <SCHEMA-NAME> подставит up().
1. Сид myapp.sql — классы в таблицу Schema + System-аккаунт:
-- myapp.sql — своя схема letopis
-- System-аккаунт: Entity.account/owner — NOT NULL, дефолт либа берёт ОТСЮДА
-- (connect ищет Account с категорией 'System'; без него первый create бросит ошибку)
INSERT INTO "<SCHEMA-NAME>"."Account" (id, categories, data)
VALUES ('00000000-0000-4000-8000-000000000001', '{System}', '{"name":"System"}')
ON CONFLICT (id) DO NOTHING;
-- Классы домена (нужен ≥1, иначе connect: "has no classes")
INSERT INTO "<SCHEMA-NAME>"."Schema"
(partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors)
VALUES
-- корень: дефолт id v7 наследуется; abstract — напрямую не создаётся
('entity','Entity','Сущность','HUB', NULL,
'{"id":{"type":"uuid","generate":7}}','{"abstract":true}','[]',100,'{Entity}'),
-- HUB: заметка
('entity','Note','Заметка','HUB','Entity',
'{"title":"string","body":"string|optional","done":{"type":"boolean","default":false}}',
'{}','[]',200,'{Note,Entity}'),
-- HUB: тег
('entity','Tag','Тег','HUB','Entity',
'{"name":"string"}','{}','[]',300,'{Tag,Entity}'),
-- LINK: заметка ↔ тег
('entity','tagged','помечена','LINK', NULL,
'{"id":{"type":"uuid","generate":7}}','{}',
'[{"class":"Note","cardinality":1},{"class":"Tag","cardinality":1}]',400,'{tagged}')
ON CONFLICT (partition, id) DO UPDATE SET
alias=EXCLUDED.alias, ancestor=EXCLUDED.ancestor, attributes=EXCLUDED.attributes,
meta=EXCLUDED.meta, links=EXCLUDED.links, "order"=EXCLUDED."order";2. up() — контейнер + база + схема + connect одной командой:
import { up } from 'letopis'
const db = await up({ schema: 'myapp', version: 1, seeds: ['./myapp.sql'] })
// [letopis.up] building image letopis-db … (первый раз тянет TimescaleDB-базу — минуты)
// [letopis.up] container letopis-timescale created (data: volume letopis-pgdata)
// [letopis.up] postgres ready in 5.0 s / redis ready on 16379
// [letopis.up] schema "v1.myapp" applied (2 files) ← ddl.sql + myapp.sql
// [letopis.up] connected (schema "v1.myapp")Изолированное окружение (если на хосте уже крутится дефолтный контейнер или нужен отдельный):
const db = await up({
schema: 'myapp', version: 1, seeds: ['./myapp.sql'],
container: 'myapp-db', // иначе дефолтный 'letopis-timescale'
image: 'myapp-timescale', // соберётся из пакетного docker/ (TimescaleDB pg17 + Redis)
dsn: 'postgres://postgres:secret@localhost:25432/myapp',
dataDir: 'myapp-pgdata', // named volume (или путь хоста = bind mount)
redisPort: 26379,
})3. Данные — работают сразу (System-аккаунт из сида закрывает account/owner):
const [n] = await db.Заметка().create({ title: 'Купить хлеб' }).rows()
const [t] = await db.Тег().create({ name: 'дом' }).rows()
await db.Заметка(n).помечена().create().Тег.set(t).rows() // связь Note ↔ Tag (§6.1)
const активные = await db.Заметка({ done: false }).rows()
await db.close()Гейты и рецепты
- ≥1 класс в
Schemaобязателен — иначеup()падает на connect:has no classes(§3). - System-аккаунт обязателен (или передать
up({ …, account: '<uuid существующей Account>' })) — иначе первыйcreateброситEntity.account is NOT NULL …. - Готовый auth из коробки (пароли/ACL): добавь пакетный сид вторым —
seeds: ['./myapp.sql', 'node_modules/letopis/sql/seed.auth.sql'](System + 16 Resource + 12 Rule); тогда своюAccount-строку не пиши. - Идемпотентно: контейнер жив → reuse; схема есть → накат пропущен. Заново с нуля:
fresh: true(дроп схемы — данные теряются) либо снести контейнер:docker rm -f myapp-db && docker volume rm myapp-pgdata. - Prod / managed PG:
dsnна живой postgres → docker пропускается целиком (только база + схема + connect); либо накатитьsql/ddl.sql+ свой сид своим мигратором иconnect({ dsn, schema: 'v1.myapp' }). Пакетныйdocker/-образ (Redis без пароля/персиста) — dev-удобство, не для прода. - Локальный путь требует установленного и запущенного Docker; managed PG — без него.
2. Хранилище
lib/sql/ddl.sql: Entity, Schema, Account, Credential, Resource, Rule + триггеры.
Весь CRUD работает на уровне БД — голый SQL равносилен либе; либа добавляет строгую
валидацию данных, цепочки и удобства.
2.1 Entity — единая append-only hypertable
| Колонка | Тип | Смысл |
|---|---|---|
| partition | text | пространство данных ('entity') |
| id | text | id сущности (uuid или детерминированная строка) |
| class | text | класс из Schema (FK) |
| data | jsonb | атрибуты (строгая валидация по Schema.attributes) |
| updated | timestamptz | момент версии (компонента PK) |
| account | uuid NOT NULL | арендатор (FK → Account, RESTRICT) |
| owner | uuid NOT NULL | владелец (FK → Account, RESTRICT) |
| tags | text[] | метки |
| links | jsonb | связи {"Класс": "id", …} — произвольное число на строку |
| deleted | timestamptz | tombstone-метка |
- PK
(partition, class, id, updated): сущность = все её версии; строка = версия. - Актуальная версия = max(
updated); живая, еслиdeleted IS NULL. - Партиционирование: ТОЛЬКО время
updated(чанки 30 дней). Hash-размерности убраны: на 1.1M строк точечный доступ равен PK-btree, простые фильтры −10–20%, но цепочки ×2…×140 медленнее и 93 чанка/год против 14 (bench/dimensions.bench.mjs). - Индексы: latest-btree, GIN
links/data(jsonb_path_ops), GINtags, btreeaccount/owner.
2.2 Триггеры Entity — CRUD в БД
| SQL | Триггер | Поведение |
|---|---|---|
| INSERT | entity_check | класс существует (плюс FK) и не abstract; ключи links — существующие классы, значения — строки-id; концы LINK по Schema.links v2 (жадный матчинг, союзы, optional, лишние связи — ошибка; legacy-строки — по-старому). Tombstone-вставки не проверяются |
| UPDATE | entity_update | физического апдейта нет: вставляется новая версия (updated = GREATEST(clock, prev+1µs)); не-latest строки игнорируются — история неизменна |
| DELETE | entity_delete | вставляется tombstone + рекурсивный каскад: DELETE живых зависимых (links ⊃ {класс: id}) повторяет триггер по дереву; advisory-lock; история/tombstone неприкосновенны (повторный DELETE — no-op) |
-- голый SQL работает как либа:
UPDATE "v1.booking"."Entity" SET data = data || '{"duration":45}'
WHERE partition='entity' AND class='Service' AND id='…'; -- → новая версия
DELETE FROM "v1.booking"."Entity"
WHERE partition='entity' AND class='booking' AND id='…'; -- → tombstone + каскадУправление объёмом истории — только политики Timescale: db/policies.mjs (§ 10.8).
2.3 FK-целостность
| FK | Правило |
|---|---|
| Entity(partition, class) → Schema(partition, id) | RESTRICT: класс с данными не удалить |
| Entity.account / owner → Account(id) | RESTRICT: аккаунт с историей не удалить — enabled=false; CASCADE/SET NULL невозможны на append-only |
| Credential.account → Account(id) | CASCADE |
| Rule.account / resource → Resource(alias) | CASCADE (оба конца ACL-правила) |
2.4 Чтение «актуального» (замена вью legacy)
Каждый шаг: DISTINCT ON (id) … ORDER BY id, updated DESC после сужения по class +
GIN-кандидатам, затем перепроверка условий на latest. Фильтр истинен относительно
актуальной версии: старое name='Вася' при новом name='Петя' не матчится.
3. Schema: классы (HUB / LINK)
| Поле | Смысл |
|---|---|
| id / alias | англ. id (Staff) и русский алиас (Мастер) — равноправны в API |
| category | HUB (сущность) | LINK (связь с атрибутами) |
| ancestor | прямой родитель |
| ancestors | [self, parent, …, root] — считает триггер schema_lineage |
| descendants | все потомки транзитивно — тот же триггер |
| attributes | fastest-validator DSL; вложенные {type:'object', props:{…}} любой глубины; строгая валидация; собираются по иерархии (§ 3.3); правило id — генерация (§ 3.2) |
| links | концы связей класса, формат v2 — массив объектов (см. ниже); legacy-строка 'Org' = {class:'Org'}, 'Entity' = старый полиморф |
| meta | { abstract?, description? } |
3.1 Концы связей — Schema.links v2
Каждый конец — объект (Schema.links jsonb — массив объектов):
"links": [
{ "class": "Staff", "cardinality": 1 }, // один класс
{ "classes": ["Service", "Product", "Complex"], "cardinality": 1 }, // союз ролей: ровно один из
{ "class": "Customer", "optional": true, "cardinality": 1 } // конец может отсутствовать
]Entityв концах не используется — классы называются явно («максимально точная идентификация связи»)- Матчинг жадный, по порядку объявления: каждый ключ
Entity.linksстроки занимает первый подходящий конец. Союз ролей: предмет записи —{Услуга|Товар|Комплекс}(бронь услуги ИЛИ продажа товара ИЛИ комплекс — ровно один из) - Обязательный конец без ключа →
requires end "Service|Product|Complex"; связь вне объявленных концов →stray link(s)— ошибки и в либе, и в БД-триггере (голый SQL ловится так же) cardinality— зарезервировано (0 — безлимит, N — точное число), пока не проверяется: связь класса в строке одна ({Класс: id}), множественность выражается строками-связками- Демо-домен (0.17, позитивная доступность):
адрес = [Мастер, Локация],окно = [Мастер, Локация, Расписание](смена — интервал доступности),запись = [Мастер, Локация, Расписание, {Услуга|Товар|Комплекс}, Клиент?]— наследник окна,содержимое = [Папка, {Услуга|Товар|Комплекс}],состав = [Комплекс, {Услуга|Товар}],навык = [Мастер, Услуга],цена = [{Услуга|Товар|Комплекс}](варианты цены:note+amountsrecord<валюта,число>) - Источник правды демо-домена — сид
lib/sql/seed.booking.sql(редактируется руками, идемпотентен)
schema_lineage (statement-триггер, рекурсивные CTE, защита от циклов/саморекурсии)
пересчитывает ancestors/descendants при любом изменении Schema.
Из либы: db.registry.resolve('связь').descendants → ['address','booking','compo','content','price','skill','slot'].
3.2 id считает схема: attributes.id
Правило рождения id объявляется в attributes.id класса (наследуется — § 3.3):
| Правило | Версия | Смысл |
|---|---|---|
| "uuid" / {"type":"uuid"} | v4 | random — дефолт (как раньше) |
| {"type":"uuid","generate":7} | v7 | unix-время в старших битах — вставки ложатся в хвост btree-индекса |
| {"type":"uuid","generate":5,"from":[…]} | v5 | детерминированный: id вычисляется из данных |
Формула v5 открыта: uuidv5("pgSchema:partition:класс:значения from"), namespace letopis —
c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90. from — имена ОБЯЗАТЕЛЬНЫХ концов Schema.links
(класс или полное имя союза 'Service|Complex'; значение — id конца) и/или скалярных полей
data. Экспорт: import { uuidv5, uuidv7, LETOPIS_NS } from 'letopis'.
Два свойства v5, ради которых всё:
- create идемпотентен: та же комбинация → та же сущность (повтор — новая версия, не дубль) — даже в гонке двух процессов оба вычислят один id; двойная бронь мертва на уровне схемы;
- id известен ДО создания: «занято ли окно» — точечный
first()по вычисленному id, без создания чего-либо.
// запись: id считается сам — uuidv5(мастер, старт); правило унаследовано от окна
const [б] = await db.Мастер(м).запись().create({ start_datetime: t, end_datetime: e })
.Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
б.id === uuidv5(`v1.booking:entity:booking:${м.id}:${t}`) // → true
// «занято?» — ДО создания чего-либо:
await db.запись(uuidv5(`v1.booking:entity:booking:${м.id}:${t}`)).first() // Row | nullЯвный id у v5-класса запрещён (computes id — его всегда считает схема); в батче
v5-классы не склеиваются в multi-VALUES (id нужны концы) — исполняются поштучно (§ 8).
Демо-схема booking: корни Entity/link объявляют дефолт {generate: 7} один раз;
v7-классы (Org/Staff/Customer/Location/Schedule/Folder) наследуют его без собственного
правила; v5 объявляются по одному разу и наследуются дальше:
Element ← v5(Org, data.name) — наследуют Услуга/Товар/Комплекс (имя уникально в
организации, classId различает классы); окно (slot) ← v5(Staff, data.start_datetime) —
наследует запись (booking): двойная бронь мертва самим id записи, а окно и запись
на одно время сосуществуют (classId в формуле разный); адрес ← v5(Staff, Location),
навык ← v5(Staff, Service), состав ← v5(Complex, Service|Product),
содержимое ← v5(Folder, Service|Product|Complex) — полные имена союзов в from;
цена ← v5(Service|Product|Complex, note) — вариант цены уникален по (элемент, note).
from-поле может быть необязательным: если его нет в create, id берёт его default
из Schema (напр. цена без note → note:'базовая', id считается стабильно). Так
необязательное поле участвует в детерминированном id, не ломая генерацию.
3.3 Наследование attributes
При загрузке реестра attributes класса собираются по цепочке ancestor: потомок ПОВЕРХ
предка, переопределение поля — замена правила ЦЕЛИКОМ (не слияние). Правило id (§ 3.2)
наследуется так же — дефолт объявляется один раз на корне иерархии; links НЕ наследуются:
концы объявляет каждый класс сам. Валидатор и типы полей компилируются из слитых attributes.
3.4 Примеры схем из таблицы Schema
Демо-домен booking — 20 классов в lib/sql/seed.booking.sql (источник правды:
правится руками, идемпотентен — по строке-INSERT на класс). Файл входит в npm-пакет;
up({ schema: 'booking' }) накатывает его дефолтом. Свой домен — тем же форматом:
await up({ schema: 'salon', version: 1, seeds: ['./salon.sql'] }) // свой сид вместо демо
// seed.auth.sql грузится отдельно и в Schema НЕ пишет — только Account/Resource/Rule (§9)Таблица Schema (lib/sql/ddl.sql) — по строке на класс:
CREATE TABLE IF NOT EXISTS "<SCHEMA-NAME>"."Schema" (
partition text NOT NULL DEFAULT 'entity',
id text NOT NULL, -- англ. id класса: 'Staff', 'skill'
alias text NOT NULL, -- рус. алиас: 'Мастер', 'навык'
category text NOT NULL CHECK (category IN ('HUB', 'LINK')),
ancestor text, -- прямой родитель (наследование)
attributes jsonb NOT NULL DEFAULT '{}', -- fastest-validator DSL (валидирует либа)
links jsonb NOT NULL DEFAULT '[]', -- массив объектов-концов (§3.1)
meta jsonb NOT NULL DEFAULT '{}', -- {abstract?, description?, appearance?}
"order" int NOT NULL DEFAULT 0,
ancestors text[] NOT NULL DEFAULT '{}', -- [self, parent, …, root] — считает триггер schema_lineage
descendants text[] NOT NULL DEFAULT '{}', -- все потомки (транзитивно) — считает триггер schema_lineage
PRIMARY KEY (partition, id)
);Формат строки (один на все 20; <SCHEMA-NAME> подставляет up() реальным именем PG-схемы):
INSERT INTO "<SCHEMA-NAME>"."Schema"
(partition, id, alias, category, ancestor, attributes, meta, links, "order", ancestors)
VALUES ('entity', 'Service', 'Услуга', 'HUB', 'Element',
'{"duration":"number|min:0|optional"}', -- attributes: DSL fastest-validator (§3)
'{"abstract":false,"description":"…"}', -- meta
'[{"class":"Org","cardinality":1}]', -- links: концы (§3.1); НЕ наследуются
800, '{Service,Element,Entity}') -- order + ancestors
ON CONFLICT (partition, id) DO UPDATE SET …; -- повторный накат = правка классаid+alias— англ. и рус. имя, равноправны в API:db.Service(…)≡db.Услуга(…).descendantsне пишем — считает триггерschema_lineage;ancestorsон тоже пересчитает.attributes.id— правило рождения id (§3.2), наследуется поancestor(§3.3).
Ниже — 4 характерные строки verbatim из сида и как ими пользоваться.
HUB · вложенный object — Org (Организация)
// attributes:
{
"name": "string",
"timezone": { "type": "string", "default": "Europe/Moscow" },
"address": "string|optional",
"phone": "string|optional",
"active": { "type": "boolean", "default": true },
"settings": { "type": "object", "optional": true, "strict": true, "props": {
"booking": { "type": "object", "optional": true, "strict": true, "props": {
"deposit": { "type": "object", "optional": true, "strict": true, "props": {
"amount": "number|integer|min:0|default:0",
"currency": { "type": "enum", "values": ["RUB","USD","EUR","AED"], "default": "RUB" }
}},
"autoconfirm": "boolean|default:false"
}}
}}
}
// links: [] ← корневой HUB, арендатор сам себе владелец (концов нет)// create: defaults подставит схема (timezone, active); вложенный object любой глубины
const [org] = await db.Организация().create({
name: 'BarberPro',
settings: { booking: { deposit: { amount: 500, currency: 'RUB' }, autoconfirm: true } },
}).rows()
// правка одного листа — deep-merge, соседи целы (§6): autoconfirm и currency сохранятся
await db.Организация(org).update({ settings: { booking: { deposit: { amount: 1000 } } } }).rows()
// фильтр по вложенному пути любой глубины (§5):
await db.Организация({ settings: { booking: { autoconfirm: true } } }).rows()Поле вне attributes → ValidationError (валидация строгая, $$strict).
HUB · наследуемый v5-id — Element → Service (Услуга)
Element объявляет правило id один раз; Service/Product/Complex наследуют (§3.3):
// Element (abstract): attributes
{ "id": { "type": "uuid", "generate": 5, "from": ["Org", "name"] }, // id = uuidv5(Org, name)
"name": "string", "description": "string|optional" }
// links: [{ "class": "Org", "cardinality": 1 }]
// Service (ancestor: Element): attributes — добавляет только своё поле
{ "duration": "number|min:0|optional" } // name и правило id унаследованы от Element
// links: [{ "class": "Org", "cardinality": 1 }] ← links НЕ наследуются, объявлены заново// create: id НЕ передаём — схема считает uuidv5(Org, name); повтор той же пары = та же услуга
const [svc] = await db.Организация(org).Услуга().create({ name: 'Стрижка', duration: 60 }).rows()
svc.id === uuidv5(`v1.booking:entity:Service:${org.id}:Стрижка`) // → true (§3.2)
// имя уникально в организации; classId в формуле различает Услугу и Товар с тем же именемLINK · наследование + союз ролей + optional — slot → booking (запись)
booking наследует slot (интервал + правило id), добавляет предмет записи и клиента:
// slot (окно): attributes
{ "id": { "type": "uuid", "generate": 5, "from": ["Staff", "start_datetime"] },
"start_datetime": { "type": "date", "convert": true },
"end_datetime": { "type": "date", "convert": true } }
// links: [{class:"Staff"}, {class:"Location"}, {class:"Schedule"}] (все cardinality:1)
// booking (ancestor: slot): attributes — только своё; start/end и правило id унаследованы
{ "notes": "string|optional" }
// links:
[ { "class": "Staff", "cardinality": 1 },
{ "class": "Location", "cardinality": 1 },
{ "class": "Schedule", "cardinality": 1 },
{ "classes": ["Service","Product","Complex"], "cardinality": 1 }, // союз: РОВНО один из
{ "class": "Customer", "optional": true, "cardinality": 1 } ] // конец может отсутствовать// владелец (Мастер) — из пути; прочие концы — слотами (§6.1); id считает схема из (Staff, start)
await db.Мастер(m).запись().create({ start_datetime: t, end_datetime: e })
.Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
// союз занимает ОДИН ключ: услуга ИЛИ товар ИЛИ комплекс; лишняя связь → ошибка
// Клиент optional → ручная бронь без него, имя в notes:
await db.Мастер(m).запись().create({ start_datetime: t, end_datetime: e, notes: 'по телефону: Аня' })
.Локация.set(л).Расписание.set(р).Товар.set(тов).rows() // Клиент опущен — okLINK · record / мультивалюта — price (цена)
// attributes:
{ "id": { "type": "uuid", "generate": 5, "from": ["Service|Product|Complex", "note"] },
"note": { "type": "string", "default": "базовая" }, // необязателен → дефолт входит в id (§3.2)
"amounts": { "type": "record",
"key": { "type": "enum", "values": ["RUB","USD","EUR","AED"] },
"value": "number|integer|min:0" } }
// links: [{ "classes": ["Service","Product","Complex"], "cardinality": 1 }] ← владелец — элемент// у элемента несколько вариантов цены; note различает (id = uuidv5(элемент, note))
await db.Услуга(svc).цена().create({ amounts: { RUB: 1500, AED: 60 } }).rows() // note → 'базовая'
await db.Услуга(svc).цена().create({ note: 'с дизайном', amounts: { RUB: 2500 } }).rows()
await db.Услуга(svc).цена().sum('data.amounts.RUB') // агрегат по record-листу (§10.3): 4000Все 20 классов и типовой вызов
| Класс (id · alias) | cat | Что это | Типовой вызов |
|---|---|---|---|
| Entity · Сущность | HUB | abstract-корень, дефолт id v7 | — не создаётся |
| Org · Организация | HUB | арендатор, владелец каталога | db.Организация().create({ name }) |
| Person · Контрагент | HUB | abstract: name + phone | — |
| Staff · Мастер | HUB | бронируемый исполнитель | db.Организация(o).Мастер().create({ name, phone }) |
| Customer · Клиент | HUB | клиент | db.Организация(o).Клиент().create({ name, phone }) |
| Location · Локация | HUB | точка обслуживания | db.Организация(o).Локация().create({ address }) |
| Element · Элемент | HUB | abstract каталог, id v5(Org, name) | — |
| Service · Услуга | HUB | услуга (+ duration) | db.Организация(o).Услуга().create({ name, duration }) |
| Product · Товар | HUB | товар (+ sku, unit) | db.Организация(o).Товар().create({ name, sku }) |
| Complex · Комплекс | HUB | комплекс (+ cost, duration) | db.Организация(o).Комплекс().create({ name, cost, duration }) |
| Schedule · Расписание | HUB | месячное расписание | db.Организация(o).Расписание().create({ name, year, month }) |
| Folder · Папка | HUB | категория (вложенная) | db.Организация(o).Папка().create({ name }) · дети: db.Папка(f).Папка().create({ name }) |
| link · связь | LINK | abstract-корень связей, id v7 | — |
| address · адрес | LINK | Мастер работает в Локации | db.Мастер(m).адрес().create({ default: true }).Локация.set(l) |
| slot · окно | LINK | доступность Мастер × Локация × Расписание | db.Мастер(m).окно().create({ start_datetime, end_datetime }).Локация.set(l).Расписание.set(s) |
| booking · запись | LINK | бронь (наследник окна) | db.Мастер(m).запись().create({…}).Локация.set(l).Расписание.set(s).Услуга.set(u).Клиент.set(c) |
| content · содержимое | LINK | элемент в папке | db.Папка(f).содержимое().create({ order: 1 }).Услуга.set(u) |
| compo · состав | LINK | элемент в комплексе | db.Комплекс(k).состав().create({ quantity: 2 }).Услуга.set(u) |
| skill · навык | LINK | Мастер умеет Услугу | db.Мастер(m).навык().create({ level: 'expert' }).Услуга.set(u) |
| price · цена | LINK | вариант цены элемента | db.Услуга(u).цена().create({ amounts: { RUB: 1500 } }) |
Полные attributes / links / meta каждого класса — в lib/sql/seed.booking.sql
(по одному INSERT-у на класс, в порядке order).
4. Чтение: цепочки
Правила обхода
| Переход | Механика |
|---|---|
| HUB → LINK | reverse: строки LINK с links ⊃ {Hub: id} |
| LINK → HUB | forward: links->>'Hub' — для любого HUB-ключа строки |
| HUB → HUB (разные) | forward, если цель ∈ Schema.links текущего; иначе reverse; иначе «no path» |
| HUB → HUB (тот же класс) | reverse = дети (db.Папка(id).Папка()); родитель — row.links.Folder |
| LINK → LINK | ошибка |
| повтор LINK-класса | pivot: возврат к тому же узлу (ветвление к другому концу + дофильтр AND) |
Путь подчиняется переходам всегда — и в чтении, и в записи; недопустимый переход — ошибка синхронно при построении цепочки (не в терминале). К концу связки, до которого прямого HUB→HUB пути нет, идут через саму связку и pivot (повтор её имени):
// «записи клиента к, где услуга у и мастер вася» — путь через запись, pivot-возврат:
db.Клиент(к).запись().Услуга(у).запись().Мастер(вася).запись().rows()
// «умеет ли Ирина стрижку» — навык, дофильтр предметом, count путей:
db.Мастер(ирина).навык().Услуга(стрижка).навык().count() // 0 | 1Узел-переменная: entity()
const p = db.запись() // ленивый узел-паттерн
await db.Клиент(к).entity(p).Услуга(у).entity(p).Мастер().run() // та же p = тот же узел (явный pivot)
await db.entity(row).Услуга().first() // старт пути с готового RowТерминалы
const пути = await db.Мастер({ name:'Вася' }).alias('Исполнитель').навык().Услуга().run()
// [{ Исполнитель: Row, навык: Row, Услуга: Row }, …]| Вызов | Возврат |
|---|---|
| run() | Path[] — все узлы каждого варианта пути; ключ = имя шага / .alias(); повтор → имя_2 |
| rows() | Row[] — уникальные сущности последнего шага |
| first() | Row \| null |
| ids() | string[] |
| count() | число путей |
Цепочка — ленивый ПЛАН: SQL уходит только на терминале. Терминалы без аргументов —
выборка настраивается модификаторами (§ 5). Если в цепочке есть операции записи (§ 6),
терминал исполняет весь план одной транзакцией.
Row = { id, class, data, links, tags, account, owner, updated, $deleted? }.
5. Фильтры и модификаторы
Фильтр — аргумент шага
db.Услуга('uuid') // по id
db.Услуга(rowИлиAccount) // объект с id — возьмётся .id
db.Услуга(['id1','id2']) // по списку ([] → пусто)
db.Услуга({ name: 'Стрижка', duration: 60 }) // eq полей data → GIN-containment
db.Услуга({ id: 'uuid', duration: gte(30) }) // ключ id — тоже id-фильтр
db.Локация({ coordinates: { lat: gte(55) } }) // вложенные пути ЛЮБОЙ глубины (object) + каст по листуОператоры (18: + not, or)
import { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends,
has, hasAny, hasAll, exists, isNull, not, or } from 'letopis'| Оператор | Типы поля | SQL |
|---|---|---|
| скаляр (eq) | любые | data @> '{"f": v}' (GIN) |
| ne(v) | любые | IS DISTINCT FROM |
| gt/gte/lt/lte(v), between(a,b) | number, date, string | (data->>'f')::cast ⋛ $ |
| inList([…]) | любые | IN (…); [] → FALSE |
| like/ilike/starts/ends(s) | string | [I]LIKE |
| has(v)/hasAll([…])/hasAny([…]) | массивы data и колонка tags | @> / ?\| |
| exists(true/false) | любые | ключ есть/нет |
| isNull() | любые | null или отсутствует |
| not(op \| скаляр) | по внутреннему | NOT (…); not(скаляр) = ne |
| or(f1, f2, …) | фильтр целиком | db.Услуга(or({name:'Стрижка'}, {duration: lt(40)})) — дизъюнкция под-фильтров |
Касты по Schema.attributes: number→::numeric, date→::timestamptz, boolean→::boolean.
Поле вне схемы фильтруется как text (записать его нельзя — строгая валидация).
Даты хранятся ISO UTC ('…+03:00' → '…Z'); сравнения — операторами (between(t, t)
для «равно моменту»: скаляр-eq по дате — строковый jsonb-containment, он про
нормализованное значение). В демо-схеме массивов в data нет — has* живут на tags.
Модификаторы цепочки
db.окно().sort('data.start_datetime').limit(10).offset(20).rows() // выборка
db.запись().sort('updated', 'desc').limit(50).rows()
db.Клиент().tags('vip') // фильтр: tags ⊇ ['vip']
db.Клиент().tags(['vip','telegram']) // все перечисленные
db.Клиент().tags(hasAny(['vip','b2b'])) // хотя бы один
db.запись().account(accId) // фильтр по колонке account (uuid | Row)
db.Организация().owner(acc) // фильтр по owner
db.Мастер({…}).alias('Исполнитель') // ключ шага в путях| Модификатор | Область | В чтении | В записи |
|---|---|---|---|
| limit(n) / offset(n) / sort(field, dir?) | вся цепочка | LIMIT/OFFSET/ORDER BY | ограничивает набор целей update() |
| asOf(t) | вся цепочка | «как было на T» (§ 10.1) | — |
| after(cursor) | вся цепочка | keyset-пагинация, требует sort (§ 10.2) | — |
| deep(max?) | текущий шаг (self-hop) | рекурсивные дети, $depth (§ 10.4) | — |
| tags(v) | текущий шаг | фильтр по колонке | значение тегов при create() (string | string[]) |
| account(v) / owner(v) | текущий шаг | фильтр по колонке | значение при create() |
| .Класс.set(x) / .Класс.unset() | слот связи — после глагола записи | — | записать / снять конец связи БЕЗ участия в фильтре целей (§ 6.1) |
| alias(name) | текущий шаг | ключ в путях | — |
6. Запись: операции — звенья плана
.create(data?) / .update(data?) / .delete(opts?) / .anonymize(fields) — звенья
цепочки: операция применяется к шагу, к которому приклеена точкой, и возвращает цепочку
для продолжения. Сама по себе ничего не пишет — исполняет терминал
(rows()/first()/count()/run()/…), весь план одной транзакцией: отказ любого сегмента
(валидация, ACL) откатывает всё.
Глаголы не перепутать: create вставляет (или версионирует по известному id),
update правит найденное путём и никогда не создаёт. Старый set() разбит на них
в 0.16.0 (бросает подсказку) — мина «Класс() → INSERT, Класс({}) → UPDATE всех» мертва,
намерение всегда явное.
// одиночная запись: операция + терминал
const [вася] = await db.Организация(org).Мастер().create({ name: 'Вася', phone: '+7 900 …' }).rows()
// несколько операций в одной цепочке: продолжение — ОТ РЕЗУЛЬТАТА предыдущей
await db.Организация().tags('сеть').update({ active: true }) // новая версия всех сетевых орг
.Услуга().create({ name: 'Акция месяца', duration: 30 }) // INSERT услуги КАЖДОЙ (fan-out)
.rows() // → акционные услуги
// операция сразу после операции — к тем же сущностям (две версии подряд)
await db.запись(id).update({ notes: 'подтверждена' }).update({ notes: 'оплачена' }).rows()Анатомия
db.Ктx1(id).Ктx2(id).Класс( ФИЛЬТР ).глагол( DATA ).Хвост()… .rows()
└───── контекст ─────┘ └─────┘ └────┘ └─ продолжение ─┘ └─ терминал: исполняет план
каждый шаг = связь кого трогаем create|update|delete от записанных
(только update)- Контекст-шаги (шаги до операции): каждый резолвится в ровно одну сущность —
id-фильтром (без запроса) или уникальным фильтром (0 или >1 → ошибка). Дают:
linksприcreateи containment-фильтр целей приupdate/delete. - Режим выбирает глагол (фильтр-объект допустим только перед
update):
| Глагол | Шаг операции | Действие |
|---|---|---|
| create(data?) | Класс() — без фильтра | INSERT: id по правилу схемы (§ 3.2), links = контекст + слоты |
| | Класс(id) / data.id / вычисленный v5-id уже существует | новая версия (идемпотентный create, REST-PUT семантика); не существует → INSERT с этим id |
| | Класс({фильтр}) | ошибка ПОСТРОЕНИЯ create() takes no filter; create на pivot-шаге — тоже ошибка |
| update(data?) | Класс() ≡ Класс({}) | новая версия всех в границах контекста |
| | Класс(id) / Класс({поля}) / pivot | новая версия каждого найденного путём; не найдено → [] — update НИКОГДА не создаёт |
- Продолжение после операции — от её результата: класс-шаг = переход (create-цель
следующего сегмента получает связь на записанное; чтение — обычный hop). При
множественном результате следующий сегмент исполняется для каждой строки (fan-out).
После
deleteпродолжение идёт от строк класса цели. - DATA — поля сущности/связи; явный
id— здесь ({ id, … }) или шагомКласс(id); у v5-класса явный id запрещён — его считает схема (§ 3.2). Валидация строгая, всегда: поле внеSchema.attributes→ValidationError; default-ы схемы подставляются; id вdataне хранится (он — колонка). - Deep-merge при
update(и у create-версии по известному id): меняются только указанные листья —update({ coordinates: { lat: 55.8 } })сохранитlngи остальные поля. Массивы/скаляры заменяются целиком.linksдомерживаются по ключам. - Концы LINK: по
Schema.linksv2 (§3.1) — обязательные требуются, союз занимает один ключ, лишние связи — ошибка; значения — id, Row или вложенная цепочка (§6.1). - account/owner NOT NULL:
.account()/.owner()→connect()→ System-аккаунт. - Версии монотонны (
GREATEST(clock, prev+1µs)), коллизия 23505 ретраится. - Цепочка переиспользуема: повторный терминал = повторное исполнение плана.
6.1 Связи: путь и слоты .Класс.set() / .Класс.unset()
Один закон: владелец создаваемой связки берётся из валидного пути; прочие концы —
слотами .Класс.set(значение) после глагола записи. Слот — свойство-класс БЕЗ вызова
(.Клиент.set(x), не .Клиент(x)); валиден только для конца из Schema.links и
требует операцию записи ПЕРЕД собой: …create(…).Класс.set(x) / …update(…).Класс.unset();
слот без глагола — ошибка link slot needs a write.
// СОЗДАНИЕ: владелец (Мастер) — из пути, остальные концы — слотами; id считает схема (§ 3.2)
await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
.Локация.set(л).Расписание.set(р).Услуга.set(у).Клиент.set(к).rows()
// навык: владелец Мастер из пути, предмет — слотом
await db.Мастер(s).навык().create({ level: 'expert' }).Услуга.set(у).rows()
// ПЕРЕВЕС связи (update): цель ищется путём/pivot, слот пишет новое значение БЕЗ фильтра
await tr.Клиент(к).запись().Услуга(старая).запись().update().Услуга.set(новая).rows()
// СОЮЗ-конец [Услуга|Товар|Комплекс]: слот замещает конец целиком (соседний класс снимается)
await db.запись(b).update().Комплекс.set(k).rows() // была Услуга — снята, стал Комплекс
// СНЯТИЕ optional-конца: ручная бронь — клиент отвязан, имя в notes
await db.запись(b).update({ notes: 'по телефону: Аня' }).Клиент.unset().rows()
// вложенная цепочка как значение слота — та же транзакция, ровно одна сущность:
await db.Мастер(s).запись().create({ start_datetime: t, end_datetime: e })
.Локация.set(л).Расписание.set(р)
.Услуга.set(db.Организация(org).Услуга().create({ name: 'Новинка', duration: 45 })).rows() // создать И привязать.delete({ confirm }) — превью и серверное удаление
const превью = await db.Клиент(cid).delete().rows() // БЕЗ confirm — ПРЕВЬЮ
// кандидаты (цель + каскад: записи клиента), живые, БД не тронута:
// [{class:'Customer'}, {class:'booking'}]
const удалено = await db.Клиент(cid).delete({ confirm: true }).rows()
// удалённое дерево, каждый Row с $deleted: true
await db.Мастер(m).окно().delete({ confirm: true }).rows() // контекст: только окна мастераЦели = фильтр шага + контекст-связи. Замыкание (цели + все живые зависимые) собирается
одним CTE; confirm: true шлёт один SQL DELETE — триггер БД тумбстоунит дерево
(+advisory-lock). История неприкосновенна; повторный delete → []; create() с тем же
id — воскрешение.
.purge({ confirm }) — ФИЗИЧЕСКИЙ hard-erase (необратимо)
await db.Клиент(cid).delete({ confirm: true }).rows() // 1) мягко: tombstone (обратимо)
await db.Клиент(cid).purge().rows() // превью замыкания (что сотрётся), БД цела
await db.Клиент(cid).purge({ confirm: true }).rows() // 2) физически: tombstone + всё поддерево (все версии)Двухфазно: .purge() работает ТОЛЬКО по уже логически удалённому (tombstone) — живую сущность
не трогает (сначала .delete()). Историю, в отличие от .delete(), НЕ сохраняет. Внутри — серверная
purge(partition,class,id): SET LOCAL letopis.purge='on' отключает append-only-триггер (через WHEN)
→ плоский физический DELETE замыкания (детерминированно, без вложенного DML). Голый SQL:
SELECT * FROM "v1.notify".purge('entity','Клиент','…') (или …,true) — dry-превью).
.withDeleted() — включить удалённые (tombstone) в выдачу
await db.Клиент(cid).rows() // только живые
await db.Клиент(cid).withDeleted().rows() // + tombstone (последний шаг)Снимает только фильтр deleted IS NULL; изоляция арендатора (enforceAccount) и ACL (enforceAcl) действуют.
Откат плана
await db.Организация(org).update({ phone: '+7 495 …' }) // валидный сегмент…
.Услуга().create({ name: 'X', чепуха: 1 }) // …невалидный: ValidationError
.rows()
// ← ОТКАТ ВСЕГО: телефон не изменился, версий не прибавилось7. Транзакции
const bookingId = uuidv5(`v1.booking:entity:booking:${staffId}:${start}`) // id известен ДО создания (§ 3.2)
const tr = await db.begin() // тот же API на выделенном соединении
await tr.lock('booking', staffId, start) // advisory-xact-lock до конца транзакции
const занято = await tr.запись(bookingId).first()
if (!занято) await tr.Мастер(s).запись().create({ start_datetime: start, end_datetime: end })
.Локация.set(л).Расписание.set(р).Услуга.set(у).rows()
await db.commit(tr) // или db.rollback(tr) / tr.commit() / tr.rollback()Повторный commit/rollback — no-op. lock() вне транзакции — ошибка. Держите транзакции
короткими. Рецепт двойной брони: id записи детерминирован схемой (v5 — § 3.2), так
что дубль невозможен в принципе (второй create стал бы версией той же записи);
lock(мастер, старт) + перечитка first() под локом нужны, чтобы сопернику честно
ОТКАЗАТЬ, а не молча версионировать чужую бронь (ровно одна успешна — покрыто тестом-гонкой).
8. Батчи
db.batch('смены').Мастер(m).окно().create({ start_datetime, end_datetime }) // план встал в очередь
.Локация.set(loc).Расписание.set(sch)
db.batch('смены').Мастер(m2).окно().create({ … }).Локация.set(loc).Расписание.set(sch)
db.batch('смены').size() // 2
const res = await db.batch('смены').run() // одна транзакция; Row[][] по порядку
db.batch('смены').discard() // отменитьrun()атомарен: любая ошибка откатывает всё.- Операции в батч-цепочке кладут ПЛАН в очередь (многосегментные планы — одним элементом);
терминал на такой цепочке — ошибка
plan is queued in the batch — call batch.run(). - Подряд идущие чистые
create()одного класса (план из одного шага, безdata.idи слотов) склеиваются в один multi-VALUES; v5-классы — поштучно (id считается из концов/полей — § 3.2), семантика та же. - Read-вызовы на батч-фасаде исполняются сразу, мимо очереди.
9. Служебные таблицы (auth/ACL)
Обычные таблицы (UPDATE/DELETE стандартные), на db и tr. Имена
accounts / credentials / resources / rules зарезервированы.
const acc = await db.accounts.set({ categories: ['User'], data: { name: 'Вася' } })
await db.accounts.set({ id: acc.id, enabled: false })
await db.accounts.find({ category: 'User', enabled: true })
await db.accounts.delete(acc.id) // физический; RESTRICT при истории Entity
await db.credentials.set({ account: acc.id, category: 'APIKEY', identifier: 'k1' })
// upsert по UNIQUE (account, category, identifier); повторный set воскрешает
await db.credentials.find({ account: acc.id }) // deleted IS NULL по умолчанию
await db.credentials.delete(credId) // мягкое: deleted = now()
await db.resources.set({ alias: 'shop:API', category: 'API', pattern: { endpoint: 'booking.*' } })
await db.rules.set({ account: 'authenticated:ACCOUNT', resource: 'shop:API', permission: 'allow', weight: 90 })Связка с Entity: account/owner NOT NULL, default — System-аккаунт; фильтры и значения —
модификаторы .account()/.owner(); профиль владельца — db.accounts.get(row.owner).
Сид lib/sql/seed.auth.sql: System + 16 Resource + 12 Rule.
9.1 Вход: db.auth — много кредов на аккаунт
Слой над db.credentials: пароль (логин или почта), api-ключ, ключ-секрет, внешние
identity (oauth/sso/telegram). Однозначность входа гарантирует БД:
credential_identity_udx — один живой кред на (category, identifier) во всей схеме
(мягко удалённый identifier освобождается). Пароли — scrypt (node:crypto, зависимостей нет),
ключи и секреты хранятся только sha256-хэшем. Все verify* возвращают
{ account, credential } | null и проверяют три ворот: кред жив (deleted IS NULL),
подтверждён (confirmed, отключаемо requireConfirmed: false), аккаунт enabled.
const acc = await db.accounts.set({ categories: ['User'], data: { name: 'Вася' } })
// ПАРОЛЬ (почта/пароль и логин/пароль — одна механика, различает category)
await db.auth.setPassword({ account: acc, identifier: '[email protected]', password: 'корень-огня-77' })
// → Credential; в БД НЕ пароль, а слоёный хэш: [64.8 ms]
// meta.password = "scrypt$32768$8$1$EmyOQlletdEahwgoXendhw==$vGQB9LrwcDSIB0+…"
await db.auth.verifyPassword({ identifier: '[email protected]', password: 'корень-огня-77' })
// → { account: {id: '7277…', data: {name: 'Вася'}, enabled: true, …},
// credential: {category: 'PASSWORD', identifier: '[email protected]', …} } [71.8 ms]
await db.auth.verifyPassword({ identifier: '[email protected]', password: 'хм' }) // → null [82.8 ms]
// цена задана scrypt-ом (~70 ms) и выровнена: «нет такого identifier» не быстрее «пароль неверен»
// API-КЛЮЧ: показывается ОДИН раз, в БД — только sha256
const { key } = await db.auth.issueApiKey({ account: acc, name: 'CI' })
// key = "lts_a61118c4af31868640529131927eb578211730bcd092856c" [6.3 ms]
// в БД: identifier = "ae9002e2cd4c…" (sha256), meta = {name: 'CI', prefix: 'lts_a61118c4'}
await db.auth.verifyApiKey(key) // → { account, credential } [5.6 ms]
// КЛЮЧ-СЕКРЕТ: key — открытый id пары, secret — только sha256
const { key: k, secret } = await db.auth.issueKeySecret({ account: acc, name: 'integration' })
// k = "96d327b741421f74", secret = "38f69922c9748aa8…" (48 hex) [4.7 ms]
await db.auth.verifyKeySecret(k, secret) // → { account, credential } [3.4 ms]
// ВНЕШНЯЯ IDENTITY (oauth/sso/telegram): токен проверяет приложение, тут — связка
await db.auth.link({ account: acc, category: 'TELEGRAM', identifier: '1635246915', meta: { username: 'roboteza' } })
await db.auth.lookup({ category: 'TELEGRAM', identifier: '1635246915' })
// → { account, credential } [2.6 ms]
// TOTP (authenticator, RFC 6238): секрет в QR, активен после первой проверки
const { secret, uri } = await db.auth.enrollTotp({ account: acc, issuer: 'clockz', label: '[email protected]' })
// secret = "RWVB3WWQ62LEED55AMU6L425C6KIT5EJ" (base32, 20 байт) [5.6 ms]
// uri = "otpauth://totp/anna%40salon.io?secret=…&issuer=clockz&algorithm=SHA1&digits=6&period=30"
await db.auth.verifyTotp({ account: acc, code: '139999' }) // → true [4.9 ms]
await db.auth.verifyTotp({ account: acc, code: '139999' }) // → false — replay того же шага отбит
await db.auth.totpEnabled(acc) // → true (после первой проверки)
// ОДНОРАЗОВЫЙ КОД (email/SMS/reset — доставка на приложении)
const { code } = await db.auth.issueOtp({ account: acc, identifier: '[email protected]', ttlSec: 600 })
// code = "273746"; в БД sha256 + expires + attempts [3.3 ms]
await db.auth.verifyOtp({ identifier: '[email protected]', code })
// → { account, credential }; код СОЖЖЁН (одноразовость) [6.0 ms]; повторно → null
// 5 неверных попыток тоже сжигают; повторный issue перезаписывает код (валиден последний)Хелпер totpCode(secret, atMs?) (экспорт) — код authenticator-а для секрета: тесты,
серверная генерация.
Сессии — во внешнем Redis (клиент инжектируется, в зависимости не входит; ioredis
подходит как есть — интерфейс SessionStore):
import Redis from 'ioredis'
const s = db.auth.sessions(new Redis('redis://localhost:16379'))
const token = await s.start(acc, { ttlSec: 3600, meta: { device: 'iphone' } })
// → "843a351714b3a9e43447ad0cfa2a88f8d2d166cd1642b49472335e7a13291585" [2.4 ms]
await s.check(token)
// → { account: '7277…', meta: {device: 'iphone'}, created: '2026-07-10T18:43:12.815Z' } [0.4 ms]
// в Redis лежит sha256(token), не сам токен (+ set-индекс аккаунта для revokeAll):
// sess:d3fa766175d67e10… sess:acc:72778374-3857-…
await s.revoke(token) // → true; повторно → false
await s.revokeAll(acc) // → сколько сессий погаслоГраницы (осознанно на приложении): OAuth-танец/проверка внешних токенов, rate-limit,
lockout после N неудач. Legacy-bcrypt-хэши ($2b$… из дампа) библиотека не верифицирует —
пароль задаётся заново через setPassword.
9.2 ACL: db.acl + enforceAcl — Resource/Rule в работе
Resource — именованное множество, pattern — шаблон «своей» сущности. Rule — стрелка
«группа → группа» (allow|deny + weight); правила никогда не указывают на конкретный
аккаунт — принадлежность вычисляется из Account.categories на лету.
| category | pattern — шаблон чего | пример |
|---|---|---|
| ACCOUNT | аккаунта | {"categories": "{Staff}"}; "!{Anonymous,Shadow}" — нет ни одной; NULL — все |
| API | адреса эндпоинта | {"endpoint": "v2.auth.apikey.*"} — маска: .-сегменты, {a,b}, * — хвост |
| READ / WRITE / DELETE | строки Entity (операция = категория) | {"class": "booking", "owner": "$account"} |
Pattern операций — реальные колонки Entity (class, owner, account, tags, data,
links…), значения — литералы или "$account" (id субъекта, подставляется в запрос);
class — маска с наследованием (право на Vehicle действует на SportsCar).
Резолюция: субъекты аккаунта × объекты цели → правила → побеждает ровно одно
(max weight; при равенстве deny); нет правил — deny. Остальные ключи победившего
allow — предикат строк, вливается в SQL до сортировки и лимита: пагинация, count(),
keyset честные, постфильтрации нет.
await db.acl.check(anon, 'v2.auth.password.signup')
// → { allow: true, rule: {account: 'any:ACCOUNT', resource: 'auth.anonymous:API', weight: 105} } [2.0 ms]
await db.acl.check(anon, 'v2.auth.apikey.create')
// → { allow: false, code: 403, message: 'Access denied - default …' } — победило дно-правило deny 0
await db.acl.checkData(user, 'booking', 'READ') // ресурс {"class":"booking","owner":"$account"}
// → { allow: true, filter: { owner: '24c49a43-…' }, rule: {…weight: 60} } [1.7 ms]
await db.acl.checkData(user, 'SportsCar', 'READ') // право дал предок Vehicle (lineage)
// → { allow: true } — безусловный, без предиката
await db.acl.checkData(user, 'Org', 'READ')
// → { allow: false, message: 'no matching rule (deny by default)' }
db.acl.reload() // сброс кэша Resource/Rule фасадаconnect({ account, enforceAcl: true }) — те же решения в цепочках: каждый шаг —
READ, create()/update()/anonymize/батчи — WRITE, delete() — DELETE по всем классам каскада
(deny в замыкании откатывает транзакцию); watch() отдаёт события только безусловных
allow-классов (payload нечем проверить предикат). Правила фиксируются на connect.
const u = await connect({ dsn, schema, account: user.id, enforceAcl: true })
await u.запись().rows() // [7.6 ms] только owner = user.id — предикат в WHERE заранее
await u.запись().count() // честный count по суженному множеству
await u.Организация().rows() // Error: letopis: acl denies READ on Org — no matching rule
const [z] = await u.Мастер(m).запись().create({…}).rows() // owner пришпилен правилом → user.id
await u.запись().owner(other).update({…}).rows() // Error: acl pins booking writes to owner …
await u.запись(чужаяId).update({ notes: '…' }).rows() // → [] — цель вне предиката не находится
// create той же v5-пары (id чужой записи) тоже НЕ перехватывает: → [] вместо новой версииОверхед (полигон v1.salondemo ~980k, booking 440k×2; p50 из 20; bench/acl.bench.mjs):
| Сцена | без ACL | allow (класс целиком) | allow с предикатом* |
|---|---|---|---|
| точечный first(id) | 2.9 ms | 4.2 ms | 6.1 ms |
| фильтр rows limit 100 | 1394 ms | 1454 ms | 1362 ms |
| keyset-страница всего класса 440k | 5495 ms | 5576 ms | 5364 ms |
| count() класса 440k | 3196 ms | 3049 ms | 2874 ms |
| цепочка запись(id).Услуга() | 5.5 ms | 6.6 ms | 5.1 ms |
Безусловное правило — бесплатно (решение из кэша, SQL тот же). *Предикат здесь на
селективности 100% (все строки booking — System-аккаунт, предикат никого не отсекает) —
на тяжёлых сценах ACL в пределах шума с базой; в реальности предикат СУЖАЕТ выборку,
и такие сцены становятся ДЕШЕВЛЕ, чем без ACL.
db.acl.checkData — справочный (1.0 ms: SQL за категориями аккаунта на каждый вызов);
горячий путь цепочек использует резолвер, скомпилированный на connect (микросекунды, memo).
enforceAccount (§ 10.6) остаётся простым флагом-изоляцией без таблиц правил.
10. Срез актуальных данных и история
await db.Услуга().rows() // срез класса (актуальное, живое)
await db.запись().sort('updated','desc').limit(50).rows()
await db.Клиент(cid).запись().Услуга().run() // связный подграф-- вся партиция одним SQL
SELECT * FROM (SELECT DISTINCT ON (class, id) * FROM "v1.booking"."Entity"
WHERE partition='entity' ORDER BY class, id, updated DESC) t WHERE t.deleted IS NULL;
-- история сущности (все версии, включая tombstone)
SELECT updated, deleted, data, links FROM "v1.booking"."Entity"
WHERE partition='entity' AND class='booking' AND id=$1 ORDER BY updated;10.1 Время-путешествия: .asOf() / .versions()
// «какая цена была на момент брони» — версии позже T невидимы, tombstone до T = «удалён»
const тогда = await db.Услуга(id).asOf('2026-07-01T12:00:00Z').first()
const срезДня = await db.запись().asOf(вчера).count() // работает со ВСЕМИ терминалами
// вся история сущности без сырого SQL (tombstone-версии приходят с $deleted: true)
const история = await db.запись(id).versions()
// [{data:{…}}, {data:{…, notes:'подтверждена'}}, {…, $deleted:true}]asOf применяется к каждому шагу цепочки — подграф целиком «как был». versions()
работает и после удаления сущности (у последнего шага tombstone не отфильтровывается).
10.2 Пагинация курсором: .after() + cursorOf()
Offset заставляет БД построить и выбросить всё пропущенное (и «съезжает» при вставках).
Keyset — закладка: курсор = значение поля сортировки + id последней строки страницы;
следующая страница — «строго после закладки» (WHERE (поле, id) > (v, id) — пара делает
порядок тотальным, дубли значений не теряются и не повторяются).
const стр1 = await db.запись().sort('updated', 'desc').limit(50).rows()
const кур = cursorOf(стр1.at(-1)!) // { v: '<updated>', id: '…' } — просто объект,
const стр2 = await db.запись().sort('updated', 'desc') // можно хранить в URL/state
.after(кур).limit(50).rows()
// по data-пути ЛЮБОЙ глубины — field тот же, что в sort
const дальше = await db.окно().sort('data.start_datetime')
.after(cursorOf(окно, 'data.start_datetime')).limit(20).rows()
// бесконечная лента: .after(undefined) не добавляет условия — один код для всех страниц
let cursor
do {
const page = await db.Мастер(m).запись().sort('updated', 'desc')
.after(cursor).limit(50).rows()
render(page)
cursor = page.length ? cursorOf(page.at(-1)!) : undefined
} while (cursor).after() требует .sort() (иначе ошибка); поле курсора — то же, что в sort.
Произвольная страница № N (курсор знает только «после X»): гибрид — прыжок offset-ом один
раз, дальше курсором; или прыжок по значению — курсор собирается руками:
.after({ v: 1300, id: '' }) → «к ценам ниже 1300», .after({ v: '2026-03-01', id: '' }) →
«к записям до марта» — без чтения промежуточных страниц.
10.3 Агрегации: считает БД
await db.цена().sum('data.amounts.RUB') // сумма всех прайсов (record-лист): number | null
await db.Услуга().avg('data.duration') // среднее
await db.Услуга().countBy('data.duration') // { '30': 2, '60': 1 } ({} на пустом)
await db.окно().min('data.start_datetime') // min/max — каст по типу поля из SchemaОдин проход в БД вместо перекачки строк в JS. Путь — 'data.<поле>' или вложенный лист
('data.coordinates.lat'). sum/avg кастуются в numeric; пустое множество → null.
10.4 Деревья: .deep()
const всё = await db.Папка(root).Папка().deep().rows() // ВСЕ вложенные папки (default ≤ 32)
// Row.$depth: 1 = прямой ребёнок, 2 = внук…
const дваУровня = await db.Папка(root).Папка().deep(2).rows()Один recursive-CTE-запрос вместо запроса на уровень. Только self-hop (тот же класс,
reverse = дети); на другом переходе — ошибка. Работает с asOf.
10.5 Realtime: db.watch()
const stop = await db.watch('запись', (e) => {
// e = { partition, class, id, updated, deleted } — факт версии (insert/update/tombstone)
обновитьКалендарь(e.id)
})
const stopAll = await db.watch((e) => log(e)) // все классы
await stop() // отпискаТриггер entity_notify шлёт лёгкий pg_notify (канал = имя PG-схемы) на каждую
вставленную версию; данные подписчик дочитывает обычной цепочкой. Основа живых
интерфейсов без поллинга.
Обрывы LISTEN-соединение переживает само (postgres.js переподключается с backoff и
повторяет LISTEN), но NOTIFY за время разрыва потеряны — для этого onReconnect:
const stop = await db.watch('запись', onEvent, {
onReconnect: () => дочитатьПропущенное(), // напр. перечитать всё с последнего e.updated
})10.6 Изоляция арендатора: enforceAccount
const db = await connect({ dsn, schema, account: tenantId, enforceAccount: true })
await db.запись().rows() // ТОЛЬКО строки этого account (фильтр на каждом шаге)
await db.Клиент().create({ name: 'X', phone: '+7…' }) // запись пришпилена к account
db.запись().account(чужой).rows() // ошибка: reads are pinned to account …Изоляцию гарантирует либа, а не дисциплина: забытый .account() в одном запросе
больше не утечка данных соседнего салона. Это простой флаг «всё по одному арендатору»;
гранулярные права (по классам, операциям, со срезами строк правилами) — ACL § 9.2.
10.7 Анонимизация (GDPR): .anonymize()
await db.Клиент(id).anonymize(['name', 'phone']).rows()
// новая версия: string-поля = '[erased]', тег 'anonymized'; остальные поля целыФизического стирания anonymize() НЕ делает — история священна: старые версии хранят PII до
retention-политики (§ 10.8). Три уровня «права на забвение»: anonymize() (затереть PII, запись
живёт) → retention (снос по времени) → .purge({ confirm }) — немедленный физический hard-erase
удалённого (tombstone) + всего поддерева, а db.accounts.purge(id) — целого тенанта (см. § 11).
Не-string поле в списке anonymize — ошибка (типы сверяются по Schema).
10.8 Политики хранения: db/policies.mjs
db/policies.mjsживёт в репозитории (корневойdb/), в npm-пакет НЕ входит — запускать из клона. Логика — чистый Timescale SQL (add_compression_policy/add_retention_policy), при желании вызывается напрямую тем же DSN.
node db/policies.mjs --dsn=… --schema=v1.booking --compress-after=30d # сжатие (история цела)
node db/policies.mjs --dsn=… --schema=v1.booking --retain=2y # + retention (drop навсегда!)
node db/policies.mjs --dsn=… --schema=v1.booking # текущее состояние- Сжатие — чанки старше N:
segmentby (partition, class, id)кладёт версии сущности рядом (~10–20× экономии); чтение прозрачно (rows/versions/asOf— покрыто тестом); новые версии идут в свежие несжатые чанки — идеально ложится на append-only. - Retention — осознанный рубильник: чанки старше N УДАЛЯЮТСЯ насовсем (здесь наступает «забвение» из § 10.7). По умолчанию ВЫКЛ.
- Интервалы:
30d,2y,12h,6monили сырой PG ('90 days'). Повторный запуск переустанавливает политику.
10.9 Миграции классов: scripts/schema-sync.mjs
Входит в npm-пакет (
files: ["scripts"]); в установке путь —node_modules/letopis/scripts/schema-sync.mjs. Зависимости —postgresиfastest-validator(обе — deps пакета),tsxне нужен.
node scripts/schema-sync.mjs --file=my-schema.json --dsn=… --schema=v1.booking
# schema-sync: … ↔ booking.Schema (partition entity)
# + Coupon (HUB · Купон) — новый класс
# ~ Service — изменены: attributes
# ! Service: 3/50 живых строк НЕ пройдут новую валидацию:
# id=… → brand — The 'brand' field is required.
# ИТОГ: ломающие изменения (3 строк) … exit 1
node scripts/schema-sync.mjs --file=… --dsn=… --schema=v1.booking --apply # применитьDiff файла и таблицы Schema (новые/изменённые/отсутствующие классы) + отчёт совместимости:
живые latest-строки изменённых классов прогоняются НОВОЙ строгой валидацией до применения.
Без --apply — только отчёт (exit 1 при breaking — удобно в CI). Отсутствующие в файле
классы не удаляются. ancestors/descendants пересчитает триггер schema_lineage.
10.10 Наблюдаемость: onQuery / slowMs
const db = await connect({
dsn, schema,
onQuery: (e) => metrics.observe(e), // {mode:'rows', classes:['Staff','skill'], ms:4.2, rows:7, slow:false}
slowMs: 200, // ms > 200 → slow: true
})
// slowMs БЕЗ onQuery: console.warn('letopis: slow query 312ms — rows Staff→skill')Событие на каждый SQL цепочек: чтения (paths/rows/ids/count/versions/agg) и записи
(insert/delete). Служебные запросы (реестр, auth-таблицы, listen) не шумят.
10.11 TS-типы из Schema: scripts/gen-types.mjs
Входит в npm-пакет; чистый Node-ESM (только
postgres),tsxне нужен — в установкеnode node_modules/letopis/scripts/gen-types.mjs ….
npx tsx scripts/gen-types.mjs --dsn=… --schema=v1.booking --out=entity-types.d.tsimport type { TypedDb } from './entity-types'
const t = db as unknown as TypedDb
const [svc] = await t.Услуга({ duration: 60 }).rows() // svc.data.duration: numberИнтерфейсы data-полей всех классов (enum → union-литералы, record → Partial<Record<…>>)
- фасад
TypedDb. Ядро остаётся динамическим — типы это надстройка.
10.12 Устойчивость к сбоям
Транзиентные ошибки PG — deadlock (40P01) и serialization failure (40001) —
библиотека гасит сама там, где повтор безопасен:
- чтения в автокоммите — до 3 попыток с backoff (40–160 ms);
insertOne— ретрай гонкиupdated(23505, мгновенно) и transient (с backoff);- внутренние транзакции (
.delete()-каскад, батчи) — повтор ВСЕЙ транзакции: откат полный, побочек нет, повтор честен.
Внутри db.begin() повтор невозможен семантикой PG (вся транзакция aborted) — ошибка
уходит наружу немедленно, с дописанной подсказкой:
deadlock detected — letopis: transaction is aborted, retry the whole db.begin() blockРетраить весь db.begin()-блок — ответственность приложения (то
