letopis 0.20.3 → 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 +35 -0
- package/README.md +3 -0
- package/dist/chain.js +20 -1
- package/dist/schema.js +1 -0
- package/dist/types.d.ts +3 -1
- package/dist/types.js +1 -1
- package/package.json +1 -1
- package/sql/ddl.sql +88 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
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
|
+
|
|
5
40
|
## [0.20.3] — 2026-07-31
|
|
6
41
|
|
|
7
42
|
### Fixed — релиз доезжает до npm
|
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) =>
|
|
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 =
|
|
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 =
|
|
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.
|
|
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",
|
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:
|
|
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=
|
|
670
|
+
COMMENT ON SCHEMA "<SCHEMA-NAME>" IS 'letopis ddl_revision=2';
|
|
586
671
|
|
|
587
672
|
-- -----------------------------------------------------------------------------
|
|
588
673
|
-- Опционально (включать по мере роста данных):
|