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/CHANGELOG.md +98 -0
- package/README.md +362 -199
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +197 -26
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/schema.js +96 -4
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +71 -24
- package/dist/types.d.ts +40 -4
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +32 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +213 -95
- package/package.json +1 -1
- package/sql/ddl.sql +53 -11
- package/sql/seed.booking.sql +21 -16
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
|
-
* Запись: .
|
|
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. В
|
|
53
|
+
/** Колонка tags: строка | string[] (все) | has/hasAny/hasAll. В записи — значение тегов. */
|
|
53
54
|
tags(v: string | string[] | object): Chain;
|
|
54
|
-
/** Колонка account (uuid или Row/Account). В
|
|
55
|
+
/** Колонка account (uuid или Row/Account). В create() — значение. */
|
|
55
56
|
account(v: string | {
|
|
56
57
|
id: string;
|
|
57
58
|
}): Chain;
|
|
58
|
-
/** Колонка owner (uuid или Row/Account). В
|
|
59
|
+
/** Колонка owner (uuid или Row/Account). В create() — значение. */
|
|
59
60
|
owner(v: string | {
|
|
60
61
|
id: string;
|
|
61
62
|
}): Chain;
|
|
62
63
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
64
|
+
* Вставить узел в путь: x — ленивая цепочка-паттерн (стык проверяется правилами
|
|
65
|
+
* переходов) либо Row (шаг его класса по id). ТА ЖЕ переменная-цепочка повторно
|
|
66
|
+
* в одном пути — возврат к её узлу (ветвление); разные переменные — разные узлы.
|
|
65
67
|
*/
|
|
66
|
-
|
|
67
|
-
id: string;
|
|
68
|
-
}): Chain;
|
|
68
|
+
entity(x: Chain | Row): Chain;
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
* await db.Организация(org).Сотрудник().
|
|
72
|
-
* Связи — контекст-шагами до
|
|
73
|
-
* id
|
|
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
|
-
|
|
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]:
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({ ...
|
|
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(
|
|
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:
|
|
124
|
-
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
|
-
/**
|
|
74
|
-
|
|
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
|
-
/** Операция записи на шаге (.
|
|
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';
|