npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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)

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 операторов фильтров (ne gt between inList like has exists not or…), вложенные пути, касты по схеме (§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), GIN tags, btree account/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 + amounts record<валюта,число>)
  • Источник правды демо-домена — сид 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 (напр. цена без notenote:'базовая', 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()

Поле вне attributesValidationError (валидация строгая, $$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()   // Клиент опущен — ok

LINK · 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.attributesValidationError; default-ы схемы подставляются; id в data не хранится (он — колонка).
  • Deep-merge при update (и у create-версии по известному id): меняются только указанные листья — update({ coordinates: { lat: 55.8 } }) сохранит lng и остальные поля. Массивы/скаляры заменяются целиком. links домерживаются по ключам.
  • Концы LINK: по Schema.links v2 (§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.ts
import 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()-блок — ответственность приложения (то