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/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>, extraLinks?: Record<string, string>): Promise<Row[]>;
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
- * Триггер entity_delete: tombstone + рекурсивный каскад (+advisory-lock).
48
- * Возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
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 interface BatchOp {
52
- kind: 'set' | 'delete';
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: BatchOp[]): Promise<Row[][]>;
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
- * Запись: терминалы .set(data) / .delete() на цепочке + исполнение батчей.
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: 'Вася' }) // INSERT + links {Org}
10
- * tr.Сотрудник(s).Окно(w).Запись(b).занятость().set({ id, kind }) // UPSERT по data.id
11
- * db.Клиент(c).Запись({ status: 'created' }).set({ status: 'confirmed' }) // UPDATE найденных
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 одной строки с ретраем коллизии PK по updated (23505). */
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
- if (e.code === '23505' && attempt < 3)
96
- continue;
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 (db/seed.auth.sql)');
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, extraLinks = {}) {
207
+ export async function setOp(ctx, steps, mods, data) {
169
208
  const target = steps[steps.length - 1];
170
209
  const cls = target.cls;
171
- // контекст-шаги: и фильтр целей, и значения; довешенные extraLinks — ТОЛЬКО значения
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
- return (await ctx.sql.begin(async (tsql) => fn({ ...ctx, sql: tsql, inTx: true })));
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
- * Триггер entity_delete: tombstone + рекурсивный каскад (+advisory-lock).
245
- * Возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
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
- const isPureInsert = (op) => op.kind === 'set' &&
261
- op.steps.length === 1 &&
262
- noFilter(op.steps[0].filter) &&
263
- typeof op.data?.id !== 'string' &&
264
- !Object.keys(op.extraLinks ?? {}).length;
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
- const op = queue[i];
273
- if (isPureInsert(op)) {
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 && isPureInsert(queue[j]) && queue[j].steps[0].cls.id === cls.id)
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, g.data ?? {}, {});
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;