letopis 1.4.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 (52) hide show
  1. package/AGENT-CHEATSHEET.en.md +10 -5
  2. package/AGENT-CHEATSHEET.md +14 -9
  3. package/CHANGELOG.md +159 -0
  4. package/README.en.md +119 -52
  5. package/README.md +107 -47
  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 +12 -5
  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 +30 -11
  28. package/dist/project.js +30 -4
  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/tx.js +10 -1
  34. package/dist/types.d.ts +14 -7
  35. package/dist/types.js +13 -6
  36. package/dist/up.d.ts +48 -25
  37. package/dist/up.js +89 -41
  38. package/dist/validate.d.ts +9 -2
  39. package/dist/validate.js +19 -3
  40. package/dist/watch.d.ts +17 -5
  41. package/dist/watch.js +40 -8
  42. package/dist/write.d.ts +3 -2
  43. package/dist/write.js +29 -10
  44. package/package.json +1 -1
  45. package/sql/10-core.sql +6 -1
  46. package/sql/60-write.sql +38 -18
  47. package/sql/80-auth.sql +39 -2
  48. package/sql/88-derived.sql +1 -0
  49. package/sql/90-time.sql +15 -5
  50. package/sql/95-seed.system.sql +6 -3
  51. package/sql/99-revision.sql +1 -1
  52. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/up.js CHANGED
@@ -24,6 +24,13 @@ import { v5Id } from './uuid.js';
24
24
  import { SIGNAL_MODES } from './signals.js';
25
25
  export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
26
26
  export const SCHEMA_VERSION = 'v2';
27
+ /**
28
+ * Advisory-ключ наката одной схемы: пара int4, как у общей части установки (`hashtext('install')`), — имя схемы
29
+ * всегда с точкой (`v2.имя`), поэтому со словом install ключи не совпадут.
30
+ */
31
+ async function lockSchema(tx, schema) {
32
+ await tx.unsafe(`select pg_advisory_xact_lock(hashtext('letopis'), hashtext($1))`, [schema]);
33
+ }
27
34
  const SQL_DIR = fileURLToPath(new URL('../sql/', import.meta.url));
28
35
  const MIGRATE_DIR = join(SQL_DIR, 'migrate');
29
36
  const ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -92,7 +99,7 @@ export async function stampDrift(schema, comment) {
92
99
  return `letopis: схему ${schema} обновила letopis ${was}, библиотека старее (${code.version}) — обновите пакет: up() этой версией вернул бы прежние функции`;
93
100
  }
94
101
  const by = !was ? 'версия до 1.4.0' : was === code.version ? `другая сборка letopis ${was}` : `letopis ${was}`;
95
- return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: накатите up({ schema: '${schema}' })`;
102
+ return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: обновите схему — вызовите up({ schema: '${schema}' })`;
96
103
  }
97
104
  export function substitute(text, vars) {
98
105
  return text.replace(/@([A-Z_]+)@/g, (m, k) => {
@@ -115,23 +122,40 @@ export async function install(sql, opts) {
115
122
  const [{ v }] = await sql `select current_setting('server_version_num') as v`;
116
123
  if (Number(v) < 180000)
117
124
  throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
118
- // Роли — общие для кластера; создаются идемпотентно.
119
- for (const r of [owner, app]) {
120
- await sql.unsafe(`do $$ begin
121
- if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(r)}) then
122
- create role ${quoteIdent(r)} nologin;
123
- end if;
124
- exception when duplicate_object then null; end $$`);
125
- }
126
- // Установщик получает права владельца только на время set role (план, §2.9).
127
- await sql.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`).catch((e) => {
128
- if (e.code !== '0LP01')
129
- 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;
130
158
  });
131
- await sql.unsafe(`create extension if not exists pgcrypto`);
132
- const [{ nsp }] = await sql `
133
- select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`;
134
- await sql.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
135
159
  const vars = {
136
160
  SCHEMA: quoteIdent(opts.schema),
137
161
  SCHEMA_NAME: opts.schema,
@@ -152,13 +176,27 @@ export async function install(sql, opts) {
152
176
  const st = await engineStamp();
153
177
  vars.ENGINE_VERSION = st.version;
154
178
  vars.ENGINE_SQL = st.sql;
155
- // Схема создаётся до set role: у владельца может не быть права CREATE в базе.
156
- await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
157
179
  const files = await engineFiles(opts.upTo);
158
- const migrations = opts.migrateFrom === undefined ? [] : await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, opts.migrateFrom);
180
+ let migrations = [];
181
+ let found = null;
159
182
  let serviceKey = null;
160
183
  let signals = 'class';
161
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)}`);
162
200
  await tx.unsafe(`set local role ${quoteIdent(owner)}`);
163
201
  // режим сигналов (план 1.1, §2.6): опция, иначе сохранённый в схеме (meta строки ключа), иначе class
164
202
  let saved = null;
@@ -236,7 +274,7 @@ export async function install(sql, opts) {
236
274
  }
237
275
  }
238
276
  });
239
- return { schema: opts.schema, owner, app, serviceKey, migrations, signals };
277
+ return { schema: opts.schema, owner, app, serviceKey, migrations, signals, found };
240
278
  }
241
279
  /** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
242
280
  export async function migrationFiles(dir, from) {
@@ -313,14 +351,15 @@ export function checkServer(versionNum, encoding) {
313
351
  }
314
352
  /** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
315
353
  export async function schemaRevision(sql, schema) {
316
- 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]);
317
355
  if (!r)
318
356
  return null;
319
357
  return Number(/ddl_revision=(\d+)/.exec(String(r.c ?? ''))?.[1] ?? 0);
320
358
  }
321
359
  /**
322
- * Поставить или обновить установку letopis. Схема есть и её ревизия совпадает — повторный накат
323
- * (идемпотентный); ревизия отстала — только с upgrade: true; новее библиотеки — отказ.
360
+ * Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
361
+ * повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
362
+ * Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
324
363
  */
325
364
  export async function up(opts) {
326
365
  const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
@@ -362,11 +401,8 @@ export async function up(opts) {
362
401
  if (!opts.quiet)
363
402
  for (const w of warnings)
364
403
  console.warn(`letopis.up: ${w}`);
365
- if (opts.fresh) {
366
- await sql.unsafe(`drop schema if exists ${quoteIdent(schema)} cascade`);
367
- log(`схема ${schema} удалена (fresh)`);
368
- }
369
- const before = await schemaRevision(sql, schema);
404
+ // ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
405
+ const before = opts.fresh ? null : await schemaRevision(sql, schema);
370
406
  if (before !== null && before > DDL_REVISION) {
371
407
  throw new Error(`letopis.up: схема ${schema} ревизии ${before}, а библиотека знает только ${DDL_REVISION} — обновите пакет`);
372
408
  }
@@ -374,7 +410,11 @@ export async function up(opts) {
374
410
  throw new Error(`letopis.up: схема ${schema} ревизии ${before}, библиотека — ${DDL_REVISION}: обновите её up({ schema: '${schema}', upgrade: true })`);
375
411
  }
376
412
  const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
377
- const res = await install(sql, { ...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)`);
378
418
  // ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть
379
419
  const fresh = await sql `select relname from pg_stat_user_tables
380
420
  where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`;
@@ -382,9 +422,9 @@ export async function up(opts) {
382
422
  for (const t of analyzed)
383
423
  await sql.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
384
424
  const revision = (await schemaRevision(sql, schema)) ?? 0;
385
- log(`${before === null ? 'схема поставлена' : before < DDL_REVISION ? `схема обновлена с ревизии ${before}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
425
+ log(`${found === null ? 'схема поставлена' : found < DDL_REVISION ? `схема обновлена с ревизии ${found}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
386
426
  const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
387
- 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,
388
428
  ...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
389
429
  }
390
430
  catch (e) {
@@ -399,8 +439,11 @@ export async function up(opts) {
399
439
  await sql.end({ timeout: 5 }).catch(() => { });
400
440
  }
401
441
  }
442
+ /** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
443
+ const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'PROJECTOR', 'LOCK'];
402
444
  /**
403
- * Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Членство
445
+ * Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
446
+ * функций кредов (auth_kind_ok). Членство
404
447
  * пишет sys_create от владельца — без проверки класса member, поэтому список ролей проверяется здесь:
405
448
  * непустой, без повторов, каждая роль — имя, а не маска группы '{a,b}': не пустое, без пробелов по
406
449
  * краям (маска их отрезает) и запятых (разделитель маски), без фигурных скобок и «!» в начале
@@ -409,6 +452,12 @@ export async function up(opts) {
409
452
  function checkTenant(opts) {
410
453
  if (!opts.name)
411
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
+ }
412
461
  if (opts.user?.roles === undefined)
413
462
  return ['owner'];
414
463
  const roles = opts.user.roles;
@@ -427,14 +476,10 @@ function checkTenant(opts) {
427
476
  return roles;
428
477
  }
429
478
  /**
430
- * Арендатор и его первый пользователь — то, что раньше делал свой сид функциями владельца
431
- * (sys_create, sys_grant_all). Нужно администраторское подключение (роль может стать владельцем);
432
- * всё — одной транзакцией от владельца. Повтор с тем же именем и логином ничего не дублирует:
433
- * id — v5 от имени и логина, существующие строки пропускаются (пароль и роли членства не меняются),
434
- * сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
435
- * журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
436
- * что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
437
- * версию 1 — её занимает история; вернуть строку — restore() цепочкой.
479
+ * Заводит арендатора и, по желанию, его первого пользователя (аккаунт, членство, пароль, сессию) одной транзакцией от
480
+ * роли-владельца схемы — поэтому нужен администраторский пул. Повтор с тем же именем и логином ничего не дублирует и не
481
+ * меняет, только выдаёт пользователю новую сессию; одновременные вызовы заводят арендатора один раз. Если арендатор,
482
+ * пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
438
483
  */
439
484
  export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
440
485
  const roles = checkTenant(opts);
@@ -444,6 +489,9 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
444
489
  const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
445
490
  const hash = u ? await hashPassword(u.password) : null;
446
491
  return sql.begin(async (tx) => {
492
+ // под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
493
+ // раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
494
+ await lockSchema(tx, schema);
447
495
  await tx.unsafe(`set local role ${quoteIdent(owner)}`);
448
496
  /** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
449
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;
package/dist/write.js CHANGED
@@ -29,8 +29,9 @@ import { applyDefaults } from './validate.js';
29
29
  /** Метка приращения; Symbol.for — маркер узнаёт и вторая копия пакета в процессе. */
30
30
  export const INC = Symbol.for('letopis.inc');
31
31
  /**
32
- * Приращение в update: `update({ balance: inc(-300) })` — величина прибавляется в базе, внутри
33
- * оператора, над свежей строкой (функция inc). Поля нет — ошибка; `{ start }` — начать с него.
32
+ * Приращение числового поля в `update()`: `db.Wallet(w).update({ balance: inc(-300) })`. Сложение делает база над
33
+ * текущим значением поля в момент записи, поэтому одновременные приращения не теряются. Если поля ещё нет, `{ start }`
34
+ * задаёт, с чего начать (поле получит `start + by`), а без него — ошибка `invalid_data`.
34
35
  */
35
36
  export function inc(by, opts = {}) {
36
37
  return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
@@ -377,7 +378,11 @@ function applySlotsLocal(links, ops) {
377
378
  }
378
379
  return out;
379
380
  }
380
- const tagsValue = (s) => s.tagsFilter === undefined ? null : typeof s.tagsFilter === 'string' ? [s.tagsFilter] : s.tagsFilter;
381
+ /**
382
+ * Теги, которые пишет глагол: строка или список из `.tags(…)` (перед update они же и отбирают цели). Оператор
383
+ * (has, hasAny, hasAll) только отбирает — теги целей не меняются; create и upsert его не принимают (precheck).
384
+ */
385
+ const tagsValue = (s) => typeof s.tagsFilter === 'string' ? [s.tagsFilter] : Array.isArray(s.tagsFilter) ? s.tagsFilter : null;
381
386
  /** «Уже существует»: id вычисляет библиотека (idOf), а не разбирает текст ошибки сервера (§2.5). */
382
387
  function existsError(w, cls, data, links, given, cause) {
383
388
  let id = given;
@@ -440,12 +445,23 @@ async function explainMissing(w, missing, rev) {
440
445
  }
441
446
  return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
442
447
  }
448
+ /**
449
+ * Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
450
+ * отдаёт их в порядке обхода таблицы, и `first()` после глагола брал бы случайную из исправленных строк.
451
+ */
452
+ function inTargetOrder(ids, rows) {
453
+ const pos = new Map(ids.map((id, i) => [id, i]));
454
+ return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
455
+ }
443
456
  /** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
444
457
  async function targetIds(w, steps, opStep, dataId) {
445
458
  const path = steps.map(bare);
446
459
  if (opStep.filter === undefined && dataId !== undefined)
447
460
  path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
448
- return readIds(w, path, opStep.opMods ?? {});
461
+ const ids = await readIds(w, path, opStep.opMods ?? {});
462
+ // без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
463
+ // записей не должен зависеть от плана чтения
464
+ return opStep.opMods?.order ? ids : [...ids].sort();
449
465
  }
450
466
  async function updateOp(w, steps, opStep) {
451
467
  const op = opStep.op;
@@ -456,7 +472,7 @@ async function updateOp(w, steps, opStep) {
456
472
  const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
457
473
  if (op.rev !== undefined) {
458
474
  if (ids.length > 1)
459
- fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — строгий режим только для одной`);
475
+ fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
460
476
  if (!ids.length)
461
477
  fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
462
478
  }
@@ -481,7 +497,7 @@ async function updateOp(w, steps, opStep) {
481
497
  const got = new Set(res.map((r) => r.row.id));
482
498
  await explainMissing(w, ids.filter((x) => !got.has(x)), op.rev);
483
499
  }
484
- return res.map((r) => toRow(r.row));
500
+ return inTargetOrder(ids, res.map((r) => toRow(r.row)));
485
501
  }
486
502
  async function deleteOp(w, steps, opStep) {
487
503
  const op = opStep.op;
@@ -510,9 +526,11 @@ async function deleteOp(w, steps, opStep) {
510
526
  }
511
527
  const tombs = await query(w, `select ${rowJson('l')} as row from ${S}.log l where l.tx = pg_current_xact_id() and l.seq > $1::bigint and l.op = 'delete' order by l.seq`, [mark]);
512
528
  const out = tombs.map((r) => toRow(r.row));
513
- // строки классов без журнала (history: false) надгробий не оставляют — берём их из returning
529
+ // строки классов без журнала (history: false) надгробий не оставляют — берём их из returning, по id (порядок
530
+ // returning зависит от плана, а итог delete() — нет: надгробия идут по seq, триггер пишет их в порядке id)
514
531
  const have = new Set(out.map((r) => r.id));
515
- for (const g of gone) {
532
+ const byId = (a, b) => String(a.row.id) < String(b.row.id) ? -1 : String(a.row.id) > String(b.row.id) ? 1 : 0;
533
+ for (const g of [...gone].sort(byId)) {
516
534
  if (have.has(g.row.id))
517
535
  continue;
518
536
  const row = toRow(g.row);
@@ -534,7 +552,7 @@ async function anonymizeOp(w, steps, opStep) {
534
552
  const got = new Set(res.map((r) => r.row.id));
535
553
  await explainMissing(w, ids.filter((x) => !got.has(x)));
536
554
  }
537
- return res.map((r) => toRow(r.row));
555
+ return inTargetOrder(ids, res.map((r) => toRow(r.row)));
538
556
  }
539
557
  async function reclassOp(w, steps, opStep) {
540
558
  const op = opStep.op;
@@ -554,7 +572,8 @@ async function deletedTargets(w, steps, opStep, verb) {
554
572
  const dead = rows.filter((r) => r.$deleted);
555
573
  if (!dead.length && rows.length)
556
574
  fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
557
- return dead;
575
+ // без явного порядка — по id: restore и purge пишут журнал по очереди целей
576
+ return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
558
577
  }
559
578
  /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
560
579
  async function restoreOp(w, steps, opStep) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
5
5
  "keywords": [
6
6
  "postgres",
package/sql/10-core.sql CHANGED
@@ -107,9 +107,12 @@ create index if not exists log_ends on @SCHEMA@.log using gin (@SCHEMA@.link_ids
107
107
  -- журнал часто прореживается (§2.11): чаще чистим
108
108
  alter table @SCHEMA@.log set (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.02);
109
109
 
110
+ -- account — строка entity (аккаунт; у служебных строк KEY, LOCK, PROJECTOR — System): удалённый аккаунт уносит свои строки
111
+ -- каскадом внешнего ключа — сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (ревизия 7; в схему
112
+ -- прежней ревизии ключ добавляет миграция 0007). Строка Account у System не удаляется (trg_entity_bd_account, 60-write.sql)
110
113
  create table if not exists @SCHEMA@.secret (
111
114
  id uuid primary key default gen_random_uuid(),
112
- account uuid not null,
115
+ account uuid not null constraint secret_account_fkey references @SCHEMA@.entity (id) on delete cascade,
113
116
  kind text not null, -- PASSWORD, APIKEY, KEYSECRET, OTP, TOTP, IDENTITY, SESSION
114
117
  ident text, -- email, телефон, префикс ключа, внешний id
115
118
  material bytea, -- хэш пароля, ключа, кода или токена; секрет TOTP
@@ -121,6 +124,8 @@ create table if not exists @SCHEMA@.secret (
121
124
  create unique index if not exists secret_kind_ident on @SCHEMA@.secret (kind, casefold(ident) collate pg_unicode_fast)
122
125
  where deleted_at is null and ident is not null;
123
126
  create index if not exists secret_material on @SCHEMA@.secret (material) where kind = 'SESSION' and deleted_at is null;
127
+ -- строки аккаунта: каскад внешнего ключа при удалении аккаунта и гашение его сессий (auth_kill) — без перебора таблицы
128
+ create index if not exists secret_account on @SCHEMA@.secret (account);
124
129
 
125
130
  alter table @SCHEMA@.entity owner to @OWNER@;
126
131
  alter table @SCHEMA@.log owner to @OWNER@;