letopis 0.21.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +313 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3469
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +362 -563
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -252
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -352
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -685
  89. package/sql/seed.auth.sql +0 -73
package/dist/chain.js CHANGED
@@ -1,366 +1,375 @@
1
- import { buildRead, runQuery, leafType, resolveHop, nextNodeKey, } from './sql.js';
2
- import { runPlan, executeBatch, toRow } from './write.js';
3
- import { beginTx, lock } from './tx.js';
4
- import { makeTables } from './tables.js';
5
- import { makeAuth } from './auth.js';
6
- import { makeAcl } from './acl.js';
7
- /** Фильтр-аргумент принимает и Row/Account-объект — берётся его id. */
8
- // START_CONTRACT: normalizeFilter
9
- // PURPOSE: Нормализует фильтр-аргумент — Row/Account-объект сворачивается в свой id.
10
- // INPUTS: { f: Filter | undefined - id-строка, объект-предикат, массив либо Row/Account }
11
- // OUTPUTS: { Filter | undefined - id-строка для Row/Account, иначе исходный фильтр }
12
- // SIDE_EFFECTS: none
13
- // LINKS: M-CHAIN, V-M-CHAIN
14
- // END_CONTRACT: normalizeFilter
1
+ /**
2
+ * Цепочки чтения (план, этап 3, п. 1, 6, 7, 11): `t.Класс(фильтр).Связь().Класс().rows()`.
3
+ *
4
+ * db и цепочка — Proxy над реестром классов: имя класса, его алиас или имя роли (конца) — шаг пути.
5
+ * Шаг-роль ведёт именно этим концом: `t.Folder(f).Parent()` — родительская папка,
6
+ * `t.Referal(anna).Customer()` — клиенты, которых привела anna. Повтор связи в пути — возврат к её
7
+ * узлу (pivot): от него ветвится следующий шаг. Модификаторы возвращают новую цепочку; терминалы
8
+ * строят один запрос (sql.ts) и исполняют его транзакцией под сессией (tx.ts).
9
+ *
10
+ * Запись (этап 4): глаголы create, update, upsert, delete, anonymize, reclass — звенья плана; слоты
11
+ * `.Роль.set/unset/add/remove` правят концы записываемой версии. Терминал цепочки с глаголами
12
+ * исполняет весь план одной транзакцией (write.ts): продолжение идёт от результата глагола.
13
+ * Этап 6: restore, purge, rekey; versions({ follow }); кэш результатов .cache() (cache.ts).
14
+ */
15
+ import { LetopisError, removed } from './errors.js';
16
+ import { sessionTag } from './cache.js';
17
+ import { buildRead, nextNodeKey, resolveHop, roleTargets, stepClasses, toRow, } from './sql.js';
18
+ import { execBatch, execPlan, precheckPlan, slotRole } from './write.js';
19
+ /** Внутренний канал: план чужой ленивой цепочки (entity()). */
20
+ export const PLAN = Symbol('letopis.plan');
21
+ const peekPlan = (x) => x !== null && (typeof x === 'object' || typeof x === 'function') ? x[PLAN] : undefined;
22
+ const NO_NODES = new Map();
23
+ /** Строка ответа как фильтр — её id. */
15
24
  function normalizeFilter(f) {
16
- if (f !== undefined &&
17
- typeof f === 'object' &&
18
- !Array.isArray(f) &&
19
- typeof f.id === 'string' &&
20
- (typeof f.class === 'string' || 'categories' in f)) {
21
- return f.id;
25
+ if (f !== null && typeof f === 'object' && !Array.isArray(f)) {
26
+ const o = f;
27
+ if (typeof o.id === 'string' && typeof o.class === 'string')
28
+ return o.id;
22
29
  }
23
30
  return f;
24
31
  }
25
- const clsOf = (steps) => steps.map((s) => s.cls.id);
26
- // no-history класс (meta.history=false): в БД одна актуальная строка на id — .versions()
27
- // вернёт её одну, .asOf() отдаст «текущее» вместо point-in-time. Предупреждаем (не бросаем:
28
- // вызовы могли жить до перевода класса), один раз на класс+метод за процесс.
32
+ /** Класс, алиас или роль → класс шага (для роли — первый класс её целей). */
33
+ export function lookupStep(reg, name) {
34
+ const cls = reg.find(name);
35
+ if (cls)
36
+ return { cls };
37
+ const targets = roleTargets(reg, name);
38
+ const t = targets.map((n) => reg.find(n)).find((c) => !!c);
39
+ return t ? { cls: t, role: name } : undefined;
40
+ }
41
+ const unknownName = (name) => {
42
+ throw new LetopisError('invalid_class', `класс или роль ${name} не найдены`);
43
+ };
44
+ const nodeOf = (steps, s) => s.pivotKey === undefined ? s : steps.find((x) => x.nodeKey === s.pivotKey) ?? s;
45
+ /** Узел для возврата: последний реальный шаг того же класса-связи в текущем читающем сегменте. */
46
+ function pivotNodeOf(steps, cls) {
47
+ for (let i = steps.length - 1; i >= 0; i--) {
48
+ const s = steps[i];
49
+ if (s.op)
50
+ return undefined; // глагол закрывает паттерн — узлы до него недоступны
51
+ if (s.pivotKey === undefined && !s.role && s.cls.name === cls)
52
+ return s;
53
+ }
54
+ return undefined;
55
+ }
29
56
  const noHistoryWarned = new Set();
30
57
  function warnNoHistory(steps, method) {
31
58
  const cls = steps[steps.length - 1]?.cls;
32
- if (!cls || cls.history)
59
+ if (!cls || cls.history !== false)
33
60
  return;
34
- const key = `${cls.id}|${method}`;
61
+ const key = `${cls.name}|${method}`;
35
62
  if (noHistoryWarned.has(key))
36
63
  return;
37
64
  noHistoryWarned.add(key);
38
- console.warn(`letopis: .${method}() on no-history class "${cls.id}" (meta.history=false) — ` +
39
- `хранится одна актуальная версия на id, результат не отражает историю`);
65
+ console.warn(`letopis: .${method}() у класса ${cls.name} без журнала (history: false) — истории нет`);
40
66
  }
41
- /** Внутренний канал: план чужой ленивой цепочки (слот-значения, entity()). */
42
- export const PLAN = Symbol('letopis.plan');
43
- const peekPlan = (x) => x !== null && typeof x === 'object' ? x[PLAN] : undefined;
44
- /** Конец схемы владельца, принимающий класс слота (legacy: 'Entity'-конец берёт любой HUB). */
45
- // START_CONTRACT: findEnd
46
- // PURPOSE: Находит конец Schema.links владельца, принимающий класс слота (legacy: конец 'Entity' берёт любой HUB при нестрогих концах).
47
- // INPUTS: { owner: ClassDef - класс-владелец связей; slot: ClassDef - класс устанавливаемого конца }
48
- // OUTPUTS: { LinkEnd | undefined - подходящий конец связи или undefined }
49
- // SIDE_EFFECTS: none
50
- // LINKS: M-CHAIN, V-M-CHAIN
51
- // END_CONTRACT: findEnd
52
- function findEnd(owner, slot) {
53
- return owner.links.find((e) => e.classes.includes(slot.id) || (!owner.strictEnds && e.classes.includes('Entity') && slot.category === 'HUB'));
67
+ function report(ctx, mode, steps, t0, rows) {
68
+ if (!ctx.onQuery && ctx.slowMs === undefined)
69
+ return;
70
+ const ms = performance.now() - t0;
71
+ const slow = ctx.slowMs !== undefined && ms > ctx.slowMs;
72
+ const classes = steps.map((s) => s.cls.name);
73
+ if (ctx.onQuery)
74
+ ctx.onQuery({ mode, classes, ms, rows, slow });
75
+ else if (slow)
76
+ console.warn(`letopis: медленный запрос ${ms.toFixed(0)} мс — ${mode} ${classes.join('→')}`);
54
77
  }
55
- /** Значение слота: id-строка | Row | цепочка (одношаговый адрес сворачивается в id). */
56
- // START_CONTRACT: slotValue
57
- // PURPOSE: Приводит значение слота к SlotValue — id-строка, Row (берётся id) или ленивая цепочка (одношаговый адрес db.Класс(id) сворачивается в id, иначе вложенный план).
58
- // INPUTS: { slotCls: ClassDef - класс конца связи; v: unknown - id | Row | Chain }
59
- // OUTPUTS: { SlotValue - id-строка либо { plan, mods } вложенной цепочки }
60
- // SIDE_EFFECTS: бросает при цепочке чужого класса или неподдержанном значении
61
- // LINKS: M-CHAIN, V-M-CHAIN
62
- // END_CONTRACT: slotValue
63
- function slotValue(slotCls, v) {
64
- if (typeof v === 'string')
65
- return v;
66
- const plan = peekPlan(v);
67
- if (plan) {
68
- const [head] = plan.steps;
69
- if (plan.steps.length === 1 && !head.op && typeof head.filter === 'string') {
70
- if (head.cls.id !== slotCls.id) {
71
- throw new Error(`letopis: slot "${slotCls.id}" got a chain of class "${head.cls.id}"`);
72
- }
73
- return head.filter; // просто адрес db.Класс(id) — без вложенного исполнения
74
- }
75
- return { plan: plan.steps, mods: plan.mods };
76
- }
77
- if (v !== null && typeof v === 'object' && typeof v.id === 'string') {
78
- return v.id; // Row / Account
79
- }
80
- throw new Error(`letopis: slot "${slotCls.id}" accepts an id, a Row or a chain`);
78
+ /** Классы, которые читает цепочка (семейства шагов): по ним сбрасывается её ответ в кэше. */
79
+ function readClasses(reg, steps) {
80
+ return [...new Set(steps.flatMap((s) => stepClasses(reg, s)))];
81
81
  }
82
- /** Узел для pivot: последний реальный шаг класса cls в ТЕКУЩЕМ читающем сегменте (до op). */
83
- // START_CONTRACT: pivotNodeOf
84
- // PURPOSE: Ищет последний реальный шаг класса cls в текущем читающем сегменте (до первой операции) — узел для pivot-возврата.
85
- // INPUTS: { steps: Step[] - шаги цепочки; cls: ClassDef - искомый класс }
86
- // OUTPUTS: { Step | undefined - шаг-узел, либо undefined если операция закрыла паттерн }
87
- // SIDE_EFFECTS: none
88
- // LINKS: M-CHAIN, V-M-CHAIN
89
- // END_CONTRACT: pivotNodeOf
90
- function pivotNodeOf(steps, cls) {
91
- for (let i = steps.length - 1; i >= 0; i--) {
92
- const s = steps[i];
93
- if (s.op)
94
- return undefined; // операция закрывает паттерн — узлы до неё недоступны
95
- if (s.pivotKey === undefined && s.cls.id === cls.id)
96
- return s;
82
+ /** Срок ответа в кэше, мс: .cache({ ttl }) или пометка cache у всех классов чтения; null — не кэшировать. */
83
+ function cacheTtl(ctx, steps, mods, classes) {
84
+ const c = ctx.cache;
85
+ if (!c || !c.store.enabled || ctx.exec.explicit || ctx.batch || mods.forUpdate)
86
+ return null;
87
+ if (mods.cache)
88
+ return mods.cache.ttl !== undefined ? mods.cache.ttl * 1000 : c.store.ttlMs;
89
+ const marks = classes.map((n) => ctx.reg.find(n)?.cache);
90
+ if (!marks.length || marks.some((m) => !m))
91
+ return null;
92
+ return Math.min(...marks.map((m) => (m.ttl !== undefined ? m.ttl * 1000 : c.store.ttlMs)));
93
+ }
94
+ async function exec(ctx, q, mode, steps, mods = {}) {
95
+ const classes = ctx.cache ? readClasses(ctx.reg, steps) : [];
96
+ const ttl = ctx.cache ? cacheTtl(ctx, steps, mods, classes) : null;
97
+ if (ttl !== null) {
98
+ const store = ctx.cache.store;
99
+ const who = ctx.cache.who();
100
+ const key = store.key(q.text, q.params, who);
101
+ const hit = store.get(key);
102
+ if (hit)
103
+ return hit;
104
+ const tag = sessionTag(who.token);
105
+ const tok = store.token(classes, tag);
106
+ const t0 = performance.now();
107
+ const S = `"${ctx.schema.replace(/"/g, '""')}"`;
108
+ const [res, exp] = await ctx.exec.run((tx) => Promise.all([
109
+ tx.unsafe(q.text, q.params, { prepare: true }),
110
+ tx.unsafe(`select ${S}.session_expires() as e`),
111
+ ]), { read: true });
112
+ report(ctx, mode, steps, t0, res.length);
113
+ const e = exp[0]?.e;
114
+ store.set(key, [...res], classes, tag, tok, ttl, e ? Date.parse(e) : null);
115
+ return res;
97
116
  }
98
- return undefined;
117
+ const t0 = performance.now();
118
+ const res = await ctx.exec.run((tx) => tx.unsafe(q.text, q.params, { prepare: true }), { read: true });
119
+ report(ctx, mode, steps, t0, res.length);
120
+ return res;
99
121
  }
100
- // START_CONTRACT: runPaths
101
- // PURPOSE: Исполняет читающий план в режиме путей — строит SQL через buildRead и мапит строки в Path (узлы каждого варианта).
102
- // INPUTS: { ctx: Ctx - контекст соединения/схемы; steps: Step[] - шаги; mods: ChainMods - модификаторы }
103
- // OUTPUTS: { Promise<Path[]> - массив путей вида { ключ_шага: Row } }
104
- // SIDE_EFFECTS: читает БД через M-SQL.buildRead/runQuery
105
- // LINKS: M-CHAIN, V-M-CHAIN
106
- // END_CONTRACT: runPaths
107
- async function runPaths(ctx, steps, mods) {
108
- // START_BLOCK_RUN_PATHS
109
- const q = buildRead(ctx, steps, mods, 'paths');
110
- const res = await runQuery(ctx, q.text, q.params, 'paths', clsOf(steps));
111
- return res.map((r) => {
112
- const out = {};
113
- for (const [k, v] of Object.entries(r.path))
114
- out[k] = toRow(v);
115
- return out;
116
- });
117
- // END_BLOCK_RUN_PATHS
122
+ async function read(ctx, steps, mods, mode, agg) {
123
+ const hasOps = steps.some((s) => s.op);
124
+ if (ctx.batch && hasOps)
125
+ throw new LetopisError('invalid_query', 'план стоит в очереди батча — его исполнит batch.run()');
126
+ if (mods.forUpdate)
127
+ return lockedRead(ctx, steps, mods, mode);
128
+ if (hasOps)
129
+ return plan(ctx, steps, mods, mode, agg);
130
+ const { cache: _c, ...plain } = mods;
131
+ const q = buildRead(ctx.reg, ctx.schema, steps, plain, mode, agg);
132
+ const ev = mode === 'countPaths' ? 'count' : mode;
133
+ return exec(ctx, q, ev, steps, mods).then((res) => ({ q, res }));
118
134
  }
119
- // START_CONTRACT: runRows
120
- // PURPOSE: Исполняет читающий план в режиме строк — уникальные сущности последнего шага, с $depth при рекурсивном обходе.
121
- // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods }
122
- // OUTPUTS: { Promise<Row[]> - строки последнего шага (с $depth при deep) }
123
- // SIDE_EFFECTS: читает БД через M-SQL.buildRead/runQuery
124
- // LINKS: M-CHAIN, V-M-CHAIN
125
- // END_CONTRACT: runRows
126
- async function runRows(ctx, steps, mods) {
127
- // START_BLOCK_RUN_ROWS
128
- const q = buildRead(ctx, steps, mods, 'rows');
129
- const res = await runQuery(ctx, q.text, q.params, 'rows', clsOf(steps));
130
- return res.map((r) => {
131
- const row = toRow(r.row);
132
- const d = r.row.depth;
133
- return d != null ? { ...row, $depth: d } : row;
134
- });
135
- // END_BLOCK_RUN_ROWS
135
+ /**
136
+ * Цепочка с глаголами: весь план одной транзакцией (§2.5). Результат приводится к виду ответа
137
+ * чтения (строки rows/paths, id, n, агрегаты), чтобы терминалы разбирали его одинаково.
138
+ */
139
+ async function plan(ctx, steps, mods, mode, agg) {
140
+ precheckPlan(ctx.reg, steps);
141
+ const t0 = performance.now();
142
+ const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, steps, mods, mode, agg));
143
+ if (r.kind === 'read') {
144
+ report(ctx, mode === 'countPaths' ? 'count' : mode, steps, t0, r.res.length);
145
+ if (r.q === null) {
146
+ if (mode === 'count' || mode === 'countPaths')
147
+ return { q: null, res: [{ n: 0 }] };
148
+ if (mode === 'agg')
149
+ return { q: null, res: agg?.fn === 'countBy' ? [] : [{ v: null }] };
150
+ }
151
+ return r;
152
+ }
153
+ const rows = r.rows;
154
+ report(ctx, mode === 'countPaths' ? 'count' : mode === 'paths' ? 'paths' : mode === 'ids' ? 'ids' : 'rows', steps, t0, rows.length);
155
+ // не switch: check-docs принимает case-метки этого файла за имена, которые Proxy разбирает раньше классов
156
+ if (mode === 'paths')
157
+ return { q: null, res: rows.map((row) => ({ path: { [r.key]: row } })) };
158
+ if (mode === 'ids')
159
+ return { q: null, res: rows.map((row) => ({ id: row.id })) };
160
+ if (mode === 'count' || mode === 'countPaths')
161
+ return { q: null, res: [{ n: rows.length }] };
162
+ return { q: null, res: (mods.limit !== undefined ? rows.slice(0, mods.limit) : rows).map((row) => ({ row, done: true })) };
136
163
  }
137
- const NO_NODES = new Map();
138
164
  /**
139
- * Свойство-класс (вызов = шаг/pivot; без скобок = слот). ownerSteps undefined — корень
140
- * (db/batch): слот без владельца — ошибка.
165
+ * Чтение с блокировкой (§2.5, этап 4, п. 9): строки последнего шага блокируются for no key update
166
+ * в порядке id до конца явной транзакции. Сначала id шага, затем блокировка (политики правки RLS
167
+ * не дают заблокировать то, что править нельзя), затем то же чтение по свежим строкам: строка,
168
+ * которая перестала подходить под условия шага, в ответ не попадает; подходящая, но не
169
+ * заблокированная — acl_denied.
141
170
  */
142
- // START_CONTRACT: classProp
143
- // PURPOSE: Строит StepProp для класса — вызов даёт шаг-навигацию или pivot-возврат, обращение без скобок — слот связи (set/unset) записываемой версии.
144
- // INPUTS: { ctx: Ctx; name: string - имя/alias класса; cls: ClassDef; ownerSteps: Step[] | undefined - шаги владельца (undefined = корень db/batch); mods: ChainMods; batch: BatchRef | undefined; nodes: NodeMap - карта entity-идентичности; applySlot?: (slotCls, value) => Chain }
145
- // OUTPUTS: { StepProp - вызываемый (шаг) Proxy с методами set/unset }
146
- // SIDE_EFFECTS: возвращает новые Chain; бросает подсказки при слоте без владельца / не-конце / обращении без вызова
147
- // LINKS: M-CHAIN, V-M-CHAIN
148
- // END_CONTRACT: classProp
149
- function classProp(ctx, name, cls, ownerSteps, mods, batch, nodes, applySlot) {
150
- // START_BLOCK_CLASS_STEP
171
+ async function lockedRead(ctx, steps, mods, mode) {
172
+ if (!ctx.exec.explicit)
173
+ throw new LetopisError('tx_required', 'forUpdate() — только внутри db.begin(): вне явной транзакции блокировка снялась бы сразу');
174
+ if ((mode !== 'rows' && mode !== 'ids') || mods.asOf !== undefined || mods.withDeleted || steps.some((s) => s.deepMax !== undefined || s.op)) {
175
+ throw new LetopisError('lock_unsupported', 'forUpdate() — с терминалами rows, first, ids, без агрегатов, asOf, versions, withDeleted, deep и глаголов');
176
+ }
177
+ const S = `"${ctx.schema.replace(/"/g, '""')}"`;
178
+ const { forUpdate: _f, ...plain } = mods;
179
+ const idsQ = buildRead(ctx.reg, ctx.schema, steps, plain, 'ids');
180
+ const { limit: _l, offset: _o, after: _a, ...unbounded } = plain;
181
+ const rowsQ = buildRead(ctx.reg, ctx.schema, steps, unbounded, 'rows');
182
+ const t0 = performance.now();
183
+ const res = await ctx.exec.run(async (q) => {
184
+ const ids = (await q.unsafe(idsQ.text, idsQ.params, { prepare: true })).map((r) => r.id);
185
+ if (!ids.length)
186
+ return [];
187
+ const locked = new Set((await q.unsafe(`select id from ${S}.entity where id = any ($1::uuid[]) order by id for no key update`, [ids], { prepare: true })).map((r) => r.id));
188
+ const want = new Set(ids);
189
+ const fresh = (await q.unsafe(rowsQ.text, rowsQ.params, { prepare: true }))
190
+ .filter((r) => want.has(r.row.id));
191
+ const denied = fresh.filter((r) => !locked.has(r.row.id)).map((r) => r.row.id);
192
+ if (denied.length)
193
+ throw new LetopisError('acl_denied', `строки видны, но заблокировать их нельзя: ${denied.join(', ')}`, { ids: denied });
194
+ return fresh;
195
+ });
196
+ report(ctx, mode, steps, t0, res.length);
197
+ return { q: rowsQ, res: mode === 'ids' ? res.map((r) => ({ id: r.row.id })) : res };
198
+ }
199
+ /** Строка ответа: из чтения — to_jsonb узла, из плана — готовая строка записи. */
200
+ const rowOf = (r) => (r.done ? r.row : toRow(r.row));
201
+ /** Значение слота: id, список id, строка ответа или ленивая цепочка. */
202
+ function slotValue(v) {
203
+ const peek = peekPlan(v);
204
+ if (peek)
205
+ return { plan: peek.steps, mods: peek.mods };
206
+ if (v === null || typeof v === 'string')
207
+ return v;
208
+ if (Array.isArray(v))
209
+ return v.map((x) => (typeof x === 'string' ? x : x.id));
210
+ if (typeof v === 'object' && typeof v.id === 'string')
211
+ return { id: v.id };
212
+ throw new LetopisError('invalid_query', 'значение слота — id, список id, строка ответа или цепочка');
213
+ }
214
+ function classProp(ctx, name, found, ownerSteps, mods, nodes) {
215
+ const { cls, role } = found;
151
216
  const step = (filter) => {
152
217
  const prev = ownerSteps?.[ownerSteps.length - 1];
153
- // повтор LINK-класса в текущем паттерне — возврат к узлу (pivot)
154
- const node = prev && cls.category === 'LINK' ? pivotNodeOf(ownerSteps, cls) : undefined;
218
+ // повтор связи в пути — возврат к её узлу
219
+ const node = prev && cls.kind === 'link' && !role ? pivotNodeOf(ownerSteps, cls.name) : undefined;
155
220
  if (node) {
156
- const pivot = { name, cls, filter: normalizeFilter(filter), pivotKey: node.nodeKey };
157
- return makeChain(ctx, [...ownerSteps, pivot], mods, batch, nodes);
221
+ return makeChain(ctx, [...ownerSteps, { name, cls, filter: normalizeFilter(filter), pivotKey: node.nodeKey }], mods, nodes);
158
222
  }
159
- if (prev && !prev.op)
160
- resolveHop(prev.cls, cls); // ранняя проверка пути (после op — продолжение от результата)
161
- const next = { name, cls, filter: normalizeFilter(filter), nodeKey: nextNodeKey() };
162
- return makeChain(ctx, [...(ownerSteps ?? []), next], ownerSteps ? mods : {}, batch, nodes);
223
+ const next = { name, cls, role, filter: normalizeFilter(filter), nodeKey: nextNodeKey() };
224
+ if (prev)
225
+ resolveHop(ctx.reg, nodeOf(ownerSteps, prev), next); // ранняя проверка пути
226
+ return makeChain(ctx, [...(ownerSteps ?? []), next], ownerSteps ? mods : {}, nodes);
163
227
  };
164
- // END_BLOCK_CLASS_STEP
165
- // START_BLOCK_LINK_SLOT
166
- const slotGuard = () => {
167
- if (!ownerSteps?.length || !applySlot) {
168
- throw new Error(`letopis: slot "${name}" needs an owner step — start with a class call: db.Класс(…).${name}.set(…)`);
228
+ // слот: .Роль.set(x) / .unset() / .add(x) / .remove(x) — концы версии, которую пишет глагол
229
+ const slot = (op) => (v) => {
230
+ const last = ownerSteps?.[ownerSteps.length - 1];
231
+ const kind = last?.op?.kind;
232
+ if (!last || (kind !== 'create' && kind !== 'update' && kind !== 'upsert')) {
233
+ throw new LetopisError('invalid_query', `слот .${name}.${op}() — после create(), update() или upsert(): db.Класс(…).update(…).${name}.${op}(…)`);
169
234
  }
170
- const owner = ownerSteps[ownerSteps.length - 1];
171
- if (!findEnd(owner.cls, cls)) {
172
- throw new Error(`letopis: "${cls.id}" is not a link end of "${owner.cls.id}" (Schema.links: ${owner.cls.links.map((e) => e.classes.join('|')).join(', ') || '—'})`);
235
+ const role = slotRole(ctx.reg, last.cls, name);
236
+ const end = last.cls.ends[role];
237
+ if ((op === 'add' || op === 'remove') && !end.many)
238
+ throw new LetopisError('invalid_query', `слот .${name}.${op}(): конец ${role} одиночный — set() или unset()`);
239
+ if (op === 'unset' && (end.required || (end.min ?? 0) > 0))
240
+ throw new LetopisError('invalid_query', `слот .${name}.unset(): конец ${role} обязателен`);
241
+ if ((op === 'set' || op === 'unset') && last.slots?.some((x) => x.role === role && (x.op === 'set' || x.op === 'unset'))) {
242
+ throw new LetopisError('invalid_query', `слот ${role} задан дважды в одной записи`);
173
243
  }
174
- return owner.cls;
244
+ const value = op === 'unset' ? null : slotValue(v);
245
+ const next = ownerSteps.slice();
246
+ next[next.length - 1] = { ...last, slots: [...(last.slots ?? []), { role, op, value }] };
247
+ if (ctx.batch?.entry)
248
+ ctx.batch.entry.steps = next;
249
+ return makeChain(ctx, next, mods, nodes);
175
250
  };
176
251
  return new Proxy(step, {
177
- apply: (t, _self, args) => t(...args),
178
252
  get(t, p, r) {
179
- if (p === 'set') {
180
- return (v) => {
181
- slotGuard();
182
- return applySlot(cls, slotValue(cls, v));
183
- };
184
- }
185
- if (p === 'unset') {
186
- return () => {
187
- const ownerCls = slotGuard();
188
- const end = findEnd(ownerCls, cls);
189
- if (ownerCls.strictEnds && !end.optional) {
190
- throw new Error(`letopis: link end "${cls.id}" of "${ownerCls.id}" is required — cannot unset`);
191
- }
192
- return applySlot(cls, null);
193
- };
194
- }
195
- if (p === 'delete') {
196
- return () => {
197
- throw new Error(`letopis: slot .delete() renamed to .unset() (0.16.0) — .${name}.unset() removes the link end`);
198
- };
199
- }
200
- if (typeof p === 'symbol' || p === 'then' || p in Function.prototype || p === 'name' || p === 'length' || p === 'prototype') {
201
- return Reflect.get(t, p, r);
202
- }
203
- throw new Error(`letopis: "${name}" without call is a link slot (set/unset); for navigation call ${name}(…)`);
253
+ if (p === 'set' || p === 'unset' || p === 'add' || p === 'remove')
254
+ return slot(p);
255
+ return Reflect.get(t, p, r);
204
256
  },
205
257
  });
206
- // END_BLOCK_LINK_SLOT
207
258
  }
208
- // START_CONTRACT: makeChain
209
- // PURPOSE: Собирает Proxy-цепочку над списком шагов — модификаторы и слоты порождают новые цепочки, терминалы делегируют чтение (M-SQL) или исполнение плана (M-WRITE.runPlan).
210
- // INPUTS: { ctx: Ctx; steps: Step[] - накопленные шаги; mods: ChainMods; batch?: BatchRef - контекст батча; nodes?: NodeMap - entity-идентичность }
211
- // OUTPUTS: { Chain - Proxy с терминалами/модификаторами и свойствами-классами }
212
- // SIDE_EFFECTS: терминалы читают/пишут БД (runPaths/runRows/runQuery, runPlan); в батче мутирует очередь через syncBatch
213
- // LINKS: M-CHAIN, V-M-CHAIN
214
- // END_CONTRACT: makeChain
215
- function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
216
- const hasOps = steps.some((s) => s.op);
217
- // START_BLOCK_STEP_MUTATORS
259
+ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
218
260
  const withLast = (patch) => {
219
- const last = steps[steps.length - 1];
220
- if (last.op) {
221
- throw new Error(`letopis: step modifier after ${last.op.kind}() — add a class step first`);
222
- }
261
+ const lastOp = steps[steps.length - 1]?.op;
262
+ if (lastOp)
263
+ throw new LetopisError('invalid_query', `модификатор шага после ${lastOp.kind}() — сначала шаг класса`);
223
264
  const next = steps.slice();
224
- next[next.length - 1] = { ...last, ...patch };
225
- return makeChain(ctx, next, mods, batch, nodes);
265
+ next[next.length - 1] = { ...steps[steps.length - 1], ...patch };
266
+ return makeChain(ctx, next, mods, nodes);
226
267
  };
227
- const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batch, nodes);
228
- // END_BLOCK_STEP_MUTATORS
229
- // START_BLOCK_WRITE_DISPATCH
230
- /** Обновить план в батче (регистрируется на первой операции, дальше мутируется). */
268
+ const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, nodes);
269
+ /** Батч: первый глагол ставит план в очередь, следующие звенья обновляют его. */
231
270
  const syncBatch = (next) => {
232
- if (!batch)
271
+ const b = ctx.batch;
272
+ if (!b)
233
273
  return;
234
- if (batch.entry)
235
- batch.entry.steps = next;
274
+ if (b.entry)
275
+ b.entry.steps = next;
236
276
  else {
237
- batch.entry = { steps: next };
238
- batch.queue.push(batch.entry);
277
+ b.entry = { steps: next };
278
+ b.queue.push(b.entry);
239
279
  }
240
280
  };
241
- /** Операция-звено: op на последний шаг (op уже есть → self-шаг «те же сущности»); mods сбрасываются. */
281
+ /** Глагол — звено плана на последнем шаге (глагол уже есть — новый шаг «те же сущности»); модификаторы — его целям. */
242
282
  const withOp = (op) => {
243
283
  const last = steps[steps.length - 1];
284
+ if (!last)
285
+ throw new LetopisError('invalid_query', `${op.kind}() — нужен шаг класса: db.Класс(…).${op.kind}(…)`);
244
286
  const next = last.op
245
- ? [...steps, { name: last.name, cls: last.cls, self: true, op, nodeKey: nextNodeKey() }]
246
- : [...steps.slice(0, -1), { ...last, op }];
287
+ ? [...steps, { name: last.name, cls: last.cls, role: last.role, aliasKey: last.aliasKey, self: true, op, opMods: mods, nodeKey: nextNodeKey() }]
288
+ : [...steps.slice(0, -1), { ...last, op, opMods: mods }];
247
289
  syncBatch(next);
248
- return makeChain(ctx, next, {}, batch, nodes);
290
+ return makeChain(ctx, next, {}, nodes);
249
291
  };
250
- /**
251
- * Слот связи: значение конца ЗАПИСЫВАЕМОЙ версии последнего шага — только после
252
- * операции записи (…create(…).Класс.set(x) / …update(…).Класс.unset()): та же версия.
253
- */
254
- const applySlot = (slotCls, value) => {
255
- const last = steps[steps.length - 1];
256
- if (!last.op) {
257
- throw new Error(`letopis: link slot "${slotCls.alias}" needs a write — add .create(…)/.update(…) before .${slotCls.alias}.${value === null ? 'unset()' : 'set(…)'}`);
258
- }
259
- if (last.op.kind !== 'create' && last.op.kind !== 'update') {
260
- throw new Error(`letopis: link slot after ${last.op.kind}() — slots apply to create()/update()`);
261
- }
262
- if (last.extraLinks && slotCls.id in last.extraLinks) {
263
- throw new Error(`letopis: duplicate link slot "${slotCls.id}" in one write`);
264
- }
265
- const patched = {
266
- ...last,
267
- extraLinks: { ...(last.extraLinks ?? {}), [slotCls.id]: value },
268
- };
269
- const next = [...steps.slice(0, -1), patched];
270
- syncBatch(next);
271
- return makeChain(ctx, next, mods, batch, nodes);
272
- };
273
- // END_BLOCK_WRITE_DISPATCH
274
- // START_BLOCK_ENTITY_EMBED
275
- /** entity(x): вклейка узла/паттерна; та же переменная повторно — возврат к её узлу. */
276
292
  const entityStep = (x) => {
277
293
  const plan = peekPlan(x);
294
+ const prev = steps.length ? nodeOf(steps, steps[steps.length - 1]) : undefined;
295
+ if (plan?.steps.some((s) => s.op))
296
+ throw new LetopisError('invalid_query', 'entity() принимает читающий паттерн — цепочку с глаголами вклеить нельзя');
278
297
  if (plan) {
279
298
  const known = nodes.get(x);
280
299
  if (known !== undefined) {
281
300
  const lastX = plan.steps[plan.steps.length - 1];
282
- const pivot = { name: lastX.name, cls: lastX.cls, pivotKey: known };
283
- return makeChain(ctx, [...steps, pivot], mods, batch, nodes);
301
+ return makeChain(ctx, [...steps, { name: lastX.name, cls: lastX.cls, pivotKey: known }], mods, nodes);
284
302
  }
285
- if (plan.steps.some((s) => s.op)) {
286
- throw new Error('letopis: entity() takes a read pattern — a chain with operations cannot be embedded');
287
- }
288
- const prev = steps[steps.length - 1];
289
- if (prev && !prev.op)
290
- resolveHop(prev.cls, plan.steps[0].cls); // стык по правилам пути
303
+ if (prev)
304
+ resolveHop(ctx.reg, prev, plan.steps[0]);
291
305
  const nodeKey = plan.steps[plan.steps.length - 1].nodeKey;
292
306
  const nextNodes = new Map(nodes);
293
307
  nextNodes.set(x, nodeKey);
294
- return makeChain(ctx, [...steps, ...plan.steps], mods, batch, nextNodes);
308
+ return makeChain(ctx, [...steps, ...plan.steps], mods, nextNodes);
295
309
  }
296
310
  if (x !== null && typeof x === 'object' && typeof x.id === 'string' && typeof x.class === 'string') {
297
311
  const row = x;
298
- const cls = ctx.registry.resolve(row.class);
299
- const prev = steps[steps.length - 1];
300
- if (prev && !prev.op)
301
- resolveHop(prev.cls, cls);
302
- const next = { name: cls.alias, cls, filter: row.id, nodeKey: nextNodeKey() };
303
- return makeChain(ctx, [...steps, next], mods, batch, nodes);
312
+ const cls = ctx.reg.resolve(row.class);
313
+ const next = { name: cls.name, cls, filter: row.id, nodeKey: nextNodeKey(), exact: true };
314
+ if (prev)
315
+ resolveHop(ctx.reg, prev, next);
316
+ return makeChain(ctx, [...steps, next], mods, nodes);
304
317
  }
305
- throw new Error('letopis: entity() accepts a lazy chain or a Row');
318
+ throw new LetopisError('invalid_query', 'entity() принимает ленивую цепочку или строку ответа');
306
319
  };
307
- // END_BLOCK_ENTITY_EMBED
308
- /** Терминал: план (есть операции) — runPlan одной транзакцией; иначе прямое чтение. */
309
- const guardBatch = () => {
310
- if (batch && hasOps) {
311
- throw new Error('letopis: plan is queued in the batch — call batch.run()');
312
- }
320
+ const agg = (fn) => async (field) => {
321
+ const { q, res } = await read(ctx, steps, mods, 'agg', { fn, field });
322
+ if (fn === 'countBy')
323
+ return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
324
+ const v = res[0]?.v;
325
+ if (v === null || v === undefined)
326
+ return null;
327
+ if (typeof v !== 'string')
328
+ return v;
329
+ if (fn === 'sum' || fn === 'avg' || q?.aggLeaf === 'numeric')
330
+ return Number(v);
331
+ return v;
313
332
  };
314
333
  const handler = {
315
334
  get(_t, prop) {
316
335
  if (prop === PLAN)
317
336
  return { steps, mods };
318
- if (typeof prop === 'symbol' || prop === 'then')
337
+ if (typeof prop === 'symbol')
319
338
  return undefined;
320
339
  switch (prop) {
321
- // START_BLOCK_READ_TERMINALS
340
+ case 'then':
341
+ return undefined;
322
342
  case 'entity':
323
343
  return entityStep;
324
- case 'run':
325
- return () => {
326
- guardBatch();
327
- return hasOps ? runPlan(ctx, steps, mods, 'paths') : runPaths(ctx, steps, mods);
344
+ case 'paths':
345
+ return async () => {
346
+ // q === null — пути из результата глагола: строки уже готовы
347
+ const { q, res } = await read(ctx, steps, mods, 'paths');
348
+ return res.map((r) => Object.fromEntries(Object.entries(r.path).map(([k, v]) => [k, q === null ? v : toRow(v)])));
328
349
  };
329
- case 'execute':
330
- throw new Error('letopis: execute() renamed to run() (0.11.0)');
331
350
  case 'rows':
332
- return () => {
333
- guardBatch();
334
- return hasOps ? runPlan(ctx, steps, mods, 'rows') : runRows(ctx, steps, mods);
335
- };
351
+ return async () => (await read(ctx, steps, mods, 'rows')).res.map(rowOf);
336
352
  case 'first':
337
353
  return async () => {
338
- guardBatch();
339
- const rows = hasOps
340
- ? (await runPlan(ctx, steps, mods, 'rows'))
341
- : await runRows(ctx, steps, { ...mods, limit: 1 });
342
- return rows[0] ?? null;
354
+ const { res } = await read(ctx, steps, { ...mods, limit: 1 }, 'rows');
355
+ return res[0] ? rowOf(res[0]) : null;
343
356
  };
344
357
  case 'ids':
345
- return async () => {
346
- guardBatch();
347
- if (hasOps)
348
- return runPlan(ctx, steps, mods, 'ids');
349
- const q = buildRead(ctx, steps, mods, 'ids');
350
- const res = await runQuery(ctx, q.text, q.params, 'ids', clsOf(steps));
351
- return res.map((r) => r.id);
352
- };
358
+ return async () => (await read(ctx, steps, mods, 'ids')).res.map((r) => r.id);
353
359
  case 'count':
354
- return async () => {
355
- guardBatch();
356
- if (hasOps)
357
- return runPlan(ctx, steps, mods, 'count');
358
- const q = buildRead(ctx, steps, mods, 'count');
359
- const res = await runQuery(ctx, q.text, q.params, 'count', clsOf(steps));
360
- return res[0].n;
360
+ return async (opts) => (await read(ctx, steps, mods, opts?.paths ? 'countPaths' : 'count')).res[0].n;
361
+ case 'versions':
362
+ return async (opts) => {
363
+ warnNoHistory(steps, 'versions');
364
+ const m = opts?.follow ? { ...mods, follow: true } : mods;
365
+ return (await read(ctx, steps, m, 'versions')).res.map((r) => toRow(r.row));
361
366
  };
362
- // END_BLOCK_READ_TERMINALS
363
- // START_BLOCK_READ_MODIFIERS
367
+ case 'sum':
368
+ case 'avg':
369
+ case 'min':
370
+ case 'max':
371
+ case 'countBy':
372
+ return agg(prop);
364
373
  case 'limit':
365
374
  return (n) => withMods({ limit: n });
366
375
  case 'offset':
@@ -372,313 +381,103 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
372
381
  warnNoHistory(steps, 'asOf');
373
382
  return withMods({ asOf: t instanceof Date ? t.toISOString() : t });
374
383
  };
384
+ case 'after':
385
+ return (cursor) => withMods({ after: cursor });
375
386
  case 'withDeleted':
376
387
  return () => withMods({ withDeleted: true });
377
388
  case 'deep':
378
389
  return (max = 32) => withLast({ deepMax: max });
379
390
  case 'exact':
380
- // снять полиморфизм ТЕКУЩЕГО шага: только свой класс, без потомков
381
- return () => withLast({ exactClass: true });
382
- // END_BLOCK_READ_MODIFIERS
383
- // START_BLOCK_AGGREGATIONS
384
- case 'sum':
385
- case 'avg':
386
- case 'min':
387
- case 'max':
388
- return async (field) => {
389
- guardBatch();
390
- const aggMods = { ...mods, aggFn: prop, aggField: field };
391
- const res = hasOps
392
- ? (await runPlan(ctx, steps, aggMods, 'agg'))
393
- : await (async () => {
394
- const q = buildRead(ctx, steps, aggMods, 'agg');
395
- return runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
396
- })();
397
- const v = res[0]?.v;
398
- if (v === null || v === undefined)
399
- return null;
400
- if (typeof v !== 'string')
401
- return v;
402
- if (prop === 'sum' || prop === 'avg')
403
- return Number(v);
404
- // min/max: postgres.js отдаёт numeric строкой — приводим, если лист числовой по Schema
405
- const leaf = leafType(steps[steps.length - 1].cls, field.slice(5).split('.'));
406
- return leaf?.kind === 'number' ? Number(v) : v;
407
- };
408
- case 'countBy':
409
- return async (field) => {
410
- guardBatch();
411
- const aggMods = { ...mods, aggFn: 'countBy', aggField: field };
412
- const res = hasOps
413
- ? (await runPlan(ctx, steps, aggMods, 'agg'))
414
- : await (async () => {
415
- const q = buildRead(ctx, steps, aggMods, 'agg');
416
- return runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
417
- })();
418
- return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
419
- };
420
- // END_BLOCK_AGGREGATIONS
421
- // START_BLOCK_VERSIONS_CURSOR
422
- case 'after':
423
- return (cursor) => withMods({ after: cursor });
424
- case 'versions':
425
- return async () => {
426
- guardBatch();
427
- warnNoHistory(steps, 'versions');
428
- if (hasOps)
429
- return runPlan(ctx, steps, mods, 'versions');
430
- const q = buildRead(ctx, steps, mods, 'versions');
431
- const res = await runQuery(ctx, q.text, q.params, 'versions', clsOf(steps));
432
- return res.map((r) => {
433
- const raw = r.row;
434
- const row = toRow(r.row);
435
- return raw.deleted ? { ...row, $deleted: true } : row;
436
- });
437
- };
438
- // END_BLOCK_VERSIONS_CURSOR
439
- // START_BLOCK_WRITE_TERMINALS
440
- case 'create':
441
- return (data = {}) => {
442
- const last = steps[steps.length - 1];
443
- if (last && !last.op) { // после op create пишет результат — фильтр там подставит план
444
- if (last.pivotKey !== undefined) {
445
- throw new Error('letopis: create() on a pivot step — pivot returns to an existing node, use update()');
446
- }
447
- if (last.filter !== undefined && typeof last.filter !== 'string') {
448
- throw new Error(`letopis: create() takes no filter — ${last.name}(id).create(…) fixes the id, searching is update()`);
449
- }
450
- }
451
- return withOp({ kind: 'create', data, mods });
452
- };
453
- case 'update':
454
- return (data = {}) => withOp({ kind: 'update', data, mods });
455
- case 'set':
456
- throw new Error('letopis: set() split into create()/update() (0.16.0) — create() inserts, update() versions what the path finds');
457
- case 'delete':
458
- return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
459
- case 'anonymize':
460
- return (fields) => withOp({ kind: 'anonymize', fields, mods });
461
- case 'purge':
462
- return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, mods });
463
- // END_BLOCK_WRITE_TERMINALS
464
- // START_BLOCK_COLUMN_MODS
391
+ return () => withLast({ exact: true });
465
392
  case 'alias':
466
393
  return (name) => withLast({ aliasKey: name });
467
394
  case 'tags':
468
395
  return (v) => withLast({ tagsFilter: v });
469
- case 'account':
470
- return (v) => withLast({ accountFilter: typeof v === 'object' ? v.id : v });
471
396
  case 'owner':
472
397
  return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
473
- // NB: охранника `case 'link'` здесь больше нет (снят в 0.20.0). Он затенял РЕАЛЬНЫЙ
474
- // класс схемы: в демо-домене `link` — абстрактный корень всех связок, и шаг
475
- // db.Клиент(c).link() падал migration-ошибкой вместо обхода. Обратная совместимость
476
- // со снесённым в 0.15.0 методом .link() принесена в жертву достижимости класса.
477
- // END_BLOCK_COLUMN_MODS
478
- }
479
- // START_BLOCK_CLASS_RESOLVE
480
- if (ctx.registry.has(prop)) {
481
- return classProp(ctx, prop, ctx.registry.resolve(prop), steps, mods, batch, nodes, applySlot);
398
+ // охранники снесённого API (этап 3, п. 11)
399
+ case 'run':
400
+ throw removed('run()', 'paths()');
401
+ case 'execute':
402
+ throw removed('execute()', 'paths()');
403
+ case 'account':
404
+ throw removed('account()', 'арендатор сессии и auth.switch() (этап 5)');
405
+ // глаголы записи — звенья плана (этап 4)
406
+ case 'create':
407
+ return (data = {}) => withOp({ kind: 'create', data });
408
+ case 'update':
409
+ return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}) });
410
+ case 'upsert':
411
+ return (data = {}) => withOp({ kind: 'upsert', data });
412
+ case 'delete':
413
+ return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true });
414
+ case 'anonymize':
415
+ return (fields) => withOp({ kind: 'anonymize', fields });
416
+ case 'reclass':
417
+ return (cls, data) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}) });
418
+ case 'set':
419
+ case 'unset':
420
+ case 'add':
421
+ case 'remove':
422
+ throw new LetopisError('invalid_query', `${prop}() — метод слота: db.Класс(…).update(…).Роль.${prop}(…)`);
423
+ case 'forUpdate':
424
+ return () => withMods({ forUpdate: true });
425
+ case 'restore':
426
+ return () => withOp({ kind: 'restore' });
427
+ case 'purge':
428
+ return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true });
429
+ case 'rekey':
430
+ return (patch) => withOp({ kind: 'rekey', data: patch ?? {} });
431
+ case 'cache':
432
+ return (opts) => {
433
+ if (opts?.ttl !== undefined && !(opts.ttl > 0))
434
+ throw new LetopisError('invalid_query', 'cache({ ttl }): срок — положительное число секунд');
435
+ return withMods({ cache: opts ?? {} });
436
+ };
482
437
  }
483
- ctx.registry.resolve(prop); // неизвестный класс — понятная ошибка со списком
484
- return undefined;
485
- // END_BLOCK_CLASS_RESOLVE
438
+ const found = lookupStep(ctx.reg, prop) ?? unknownName(prop);
439
+ return classProp(ctx, prop, found, steps, mods, nodes);
486
440
  },
487
441
  };
488
442
  return new Proxy({}, handler);
489
443
  }
490
- // START_CONTRACT: makeBatch
491
- // PURPOSE: Собирает Proxy-батч над общей очередью планов — свойства-классы копят цепочки, run() исполняет всю очередь одной транзакцией.
492
- // INPUTS: { ctx: Ctx; queue: BatchPlan[] - общая очередь планов батча }
493
- // OUTPUTS: { Batch - Proxy с run/discard/size и свойствами-классами }
494
- // SIDE_EFFECTS: run() исполняет и очищает очередь через M-WRITE.executeBatch; discard() очищает очередь
495
- // LINKS: M-CHAIN, V-M-CHAIN
496
- // END_CONTRACT: makeBatch
497
- function makeBatch(ctx, queue) {
498
- const handler = {
444
+ /**
445
+ * Батч: очередь планов; каждый вызов класса — своя ссылка на очередь (свой план). run() исполняет очередь
446
+ * в контексте ctx (актор, арендатор, владелец, транзакция) — поэтому очередь принадлежит фасаду этого ctx
447
+ * и общей с другими фасадами быть не может: иначе план исполнился бы от имени чужого актора.
448
+ */
449
+ export function makeBatch(ctx, queue) {
450
+ return new Proxy({}, {
499
451
  get(_t, prop) {
500
452
  if (typeof prop === 'symbol' || prop === 'then')
501
453
  return undefined;
502
454
  switch (prop) {
503
- // START_BLOCK_BATCH_QUEUE
504
455
  case 'run':
505
456
  return async () => {
506
- const plans = queue.splice(0, queue.length);
507
- return executeBatch(ctx, plans);
457
+ const plans = queue.splice(0, queue.length).map((x) => x.steps);
458
+ for (const p of plans)
459
+ precheckPlan(ctx.reg, p);
460
+ if (!plans.length)
461
+ return [];
462
+ return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, plans));
508
463
  };
509
464
  case 'execute':
510
- throw new Error('letopis: batch.execute() renamed to batch.run() (0.11.0)');
465
+ throw removed('batch.execute()', 'batch.run()');
511
466
  case 'discard':
512
467
  return () => void queue.splice(0, queue.length);
513
468
  case 'size':
514
469
  return () => queue.length;
515
- // END_BLOCK_BATCH_QUEUE
516
470
  }
517
- if (ctx.registry.has(prop)) {
518
- return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, { queue }, NO_NODES);
519
- }
520
- ctx.registry.resolve(prop);
521
- return undefined;
471
+ return startStep({ ...ctx, batch: { queue } }, prop);
522
472
  },
523
- };
524
- return new Proxy({}, handler);
473
+ });
525
474
  }
526
- // START_CONTRACT: makeDb
527
- // PURPOSE: Фабрика корневого фасада EntityDb над Ctx — классы как стартовые шаги, транзакции (begin/commit/rollback/lock), батчи, watch, служебные таблицы/auth/acl, entity()-старт.
528
- // INPUTS: { ctx: Ctx - контекст соединения/схемы (или TxCtx внутри begin()); root?: RootState - общее состояние батчей }
529
- // OUTPUTS: { EntityDb - Proxy-фасад }
530
- // SIDE_EFFECTS: begin() открывает транзакцию (M-TX.beginTx); watch() ставит LISTEN на канал = имя схемы (ctx.pgSchema); close() закрывает соединение; лениво поднимает M-TABLES/M-AUTH/M-ACL
531
- // LINKS: M-CHAIN, V-M-CHAIN
532
- // END_CONTRACT: makeDb
533
- export function makeDb(ctx, root) {
534
- const state = root ?? { batches: new Map() };
535
- let tables; // лениво, на ctx этого фасада (работает и в tr)
536
- let auth;
537
- let acl;
538
- const handler = {
539
- get(_t, prop) {
540
- if (typeof prop === 'symbol' || prop === 'then')
541
- return undefined;
542
- // START_BLOCK_TABLES_AUTH_ACL
543
- if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules' || prop === 'schema') {
544
- tables ??= makeTables(ctx);
545
- return tables[prop];
546
- }
547
- if (prop === 'auth') {
548
- tables ??= makeTables(ctx);
549
- auth ??= makeAuth(tables);
550
- return auth;
551
- }
552
- if (prop === 'acl') {
553
- tables ??= makeTables(ctx);
554
- acl ??= makeAcl(tables, ctx.registry);
555
- return acl;
556
- }
557
- // END_BLOCK_TABLES_AUTH_ACL
558
- switch (prop) {
559
- // START_BLOCK_SCOPE_AS
560
- // db.as(account): тот же пул, но вызов от имени этого арендатора. Дешёвый клон ctx
561
- // (как beginTx), НЕ новое соединение. Батчи не наследуются: очередь планов, общая
562
- // для разных арендаторов, — это утечка записи между ними.
563
- case 'as':
564
- return async (a, o) => {
565
- const account = typeof a === 'object' ? a?.id : a;
566
- if (!account)
567
- throw new Error('letopis: db.as(account) requires an account id (uuid or { id })');
568
- const scoped = { ...ctx, account, owner: o?.owner, aclDecide: undefined };
569
- // reloadSchema() со scope: реестр перечитывает КОРЕНЬ (единый источник), затем
570
- // scope подхватывает его и перекомпилирует свой энфорсер под своего субъекта.
571
- scoped.reload = async () => {
572
- await ctx.reload?.();
573
- scoped.registry = ctx.registry;
574
- await ctx.compileAcl?.(scoped);
575
- };
576
- await ctx.compileAcl?.(scoped);
577
- return makeDb(scoped);
578
- };
579
- // END_BLOCK_SCOPE_AS
580
- // START_BLOCK_TX_LOCK
581
- case 'begin':
582
- return async () => makeDb(await beginTx(ctx), state);
583
- case 'commit':
584
- return (tr) => {
585
- if (tr)
586
- return tr.commit();
587
- const c = ctx;
588
- if (typeof c.commit === 'function')
589
- return c.commit();
590
- throw new Error('letopis: commit() needs a transaction: db.commit(tr) or tr.commit()');
591
- };
592
- case 'rollback':
593
- return (tr) => {
594
- if (tr)
595
- return tr.rollback();
596
- const c = ctx;
597
- if (typeof c.rollback === 'function')
598
- return c.rollback();
599
- throw new Error('letopis: rollback() needs a transaction: db.rollback(tr) or tr.rollback()');
600
- };
601
- case 'lock':
602
- return (...keys) => lock(ctx, ...keys);
603
- // END_BLOCK_TX_LOCK
604
- // START_BLOCK_BATCH_OPEN
605
- case 'batch':
606
- return (name) => {
607
- let q = state.batches.get(name);
608
- if (!q) {
609
- q = [];
610
- state.batches.set(name, q);
611
- }
612
- return makeBatch(ctx, q);
613
- };
614
- // END_BLOCK_BATCH_OPEN
615
- // START_BLOCK_WATCH_LISTEN
616
- case 'watch':
617
- return async (a, b, c) => {
618
- const cls = typeof a === 'string' ? ctx.registry.resolve(a).id : undefined;
619
- const cb = typeof a === 'function' ? a : b;
620
- const opts = (typeof a === 'function' ? b : c) ?? {};
621
- const listener = (payload) => {
622
- const e = JSON.parse(payload);
623
- if (e.partition !== ctx.partition)
624
- return;
625
- if (cls && e.class !== cls)
626
- return;
627
- // enforceAcl: отдаём только при БЕЗУСЛОВНОМ allow — payload не содержит
628
- // owner/account/data, предикат строки проверить нечем (id — тоже утечка)
629
- if (ctx.aclDecide) {
630
- try {
631
- const d = ctx.aclDecide(ctx.registry.resolve(e.class), 'READ');
632
- if (!d.allow || d.filter)
633
- return;
634
- }
635
- catch {
636
- return;
637
- }
638
- }
639
- cb(e);
640
- };
641
- // postgres.js: dedicated LISTEN-соединение само переподключается (backoff) и
642
- // повторяет LISTEN; onlisten зовётся на первом connect И на каждом re-listen.
643
- // NOTIFY за время разрыва потеряны — onReconnect даёт точку дочитать пропущенное.
644
- let first = true;
645
- const onlisten = () => {
646
- if (first) {
647
- first = false;
648
- return;
649
- }
650
- opts.onReconnect?.();
651
- };
652
- const { unlisten } = await ctx.sql.listen(ctx.pgSchema, listener, onlisten);
653
- return unlisten;
654
- };
655
- // END_BLOCK_WATCH_LISTEN
656
- // START_BLOCK_CLOSE_META
657
- case 'close':
658
- return () => ctx.sql.end();
659
- case 'reloadSchema':
660
- return async () => {
661
- await ctx.reload?.();
662
- acl = undefined; // db.acl-фасад перестроится на свежем registry при следующем доступе
663
- };
664
- case 'registry':
665
- return ctx.registry;
666
- case 'sql':
667
- return ctx.sql;
668
- // END_BLOCK_CLOSE_META
669
- }
670
- // START_BLOCK_ENTITY_START
671
- if (prop === 'entity') {
672
- // старт пути с готового паттерна/строки: db.entity(pos).…
673
- return (x) => makeChain(ctx, [], {}, undefined, NO_NODES).entity(x);
674
- }
675
- if (ctx.registry.has(prop)) {
676
- return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, undefined, NO_NODES);
677
- }
678
- ctx.registry.resolve(prop);
679
- return undefined;
680
- // END_BLOCK_ENTITY_START
681
- },
682
- };
683
- return new Proxy({}, handler);
475
+ /** Старт пути от корня db: класс, алиас или роль. */
476
+ export function startStep(ctx, name) {
477
+ const found = lookupStep(ctx.reg, name) ?? unknownName(name);
478
+ return classProp(ctx, name, found, undefined, {}, NO_NODES);
479
+ }
480
+ /** Старт пути с готового узла: db.entity(row | цепочка). */
481
+ export function startEntity(ctx, x) {
482
+ return makeChain(ctx, [], {}, NO_NODES).entity(x);
684
483
  }