letopis 0.13.0 → 0.16.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.
package/dist/chain.d.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * Выборка настраивается модификаторами: .limit(n) .offset(n) .sort(field, dir?)
6
6
  * Колонки — модификаторами: .tags(…) .account(…) .owner(…); ключ путей — .alias(…).
7
- * Запись: .set(data) / .delete({confirm}) / .anonymize(fields) — ЗВЕНЬЯ (возвращают
8
- * цепочку, продолжение — от результата); исполняет ТЕРМИНАЛ, весь план одной транзакцией.
7
+ * Запись: .create(data) / .update(data) / .delete({confirm}) / .anonymize(fields) — ЗВЕНЬЯ
8
+ * (возвращают цепочку, продолжение — от результата); исполняет ТЕРМИНАЛ, весь план
9
+ * одной транзакцией.
9
10
  */
10
11
  import type { Row, Path, Filter } from './types.js';
11
12
  import { type Ctx } from './sql.js';
@@ -49,31 +50,37 @@ export interface ChainCore {
49
50
  anonymize(fields: string[]): Chain;
50
51
  /** Переименовать ключ ТЕКУЩЕГО шага в выводе путей. */
51
52
  alias(name: string): Chain;
52
- /** Колонка tags: строка | string[] (все) | has/hasAny/hasAll. В set() — значение тегов. */
53
+ /** Колонка tags: строка | string[] (все) | has/hasAny/hasAll. В записи — значение тегов. */
53
54
  tags(v: string | string[] | object): Chain;
54
- /** Колонка account (uuid или Row/Account). В set()-insert — значение. */
55
+ /** Колонка account (uuid или Row/Account). В create() — значение. */
55
56
  account(v: string | {
56
57
  id: string;
57
58
  }): Chain;
58
- /** Колонка owner (uuid или Row/Account). В set()-insert — значение. */
59
+ /** Колонка owner (uuid или Row/Account). В create() — значение. */
59
60
  owner(v: string | {
60
61
  id: string;
61
62
  }): Chain;
62
63
  /**
63
- * Значение связи для записи БЕЗ участия в фильтре целей (в отличие от контекст-шага):
64
- * tr.Запись(b).позиция({}).link('Сотрудник', новый).set({}).rows() // перевесить исполнителя
64
+ * Вставить узел в путь: x — ленивая цепочка-паттерн (стык проверяется правилами
65
+ * переходов) либо Row (шаг его класса по id). ТА ЖЕ переменная-цепочка повторно
66
+ * в одном пути — возврат к её узлу (ветвление); разные переменные — разные узлы.
65
67
  */
66
- link(className: string, target: string | {
67
- id: string;
68
- }): Chain;
68
+ entity(x: Chain | Row): Chain;
69
69
  /**
70
- * Записать в шаг, к которому приклеен точкой (ЗВЕНО — вернёт цепочку, исполняет терминал):
71
- * await db.Организация(org).Сотрудник().set({ name: 'Вася' }).rows()
72
- * Связи — контекст-шагами до set(). Пустой фильтр без data.id → INSERT;
73
- * id (фильтр или data.id) → UPSERT; фильтр-объект → версия каждого найденного.
70
+ * Создать сущность (ЗВЕНО — вернёт цепочку, исполняет терминал):
71
+ * await db.Организация(org).Сотрудник().create({ name: 'Вася' }).rows()
72
+ * Связи — контекст-шагами до create() + слотами .Класс.set(…) после. id — по Schema
73
+ * (attributes.id: v4 | v7 | v5-вычисляемый); известный id (Класс(id) / data.id / v5)
74
+ * уже существует → новая версия (идемпотентно). Фильтр-объект — ошибка: это update().
75
+ */
76
+ create(data?: Record<string, unknown>): Chain;
77
+ /**
78
+ * Новая версия КАЖДОГО найденного путём (deep-merge листьев data):
79
+ * await db.Запись(з).позиция({ qty: 1 }).update({ qty: 2 }).rows()
80
+ * Класс() ≡ Класс({}) — все в границах контекста. Не найдено → [] (НИКОГДА не создаёт).
74
81
  * Продолжение цепочки — от записанных строк (fan-out при множестве).
75
82
  */
76
- set(data?: Record<string, unknown>): Chain;
83
+ update(data?: Record<string, unknown>): Chain;
77
84
  /**
78
85
  * Удаление-звено. { confirm: true } — серверный tombstone + каскад (продолжение — от
79
86
  * затомбстоуненных, $deleted: true); БЕЗ confirm — превью: терминал вернёт кандидатов
@@ -83,8 +90,25 @@ export interface ChainCore {
83
90
  confirm?: boolean;
84
91
  }): Chain;
85
92
  }
93
+ /**
94
+ * Свойство-класс на цепочке:
95
+ * ВЫЗОВ `Класс(фильтр?)` — шаг-навигация; повтор LINK-класса в пути = возврат
96
+ * к его узлу (pivot: ветвление к другому концу, дофильтровка AND);
97
+ * СЛОТ `Класс` без скобок — конец links записываемой версии:
98
+ * .set(target) — установить конец (id | Row | вложенная цепочка: та же транзакция,
99
+ * ровно одна сущность класса конца); союз-конец замещается целиком;
100
+ * .unset() — снять optional-конец.
101
+ * Слот валиден только для конца из Schema.links владельца и ТОЛЬКО после операции
102
+ * записи: …create(…).Класс.set(x) / …update(…).Класс.unset() — та же версия строки.
103
+ */
104
+ export type StepProp = ((filter?: Filter) => Chain) & {
105
+ set(target: string | {
106
+ id: string;
107
+ } | Chain): Chain;
108
+ unset(): Chain;
109
+ };
86
110
  export type Chain = ChainCore & {
87
- [className: string]: (filter?: Filter) => Chain;
111
+ [className: string]: StepProp;
88
112
  };
89
113
  export interface DbCore {
90
114
  /** Открыть транзакцию: tr — тот же API + commit/rollback/lock. */
@@ -114,6 +138,8 @@ export interface DbCore {
114
138
  auth: AuthApi;
115
139
  /** ACL по Resource/Rule: check(эндпоинт) / checkData(класс, READ|WRITE|DELETE) / reload. */
116
140
  acl: AclApi;
141
+ /** Начать путь с готового узла/паттерна: db.entity(pos).… (pos — ленивая цепочка или Row). */
142
+ entity(x: Chain | Row): Chain;
117
143
  }
118
144
  export type EntityDb = DbCore & {
119
145
  [className: string]: (filter?: Filter) => Chain;
@@ -147,5 +173,7 @@ export type Batch = {
147
173
  interface RootState {
148
174
  batches: Map<string, BatchPlan[]>;
149
175
  }
176
+ /** Внутренний канал: план чужой ленивой цепочки (слот-значения, entity()). */
177
+ export declare const PLAN: unique symbol;
150
178
  export declare function makeDb(ctx: Ctx, root?: RootState): EntityDb;
151
179
  export {};
package/dist/chain.js CHANGED
@@ -1,4 +1,4 @@
1
- import { buildRead, runQuery, leafType } from './sql.js';
1
+ import { buildRead, runQuery, leafType, resolveHop, nextNodeKey, } from './sql.js';
2
2
  import { runPlan, executeBatch, toRow } from './write.js';
3
3
  import { beginTx, lock } from './tx.js';
4
4
  import { makeTables } from './tables.js';
@@ -16,6 +16,44 @@ function normalizeFilter(f) {
16
16
  return f;
17
17
  }
18
18
  const clsOf = (steps) => steps.map((s) => s.cls.id);
19
+ /** Внутренний канал: план чужой ленивой цепочки (слот-значения, entity()). */
20
+ export const PLAN = Symbol('letopis.plan');
21
+ const peekPlan = (x) => x !== null && typeof x === 'object' ? x[PLAN] : undefined;
22
+ /** Конец схемы владельца, принимающий класс слота (legacy: 'Entity'-конец берёт любой HUB). */
23
+ function findEnd(owner, slot) {
24
+ return owner.links.find((e) => e.classes.includes(slot.id) || (!owner.strictEnds && e.classes.includes('Entity') && slot.category === 'HUB'));
25
+ }
26
+ /** Значение слота: id-строка | Row | цепочка (одношаговый адрес сворачивается в id). */
27
+ function slotValue(slotCls, v) {
28
+ if (typeof v === 'string')
29
+ return v;
30
+ const plan = peekPlan(v);
31
+ if (plan) {
32
+ const [head] = plan.steps;
33
+ if (plan.steps.length === 1 && !head.op && typeof head.filter === 'string') {
34
+ if (head.cls.id !== slotCls.id) {
35
+ throw new Error(`letopis: slot "${slotCls.id}" got a chain of class "${head.cls.id}"`);
36
+ }
37
+ return head.filter; // просто адрес db.Класс(id) — без вложенного исполнения
38
+ }
39
+ return { plan: plan.steps, mods: plan.mods };
40
+ }
41
+ if (v !== null && typeof v === 'object' && typeof v.id === 'string') {
42
+ return v.id; // Row / Account
43
+ }
44
+ throw new Error(`letopis: slot "${slotCls.id}" accepts an id, a Row or a chain`);
45
+ }
46
+ /** Узел для pivot: последний реальный шаг класса cls в ТЕКУЩЕМ читающем сегменте (до op). */
47
+ function pivotNodeOf(steps, cls) {
48
+ for (let i = steps.length - 1; i >= 0; i--) {
49
+ const s = steps[i];
50
+ if (s.op)
51
+ return undefined; // операция закрывает паттерн — узлы до неё недоступны
52
+ if (s.pivotKey === undefined && s.cls.id === cls.id)
53
+ return s;
54
+ }
55
+ return undefined;
56
+ }
19
57
  async function runPaths(ctx, steps, mods) {
20
58
  const q = buildRead(ctx, steps, mods, 'paths');
21
59
  const res = await runQuery(ctx, q.text, q.params, 'paths', clsOf(steps));
@@ -35,7 +73,67 @@ async function runRows(ctx, steps, mods) {
35
73
  return d != null ? { ...row, $depth: d } : row;
36
74
  });
37
75
  }
38
- function makeChain(ctx, steps, mods, batch) {
76
+ const NO_NODES = new Map();
77
+ /**
78
+ * Свойство-класс (вызов = шаг/pivot; без скобок = слот). ownerSteps undefined — корень
79
+ * (db/batch): слот без владельца — ошибка.
80
+ */
81
+ function classProp(ctx, name, cls, ownerSteps, mods, batch, nodes, applySlot) {
82
+ const step = (filter) => {
83
+ const prev = ownerSteps?.[ownerSteps.length - 1];
84
+ // повтор LINK-класса в текущем паттерне — возврат к узлу (pivot)
85
+ const node = prev && cls.category === 'LINK' ? pivotNodeOf(ownerSteps, cls) : undefined;
86
+ if (node) {
87
+ const pivot = { name, cls, filter: normalizeFilter(filter), pivotKey: node.nodeKey };
88
+ return makeChain(ctx, [...ownerSteps, pivot], mods, batch, nodes);
89
+ }
90
+ if (prev && !prev.op)
91
+ resolveHop(prev.cls, cls); // ранняя проверка пути (после op — продолжение от результата)
92
+ const next = { name, cls, filter: normalizeFilter(filter), nodeKey: nextNodeKey() };
93
+ return makeChain(ctx, [...(ownerSteps ?? []), next], ownerSteps ? mods : {}, batch, nodes);
94
+ };
95
+ const slotGuard = () => {
96
+ if (!ownerSteps?.length || !applySlot) {
97
+ throw new Error(`letopis: slot "${name}" needs an owner step — start with a class call: db.Класс(…).${name}.set(…)`);
98
+ }
99
+ const owner = ownerSteps[ownerSteps.length - 1];
100
+ if (!findEnd(owner.cls, cls)) {
101
+ 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(', ') || '—'})`);
102
+ }
103
+ return owner.cls;
104
+ };
105
+ return new Proxy(step, {
106
+ apply: (t, _self, args) => t(...args),
107
+ get(t, p, r) {
108
+ if (p === 'set') {
109
+ return (v) => {
110
+ slotGuard();
111
+ return applySlot(cls, slotValue(cls, v));
112
+ };
113
+ }
114
+ if (p === 'unset') {
115
+ return () => {
116
+ const ownerCls = slotGuard();
117
+ const end = findEnd(ownerCls, cls);
118
+ if (ownerCls.strictEnds && !end.optional) {
119
+ throw new Error(`letopis: link end "${cls.id}" of "${ownerCls.id}" is required — cannot unset`);
120
+ }
121
+ return applySlot(cls, null);
122
+ };
123
+ }
124
+ if (p === 'delete') {
125
+ return () => {
126
+ throw new Error(`letopis: slot .delete() renamed to .unset() (0.16.0) — .${name}.unset() removes the link end`);
127
+ };
128
+ }
129
+ if (typeof p === 'symbol' || p === 'then' || p in Function.prototype || p === 'name' || p === 'length' || p === 'prototype') {
130
+ return Reflect.get(t, p, r);
131
+ }
132
+ throw new Error(`letopis: "${name}" without call is a link slot (set/unset); for navigation call ${name}(…)`);
133
+ },
134
+ });
135
+ }
136
+ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
39
137
  const hasOps = steps.some((s) => s.op);
40
138
  const withLast = (patch) => {
41
139
  const last = steps[steps.length - 1];
@@ -44,24 +142,83 @@ function makeChain(ctx, steps, mods, batch) {
44
142
  }
45
143
  const next = steps.slice();
46
144
  next[next.length - 1] = { ...last, ...patch };
47
- return makeChain(ctx, next, mods, batch);
145
+ return makeChain(ctx, next, mods, batch, nodes);
146
+ };
147
+ const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batch, nodes);
148
+ /** Обновить план в батче (регистрируется на первой операции, дальше мутируется). */
149
+ const syncBatch = (next) => {
150
+ if (!batch)
151
+ return;
152
+ if (batch.entry)
153
+ batch.entry.steps = next;
154
+ else {
155
+ batch.entry = { steps: next };
156
+ batch.queue.push(batch.entry);
157
+ }
48
158
  };
49
- const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batch);
50
159
  /** Операция-звено: op на последний шаг (op уже есть → self-шаг «те же сущности»); mods сбрасываются. */
51
160
  const withOp = (op) => {
52
161
  const last = steps[steps.length - 1];
53
162
  const next = last.op
54
- ? [...steps, { name: last.name, cls: last.cls, self: true, op }]
163
+ ? [...steps, { name: last.name, cls: last.cls, self: true, op, nodeKey: nextNodeKey() }]
55
164
  : [...steps.slice(0, -1), { ...last, op }];
56
- if (batch) {
57
- if (batch.entry)
58
- batch.entry.steps = next;
59
- else {
60
- batch.entry = { steps: next };
61
- batch.queue.push(batch.entry);
165
+ syncBatch(next);
166
+ return makeChain(ctx, next, {}, batch, nodes);
167
+ };
168
+ /**
169
+ * Слот связи: значение конца ЗАПИСЫВАЕМОЙ версии последнего шага — только после
170
+ * операции записи (…create(…).Класс.set(x) / …update(…).Класс.unset()): та же версия.
171
+ */
172
+ const applySlot = (slotCls, value) => {
173
+ const last = steps[steps.length - 1];
174
+ if (!last.op) {
175
+ throw new Error(`letopis: link slot "${slotCls.alias}" needs a write — add .create(…)/.update(…) before .${slotCls.alias}.${value === null ? 'unset()' : 'set(…)'}`);
176
+ }
177
+ if (last.op.kind !== 'create' && last.op.kind !== 'update') {
178
+ throw new Error(`letopis: link slot after ${last.op.kind}() — slots apply to create()/update()`);
179
+ }
180
+ if (last.extraLinks && slotCls.id in last.extraLinks) {
181
+ throw new Error(`letopis: duplicate link slot "${slotCls.id}" in one write`);
182
+ }
183
+ const patched = {
184
+ ...last,
185
+ extraLinks: { ...(last.extraLinks ?? {}), [slotCls.id]: value },
186
+ };
187
+ const next = [...steps.slice(0, -1), patched];
188
+ syncBatch(next);
189
+ return makeChain(ctx, next, mods, batch, nodes);
190
+ };
191
+ /** entity(x): вклейка узла/паттерна; та же переменная повторно — возврат к её узлу. */
192
+ const entityStep = (x) => {
193
+ const plan = peekPlan(x);
194
+ if (plan) {
195
+ const known = nodes.get(x);
196
+ if (known !== undefined) {
197
+ const lastX = plan.steps[plan.steps.length - 1];
198
+ const pivot = { name: lastX.name, cls: lastX.cls, pivotKey: known };
199
+ return makeChain(ctx, [...steps, pivot], mods, batch, nodes);
62
200
  }
201
+ if (plan.steps.some((s) => s.op)) {
202
+ throw new Error('letopis: entity() takes a read pattern — a chain with operations cannot be embedded');
203
+ }
204
+ const prev = steps[steps.length - 1];
205
+ if (prev && !prev.op)
206
+ resolveHop(prev.cls, plan.steps[0].cls); // стык по правилам пути
207
+ const nodeKey = plan.steps[plan.steps.length - 1].nodeKey;
208
+ const nextNodes = new Map(nodes);
209
+ nextNodes.set(x, nodeKey);
210
+ return makeChain(ctx, [...steps, ...plan.steps], mods, batch, nextNodes);
211
+ }
212
+ if (x !== null && typeof x === 'object' && typeof x.id === 'string' && typeof x.class === 'string') {
213
+ const row = x;
214
+ const cls = ctx.registry.resolve(row.class);
215
+ const prev = steps[steps.length - 1];
216
+ if (prev && !prev.op)
217
+ resolveHop(prev.cls, cls);
218
+ const next = { name: cls.alias, cls, filter: row.id, nodeKey: nextNodeKey() };
219
+ return makeChain(ctx, [...steps, next], mods, batch, nodes);
63
220
  }
64
- return makeChain(ctx, next, {}, batch);
221
+ throw new Error('letopis: entity() accepts a lazy chain or a Row');
65
222
  };
66
223
  /** Терминал: план (есть операции) — runPlan одной транзакцией; иначе прямое чтение. */
67
224
  const guardBatch = () => {
@@ -71,9 +228,13 @@ function makeChain(ctx, steps, mods, batch) {
71
228
  };
72
229
  const handler = {
73
230
  get(_t, prop) {
231
+ if (prop === PLAN)
232
+ return { steps, mods };
74
233
  if (typeof prop === 'symbol' || prop === 'then')
75
234
  return undefined;
76
235
  switch (prop) {
236
+ case 'entity':
237
+ return entityStep;
77
238
  case 'run':
78
239
  return () => {
79
240
  guardBatch();
@@ -173,8 +334,23 @@ function makeChain(ctx, steps, mods, batch) {
173
334
  return raw.deleted ? { ...row, $deleted: true } : row;
174
335
  });
175
336
  };
337
+ case 'create':
338
+ return (data = {}) => {
339
+ const last = steps[steps.length - 1];
340
+ if (last && !last.op) { // после op create пишет результат — фильтр там подставит план
341
+ if (last.pivotKey !== undefined) {
342
+ throw new Error('letopis: create() on a pivot step — pivot returns to an existing node, use update()');
343
+ }
344
+ if (last.filter !== undefined && typeof last.filter !== 'string') {
345
+ throw new Error(`letopis: create() takes no filter — ${last.name}(id).create(…) fixes the id, searching is update()`);
346
+ }
347
+ }
348
+ return withOp({ kind: 'create', data, mods });
349
+ };
350
+ case 'update':
351
+ return (data = {}) => withOp({ kind: 'update', data, mods });
176
352
  case 'set':
177
- return (data = {}) => withOp({ kind: 'set', data, mods });
353
+ throw new Error('letopis: set() split into create()/update() (0.16.0) — create() inserts, update() versions what the path finds');
178
354
  case 'delete':
179
355
  return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
180
356
  case 'anonymize':
@@ -188,17 +364,10 @@ function makeChain(ctx, steps, mods, batch) {
188
364
  case 'owner':
189
365
  return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
190
366
  case 'link':
191
- return (clsName, target) => {
192
- const linkCls = ctx.registry.resolve(clsName);
193
- const last = steps[steps.length - 1];
194
- return withLast({
195
- extraLinks: { ...(last.extraLinks ?? {}), [linkCls.id]: typeof target === 'object' ? target.id : target },
196
- });
197
- };
367
+ throw new Error('letopis: .link() removed (0.15.0) — use the link slot: .Класс.set(target)');
198
368
  }
199
369
  if (ctx.registry.has(prop)) {
200
- const cls = ctx.registry.resolve(prop);
201
- return (filter) => makeChain(ctx, [...steps, { name: prop, cls, filter: normalizeFilter(filter) }], mods, batch);
370
+ return classProp(ctx, prop, ctx.registry.resolve(prop), steps, mods, batch, nodes, applySlot);
202
371
  }
203
372
  ctx.registry.resolve(prop); // неизвестный класс — понятная ошибка со списком
204
373
  return undefined;
@@ -225,8 +394,7 @@ function makeBatch(ctx, queue) {
225
394
  return () => queue.length;
226
395
  }
227
396
  if (ctx.registry.has(prop)) {
228
- const cls = ctx.registry.resolve(prop);
229
- return (filter) => makeChain(ctx, [{ name: String(prop), cls, filter: normalizeFilter(filter) }], {}, { queue });
397
+ return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, { queue }, NO_NODES);
230
398
  }
231
399
  ctx.registry.resolve(prop);
232
400
  return undefined;
@@ -335,9 +503,12 @@ export function makeDb(ctx, root) {
335
503
  case 'sql':
336
504
  return ctx.sql;
337
505
  }
506
+ if (prop === 'entity') {
507
+ // старт пути с готового паттерна/строки: db.entity(pos).…
508
+ return (x) => makeChain(ctx, [], {}, undefined, NO_NODES).entity(x);
509
+ }
338
510
  if (ctx.registry.has(prop)) {
339
- const cls = ctx.registry.resolve(prop);
340
- return (filter) => makeChain(ctx, [{ name: String(prop), cls, filter: normalizeFilter(filter) }], {});
511
+ return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, undefined, NO_NODES);
341
512
  }
342
513
  ctx.registry.resolve(prop);
343
514
  return undefined;
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare function connect(opts: ConnectOpts): Promise<EntityDb>;
5
5
  export declare function cursorOf(row: import('./types.js').Row, field?: string): import('./types.js').Cursor;
6
6
  export { up } from './up.js';
7
7
  export type { UpOpts } from './up.js';
8
+ export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
8
9
  export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
9
10
  export type { Row, Path, Filter, ChainMods, Cursor, ConnectOpts, QueryEvent, Account, Credential, Resource, Rule, AclOp, AclDecision, } from './types.js';
10
11
  export type { EntityDb, EntityTx, Chain, Batch, WatchEvent, WatchOpts } from './chain.js';
package/dist/index.js CHANGED
@@ -57,6 +57,8 @@ export function cursorOf(row, field = 'updated') {
57
57
  }
58
58
  // одна точка входа: контейнер + готовность + схема + connect
59
59
  export { up } from './up.js';
60
+ // генерация id (attributes.id): формула v5 открыта — id считается ДО создания
61
+ export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
60
62
  // операторы фильтров
61
63
  export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
62
64
  export { totpCode } from './auth.js';
package/dist/schema.js CHANGED
@@ -2,6 +2,8 @@
2
2
  * Загрузка таблицы Schema → реестр классов:
3
3
  * - резолв имени по id и alias;
4
4
  * - цепочка ancestors (walk по ancestor, если в БД не заполнена);
5
+ * - attributes НАСЛЕДУЮТСЯ по цепочке (потомок поверх предка; поле = замена правила
6
+ * целиком) — включая правило id (генерация v4/v5/v7); links НЕ наследуются;
5
7
  * - скомпилированный fastest-validator per class;
6
8
  * - карта типов полей (для SQL-кастов фильтров).
7
9
  */
@@ -88,6 +90,58 @@ export function fieldTypeOf(attr) {
88
90
  }
89
91
  return { kind: 'any' };
90
92
  }
93
+ /**
94
+ * Элемент Schema.links: объект-конец v2 (jsonb) либо legacy-строка 'Org'
95
+ * (включая JSON-текст в text[] у старых схем). Возвращает [конец, isV2].
96
+ */
97
+ function parseEnd(cls, raw) {
98
+ let o;
99
+ if (typeof raw === 'string') {
100
+ if (!raw.trimStart().startsWith('{'))
101
+ return [{ classes: [raw] }, false];
102
+ try {
103
+ o = JSON.parse(raw);
104
+ }
105
+ catch {
106
+ throw new Error(`letopis: class "${cls}" has malformed link end (bad JSON): ${raw}`);
107
+ }
108
+ }
109
+ else {
110
+ o = raw;
111
+ }
112
+ const classes = o.classes ?? (o.class ? [o.class] : []);
113
+ if (!classes.length || classes.some((c) => typeof c !== 'string' || !c)) {
114
+ throw new Error(`letopis: class "${cls}" has link end without classes: ${JSON.stringify(raw)}`);
115
+ }
116
+ return [{ classes, optional: o.optional === true, cardinality: o.cardinality }, true];
117
+ }
118
+ /**
119
+ * attributes.id: правило валидации + (объектом) спецификация генерации.
120
+ * generate/from — ключи letopis, вырезаются из правила перед компиляцией валидатора.
121
+ */
122
+ function parseIdGen(cls, attributes) {
123
+ const raw = attributes.id;
124
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
125
+ return { idGen: { version: 4 }, attrs: attributes };
126
+ }
127
+ const { generate, from, ...rule } = raw;
128
+ const attrs = { ...attributes, id: rule };
129
+ if (generate === undefined)
130
+ return { idGen: { version: 4 }, attrs };
131
+ if (generate !== 4 && generate !== 5 && generate !== 7) {
132
+ throw new Error(`letopis: class "${cls}" — attributes.id.generate must be 4 | 5 | 7, got ${JSON.stringify(generate)}`);
133
+ }
134
+ if (generate === 5) {
135
+ if (!Array.isArray(from) || !from.length || from.some((f) => typeof f !== 'string' || !f)) {
136
+ throw new Error(`letopis: class "${cls}" — id generate:5 needs "from": non-empty string[] (link ends / data fields)`);
137
+ }
138
+ return { idGen: { version: 5, from: from }, attrs };
139
+ }
140
+ if (from !== undefined) {
141
+ throw new Error(`letopis: class "${cls}" — attributes.id.from is for generate:5 only`);
142
+ }
143
+ return { idGen: { version: generate }, attrs };
144
+ }
91
145
  function buildDef(row, byId) {
92
146
  // цепочка наследования: из БД или walk по ancestor
93
147
  let ancestors = row.ancestors ?? [];
@@ -101,18 +155,54 @@ function buildDef(row, byId) {
101
155
  cur = byId.get(cur)?.ancestor ?? null;
102
156
  }
103
157
  }
158
+ // attributes НАСЛЕДУЮТСЯ по цепочке ancestor: потомок ПОВЕРХ предка, переопределение
159
+ // поля — замена правила ЦЕЛИКОМ (не слияние объекта-правила). links не наследуются.
160
+ const merged = {};
161
+ for (let i = ancestors.length - 1; i >= 0; i--) {
162
+ Object.assign(merged, byId.get(ancestors[i])?.attributes ?? {});
163
+ }
164
+ // attributes.id: спецификация генерации (generate/from) отделяется от правила валидации
165
+ const { idGen, attrs } = parseIdGen(row.id, merged);
104
166
  // fastest-validator: валидируем {id, ...data}; СТРОГО — лишние поля запрещены
105
- const compiled = v.compile({ ...row.attributes, $$strict: true });
167
+ const compiled = v.compile({ ...attrs, $$strict: true });
106
168
  const check = (data) => {
107
169
  const res = compiled(data);
108
170
  return res === true ? true : res;
109
171
  };
110
172
  const fieldTypes = new Map();
111
- for (const [field, attr] of Object.entries(row.attributes)) {
173
+ for (const [field, attr] of Object.entries(attrs)) {
112
174
  if (field.startsWith('$$'))
113
175
  continue;
114
176
  fieldTypes.set(field, fieldTypeOf(attr));
115
177
  }
178
+ let strictEnds = false;
179
+ // jsonb-массив; text[] у legacy-схем даёт string[]; jsonb-строка "[…]" (двойная
180
+ // сериализация внешних писателей) — распарсить
181
+ let rawLinks = row.links ?? [];
182
+ if (typeof rawLinks === 'string')
183
+ rawLinks = JSON.parse(rawLinks);
184
+ const ends = rawLinks.map((raw) => {
185
+ const [end, v2] = parseEnd(row.id, raw);
186
+ if (v2)
187
+ strictEnds = true;
188
+ return end;
189
+ });
190
+ // id v5: каждый источник from — обязательный конец links (класс или полное имя союза
191
+ // 'Service|Complex') ЛИБО поле data
192
+ if (idGen.version === 5) {
193
+ for (const f of idGen.from) {
194
+ const end = ends.find((e) => e.classes.includes(f) || e.classes.join('|') === f);
195
+ if (end) {
196
+ if (end.optional) {
197
+ throw new Error(`letopis: class "${row.id}" — id (uuid v5) can't depend on optional end "${f}"`);
198
+ }
199
+ continue;
200
+ }
201
+ if (f !== 'id' && f in attrs)
202
+ continue;
203
+ throw new Error(`letopis: class "${row.id}" — id "from" source "${f}" is neither a link end nor a data field`);
204
+ }
205
+ }
116
206
  return {
117
207
  id: row.id,
118
208
  alias: row.alias,
@@ -120,8 +210,10 @@ function buildDef(row, byId) {
120
210
  ancestor: row.ancestor,
121
211
  ancestors,
122
212
  descendants: row.descendants ?? [], // считает триггер schema_lineage
123
- attributes: row.attributes,
124
- links: row.links ?? [],
213
+ attributes: merged,
214
+ links: ends,
215
+ strictEnds,
216
+ idGen,
125
217
  meta: row.meta ?? {},
126
218
  abstract: row.meta?.abstract === true || row.meta?.abstract === 'true',
127
219
  order: row.order,
package/dist/sql.d.ts CHANGED
@@ -55,11 +55,25 @@ export declare const retryDelay: (attempt: number) => Promise<void>;
55
55
  * Служебные запросы (loadRegistry, tables, listen) через неё не идут.
56
56
  */
57
57
  export declare function runQuery<T>(ctx: Ctx, text: string, params: unknown[], mode: QueryEvent['mode'], classes: string[]): Promise<T[]>;
58
+ /** Значение слота связи: id | вложенный план | null (снять конец). */
59
+ export type SlotValue = string | {
60
+ plan: Step[];
61
+ mods?: ChainMods;
62
+ } | null;
63
+ /** Уникальная метка узла пути (для pivot-возвратов; переживает нарезку плана). */
64
+ export declare const nextNodeKey: () => number;
58
65
  export interface Step {
59
66
  /** Имя, как вызвано в цепочке (алиас или id). */
60
67
  name: string;
61
68
  cls: ClassDef;
62
69
  filter?: Filter;
70
+ /** Метка узла (есть у каждого реального шага). */
71
+ nodeKey?: number;
72
+ /**
73
+ * Pivot: шаг-ВОЗВРАТ к уже введённому узлу (повтор LINK-класса / entity(та же переменная)).
74
+ * Не создаёт узел; его фильтры дофильтровывают узел (AND); следующий шаг ветвится от узла.
75
+ */
76
+ pivotKey?: number;
63
77
  /** .alias(name) — ключ шага в путях. */
64
78
  aliasKey?: string;
65
79
  /** .tags(…) — фильтр по колонке tags: строка | string[] (все) | has/hasAny/hasAll. */
@@ -70,13 +84,17 @@ export interface Step {
70
84
  ownerFilter?: string;
71
85
  /** Внутреннее (write-цепочки): containment по links — контекст-связи. */
72
86
  linksFilter?: Record<string, string>;
73
- /** .link(Класс, target) — значение связи при записи БЕЗ участия в фильтре целей. */
74
- extraLinks?: Record<string, string>;
87
+ /**
88
+ * Слоты связей (`.Класс.set(target)` / `.Класс.unset()`): значения концов записываемой
89
+ * версии БЕЗ участия в фильтре целей. string — id; {plan} — вложенная цепочка (та же
90
+ * транзакция, ровно одна сущность класса конца); null — снять optional-конец.
91
+ */
92
+ extraLinks?: Record<string, SlotValue>;
75
93
  /** Внутреннее (enforceAcl): предикат WRITE/DELETE-правила — цели ищутся только среди своих. */
76
94
  aclFilter?: Record<string, unknown>;
77
95
  /** .deep(max): рекурсивный self-обход (дети любой глубины), только reverse того же класса. */
78
96
  deepMax?: number;
79
- /** Операция записи на шаге (.set/.delete/.anonymize) — исполняется терминалом плана. */
97
+ /** Операция записи на шаге (.create/.update/.delete/.anonymize) — исполняется терминалом плана. */
80
98
  op?: import('./types.js').PlanOp;
81
99
  /** Внутреннее: шаг «те же сущности» (операция сразу после операции) — цель = строки старта. */
82
100
  self?: boolean;
@@ -85,6 +103,8 @@ export declare const entityTable: (pgSchema: string) => string;
85
103
  /** Тип листа data-пути по Schema: спуск через record (значение) и object (props). */
86
104
  export declare function leafType(cls: ClassDef, path: string[]): FieldType | undefined;
87
105
  type HopMode = 'forward' | 'reverse';
106
+ /** Класс target входит в какой-нибудь конец def (союзы учитываются). */
107
+ export declare function linksTo(def: ClassDef, target: string): boolean;
88
108
  /** Правило обхода между шагами (по реестру Schema). */
89
109
  export declare function resolveHop(prev: ClassDef, next: ClassDef): HopMode;
90
110
  export type ReadMode = 'paths' | 'rows' | 'ids' | 'count' | 'versions' | 'agg';