letopis 0.20.1 → 0.21.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,92 @@
2
2
 
3
3
  Формат: [Keep a Changelog](https://keepachangelog.com/), версии — semver.
4
4
 
5
+ ## [0.21.0] — 2026-08-23
6
+
7
+ ### Added — no-history классы: `meta.history: false` (DDL_REVISION 2)
8
+
9
+ Класс, помеченный в `Schema.meta` флагом `history: false`, хранит **одну актуальную строку
10
+ на id**: новый AFTER INSERT триггер `entity_collapse` после каждой вставки физически удаляет
11
+ более старые версии этого id. Дефолт (`history` отсутствует или `true`) — прежнее поведение,
12
+ все версии копятся; фича строго opt-in, существующие схемы и сервисы не затронуты.
13
+
14
+ Зачем: у мутабельных статус-сущностей (очереди, outbox — статус живёт в `data`) история
15
+ версий делает jsonb-фильтры неселективными: GIN по `data` матчит каждую версию, что
16
+ когда-либо была в искомом статусе, и сканы растут O(вся история). Под `history:false`
17
+ фильтр матчит только актуальные строки — скан остаётся O(активных) при любом росте данных.
18
+
19
+ Механика:
20
+ - `entity_collapse` — AFTER INSERT (как `entity_notify`; collapse по алфавиту раньше —
21
+ один NOTIFY на выжившую строку). In-place UPDATE не годится: Entity — гипертаблица по
22
+ `updated` (перенос между чанками), а `entity_notify` слушает только INSERT.
23
+ - DELETE старых версий идёт под `letopis.purge='on'` (save+restore флага):
24
+ `entity_delete` пропущен — без tombstone и каскада. Advisory-lock тем же ключом,
25
+ что `entity_delete`/`purge`, сериализует конкурентные вставки одного id.
26
+ - `.delete()` под no-history оставляет один tombstone (его `updated` строго больше),
27
+ `purge()` работает как прежде.
28
+ - Новая серверная функция `compact(partition, class) → bigint` — разовая чистка УЖЕ
29
+ накопленной истории при переводе класса на `history:false` (оставить последнюю версию
30
+ каждого id, вернуть число удалённых). Идемпотентна. После вызова — `ANALYZE "Entity"`.
31
+ - `ClassDef.history` в реестре; `.versions()`/`.asOf()` по no-history классу предупреждают
32
+ в console.warn (один раз на класс+метод за процесс) — результат не отражает историю.
33
+
34
+ Накат на существующие схемы: `up({ upgrade: true })` или `node db/apply.mjs --upgrade`
35
+ (файл идемпотентен, данные целы); до наката `connect()` предупредит о ревизии (1 → 2).
36
+
37
+ - `test/no-history.test.ts` — 7 сцен: реестр, схлопывание, соседний history-класс,
38
+ NOTIFY, tombstone, `compact()`, warning; суммарно 205 тестов.
39
+
40
+ ## [0.20.3] — 2026-07-31
41
+
42
+ ### Fixed — релиз доезжает до npm
43
+
44
+ 0.20.2 опубликовался на GitHub, но в npm **не уехал**: шаг заметок падал, а из-за `set -e`
45
+ вместе с ним пропускался `npm publish`.
46
+
47
+ Причина оказалась не в правах, а в том, откуда GitHub берёт workflow: для события `release`
48
+ файл читается **из коммита, на который указывает тег**, а не из `main`. Тег `v0.20.2` был
49
+ создан до правки, поэтому все перевыпуски крутили старую версию шага — тот, что ходил в PR-API
50
+ и получал `Resource not accessible by integration`. Исправление доехало только с новым тегом.
51
+
52
+ Сам шаг переписан: тело релиза берётся из `lib/CHANGELOG.md` тем же `scripts/release-notes.mjs`,
53
+ которым собирается описание релизного PR. Зависимости от PR-API нет, из прав нужен только
54
+ `contents: write`. Релиз 0.20.2 удалён как не дошедший до npm — его содержимое здесь, ниже.
55
+
56
+ ## [0.20.2] — 2026-07-31
57
+
58
+ ### Changed — заметки релиза перестали дублироваться
59
+
60
+ Тело GitHub Release собиралось `git-cliff`-ом, то есть из заголовков коммитов — вместо
61
+ осмысленного описания. Теперь текст живёт в одном месте и переиспользуется по цепочке:
62
+
63
+ ```
64
+ lib/CHANGELOG.md (руками) → описание PR → тело релиза → npm
65
+ ```
66
+
67
+ - `release.yml` при публикации ставит в тело релиза **описание релизного PR** `dev→main`. PR
68
+ находится по коммиту, на который указывает тег; не нашёлся или пуст — тело не трогается.
69
+ - Новый `lib/scripts/release-notes.mjs` печатает секцию `## [x.y.z]` **текущей** версии пакета —
70
+ это и есть тело PR:
71
+ `gh pr create … --body "$(cd lib && node scripts/release-notes.mjs)"`.
72
+ Версия берётся из `package.json`, а не из аргумента: иначе можно выпустить заметки одной
73
+ версии под тегом другой. Нет записи для версии — exit 1 с той же подсказкой, что у `check:docs`.
74
+ - `git-cliff` и `cliff.toml` удалены. После перехода на заметки из PR от них оставались только
75
+ аннотация тега и подсказка про bump: первое даёт обычный `git log`, второе избыточно —
76
+ забытый бамп и так виден (тег такой версии уже есть → шаг пропускается → релиза нет).
77
+ Побочно: в job, который делает `npm publish`, больше нет стороннего экшена; из workflow
78
+ осталось только first-party (`actions/checkout`, `actions/setup-node`).
79
+
80
+ - `release.yml` берёт тело релиза **тем же скриптом из CHANGELOG**, а не через PR-API. Первая
81
+ попытка читала описание PR (`gh pr list/view`) и падала `Resource not accessible by
82
+ integration` — GITHUB_TOKEN на release-событии не получает доступ к PR-API этого репозитория,
83
+ и `pull-requests: read` не помог. Из-за `set -e` падал весь job, то есть `npm publish`
84
+ пропускался. Теперь зависимости от PR нет: чекаут стоит на теге, значит CHANGELOG и
85
+ package.json — ровно той версии, что публикуется; из прав нужен только `contents: write`.
86
+
87
+ Тег остался **аннотированным** — в сообщении список коммитов от предыдущего тега
88
+ (`git log --no-merges --pretty='- %s'`), так что `git show <tag>` показывает состав версии.
89
+ Шагов в workflow стало меньше: `tag` 3 (было 6), `release` 6 (было 8).
90
+
5
91
  ## [0.20.1] — 2026-07-31
6
92
 
7
93
  ### Fixed — движок схемы больше не отстаёт молча
@@ -60,7 +146,7 @@
60
146
  ```
61
147
  push dev → ci (typecheck + check:docs + 198 тестов)
62
148
  merge dev→main → tag (аннотированный тег из lib/package.json)
63
- publish Release → release (заметки git-cliff + npm publish + смоук артефакта)
149
+ publish Release → release (описание из PR + npm publish + смоук артефакта)
64
150
  ```
65
151
 
66
152
  - `ci.yml` — только `push: dev`. Ни `pull_request`, ни `push: main`: релизный PR и сам merge
@@ -75,18 +161,21 @@ publish Release → release (заметки git-cliff + npm publish + см
75
161
  `test`. Без неё запрет «не мерджить на красном» держится на дисциплине: у PR своей проверки
76
162
  нет, гарантию даёт зелёный прогон того же SHA на `dev`.
77
163
 
78
- ### Changed — флоу релиза по логике VP
164
+ ### Changed — флоу релиза: тег с историей, заметки из PR, без сторонних экшенов
165
+
166
+ `tag.yml` ставит **аннотированный** тег: в сообщении — список коммитов от предыдущего тега,
167
+ поэтому `git show <tag>` и GitHub → Tags показывают состав версии без GitHub Release.
79
168
 
80
- `tag.yml` ставит **аннотированный** тег: аннотация несёт changelog коммитов по Conventional
81
- Commits (`git-cliff`, конфиг `cliff.toml`), так что `git show <tag>` и GitHub → Tags
82
- показывают состав версии без GitHub Release.
169
+ `release.yml` при публикации ставит в тело релиза **описание релизного PR** `dev→main`: оно
170
+ писалось осмысленно (что и почему, с замерами), в отличие от списка заголовков коммитов. PR
171
+ находится по коммиту, на который указывает тег; если PR не нашёлся или пуст — тело не трогается.
83
172
 
84
- `release.yml` при публикации **сам собирает тело релиза**: тот же `git-cliff`, диапазон — от
85
- предыдущего **опубликованного** релиза до этого тега (черновики и pre-release не считаются,
86
- поэтому заметки не теряют коммиты между публикациями). Описание релиза писать руками не нужно.
173
+ Источник версии — `lib/package.json`: тег обязан равняться тому, что уедет в npm. Забытый бамп
174
+ виден сразу — тег такой версии уже существует, шаг пропускается, релиза нет.
87
175
 
88
- Источник версии — `lib/package.json` (тег обязан равняться тому, что уедет в npm); расчёт
89
- `git-cliff` печатается предупреждением, если бамп забыли.
176
+ `git-cliff` и `cliff.toml` (заводились по образцу VP) удалены: после перехода на заметки из PR
177
+ от них оставались только аннотация тега и подсказка про bump — первое даёт обычный `git log`,
178
+ второе избыточно. Побочно ушла и зависимость от стороннего экшена в job, который публикует в npm.
90
179
 
91
180
  ## [0.20.0] — 2026-07-29
92
181
 
package/README.md CHANGED
@@ -3838,6 +3838,9 @@ cd lib && npm test # весь набор против живого docker (ti
3838
3838
  # idgen — генерация id по Schema (§ 3.2): v4/v7/v5 из концов и полей,
3839
3839
  # гонка без локов, наследование attributes и правила id, гарды
3840
3840
  # integration — E2E-барбершоп (8 сцен)
3841
+ # no-history — meta.history=false: одна строка на id (entity_collapse),
3842
+ # NOTIFY один на вставку, tombstone цел, compact() идемпотентна,
3843
+ # warning на .versions()/.asOf()
3841
3844
  # real-life — 16 сцен «дня салона»: 4 руки, гонки ×3, переносы, no-show
3842
3845
  # tables — auth/ACL-таблицы
3843
3846
  # up — up(): docker-argv, probe-ветка, идемпотентность, fresh,
package/dist/chain.js CHANGED
@@ -23,6 +23,21 @@ function normalizeFilter(f) {
23
23
  return f;
24
24
  }
25
25
  const clsOf = (steps) => steps.map((s) => s.cls.id);
26
+ // no-history класс (meta.history=false): в БД одна актуальная строка на id — .versions()
27
+ // вернёт её одну, .asOf() отдаст «текущее» вместо point-in-time. Предупреждаем (не бросаем:
28
+ // вызовы могли жить до перевода класса), один раз на класс+метод за процесс.
29
+ const noHistoryWarned = new Set();
30
+ function warnNoHistory(steps, method) {
31
+ const cls = steps[steps.length - 1]?.cls;
32
+ if (!cls || cls.history)
33
+ return;
34
+ const key = `${cls.id}|${method}`;
35
+ if (noHistoryWarned.has(key))
36
+ return;
37
+ noHistoryWarned.add(key);
38
+ console.warn(`letopis: .${method}() on no-history class "${cls.id}" (meta.history=false) — ` +
39
+ `хранится одна актуальная версия на id, результат не отражает историю`);
40
+ }
26
41
  /** Внутренний канал: план чужой ленивой цепочки (слот-значения, entity()). */
27
42
  export const PLAN = Symbol('letopis.plan');
28
43
  const peekPlan = (x) => x !== null && typeof x === 'object' ? x[PLAN] : undefined;
@@ -353,7 +368,10 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
353
368
  case 'sort':
354
369
  return (field, dir) => withMods({ order: field, desc: dir === true || dir === 'desc' });
355
370
  case 'asOf':
356
- return (t) => withMods({ asOf: t instanceof Date ? t.toISOString() : t });
371
+ return (t) => {
372
+ warnNoHistory(steps, 'asOf');
373
+ return withMods({ asOf: t instanceof Date ? t.toISOString() : t });
374
+ };
357
375
  case 'withDeleted':
358
376
  return () => withMods({ withDeleted: true });
359
377
  case 'deep':
@@ -406,6 +424,7 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
406
424
  case 'versions':
407
425
  return async () => {
408
426
  guardBatch();
427
+ warnNoHistory(steps, 'versions');
409
428
  if (hasOps)
410
429
  return runPlan(ctx, steps, mods, 'versions');
411
430
  const q = buildRead(ctx, steps, mods, 'versions');
package/dist/schema.js CHANGED
@@ -281,6 +281,7 @@ function buildDef(row, byId) {
281
281
  idGen,
282
282
  meta: row.meta ?? {},
283
283
  abstract: row.meta?.abstract === true || row.meta?.abstract === 'true',
284
+ history: row.meta?.history !== false && row.meta?.history !== 'false', // дефолт true; только явный false отключает историю
284
285
  order: row.order,
285
286
  check,
286
287
  fieldTypes,
package/dist/types.d.ts CHANGED
@@ -64,6 +64,8 @@ export interface ClassDef {
64
64
  strictEnds: boolean;
65
65
  meta: Record<string, unknown>;
66
66
  abstract: boolean;
67
+ /** false — движок хранит одну актуальную строку на id (триггер entity_collapse), .versions()/.asOf() теряют смысл. */
68
+ history: boolean;
67
69
  order: number;
68
70
  /** Как генерить id новой сущности (attributes.id). */
69
71
  idGen: IdGen;
@@ -285,6 +287,6 @@ export declare function reservedNamesOf(def: {
285
287
  * Совпадение константы с маркером `-- DDL_REVISION:` в ddl.sql проверяет
286
288
  * `scripts/check-docs.mjs` — иначе одно уедет без другого.
287
289
  */
288
- export declare const DDL_REVISION = 1;
290
+ export declare const DDL_REVISION = 2;
289
291
  /** Метка ревизии в комментарии схемы: 'letopis ddl_revision=N' → N; иначе null. */
290
292
  export declare function parseDdlRevision(comment: string | null | undefined): number | null;
package/dist/types.js CHANGED
@@ -104,7 +104,7 @@ export function reservedNamesOf(def) {
104
104
  * Совпадение константы с маркером `-- DDL_REVISION:` в ddl.sql проверяет
105
105
  * `scripts/check-docs.mjs` — иначе одно уедет без другого.
106
106
  */
107
- export const DDL_REVISION = 1;
107
+ export const DDL_REVISION = 2;
108
108
  /** Метка ревизии в комментарии схемы: 'letopis ddl_revision=N' → N; иначе null. */
109
109
  export function parseDdlRevision(comment) {
110
110
  const m = /letopis ddl_revision=(\d+)/.exec(comment ?? '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "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)",
5
5
  "keywords": [
6
6
  "timescaledb",
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Печатает в stdout секцию CHANGELOG для ТЕКУЩЕЙ версии пакета — тело релизного PR.
4
+ *
5
+ * cd lib && node scripts/release-notes.mjs # посмотреть
6
+ * gh pr create --base main --head dev --title "Release: …" \
7
+ * --body "$(cd lib && node scripts/release-notes.mjs)" # так и создавать PR
8
+ *
9
+ * Зачем: описание PR было единственным местом контура, где текст писался заново, хотя тот же
10
+ * текст уже есть в lib/CHANGELOG.md — источнике истины версии (его сверяет check-docs.mjs с
11
+ * package.json). Цепочка становится без дублирования:
12
+ *
13
+ * lib/CHANGELOG.md (руками) → описание PR → тело релиза (release.yml берёт из PR) → npm
14
+ *
15
+ * Версия НЕ передаётся аргументом намеренно: берётся из package.json, чтобы нельзя было
16
+ * выпустить заметки одной версии под тегом другой.
17
+ */
18
+ //
19
+ // FILE: lib/scripts/release-notes.mjs
20
+ // VERSION: 1.0.0
21
+ // START_MODULE_CONTRACT
22
+ // PURPOSE: Вырезать из lib/CHANGELOG.md секцию текущей версии пакета и напечатать её — тело релизного PR, из которого release.yml берёт заметки релиза.
23
+ // SCOPE: чтение package.json + CHANGELOG, извлечение секции, печать в stdout.
24
+ // DEPENDS: none
25
+ // LINKS: M-RELEASE-NOTES, V-M-RELEASE-NOTES
26
+ // ROLE: SCRIPT
27
+ // MAP_MODE: LOCALS
28
+ // END_MODULE_CONTRACT
29
+ //
30
+ // START_MODULE_MAP
31
+ // section - (local) секция CHANGELOG по версии: от "## [x.y.z]" до следующей "## ["
32
+ // END_MODULE_MAP
33
+ //
34
+ // START_CHANGE_SUMMARY
35
+ // LAST_CHANGE: [v1.0.0 - Новый скрипт: тело релизного PR берётся из CHANGELOG, а не пишется заново]
36
+ // END_CHANGE_SUMMARY
37
+ import { readFile } from 'node:fs/promises';
38
+ import { fileURLToPath } from 'node:url';
39
+ import { join } from 'node:path';
40
+
41
+ const LIB = fileURLToPath(new URL('..', import.meta.url));
42
+
43
+ // START_CONTRACT: section
44
+ // PURPOSE: Достать из текста CHANGELOG секцию конкретной версии (без её заголовка).
45
+ // INPUTS: { changelog: string - текст lib/CHANGELOG.md; version: string - x.y.z }
46
+ // OUTPUTS: { string - тело секции; пусто, если версии в файле нет }
47
+ // SIDE_EFFECTS: none
48
+ // LINKS: M-RELEASE-NOTES, V-M-RELEASE-NOTES
49
+ // END_CONTRACT: section
50
+ function section(changelog, version) {
51
+ const start = changelog.indexOf(`## [${version}]`);
52
+ if (start < 0) return '';
53
+ const after = changelog.indexOf('\n## [', start + 1);
54
+ const body = changelog.slice(changelog.indexOf('\n', start) + 1, after < 0 ? undefined : after);
55
+ return body.trim();
56
+ }
57
+
58
+ const [pkgRaw, changelog] = await Promise.all([
59
+ readFile(join(LIB, 'package.json'), 'utf8'),
60
+ readFile(join(LIB, 'CHANGELOG.md'), 'utf8'),
61
+ ]);
62
+ const { version } = JSON.parse(pkgRaw);
63
+ const body = section(changelog, version);
64
+
65
+ if (!body) {
66
+ console.error(
67
+ `release-notes: в lib/CHANGELOG.md нет записи "## [${version}]" — подними версию в ` +
68
+ 'package.json или добавь запись (то же самое проверяет npm run check:docs)',
69
+ );
70
+ process.exit(1);
71
+ }
72
+
73
+ // Ссылку на полный changelog добавляем всегда: релиз читают из GitHub, а не из репозитория.
74
+ process.stdout.write(
75
+ `${body}\n\n---\n\nПолный changelog — [lib/CHANGELOG.md](https://github.com/alepri51/letopis/blob/main/lib/CHANGELOG.md).\n`,
76
+ );
package/sql/ddl.sql CHANGED
@@ -8,10 +8,10 @@
8
8
 
9
9
  -- FILE: lib/sql/ddl.sql
10
10
  -- VERSION: 1.1.0
11
- -- DDL_REVISION: 1 (последняя строка файла штампует её в COMMENT ON SCHEMA; см. там же)
11
+ -- DDL_REVISION: 2 (последняя строка файла штампует её в COMMENT ON SCHEMA; см. там же)
12
12
  -- START_MODULE_CONTRACT
13
13
  -- PURPOSE: Движок-хранилище PostgreSQL/TimescaleDB — таблицы, Entity-hypertable, индексы и триггеры целостности (валидация, версионирование, каскад-tombstone, lineage, notify).
14
- -- SCOPE: таблицы Schema/Entity/Account/Credential/Resource/Rule + триггеры schema_lineage/entity_check/entity_update/entity_delete/entity_notify.
14
+ -- SCOPE: таблицы Schema/Entity/Account/Credential/Resource/Rule + триггеры schema_lineage/entity_check/entity_update/entity_delete/entity_notify/entity_collapse + compact.
15
15
  -- DEPENDS: none
16
16
  -- LINKS: M-DDL, V-M-DDL
17
17
  -- ROLE: RUNTIME
@@ -571,6 +571,91 @@ CREATE TRIGGER entity_notify
571
571
  AFTER INSERT ON "<SCHEMA-NAME>"."Entity"
572
572
  FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_notify();
573
573
 
574
+ -- -----------------------------------------------------------------------------
575
+ -- Триггер 5: no-history — класс с Schema.meta.history=false хранит ОДНУ актуальную
576
+ -- строку на id: после вставки новой версии старые физически удаляются.
577
+ -- Зачем: у мутабельных статус-сущностей (очереди, outbox) история версий делает
578
+ -- jsonb-фильтры неселективными (GIN матчит каждую версию, что когда-либо была в
579
+ -- статусе) — сканы растут O(вся история). Дефолт (meta.history отсутствует/true) —
580
+ -- поведение прежнее, все версии копятся.
581
+ -- Почему AFTER INSERT, а не in-place UPDATE: Entity — гипертаблица по updated
582
+ -- (апдейт time-колонки = перенос между чанками), а entity_notify слушает только
583
+ -- INSERT (in-place UPDATE потерял бы NOTIFY). Оба AFTER INSERT триггера идут по
584
+ -- алфавиту: entity_collapse раньше entity_notify — один NOTIFY на выжившую строку.
585
+ -- -----------------------------------------------------------------------------
586
+ -- START_CONTRACT: entity_collapse
587
+ -- PURPOSE: Для класса с meta.history=false удалить версии старее только что вставленной (одна актуальная строка на id).
588
+ -- INPUTS: { trigger: AFTER INSERT ON Entity (NEW) }
589
+ -- OUTPUTS: { NULL }
590
+ -- SIDE_EFFECTS: DELETE старых версий под letopis.purge='on' (entity_delete пропущен: без tombstone/каскада); pg_advisory_xact_lock на partition|class|id
591
+ -- LINKS: M-DDL, V-M-DDL
592
+ -- END_CONTRACT: entity_collapse
593
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".entity_collapse() RETURNS trigger
594
+ LANGUAGE plpgsql AS $$
595
+ DECLARE prev text;
596
+ BEGIN
597
+ -- per-class флаг из Schema.meta (PK-lookup; зеркало чтения abstract в entity_check).
598
+ -- jsonb ->> отдаёт текст: boolean false -> 'false'.
599
+ IF NOT EXISTS (SELECT 1 FROM "<SCHEMA-NAME>"."Schema"
600
+ WHERE partition = NEW.partition AND id = NEW.class
601
+ AND meta->>'history' = 'false') THEN
602
+ RETURN NULL; -- history-класс (дефолт): версии копятся
603
+ END IF;
604
+ -- сериализация конкурентных INSERT одного id: тот же ключ, что в entity_delete/purge.
605
+ -- Лок транзакционный и реентерабельный — tombstone-INSERT внутри entity_delete
606
+ -- (он уже держит этот лок) не дедлочится.
607
+ PERFORM pg_advisory_xact_lock(
608
+ hashtextextended(NEW.partition || '|' || NEW.class || '|' || NEW.id, 0));
609
+ -- purge-guard: entity_delete висит с WHEN (letopis.purge IS DISTINCT FROM 'on') —
610
+ -- DELETE ниже идёт физически, без tombstone и каскада. save+restore, чтобы не
611
+ -- затереть флаг, выставленный снаружи, до конца транзакции.
612
+ prev := current_setting('letopis.purge', true);
613
+ PERFORM set_config('letopis.purge', 'on', true);
614
+ DELETE FROM "<SCHEMA-NAME>"."Entity" e
615
+ WHERE e.partition = NEW.partition AND e.class = NEW.class AND e.id = NEW.id
616
+ AND e.updated < NEW.updated; -- выживает ровно вставленная версия
617
+ PERFORM set_config('letopis.purge', COALESCE(prev, ''), true);
618
+ RETURN NULL;
619
+ END $$;
620
+
621
+ DROP TRIGGER IF EXISTS entity_collapse ON "<SCHEMA-NAME>"."Entity";
622
+ CREATE TRIGGER entity_collapse
623
+ AFTER INSERT ON "<SCHEMA-NAME>"."Entity"
624
+ FOR EACH ROW EXECUTE FUNCTION "<SCHEMA-NAME>".entity_collapse();
625
+
626
+ -- -----------------------------------------------------------------------------
627
+ -- compact(): разовая компакция класса — оставить по одной (последней) версии на id.
628
+ -- Для перевода класса на meta.history=false с уже накопленной историей: триггер
629
+ -- entity_collapse останавливает накопление ВПЕРЁД, старые версии убирает compact().
630
+ -- Идемпотентна (повторный вызов вернёт 0). GROUP BY покрыт entity_latest_idx.
631
+ -- Не по ctid: в гипертаблице он не уникален между чанками — только PK-условие.
632
+ -- После вызова: ANALYZE "Entity" (статистика для планировщика гипертаблицы).
633
+ -- -----------------------------------------------------------------------------
634
+ -- START_CONTRACT: compact
635
+ -- PURPOSE: Удалить все версии класса, кроме последней на каждый id; вернуть число удалённых строк.
636
+ -- INPUTS: { p_partition: text; p_class: text }
637
+ -- OUTPUTS: { bigint - удалено строк }
638
+ -- SIDE_EFFECTS: физический DELETE под letopis.purge='on' (SET LOCAL, сброс в '' как в purge)
639
+ -- LINKS: M-DDL, V-M-DDL
640
+ -- END_CONTRACT: compact
641
+ CREATE OR REPLACE FUNCTION "<SCHEMA-NAME>".compact(p_partition text, p_class text)
642
+ RETURNS bigint LANGUAGE plpgsql AS $$
643
+ DECLARE n bigint;
644
+ BEGIN
645
+ PERFORM set_config('letopis.purge', 'on', true); -- SET LOCAL: физический DELETE
646
+ WITH keep AS (
647
+ SELECT partition, class, id, max(updated) AS mx
648
+ FROM "<SCHEMA-NAME>"."Entity"
649
+ WHERE partition = p_partition AND class = p_class
650
+ GROUP BY partition, class, id)
651
+ DELETE FROM "<SCHEMA-NAME>"."Entity" e USING keep
652
+ WHERE e.partition = keep.partition AND e.class = keep.class AND e.id = keep.id
653
+ AND e.updated < keep.mx;
654
+ GET DIAGNOSTICS n = ROW_COUNT;
655
+ PERFORM set_config('letopis.purge', '', true);
656
+ RETURN n;
657
+ END $$;
658
+
574
659
  -- -----------------------------------------------------------------------------
575
660
  -- Метка ревизии движка — ПОСЛЕДНЕЙ строкой: комментарий появляется только если весь
576
661
  -- файл применился. Её читает connect() и предупреждает, если схема отстала от либы
@@ -582,7 +667,7 @@ CREATE TRIGGER entity_notify
582
667
  -- Несовместимая правка СТРУКТУРЫ таблиц — это смена version в имени схемы (v1 → v2),
583
668
  -- а не ревизия.
584
669
  -- -----------------------------------------------------------------------------
585
- COMMENT ON SCHEMA "<SCHEMA-NAME>" IS 'letopis ddl_revision=1';
670
+ COMMENT ON SCHEMA "<SCHEMA-NAME>" IS 'letopis ddl_revision=2';
586
671
 
587
672
  -- -----------------------------------------------------------------------------
588
673
  -- Опционально (включать по мере роста данных):