letopis 0.13.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/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.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Namespace letopis для uuidv5 (фиксированная константа — стабильность id между релизами). */
2
+ export declare const LETOPIS_NS = "c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90";
3
+ /** RFC 4122 v5: sha1(namespace + name); детерминирован — одно имя → один uuid. */
4
+ export declare function uuidv5(name: string, ns?: string): string;
5
+ /** RFC 9562 v7: 48 бит unix-ms + random — время в старших битах, вставки ложатся в хвост индекса. */
6
+ export declare function uuidv7(): string;
package/dist/uuid.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Генерация id по Schema (attributes.id):
3
+ * "uuid" → v4 (random) — дефолт;
4
+ * { type: 'uuid', generate: 7 } → v7 (unix-время в старших битах: btree-локальность);
5
+ * { type: 'uuid', generate: 5, from: […] } → v5: sha1 от «схема:партиция:класс:значения from» —
6
+ * детерминированный id из концов/полей: та же комбинация → тот же id (идемпотентный create,
7
+ * дубль невозможен даже в гонке — оба запроса вычислят один id, второй станет версией).
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
31
+ import { createHash, randomBytes } from 'node:crypto';
32
+ /** Namespace letopis для uuidv5 (фиксированная константа — стабильность id между релизами). */
33
+ export const LETOPIS_NS = 'c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90';
34
+ const fmt = (b) => {
35
+ const h = b.toString('hex');
36
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
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
45
+ /** RFC 4122 v5: sha1(namespace + name); детерминирован — одно имя → один uuid. */
46
+ export function uuidv5(name, ns = LETOPIS_NS) {
47
+ const nsBytes = Buffer.from(ns.replace(/-/g, ''), 'hex');
48
+ const h = createHash('sha1').update(nsBytes).update(name, 'utf8').digest().subarray(0, 16);
49
+ h[6] = (h[6] & 0x0f) | 0x50; // version 5
50
+ h[8] = (h[8] & 0x3f) | 0x80; // variant 10xx
51
+ return fmt(h);
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
60
+ /** RFC 9562 v7: 48 бит unix-ms + random — время в старших битах, вставки ложатся в хвост индекса. */
61
+ export function uuidv7() {
62
+ const b = Buffer.alloc(16);
63
+ b.writeUIntBE(Date.now(), 0, 6);
64
+ randomBytes(10).copy(b, 6);
65
+ b[6] = (b[6] & 0x0f) | 0x70; // version 7
66
+ b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
67
+ return fmt(b);
68
+ }
package/dist/write.d.ts CHANGED
@@ -31,12 +31,19 @@ export declare function readRows(ctx: Ctx, steps: Step[], mods?: ChainMods): Pro
31
31
  */
32
32
  export declare function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
33
33
  /**
34
- * .set(data):
35
- * - пустой фильтр последнего шага и нет data.id → INSERT (links = контекст);
36
- * - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
37
- * - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
34
+ * .create(data) — «чтобы сущность существовала»:
35
+ * - id не задан → INSERT (id по IdGen класса; links = концы из пути + слоты);
36
+ * - id известен (Класс(id) / data.id / вычислен v5): есть → новая версия (deep-merge),
37
+ * нет → INSERT с этим id (идемпотентный create, REST-PUT семантика);
38
+ * - фильтр-объект / pivot — ошибка: create не ищет, это update().
38
39
  */
39
- export declare function setOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
40
+ export declare function createOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
41
+ /**
42
+ * .update(data?) — новая версия КАЖДОГО найденного путём (deep-merge листьев);
43
+ * цели: Класс() ≡ Класс({}) — все в границах контекста, id/фильтр/pivot — как в чтении.
44
+ * Не найдено → [] — update НИКОГДА не создаёт.
45
+ */
46
+ export declare function updateOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
40
47
  /**
41
48
  * .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
42
49
  * + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).