letopis 1.4.1 → 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.
package/dist/watch.js CHANGED
@@ -61,6 +61,14 @@ export class Subscription {
61
61
  closed = false;
62
62
  wake = null;
63
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;
64
72
  off = null;
65
73
  timer = null;
66
74
  failure = null;
@@ -76,8 +84,10 @@ export class Subscription {
76
84
  this.batch = opts.batch ?? 500;
77
85
  this.pollMs = opts.pollMs ?? 1000;
78
86
  const from = opts.from ?? 'now';
79
- if (from === 'start')
87
+ if (from === 'start') {
80
88
  this.pos = { tx: '0', seq: '0', at: null };
89
+ this.catchingUp = true;
90
+ }
81
91
  else if (from !== 'now')
82
92
  this.pos = decode(from);
83
93
  else {
@@ -85,11 +95,12 @@ export class Subscription {
85
95
  this.start.catch(() => { });
86
96
  }
87
97
  if (ctx.onSignal) {
88
- const watched = ctx.classes ? new Set(ctx.classes) : null;
89
98
  this.off = ctx.onSignal((s) => {
90
- 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))
91
101
  return;
92
102
  this.dirty = true;
103
+ this.burst = true;
93
104
  this.wake?.();
94
105
  });
95
106
  }
@@ -150,7 +161,7 @@ export class Subscription {
150
161
  where (select count(*) from r) < $5 and (l.tx, l.seq) > ($1::text::xid8, $2::bigint)
151
162
  and l.tx >= pg_snapshot_xmin(pg_current_snapshot()) and ($4::text[] is null or l.class = any ($4::text[]))
152
163
  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 });
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 });
154
165
  const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
155
166
  const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
156
167
  const read = both.filter((r) => r.src === 0).sort(order);
@@ -178,8 +189,10 @@ export class Subscription {
178
189
  }
179
190
  // догнала журнал — время курсора подписки (sub.cursor) обновляется и на пустых опросах: прореживать после него нечего,
180
191
  // и подписка, к которой долго не было событий, не получает ложный cursor_expired. Курсоры событий хранят время события
181
- if (caught && away.length < this.batch)
192
+ if (caught && away.length < this.batch) {
182
193
  this.pos = { ...(this.pos ?? p), at: String(caught.row.at) };
194
+ this.catchingUp = false;
195
+ }
183
196
  return rows.length;
184
197
  }
185
198
  /**
@@ -216,12 +229,15 @@ export class Subscription {
216
229
  this.close();
217
230
  throw e;
218
231
  }
219
- // сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной опрос
220
- if (++empty < 40) {
232
+ // сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной опрос. Опрос по таймеру —
233
+ // один запрос: серия повторов на каждый тик давала около 4 запросов в секунду на молчащую подписку (план 2.0,
234
+ // 7.4 №1)
235
+ if (this.burst && ++empty < 40) {
221
236
  await sleep(Math.min(25 * empty, 250));
222
237
  continue;
223
238
  }
224
239
  this.dirty = false;
240
+ this.burst = false;
225
241
  empty = 0;
226
242
  }
227
243
  await new Promise((r) => {
@@ -248,6 +264,7 @@ export class Subscription {
248
264
  return;
249
265
  this.closed = true;
250
266
  this.off?.();
267
+ this.ctx.onClose?.(this);
251
268
  if (this.timer)
252
269
  clearInterval(this.timer);
253
270
  this.wake?.();
package/dist/write.js CHANGED
@@ -185,6 +185,9 @@ export function precheckPlan(reg, steps) {
185
185
  if (!Array.isArray(op.fields) || !op.fields.length)
186
186
  fail('invalid_query', 'anonymize(поля): нужен список полей');
187
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}`);
188
191
  const t = reg.leafType(s.cls.name, [f]);
189
192
  if (t !== 'text')
190
193
  fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
@@ -343,9 +346,17 @@ async function slotIds(w, v) {
343
346
  if (Array.isArray(v))
344
347
  return v;
345
348
  if ('plan' in v) {
346
- const rows = v.plan.some((s) => s.op)
347
- ? (await execPlan(w, v.plan, v.mods, 'rows')).rows
348
- : 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
+ }
349
360
  return rows.map((r) => r.id);
350
361
  }
351
362
  return [v.id];
@@ -564,7 +575,8 @@ async function reclassOp(w, steps, opStep) {
564
575
  // у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
565
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]);
566
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)]);
567
- 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)));
568
580
  }
569
581
  /** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
570
582
  async function deletedTargets(w, steps, opStep, verb) {
@@ -583,8 +595,8 @@ async function restoreOp(w, steps, opStep) {
583
595
  return [];
584
596
  const ids = dead.map((r) => r.id);
585
597
  await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
586
- 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]);
587
- 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)));
588
600
  }
589
601
  /**
590
602
  * Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
@@ -629,8 +641,8 @@ async function rekeyOp(w, steps, opStep) {
629
641
  throw e;
630
642
  }
631
643
  }
632
- 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]);
633
- 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)));
634
646
  }
635
647
  async function opCall(w, steps, opStep) {
636
648
  await useReason(w, reasonFor(w, opStep));
@@ -771,7 +783,9 @@ export async function execBatch(w, plans) {
771
783
  && reasonFor(w, plans[j][0]) === why) {
772
784
  const s = plans[j][0];
773
785
  const id = idFor(s);
774
- if (!id || (verb === 'upsert' && seen.has(id)))
786
+ // повтор id режет группу и у create: иначе вторая строка «находила» строку первой, и батч проходил молча
787
+ // (план 2.0, 7.2 №1); повтор уходит следующим оператором и получает exists
788
+ if (!id || seen.has(id))
775
789
  break;
776
790
  seen.add(id);
777
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.1",
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/60-write.sql CHANGED
@@ -472,8 +472,12 @@ as $$
472
472
  declare
473
473
  e @SCHEMA@.entity;
474
474
  c record;
475
+ was text;
475
476
  begin
476
477
  select * into c from @SCHEMA@.ctx();
478
+ if p_class is not null then
479
+ select x.class into was from @SCHEMA@.entity x where x.id = p_id;
480
+ end if;
477
481
  update @SCHEMA@.entity x
478
482
  set class = coalesce(p_class, x.class), data = p_data, links = p_links, validator = p_validator,
479
483
  rev = x.rev + 1, prev_hash = x.hash,
@@ -489,6 +493,11 @@ begin
489
493
  select x.* into e from @SCHEMA@.entity x where x.id = p_id;
490
494
  end if;
491
495
  perform @SCHEMA@.log_version(e);
496
+ -- смена класса: сигнал и прежнего класса — его ответы кэша и подписки тоже устарели (log_version сигналит только новый;
497
+ -- путь через триггер шлёт оба; план 2.0, 7.4 №4)
498
+ if was is not null and was <> e.class then
499
+ perform @SCHEMA@.signal(array[was], array[e.tenant]);
500
+ end if;
492
501
  return e;
493
502
  end $$;
494
503
 
package/sql/70-read.sql CHANGED
@@ -53,9 +53,11 @@ as $$
53
53
  where r.class = any (p_classes) and r.at <= coalesce(p_at, 'infinity'::timestamptz)) c
54
54
  cross join lateral (
55
55
  select y.* from (select x.* from @SCHEMA@.log x where x.id = c.id offset 0) y
56
- where y.class = any (p_classes) and y.at <= coalesce(p_at, 'infinity'::timestamptz)
56
+ where y.at <= coalesce(p_at, 'infinity'::timestamptz)
57
57
  order by y.rev desc limit 1) w
58
- where @SCHEMA@.link_ids(w.links) @> array[p_src]
58
+ -- класс — у последней версии на момент, а не среди версий классов шага: после смены класса без смены id объект
59
+ -- не числится в прежнем классе (план 2.0, 7.1 №1)
60
+ where w.class = any (p_classes) and @SCHEMA@.link_ids(w.links) @> array[p_src]
59
61
  $$;
60
62
 
61
63
  -- id строк классов p_classes: по вложению в data (GIN entity_data), по целям концов (GIN entity_ends),
@@ -134,12 +136,16 @@ begin
134
136
  else
135
137
  -- арендатор id неизменен, записи purge и trim несут арендатора объекта: условие до distinct on не меняет, какая
136
138
  -- версия id последняя (индекс log_class_tenant_at)
139
+ -- кандидаты — по классу (индекс log_class_tenant_at), последняя версия id на момент — любого класса, и класс
140
+ -- проверяется у неё: иначе после смены класса без смены id объект числился бы в прежнем классе (план 2.0, 7.1 №1)
137
141
  return query select v.id, v.rev from (
138
- select distinct on (l.id) l.id, l.rev, l.op, l.class, l.tenant, l.owner, l.tags, l.data, l.links
139
- from @SCHEMA@.log l
140
- where l.class = any (p_classes) and l.tenant = any (v_tenants) and l.at <= coalesce(p_at, 'infinity'::timestamptz)
141
- order by l.id, l.rev desc) v
142
- where (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
142
+ select distinct l.id from @SCHEMA@.log l
143
+ where l.class = any (p_classes) and l.tenant = any (v_tenants) and l.at <= coalesce(p_at, 'infinity'::timestamptz)) c
144
+ cross join lateral (
145
+ select y.id, y.rev, y.op, y.class, y.tenant, y.owner, y.tags, y.data, y.links
146
+ from @SCHEMA@.log y where y.id = c.id and y.at <= coalesce(p_at, 'infinity'::timestamptz)
147
+ order by y.rev desc limit 1) v
148
+ where v.class = any (p_classes) and (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
143
149
  and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
144
150
  and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan);
145
151
  -- свои членства в других арендаторах: версии, ссылавшиеся на аккаунт (GIN log_ends)
@@ -11,6 +11,7 @@
11
11
  -- change_paths — пути для колонки changed (пишут триггер правки и log_version);
12
12
  -- row_leaves — листья снимка строки (то, что объясняет blame);
13
13
  -- version_paths — пути из changed тех версий, у которых предыдущая версия видна вызывающему;
14
+ -- history_segments — отрезки истории объекта по переносам id (follow и blame);
14
15
  -- diff_versions — что изменилось между двумя версиями или моментами (jsonb в виде ответа diff());
15
16
  -- blame — какая версия, кто, когда и почему последней изменила каждый лист (ответ blame()).
16
17
 
@@ -154,21 +155,79 @@ begin
154
155
  if c_account is null then
155
156
  return;
156
157
  end if;
158
+ -- обе версии — поиском по (id, rev) на каждую пару: lateral с limit 1 не даёт планировщику соединить журнал хэшем.
159
+ -- Свой план вызова (первые пять на соединении) по фильтру видимости оценивал версии n в 2 строки и искал
160
+ -- предыдущую версию по одному id, проверяя номер фильтром, — квадрат от числа версий: blame по 1000 версиям
161
+ -- ~250 мс, по 10 000 — секунды (план 2.0, попутно с 7.1 №2)
157
162
  return query
158
163
  select n.id, n.rev, n.changed
159
164
  from unnest(p_ids, p_revs) q(id, rev)
160
- join @SCHEMA@.log n on n.id = q.id and n.rev = q.rev
161
- join @SCHEMA@.log o on o.id = n.id and o.rev = n.rev - 1
162
- where n.changed is not null
163
- and n.op not in ('purge', 'trim', 'reset') and o.op not in ('purge', 'trim', 'reset')
164
- and @SCHEMA@.row_visible(n.tenant, n.class, n.id, n.owner, n.tags, n.data, n.links, c_tenant, c_account, c_plan)
165
- and @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c_tenant, c_account, c_plan);
165
+ cross join lateral (select x.id, x.rev, x.changed from @SCHEMA@.log x
166
+ where x.id = q.id and x.rev = q.rev and x.changed is not null and x.op not in ('purge', 'trim', 'reset')
167
+ and @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links, c_tenant, c_account, c_plan)
168
+ limit 1) n
169
+ cross join lateral (select 1 from @SCHEMA@.log o
170
+ where o.id = n.id and o.rev = n.rev - 1 and o.op not in ('purge', 'trim', 'reset')
171
+ and @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c_tenant, c_account, c_plan)
172
+ limit 1) o;
173
+ end $$;
174
+
175
+ -- Отрезки истории объекта (план 2.0, 7.1 №2): версии id с номерами (lo, hi], hi null — до последней. Границы
176
+ -- отрезков на id — переносы: надгробие переноса (delete с moved) кончает отрезок, первая версия на новом id (reclass,
177
+ -- rekey с moved) начинает. Объект, заведённый на прежнем id после переноса, — другой отрезок: follow и blame его не
178
+ -- берут (прежде брали все версии всех связанных id). Начало — отрезок с версией p_rev объекта p_id (n = 0), дальше по
179
+ -- переносам назад (n < 0) и, если не p_back, вперёд (n > 0). Перенос пишется парой строк с moved друг на друга
180
+ -- (надгробие на старом id → новый id, первая версия на новом → старый): строки переноса id находит индекс log_moved
181
+ -- через парную строку, без прохода по версиям id; у объекта без переносов — один пустой поиск. Строка, чья пара стёрта
182
+ -- (история другой стороны удалена целиком или обрезана), границей не считается. Пара одного переноса — ближайшая по
183
+ -- seq (время у них может разойтись на микросекунды). Тот же обход строит versions({ follow }) в построителе
184
+ -- (lib/src/sql.ts:buildRead): меняются вместе. plpgsql, а не sql: план запроса кэшируется (тело функции на sql
185
+ -- планировалось на каждом вызове).
186
+ create or replace function @SCHEMA@.history_segments(p_id uuid, p_rev int, p_back boolean default false)
187
+ returns table (id uuid, lo int, hi int, n int)
188
+ language plpgsql stable
189
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
190
+ as $$
191
+ #variable_conflict use_column
192
+ begin
193
+ return query with recursive s(id, lo, hi, n) as (
194
+ select p_id, b.lo, b.hi, 0 from (
195
+ select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < p_rev), 0),
196
+ coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= p_rev), 0)) as lo,
197
+ least(min(x.rev) filter (where x.op = 'delete' and x.rev >= p_rev),
198
+ min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > p_rev)) as hi
199
+ from @SCHEMA@.log m join @SCHEMA@.log x on x.moved = m.id and x.id = p_id where m.moved = p_id) b
200
+ union
201
+ select g.id, b.lo, b.hi, g.n
202
+ from s cross join lateral (
203
+ -- назад: первая версия отрезка — прибытие с прежнего id; прежний отрезок кончается надгробием переноса сюда
204
+ select a.moved as id, (select t.rev from @SCHEMA@.log t where t.id = a.moved and t.op = 'delete' and t.moved = s.id
205
+ order by abs(t.seq - a.seq) limit 1) as r, s.n - 1 as n
206
+ from @SCHEMA@.log a
207
+ where s.n <= 0 and a.id = s.id and a.rev = s.lo + 1 and a.moved is not null and a.op <> 'delete'
208
+ union all
209
+ -- вперёд: последняя версия отрезка — надгробие переноса; следующий отрезок начинается прибытием отсюда
210
+ select t.moved, (select a.rev from @SCHEMA@.log a where a.id = t.moved and a.op <> 'delete' and a.moved = s.id
211
+ order by abs(a.seq - t.seq) limit 1), s.n + 1
212
+ from @SCHEMA@.log t
213
+ where not p_back and s.n >= 0 and t.id = s.id and t.rev = s.hi and t.op = 'delete' and t.moved is not null
214
+ ) g
215
+ cross join lateral (
216
+ select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < g.r), 0),
217
+ coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= g.r), 0)) as lo,
218
+ least(min(x.rev) filter (where x.op = 'delete' and x.rev >= g.r),
219
+ min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > g.r)) as hi
220
+ from @SCHEMA@.log m join @SCHEMA@.log x on x.moved = m.id and x.id = g.id where m.moved = g.id) b
221
+ where g.r is not null and abs(g.n) <= 1000
222
+ )
223
+ select s.id, s.lo, s.hi, s.n from s;
166
224
  end $$;
167
225
 
168
226
  -- diff: что изменилось между двумя версиями объекта (ответ терминала diff()). «После» — номер версии
169
227
  -- p_to, последняя версия на момент p_to_at или последняя вообще; «до» — номер p_from, последняя живая
170
228
  -- версия на момент p_from_at (объекта не было — null, все пути added) или версия перед «после».
171
- -- p_follow — через смену id (reclass, rekey) по колонке moved; надгробие переноса — не версия.
229
+ -- p_follow — через смену id (reclass, rekey) по колонке moved: отрезки истории этого объекта (history_segments) от
230
+ -- версии p_to или последней версии p_id; надгробие переноса — не версия.
172
231
  -- «После» — надгробие: to.deleted, изменения полей не перечисляются.
173
232
  create or replace function @SCHEMA@.diff_versions(p_id uuid, p_from int default null, p_to int default null,
174
233
  p_from_at timestamptz default null, p_to_at timestamptz default null, p_follow boolean default false)
@@ -181,21 +240,26 @@ set jit = off
181
240
  as $$
182
241
  declare
183
242
  ids uuid[] := array[p_id];
243
+ los int[] := array[0];
244
+ his int[] := array[null::int];
245
+ multi boolean := false;
246
+ whole boolean := true;
184
247
  t record;
185
248
  f record;
186
249
  has_f boolean;
187
250
  begin
188
251
  if p_follow then
189
- with recursive g(id) as (
190
- select p_id
191
- union
192
- select x.id from g cross join lateral (
193
- select l.moved as id from @SCHEMA@.log l where l.id = g.id and l.moved is not null
194
- union select l.id from @SCHEMA@.log l where l.moved = g.id) x)
195
- select array_agg(g.id) into ids from g;
252
+ select array_agg(s.id), array_agg(s.lo), array_agg(s.hi) into ids, los, his
253
+ from (select distinct g.id, g.lo, g.hi from @SCHEMA@.history_segments(p_id, coalesce(p_to, (select max(l.rev) from @SCHEMA@.log l
254
+ where l.id = p_id and l.op not in ('purge', 'trim', 'reset')))) g) s;
255
+ multi := cardinality(ids) > (select count(distinct x) from unnest(ids) x);
256
+ whole := cardinality(ids) = 1 and los[1] = 0 and his[1] is null;
196
257
  end if;
197
258
  select l.id, l.rev, l.at, l.seq, l.op, l.class, l.data, l.links, l.tags, l.owner into t from @SCHEMA@.log l
198
259
  where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
260
+ and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
261
+ and l.rev <= coalesce(s.hi, 2147483647))
262
+ else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
199
263
  and (p_to is null or (l.id = p_id and l.rev = p_to)) and (p_to_at is null or l.at <= p_to_at)
200
264
  order by l.at desc, l.seq desc limit 1;
201
265
  if t.id is null then
@@ -208,6 +272,9 @@ begin
208
272
  elsif p_from_at is not null then
209
273
  select l.id, l.rev, l.at, l.op, l.class, l.data, l.links, l.tags, l.owner into f from @SCHEMA@.log l
210
274
  where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
275
+ and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
276
+ and l.rev <= coalesce(s.hi, 2147483647))
277
+ else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
211
278
  and l.at <= p_from_at
212
279
  order by l.at desc, l.seq desc limit 1;
213
280
  -- срез на момент: удалённого объекта тогда не было
@@ -215,6 +282,9 @@ begin
215
282
  else
216
283
  select l.id, l.rev, l.at, l.op, l.class, l.data, l.links, l.tags, l.owner into f from @SCHEMA@.log l
217
284
  where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
285
+ and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
286
+ and l.rev <= coalesce(s.hi, 2147483647))
287
+ else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
218
288
  and (l.at, l.seq) < (t.at, t.seq)
219
289
  order by l.at desc, l.seq desc limit 1;
220
290
  has_f := f.id is not null;
@@ -241,7 +311,9 @@ end $$;
241
311
  -- blame: для каждого листа версии (последней на момент p_at или текущей) — версия, которая последней его
242
312
  -- изменила, с автором, агентом, видом операции и причиной (ответ терминала blame()). Просматривается не
243
313
  -- больше p_limit последних версий, назад через смену id (первая версия нового id указывает на прежний
244
- -- колонкой moved). Пропуск номера версии (прореживание, обрезка, невидимая версия) — выпавшая версия могла
314
+ -- колонкой moved) — по отрезкам истории этого объекта (history_segments): объект, что жил на этом id до переноса
315
+ -- или заведён на прежнем id после, — не его история; начало истории — первая версия самого раннего отрезка (у
316
+ -- объекта без переносов — версия 1). Пропуск номера версии (прореживание, обрезка, невидимая версия) — выпавшая версия могла
245
317
  -- изменить лист и вернуть значение, поэтому лист точен (exact), только если от текущей версии до найденной
246
318
  -- пропусков нет; лист, не менявшийся в просмотренной истории, принадлежит самой старой просмотренной версии —
247
319
  -- exact, только если это версия 1 и пропусков нет вовсе. complete — история просмотрена целиком и без пропусков.
@@ -254,9 +326,12 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
254
326
  set jit = off
255
327
  as $$
256
328
  declare
257
- ids uuid[] := array[p_id];
329
+ ids uuid[];
330
+ los int[];
331
+ his int[];
332
+ multi boolean;
333
+ whole boolean;
258
334
  t record;
259
- prev uuid;
260
335
  lim int := greatest(coalesce(p_limit, 1000), 1);
261
336
  res jsonb;
262
337
  begin
@@ -264,12 +339,11 @@ begin
264
339
  where l.id = p_id and l.op not in ('purge', 'trim', 'reset') and (p_at is null or l.at <= p_at)
265
340
  order by l.rev desc limit 1;
266
341
  if t.id is null then return null; end if;
267
- loop
268
- select l.moved into prev from @SCHEMA@.log l
269
- where l.id = ids[cardinality(ids)] and l.moved is not null and l.op in ('reclass', 'rekey') order by l.rev limit 1;
270
- exit when prev is null or prev = any (ids);
271
- ids := ids || prev;
272
- end loop;
342
+ -- отрезки от самого раннего: ids[1], los[1] — начало истории объекта
343
+ select array_agg(s.id order by s.n), array_agg(s.lo order by s.n), array_agg(s.hi order by s.n) into ids, los, his
344
+ from (select g.id, g.lo, g.hi, min(g.n) as n from @SCHEMA@.history_segments(p_id, t.rev, true) g group by 1, 2, 3) s;
345
+ multi := cardinality(ids) > (select count(distinct x) from unnest(ids) x);
346
+ whole := cardinality(ids) = 1 and los[1] = 0 and his[1] is null;
273
347
  with v as (
274
348
  -- соседняя более старая версия — окном (lead), а не соединением CTE с собой: соединение шло бы вложенным
275
349
  -- циклом по квадрату числа версий
@@ -279,6 +353,9 @@ begin
279
353
  lead(l.owner) over o as o_owner, lead(l.class) over o as o_class
280
354
  from @SCHEMA@.log l
281
355
  where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (l.op = 'delete' and l.moved is not null)
356
+ and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
357
+ and l.rev <= coalesce(s.hi, 2147483647))
358
+ else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
282
359
  and (l.at, l.seq) <= (t.at, t.seq)
283
360
  window o as (order by l.at desc, l.seq desc)
284
361
  order by l.at desc, l.seq desc limit lim + 1
@@ -312,14 +389,15 @@ begin
312
389
  from leaves lv join ch on lv.path[1:cardinality(ch.path)] = ch.path or ch.path[1:cardinality(lv.path)] = lv.path
313
390
  group by lv.path
314
391
  ), oldest as (
315
- select w.n, w.rev from w order by w.n desc limit 1
392
+ -- first — самая старая версия окна и есть начало истории объекта
393
+ select w.n, w.rev, w.id = ids[1] and w.rev = los[1] + 1 as first from w order by w.n desc limit 1
316
394
  ), cut as (
317
395
  select count(*) > lim as cut from v
318
396
  ), fields as (
319
397
  select lv.path,
320
398
  jsonb_build_object('path', to_jsonb(lv.path), 'rev', cv.rev, 'at', cv.at, 'author', cv.author, 'op', cv.op,
321
399
  'exact', case when h.n is not null then not exists (select 1 from gaps g where g.n <= h.n)
322
- else (select o.rev = 1 from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps) end)
400
+ else (select o.first from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps) end)
323
401
  || case when cv.id <> p_id then jsonb_build_object('id', cv.id) else '{}'::jsonb end
324
402
  || case when cv.agent is not null then jsonb_build_object('agent', cv.agent) else '{}'::jsonb end
325
403
  || case when cv.reason is not null then jsonb_build_object('reason', cv.reason) else '{}'::jsonb end as obj
@@ -328,7 +406,7 @@ begin
328
406
  join w cv on cv.n = coalesce(h.n, (select o.n from oldest o))
329
407
  )
330
408
  select jsonb_build_object('id', p_id, 'class', t.class, 'rev', t.rev,
331
- 'complete', (select o.rev = 1 from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps),
409
+ 'complete', (select o.first from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps),
332
410
  'fields', coalesce((select jsonb_agg(f.obj order by f.path) from fields f), '[]'::jsonb))
333
411
  || case when t.op = 'delete' then '{"deleted": true}'::jsonb else '{}'::jsonb end
334
412
  into res;
@@ -342,7 +420,7 @@ begin
342
420
  'jsonb_changes(jsonb, jsonb, text[])',
343
421
  'row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
344
422
  'change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
345
- 'row_leaves(jsonb, jsonb, text[], uuid, text)', 'version_paths(uuid[], int[])',
423
+ 'row_leaves(jsonb, jsonb, text[], uuid, text)', 'version_paths(uuid[], int[])', 'history_segments(uuid, int, boolean)',
346
424
  'diff_versions(uuid, int, int, timestamptz, timestamptz, boolean)', 'blame(uuid, timestamptz, int)'] loop
347
425
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
348
426
  execute format('revoke all on function @SCHEMA@.%s from public', f);
@@ -354,5 +432,5 @@ grant execute on function @SCHEMA@.jsonb_changes(jsonb, jsonb, text[]),
354
432
  @SCHEMA@.row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
355
433
  @SCHEMA@.change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
356
434
  @SCHEMA@.row_leaves(jsonb, jsonb, text[], uuid, text), @SCHEMA@.version_paths(uuid[], int[]),
357
- @SCHEMA@.diff_versions(uuid, int, int, timestamptz, timestamptz, boolean),
435
+ @SCHEMA@.history_segments(uuid, int, boolean), @SCHEMA@.diff_versions(uuid, int, int, timestamptz, timestamptz, boolean),
358
436
  @SCHEMA@.blame(uuid, timestamptz, int) to @APP@;
package/sql/80-auth.sql CHANGED
@@ -755,7 +755,8 @@ declare
755
755
  s record;
756
756
  begin
757
757
  if nullif(current_setting('letopis.account', true), '') is not null then
758
- perform @SCHEMA@.lt_fail('invalid_query', 'auth.switch — для собственной сессии, не при имперсонации');
758
+ -- код из каталога базы (invalid_query в нём нет — lt_fail дал бы LT000 и сырую ошибку; план 2.0, 7.5 №3)
759
+ perform @SCHEMA@.lt_fail('acl_denied', 'auth.switch — для собственной сессии, не при имперсонации');
759
760
  end if;
760
761
  select x.id, x.account into s from @SCHEMA@.secret x
761
762
  where x.kind = 'SESSION' and x.deleted_at is null and x.material = sha256(convert_to(coalesce(tok, ''), 'UTF8'))
@@ -25,7 +25,8 @@ begin
25
25
  for update;
26
26
  if not found then
27
27
  update @SCHEMA@.secret s set deleted_at = clock_timestamp()
28
- where s.kind = 'PROJECTOR' and casefold(s.ident) = casefold(p_name) and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
28
+ where s.kind = 'PROJECTOR' and casefold(s.ident) collate pg_unicode_fast = casefold(p_name) collate pg_unicode_fast
29
+ and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
29
30
  insert into @SCHEMA@.secret (account, kind, ident, meta) values ('@SYSTEM@'::uuid, 'PROJECTOR', p_name, '{}'::jsonb);
30
31
  return '{}'::jsonb;
31
32
  end if;