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