letopis 0.16.0 → 0.18.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 +107 -5
- package/README.md +602 -542
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.js +112 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +36 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +73 -0
- package/dist/sessions.js +66 -0
- package/dist/sql.js +90 -1
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.js +36 -0
- package/dist/write.js +148 -2
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +71 -1
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +60 -18
package/dist/up.js
CHANGED
|
@@ -5,6 +5,34 @@
|
|
|
5
5
|
*
|
|
6
6
|
* const db = await up({ schema: 'booking', version: 1 }); // → PG-схема "v1.booking"
|
|
7
7
|
*/
|
|
8
|
+
// FILE: lib/src/up.ts
|
|
9
|
+
// VERSION: 1.0.0
|
|
10
|
+
// START_MODULE_CONTRACT
|
|
11
|
+
// PURPOSE: Dev-bootstrap — поднять Docker-контейнер Postgres (TimescaleDB) + Redis, обеспечить базу, накатить DDL-схему и сиды, дождаться готовности и подключиться.
|
|
12
|
+
// SCOPE: валидация схемы/версии; проба postgres; docker build/run/start контейнера; создание базы; ожидание готовности PG и Redis; применение схемы + сиды; connect
|
|
13
|
+
// DEPENDS: M-CONNECT, M-CHAIN, M-TYPES
|
|
14
|
+
// LINKS: M-UP, V-M-UP
|
|
15
|
+
// ROLE: RUNTIME
|
|
16
|
+
// MAP_MODE: EXPORTS
|
|
17
|
+
// END_MODULE_CONTRACT
|
|
18
|
+
//
|
|
19
|
+
// START_MODULE_MAP
|
|
20
|
+
// UpOpts - опции up(): dsn, schema, version, контейнер/образ, dataDir, seeds, fresh, quiet, таймаут
|
|
21
|
+
// dockerRunArgs - чистая сборка argv для `docker run` (bind/volume, trust/пароль)
|
|
22
|
+
// RunCfg - конфиг запуска контейнера (порты, креды, база, dataDir)
|
|
23
|
+
// up - точка входа: контейнер -> база -> схема -> готовность -> connect (возвращает EntityDb)
|
|
24
|
+
// docker - (local) обёртка execFile над docker CLI (ENOENT -> понятная ошибка)
|
|
25
|
+
// ensureContainer - (local) reuse/start/build+run dev-контейнера
|
|
26
|
+
// pgPing - (local) проба postgres: ok | no-server | no-database | starting | fatal
|
|
27
|
+
// ensureDatabase - (local) создать базу, если её нет (гонка 42P04 глушится)
|
|
28
|
+
// tcpAlive - (local) TCP-проба хоста:порта (для redis)
|
|
29
|
+
// applySchema - (local) drop (fresh) -> накат ddl+сидов с подстановкой <SCHEMA-NAME>
|
|
30
|
+
// END_MODULE_MAP
|
|
31
|
+
//
|
|
32
|
+
// START_CHANGE_SUMMARY
|
|
33
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
34
|
+
// END_CHANGE_SUMMARY
|
|
35
|
+
//
|
|
8
36
|
import { execFile } from 'node:child_process';
|
|
9
37
|
import { promisify } from 'node:util';
|
|
10
38
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
@@ -19,6 +47,13 @@ const DEFAULT_DSN = 'postgres://postgres:test@localhost:15432/letopis';
|
|
|
19
47
|
const qi = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
20
48
|
const pkgPath = (rel) => fileURLToPath(new URL(rel, import.meta.url));
|
|
21
49
|
/** argv для docker run (чистая функция — покрыта юнитом). dataDir: путь → bind, имя → volume. */
|
|
50
|
+
// START_CONTRACT: dockerRunArgs
|
|
51
|
+
// PURPOSE: Чистая функция — собирает argv для `docker run` dev-контейнера (порты PG/Redis, том данных, режим trust либо пароль).
|
|
52
|
+
// INPUTS: { cfg: RunCfg - контейнер/образ, порты, креды, база, dataDir (путь -> bind, имя -> volume) }
|
|
53
|
+
// OUTPUTS: { string[] - аргументы командной строки docker run }
|
|
54
|
+
// SIDE_EFFECTS: none
|
|
55
|
+
// LINKS: M-UP, V-M-UP
|
|
56
|
+
// END_CONTRACT: dockerRunArgs
|
|
22
57
|
export function dockerRunArgs(cfg) {
|
|
23
58
|
const args = ['run', '-d', '--name', cfg.container,
|
|
24
59
|
'-p', `${cfg.pgPort}:5432`, '-p', `${cfg.redisPort}:6379`,
|
|
@@ -42,6 +77,13 @@ async function docker(args) {
|
|
|
42
77
|
return { ok: false, out: (x.stdout ?? '').trim(), err: (x.stderr || x.message || '').trim() };
|
|
43
78
|
}
|
|
44
79
|
}
|
|
80
|
+
// START_CONTRACT: ensureContainer
|
|
81
|
+
// PURPOSE: Гарантирует запущенный dev-контейнер — reuse запущенного, start остановленного, иначе build образа (если нет) и run.
|
|
82
|
+
// INPUTS: { cfg: RunCfg - параметры контейнера/образа/портов; log: (m: string) => void - прогресс }
|
|
83
|
+
// OUTPUTS: { Promise<void> }
|
|
84
|
+
// SIDE_EFFECTS: вызывает docker inspect/start/build/run через execFile; бросает при неудаче start/build/run
|
|
85
|
+
// LINKS: M-UP, V-M-UP
|
|
86
|
+
// END_CONTRACT: ensureContainer
|
|
45
87
|
async function ensureContainer(cfg, log) {
|
|
46
88
|
const st = await docker(['container', 'inspect', '--format', '{{.State.Running}}', cfg.container]);
|
|
47
89
|
if (st.ok && st.out === 'true') {
|
|
@@ -68,6 +110,13 @@ async function ensureContainer(cfg, log) {
|
|
|
68
110
|
throw new Error(`letopis.up: docker run ${cfg.container} failed: ${r.err}`);
|
|
69
111
|
log(`container ${cfg.container} created (data: ${cfg.dataDir ?? 'volume letopis-pgdata'})`);
|
|
70
112
|
}
|
|
113
|
+
// START_CONTRACT: pgPing
|
|
114
|
+
// PURPOSE: Одноразовая проба postgres по dsn — классифицирует ответ: ok, no-database (3D000), starting (57P03), fatal (28P01/28000), no-server.
|
|
115
|
+
// INPUTS: { dsn: string - строка подключения }
|
|
116
|
+
// OUTPUTS: { Promise<Ping> - { ok: true } | { ok: false, kind, err } }
|
|
117
|
+
// SIDE_EFFECTS: открывает и закрывает разовое соединение postgres (max 1, connect_timeout 3)
|
|
118
|
+
// LINKS: M-UP, V-M-UP
|
|
119
|
+
// END_CONTRACT: pgPing
|
|
71
120
|
async function pgPing(dsn) {
|
|
72
121
|
const sql = postgres(dsn, { max: 1, connect_timeout: 3, onnotice: () => { } });
|
|
73
122
|
try {
|
|
@@ -88,6 +137,13 @@ async function pgPing(dsn) {
|
|
|
88
137
|
await sql.end({ timeout: 1 }).catch(() => { });
|
|
89
138
|
}
|
|
90
139
|
}
|
|
140
|
+
// START_CONTRACT: ensureDatabase
|
|
141
|
+
// PURPOSE: Создаёт целевую базу, если её ещё нет — подключаясь к служебной базе postgres (гонку параллельных up() 42P04 глушит).
|
|
142
|
+
// INPUTS: { url: URL - разобранный dsn (имя базы из pathname/username); log: (m: string) => void }
|
|
143
|
+
// OUTPUTS: { Promise<void> }
|
|
144
|
+
// SIDE_EFFECTS: подключается к базе postgres, выполняет CREATE DATABASE; закрывает соединение
|
|
145
|
+
// LINKS: M-UP, V-M-UP
|
|
146
|
+
// END_CONTRACT: ensureDatabase
|
|
91
147
|
async function ensureDatabase(url, log) {
|
|
92
148
|
const dbName = decodeURIComponent(url.pathname.slice(1)) || url.username || 'postgres';
|
|
93
149
|
const admin = new URL(url);
|
|
@@ -107,6 +163,13 @@ async function ensureDatabase(url, log) {
|
|
|
107
163
|
await sql.end();
|
|
108
164
|
}
|
|
109
165
|
}
|
|
166
|
+
// START_CONTRACT: tcpAlive
|
|
167
|
+
// PURPOSE: TCP-проба доступности хоста:порта (готовность Redis) с таймаутом 2 с.
|
|
168
|
+
// INPUTS: { host: string; port: number }
|
|
169
|
+
// OUTPUTS: { Promise<boolean> - true если соединение установилось }
|
|
170
|
+
// SIDE_EFFECTS: открывает и сразу закрывает TCP-сокет
|
|
171
|
+
// LINKS: M-UP, V-M-UP
|
|
172
|
+
// END_CONTRACT: tcpAlive
|
|
110
173
|
function tcpAlive(host, port) {
|
|
111
174
|
return new Promise((res) => {
|
|
112
175
|
const s = net.connect({ host, port });
|
|
@@ -116,6 +179,13 @@ function tcpAlive(host, port) {
|
|
|
116
179
|
s.setTimeout(2000, () => done(false));
|
|
117
180
|
});
|
|
118
181
|
}
|
|
182
|
+
// START_CONTRACT: applySchema
|
|
183
|
+
// PURPOSE: Применяет DDL-схему и сиды — при fresh дропает схему; если схемы нет, накатывает ddl.sql (+ дефолтные booking/auth или свои сиды), подставляя <SCHEMA-NAME>.
|
|
184
|
+
// INPUTS: { dsn: string; full: string - полное имя схемы v<N>.<schema>; seeds: string[] | false | undefined - свои пути / false (без сидов) / undefined (дефолт); fresh: boolean; log: (m: string) => void }
|
|
185
|
+
// OUTPUTS: { Promise<void> }
|
|
186
|
+
// SIDE_EFFECTS: читает sql-файлы; выполняет DROP/накат схемы в postgres; закрывает соединение
|
|
187
|
+
// LINKS: M-UP, V-M-UP
|
|
188
|
+
// END_CONTRACT: applySchema
|
|
119
189
|
async function applySchema(dsn, full, seeds, fresh, log) {
|
|
120
190
|
const sql = postgres(dsn, { max: 1, onnotice: () => { } });
|
|
121
191
|
try {
|
|
@@ -143,6 +213,13 @@ async function applySchema(dsn, full, seeds, fresh, log) {
|
|
|
143
213
|
await sql.end();
|
|
144
214
|
}
|
|
145
215
|
}
|
|
216
|
+
// START_CONTRACT: up
|
|
217
|
+
// PURPOSE: Единая точка входа dev-bootstrap — валидирует схему/версию, при живом postgres пропускает docker (иначе поднимает контейнер и ждёт готовности PG+Redis), применяет схему и подключается.
|
|
218
|
+
// INPUTS: { opts: UpOpts - dsn, schema, version, контейнер/образ, dataDir, redisPort, seeds, fresh, quiet, waitTimeoutMs + прочие ConnectOpts }
|
|
219
|
+
// OUTPUTS: { Promise<EntityDb> - подключённый фасад на схеме "v<version>.<schema>" }
|
|
220
|
+
// SIDE_EFFECTS: shell docker через M-UP.ensureContainer; TCP-пинги; чтение sql и накат схемы; console.log с префиксом [letopis.up]; connect (M-CONNECT); бросает при плохом имени схемы/версии и таймауте готовности
|
|
221
|
+
// LINKS: M-UP, V-M-UP
|
|
222
|
+
// END_CONTRACT: up
|
|
146
223
|
export async function up(opts) {
|
|
147
224
|
const { dsn = DEFAULT_DSN, schema, version, container = 'letopis-timescale', image = 'letopis-db', dataDir, redisPort = 16379, seeds, fresh, quiet, waitTimeoutMs = 120_000, ...connectRest } = opts;
|
|
148
225
|
if (!/^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(schema))
|
|
@@ -166,6 +243,7 @@ export async function up(opts) {
|
|
|
166
243
|
throw ping.err;
|
|
167
244
|
}
|
|
168
245
|
else {
|
|
246
|
+
// START_BLOCK_ENSURE_CONTAINER
|
|
169
247
|
if (local) {
|
|
170
248
|
await ensureContainer({
|
|
171
249
|
container, image, pgPort: url.port || '5432', redisPort,
|
|
@@ -176,6 +254,8 @@ export async function up(opts) {
|
|
|
176
254
|
else {
|
|
177
255
|
log(`waiting for remote postgres at ${host} (no docker for non-local dsn)…`);
|
|
178
256
|
}
|
|
257
|
+
// END_BLOCK_ENSURE_CONTAINER
|
|
258
|
+
// START_BLOCK_WAIT_READY
|
|
179
259
|
const t0 = Date.now();
|
|
180
260
|
for (;;) {
|
|
181
261
|
ping = await pgPing(dsn);
|
|
@@ -201,9 +281,12 @@ export async function up(opts) {
|
|
|
201
281
|
}
|
|
202
282
|
log(`redis ready on ${redisPort}`);
|
|
203
283
|
}
|
|
284
|
+
// END_BLOCK_WAIT_READY
|
|
204
285
|
}
|
|
286
|
+
// START_BLOCK_APPLY_SCHEMA
|
|
205
287
|
await applySchema(dsn, full, seeds, fresh ?? false, log);
|
|
206
288
|
const db = await connect({ ...connectRest, dsn, schema: full });
|
|
207
289
|
log(`connected (schema "${full}")`);
|
|
290
|
+
// END_BLOCK_APPLY_SCHEMA
|
|
208
291
|
return db;
|
|
209
292
|
}
|
package/dist/uuid.js
CHANGED
|
@@ -6,6 +6,28 @@
|
|
|
6
6
|
* детерминированный id из концов/полей: та же комбинация → тот же id (идемпотентный create,
|
|
7
7
|
* дубль невозможен даже в гонке — оба запроса вычислят один id, второй станет версией).
|
|
8
8
|
*/
|
|
9
|
+
//
|
|
10
|
+
// FILE: lib/src/uuid.ts
|
|
11
|
+
// VERSION: 1.0.0
|
|
12
|
+
// START_MODULE_CONTRACT
|
|
13
|
+
// PURPOSE: Детерминированная (v5) и упорядоченная по времени (v7) генерация UUID для id сущностей.
|
|
14
|
+
// SCOPE: namespace-константа LETOPIS_NS, форматирование 16 байт в каноничный UUID, v5 (sha1 от namespace+name), v7 (unix-ms в старших битах + random).
|
|
15
|
+
// DEPENDS: none
|
|
16
|
+
// LINKS: M-UUID, V-M-UUID
|
|
17
|
+
// ROLE: RUNTIME
|
|
18
|
+
// MAP_MODE: EXPORTS
|
|
19
|
+
// END_MODULE_CONTRACT
|
|
20
|
+
//
|
|
21
|
+
// START_MODULE_MAP
|
|
22
|
+
// LETOPIS_NS - фиксированный namespace letopis для uuidv5 (стабильность id между релизами).
|
|
23
|
+
// fmt - (локальная) форматирует 16-байтный Buffer в каноничную строку UUID 8-4-4-4-12.
|
|
24
|
+
// uuidv5 - RFC 4122 v5: детерминированный uuid = sha1(namespace + name).
|
|
25
|
+
// uuidv7 - RFC 9562 v7: 48 бит unix-ms в старших битах + random.
|
|
26
|
+
// END_MODULE_MAP
|
|
27
|
+
//
|
|
28
|
+
// START_CHANGE_SUMMARY
|
|
29
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
30
|
+
// END_CHANGE_SUMMARY
|
|
9
31
|
import { createHash, randomBytes } from 'node:crypto';
|
|
10
32
|
/** Namespace letopis для uuidv5 (фиксированная константа — стабильность id между релизами). */
|
|
11
33
|
export const LETOPIS_NS = 'c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90';
|
|
@@ -13,6 +35,13 @@ const fmt = (b) => {
|
|
|
13
35
|
const h = b.toString('hex');
|
|
14
36
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
15
37
|
};
|
|
38
|
+
// START_CONTRACT: uuidv5
|
|
39
|
+
// PURPOSE: Детерминированный UUID v5 из имени и namespace (одно имя → один uuid).
|
|
40
|
+
// INPUTS: { name: string - имя (склеенные значения from класса), ns: string - namespace (default LETOPIS_NS) }
|
|
41
|
+
// OUTPUTS: { string - каноничный UUID v5 (version=5, variant=10xx) }
|
|
42
|
+
// SIDE_EFFECTS: none (чистая функция — только хэширование входа)
|
|
43
|
+
// LINKS: M-UUID, V-M-UUID
|
|
44
|
+
// END_CONTRACT: uuidv5
|
|
16
45
|
/** RFC 4122 v5: sha1(namespace + name); детерминирован — одно имя → один uuid. */
|
|
17
46
|
export function uuidv5(name, ns = LETOPIS_NS) {
|
|
18
47
|
const nsBytes = Buffer.from(ns.replace(/-/g, ''), 'hex');
|
|
@@ -21,6 +50,13 @@ export function uuidv5(name, ns = LETOPIS_NS) {
|
|
|
21
50
|
h[8] = (h[8] & 0x3f) | 0x80; // variant 10xx
|
|
22
51
|
return fmt(h);
|
|
23
52
|
}
|
|
53
|
+
// START_CONTRACT: uuidv7
|
|
54
|
+
// PURPOSE: Упорядоченный по времени UUID v7 — 48 бит unix-ms в старших битах + random.
|
|
55
|
+
// INPUTS: { none }
|
|
56
|
+
// OUTPUTS: { string - каноничный UUID v7 (version=7, variant=10xx) }
|
|
57
|
+
// SIDE_EFFECTS: недетерминирован — читает Date.now() и криптослучайные байты (randomBytes)
|
|
58
|
+
// LINKS: M-UUID, V-M-UUID
|
|
59
|
+
// END_CONTRACT: uuidv7
|
|
24
60
|
/** RFC 9562 v7: 48 бит unix-ms + random — время в старших битах, вставки ложатся в хвост индекса. */
|
|
25
61
|
export function uuidv7() {
|
|
26
62
|
const b = Buffer.alloc(16);
|
package/dist/write.js
CHANGED
|
@@ -14,10 +14,41 @@
|
|
|
14
14
|
* .Запись().delete({ confirm: true }).rows() // сегмент 2: снести записи каждого
|
|
15
15
|
* Продолжение после операции идёт ОТ ЕЁ РЕЗУЛЬТАТА (fan-out по записанным строкам).
|
|
16
16
|
*/
|
|
17
|
+
//
|
|
18
|
+
// FILE: lib/src/write.ts
|
|
19
|
+
// VERSION: 1.0.0
|
|
20
|
+
// START_MODULE_CONTRACT
|
|
21
|
+
// PURPOSE: Write-side движок: валидация, резолв концов/слотов, вычисление id (v5/v7), вставка новых версий, каскадное удаление, анонимизация, исполнение планов и батчей в транзакции.
|
|
22
|
+
// SCOPE: ValidationError, runPlan, executeBatch, createOp/updateOp/delOp/anonymizeOp, toRow/readRows/deepMerge, BatchPlan.
|
|
23
|
+
// DEPENDS: M-UUID, M-TYPES, M-SQL, M-ACL
|
|
24
|
+
// LINKS: M-WRITE, V-M-WRITE
|
|
25
|
+
// ROLE: RUNTIME
|
|
26
|
+
// MAP_MODE: EXPORTS
|
|
27
|
+
// END_MODULE_CONTRACT
|
|
28
|
+
//
|
|
29
|
+
// START_MODULE_MAP
|
|
30
|
+
// ValidationError - ошибка валидации с полями issues
|
|
31
|
+
// toRow/readRows/deepMerge - нормализация строки, чтение, deep-merge патча
|
|
32
|
+
// createOp/updateOp/anonymizeOp/delOp - операции записи (create/update/anonymize/delete)
|
|
33
|
+
// runPlan - исполнить план (сегменты op + читающий хвост) одной транзакцией
|
|
34
|
+
// executeBatch - исполнить очередь планов (fast-path multi-insert для чистых INSERT)
|
|
35
|
+
// BatchPlan/PlanMode - модель плана батча и режимы чтения
|
|
36
|
+
// END_MODULE_MAP
|
|
37
|
+
//
|
|
38
|
+
// START_CHANGE_SUMMARY
|
|
39
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
40
|
+
// END_CHANGE_SUMMARY
|
|
17
41
|
import { randomUUID } from 'node:crypto';
|
|
18
42
|
import { uuidv5, uuidv7 } from './uuid.js';
|
|
19
43
|
import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, isTransient, retryDelay, RETRIES, } from './sql.js';
|
|
20
44
|
import { aclDenied } from './acl.js';
|
|
45
|
+
// START_CONTRACT: ValidationError
|
|
46
|
+
// PURPOSE: Ошибка валидации класса, несущая список проблемных полей (issues).
|
|
47
|
+
// INPUTS: { cls: string; issues: {field, message?}[] }
|
|
48
|
+
// OUTPUTS: { Error - message с перечнем «поле — сообщение» }
|
|
49
|
+
// SIDE_EFFECTS: none
|
|
50
|
+
// LINKS: M-WRITE, V-M-WRITE
|
|
51
|
+
// END_CONTRACT: ValidationError
|
|
21
52
|
export class ValidationError extends Error {
|
|
22
53
|
issues;
|
|
23
54
|
constructor(cls, issues) {
|
|
@@ -59,6 +90,14 @@ export function deepMerge(base, patch) {
|
|
|
59
90
|
}
|
|
60
91
|
return out;
|
|
61
92
|
}
|
|
93
|
+
// START_CONTRACT: validate
|
|
94
|
+
// PURPOSE: Проверить данные по fastest-validator (строго, + id) и концы LINK; вернуть очищенные данные.
|
|
95
|
+
// INPUTS: { cls: ClassDef; id: string; data: object; links: object }
|
|
96
|
+
// OUTPUTS: { object - валидные данные (без id) }
|
|
97
|
+
// SIDE_EFFECTS: none
|
|
98
|
+
// ERRORS: class is abstract; ValidationError; link requires end; stray link(s)
|
|
99
|
+
// LINKS: M-WRITE, V-M-WRITE, M-SCHEMA
|
|
100
|
+
// END_CONTRACT: validate
|
|
62
101
|
/** Валидация данных по fastest-validator (строгая, + id) и концов LINK. */
|
|
63
102
|
function validate(cls, id, data, links) {
|
|
64
103
|
if (cls.abstract)
|
|
@@ -68,6 +107,7 @@ function validate(cls, id, data, links) {
|
|
|
68
107
|
if (res !== true)
|
|
69
108
|
throw new ValidationError(cls.id, res);
|
|
70
109
|
delete merged.id;
|
|
110
|
+
// START_BLOCK_VALIDATE_LINK_ENDS
|
|
71
111
|
if (cls.category === 'LINK') {
|
|
72
112
|
const present = new Set(Object.keys(links));
|
|
73
113
|
if (cls.strictEnds) {
|
|
@@ -101,8 +141,16 @@ function validate(cls, id, data, links) {
|
|
|
101
141
|
}
|
|
102
142
|
}
|
|
103
143
|
}
|
|
144
|
+
// END_BLOCK_VALIDATE_LINK_ENDS
|
|
104
145
|
return merged;
|
|
105
146
|
}
|
|
147
|
+
// START_CONTRACT: insertOne
|
|
148
|
+
// PURPOSE: INSERT одной строки-версии с ретраем гонок (23505 коллизия updated, 40P01/40001 transient) вне транзакции.
|
|
149
|
+
// INPUTS: { ctx: Ctx; a: InsertArgs }
|
|
150
|
+
// OUTPUTS: { Promise<Row> - вставленная строка }
|
|
151
|
+
// SIDE_EFFECTS: INSERT в Entity через runQuery
|
|
152
|
+
// LINKS: M-WRITE, V-M-WRITE, M-SQL, M-DDL
|
|
153
|
+
// END_CONTRACT: insertOne
|
|
106
154
|
/** INSERT одной строки с ретраем гонок: 23505 (коллизия updated) и transient (40P01/40001). */
|
|
107
155
|
async function insertOne(ctx, a) {
|
|
108
156
|
// jsonb-параметры — JS-объектами (postgres.js сериализует сам)
|
|
@@ -110,6 +158,7 @@ async function insertOne(ctx, a) {
|
|
|
110
158
|
ctx.partition, a.id, a.cls.id, a.data, a.links, a.tags,
|
|
111
159
|
a.account, a.owner, a.prevUpdated, a.deleted,
|
|
112
160
|
];
|
|
161
|
+
// START_BLOCK_INSERT_ONE_RETRY
|
|
113
162
|
for (let attempt = 1;; attempt++) {
|
|
114
163
|
try {
|
|
115
164
|
const res = await runQuery(ctx, insertSql(ctx.pgSchema), params, 'insert', [a.cls.id]);
|
|
@@ -128,6 +177,7 @@ async function insertOne(ctx, a) {
|
|
|
128
177
|
throw e;
|
|
129
178
|
}
|
|
130
179
|
}
|
|
180
|
+
// END_BLOCK_INSERT_ONE_RETRY
|
|
131
181
|
}
|
|
132
182
|
/**
|
|
133
183
|
* enforceAcl: право операции на класс цели. Предикат победившего правила:
|
|
@@ -136,6 +186,14 @@ async function insertOne(ctx, a) {
|
|
|
136
186
|
* (явный чужой модификатор на цепочке → ошибка).
|
|
137
187
|
* Мутирует step (объект создаётся цепочкой на вызов — локально безопасно).
|
|
138
188
|
*/
|
|
189
|
+
// START_CONTRACT: aclWrite
|
|
190
|
+
// PURPOSE: enforceAcl-проверка права записи/удаления на класс цели; предикат правила пришпиливает owner/account и фильтрует цели.
|
|
191
|
+
// INPUTS: { ctx: Ctx; step: Step; op: 'WRITE'|'DELETE' }
|
|
192
|
+
// OUTPUTS: { void - мутирует step (aclFilter/ownerFilter/accountFilter) }
|
|
193
|
+
// SIDE_EFFECTS: мутирует step
|
|
194
|
+
// ERRORS: acl denies WRITE/DELETE; acl pins writes to owner/account
|
|
195
|
+
// LINKS: M-WRITE, V-M-WRITE, M-ACL
|
|
196
|
+
// END_CONTRACT: aclWrite
|
|
139
197
|
function aclWrite(ctx, step, op) {
|
|
140
198
|
if (!ctx.aclDecide)
|
|
141
199
|
return;
|
|
@@ -188,6 +246,14 @@ function endFor(target, cls) {
|
|
|
188
246
|
* target, обязан дать ровно одну сущность (id-фильтр — как есть; иначе чтение путём
|
|
189
247
|
* от начала до него: контекст честно учитывается).
|
|
190
248
|
*/
|
|
249
|
+
// START_CONTRACT: resolvePathEnds
|
|
250
|
+
// PURPOSE: Связи создаваемой сущности из пути: каждый шаг-предок, чей класс — конец target, должен дать ровно одну сущность.
|
|
251
|
+
// INPUTS: { ctx: Ctx; steps: Step[] }
|
|
252
|
+
// OUTPUTS: { Promise<Record<string,string>> - класс конца → id }
|
|
253
|
+
// SIDE_EFFECTS: чтения контекста (readRows)
|
|
254
|
+
// ERRORS: context step must resolve to exactly one entity
|
|
255
|
+
// LINKS: M-WRITE, V-M-WRITE
|
|
256
|
+
// END_CONTRACT: resolvePathEnds
|
|
191
257
|
async function resolvePathEnds(ctx, steps) {
|
|
192
258
|
const target = steps[steps.length - 1].cls;
|
|
193
259
|
const links = {};
|
|
@@ -213,6 +279,14 @@ async function resolvePathEnds(ctx, steps) {
|
|
|
213
279
|
return links;
|
|
214
280
|
}
|
|
215
281
|
/** Резолв слот-значений: строки как есть; вложенные планы — в той же транзакции, ровно одна сущность. */
|
|
282
|
+
// START_CONTRACT: resolveSlots
|
|
283
|
+
// PURPOSE: Резолв слот-значений (.Класс.set/.unset): строки как есть, вложенные планы — та же транзакция и ровно одна сущность; союз-конец снимается целиком.
|
|
284
|
+
// INPUTS: { ctx: Ctx; target: ClassDef; extra: Step['extraLinks'] }
|
|
285
|
+
// OUTPUTS: { Promise<{ vals: Record<string,string>; drops: Set<string> }> }
|
|
286
|
+
// SIDE_EFFECTS: может исполнять вложенные планы/чтения
|
|
287
|
+
// ERRORS: slot value plan must resolve to exactly one entity; resolved to class "<x>"
|
|
288
|
+
// LINKS: M-WRITE, V-M-WRITE
|
|
289
|
+
// END_CONTRACT: resolveSlots
|
|
216
290
|
async function resolveSlots(ctx, target, extra) {
|
|
217
291
|
const vals = {};
|
|
218
292
|
const drops = new Set();
|
|
@@ -245,11 +319,36 @@ async function resolveSlots(ctx, target, extra) {
|
|
|
245
319
|
}
|
|
246
320
|
/** id новой сущности по IdGen класса (v5 считается отдельно — нужны концы/данные). */
|
|
247
321
|
const genId = (cls) => (cls.idGen.version === 7 ? uuidv7() : randomUUID());
|
|
322
|
+
/**
|
|
323
|
+
* default поля из правила Schema.attributes: объект-правило `{default: …}` или
|
|
324
|
+
* DSL-строка `'string|default:базовая'`. Нужен v5Id: id считается ДО валидации (та
|
|
325
|
+
* подставляет defaults позже), поэтому необязательное from-поле берёт дефолт здесь.
|
|
326
|
+
*/
|
|
327
|
+
function defaultOf(rule) {
|
|
328
|
+
if (rule && typeof rule === 'object' && !Array.isArray(rule) && 'default' in rule) {
|
|
329
|
+
return rule.default;
|
|
330
|
+
}
|
|
331
|
+
if (typeof rule === 'string') {
|
|
332
|
+
const seg = rule.split('|').map((s) => s.trim()).find((s) => s.startsWith('default:'));
|
|
333
|
+
if (seg)
|
|
334
|
+
return seg.slice('default:'.length);
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
248
338
|
/**
|
|
249
339
|
* Детерминированный id (uuid v5): имя = «схема:партиция:класс:значения from».
|
|
250
340
|
* from-источник — конец Schema.links (класс или полное имя союза 'Service|Complex';
|
|
251
|
-
* значение — id присутствующего класса конца) или скалярное поле data
|
|
341
|
+
* значение — id присутствующего класса конца) или скалярное поле data (если не задано —
|
|
342
|
+
* его default из Schema.attributes: id стабилен и без явного ввода необязательного поля).
|
|
252
343
|
*/
|
|
344
|
+
// START_CONTRACT: v5Id
|
|
345
|
+
// PURPOSE: Детерминированный id (uuid v5) из «схема:партиция:класс:значения from» (концы Schema.links или скалярные поля/дефолты).
|
|
346
|
+
// INPUTS: { ctx: Ctx; cls: ClassDef; links: object; data: object }
|
|
347
|
+
// OUTPUTS: { string - uuid v5 }
|
|
348
|
+
// SIDE_EFFECTS: none
|
|
349
|
+
// ERRORS: needs end "<...>"; needs scalar data field "<f>"
|
|
350
|
+
// LINKS: M-WRITE, V-M-WRITE, M-UUID
|
|
351
|
+
// END_CONTRACT: v5Id
|
|
253
352
|
function v5Id(ctx, cls, links, data) {
|
|
254
353
|
const parts = [];
|
|
255
354
|
for (const f of cls.idGen.from) {
|
|
@@ -262,7 +361,7 @@ function v5Id(ctx, cls, links, data) {
|
|
|
262
361
|
parts.push(links[hit]);
|
|
263
362
|
}
|
|
264
363
|
else {
|
|
265
|
-
const v = data[f];
|
|
364
|
+
const v = data[f] === undefined ? defaultOf(cls.attributes[f]) : data[f];
|
|
266
365
|
if (v === undefined || v === null || typeof v === 'object') {
|
|
267
366
|
throw new Error(`letopis: id (uuid v5) of "${cls.id}" needs scalar data field "${f}"`);
|
|
268
367
|
}
|
|
@@ -297,7 +396,16 @@ async function versionRows(ctx, target, found, data, slots) {
|
|
|
297
396
|
* нет → INSERT с этим id (идемпотентный create, REST-PUT семантика);
|
|
298
397
|
* - фильтр-объект / pivot — ошибка: create не ищет, это update().
|
|
299
398
|
*/
|
|
399
|
+
// START_CONTRACT: createOp
|
|
400
|
+
// PURPOSE: .create(data) — INSERT новой сущности либо новая версия при известном/вычисленном id (идемпотентный create); поиск — это update().
|
|
401
|
+
// INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; data: object }
|
|
402
|
+
// OUTPUTS: { Promise<Row[]> }
|
|
403
|
+
// SIDE_EFFECTS: INSERT в Entity (в транзакции runPlan)
|
|
404
|
+
// ERRORS: create() on a pivot; takes no filter; got two ids; computes id (remove explicit); acl denies WRITE
|
|
405
|
+
// LINKS: M-WRITE, V-M-WRITE, M-ACL
|
|
406
|
+
// END_CONTRACT: createOp
|
|
300
407
|
export async function createOp(ctx, steps, mods, data) {
|
|
408
|
+
// START_BLOCK_CREATE_RESOLVE
|
|
301
409
|
const target = steps[steps.length - 1];
|
|
302
410
|
const cls = target.cls;
|
|
303
411
|
aclWrite(ctx, target, 'WRITE');
|
|
@@ -361,12 +469,20 @@ export async function createOp(ctx, steps, mods, data) {
|
|
|
361
469
|
tags: tagsValue(target) ?? [], account, owner, prevUpdated: null, deleted: null,
|
|
362
470
|
}),
|
|
363
471
|
];
|
|
472
|
+
// END_BLOCK_CREATE_RESOLVE
|
|
364
473
|
}
|
|
365
474
|
/**
|
|
366
475
|
* .update(data?) — новая версия КАЖДОГО найденного путём (deep-merge листьев);
|
|
367
476
|
* цели: Класс() ≡ Класс({}) — все в границах контекста, id/фильтр/pivot — как в чтении.
|
|
368
477
|
* Не найдено → [] — update НИКОГДА не создаёт.
|
|
369
478
|
*/
|
|
479
|
+
// START_CONTRACT: updateOp
|
|
480
|
+
// PURPOSE: .update(data?) — новая версия каждого найденного путём (deep-merge листьев); никогда не создаёт.
|
|
481
|
+
// INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; data: object }
|
|
482
|
+
// OUTPUTS: { Promise<Row[]> }
|
|
483
|
+
// SIDE_EFFECTS: INSERT новых версий
|
|
484
|
+
// LINKS: M-WRITE, V-M-WRITE
|
|
485
|
+
// END_CONTRACT: updateOp
|
|
370
486
|
export async function updateOp(ctx, steps, mods, data) {
|
|
371
487
|
const target = steps[steps.length - 1];
|
|
372
488
|
aclWrite(ctx, target, 'WRITE');
|
|
@@ -411,6 +527,7 @@ export async function anonymizeOp(ctx, steps, mods, fields) {
|
|
|
411
527
|
async function inTransaction(ctx, fn) {
|
|
412
528
|
if (ctx.inTx)
|
|
413
529
|
return fn(ctx);
|
|
530
|
+
// START_BLOCK_IN_TRANSACTION_RETRY
|
|
414
531
|
for (let attempt = 1;; attempt++) {
|
|
415
532
|
try {
|
|
416
533
|
return (await ctx.sql.begin(async (tsql) => fn({ ...ctx, sql: tsql, inTx: true })));
|
|
@@ -423,6 +540,7 @@ async function inTransaction(ctx, fn) {
|
|
|
423
540
|
throw e;
|
|
424
541
|
}
|
|
425
542
|
}
|
|
543
|
+
// END_BLOCK_IN_TRANSACTION_RETRY
|
|
426
544
|
}
|
|
427
545
|
/**
|
|
428
546
|
* .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
|
|
@@ -430,6 +548,14 @@ async function inTransaction(ctx, fn) {
|
|
|
430
548
|
* каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
|
|
431
549
|
* Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
|
|
432
550
|
*/
|
|
551
|
+
// START_CONTRACT: delOp
|
|
552
|
+
// PURPOSE: .delete({confirm}) — серверное удаление (tombstone + рекурсивный каскад) при confirm, иначе превью замыкания; DELETE-право на каждый класс каскада.
|
|
553
|
+
// INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; confirm: boolean }
|
|
554
|
+
// OUTPUTS: { Promise<Row[]> - удалённые (цели+каскад) с $deleted, либо превью }
|
|
555
|
+
// SIDE_EFFECTS: DELETE в Entity (триггер entity_delete) при confirm
|
|
556
|
+
// ERRORS: acl denies DELETE (в каскаде)
|
|
557
|
+
// LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL
|
|
558
|
+
// END_CONTRACT: delOp
|
|
433
559
|
export async function delOp(ctx, steps, mods, confirm) {
|
|
434
560
|
const target = steps[steps.length - 1];
|
|
435
561
|
aclWrite(ctx, target, 'DELETE');
|
|
@@ -437,6 +563,7 @@ export async function delOp(ctx, steps, mods, confirm) {
|
|
|
437
563
|
if (!targets.length)
|
|
438
564
|
return [];
|
|
439
565
|
const params = [ctx.partition, target.cls.id, ...targets.map((r) => r.id)];
|
|
566
|
+
// START_BLOCK_DELETE_CASCADE
|
|
440
567
|
return inTransaction(ctx, async (c) => {
|
|
441
568
|
const closure = await runQuery(c, closureSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
|
|
442
569
|
if (!confirm)
|
|
@@ -452,6 +579,7 @@ export async function delOp(ctx, steps, mods, confirm) {
|
|
|
452
579
|
await runQuery(c, deleteSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
|
|
453
580
|
return closure.map((r) => ({ ...toRow(r), $deleted: true }));
|
|
454
581
|
});
|
|
582
|
+
// END_BLOCK_DELETE_CASCADE
|
|
455
583
|
}
|
|
456
584
|
/** Разрезать план: сегменты (…шаги + op-шаг) и читающий хвост после последней операции. */
|
|
457
585
|
function splitPlan(steps) {
|
|
@@ -495,9 +623,17 @@ const RawRowFmt = {
|
|
|
495
623
|
* строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
|
|
496
624
|
* После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
|
|
497
625
|
*/
|
|
626
|
+
// START_CONTRACT: runPlan
|
|
627
|
+
// PURPOSE: Исполнить план целиком (сегменты операций + читающий хвост) одной транзакцией; продолжение = fan-out по строкам результата.
|
|
628
|
+
// INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; mode: PlanMode }
|
|
629
|
+
// OUTPUTS: { Promise<unknown> - результат в запрошенном режиме }
|
|
630
|
+
// SIDE_EFFECTS: записи + чтения в одной транзакции
|
|
631
|
+
// LINKS: M-WRITE, V-M-WRITE, M-SQL
|
|
632
|
+
// END_CONTRACT: runPlan
|
|
498
633
|
export async function runPlan(ctx, steps, mods, mode) {
|
|
499
634
|
const { segments, tail } = splitPlan(steps);
|
|
500
635
|
return inTransaction(ctx, async (c) => {
|
|
636
|
+
// START_BLOCK_RUNPLAN_SEGMENTS
|
|
501
637
|
let last = []; // полный результат последней операции (для терминала)
|
|
502
638
|
let start = []; // строки для продолжения (класс цели)
|
|
503
639
|
for (let i = 0; i < segments.length; i++) {
|
|
@@ -517,6 +653,7 @@ export async function runPlan(ctx, steps, mods, mode) {
|
|
|
517
653
|
}
|
|
518
654
|
start = seg.op.kind === 'delete' ? last.filter((r) => r.class === targetCls.id) : last;
|
|
519
655
|
}
|
|
656
|
+
// END_BLOCK_RUNPLAN_SEGMENTS
|
|
520
657
|
const opStep = segments[segments.length - 1].steps.at(-1);
|
|
521
658
|
const keyName = opStep.aliasKey ?? opStep.name;
|
|
522
659
|
// хвост-чтение от результата последней операции (в той же транзакции)
|
|
@@ -595,6 +732,13 @@ const isPureInsertPlan = (p) => p.steps.length === 1 &&
|
|
|
595
732
|
typeof p.steps[0].op.data?.id !== 'string' &&
|
|
596
733
|
!Object.keys(p.steps[0].extraLinks ?? {}).length &&
|
|
597
734
|
p.steps[0].cls.idGen.version !== 5;
|
|
735
|
+
// START_CONTRACT: executeBatch
|
|
736
|
+
// PURPOSE: Исполнить очередь планов одной транзакцией; подряд идущие чистые INSERT одного класса склеиваются в multi-VALUES.
|
|
737
|
+
// INPUTS: { ctx: Ctx; queue: BatchPlan[] }
|
|
738
|
+
// OUTPUTS: { Promise<Row[][]> - результат по каждому плану }
|
|
739
|
+
// SIDE_EFFECTS: INSERT (multi/по-плану) в транзакции
|
|
740
|
+
// LINKS: M-WRITE, V-M-WRITE, M-SQL
|
|
741
|
+
// END_CONTRACT: executeBatch
|
|
598
742
|
export async function executeBatch(ctx, queue) {
|
|
599
743
|
if (!queue.length)
|
|
600
744
|
return [];
|
|
@@ -602,6 +746,7 @@ export async function executeBatch(ctx, queue) {
|
|
|
602
746
|
const results = new Array(queue.length);
|
|
603
747
|
let i = 0;
|
|
604
748
|
while (i < queue.length) {
|
|
749
|
+
// START_BLOCK_BATCH_FASTPATH
|
|
605
750
|
if (isPureInsertPlan(queue[i])) {
|
|
606
751
|
const cls = queue[i].steps[0].cls;
|
|
607
752
|
let j = i;
|
|
@@ -623,6 +768,7 @@ export async function executeBatch(ctx, queue) {
|
|
|
623
768
|
i = j;
|
|
624
769
|
continue;
|
|
625
770
|
}
|
|
771
|
+
// END_BLOCK_BATCH_FASTPATH
|
|
626
772
|
results[i] = (await runPlan(c, queue[i].steps, {}, 'rows'));
|
|
627
773
|
i++;
|
|
628
774
|
}
|
package/docker/Dockerfile
CHANGED
|
@@ -4,6 +4,28 @@
|
|
|
4
4
|
# docker run -d --name letopis-timescale -p 15432:5432 -p 16379:6379 \
|
|
5
5
|
# -v letopis-pgdata:/var/lib/postgresql/data \
|
|
6
6
|
# -e POSTGRES_PASSWORD=test -e POSTGRES_DB=letopis letopis-db
|
|
7
|
+
# FILE: lib/docker/Dockerfile
|
|
8
|
+
# VERSION: 1.0.0
|
|
9
|
+
# START_MODULE_CONTRACT
|
|
10
|
+
# PURPOSE: Dev-образ БД — TimescaleDB pg17 + Redis в одном контейнере (для letopis.up()).
|
|
11
|
+
# SCOPE: базовый образ, установка redis, копирование start.sh, EXPOSE 5432/6379, entrypoint.
|
|
12
|
+
# DEPENDS: none
|
|
13
|
+
# LINKS: M-DOCKER, V-M-DOCKER
|
|
14
|
+
# ROLE: CONFIG
|
|
15
|
+
# MAP_MODE: SUMMARY
|
|
16
|
+
# END_MODULE_CONTRACT
|
|
17
|
+
#
|
|
18
|
+
# START_MODULE_MAP
|
|
19
|
+
# FROM timescale/timescaledb:latest-pg17 - база (PostgreSQL 17 + TimescaleDB)
|
|
20
|
+
# apk add redis - Redis в тот же образ
|
|
21
|
+
# ENTRYPOINT start-letopis.sh - запуск redis + postgres
|
|
22
|
+
# END_MODULE_MAP
|
|
23
|
+
#
|
|
24
|
+
# START_CHANGE_SUMMARY
|
|
25
|
+
# LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
26
|
+
# END_CHANGE_SUMMARY
|
|
27
|
+
|
|
28
|
+
# START_BLOCK_IMAGE_BUILD
|
|
7
29
|
FROM timescale/timescaledb:latest-pg17
|
|
8
30
|
|
|
9
31
|
RUN apk add --no-cache redis
|
|
@@ -15,3 +37,4 @@ EXPOSE 5432 6379
|
|
|
15
37
|
|
|
16
38
|
ENTRYPOINT ["/usr/local/bin/start-letopis.sh"]
|
|
17
39
|
CMD ["postgres"]
|
|
40
|
+
# END_BLOCK_IMAGE_BUILD
|
package/docker/start.sh
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
+
# FILE: lib/docker/start.sh
|
|
3
|
+
# VERSION: 1.0.0
|
|
4
|
+
# START_MODULE_CONTRACT
|
|
5
|
+
# PURPOSE: Entrypoint dev-контейнера — поднять Redis фоном, затем штатный postgres docker-entrypoint.
|
|
6
|
+
# SCOPE: redis-server (daemonize) + exec docker-entrypoint.sh.
|
|
7
|
+
# DEPENDS: none
|
|
8
|
+
# LINKS: M-DOCKER, V-M-DOCKER
|
|
9
|
+
# ROLE: SCRIPT
|
|
10
|
+
# MAP_MODE: NONE
|
|
11
|
+
# END_MODULE_CONTRACT
|
|
12
|
+
#
|
|
13
|
+
# START_CHANGE_SUMMARY
|
|
14
|
+
# LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
15
|
+
# END_CHANGE_SUMMARY
|
|
2
16
|
# Redis фоном (dev: без пароля и персиста — сессии эфемерны), затем штатный entrypoint postgres.
|
|
3
17
|
redis-server --port 6379 --bind 0.0.0.0 --protected-mode no --save '' --appendonly no --daemonize yes
|
|
4
18
|
exec docker-entrypoint.sh "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|