letopis 0.16.0 → 0.18.1

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/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.16.0",
3
+ "version": "0.18.1",
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",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "files": [
37
37
  "dist",
38
+ "scripts",
38
39
  "sql",
39
40
  "docker",
40
41
  "README.md",