letopis 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/project.js CHANGED
@@ -50,7 +50,7 @@ function withFailed(meta, failed) {
50
50
  delete next.failed;
51
51
  return next;
52
52
  }
53
- const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0 });
53
+ const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0, locked: [] });
54
54
  function change(row, cursor, rebuild = false) {
55
55
  return {
56
56
  cursor, id: row.id, class: row.class, tenant: row.tenant, op: rebuild ? 'create' : row.op, rev: row.rev, at: row.at,
@@ -67,6 +67,10 @@ function change(row, cursor, rebuild = false) {
67
67
  * значениями (22…) и пределами размера (54…). Сбой окружения (блокировки, отмена запроса, связь) — не причина
68
68
  * выводить арендатора: пачка откатывается целиком, повтор начнётся с того же курсора.
69
69
  */
70
+ /** Ожидание чужой блокировки строки дольше lock_timeout (55P03): сбой окружения, проход повторяется. */
71
+ function lockWait(e) {
72
+ return e?.code === '55P03';
73
+ }
70
74
  function tenantFault(e) {
71
75
  if (e instanceof LetopisError)
72
76
  return true;
@@ -198,12 +202,18 @@ export class Projector {
198
202
  /** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
199
203
  blind = new Map();
200
204
  stale = true;
205
+ /** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
206
+ tenantsPending = true;
201
207
  running = false;
202
208
  stopped = false;
203
209
  wake = null;
204
210
  off = null;
205
211
  batch;
206
212
  pollMs;
213
+ lockMs;
214
+ lockRetries;
215
+ /** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
216
+ lockHits = new Map();
207
217
  only;
208
218
  constructor(env, name, def) {
209
219
  this.env = env;
@@ -228,6 +238,12 @@ export class Projector {
228
238
  this.only = def.tenants ? new Set(def.tenants.map((x) => x.toLowerCase())) : null;
229
239
  this.batch = def.batch ?? 500;
230
240
  this.pollMs = def.pollMs ?? 1000;
241
+ this.lockMs = def.lockTimeout ?? 5000;
242
+ if (!Number.isInteger(this.lockMs) || this.lockMs < 0)
243
+ throw new LetopisError('invalid_data', `проектор ${name}: lockTimeout — целое число мс, 0 и больше`);
244
+ this.lockRetries = def.lockRetries ?? 0;
245
+ if (!Number.isInteger(this.lockRetries) || this.lockRetries < 0)
246
+ throw new LetopisError('invalid_data', `проектор ${name}: lockRetries — целое число, 0 и больше`);
231
247
  if (!Number.isInteger(this.batch) || this.batch < 1 || this.batch > 10_000)
232
248
  throw new LetopisError('invalid_data', `проектор ${name}: batch — от 1 до 10 000`);
233
249
  if (env.onSignal) {
@@ -261,18 +277,37 @@ export class Projector {
261
277
  r = await this.step(rep);
262
278
  }
263
279
  catch (e) {
264
- if (!(e instanceof LetopisError && e.code === 'cursor_expired' && this.def.onExpired === 'rebuild'))
280
+ if (lockWait(e)) {
281
+ rep.skipped++;
282
+ r = 'busy';
283
+ }
284
+ else if (e instanceof LetopisError && e.code === 'cursor_expired' && this.def.onExpired === 'rebuild') {
285
+ r = 'rebuild';
286
+ }
287
+ else
265
288
  throw e;
266
- r = 'rebuild';
267
289
  }
268
290
  // курсор истёк у части арендаторов: с onExpired: 'rebuild' они перестраиваются сами, пачками (доработки после
269
291
  // точки Б). Состояние — в чекпоинте, поэтому перестройку продолжает run() любого процесса, а занятая
270
- // блокировка — только повтор на следующем проходе; ошибка данных выводит арендатора с её кодом, без повторов
271
- if (this.def.onExpired === 'rebuild' && r !== 'rebuild' && (await this.tenantWork(rep)) && r !== 'busy')
272
- r = 'more';
273
- // пачку перестройки держит другой процесс того же проектора — подождать и продолжить
274
- if (r === 'rebuild')
275
- r = (await this.rebuildNow(rep, 'auto')) === 'busy' ? 'busy' : 'more';
292
+ // блокировка — только повтор на следующем проходе; ошибка данных выводит арендатора с её кодом, без повторов.
293
+ // Начатую перестройку арендатора пачками — и прерванную rebuild({ tenant, chunked: true }) — продвигает run()
294
+ // и без onExpired (ревью безопасности №3): иначе хвост бессрочно пропускал бы изменения арендатора с id выше
295
+ // последней пачки. Начинает перестройку выбывших по ярусу только onExpired: 'rebuild'. Блокировку проектора держит
296
+ // другой процесс (busy) — перестройку продвинет он: чтение чекпоинта ждало бы его пачку
297
+ const auto = this.def.onExpired === 'rebuild';
298
+ try {
299
+ if (r !== 'rebuild' && r !== 'busy' && (auto || this.tenantsPending) && (await this.tenantWork(rep, auto)))
300
+ r = 'more';
301
+ // пачку перестройки держит другой процесс того же проектора — подождать и продолжить
302
+ if (r === 'rebuild')
303
+ r = (await this.rebuildNow(rep, 'auto')) === 'busy' ? 'busy' : 'more';
304
+ }
305
+ catch (e) {
306
+ if (!lockWait(e))
307
+ throw e;
308
+ rep.skipped++;
309
+ r = 'busy';
310
+ }
276
311
  if (r === 'more')
277
312
  continue;
278
313
  if (opts.once)
@@ -306,16 +341,28 @@ export class Projector {
306
341
  return rep;
307
342
  }
308
343
  await this.startTenant(rep, tenant, false);
344
+ const started = rep.rebuilds;
309
345
  for (;;) {
310
- const r = await this.tenantChunk(rep, tenant);
346
+ // занятая строка (lockTimeout) — как занятая блокировка проектора: подождать и повторить пачку
347
+ const r = await this.tenantChunk(rep, tenant).catch((e) => {
348
+ if (!lockWait(e))
349
+ throw e;
350
+ rep.skipped++;
351
+ return 'busy';
352
+ });
311
353
  if (r === 'busy')
312
354
  await new Promise((res) => setTimeout(res, 20));
313
355
  if (r !== 'done')
314
356
  continue;
315
- // ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор выбывший
316
- const f = (await this.tx(async (q) => this.state(q))).failed?.[tenant];
357
+ // ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор выбывший; чекпоинт держит
358
+ // пачка run() другого процесса дольше lockTimeout — подождать
359
+ const f = (await this.untilFree(rep, () => this.tx(async (q) => this.state(q)))).failed?.[tenant];
317
360
  if (f)
318
361
  throw new LetopisError(f.code, f.message);
362
+ // последнюю пачку мог записать run() другого процесса (он продвигает начатые перестройки): перестройка этого
363
+ // вызова всё равно завершена
364
+ if (rep.rebuilds === started)
365
+ rep.rebuilds++;
319
366
  return rep;
320
367
  }
321
368
  }
@@ -369,6 +416,10 @@ export class Projector {
369
416
  await q.unsafe('begin isolation level read committed');
370
417
  await q.unsafe(`set local role ${ident(this.env.ownerRole)}`);
371
418
  await q.unsafe(`set local timezone = 'UTC'`);
419
+ // чужая блокировка строки (транзакция любого арендатора держит итог или цель) не держит пачку всех арендаторов
420
+ // дольше lockTimeout (хвост ревью безопасности №3): 55P03 — проход пропускается, курсор остаётся
421
+ if (this.lockMs > 0)
422
+ await q.unsafe(`set local lock_timeout = ${this.lockMs}`);
372
423
  const out = await fn(q);
373
424
  await q.unsafe('commit');
374
425
  return out;
@@ -489,6 +540,7 @@ export class Projector {
489
540
  return 'rebuild';
490
541
  if (meta.version !== this.def.version)
491
542
  throw this.versionError(meta);
543
+ this.tenantsPending = Object.values(meta.retenant ?? {}).some((v) => !v.done);
492
544
  const cur = dec(meta.cursor);
493
545
  const rows = await q.unsafe(`select l.tx::text as tx, l.seq::text as seq, ${rowJson('l')} as row
494
546
  from ${this.env.S}.watch_read($1::text::xid8, $2::bigint, null, $3::text[], $4::int, $5::text::pg_snapshot, true) l`, [cur.tx, cur.seq, [...this.from, '#reset'], this.batch, cur.snap ?? null]);
@@ -682,6 +734,27 @@ export class Projector {
682
734
  catch (e) {
683
735
  // причина — первая ошибка базы группы (следом за ней база отвечает лишь «транзакция прервана»)
684
736
  const cause = out.aborted ?? e;
737
+ // строку арендатора держит чужая транзакция дольше lockTimeout (хвост ревью доработок после 1.2.0): отчёт
738
+ // называет арендатора; с lockRetries после стольких проходов подряд он выбывает, и пачка идёт для остальных,
739
+ // иначе откатывается вся пачка и проход повторяется
740
+ if (failed && lockWait(cause)) {
741
+ if (!rep.locked.includes(tenant))
742
+ rep.locked.push(tenant);
743
+ const hits = (this.lockHits.get(tenant) ?? 0) + 1;
744
+ if (this.lockRetries === 0 || hits < this.lockRetries) {
745
+ this.lockHits.set(tenant, hits);
746
+ throw e;
747
+ }
748
+ this.lockHits.delete(tenant);
749
+ await q.unsafe('rollback to savepoint letopis_projector');
750
+ await q.unsafe('release savepoint letopis_projector');
751
+ failed[tenant] = {
752
+ cursor: list[0].cursor || start,
753
+ code: '55P03',
754
+ message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — ${hits} проходов подряд дольше lockTimeout`,
755
+ };
756
+ continue;
757
+ }
685
758
  if (!failed || !out.owns(e) || !tenantFault(cause))
686
759
  throw e;
687
760
  await q.unsafe('rollback to savepoint letopis_projector');
@@ -696,6 +769,7 @@ export class Projector {
696
769
  }
697
770
  if (failed)
698
771
  await q.unsafe('release savepoint letopis_projector');
772
+ this.lockHits.delete(tenant);
699
773
  rep.created += out.stats.created;
700
774
  rep.updated += out.stats.updated;
701
775
  rep.deleted += out.stats.deleted;
@@ -961,6 +1035,9 @@ export class Projector {
961
1035
  */
962
1036
  async startTenant(rep, tenant, auto) {
963
1037
  for (;;) {
1038
+ // строку арендатора (его производные строки удаляются) держит чужая транзакция дольше lockTimeout — как занятая
1039
+ // блокировка проектора: run() повторит на следующем проходе, rebuild() подождёт (хвост ревью доработок после 1.2.0:
1040
+ // rebuild({ tenant, chunked: true }) отдавал сырую 55P03)
964
1041
  const r = await this.tx(async (q) => {
965
1042
  if (!(await this.lock(q)))
966
1043
  return 'busy';
@@ -992,6 +1069,12 @@ export class Projector {
992
1069
  delete next.resync;
993
1070
  await this.advance(q, meta.cursor, withFailed(next, failed));
994
1071
  return 'ok';
1072
+ }).catch((e) => {
1073
+ if (!lockWait(e))
1074
+ throw e;
1075
+ if (!auto)
1076
+ rep.skipped++;
1077
+ return 'busy';
995
1078
  });
996
1079
  if (r !== 'busy' || auto) {
997
1080
  if (r === 'busy')
@@ -1059,29 +1142,47 @@ export class Projector {
1059
1142
  });
1060
1143
  }
1061
1144
  /**
1062
- * Автоматическая перестройка выбывших по cursor_expired (onExpired: 'rebuild') — проход run(): начинает перестройку
1063
- * пачками у каждого такого арендатора и продвигает начатые на пачку. true — работа ещё осталась.
1145
+ * Перестройка арендаторов пачками — проход run(): с start (onExpired: 'rebuild') начинает её у каждого выбывшего по
1146
+ * cursor_expired, затем продвигает на пачку каждую начатую — автоматическую и прерванную rebuild({ tenant, chunked:
1147
+ * true }). true — работа ещё осталась.
1064
1148
  */
1065
- async tenantWork(rep) {
1149
+ async tenantWork(rep, start) {
1066
1150
  const meta = await this.tx(async (q) => this.state(q));
1067
1151
  if (!meta.cursor || meta.state === 'rebuilding' || meta.version !== this.def.version)
1068
1152
  return false;
1069
1153
  let more = false;
1070
- for (const [t, f] of Object.entries(meta.failed ?? {})) {
1071
- if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
1072
- continue;
1073
- if ((await this.startTenant(rep, t, true)) === 'ok')
1074
- more = true;
1154
+ if (start) {
1155
+ for (const [t, f] of Object.entries(meta.failed ?? {})) {
1156
+ if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
1157
+ continue;
1158
+ if ((await this.startTenant(rep, t, true)) === 'ok')
1159
+ more = true;
1160
+ }
1075
1161
  }
1076
- const fresh = await this.tx(async (q) => this.state(q));
1162
+ const fresh = start ? await this.tx(async (q) => this.state(q)) : meta;
1077
1163
  for (const [t, v] of Object.entries(fresh.retenant ?? {})) {
1078
1164
  if (v.done)
1079
1165
  continue;
1080
1166
  if ((await this.tenantChunk(rep, t)) === 'more')
1081
1167
  more = true;
1082
1168
  }
1169
+ this.tenantsPending = more;
1083
1170
  return more;
1084
1171
  }
1172
+ /** Повторять fn, пока строку держит чужая транзакция дольше lockTimeout (55P03): пауза 20 мс, проход — в skipped. */
1173
+ async untilFree(rep, fn) {
1174
+ for (;;) {
1175
+ try {
1176
+ return await fn();
1177
+ }
1178
+ catch (e) {
1179
+ if (!lockWait(e))
1180
+ throw e;
1181
+ rep.skipped++;
1182
+ }
1183
+ await new Promise((res) => setTimeout(res, 20));
1184
+ }
1185
+ }
1085
1186
  /** Ждать сигнал источников, остановку или запасной опрос. */
1086
1187
  sleep() {
1087
1188
  return new Promise((resolve) => {
package/dist/sync.js CHANGED
@@ -11,13 +11,28 @@ function canon(v) {
11
11
  export async function syncModels(db, models, opts = {}) {
12
12
  const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
13
13
  const S = `"${db.schema.replace(/"/g, '""')}"`;
14
- // предок раньше потомка
14
+ // предок раньше потомка; класс-источник агрегата раньше цели: агрегат над классом, которого ещё нет, база отклоняет.
15
+ // Круг (агрегат над собственным потомком, цикл агрегатов) так не упорядочить — отказ до записи с самим кругом (хвост
16
+ // ревью доработок после 1.2.0: раньше обход молча ставил потомка раньше предка)
15
17
  const ordered = [];
18
+ const path = [];
16
19
  const visit = (m) => {
17
20
  if (ordered.includes(m))
18
21
  return;
22
+ const at = path.indexOf(m);
23
+ if (at >= 0) {
24
+ throw new LetopisError('invalid_class', `syncModels: модели зависят друг от друга по кругу: ${[...path.slice(at), m].map((x) => x.name).join(' → ')} — `
25
+ + 'родителя и источник агрегата база ждёт раньше класса');
26
+ }
27
+ path.push(m);
19
28
  if (m.parent && models.includes(m.parent))
20
29
  visit(m.parent);
30
+ for (const a of Object.values(m.describe().aggregate ?? {})) {
31
+ const src = models.find((x) => x.name === a.of);
32
+ if (src)
33
+ visit(src);
34
+ }
35
+ path.pop();
21
36
  ordered.push(m);
22
37
  };
23
38
  models.forEach(visit);
package/dist/up.d.ts CHANGED
@@ -57,6 +57,23 @@ export interface InstallResult {
57
57
  export declare const quoteIdent: (name: string) => string;
58
58
  /** Файлы ядра по номеру: `10-core.sql` … `99-revision.sql`; сиды демо-домена сюда не входят. */
59
59
  export declare function engineFiles(upTo?: number): Promise<string[]>;
60
+ /** Штамп движка: версия пакета и хэш файлов ядра (`99-revision.sql` пишет его в комментарий схемы). */
61
+ export interface EngineStamp {
62
+ version: string;
63
+ sql: string;
64
+ }
65
+ /**
66
+ * Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
67
+ * сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
68
+ */
69
+ export declare function engineStamp(): Promise<EngineStamp>;
70
+ /** Сравнение версий semver (метаданные сборки не учитываются): меньше нуля, ноль, больше нуля. */
71
+ export declare function compareVersions(a: string, b: string): number;
72
+ /**
73
+ * Штамп схемы (комментарий схемы) против файлов ядра пакета — текст предупреждения или null. Ревизия та же, а
74
+ * функции поставила другая версия движка: после обновления пакета не накатили up() (так было при 1.1 → 1.2).
75
+ */
76
+ export declare function stampDrift(schema: string, comment: string): Promise<string | null>;
60
77
  export declare function substitute(text: string, vars: Record<string, string>): string;
61
78
  export declare function install(sql: postgres.Sql, opts: InstallOptions): Promise<InstallResult>;
62
79
  /** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
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';
@@ -34,6 +35,65 @@ export async function engineFiles(upTo = 99) {
34
35
  const files = (await readdir(SQL_DIR)).filter((f) => /^\d{2}-[a-z0-9.-]+\.sql$/.test(f));
35
36
  return files.filter((f) => Number(f.slice(0, 2)) <= upTo).sort();
36
37
  }
38
+ let stamp;
39
+ /**
40
+ * Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
41
+ * сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
42
+ */
43
+ export async function engineStamp() {
44
+ if (stamp)
45
+ return stamp;
46
+ const h = createHash('sha256');
47
+ for (const f of await engineFiles())
48
+ h.update(`${f}\n${(await readFile(join(SQL_DIR, f), 'utf8')).replace(/\r\n/g, '\n')}\n`);
49
+ const version = createRequire(import.meta.url)('../package.json').version;
50
+ return (stamp = { version, sql: h.digest('hex').slice(0, 16) });
51
+ }
52
+ /** Сравнение версий semver (метаданные сборки не учитываются): меньше нуля, ноль, больше нуля. */
53
+ export function compareVersions(a, b) {
54
+ const parse = (v) => {
55
+ const [core, pre] = v.split('+')[0].split(/-(.*)/s);
56
+ return { core: core.split('.').map(Number), pre: pre ? pre.split('.') : [] };
57
+ };
58
+ const x = parse(a);
59
+ const y = parse(b);
60
+ for (let i = 0; i < 3; i++)
61
+ if ((x.core[i] ?? 0) !== (y.core[i] ?? 0))
62
+ return (x.core[i] ?? 0) - (y.core[i] ?? 0);
63
+ if (!x.pre.length || !y.pre.length)
64
+ return y.pre.length - x.pre.length; // выпуск старше своих предварительных версий
65
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
66
+ const p = x.pre[i];
67
+ const q = y.pre[i];
68
+ if (p === undefined || q === undefined)
69
+ return p === undefined ? -1 : 1;
70
+ if (p === q)
71
+ continue;
72
+ const pn = /^\d+$/.test(p);
73
+ const qn = /^\d+$/.test(q);
74
+ if (pn && qn)
75
+ return Number(p) - Number(q);
76
+ if (pn !== qn)
77
+ return pn ? -1 : 1;
78
+ return p < q ? -1 : 1;
79
+ }
80
+ return 0;
81
+ }
82
+ /**
83
+ * Штамп схемы (комментарий схемы) против файлов ядра пакета — текст предупреждения или null. Ревизия та же, а
84
+ * функции поставила другая версия движка: после обновления пакета не накатили up() (так было при 1.1 → 1.2).
85
+ */
86
+ export async function stampDrift(schema, comment) {
87
+ const code = await engineStamp().catch(() => null); // файлов ядра рядом со сборкой нет — сверять не с чем
88
+ if (!code || /\bsql=([0-9a-f]+)/.exec(comment)?.[1] === code.sql)
89
+ return null;
90
+ const was = /\bengine=(\S+)/.exec(comment)?.[1];
91
+ if (was && compareVersions(was, code.version) > 0) {
92
+ return `letopis: схему ${schema} обновила letopis ${was}, библиотека старее (${code.version}) — обновите пакет: up() этой версией вернул бы прежние функции`;
93
+ }
94
+ const by = !was ? 'версия до 1.4.0' : was === code.version ? `другая сборка letopis ${was}` : `letopis ${was}`;
95
+ return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: накатите up({ schema: '${schema}' })`;
96
+ }
37
97
  export function substitute(text, vars) {
38
98
  return text.replace(/@([A-Z_]+)@/g, (m, k) => {
39
99
  if (!(k in vars))
@@ -88,6 +148,10 @@ export async function install(sql, opts) {
88
148
  // режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
89
149
  SIGNALS: 'class',
90
150
  };
151
+ // штамп движка — в комментарий схемы (99-revision.sql): по нему connect() узнаёт схему, не накатанную этой версией
152
+ const st = await engineStamp();
153
+ vars.ENGINE_VERSION = st.version;
154
+ vars.ENGINE_SQL = st.sql;
91
155
  // Схема создаётся до set role: у владельца может не быть права CREATE в базе.
92
156
  await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
93
157
  const files = await engineFiles(opts.upTo);
package/dist/watch.js CHANGED
@@ -116,10 +116,35 @@ export class Subscription {
116
116
  if (!this.pos)
117
117
  this.pos = await (this.start ?? this.head());
118
118
  const p = this.pos;
119
- const rows = await this.ctx.exec.run((q) => q.unsafe(`select l.tx::text as tx, l.seq::text as seq, jsonb_build_object('id', l.id, 'rev', l.rev, 'class', l.class, 'tenant', l.tenant, `
119
+ const cols = `l.tx::text as tx, l.seq::text as seq, jsonb_build_object('id', l.id, 'rev', l.rev, 'class', l.class, 'tenant', l.tenant, `
120
120
  + `'owner', l.owner, 'links', l.links, 'data', l.data, 'tags', l.tags, 'at', l.at, 'author', l.author, 'agent', l.agent, `
121
- + `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row `
122
- + `from ${this.ctx.S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
121
+ + `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row`;
122
+ // уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
123
+ // версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
124
+ // оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
125
+ // (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом
126
+ const S = this.ctx.S;
127
+ 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
+ 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
+ select 0 as src, ${cols} from r l
130
+ union all
131
+ 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 });
133
+ const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
134
+ const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
135
+ const read = both.filter((r) => r.src === 0).sort(order);
136
+ const away = both.filter((r) => r.src === 1).sort(order);
137
+ let rows = read;
138
+ {
139
+ const upto = read.length === this.batch ? read[read.length - 1] : null;
140
+ if (away.length) {
141
+ let bound = upto;
142
+ const lastAway = away[away.length - 1];
143
+ if (away.length === this.batch && (!bound || before(lastAway, bound)))
144
+ bound = lastAway;
145
+ rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
146
+ }
147
+ }
123
148
  for (const r of rows) {
124
149
  const row = toRow(r.row);
125
150
  const snap = keepSnap(p.snap, r.tx);
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
4
4
 
5
- Key facts for writing code with letopis 1.2:
5
+ Key facts for writing code with letopis 1.4:
6
6
 
7
7
  - ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
8
8
  - Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
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",
@@ -101,6 +101,7 @@
101
101
  "@types/node": "^22.20.5",
102
102
  "letopis-0": "npm:letopis@0.21.0",
103
103
  "letopis-1": "npm:letopis@1.0.1",
104
+ "letopis-11": "npm:letopis@1.1.0",
104
105
  "tsx": "^4.23.15",
105
106
  "typescript": "~5.9.3",
106
107
  "zod": "4.6.5"
package/sql/10-core.sql CHANGED
@@ -6,7 +6,9 @@
6
6
  revoke all on schema @SCHEMA@ from public;
7
7
  grant usage on schema @SCHEMA@ to @APP@;
8
8
 
9
- -- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}.
9
+ -- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}. Тело — SQL-стандарта (return): имена связываются
10
+ -- при создании и от search_path сеанса не зависят, а колонку ends вычисляет роль приложения. Вариант на plpgsql быстрее
11
+ -- на 17 % (бенч gencols), но его имена разрешались бы при исполнении (ревью безопасности доработок после 1.2.0)
10
12
  create or replace function @SCHEMA@.link_ids(links jsonb) returns uuid[]
11
13
  language sql immutable parallel safe
12
14
  return (
package/sql/50-acl.sql CHANGED
@@ -228,9 +228,13 @@ begin
228
228
  and jsonb_typeof(x.pattern->'class') <> 'string', false) as bad) m
229
229
  where x.deny or not m.bad
230
230
  ), cl as (
231
- select c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
231
+ -- один класс на имя — тот, что строки арендатора находят по имени (class_row): свой, иначе System. Одноимённый
232
+ -- класс System, заведённый после класса арендатора, в план не входит: его предки и алиас давали бы права на строки
233
+ -- класса арендатора (хвост ревью доработок после 1.2.0)
234
+ select distinct on (c.data->>'name') c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
232
235
  from @SCHEMA@.entity c
233
236
  where c.class = 'Class' and c.tenant in (p_tenant, '@SYSTEM@'::uuid)
237
+ order by c.data->>'name', c.tenant = '@SYSTEM@'::uuid
234
238
  ), k as (
235
239
  select cl.name,
236
240
  array(select jsonb_array_elements_text(cl.anc))