odant-work 1.0.12 → 1.0.13
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/#system/certs/russian_trusted_root_ca.pem +33 -0
- package/#system/mail.json.example +9 -0
- package/#system/package-manifest.json +986 -0
- package/#system/sandbox.json +19 -0
- package/#system/settings.json.example +3 -0
- package/package.json +1 -1
- package/run.mjs +78 -11
- package/scripts/check-ignored-code.mjs +25 -1
- package/sources/data/api.js +1071 -0
- package/sources/data/choices.js +125 -0
- package/sources/data/classes.js +412 -0
- package/sources/data/db.js +383 -0
- package/sources/data/errors.js +15 -0
- package/sources/data/expr.js +456 -0
- package/sources/data/objects.js +950 -0
- package/sources/data/posting.js +657 -0
- package/sources/data/print.js +524 -0
- package/sources/data/progress.md +526 -0
- package/sources/data/readme.md +77 -0
- package/sources/data/reports.js +1033 -0
- package/sources/data/schema.js +586 -0
- package/sources/data/scope.js +281 -0
- package/sources/data/sql-inspector.js +892 -0
- package/sources/data/time.js +67 -0
- package/sources/data/workspace.js +152 -0
- package/sources/host/auto-updater.js +52 -10
- package/.vscode/launch.json +0 -32
- package/.vscode/settings.json +0 -2
- package/.vscode/tasks.json +0 -65
- package/ORGANIZATIONS//320/221/320/270/320/267/320/275/320/265/321/201 /320/230/320/275/321/202/320/265/321/200 /320/241/320/276/321/204/321/202/orders/$structure/video/IMG_8524.MP4 +0 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* БД прикладных данных WORK (`./.data/work.db`, node:sqlite).
|
|
3
|
+
* Источник истины для объектов справочников и операций, их версий и проводок
|
|
4
|
+
* единого журнала. Метаданные (class.js) остаются файлами дерева; здесь — только
|
|
5
|
+
* их кэш (`classes`, `class_tree`). В отличие от `.index/work.db` (RAG, ленты —
|
|
6
|
+
* пересобираемые индексы) эту БД обязательно включать в резервную копию.
|
|
7
|
+
*
|
|
8
|
+
* Миграции — по `PRAGMA user_version`: каждый элемент MIGRATIONS выполняется один раз.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { importSqlite } from '../host/index-db.js';
|
|
13
|
+
|
|
14
|
+
export const DATA_DIR = '.data';
|
|
15
|
+
export const DATA_FILE = 'work.db';
|
|
16
|
+
|
|
17
|
+
/** Схема по версиям. Новая версия — новый элемент в конце; старые не правятся. */
|
|
18
|
+
export const MIGRATIONS = [
|
|
19
|
+
// 1 — классы, объекты, версии, ссылки, полнотекстовый поиск, проводки, настройки
|
|
20
|
+
`
|
|
21
|
+
CREATE TABLE classes (
|
|
22
|
+
cid TEXT PRIMARY KEY, -- стабильный id класса (CID в class.js, иначе путь)
|
|
23
|
+
path TEXT NOT NULL UNIQUE, -- путь в дереве WORK (для показа и поиска)
|
|
24
|
+
parent TEXT, -- cid ближайшего класса-предка по пути
|
|
25
|
+
type TEXT NOT NULL, -- метапапка: $catalog, $operation, $account, $base, ...
|
|
26
|
+
label TEXT NOT NULL,
|
|
27
|
+
own TEXT NOT NULL DEFAULT '{}', -- собственный слой class.js (JSON без функций и геттеров)
|
|
28
|
+
hash TEXT NOT NULL DEFAULT '' -- хеш own: обнаружение изменений
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX classes_parent ON classes(parent);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE class_tree (
|
|
33
|
+
ancestor TEXT NOT NULL,
|
|
34
|
+
descendant TEXT NOT NULL,
|
|
35
|
+
depth INTEGER NOT NULL, -- 0 — сам класс
|
|
36
|
+
PRIMARY KEY (ancestor, descendant)
|
|
37
|
+
) WITHOUT ROWID;
|
|
38
|
+
CREATE INDEX class_tree_descendant ON class_tree(descendant, depth);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE objects (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
cid TEXT NOT NULL, -- класс-лист, в котором создан объект
|
|
43
|
+
status TEXT NOT NULL, -- справочник: active|deleted; операция: draft|registered|cancelled
|
|
44
|
+
time INTEGER NOT NULL, -- дата объекта (для операции — дата факта), мс
|
|
45
|
+
author TEXT,
|
|
46
|
+
place TEXT, -- рабочее место регистрации
|
|
47
|
+
unit TEXT, -- подразделение регистрации (cid структуры)
|
|
48
|
+
ext_of TEXT, -- запись-расширение виртуального справочника: id реального объекта
|
|
49
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
50
|
+
created INTEGER NOT NULL,
|
|
51
|
+
updated INTEGER NOT NULL,
|
|
52
|
+
body TEXT NOT NULL DEFAULT '{}'
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX objects_cid_time ON objects(cid, time);
|
|
55
|
+
CREATE INDEX objects_unit_time ON objects(unit, time);
|
|
56
|
+
CREATE INDEX objects_ext_of ON objects(ext_of);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE object_versions (
|
|
59
|
+
id TEXT NOT NULL,
|
|
60
|
+
version INTEGER NOT NULL,
|
|
61
|
+
valid_from INTEGER NOT NULL, -- с какого момента действует версия, мс
|
|
62
|
+
author TEXT,
|
|
63
|
+
status TEXT NOT NULL,
|
|
64
|
+
body TEXT NOT NULL,
|
|
65
|
+
PRIMARY KEY (id, version)
|
|
66
|
+
) WITHOUT ROWID;
|
|
67
|
+
CREATE INDEX object_versions_valid ON object_versions(id, valid_from);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE refs (
|
|
70
|
+
obj_id TEXT NOT NULL,
|
|
71
|
+
field TEXT NOT NULL, -- поле (для табличной части — 'таблица.поле')
|
|
72
|
+
row INTEGER NOT NULL DEFAULT -1,
|
|
73
|
+
ref_id TEXT NOT NULL,
|
|
74
|
+
ref_cid TEXT
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX refs_obj ON refs(obj_id);
|
|
77
|
+
CREATE INDEX refs_ref ON refs(ref_id);
|
|
78
|
+
|
|
79
|
+
-- rowid строки поиска = rowid объекта (удаление и замена — по ключу, без прохода таблицы)
|
|
80
|
+
CREATE VIRTUAL TABLE objects_fts USING fts5(
|
|
81
|
+
text,
|
|
82
|
+
tokenize = 'unicode61 remove_diacritics 2'
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE entries (
|
|
86
|
+
id INTEGER PRIMARY KEY,
|
|
87
|
+
op_id TEXT NOT NULL,
|
|
88
|
+
op_version INTEGER NOT NULL,
|
|
89
|
+
op_cid TEXT NOT NULL,
|
|
90
|
+
rule TEXT NOT NULL,
|
|
91
|
+
row INTEGER NOT NULL DEFAULT -1, -- строка табличной части (each) или -1
|
|
92
|
+
time INTEGER NOT NULL,
|
|
93
|
+
debit_cid TEXT NOT NULL,
|
|
94
|
+
credit_cid TEXT NOT NULL,
|
|
95
|
+
amount INTEGER NOT NULL DEFAULT 0, -- копейки; сторно — с обратным знаком
|
|
96
|
+
qty REAL NOT NULL DEFAULT 0, -- в базовой единице
|
|
97
|
+
qty2 REAL NOT NULL DEFAULT 0, -- второе измерение (масса/объём)
|
|
98
|
+
doc_qty REAL,
|
|
99
|
+
doc_unit TEXT,
|
|
100
|
+
author TEXT,
|
|
101
|
+
place TEXT,
|
|
102
|
+
unit TEXT,
|
|
103
|
+
storno INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
created INTEGER NOT NULL
|
|
105
|
+
);
|
|
106
|
+
CREATE INDEX entries_debit ON entries(debit_cid, time);
|
|
107
|
+
CREATE INDEX entries_credit ON entries(credit_cid, time);
|
|
108
|
+
CREATE INDEX entries_unit ON entries(unit, time);
|
|
109
|
+
CREATE INDEX entries_op ON entries(op_id);
|
|
110
|
+
CREATE INDEX entries_time ON entries(time);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE entry_analytics (
|
|
113
|
+
entry_id INTEGER NOT NULL,
|
|
114
|
+
side TEXT NOT NULL CHECK (side IN ('D', 'C')),
|
|
115
|
+
slot TEXT NOT NULL,
|
|
116
|
+
ref_id TEXT NOT NULL,
|
|
117
|
+
ref_cid TEXT,
|
|
118
|
+
PRIMARY KEY (entry_id, side, slot)
|
|
119
|
+
) WITHOUT ROWID;
|
|
120
|
+
CREATE INDEX entry_analytics_ref ON entry_analytics(ref_id, entry_id);
|
|
121
|
+
|
|
122
|
+
CREATE TABLE settings (
|
|
123
|
+
key TEXT PRIMARY KEY,
|
|
124
|
+
value TEXT
|
|
125
|
+
);
|
|
126
|
+
`,
|
|
127
|
+
// 2 — явная двойная запись по классам счетов ($account): отдельная запись в счёте Дебета
|
|
128
|
+
// со своими аналитиками и отдельная запись в счёте Кредита со своими аналитиками
|
|
129
|
+
`
|
|
130
|
+
CREATE TABLE account_entries (
|
|
131
|
+
id INTEGER PRIMARY KEY,
|
|
132
|
+
entry_id INTEGER NOT NULL, -- общий ключ проводки (связка Дт и Кт)
|
|
133
|
+
side TEXT NOT NULL CHECK (side IN ('D', 'C')),
|
|
134
|
+
account_cid TEXT NOT NULL, -- класс счёта ($account), владеющий записью
|
|
135
|
+
corr_cid TEXT NOT NULL, -- корреспондирующий счёт
|
|
136
|
+
op_id TEXT NOT NULL,
|
|
137
|
+
op_version INTEGER NOT NULL,
|
|
138
|
+
op_cid TEXT NOT NULL,
|
|
139
|
+
rule TEXT NOT NULL,
|
|
140
|
+
row INTEGER NOT NULL DEFAULT -1,
|
|
141
|
+
time INTEGER NOT NULL,
|
|
142
|
+
debit INTEGER NOT NULL DEFAULT 0, -- копейки
|
|
143
|
+
credit INTEGER NOT NULL DEFAULT 0, -- копейки
|
|
144
|
+
qty_in REAL NOT NULL DEFAULT 0,
|
|
145
|
+
qty_out REAL NOT NULL DEFAULT 0,
|
|
146
|
+
qty2 REAL NOT NULL DEFAULT 0,
|
|
147
|
+
doc_qty REAL,
|
|
148
|
+
doc_unit TEXT,
|
|
149
|
+
analytics TEXT NOT NULL DEFAULT '{}', -- JSON аналитик ИМЕННО этого счёта { слот: ref_id }
|
|
150
|
+
author TEXT,
|
|
151
|
+
place TEXT,
|
|
152
|
+
unit TEXT,
|
|
153
|
+
storno INTEGER NOT NULL DEFAULT 0,
|
|
154
|
+
created INTEGER NOT NULL
|
|
155
|
+
);
|
|
156
|
+
CREATE INDEX account_entries_acc_time ON account_entries(account_cid, time);
|
|
157
|
+
CREATE INDEX account_entries_entry ON account_entries(entry_id);
|
|
158
|
+
CREATE INDEX account_entries_op ON account_entries(op_id);
|
|
159
|
+
`,
|
|
160
|
+
// 3 — журнал событий и лент ($class, $user, $structure, $base) в SQLite вместо файлов .logs
|
|
161
|
+
`
|
|
162
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
163
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
164
|
+
feed_path TEXT NOT NULL,
|
|
165
|
+
day TEXT NOT NULL,
|
|
166
|
+
time INTEGER NOT NULL,
|
|
167
|
+
sender TEXT NOT NULL DEFAULT '',
|
|
168
|
+
ext TEXT NOT NULL DEFAULT '',
|
|
169
|
+
path TEXT NOT NULL DEFAULT '',
|
|
170
|
+
stub_path TEXT NOT NULL DEFAULT '',
|
|
171
|
+
body TEXT NOT NULL DEFAULT '{}'
|
|
172
|
+
);
|
|
173
|
+
CREATE INDEX IF NOT EXISTS logs_feed_day_time ON logs(feed_path, day, time DESC);
|
|
174
|
+
CREATE INDEX IF NOT EXISTS logs_feed_path ON logs(feed_path, path);
|
|
175
|
+
CREATE INDEX IF NOT EXISTS logs_stub_path ON logs(stub_path);
|
|
176
|
+
CREATE INDEX IF NOT EXISTS logs_path ON logs(path);
|
|
177
|
+
`,
|
|
178
|
+
// 4 — покрывающие индексы для мгновенного подсчёта объектов и проводок по классам (getDbSummary)
|
|
179
|
+
`
|
|
180
|
+
CREATE INDEX IF NOT EXISTS objects_cid_status_ext ON objects(cid, status, ext_of);
|
|
181
|
+
CREATE INDEX IF NOT EXISTS account_entries_acc_storno ON account_entries(account_cid, storno);
|
|
182
|
+
`,
|
|
183
|
+
// 5 — подшивка операций (связи «Основание → Следствие» между документами, например Счёт → Платёжное поручение)
|
|
184
|
+
`
|
|
185
|
+
CREATE TABLE IF NOT EXISTS doc_links (
|
|
186
|
+
source_id TEXT NOT NULL,
|
|
187
|
+
target_id TEXT NOT NULL,
|
|
188
|
+
kind TEXT NOT NULL DEFAULT 'basis',
|
|
189
|
+
note TEXT NOT NULL DEFAULT '',
|
|
190
|
+
author TEXT,
|
|
191
|
+
created INTEGER NOT NULL,
|
|
192
|
+
PRIMARY KEY (source_id, target_id)
|
|
193
|
+
) WITHOUT ROWID;
|
|
194
|
+
CREATE INDEX IF NOT EXISTS doc_links_source ON doc_links(source_id);
|
|
195
|
+
CREATE INDEX IF NOT EXISTS doc_links_target ON doc_links(target_id);
|
|
196
|
+
`,
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
/** Применить недостающие миграции. @returns {number} версия схемы после миграции */
|
|
200
|
+
export function migrate(db) {
|
|
201
|
+
const current = db.prepare('PRAGMA user_version').get().user_version;
|
|
202
|
+
for (let v = current; v < MIGRATIONS.length; v++) {
|
|
203
|
+
tx(db, () => {
|
|
204
|
+
db.exec(MIGRATIONS[v]);
|
|
205
|
+
db.exec(`PRAGMA user_version = ${v + 1}`);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return MIGRATIONS.length;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Открыть БД данных и привести схему к текущей версии.
|
|
213
|
+
* @param {string} [file] Путь к файлу или ':memory:' (тесты). По умолчанию `./.data/work.db`.
|
|
214
|
+
* @returns {Promise<import('node:sqlite').DatabaseSync>}
|
|
215
|
+
*/
|
|
216
|
+
export async function openDataDb(file = path.resolve(DATA_DIR, DATA_FILE)) {
|
|
217
|
+
const { DatabaseSync } = await importSqlite();
|
|
218
|
+
if (file !== ':memory:')
|
|
219
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
220
|
+
const db = new DatabaseSync(file);
|
|
221
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000; PRAGMA temp_store = MEMORY; PRAGMA cache_size = -32000; PRAGMA mmap_size = 268435456;');
|
|
222
|
+
migrate(db);
|
|
223
|
+
return db;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let opened = null;
|
|
227
|
+
let openedFile = '';
|
|
228
|
+
const openedByBase = new Map();
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Путь к файлу БД конкретной организации ($base): `<Организация>/$base/#system/work.db`.
|
|
232
|
+
* Для пустого basePath (режим по умолчанию) — `./.data/work.db`.
|
|
233
|
+
*/
|
|
234
|
+
export function baseDbFile(rootDir = process.cwd(), basePath = '') {
|
|
235
|
+
const norm = String(basePath ?? '').trim().replace(/\/+$/, '');
|
|
236
|
+
if (norm && norm !== '/') {
|
|
237
|
+
const rel = norm.replace(/^\/+/, '');
|
|
238
|
+
return path.resolve(rootDir, rel, '$base', '#system', DATA_FILE);
|
|
239
|
+
}
|
|
240
|
+
return path.resolve(rootDir, DATA_DIR, DATA_FILE);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* БД данных организации ($base) или сервера по умолчанию.
|
|
245
|
+
* @param {string} [basePath] Путь организации (например, '/ORGANIZATIONS/Маслоторг'); пусто — дефолтная БД
|
|
246
|
+
* @param {string} [rootDir] Корневая директория сервера (по умолчанию process.cwd())
|
|
247
|
+
*/
|
|
248
|
+
export async function dataDb(basePath = '', rootDir = process.cwd()) {
|
|
249
|
+
const key = String(basePath ?? '').trim().replace(/\/+$/, '');
|
|
250
|
+
const file = baseDbFile(rootDir, key);
|
|
251
|
+
if (!key) {
|
|
252
|
+
if (!opened || openedFile !== file) {
|
|
253
|
+
openedFile = file;
|
|
254
|
+
opened = openDataDb(file);
|
|
255
|
+
openedByBase.set(file, opened);
|
|
256
|
+
}
|
|
257
|
+
return opened;
|
|
258
|
+
}
|
|
259
|
+
let p = openedByBase.get(file) || openedByBase.get(key);
|
|
260
|
+
if (!p) {
|
|
261
|
+
p = openDataDb(file);
|
|
262
|
+
openedByBase.set(key, p);
|
|
263
|
+
openedByBase.set(file, p);
|
|
264
|
+
}
|
|
265
|
+
return p;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Сбросить WAL-журнал БД организации в основной файл `work.db` перед архивацией.
|
|
270
|
+
* @param {string} basePath Путь организации (например, '/ORGANIZATIONS/Деловая сфера')
|
|
271
|
+
*/
|
|
272
|
+
export async function checkpointBaseDb(basePath = '') {
|
|
273
|
+
const key = String(basePath ?? '').trim().replace(/\/+$/, '');
|
|
274
|
+
const file = baseDbFile(process.cwd(), key);
|
|
275
|
+
if (!fs.existsSync(file))
|
|
276
|
+
return;
|
|
277
|
+
const p = openedByBase.get(file) || openedByBase.get(key);
|
|
278
|
+
if (p) {
|
|
279
|
+
const db = await p;
|
|
280
|
+
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); } catch { /* ignore */ }
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (fs.existsSync(file + '-wal')) {
|
|
284
|
+
const db = await openDataDb(file);
|
|
285
|
+
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); } catch { /* ignore */ }
|
|
286
|
+
try { db.close(); } catch { /* ignore */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Закрыть соединение с БД конкретной организации (перед перезаписью из архива).
|
|
292
|
+
* @param {string} basePath Путь организации
|
|
293
|
+
*/
|
|
294
|
+
export async function closeBaseDb(basePath = '') {
|
|
295
|
+
const key = String(basePath ?? '').trim().replace(/\/+$/, '');
|
|
296
|
+
const file = baseDbFile(process.cwd(), key);
|
|
297
|
+
const p = openedByBase.get(file) || openedByBase.get(key);
|
|
298
|
+
if (!p)
|
|
299
|
+
return;
|
|
300
|
+
openedByBase.delete(key);
|
|
301
|
+
openedByBase.delete(file);
|
|
302
|
+
try {
|
|
303
|
+
const db = await p;
|
|
304
|
+
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); } catch { /* ignore */ }
|
|
305
|
+
db.close();
|
|
306
|
+
}
|
|
307
|
+
catch { /* ignore */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Закрыть открытые БД данных (тесты, остановка сервера). */
|
|
311
|
+
export async function closeDataDb() {
|
|
312
|
+
const p = opened;
|
|
313
|
+
opened = null;
|
|
314
|
+
openedFile = '';
|
|
315
|
+
const list = new Set(openedByBase.values());
|
|
316
|
+
if (p)
|
|
317
|
+
list.add(p);
|
|
318
|
+
openedByBase.clear();
|
|
319
|
+
for (const dbPromise of list) {
|
|
320
|
+
try { (await dbPromise).close(); } catch { /* ignore */ }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Значение настройки (JSON) или undefined. */
|
|
325
|
+
export function getSetting(db, key) {
|
|
326
|
+
const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(String(key));
|
|
327
|
+
return r ? JSON.parse(r.value) : undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Записать настройку; null/undefined — удалить. */
|
|
331
|
+
export function setSetting(db, key, value) {
|
|
332
|
+
if (value === undefined || value === null)
|
|
333
|
+
db.prepare('DELETE FROM settings WHERE key = ?').run(String(key));
|
|
334
|
+
else
|
|
335
|
+
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
336
|
+
.run(String(key), JSON.stringify(value));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const depth = new WeakMap();
|
|
340
|
+
const dataGenerations = new WeakMap();
|
|
341
|
+
|
|
342
|
+
/** Поколение прикладных данных БД: растёт при каждом успешном COMMIT транзакции. */
|
|
343
|
+
export function dataGeneration(db) {
|
|
344
|
+
return dataGenerations.get(db) || 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Принудительно увеличить поколение прикладных данных БД. */
|
|
348
|
+
export function bumpDataGeneration(db) {
|
|
349
|
+
const next = (dataGenerations.get(db) || 0) + 1;
|
|
350
|
+
dataGenerations.set(db, next);
|
|
351
|
+
return next;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Выполнить fn в транзакции. Вложенный вызов — точка сохранения (SAVEPOINT),
|
|
356
|
+
* поэтому владельцы могут вызывать друг друга, не заботясь о внешней транзакции.
|
|
357
|
+
* @template T
|
|
358
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
359
|
+
* @param {() => T} fn Синхронная функция (node:sqlite синхронный)
|
|
360
|
+
* @returns {T}
|
|
361
|
+
*/
|
|
362
|
+
export function tx(db, fn) {
|
|
363
|
+
const level = depth.get(db) || 0;
|
|
364
|
+
const sp = 'sp' + level;
|
|
365
|
+
db.exec(level ? `SAVEPOINT ${sp}` : 'BEGIN IMMEDIATE');
|
|
366
|
+
depth.set(db, level + 1);
|
|
367
|
+
try {
|
|
368
|
+
const res = fn();
|
|
369
|
+
if (typeof res?.then === 'function')
|
|
370
|
+
throw new Error('tx: функция транзакции должна быть синхронной');
|
|
371
|
+
db.exec(level ? `RELEASE ${sp}` : 'COMMIT');
|
|
372
|
+
if (!level)
|
|
373
|
+
bumpDataGeneration(db);
|
|
374
|
+
return res;
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
db.exec(level ? `ROLLBACK TO ${sp}; RELEASE ${sp}` : 'ROLLBACK');
|
|
378
|
+
throw e;
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
depth.set(db, level);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ошибка данных учётного ядра: нарушение схемы, прав, состояния объекта.
|
|
3
|
+
* `errors` — все найденные нарушения (форма показывает их у полей, ИИ исправляет за один шаг).
|
|
4
|
+
*/
|
|
5
|
+
export class DataError extends Error {
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @param {Array<{field?: string, message: string}>} [errors]
|
|
9
|
+
*/
|
|
10
|
+
constructor(message, errors = []) {
|
|
11
|
+
super(errors.length ? `${message}: ${errors.map(e => (e.field ? `${e.field} — ` : '') + e.message).join('; ')}` : message);
|
|
12
|
+
this.name = 'DataError';
|
|
13
|
+
this.errors = errors;
|
|
14
|
+
}
|
|
15
|
+
}
|