letopis 1.4.0 → 1.4.2
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 +15 -7
- package/AGENT-CHEATSHEET.md +19 -11
- package/CHANGELOG.md +299 -1
- package/README.en.md +180 -62
- package/README.md +165 -55
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +53 -18
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +92 -15
- package/dist/chain.js +56 -7
- package/dist/cli.js +16 -2
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +73 -45
- package/dist/index.js +69 -14
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.d.ts +10 -3
- package/dist/load.js +39 -11
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +8 -6
- package/dist/model.d.ts +4 -1
- package/dist/model.js +7 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +39 -11
- package/dist/project.js +75 -15
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +8 -4
- package/dist/sql.js +110 -25
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +22 -4
- package/dist/types.d.ts +21 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +48 -25
- package/dist/up.js +94 -41
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +29 -5
- package/dist/watch.js +62 -13
- package/dist/write.d.ts +3 -2
- package/dist/write.js +52 -19
- package/package.json +8 -4
- package/sql/10-core.sql +6 -1
- package/sql/60-write.sql +47 -18
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +41 -3
- package/sql/88-derived.sql +3 -1
- package/sql/90-time.sql +118 -30
- 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/tx.d.ts
CHANGED
|
@@ -31,6 +31,21 @@ export declare function isTransient(e: unknown): boolean;
|
|
|
31
31
|
export declare function isLogConflict(e: unknown): boolean;
|
|
32
32
|
/** Обрыв связи: сокет закрыт или серверный процесс завершён (FATAL) — исход отправленного неизвестен. */
|
|
33
33
|
export declare function isConnectionError(e: unknown): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Откатить транзакцию на зарезервированном соединении после ошибки: после обрыва в него не пишут (postgres.js 3.4
|
|
36
|
+
* уронил бы процесс необработанным TypeError), иначе — rollback, ошибка которого не важна (план 2.0, 7.4 №7).
|
|
37
|
+
*/
|
|
38
|
+
export declare function rollbackAfter(conn: {
|
|
39
|
+
unsafe(text: string): Promise<unknown>;
|
|
40
|
+
}, err: unknown): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Вернуть зарезервированное соединение в пул. После обрыва сокета пул postgres.js уже перевёл
|
|
43
|
+
* соединение в закрытые (onclose); release() вернул бы его в открытые, и следующая операция
|
|
44
|
+
* писала бы в сокет null — необработанный TypeError роняет процесс.
|
|
45
|
+
*/
|
|
46
|
+
export declare function release(conn: {
|
|
47
|
+
release(): void;
|
|
48
|
+
}, err: unknown): void;
|
|
34
49
|
export declare const RETRIES = 3;
|
|
35
50
|
export declare const retryDelay: (attempt: number) => Promise<void>;
|
|
36
51
|
/** Нотификатор процесса: копит нагрузки сигналов и отправляет их пачкой раз в интервал. */
|
package/dist/tx.js
CHANGED
|
@@ -17,12 +17,21 @@ export function isConnectionError(e) {
|
|
|
17
17
|
const code = String(err?.code ?? '');
|
|
18
18
|
return code.startsWith('CONNECTION_') || ['ECONNRESET', 'EPIPE', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNABORTED'].includes(code);
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Откатить транзакцию на зарезервированном соединении после ошибки: после обрыва в него не пишут (postgres.js 3.4
|
|
22
|
+
* уронил бы процесс необработанным TypeError), иначе — rollback, ошибка которого не важна (план 2.0, 7.4 №7).
|
|
23
|
+
*/
|
|
24
|
+
export async function rollbackAfter(conn, err) {
|
|
25
|
+
if (isConnectionError(err))
|
|
26
|
+
return;
|
|
27
|
+
await conn.unsafe('rollback').catch(() => { });
|
|
28
|
+
}
|
|
20
29
|
/**
|
|
21
30
|
* Вернуть зарезервированное соединение в пул. После обрыва сокета пул postgres.js уже перевёл
|
|
22
31
|
* соединение в закрытые (onclose); release() вернул бы его в открытые, и следующая операция
|
|
23
32
|
* писала бы в сокет null — необработанный TypeError роняет процесс.
|
|
24
33
|
*/
|
|
25
|
-
function release(conn, err) {
|
|
34
|
+
export function release(conn, err) {
|
|
26
35
|
const e = err;
|
|
27
36
|
if (e && !e.severity && isConnectionError(e))
|
|
28
37
|
return;
|
|
@@ -233,8 +242,8 @@ export class ExplicitTx {
|
|
|
233
242
|
ctx.passes.set(ctx, (await passSql(conn, ctx))[0]?.p);
|
|
234
243
|
}
|
|
235
244
|
catch (e) {
|
|
236
|
-
await conn
|
|
237
|
-
|
|
245
|
+
await rollbackAfter(conn, e);
|
|
246
|
+
release(conn, e);
|
|
238
247
|
throw fromDbError(e);
|
|
239
248
|
}
|
|
240
249
|
return new ExplicitTx(ctx, conn);
|
|
@@ -282,7 +291,16 @@ export class ExplicitTx {
|
|
|
282
291
|
let changed = null;
|
|
283
292
|
try {
|
|
284
293
|
// чтение letopis.changed и COMMIT — одним конвейером (как в runTx); обрыв — исход неизвестен
|
|
285
|
-
const
|
|
294
|
+
const read = this.conn.unsafe(`select current_setting('letopis.changed', true) as changed`);
|
|
295
|
+
const commit = this.conn.unsafe('commit');
|
|
296
|
+
const [rows, res] = (await Promise.all([read, commit]).catch(async (e) => {
|
|
297
|
+
// транзакцию уже прервала ошибка, которую код внутри db.begin() поймал и проглотил: чтение получает 25P02,
|
|
298
|
+
// а COMMIT того же конвейера отвечает ROLLBACK — это tx_rolled_back, а не сырая ошибка PostgreSQL
|
|
299
|
+
const tail = e.code === '25P02' ? await commit.catch(() => null) : null;
|
|
300
|
+
if (tail?.command === 'ROLLBACK')
|
|
301
|
+
return [[], tail];
|
|
302
|
+
throw e;
|
|
303
|
+
}));
|
|
286
304
|
changed = rows[0]?.changed ?? null;
|
|
287
305
|
if (res.command === 'ROLLBACK') {
|
|
288
306
|
this.failure = new LetopisError('tx_rolled_back', 'commit: PostgreSQL откатил транзакцию (в ней была ошибка)');
|
package/dist/types.d.ts
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Общие типы letopis 1.0. Файл наполняется по этапам плана (reports/PLAN-LETOPIS-1.0.md).
|
|
3
3
|
*/
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Ревизия схемы, которую знает эта версия пакета, — номер версии SQL-части letopis (таблиц и функций в схеме установки).
|
|
6
|
+
* После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии `up()` обновляет только с `upgrade: true`,
|
|
7
|
+
* а `connect()` о ней предупреждает.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DDL_REVISION = 7;
|
|
6
10
|
/**
|
|
7
11
|
* Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
|
|
8
12
|
* сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
|
|
9
13
|
*/
|
|
10
14
|
export declare const ACL_CLASS_NAMES: readonly string[];
|
|
11
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Имена методов цепочки: модификаторы, терминалы, глаголы записи и слоты концов, а также `then` и снятые методы прежних
|
|
17
|
+
* версий. После шага цепочки эти имена библиотека не ищет среди классов; все они входят в `RESERVED_CLASS_NAMES`.
|
|
18
|
+
*/
|
|
12
19
|
export declare const CHAIN_METHODS: readonly ["then", "entity", "paths", "rows", "first", "ids", "count", "limit", "offset", "sort", "asOf", "withDeleted", "deep", "exact", "sum", "avg", "min", "max", "countBy", "after", "versions", "create", "update", "upsert", "set", "unset", "add", "remove", "delete", "anonymize", "reclass", "restore", "purge", "alias", "tags", "owner", "forUpdate", "cache", "rekey", "run", "execute", "account", "diff", "blame"];
|
|
13
20
|
/** Свойства корня db (и транзакции db.begin()). */
|
|
14
21
|
export declare const DB_METHODS: readonly ["as", "begin", "commit", "rollback", "lock", "batch", "watch", "close", "registry", "sql", "load", "verify", "maintain", "reset", "describe", "auth", "acl", "idOf", "refresh", "transaction", "flushSignals", "schema", "token", "tenant", "reloadSchema", "accounts", "credentials", "resources", "rules", "compact", "trimHistory", "rekeyClass", "cache", "projector", "rebuildAggregates"];
|
|
15
22
|
/** Свойства батча db.batch(name). */
|
|
16
23
|
export declare const BATCH_METHODS: readonly ["discard", "size"];
|
|
17
24
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
25
|
+
* Имена методов цепочки, корня `db` и батча. Там, где имя занято методом, библиотека не ищет его среди классов, и шаг с
|
|
26
|
+
* таким именем до класса не дойдёт; класс-связь (`link`) с таким именем база не создаст (`invalid_class`). Не давайте
|
|
27
|
+
* классам эти имена.
|
|
21
28
|
*/
|
|
22
29
|
export declare const RESERVED_CLASS_NAMES: readonly string[];
|
|
23
30
|
/** Строка ответа: текущая версия (или версия журнала для asOf, versions, withDeleted). */
|
|
@@ -78,10 +85,17 @@ export type Filter = string | string[] | Op | {
|
|
|
78
85
|
} | {
|
|
79
86
|
[field: string]: FieldFilter | undefined;
|
|
80
87
|
};
|
|
81
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Курсор постраничного вывода для `after()`: значение поля сортировки и id последней строки страницы. Равные по полю
|
|
90
|
+
* строки разводят `rev` (версии в `versions()`) и `path` (id всех узлов пути в `paths()`); `cursorOf` заполняет их сам.
|
|
91
|
+
*/
|
|
82
92
|
export interface Cursor {
|
|
83
93
|
v: string | number | boolean | null;
|
|
84
94
|
id: string;
|
|
95
|
+
/** Номер версии строки: в `versions()` отделяет версии одного объекта с равным полем сортировки. */
|
|
96
|
+
rev?: number;
|
|
97
|
+
/** Узлы пути из `paths()`: ключ узла → id; отделяет пути с общим последним узлом. */
|
|
98
|
+
path?: Record<string, string>;
|
|
85
99
|
}
|
|
86
100
|
/** Модификаторы выборки цепочки. */
|
|
87
101
|
export interface ChainMods {
|
package/dist/types.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Общие типы letopis 1.0. Файл наполняется по этапам плана (reports/PLAN-LETOPIS-1.0.md).
|
|
3
3
|
*/
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Ревизия схемы, которую знает эта версия пакета, — номер версии SQL-части letopis (таблиц и функций в схеме установки).
|
|
6
|
+
* После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии `up()` обновляет только с `upgrade: true`,
|
|
7
|
+
* а `connect()` о ней предупреждает.
|
|
8
|
+
*/
|
|
9
|
+
export const DDL_REVISION = 7;
|
|
6
10
|
/**
|
|
7
11
|
* Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
|
|
8
12
|
* сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
|
|
@@ -11,7 +15,10 @@ export const ACL_CLASS_NAMES = ['rule', 'Resource', 'member', 'Account', 'Class'
|
|
|
11
15
|
// ---------------------------------------------------------------------------
|
|
12
16
|
// Методы цепочки — единственный источник зарезервированных имён (план, §2.14, этап 3, п. 10)
|
|
13
17
|
// ---------------------------------------------------------------------------
|
|
14
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Имена методов цепочки: модификаторы, терминалы, глаголы записи и слоты концов, а также `then` и снятые методы прежних
|
|
20
|
+
* версий. После шага цепочки эти имена библиотека не ищет среди классов; все они входят в `RESERVED_CLASS_NAMES`.
|
|
21
|
+
*/
|
|
15
22
|
export const CHAIN_METHODS = [
|
|
16
23
|
'then', 'entity', 'paths', 'rows', 'first', 'ids', 'count', 'limit', 'offset', 'sort', 'asOf', 'withDeleted',
|
|
17
24
|
'deep', 'exact', 'sum', 'avg', 'min', 'max', 'countBy', 'after', 'versions', 'create', 'update', 'upsert', 'set',
|
|
@@ -28,9 +35,9 @@ export const DB_METHODS = [
|
|
|
28
35
|
/** Свойства батча db.batch(name). */
|
|
29
36
|
export const BATCH_METHODS = ['discard', 'size'];
|
|
30
37
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
38
|
+
* Имена методов цепочки, корня `db` и батча. Там, где имя занято методом, библиотека не ищет его среди классов, и шаг с
|
|
39
|
+
* таким именем до класса не дойдёт; класс-связь (`link`) с таким именем база не создаст (`invalid_class`). Не давайте
|
|
40
|
+
* классам эти имена.
|
|
34
41
|
*/
|
|
35
42
|
export const RESERVED_CLASS_NAMES = [...new Set([...CHAIN_METHODS, ...DB_METHODS, ...BATCH_METHODS])];
|
|
36
43
|
/** Метка операторов фильтра (символ не пересекается с данными). */
|
package/dist/up.d.ts
CHANGED
|
@@ -8,28 +8,36 @@ export interface InstallOptions {
|
|
|
8
8
|
schema: string;
|
|
9
9
|
owner?: string;
|
|
10
10
|
app?: string;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Разрешить `db.reset()` и `maintain({ now })` — для тестовых установок и стендов; по умолчанию запрещено. Значение
|
|
13
|
+
* задаётся при установке и хранится в схеме: каждый `up()` записывает его заново (план, §2.2).
|
|
14
|
+
*/
|
|
12
15
|
allowReset?: boolean;
|
|
13
16
|
/**
|
|
14
|
-
*
|
|
15
|
-
* `{ all: '1 day', daily: '1 week', weekly: '1 month', monthly: '1 year', yearly: 'forever' }
|
|
16
|
-
*
|
|
17
|
+
* Политика хранения истории для классов без своей, например
|
|
18
|
+
* `{ all: '1 day', daily: '1 week', weekly: '1 month', monthly: '1 year', yearly: 'forever' }`; не задана — история
|
|
19
|
+
* хранится вся. Значение задаётся при установке и хранится в схеме: каждый `up()` записывает его заново (план, §2.11).
|
|
17
20
|
*/
|
|
18
21
|
history?: HistoryPolicy;
|
|
19
22
|
/** Номер последнего накатываемого файла — для пошаговых тестов. */
|
|
20
23
|
upTo?: number;
|
|
21
24
|
/** Сиды после ядра: имена lib/sql/seed.<имя>.sql (например 'booking') или пути к своим файлам .sql. */
|
|
22
25
|
seeds?: string[];
|
|
23
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Обновление (upgrade): ревизия схемы до наката — миграции lib/sql/migrate/ с номером выше неё. Если под блокировкой
|
|
28
|
+
* схемы видна более новая ревизия (её уже обновил одновременный up()), миграции считаются от неё.
|
|
29
|
+
*/
|
|
24
30
|
migrateFrom?: number;
|
|
31
|
+
/** Удалить схему и поставить заново (up({ fresh })) — под той же блокировкой схемы, что и накат. ДАННЫЕ ТЕРЯЮТСЯ. */
|
|
32
|
+
fresh?: boolean;
|
|
25
33
|
/** Каталог миграций (по умолчанию lib/sql/migrate). */
|
|
26
34
|
migrationsDir?: string;
|
|
27
35
|
/** Свой ключ сервиса: зарегистрировать, если такого ещё нет (вместо выпуска случайного). */
|
|
28
36
|
serviceKey?: string;
|
|
29
37
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* схеме:
|
|
38
|
+
* Что видно в сигналах базы (`NOTIFY`): `'class'` (у новой схемы по умолчанию) — имя класса; `'tenant'` — имя класса и
|
|
39
|
+
* псевдоним арендатора (токен); `'opaque'` — только псевдонимы класса и арендатора; `'none'` — сигналов нет.
|
|
40
|
+
* Режим хранится в схеме: `up()` без опции его не меняет, `reset({ all })` сохраняет (план 1.1, §2.2).
|
|
33
41
|
*/
|
|
34
42
|
signals?: SignalMode;
|
|
35
43
|
}
|
|
@@ -47,7 +55,11 @@ export interface InstallResult {
|
|
|
47
55
|
schema: string;
|
|
48
56
|
owner: string;
|
|
49
57
|
app: string;
|
|
50
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Ключ API сервисного аккаунта, выпущенный или зарегистрированный этим накатом, иначе `null`. Без опции `serviceKey`
|
|
60
|
+
* новый ключ выпускается на любом накате, где в схеме не осталось ни одного неотозванного ключа API, — не только при
|
|
61
|
+
* первой установке; свой `serviceKey` регистрируется и возвращается, если неотозванного ключа с таким значением ещё нет.
|
|
62
|
+
*/
|
|
51
63
|
serviceKey: string | null;
|
|
52
64
|
/** Применённые миграции. */
|
|
53
65
|
migrations: string[];
|
|
@@ -75,7 +87,9 @@ export declare function compareVersions(a: string, b: string): number;
|
|
|
75
87
|
*/
|
|
76
88
|
export declare function stampDrift(schema: string, comment: string): Promise<string | null>;
|
|
77
89
|
export declare function substitute(text: string, vars: Record<string, string>): string;
|
|
78
|
-
export declare function install(sql: postgres.Sql, opts: InstallOptions): Promise<InstallResult
|
|
90
|
+
export declare function install(sql: postgres.Sql, opts: InstallOptions): Promise<InstallResult & {
|
|
91
|
+
found: number | null;
|
|
92
|
+
}>;
|
|
79
93
|
/** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
|
|
80
94
|
export declare function migrationFiles(dir: string, from: number): Promise<string[]>;
|
|
81
95
|
export interface UpOptions extends Omit<InstallOptions, 'schema' | 'upTo' | 'migrateFrom'> {
|
|
@@ -94,11 +108,16 @@ export interface UpOptions extends Omit<InstallOptions, 'schema' | 'upTo' | 'mig
|
|
|
94
108
|
/** Сколько ждать базу, мс (по умолчанию 60 000). */
|
|
95
109
|
waitTimeoutMs?: number;
|
|
96
110
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
111
|
+
* Запустить PGlite — PostgreSQL прямо в процессе Node — для разработки без своего сервера (решение 6 точки А);
|
|
112
|
+
* подключение к нему — `connect({ dsn: r.pglite.dsn, schema: r.schema, listen: false, max: 1, allowBypassRls: true })`.
|
|
113
|
+
* Соединение у PGlite одно, поэтому одновременные транзакции и гонки на нём не проверить: для таких тестов и в CI нужен
|
|
114
|
+
* настоящий PostgreSQL.
|
|
99
115
|
*/
|
|
100
116
|
pglite?: boolean | PgliteOptions;
|
|
101
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Арендатор и его первый пользователь: после установки `up()` заводит их сам, как `createTenant()` с этими опциями, и
|
|
119
|
+
* кладёт итог в `r.tenant` — свой сид для этого не нужен.
|
|
120
|
+
*/
|
|
102
121
|
tenant?: TenantOptions;
|
|
103
122
|
}
|
|
104
123
|
export interface UpResult extends InstallResult {
|
|
@@ -134,14 +153,18 @@ export declare function ensureDatabase(dsn: string, log?: (m: string) => void):
|
|
|
134
153
|
*/
|
|
135
154
|
export declare function checkServer(versionNum: number, encoding: string): string[];
|
|
136
155
|
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
137
|
-
export declare function schemaRevision(sql: postgres.Sql, schema: string): Promise<number | null>;
|
|
156
|
+
export declare function schemaRevision(sql: Pick<postgres.Sql, 'unsafe'>, schema: string): Promise<number | null>;
|
|
138
157
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
158
|
+
* Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
|
|
159
|
+
* повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
|
|
160
|
+
* Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
|
|
141
161
|
*/
|
|
142
162
|
export declare function up(opts: UpOptions): Promise<UpResult>;
|
|
143
163
|
export interface TenantOptions {
|
|
144
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Имя арендатора — его аккаунта в System. Из имени вычисляется id арендатора, поэтому одно имя — всегда один и тот же
|
|
166
|
+
* арендатор (так же его находит `letopis import --tenant`).
|
|
167
|
+
*/
|
|
145
168
|
name: string;
|
|
146
169
|
/** Правило арендатора «аутентифицированным можно всё» (READ, WRITE, DELETE; по умолчанию да). */
|
|
147
170
|
grantAll?: boolean;
|
|
@@ -150,6 +173,10 @@ export interface TenantOptions {
|
|
|
150
173
|
name: string;
|
|
151
174
|
login: string;
|
|
152
175
|
password: string;
|
|
176
|
+
/**
|
|
177
|
+
* Вид пароля (по умолчанию PASSWORD) — как у auth.setPassword: `^[A-Z][A-Z0-9_]{1,31}$`, не служебный вид (SESSION,
|
|
178
|
+
* APIKEY, KEYSECRET, OTP, TOTP, KEY, PROJECTOR, LOCK); иначе invalid_data.
|
|
179
|
+
*/
|
|
153
180
|
kind?: string;
|
|
154
181
|
ttl?: string;
|
|
155
182
|
/** Роли членства (по умолчанию ['owner']); роли уже существующего членства повтор не меняет. */
|
|
@@ -167,14 +194,10 @@ export interface TenantResult {
|
|
|
167
194
|
created: boolean;
|
|
168
195
|
}
|
|
169
196
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
|
|
175
|
-
* журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
|
|
176
|
-
* что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
|
|
177
|
-
* версию 1 — её занимает история; вернуть строку — restore() цепочкой.
|
|
197
|
+
* Заводит арендатора и, по желанию, его первого пользователя (аккаунт, членство, пароль, сессию) одной транзакцией от
|
|
198
|
+
* роли-владельца схемы — поэтому нужен администраторский пул. Повтор с тем же именем и логином ничего не дублирует и не
|
|
199
|
+
* меняет, только выдаёт пользователю новую сессию; одновременные вызовы заводят арендатора один раз. Если арендатор,
|
|
200
|
+
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
178
201
|
*/
|
|
179
202
|
export declare function createTenant(sql: postgres.Sql, schema: string, opts: TenantOptions, owner?: string): Promise<TenantResult>;
|
|
180
203
|
export {};
|
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,45 @@ 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
|
+
// роль владельца создал кто-то другой, а у установщика нет на неё ADMIN — понятная ошибка вместо сырой 42501
|
|
151
|
+
// (план 2.0, 7.5 №3)
|
|
152
|
+
if (e.code === '42501') {
|
|
153
|
+
throw Object.assign(new Error(`letopis.up: роль ${owner} уже есть, а у установщика нет на неё ADMIN OPTION — установку ведёт роль, создавшая ${owner}, или роль с этим правом (grant ${owner} to <установщик> with admin option)`), { cause: e });
|
|
154
|
+
}
|
|
155
|
+
if (e.code !== '0LP01' && e.code !== '23505')
|
|
156
|
+
throw e; // уже выдано циклом — нет; 23505 — выдал up() другой базы
|
|
157
|
+
});
|
|
158
|
+
await tx.unsafe(`create extension if not exists pgcrypto`);
|
|
159
|
+
const [{ nsp }] = await tx.unsafe(`
|
|
160
|
+
select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`);
|
|
161
|
+
await tx.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
|
|
162
|
+
return nsp;
|
|
130
163
|
});
|
|
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
164
|
const vars = {
|
|
136
165
|
SCHEMA: quoteIdent(opts.schema),
|
|
137
166
|
SCHEMA_NAME: opts.schema,
|
|
@@ -152,13 +181,27 @@ export async function install(sql, opts) {
|
|
|
152
181
|
const st = await engineStamp();
|
|
153
182
|
vars.ENGINE_VERSION = st.version;
|
|
154
183
|
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
184
|
const files = await engineFiles(opts.upTo);
|
|
158
|
-
|
|
185
|
+
let migrations = [];
|
|
186
|
+
let found = null;
|
|
159
187
|
let serviceKey = null;
|
|
160
188
|
let signals = 'class';
|
|
161
189
|
await sql.begin(async (tx) => {
|
|
190
|
+
// Накат схемы — под advisory-блокировкой самой схемы. Несколько экземпляров сервиса зовут up() одной схемы при
|
|
191
|
+
// запуске разом: без блокировки второй create schema падал на уникальном индексе каталога (23505), а второй
|
|
192
|
+
// create or replace function или comment — «tuple concurrently updated» (XX000). С ней следующий up() ждёт, пока
|
|
193
|
+
// предыдущий зафиксирует накат, и делает повторный накат по готовой схеме. Поэтому и fresh, и ревизия, от которой
|
|
194
|
+
// считаются миграции, — под блокировкой: up() видит ревизию до неё, и второй upgrade повторил бы миграции, уже
|
|
195
|
+
// накатанные первым. Разные схемы друг друга не ждут: ключ — имя схемы.
|
|
196
|
+
await lockSchema(tx, opts.schema);
|
|
197
|
+
if (opts.fresh)
|
|
198
|
+
await tx.unsafe(`drop schema if exists ${quoteIdent(opts.schema)} cascade`);
|
|
199
|
+
found = await schemaRevision(tx, opts.schema);
|
|
200
|
+
if (opts.migrateFrom !== undefined && found !== null) {
|
|
201
|
+
migrations = await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, Math.max(opts.migrateFrom, found));
|
|
202
|
+
}
|
|
203
|
+
// Схема создаётся до set role: у владельца может не быть права CREATE в базе.
|
|
204
|
+
await tx.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
|
|
162
205
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
163
206
|
// режим сигналов (план 1.1, §2.6): опция, иначе сохранённый в схеме (meta строки ключа), иначе class
|
|
164
207
|
let saved = null;
|
|
@@ -236,7 +279,7 @@ export async function install(sql, opts) {
|
|
|
236
279
|
}
|
|
237
280
|
}
|
|
238
281
|
});
|
|
239
|
-
return { schema: opts.schema, owner, app, serviceKey, migrations, signals };
|
|
282
|
+
return { schema: opts.schema, owner, app, serviceKey, migrations, signals, found };
|
|
240
283
|
}
|
|
241
284
|
/** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
|
|
242
285
|
export async function migrationFiles(dir, from) {
|
|
@@ -313,14 +356,15 @@ export function checkServer(versionNum, encoding) {
|
|
|
313
356
|
}
|
|
314
357
|
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
315
358
|
export async function schemaRevision(sql, schema) {
|
|
316
|
-
const [r] = await sql
|
|
359
|
+
const [r] = await sql.unsafe(`select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = $1`, [schema]);
|
|
317
360
|
if (!r)
|
|
318
361
|
return null;
|
|
319
362
|
return Number(/ddl_revision=(\d+)/.exec(String(r.c ?? ''))?.[1] ?? 0);
|
|
320
363
|
}
|
|
321
364
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
365
|
+
* Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
|
|
366
|
+
* повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
|
|
367
|
+
* Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
|
|
324
368
|
*/
|
|
325
369
|
export async function up(opts) {
|
|
326
370
|
const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
|
|
@@ -362,11 +406,8 @@ export async function up(opts) {
|
|
|
362
406
|
if (!opts.quiet)
|
|
363
407
|
for (const w of warnings)
|
|
364
408
|
console.warn(`letopis.up: ${w}`);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
log(`схема ${schema} удалена (fresh)`);
|
|
368
|
-
}
|
|
369
|
-
const before = await schemaRevision(sql, schema);
|
|
409
|
+
// ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
|
|
410
|
+
const before = opts.fresh ? null : await schemaRevision(sql, schema);
|
|
370
411
|
if (before !== null && before > DDL_REVISION) {
|
|
371
412
|
throw new Error(`letopis.up: схема ${schema} ревизии ${before}, а библиотека знает только ${DDL_REVISION} — обновите пакет`);
|
|
372
413
|
}
|
|
@@ -374,7 +415,11 @@ export async function up(opts) {
|
|
|
374
415
|
throw new Error(`letopis.up: схема ${schema} ревизии ${before}, библиотека — ${DDL_REVISION}: обновите её up({ schema: '${schema}', upgrade: true })`);
|
|
375
416
|
}
|
|
376
417
|
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, {
|
|
418
|
+
const { found, ...res } = await install(sql, {
|
|
419
|
+
...rest, schema, ...(opts.fresh ? { fresh: true } : {}), ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}),
|
|
420
|
+
});
|
|
421
|
+
if (opts.fresh)
|
|
422
|
+
log(`схема ${schema} удалена и поставлена заново (fresh)`);
|
|
378
423
|
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть
|
|
379
424
|
const fresh = await sql `select relname from pg_stat_user_tables
|
|
380
425
|
where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`;
|
|
@@ -382,9 +427,9 @@ export async function up(opts) {
|
|
|
382
427
|
for (const t of analyzed)
|
|
383
428
|
await sql.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
|
|
384
429
|
const revision = (await schemaRevision(sql, schema)) ?? 0;
|
|
385
|
-
log(`${
|
|
430
|
+
log(`${found === null ? 'схема поставлена' : found < DDL_REVISION ? `схема обновлена с ревизии ${found}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
|
|
386
431
|
const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
|
|
387
|
-
return { ...res, created:
|
|
432
|
+
return { ...res, created: found === null, upgraded: found !== null && found < DDL_REVISION, revision, analyzed, warnings,
|
|
388
433
|
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
|
|
389
434
|
}
|
|
390
435
|
catch (e) {
|
|
@@ -399,8 +444,11 @@ export async function up(opts) {
|
|
|
399
444
|
await sql.end({ timeout: 5 }).catch(() => { });
|
|
400
445
|
}
|
|
401
446
|
}
|
|
447
|
+
/** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
|
|
448
|
+
const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'PROJECTOR', 'LOCK'];
|
|
402
449
|
/**
|
|
403
|
-
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner).
|
|
450
|
+
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
|
|
451
|
+
* функций кредов (auth_kind_ok). Членство
|
|
404
452
|
* пишет sys_create от владельца — без проверки класса member, поэтому список ролей проверяется здесь:
|
|
405
453
|
* непустой, без повторов, каждая роль — имя, а не маска группы '{a,b}': не пустое, без пробелов по
|
|
406
454
|
* краям (маска их отрезает) и запятых (разделитель маски), без фигурных скобок и «!» в начале
|
|
@@ -409,6 +457,12 @@ export async function up(opts) {
|
|
|
409
457
|
function checkTenant(opts) {
|
|
410
458
|
if (!opts.name)
|
|
411
459
|
throw new Error('letopis.createTenant: имя арендатора обязательно');
|
|
460
|
+
// вид пароля — по правилу функций кредов (auth_kind_ok в 80-auth.sql): пароль пишет функция владельца мимо них, а с
|
|
461
|
+
// видом в другом регистре или служебным (SESSION, TOTP…) по нему не войти, и служебные строки он подменял бы
|
|
462
|
+
const kind = opts.user?.kind;
|
|
463
|
+
if (kind != null && (typeof kind !== 'string' || !/^[A-Z][A-Z0-9_]{1,31}$/.test(kind) || SERVICE_KINDS.includes(kind))) {
|
|
464
|
+
throw new LetopisError('invalid_data', `createTenant: user.kind ${JSON.stringify(kind)} — вид пароля: заглавная латинская буква, затем заглавные буквы, цифры или «_», всего от 2 до 32 знаков; служебные виды ${SERVICE_KINDS.join(', ')} заняты (по умолчанию — PASSWORD)`);
|
|
465
|
+
}
|
|
412
466
|
if (opts.user?.roles === undefined)
|
|
413
467
|
return ['owner'];
|
|
414
468
|
const roles = opts.user.roles;
|
|
@@ -427,14 +481,10 @@ function checkTenant(opts) {
|
|
|
427
481
|
return roles;
|
|
428
482
|
}
|
|
429
483
|
/**
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
|
|
435
|
-
* журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
|
|
436
|
-
* что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
|
|
437
|
-
* версию 1 — её занимает история; вернуть строку — restore() цепочкой.
|
|
484
|
+
* Заводит арендатора и, по желанию, его первого пользователя (аккаунт, членство, пароль, сессию) одной транзакцией от
|
|
485
|
+
* роли-владельца схемы — поэтому нужен администраторский пул. Повтор с тем же именем и логином ничего не дублирует и не
|
|
486
|
+
* меняет, только выдаёт пользователю новую сессию; одновременные вызовы заводят арендатора один раз. Если арендатор,
|
|
487
|
+
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
438
488
|
*/
|
|
439
489
|
export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
440
490
|
const roles = checkTenant(opts);
|
|
@@ -444,6 +494,9 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
|
444
494
|
const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
|
|
445
495
|
const hash = u ? await hashPassword(u.password) : null;
|
|
446
496
|
return sql.begin(async (tx) => {
|
|
497
|
+
// под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
|
|
498
|
+
// раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
|
|
499
|
+
await lockSchema(tx, schema);
|
|
447
500
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
448
501
|
/** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
|
|
449
502
|
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 {};
|