letopis 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENT-CHEATSHEET.en.md +14 -6
  2. package/AGENT-CHEATSHEET.md +18 -10
  3. package/CHANGELOG.md +247 -0
  4. package/README.en.md +146 -58
  5. package/README.md +129 -51
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +42 -16
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/chain.d.ts +91 -15
  13. package/dist/chain.js +47 -4
  14. package/dist/cli.js +6 -0
  15. package/dist/errors.d.ts +2 -2
  16. package/dist/errors.js +5 -5
  17. package/dist/import.js +7 -0
  18. package/dist/index.d.ts +74 -45
  19. package/dist/index.js +18 -4
  20. package/dist/load.d.ts +10 -3
  21. package/dist/load.js +19 -4
  22. package/dist/mcp.d.ts +20 -11
  23. package/dist/mcp.js +4 -4
  24. package/dist/model.d.ts +4 -1
  25. package/dist/ops.d.ts +58 -18
  26. package/dist/ops.js +58 -18
  27. package/dist/project.d.ts +44 -12
  28. package/dist/project.js +90 -8
  29. package/dist/registry.d.ts +4 -1
  30. package/dist/registry.js +4 -1
  31. package/dist/sql.d.ts +4 -1
  32. package/dist/sql.js +12 -4
  33. package/dist/sync.js +11 -5
  34. package/dist/tx.js +10 -1
  35. package/dist/types.d.ts +14 -7
  36. package/dist/types.js +13 -6
  37. package/dist/up.d.ts +65 -25
  38. package/dist/up.js +152 -40
  39. package/dist/validate.d.ts +9 -2
  40. package/dist/validate.js +19 -3
  41. package/dist/watch.d.ts +17 -5
  42. package/dist/watch.js +40 -8
  43. package/dist/write.d.ts +3 -2
  44. package/dist/write.js +29 -10
  45. package/llms.txt +1 -1
  46. package/package.json +1 -1
  47. package/sql/10-core.sql +6 -1
  48. package/sql/50-acl.sql +5 -1
  49. package/sql/60-write.sql +77 -22
  50. package/sql/80-auth.sql +39 -2
  51. package/sql/88-derived.sql +82 -1
  52. package/sql/90-time.sql +75 -38
  53. package/sql/95-seed.system.sql +6 -3
  54. package/sql/99-revision.sql +3 -2
  55. package/sql/README.md +2 -0
  56. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/up.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * выполняет ANALYZE только для таблиц без статистики.
12
12
  */
13
13
  import { readFile, readdir } from 'node:fs/promises';
14
+ import { createRequire } from 'node:module';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { join, resolve } from 'node:path';
16
17
  import { createHash, randomBytes } from 'node:crypto';
@@ -23,6 +24,13 @@ import { v5Id } from './uuid.js';
23
24
  import { SIGNAL_MODES } from './signals.js';
24
25
  export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
25
26
  export const SCHEMA_VERSION = 'v2';
27
+ /**
28
+ * Advisory-ключ наката одной схемы: пара int4, как у общей части установки (`hashtext('install')`), — имя схемы
29
+ * всегда с точкой (`v2.имя`), поэтому со словом install ключи не совпадут.
30
+ */
31
+ async function lockSchema(tx, schema) {
32
+ await tx.unsafe(`select pg_advisory_xact_lock(hashtext('letopis'), hashtext($1))`, [schema]);
33
+ }
26
34
  const SQL_DIR = fileURLToPath(new URL('../sql/', import.meta.url));
27
35
  const MIGRATE_DIR = join(SQL_DIR, 'migrate');
28
36
  const ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -34,6 +42,65 @@ export async function engineFiles(upTo = 99) {
34
42
  const files = (await readdir(SQL_DIR)).filter((f) => /^\d{2}-[a-z0-9.-]+\.sql$/.test(f));
35
43
  return files.filter((f) => Number(f.slice(0, 2)) <= upTo).sort();
36
44
  }
45
+ let stamp;
46
+ /**
47
+ * Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
48
+ * сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
49
+ */
50
+ export async function engineStamp() {
51
+ if (stamp)
52
+ return stamp;
53
+ const h = createHash('sha256');
54
+ for (const f of await engineFiles())
55
+ h.update(`${f}\n${(await readFile(join(SQL_DIR, f), 'utf8')).replace(/\r\n/g, '\n')}\n`);
56
+ const version = createRequire(import.meta.url)('../package.json').version;
57
+ return (stamp = { version, sql: h.digest('hex').slice(0, 16) });
58
+ }
59
+ /** Сравнение версий semver (метаданные сборки не учитываются): меньше нуля, ноль, больше нуля. */
60
+ export function compareVersions(a, b) {
61
+ const parse = (v) => {
62
+ const [core, pre] = v.split('+')[0].split(/-(.*)/s);
63
+ return { core: core.split('.').map(Number), pre: pre ? pre.split('.') : [] };
64
+ };
65
+ const x = parse(a);
66
+ const y = parse(b);
67
+ for (let i = 0; i < 3; i++)
68
+ if ((x.core[i] ?? 0) !== (y.core[i] ?? 0))
69
+ return (x.core[i] ?? 0) - (y.core[i] ?? 0);
70
+ if (!x.pre.length || !y.pre.length)
71
+ return y.pre.length - x.pre.length; // выпуск старше своих предварительных версий
72
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
73
+ const p = x.pre[i];
74
+ const q = y.pre[i];
75
+ if (p === undefined || q === undefined)
76
+ return p === undefined ? -1 : 1;
77
+ if (p === q)
78
+ continue;
79
+ const pn = /^\d+$/.test(p);
80
+ const qn = /^\d+$/.test(q);
81
+ if (pn && qn)
82
+ return Number(p) - Number(q);
83
+ if (pn !== qn)
84
+ return pn ? -1 : 1;
85
+ return p < q ? -1 : 1;
86
+ }
87
+ return 0;
88
+ }
89
+ /**
90
+ * Штамп схемы (комментарий схемы) против файлов ядра пакета — текст предупреждения или null. Ревизия та же, а
91
+ * функции поставила другая версия движка: после обновления пакета не накатили up() (так было при 1.1 → 1.2).
92
+ */
93
+ export async function stampDrift(schema, comment) {
94
+ const code = await engineStamp().catch(() => null); // файлов ядра рядом со сборкой нет — сверять не с чем
95
+ if (!code || /\bsql=([0-9a-f]+)/.exec(comment)?.[1] === code.sql)
96
+ return null;
97
+ 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}' })`;
103
+ }
37
104
  export function substitute(text, vars) {
38
105
  return text.replace(/@([A-Z_]+)@/g, (m, k) => {
39
106
  if (!(k in vars))
@@ -55,23 +122,40 @@ export async function install(sql, opts) {
55
122
  const [{ v }] = await sql `select current_setting('server_version_num') as v`;
56
123
  if (Number(v) < 180000)
57
124
  throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
58
- // Роли — общие для кластера; создаются идемпотентно.
59
- for (const r of [owner, app]) {
60
- await sql.unsafe(`do $$ begin
61
- if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(r)}) then
62
- create role ${quoteIdent(r)} nologin;
63
- end if;
64
- exception when duplicate_object then null; end $$`);
65
- }
66
- // Установщик получает права владельца только на время set role (план, §2.9).
67
- await sql.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`).catch((e) => {
68
- if (e.code !== '0LP01')
69
- throw e; // уже выдано циклом — нет
125
+ // Общая часть установки — роли, членство установщика, pgcrypto и право владельца на схему pgcrypto — одна на все
126
+ // установки базы, а роли — на весь кластер. Параллельные up() разных схем меняли её одновременно: grant на схему
127
+ // pgcrypto переписывает её строку pg_namespace, даже если право уже есть, и второй такой grant падал «tuple
128
+ // concurrently updated» (XX000); на чистой базе второй create role или create extension упал бы на уникальном индексе
129
+ // каталога. Поэтому общая часть — отдельная транзакция под advisory-блокировкой: другой up() ждёт её фиксации и
130
+ // видит готовые объекты. Строка-замок, как у внутренних блокировок движка (60-write.sql), не годится: таблицы secret
131
+ // до установки нет, и у каждой схемы она своя — разные установки друг друга не увидели бы. Advisory-ключ может занять
132
+ // любая роль базы (ревью безопасности №2 и №3), но так посторонний задержит только up() — операцию администратора, а
133
+ // не запись; кто держит ключ, видно в pg_locks. Ключ — пара int4: это пространство ключей отдельно от lock() явной
134
+ // транзакции (int8). Блокировка живёт до конца транзакции, обрыв соединения её снимает.
135
+ // Advisory-блокировка действует в пределах базы, а роли общие для кластера: up() в другой базе может создать роль
136
+ // или членство одновременно с этим, тогда здесь unique_violation (23505) — роль или членство уже есть.
137
+ const nsp = await sql.begin(async (tx) => {
138
+ await tx.unsafe(`select pg_advisory_xact_lock(hashtext('letopis'), hashtext('install'))`);
139
+ for (const r of [owner, app]) {
140
+ await tx.unsafe(`do $$ begin
141
+ if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(r)}) then
142
+ create role ${quoteIdent(r)} nologin;
143
+ end if;
144
+ exception when duplicate_object or unique_violation then null; end $$`);
145
+ }
146
+ // Установщик получает права владельца только на время set role (план, §2.9). Отказ гранта откатывает только
147
+ // точку сохранения, а не всю транзакцию.
148
+ await tx.savepoint((sp) => sp.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`))
149
+ .catch((e) => {
150
+ if (e.code !== '0LP01' && e.code !== '23505')
151
+ throw e; // уже выдано циклом — нет; 23505 — выдал up() другой базы
152
+ });
153
+ await tx.unsafe(`create extension if not exists pgcrypto`);
154
+ const [{ nsp }] = await tx.unsafe(`
155
+ select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`);
156
+ await tx.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
157
+ return nsp;
70
158
  });
71
- await sql.unsafe(`create extension if not exists pgcrypto`);
72
- const [{ nsp }] = await sql `
73
- select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`;
74
- await sql.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
75
159
  const vars = {
76
160
  SCHEMA: quoteIdent(opts.schema),
77
161
  SCHEMA_NAME: opts.schema,
@@ -88,13 +172,31 @@ export async function install(sql, opts) {
88
172
  // режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
89
173
  SIGNALS: 'class',
90
174
  };
91
- // Схема создаётся до set role: у владельца может не быть права CREATE в базе.
92
- await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
175
+ // штамп движка — в комментарий схемы (99-revision.sql): по нему connect() узнаёт схему, не накатанную этой версией
176
+ const st = await engineStamp();
177
+ vars.ENGINE_VERSION = st.version;
178
+ vars.ENGINE_SQL = st.sql;
93
179
  const files = await engineFiles(opts.upTo);
94
- const migrations = opts.migrateFrom === undefined ? [] : await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, opts.migrateFrom);
180
+ let migrations = [];
181
+ let found = null;
95
182
  let serviceKey = null;
96
183
  let signals = 'class';
97
184
  await sql.begin(async (tx) => {
185
+ // Накат схемы — под advisory-блокировкой самой схемы. Несколько экземпляров сервиса зовут up() одной схемы при
186
+ // запуске разом: без блокировки второй create schema падал на уникальном индексе каталога (23505), а второй
187
+ // create or replace function или comment — «tuple concurrently updated» (XX000). С ней следующий up() ждёт, пока
188
+ // предыдущий зафиксирует накат, и делает повторный накат по готовой схеме. Поэтому и fresh, и ревизия, от которой
189
+ // считаются миграции, — под блокировкой: up() видит ревизию до неё, и второй upgrade повторил бы миграции, уже
190
+ // накатанные первым. Разные схемы друг друга не ждут: ключ — имя схемы.
191
+ await lockSchema(tx, opts.schema);
192
+ if (opts.fresh)
193
+ await tx.unsafe(`drop schema if exists ${quoteIdent(opts.schema)} cascade`);
194
+ found = await schemaRevision(tx, opts.schema);
195
+ if (opts.migrateFrom !== undefined && found !== null) {
196
+ migrations = await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, Math.max(opts.migrateFrom, found));
197
+ }
198
+ // Схема создаётся до set role: у владельца может не быть права CREATE в базе.
199
+ await tx.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
98
200
  await tx.unsafe(`set local role ${quoteIdent(owner)}`);
99
201
  // режим сигналов (план 1.1, §2.6): опция, иначе сохранённый в схеме (meta строки ключа), иначе class
100
202
  let saved = null;
@@ -172,7 +274,7 @@ export async function install(sql, opts) {
172
274
  }
173
275
  }
174
276
  });
175
- return { schema: opts.schema, owner, app, serviceKey, migrations, signals };
277
+ return { schema: opts.schema, owner, app, serviceKey, migrations, signals, found };
176
278
  }
177
279
  /** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
178
280
  export async function migrationFiles(dir, from) {
@@ -249,14 +351,15 @@ export function checkServer(versionNum, encoding) {
249
351
  }
250
352
  /** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
251
353
  export async function schemaRevision(sql, schema) {
252
- const [r] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${schema}`;
354
+ const [r] = await sql.unsafe(`select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = $1`, [schema]);
253
355
  if (!r)
254
356
  return null;
255
357
  return Number(/ddl_revision=(\d+)/.exec(String(r.c ?? ''))?.[1] ?? 0);
256
358
  }
257
359
  /**
258
- * Поставить или обновить установку letopis. Схема есть и её ревизия совпадает — повторный накат
259
- * (идемпотентный); ревизия отстала — только с upgrade: true; новее библиотеки — отказ.
360
+ * Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
361
+ * повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
362
+ * Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
260
363
  */
261
364
  export async function up(opts) {
262
365
  const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
@@ -298,11 +401,8 @@ export async function up(opts) {
298
401
  if (!opts.quiet)
299
402
  for (const w of warnings)
300
403
  console.warn(`letopis.up: ${w}`);
301
- if (opts.fresh) {
302
- await sql.unsafe(`drop schema if exists ${quoteIdent(schema)} cascade`);
303
- log(`схема ${schema} удалена (fresh)`);
304
- }
305
- const before = await schemaRevision(sql, schema);
404
+ // ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
405
+ const before = opts.fresh ? null : await schemaRevision(sql, schema);
306
406
  if (before !== null && before > DDL_REVISION) {
307
407
  throw new Error(`letopis.up: схема ${schema} ревизии ${before}, а библиотека знает только ${DDL_REVISION} — обновите пакет`);
308
408
  }
@@ -310,7 +410,11 @@ export async function up(opts) {
310
410
  throw new Error(`letopis.up: схема ${schema} ревизии ${before}, библиотека — ${DDL_REVISION}: обновите её up({ schema: '${schema}', upgrade: true })`);
311
411
  }
312
412
  const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
313
- const res = await install(sql, { ...rest, schema, ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}) });
413
+ const { found, ...res } = await install(sql, {
414
+ ...rest, schema, ...(opts.fresh ? { fresh: true } : {}), ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}),
415
+ });
416
+ if (opts.fresh)
417
+ log(`схема ${schema} удалена и поставлена заново (fresh)`);
314
418
  // ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть
315
419
  const fresh = await sql `select relname from pg_stat_user_tables
316
420
  where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`;
@@ -318,9 +422,9 @@ export async function up(opts) {
318
422
  for (const t of analyzed)
319
423
  await sql.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
320
424
  const revision = (await schemaRevision(sql, schema)) ?? 0;
321
- log(`${before === null ? 'схема поставлена' : before < DDL_REVISION ? `схема обновлена с ревизии ${before}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
425
+ log(`${found === null ? 'схема поставлена' : found < DDL_REVISION ? `схема обновлена с ревизии ${found}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
322
426
  const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
323
- return { ...res, created: before === null, upgraded: before !== null && before < DDL_REVISION, revision, analyzed, warnings,
427
+ return { ...res, created: found === null, upgraded: found !== null && found < DDL_REVISION, revision, analyzed, warnings,
324
428
  ...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
325
429
  }
326
430
  catch (e) {
@@ -335,8 +439,11 @@ export async function up(opts) {
335
439
  await sql.end({ timeout: 5 }).catch(() => { });
336
440
  }
337
441
  }
442
+ /** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
443
+ const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'PROJECTOR', 'LOCK'];
338
444
  /**
339
- * Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Членство
445
+ * Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
446
+ * функций кредов (auth_kind_ok). Членство
340
447
  * пишет sys_create от владельца — без проверки класса member, поэтому список ролей проверяется здесь:
341
448
  * непустой, без повторов, каждая роль — имя, а не маска группы '{a,b}': не пустое, без пробелов по
342
449
  * краям (маска их отрезает) и запятых (разделитель маски), без фигурных скобок и «!» в начале
@@ -345,6 +452,12 @@ export async function up(opts) {
345
452
  function checkTenant(opts) {
346
453
  if (!opts.name)
347
454
  throw new Error('letopis.createTenant: имя арендатора обязательно');
455
+ // вид пароля — по правилу функций кредов (auth_kind_ok в 80-auth.sql): пароль пишет функция владельца мимо них, а с
456
+ // видом в другом регистре или служебным (SESSION, TOTP…) по нему не войти, и служебные строки он подменял бы
457
+ const kind = opts.user?.kind;
458
+ if (kind != null && (typeof kind !== 'string' || !/^[A-Z][A-Z0-9_]{1,31}$/.test(kind) || SERVICE_KINDS.includes(kind))) {
459
+ throw new LetopisError('invalid_data', `createTenant: user.kind ${JSON.stringify(kind)} — вид пароля: заглавная латинская буква, затем заглавные буквы, цифры или «_», всего от 2 до 32 знаков; служебные виды ${SERVICE_KINDS.join(', ')} заняты (по умолчанию — PASSWORD)`);
460
+ }
348
461
  if (opts.user?.roles === undefined)
349
462
  return ['owner'];
350
463
  const roles = opts.user.roles;
@@ -363,14 +476,10 @@ function checkTenant(opts) {
363
476
  return roles;
364
477
  }
365
478
  /**
366
- * Арендатор и его первый пользователь — то, что раньше делал свой сид функциями владельца
367
- * (sys_create, sys_grant_all). Нужно администраторское подключение (роль может стать владельцем);
368
- * всё — одной транзакцией от владельца. Повтор с тем же именем и логином ничего не дублирует:
369
- * id — v5 от имени и логина, существующие строки пропускаются (пароль и роли членства не меняются),
370
- * сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
371
- * журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
372
- * что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
373
- * версию 1 — её занимает история; вернуть строку — restore() цепочкой.
479
+ * Заводит арендатора и, по желанию, его первого пользователя (аккаунт, членство, пароль, сессию) одной транзакцией от
480
+ * роли-владельца схемы — поэтому нужен администраторский пул. Повтор с тем же именем и логином ничего не дублирует и не
481
+ * меняет, только выдаёт пользователю новую сессию; одновременные вызовы заводят арендатора один раз. Если арендатор,
482
+ * пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
374
483
  */
375
484
  export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
376
485
  const roles = checkTenant(opts);
@@ -380,6 +489,9 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
380
489
  const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
381
490
  const hash = u ? await hashPassword(u.password) : null;
382
491
  return sql.begin(async (tx) => {
492
+ // под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
493
+ // раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
494
+ await lockSchema(tx, schema);
383
495
  await tx.unsafe(`set local role ${quoteIdent(owner)}`);
384
496
  /** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
385
497
  const exists = async (id, cls, gone) => {
@@ -17,8 +17,15 @@ export interface Validator {
17
17
  /** Нарушения ([] — данные приняты). */
18
18
  (data: unknown): Issue[];
19
19
  }
20
- /** Компилирует JSON Schema (разворот resolved.schema класса) в функцию проверки. */
20
+ /**
21
+ * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
22
+ * класса — `db.registry.resolve(имя).schema`.
23
+ */
21
24
  export declare function compileValidator(schema: JsonSchema): Validator;
22
- /** Умолчания, как apply_defaults в базе: на каждом уровне, во вложенные объекты — только если они есть. */
25
+ /**
26
+ * Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
27
+ * записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не
28
+ * меняются.
29
+ */
23
30
  export declare function applyDefaults(schema: JsonSchema, data: unknown): unknown;
24
31
  export {};
package/dist/validate.js CHANGED
@@ -136,7 +136,16 @@ function compileNode(s, sel, path, inAny, out) {
136
136
  if (sch.multipleOf !== undefined)
137
137
  out.push({ path, keyword: 'multipleOf', select: sel, test: num((v, b) => isMultiple(v, b), sch.multipleOf) });
138
138
  if (sch.pattern !== undefined) {
139
- const re = new RegExp(translatePattern(sch.pattern));
139
+ const src = translatePattern(sch.pattern); // то, чего база не понимает, — своя ошибка invalid_class
140
+ let re;
141
+ try {
142
+ re = new RegExp(src);
143
+ }
144
+ catch {
145
+ // шаблон, неверный уже для JavaScript (незакрытая скобка и т. п.): translatePattern его пропускает, RegExp — нет.
146
+ // Текст V8 в сообщение не идёт: он зависит от версии движка
147
+ fail(path, `pattern ${JSON.stringify(sch.pattern)} — неверное регулярное выражение`);
148
+ }
140
149
  out.push({ path, keyword: 'pattern', select: sel, test: (v) => typeof v !== 'string' || re.test(v) });
141
150
  }
142
151
  if (sch.format !== undefined) {
@@ -211,7 +220,10 @@ function compileNode(s, sel, path, inAny, out) {
211
220
  : (v) => branches.filter((b) => b(v)).length === 1 });
212
221
  }
213
222
  }
214
- /** Компилирует JSON Schema (разворот resolved.schema класса) в функцию проверки. */
223
+ /**
224
+ * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
225
+ * класса — `db.registry.resolve(имя).schema`.
226
+ */
215
227
  export function compileValidator(schema) {
216
228
  const rules = [];
217
229
  compileNode(schema, (root) => [root], [], false, rules);
@@ -233,7 +245,11 @@ export function compileValidator(schema) {
233
245
  return issues;
234
246
  };
235
247
  }
236
- /** Умолчания, как apply_defaults в базе: на каждом уровне, во вложенные объекты — только если они есть. */
248
+ /**
249
+ * Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
250
+ * записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не
251
+ * меняются.
252
+ */
237
253
  export function applyDefaults(schema, data) {
238
254
  if (!isObj(data) || !isObj(schema.properties))
239
255
  return data;
package/dist/watch.d.ts CHANGED
@@ -2,13 +2,17 @@ import type { Executor } from './tx.js';
2
2
  import type { Row } from './types.js';
3
3
  import type { Signal } from './signals.js';
4
4
  export interface WatchOptions {
5
- /** Откуда: курсор события, 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
5
+ /** Откуда: курсор (sub.cursor или курсор события), 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
6
6
  from?: string;
7
7
  /** Только эти классы (с потомками). */
8
8
  classes?: string[];
9
9
  /** Событий за запрос (по умолчанию 500). */
10
10
  batch?: number;
11
- /** Запасной опрос, мс (по умолчанию 1000; без LISTEN — единственный способ узнать о событиях). */
11
+ /**
12
+ * Как часто подписка сама проверяет журнал, мс (по умолчанию 1000): так она находит события, даже если сигнал базы не
13
+ * пришёл. Без сигналов (`connect({ listen: false })` или установка с `up({ signals: 'none' })`) новые события она
14
+ * узнаёт только так.
15
+ */
12
16
  pollMs?: number;
13
17
  }
14
18
  export interface WatchEvent {
@@ -43,6 +47,8 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
43
47
  private opts;
44
48
  private pos;
45
49
  private buf;
50
+ /** Курсор после последнего выданного события, пока в буфере есть невыданные (pos — уже после всей пачки). */
51
+ private given;
46
52
  private closed;
47
53
  private wake;
48
54
  private dirty;
@@ -56,11 +62,17 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
56
62
  /** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
57
63
  private queue;
58
64
  constructor(ctx: WatchCtx, opts: WatchOptions);
59
- /** Курсор последнего выданного события (или начальной точки). */
65
+ /**
66
+ * Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
67
+ * в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
68
+ * курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
69
+ * cursor_expired.
70
+ */
60
71
  get cursor(): string | null;
61
72
  /**
62
- * Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
63
- * у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
73
+ * Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
74
+ * (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
75
+ * выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
64
76
  */
65
77
  ready(): Promise<void>;
66
78
  private head;
package/dist/watch.js CHANGED
@@ -13,7 +13,9 @@
13
13
  * без LISTEN и на случай потери сигнала — запасной опрос.
14
14
  *
15
15
  * Курсор подписчика старше яруса all политики хранения его классов — cursor_expired: события могли
16
- * проредиться, нужно перечитать текущее состояние и подписаться заново.
16
+ * проредиться, нужно перечитать текущее состояние и подписаться заново. Время курсора подписки — время последнего
17
+ * события, а догнавшей журнал подписки — время опроса (не позже самой старой ещё не отданной записи): подписка без
18
+ * событий не стареет. Курсор события хранит время события.
17
19
  */
18
20
  import { LetopisError } from './errors.js';
19
21
  import { toRow } from './sql.js';
@@ -54,6 +56,8 @@ export class Subscription {
54
56
  opts;
55
57
  pos = null;
56
58
  buf = [];
59
+ /** Курсор после последнего выданного события, пока в буфере есть невыданные (pos — уже после всей пачки). */
60
+ given = null;
57
61
  closed = false;
58
62
  wake = null;
59
63
  dirty = true;
@@ -95,13 +99,21 @@ export class Subscription {
95
99
  }, this.pollMs);
96
100
  this.timer.unref?.();
97
101
  }
98
- /** Курсор последнего выданного события (или начальной точки). */
102
+ /**
103
+ * Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
104
+ * в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
105
+ * курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
106
+ * cursor_expired.
107
+ */
99
108
  get cursor() {
109
+ if (this.buf.length)
110
+ return this.given;
100
111
  return this.pos ? encode(this.pos) : null;
101
112
  }
102
113
  /**
103
- * Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
104
- * у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
114
+ * Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
115
+ * (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
116
+ * выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
105
117
  */
106
118
  async ready() {
107
119
  if (!this.pos && this.start)
@@ -122,18 +134,28 @@ export class Subscription {
122
134
  // уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
123
135
  // версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
124
136
  // оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
125
- // (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом
137
+ // (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом.
138
+ // Третья строка (src 2) — время курсора, когда пачка неполная (подписка догнала журнал): «сейчас», но не позже самой
139
+ // старой записи подписки, которая уже зафиксирована, а горизонт ещё не прошла (её держит более старая транзакция;
140
+ // снимок «now» — без записей, которые подписка не отдаёт). Тот же снимок, что у watch_read
126
141
  const S = this.ctx.S;
127
142
  const both = await this.ctx.exec.run((q) => q.unsafe(`with r as (select l.* from ${S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l),
128
143
  u as (select r.tx, r.seq from r where (select count(*) from r) = $5 order by r.tx desc, r.seq desc limit 1)
129
144
  select 0 as src, ${cols} from r l
130
145
  union all
131
146
  select 1 as src, ${cols} from ${S}.watch_leaving($1::text::xid8, $2::bigint, $4::text[], $5::int, $6::text::pg_snapshot,
132
- (select tx from u), (select seq from u)) l`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
147
+ (select tx from u), (select seq from u)) l
148
+ union all
149
+ select 2 as src, null, null, jsonb_build_object('at', least(statement_timestamp(), min(l.at))) from ${S}.log l
150
+ where (select count(*) from r) < $5 and (l.tx, l.seq) > ($1::text::xid8, $2::bigint)
151
+ and l.tx >= pg_snapshot_xmin(pg_current_snapshot()) and ($4::text[] is null or l.class = any ($4::text[]))
152
+ and ($6::text::pg_snapshot is null or not pg_visible_in_snapshot(l.tx, $6::text::pg_snapshot))
153
+ having (select count(*) from r) < $5`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
133
154
  const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
134
155
  const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
135
156
  const read = both.filter((r) => r.src === 0).sort(order);
136
157
  const away = both.filter((r) => r.src === 1).sort(order);
158
+ const caught = both.find((r) => r.src === 2);
137
159
  let rows = read;
138
160
  {
139
161
  const upto = read.length === this.batch ? read[read.length - 1] : null;
@@ -145,12 +167,19 @@ export class Subscription {
145
167
  rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
146
168
  }
147
169
  }
170
+ // пока события пачки не выданы, курсор подписки — позиция до пачки
171
+ if (rows.length)
172
+ this.given = encode(p);
148
173
  for (const r of rows) {
149
174
  const row = toRow(r.row);
150
175
  const snap = keepSnap(p.snap, r.tx);
151
176
  this.pos = { tx: r.tx, seq: r.seq, at: row.at, ...(snap ? { snap } : {}) };
152
177
  this.buf.push({ cursor: encode(this.pos), row, id: row.id, class: row.class, op: row.op, rev: row.rev, at: row.at });
153
178
  }
179
+ // догнала журнал — время курсора подписки (sub.cursor) обновляется и на пустых опросах: прореживать после него нечего,
180
+ // и подписка, к которой долго не было событий, не получает ложный cursor_expired. Курсоры событий хранят время события
181
+ if (caught && away.length < this.batch)
182
+ this.pos = { ...(this.pos ?? p), at: String(caught.row.at) };
154
183
  return rows.length;
155
184
  }
156
185
  /**
@@ -167,8 +196,11 @@ export class Subscription {
167
196
  for (;;) {
168
197
  if (this.failure)
169
198
  throw this.failure;
170
- if (this.buf.length)
171
- return { value: this.buf.shift(), done: false };
199
+ if (this.buf.length) {
200
+ const ev = this.buf.shift();
201
+ this.given = ev.cursor;
202
+ return { value: ev, done: false };
203
+ }
172
204
  if (this.closed)
173
205
  return { value: undefined, done: true };
174
206
  if (this.dirty) {
package/dist/write.d.ts CHANGED
@@ -10,8 +10,9 @@ export interface IncMarker {
10
10
  readonly start?: number;
11
11
  }
12
12
  /**
13
- * Приращение в update: `update({ balance: inc(-300) })` — величина прибавляется в базе, внутри
14
- * оператора, над свежей строкой (функция inc). Поля нет — ошибка; `{ start }` — начать с него.
13
+ * Приращение числового поля в `update()`: `db.Wallet(w).update({ balance: inc(-300) })`. Сложение делает база над
14
+ * текущим значением поля в момент записи, поэтому одновременные приращения не теряются. Если поля ещё нет, `{ start }`
15
+ * задаёт, с чего начать (поле получит `start + by`), а без него — ошибка `invalid_data`.
15
16
  */
16
17
  export declare function inc(by: number, opts?: {
17
18
  start?: number;