letopis 0.20.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/dist/up.js
CHANGED
|
@@ -1,126 +1,177 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* letopis.
|
|
3
|
-
* версионная схема с сидами, connect. Идемпотентно: живой postgres на dsn → docker
|
|
4
|
-
* пропускается целиком; существующая схема не трогается (fresh: true — дроп и накат).
|
|
2
|
+
* Установщик letopis 1.0 (план, этап 1, п. 2) и полный up() (этап 6, п. 9–10).
|
|
5
3
|
*
|
|
6
|
-
*
|
|
4
|
+
* install() накатывает lib/sql/NN-*.sql по возрастанию номера с подстановкой имён, идемпотентно
|
|
5
|
+
* создаёт роли, выдаёт себе членство в роли-владельце `with inherit false, set true` и работает от
|
|
6
|
+
* неё только на время явного `set role`. Повторный накат ничего не меняет.
|
|
7
|
+
*
|
|
8
|
+
* up() — оркестратор без Docker: ждёт базу (удалённую — до таймаута), создаёт её, проверяет версию
|
|
9
|
+
* PostgreSQL (18+) и кодировку UTF8, ставит схему (fresh — заново), обновляет отставшую (upgrade:
|
|
10
|
+
* миграции lib/sql/migrate/ и повторный накат), кладёт сиды, выдаёт или регистрирует сервисный ключ и
|
|
11
|
+
* выполняет ANALYZE только для таблиц без статистики.
|
|
7
12
|
*/
|
|
8
|
-
|
|
9
|
-
// VERSION: 1.1.0
|
|
10
|
-
// START_MODULE_CONTRACT
|
|
11
|
-
// PURPOSE: Dev-bootstrap — поднять Docker-контейнер Postgres (TimescaleDB) + Redis, обеспечить базу, накатить DDL-схему и сиды, дождаться готовности и подключиться.
|
|
12
|
-
// SCOPE: валидация схемы/версии; проба postgres; docker build/run/start контейнера; создание базы; ожидание готовности PG и Redis; применение схемы + сиды; connect
|
|
13
|
-
// DEPENDS: M-CONNECT, M-CHAIN, M-TYPES
|
|
14
|
-
// LINKS: M-UP, V-M-UP
|
|
15
|
-
// ROLE: RUNTIME
|
|
16
|
-
// MAP_MODE: EXPORTS
|
|
17
|
-
// END_MODULE_CONTRACT
|
|
18
|
-
//
|
|
19
|
-
// START_MODULE_MAP
|
|
20
|
-
// UpOpts - опции up(): dsn, schema, version, контейнер/образ, dataDir, seeds, fresh, upgrade, quiet, таймаут
|
|
21
|
-
// dockerRunArgs - чистая сборка argv для `docker run` (bind/volume, trust/пароль)
|
|
22
|
-
// RunCfg - конфиг запуска контейнера (порты, креды, база, dataDir)
|
|
23
|
-
// up - точка входа: контейнер -> база -> схема -> готовность -> connect (возвращает EntityDb)
|
|
24
|
-
// docker - (local) обёртка execFile над docker CLI (ENOENT -> понятная ошибка)
|
|
25
|
-
// ensureContainer - (local) reuse/start/build+run dev-контейнера
|
|
26
|
-
// pgPing - (local) проба postgres: ok | no-server | no-database | starting | fatal
|
|
27
|
-
// ensureDatabase - (local) создать базу, если её нет (гонка 42P04 глушится)
|
|
28
|
-
// tcpAlive - (local) TCP-проба хоста:порта (для redis)
|
|
29
|
-
// applySchema - (local) drop (fresh) | перекат ddl (upgrade) -> накат ddl+сидов с подстановкой <SCHEMA-NAME> -> ANALYZE Entity
|
|
30
|
-
// END_MODULE_MAP
|
|
31
|
-
//
|
|
32
|
-
// START_CHANGE_SUMMARY
|
|
33
|
-
// LAST_CHANGE: [v1.1.0 - applySchema делает ANALYZE Entity после наката: у гипертаблицы нет
|
|
34
|
-
// статистики родителя, и планировщик оценивает подзапрос кандидатов в 200 строк — выбирался
|
|
35
|
-
// Nested Loop, чтения были медленнее до x18 (замер в README 14.1).
|
|
36
|
-
// Ранее: Documented existing module: reverse-engineered contract + markup]
|
|
37
|
-
// END_CHANGE_SUMMARY
|
|
38
|
-
//
|
|
39
|
-
import { execFile } from 'node:child_process';
|
|
40
|
-
import { promisify } from 'node:util';
|
|
41
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
42
|
-
import net from 'node:net';
|
|
43
|
-
import { readFile } from 'node:fs/promises';
|
|
44
|
-
import { resolve } from 'node:path';
|
|
13
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
45
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
46
17
|
import postgres from 'postgres';
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (cfg.user && cfg.user !== 'postgres')
|
|
65
|
-
args.push('-e', `POSTGRES_USER=${cfg.user}`);
|
|
66
|
-
// без пароля в dsn контейнер поднимается в trust-режиме (dev), иначе postgres-образ не стартует
|
|
67
|
-
args.push('-e', cfg.password ? `POSTGRES_PASSWORD=${cfg.password}` : 'POSTGRES_HOST_AUTH_METHOD=trust');
|
|
68
|
-
args.push('-e', `POSTGRES_DB=${cfg.database}`, cfg.image);
|
|
69
|
-
return args;
|
|
18
|
+
import { DDL_REVISION, RESERVED_CLASS_NAMES } from './types.js';
|
|
19
|
+
import { startPglite } from './pglite.js';
|
|
20
|
+
import { hashPassword } from './auth.js';
|
|
21
|
+
import { LetopisError } from './errors.js';
|
|
22
|
+
import { v5Id } from './uuid.js';
|
|
23
|
+
export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
24
|
+
export const SCHEMA_VERSION = 'v2';
|
|
25
|
+
const SQL_DIR = fileURLToPath(new URL('../sql/', import.meta.url));
|
|
26
|
+
const MIGRATE_DIR = join(SQL_DIR, 'migrate');
|
|
27
|
+
const ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
28
|
+
const SCHEMA_NAME = /^v\d+\.[a-z_][a-z0-9_]{0,40}$/;
|
|
29
|
+
export const quoteIdent = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
30
|
+
const quoteLiteral = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
31
|
+
/** Файлы ядра по номеру: `10-core.sql` … `99-revision.sql`; сиды демо-домена сюда не входят. */
|
|
32
|
+
export async function engineFiles(upTo = 99) {
|
|
33
|
+
const files = (await readdir(SQL_DIR)).filter((f) => /^\d{2}-[a-z0-9.-]+\.sql$/.test(f));
|
|
34
|
+
return files.filter((f) => Number(f.slice(0, 2)) <= upTo).sort();
|
|
70
35
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const x = e;
|
|
78
|
-
if (x.code === 'ENOENT')
|
|
79
|
-
throw new Error('letopis.up: docker CLI not found — install Docker or point dsn at an already running postgres');
|
|
80
|
-
return { ok: false, out: (x.stdout ?? '').trim(), err: (x.stderr || x.message || '').trim() };
|
|
81
|
-
}
|
|
36
|
+
export function substitute(text, vars) {
|
|
37
|
+
return text.replace(/@([A-Z_]+)@/g, (m, k) => {
|
|
38
|
+
if (!(k in vars))
|
|
39
|
+
throw new Error(`letopis.up: неизвестный маркер ${m} в SQL`);
|
|
40
|
+
return vars[k];
|
|
41
|
+
});
|
|
82
42
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
43
|
+
export async function install(sql, opts) {
|
|
44
|
+
const owner = opts.owner ?? 'letopis_owner';
|
|
45
|
+
const app = opts.app ?? 'letopis_app';
|
|
46
|
+
if (!SCHEMA_NAME.test(opts.schema))
|
|
47
|
+
throw new Error(`letopis.up: bad schema name "${opts.schema}" (нужно v<N>.<имя>)`);
|
|
48
|
+
for (const r of [owner, app])
|
|
49
|
+
if (!ROLE_NAME.test(r))
|
|
50
|
+
throw new Error(`letopis.up: bad role name "${r}"`);
|
|
51
|
+
const [{ v }] = await sql `select current_setting('server_version_num') as v`;
|
|
52
|
+
if (Number(v) < 180000)
|
|
53
|
+
throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
|
|
54
|
+
// Роли — общие для кластера; создаются идемпотентно.
|
|
55
|
+
for (const r of [owner, app]) {
|
|
56
|
+
await sql.unsafe(`do $$ begin
|
|
57
|
+
if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(r)}) then
|
|
58
|
+
create role ${quoteIdent(r)} nologin;
|
|
59
|
+
end if;
|
|
60
|
+
exception when duplicate_object then null; end $$`);
|
|
95
61
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
throw
|
|
100
|
-
|
|
101
|
-
|
|
62
|
+
// Установщик получает права владельца только на время set role (план, §2.9).
|
|
63
|
+
await sql.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`).catch((e) => {
|
|
64
|
+
if (e.code !== '0LP01')
|
|
65
|
+
throw e; // уже выдано циклом — нет
|
|
66
|
+
});
|
|
67
|
+
await sql.unsafe(`create extension if not exists pgcrypto`);
|
|
68
|
+
const [{ nsp }] = await sql `
|
|
69
|
+
select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`;
|
|
70
|
+
await sql.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
|
|
71
|
+
const vars = {
|
|
72
|
+
SCHEMA: quoteIdent(opts.schema),
|
|
73
|
+
SCHEMA_NAME: opts.schema,
|
|
74
|
+
OWNER: quoteIdent(owner),
|
|
75
|
+
OWNER_NAME: owner,
|
|
76
|
+
APP: quoteIdent(app),
|
|
77
|
+
APP_NAME: app,
|
|
78
|
+
PGCRYPTO: quoteIdent(nsp),
|
|
79
|
+
SYSTEM: SYSTEM_ID,
|
|
80
|
+
ALLOW_RESET: opts.allowReset ? 'true' : 'false',
|
|
81
|
+
HISTORY_DEFAULT: quoteLiteral(JSON.stringify(opts.history ?? null)),
|
|
82
|
+
// методы цепочки — в тексте функции reserved_names() (план, §2.14)
|
|
83
|
+
RESERVED_NAMES: RESERVED_CLASS_NAMES.map(quoteLiteral).join(', '),
|
|
84
|
+
};
|
|
85
|
+
// Схема создаётся до set role: у владельца может не быть права CREATE в базе.
|
|
86
|
+
await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
|
|
87
|
+
const files = await engineFiles(opts.upTo);
|
|
88
|
+
const migrations = opts.migrateFrom === undefined ? [] : await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, opts.migrateFrom);
|
|
89
|
+
let serviceKey = null;
|
|
90
|
+
await sql.begin(async (tx) => {
|
|
91
|
+
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
92
|
+
// структурные изменения прежних ревизий — до повторного наката функций (этап 6, п. 10)
|
|
93
|
+
for (const m of migrations) {
|
|
94
|
+
try {
|
|
95
|
+
await tx.unsafe(substitute(await readFile(join(opts.migrationsDir ?? MIGRATE_DIR, m), 'utf8'), vars));
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
const err = e;
|
|
99
|
+
err.message = `letopis.up: migrate/${m}: ${err.message}`;
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
const text = substitute(await readFile(join(SQL_DIR, f), 'utf8'), vars);
|
|
105
|
+
try {
|
|
106
|
+
await tx.unsafe(text);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
const err = e;
|
|
110
|
+
err.message = `letopis.up: ${f}: ${err.message}`;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const seed of opts.seeds ?? []) {
|
|
115
|
+
// имя сида пакета или путь к своему файлу
|
|
116
|
+
const own = seed.endsWith('.sql') || /[\\/]/.test(seed);
|
|
117
|
+
if (!own && !/^[a-z0-9-]+$/.test(seed))
|
|
118
|
+
throw new Error(`letopis.up: bad seed name "${seed}"`);
|
|
119
|
+
const f = own ? resolve(seed) : join(SQL_DIR, `seed.${seed}.sql`);
|
|
120
|
+
try {
|
|
121
|
+
await tx.unsafe(substitute(await readFile(f, 'utf8'), vars));
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
const err = e;
|
|
125
|
+
err.message = `letopis.up: ${own ? seed : `seed.${seed}.sql`}: ${err.message}`;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (opts.history && files.some((f) => f.startsWith('40-'))) {
|
|
130
|
+
// умолчание проверяется той же метасхемой, что политика класса (сроки по возрастанию, all ≥ суток)
|
|
131
|
+
await tx.unsafe(`select ${quoteIdent(opts.schema)}.class_prepare($1::jsonb, $2::uuid)`, [{ name: 'HistoryDefault', kind: 'hub', history: opts.history }, SYSTEM_ID]).catch((e) => {
|
|
132
|
+
e.message = `letopis.up: history: ${e.message}`;
|
|
133
|
+
throw e;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (files.some((f) => f.startsWith('95-'))) {
|
|
137
|
+
const S = quoteIdent(opts.schema);
|
|
138
|
+
if (opts.serviceKey) {
|
|
139
|
+
// свой ключ: регистрируется, если его ещё нет (ключ хранится как sha256)
|
|
140
|
+
if (!/^lts_[0-9a-f]{32,128}$/.test(opts.serviceKey))
|
|
141
|
+
throw new Error('letopis.up: serviceKey — lts_ и не меньше 32 шестнадцатеричных знаков');
|
|
142
|
+
const hash = createHash('sha256').update(opts.serviceKey, 'utf8').digest();
|
|
143
|
+
const [{ n }] = await tx.unsafe(`select count(*)::int as n from ${S}.secret where kind = 'APIKEY' and material = $1 and deleted_at is null`, [hash]);
|
|
144
|
+
if (n === 0) {
|
|
145
|
+
await tx.unsafe(`select ${S}.bootstrap_service($1)`, [opts.serviceKey]);
|
|
146
|
+
serviceKey = opts.serviceKey;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const [{ n }] = await tx.unsafe(`select count(*)::int as n from ${S}.secret where kind = 'APIKEY' and deleted_at is null`);
|
|
151
|
+
if (n === 0) {
|
|
152
|
+
serviceKey = `lts_${randomBytes(32).toString('hex')}`;
|
|
153
|
+
await tx.unsafe(`select ${S}.bootstrap_service($1)`, [serviceKey]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return { schema: opts.schema, owner, app, serviceKey, migrations };
|
|
159
|
+
}
|
|
160
|
+
/** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
|
|
161
|
+
export async function migrationFiles(dir, from) {
|
|
162
|
+
let files;
|
|
163
|
+
try {
|
|
164
|
+
files = await readdir(dir);
|
|
102
165
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
log(`building image ${cfg.image} (first time pulls the timescaledb base — may take minutes)…`);
|
|
106
|
-
const b = await docker(['build', '-t', cfg.image, pkgPath('../docker/')]);
|
|
107
|
-
if (!b.ok)
|
|
108
|
-
throw new Error(`letopis.up: docker build ${cfg.image} failed: ${b.err}`);
|
|
109
|
-
log(`image ${cfg.image} built`);
|
|
166
|
+
catch {
|
|
167
|
+
return [];
|
|
110
168
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
log(`container ${cfg.container} created (data: ${cfg.dataDir ?? 'volume letopis-pgdata'})`);
|
|
169
|
+
return files.filter((f) => /^\d{4}-[a-z0-9.-]+\.sql$/.test(f))
|
|
170
|
+
.filter((f) => Number(f.slice(0, 4)) > from && Number(f.slice(0, 4)) <= DDL_REVISION)
|
|
171
|
+
.sort();
|
|
115
172
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// INPUTS: { dsn: string - строка подключения }
|
|
119
|
-
// OUTPUTS: { Promise<Ping> - { ok: true } | { ok: false, kind, err } }
|
|
120
|
-
// SIDE_EFFECTS: открывает и закрывает разовое соединение postgres (max 1, connect_timeout 3)
|
|
121
|
-
// LINKS: M-UP, V-M-UP
|
|
122
|
-
// END_CONTRACT: pgPing
|
|
123
|
-
async function pgPing(dsn) {
|
|
173
|
+
/** Проба сервера: ok | нет сервера | нет базы | запускается | роковая ошибка (неверный пароль). */
|
|
174
|
+
export async function pgPing(dsn) {
|
|
124
175
|
const sql = postgres(dsn, { max: 1, connect_timeout: 3, onnotice: () => { } });
|
|
125
176
|
try {
|
|
126
177
|
await sql `select 1`;
|
|
@@ -129,190 +180,211 @@ async function pgPing(dsn) {
|
|
|
129
180
|
catch (e) {
|
|
130
181
|
const code = e.code;
|
|
131
182
|
if (code === '3D000')
|
|
132
|
-
return { ok: false, kind: 'no-database', err: e };
|
|
183
|
+
return { ok: false, kind: 'no-database', err: e };
|
|
133
184
|
if (code === '57P03')
|
|
134
|
-
return { ok: false, kind: 'starting', err: e };
|
|
185
|
+
return { ok: false, kind: 'starting', err: e };
|
|
135
186
|
if (code === '28P01' || code === '28000')
|
|
136
|
-
return { ok: false, kind: 'fatal', err: e };
|
|
187
|
+
return { ok: false, kind: 'fatal', err: e };
|
|
137
188
|
return { ok: false, kind: 'no-server', err: e };
|
|
138
189
|
}
|
|
139
190
|
finally {
|
|
140
191
|
await sql.end({ timeout: 1 }).catch(() => { });
|
|
141
192
|
}
|
|
142
193
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// SIDE_EFFECTS: подключается к базе postgres, выполняет CREATE DATABASE; закрывает соединение
|
|
148
|
-
// LINKS: M-UP, V-M-UP
|
|
149
|
-
// END_CONTRACT: ensureDatabase
|
|
150
|
-
async function ensureDatabase(url, log) {
|
|
151
|
-
const dbName = decodeURIComponent(url.pathname.slice(1)) || url.username || 'postgres';
|
|
194
|
+
/** Создать базу строки подключения, если её нет (гонка параллельных up() — 42P04 — не ошибка). */
|
|
195
|
+
export async function ensureDatabase(dsn, log = () => { }) {
|
|
196
|
+
const url = new URL(dsn);
|
|
197
|
+
const name = decodeURIComponent(url.pathname.slice(1)) || decodeURIComponent(url.username) || 'postgres';
|
|
152
198
|
const admin = new URL(url);
|
|
153
199
|
admin.pathname = '/postgres';
|
|
154
200
|
const sql = postgres(admin.toString(), { max: 1, onnotice: () => { } });
|
|
155
201
|
try {
|
|
156
|
-
const rows = await sql `
|
|
157
|
-
if (rows.length
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
202
|
+
const rows = await sql `select 1 from pg_database where datname = ${name}`;
|
|
203
|
+
if (rows.length)
|
|
204
|
+
return false;
|
|
205
|
+
await sql.unsafe(`create database ${quoteIdent(name)} encoding 'UTF8' template template0`).catch((e) => {
|
|
206
|
+
if (e.code !== '42P04')
|
|
207
|
+
throw e;
|
|
208
|
+
});
|
|
209
|
+
log(`база ${name} создана`);
|
|
210
|
+
return true;
|
|
164
211
|
}
|
|
165
212
|
finally {
|
|
166
|
-
await sql.end();
|
|
213
|
+
await sql.end({ timeout: 1 });
|
|
167
214
|
}
|
|
168
215
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// END_CONTRACT: tcpAlive
|
|
176
|
-
function tcpAlive(host, port) {
|
|
177
|
-
return new Promise((res) => {
|
|
178
|
-
const s = net.connect({ host, port });
|
|
179
|
-
const done = (v) => { s.destroy(); res(v); };
|
|
180
|
-
s.once('connect', () => done(true));
|
|
181
|
-
s.once('error', () => done(false));
|
|
182
|
-
s.setTimeout(2000, () => done(false));
|
|
183
|
-
});
|
|
216
|
+
/** Версия сервера и кодировка базы: PostgreSQL 18+ и UTF8 (casefold и pg_unicode_fast требуют UTF8). */
|
|
217
|
+
export function checkServer(versionNum, encoding) {
|
|
218
|
+
if (versionNum < 180000)
|
|
219
|
+
throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${Math.floor(versionNum / 10000)} (${versionNum})`);
|
|
220
|
+
if (encoding.toUpperCase() !== 'UTF8')
|
|
221
|
+
throw new Error(`letopis.up: база в кодировке ${encoding}, нужна UTF8 (casefold и коллация pg_unicode_fast)`);
|
|
184
222
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// END_CONTRACT: applySchema
|
|
192
|
-
async function applySchema(dsn, full, seeds, fresh, upgrade, log) {
|
|
193
|
-
const sql = postgres(dsn, { max: 1, onnotice: () => { } });
|
|
194
|
-
try {
|
|
195
|
-
if (fresh) {
|
|
196
|
-
await sql.unsafe(`DROP SCHEMA IF EXISTS ${qi(full)} CASCADE`);
|
|
197
|
-
log(`schema "${full}" dropped (fresh)`);
|
|
198
|
-
}
|
|
199
|
-
const have = await sql `SELECT 1 FROM information_schema.schemata WHERE schema_name = ${full}`;
|
|
200
|
-
if (have.length > 0) {
|
|
201
|
-
// START_BLOCK_UPGRADE_DDL
|
|
202
|
-
// upgrade: перекатить ТОЛЬКО ddl.sql на существующую схему. Сиды не трогаем — они
|
|
203
|
-
// вставляют данные, повторный прогон дал бы дубли смысла. Файл идемпотентен
|
|
204
|
-
// (CREATE OR REPLACE у функций, DROP IF EXISTS + CREATE у триггеров, IF NOT EXISTS
|
|
205
|
-
// у таблиц/индексов), поэтому данные целы, а аддитивные правки движка доезжают.
|
|
206
|
-
// Так закрывается разрыв: схема, накатанная старой либой, не имела новых функций
|
|
207
|
-
// (purge/purge_account из 0.19.0) и падала сырым «function … does not exist».
|
|
208
|
-
if (upgrade) {
|
|
209
|
-
const text = (await readFile(pkgPath('../sql/ddl.sql'), 'utf8')).replaceAll('<SCHEMA-NAME>', full);
|
|
210
|
-
await sql.unsafe(text);
|
|
211
|
-
await sql.unsafe(`ANALYZE ${qi(full)}."Entity"`);
|
|
212
|
-
log(`schema "${full}" upgraded (ddl re-applied, data intact)`);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
// END_BLOCK_UPGRADE_DDL
|
|
216
|
-
log(`schema "${full}" already exists — apply skipped`);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
const files = [pkgPath('../sql/ddl.sql')];
|
|
220
|
-
if (seeds === undefined)
|
|
221
|
-
files.push(pkgPath('../sql/seed.booking.sql'), pkgPath('../sql/seed.auth.sql'));
|
|
222
|
-
else if (seeds)
|
|
223
|
-
files.push(...seeds.map((p) => resolve(p)));
|
|
224
|
-
for (const f of files) {
|
|
225
|
-
const text = (await readFile(f, 'utf8')).replaceAll('<SCHEMA-NAME>', full);
|
|
226
|
-
await sql.unsafe(text); // multi-statement simple query
|
|
227
|
-
}
|
|
228
|
-
// START_BLOCK_ANALYZE_ENTITY
|
|
229
|
-
// ANALYZE обязателен: Entity — гипертаблица, у РОДИТЕЛЯ своих строк нет, и без статистики
|
|
230
|
-
// планировщик берёт дефолт «200 уникальных id». Ядро всех чтений либы — semi-join с
|
|
231
|
-
// подзапросом кандидатов (`e.id IN (SELECT c.id …)`), и на оценке 200 вместо сотен тысяч
|
|
232
|
-
// он выбирает Nested Loop: на полигоне 980k это давало 57 с вместо 3 с (×18).
|
|
233
|
-
// Стоит копейки на свежей схеме; после МАССОВОЙ заливки данных ANALYZE нужно повторить.
|
|
234
|
-
await sql.unsafe(`ANALYZE ${qi(full)}."Entity"`);
|
|
235
|
-
// END_BLOCK_ANALYZE_ENTITY
|
|
236
|
-
log(`schema "${full}" applied (${files.length} files, statistics collected)`);
|
|
237
|
-
}
|
|
238
|
-
finally {
|
|
239
|
-
await sql.end();
|
|
240
|
-
}
|
|
223
|
+
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
224
|
+
export async function schemaRevision(sql, schema) {
|
|
225
|
+
const [r] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${schema}`;
|
|
226
|
+
if (!r)
|
|
227
|
+
return null;
|
|
228
|
+
return Number(/ddl_revision=(\d+)/.exec(String(r.c ?? ''))?.[1] ?? 0);
|
|
241
229
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// SIDE_EFFECTS: shell docker через M-UP.ensureContainer; TCP-пинги; чтение sql и накат схемы; console.log с префиксом [letopis.up]; connect (M-CONNECT); бросает при плохом имени схемы/версии и таймауте готовности
|
|
247
|
-
// LINKS: M-UP, V-M-UP
|
|
248
|
-
// END_CONTRACT: up
|
|
230
|
+
/**
|
|
231
|
+
* Поставить или обновить установку letopis. Схема есть и её ревизия совпадает — повторный накат
|
|
232
|
+
* (идемпотентный); ревизия отстала — только с upgrade: true; новее библиотеки — отказ.
|
|
233
|
+
*/
|
|
249
234
|
export async function up(opts) {
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
log(`postgres is up at ${host}:${url.port || '5432'} — docker skipped`);
|
|
267
|
-
}
|
|
268
|
-
else if (ping.kind === 'fatal') {
|
|
269
|
-
throw ping.err;
|
|
270
|
-
}
|
|
271
|
-
else {
|
|
272
|
-
// START_BLOCK_ENSURE_CONTAINER
|
|
273
|
-
if (local) {
|
|
274
|
-
await ensureContainer({
|
|
275
|
-
container, image, pgPort: url.port || '5432', redisPort,
|
|
276
|
-
user: decodeURIComponent(url.username), password: decodeURIComponent(url.password),
|
|
277
|
-
database: decodeURIComponent(url.pathname.slice(1)) || url.username || 'postgres', dataDir,
|
|
278
|
-
}, log);
|
|
279
|
-
}
|
|
280
|
-
else {
|
|
281
|
-
log(`waiting for remote postgres at ${host} (no docker for non-local dsn)…`);
|
|
282
|
-
}
|
|
283
|
-
// END_BLOCK_ENSURE_CONTAINER
|
|
284
|
-
// START_BLOCK_WAIT_READY
|
|
285
|
-
const t0 = Date.now();
|
|
235
|
+
const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
|
|
236
|
+
const schema = opts.schema.includes('.') ? opts.schema : `${SCHEMA_VERSION}.${opts.schema}`;
|
|
237
|
+
if (!SCHEMA_NAME.test(schema))
|
|
238
|
+
throw new Error(`letopis.up: bad schema name "${opts.schema}" (нужно <имя> или v<N>.<имя>)`);
|
|
239
|
+
// опции арендатора — до подключения и наката: с неверными up() ничего не начинает
|
|
240
|
+
if (opts.tenant)
|
|
241
|
+
checkTenant(opts.tenant);
|
|
242
|
+
let sql = opts.sql;
|
|
243
|
+
const own = !sql;
|
|
244
|
+
const lite = opts.pglite ? await startPglite(opts.pglite === true ? {} : opts.pglite) : undefined;
|
|
245
|
+
if (lite)
|
|
246
|
+
sql = postgres(lite.dsn, { max: 1, onnotice: () => { } });
|
|
247
|
+
if (!sql) {
|
|
248
|
+
if (!opts.dsn)
|
|
249
|
+
throw new Error('letopis.up: нужен dsn или sql');
|
|
250
|
+
const deadline = Date.now() + (opts.waitTimeoutMs ?? 60_000);
|
|
286
251
|
for (;;) {
|
|
287
|
-
ping = await pgPing(dsn);
|
|
252
|
+
const ping = await pgPing(opts.dsn);
|
|
288
253
|
if (ping.ok)
|
|
289
254
|
break;
|
|
290
255
|
if (ping.kind === 'fatal')
|
|
291
256
|
throw ping.err;
|
|
292
257
|
if (ping.kind === 'no-database') {
|
|
293
|
-
await ensureDatabase(
|
|
258
|
+
await ensureDatabase(opts.dsn, log);
|
|
294
259
|
continue;
|
|
295
260
|
}
|
|
296
261
|
if (Date.now() > deadline)
|
|
297
|
-
throw new Error(`letopis.up:
|
|
298
|
-
await
|
|
262
|
+
throw new Error(`letopis.up: база ${new URL(opts.dsn).host} не готова за ${Math.round((opts.waitTimeoutMs ?? 60_000) / 1000)} с`);
|
|
263
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
299
264
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
log(
|
|
265
|
+
sql = postgres(opts.dsn, { max: 1, onnotice: () => { } });
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const [srv] = await sql `select current_setting('server_version_num')::int as v, pg_encoding_to_char(encoding) as enc
|
|
269
|
+
from pg_database where datname = current_database()`;
|
|
270
|
+
checkServer(srv.v, srv.enc);
|
|
271
|
+
if (opts.fresh) {
|
|
272
|
+
await sql.unsafe(`drop schema if exists ${quoteIdent(schema)} cascade`);
|
|
273
|
+
log(`схема ${schema} удалена (fresh)`);
|
|
274
|
+
}
|
|
275
|
+
const before = await schemaRevision(sql, schema);
|
|
276
|
+
if (before !== null && before > DDL_REVISION) {
|
|
277
|
+
throw new Error(`letopis.up: схема ${schema} ревизии ${before}, а библиотека знает только ${DDL_REVISION} — обновите пакет`);
|
|
309
278
|
}
|
|
310
|
-
|
|
279
|
+
if (before !== null && before < DDL_REVISION && !opts.upgrade) {
|
|
280
|
+
throw new Error(`letopis.up: схема ${schema} ревизии ${before}, библиотека — ${DDL_REVISION}: обновите её up({ schema: '${schema}', upgrade: true })`);
|
|
281
|
+
}
|
|
282
|
+
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
|
|
283
|
+
const res = await install(sql, { ...rest, schema, ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}) });
|
|
284
|
+
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть
|
|
285
|
+
const fresh = await sql `select relname from pg_stat_user_tables
|
|
286
|
+
where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`;
|
|
287
|
+
const analyzed = fresh.map((r) => r.relname);
|
|
288
|
+
for (const t of analyzed)
|
|
289
|
+
await sql.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
|
|
290
|
+
const revision = (await schemaRevision(sql, schema)) ?? 0;
|
|
291
|
+
log(`${before === null ? 'схема поставлена' : before < DDL_REVISION ? `схема обновлена с ревизии ${before}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
|
|
292
|
+
const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
|
|
293
|
+
return { ...res, created: before === null, upgraded: before !== null && before < DDL_REVISION, revision, analyzed,
|
|
294
|
+
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
|
|
311
295
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
296
|
+
catch (e) {
|
|
297
|
+
if (lite)
|
|
298
|
+
await sql.end({ timeout: 1 }).then(() => lite.close(), () => lite.close());
|
|
299
|
+
throw e;
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
if (own && !lite)
|
|
303
|
+
await sql.end({ timeout: 5 });
|
|
304
|
+
else if (lite)
|
|
305
|
+
await sql.end({ timeout: 5 }).catch(() => { });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Членство
|
|
310
|
+
* пишет sys_create от владельца — без проверки класса member, поэтому список ролей проверяется здесь:
|
|
311
|
+
* непустой, без повторов, каждая роль — имя, а не маска группы '{a,b}': не пустое, без пробелов по
|
|
312
|
+
* краям (маска их отрезает) и запятых (разделитель маски), без фигурных скобок и «!» в начале
|
|
313
|
+
* (синтаксис маски).
|
|
314
|
+
*/
|
|
315
|
+
function checkTenant(opts) {
|
|
316
|
+
if (!opts.name)
|
|
317
|
+
throw new Error('letopis.createTenant: имя арендатора обязательно');
|
|
318
|
+
if (opts.user?.roles === undefined)
|
|
319
|
+
return ['owner'];
|
|
320
|
+
const roles = opts.user.roles;
|
|
321
|
+
const bad = (why) => new LetopisError('invalid_data', `createTenant: user.roles — ${why}`);
|
|
322
|
+
if (!Array.isArray(roles) || roles.length === 0)
|
|
323
|
+
throw bad("непустой список ролей, например ['staff']");
|
|
324
|
+
for (const r of roles) {
|
|
325
|
+
if (typeof r !== 'string')
|
|
326
|
+
throw bad(`роль — строка, а не ${r === null ? 'null' : typeof r}`);
|
|
327
|
+
if (!r || r !== r.trim() || /[,{}]/.test(r) || r.startsWith('!')) {
|
|
328
|
+
throw bad(`роль ${JSON.stringify(r)}: нужно имя роли, а не маска группы — не пустое, без пробелов по краям, запятых, фигурных скобок и «!» в начале`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (new Set(roles).size !== roles.length)
|
|
332
|
+
throw bad('роли повторяются');
|
|
333
|
+
return roles;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Арендатор и его первый пользователь — то, что раньше делал свой сид функциями владельца
|
|
337
|
+
* (sys_create, sys_grant_all). Нужно администраторское подключение (роль может стать владельцем);
|
|
338
|
+
* всё — одной транзакцией от владельца. Повтор с тем же именем и логином ничего не дублирует:
|
|
339
|
+
* id — v5 от имени и логина, существующие строки пропускаются (пароль и роли членства не меняются),
|
|
340
|
+
* сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
|
|
341
|
+
* журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
|
|
342
|
+
* что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
|
|
343
|
+
* версию 1 — её занимает история; вернуть строку — restore() цепочкой.
|
|
344
|
+
*/
|
|
345
|
+
export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
346
|
+
const roles = checkTenant(opts);
|
|
347
|
+
const S = quoteIdent(schema);
|
|
348
|
+
const tenant = v5Id(SYSTEM_ID, 'Account', [`tenant:${opts.name}`]);
|
|
349
|
+
const u = opts.user;
|
|
350
|
+
const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
|
|
351
|
+
const hash = u ? await hashPassword(u.password) : null;
|
|
352
|
+
return sql.begin(async (tx) => {
|
|
353
|
+
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
354
|
+
/** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
|
|
355
|
+
const exists = async (id, cls, gone) => {
|
|
356
|
+
const [r] = await tx.unsafe(`select exists (select 1 from ${S}.entity where id = $1::uuid) as live,
|
|
357
|
+
exists (select 1 from ${S}.log where id = $1::uuid) as was`, [id]);
|
|
358
|
+
if (!r.live && r.was)
|
|
359
|
+
throw new LetopisError('invalid_data', `createTenant: ${gone}`, { id, class: cls });
|
|
360
|
+
return r.live;
|
|
361
|
+
};
|
|
362
|
+
const why = 'а его история осталась в журнале — повтор удалённое не воскрешает';
|
|
363
|
+
const created = !(await exists(tenant, 'Account', `арендатор «${opts.name}» (${tenant}) удалён, ${why}: `
|
|
364
|
+
+ 'верните его db.Account(id).restore() от имени системного администратора или выберите другое имя'));
|
|
365
|
+
if (created) {
|
|
366
|
+
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [tenant, SYSTEM_ID, { name: opts.name, categories: [], enabled: true }]);
|
|
367
|
+
if (opts.grantAll !== false)
|
|
368
|
+
await tx.unsafe(`select ${S}.sys_grant_all($1::uuid)`, [tenant]);
|
|
369
|
+
}
|
|
370
|
+
let token = null;
|
|
371
|
+
if (u && user) {
|
|
372
|
+
if (!(await exists(user, 'Account', `аккаунт пользователя ${u.login} (${user}) удалён, ${why}: `
|
|
373
|
+
+ 'верните его db.Account(id).restore() от имени системного администратора или выберите другой логин'))) {
|
|
374
|
+
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [user, SYSTEM_ID, { name: u.name, categories: ['User'], enabled: true }]);
|
|
375
|
+
}
|
|
376
|
+
const member = (await tx.unsafe(`select ${S}.v5_id($1::uuid, 'member', array[$2::text, $1::text]) as id`, [tenant, user]))[0].id;
|
|
377
|
+
// существующее членство не трогаем: повтор с другими ролями их не меняет (их правят правкой member)
|
|
378
|
+
if (!(await exists(member, 'member', `членство ${u.login} в арендаторе «${opts.name}» (${member}) удалено, ${why}: `
|
|
379
|
+
+ 'верните его db.member(id).restore() в этом арендаторе или выберите другой логин'))) {
|
|
380
|
+
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'member', $2::uuid, $3::jsonb, $4::jsonb)`, [member, tenant, { User: user, Tenant: tenant }, { roles }]);
|
|
381
|
+
}
|
|
382
|
+
const kind = u.kind ?? 'PASSWORD';
|
|
383
|
+
await tx.unsafe(`insert into ${S}.secret (account, kind, ident, material, meta)
|
|
384
|
+
select $1::uuid, $2, $3, convert_to($4, 'UTF8'), '{"confirmed": true}'::jsonb
|
|
385
|
+
where not exists (select 1 from ${S}.secret where kind = $2 and casefold(ident) collate pg_unicode_fast = casefold($3) collate pg_unicode_fast and deleted_at is null)`, [user, kind, u.login, hash]);
|
|
386
|
+
token = (await tx.unsafe(`select ${S}.new_session($1::uuid, $2::interval, $3::jsonb) as t`, [user, u.ttl ?? '7 days', { tenant }]))[0].t;
|
|
387
|
+
}
|
|
388
|
+
return { tenant, user, token, created };
|
|
389
|
+
});
|
|
318
390
|
}
|