letopis 1.4.2 → 2.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 (112) hide show
  1. package/AGENT-CHEATSHEET.md +329 -281
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +572 -1053
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -184
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +30 -8
  12. package/dist/chain.d.ts +18 -41
  13. package/dist/chain.js +165 -203
  14. package/dist/cli.js +45 -90
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -70
  21. package/dist/index.js +189 -122
  22. package/dist/internal.d.ts +5 -0
  23. package/dist/jsonschema.js +18 -17
  24. package/dist/load.d.ts +20 -12
  25. package/dist/load.js +207 -181
  26. package/dist/mcp.d.ts +5 -7
  27. package/dist/mcp.js +168 -214
  28. package/dist/messages/access.d.ts +90 -0
  29. package/dist/messages/access.js +113 -0
  30. package/dist/messages/api.d.ts +171 -0
  31. package/dist/messages/api.js +215 -0
  32. package/dist/messages/boot.d.ts +201 -0
  33. package/dist/messages/boot.js +256 -0
  34. package/dist/messages/codes.d.ts +172 -0
  35. package/dist/messages/codes.js +173 -0
  36. package/dist/messages/index.d.ts +95 -0
  37. package/dist/messages/index.js +118 -0
  38. package/dist/messages/mcp.d.ts +306 -0
  39. package/dist/messages/mcp.js +387 -0
  40. package/dist/messages/model.d.ts +233 -0
  41. package/dist/messages/model.js +283 -0
  42. package/dist/messages/query.d.ts +131 -0
  43. package/dist/messages/query.js +158 -0
  44. package/dist/messages/sql-access.d.ts +70 -0
  45. package/dist/messages/sql-access.js +87 -0
  46. package/dist/messages/sql-core.d.ts +150 -0
  47. package/dist/messages/sql-core.js +181 -0
  48. package/dist/messages/sql-time.d.ts +71 -0
  49. package/dist/messages/sql-time.js +88 -0
  50. package/dist/messages/sql-write.d.ts +246 -0
  51. package/dist/messages/sql-write.js +270 -0
  52. package/dist/messages/time.d.ts +23 -0
  53. package/dist/messages/time.js +29 -0
  54. package/dist/messages/types.d.ts +27 -0
  55. package/dist/messages/types.js +5 -0
  56. package/dist/messages/write.d.ts +405 -0
  57. package/dist/messages/write.js +509 -0
  58. package/dist/model.d.ts +73 -60
  59. package/dist/model.js +20 -29
  60. package/dist/ops.d.ts +35 -0
  61. package/dist/ops.js +101 -0
  62. package/dist/pglite.d.ts +0 -9
  63. package/dist/pglite.js +3 -1
  64. package/dist/registry.d.ts +3 -19
  65. package/dist/registry.js +3 -16
  66. package/dist/signals.d.ts +1 -5
  67. package/dist/signals.js +1 -5
  68. package/dist/sql.d.ts +7 -1
  69. package/dist/sql.js +101 -85
  70. package/dist/sync.d.ts +9 -3
  71. package/dist/sync.js +40 -17
  72. package/dist/tx.d.ts +11 -1
  73. package/dist/tx.js +32 -11
  74. package/dist/typed.d.ts +240 -45
  75. package/dist/types.d.ts +15 -90
  76. package/dist/types.js +12 -14
  77. package/dist/up.d.ts +53 -32
  78. package/dist/up.js +180 -74
  79. package/dist/uuid.js +5 -5
  80. package/dist/validate.d.ts +13 -1
  81. package/dist/validate.js +61 -29
  82. package/dist/watch.d.ts +5 -16
  83. package/dist/watch.js +23 -77
  84. package/dist/write.d.ts +9 -16
  85. package/dist/write.js +102 -185
  86. package/llms.txt +22 -25
  87. package/package.json +2 -9
  88. package/sql/10-core.sql +7 -50
  89. package/sql/15-errors.sql +64 -10
  90. package/sql/20-context.sql +7 -6
  91. package/sql/30-validate.sql +35 -27
  92. package/sql/40-class.sql +20 -114
  93. package/sql/50-acl.sql +8 -13
  94. package/sql/60-write.sql +220 -676
  95. package/sql/70-read.sql +11 -13
  96. package/sql/80-auth.sql +23 -27
  97. package/sql/90-time.sql +77 -944
  98. package/sql/95-seed.system.sql +7 -14
  99. package/sql/99-revision.sql +2 -1
  100. package/sql/README.md +32 -24
  101. package/sql/migrate/README.md +14 -0
  102. package/AGENT-CHEATSHEET.en.md +0 -488
  103. package/MIGRATION.md +0 -190
  104. package/README.en.md +0 -2910
  105. package/dist/import.d.ts +0 -185
  106. package/dist/import.js +0 -820
  107. package/dist/project.d.ts +0 -359
  108. package/dist/project.js +0 -1258
  109. package/sql/75-history.sql +0 -436
  110. package/sql/88-derived.sql +0 -1236
  111. package/sql/migrate/0006-changed-paths.sql +0 -3
  112. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/up.js CHANGED
@@ -6,9 +6,9 @@
6
6
  * неё только на время явного `set role`. Повторный накат ничего не меняет.
7
7
  *
8
8
  * up() — оркестратор без Docker: ждёт базу (удалённую — до таймаута), создаёт её, проверяет версию
9
- * PostgreSQL (18+) и кодировку UTF8, ставит схему (fresh — заново), обновляет отставшую (upgrade:
9
+ * PostgreSQL (18+) и кодировку UTF8, ставит схему (fresh — заново), обновляет отставшую ревизию 2.x (upgrade:
10
10
  * миграции lib/sql/migrate/ и повторный накат), кладёт сиды, выдаёт или регистрирует сервисный ключ и
11
- * выполняет ANALYZE только для таблиц без статистики.
11
+ * выполняет ANALYZE только для таблиц без статистики. Схему letopis 1.x не трогает: 2.0 ставится только заново.
12
12
  */
13
13
  import { readFile, readdir } from 'node:fs/promises';
14
14
  import { createRequire } from 'node:module';
@@ -19,11 +19,27 @@ import postgres from 'postgres';
19
19
  import { DDL_REVISION, RESERVED_CLASS_NAMES } from './types.js';
20
20
  import { startPglite } from './pglite.js';
21
21
  import { hashPassword } from './auth.js';
22
- import { LetopisError } from './errors.js';
22
+ import { checkOptions, lerr, lmsg } from './errors.js';
23
+ import { sqlCatalog } from './messages/index.js';
23
24
  import { v5Id } from './uuid.js';
24
25
  import { SIGNAL_MODES } from './signals.js';
25
26
  export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
26
27
  export const SCHEMA_VERSION = 'v2';
28
+ /**
29
+ * Первая ревизия движка 2.0 (план 2.0, редакция 2): схема ниже неё — letopis 1.x или схема без штампа ревизии. Её
30
+ * `up()` не обновляет, а `connect()` к ней не подключается: 2.0 ставится только заново (`up({ fresh: true })`), данные
31
+ * 1.x не переносятся. Накат миграций `up({ upgrade })` — в пределах 2.x, от этой ревизии до `DDL_REVISION`.
32
+ */
33
+ export const FIRST_REVISION = 8;
34
+ /**
35
+ * Отказ на схеме letopis 1.x (план 2.0, 7.10 №5): ревизия из комментария схемы ниже `FIRST_REVISION` — 7 и ниже у 1.x,
36
+ * 0 — штампа ревизии нет. `up()` зовёт её до любых изменений (до общей части `install()`), `connect()` — до входа по
37
+ * ключу: функции схемы 1.x не вызываются.
38
+ */
39
+ export function refuse1x(schema, rev) {
40
+ if (rev < FIRST_REVISION)
41
+ throw lerr('boot.schema_1x', { schema, rev, first: FIRST_REVISION });
42
+ }
27
43
  /**
28
44
  * Advisory-ключ наката одной схемы: пара int4, как у общей части установки (`hashtext('install')`), — имя схемы
29
45
  * всегда с точкой (`v2.имя`), поэтому со словом install ключи не совпадут.
@@ -44,8 +60,9 @@ export async function engineFiles(upTo = 99) {
44
60
  }
45
61
  let stamp;
46
62
  /**
47
- * Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
48
- * сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
63
+ * Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF) и по каталогу
64
+ * сообщений базы (разделы sql-*, подстановка @MESSAGES@): одна и та же сборка даёт один штамп при любых опциях
65
+ * установки, а версия пакета без правок SQL и этих разделов штамп не меняет.
49
66
  */
50
67
  export async function engineStamp() {
51
68
  if (stamp)
@@ -53,6 +70,7 @@ export async function engineStamp() {
53
70
  const h = createHash('sha256');
54
71
  for (const f of await engineFiles())
55
72
  h.update(`${f}\n${(await readFile(join(SQL_DIR, f), 'utf8')).replace(/\r\n/g, '\n')}\n`);
73
+ h.update(`@MESSAGES@\n${JSON.stringify(sqlCatalog())}\n`);
56
74
  const version = createRequire(import.meta.url)('../package.json').version;
57
75
  return (stamp = { version, sql: h.digest('hex').slice(0, 16) });
58
76
  }
@@ -95,16 +113,16 @@ export async function stampDrift(schema, comment) {
95
113
  if (!code || /\bsql=([0-9a-f]+)/.exec(comment)?.[1] === code.sql)
96
114
  return null;
97
115
  const was = /\bengine=(\S+)/.exec(comment)?.[1];
98
- if (was && compareVersions(was, code.version) > 0) {
99
- return `letopis: схему ${schema} обновила letopis ${was}, библиотека старее (${code.version}) — обновите пакет: up() этой версией вернул бы прежние функции`;
100
- }
101
- const by = !was ? 'версия до 1.4.0' : was === code.version ? `другая сборка letopis ${was}` : `letopis ${was}`;
102
- return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: обновите схему — вызовите up({ schema: '${schema}' })`;
116
+ // схему обновила версия новее пакета — или в штампе нет версии (его правили руками: штамп ревизии 2.x up() пишет
117
+ // всегда с версией), другая сборка той же версии, другая версия
118
+ const key = was && compareVersions(was, code.version) > 0 ? 'boot.stamp_newer'
119
+ : !was ? 'boot.stamp_unknown' : was === code.version ? 'boot.stamp_other_build' : 'boot.stamp_other_version';
120
+ return 'letopis: ' + lmsg(key, { schema, version: code.version, ...(was ? { was } : {}) });
103
121
  }
104
122
  export function substitute(text, vars) {
105
123
  return text.replace(/@([A-Z_]+)@/g, (m, k) => {
106
124
  if (!(k in vars))
107
- throw new Error(`letopis.up: неизвестный маркер ${m} в SQL`);
125
+ throw lerr('boot.unknown_marker', { marker: m });
108
126
  return vars[k];
109
127
  });
110
128
  }
@@ -112,16 +130,16 @@ export async function install(sql, opts) {
112
130
  const owner = opts.owner ?? 'letopis_owner';
113
131
  const app = opts.app ?? 'letopis_app';
114
132
  if (!SCHEMA_NAME.test(opts.schema))
115
- throw new Error(`letopis.up: bad schema name "${opts.schema}" (нужно v<N>.<имя>)`);
133
+ throw lerr('boot.install_schema_name', { schema: opts.schema });
116
134
  for (const r of [owner, app])
117
135
  if (!ROLE_NAME.test(r))
118
- throw new Error(`letopis.up: bad role name "${r}"`);
136
+ throw lerr('boot.role_name', { role: r });
119
137
  if (opts.signals !== undefined && !SIGNAL_MODES.includes(opts.signals)) {
120
- throw new Error(`letopis.up: signals — ${SIGNAL_MODES.join(', ')}, а не ${String(opts.signals)}`);
138
+ throw lerr('boot.signals_mode', { modes: SIGNAL_MODES.join(', '), signals: String(opts.signals) });
121
139
  }
122
140
  const [{ v }] = await sql `select current_setting('server_version_num') as v`;
123
141
  if (Number(v) < 180000)
124
- throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
142
+ throw new Error(`letopis.up: ${lmsg('boot.install_pg_version', { version: v })}`);
125
143
  // Общая часть установки — роли, членство установщика, pgcrypto и право владельца на схему pgcrypto — одна на все
126
144
  // установки базы, а роли — на весь кластер. Параллельные up() разных схем меняли её одновременно: grant на схему
127
145
  // pgcrypto переписывает её строку pg_namespace, даже если право уже есть, и второй такой grant падал «tuple
@@ -150,7 +168,7 @@ export async function install(sql, opts) {
150
168
  // роль владельца создал кто-то другой, а у установщика нет на неё ADMIN — понятная ошибка вместо сырой 42501
151
169
  // (план 2.0, 7.5 №3)
152
170
  if (e.code === '42501') {
153
- throw Object.assign(new Error(`letopis.up: роль ${owner} уже есть, а у установщика нет на неё ADMIN OPTION — установку ведёт роль, создавшая ${owner}, или роль с этим правом (grant ${owner} to <установщик> with admin option)`), { cause: e });
171
+ throw lerr('boot.owner_no_admin', { owner }, { cause: e });
154
172
  }
155
173
  if (e.code !== '0LP01' && e.code !== '23505')
156
174
  throw e; // уже выдано циклом — нет; 23505 — выдал up() другой базы
@@ -170,10 +188,11 @@ export async function install(sql, opts) {
170
188
  APP_NAME: app,
171
189
  PGCRYPTO: quoteIdent(nsp),
172
190
  SYSTEM: SYSTEM_ID,
173
- ALLOW_RESET: opts.allowReset ? 'true' : 'false',
174
- HISTORY_DEFAULT: quoteLiteral(JSON.stringify(opts.history ?? null)),
175
191
  // методы цепочки — в тексте функции reserved_names() (план, §2.14)
176
192
  RESERVED_NAMES: RESERVED_CLASS_NAMES.map(quoteLiteral).join(', '),
193
+ // каталог сообщений базы — разделы sql-* (план 2.0, этап 3, п. 3): ключ → имя ошибки и русский шаблон, JSON-литерал
194
+ // функции message_catalog() (15-errors.sql); обновляет его каждый up(), как прочие функции
195
+ MESSAGES: quoteLiteral(JSON.stringify(sqlCatalog())),
177
196
  // режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
178
197
  SIGNALS: 'class',
179
198
  };
@@ -194,8 +213,19 @@ export async function install(sql, opts) {
194
213
  // считаются миграции, — под блокировкой: up() видит ревизию до неё, и второй upgrade повторил бы миграции, уже
195
214
  // накатанные первым. Разные схемы друг друга не ждут: ключ — имя схемы.
196
215
  await lockSchema(tx, opts.schema);
197
- if (opts.fresh)
216
+ if (opts.fresh) {
217
+ // схему удаляет её владелец или суперпользователь. Установщик без суперпользователя состоит в роли владельца без
218
+ // наследования прав (inherit false, set true): сам он получил бы «must be owner of schema» (42501), а от роли
219
+ // владельца — удаляет (ревью: fresh от такого установщика падал)
220
+ const [own] = await tx.unsafe(`select r.rolname as role, not pg_has_role(current_user, n.nspowner, 'USAGE')
221
+ and pg_has_role(current_user, n.nspowner, 'SET') as via
222
+ from pg_namespace n join pg_roles r on r.oid = n.nspowner where n.nspname = $1`, [opts.schema]);
223
+ if (own?.via)
224
+ await tx.unsafe(`set local role ${quoteIdent(own.role)}`);
198
225
  await tx.unsafe(`drop schema if exists ${quoteIdent(opts.schema)} cascade`);
226
+ if (own?.via)
227
+ await tx.unsafe('set local role none');
228
+ }
199
229
  found = await schemaRevision(tx, opts.schema);
200
230
  if (opts.migrateFrom !== undefined && found !== null) {
201
231
  migrations = await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, Math.max(opts.migrateFrom, found));
@@ -239,7 +269,7 @@ export async function install(sql, opts) {
239
269
  // имя сида пакета или путь к своему файлу
240
270
  const own = seed.endsWith('.sql') || /[\\/]/.test(seed);
241
271
  if (!own && !/^[a-z0-9-]+$/.test(seed))
242
- throw new Error(`letopis.up: bad seed name "${seed}"`);
272
+ throw lerr('boot.seed_name', { seed });
243
273
  const f = own ? resolve(seed) : join(SQL_DIR, `seed.${seed}.sql`);
244
274
  try {
245
275
  await tx.unsafe(substitute(await readFile(f, 'utf8'), vars));
@@ -250,19 +280,12 @@ export async function install(sql, opts) {
250
280
  throw err;
251
281
  }
252
282
  }
253
- if (opts.history && files.some((f) => f.startsWith('40-'))) {
254
- // умолчание проверяется той же метасхемой, что политика класса (сроки по возрастанию, all ≥ суток)
255
- await tx.unsafe(`select ${quoteIdent(opts.schema)}.class_prepare($1::jsonb, $2::uuid)`, [{ name: 'HistoryDefault', kind: 'hub', history: opts.history }, SYSTEM_ID]).catch((e) => {
256
- e.message = `letopis.up: history: ${e.message}`;
257
- throw e;
258
- });
259
- }
260
283
  if (files.some((f) => f.startsWith('95-'))) {
261
284
  const S = quoteIdent(opts.schema);
262
285
  if (opts.serviceKey) {
263
286
  // свой ключ: регистрируется, если его ещё нет (ключ хранится как sha256)
264
287
  if (!/^lts_[0-9a-f]{32,128}$/.test(opts.serviceKey))
265
- throw new Error('letopis.up: serviceKey — lts_ и не меньше 32 шестнадцатеричных знаков');
288
+ throw lerr('boot.service_key_format');
266
289
  const hash = createHash('sha256').update(opts.serviceKey, 'utf8').digest();
267
290
  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]);
268
291
  if (n === 0) {
@@ -294,6 +317,12 @@ export async function migrationFiles(dir, from) {
294
317
  .filter((f) => Number(f.slice(0, 4)) > from && Number(f.slice(0, 4)) <= DDL_REVISION)
295
318
  .sort();
296
319
  }
320
+ /** Все опции up(): неизвестная — invalid_data с похожими именами до подключения (checkOptions; план 2.0, 7.10 №4). */
321
+ const UP_OPTIONS = {
322
+ dsn: true, sql: true, schema: true, fresh: true, upgrade: true, quiet: true, waitTimeoutMs: true, pglite: true, tenant: true,
323
+ login: true, owner: true, app: true, seeds: true, migrationsDir: true, serviceKey: true,
324
+ signals: true,
325
+ };
297
326
  /** Проба сервера: ok | нет сервера | нет базы | запускается | роковая ошибка (неверный пароль). */
298
327
  export async function pgPing(dsn) {
299
328
  const sql = postgres(dsn, { max: 1, connect_timeout: 3, onnotice: () => { } });
@@ -330,7 +359,7 @@ export async function ensureDatabase(dsn, log = () => { }) {
330
359
  if (e.code !== '42P04')
331
360
  throw e;
332
361
  });
333
- log(`база ${name} создана`);
362
+ log(lmsg('boot.log_database_created', { name }));
334
363
  return true;
335
364
  }
336
365
  finally {
@@ -342,19 +371,18 @@ export async function ensureDatabase(dsn, log = () => { }) {
342
371
  * иначе отказ. Возвращает предупреждения: с чем установка работает, но рискует.
343
372
  */
344
373
  export function checkServer(versionNum, encoding) {
374
+ // обычный Error: подходящего кода нет
345
375
  if (versionNum < 180000)
346
- throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${Math.floor(versionNum / 10000)} (${versionNum})`);
376
+ throw new Error(`letopis.up: ${lmsg('boot.pg_version', { major: Math.floor(versionNum / 10000), num: versionNum })}`);
347
377
  if (encoding.toUpperCase() !== 'UTF8')
348
- throw new Error(`letopis.up: база в кодировке ${encoding}, нужна UTF8 (casefold и коллация pg_unicode_fast)`);
378
+ throw new Error(`letopis.up: ${lmsg('boot.encoding', { encoding })}`);
349
379
  const warnings = [];
350
380
  // план 1.1, этап 2: исправлено в 18.2
351
- if (versionNum < 180002) {
352
- warnings.push(`PostgreSQL ${Math.floor(versionNum / 10000)}.${versionNum % 10000}: до 18.2 сервер мог усечь CLOG, который ещё нужен `
353
- + 'непрочитанным сигналам NOTIFY; обновите сервер до 18.2 или новее');
354
- }
381
+ if (versionNum < 180002)
382
+ warnings.push(lmsg('boot.pg_clog', { version: `${Math.floor(versionNum / 10000)}.${versionNum % 10000}` }));
355
383
  return warnings;
356
384
  }
357
- /** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
385
+ /** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null, штампа ревизии нет — 0. */
358
386
  export async function schemaRevision(sql, schema) {
359
387
  const [r] = await sql.unsafe(`select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = $1`, [schema]);
360
388
  if (!r)
@@ -364,16 +392,20 @@ export async function schemaRevision(sql, schema) {
364
392
  /**
365
393
  * Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
366
394
  * повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
367
- * Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
395
+ * Схему отставшей ревизии 2.x `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает. Схему без
396
+ * штампа ревизии не трогает и отказывает (`invalid_data`): заново её ставит `up({ fresh: true })`.
368
397
  */
369
398
  export async function up(opts) {
399
+ checkOptions('up', opts, UP_OPTIONS);
370
400
  const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
371
401
  const schema = opts.schema.includes('.') ? opts.schema : `${SCHEMA_VERSION}.${opts.schema}`;
372
402
  if (!SCHEMA_NAME.test(schema))
373
- throw new Error(`letopis.up: bad schema name "${opts.schema}" (нужно <имя> или v<N>.<имя>)`);
374
- // опции арендатора — до подключения и наката: с неверными up() ничего не начинает
403
+ throw lerr('boot.up_schema_name', { schema: opts.schema });
404
+ // опции арендатора и роли приложения — до подключения и наката: с неверными up() ничего не начинает
375
405
  if (opts.tenant)
376
406
  checkTenant(opts.tenant);
407
+ if (opts.login !== undefined)
408
+ checkLogin(opts.login);
377
409
  let sql = opts.sql;
378
410
  const own = !sql;
379
411
  const lite = opts.pglite ? await startPglite(opts.pglite === true ? {} : opts.pglite) : undefined;
@@ -381,7 +413,7 @@ export async function up(opts) {
381
413
  sql = postgres(lite.dsn, { max: 1, onnotice: () => { } });
382
414
  if (!sql) {
383
415
  if (!opts.dsn)
384
- throw new Error('letopis.up: нужен dsn или sql');
416
+ throw lerr('boot.no_connection');
385
417
  const deadline = Date.now() + (opts.waitTimeoutMs ?? 60_000);
386
418
  for (;;) {
387
419
  const ping = await pgPing(opts.dsn);
@@ -393,8 +425,9 @@ export async function up(opts) {
393
425
  await ensureDatabase(opts.dsn, log);
394
426
  continue;
395
427
  }
396
- if (Date.now() > deadline)
397
- throw new Error(`letopis.up: база ${new URL(opts.dsn).host} не готова за ${Math.round((opts.waitTimeoutMs ?? 60_000) / 1000)} с`);
428
+ if (Date.now() > deadline) {
429
+ throw new Error(`letopis.up: ${lmsg('boot.wait_timeout', { host: new URL(opts.dsn).host, seconds: Math.round((opts.waitTimeoutMs ?? 60_000) / 1000) })}`);
430
+ }
398
431
  await new Promise((r) => setTimeout(r, 500));
399
432
  }
400
433
  sql = postgres(opts.dsn, { max: 1, onnotice: () => { } });
@@ -409,28 +442,66 @@ export async function up(opts) {
409
442
  // ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
410
443
  const before = opts.fresh ? null : await schemaRevision(sql, schema);
411
444
  if (before !== null && before > DDL_REVISION) {
412
- throw new Error(`letopis.up: схема ${schema} ревизии ${before}, а библиотека знает только ${DDL_REVISION} — обновите пакет`);
445
+ throw new Error(`letopis.up: ${lmsg('boot.schema_newer', { schema, rev: before, known: DDL_REVISION })}`);
413
446
  }
447
+ // схема letopis 1.x — отказ до любых изменений, с upgrade тоже: 2.0 ставится только заново (план 2.0, 7.10 №5)
448
+ if (before !== null)
449
+ refuse1x(schema, before);
450
+ // отставшая ревизия 2.x — только с upgrade (миграции lib/sql/migrate/)
414
451
  if (before !== null && before < DDL_REVISION && !opts.upgrade) {
415
- throw new Error(`letopis.up: схема ${schema} ревизии ${before}, библиотека — ${DDL_REVISION}: обновите её up({ schema: '${schema}', upgrade: true })`);
452
+ throw new Error(`letopis.up: ${lmsg('boot.schema_older', { schema, rev: before, want: DDL_REVISION })}`);
416
453
  }
417
- const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
454
+ const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, login: _l, ...rest } = opts;
418
455
  const { found, ...res } = await install(sql, {
419
456
  ...rest, schema, ...(opts.fresh ? { fresh: true } : {}), ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}),
420
457
  });
421
458
  if (opts.fresh)
422
- log(`схема ${schema} удалена и поставлена заново (fresh)`);
423
- // ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть
424
- const fresh = await sql `select relname from pg_stat_user_tables
425
- where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`;
426
- const analyzed = fresh.map((r) => r.relname);
427
- for (const t of analyzed)
428
- await sql.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
459
+ log(lmsg('boot.log_fresh', { schema }));
460
+ // ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть. От роли владельца, как у
461
+ // загрузчика: таблицы принадлежат ей, а установщик без суперпользователя наследования её прав не имеет — его ANALYZE
462
+ // PostgreSQL пропускал с WARNING (ревью). В analyzed — только таблицы, где статистика появилась; оставшиеся без
463
+ // неё — предупреждение
464
+ const missing = async () => (await sql `select relname from pg_stat_user_tables
465
+ where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`).map((r) => r.relname);
466
+ const todo = await missing();
467
+ if (todo.length) {
468
+ await sql.begin(async (tx) => {
469
+ await tx.unsafe(`set local role ${quoteIdent(res.owner)}`);
470
+ for (const t of todo)
471
+ await tx.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
472
+ });
473
+ }
474
+ const left = todo.length ? await missing() : [];
475
+ const analyzed = todo.filter((t) => !left.includes(t));
476
+ if (left.length) {
477
+ const w = lmsg('boot.analyze_skipped', { owner: res.owner, tables: left.map((t) => `${schema}.${t}`).join(', ') });
478
+ warnings.push(w);
479
+ if (!opts.quiet)
480
+ console.warn(`letopis.up: ${w}`);
481
+ }
482
+ // хабы с зарезервированными именами, заведённые мимо проверки базы (план 2.0, этап 4, п. 2; 7.10 №3): накат их
483
+ // оставляет и называет, новых таких база не пишет. Строки Class — от роли владельца, как ANALYZE: у установщика без
484
+ // суперпользователя прав на таблицы нет, а RLS владельца таблицы не касается
485
+ const reserved = await sql.begin(async (tx) => {
486
+ await tx.unsafe(`set local role ${quoteIdent(res.owner)}`);
487
+ return tx.unsafe(`select x.data->>'name' as name, x.tenant::text as tenant from ${quoteIdent(schema)}.entity x
488
+ where x.class = 'Class' and x.data->>'kind' = 'hub' and x.data->>'name' = any ($1::text[])
489
+ order by x.data->>'name', x.tenant`, [[...RESERVED_CLASS_NAMES]]);
490
+ });
491
+ if (reserved.length) {
492
+ const w = lmsg('boot.reserved_hubs', { hubs: reserved.map((h) => `${h.name} (${h.tenant})`).join(', ') });
493
+ warnings.push(w);
494
+ if (!opts.quiet)
495
+ console.warn(`letopis.up: ${w}`);
496
+ }
429
497
  const revision = (await schemaRevision(sql, schema)) ?? 0;
430
- log(`${found === null ? 'схема поставлена' : found < DDL_REVISION ? `схема обновлена с ревизии ${found}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
498
+ log(lmsg(found === null ? 'boot.log_installed' : found < DDL_REVISION ? 'boot.log_upgraded' : 'boot.log_reapplied', { schema, revision, from: found }));
431
499
  const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
500
+ const login = opts.login ? await ensureLogin(sql, opts.login, res.owner, res.app, opts.dsn ?? lite?.dsn) : undefined;
501
+ if (login)
502
+ log(lmsg(login.created ? 'boot.log_login_created' : 'boot.log_login_updated', { user: login.user }));
432
503
  return { ...res, created: found === null, upgraded: found !== null && found < DDL_REVISION, revision, analyzed, warnings,
433
- ...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
504
+ ...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}), ...(login ? { login } : {}) };
434
505
  }
435
506
  catch (e) {
436
507
  if (lite)
@@ -444,8 +515,49 @@ export async function up(opts) {
444
515
  await sql.end({ timeout: 5 }).catch(() => { });
445
516
  }
446
517
  }
518
+ /** Проверка up({ login }) до подключения: имя роли и пароль. */
519
+ function checkLogin(login) {
520
+ if (!login || typeof login !== 'object')
521
+ throw lerr('boot.login_not_object');
522
+ const { user, password } = login;
523
+ if (typeof user !== 'string' || !user.trim() || Buffer.byteLength(user, 'utf8') > 63 || user.startsWith('pg_') || user.includes('\0')) {
524
+ throw lerr('boot.login_user', { user: JSON.stringify(user) });
525
+ }
526
+ if (typeof password !== 'string' || !password || password.includes('\0'))
527
+ throw lerr('boot.login_password');
528
+ }
529
+ /**
530
+ * Роль приложения up({ login }) (план 2.0, 7.9 №4): заводится или получает новый пароль, затем членство в роли
531
+ * приложения. Роли — объекты кластера: под той же блокировкой, что общая часть install(). Текст create/alter/grant
532
+ * собирает база (`format` с `%I` и `%L`), поэтому кавычки в имени и пароле не ломают SQL.
533
+ */
534
+ async function ensureLogin(sql, login, owner, app, dsn) {
535
+ const created = await sql.begin(async (tx) => {
536
+ await tx `select pg_advisory_xact_lock(hashtext('letopis'), hashtext('install'))`;
537
+ const [r] = await tx `select r.rolsuper or r.rolcreaterole or r.rolcreatedb or r.rolreplication or r.rolbypassrls
538
+ or pg_has_role(r.oid, ${owner}, 'USAGE') or pg_has_role(r.oid, ${owner}, 'SET') as privileged
539
+ from pg_roles r where r.rolname = ${login.user}`;
540
+ if (r?.privileged || login.user === owner || login.user === app) {
541
+ throw lerr('boot.login_privileged', { user: login.user });
542
+ }
543
+ const verb = r ? 'alter role %I login password %L' : 'create role %I login password %L';
544
+ const [{ q }] = await tx `select format(${verb}::text, ${login.user}::text, ${login.password}::text) as q`;
545
+ await tx.unsafe(q);
546
+ const [{ g }] = await tx `select format('grant %I to %I', ${app}::text, ${login.user}::text) as g`;
547
+ await tx.unsafe(g);
548
+ return !r;
549
+ });
550
+ let url;
551
+ if (dsn) {
552
+ const u = new URL(dsn);
553
+ u.username = login.user;
554
+ u.password = login.password;
555
+ url = u.toString();
556
+ }
557
+ return { user: login.user, created, ...(url ? { dsn: url } : {}) };
558
+ }
447
559
  /** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
448
- const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'PROJECTOR', 'LOCK'];
560
+ const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'LOCK'];
449
561
  /**
450
562
  * Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
451
563
  * функций кредов (auth_kind_ok). Членство
@@ -456,28 +568,26 @@ const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', '
456
568
  */
457
569
  function checkTenant(opts) {
458
570
  if (!opts.name)
459
- throw new Error('letopis.createTenant: имя арендатора обязательно');
571
+ throw lerr('boot.tenant_name_required');
460
572
  // вид пароля — по правилу функций кредов (auth_kind_ok в 80-auth.sql): пароль пишет функция владельца мимо них, а с
461
573
  // видом в другом регистре или служебным (SESSION, TOTP…) по нему не войти, и служебные строки он подменял бы
462
574
  const kind = opts.user?.kind;
463
575
  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)`);
576
+ throw lerr('boot.tenant_kind', { kind: JSON.stringify(kind), kinds: SERVICE_KINDS.join(', ') });
465
577
  }
466
578
  if (opts.user?.roles === undefined)
467
579
  return ['owner'];
468
580
  const roles = opts.user.roles;
469
- const bad = (why) => new LetopisError('invalid_data', `createTenant: user.roles — ${why}`);
470
581
  if (!Array.isArray(roles) || roles.length === 0)
471
- throw bad("непустой список ролей, например ['staff']");
582
+ throw lerr('boot.tenant_roles_empty');
472
583
  for (const r of roles) {
473
584
  if (typeof r !== 'string')
474
- throw bad(`роль — строка, а не ${r === null ? 'null' : typeof r}`);
475
- if (!r || r !== r.trim() || /[,{}]/.test(r) || r.startsWith('!')) {
476
- throw bad(`роль ${JSON.stringify(r)}: нужно имя роли, а не маска группы — не пустое, без пробелов по краям, запятых, фигурных скобок и «!» в начале`);
477
- }
585
+ throw lerr('boot.tenant_role_type', { type: r === null ? 'null' : typeof r });
586
+ if (!r || r !== r.trim() || /[,{}]/.test(r) || r.startsWith('!'))
587
+ throw lerr('boot.tenant_role_mask', { role: JSON.stringify(r) });
478
588
  }
479
589
  if (new Set(roles).size !== roles.length)
480
- throw bad('роли повторяются');
590
+ throw lerr('boot.tenant_roles_repeat');
481
591
  return roles;
482
592
  }
483
593
  /**
@@ -498,17 +608,15 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
498
608
  // раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
499
609
  await lockSchema(tx, schema);
500
610
  await tx.unsafe(`set local role ${quoteIdent(owner)}`);
501
- /** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
502
- const exists = async (id, cls, gone) => {
611
+ /** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ по ключу gone (что и как вернуть). */
612
+ const exists = async (id, cls, gone, params) => {
503
613
  const [r] = await tx.unsafe(`select exists (select 1 from ${S}.entity where id = $1::uuid) as live,
504
614
  exists (select 1 from ${S}.log where id = $1::uuid) as was`, [id]);
505
615
  if (!r.live && r.was)
506
- throw new LetopisError('invalid_data', `createTenant: ${gone}`, { id, class: cls });
616
+ throw lerr(gone, { ...params, id }, { detail: { id, class: cls } });
507
617
  return r.live;
508
618
  };
509
- const why = 'а его история осталась в журнале — повтор удалённое не воскрешает';
510
- const created = !(await exists(tenant, 'Account', `арендатор «${opts.name}» (${tenant}) удалён, ${why}: `
511
- + 'верните его db.Account(id).restore() от имени системного администратора или выберите другое имя'));
619
+ const created = !(await exists(tenant, 'Account', 'boot.tenant_deleted', { name: opts.name }));
512
620
  if (created) {
513
621
  await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [tenant, SYSTEM_ID, { name: opts.name, categories: [], enabled: true }]);
514
622
  if (opts.grantAll !== false)
@@ -516,14 +624,12 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
516
624
  }
517
625
  let token = null;
518
626
  if (u && user) {
519
- if (!(await exists(user, 'Account', `аккаунт пользователя ${u.login} (${user}) удалён, ${why}: `
520
- + 'верните его db.Account(id).restore() от имени системного администратора или выберите другой логин'))) {
627
+ if (!(await exists(user, 'Account', 'boot.tenant_user_deleted', { login: u.login }))) {
521
628
  await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [user, SYSTEM_ID, { name: u.name, categories: ['User'], enabled: true }]);
522
629
  }
523
630
  const member = (await tx.unsafe(`select ${S}.v5_id($1::uuid, 'member', array[$2::text, $1::text]) as id`, [tenant, user]))[0].id;
524
631
  // существующее членство не трогаем: повтор с другими ролями их не меняет (их правят правкой member)
525
- if (!(await exists(member, 'member', `членство ${u.login} в арендаторе «${opts.name}» (${member}) удалено, ${why}: `
526
- + 'верните его db.member(id).restore() в этом арендаторе или выберите другой логин'))) {
632
+ if (!(await exists(member, 'member', 'boot.tenant_member_deleted', { login: u.login, name: opts.name }))) {
527
633
  await tx.unsafe(`select ${S}.sys_create($1::uuid, 'member', $2::uuid, $3::jsonb, $4::jsonb)`, [member, tenant, { User: user, Tenant: tenant }, { roles }]);
528
634
  }
529
635
  const kind = u.kind ?? 'PASSWORD';
package/dist/uuid.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * разделитель — запятая с пробелом. Совпадение закрепляет общий файл векторов lib/test/vectors/v5.json.
7
7
  */
8
8
  import { hash } from 'node:crypto';
9
- import { LetopisError } from './errors.js';
9
+ import { lerr } from './errors.js';
10
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
11
  // байты пространств имён (арендаторов): в загрузке их единицы, а проверка и разбор шли на каждый id
12
12
  const NS = new Map();
@@ -14,7 +14,7 @@ function nsBytes(ns) {
14
14
  let b = NS.get(ns);
15
15
  if (!b) {
16
16
  if (!UUID.test(ns))
17
- throw new LetopisError('invalid_data', `пространство имён v5 — не uuid: ${ns}`);
17
+ throw lerr('model.v5_namespace_not_uuid', { ns });
18
18
  b = Buffer.from(ns.replace(/-/g, ''), 'hex');
19
19
  if (NS.size >= 10_000)
20
20
  NS.clear();
@@ -44,17 +44,17 @@ export function v5Id(tenant, cls, values) {
44
44
  */
45
45
  export function idOf(cls, key, opts) {
46
46
  if (!cls.key.length) {
47
- throw new LetopisError('no_key', `у класса ${cls.name} нет ключа: id выдаёт база при создании`);
47
+ throw lerr('model.class_no_key', { cls: cls.name });
48
48
  }
49
49
  const ends = new Set(cls.ends ?? []);
50
50
  const values = cls.key.map((k) => {
51
51
  const v = key[k];
52
52
  if (typeof v !== 'string') {
53
- throw new LetopisError('invalid_data', `idOf(${cls.name}): часть ключа ${k} должна быть строкой`, { key: cls.key });
53
+ throw lerr('model.key_part_not_string', { cls: cls.name, part: k }, { detail: { key: cls.key } });
54
54
  }
55
55
  if (ends.has(k)) {
56
56
  if (!UUID.test(v))
57
- throw new LetopisError('invalid_data', `idOf(${cls.name}): конец ${k} — не uuid`);
57
+ throw lerr('model.key_end_not_uuid', { cls: cls.name, end: k });
58
58
  return v.toLowerCase();
59
59
  }
60
60
  return v;
@@ -4,6 +4,12 @@
4
4
  * слова нарушений. Им пользуются загрузчик (этап 4) и ранние ошибки библиотеки; совпадение с базой
5
5
  * закрепляет дифференциальный тест validator-parity.
6
6
  *
7
+ * Исключение — `pattern` (ревью): шаблон задаёт арендатор, и движок V8 с перебором на шаблоне вроде
8
+ * `^(a+)+$` повесил бы процесс приложения. Такие правила (`db`) проверяет только база (`like_regex`,
9
+ * движок без катастрофического перебора); здесь шаблон лишь разбирается — то, что база не примет, и
10
+ * шаблон, неверный для JavaScript, — `invalid_class`. `anyOf`/`oneOf`, в ветке которых есть `pattern`,
11
+ * без него не решить (у `oneOf` вышло бы ложное нарушение) — их тоже проверяет только база.
12
+ *
7
13
  * Как и в базе, правило — «нет узла по пути, нарушающего условие»: обычное правило даёт одно
8
14
  * нарушение, сколько бы узлов его ни нарушили; дополнительные проверки (длина строки, uniqueItems,
9
15
  * смысл даты, перечисления объектов, число ключей) — по нарушению на каждый узел.
@@ -18,10 +24,16 @@ export interface Validator {
18
24
  (data: unknown): Issue[];
19
25
  }
20
26
  /**
21
- * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
27
+ * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи, кроме
28
+ * `pattern` (и `anyOf`/`oneOf` с ним в ветке): шаблоны задаёт арендатор, их проверяет только база. Схема
22
29
  * класса — `db.registry.resolve(имя).schema`.
23
30
  */
24
31
  export declare function compileValidator(schema: JsonSchema): Validator;
32
+ /**
33
+ * Оставляет ли валидатор правила базе (`pattern` и `anyOf`/`oneOf` с ним): строки таких классов загрузчик, который
34
+ * пишет мимо триггеров, сверяет запросом к базе. Не входит в API пакета.
35
+ */
36
+ export declare function leavesToDb(v: Validator): boolean;
25
37
  /**
26
38
  * Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
27
39
  * записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не