letopis 1.4.0 → 1.4.2

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 (62) hide show
  1. package/AGENT-CHEATSHEET.en.md +15 -7
  2. package/AGENT-CHEATSHEET.md +19 -11
  3. package/CHANGELOG.md +299 -1
  4. package/README.en.md +180 -62
  5. package/README.md +165 -55
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +53 -18
  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/cache.js +3 -2
  13. package/dist/chain.d.ts +92 -15
  14. package/dist/chain.js +56 -7
  15. package/dist/cli.js +16 -2
  16. package/dist/errors.d.ts +2 -2
  17. package/dist/errors.js +5 -5
  18. package/dist/import.js +7 -0
  19. package/dist/index.d.ts +73 -45
  20. package/dist/index.js +69 -14
  21. package/dist/internal.d.ts +7 -0
  22. package/dist/internal.js +5 -0
  23. package/dist/load.d.ts +10 -3
  24. package/dist/load.js +39 -11
  25. package/dist/mcp.d.ts +20 -11
  26. package/dist/mcp.js +8 -6
  27. package/dist/model.d.ts +4 -1
  28. package/dist/model.js +7 -1
  29. package/dist/ops.d.ts +58 -18
  30. package/dist/ops.js +58 -18
  31. package/dist/project.d.ts +39 -11
  32. package/dist/project.js +75 -15
  33. package/dist/registry.d.ts +4 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/signals.d.ts +4 -4
  36. package/dist/signals.js +4 -4
  37. package/dist/sql.d.ts +8 -4
  38. package/dist/sql.js +110 -25
  39. package/dist/sync.js +11 -4
  40. package/dist/tx.d.ts +15 -0
  41. package/dist/tx.js +22 -4
  42. package/dist/types.d.ts +21 -7
  43. package/dist/types.js +13 -6
  44. package/dist/up.d.ts +48 -25
  45. package/dist/up.js +94 -41
  46. package/dist/validate.d.ts +9 -2
  47. package/dist/validate.js +19 -3
  48. package/dist/watch.d.ts +29 -5
  49. package/dist/watch.js +62 -13
  50. package/dist/write.d.ts +3 -2
  51. package/dist/write.js +52 -19
  52. package/package.json +8 -4
  53. package/sql/10-core.sql +6 -1
  54. package/sql/60-write.sql +47 -18
  55. package/sql/70-read.sql +13 -7
  56. package/sql/75-history.sql +106 -28
  57. package/sql/80-auth.sql +41 -3
  58. package/sql/88-derived.sql +3 -1
  59. package/sql/90-time.sql +118 -30
  60. package/sql/95-seed.system.sql +6 -3
  61. package/sql/99-revision.sql +1 -1
  62. package/sql/migrate/0007-secret-account-fk.sql +12 -0
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 {
@@ -27,10 +31,14 @@ export interface WatchCtx {
27
31
  exec: Executor;
28
32
  /** Классы подписки с потомками (null — все). */
29
33
  classes: string[] | null;
34
+ /** Классы с потомками заново — по текущему реестру: потомки, заведённые после watch(), тоже в подписке (план 2.0, 7.4 №2). */
35
+ classesOf?: () => string[] | null;
30
36
  /** Подписка на сигналы базы (разобранные); возвращает отписку. */
31
37
  onSignal?: (fn: (s: Signal) => void) => () => void;
32
38
  /** Токен арендатора подписки (null — процесс без ключа или режим без токенов). */
33
39
  tenant?: () => string | null;
40
+ /** Подписка закрылась: подключение перестаёт её учитывать (план 2.0, 7.4 №8). */
41
+ onClose?: (s: Subscription) => void;
34
42
  }
35
43
  /**
36
44
  * Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
@@ -43,9 +51,19 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
43
51
  private opts;
44
52
  private pos;
45
53
  private buf;
54
+ /** Курсор после последнего выданного события, пока в буфере есть невыданные (pos — уже после всей пачки). */
55
+ private given;
46
56
  private closed;
47
57
  private wake;
48
58
  private dirty;
59
+ /** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
60
+ private burst;
61
+ /**
62
+ * from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
63
+ * cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
64
+ * после прореживания — cursor_expired, как обещано.
65
+ */
66
+ private catchingUp;
49
67
  private off;
50
68
  private timer;
51
69
  private failure;
@@ -56,11 +74,17 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
56
74
  /** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
57
75
  private queue;
58
76
  constructor(ctx: WatchCtx, opts: WatchOptions);
59
- /** Курсор последнего выданного события (или начальной точки). */
77
+ /**
78
+ * Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
79
+ * в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
80
+ * курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
81
+ * cursor_expired.
82
+ */
60
83
  get cursor(): string | null;
61
84
  /**
62
- * Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
63
- * у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
85
+ * Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
86
+ * (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
87
+ * выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
64
88
  */
65
89
  ready(): Promise<void>;
66
90
  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,9 +56,19 @@ 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;
64
+ /** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
65
+ burst = false;
66
+ /**
67
+ * from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
68
+ * cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
69
+ * после прореживания — cursor_expired, как обещано.
70
+ */
71
+ catchingUp = false;
60
72
  off = null;
61
73
  timer = null;
62
74
  failure = null;
@@ -72,8 +84,10 @@ export class Subscription {
72
84
  this.batch = opts.batch ?? 500;
73
85
  this.pollMs = opts.pollMs ?? 1000;
74
86
  const from = opts.from ?? 'now';
75
- if (from === 'start')
87
+ if (from === 'start') {
76
88
  this.pos = { tx: '0', seq: '0', at: null };
89
+ this.catchingUp = true;
90
+ }
77
91
  else if (from !== 'now')
78
92
  this.pos = decode(from);
79
93
  else {
@@ -81,11 +95,12 @@ export class Subscription {
81
95
  this.start.catch(() => { });
82
96
  }
83
97
  if (ctx.onSignal) {
84
- const watched = ctx.classes ? new Set(ctx.classes) : null;
85
98
  this.off = ctx.onSignal((s) => {
86
- if (!wakes(s, watched, ctx.tenant?.() ?? null))
99
+ const cls = ctx.classesOf?.() ?? ctx.classes;
100
+ if (!wakes(s, cls ? new Set(cls) : null, ctx.tenant?.() ?? null))
87
101
  return;
88
102
  this.dirty = true;
103
+ this.burst = true;
89
104
  this.wake?.();
90
105
  });
91
106
  }
@@ -95,13 +110,21 @@ export class Subscription {
95
110
  }, this.pollMs);
96
111
  this.timer.unref?.();
97
112
  }
98
- /** Курсор последнего выданного события (или начальной точки). */
113
+ /**
114
+ * Курсор подписки: позиция после последнего выданного события (или начальная точка). Догнав журнал, подписка обновляет
115
+ * в нём время и на пустых опросах, поэтому его, а не курсор события, сохраняют, чтобы продолжить после перезапуска:
116
+ * курсор события хранит время события, и когда оно выйдет за ярус all политики хранения, продолжение с него —
117
+ * cursor_expired.
118
+ */
99
119
  get cursor() {
120
+ if (this.buf.length)
121
+ return this.given;
100
122
  return this.pos ? encode(this.pos) : null;
101
123
  }
102
124
  /**
103
- * Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
104
- * у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
125
+ * Промис выполняется, когда подписка запомнила начальную точку: запись, сделанная после этого, придёт в поток
126
+ * (доработки после точки Б). У `from: 'now'` точку запоминает отдельный запрос к базе, у `'start'` и курсора промис
127
+ * выполняется сразу; если запрос не удался, промис отклоняется с его ошибкой.
105
128
  */
106
129
  async ready() {
107
130
  if (!this.pos && this.start)
@@ -122,18 +145,28 @@ export class Subscription {
122
145
  // уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
123
146
  // версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
124
147
  // оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
125
- // (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом
148
+ // (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом.
149
+ // Третья строка (src 2) — время курсора, когда пачка неполная (подписка догнала журнал): «сейчас», но не позже самой
150
+ // старой записи подписки, которая уже зафиксирована, а горизонт ещё не прошла (её держит более старая транзакция;
151
+ // снимок «now» — без записей, которые подписка не отдаёт). Тот же снимок, что у watch_read
126
152
  const S = this.ctx.S;
127
153
  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
154
  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
155
  select 0 as src, ${cols} from r l
130
156
  union all
131
157
  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 });
158
+ (select tx from u), (select seq from u)) l
159
+ union all
160
+ select 2 as src, null, null, jsonb_build_object('at', least(statement_timestamp(), min(l.at))) from ${S}.log l
161
+ where (select count(*) from r) < $5 and (l.tx, l.seq) > ($1::text::xid8, $2::bigint)
162
+ and l.tx >= pg_snapshot_xmin(pg_current_snapshot()) and ($4::text[] is null or l.class = any ($4::text[]))
163
+ and ($6::text::pg_snapshot is null or not pg_visible_in_snapshot(l.tx, $6::text::pg_snapshot))
164
+ having (select count(*) from r) < $5`, [p.tx, p.seq, this.catchingUp ? null : p.at, this.ctx.classesOf?.() ?? this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
133
165
  const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
134
166
  const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
135
167
  const read = both.filter((r) => r.src === 0).sort(order);
136
168
  const away = both.filter((r) => r.src === 1).sort(order);
169
+ const caught = both.find((r) => r.src === 2);
137
170
  let rows = read;
138
171
  {
139
172
  const upto = read.length === this.batch ? read[read.length - 1] : null;
@@ -145,12 +178,21 @@ export class Subscription {
145
178
  rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
146
179
  }
147
180
  }
181
+ // пока события пачки не выданы, курсор подписки — позиция до пачки
182
+ if (rows.length)
183
+ this.given = encode(p);
148
184
  for (const r of rows) {
149
185
  const row = toRow(r.row);
150
186
  const snap = keepSnap(p.snap, r.tx);
151
187
  this.pos = { tx: r.tx, seq: r.seq, at: row.at, ...(snap ? { snap } : {}) };
152
188
  this.buf.push({ cursor: encode(this.pos), row, id: row.id, class: row.class, op: row.op, rev: row.rev, at: row.at });
153
189
  }
190
+ // догнала журнал — время курсора подписки (sub.cursor) обновляется и на пустых опросах: прореживать после него нечего,
191
+ // и подписка, к которой долго не было событий, не получает ложный cursor_expired. Курсоры событий хранят время события
192
+ if (caught && away.length < this.batch) {
193
+ this.pos = { ...(this.pos ?? p), at: String(caught.row.at) };
194
+ this.catchingUp = false;
195
+ }
154
196
  return rows.length;
155
197
  }
156
198
  /**
@@ -167,8 +209,11 @@ export class Subscription {
167
209
  for (;;) {
168
210
  if (this.failure)
169
211
  throw this.failure;
170
- if (this.buf.length)
171
- return { value: this.buf.shift(), done: false };
212
+ if (this.buf.length) {
213
+ const ev = this.buf.shift();
214
+ this.given = ev.cursor;
215
+ return { value: ev, done: false };
216
+ }
172
217
  if (this.closed)
173
218
  return { value: undefined, done: true };
174
219
  if (this.dirty) {
@@ -184,12 +229,15 @@ export class Subscription {
184
229
  this.close();
185
230
  throw e;
186
231
  }
187
- // сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной опрос
188
- if (++empty < 40) {
232
+ // сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной опрос. Опрос по таймеру —
233
+ // один запрос: серия повторов на каждый тик давала около 4 запросов в секунду на молчащую подписку (план 2.0,
234
+ // 7.4 №1)
235
+ if (this.burst && ++empty < 40) {
189
236
  await sleep(Math.min(25 * empty, 250));
190
237
  continue;
191
238
  }
192
239
  this.dirty = false;
240
+ this.burst = false;
193
241
  empty = 0;
194
242
  }
195
243
  await new Promise((r) => {
@@ -216,6 +264,7 @@ export class Subscription {
216
264
  return;
217
265
  this.closed = true;
218
266
  this.off?.();
267
+ this.ctx.onClose?.(this);
219
268
  if (this.timer)
220
269
  clearInterval(this.timer);
221
270
  this.wake?.();
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 } : {}) });
@@ -184,6 +185,9 @@ export function precheckPlan(reg, steps) {
184
185
  if (!Array.isArray(op.fields) || !op.fields.length)
185
186
  fail('invalid_query', 'anonymize(поля): нужен список полей');
186
187
  for (const f of op.fields) {
188
+ // поля нет в классе — так и сказано, а не «поле jsonb» (план 2.0, 7.5 №3)
189
+ if (!(f in (s.cls.schema.properties ?? {})))
190
+ fail('invalid_query', `anonymize(): поля ${f} нет в классе ${s.cls.name}`);
187
191
  const t = reg.leafType(s.cls.name, [f]);
188
192
  if (t !== 'text')
189
193
  fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
@@ -342,9 +346,17 @@ async function slotIds(w, v) {
342
346
  if (Array.isArray(v))
343
347
  return v;
344
348
  if ('plan' in v) {
345
- const rows = v.plan.some((s) => s.op)
346
- ? (await execPlan(w, v.plan, v.mods, 'rows')).rows
347
- : await readRows(w, v.plan, v.mods);
349
+ let rows;
350
+ if (v.plan.some((s) => s.op)) {
351
+ // вложенный план — те же проверки до базы, что у плана терминала; с читающим хвостом после глагола execPlan
352
+ // отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
353
+ precheckPlan(w.reg, v.plan);
354
+ const r = await execPlan(w, v.plan, v.mods, 'rows');
355
+ rows = r.kind === 'rows' ? r.rows : r.res;
356
+ }
357
+ else {
358
+ rows = await readRows(w, v.plan, v.mods);
359
+ }
348
360
  return rows.map((r) => r.id);
349
361
  }
350
362
  return [v.id];
@@ -377,7 +389,11 @@ function applySlotsLocal(links, ops) {
377
389
  }
378
390
  return out;
379
391
  }
380
- const tagsValue = (s) => s.tagsFilter === undefined ? null : typeof s.tagsFilter === 'string' ? [s.tagsFilter] : s.tagsFilter;
392
+ /**
393
+ * Теги, которые пишет глагол: строка или список из `.tags(…)` (перед update они же и отбирают цели). Оператор
394
+ * (has, hasAny, hasAll) только отбирает — теги целей не меняются; create и upsert его не принимают (precheck).
395
+ */
396
+ const tagsValue = (s) => typeof s.tagsFilter === 'string' ? [s.tagsFilter] : Array.isArray(s.tagsFilter) ? s.tagsFilter : null;
381
397
  /** «Уже существует»: id вычисляет библиотека (idOf), а не разбирает текст ошибки сервера (§2.5). */
382
398
  function existsError(w, cls, data, links, given, cause) {
383
399
  let id = given;
@@ -440,12 +456,23 @@ async function explainMissing(w, missing, rev) {
440
456
  }
441
457
  return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
442
458
  }
459
+ /**
460
+ * Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
461
+ * отдаёт их в порядке обхода таблицы, и `first()` после глагола брал бы случайную из исправленных строк.
462
+ */
463
+ function inTargetOrder(ids, rows) {
464
+ const pos = new Map(ids.map((id, i) => [id, i]));
465
+ return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
466
+ }
443
467
  /** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
444
468
  async function targetIds(w, steps, opStep, dataId) {
445
469
  const path = steps.map(bare);
446
470
  if (opStep.filter === undefined && dataId !== undefined)
447
471
  path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
448
- return readIds(w, path, opStep.opMods ?? {});
472
+ const ids = await readIds(w, path, opStep.opMods ?? {});
473
+ // без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
474
+ // записей не должен зависеть от плана чтения
475
+ return opStep.opMods?.order ? ids : [...ids].sort();
449
476
  }
450
477
  async function updateOp(w, steps, opStep) {
451
478
  const op = opStep.op;
@@ -456,7 +483,7 @@ async function updateOp(w, steps, opStep) {
456
483
  const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
457
484
  if (op.rev !== undefined) {
458
485
  if (ids.length > 1)
459
- fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — строгий режим только для одной`);
486
+ fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
460
487
  if (!ids.length)
461
488
  fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
462
489
  }
@@ -481,7 +508,7 @@ async function updateOp(w, steps, opStep) {
481
508
  const got = new Set(res.map((r) => r.row.id));
482
509
  await explainMissing(w, ids.filter((x) => !got.has(x)), op.rev);
483
510
  }
484
- return res.map((r) => toRow(r.row));
511
+ return inTargetOrder(ids, res.map((r) => toRow(r.row)));
485
512
  }
486
513
  async function deleteOp(w, steps, opStep) {
487
514
  const op = opStep.op;
@@ -510,9 +537,11 @@ async function deleteOp(w, steps, opStep) {
510
537
  }
511
538
  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
539
  const out = tombs.map((r) => toRow(r.row));
513
- // строки классов без журнала (history: false) надгробий не оставляют — берём их из returning
540
+ // строки классов без журнала (history: false) надгробий не оставляют — берём их из returning, по id (порядок
541
+ // returning зависит от плана, а итог delete() — нет: надгробия идут по seq, триггер пишет их в порядке id)
514
542
  const have = new Set(out.map((r) => r.id));
515
- for (const g of gone) {
543
+ const byId = (a, b) => String(a.row.id) < String(b.row.id) ? -1 : String(a.row.id) > String(b.row.id) ? 1 : 0;
544
+ for (const g of [...gone].sort(byId)) {
516
545
  if (have.has(g.row.id))
517
546
  continue;
518
547
  const row = toRow(g.row);
@@ -534,7 +563,7 @@ async function anonymizeOp(w, steps, opStep) {
534
563
  const got = new Set(res.map((r) => r.row.id));
535
564
  await explainMissing(w, ids.filter((x) => !got.has(x)));
536
565
  }
537
- return res.map((r) => toRow(r.row));
566
+ return inTargetOrder(ids, res.map((r) => toRow(r.row)));
538
567
  }
539
568
  async function reclassOp(w, steps, opStep) {
540
569
  const op = opStep.op;
@@ -546,7 +575,8 @@ async function reclassOp(w, steps, opStep) {
546
575
  // у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
547
576
  const moved = await query(w, `select ${S}.reclass(x, $2::text, $3::jsonb) as id from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids, cls, op.data ?? null]);
548
577
  const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved.map((m) => m.id)]);
549
- return res.map((r) => toRow(r.row));
578
+ // строки ответа — в порядке целей (moved — по порядку целей, план 2.0, 7.2 №2)
579
+ return inTargetOrder(moved.map((m) => m.id), res.map((r) => toRow(r.row)));
550
580
  }
551
581
  /** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
552
582
  async function deletedTargets(w, steps, opStep, verb) {
@@ -554,7 +584,8 @@ async function deletedTargets(w, steps, opStep, verb) {
554
584
  const dead = rows.filter((r) => r.$deleted);
555
585
  if (!dead.length && rows.length)
556
586
  fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
557
- return dead;
587
+ // без явного порядка — по id: restore и purge пишут журнал по очереди целей
588
+ return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
558
589
  }
559
590
  /** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
560
591
  async function restoreOp(w, steps, opStep) {
@@ -564,8 +595,8 @@ async function restoreOp(w, steps, opStep) {
564
595
  return [];
565
596
  const ids = dead.map((r) => r.id);
566
597
  await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
567
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [ids]);
568
- return res.map((r) => toRow(r.row));
598
+ const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
599
+ return inTargetOrder(ids, res.map((r) => toRow(r.row)));
569
600
  }
570
601
  /**
571
602
  * Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
@@ -610,8 +641,8 @@ async function rekeyOp(w, steps, opStep) {
610
641
  throw e;
611
642
  }
612
643
  }
613
- const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [moved]);
614
- return res.map((r) => toRow(r.row));
644
+ const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved]);
645
+ return inTargetOrder(moved, res.map((r) => toRow(r.row)));
615
646
  }
616
647
  async function opCall(w, steps, opStep) {
617
648
  await useReason(w, reasonFor(w, opStep));
@@ -752,7 +783,9 @@ export async function execBatch(w, plans) {
752
783
  && reasonFor(w, plans[j][0]) === why) {
753
784
  const s = plans[j][0];
754
785
  const id = idFor(s);
755
- if (!id || (verb === 'upsert' && seen.has(id)))
786
+ // повтор id режет группу и у create: иначе вторая строка «находила» строку первой, и батч проходил молча
787
+ // (план 2.0, 7.2 №1); повтор уходит следующим оператором и получает exists
788
+ if (!id || seen.has(id))
756
789
  break;
757
790
  seen.add(id);
758
791
  group.push({ at: j, s, id });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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",
@@ -68,16 +68,19 @@
68
68
  "seed:booking": "tsx scripts/gen-seed-booking.mjs",
69
69
  "test:import-salon": "tsx bench/import-salon.mjs",
70
70
  "api:contract": "node scripts/gen-api-contract.mjs",
71
- "prepublishOnly": "npm run typecheck && npm run check:docs && npm run build"
71
+ "prepublishOnly": "npm run typecheck && npm run check:docs && npm run build",
72
+ "check:semver": "node scripts/check-semver.mjs",
73
+ "prerelease": "node scripts/prerelease.mjs",
74
+ "soak": "tsx bench/soak.mjs"
72
75
  },
73
76
  "dependencies": {
74
77
  "postgres": "^3.4.9"
75
78
  },
76
79
  "peerDependencies": {
77
- "zod": "^4",
78
80
  "@electric-sql/pglite": "^0.5.8",
79
81
  "@electric-sql/pglite-socket": "^0.2.11",
80
- "@modelcontextprotocol/server": "^2.4.0"
82
+ "@modelcontextprotocol/server": "^2.4.0",
83
+ "zod": "^4"
81
84
  },
82
85
  "peerDependenciesMeta": {
83
86
  "zod": {
@@ -102,6 +105,7 @@
102
105
  "letopis-0": "npm:letopis@0.21.0",
103
106
  "letopis-1": "npm:letopis@1.0.1",
104
107
  "letopis-11": "npm:letopis@1.1.0",
108
+ "letopis-141": "npm:letopis@1.4.1",
105
109
  "tsx": "^4.23.15",
106
110
  "typescript": "~5.9.3",
107
111
  "zod": "4.6.5"
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@;