letopis 0.5.0 → 0.16.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 +269 -0
- package/README.md +2472 -240
- package/dist/acl.d.ts +48 -0
- package/dist/acl.js +208 -0
- package/dist/auth.d.ts +116 -0
- package/dist/auth.js +263 -0
- package/dist/chain.d.ts +81 -28
- package/dist/chain.js +312 -72
- package/dist/index.d.ts +10 -3
- package/dist/index.js +26 -3
- package/dist/schema.js +107 -4
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +43 -1
- package/dist/sql.js +238 -79
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +81 -2
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +32 -0
- package/dist/write.d.ts +29 -16
- package/dist/write.js +427 -98
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +404 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +69 -0
package/dist/types.d.ts
CHANGED
|
@@ -16,8 +16,34 @@ export interface Row {
|
|
|
16
16
|
/** Глубина узла при .deep()-обходе (1 = прямой ребёнок). */
|
|
17
17
|
$depth?: number;
|
|
18
18
|
}
|
|
19
|
-
/** Результат
|
|
19
|
+
/** Результат run(): вариант пути — узел на каждый шаг цепочки. */
|
|
20
20
|
export type Path = Record<string, Row>;
|
|
21
|
+
/**
|
|
22
|
+
* Конец связи класса (Schema.links v2). В БД элемент text[]: JSON-объект
|
|
23
|
+
* `{"class":"Org","cardinality":1}` / `{"classes":["Service","Complex"],"cardinality":1}`
|
|
24
|
+
* (союз ролей); legacy-строка 'Org' — сахар для {classes:['Org']} (старые схемы).
|
|
25
|
+
*/
|
|
26
|
+
export interface LinkEnd {
|
|
27
|
+
/** Допустимые классы конца; ровно один из них присутствует в Entity.links. */
|
|
28
|
+
classes: string[];
|
|
29
|
+
/** Конец может отсутствовать. Default false. */
|
|
30
|
+
optional?: boolean;
|
|
31
|
+
/** ЗАРЕЗЕРВИРОВАНО (не имплементировано): 0 — безлимит, N — точное число. Default 1. */
|
|
32
|
+
cardinality?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Генерация id класса — из attributes.id:
|
|
36
|
+
* "uuid" | {type:'uuid'} → v4 (random, дефолт);
|
|
37
|
+
* {type:'uuid', generate: 7} → v7 (время в старших битах);
|
|
38
|
+
* {type:'uuid', generate: 5, from: ['Slot','Staff']} → v5: детерминированный id из значений
|
|
39
|
+
* from — имена ОБЯЗАТЕЛЬНЫХ концов Schema.links (класс или полное имя союза
|
|
40
|
+
* 'Service|Complex') и/или скалярных полей data.
|
|
41
|
+
*/
|
|
42
|
+
export interface IdGen {
|
|
43
|
+
version: 4 | 5 | 7;
|
|
44
|
+
/** Только v5: источники имени (порядок значим). */
|
|
45
|
+
from?: string[];
|
|
46
|
+
}
|
|
21
47
|
/** Класс из таблицы Schema. */
|
|
22
48
|
export interface ClassDef {
|
|
23
49
|
id: string;
|
|
@@ -27,11 +53,20 @@ export interface ClassDef {
|
|
|
27
53
|
ancestors: string[];
|
|
28
54
|
/** Все потомки (транзитивно) — считает триггер schema_lineage. */
|
|
29
55
|
descendants: string[];
|
|
56
|
+
/** Поля data с НАСЛЕДОВАНИЕМ по ancestor-цепочке (потомок поверх предка). */
|
|
30
57
|
attributes: Record<string, unknown>;
|
|
31
|
-
links:
|
|
58
|
+
links: LinkEnd[];
|
|
59
|
+
/**
|
|
60
|
+
* true — хотя бы один конец объявлен объектом (схема v2): строгая валидация
|
|
61
|
+
* (жадный матчинг по порядку, союзы, optional, лишние связи — ошибка).
|
|
62
|
+
* false — все концы legacy-строками: старое поведение ('Entity' = полиморф, лишние молчат).
|
|
63
|
+
*/
|
|
64
|
+
strictEnds: boolean;
|
|
32
65
|
meta: Record<string, unknown>;
|
|
33
66
|
abstract: boolean;
|
|
34
67
|
order: number;
|
|
68
|
+
/** Как генерить id новой сущности (attributes.id). */
|
|
69
|
+
idGen: IdGen;
|
|
35
70
|
/** Скомпилированный fastest-validator: true | ошибки. */
|
|
36
71
|
check: (data: Record<string, unknown>) => true | {
|
|
37
72
|
field: string;
|
|
@@ -53,6 +88,9 @@ export type FieldType = {
|
|
|
53
88
|
} | {
|
|
54
89
|
kind: 'record';
|
|
55
90
|
value: FieldType;
|
|
91
|
+
} | {
|
|
92
|
+
kind: 'object';
|
|
93
|
+
props: Map<string, FieldType>;
|
|
56
94
|
} | {
|
|
57
95
|
kind: 'any';
|
|
58
96
|
};
|
|
@@ -81,6 +119,22 @@ export interface Cursor {
|
|
|
81
119
|
v: string | number;
|
|
82
120
|
id: string;
|
|
83
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Операция записи, привязанная к шагу цепочки:
|
|
124
|
+
* .create() / .update() / .delete() / .anonymize().
|
|
125
|
+
* Терминал исполняет план (все операции + финальное чтение) одной транзакцией.
|
|
126
|
+
*/
|
|
127
|
+
export interface PlanOp {
|
|
128
|
+
kind: 'create' | 'update' | 'delete' | 'anonymize';
|
|
129
|
+
/** create/update: данные новой версии (deep-merge листьев). */
|
|
130
|
+
data?: Record<string, unknown>;
|
|
131
|
+
/** anonymize: string-поля под '[erased]'. */
|
|
132
|
+
fields?: string[];
|
|
133
|
+
/** delete: true — удалить; без confirm — превью (вернуть кандидатов, БД не трогать). */
|
|
134
|
+
confirm?: boolean;
|
|
135
|
+
/** Снапшот модификаторов на момент вызова операции (limit/sort для поиска целей). */
|
|
136
|
+
mods: ChainMods;
|
|
137
|
+
}
|
|
84
138
|
/** Модификаторы выборки цепочки: .limit() / .offset() / .sort() / .asOf() / .after(). */
|
|
85
139
|
export interface ChainMods {
|
|
86
140
|
limit?: number;
|
|
@@ -131,6 +185,23 @@ export interface Rule {
|
|
|
131
185
|
meta: Record<string, unknown> | null;
|
|
132
186
|
enabled: boolean;
|
|
133
187
|
}
|
|
188
|
+
/** Операция над данными = категория Resource: 'READ' | 'WRITE' | 'DELETE'. */
|
|
189
|
+
export type AclOp = 'READ' | 'WRITE' | 'DELETE';
|
|
190
|
+
/** Решение ACL: победившее правило (max weight; при равенстве deny; нет правил — deny). */
|
|
191
|
+
export interface AclDecision {
|
|
192
|
+
allow: boolean;
|
|
193
|
+
/** Победившее правило (нет — deny «no matching rule»). */
|
|
194
|
+
rule?: Rule;
|
|
195
|
+
/**
|
|
196
|
+
* Остаточный шаблон строки Entity из pattern победившего allow-ресурса
|
|
197
|
+
* (ключи-колонки без class, "$account" уже подставлен) — вливается в SQL
|
|
198
|
+
* до сортировки/лимита. Отсутствует = класс целиком.
|
|
199
|
+
*/
|
|
200
|
+
filter?: Record<string, unknown>;
|
|
201
|
+
/** Из meta deny-правила: код и сообщение для ответа клиенту. */
|
|
202
|
+
code?: number;
|
|
203
|
+
message?: string;
|
|
204
|
+
}
|
|
134
205
|
/** Событие хука onQuery: один SQL-запрос цепочки (чтение или запись). */
|
|
135
206
|
export interface QueryEvent {
|
|
136
207
|
/** Режим чтения или write-операция. */
|
|
@@ -159,6 +230,14 @@ export interface ConnectOpts {
|
|
|
159
230
|
max?: number;
|
|
160
231
|
/** Жёсткая изоляция арендатора: чтения фильтруются по account, записи пришпилены к нему. */
|
|
161
232
|
enforceAccount?: boolean;
|
|
233
|
+
/**
|
|
234
|
+
* ACL по Resource/Rule: категории READ/WRITE/DELETE, pattern — шаблон строки Entity
|
|
235
|
+
* (колонки + "$account"). Каждый шаг цепочки проверяется на READ, записи — WRITE,
|
|
236
|
+
* delete — DELETE (включая классы каскада); предикат победившего правила вливается
|
|
237
|
+
* в SQL до сортировки/лимита. Требует account. Deny-by-default.
|
|
238
|
+
* Правила читаются один раз при connect (перечитка — новый connect).
|
|
239
|
+
*/
|
|
240
|
+
enforceAcl?: boolean;
|
|
162
241
|
/** Хук на каждый запрос цепочки (метрики, лог). */
|
|
163
242
|
onQuery?: (e: QueryEvent) => void;
|
|
164
243
|
/** Порог «медленного» запроса, мс: событие получает slow: true; без onQuery — console.warn. */
|
package/dist/up.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EntityDb } from './chain.js';
|
|
2
|
+
import type { ConnectOpts } from './types.js';
|
|
3
|
+
export interface UpOpts extends Omit<ConnectOpts, 'dsn' | 'schema'> {
|
|
4
|
+
/** postgres://user:pass@host:port/db. Default postgres://postgres:test@localhost:15432/letopis. */
|
|
5
|
+
dsn?: string;
|
|
6
|
+
/** Базовое имя схемы БЕЗ версии и точек (напр. 'booking'). */
|
|
7
|
+
schema: string;
|
|
8
|
+
/** Версия движка: итоговая PG-схема "v<N>.<schema>". Бамп руками при breaking-изменении DDL. */
|
|
9
|
+
version: number;
|
|
10
|
+
/** Имя dev-контейнера. Default 'letopis-timescale'. */
|
|
11
|
+
container?: string;
|
|
12
|
+
/** Имя docker-образа; при отсутствии соберётся из пакованного Dockerfile. Default 'letopis-db'. */
|
|
13
|
+
image?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Где держать данные PG: путь на хосте (bind mount, на Windows/NTFS — на свой риск)
|
|
16
|
+
* либо имя docker-volume. Default — named volume 'letopis-pgdata' (кроссплатформенно;
|
|
17
|
+
* переживает пересоздание контейнера).
|
|
18
|
+
*/
|
|
19
|
+
dataDir?: string;
|
|
20
|
+
/** Хост-порт Redis контейнера. Default 16379. */
|
|
21
|
+
redisPort?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Сиды после ddl: массив путей — свои файлы (маркер "<SCHEMA-NAME>" в них поддержан);
|
|
24
|
+
* default — демо booking + auth. Свой сид должен дать хотя бы один класс в Schema
|
|
25
|
+
* (иначе connect в конце честно упадёт «has no classes»); false — голая структура
|
|
26
|
+
* без сидов, годится только чтобы поставить движок — connect не переживёт.
|
|
27
|
+
*/
|
|
28
|
+
seeds?: string[] | false;
|
|
29
|
+
/** Дропнуть схему и накатить заново. ДАННЫЕ СХЕМЫ ТЕРЯЮТСЯ. */
|
|
30
|
+
fresh?: boolean;
|
|
31
|
+
/** Без console.log-прогресса. */
|
|
32
|
+
quiet?: boolean;
|
|
33
|
+
/** Максимум ожидания готовности, мс. Default 120 000 (первый запуск: pull образа + initdb). */
|
|
34
|
+
waitTimeoutMs?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RunCfg {
|
|
37
|
+
container: string;
|
|
38
|
+
image: string;
|
|
39
|
+
pgPort: string;
|
|
40
|
+
redisPort: number;
|
|
41
|
+
user: string;
|
|
42
|
+
password: string;
|
|
43
|
+
database: string;
|
|
44
|
+
dataDir?: string;
|
|
45
|
+
}
|
|
46
|
+
/** argv для docker run (чистая функция — покрыта юнитом). dataDir: путь → bind, имя → volume. */
|
|
47
|
+
export declare function dockerRunArgs(cfg: RunCfg): string[];
|
|
48
|
+
export declare function up(opts: UpOpts): Promise<EntityDb>;
|
package/dist/up.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* letopis.up(): одна точка входа — dev-контейнер (TimescaleDB + Redis), готовность БД,
|
|
3
|
+
* версионная схема с сидами, connect. Идемпотентно: живой postgres на dsn → docker
|
|
4
|
+
* пропускается целиком; существующая схема не трогается (fresh: true — дроп и накат).
|
|
5
|
+
*
|
|
6
|
+
* const db = await up({ schema: 'booking', version: 1 }); // → PG-схема "v1.booking"
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import postgres from 'postgres';
|
|
16
|
+
import { connect } from './index.js';
|
|
17
|
+
const exec = promisify(execFile);
|
|
18
|
+
const DEFAULT_DSN = 'postgres://postgres:test@localhost:15432/letopis';
|
|
19
|
+
const qi = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
20
|
+
const pkgPath = (rel) => fileURLToPath(new URL(rel, import.meta.url));
|
|
21
|
+
/** argv для docker run (чистая функция — покрыта юнитом). dataDir: путь → bind, имя → volume. */
|
|
22
|
+
export function dockerRunArgs(cfg) {
|
|
23
|
+
const args = ['run', '-d', '--name', cfg.container,
|
|
24
|
+
'-p', `${cfg.pgPort}:5432`, '-p', `${cfg.redisPort}:6379`,
|
|
25
|
+
'-v', `${cfg.dataDir ?? 'letopis-pgdata'}:/var/lib/postgresql/data`];
|
|
26
|
+
if (cfg.user && cfg.user !== 'postgres')
|
|
27
|
+
args.push('-e', `POSTGRES_USER=${cfg.user}`);
|
|
28
|
+
// без пароля в dsn контейнер поднимается в trust-режиме (dev), иначе postgres-образ не стартует
|
|
29
|
+
args.push('-e', cfg.password ? `POSTGRES_PASSWORD=${cfg.password}` : 'POSTGRES_HOST_AUTH_METHOD=trust');
|
|
30
|
+
args.push('-e', `POSTGRES_DB=${cfg.database}`, cfg.image);
|
|
31
|
+
return args;
|
|
32
|
+
}
|
|
33
|
+
async function docker(args) {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout, stderr } = await exec('docker', args, { maxBuffer: 64 * 1024 * 1024 });
|
|
36
|
+
return { ok: true, out: stdout.trim(), err: stderr };
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
const x = e;
|
|
40
|
+
if (x.code === 'ENOENT')
|
|
41
|
+
throw new Error('letopis.up: docker CLI not found — install Docker or point dsn at an already running postgres');
|
|
42
|
+
return { ok: false, out: (x.stdout ?? '').trim(), err: (x.stderr || x.message || '').trim() };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function ensureContainer(cfg, log) {
|
|
46
|
+
const st = await docker(['container', 'inspect', '--format', '{{.State.Running}}', cfg.container]);
|
|
47
|
+
if (st.ok && st.out === 'true') {
|
|
48
|
+
log(`container ${cfg.container} is running — reused`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (st.ok) {
|
|
52
|
+
const r = await docker(['start', cfg.container]);
|
|
53
|
+
if (!r.ok)
|
|
54
|
+
throw new Error(`letopis.up: docker start ${cfg.container} failed: ${r.err}`);
|
|
55
|
+
log(`container ${cfg.container} started`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const img = await docker(['image', 'inspect', cfg.image]);
|
|
59
|
+
if (!img.ok) {
|
|
60
|
+
log(`building image ${cfg.image} (first time pulls the timescaledb base — may take minutes)…`);
|
|
61
|
+
const b = await docker(['build', '-t', cfg.image, pkgPath('../docker/')]);
|
|
62
|
+
if (!b.ok)
|
|
63
|
+
throw new Error(`letopis.up: docker build ${cfg.image} failed: ${b.err}`);
|
|
64
|
+
log(`image ${cfg.image} built`);
|
|
65
|
+
}
|
|
66
|
+
const r = await docker(dockerRunArgs(cfg));
|
|
67
|
+
if (!r.ok)
|
|
68
|
+
throw new Error(`letopis.up: docker run ${cfg.container} failed: ${r.err}`);
|
|
69
|
+
log(`container ${cfg.container} created (data: ${cfg.dataDir ?? 'volume letopis-pgdata'})`);
|
|
70
|
+
}
|
|
71
|
+
async function pgPing(dsn) {
|
|
72
|
+
const sql = postgres(dsn, { max: 1, connect_timeout: 3, onnotice: () => { } });
|
|
73
|
+
try {
|
|
74
|
+
await sql `select 1`;
|
|
75
|
+
return { ok: true };
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
const code = e.code;
|
|
79
|
+
if (code === '3D000')
|
|
80
|
+
return { ok: false, kind: 'no-database', err: e }; // сервер жив, базы нет
|
|
81
|
+
if (code === '57P03')
|
|
82
|
+
return { ok: false, kind: 'starting', err: e }; // initdb/recovery
|
|
83
|
+
if (code === '28P01' || code === '28000')
|
|
84
|
+
return { ok: false, kind: 'fatal', err: e }; // ретраи бессмысленны
|
|
85
|
+
return { ok: false, kind: 'no-server', err: e };
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await sql.end({ timeout: 1 }).catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function ensureDatabase(url, log) {
|
|
92
|
+
const dbName = decodeURIComponent(url.pathname.slice(1)) || url.username || 'postgres';
|
|
93
|
+
const admin = new URL(url);
|
|
94
|
+
admin.pathname = '/postgres';
|
|
95
|
+
const sql = postgres(admin.toString(), { max: 1, onnotice: () => { } });
|
|
96
|
+
try {
|
|
97
|
+
const rows = await sql `SELECT 1 FROM pg_database WHERE datname = ${dbName}`;
|
|
98
|
+
if (rows.length === 0) {
|
|
99
|
+
await sql.unsafe(`CREATE DATABASE ${qi(dbName)}`).catch((e) => {
|
|
100
|
+
if (e.code !== '42P04')
|
|
101
|
+
throw e; // 42P04 — гонка параллельных up()
|
|
102
|
+
});
|
|
103
|
+
log(`database "${dbName}" created`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await sql.end();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function tcpAlive(host, port) {
|
|
111
|
+
return new Promise((res) => {
|
|
112
|
+
const s = net.connect({ host, port });
|
|
113
|
+
const done = (v) => { s.destroy(); res(v); };
|
|
114
|
+
s.once('connect', () => done(true));
|
|
115
|
+
s.once('error', () => done(false));
|
|
116
|
+
s.setTimeout(2000, () => done(false));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function applySchema(dsn, full, seeds, fresh, log) {
|
|
120
|
+
const sql = postgres(dsn, { max: 1, onnotice: () => { } });
|
|
121
|
+
try {
|
|
122
|
+
if (fresh) {
|
|
123
|
+
await sql.unsafe(`DROP SCHEMA IF EXISTS ${qi(full)} CASCADE`);
|
|
124
|
+
log(`schema "${full}" dropped (fresh)`);
|
|
125
|
+
}
|
|
126
|
+
const have = await sql `SELECT 1 FROM information_schema.schemata WHERE schema_name = ${full}`;
|
|
127
|
+
if (have.length > 0) {
|
|
128
|
+
log(`schema "${full}" already exists — apply skipped`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const files = [pkgPath('../sql/ddl.sql')];
|
|
132
|
+
if (seeds === undefined)
|
|
133
|
+
files.push(pkgPath('../sql/seed.booking.sql'), pkgPath('../sql/seed.auth.sql'));
|
|
134
|
+
else if (seeds)
|
|
135
|
+
files.push(...seeds.map((p) => resolve(p)));
|
|
136
|
+
for (const f of files) {
|
|
137
|
+
const text = (await readFile(f, 'utf8')).replaceAll('<SCHEMA-NAME>', full);
|
|
138
|
+
await sql.unsafe(text); // multi-statement simple query
|
|
139
|
+
}
|
|
140
|
+
log(`schema "${full}" applied (${files.length} files)`);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await sql.end();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export async function up(opts) {
|
|
147
|
+
const { dsn = DEFAULT_DSN, schema, version, container = 'letopis-timescale', image = 'letopis-db', dataDir, redisPort = 16379, seeds, fresh, quiet, waitTimeoutMs = 120_000, ...connectRest } = opts;
|
|
148
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(schema))
|
|
149
|
+
throw new Error(`letopis.up: bad schema name "${schema}" (базовое имя без версии и точек; версию задаёт version)`);
|
|
150
|
+
if (!Number.isInteger(version) || version < 1)
|
|
151
|
+
throw new Error(`letopis.up: bad version ${version} (целое ≥ 1)`);
|
|
152
|
+
const full = `v${version}.${schema}`;
|
|
153
|
+
const url = new URL(dsn);
|
|
154
|
+
const host = url.hostname;
|
|
155
|
+
const local = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
156
|
+
const log = quiet ? () => { } : (m) => console.log(`[letopis.up] ${m}`);
|
|
157
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
158
|
+
let ping = await pgPing(dsn);
|
|
159
|
+
if (ping.ok || ping.kind === 'no-database') {
|
|
160
|
+
// postgres уже отвечает (свой контейнер, CI-сервис, внешняя БД) — docker не нужен
|
|
161
|
+
if (!ping.ok)
|
|
162
|
+
await ensureDatabase(url, log);
|
|
163
|
+
log(`postgres is up at ${host}:${url.port || '5432'} — docker skipped`);
|
|
164
|
+
}
|
|
165
|
+
else if (ping.kind === 'fatal') {
|
|
166
|
+
throw ping.err;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
if (local) {
|
|
170
|
+
await ensureContainer({
|
|
171
|
+
container, image, pgPort: url.port || '5432', redisPort,
|
|
172
|
+
user: decodeURIComponent(url.username), password: decodeURIComponent(url.password),
|
|
173
|
+
database: decodeURIComponent(url.pathname.slice(1)) || url.username || 'postgres', dataDir,
|
|
174
|
+
}, log);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
log(`waiting for remote postgres at ${host} (no docker for non-local dsn)…`);
|
|
178
|
+
}
|
|
179
|
+
const t0 = Date.now();
|
|
180
|
+
for (;;) {
|
|
181
|
+
ping = await pgPing(dsn);
|
|
182
|
+
if (ping.ok)
|
|
183
|
+
break;
|
|
184
|
+
if (ping.kind === 'fatal')
|
|
185
|
+
throw ping.err;
|
|
186
|
+
if (ping.kind === 'no-database') {
|
|
187
|
+
await ensureDatabase(url, log);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (Date.now() > deadline)
|
|
191
|
+
throw new Error(`letopis.up: postgres not ready in ${Math.round(waitTimeoutMs / 1000)} s${local ? ` — check: docker logs ${container}` : ''}`);
|
|
192
|
+
await sleep(500);
|
|
193
|
+
}
|
|
194
|
+
log(`postgres ready in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
|
|
195
|
+
if (local) {
|
|
196
|
+
// контейнер поднимали мы — убеждаемся, что и redis-порт жив
|
|
197
|
+
while (!(await tcpAlive(host, redisPort))) {
|
|
198
|
+
if (Date.now() > deadline)
|
|
199
|
+
throw new Error(`letopis.up: redis not ready on ${redisPort} — check: docker logs ${container}`);
|
|
200
|
+
await sleep(300);
|
|
201
|
+
}
|
|
202
|
+
log(`redis ready on ${redisPort}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
await applySchema(dsn, full, seeds, fresh ?? false, log);
|
|
206
|
+
const db = await connect({ ...connectRest, dsn, schema: full });
|
|
207
|
+
log(`connected (schema "${full}")`);
|
|
208
|
+
return db;
|
|
209
|
+
}
|
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,32 @@
|
|
|
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
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
10
|
+
/** Namespace letopis для uuidv5 (фиксированная константа — стабильность id между релизами). */
|
|
11
|
+
export const LETOPIS_NS = 'c7a2f9d4-3b61-4e8a-9f05-8d2c1e6b7a90';
|
|
12
|
+
const fmt = (b) => {
|
|
13
|
+
const h = b.toString('hex');
|
|
14
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
15
|
+
};
|
|
16
|
+
/** RFC 4122 v5: sha1(namespace + name); детерминирован — одно имя → один uuid. */
|
|
17
|
+
export function uuidv5(name, ns = LETOPIS_NS) {
|
|
18
|
+
const nsBytes = Buffer.from(ns.replace(/-/g, ''), 'hex');
|
|
19
|
+
const h = createHash('sha1').update(nsBytes).update(name, 'utf8').digest().subarray(0, 16);
|
|
20
|
+
h[6] = (h[6] & 0x0f) | 0x50; // version 5
|
|
21
|
+
h[8] = (h[8] & 0x3f) | 0x80; // variant 10xx
|
|
22
|
+
return fmt(h);
|
|
23
|
+
}
|
|
24
|
+
/** RFC 9562 v7: 48 бит unix-ms + random — время в старших битах, вставки ложатся в хвост индекса. */
|
|
25
|
+
export function uuidv7() {
|
|
26
|
+
const b = Buffer.alloc(16);
|
|
27
|
+
b.writeUIntBE(Date.now(), 0, 6);
|
|
28
|
+
randomBytes(10).copy(b, 6);
|
|
29
|
+
b[6] = (b[6] & 0x0f) | 0x70; // version 7
|
|
30
|
+
b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
|
|
31
|
+
return fmt(b);
|
|
32
|
+
}
|
package/dist/write.d.ts
CHANGED
|
@@ -31,30 +31,43 @@ 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).
|
|
43
50
|
*/
|
|
44
51
|
export declare function anonymizeOp(ctx: Ctx, steps: Step[], mods: ChainMods, fields: string[]): Promise<Row[]>;
|
|
45
52
|
/**
|
|
46
|
-
* .delete(): цели = фильтр последнего шага +
|
|
47
|
-
*
|
|
48
|
-
*
|
|
53
|
+
* .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
|
|
54
|
+
* confirm: true — серверное удаление (триггер entity_delete: tombstone + рекурсивный
|
|
55
|
+
* каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
|
|
56
|
+
* Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
|
|
57
|
+
*/
|
|
58
|
+
export declare function delOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
|
|
59
|
+
export type PlanMode = 'rows' | 'ids' | 'count' | 'paths' | 'versions' | 'agg';
|
|
60
|
+
/**
|
|
61
|
+
* Исполнить план целиком: все операции + финальное чтение — одна транзакция
|
|
62
|
+
* (внутренняя, с ретраем transient; внутри db.begin() — транзакция пользователя).
|
|
63
|
+
* Продолжение после операции: fan-out — каждый следующий сегмент исполняется от каждой
|
|
64
|
+
* строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
|
|
65
|
+
* После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
|
|
49
66
|
*/
|
|
50
|
-
export declare function
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
export declare function runPlan(ctx: Ctx, steps: Step[], mods: ChainMods, mode: PlanMode): Promise<unknown>;
|
|
68
|
+
/** План в очереди батча; steps обновляется по мере роста цепочки. */
|
|
69
|
+
export interface BatchPlan {
|
|
53
70
|
steps: Step[];
|
|
54
|
-
mods: ChainMods;
|
|
55
|
-
data?: Record<string, unknown>;
|
|
56
|
-
/** Связи, довешенные после set() (мутируется билдером до execute()). */
|
|
57
|
-
extraLinks?: Record<string, string>;
|
|
58
71
|
}
|
|
59
|
-
export declare function executeBatch(ctx: Ctx, queue:
|
|
72
|
+
export declare function executeBatch(ctx: Ctx, queue: BatchPlan[]): Promise<Row[][]>;
|
|
60
73
|
export {};
|