letopis 1.4.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 +10 -5
- package/AGENT-CHEATSHEET.md +14 -9
- package/CHANGELOG.md +159 -0
- package/README.en.md +119 -52
- package/README.md +107 -47
- 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 +12 -5
- 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 +30 -11
- package/dist/project.js +30 -4
- 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/tx.js +10 -1
- package/dist/types.d.ts +14 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +48 -25
- package/dist/up.js +89 -41
- 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/package.json +1 -1
- package/sql/10-core.sql +6 -1
- package/sql/60-write.sql +38 -18
- package/sql/80-auth.sql +39 -2
- package/sql/88-derived.sql +1 -0
- package/sql/90-time.sql +15 -5
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +1 -1
- package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/up.js
CHANGED
|
@@ -24,6 +24,13 @@ import { v5Id } from './uuid.js';
|
|
|
24
24
|
import { SIGNAL_MODES } from './signals.js';
|
|
25
25
|
export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
26
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
|
+
}
|
|
27
34
|
const SQL_DIR = fileURLToPath(new URL('../sql/', import.meta.url));
|
|
28
35
|
const MIGRATE_DIR = join(SQL_DIR, 'migrate');
|
|
29
36
|
const ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
@@ -92,7 +99,7 @@ export async function stampDrift(schema, comment) {
|
|
|
92
99
|
return `letopis: схему ${schema} обновила letopis ${was}, библиотека старее (${code.version}) — обновите пакет: up() этой версией вернул бы прежние функции`;
|
|
93
100
|
}
|
|
94
101
|
const by = !was ? 'версия до 1.4.0' : was === code.version ? `другая сборка letopis ${was}` : `letopis ${was}`;
|
|
95
|
-
return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}:
|
|
102
|
+
return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: обновите схему — вызовите up({ schema: '${schema}' })`;
|
|
96
103
|
}
|
|
97
104
|
export function substitute(text, vars) {
|
|
98
105
|
return text.replace(/@([A-Z_]+)@/g, (m, k) => {
|
|
@@ -115,23 +122,40 @@ export async function install(sql, opts) {
|
|
|
115
122
|
const [{ v }] = await sql `select current_setting('server_version_num') as v`;
|
|
116
123
|
if (Number(v) < 180000)
|
|
117
124
|
throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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;
|
|
130
158
|
});
|
|
131
|
-
await sql.unsafe(`create extension if not exists pgcrypto`);
|
|
132
|
-
const [{ nsp }] = await sql `
|
|
133
|
-
select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`;
|
|
134
|
-
await sql.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
|
|
135
159
|
const vars = {
|
|
136
160
|
SCHEMA: quoteIdent(opts.schema),
|
|
137
161
|
SCHEMA_NAME: opts.schema,
|
|
@@ -152,13 +176,27 @@ export async function install(sql, opts) {
|
|
|
152
176
|
const st = await engineStamp();
|
|
153
177
|
vars.ENGINE_VERSION = st.version;
|
|
154
178
|
vars.ENGINE_SQL = st.sql;
|
|
155
|
-
// Схема создаётся до set role: у владельца может не быть права CREATE в базе.
|
|
156
|
-
await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
|
|
157
179
|
const files = await engineFiles(opts.upTo);
|
|
158
|
-
|
|
180
|
+
let migrations = [];
|
|
181
|
+
let found = null;
|
|
159
182
|
let serviceKey = null;
|
|
160
183
|
let signals = 'class';
|
|
161
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)}`);
|
|
162
200
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
163
201
|
// режим сигналов (план 1.1, §2.6): опция, иначе сохранённый в схеме (meta строки ключа), иначе class
|
|
164
202
|
let saved = null;
|
|
@@ -236,7 +274,7 @@ export async function install(sql, opts) {
|
|
|
236
274
|
}
|
|
237
275
|
}
|
|
238
276
|
});
|
|
239
|
-
return { schema: opts.schema, owner, app, serviceKey, migrations, signals };
|
|
277
|
+
return { schema: opts.schema, owner, app, serviceKey, migrations, signals, found };
|
|
240
278
|
}
|
|
241
279
|
/** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
|
|
242
280
|
export async function migrationFiles(dir, from) {
|
|
@@ -313,14 +351,15 @@ export function checkServer(versionNum, encoding) {
|
|
|
313
351
|
}
|
|
314
352
|
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
315
353
|
export async function schemaRevision(sql, schema) {
|
|
316
|
-
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]);
|
|
317
355
|
if (!r)
|
|
318
356
|
return null;
|
|
319
357
|
return Number(/ddl_revision=(\d+)/.exec(String(r.c ?? ''))?.[1] ?? 0);
|
|
320
358
|
}
|
|
321
359
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
360
|
+
* Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
|
|
361
|
+
* повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
|
|
362
|
+
* Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
|
|
324
363
|
*/
|
|
325
364
|
export async function up(opts) {
|
|
326
365
|
const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
|
|
@@ -362,11 +401,8 @@ export async function up(opts) {
|
|
|
362
401
|
if (!opts.quiet)
|
|
363
402
|
for (const w of warnings)
|
|
364
403
|
console.warn(`letopis.up: ${w}`);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
log(`схема ${schema} удалена (fresh)`);
|
|
368
|
-
}
|
|
369
|
-
const before = await schemaRevision(sql, schema);
|
|
404
|
+
// ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
|
|
405
|
+
const before = opts.fresh ? null : await schemaRevision(sql, schema);
|
|
370
406
|
if (before !== null && before > DDL_REVISION) {
|
|
371
407
|
throw new Error(`letopis.up: схема ${schema} ревизии ${before}, а библиотека знает только ${DDL_REVISION} — обновите пакет`);
|
|
372
408
|
}
|
|
@@ -374,7 +410,11 @@ export async function up(opts) {
|
|
|
374
410
|
throw new Error(`letopis.up: схема ${schema} ревизии ${before}, библиотека — ${DDL_REVISION}: обновите её up({ schema: '${schema}', upgrade: true })`);
|
|
375
411
|
}
|
|
376
412
|
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
|
|
377
|
-
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)`);
|
|
378
418
|
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть
|
|
379
419
|
const fresh = await sql `select relname from pg_stat_user_tables
|
|
380
420
|
where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`;
|
|
@@ -382,9 +422,9 @@ export async function up(opts) {
|
|
|
382
422
|
for (const t of analyzed)
|
|
383
423
|
await sql.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
|
|
384
424
|
const revision = (await schemaRevision(sql, schema)) ?? 0;
|
|
385
|
-
log(`${
|
|
425
|
+
log(`${found === null ? 'схема поставлена' : found < DDL_REVISION ? `схема обновлена с ревизии ${found}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
|
|
386
426
|
const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
|
|
387
|
-
return { ...res, created:
|
|
427
|
+
return { ...res, created: found === null, upgraded: found !== null && found < DDL_REVISION, revision, analyzed, warnings,
|
|
388
428
|
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
|
|
389
429
|
}
|
|
390
430
|
catch (e) {
|
|
@@ -399,8 +439,11 @@ export async function up(opts) {
|
|
|
399
439
|
await sql.end({ timeout: 5 }).catch(() => { });
|
|
400
440
|
}
|
|
401
441
|
}
|
|
442
|
+
/** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
|
|
443
|
+
const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'PROJECTOR', 'LOCK'];
|
|
402
444
|
/**
|
|
403
|
-
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner).
|
|
445
|
+
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
|
|
446
|
+
* функций кредов (auth_kind_ok). Членство
|
|
404
447
|
* пишет sys_create от владельца — без проверки класса member, поэтому список ролей проверяется здесь:
|
|
405
448
|
* непустой, без повторов, каждая роль — имя, а не маска группы '{a,b}': не пустое, без пробелов по
|
|
406
449
|
* краям (маска их отрезает) и запятых (разделитель маски), без фигурных скобок и «!» в начале
|
|
@@ -409,6 +452,12 @@ export async function up(opts) {
|
|
|
409
452
|
function checkTenant(opts) {
|
|
410
453
|
if (!opts.name)
|
|
411
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
|
+
}
|
|
412
461
|
if (opts.user?.roles === undefined)
|
|
413
462
|
return ['owner'];
|
|
414
463
|
const roles = opts.user.roles;
|
|
@@ -427,14 +476,10 @@ function checkTenant(opts) {
|
|
|
427
476
|
return roles;
|
|
428
477
|
}
|
|
429
478
|
/**
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
|
|
435
|
-
* журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
|
|
436
|
-
* что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
|
|
437
|
-
* версию 1 — её занимает история; вернуть строку — restore() цепочкой.
|
|
479
|
+
* Заводит арендатора и, по желанию, его первого пользователя (аккаунт, членство, пароль, сессию) одной транзакцией от
|
|
480
|
+
* роли-владельца схемы — поэтому нужен администраторский пул. Повтор с тем же именем и логином ничего не дублирует и не
|
|
481
|
+
* меняет, только выдаёт пользователю новую сессию; одновременные вызовы заводят арендатора один раз. Если арендатор,
|
|
482
|
+
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
438
483
|
*/
|
|
439
484
|
export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
440
485
|
const roles = checkTenant(opts);
|
|
@@ -444,6 +489,9 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
|
444
489
|
const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
|
|
445
490
|
const hash = u ? await hashPassword(u.password) : null;
|
|
446
491
|
return sql.begin(async (tx) => {
|
|
492
|
+
// под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
|
|
493
|
+
// раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
|
|
494
|
+
await lockSchema(tx, schema);
|
|
447
495
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
448
496
|
/** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
|
|
449
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;
|
package/dist/write.js
CHANGED
|
@@ -29,8 +29,9 @@ import { applyDefaults } from './validate.js';
|
|
|
29
29
|
/** Метка приращения; Symbol.for — маркер узнаёт и вторая копия пакета в процессе. */
|
|
30
30
|
export const INC = Symbol.for('letopis.inc');
|
|
31
31
|
/**
|
|
32
|
-
* Приращение в update
|
|
33
|
-
*
|
|
32
|
+
* Приращение числового поля в `update()`: `db.Wallet(w).update({ balance: inc(-300) })`. Сложение делает база над
|
|
33
|
+
* текущим значением поля в момент записи, поэтому одновременные приращения не теряются. Если поля ещё нет, `{ start }`
|
|
34
|
+
* задаёт, с чего начать (поле получит `start + by`), а без него — ошибка `invalid_data`.
|
|
34
35
|
*/
|
|
35
36
|
export function inc(by, opts = {}) {
|
|
36
37
|
return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
|
|
@@ -377,7 +378,11 @@ function applySlotsLocal(links, ops) {
|
|
|
377
378
|
}
|
|
378
379
|
return out;
|
|
379
380
|
}
|
|
380
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Теги, которые пишет глагол: строка или список из `.tags(…)` (перед update они же и отбирают цели). Оператор
|
|
383
|
+
* (has, hasAny, hasAll) только отбирает — теги целей не меняются; create и upsert его не принимают (precheck).
|
|
384
|
+
*/
|
|
385
|
+
const tagsValue = (s) => typeof s.tagsFilter === 'string' ? [s.tagsFilter] : Array.isArray(s.tagsFilter) ? s.tagsFilter : null;
|
|
381
386
|
/** «Уже существует»: id вычисляет библиотека (idOf), а не разбирает текст ошибки сервера (§2.5). */
|
|
382
387
|
function existsError(w, cls, data, links, given, cause) {
|
|
383
388
|
let id = given;
|
|
@@ -440,12 +445,23 @@ async function explainMissing(w, missing, rev) {
|
|
|
440
445
|
}
|
|
441
446
|
return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
|
|
442
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
|
|
450
|
+
* отдаёт их в порядке обхода таблицы, и `first()` после глагола брал бы случайную из исправленных строк.
|
|
451
|
+
*/
|
|
452
|
+
function inTargetOrder(ids, rows) {
|
|
453
|
+
const pos = new Map(ids.map((id, i) => [id, i]));
|
|
454
|
+
return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
|
|
455
|
+
}
|
|
443
456
|
/** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
|
|
444
457
|
async function targetIds(w, steps, opStep, dataId) {
|
|
445
458
|
const path = steps.map(bare);
|
|
446
459
|
if (opStep.filter === undefined && dataId !== undefined)
|
|
447
460
|
path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
|
|
448
|
-
|
|
461
|
+
const ids = await readIds(w, path, opStep.opMods ?? {});
|
|
462
|
+
// без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
|
|
463
|
+
// записей не должен зависеть от плана чтения
|
|
464
|
+
return opStep.opMods?.order ? ids : [...ids].sort();
|
|
449
465
|
}
|
|
450
466
|
async function updateOp(w, steps, opStep) {
|
|
451
467
|
const op = opStep.op;
|
|
@@ -456,7 +472,7 @@ async function updateOp(w, steps, opStep) {
|
|
|
456
472
|
const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
|
|
457
473
|
if (op.rev !== undefined) {
|
|
458
474
|
if (ids.length > 1)
|
|
459
|
-
fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей —
|
|
475
|
+
fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
|
|
460
476
|
if (!ids.length)
|
|
461
477
|
fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
|
|
462
478
|
}
|
|
@@ -481,7 +497,7 @@ async function updateOp(w, steps, opStep) {
|
|
|
481
497
|
const got = new Set(res.map((r) => r.row.id));
|
|
482
498
|
await explainMissing(w, ids.filter((x) => !got.has(x)), op.rev);
|
|
483
499
|
}
|
|
484
|
-
return res.map((r) => toRow(r.row));
|
|
500
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
485
501
|
}
|
|
486
502
|
async function deleteOp(w, steps, opStep) {
|
|
487
503
|
const op = opStep.op;
|
|
@@ -510,9 +526,11 @@ async function deleteOp(w, steps, opStep) {
|
|
|
510
526
|
}
|
|
511
527
|
const tombs = await query(w, `select ${rowJson('l')} as row from ${S}.log l where l.tx = pg_current_xact_id() and l.seq > $1::bigint and l.op = 'delete' order by l.seq`, [mark]);
|
|
512
528
|
const out = tombs.map((r) => toRow(r.row));
|
|
513
|
-
// строки классов без журнала (history: false) надгробий не оставляют — берём их из returning
|
|
529
|
+
// строки классов без журнала (history: false) надгробий не оставляют — берём их из returning, по id (порядок
|
|
530
|
+
// returning зависит от плана, а итог delete() — нет: надгробия идут по seq, триггер пишет их в порядке id)
|
|
514
531
|
const have = new Set(out.map((r) => r.id));
|
|
515
|
-
|
|
532
|
+
const byId = (a, b) => String(a.row.id) < String(b.row.id) ? -1 : String(a.row.id) > String(b.row.id) ? 1 : 0;
|
|
533
|
+
for (const g of [...gone].sort(byId)) {
|
|
516
534
|
if (have.has(g.row.id))
|
|
517
535
|
continue;
|
|
518
536
|
const row = toRow(g.row);
|
|
@@ -534,7 +552,7 @@ async function anonymizeOp(w, steps, opStep) {
|
|
|
534
552
|
const got = new Set(res.map((r) => r.row.id));
|
|
535
553
|
await explainMissing(w, ids.filter((x) => !got.has(x)));
|
|
536
554
|
}
|
|
537
|
-
return res.map((r) => toRow(r.row));
|
|
555
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
538
556
|
}
|
|
539
557
|
async function reclassOp(w, steps, opStep) {
|
|
540
558
|
const op = opStep.op;
|
|
@@ -554,7 +572,8 @@ async function deletedTargets(w, steps, opStep, verb) {
|
|
|
554
572
|
const dead = rows.filter((r) => r.$deleted);
|
|
555
573
|
if (!dead.length && rows.length)
|
|
556
574
|
fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
|
|
557
|
-
|
|
575
|
+
// без явного порядка — по id: restore и purge пишут журнал по очереди целей
|
|
576
|
+
return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
558
577
|
}
|
|
559
578
|
/** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
|
|
560
579
|
async function restoreOp(w, steps, opStep) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"postgres",
|
package/sql/10-core.sql
CHANGED
|
@@ -107,9 +107,12 @@ create index if not exists log_ends on @SCHEMA@.log using gin (@SCHEMA@.link_ids
|
|
|
107
107
|
-- журнал часто прореживается (§2.11): чаще чистим
|
|
108
108
|
alter table @SCHEMA@.log set (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.02);
|
|
109
109
|
|
|
110
|
+
-- account — строка entity (аккаунт; у служебных строк KEY, LOCK, PROJECTOR — System): удалённый аккаунт уносит свои строки
|
|
111
|
+
-- каскадом внешнего ключа — сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (ревизия 7; в схему
|
|
112
|
+
-- прежней ревизии ключ добавляет миграция 0007). Строка Account у System не удаляется (trg_entity_bd_account, 60-write.sql)
|
|
110
113
|
create table if not exists @SCHEMA@.secret (
|
|
111
114
|
id uuid primary key default gen_random_uuid(),
|
|
112
|
-
account uuid not null,
|
|
115
|
+
account uuid not null constraint secret_account_fkey references @SCHEMA@.entity (id) on delete cascade,
|
|
113
116
|
kind text not null, -- PASSWORD, APIKEY, KEYSECRET, OTP, TOTP, IDENTITY, SESSION
|
|
114
117
|
ident text, -- email, телефон, префикс ключа, внешний id
|
|
115
118
|
material bytea, -- хэш пароля, ключа, кода или токена; секрет TOTP
|
|
@@ -121,6 +124,8 @@ create table if not exists @SCHEMA@.secret (
|
|
|
121
124
|
create unique index if not exists secret_kind_ident on @SCHEMA@.secret (kind, casefold(ident) collate pg_unicode_fast)
|
|
122
125
|
where deleted_at is null and ident is not null;
|
|
123
126
|
create index if not exists secret_material on @SCHEMA@.secret (material) where kind = 'SESSION' and deleted_at is null;
|
|
127
|
+
-- строки аккаунта: каскад внешнего ключа при удалении аккаунта и гашение его сессий (auth_kill) — без перебора таблицы
|
|
128
|
+
create index if not exists secret_account on @SCHEMA@.secret (account);
|
|
124
129
|
|
|
125
130
|
alter table @SCHEMA@.entity owner to @OWNER@;
|
|
126
131
|
alter table @SCHEMA@.log owner to @OWNER@;
|