letopis 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,260 @@
2
2
 
3
3
  Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
4
4
 
5
+ ## [0.20.0] — 2026-07-29
6
+
7
+ ### Fixed — `ANALYZE` при накате схемы: чтения были медленнее на порядок
8
+
9
+ `up()` и `db/apply.mjs` теперь вызывают `ANALYZE "<схема>"."Entity"` после наката DDL и сидов.
10
+
11
+ `Entity` — гипертаблица: строки лежат в чанках, у родительской таблицы своих строк нет. Пока
12
+ `ANALYZE` по ней не прошёл, планировщик берёт дефолт «200 уникальных `id`». Ядро всех чтений
13
+ либы — semi-join с подзапросом кандидатов (`e.id IN (SELECT c.id FROM Entity c WHERE …)`), и на
14
+ такой оценке он выбирает `Nested Loop` вместо `Merge Semi Join`, с уходом хеша и сортировки на
15
+ диск. Замерено на полигоне 980k строк:
16
+
17
+ | Сцена | без статистики | после `ANALYZE` | |
18
+ |---|---|---|---|
19
+ | `count()` класса под изоляцией арендатора | 57.6 s | 3.2 s | ×18 |
20
+ | фильтр `rows` limit 100 по 440k | 1371 ms | 11 ms | ×126 |
21
+ | `count()` класса без фильтра | 2981 ms | 1373 ms | ×2.2 |
22
+
23
+ Тайминги README §14 и §9.2 перемерены на проанализированной базе. Своя массовая вставка
24
+ (`COPY`, миграция, генератор) требует `ANALYZE` вручную — это отдельная грабля в
25
+ `AGENT-CHEATSHEET.md` (#17) и новая секция README §14.1.
26
+
27
+ Заодно вычищен доковый дрейф в §14: строка «обход `запись→Мастер` по всему классу» показывала
28
+ ≈6 с с версии 0.17.0, где полигон был 250 000 записей / 613 тыс. строк, и переезд на
29
+ 440 006 / 980 328 её не обновил — настоящее значение ≈30 с. Стоимость этой сцены = `строки
30
+ внешнего шага × чанки` (одна проба — 0.924 мс / 80 буферов при 13 чанках), потому что
31
+ forward-hop идёт коррелированным `JOIN LATERAL` с пробой по всем чанкам. Первое объяснение
32
+ в §14 («планировщик недооценивает внешнюю сторону и не ставит `Memoize`») **снято как
33
+ неверное**: подстановка класса литералом и `SET enable_nestloop = off` план не меняют, дело в
34
+ самой форме — корреляция внутри подзапроса с `DISTINCT ON` не раскоррелируется и не
35
+ мемоизируется. Некоррелированный join измерен и хуже в 18 раз (561 с), так что форма
36
+ оставлена как есть; лечится стороной старта цепочки — `db.запись(id).Мастер()` 7.5 мс и
37
+ `db.Мастер(id).запись()` 11.7 мс против 30 с у обхода всего класса.
38
+
39
+ Попутно исправлен `bench/acl.bench.mjs`: его колонки были несравнимы — `base` шёл без изоляции
40
+ арендатора, а ACL-хендлы с ней, из-за чего бенч приписывал авторизации до 18× чужой цены.
41
+ Теперь все три режима с `enforceAccount: false`, и видно настоящее: **ACL бесплатен на всех
42
+ сценах** (отличия в пределах шума). Цена изоляции измеряется отдельно и зависит от доли
43
+ арендатора в классе — на 10-арендаторном полигоне она ускоряет чтение до 27× (README §10.6).
44
+
45
+ ### BREAKING — арендатор стал свойством ВЫЗОВА: `db.as(account)`, опции `connect({ account })` больше нет
46
+
47
+ Из `ConnectOpts` **удалены `account` и `owner`**. Аккаунт на подключении — это модель
48
+ «одно соединение = один тенант», а для веб-приложения она неверна: пул общий, арендатор
49
+ меняется от запроса к запросу. Теперь подключение безличное, а идентичность даёт хендл:
50
+
51
+ ```ts
52
+ const db = await connect({ dsn, schema }) // было: connect({ …, account: X })
53
+ const t = await db.as(accountId) // стало: имя вызова, хендл того же пула
54
+ await t.Клиент().rows()
55
+ ```
56
+
57
+ `db.as(account, { owner? })` клонирует контекст на **том же пуле** (нового соединения не
58
+ открывает, SQL не шлёт), поэтому его нормально создавать на каждый HTTP-запрос. `close()`
59
+ по-прежнему один на пул. Батчи с корня в scope не наследуются: очередь планов, общая для
60
+ разных арендаторов, была бы утечкой записи.
61
+
62
+ Под `enforceAcl` **правила компилятся под субъекта scope** — у каждого `db.as()` свой
63
+ энфорсер. Это не оптимизация, а требование корректности: ключ memo в `acl.ts` — «класс +
64
+ операция», без аккаунта, так что переиспользование одного энфорсера двумя арендаторами
65
+ отдало бы второму решения первого. Закреплено тестом (`acl.test.ts`, «memo энфорсера не
66
+ течёт между scope одного пула»); проверено негативно — с общим энфорсером тест падает.
67
+
68
+ `enforceAccount` — **`true`** по умолчанию: чтения фильтруются по `account` scope-хендла,
69
+ записи к нему пришпилены. Забыть флаг было слишком легко, а цена забывчивости — чужие
70
+ строки в выдаче без всякой ошибки. Теперь и забыть `db.as()` нельзя: с безличного корня
71
+ чтение/запись бросают
72
+
73
+ ```
74
+ letopis: enforceAccount is on — call db.as(account) to name the tenant of this call,
75
+ or connect({ enforceAccount: false }) for admin access
76
+ ```
77
+
78
+ Админским и сервисным подключениям (миграции, отчёты по всем арендаторам, дев-скрипты)
79
+ нужен явный `connect({ …, enforceAccount: false })` — там колонку `account` закрывает
80
+ System-аккаунт схемы, как раньше.
81
+
82
+ Смысл модификатора `.account()` не изменился: это точечный фильтр/значение одного шага;
83
+ на scope-хендле чужое значение — ошибка `pinned`.
84
+
85
+ `enforceAcl` остаётся **`false`** (opt-in): включённый по умолчанию deny-by-default без
86
+ настроенных `Resource`/`Rule` давал бы пустые выборки. Вместо смены дефолта `connect()`
87
+ печатает предупреждение один раз на процесс: авторизация не проверяется — это должно быть
88
+ шумно, а не молча.
89
+
90
+ **Миграция.** `connect({ …, account: X })` → `(await connect({ … })).as(X)`;
91
+ `connect({ …, account: X, owner: Y })` → `.as(X, { owner: Y })`; подключения, которые
92
+ работали без `account` и должны видеть всё, — `connect({ …, enforceAccount: false })`.
93
+ `accounts.purge(id)` требует scoped-хендла: `(await db.as(caller)).accounts.purge(id)`.
94
+
95
+ ### BREAKING — снят охранник `.link()`
96
+
97
+ `case 'link'` в цепочке удалён. Он затенял **реальный класс схемы**: в демо-домене `link`
98
+ (алиас `связь`) — абстрактный корень всех связок, и `db.Клиент(c).link()` падал
99
+ migration-ошибкой вместо обхода, хотя `db.link()` с корня работал. Теперь имя `link`
100
+ резолвится как класс на всех уровнях; зарезервированных имён 52 (`link` ушло, `as` пришло).
101
+ Совместимость со снесённым в 0.15.0 методом `.link()` принесена в жертву достижимости
102
+ класса — при обращении к нему теперь будет обычная ошибка пути, а не подсказка миграции.
103
+ Остальные migration-охранники (`execute()`, `set(data)`, слот `.delete()`,
104
+ `batch.execute()`) не тронуты: они не затеняют классов.
105
+
106
+ ### Added — тест паритета клиент ↔ триггер БД
107
+
108
+ `test/parity.test.ts`: правило концов связей реализовано дважды — в plpgsql
109
+ (`entity_check`) и в TypeScript (`write.validate`), и до сих пор ничто не проверяло, что
110
+ реализации не разошлись. Теперь каждый кейс исполняется двумя путями (через либу и голым
111
+ `INSERT` в `Entity`), и утверждается одинаковое решение: abstract-класс, неизвестный
112
+ класс, отсутствующий обязательный конец, посторонняя связь, ключ `links` не-класс,
113
+ союз ролей, отсутствие optional-конца. Тест не привязан к схеме потребителя — проверяется
114
+ само правило, одно для всех схем.
115
+
116
+ Отдельным кейсом закреплена **известная асимметрия**: валидация данных по `attributes`
117
+ живёт только в либе (fastest-validator нельзя запустить в plpgsql), поэтому голый SQL
118
+ структурный мусор в `data` принимает. Если это изменится — тест упадёт и потребует
119
+ поправить README §2.
120
+
121
+ ### BREAKING — чтение стало полиморфным по иерархии классов
122
+
123
+ Шаг цепочки по родительскому классу теперь отдаёт **объединение с потомками любой глубины**
124
+ (как и права ACL наследуются по иерархии). Раньше фильтр шёл по точной колонке `class`,
125
+ поэтому abstract-классы молча возвращали пусто, а родитель не видел потомков:
126
+
127
+ - `db.Контрагент()` (`Person`, abstract): было `0` → стало `Мастер` + `Клиент`;
128
+ - `db.связь()` (`link`, abstract): было `0` → стало все классы-связки;
129
+ - `db.окно()` (`slot`, конкретный, потомок `booking`): в демо-полигоне было `54 005` →
130
+ стало `494 008`.
131
+
132
+ **Что нужно проверить в своём коде.** Если наследование у вас — переиспользование
133
+ `attributes`, а не «is-a» для выборки, добавьте **`.exact()`** на шаг: он возвращает
134
+ одиночный класс. Ровно этот случай в демо-домене — `запись` наследует `окно` (интервал и
135
+ правило v5-id), но смена ≠ бронь, поэтому «смены мастера» теперь
136
+ `db.Мастер(id).окно().exact()`. На классе без потомков `.exact()` — no-op.
137
+
138
+ - **Идентичность сущности в полиморфной выборке — пара `(class, id)`**: `DISTINCT ON`,
139
+ подзапрос кандидатов и join истории в `versions()` переведены на неё; `Row.class`
140
+ показывает конкретный класс строки.
141
+ - **Ключи `Entity.links` конкретны**, поэтому обходы (forward/reverse/`deep`) берут ключ
142
+ из колонки `class` строки, а не из литерала родителя.
143
+ - **ACL решается по конкретному классу каждой строки.** `deny` на потомке больше не
144
+ обходится шагом по родителю: запрещённые классы выпадают из выборки, у каждого
145
+ разрешённого действует его собственный row-предикат; отказ шага целиком — только если
146
+ запрещено всё семейство. Покрыто `test/acl.test.ts` (2 новых теста).
147
+ - **Запись полиморфной не бывает**: `create()` пишет строго в свой класс (в abstract —
148
+ `class "X" is abstract`); `update()`/`delete()`/`purge()` работают со строками в их
149
+ собственных классах.
150
+ - `exact` добавлено в зарезервированные имена классов (52).
151
+
152
+ Ревизия документации на пригодность для ИИ-агента: устранён дрейф между доками и кодом,
153
+ добавлен агентский слой и автопроверки, чтобы дрейф не возвращался.
154
+
155
+ ### Fixed — кодоген `scripts/gen-types.mjs` выдавал мёртвый API
156
+
157
+ - Фасад `TypedChain` содержал **`execute()`** (снесён в 0.11.0) и **старый глагол записи
158
+ `set(data)`** (снесён в 0.16.0) — оба бросают в рантайме; не было `run`/`create`/`update`/
159
+ `purge`/`withDeleted`/`deep`/агрегаций/слотов. Индекс-сигнатура `[className: string]: unknown`
160
+ глушила любую ошибку компилятора. Слот-сеттер `.Класс.set(target)` — жив, он ни при чём.
161
+ - `attributes` **не наследовались** по `ancestors`: `StaffData` шёл без `name`/`phone`
162
+ от `Person`, т.е. сгенерированные типы отвергали код из README §15.
163
+ - Теперь: полная поверхность `ChainCore`, шаги по классам типизированы (опечатка в имени
164
+ класса — ошибка компиляции), слоты выведены из `Schema.links` (`unset()` только у optional),
165
+ два фасада — `TypedDb` (строгий) и `TypedFullDb` (+ `begin`/`batch`/`auth`/`acl`/таблицы).
166
+ - Гвард: `test/wave2.test.ts` проверяет форму фасада, отсутствие снесённых имён и
167
+ **компилирует** сгенерированный `.d.ts` (валидный код проходит, `set()`/`execute()`/
168
+ опечатки — ошибка `tsc`).
169
+
170
+ ### Added — зарезервированные имена классов
171
+
172
+ - **52 имени** разбирается Proxy до резолва класса, причём в двух формах: `case`-метки
173
+ switch'ей (`count`, `sort`, `account`, `as`, `set`, `size`, …) **и** ранние `if (prop === …)`
174
+ — гашение `then` во всех трёх Proxy и фасады корня `db` (`schema`, `auth`, `acl`, `accounts`,
175
+ `credentials`, `resources`, `rules`). Класс с таким `id`/`alias` недостижим как шаг под этим
176
+ именем; раньше это нигде не было записано, хотя коллизия живёт в самом демо-сиде (класс `link`).
177
+ - `RESERVED_CLASS_NAMES` / `reservedNamesOf()` в `types.ts`; `db.schema.define()` **отказывает**
178
+ на таком имени; `loadRegistry` предупреждает (один раз на схему+класс за процесс, не throw —
179
+ иначе демо-сид ронял бы `up()`); `gen-types` не выдаёт таких шагов; таблица в README §3.
180
+
181
+ ### Added — агентский слой и автопроверки
182
+
183
+ - **`scripts/check-docs.mjs`** (`npm run check:docs`, шаг в CI после typecheck) — 8 проверок:
184
+ версия ↔ CHANGELOG, мёртвые ссылки, список тестов в README §16, счётчики классов в сидах,
185
+ codescribe-целостность (парность маркеров, `M-*` ↔ файл, `M-*` → `V-M-*`), актуальность
186
+ `docs/api-contract.json`, зарезервированные имена (код ↔ константа ↔ README), синтаксис и
187
+ импорты `bench/*.mjs`. БД не нужна.
188
+ - **`scripts/gen-api-contract.mjs`** → `docs/api-contract.json`: машиночитаемый контракт
189
+ (экспорты, поверхность цепочки, снесённые имена с версией и заменой, каталог ошибок,
190
+ зарезервированные имена). Генерируется из исходников, `--check` падает при устаревании.
191
+ - Корневой `README.md`, `AGENT-CHEATSHEET.md` (рабочий цикл + 18 граблей), `LICENSE` (MIT),
192
+ `engines: node >= 20`, таблица статусов корневых доков в `AGENTS.md`.
193
+ - Шпаргалка проверена двумя приёмочными прогонами «агент видит только её»: первый дал
194
+ 3 падения рантайма и 4 пробела, после правок второй прошёл сценарий с первого раза без
195
+ ошибок и догадок. Закрыто по итогам: шаблон сида с маркером `<SCHEMA-NAME>` и точными
196
+ колонками (`Account.categories text[]`, а не `category` — было написано неверно), форма
197
+ `Row` (атрибуты в `row.data`, иначе молчаливый `undefined`), определение LINK-класса и его
198
+ концов, таблица фасадов `db`, `fastest-validator` как язык `attributes`, партиция
199
+ по умолчанию, идемпотентность `up()` по docker-пробе и по схеме, радиус `fresh: true`
200
+ (только `DROP SCHEMA` целевой схемы), `versions()` сам включает tombstone, ключи `Path`
201
+ (имя как в цепочке; порядок не совпадает с обходом), форма `ClassDef` в `db.registry`,
202
+ замыкание в результате `delete`/`purge`.
203
+ - `lib/README.md` §11.1a: `opts.dsn` — docker-шаги решает живая проба postgres, а не имя
204
+ хоста (прежняя формулировка «хост не localhost» была неточной).
205
+
206
+ ### Fixed — противоречия и устаревшие факты в документации
207
+
208
+ - Утверждение «реестр классов фиксируется до нового `connect()`» противоречило
209
+ `db.reloadSchema()` — исправлено в README (§11.1, §11.10, §9.2), `acl.ts`, `types.ts`;
210
+ добавлена таблица «`db.reloadSchema()` vs `db.acl.reload()` — разные подсистемы».
211
+ - JSDoc точки входа `index.ts` показывал `schema: 'booking'` (нужно полное `'v1.booking'`)
212
+ и несуществующий алиас `Сотрудник`.
213
+ - `AGENTS.md` / `requirements.xml` / `verification-plan.xml` заявляли отсутствие юнит-теста
214
+ M-ACL — `test/acl.test.ts` существовал раньше, чем эти доки.
215
+ - Формат имени схемы `<schema>.v<version>` → `v<version>.<schema>`; 5 живых ссылок на
216
+ снесённый `SALON.md`; `wave5` в списке тестов; «19 классов» в заголовке сида при 20 INSERT-ах;
217
+ устаревшие LOC и дублированный semver убраны (нечему дрейфовать); `RESEARCH.md` помечен
218
+ HISTORICAL; ранние дизайн-файлы `schema.booking.md` / `.json` удалены — они описывали
219
+ 13 HUB + 11 LINK и класс `Contragent`, которых в реальном сиде нет, и могли быть приняты
220
+ за схему проекта (истина — `lib/sql/seed.booking.sql`).
221
+ - README: `npm install letopis`, разведены semver пакета и `opts.version` (версия DDL-схемы),
222
+ `####`-блоки для `uuidv5`/`uuidv7`/`LETOPIS_NS` с формулой v5-id как публичным контрактом,
223
+ предупреждения про `seeds` по умолчанию и про `.purge()` на живой сущности (`[]`, не ошибка).
224
+
225
+ ### Changed
226
+
227
+ - `connect()` на схему без таблицы `Schema` даёт адресную ошибку со списком схем letopis
228
+ этой БД вместо сырой `42P01` (типовая ошибка — базовое имя вместо `vN.имя`).
229
+ - `prepublishOnly` = `typecheck && check:docs && build`; `LICENSE` включён в `files`.
230
+
231
+ ## [0.19.0] — 2026-07-14
232
+
233
+ ### Added — физический purge (hard-erase) + живой подхват правок схемы
234
+
235
+ - **`.purge({ confirm })`** — необратимый физический снос уже логически удалённой (tombstone)
236
+ сущности + всего поддерева по `links` (все версии). Двухфазно: живую не трогает (сначала
237
+ `.delete()`); без `confirm` — dry-превью замыкания. Внутри — серверная `purge()`:
238
+ `SET LOCAL letopis.purge='on'` отключает append-only-триггер через `WHEN`-условие → плоский
239
+ `DELETE` (детерминированно, без `TM_SelfModified`).
240
+ - **`.withDeleted()`** — включить удалённые (tombstone) в выдачу последнего шага; снимает ТОЛЬКО
241
+ фильтр `deleted`, изоляция арендатора (`enforceAccount`) и ACL (`enforceAcl`) действуют.
242
+ - **`db.accounts.purge(id)`** — полный офбординг тенанта: физ. снос всех Entity (`account|owner`)
243
+ + сам Account (Credential — FK-каскад). Гарды: только Owner/System, не последний Owner, не свой аккаунт.
244
+ - **`db.reloadSchema()` / `db.schema.define(def)`** — правка определений классов простым SQL в
245
+ таблице `Schema` подхватывается без реконнекта (пересборка registry + ACL-резолвера).
246
+
247
+ ### Changed — DDL (требует наката `ddl.sql` на существующие схемы)
248
+
249
+ - `entity_delete` получил `WHEN (current_setting('letopis.purge', true) IS DISTINCT FROM 'on')` —
250
+ обычный путь (tombstone + каскад) неизменен; под флагом триггер пропускается для физ-сноса.
251
+ - Новые серверные функции: `purge_closure(partition,class,ids[])`,
252
+ `purge(partition,class,id[,dry]) RETURNS SETOF Entity`, `purge_account(id)`.
253
+
254
+ ### Notes
255
+
256
+ - Голый повторный `DELETE` надгробия БЕЗ флага остаётся no-op (safety-инвариант жив).
257
+ - Тесты: новый `test/wave5.test.ts` (13 кейсов P1/P2) — весь набор 181/181 зелёный.
258
+
5
259
  ## [0.18.1] — 2026-07-13
6
260
 
7
261
  ### Fixed — упаковка: CLI-скрипты в npm-пакете
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alepri51
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.