letopis 0.5.0 → 0.13.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 +171 -0
- package/README.md +2280 -211
- 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 +51 -26
- package/dist/chain.js +143 -74
- package/dist/index.d.ts +9 -3
- package/dist/index.js +24 -3
- package/dist/schema.js +11 -0
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +23 -1
- package/dist/sql.js +167 -55
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +44 -1
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/write.d.ts +18 -12
- package/dist/write.js +247 -36
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +362 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +64 -0
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/write.d.ts
CHANGED
|
@@ -36,25 +36,31 @@ export declare function deepMerge(base: Record<string, unknown>, patch: Record<s
|
|
|
36
36
|
* - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
|
|
37
37
|
* - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
|
|
38
38
|
*/
|
|
39
|
-
export declare function setOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown
|
|
39
|
+
export declare function setOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
|
|
40
40
|
/**
|
|
41
41
|
* .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
|
|
42
42
|
* + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).
|
|
43
43
|
*/
|
|
44
44
|
export declare function anonymizeOp(ctx: Ctx, steps: Step[], mods: ChainMods, fields: string[]): Promise<Row[]>;
|
|
45
45
|
/**
|
|
46
|
-
* .delete(): цели = фильтр последнего шага +
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
|
|
47
|
+
* confirm: true — серверное удаление (триггер entity_delete: tombstone + рекурсивный
|
|
48
|
+
* каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
|
|
49
|
+
* Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
|
|
49
50
|
*/
|
|
50
|
-
export declare function delOp(ctx: Ctx, steps: Step[], mods: ChainMods): Promise<Row[]>;
|
|
51
|
-
export
|
|
52
|
-
|
|
51
|
+
export declare function delOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
|
|
52
|
+
export type PlanMode = 'rows' | 'ids' | 'count' | 'paths' | 'versions' | 'agg';
|
|
53
|
+
/**
|
|
54
|
+
* Исполнить план целиком: все операции + финальное чтение — одна транзакция
|
|
55
|
+
* (внутренняя, с ретраем transient; внутри db.begin() — транзакция пользователя).
|
|
56
|
+
* Продолжение после операции: fan-out — каждый следующий сегмент исполняется от каждой
|
|
57
|
+
* строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
|
|
58
|
+
* После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
|
|
59
|
+
*/
|
|
60
|
+
export declare function runPlan(ctx: Ctx, steps: Step[], mods: ChainMods, mode: PlanMode): Promise<unknown>;
|
|
61
|
+
/** План в очереди батча; steps обновляется по мере роста цепочки. */
|
|
62
|
+
export interface BatchPlan {
|
|
53
63
|
steps: Step[];
|
|
54
|
-
mods: ChainMods;
|
|
55
|
-
data?: Record<string, unknown>;
|
|
56
|
-
/** Связи, довешенные после set() (мутируется билдером до execute()). */
|
|
57
|
-
extraLinks?: Record<string, string>;
|
|
58
64
|
}
|
|
59
|
-
export declare function executeBatch(ctx: Ctx, queue:
|
|
65
|
+
export declare function executeBatch(ctx: Ctx, queue: BatchPlan[]): Promise<Row[][]>;
|
|
60
66
|
export {};
|
package/dist/write.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Запись:
|
|
2
|
+
* Запись: операции .set(data) / .delete(opts) / .anonymize(fields) — ЗВЕНЬЯ цепочки;
|
|
3
|
+
* терминал (rows/first/ids/count/run/versions/агрегации) исполняет весь план
|
|
4
|
+
* ОДНОЙ транзакцией (runPlan) — любой отказ (валидация/ACL) откатывает всё.
|
|
3
5
|
*
|
|
4
|
-
* Связи задаются dot-цепочкой:
|
|
6
|
+
* Связи задаются dot-цепочкой: шаги до операции (контекст) резолвятся в ровно одну
|
|
5
7
|
* сущность каждый и дают:
|
|
6
8
|
* - containment-фильтр целей при UPDATE/DELETE (links ⊇ {Класс: id}),
|
|
7
9
|
* - links создаваемой строки при INSERT.
|
|
8
10
|
*
|
|
9
|
-
* db.Организация(org).Сотрудник().set({ name: 'Вася' })
|
|
10
|
-
*
|
|
11
|
-
* db.Клиент(
|
|
11
|
+
* await db.Организация(org).Сотрудник().set({ name: 'Вася' }).rows() // INSERT + links {Org}
|
|
12
|
+
* await db.Клиент(c).Запись({ status: 'created' }).set({ status: 'confirmed' }).rows()
|
|
13
|
+
* await db.Клиент({ vip: true }).set({ bonus: 500 }) // сегмент 1: UPDATE всех vip
|
|
14
|
+
* .Запись().delete({ confirm: true }).rows() // сегмент 2: снести записи каждого
|
|
15
|
+
* Продолжение после операции идёт ОТ ЕЁ РЕЗУЛЬТАТА (fan-out по записанным строкам).
|
|
12
16
|
*/
|
|
13
17
|
import { randomUUID } from 'node:crypto';
|
|
14
|
-
import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql } from './sql.js';
|
|
18
|
+
import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, isTransient, retryDelay, RETRIES, } from './sql.js';
|
|
19
|
+
import { aclDenied } from './acl.js';
|
|
15
20
|
export class ValidationError extends Error {
|
|
16
21
|
issues;
|
|
17
22
|
constructor(cls, issues) {
|
|
@@ -79,7 +84,7 @@ function validate(cls, id, data, links) {
|
|
|
79
84
|
}
|
|
80
85
|
return merged;
|
|
81
86
|
}
|
|
82
|
-
/** INSERT одной строки с ретраем
|
|
87
|
+
/** INSERT одной строки с ретраем гонок: 23505 (коллизия updated) и transient (40P01/40001). */
|
|
83
88
|
async function insertOne(ctx, a) {
|
|
84
89
|
// jsonb-параметры — JS-объектами (postgres.js сериализует сам)
|
|
85
90
|
const params = [
|
|
@@ -92,12 +97,46 @@ async function insertOne(ctx, a) {
|
|
|
92
97
|
return toRow(res[0]);
|
|
93
98
|
}
|
|
94
99
|
catch (e) {
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
// внутри транзакции повтор невозможен (aborted) — любая ошибка наружу
|
|
101
|
+
if (!ctx.inTx && attempt < RETRIES) {
|
|
102
|
+
if (e.code === '23505')
|
|
103
|
+
continue; // новый clock_timestamp сразу
|
|
104
|
+
if (isTransient(e)) {
|
|
105
|
+
await retryDelay(attempt);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
97
109
|
throw e;
|
|
98
110
|
}
|
|
99
111
|
}
|
|
100
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* enforceAcl: право операции на класс цели. Предикат победившего правила:
|
|
115
|
+
* - фильтрует цели UPDATE/DELETE (step.aclFilter → WHERE при поиске);
|
|
116
|
+
* - колоночные ключи owner/account пришпиливают значения INSERT
|
|
117
|
+
* (явный чужой модификатор на цепочке → ошибка).
|
|
118
|
+
* Мутирует step (объект создаётся цепочкой на вызов — локально безопасно).
|
|
119
|
+
*/
|
|
120
|
+
function aclWrite(ctx, step, op) {
|
|
121
|
+
if (!ctx.aclDecide)
|
|
122
|
+
return;
|
|
123
|
+
const d = ctx.aclDecide(step.cls, op);
|
|
124
|
+
if (!d.allow)
|
|
125
|
+
throw aclDenied(op, step.cls.id, d);
|
|
126
|
+
if (!d.filter)
|
|
127
|
+
return;
|
|
128
|
+
step.aclFilter = d.filter;
|
|
129
|
+
for (const col of ['owner', 'account']) {
|
|
130
|
+
const v = d.filter[col];
|
|
131
|
+
if (typeof v !== 'string')
|
|
132
|
+
continue;
|
|
133
|
+
const key = col === 'owner' ? 'ownerFilter' : 'accountFilter';
|
|
134
|
+
if (step[key] && step[key] !== v) {
|
|
135
|
+
throw new Error(`letopis: acl pins ${step.cls.id} writes to ${col} ${v}`);
|
|
136
|
+
}
|
|
137
|
+
step[key] = v;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
101
140
|
/**
|
|
102
141
|
* Различие форм фильтра в записи:
|
|
103
142
|
* Класс() — БЕЗ аргумента → INSERT новой сущности;
|
|
@@ -111,7 +150,7 @@ function resolveAccount(ctx, step) {
|
|
|
111
150
|
}
|
|
112
151
|
const acc = step.accountFilter ?? ctx.account ?? ctx.systemAccount;
|
|
113
152
|
if (!acc) {
|
|
114
|
-
throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / connect({account}) or seed a System account (
|
|
153
|
+
throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / connect({account}) or seed a System account (lib/sql/seed.auth.sql)');
|
|
115
154
|
}
|
|
116
155
|
return acc;
|
|
117
156
|
}
|
|
@@ -165,12 +204,13 @@ function targetStep(target, contextLinks, explicitId) {
|
|
|
165
204
|
* - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
|
|
166
205
|
* - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
|
|
167
206
|
*/
|
|
168
|
-
export async function setOp(ctx, steps, mods, data
|
|
207
|
+
export async function setOp(ctx, steps, mods, data) {
|
|
169
208
|
const target = steps[steps.length - 1];
|
|
170
209
|
const cls = target.cls;
|
|
171
|
-
|
|
210
|
+
aclWrite(ctx, target, 'WRITE');
|
|
211
|
+
// контекст-шаги: и фильтр целей, и значения; .link() — ТОЛЬКО значения (фильтр не трогает)
|
|
172
212
|
const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
|
|
173
|
-
const writeLinks = { ...contextLinks, ...extraLinks };
|
|
213
|
+
const writeLinks = { ...contextLinks, ...(target.extraLinks ?? {}) };
|
|
174
214
|
const dataId = typeof data.id === 'string' ? data.id : undefined;
|
|
175
215
|
const filterId = typeof target.filter === 'string' ? target.filter : undefined;
|
|
176
216
|
const explicitId = filterId ?? dataId;
|
|
@@ -182,7 +222,7 @@ export async function setOp(ctx, steps, mods, data, extraLinks = {}) {
|
|
|
182
222
|
const out = [];
|
|
183
223
|
for (const row of found) {
|
|
184
224
|
const mergedData = deepMerge(row.data, data); // только указанные листья
|
|
185
|
-
const mergedLinks = { ...row.links, ...writeLinks }; // контекст +
|
|
225
|
+
const mergedLinks = { ...row.links, ...writeLinks }; // контекст + .link()
|
|
186
226
|
const full = validate(cls, row.id, mergedData, mergedLinks);
|
|
187
227
|
out.push(await insertOne(ctx, {
|
|
188
228
|
id: row.id, cls, data: full, links: mergedLinks,
|
|
@@ -195,6 +235,22 @@ export async function setOp(ctx, steps, mods, data, extraLinks = {}) {
|
|
|
195
235
|
// не найдено: INSERT только при явном id или Класс() без аргумента
|
|
196
236
|
if (!explicitId && !insertMode)
|
|
197
237
|
return [];
|
|
238
|
+
// enforceAcl-предикат + явный id: «не нашёл среди доступных» НЕ значит «можно создать» —
|
|
239
|
+
// существующая, но недоступная сущность иначе перехватывалась бы новой версией с чужим owner.
|
|
240
|
+
// Проверка существования — БЕЗ предиката и БЕЗ пришпиленных им колонок.
|
|
241
|
+
if (explicitId && target.aclFilter) {
|
|
242
|
+
const bare = { ...ctx, aclDecide: undefined };
|
|
243
|
+
const probe = {
|
|
244
|
+
...targetStep(target, contextLinks, explicitId),
|
|
245
|
+
aclFilter: undefined,
|
|
246
|
+
ownerFilter: undefined,
|
|
247
|
+
accountFilter: undefined,
|
|
248
|
+
linksFilter: undefined,
|
|
249
|
+
};
|
|
250
|
+
const taken = await readRows(bare, [probe], {});
|
|
251
|
+
if (taken.length)
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
198
254
|
const id = explicitId ?? randomUUID();
|
|
199
255
|
const account = resolveAccount(ctx, target);
|
|
200
256
|
const owner = target.ownerFilter ?? ctx.owner ?? account;
|
|
@@ -213,6 +269,7 @@ export async function setOp(ctx, steps, mods, data, extraLinks = {}) {
|
|
|
213
269
|
export async function anonymizeOp(ctx, steps, mods, fields) {
|
|
214
270
|
const target = steps[steps.length - 1];
|
|
215
271
|
const cls = target.cls;
|
|
272
|
+
aclWrite(ctx, target, 'WRITE');
|
|
216
273
|
for (const f of fields) {
|
|
217
274
|
if (cls.fieldTypes.get(f)?.kind !== 'string') {
|
|
218
275
|
throw new Error(`letopis: anonymize() erases string fields only; "${f}" is ${cls.fieldTypes.get(f)?.kind ?? 'unknown'}`);
|
|
@@ -233,19 +290,33 @@ export async function anonymizeOp(ctx, steps, mods, fields) {
|
|
|
233
290
|
}
|
|
234
291
|
return out;
|
|
235
292
|
}
|
|
236
|
-
/** Транзакционная обёртка: в tr исполняем как есть, вне — sql.begin
|
|
293
|
+
/** Транзакционная обёртка: в tr исполняем как есть, вне — sql.begin с ретраем transient
|
|
294
|
+
* (deadlock/serialization откатывает ВСЮ внутреннюю транзакцию — повтор честен). */
|
|
237
295
|
async function inTransaction(ctx, fn) {
|
|
238
296
|
if (ctx.inTx)
|
|
239
297
|
return fn(ctx);
|
|
240
|
-
|
|
298
|
+
for (let attempt = 1;; attempt++) {
|
|
299
|
+
try {
|
|
300
|
+
return (await ctx.sql.begin(async (tsql) => fn({ ...ctx, sql: tsql, inTx: true })));
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
if (isTransient(e) && attempt < RETRIES) {
|
|
304
|
+
await retryDelay(attempt);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
throw e;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
241
310
|
}
|
|
242
311
|
/**
|
|
243
|
-
* .delete(): цели = фильтр последнего шага +
|
|
244
|
-
*
|
|
245
|
-
*
|
|
312
|
+
* .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
|
|
313
|
+
* confirm: true — серверное удаление (триггер entity_delete: tombstone + рекурсивный
|
|
314
|
+
* каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
|
|
315
|
+
* Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
|
|
246
316
|
*/
|
|
247
|
-
export async function delOp(ctx, steps, mods) {
|
|
317
|
+
export async function delOp(ctx, steps, mods, confirm) {
|
|
248
318
|
const target = steps[steps.length - 1];
|
|
319
|
+
aclWrite(ctx, target, 'DELETE');
|
|
249
320
|
const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
|
|
250
321
|
const targets = await readRows(ctx, [targetStep(target, contextLinks)], mods);
|
|
251
322
|
if (!targets.length)
|
|
@@ -253,15 +324,159 @@ export async function delOp(ctx, steps, mods) {
|
|
|
253
324
|
const params = [ctx.partition, target.cls.id, ...targets.map((r) => r.id)];
|
|
254
325
|
return inTransaction(ctx, async (c) => {
|
|
255
326
|
const closure = await runQuery(c, closureSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
|
|
327
|
+
if (!confirm)
|
|
328
|
+
return closure.map((r) => toRow(r)); // превью: кандидаты живы, БД не тронута
|
|
329
|
+
// каскад дотянется до зависимых ЛЮБЫХ классов — на каждый нужно DELETE-право
|
|
330
|
+
if (c.aclDecide) {
|
|
331
|
+
for (const cn of new Set(closure.map((r) => r.class))) {
|
|
332
|
+
const d = c.aclDecide(c.registry.resolve(cn), 'DELETE');
|
|
333
|
+
if (!d.allow)
|
|
334
|
+
throw aclDenied('DELETE', `${cn} (в каскаде от ${target.cls.id})`, d);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
256
337
|
await runQuery(c, deleteSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
|
|
257
338
|
return closure.map((r) => ({ ...toRow(r), $deleted: true }));
|
|
258
339
|
});
|
|
259
340
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
341
|
+
/** Разрезать план: сегменты (…шаги + op-шаг) и читающий хвост после последней операции. */
|
|
342
|
+
function splitPlan(steps) {
|
|
343
|
+
const segments = [];
|
|
344
|
+
let buf = [];
|
|
345
|
+
for (const s of steps) {
|
|
346
|
+
buf.push(s);
|
|
347
|
+
if (s.op) {
|
|
348
|
+
segments.push({ steps: buf, op: s.op });
|
|
349
|
+
buf = [];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { segments, tail: buf };
|
|
353
|
+
}
|
|
354
|
+
function opCall(c, steps, op) {
|
|
355
|
+
switch (op.kind) {
|
|
356
|
+
case 'set':
|
|
357
|
+
return setOp(c, steps, op.mods, op.data ?? {});
|
|
358
|
+
case 'anonymize':
|
|
359
|
+
return anonymizeOp(c, steps, op.mods, op.fields ?? []);
|
|
360
|
+
case 'delete':
|
|
361
|
+
return delOp(c, steps, op.mods, op.confirm === true);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Виртуальный старт-шаг «от этих строк»: контекст/чтение следующего сегмента. */
|
|
365
|
+
const virtualStep = (c, row) => ({ name: row.class, cls: c.registry.resolve(row.class), filter: row.id });
|
|
366
|
+
const RawRowFmt = {
|
|
367
|
+
rows: (res) => res.map((r) => {
|
|
368
|
+
const row = toRow(r.row);
|
|
369
|
+
return r.row.depth != null ? { ...row, $depth: r.row.depth } : row;
|
|
370
|
+
}),
|
|
371
|
+
paths: (res) => res.map((r) => Object.fromEntries(Object.entries(r.path).map(([k, v]) => [k, toRow(v)]))),
|
|
372
|
+
versions: (res) => res.map((r) => (r.row.deleted ? { ...toRow(r.row), $deleted: true } : toRow(r.row))),
|
|
373
|
+
};
|
|
374
|
+
/**
|
|
375
|
+
* Исполнить план целиком: все операции + финальное чтение — одна транзакция
|
|
376
|
+
* (внутренняя, с ретраем transient; внутри db.begin() — транзакция пользователя).
|
|
377
|
+
* Продолжение после операции: fan-out — каждый следующий сегмент исполняется от каждой
|
|
378
|
+
* строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
|
|
379
|
+
* После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
|
|
380
|
+
*/
|
|
381
|
+
export async function runPlan(ctx, steps, mods, mode) {
|
|
382
|
+
const { segments, tail } = splitPlan(steps);
|
|
383
|
+
return inTransaction(ctx, async (c) => {
|
|
384
|
+
let last = []; // полный результат последней операции (для терминала)
|
|
385
|
+
let start = []; // строки для продолжения (класс цели)
|
|
386
|
+
for (let i = 0; i < segments.length; i++) {
|
|
387
|
+
const seg = segments[i];
|
|
388
|
+
const targetCls = seg.steps[seg.steps.length - 1].cls;
|
|
389
|
+
if (i === 0) {
|
|
390
|
+
last = await opCall(c, seg.steps, seg.op);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
last = [];
|
|
394
|
+
for (const row of start) {
|
|
395
|
+
const rowSteps = seg.steps[0].self
|
|
396
|
+
? [{ ...seg.steps[0], self: undefined, filter: row.id, op: undefined }]
|
|
397
|
+
: [virtualStep(c, row), ...seg.steps.map((s) => ({ ...s, op: undefined }))];
|
|
398
|
+
last.push(...await opCall(c, rowSteps, seg.op));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
start = seg.op.kind === 'delete' ? last.filter((r) => r.class === targetCls.id) : last;
|
|
402
|
+
}
|
|
403
|
+
const opStep = segments[segments.length - 1].steps.at(-1);
|
|
404
|
+
const keyName = opStep.aliasKey ?? opStep.name;
|
|
405
|
+
// хвост-чтение от результата последней операции (в той же транзакции)
|
|
406
|
+
if (tail.length) {
|
|
407
|
+
if (!start.length)
|
|
408
|
+
return emptyFor(mode, mods);
|
|
409
|
+
const virt = { name: keyName, cls: opStep.cls, filter: start.map((r) => r.id) };
|
|
410
|
+
return readByMode(c, [virt, ...tail], mods, mode);
|
|
411
|
+
}
|
|
412
|
+
// терминал прямо на операции
|
|
413
|
+
switch (mode) {
|
|
414
|
+
case 'rows':
|
|
415
|
+
return last;
|
|
416
|
+
case 'ids':
|
|
417
|
+
return last.map((r) => r.id);
|
|
418
|
+
case 'count':
|
|
419
|
+
return last.length;
|
|
420
|
+
case 'paths':
|
|
421
|
+
return last.map((r) => ({ [keyName]: r }));
|
|
422
|
+
case 'versions':
|
|
423
|
+
case 'agg': {
|
|
424
|
+
if (!start.length)
|
|
425
|
+
return emptyFor(mode, mods);
|
|
426
|
+
const virt = { name: keyName, cls: opStep.cls, filter: start.map((r) => r.id) };
|
|
427
|
+
return readByMode(c, [virt], mods, mode);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
function emptyFor(mode, mods) {
|
|
433
|
+
switch (mode) {
|
|
434
|
+
case 'rows':
|
|
435
|
+
case 'versions':
|
|
436
|
+
case 'paths':
|
|
437
|
+
case 'ids':
|
|
438
|
+
return [];
|
|
439
|
+
case 'count':
|
|
440
|
+
return 0;
|
|
441
|
+
case 'agg':
|
|
442
|
+
return mods.aggFn === 'countBy' ? [] : [{ v: null }];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Финальное чтение плана: тот же buildRead, что у обычных цепочек. */
|
|
446
|
+
async function readByMode(c, steps, mods, mode) {
|
|
447
|
+
const classes = steps.map((s) => s.cls.id);
|
|
448
|
+
switch (mode) {
|
|
449
|
+
case 'rows': {
|
|
450
|
+
const q = buildRead(c, steps, mods, 'rows');
|
|
451
|
+
return RawRowFmt.rows(await runQuery(c, q.text, q.params, 'rows', classes));
|
|
452
|
+
}
|
|
453
|
+
case 'paths': {
|
|
454
|
+
const q = buildRead(c, steps, mods, 'paths');
|
|
455
|
+
return RawRowFmt.paths(await runQuery(c, q.text, q.params, 'paths', classes));
|
|
456
|
+
}
|
|
457
|
+
case 'versions': {
|
|
458
|
+
const q = buildRead(c, steps, mods, 'versions');
|
|
459
|
+
return RawRowFmt.versions(await runQuery(c, q.text, q.params, 'versions', classes));
|
|
460
|
+
}
|
|
461
|
+
case 'ids': {
|
|
462
|
+
const q = buildRead(c, steps, mods, 'ids');
|
|
463
|
+
return (await runQuery(c, q.text, q.params, 'ids', classes)).map((r) => r.id);
|
|
464
|
+
}
|
|
465
|
+
case 'count': {
|
|
466
|
+
const q = buildRead(c, steps, mods, 'count');
|
|
467
|
+
return (await runQuery(c, q.text, q.params, 'count', classes))[0].n;
|
|
468
|
+
}
|
|
469
|
+
case 'agg': {
|
|
470
|
+
const q = buildRead(c, steps, mods, 'agg');
|
|
471
|
+
return runQuery(c, q.text, q.params, 'agg', classes);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const isPureInsertPlan = (p) => p.steps.length === 1 &&
|
|
476
|
+
p.steps[0].op?.kind === 'set' &&
|
|
477
|
+
noFilter(p.steps[0].filter) &&
|
|
478
|
+
typeof p.steps[0].op.data?.id !== 'string' &&
|
|
479
|
+
!Object.keys(p.steps[0].extraLinks ?? {}).length;
|
|
265
480
|
export async function executeBatch(ctx, queue) {
|
|
266
481
|
if (!queue.length)
|
|
267
482
|
return [];
|
|
@@ -269,19 +484,18 @@ export async function executeBatch(ctx, queue) {
|
|
|
269
484
|
const results = new Array(queue.length);
|
|
270
485
|
let i = 0;
|
|
271
486
|
while (i < queue.length) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const step = op.steps[0];
|
|
275
|
-
const cls = step.cls;
|
|
487
|
+
if (isPureInsertPlan(queue[i])) {
|
|
488
|
+
const cls = queue[i].steps[0].cls;
|
|
276
489
|
let j = i;
|
|
277
|
-
while (j < queue.length &&
|
|
490
|
+
while (j < queue.length && isPureInsertPlan(queue[j]) && queue[j].steps[0].cls.id === cls.id)
|
|
278
491
|
j++;
|
|
279
492
|
const group = queue.slice(i, j);
|
|
280
493
|
const params = [];
|
|
281
494
|
for (const g of group) {
|
|
282
|
-
const s = g.steps[0];
|
|
495
|
+
const s = { ...g.steps[0] };
|
|
496
|
+
aclWrite(c, s, 'WRITE'); // склейка multi-VALUES идёт мимо setOp — право+пришпиливание на каждый
|
|
283
497
|
const id = randomUUID();
|
|
284
|
-
const full = validate(cls, id,
|
|
498
|
+
const full = validate(cls, id, s.op?.data ?? {}, {});
|
|
285
499
|
const account = resolveAccount(c, s);
|
|
286
500
|
params.push(c.partition, id, cls.id, full, {}, tagsValue(s) ?? [], account, s.ownerFilter ?? c.owner ?? account);
|
|
287
501
|
}
|
|
@@ -291,10 +505,7 @@ export async function executeBatch(ctx, queue) {
|
|
|
291
505
|
i = j;
|
|
292
506
|
continue;
|
|
293
507
|
}
|
|
294
|
-
results[i] =
|
|
295
|
-
op.kind === 'set'
|
|
296
|
-
? await setOp(c, op.steps, op.mods, op.data ?? {}, op.extraLinks)
|
|
297
|
-
: await delOp(c, op.steps, op.mods);
|
|
508
|
+
results[i] = (await runPlan(c, queue[i].steps, {}, 'rows'));
|
|
298
509
|
i++;
|
|
299
510
|
}
|
|
300
511
|
return results;
|