letopis 1.4.1 → 2.0.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/AGENT-CHEATSHEET.md +329 -278
- package/CHANGELOG.md +558 -1
- package/README.md +606 -1037
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -175
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +33 -10
- package/dist/chain.d.ts +19 -41
- package/dist/chain.js +169 -201
- package/dist/cli.js +51 -88
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -71
- package/dist/index.js +245 -130
- package/dist/internal.d.ts +12 -0
- package/dist/internal.js +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +225 -186
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +171 -215
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +26 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +5 -9
- package/dist/signals.js +5 -9
- package/dist/sql.d.ts +13 -6
- package/dist/sql.js +176 -83
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +51 -21
- package/dist/tx.d.ts +26 -1
- package/dist/tx.js +44 -14
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +23 -91
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +184 -73
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +11 -10
- package/dist/watch.js +33 -70
- package/dist/write.d.ts +9 -16
- package/dist/write.js +118 -187
- package/llms.txt +22 -25
- package/package.json +8 -11
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -667
- package/sql/70-read.sql +20 -16
- package/sql/80-auth.sql +24 -27
- package/sql/90-time.sql +70 -859
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -485
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2859
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -350
- package/dist/project.js +0 -1224
- package/sql/75-history.sql +0 -358
- package/sql/88-derived.sql +0 -1235
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/up.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* неё только на время явного `set role`. Повторный накат ничего не меняет.
|
|
7
7
|
*
|
|
8
8
|
* up() — оркестратор без Docker: ждёт базу (удалённую — до таймаута), создаёт её, проверяет версию
|
|
9
|
-
* PostgreSQL (18+) и кодировку UTF8, ставит схему (fresh — заново), обновляет отставшую (upgrade:
|
|
9
|
+
* PostgreSQL (18+) и кодировку UTF8, ставит схему (fresh — заново), обновляет отставшую ревизию 2.x (upgrade:
|
|
10
10
|
* миграции lib/sql/migrate/ и повторный накат), кладёт сиды, выдаёт или регистрирует сервисный ключ и
|
|
11
|
-
* выполняет ANALYZE только для таблиц без статистики.
|
|
11
|
+
* выполняет ANALYZE только для таблиц без статистики. Схему letopis 1.x не трогает: 2.0 ставится только заново.
|
|
12
12
|
*/
|
|
13
13
|
import { readFile, readdir } from 'node:fs/promises';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
@@ -19,11 +19,27 @@ import postgres from 'postgres';
|
|
|
19
19
|
import { DDL_REVISION, RESERVED_CLASS_NAMES } from './types.js';
|
|
20
20
|
import { startPglite } from './pglite.js';
|
|
21
21
|
import { hashPassword } from './auth.js';
|
|
22
|
-
import {
|
|
22
|
+
import { checkOptions, lerr, lmsg } from './errors.js';
|
|
23
|
+
import { sqlCatalog } from './messages/index.js';
|
|
23
24
|
import { v5Id } from './uuid.js';
|
|
24
25
|
import { SIGNAL_MODES } from './signals.js';
|
|
25
26
|
export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
26
27
|
export const SCHEMA_VERSION = 'v2';
|
|
28
|
+
/**
|
|
29
|
+
* Первая ревизия движка 2.0 (план 2.0, редакция 2): схема ниже неё — letopis 1.x или схема без штампа ревизии. Её
|
|
30
|
+
* `up()` не обновляет, а `connect()` к ней не подключается: 2.0 ставится только заново (`up({ fresh: true })`), данные
|
|
31
|
+
* 1.x не переносятся. Накат миграций `up({ upgrade })` — в пределах 2.x, от этой ревизии до `DDL_REVISION`.
|
|
32
|
+
*/
|
|
33
|
+
export const FIRST_REVISION = 8;
|
|
34
|
+
/**
|
|
35
|
+
* Отказ на схеме letopis 1.x (план 2.0, 7.10 №5): ревизия из комментария схемы ниже `FIRST_REVISION` — 7 и ниже у 1.x,
|
|
36
|
+
* 0 — штампа ревизии нет. `up()` зовёт её до любых изменений (до общей части `install()`), `connect()` — до входа по
|
|
37
|
+
* ключу: функции схемы 1.x не вызываются.
|
|
38
|
+
*/
|
|
39
|
+
export function refuse1x(schema, rev) {
|
|
40
|
+
if (rev < FIRST_REVISION)
|
|
41
|
+
throw lerr('boot.schema_1x', { schema, rev, first: FIRST_REVISION });
|
|
42
|
+
}
|
|
27
43
|
/**
|
|
28
44
|
* Advisory-ключ наката одной схемы: пара int4, как у общей части установки (`hashtext('install')`), — имя схемы
|
|
29
45
|
* всегда с точкой (`v2.имя`), поэтому со словом install ключи не совпадут.
|
|
@@ -44,8 +60,9 @@ export async function engineFiles(upTo = 99) {
|
|
|
44
60
|
}
|
|
45
61
|
let stamp;
|
|
46
62
|
/**
|
|
47
|
-
* Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF)
|
|
48
|
-
*
|
|
63
|
+
* Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF) и по каталогу
|
|
64
|
+
* сообщений базы (разделы sql-*, подстановка @MESSAGES@): одна и та же сборка даёт один штамп при любых опциях
|
|
65
|
+
* установки, а версия пакета без правок SQL и этих разделов штамп не меняет.
|
|
49
66
|
*/
|
|
50
67
|
export async function engineStamp() {
|
|
51
68
|
if (stamp)
|
|
@@ -53,6 +70,7 @@ export async function engineStamp() {
|
|
|
53
70
|
const h = createHash('sha256');
|
|
54
71
|
for (const f of await engineFiles())
|
|
55
72
|
h.update(`${f}\n${(await readFile(join(SQL_DIR, f), 'utf8')).replace(/\r\n/g, '\n')}\n`);
|
|
73
|
+
h.update(`@MESSAGES@\n${JSON.stringify(sqlCatalog())}\n`);
|
|
56
74
|
const version = createRequire(import.meta.url)('../package.json').version;
|
|
57
75
|
return (stamp = { version, sql: h.digest('hex').slice(0, 16) });
|
|
58
76
|
}
|
|
@@ -95,16 +113,16 @@ export async function stampDrift(schema, comment) {
|
|
|
95
113
|
if (!code || /\bsql=([0-9a-f]+)/.exec(comment)?.[1] === code.sql)
|
|
96
114
|
return null;
|
|
97
115
|
const was = /\bengine=(\S+)/.exec(comment)?.[1];
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return
|
|
116
|
+
// схему обновила версия новее пакета — или в штампе нет версии (его правили руками: штамп ревизии 2.x up() пишет
|
|
117
|
+
// всегда с версией), другая сборка той же версии, другая версия
|
|
118
|
+
const key = was && compareVersions(was, code.version) > 0 ? 'boot.stamp_newer'
|
|
119
|
+
: !was ? 'boot.stamp_unknown' : was === code.version ? 'boot.stamp_other_build' : 'boot.stamp_other_version';
|
|
120
|
+
return 'letopis: ' + lmsg(key, { schema, version: code.version, ...(was ? { was } : {}) });
|
|
103
121
|
}
|
|
104
122
|
export function substitute(text, vars) {
|
|
105
123
|
return text.replace(/@([A-Z_]+)@/g, (m, k) => {
|
|
106
124
|
if (!(k in vars))
|
|
107
|
-
throw
|
|
125
|
+
throw lerr('boot.unknown_marker', { marker: m });
|
|
108
126
|
return vars[k];
|
|
109
127
|
});
|
|
110
128
|
}
|
|
@@ -112,16 +130,16 @@ export async function install(sql, opts) {
|
|
|
112
130
|
const owner = opts.owner ?? 'letopis_owner';
|
|
113
131
|
const app = opts.app ?? 'letopis_app';
|
|
114
132
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
115
|
-
throw
|
|
133
|
+
throw lerr('boot.install_schema_name', { schema: opts.schema });
|
|
116
134
|
for (const r of [owner, app])
|
|
117
135
|
if (!ROLE_NAME.test(r))
|
|
118
|
-
throw
|
|
136
|
+
throw lerr('boot.role_name', { role: r });
|
|
119
137
|
if (opts.signals !== undefined && !SIGNAL_MODES.includes(opts.signals)) {
|
|
120
|
-
throw
|
|
138
|
+
throw lerr('boot.signals_mode', { modes: SIGNAL_MODES.join(', '), signals: String(opts.signals) });
|
|
121
139
|
}
|
|
122
140
|
const [{ v }] = await sql `select current_setting('server_version_num') as v`;
|
|
123
141
|
if (Number(v) < 180000)
|
|
124
|
-
throw new Error(`letopis.up:
|
|
142
|
+
throw new Error(`letopis.up: ${lmsg('boot.install_pg_version', { version: v })}`);
|
|
125
143
|
// Общая часть установки — роли, членство установщика, pgcrypto и право владельца на схему pgcrypto — одна на все
|
|
126
144
|
// установки базы, а роли — на весь кластер. Параллельные up() разных схем меняли её одновременно: grant на схему
|
|
127
145
|
// pgcrypto переписывает её строку pg_namespace, даже если право уже есть, и второй такой grant падал «tuple
|
|
@@ -147,6 +165,11 @@ export async function install(sql, opts) {
|
|
|
147
165
|
// точку сохранения, а не всю транзакцию.
|
|
148
166
|
await tx.savepoint((sp) => sp.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`))
|
|
149
167
|
.catch((e) => {
|
|
168
|
+
// роль владельца создал кто-то другой, а у установщика нет на неё ADMIN — понятная ошибка вместо сырой 42501
|
|
169
|
+
// (план 2.0, 7.5 №3)
|
|
170
|
+
if (e.code === '42501') {
|
|
171
|
+
throw lerr('boot.owner_no_admin', { owner }, { cause: e });
|
|
172
|
+
}
|
|
150
173
|
if (e.code !== '0LP01' && e.code !== '23505')
|
|
151
174
|
throw e; // уже выдано циклом — нет; 23505 — выдал up() другой базы
|
|
152
175
|
});
|
|
@@ -165,10 +188,11 @@ export async function install(sql, opts) {
|
|
|
165
188
|
APP_NAME: app,
|
|
166
189
|
PGCRYPTO: quoteIdent(nsp),
|
|
167
190
|
SYSTEM: SYSTEM_ID,
|
|
168
|
-
ALLOW_RESET: opts.allowReset ? 'true' : 'false',
|
|
169
|
-
HISTORY_DEFAULT: quoteLiteral(JSON.stringify(opts.history ?? null)),
|
|
170
191
|
// методы цепочки — в тексте функции reserved_names() (план, §2.14)
|
|
171
192
|
RESERVED_NAMES: RESERVED_CLASS_NAMES.map(quoteLiteral).join(', '),
|
|
193
|
+
// каталог сообщений базы — разделы sql-* (план 2.0, этап 3, п. 3): ключ → имя ошибки и русский шаблон, JSON-литерал
|
|
194
|
+
// функции message_catalog() (15-errors.sql); обновляет его каждый up(), как прочие функции
|
|
195
|
+
MESSAGES: quoteLiteral(JSON.stringify(sqlCatalog())),
|
|
172
196
|
// режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
|
|
173
197
|
SIGNALS: 'class',
|
|
174
198
|
};
|
|
@@ -189,8 +213,19 @@ export async function install(sql, opts) {
|
|
|
189
213
|
// считаются миграции, — под блокировкой: up() видит ревизию до неё, и второй upgrade повторил бы миграции, уже
|
|
190
214
|
// накатанные первым. Разные схемы друг друга не ждут: ключ — имя схемы.
|
|
191
215
|
await lockSchema(tx, opts.schema);
|
|
192
|
-
if (opts.fresh)
|
|
216
|
+
if (opts.fresh) {
|
|
217
|
+
// схему удаляет её владелец или суперпользователь. Установщик без суперпользователя состоит в роли владельца без
|
|
218
|
+
// наследования прав (inherit false, set true): сам он получил бы «must be owner of schema» (42501), а от роли
|
|
219
|
+
// владельца — удаляет (ревью: fresh от такого установщика падал)
|
|
220
|
+
const [own] = await tx.unsafe(`select r.rolname as role, not pg_has_role(current_user, n.nspowner, 'USAGE')
|
|
221
|
+
and pg_has_role(current_user, n.nspowner, 'SET') as via
|
|
222
|
+
from pg_namespace n join pg_roles r on r.oid = n.nspowner where n.nspname = $1`, [opts.schema]);
|
|
223
|
+
if (own?.via)
|
|
224
|
+
await tx.unsafe(`set local role ${quoteIdent(own.role)}`);
|
|
193
225
|
await tx.unsafe(`drop schema if exists ${quoteIdent(opts.schema)} cascade`);
|
|
226
|
+
if (own?.via)
|
|
227
|
+
await tx.unsafe('set local role none');
|
|
228
|
+
}
|
|
194
229
|
found = await schemaRevision(tx, opts.schema);
|
|
195
230
|
if (opts.migrateFrom !== undefined && found !== null) {
|
|
196
231
|
migrations = await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, Math.max(opts.migrateFrom, found));
|
|
@@ -234,7 +269,7 @@ export async function install(sql, opts) {
|
|
|
234
269
|
// имя сида пакета или путь к своему файлу
|
|
235
270
|
const own = seed.endsWith('.sql') || /[\\/]/.test(seed);
|
|
236
271
|
if (!own && !/^[a-z0-9-]+$/.test(seed))
|
|
237
|
-
throw
|
|
272
|
+
throw lerr('boot.seed_name', { seed });
|
|
238
273
|
const f = own ? resolve(seed) : join(SQL_DIR, `seed.${seed}.sql`);
|
|
239
274
|
try {
|
|
240
275
|
await tx.unsafe(substitute(await readFile(f, 'utf8'), vars));
|
|
@@ -245,19 +280,12 @@ export async function install(sql, opts) {
|
|
|
245
280
|
throw err;
|
|
246
281
|
}
|
|
247
282
|
}
|
|
248
|
-
if (opts.history && files.some((f) => f.startsWith('40-'))) {
|
|
249
|
-
// умолчание проверяется той же метасхемой, что политика класса (сроки по возрастанию, all ≥ суток)
|
|
250
|
-
await tx.unsafe(`select ${quoteIdent(opts.schema)}.class_prepare($1::jsonb, $2::uuid)`, [{ name: 'HistoryDefault', kind: 'hub', history: opts.history }, SYSTEM_ID]).catch((e) => {
|
|
251
|
-
e.message = `letopis.up: history: ${e.message}`;
|
|
252
|
-
throw e;
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
283
|
if (files.some((f) => f.startsWith('95-'))) {
|
|
256
284
|
const S = quoteIdent(opts.schema);
|
|
257
285
|
if (opts.serviceKey) {
|
|
258
286
|
// свой ключ: регистрируется, если его ещё нет (ключ хранится как sha256)
|
|
259
287
|
if (!/^lts_[0-9a-f]{32,128}$/.test(opts.serviceKey))
|
|
260
|
-
throw
|
|
288
|
+
throw lerr('boot.service_key_format');
|
|
261
289
|
const hash = createHash('sha256').update(opts.serviceKey, 'utf8').digest();
|
|
262
290
|
const [{ n }] = await tx.unsafe(`select count(*)::int as n from ${S}.secret where kind = 'APIKEY' and material = $1 and deleted_at is null`, [hash]);
|
|
263
291
|
if (n === 0) {
|
|
@@ -289,6 +317,12 @@ export async function migrationFiles(dir, from) {
|
|
|
289
317
|
.filter((f) => Number(f.slice(0, 4)) > from && Number(f.slice(0, 4)) <= DDL_REVISION)
|
|
290
318
|
.sort();
|
|
291
319
|
}
|
|
320
|
+
/** Все опции up(): неизвестная — invalid_data с похожими именами до подключения (checkOptions; план 2.0, 7.10 №4). */
|
|
321
|
+
const UP_OPTIONS = {
|
|
322
|
+
dsn: true, sql: true, schema: true, fresh: true, upgrade: true, quiet: true, waitTimeoutMs: true, pglite: true, tenant: true,
|
|
323
|
+
login: true, owner: true, app: true, seeds: true, migrationsDir: true, serviceKey: true,
|
|
324
|
+
signals: true,
|
|
325
|
+
};
|
|
292
326
|
/** Проба сервера: ok | нет сервера | нет базы | запускается | роковая ошибка (неверный пароль). */
|
|
293
327
|
export async function pgPing(dsn) {
|
|
294
328
|
const sql = postgres(dsn, { max: 1, connect_timeout: 3, onnotice: () => { } });
|
|
@@ -325,7 +359,7 @@ export async function ensureDatabase(dsn, log = () => { }) {
|
|
|
325
359
|
if (e.code !== '42P04')
|
|
326
360
|
throw e;
|
|
327
361
|
});
|
|
328
|
-
log(
|
|
362
|
+
log(lmsg('boot.log_database_created', { name }));
|
|
329
363
|
return true;
|
|
330
364
|
}
|
|
331
365
|
finally {
|
|
@@ -337,19 +371,18 @@ export async function ensureDatabase(dsn, log = () => { }) {
|
|
|
337
371
|
* иначе отказ. Возвращает предупреждения: с чем установка работает, но рискует.
|
|
338
372
|
*/
|
|
339
373
|
export function checkServer(versionNum, encoding) {
|
|
374
|
+
// обычный Error: подходящего кода нет
|
|
340
375
|
if (versionNum < 180000)
|
|
341
|
-
throw new Error(`letopis.up:
|
|
376
|
+
throw new Error(`letopis.up: ${lmsg('boot.pg_version', { major: Math.floor(versionNum / 10000), num: versionNum })}`);
|
|
342
377
|
if (encoding.toUpperCase() !== 'UTF8')
|
|
343
|
-
throw new Error(`letopis.up:
|
|
378
|
+
throw new Error(`letopis.up: ${lmsg('boot.encoding', { encoding })}`);
|
|
344
379
|
const warnings = [];
|
|
345
380
|
// план 1.1, этап 2: исправлено в 18.2
|
|
346
|
-
if (versionNum < 180002)
|
|
347
|
-
warnings.push(
|
|
348
|
-
+ 'непрочитанным сигналам NOTIFY; обновите сервер до 18.2 или новее');
|
|
349
|
-
}
|
|
381
|
+
if (versionNum < 180002)
|
|
382
|
+
warnings.push(lmsg('boot.pg_clog', { version: `${Math.floor(versionNum / 10000)}.${versionNum % 10000}` }));
|
|
350
383
|
return warnings;
|
|
351
384
|
}
|
|
352
|
-
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
385
|
+
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null, штампа ревизии нет — 0. */
|
|
353
386
|
export async function schemaRevision(sql, schema) {
|
|
354
387
|
const [r] = await sql.unsafe(`select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = $1`, [schema]);
|
|
355
388
|
if (!r)
|
|
@@ -359,16 +392,20 @@ export async function schemaRevision(sql, schema) {
|
|
|
359
392
|
/**
|
|
360
393
|
* Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
|
|
361
394
|
* повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
|
|
362
|
-
* Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
|
|
395
|
+
* Схему отставшей ревизии 2.x `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает. Схему без
|
|
396
|
+
* штампа ревизии не трогает и отказывает (`invalid_data`): заново её ставит `up({ fresh: true })`.
|
|
363
397
|
*/
|
|
364
398
|
export async function up(opts) {
|
|
399
|
+
checkOptions('up', opts, UP_OPTIONS);
|
|
365
400
|
const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
|
|
366
401
|
const schema = opts.schema.includes('.') ? opts.schema : `${SCHEMA_VERSION}.${opts.schema}`;
|
|
367
402
|
if (!SCHEMA_NAME.test(schema))
|
|
368
|
-
throw
|
|
369
|
-
// опции арендатора — до подключения и наката: с неверными up() ничего не начинает
|
|
403
|
+
throw lerr('boot.up_schema_name', { schema: opts.schema });
|
|
404
|
+
// опции арендатора и роли приложения — до подключения и наката: с неверными up() ничего не начинает
|
|
370
405
|
if (opts.tenant)
|
|
371
406
|
checkTenant(opts.tenant);
|
|
407
|
+
if (opts.login !== undefined)
|
|
408
|
+
checkLogin(opts.login);
|
|
372
409
|
let sql = opts.sql;
|
|
373
410
|
const own = !sql;
|
|
374
411
|
const lite = opts.pglite ? await startPglite(opts.pglite === true ? {} : opts.pglite) : undefined;
|
|
@@ -376,7 +413,7 @@ export async function up(opts) {
|
|
|
376
413
|
sql = postgres(lite.dsn, { max: 1, onnotice: () => { } });
|
|
377
414
|
if (!sql) {
|
|
378
415
|
if (!opts.dsn)
|
|
379
|
-
throw
|
|
416
|
+
throw lerr('boot.no_connection');
|
|
380
417
|
const deadline = Date.now() + (opts.waitTimeoutMs ?? 60_000);
|
|
381
418
|
for (;;) {
|
|
382
419
|
const ping = await pgPing(opts.dsn);
|
|
@@ -388,8 +425,9 @@ export async function up(opts) {
|
|
|
388
425
|
await ensureDatabase(opts.dsn, log);
|
|
389
426
|
continue;
|
|
390
427
|
}
|
|
391
|
-
if (Date.now() > deadline)
|
|
392
|
-
throw new Error(`letopis.up:
|
|
428
|
+
if (Date.now() > deadline) {
|
|
429
|
+
throw new Error(`letopis.up: ${lmsg('boot.wait_timeout', { host: new URL(opts.dsn).host, seconds: Math.round((opts.waitTimeoutMs ?? 60_000) / 1000) })}`);
|
|
430
|
+
}
|
|
393
431
|
await new Promise((r) => setTimeout(r, 500));
|
|
394
432
|
}
|
|
395
433
|
sql = postgres(opts.dsn, { max: 1, onnotice: () => { } });
|
|
@@ -404,28 +442,66 @@ export async function up(opts) {
|
|
|
404
442
|
// ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
|
|
405
443
|
const before = opts.fresh ? null : await schemaRevision(sql, schema);
|
|
406
444
|
if (before !== null && before > DDL_REVISION) {
|
|
407
|
-
throw new Error(`letopis.up:
|
|
445
|
+
throw new Error(`letopis.up: ${lmsg('boot.schema_newer', { schema, rev: before, known: DDL_REVISION })}`);
|
|
408
446
|
}
|
|
447
|
+
// схема letopis 1.x — отказ до любых изменений, с upgrade тоже: 2.0 ставится только заново (план 2.0, 7.10 №5)
|
|
448
|
+
if (before !== null)
|
|
449
|
+
refuse1x(schema, before);
|
|
450
|
+
// отставшая ревизия 2.x — только с upgrade (миграции lib/sql/migrate/)
|
|
409
451
|
if (before !== null && before < DDL_REVISION && !opts.upgrade) {
|
|
410
|
-
throw new Error(`letopis.up:
|
|
452
|
+
throw new Error(`letopis.up: ${lmsg('boot.schema_older', { schema, rev: before, want: DDL_REVISION })}`);
|
|
411
453
|
}
|
|
412
|
-
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
|
|
454
|
+
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, login: _l, ...rest } = opts;
|
|
413
455
|
const { found, ...res } = await install(sql, {
|
|
414
456
|
...rest, schema, ...(opts.fresh ? { fresh: true } : {}), ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}),
|
|
415
457
|
});
|
|
416
458
|
if (opts.fresh)
|
|
417
|
-
log(
|
|
418
|
-
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
459
|
+
log(lmsg('boot.log_fresh', { schema }));
|
|
460
|
+
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть. От роли владельца, как у
|
|
461
|
+
// загрузчика: таблицы принадлежат ей, а установщик без суперпользователя наследования её прав не имеет — его ANALYZE
|
|
462
|
+
// PostgreSQL пропускал с WARNING (ревью). В analyzed — только таблицы, где статистика появилась; оставшиеся без
|
|
463
|
+
// неё — предупреждение
|
|
464
|
+
const missing = async () => (await sql `select relname from pg_stat_user_tables
|
|
465
|
+
where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`).map((r) => r.relname);
|
|
466
|
+
const todo = await missing();
|
|
467
|
+
if (todo.length) {
|
|
468
|
+
await sql.begin(async (tx) => {
|
|
469
|
+
await tx.unsafe(`set local role ${quoteIdent(res.owner)}`);
|
|
470
|
+
for (const t of todo)
|
|
471
|
+
await tx.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const left = todo.length ? await missing() : [];
|
|
475
|
+
const analyzed = todo.filter((t) => !left.includes(t));
|
|
476
|
+
if (left.length) {
|
|
477
|
+
const w = lmsg('boot.analyze_skipped', { owner: res.owner, tables: left.map((t) => `${schema}.${t}`).join(', ') });
|
|
478
|
+
warnings.push(w);
|
|
479
|
+
if (!opts.quiet)
|
|
480
|
+
console.warn(`letopis.up: ${w}`);
|
|
481
|
+
}
|
|
482
|
+
// хабы с зарезервированными именами, заведённые мимо проверки базы (план 2.0, этап 4, п. 2; 7.10 №3): накат их
|
|
483
|
+
// оставляет и называет, новых таких база не пишет. Строки Class — от роли владельца, как ANALYZE: у установщика без
|
|
484
|
+
// суперпользователя прав на таблицы нет, а RLS владельца таблицы не касается
|
|
485
|
+
const reserved = await sql.begin(async (tx) => {
|
|
486
|
+
await tx.unsafe(`set local role ${quoteIdent(res.owner)}`);
|
|
487
|
+
return tx.unsafe(`select x.data->>'name' as name, x.tenant::text as tenant from ${quoteIdent(schema)}.entity x
|
|
488
|
+
where x.class = 'Class' and x.data->>'kind' = 'hub' and x.data->>'name' = any ($1::text[])
|
|
489
|
+
order by x.data->>'name', x.tenant`, [[...RESERVED_CLASS_NAMES]]);
|
|
490
|
+
});
|
|
491
|
+
if (reserved.length) {
|
|
492
|
+
const w = lmsg('boot.reserved_hubs', { hubs: reserved.map((h) => `${h.name} (${h.tenant})`).join(', ') });
|
|
493
|
+
warnings.push(w);
|
|
494
|
+
if (!opts.quiet)
|
|
495
|
+
console.warn(`letopis.up: ${w}`);
|
|
496
|
+
}
|
|
424
497
|
const revision = (await schemaRevision(sql, schema)) ?? 0;
|
|
425
|
-
log(
|
|
498
|
+
log(lmsg(found === null ? 'boot.log_installed' : found < DDL_REVISION ? 'boot.log_upgraded' : 'boot.log_reapplied', { schema, revision, from: found }));
|
|
426
499
|
const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
|
|
500
|
+
const login = opts.login ? await ensureLogin(sql, opts.login, res.owner, res.app, opts.dsn ?? lite?.dsn) : undefined;
|
|
501
|
+
if (login)
|
|
502
|
+
log(lmsg(login.created ? 'boot.log_login_created' : 'boot.log_login_updated', { user: login.user }));
|
|
427
503
|
return { ...res, created: found === null, upgraded: found !== null && found < DDL_REVISION, revision, analyzed, warnings,
|
|
428
|
-
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
|
|
504
|
+
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}), ...(login ? { login } : {}) };
|
|
429
505
|
}
|
|
430
506
|
catch (e) {
|
|
431
507
|
if (lite)
|
|
@@ -439,8 +515,49 @@ export async function up(opts) {
|
|
|
439
515
|
await sql.end({ timeout: 5 }).catch(() => { });
|
|
440
516
|
}
|
|
441
517
|
}
|
|
518
|
+
/** Проверка up({ login }) до подключения: имя роли и пароль. */
|
|
519
|
+
function checkLogin(login) {
|
|
520
|
+
if (!login || typeof login !== 'object')
|
|
521
|
+
throw lerr('boot.login_not_object');
|
|
522
|
+
const { user, password } = login;
|
|
523
|
+
if (typeof user !== 'string' || !user.trim() || Buffer.byteLength(user, 'utf8') > 63 || user.startsWith('pg_') || user.includes('\0')) {
|
|
524
|
+
throw lerr('boot.login_user', { user: JSON.stringify(user) });
|
|
525
|
+
}
|
|
526
|
+
if (typeof password !== 'string' || !password || password.includes('\0'))
|
|
527
|
+
throw lerr('boot.login_password');
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Роль приложения up({ login }) (план 2.0, 7.9 №4): заводится или получает новый пароль, затем членство в роли
|
|
531
|
+
* приложения. Роли — объекты кластера: под той же блокировкой, что общая часть install(). Текст create/alter/grant
|
|
532
|
+
* собирает база (`format` с `%I` и `%L`), поэтому кавычки в имени и пароле не ломают SQL.
|
|
533
|
+
*/
|
|
534
|
+
async function ensureLogin(sql, login, owner, app, dsn) {
|
|
535
|
+
const created = await sql.begin(async (tx) => {
|
|
536
|
+
await tx `select pg_advisory_xact_lock(hashtext('letopis'), hashtext('install'))`;
|
|
537
|
+
const [r] = await tx `select r.rolsuper or r.rolcreaterole or r.rolcreatedb or r.rolreplication or r.rolbypassrls
|
|
538
|
+
or pg_has_role(r.oid, ${owner}, 'USAGE') or pg_has_role(r.oid, ${owner}, 'SET') as privileged
|
|
539
|
+
from pg_roles r where r.rolname = ${login.user}`;
|
|
540
|
+
if (r?.privileged || login.user === owner || login.user === app) {
|
|
541
|
+
throw lerr('boot.login_privileged', { user: login.user });
|
|
542
|
+
}
|
|
543
|
+
const verb = r ? 'alter role %I login password %L' : 'create role %I login password %L';
|
|
544
|
+
const [{ q }] = await tx `select format(${verb}::text, ${login.user}::text, ${login.password}::text) as q`;
|
|
545
|
+
await tx.unsafe(q);
|
|
546
|
+
const [{ g }] = await tx `select format('grant %I to %I', ${app}::text, ${login.user}::text) as g`;
|
|
547
|
+
await tx.unsafe(g);
|
|
548
|
+
return !r;
|
|
549
|
+
});
|
|
550
|
+
let url;
|
|
551
|
+
if (dsn) {
|
|
552
|
+
const u = new URL(dsn);
|
|
553
|
+
u.username = login.user;
|
|
554
|
+
u.password = login.password;
|
|
555
|
+
url = u.toString();
|
|
556
|
+
}
|
|
557
|
+
return { user: login.user, created, ...(url ? { dsn: url } : {}) };
|
|
558
|
+
}
|
|
442
559
|
/** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
|
|
443
|
-
const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', '
|
|
560
|
+
const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'LOCK'];
|
|
444
561
|
/**
|
|
445
562
|
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
|
|
446
563
|
* функций кредов (auth_kind_ok). Членство
|
|
@@ -451,28 +568,26 @@ const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', '
|
|
|
451
568
|
*/
|
|
452
569
|
function checkTenant(opts) {
|
|
453
570
|
if (!opts.name)
|
|
454
|
-
throw
|
|
571
|
+
throw lerr('boot.tenant_name_required');
|
|
455
572
|
// вид пароля — по правилу функций кредов (auth_kind_ok в 80-auth.sql): пароль пишет функция владельца мимо них, а с
|
|
456
573
|
// видом в другом регистре или служебным (SESSION, TOTP…) по нему не войти, и служебные строки он подменял бы
|
|
457
574
|
const kind = opts.user?.kind;
|
|
458
575
|
if (kind != null && (typeof kind !== 'string' || !/^[A-Z][A-Z0-9_]{1,31}$/.test(kind) || SERVICE_KINDS.includes(kind))) {
|
|
459
|
-
throw
|
|
576
|
+
throw lerr('boot.tenant_kind', { kind: JSON.stringify(kind), kinds: SERVICE_KINDS.join(', ') });
|
|
460
577
|
}
|
|
461
578
|
if (opts.user?.roles === undefined)
|
|
462
579
|
return ['owner'];
|
|
463
580
|
const roles = opts.user.roles;
|
|
464
|
-
const bad = (why) => new LetopisError('invalid_data', `createTenant: user.roles — ${why}`);
|
|
465
581
|
if (!Array.isArray(roles) || roles.length === 0)
|
|
466
|
-
throw
|
|
582
|
+
throw lerr('boot.tenant_roles_empty');
|
|
467
583
|
for (const r of roles) {
|
|
468
584
|
if (typeof r !== 'string')
|
|
469
|
-
throw
|
|
470
|
-
if (!r || r !== r.trim() || /[,{}]/.test(r) || r.startsWith('!'))
|
|
471
|
-
throw
|
|
472
|
-
}
|
|
585
|
+
throw lerr('boot.tenant_role_type', { type: r === null ? 'null' : typeof r });
|
|
586
|
+
if (!r || r !== r.trim() || /[,{}]/.test(r) || r.startsWith('!'))
|
|
587
|
+
throw lerr('boot.tenant_role_mask', { role: JSON.stringify(r) });
|
|
473
588
|
}
|
|
474
589
|
if (new Set(roles).size !== roles.length)
|
|
475
|
-
throw
|
|
590
|
+
throw lerr('boot.tenant_roles_repeat');
|
|
476
591
|
return roles;
|
|
477
592
|
}
|
|
478
593
|
/**
|
|
@@ -493,17 +608,15 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
|
493
608
|
// раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
|
|
494
609
|
await lockSchema(tx, schema);
|
|
495
610
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
496
|
-
/** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
|
|
497
|
-
const exists = async (id, cls, gone) => {
|
|
611
|
+
/** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ по ключу gone (что и как вернуть). */
|
|
612
|
+
const exists = async (id, cls, gone, params) => {
|
|
498
613
|
const [r] = await tx.unsafe(`select exists (select 1 from ${S}.entity where id = $1::uuid) as live,
|
|
499
614
|
exists (select 1 from ${S}.log where id = $1::uuid) as was`, [id]);
|
|
500
615
|
if (!r.live && r.was)
|
|
501
|
-
throw
|
|
616
|
+
throw lerr(gone, { ...params, id }, { detail: { id, class: cls } });
|
|
502
617
|
return r.live;
|
|
503
618
|
};
|
|
504
|
-
const
|
|
505
|
-
const created = !(await exists(tenant, 'Account', `арендатор «${opts.name}» (${tenant}) удалён, ${why}: `
|
|
506
|
-
+ 'верните его db.Account(id).restore() от имени системного администратора или выберите другое имя'));
|
|
619
|
+
const created = !(await exists(tenant, 'Account', 'boot.tenant_deleted', { name: opts.name }));
|
|
507
620
|
if (created) {
|
|
508
621
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [tenant, SYSTEM_ID, { name: opts.name, categories: [], enabled: true }]);
|
|
509
622
|
if (opts.grantAll !== false)
|
|
@@ -511,14 +624,12 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
|
511
624
|
}
|
|
512
625
|
let token = null;
|
|
513
626
|
if (u && user) {
|
|
514
|
-
if (!(await exists(user, 'Account',
|
|
515
|
-
+ 'верните его db.Account(id).restore() от имени системного администратора или выберите другой логин'))) {
|
|
627
|
+
if (!(await exists(user, 'Account', 'boot.tenant_user_deleted', { login: u.login }))) {
|
|
516
628
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [user, SYSTEM_ID, { name: u.name, categories: ['User'], enabled: true }]);
|
|
517
629
|
}
|
|
518
630
|
const member = (await tx.unsafe(`select ${S}.v5_id($1::uuid, 'member', array[$2::text, $1::text]) as id`, [tenant, user]))[0].id;
|
|
519
631
|
// существующее членство не трогаем: повтор с другими ролями их не меняет (их правят правкой member)
|
|
520
|
-
if (!(await exists(member, 'member',
|
|
521
|
-
+ 'верните его db.member(id).restore() в этом арендаторе или выберите другой логин'))) {
|
|
632
|
+
if (!(await exists(member, 'member', 'boot.tenant_member_deleted', { login: u.login, name: opts.name }))) {
|
|
522
633
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'member', $2::uuid, $3::jsonb, $4::jsonb)`, [member, tenant, { User: user, Tenant: tenant }, { roles }]);
|
|
523
634
|
}
|
|
524
635
|
const kind = u.kind ?? 'PASSWORD';
|
package/dist/uuid.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* разделитель — запятая с пробелом. Совпадение закрепляет общий файл векторов lib/test/vectors/v5.json.
|
|
7
7
|
*/
|
|
8
8
|
import { hash } from 'node:crypto';
|
|
9
|
-
import {
|
|
9
|
+
import { lerr } from './errors.js';
|
|
10
10
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
11
|
// байты пространств имён (арендаторов): в загрузке их единицы, а проверка и разбор шли на каждый id
|
|
12
12
|
const NS = new Map();
|
|
@@ -14,7 +14,7 @@ function nsBytes(ns) {
|
|
|
14
14
|
let b = NS.get(ns);
|
|
15
15
|
if (!b) {
|
|
16
16
|
if (!UUID.test(ns))
|
|
17
|
-
throw
|
|
17
|
+
throw lerr('model.v5_namespace_not_uuid', { ns });
|
|
18
18
|
b = Buffer.from(ns.replace(/-/g, ''), 'hex');
|
|
19
19
|
if (NS.size >= 10_000)
|
|
20
20
|
NS.clear();
|
|
@@ -44,17 +44,17 @@ export function v5Id(tenant, cls, values) {
|
|
|
44
44
|
*/
|
|
45
45
|
export function idOf(cls, key, opts) {
|
|
46
46
|
if (!cls.key.length) {
|
|
47
|
-
throw
|
|
47
|
+
throw lerr('model.class_no_key', { cls: cls.name });
|
|
48
48
|
}
|
|
49
49
|
const ends = new Set(cls.ends ?? []);
|
|
50
50
|
const values = cls.key.map((k) => {
|
|
51
51
|
const v = key[k];
|
|
52
52
|
if (typeof v !== 'string') {
|
|
53
|
-
throw
|
|
53
|
+
throw lerr('model.key_part_not_string', { cls: cls.name, part: k }, { detail: { key: cls.key } });
|
|
54
54
|
}
|
|
55
55
|
if (ends.has(k)) {
|
|
56
56
|
if (!UUID.test(v))
|
|
57
|
-
throw
|
|
57
|
+
throw lerr('model.key_end_not_uuid', { cls: cls.name, end: k });
|
|
58
58
|
return v.toLowerCase();
|
|
59
59
|
}
|
|
60
60
|
return v;
|
package/dist/validate.d.ts
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* слова нарушений. Им пользуются загрузчик (этап 4) и ранние ошибки библиотеки; совпадение с базой
|
|
5
5
|
* закрепляет дифференциальный тест validator-parity.
|
|
6
6
|
*
|
|
7
|
+
* Исключение — `pattern` (ревью): шаблон задаёт арендатор, и движок V8 с перебором на шаблоне вроде
|
|
8
|
+
* `^(a+)+$` повесил бы процесс приложения. Такие правила (`db`) проверяет только база (`like_regex`,
|
|
9
|
+
* движок без катастрофического перебора); здесь шаблон лишь разбирается — то, что база не примет, и
|
|
10
|
+
* шаблон, неверный для JavaScript, — `invalid_class`. `anyOf`/`oneOf`, в ветке которых есть `pattern`,
|
|
11
|
+
* без него не решить (у `oneOf` вышло бы ложное нарушение) — их тоже проверяет только база.
|
|
12
|
+
*
|
|
7
13
|
* Как и в базе, правило — «нет узла по пути, нарушающего условие»: обычное правило даёт одно
|
|
8
14
|
* нарушение, сколько бы узлов его ни нарушили; дополнительные проверки (длина строки, uniqueItems,
|
|
9
15
|
* смысл даты, перечисления объектов, число ключей) — по нарушению на каждый узел.
|
|
@@ -18,10 +24,16 @@ export interface Validator {
|
|
|
18
24
|
(data: unknown): Issue[];
|
|
19
25
|
}
|
|
20
26
|
/**
|
|
21
|
-
* Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при
|
|
27
|
+
* Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи, кроме
|
|
28
|
+
* `pattern` (и `anyOf`/`oneOf` с ним в ветке): шаблоны задаёт арендатор, их проверяет только база. Схема
|
|
22
29
|
* класса — `db.registry.resolve(имя).schema`.
|
|
23
30
|
*/
|
|
24
31
|
export declare function compileValidator(schema: JsonSchema): Validator;
|
|
32
|
+
/**
|
|
33
|
+
* Оставляет ли валидатор правила базе (`pattern` и `anyOf`/`oneOf` с ним): строки таких классов загрузчик, который
|
|
34
|
+
* пишет мимо триггеров, сверяет запросом к базе. Не входит в API пакета.
|
|
35
|
+
*/
|
|
36
|
+
export declare function leavesToDb(v: Validator): boolean;
|
|
25
37
|
/**
|
|
26
38
|
* Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
|
|
27
39
|
* записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не
|