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/CHANGELOG.md +203 -3
- package/README.md +825 -602
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +309 -26
- package/dist/index.d.ts +7 -0
- package/dist/index.js +38 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +169 -4
- package/dist/sessions.js +66 -0
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +161 -25
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.d.ts +40 -4
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +68 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +359 -95
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +124 -12
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +64 -17
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
|
-
* .
|
|
35
|
-
* -
|
|
36
|
-
* - id (
|
|
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
|
|
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).
|