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.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
package/dist/up.js CHANGED
@@ -1,126 +1,177 @@
1
1
  /**
2
- * letopis.up(): одна точка входа — dev-контейнер (TimescaleDB + Redis), готовность БД,
3
- * версионная схема с сидами, connect. Идемпотентно: живой postgres на dsn → docker
4
- * пропускается целиком; существующая схема не трогается (fresh: true — дроп и накат).
2
+ * Установщик letopis 1.0 (план, этап 1, п. 2) и полный up() (этап 6, п. 9–10).
5
3
  *
6
- * const db = await up({ schema: 'booking', version: 1 }); // → PG-схема "v1.booking"
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
- // FILE: lib/src/up.ts
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 { connect } from './index.js';
48
- const exec = promisify(execFile);
49
- const DEFAULT_DSN = 'postgres://postgres:test@localhost:15432/letopis';
50
- const qi = (s) => `"${s.replace(/"/g, '""')}"`;
51
- const pkgPath = (rel) => fileURLToPath(new URL(rel, import.meta.url));
52
- /** argv для docker run (чистая функция — покрыта юнитом). dataDir: путь → bind, имя → volume. */
53
- // START_CONTRACT: dockerRunArgs
54
- // PURPOSE: Чистая функция — собирает argv для `docker run` dev-контейнера (порты PG/Redis, том данных, режим trust либо пароль).
55
- // INPUTS: { cfg: RunCfg - контейнер/образ, порты, креды, база, dataDir (путь -> bind, имя -> volume) }
56
- // OUTPUTS: { string[] - аргументы командной строки docker run }
57
- // SIDE_EFFECTS: none
58
- // LINKS: M-UP, V-M-UP
59
- // END_CONTRACT: dockerRunArgs
60
- export function dockerRunArgs(cfg) {
61
- const args = ['run', '-d', '--name', cfg.container,
62
- '-p', `${cfg.pgPort}:5432`, '-p', `${cfg.redisPort}:6379`,
63
- '-v', `${cfg.dataDir ?? 'letopis-pgdata'}:/var/lib/postgresql/data`];
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
- async function docker(args) {
72
- try {
73
- const { stdout, stderr } = await exec('docker', args, { maxBuffer: 64 * 1024 * 1024 });
74
- return { ok: true, out: stdout.trim(), err: stderr };
75
- }
76
- catch (e) {
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
- // START_CONTRACT: ensureContainer
84
- // PURPOSE: Гарантирует запущенный dev-контейнер — reuse запущенного, start остановленного, иначе build образа (если нет) и run.
85
- // INPUTS: { cfg: RunCfg - параметры контейнера/образа/портов; log: (m: string) => void - прогресс }
86
- // OUTPUTS: { Promise<void> }
87
- // SIDE_EFFECTS: вызывает docker inspect/start/build/run через execFile; бросает при неудаче start/build/run
88
- // LINKS: M-UP, V-M-UP
89
- // END_CONTRACT: ensureContainer
90
- async function ensureContainer(cfg, log) {
91
- const st = await docker(['container', 'inspect', '--format', '{{.State.Running}}', cfg.container]);
92
- if (st.ok && st.out === 'true') {
93
- log(`container ${cfg.container} is running — reused`);
94
- return;
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
- if (st.ok) {
97
- const r = await docker(['start', cfg.container]);
98
- if (!r.ok)
99
- throw new Error(`letopis.up: docker start ${cfg.container} failed: ${r.err}`);
100
- log(`container ${cfg.container} started`);
101
- return;
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
- const img = await docker(['image', 'inspect', cfg.image]);
104
- if (!img.ok) {
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
- const r = await docker(dockerRunArgs(cfg));
112
- if (!r.ok)
113
- throw new Error(`letopis.up: docker run ${cfg.container} failed: ${r.err}`);
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
- // START_CONTRACT: pgPing
117
- // PURPOSE: Одноразовая проба postgres по dsn — классифицирует ответ: ok, no-database (3D000), starting (57P03), fatal (28P01/28000), no-server.
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 }; // initdb/recovery
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
- // START_CONTRACT: ensureDatabase
144
- // PURPOSE: Создаёт целевую базу, если её ещё нет — подключаясь к служебной базе postgres (гонку параллельных up() 42P04 глушит).
145
- // INPUTS: { url: URL - разобранный dsn (имя базы из pathname/username); log: (m: string) => void }
146
- // OUTPUTS: { Promise<void> }
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 `SELECT 1 FROM pg_database WHERE datname = ${dbName}`;
157
- if (rows.length === 0) {
158
- await sql.unsafe(`CREATE DATABASE ${qi(dbName)}`).catch((e) => {
159
- if (e.code !== '42P04')
160
- throw e; // 42P04 — гонка параллельных up()
161
- });
162
- log(`database "${dbName}" created`);
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
- // START_CONTRACT: tcpAlive
170
- // PURPOSE: TCP-проба доступности хоста:порта (готовность Redis) с таймаутом 2 с.
171
- // INPUTS: { host: string; port: number }
172
- // OUTPUTS: { Promise<boolean> - true если соединение установилось }
173
- // SIDE_EFFECTS: открывает и сразу закрывает TCP-сокет
174
- // LINKS: M-UP, V-M-UP
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
- // START_CONTRACT: applySchema
186
- // PURPOSE: Применяет DDL-схему и сиды — при fresh дропает схему; если схемы нет, накатывает ddl.sql (+ дефолтные booking/auth или свои сиды), подставляя <SCHEMA-NAME>.
187
- // INPUTS: { dsn: string; full: string - полное имя схемы v<N>.<schema>; seeds: string[] | false | undefined - свои пути / false (без сидов) / undefined (дефолт); fresh: boolean; upgrade: boolean - перекатить ddl на существующую схему; log: (m: string) => void }
188
- // OUTPUTS: { Promise<void> }
189
- // SIDE_EFFECTS: читает sql-файлы; выполняет DROP/накат схемы в postgres; закрывает соединение
190
- // LINKS: M-UP, V-M-UP
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
- // START_CONTRACT: up
243
- // PURPOSE: Единая точка входа dev-bootstrap — валидирует схему/версию, при живом postgres пропускает docker (иначе поднимает контейнер и ждёт готовности PG+Redis), применяет схему и подключается.
244
- // INPUTS: { opts: UpOpts - dsn, schema, version, контейнер/образ, dataDir, redisPort, seeds, fresh, upgrade, quiet, waitTimeoutMs + прочие ConnectOpts }
245
- // OUTPUTS: { Promise<EntityDb> - подключённый фасад на схеме "v<version>.<schema>" }
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 { dsn = DEFAULT_DSN, schema, version, container = 'letopis-timescale', image = 'letopis-db', dataDir, redisPort = 16379, seeds, fresh, upgrade, quiet, waitTimeoutMs = 120_000, ...connectRest } = opts;
251
- if (!/^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(schema))
252
- throw new Error(`letopis.up: bad schema name "${schema}" (базовое имя без версии и точек; версию задаёт version)`);
253
- if (!Number.isInteger(version) || version < 1)
254
- throw new Error(`letopis.up: bad version ${version} (целое ≥ 1)`);
255
- const full = `v${version}.${schema}`;
256
- const url = new URL(dsn);
257
- const host = url.hostname;
258
- const local = host === 'localhost' || host === '127.0.0.1' || host === '::1';
259
- const log = quiet ? () => { } : (m) => console.log(`[letopis.up] ${m}`);
260
- const deadline = Date.now() + waitTimeoutMs;
261
- let ping = await pgPing(dsn);
262
- if (ping.ok || ping.kind === 'no-database') {
263
- // postgres уже отвечает (свой контейнер, CI-сервис, внешняя БД) — docker не нужен
264
- if (!ping.ok)
265
- await ensureDatabase(url, log);
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(url, log);
258
+ await ensureDatabase(opts.dsn, log);
294
259
  continue;
295
260
  }
296
261
  if (Date.now() > deadline)
297
- throw new Error(`letopis.up: postgres not ready in ${Math.round(waitTimeoutMs / 1000)} s${local ? ` — check: docker logs ${container}` : ''}`);
298
- await sleep(500);
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
- log(`postgres ready in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
301
- if (local) {
302
- // контейнер поднимали мы — убеждаемся, что и redis-порт жив
303
- while (!(await tcpAlive(host, redisPort))) {
304
- if (Date.now() > deadline)
305
- throw new Error(`letopis.up: redis not ready on ${redisPort} — check: docker logs ${container}`);
306
- await sleep(300);
307
- }
308
- log(`redis ready on ${redisPort}`);
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
- // END_BLOCK_WAIT_READY
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
- // START_BLOCK_APPLY_SCHEMA
313
- await applySchema(dsn, full, seeds, fresh ?? false, upgrade ?? false, log);
314
- const db = await connect({ ...connectRest, dsn, schema: full });
315
- log(`connected (schema "${full}")`);
316
- // END_BLOCK_APPLY_SCHEMA
317
- return db;
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
  }