letopis 1.4.2 → 3.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 +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- 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 +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -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 +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -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 +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -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 +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- 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 +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- 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';
|
|
@@ -16,14 +16,31 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import { join, resolve } from 'node:path';
|
|
17
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
18
18
|
import postgres from 'postgres';
|
|
19
|
-
import { DDL_REVISION, RESERVED_CLASS_NAMES } from './types.js';
|
|
19
|
+
import { DDL_REVISION, RESERVED_CLASS_NAMES, SCHEMA_VERSION } from './types.js';
|
|
20
|
+
import { makeLog, maskDsn, traceSql, traced } from './log.js';
|
|
20
21
|
import { startPglite } from './pglite.js';
|
|
21
22
|
import { hashPassword } from './auth.js';
|
|
22
|
-
import {
|
|
23
|
+
import { checkOptions, lerr, lmsg } from './errors.js';
|
|
24
|
+
import { sqlCatalog } from './messages/index.js';
|
|
23
25
|
import { v5Id } from './uuid.js';
|
|
24
26
|
import { SIGNAL_MODES } from './signals.js';
|
|
25
27
|
export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
26
|
-
export
|
|
28
|
+
export { SCHEMA_VERSION };
|
|
29
|
+
/**
|
|
30
|
+
* Первая ревизия движка 2.0 (план 2.0, редакция 2): схема ниже неё — letopis 1.x или схема без штампа ревизии. Её
|
|
31
|
+
* `up()` не обновляет, а `connect()` к ней не подключается: 2.0 ставится только заново (`up({ fresh: true })`), данные
|
|
32
|
+
* 1.x не переносятся. Накат миграций `up({ upgrade })` — в пределах 2.x, от этой ревизии до `DDL_REVISION`.
|
|
33
|
+
*/
|
|
34
|
+
export const FIRST_REVISION = 8;
|
|
35
|
+
/**
|
|
36
|
+
* Отказ на схеме letopis 1.x (план 2.0, 7.10 №5): ревизия из комментария схемы ниже `FIRST_REVISION` — 7 и ниже у 1.x,
|
|
37
|
+
* 0 — штампа ревизии нет. `up()` зовёт её до любых изменений (до общей части `install()`), `connect()` — до входа по
|
|
38
|
+
* ключу: функции схемы 1.x не вызываются.
|
|
39
|
+
*/
|
|
40
|
+
export function refuse1x(schema, rev) {
|
|
41
|
+
if (rev < FIRST_REVISION)
|
|
42
|
+
throw lerr('boot.schema_1x', { schema, rev, first: FIRST_REVISION });
|
|
43
|
+
}
|
|
27
44
|
/**
|
|
28
45
|
* Advisory-ключ наката одной схемы: пара int4, как у общей части установки (`hashtext('install')`), — имя схемы
|
|
29
46
|
* всегда с точкой (`v2.имя`), поэтому со словом install ключи не совпадут.
|
|
@@ -44,8 +61,9 @@ export async function engineFiles(upTo = 99) {
|
|
|
44
61
|
}
|
|
45
62
|
let stamp;
|
|
46
63
|
/**
|
|
47
|
-
* Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF)
|
|
48
|
-
*
|
|
64
|
+
* Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF) и по каталогу
|
|
65
|
+
* сообщений базы (разделы sql-*, подстановка @MESSAGES@): одна и та же сборка даёт один штамп при любых опциях
|
|
66
|
+
* установки, а версия пакета без правок SQL и этих разделов штамп не меняет.
|
|
49
67
|
*/
|
|
50
68
|
export async function engineStamp() {
|
|
51
69
|
if (stamp)
|
|
@@ -53,6 +71,7 @@ export async function engineStamp() {
|
|
|
53
71
|
const h = createHash('sha256');
|
|
54
72
|
for (const f of await engineFiles())
|
|
55
73
|
h.update(`${f}\n${(await readFile(join(SQL_DIR, f), 'utf8')).replace(/\r\n/g, '\n')}\n`);
|
|
74
|
+
h.update(`@MESSAGES@\n${JSON.stringify(sqlCatalog())}\n`);
|
|
56
75
|
const version = createRequire(import.meta.url)('../package.json').version;
|
|
57
76
|
return (stamp = { version, sql: h.digest('hex').slice(0, 16) });
|
|
58
77
|
}
|
|
@@ -95,16 +114,16 @@ export async function stampDrift(schema, comment) {
|
|
|
95
114
|
if (!code || /\bsql=([0-9a-f]+)/.exec(comment)?.[1] === code.sql)
|
|
96
115
|
return null;
|
|
97
116
|
const was = /\bengine=(\S+)/.exec(comment)?.[1];
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return
|
|
117
|
+
// схему обновила версия новее пакета — или в штампе нет версии (его правили руками: штамп ревизии 2.x up() пишет
|
|
118
|
+
// всегда с версией), другая сборка той же версии, другая версия
|
|
119
|
+
const key = was && compareVersions(was, code.version) > 0 ? 'boot.stamp_newer'
|
|
120
|
+
: !was ? 'boot.stamp_unknown' : was === code.version ? 'boot.stamp_other_build' : 'boot.stamp_other_version';
|
|
121
|
+
return 'letopis: ' + lmsg(key, { schema, version: code.version, ...(was ? { was } : {}) });
|
|
103
122
|
}
|
|
104
123
|
export function substitute(text, vars) {
|
|
105
124
|
return text.replace(/@([A-Z_]+)@/g, (m, k) => {
|
|
106
125
|
if (!(k in vars))
|
|
107
|
-
throw
|
|
126
|
+
throw lerr('boot.unknown_marker', { marker: m });
|
|
108
127
|
return vars[k];
|
|
109
128
|
});
|
|
110
129
|
}
|
|
@@ -112,16 +131,16 @@ export async function install(sql, opts) {
|
|
|
112
131
|
const owner = opts.owner ?? 'letopis_owner';
|
|
113
132
|
const app = opts.app ?? 'letopis_app';
|
|
114
133
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
115
|
-
throw
|
|
134
|
+
throw lerr('boot.install_schema_name', { schema: opts.schema });
|
|
116
135
|
for (const r of [owner, app])
|
|
117
136
|
if (!ROLE_NAME.test(r))
|
|
118
|
-
throw
|
|
137
|
+
throw lerr('boot.role_name', { role: r });
|
|
119
138
|
if (opts.signals !== undefined && !SIGNAL_MODES.includes(opts.signals)) {
|
|
120
|
-
throw
|
|
139
|
+
throw lerr('boot.signals_mode', { modes: SIGNAL_MODES.join(', '), signals: String(opts.signals) });
|
|
121
140
|
}
|
|
122
141
|
const [{ v }] = await sql `select current_setting('server_version_num') as v`;
|
|
123
142
|
if (Number(v) < 180000)
|
|
124
|
-
throw new Error(`letopis.up:
|
|
143
|
+
throw new Error(`letopis.up: ${lmsg('boot.install_pg_version', { version: v })}`);
|
|
125
144
|
// Общая часть установки — роли, членство установщика, pgcrypto и право владельца на схему pgcrypto — одна на все
|
|
126
145
|
// установки базы, а роли — на весь кластер. Параллельные up() разных схем меняли её одновременно: grant на схему
|
|
127
146
|
// pgcrypto переписывает её строку pg_namespace, даже если право уже есть, и второй такой grant падал «tuple
|
|
@@ -150,7 +169,7 @@ export async function install(sql, opts) {
|
|
|
150
169
|
// роль владельца создал кто-то другой, а у установщика нет на неё ADMIN — понятная ошибка вместо сырой 42501
|
|
151
170
|
// (план 2.0, 7.5 №3)
|
|
152
171
|
if (e.code === '42501') {
|
|
153
|
-
throw
|
|
172
|
+
throw lerr('boot.owner_no_admin', { owner }, { cause: e });
|
|
154
173
|
}
|
|
155
174
|
if (e.code !== '0LP01' && e.code !== '23505')
|
|
156
175
|
throw e; // уже выдано циклом — нет; 23505 — выдал up() другой базы
|
|
@@ -170,10 +189,11 @@ export async function install(sql, opts) {
|
|
|
170
189
|
APP_NAME: app,
|
|
171
190
|
PGCRYPTO: quoteIdent(nsp),
|
|
172
191
|
SYSTEM: SYSTEM_ID,
|
|
173
|
-
ALLOW_RESET: opts.allowReset ? 'true' : 'false',
|
|
174
|
-
HISTORY_DEFAULT: quoteLiteral(JSON.stringify(opts.history ?? null)),
|
|
175
192
|
// методы цепочки — в тексте функции reserved_names() (план, §2.14)
|
|
176
193
|
RESERVED_NAMES: RESERVED_CLASS_NAMES.map(quoteLiteral).join(', '),
|
|
194
|
+
// каталог сообщений базы — разделы sql-* (план 2.0, этап 3, п. 3): ключ → имя ошибки и русский шаблон, JSON-литерал
|
|
195
|
+
// функции message_catalog() (15-errors.sql); обновляет его каждый up(), как прочие функции
|
|
196
|
+
MESSAGES: quoteLiteral(JSON.stringify(sqlCatalog())),
|
|
177
197
|
// режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
|
|
178
198
|
SIGNALS: 'class',
|
|
179
199
|
};
|
|
@@ -194,8 +214,19 @@ export async function install(sql, opts) {
|
|
|
194
214
|
// считаются миграции, — под блокировкой: up() видит ревизию до неё, и второй upgrade повторил бы миграции, уже
|
|
195
215
|
// накатанные первым. Разные схемы друг друга не ждут: ключ — имя схемы.
|
|
196
216
|
await lockSchema(tx, opts.schema);
|
|
197
|
-
if (opts.fresh)
|
|
217
|
+
if (opts.fresh) {
|
|
218
|
+
// схему удаляет её владелец или суперпользователь. Установщик без суперпользователя состоит в роли владельца без
|
|
219
|
+
// наследования прав (inherit false, set true): сам он получил бы «must be owner of schema» (42501), а от роли
|
|
220
|
+
// владельца — удаляет (ревью: fresh от такого установщика падал)
|
|
221
|
+
const [own] = await tx.unsafe(`select r.rolname as role, not pg_has_role(current_user, n.nspowner, 'USAGE')
|
|
222
|
+
and pg_has_role(current_user, n.nspowner, 'SET') as via
|
|
223
|
+
from pg_namespace n join pg_roles r on r.oid = n.nspowner where n.nspname = $1`, [opts.schema]);
|
|
224
|
+
if (own?.via)
|
|
225
|
+
await tx.unsafe(`set local role ${quoteIdent(own.role)}`);
|
|
198
226
|
await tx.unsafe(`drop schema if exists ${quoteIdent(opts.schema)} cascade`);
|
|
227
|
+
if (own?.via)
|
|
228
|
+
await tx.unsafe('set local role none');
|
|
229
|
+
}
|
|
199
230
|
found = await schemaRevision(tx, opts.schema);
|
|
200
231
|
if (opts.migrateFrom !== undefined && found !== null) {
|
|
201
232
|
migrations = await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, Math.max(opts.migrateFrom, found));
|
|
@@ -239,7 +270,7 @@ export async function install(sql, opts) {
|
|
|
239
270
|
// имя сида пакета или путь к своему файлу
|
|
240
271
|
const own = seed.endsWith('.sql') || /[\\/]/.test(seed);
|
|
241
272
|
if (!own && !/^[a-z0-9-]+$/.test(seed))
|
|
242
|
-
throw
|
|
273
|
+
throw lerr('boot.seed_name', { seed });
|
|
243
274
|
const f = own ? resolve(seed) : join(SQL_DIR, `seed.${seed}.sql`);
|
|
244
275
|
try {
|
|
245
276
|
await tx.unsafe(substitute(await readFile(f, 'utf8'), vars));
|
|
@@ -250,19 +281,12 @@ export async function install(sql, opts) {
|
|
|
250
281
|
throw err;
|
|
251
282
|
}
|
|
252
283
|
}
|
|
253
|
-
if (opts.history && files.some((f) => f.startsWith('40-'))) {
|
|
254
|
-
// умолчание проверяется той же метасхемой, что политика класса (сроки по возрастанию, all ≥ суток)
|
|
255
|
-
await tx.unsafe(`select ${quoteIdent(opts.schema)}.class_prepare($1::jsonb, $2::uuid)`, [{ name: 'HistoryDefault', kind: 'hub', history: opts.history }, SYSTEM_ID]).catch((e) => {
|
|
256
|
-
e.message = `letopis.up: history: ${e.message}`;
|
|
257
|
-
throw e;
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
284
|
if (files.some((f) => f.startsWith('95-'))) {
|
|
261
285
|
const S = quoteIdent(opts.schema);
|
|
262
286
|
if (opts.serviceKey) {
|
|
263
287
|
// свой ключ: регистрируется, если его ещё нет (ключ хранится как sha256)
|
|
264
288
|
if (!/^lts_[0-9a-f]{32,128}$/.test(opts.serviceKey))
|
|
265
|
-
throw
|
|
289
|
+
throw lerr('boot.service_key_format');
|
|
266
290
|
const hash = createHash('sha256').update(opts.serviceKey, 'utf8').digest();
|
|
267
291
|
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]);
|
|
268
292
|
if (n === 0) {
|
|
@@ -294,6 +318,12 @@ export async function migrationFiles(dir, from) {
|
|
|
294
318
|
.filter((f) => Number(f.slice(0, 4)) > from && Number(f.slice(0, 4)) <= DDL_REVISION)
|
|
295
319
|
.sort();
|
|
296
320
|
}
|
|
321
|
+
/** Все опции up(): неизвестная — invalid_data с похожими именами до подключения (checkOptions; план 2.0, 7.10 №4). */
|
|
322
|
+
const UP_OPTIONS = {
|
|
323
|
+
dsn: true, sql: true, schema: true, fresh: true, upgrade: true, quiet: true, waitTimeoutMs: true, pglite: true, tenant: true,
|
|
324
|
+
login: true, owner: true, app: true, seeds: true, migrationsDir: true, serviceKey: true,
|
|
325
|
+
signals: true, log: true, logLevel: true, redact: true,
|
|
326
|
+
};
|
|
297
327
|
/** Проба сервера: ok | нет сервера | нет базы | запускается | роковая ошибка (неверный пароль). */
|
|
298
328
|
export async function pgPing(dsn) {
|
|
299
329
|
const sql = postgres(dsn, { max: 1, connect_timeout: 3, onnotice: () => { } });
|
|
@@ -330,7 +360,7 @@ export async function ensureDatabase(dsn, log = () => { }) {
|
|
|
330
360
|
if (e.code !== '42P04')
|
|
331
361
|
throw e;
|
|
332
362
|
});
|
|
333
|
-
log(
|
|
363
|
+
log(lmsg('boot.log_database_created', { name }));
|
|
334
364
|
return true;
|
|
335
365
|
}
|
|
336
366
|
finally {
|
|
@@ -342,19 +372,18 @@ export async function ensureDatabase(dsn, log = () => { }) {
|
|
|
342
372
|
* иначе отказ. Возвращает предупреждения: с чем установка работает, но рискует.
|
|
343
373
|
*/
|
|
344
374
|
export function checkServer(versionNum, encoding) {
|
|
375
|
+
// обычный Error: подходящего кода нет
|
|
345
376
|
if (versionNum < 180000)
|
|
346
|
-
throw new Error(`letopis.up:
|
|
377
|
+
throw new Error(`letopis.up: ${lmsg('boot.pg_version', { major: Math.floor(versionNum / 10000), num: versionNum })}`);
|
|
347
378
|
if (encoding.toUpperCase() !== 'UTF8')
|
|
348
|
-
throw new Error(`letopis.up:
|
|
379
|
+
throw new Error(`letopis.up: ${lmsg('boot.encoding', { encoding })}`);
|
|
349
380
|
const warnings = [];
|
|
350
381
|
// план 1.1, этап 2: исправлено в 18.2
|
|
351
|
-
if (versionNum < 180002)
|
|
352
|
-
warnings.push(
|
|
353
|
-
+ 'непрочитанным сигналам NOTIFY; обновите сервер до 18.2 или новее');
|
|
354
|
-
}
|
|
382
|
+
if (versionNum < 180002)
|
|
383
|
+
warnings.push(lmsg('boot.pg_clog', { version: `${Math.floor(versionNum / 10000)}.${versionNum % 10000}` }));
|
|
355
384
|
return warnings;
|
|
356
385
|
}
|
|
357
|
-
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
386
|
+
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null, штампа ревизии нет — 0. */
|
|
358
387
|
export async function schemaRevision(sql, schema) {
|
|
359
388
|
const [r] = await sql.unsafe(`select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = $1`, [schema]);
|
|
360
389
|
if (!r)
|
|
@@ -364,16 +393,43 @@ export async function schemaRevision(sql, schema) {
|
|
|
364
393
|
/**
|
|
365
394
|
* Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
|
|
366
395
|
* повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
|
|
367
|
-
* Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
|
|
396
|
+
* Схему отставшей ревизии 2.x `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает. Схему без
|
|
397
|
+
* штампа ревизии не трогает и отказывает (`invalid_data`): заново её ставит `up({ fresh: true })`.
|
|
368
398
|
*/
|
|
369
399
|
export async function up(opts) {
|
|
370
|
-
|
|
400
|
+
checkOptions('up', opts, UP_OPTIONS);
|
|
401
|
+
// логирование 3.0: события up, migrate, up.step, tenant.create, warning, error, sql — в логгер opts.log (или LETOPIS_LOG)
|
|
402
|
+
const lg = makeLog({ log: opts.log, logLevel: opts.logLevel, redact: opts.redact });
|
|
403
|
+
return traced(lg, 'up', { op: 'up' }, () => upCore(opts, lg), (r) => ({
|
|
404
|
+
detail: {
|
|
405
|
+
// schema — фактическое имя (имя без точки получает префикс версии: 'salon' → 'v2.salon'); requested — как передано
|
|
406
|
+
schema: r.schema, ...(opts.schema !== r.schema ? { requested: opts.schema } : {}),
|
|
407
|
+
created: r.created, upgraded: r.upgraded, revision: r.revision, signals: r.signals,
|
|
408
|
+
...(opts.dsn ? { dsn: maskDsn(opts.dsn) } : {}), ...(r.pglite ? { pglite: true } : {}), ...(opts.fresh ? { fresh: true } : {}),
|
|
409
|
+
analyzed: r.analyzed.length, warnings: r.warnings.length, ...(r.serviceKey ? { issuedKey: true } : {}),
|
|
410
|
+
...(r.tenant ? { tenant: r.tenant.tenant } : {}), ...(r.login ? { login: r.login.user } : {}),
|
|
411
|
+
},
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
/** up() после проверки опций; lg — логгер. */
|
|
415
|
+
async function upCore(opts, lg) {
|
|
416
|
+
// сообщения шагов: при заданном логгере — событие up.step вместо console.log
|
|
417
|
+
const log = opts.quiet ? () => { } : lg.active ? (m) => lg.emit('up.step', { op: 'up', msg: m }) : (m) => console.log(`letopis.up: ${m}`);
|
|
418
|
+
/** Предупреждение: в логгер (warning), иначе console.warn без quiet. */
|
|
419
|
+
const warn = (w) => {
|
|
420
|
+
if (lg.active)
|
|
421
|
+
lg.warning(undefined, w);
|
|
422
|
+
else if (!opts.quiet)
|
|
423
|
+
console.warn(`letopis.up: ${w}`);
|
|
424
|
+
};
|
|
371
425
|
const schema = opts.schema.includes('.') ? opts.schema : `${SCHEMA_VERSION}.${opts.schema}`;
|
|
372
426
|
if (!SCHEMA_NAME.test(schema))
|
|
373
|
-
throw
|
|
374
|
-
// опции арендатора — до подключения и наката: с неверными up() ничего не начинает
|
|
427
|
+
throw lerr('boot.up_schema_name', { schema: opts.schema });
|
|
428
|
+
// опции арендатора и роли приложения — до подключения и наката: с неверными up() ничего не начинает
|
|
375
429
|
if (opts.tenant)
|
|
376
430
|
checkTenant(opts.tenant);
|
|
431
|
+
if (opts.login !== undefined)
|
|
432
|
+
checkLogin(opts.login);
|
|
377
433
|
let sql = opts.sql;
|
|
378
434
|
const own = !sql;
|
|
379
435
|
const lite = opts.pglite ? await startPglite(opts.pglite === true ? {} : opts.pglite) : undefined;
|
|
@@ -381,7 +437,7 @@ export async function up(opts) {
|
|
|
381
437
|
sql = postgres(lite.dsn, { max: 1, onnotice: () => { } });
|
|
382
438
|
if (!sql) {
|
|
383
439
|
if (!opts.dsn)
|
|
384
|
-
throw
|
|
440
|
+
throw lerr('boot.no_connection');
|
|
385
441
|
const deadline = Date.now() + (opts.waitTimeoutMs ?? 60_000);
|
|
386
442
|
for (;;) {
|
|
387
443
|
const ping = await pgPing(opts.dsn);
|
|
@@ -393,44 +449,88 @@ export async function up(opts) {
|
|
|
393
449
|
await ensureDatabase(opts.dsn, log);
|
|
394
450
|
continue;
|
|
395
451
|
}
|
|
396
|
-
if (Date.now() > deadline)
|
|
397
|
-
throw new Error(`letopis.up:
|
|
452
|
+
if (Date.now() > deadline) {
|
|
453
|
+
throw new Error(`letopis.up: ${lmsg('boot.wait_timeout', { host: new URL(opts.dsn).host, seconds: Math.round((opts.waitTimeoutMs ?? 60_000) / 1000) })}`);
|
|
454
|
+
}
|
|
398
455
|
await new Promise((r) => setTimeout(r, 500));
|
|
399
456
|
}
|
|
400
457
|
sql = postgres(opts.dsn, { max: 1, onnotice: () => { } });
|
|
401
458
|
}
|
|
459
|
+
// порог debug — событие sql на каждый запрос установки (тексты create/alter role — без пароля)
|
|
460
|
+
sql = traceSql(sql, lg);
|
|
402
461
|
try {
|
|
403
462
|
const [srv] = await sql `select current_setting('server_version_num')::int as v, pg_encoding_to_char(encoding) as enc
|
|
404
463
|
from pg_database where datname = current_database()`;
|
|
405
464
|
const warnings = checkServer(srv.v, srv.enc);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
console.warn(`letopis.up: ${w}`);
|
|
465
|
+
for (const w of warnings)
|
|
466
|
+
warn(w);
|
|
409
467
|
// ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
|
|
410
468
|
const before = opts.fresh ? null : await schemaRevision(sql, schema);
|
|
411
469
|
if (before !== null && before > DDL_REVISION) {
|
|
412
|
-
throw new Error(`letopis.up:
|
|
470
|
+
throw new Error(`letopis.up: ${lmsg('boot.schema_newer', { schema, rev: before, known: DDL_REVISION })}`);
|
|
413
471
|
}
|
|
472
|
+
// схема letopis 1.x — отказ до любых изменений, с upgrade тоже: 2.0 ставится только заново (план 2.0, 7.10 №5)
|
|
473
|
+
if (before !== null)
|
|
474
|
+
refuse1x(schema, before);
|
|
475
|
+
// отставшая ревизия 2.x — только с upgrade (миграции lib/sql/migrate/)
|
|
414
476
|
if (before !== null && before < DDL_REVISION && !opts.upgrade) {
|
|
415
|
-
throw new Error(`letopis.up:
|
|
477
|
+
throw new Error(`letopis.up: ${lmsg('boot.schema_older', { schema, rev: before, want: DDL_REVISION })}`);
|
|
416
478
|
}
|
|
417
|
-
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
|
|
479
|
+
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, login: _l, log: _lg, logLevel: _ll, redact: _rd, ...rest } = opts;
|
|
480
|
+
const t0 = performance.now();
|
|
418
481
|
const { found, ...res } = await install(sql, {
|
|
419
482
|
...rest, schema, ...(opts.fresh ? { fresh: true } : {}), ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}),
|
|
420
483
|
});
|
|
484
|
+
if (found !== null && found < DDL_REVISION && lg.wants('migrate')) {
|
|
485
|
+
lg.emit('migrate', { op: 'up', ms: performance.now() - t0, rows: res.migrations.length, detail: { schema, from: found, to: DDL_REVISION, files: res.migrations } });
|
|
486
|
+
}
|
|
421
487
|
if (opts.fresh)
|
|
422
|
-
log(
|
|
423
|
-
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
488
|
+
log(lmsg('boot.log_fresh', { schema }));
|
|
489
|
+
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть. От роли владельца, как у
|
|
490
|
+
// загрузчика: таблицы принадлежат ей, а установщик без суперпользователя наследования её прав не имеет — его ANALYZE
|
|
491
|
+
// PostgreSQL пропускал с WARNING (ревью). В analyzed — только таблицы, где статистика появилась; оставшиеся без
|
|
492
|
+
// неё — предупреждение
|
|
493
|
+
const missing = async () => (await sql `select relname from pg_stat_user_tables
|
|
494
|
+
where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`).map((r) => r.relname);
|
|
495
|
+
const todo = await missing();
|
|
496
|
+
if (todo.length) {
|
|
497
|
+
await sql.begin(async (tx) => {
|
|
498
|
+
await tx.unsafe(`set local role ${quoteIdent(res.owner)}`);
|
|
499
|
+
for (const t of todo)
|
|
500
|
+
await tx.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
const left = todo.length ? await missing() : [];
|
|
504
|
+
const analyzed = todo.filter((t) => !left.includes(t));
|
|
505
|
+
if (left.length) {
|
|
506
|
+
const w = lmsg('boot.analyze_skipped', { owner: res.owner, tables: left.map((t) => `${schema}.${t}`).join(', ') });
|
|
507
|
+
warnings.push(w);
|
|
508
|
+
warn(w);
|
|
509
|
+
}
|
|
510
|
+
// хабы с зарезервированными именами, заведённые мимо проверки базы (план 2.0, этап 4, п. 2; 7.10 №3): накат их
|
|
511
|
+
// оставляет и называет, новых таких база не пишет. Строки Class — от роли владельца, как ANALYZE: у установщика без
|
|
512
|
+
// суперпользователя прав на таблицы нет, а RLS владельца таблицы не касается
|
|
513
|
+
const reserved = await sql.begin(async (tx) => {
|
|
514
|
+
await tx.unsafe(`set local role ${quoteIdent(res.owner)}`);
|
|
515
|
+
return tx.unsafe(`select x.data->>'name' as name, x.tenant::text as tenant from ${quoteIdent(schema)}.entity x
|
|
516
|
+
where x.class = 'Class' and x.data->>'kind' = 'hub' and x.data->>'name' = any ($1::text[])
|
|
517
|
+
order by x.data->>'name', x.tenant`, [[...RESERVED_CLASS_NAMES]]);
|
|
518
|
+
});
|
|
519
|
+
if (reserved.length) {
|
|
520
|
+
const w = lmsg('boot.reserved_hubs', { hubs: reserved.map((h) => `${h.name} (${h.tenant})`).join(', ') });
|
|
521
|
+
warnings.push(w);
|
|
522
|
+
warn(w);
|
|
523
|
+
}
|
|
429
524
|
const revision = (await schemaRevision(sql, schema)) ?? 0;
|
|
430
|
-
log(
|
|
431
|
-
const tenant = opts.tenant
|
|
525
|
+
log(lmsg(found === null ? 'boot.log_installed' : found < DDL_REVISION ? 'boot.log_upgraded' : 'boot.log_reapplied', { schema, revision, from: found }));
|
|
526
|
+
const tenant = opts.tenant
|
|
527
|
+
? await traced(lg, 'tenant.create', { op: 'createTenant' }, () => createTenant(sql, schema, opts.tenant, opts.owner), (t) => ({ tenant: t.tenant, ...(t.user ? { account: t.user } : {}), detail: { name: opts.tenant.name, created: t.created, user: Boolean(t.user) } }))
|
|
528
|
+
: undefined;
|
|
529
|
+
const login = opts.login ? await ensureLogin(sql, opts.login, res.owner, res.app, opts.dsn ?? lite?.dsn) : undefined;
|
|
530
|
+
if (login)
|
|
531
|
+
log(lmsg(login.created ? 'boot.log_login_created' : 'boot.log_login_updated', { user: login.user }));
|
|
432
532
|
return { ...res, created: found === null, upgraded: found !== null && found < DDL_REVISION, revision, analyzed, warnings,
|
|
433
|
-
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
|
|
533
|
+
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}), ...(login ? { login } : {}) };
|
|
434
534
|
}
|
|
435
535
|
catch (e) {
|
|
436
536
|
if (lite)
|
|
@@ -444,8 +544,49 @@ export async function up(opts) {
|
|
|
444
544
|
await sql.end({ timeout: 5 }).catch(() => { });
|
|
445
545
|
}
|
|
446
546
|
}
|
|
547
|
+
/** Проверка up({ login }) до подключения: имя роли и пароль. */
|
|
548
|
+
function checkLogin(login) {
|
|
549
|
+
if (!login || typeof login !== 'object')
|
|
550
|
+
throw lerr('boot.login_not_object');
|
|
551
|
+
const { user, password } = login;
|
|
552
|
+
if (typeof user !== 'string' || !user.trim() || Buffer.byteLength(user, 'utf8') > 63 || user.startsWith('pg_') || user.includes('\0')) {
|
|
553
|
+
throw lerr('boot.login_user', { user: JSON.stringify(user) });
|
|
554
|
+
}
|
|
555
|
+
if (typeof password !== 'string' || !password || password.includes('\0'))
|
|
556
|
+
throw lerr('boot.login_password');
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Роль приложения up({ login }) (план 2.0, 7.9 №4): заводится или получает новый пароль, затем членство в роли
|
|
560
|
+
* приложения. Роли — объекты кластера: под той же блокировкой, что общая часть install(). Текст create/alter/grant
|
|
561
|
+
* собирает база (`format` с `%I` и `%L`), поэтому кавычки в имени и пароле не ломают SQL.
|
|
562
|
+
*/
|
|
563
|
+
async function ensureLogin(sql, login, owner, app, dsn) {
|
|
564
|
+
const created = await sql.begin(async (tx) => {
|
|
565
|
+
await tx `select pg_advisory_xact_lock(hashtext('letopis'), hashtext('install'))`;
|
|
566
|
+
const [r] = await tx `select r.rolsuper or r.rolcreaterole or r.rolcreatedb or r.rolreplication or r.rolbypassrls
|
|
567
|
+
or pg_has_role(r.oid, ${owner}, 'USAGE') or pg_has_role(r.oid, ${owner}, 'SET') as privileged
|
|
568
|
+
from pg_roles r where r.rolname = ${login.user}`;
|
|
569
|
+
if (r?.privileged || login.user === owner || login.user === app) {
|
|
570
|
+
throw lerr('boot.login_privileged', { user: login.user });
|
|
571
|
+
}
|
|
572
|
+
const verb = r ? 'alter role %I login password %L' : 'create role %I login password %L';
|
|
573
|
+
const [{ q }] = await tx `select format(${verb}::text, ${login.user}::text, ${login.password}::text) as q`;
|
|
574
|
+
await tx.unsafe(q);
|
|
575
|
+
const [{ g }] = await tx `select format('grant %I to %I', ${app}::text, ${login.user}::text) as g`;
|
|
576
|
+
await tx.unsafe(g);
|
|
577
|
+
return !r;
|
|
578
|
+
});
|
|
579
|
+
let url;
|
|
580
|
+
if (dsn) {
|
|
581
|
+
const u = new URL(dsn);
|
|
582
|
+
u.username = login.user;
|
|
583
|
+
u.password = login.password;
|
|
584
|
+
url = u.toString();
|
|
585
|
+
}
|
|
586
|
+
return { user: login.user, created, ...(url ? { dsn: url } : {}) };
|
|
587
|
+
}
|
|
447
588
|
/** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
|
|
448
|
-
const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', '
|
|
589
|
+
const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'LOCK'];
|
|
449
590
|
/**
|
|
450
591
|
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
|
|
451
592
|
* функций кредов (auth_kind_ok). Членство
|
|
@@ -456,28 +597,26 @@ const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', '
|
|
|
456
597
|
*/
|
|
457
598
|
function checkTenant(opts) {
|
|
458
599
|
if (!opts.name)
|
|
459
|
-
throw
|
|
600
|
+
throw lerr('boot.tenant_name_required');
|
|
460
601
|
// вид пароля — по правилу функций кредов (auth_kind_ok в 80-auth.sql): пароль пишет функция владельца мимо них, а с
|
|
461
602
|
// видом в другом регистре или служебным (SESSION, TOTP…) по нему не войти, и служебные строки он подменял бы
|
|
462
603
|
const kind = opts.user?.kind;
|
|
463
604
|
if (kind != null && (typeof kind !== 'string' || !/^[A-Z][A-Z0-9_]{1,31}$/.test(kind) || SERVICE_KINDS.includes(kind))) {
|
|
464
|
-
throw
|
|
605
|
+
throw lerr('boot.tenant_kind', { kind: JSON.stringify(kind), kinds: SERVICE_KINDS.join(', ') });
|
|
465
606
|
}
|
|
466
607
|
if (opts.user?.roles === undefined)
|
|
467
608
|
return ['owner'];
|
|
468
609
|
const roles = opts.user.roles;
|
|
469
|
-
const bad = (why) => new LetopisError('invalid_data', `createTenant: user.roles — ${why}`);
|
|
470
610
|
if (!Array.isArray(roles) || roles.length === 0)
|
|
471
|
-
throw
|
|
611
|
+
throw lerr('boot.tenant_roles_empty');
|
|
472
612
|
for (const r of roles) {
|
|
473
613
|
if (typeof r !== 'string')
|
|
474
|
-
throw
|
|
475
|
-
if (!r || r !== r.trim() || /[,{}]/.test(r) || r.startsWith('!'))
|
|
476
|
-
throw
|
|
477
|
-
}
|
|
614
|
+
throw lerr('boot.tenant_role_type', { type: r === null ? 'null' : typeof r });
|
|
615
|
+
if (!r || r !== r.trim() || /[,{}]/.test(r) || r.startsWith('!'))
|
|
616
|
+
throw lerr('boot.tenant_role_mask', { role: JSON.stringify(r) });
|
|
478
617
|
}
|
|
479
618
|
if (new Set(roles).size !== roles.length)
|
|
480
|
-
throw
|
|
619
|
+
throw lerr('boot.tenant_roles_repeat');
|
|
481
620
|
return roles;
|
|
482
621
|
}
|
|
483
622
|
/**
|
|
@@ -487,28 +626,37 @@ function checkTenant(opts) {
|
|
|
487
626
|
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
488
627
|
*/
|
|
489
628
|
export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
629
|
+
// admin — пул postgres(): строка DSN, подключение connect() letopis или другой объект упали бы голым TypeError на
|
|
630
|
+
// sql.begin (3.0)
|
|
631
|
+
const pool = sql;
|
|
632
|
+
if (typeof pool !== 'function' || typeof pool.begin !== 'function' || typeof pool.unsafe !== 'function') {
|
|
633
|
+
throw lerr('api.create_tenant_admin', { type: pool === null ? 'null' : typeof pool });
|
|
634
|
+
}
|
|
635
|
+
// имя схемы — как у connect() и up(): без точки — с префиксом версии (v2.salon)
|
|
636
|
+
if (typeof schema === 'string' && !schema.includes('.'))
|
|
637
|
+
schema = `${SCHEMA_VERSION}.${schema}`;
|
|
490
638
|
const roles = checkTenant(opts);
|
|
491
639
|
const S = quoteIdent(schema);
|
|
492
640
|
const tenant = v5Id(SYSTEM_ID, 'Account', [`tenant:${opts.name}`]);
|
|
493
641
|
const u = opts.user;
|
|
494
|
-
|
|
642
|
+
// логин без крайних пробелов, как у auth.setPassword() и verifyPassword() (план 2.1, A12)
|
|
643
|
+
const login = u ? (typeof u.login === 'string' ? u.login.trim() : u.login) : '';
|
|
644
|
+
const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${login}`]) : null;
|
|
495
645
|
const hash = u ? await hashPassword(u.password) : null;
|
|
496
646
|
return sql.begin(async (tx) => {
|
|
497
647
|
// под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
|
|
498
648
|
// раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
|
|
499
649
|
await lockSchema(tx, schema);
|
|
500
650
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
501
|
-
/** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
|
|
502
|
-
const exists = async (id, cls, gone) => {
|
|
651
|
+
/** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ по ключу gone (что и как вернуть). */
|
|
652
|
+
const exists = async (id, cls, gone, params) => {
|
|
503
653
|
const [r] = await tx.unsafe(`select exists (select 1 from ${S}.entity where id = $1::uuid) as live,
|
|
504
654
|
exists (select 1 from ${S}.log where id = $1::uuid) as was`, [id]);
|
|
505
655
|
if (!r.live && r.was)
|
|
506
|
-
throw
|
|
656
|
+
throw lerr(gone, { ...params, id }, { detail: { id, class: cls } });
|
|
507
657
|
return r.live;
|
|
508
658
|
};
|
|
509
|
-
const
|
|
510
|
-
const created = !(await exists(tenant, 'Account', `арендатор «${opts.name}» (${tenant}) удалён, ${why}: `
|
|
511
|
-
+ 'верните его db.Account(id).restore() от имени системного администратора или выберите другое имя'));
|
|
659
|
+
const created = !(await exists(tenant, 'Account', 'boot.tenant_deleted', { name: opts.name }));
|
|
512
660
|
if (created) {
|
|
513
661
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [tenant, SYSTEM_ID, { name: opts.name, categories: [], enabled: true }]);
|
|
514
662
|
if (opts.grantAll !== false)
|
|
@@ -516,20 +664,18 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
|
516
664
|
}
|
|
517
665
|
let token = null;
|
|
518
666
|
if (u && user) {
|
|
519
|
-
if (!(await exists(user, 'Account',
|
|
520
|
-
+ 'верните его db.Account(id).restore() от имени системного администратора или выберите другой логин'))) {
|
|
667
|
+
if (!(await exists(user, 'Account', 'boot.tenant_user_deleted', { login }))) {
|
|
521
668
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [user, SYSTEM_ID, { name: u.name, categories: ['User'], enabled: true }]);
|
|
522
669
|
}
|
|
523
670
|
const member = (await tx.unsafe(`select ${S}.v5_id($1::uuid, 'member', array[$2::text, $1::text]) as id`, [tenant, user]))[0].id;
|
|
524
671
|
// существующее членство не трогаем: повтор с другими ролями их не меняет (их правят правкой member)
|
|
525
|
-
if (!(await exists(member, 'member',
|
|
526
|
-
+ 'верните его db.member(id).restore() в этом арендаторе или выберите другой логин'))) {
|
|
672
|
+
if (!(await exists(member, 'member', 'boot.tenant_member_deleted', { login, name: opts.name }))) {
|
|
527
673
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'member', $2::uuid, $3::jsonb, $4::jsonb)`, [member, tenant, { User: user, Tenant: tenant }, { roles }]);
|
|
528
674
|
}
|
|
529
675
|
const kind = u.kind ?? 'PASSWORD';
|
|
530
676
|
await tx.unsafe(`insert into ${S}.secret (account, kind, ident, material, meta)
|
|
531
677
|
select $1::uuid, $2, $3, convert_to($4, 'UTF8'), '{"confirmed": true}'::jsonb
|
|
532
|
-
where not exists (select 1 from ${S}.secret where kind = $2 and casefold(ident) collate pg_unicode_fast = casefold($3) collate pg_unicode_fast and deleted_at is null)`, [user, kind,
|
|
678
|
+
where not exists (select 1 from ${S}.secret where kind = $2 and casefold(ident) collate pg_unicode_fast = casefold($3) collate pg_unicode_fast and deleted_at is null)`, [user, kind, login, hash]);
|
|
533
679
|
token = (await tx.unsafe(`select ${S}.new_session($1::uuid, $2::interval, $3::jsonb) as t`, [user, u.ttl ?? '7 days', { tenant }]))[0].t;
|
|
534
680
|
}
|
|
535
681
|
return { tenant, user, token, created };
|
package/dist/uuid.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** uuid в каноническом виде 8-4-4-4-12 (регистр любой) — общий для построителя, загрузчика и MCP. */
|
|
2
|
+
export declare const UUID_RE: RegExp;
|
|
3
|
+
/**
|
|
4
|
+
* Любой вид uuid, который принимает приведение PostgreSQL `::uuid`: 32 шестнадцатеричные цифры, дефис — после любой
|
|
5
|
+
* группы из четырёх (или без дефисов), весь в фигурных скобках или без них. Фильтр с таким id работал и до проверки,
|
|
6
|
+
* поэтому проверка до запроса (sql.ts, compileFilter) отказывает только тому, что отклонила бы база.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PG_UUID_RE: RegExp;
|
|
1
9
|
/** RFC 4122 v5: sha1(пространство + имя); одно имя в одном пространстве — один id. */
|
|
2
10
|
export declare function uuidv5(name: string, ns: string): string;
|
|
3
11
|
/** Имя v5: JSON-массив из имени класса и значений ключа — как `jsonb_build_array(…)::text` в базе. */
|