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/admin.js CHANGED
@@ -29,8 +29,17 @@ export async function maintain(a, opts = {}) {
29
29
  // запуск по расписанию остановлен (close()): следующая пачка не начинается, отчёт — с more: true
30
30
  if (i > 0 && opts[MAINTAIN_STOP]?.())
31
31
  break;
32
- // блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null
33
- const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select ${a.S}.maintain($1::text::timestamptz, $2::int) as r`, [now, opts.limit ?? 10_000]));
32
+ // блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null.
33
+ // Истёкшие сессии и коды — только в первой пачке (план 2.0, 7.3 №4); схема, функции которой ещё не обновил up()
34
+ // (нет третьего аргумента, 42883), — прежний вызов: он чистит в каждой пачке
35
+ const call = (cleanup) => runTx(a.txc, (q) => q.unsafe(cleanup === null
36
+ ? `select ${a.S}.maintain($1::text::timestamptz, $2::int) as r`
37
+ : `select ${a.S}.maintain($1::text::timestamptz, $2::int, $3::boolean) as r`, (cleanup === null ? [now, opts.limit ?? 10_000] : [now, opts.limit ?? 10_000, cleanup])));
38
+ const [{ r }] = await call(i === 0).catch((e) => {
39
+ if (e.code === '42883')
40
+ return call(null);
41
+ throw e;
42
+ });
34
43
  if (r === null) {
35
44
  if (i === 0)
36
45
  return { ...report, skipped: true };
package/dist/cache.js CHANGED
@@ -85,7 +85,8 @@ export class ResultCache {
85
85
  return undefined;
86
86
  }
87
87
  this.st.hits++;
88
- return e.value;
88
+ // копия: правка выданного ответа не портит запись кэша (план 2.0, 7.2 №5)
89
+ return structuredClone(e.value);
89
90
  }
90
91
  /** Положить ответ, если с момента снимка не было сигналов о его классах и сессии. */
91
92
  set(key, value, classes, session, tok, ttlMs, sessionExpires) {
@@ -100,7 +101,7 @@ export class ResultCache {
100
101
  }
101
102
  if (this.map.size >= this.max)
102
103
  this.map.delete(this.map.keys().next().value);
103
- this.map.set(key, { value, classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
104
+ this.map.set(key, { value: structuredClone(value), classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
104
105
  this.st.stores++;
105
106
  return true;
106
107
  }
package/dist/chain.d.ts CHANGED
@@ -122,7 +122,8 @@ export interface ChainCore {
122
122
  /**
123
123
  * Постраничный вывод по курсору: ответ начинается строго после строки, на которой кончилась прошлая страница, в
124
124
  * порядке `sort()` (без `sort()` — ошибка `invalid_query`). Курсор этой строки даёт `cursorOf(строка, поле)` с тем же
125
- * полем, что в `sort()`.
125
+ * полем, что в `sort()`. Листает `rows()`, `ids()`, `versions()` и `paths()` (курсор пути — `cursorOf(путь, поле)`);
126
+ * `count()`, агрегаты, `diff()` и `blame()` курсор не учитывают.
126
127
  */
127
128
  after(cursor: Cursor): Chain;
128
129
  /**
package/dist/chain.js CHANGED
@@ -244,8 +244,11 @@ const rowOf = (r) => (r.done ? r.row : toRow(r.row));
244
244
  /** Значение слота: id, список id, строка ответа или ленивая цепочка. */
245
245
  function slotValue(v) {
246
246
  const peek = peekPlan(v);
247
- if (peek)
247
+ if (peek) {
248
+ // модификатор сразу после глагола вложенной цепочки — тот же отказ, что у терминала (план 2.0, 7.2 №3)
249
+ checkAfterVerb(peek.steps, peek.mods);
248
250
  return { plan: peek.steps, mods: peek.mods };
251
+ }
249
252
  if (v === null || typeof v === 'string')
250
253
  return v;
251
254
  if (Array.isArray(v))
@@ -398,9 +401,12 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
398
401
  case 'paths':
399
402
  return async () => {
400
403
  checkAfterVerb(steps, mods);
401
- // q === null — пути из результата глагола: строки уже готовы
404
+ // q === null — пути из результата глагола: строки уже готовы. Ключи — в порядке шагов (jsonb хранит их
405
+ // по длине), узел последнего шага — последним: по нему cursorOf(путь, поле) берёт курсор (план 2.0, 7.1 №3)
402
406
  const { q, res } = await read(ctx, steps, mods, 'paths');
403
- return res.map((r) => Object.fromEntries(Object.entries(r.path).map(([k, v]) => [k, q === null ? v : toRow(v)])));
407
+ if (q === null)
408
+ return res.map((r) => r.path);
409
+ return res.map((r) => Object.fromEntries(q.keys.map((k) => [k, toRow(r.path[k])])));
404
410
  };
405
411
  case 'rows':
406
412
  return async () => {
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { resolve } from 'node:path';
24
24
  import { connect, describeText } from './index.js';
25
25
  import { syncModels, generateTypes } from './sync.js';
26
26
  import { import021 } from './import.js';
27
+ import { LetopisError } from './errors.js';
27
28
  const USAGE = `letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
28
29
  letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
29
30
  letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
@@ -35,6 +36,8 @@ letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <ст
35
36
  [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
36
37
  letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
37
38
  (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)`;
39
+ /** Команды CLI: остальное — USAGE с кодом 2 ещё до подключения. */
40
+ const COMMANDS = new Set(['sync', 'types', 'load', 'verify', 'maintain', 'describe', 'import', 'mcp']);
38
41
  const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
39
42
  export async function main(argv) {
40
43
  const { values, positionals } = parseArgs({
@@ -58,7 +61,12 @@ export async function main(argv) {
58
61
  values.schema ??= process.env.LETOPIS_SCHEMA || undefined;
59
62
  values.token ??= process.env.LETOPIS_TOKEN || undefined;
60
63
  }
61
- if (values.help || !cmd || !values.dsn || !values.schema) {
64
+ // --help — всегда код 0; неизвестная команда — USAGE до подключения к базе (план 2.0, 7.5 №4)
65
+ if (values.help) {
66
+ console.log(USAGE);
67
+ return 0;
68
+ }
69
+ if (!cmd || !COMMANDS.has(cmd) || !values.dsn || !values.schema) {
62
70
  console.log(USAGE);
63
71
  return cmd ? 2 : 0;
64
72
  }
@@ -117,7 +125,7 @@ export async function main(argv) {
117
125
  return 2;
118
126
  }
119
127
  if (values.trust)
120
- throw new Error('letopis: removed: --trust снят в 1.0.0 — запрос по целям вне загрузки стоит 0,1–1,3 % её времени');
128
+ throw new LetopisError('removed', '--trust снят в 1.0.0 — запрос по целям вне загрузки стоит 0,1–1,3 % её времени');
121
129
  // несколько файлов — одна загрузка и одна транзакция; в файле — объект на строку
122
130
  const rows = [];
123
131
  for (const f of files) {
package/dist/errors.js CHANGED
@@ -17,7 +17,7 @@ export const REMOVED_IN = '1.0.0';
17
17
  /** Охранник снесённого API (план, этап 3, п. 11): понятная ошибка с версией и заменой. */
18
18
  export function removed(name, replacement) {
19
19
  // «опция …» — женского рода; методы и свойства — мужского
20
- const verb = name.startsWith('опция ') ? 'снята' : 'снят';
20
+ const verb = name.startsWith('опция ') ? 'снята' : / и /.test(name) ? 'сняты' : 'снят';
21
21
  return new LetopisError('removed', `${name} ${verb} в ${REMOVED_IN} — ${replacement}`);
22
22
  }
23
23
  /** Данные не проходят описание класса (`invalid_data`). */
package/dist/index.d.ts CHANGED
@@ -170,8 +170,7 @@ export interface DbCore {
170
170
  owner?: string;
171
171
  tenant?: string;
172
172
  }): Promise<Db>;
173
- /** Явная транзакция `read committed`: те же цепочки, `commit()`, `rollback()`, `lock()`, `forUpdate()`. */
174
- /** Явная транзакция; `reason` — причина изменения для всех операций блока (план 1.1). */
173
+ /** Явная транзакция `read committed`: те же цепочки, `commit()`, `rollback()`, `lock()`, `forUpdate()`; `reason` — причина изменения для всех операций блока (план 1.1). */
175
174
  begin(opts?: {
176
175
  reason?: string;
177
176
  }): Promise<DbTx>;
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { LetopisError, fromDbError, removed } from './errors.js';
12
12
  import { Registry } from './registry.js';
13
13
  import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
14
14
  import { checkReason } from './write.js';
15
- import { registerDb } from './internal.js';
15
+ import { limits, registerDb } from './internal.js';
16
16
  import { makeAuth } from './auth.js';
17
17
  import { makeAcl } from './acl.js';
18
18
  import { idOf as idOfClass } from './uuid.js';
@@ -120,13 +120,23 @@ function makeDb(sh, scope, exec, owner, tx) {
120
120
  throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
121
121
  const reg = scope.registry();
122
122
  const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
123
- return new Subscription({
124
- S: sh.S, exec: poolExecutor(txc), classes,
123
+ return sh.track(new Subscription({
124
+ S: sh.S, exec: poolExecutor(txc), classes, onClose: (s) => void sh.subs.delete(s),
125
+ // не бросает: класс удалили — список, снятый при watch(); иначе ошибка в общем разборе сигналов остановила бы
126
+ // доставку другим подписчикам
127
+ ...(o.classes ? { classesOf: () => {
128
+ try {
129
+ return [...new Set(o.classes.flatMap((c) => scope.registry().family(c).map((x) => x.name)))];
130
+ }
131
+ catch {
132
+ return classes;
133
+ }
134
+ } } : {}),
125
135
  ...(sh.listen ? {
126
136
  onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
127
137
  tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
128
138
  } : {}),
129
- }, o);
139
+ }, o));
130
140
  },
131
141
  cache: {
132
142
  clear: () => sh.cache?.clear(),
@@ -152,7 +162,7 @@ function makeDb(sh, scope, exec, owner, tx) {
152
162
  const tenant = r.tenant;
153
163
  const registry = await sh.registryFor(tenant, scoped);
154
164
  if (registry)
155
- return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
165
+ return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
156
166
  if (attempt === 3)
157
167
  throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
158
168
  [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
@@ -246,6 +256,10 @@ export async function connect(opts) {
246
256
  const owner = opts.owner ?? 'letopis_owner';
247
257
  try {
248
258
  // роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
259
+ // роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
260
+ const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
261
+ if (!known.ok)
262
+ throw new LetopisError('invalid_data', `connect: роли владельца ${owner} в базе нет — проверьте опцию owner (по умолчанию letopis_owner)`);
249
263
  const [role] = await sql `select r.rolsuper, r.rolbypassrls,
250
264
  pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
251
265
  from pg_roles r where r.rolname = current_user`;
@@ -316,8 +330,18 @@ export async function connect(opts) {
316
330
  return null;
317
331
  return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
318
332
  }, { read: true });
319
- // реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора
333
+ // реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора. Порядок карты —
334
+ // давность обращения: сверх limits.asRegistries вытесняется самый давний (план 2.0, 7.4 №8 — иначе процесс,
335
+ // который ходит от имени многих арендаторов, копил бы реестры и перечитывал их все на каждый сигнал классов)
320
336
  const others = new Map();
337
+ let latest = null;
338
+ const keep = (tenant, entry) => {
339
+ latest = tenant;
340
+ others.delete(tenant);
341
+ others.set(tenant, entry);
342
+ while (others.size > Math.max(1, limits.asRegistries))
343
+ others.delete(others.keys().next().value);
344
+ };
321
345
  const loading = new Map();
322
346
  // токены классов (режим opaque) — по именам всех реестров подключения
323
347
  const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
@@ -343,7 +367,7 @@ export async function connect(opts) {
343
367
  const registryFor = async (tenant, txc) => {
344
368
  const known = others.get(tenant);
345
369
  if (known) {
346
- known.txc = txc;
370
+ keep(tenant, { registry: known.registry, txc });
347
371
  return known.registry;
348
372
  }
349
373
  // одновременные as() нового арендатора получают один реестр
@@ -355,7 +379,7 @@ export async function connect(opts) {
355
379
  return null;
356
380
  const registry = new Registry();
357
381
  registry.load(rows);
358
- others.set(tenant, { registry, txc });
382
+ keep(tenant, { registry, txc });
359
383
  tokens();
360
384
  return registry;
361
385
  })().finally(() => loading.delete(tenant));
@@ -363,6 +387,20 @@ export async function connect(opts) {
363
387
  }
364
388
  return pending;
365
389
  };
390
+ // фасад db.as() берёт реестр через карту: свежий реестр арендатора, если его перечитал другой as(); вытесненный —
391
+ // обратно в карту (его снова обновляют сигналы) и перечитать фоном: пока он был вне карты, сигналы его не трогали
392
+ const registryOf = (tenant, registry, txc) => {
393
+ const cur = others.get(tenant);
394
+ if (cur) {
395
+ if (latest !== tenant)
396
+ keep(tenant, cur);
397
+ return cur.registry;
398
+ }
399
+ keep(tenant, { registry, txc });
400
+ void loadClasses(txc, tenant).then((rows) => { if (rows)
401
+ registry.load(rows); }).catch(() => { }).finally(tokens);
402
+ return registry;
403
+ };
366
404
  // ключ сигналов (режимы tenant и opaque) — сессии с правом letopis.signals; без права процесс
367
405
  // работает без ключа: арендатора (а в opaque — и класса) в сигнале не различает
368
406
  const fetchKey = async () => {
@@ -468,8 +506,9 @@ export async function connect(opts) {
468
506
  stamper.unref?.();
469
507
  }
470
508
  let stopMaintain = null;
509
+ const subs = new Set();
471
510
  const shared = {
472
- sql, schema: opts.schema, S, notifier, passes, root, registryFor, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
511
+ sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
473
512
  admin: Boolean(bypass && (role.rolsuper || role.can_set)),
474
513
  ownerRole: owner,
475
514
  cache,
@@ -477,7 +516,16 @@ export async function connect(opts) {
477
516
  allTenants: Boolean(bypass),
478
517
  signals,
479
518
  listen,
519
+ subs,
520
+ track: (s) => {
521
+ subs.add(s);
522
+ return s;
523
+ },
480
524
  async close() {
525
+ // живые подписки закрываются: иначе с чужим пулом (connect({ sql })) они опрашивали бы журнал и после close()
526
+ // (план 2.0, 7.4 №8)
527
+ for (const s of [...subs])
528
+ s.close();
481
529
  // идущий maintain() по расписанию доделывает пачку: пул закрывается после неё, а не посреди транзакции
482
530
  await stopMaintain?.();
483
531
  resync.stop();
@@ -10,6 +10,13 @@ export interface DbInternals {
10
10
  /** Фасад явной транзакции db.begin(). */
11
11
  readonly explicit: boolean;
12
12
  }
13
+ /**
14
+ * Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
15
+ * 7.4 №8). Тест меняет предел, чтобы проверить вытеснение без тысячи арендаторов.
16
+ */
17
+ export declare const limits: {
18
+ asRegistries: number;
19
+ };
13
20
  /** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
14
21
  export declare function registerDb(db: object, info: DbInternals): void;
15
22
  /** Свойства фасада; undefined — объект не фасад connect() этого пакета. */
package/dist/internal.js CHANGED
@@ -4,6 +4,11 @@
4
4
  * фасад db обходит RLS, не полагаясь на то, что сообщает о себе сам вызывающий.
5
5
  */
6
6
  const facades = new WeakMap();
7
+ /**
8
+ * Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
9
+ * 7.4 №8). Тест меняет предел, чтобы проверить вытеснение без тысячи арендаторов.
10
+ */
11
+ export const limits = { asRegistries: 1000 };
7
12
  /** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
8
13
  export function registerDb(db, info) {
9
14
  facades.set(db, info);
package/dist/load.js CHANGED
@@ -26,6 +26,7 @@ import { v5Id } from './uuid.js';
26
26
  import { applyDefaults } from './validate.js';
27
27
  import { isInc } from './write.js';
28
28
  import { ACL_CLASS_NAMES } from './types.js';
29
+ import { isConnectionError, release, rollbackAfter } from './tx.js';
29
30
  const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
30
31
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
31
32
  /** Классы, строки которых первая заливка сохраняет и возвращает: описания классов, аккаунты, членства, права. */
@@ -568,14 +569,15 @@ export async function load(env, input, opts = {}) {
568
569
  const classesTouched = new Set();
569
570
  // существующие объекты — только в режимах skip и update, одним запросом на пачку и до проверок:
570
571
  // пропущенный объект для ссылок на него — вне загрузки (его сверит запрос в конце)
571
- const live = new Set();
572
+ // живые строки пачки: id → класс (existing: 'update' правит только объект того же класса)
573
+ const live = new Map();
572
574
  const past = new Map();
573
575
  if (existing !== 'error' && !opts.withHistory) {
574
576
  for (let b = 0; b < portion.length; b += batch) {
575
577
  const ids = portion.slice(b, b + batch).map((i) => p1[i].id);
576
578
  const lock = existing === 'update' ? ' order by e.id for no key update' : '';
577
- for (const r of await conn.unsafe(`select e.id from ${S}.entity e where e.id = any ($1::uuid[])${lock}`, [ids]))
578
- live.add(r.id);
579
+ for (const r of await conn.unsafe(`select e.id, e.class from ${S}.entity e where e.id = any ($1::uuid[])${lock}`, [ids]))
580
+ live.set(r.id, r.class);
579
581
  for (const r of await conn.unsafe(`select distinct on (l.id) l.id, l.rev, l.hash, l.at::text as at from ${S}.log l
580
582
  where l.id = any ($1::uuid[]) order by l.id, l.rev desc`, [ids]))
581
583
  past.set(r.id, r);
@@ -690,12 +692,13 @@ export async function load(env, input, opts = {}) {
690
692
  }
691
693
  catch (e) {
692
694
  failed = e;
695
+ // обрыв: в мёртвое соединение не пишут и в пул его не возвращают (план 2.0, 7.4 №7)
693
696
  if (!ini)
694
- await conn.unsafe('rollback').catch(() => { });
697
+ await rollbackAfter(conn, e);
695
698
  }
696
699
  finally {
697
700
  if (!ini)
698
- conn.release();
701
+ release(conn, failed);
699
702
  }
700
703
  if (failed) {
701
704
  const err = failed;
@@ -848,6 +851,13 @@ export async function load(env, input, opts = {}) {
848
851
  if (!sh || issues.any)
849
852
  continue;
850
853
  if (live.has(id)) {
854
+ // строка с тем же id, но другого класса (класс без ключа после reclass, повторный импорт по $ext): правка
855
+ // прошла бы мимо триггеров с данными, проверенными по чужой схеме, — exists (план 2.0, 7.2 №4)
856
+ const was = live.get(id);
857
+ if (was !== cls.name) {
858
+ issues.add({ line: i + 1, code: 'exists', message: `объект с этим id уже есть в классе ${was}: строка класса ${cls.name} его не правит`, id, class: cls.name });
859
+ continue;
860
+ }
851
861
  updates.push({ id, data: sh.data, links: sh.links, tags: sh.tags ?? null, line: i + 1 });
852
862
  continue;
853
863
  }
@@ -1068,6 +1078,7 @@ export async function initialSession(env, fn, tune = {}) {
1068
1078
  const ident = (n) => `"${n.replace(/"/g, '""')}"`;
1069
1079
  const conn = await env.sql.reserve();
1070
1080
  const q = (text, params = []) => conn.unsafe(text, params);
1081
+ let initialFailure = null;
1071
1082
  try {
1072
1083
  await q('begin isolation level read committed');
1073
1084
  await q(`set local role ${ident(env.ownerRole)}`);
@@ -1177,10 +1188,12 @@ export async function initialSession(env, fn, tune = {}) {
1177
1188
  return out;
1178
1189
  }
1179
1190
  catch (e) {
1180
- await q('rollback').catch(() => { });
1191
+ initialFailure = e;
1192
+ if (!isConnectionError(e))
1193
+ await q('rollback').catch(() => { });
1181
1194
  throw e instanceof LetopisError ? e : fromDbError(e);
1182
1195
  }
1183
1196
  finally {
1184
- conn.release();
1197
+ release(conn, initialFailure);
1185
1198
  }
1186
1199
  }
package/dist/mcp.js CHANGED
@@ -78,7 +78,7 @@ const describeQuery = (o) => [
78
78
  'Чтение JSON-цепочкой, без SQL. Старт: class (класс, алиас или роль) и filter, id или ids.',
79
79
  'steps — переходы по связям: [{ step: "Роль или Класс", filter? | id? | ids? }].',
80
80
  'Фильтр: { поле: значение } — равенство; вложенные поля — вложенным объектом; конец — { Роль: id };',
81
- 'операторы — { поле: { $gt: 5 } }: $ne, $gt, $gte, $lt, $lte, $between [a, b], $in [..], $like, $ilike, $starts, $ends, $has, $hasAny [..], $hasAll [..], $exists true|false, $isNull true, $not; ИЛИ — { $or: [фильтр, …] }.',
81
+ 'операторы — { поле: { $gt: 5 } }: $ne, $gt, $gte, $lt, $lte, $between [a, b], $in [..], $like, $ilike, $starts, $ends (% и _ в значении $starts и $ends — шаблон LIKE, не экранируются), $has, $hasAny [..], $hasAll [..], $exists true|false, $isNull true, $not; ИЛИ — { $or: [фильтр, …] }.',
82
82
  'Модификаторы: asOf (момент ISO — срез на дату), withDeleted, sort { field: "at" | "rev" | "id" | "data.<путь>", dir }, limit, cursor (из прошлого ответа).',
83
83
  'terminal: rows (по умолчанию), first, ids, count, sum, avg, min, max, countBy — с field "data.<путь>".',
84
84
  `Ответ — не больше ${o.maxRows} строк и ${o.maxBytes} байт; дальше — cursor. Без sort строки идут по id. Только чтение.`,
@@ -522,7 +522,9 @@ async function query(db, a, o) {
522
522
  const got = await chain.limit(want + 1).rows();
523
523
  const page = got.slice(0, fit(got.slice(0, want), o));
524
524
  const more = got.length > page.length;
525
- const next = more && page.length ? encodeCursor({ f: field, d: dir, ...cursorOf(page[page.length - 1], field) }) : undefined;
525
+ const last = more && page.length ? cursorOf(page[page.length - 1], field) : undefined;
526
+ // курсор MCP — { f, d, v, id }: списки rows и ids листаются без rev (он разводит только версии)
527
+ const next = last ? encodeCursor({ f: field, d: dir, v: last.v, id: last.id }) : undefined;
526
528
  const body = terminal === 'ids' ? { ids: page.map((r) => r.id) } : { rows: page };
527
529
  return ok({ ...body, count: page.length, more, ...(next ? { cursor: next } : {}) });
528
530
  }
package/dist/model.js CHANGED
@@ -64,6 +64,8 @@ export function many(target, opts = {}) {
64
64
  return new End(targets, { many: true, required: (opts.min ?? 0) > 0, min: opts.min, max: opts.max, onDelete: opts.onDelete });
65
65
  }
66
66
  const NAME = /^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$/;
67
+ /** Ключи полной формы описания класса (ClassDef). */
68
+ const DEF_KEYS = new Set(['alias', 'extends', 'abstract', 'ends', 'fields', 'key', 'onDelete', 'history', 'ownerDefault', 'cache', 'meta', 'derivedBy', 'aggregate']);
67
69
  function isZod(v) {
68
70
  return typeof v === 'object' && v !== null && '_zod' in v;
69
71
  }
@@ -94,7 +96,11 @@ function makeModel(kind, name, defArg, optsArg) {
94
96
  throw new LetopisError('invalid_class', `имя класса ${name} недопустимо`);
95
97
  // короткая форма: hub(name, { поле: z…, … }, { key, meta, … })
96
98
  let def;
97
- const isShape = defArg && typeof defArg === 'object' && Object.values(defArg).length > 0 && Object.values(defArg).every(isZod);
99
+ // { fields: z.object(…) } без других ключей описания — полная форма, а не класс с единственным полем fields (план 2.0,
100
+ // 7.5 №2); короткая форма — все значения Zod и хотя бы один ключ вне описания класса
101
+ const fullForm = !!defArg && typeof defArg === 'object' && defArg.fields instanceof z.ZodObject
102
+ && Object.keys(defArg).every((k) => DEF_KEYS.has(k));
103
+ const isShape = !fullForm && defArg && typeof defArg === 'object' && Object.values(defArg).length > 0 && Object.values(defArg).every(isZod);
98
104
  if (optsArg !== undefined || isShape) {
99
105
  def = { ...optsArg, fields: (defArg ?? {}) };
100
106
  }
package/dist/project.d.ts CHANGED
@@ -195,6 +195,10 @@ export declare class Projector {
195
195
  /** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
196
196
  private blind;
197
197
  private stale;
198
+ /** Когда состав читался в последний раз: без LISTEN сигнала о новых классах нет (план 2.0, 7.4 №6). */
199
+ private resolvedAt;
200
+ /** Участники, которых чекпоинт ещё не знает: им начальная перестройка пачками (tenantWork). */
201
+ private newcomers;
198
202
  /** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
199
203
  private tenantsPending;
200
204
  private running;
@@ -208,6 +212,11 @@ export declare class Projector {
208
212
  /** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
209
213
  private readonly lockHits;
210
214
  private readonly only;
215
+ /**
216
+ * Перечитать состав: по сигналу классов, а без LISTEN (сигналов нет) — не реже раза в 5 с, как реестр подключения в
217
+ * режиме none, и в начале run() (план 2.0, 7.4 №6): иначе арендатор, вступивший после запуска, не участвовал бы.
218
+ */
219
+ private due;
211
220
  constructor(env: ProjectorEnv, name: string, def: ProjectorOptions);
212
221
  /**
213
222
  * Догнать журнал и следовать за ним до stop(); { once: true } — только догнать. Первый запуск (проекции