letopis 1.3.0 → 1.4.1
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.en.md +14 -6
- package/AGENT-CHEATSHEET.md +18 -10
- package/CHANGELOG.md +247 -0
- package/README.en.md +146 -58
- package/README.md +129 -51
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +42 -16
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/chain.d.ts +91 -15
- package/dist/chain.js +47 -4
- package/dist/cli.js +6 -0
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +74 -45
- package/dist/index.js +18 -4
- package/dist/load.d.ts +10 -3
- package/dist/load.js +19 -4
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +4 -4
- package/dist/model.d.ts +4 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +44 -12
- package/dist/project.js +90 -8
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/sql.d.ts +4 -1
- package/dist/sql.js +12 -4
- package/dist/sync.js +11 -5
- package/dist/tx.js +10 -1
- package/dist/types.d.ts +14 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +65 -25
- package/dist/up.js +152 -40
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +17 -5
- package/dist/watch.js +40 -8
- package/dist/write.d.ts +3 -2
- package/dist/write.js +29 -10
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/sql/10-core.sql +6 -1
- package/sql/50-acl.sql +5 -1
- package/sql/60-write.sql +77 -22
- package/sql/80-auth.sql +39 -2
- package/sql/88-derived.sql +82 -1
- package/sql/90-time.sql +75 -38
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +3 -2
- package/sql/README.md +2 -0
- package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/up.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* выполняет ANALYZE только для таблиц без статистики.
|
|
12
12
|
*/
|
|
13
13
|
import { readFile, readdir } from 'node:fs/promises';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { join, resolve } from 'node:path';
|
|
16
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
@@ -23,6 +24,13 @@ import { v5Id } from './uuid.js';
|
|
|
23
24
|
import { SIGNAL_MODES } from './signals.js';
|
|
24
25
|
export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
25
26
|
export const SCHEMA_VERSION = 'v2';
|
|
27
|
+
/**
|
|
28
|
+
* Advisory-ключ наката одной схемы: пара int4, как у общей части установки (`hashtext('install')`), — имя схемы
|
|
29
|
+
* всегда с точкой (`v2.имя`), поэтому со словом install ключи не совпадут.
|
|
30
|
+
*/
|
|
31
|
+
async function lockSchema(tx, schema) {
|
|
32
|
+
await tx.unsafe(`select pg_advisory_xact_lock(hashtext('letopis'), hashtext($1))`, [schema]);
|
|
33
|
+
}
|
|
26
34
|
const SQL_DIR = fileURLToPath(new URL('../sql/', import.meta.url));
|
|
27
35
|
const MIGRATE_DIR = join(SQL_DIR, 'migrate');
|
|
28
36
|
const ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
@@ -34,6 +42,65 @@ export async function engineFiles(upTo = 99) {
|
|
|
34
42
|
const files = (await readdir(SQL_DIR)).filter((f) => /^\d{2}-[a-z0-9.-]+\.sql$/.test(f));
|
|
35
43
|
return files.filter((f) => Number(f.slice(0, 2)) <= upTo).sort();
|
|
36
44
|
}
|
|
45
|
+
let stamp;
|
|
46
|
+
/**
|
|
47
|
+
* Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
|
|
48
|
+
* сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
|
|
49
|
+
*/
|
|
50
|
+
export async function engineStamp() {
|
|
51
|
+
if (stamp)
|
|
52
|
+
return stamp;
|
|
53
|
+
const h = createHash('sha256');
|
|
54
|
+
for (const f of await engineFiles())
|
|
55
|
+
h.update(`${f}\n${(await readFile(join(SQL_DIR, f), 'utf8')).replace(/\r\n/g, '\n')}\n`);
|
|
56
|
+
const version = createRequire(import.meta.url)('../package.json').version;
|
|
57
|
+
return (stamp = { version, sql: h.digest('hex').slice(0, 16) });
|
|
58
|
+
}
|
|
59
|
+
/** Сравнение версий semver (метаданные сборки не учитываются): меньше нуля, ноль, больше нуля. */
|
|
60
|
+
export function compareVersions(a, b) {
|
|
61
|
+
const parse = (v) => {
|
|
62
|
+
const [core, pre] = v.split('+')[0].split(/-(.*)/s);
|
|
63
|
+
return { core: core.split('.').map(Number), pre: pre ? pre.split('.') : [] };
|
|
64
|
+
};
|
|
65
|
+
const x = parse(a);
|
|
66
|
+
const y = parse(b);
|
|
67
|
+
for (let i = 0; i < 3; i++)
|
|
68
|
+
if ((x.core[i] ?? 0) !== (y.core[i] ?? 0))
|
|
69
|
+
return (x.core[i] ?? 0) - (y.core[i] ?? 0);
|
|
70
|
+
if (!x.pre.length || !y.pre.length)
|
|
71
|
+
return y.pre.length - x.pre.length; // выпуск старше своих предварительных версий
|
|
72
|
+
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
|
73
|
+
const p = x.pre[i];
|
|
74
|
+
const q = y.pre[i];
|
|
75
|
+
if (p === undefined || q === undefined)
|
|
76
|
+
return p === undefined ? -1 : 1;
|
|
77
|
+
if (p === q)
|
|
78
|
+
continue;
|
|
79
|
+
const pn = /^\d+$/.test(p);
|
|
80
|
+
const qn = /^\d+$/.test(q);
|
|
81
|
+
if (pn && qn)
|
|
82
|
+
return Number(p) - Number(q);
|
|
83
|
+
if (pn !== qn)
|
|
84
|
+
return pn ? -1 : 1;
|
|
85
|
+
return p < q ? -1 : 1;
|
|
86
|
+
}
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Штамп схемы (комментарий схемы) против файлов ядра пакета — текст предупреждения или null. Ревизия та же, а
|
|
91
|
+
* функции поставила другая версия движка: после обновления пакета не накатили up() (так было при 1.1 → 1.2).
|
|
92
|
+
*/
|
|
93
|
+
export async function stampDrift(schema, comment) {
|
|
94
|
+
const code = await engineStamp().catch(() => null); // файлов ядра рядом со сборкой нет — сверять не с чем
|
|
95
|
+
if (!code || /\bsql=([0-9a-f]+)/.exec(comment)?.[1] === code.sql)
|
|
96
|
+
return null;
|
|
97
|
+
const was = /\bengine=(\S+)/.exec(comment)?.[1];
|
|
98
|
+
if (was && compareVersions(was, code.version) > 0) {
|
|
99
|
+
return `letopis: схему ${schema} обновила letopis ${was}, библиотека старее (${code.version}) — обновите пакет: up() этой версией вернул бы прежние функции`;
|
|
100
|
+
}
|
|
101
|
+
const by = !was ? 'версия до 1.4.0' : was === code.version ? `другая сборка letopis ${was}` : `letopis ${was}`;
|
|
102
|
+
return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: обновите схему — вызовите up({ schema: '${schema}' })`;
|
|
103
|
+
}
|
|
37
104
|
export function substitute(text, vars) {
|
|
38
105
|
return text.replace(/@([A-Z_]+)@/g, (m, k) => {
|
|
39
106
|
if (!(k in vars))
|
|
@@ -55,23 +122,40 @@ export async function install(sql, opts) {
|
|
|
55
122
|
const [{ v }] = await sql `select current_setting('server_version_num') as v`;
|
|
56
123
|
if (Number(v) < 180000)
|
|
57
124
|
throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
125
|
+
// Общая часть установки — роли, членство установщика, pgcrypto и право владельца на схему pgcrypto — одна на все
|
|
126
|
+
// установки базы, а роли — на весь кластер. Параллельные up() разных схем меняли её одновременно: grant на схему
|
|
127
|
+
// pgcrypto переписывает её строку pg_namespace, даже если право уже есть, и второй такой grant падал «tuple
|
|
128
|
+
// concurrently updated» (XX000); на чистой базе второй create role или create extension упал бы на уникальном индексе
|
|
129
|
+
// каталога. Поэтому общая часть — отдельная транзакция под advisory-блокировкой: другой up() ждёт её фиксации и
|
|
130
|
+
// видит готовые объекты. Строка-замок, как у внутренних блокировок движка (60-write.sql), не годится: таблицы secret
|
|
131
|
+
// до установки нет, и у каждой схемы она своя — разные установки друг друга не увидели бы. Advisory-ключ может занять
|
|
132
|
+
// любая роль базы (ревью безопасности №2 и №3), но так посторонний задержит только up() — операцию администратора, а
|
|
133
|
+
// не запись; кто держит ключ, видно в pg_locks. Ключ — пара int4: это пространство ключей отдельно от lock() явной
|
|
134
|
+
// транзакции (int8). Блокировка живёт до конца транзакции, обрыв соединения её снимает.
|
|
135
|
+
// Advisory-блокировка действует в пределах базы, а роли общие для кластера: up() в другой базе может создать роль
|
|
136
|
+
// или членство одновременно с этим, тогда здесь unique_violation (23505) — роль или членство уже есть.
|
|
137
|
+
const nsp = await sql.begin(async (tx) => {
|
|
138
|
+
await tx.unsafe(`select pg_advisory_xact_lock(hashtext('letopis'), hashtext('install'))`);
|
|
139
|
+
for (const r of [owner, app]) {
|
|
140
|
+
await tx.unsafe(`do $$ begin
|
|
141
|
+
if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(r)}) then
|
|
142
|
+
create role ${quoteIdent(r)} nologin;
|
|
143
|
+
end if;
|
|
144
|
+
exception when duplicate_object or unique_violation then null; end $$`);
|
|
145
|
+
}
|
|
146
|
+
// Установщик получает права владельца только на время set role (план, §2.9). Отказ гранта откатывает только
|
|
147
|
+
// точку сохранения, а не всю транзакцию.
|
|
148
|
+
await tx.savepoint((sp) => sp.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`))
|
|
149
|
+
.catch((e) => {
|
|
150
|
+
if (e.code !== '0LP01' && e.code !== '23505')
|
|
151
|
+
throw e; // уже выдано циклом — нет; 23505 — выдал up() другой базы
|
|
152
|
+
});
|
|
153
|
+
await tx.unsafe(`create extension if not exists pgcrypto`);
|
|
154
|
+
const [{ nsp }] = await tx.unsafe(`
|
|
155
|
+
select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`);
|
|
156
|
+
await tx.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
|
|
157
|
+
return nsp;
|
|
70
158
|
});
|
|
71
|
-
await sql.unsafe(`create extension if not exists pgcrypto`);
|
|
72
|
-
const [{ nsp }] = await sql `
|
|
73
|
-
select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`;
|
|
74
|
-
await sql.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
|
|
75
159
|
const vars = {
|
|
76
160
|
SCHEMA: quoteIdent(opts.schema),
|
|
77
161
|
SCHEMA_NAME: opts.schema,
|
|
@@ -88,13 +172,31 @@ export async function install(sql, opts) {
|
|
|
88
172
|
// режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
|
|
89
173
|
SIGNALS: 'class',
|
|
90
174
|
};
|
|
91
|
-
//
|
|
92
|
-
|
|
175
|
+
// штамп движка — в комментарий схемы (99-revision.sql): по нему connect() узнаёт схему, не накатанную этой версией
|
|
176
|
+
const st = await engineStamp();
|
|
177
|
+
vars.ENGINE_VERSION = st.version;
|
|
178
|
+
vars.ENGINE_SQL = st.sql;
|
|
93
179
|
const files = await engineFiles(opts.upTo);
|
|
94
|
-
|
|
180
|
+
let migrations = [];
|
|
181
|
+
let found = null;
|
|
95
182
|
let serviceKey = null;
|
|
96
183
|
let signals = 'class';
|
|
97
184
|
await sql.begin(async (tx) => {
|
|
185
|
+
// Накат схемы — под advisory-блокировкой самой схемы. Несколько экземпляров сервиса зовут up() одной схемы при
|
|
186
|
+
// запуске разом: без блокировки второй create schema падал на уникальном индексе каталога (23505), а второй
|
|
187
|
+
// create or replace function или comment — «tuple concurrently updated» (XX000). С ней следующий up() ждёт, пока
|
|
188
|
+
// предыдущий зафиксирует накат, и делает повторный накат по готовой схеме. Поэтому и fresh, и ревизия, от которой
|
|
189
|
+
// считаются миграции, — под блокировкой: up() видит ревизию до неё, и второй upgrade повторил бы миграции, уже
|
|
190
|
+
// накатанные первым. Разные схемы друг друга не ждут: ключ — имя схемы.
|
|
191
|
+
await lockSchema(tx, opts.schema);
|
|
192
|
+
if (opts.fresh)
|
|
193
|
+
await tx.unsafe(`drop schema if exists ${quoteIdent(opts.schema)} cascade`);
|
|
194
|
+
found = await schemaRevision(tx, opts.schema);
|
|
195
|
+
if (opts.migrateFrom !== undefined && found !== null) {
|
|
196
|
+
migrations = await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, Math.max(opts.migrateFrom, found));
|
|
197
|
+
}
|
|
198
|
+
// Схема создаётся до set role: у владельца может не быть права CREATE в базе.
|
|
199
|
+
await tx.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
|
|
98
200
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
99
201
|
// режим сигналов (план 1.1, §2.6): опция, иначе сохранённый в схеме (meta строки ключа), иначе class
|
|
100
202
|
let saved = null;
|
|
@@ -172,7 +274,7 @@ export async function install(sql, opts) {
|
|
|
172
274
|
}
|
|
173
275
|
}
|
|
174
276
|
});
|
|
175
|
-
return { schema: opts.schema, owner, app, serviceKey, migrations, signals };
|
|
277
|
+
return { schema: opts.schema, owner, app, serviceKey, migrations, signals, found };
|
|
176
278
|
}
|
|
177
279
|
/** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
|
|
178
280
|
export async function migrationFiles(dir, from) {
|
|
@@ -249,14 +351,15 @@ export function checkServer(versionNum, encoding) {
|
|
|
249
351
|
}
|
|
250
352
|
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
251
353
|
export async function schemaRevision(sql, schema) {
|
|
252
|
-
const [r] = await sql
|
|
354
|
+
const [r] = await sql.unsafe(`select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = $1`, [schema]);
|
|
253
355
|
if (!r)
|
|
254
356
|
return null;
|
|
255
357
|
return Number(/ddl_revision=(\d+)/.exec(String(r.c ?? ''))?.[1] ?? 0);
|
|
256
358
|
}
|
|
257
359
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
360
|
+
* Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
|
|
361
|
+
* повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
|
|
362
|
+
* Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
|
|
260
363
|
*/
|
|
261
364
|
export async function up(opts) {
|
|
262
365
|
const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
|
|
@@ -298,11 +401,8 @@ export async function up(opts) {
|
|
|
298
401
|
if (!opts.quiet)
|
|
299
402
|
for (const w of warnings)
|
|
300
403
|
console.warn(`letopis.up: ${w}`);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
log(`схема ${schema} удалена (fresh)`);
|
|
304
|
-
}
|
|
305
|
-
const before = await schemaRevision(sql, schema);
|
|
404
|
+
// ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
|
|
405
|
+
const before = opts.fresh ? null : await schemaRevision(sql, schema);
|
|
306
406
|
if (before !== null && before > DDL_REVISION) {
|
|
307
407
|
throw new Error(`letopis.up: схема ${schema} ревизии ${before}, а библиотека знает только ${DDL_REVISION} — обновите пакет`);
|
|
308
408
|
}
|
|
@@ -310,7 +410,11 @@ export async function up(opts) {
|
|
|
310
410
|
throw new Error(`letopis.up: схема ${schema} ревизии ${before}, библиотека — ${DDL_REVISION}: обновите её up({ schema: '${schema}', upgrade: true })`);
|
|
311
411
|
}
|
|
312
412
|
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
|
|
313
|
-
const res = await install(sql, {
|
|
413
|
+
const { found, ...res } = await install(sql, {
|
|
414
|
+
...rest, schema, ...(opts.fresh ? { fresh: true } : {}), ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}),
|
|
415
|
+
});
|
|
416
|
+
if (opts.fresh)
|
|
417
|
+
log(`схема ${schema} удалена и поставлена заново (fresh)`);
|
|
314
418
|
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть
|
|
315
419
|
const fresh = await sql `select relname from pg_stat_user_tables
|
|
316
420
|
where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`;
|
|
@@ -318,9 +422,9 @@ export async function up(opts) {
|
|
|
318
422
|
for (const t of analyzed)
|
|
319
423
|
await sql.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
|
|
320
424
|
const revision = (await schemaRevision(sql, schema)) ?? 0;
|
|
321
|
-
log(`${
|
|
425
|
+
log(`${found === null ? 'схема поставлена' : found < DDL_REVISION ? `схема обновлена с ревизии ${found}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
|
|
322
426
|
const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
|
|
323
|
-
return { ...res, created:
|
|
427
|
+
return { ...res, created: found === null, upgraded: found !== null && found < DDL_REVISION, revision, analyzed, warnings,
|
|
324
428
|
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
|
|
325
429
|
}
|
|
326
430
|
catch (e) {
|
|
@@ -335,8 +439,11 @@ export async function up(opts) {
|
|
|
335
439
|
await sql.end({ timeout: 5 }).catch(() => { });
|
|
336
440
|
}
|
|
337
441
|
}
|
|
442
|
+
/** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
|
|
443
|
+
const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'PROJECTOR', 'LOCK'];
|
|
338
444
|
/**
|
|
339
|
-
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner).
|
|
445
|
+
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
|
|
446
|
+
* функций кредов (auth_kind_ok). Членство
|
|
340
447
|
* пишет sys_create от владельца — без проверки класса member, поэтому список ролей проверяется здесь:
|
|
341
448
|
* непустой, без повторов, каждая роль — имя, а не маска группы '{a,b}': не пустое, без пробелов по
|
|
342
449
|
* краям (маска их отрезает) и запятых (разделитель маски), без фигурных скобок и «!» в начале
|
|
@@ -345,6 +452,12 @@ export async function up(opts) {
|
|
|
345
452
|
function checkTenant(opts) {
|
|
346
453
|
if (!opts.name)
|
|
347
454
|
throw new Error('letopis.createTenant: имя арендатора обязательно');
|
|
455
|
+
// вид пароля — по правилу функций кредов (auth_kind_ok в 80-auth.sql): пароль пишет функция владельца мимо них, а с
|
|
456
|
+
// видом в другом регистре или служебным (SESSION, TOTP…) по нему не войти, и служебные строки он подменял бы
|
|
457
|
+
const kind = opts.user?.kind;
|
|
458
|
+
if (kind != null && (typeof kind !== 'string' || !/^[A-Z][A-Z0-9_]{1,31}$/.test(kind) || SERVICE_KINDS.includes(kind))) {
|
|
459
|
+
throw new LetopisError('invalid_data', `createTenant: user.kind ${JSON.stringify(kind)} — вид пароля: заглавная латинская буква, затем заглавные буквы, цифры или «_», всего от 2 до 32 знаков; служебные виды ${SERVICE_KINDS.join(', ')} заняты (по умолчанию — PASSWORD)`);
|
|
460
|
+
}
|
|
348
461
|
if (opts.user?.roles === undefined)
|
|
349
462
|
return ['owner'];
|
|
350
463
|
const roles = opts.user.roles;
|
|
@@ -363,14 +476,10 @@ function checkTenant(opts) {
|
|
|
363
476
|
return roles;
|
|
364
477
|
}
|
|
365
478
|
/**
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
|
|
371
|
-
* журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
|
|
372
|
-
* что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
|
|
373
|
-
* версию 1 — её занимает история; вернуть строку — restore() цепочкой.
|
|
479
|
+
* Заводит арендатора и, по желанию, его первого пользователя (аккаунт, членство, пароль, сессию) одной транзакцией от
|
|
480
|
+
* роли-владельца схемы — поэтому нужен администраторский пул. Повтор с тем же именем и логином ничего не дублирует и не
|
|
481
|
+
* меняет, только выдаёт пользователю новую сессию; одновременные вызовы заводят арендатора один раз. Если арендатор,
|
|
482
|
+
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
374
483
|
*/
|
|
375
484
|
export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
376
485
|
const roles = checkTenant(opts);
|
|
@@ -380,6 +489,9 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
|
380
489
|
const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
|
|
381
490
|
const hash = u ? await hashPassword(u.password) : null;
|
|
382
491
|
return sql.begin(async (tx) => {
|
|
492
|
+
// под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
|
|
493
|
+
// раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
|
|
494
|
+
await lockSchema(tx, schema);
|
|
383
495
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
384
496
|
/** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
|
|
385
497
|
const exists = async (id, cls, gone) => {
|
package/dist/validate.d.ts
CHANGED
|
@@ -17,8 +17,15 @@ export interface Validator {
|
|
|
17
17
|
/** Нарушения ([] — данные приняты). */
|
|
18
18
|
(data: unknown): Issue[];
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
|
|
22
|
+
* класса — `db.registry.resolve(имя).schema`.
|
|
23
|
+
*/
|
|
21
24
|
export declare function compileValidator(schema: JsonSchema): Validator;
|
|
22
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
|
|
27
|
+
* записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не
|
|
28
|
+
* меняются.
|
|
29
|
+
*/
|
|
23
30
|
export declare function applyDefaults(schema: JsonSchema, data: unknown): unknown;
|
|
24
31
|
export {};
|
package/dist/validate.js
CHANGED
|
@@ -136,7 +136,16 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
136
136
|
if (sch.multipleOf !== undefined)
|
|
137
137
|
out.push({ path, keyword: 'multipleOf', select: sel, test: num((v, b) => isMultiple(v, b), sch.multipleOf) });
|
|
138
138
|
if (sch.pattern !== undefined) {
|
|
139
|
-
const
|
|
139
|
+
const src = translatePattern(sch.pattern); // то, чего база не понимает, — своя ошибка invalid_class
|
|
140
|
+
let re;
|
|
141
|
+
try {
|
|
142
|
+
re = new RegExp(src);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// шаблон, неверный уже для JavaScript (незакрытая скобка и т. п.): translatePattern его пропускает, RegExp — нет.
|
|
146
|
+
// Текст V8 в сообщение не идёт: он зависит от версии движка
|
|
147
|
+
fail(path, `pattern ${JSON.stringify(sch.pattern)} — неверное регулярное выражение`);
|
|
148
|
+
}
|
|
140
149
|
out.push({ path, keyword: 'pattern', select: sel, test: (v) => typeof v !== 'string' || re.test(v) });
|
|
141
150
|
}
|
|
142
151
|
if (sch.format !== undefined) {
|
|
@@ -211,7 +220,10 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
211
220
|
: (v) => branches.filter((b) => b(v)).length === 1 });
|
|
212
221
|
}
|
|
213
222
|
}
|
|
214
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
|
|
225
|
+
* класса — `db.registry.resolve(имя).schema`.
|
|
226
|
+
*/
|
|
215
227
|
export function compileValidator(schema) {
|
|
216
228
|
const rules = [];
|
|
217
229
|
compileNode(schema, (root) => [root], [], false, rules);
|
|
@@ -233,7 +245,11 @@ export function compileValidator(schema) {
|
|
|
233
245
|
return issues;
|
|
234
246
|
};
|
|
235
247
|
}
|
|
236
|
-
/**
|
|
248
|
+
/**
|
|
249
|
+
* Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
|
|
250
|
+
* записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не
|
|
251
|
+
* меняются.
|
|
252
|
+
*/
|
|
237
253
|
export function applyDefaults(schema, data) {
|
|
238
254
|
if (!isObj(data) || !isObj(schema.properties))
|
|
239
255
|
return data;
|
package/dist/watch.d.ts
CHANGED
|
@@ -2,13 +2,17 @@ import type { Executor } from './tx.js';
|
|
|
2
2
|
import type { Row } from './types.js';
|
|
3
3
|
import type { Signal } from './signals.js';
|
|
4
4
|
export interface WatchOptions {
|
|
5
|
-
/** Откуда: курсор
|
|
5
|
+
/** Откуда: курсор (sub.cursor или курсор события), 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
|
|
6
6
|
from?: string;
|
|
7
7
|
/** Только эти классы (с потомками). */
|
|
8
8
|
classes?: string[];
|
|
9
9
|
/** Событий за запрос (по умолчанию 500). */
|
|
10
10
|
batch?: number;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Как часто подписка сама проверяет журнал, мс (по умолчанию 1000): так она находит события, даже если сигнал базы не
|
|
13
|
+
* пришёл. Без сигналов (`connect({ listen: false })` или установка с `up({ signals: 'none' })`) новые события она
|
|
14
|
+
* узнаёт только так.
|
|
15
|
+
*/
|
|
12
16
|
pollMs?: number;
|
|
13
17
|
}
|
|
14
18
|
export interface WatchEvent {
|
|
@@ -43,6 +47,8 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
43
47
|
private opts;
|
|
44
48
|
private pos;
|
|
45
49
|
private buf;
|
|
50
|
+
/** Курсор после последнего выданного события, пока в буфере есть невыданные (pos — уже после всей пачки). */
|
|
51
|
+
private given;
|
|
46
52
|
private closed;
|
|
47
53
|
private wake;
|
|
48
54
|
private dirty;
|
|
@@ -56,11 +62,17 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
56
62
|
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
57
63
|
private queue;
|
|
58
64
|
constructor(ctx: WatchCtx, opts: WatchOptions);
|
|
59
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
|
|
67
|
+
* в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
|
|
68
|
+
* курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
|
|
69
|
+
* cursor_expired.
|
|
70
|
+
*/
|
|
60
71
|
get cursor(): string | null;
|
|
61
72
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
73
|
+
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
|
|
74
|
+
* (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
|
|
75
|
+
* выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
|
|
64
76
|
*/
|
|
65
77
|
ready(): Promise<void>;
|
|
66
78
|
private head;
|
package/dist/watch.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* без LISTEN и на случай потери сигнала — запасной опрос.
|
|
14
14
|
*
|
|
15
15
|
* Курсор подписчика старше яруса all политики хранения его классов — cursor_expired: события могли
|
|
16
|
-
* проредиться, нужно перечитать текущее состояние и подписаться заново.
|
|
16
|
+
* проредиться, нужно перечитать текущее состояние и подписаться заново. Время курсора подписки — время последнего
|
|
17
|
+
* события, а догнавшей журнал подписки — время опроса (не позже самой старой ещё не отданной записи): подписка без
|
|
18
|
+
* событий не стареет. Курсор события хранит время события.
|
|
17
19
|
*/
|
|
18
20
|
import { LetopisError } from './errors.js';
|
|
19
21
|
import { toRow } from './sql.js';
|
|
@@ -54,6 +56,8 @@ export class Subscription {
|
|
|
54
56
|
opts;
|
|
55
57
|
pos = null;
|
|
56
58
|
buf = [];
|
|
59
|
+
/** Курсор после последнего выданного события, пока в буфере есть невыданные (pos — уже после всей пачки). */
|
|
60
|
+
given = null;
|
|
57
61
|
closed = false;
|
|
58
62
|
wake = null;
|
|
59
63
|
dirty = true;
|
|
@@ -95,13 +99,21 @@ export class Subscription {
|
|
|
95
99
|
}, this.pollMs);
|
|
96
100
|
this.timer.unref?.();
|
|
97
101
|
}
|
|
98
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
|
|
104
|
+
* в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
|
|
105
|
+
* курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
|
|
106
|
+
* cursor_expired.
|
|
107
|
+
*/
|
|
99
108
|
get cursor() {
|
|
109
|
+
if (this.buf.length)
|
|
110
|
+
return this.given;
|
|
100
111
|
return this.pos ? encode(this.pos) : null;
|
|
101
112
|
}
|
|
102
113
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
114
|
+
* Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
|
|
115
|
+
* (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
|
|
116
|
+
* выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
|
|
105
117
|
*/
|
|
106
118
|
async ready() {
|
|
107
119
|
if (!this.pos && this.start)
|
|
@@ -122,18 +134,28 @@ export class Subscription {
|
|
|
122
134
|
// уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
|
|
123
135
|
// версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
|
|
124
136
|
// оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
|
|
125
|
-
// (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного
|
|
137
|
+
// (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом.
|
|
138
|
+
// Третья строка (src 2) — время курсора, когда пачка неполная (подписка догнала журнал): «сейчас», но не позже самой
|
|
139
|
+
// старой записи подписки, которая уже зафиксирована, а горизонт ещё не прошла (её держит более старая транзакция;
|
|
140
|
+
// снимок «now» — без записей, которые подписка не отдаёт). Тот же снимок, что у watch_read
|
|
126
141
|
const S = this.ctx.S;
|
|
127
142
|
const both = await this.ctx.exec.run((q) => q.unsafe(`with r as (select l.* from ${S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l),
|
|
128
143
|
u as (select r.tx, r.seq from r where (select count(*) from r) = $5 order by r.tx desc, r.seq desc limit 1)
|
|
129
144
|
select 0 as src, ${cols} from r l
|
|
130
145
|
union all
|
|
131
146
|
select 1 as src, ${cols} from ${S}.watch_leaving($1::text::xid8, $2::bigint, $4::text[], $5::int, $6::text::pg_snapshot,
|
|
132
|
-
(select tx from u), (select seq from u)) l
|
|
147
|
+
(select tx from u), (select seq from u)) l
|
|
148
|
+
union all
|
|
149
|
+
select 2 as src, null, null, jsonb_build_object('at', least(statement_timestamp(), min(l.at))) from ${S}.log l
|
|
150
|
+
where (select count(*) from r) < $5 and (l.tx, l.seq) > ($1::text::xid8, $2::bigint)
|
|
151
|
+
and l.tx >= pg_snapshot_xmin(pg_current_snapshot()) and ($4::text[] is null or l.class = any ($4::text[]))
|
|
152
|
+
and ($6::text::pg_snapshot is null or not pg_visible_in_snapshot(l.tx, $6::text::pg_snapshot))
|
|
153
|
+
having (select count(*) from r) < $5`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
|
|
133
154
|
const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
|
|
134
155
|
const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
|
|
135
156
|
const read = both.filter((r) => r.src === 0).sort(order);
|
|
136
157
|
const away = both.filter((r) => r.src === 1).sort(order);
|
|
158
|
+
const caught = both.find((r) => r.src === 2);
|
|
137
159
|
let rows = read;
|
|
138
160
|
{
|
|
139
161
|
const upto = read.length === this.batch ? read[read.length - 1] : null;
|
|
@@ -145,12 +167,19 @@ export class Subscription {
|
|
|
145
167
|
rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
|
|
146
168
|
}
|
|
147
169
|
}
|
|
170
|
+
// пока события пачки не выданы, курсор подписки — позиция до пачки
|
|
171
|
+
if (rows.length)
|
|
172
|
+
this.given = encode(p);
|
|
148
173
|
for (const r of rows) {
|
|
149
174
|
const row = toRow(r.row);
|
|
150
175
|
const snap = keepSnap(p.snap, r.tx);
|
|
151
176
|
this.pos = { tx: r.tx, seq: r.seq, at: row.at, ...(snap ? { snap } : {}) };
|
|
152
177
|
this.buf.push({ cursor: encode(this.pos), row, id: row.id, class: row.class, op: row.op, rev: row.rev, at: row.at });
|
|
153
178
|
}
|
|
179
|
+
// догнала журнал — время курсора подписки (sub.cursor) обновляется и на пустых опросах: прореживать после него нечего,
|
|
180
|
+
// и подписка, к которой долго не было событий, не получает ложный cursor_expired. Курсоры событий хранят время события
|
|
181
|
+
if (caught && away.length < this.batch)
|
|
182
|
+
this.pos = { ...(this.pos ?? p), at: String(caught.row.at) };
|
|
154
183
|
return rows.length;
|
|
155
184
|
}
|
|
156
185
|
/**
|
|
@@ -167,8 +196,11 @@ export class Subscription {
|
|
|
167
196
|
for (;;) {
|
|
168
197
|
if (this.failure)
|
|
169
198
|
throw this.failure;
|
|
170
|
-
if (this.buf.length)
|
|
171
|
-
|
|
199
|
+
if (this.buf.length) {
|
|
200
|
+
const ev = this.buf.shift();
|
|
201
|
+
this.given = ev.cursor;
|
|
202
|
+
return { value: ev, done: false };
|
|
203
|
+
}
|
|
172
204
|
if (this.closed)
|
|
173
205
|
return { value: undefined, done: true };
|
|
174
206
|
if (this.dirty) {
|
package/dist/write.d.ts
CHANGED
|
@@ -10,8 +10,9 @@ export interface IncMarker {
|
|
|
10
10
|
readonly start?: number;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Приращение в update
|
|
14
|
-
*
|
|
13
|
+
* Приращение числового поля в `update()`: `db.Wallet(w).update({ balance: inc(-300) })`. Сложение делает база над
|
|
14
|
+
* текущим значением поля в момент записи, поэтому одновременные приращения не теряются. Если поля ещё нет, `{ start }`
|
|
15
|
+
* задаёт, с чего начать (поле получит `start + by`), а без него — ошибка `invalid_data`.
|
|
15
16
|
*/
|
|
16
17
|
export declare function inc(by: number, opts?: {
|
|
17
18
|
start?: number;
|