letopis 0.13.0 → 0.18.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 +203 -3
- package/README.md +825 -602
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.d.ts +44 -16
- package/dist/chain.js +309 -26
- package/dist/index.d.ts +7 -0
- package/dist/index.js +38 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +169 -4
- package/dist/sessions.js +66 -0
- package/dist/sql.d.ts +23 -3
- package/dist/sql.js +161 -25
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.d.ts +40 -4
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +68 -0
- package/dist/write.d.ts +12 -5
- package/dist/write.js +359 -95
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +124 -12
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +64 -17
package/dist/chain.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
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';
|
|
5
5
|
import { makeAuth } from './auth.js';
|
|
6
6
|
import { makeAcl } from './acl.js';
|
|
7
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
|
|
8
15
|
function normalizeFilter(f) {
|
|
9
16
|
if (f !== undefined &&
|
|
10
17
|
typeof f === 'object' &&
|
|
@@ -16,7 +23,74 @@ function normalizeFilter(f) {
|
|
|
16
23
|
return f;
|
|
17
24
|
}
|
|
18
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`);
|
|
66
|
+
}
|
|
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
|
|
75
|
+
function pivotNodeOf(steps, cls) {
|
|
76
|
+
for (let i = steps.length - 1; i >= 0; i--) {
|
|
77
|
+
const s = steps[i];
|
|
78
|
+
if (s.op)
|
|
79
|
+
return undefined; // операция закрывает паттерн — узлы до неё недоступны
|
|
80
|
+
if (s.pivotKey === undefined && s.cls.id === cls.id)
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
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
|
|
19
92
|
async function runPaths(ctx, steps, mods) {
|
|
93
|
+
// START_BLOCK_RUN_PATHS
|
|
20
94
|
const q = buildRead(ctx, steps, mods, 'paths');
|
|
21
95
|
const res = await runQuery(ctx, q.text, q.params, 'paths', clsOf(steps));
|
|
22
96
|
return res.map((r) => {
|
|
@@ -25,8 +99,17 @@ async function runPaths(ctx, steps, mods) {
|
|
|
25
99
|
out[k] = toRow(v);
|
|
26
100
|
return out;
|
|
27
101
|
});
|
|
102
|
+
// END_BLOCK_RUN_PATHS
|
|
28
103
|
}
|
|
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
|
|
29
111
|
async function runRows(ctx, steps, mods) {
|
|
112
|
+
// START_BLOCK_RUN_ROWS
|
|
30
113
|
const q = buildRead(ctx, steps, mods, 'rows');
|
|
31
114
|
const res = await runQuery(ctx, q.text, q.params, 'rows', clsOf(steps));
|
|
32
115
|
return res.map((r) => {
|
|
@@ -34,9 +117,89 @@ async function runRows(ctx, steps, mods) {
|
|
|
34
117
|
const d = r.row.depth;
|
|
35
118
|
return d != null ? { ...row, $depth: d } : row;
|
|
36
119
|
});
|
|
120
|
+
// END_BLOCK_RUN_ROWS
|
|
121
|
+
}
|
|
122
|
+
const NO_NODES = new Map();
|
|
123
|
+
/**
|
|
124
|
+
* Свойство-класс (вызов = шаг/pivot; без скобок = слот). ownerSteps undefined — корень
|
|
125
|
+
* (db/batch): слот без владельца — ошибка.
|
|
126
|
+
*/
|
|
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
|
|
136
|
+
const step = (filter) => {
|
|
137
|
+
const prev = ownerSteps?.[ownerSteps.length - 1];
|
|
138
|
+
// повтор LINK-класса в текущем паттерне — возврат к узлу (pivot)
|
|
139
|
+
const node = prev && cls.category === 'LINK' ? pivotNodeOf(ownerSteps, cls) : undefined;
|
|
140
|
+
if (node) {
|
|
141
|
+
const pivot = { name, cls, filter: normalizeFilter(filter), pivotKey: node.nodeKey };
|
|
142
|
+
return makeChain(ctx, [...ownerSteps, pivot], mods, batch, nodes);
|
|
143
|
+
}
|
|
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);
|
|
148
|
+
};
|
|
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(…)`);
|
|
154
|
+
}
|
|
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(', ') || '—'})`);
|
|
158
|
+
}
|
|
159
|
+
return owner.cls;
|
|
160
|
+
};
|
|
161
|
+
return new Proxy(step, {
|
|
162
|
+
apply: (t, _self, args) => t(...args),
|
|
163
|
+
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}(…)`);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
// END_BLOCK_LINK_SLOT
|
|
37
192
|
}
|
|
38
|
-
|
|
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) {
|
|
39
201
|
const hasOps = steps.some((s) => s.op);
|
|
202
|
+
// START_BLOCK_STEP_MUTATORS
|
|
40
203
|
const withLast = (patch) => {
|
|
41
204
|
const last = steps[steps.length - 1];
|
|
42
205
|
if (last.op) {
|
|
@@ -44,25 +207,89 @@ function makeChain(ctx, steps, mods, batch) {
|
|
|
44
207
|
}
|
|
45
208
|
const next = steps.slice();
|
|
46
209
|
next[next.length - 1] = { ...last, ...patch };
|
|
47
|
-
return makeChain(ctx, next, mods, batch);
|
|
210
|
+
return makeChain(ctx, next, mods, batch, nodes);
|
|
211
|
+
};
|
|
212
|
+
const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batch, nodes);
|
|
213
|
+
// END_BLOCK_STEP_MUTATORS
|
|
214
|
+
// START_BLOCK_WRITE_DISPATCH
|
|
215
|
+
/** Обновить план в батче (регистрируется на первой операции, дальше мутируется). */
|
|
216
|
+
const syncBatch = (next) => {
|
|
217
|
+
if (!batch)
|
|
218
|
+
return;
|
|
219
|
+
if (batch.entry)
|
|
220
|
+
batch.entry.steps = next;
|
|
221
|
+
else {
|
|
222
|
+
batch.entry = { steps: next };
|
|
223
|
+
batch.queue.push(batch.entry);
|
|
224
|
+
}
|
|
48
225
|
};
|
|
49
|
-
const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batch);
|
|
50
226
|
/** Операция-звено: op на последний шаг (op уже есть → self-шаг «те же сущности»); mods сбрасываются. */
|
|
51
227
|
const withOp = (op) => {
|
|
52
228
|
const last = steps[steps.length - 1];
|
|
53
229
|
const next = last.op
|
|
54
|
-
? [...steps, { name: last.name, cls: last.cls, self: true, op }]
|
|
230
|
+
? [...steps, { name: last.name, cls: last.cls, self: true, op, nodeKey: nextNodeKey() }]
|
|
55
231
|
: [...steps.slice(0, -1), { ...last, op }];
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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];
|
|
255
|
+
syncBatch(next);
|
|
256
|
+
return makeChain(ctx, next, mods, batch, nodes);
|
|
257
|
+
};
|
|
258
|
+
// END_BLOCK_WRITE_DISPATCH
|
|
259
|
+
// START_BLOCK_ENTITY_EMBED
|
|
260
|
+
/** entity(x): вклейка узла/паттерна; та же переменная повторно — возврат к её узлу. */
|
|
261
|
+
const entityStep = (x) => {
|
|
262
|
+
const plan = peekPlan(x);
|
|
263
|
+
if (plan) {
|
|
264
|
+
const known = nodes.get(x);
|
|
265
|
+
if (known !== undefined) {
|
|
266
|
+
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);
|
|
62
269
|
}
|
|
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); // стык по правилам пути
|
|
276
|
+
const nodeKey = plan.steps[plan.steps.length - 1].nodeKey;
|
|
277
|
+
const nextNodes = new Map(nodes);
|
|
278
|
+
nextNodes.set(x, nodeKey);
|
|
279
|
+
return makeChain(ctx, [...steps, ...plan.steps], mods, batch, nextNodes);
|
|
280
|
+
}
|
|
281
|
+
if (x !== null && typeof x === 'object' && typeof x.id === 'string' && typeof x.class === 'string') {
|
|
282
|
+
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);
|
|
63
289
|
}
|
|
64
|
-
|
|
290
|
+
throw new Error('letopis: entity() accepts a lazy chain or a Row');
|
|
65
291
|
};
|
|
292
|
+
// END_BLOCK_ENTITY_EMBED
|
|
66
293
|
/** Терминал: план (есть операции) — runPlan одной транзакцией; иначе прямое чтение. */
|
|
67
294
|
const guardBatch = () => {
|
|
68
295
|
if (batch && hasOps) {
|
|
@@ -71,9 +298,14 @@ function makeChain(ctx, steps, mods, batch) {
|
|
|
71
298
|
};
|
|
72
299
|
const handler = {
|
|
73
300
|
get(_t, prop) {
|
|
301
|
+
if (prop === PLAN)
|
|
302
|
+
return { steps, mods };
|
|
74
303
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
75
304
|
return undefined;
|
|
76
305
|
switch (prop) {
|
|
306
|
+
// START_BLOCK_READ_TERMINALS
|
|
307
|
+
case 'entity':
|
|
308
|
+
return entityStep;
|
|
77
309
|
case 'run':
|
|
78
310
|
return () => {
|
|
79
311
|
guardBatch();
|
|
@@ -112,6 +344,8 @@ function makeChain(ctx, steps, mods, batch) {
|
|
|
112
344
|
const res = await runQuery(ctx, q.text, q.params, 'count', clsOf(steps));
|
|
113
345
|
return res[0].n;
|
|
114
346
|
};
|
|
347
|
+
// END_BLOCK_READ_TERMINALS
|
|
348
|
+
// START_BLOCK_READ_MODIFIERS
|
|
115
349
|
case 'limit':
|
|
116
350
|
return (n) => withMods({ limit: n });
|
|
117
351
|
case 'offset':
|
|
@@ -122,6 +356,8 @@ function makeChain(ctx, steps, mods, batch) {
|
|
|
122
356
|
return (t) => withMods({ asOf: t instanceof Date ? t.toISOString() : t });
|
|
123
357
|
case 'deep':
|
|
124
358
|
return (max = 32) => withLast({ deepMax: max });
|
|
359
|
+
// END_BLOCK_READ_MODIFIERS
|
|
360
|
+
// START_BLOCK_AGGREGATIONS
|
|
125
361
|
case 'sum':
|
|
126
362
|
case 'avg':
|
|
127
363
|
case 'min':
|
|
@@ -158,6 +394,8 @@ function makeChain(ctx, steps, mods, batch) {
|
|
|
158
394
|
})();
|
|
159
395
|
return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
|
|
160
396
|
};
|
|
397
|
+
// END_BLOCK_AGGREGATIONS
|
|
398
|
+
// START_BLOCK_VERSIONS_CURSOR
|
|
161
399
|
case 'after':
|
|
162
400
|
return (cursor) => withMods({ after: cursor });
|
|
163
401
|
case 'versions':
|
|
@@ -173,12 +411,31 @@ function makeChain(ctx, steps, mods, batch) {
|
|
|
173
411
|
return raw.deleted ? { ...row, $deleted: true } : row;
|
|
174
412
|
});
|
|
175
413
|
};
|
|
414
|
+
// END_BLOCK_VERSIONS_CURSOR
|
|
415
|
+
// START_BLOCK_WRITE_TERMINALS
|
|
416
|
+
case 'create':
|
|
417
|
+
return (data = {}) => {
|
|
418
|
+
const last = steps[steps.length - 1];
|
|
419
|
+
if (last && !last.op) { // после op create пишет результат — фильтр там подставит план
|
|
420
|
+
if (last.pivotKey !== undefined) {
|
|
421
|
+
throw new Error('letopis: create() on a pivot step — pivot returns to an existing node, use update()');
|
|
422
|
+
}
|
|
423
|
+
if (last.filter !== undefined && typeof last.filter !== 'string') {
|
|
424
|
+
throw new Error(`letopis: create() takes no filter — ${last.name}(id).create(…) fixes the id, searching is update()`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return withOp({ kind: 'create', data, mods });
|
|
428
|
+
};
|
|
429
|
+
case 'update':
|
|
430
|
+
return (data = {}) => withOp({ kind: 'update', data, mods });
|
|
176
431
|
case 'set':
|
|
177
|
-
|
|
432
|
+
throw new Error('letopis: set() split into create()/update() (0.16.0) — create() inserts, update() versions what the path finds');
|
|
178
433
|
case 'delete':
|
|
179
434
|
return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
|
|
180
435
|
case 'anonymize':
|
|
181
436
|
return (fields) => withOp({ kind: 'anonymize', fields, mods });
|
|
437
|
+
// END_BLOCK_WRITE_TERMINALS
|
|
438
|
+
// START_BLOCK_COLUMN_MODS
|
|
182
439
|
case 'alias':
|
|
183
440
|
return (name) => withLast({ aliasKey: name });
|
|
184
441
|
case 'tags':
|
|
@@ -188,30 +445,34 @@ function makeChain(ctx, steps, mods, batch) {
|
|
|
188
445
|
case 'owner':
|
|
189
446
|
return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
|
|
190
447
|
case 'link':
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const last = steps[steps.length - 1];
|
|
194
|
-
return withLast({
|
|
195
|
-
extraLinks: { ...(last.extraLinks ?? {}), [linkCls.id]: typeof target === 'object' ? target.id : target },
|
|
196
|
-
});
|
|
197
|
-
};
|
|
448
|
+
throw new Error('letopis: .link() removed (0.15.0) — use the link slot: .Класс.set(target)');
|
|
449
|
+
// END_BLOCK_COLUMN_MODS
|
|
198
450
|
}
|
|
451
|
+
// START_BLOCK_CLASS_RESOLVE
|
|
199
452
|
if (ctx.registry.has(prop)) {
|
|
200
|
-
|
|
201
|
-
return (filter) => makeChain(ctx, [...steps, { name: prop, cls, filter: normalizeFilter(filter) }], mods, batch);
|
|
453
|
+
return classProp(ctx, prop, ctx.registry.resolve(prop), steps, mods, batch, nodes, applySlot);
|
|
202
454
|
}
|
|
203
455
|
ctx.registry.resolve(prop); // неизвестный класс — понятная ошибка со списком
|
|
204
456
|
return undefined;
|
|
457
|
+
// END_BLOCK_CLASS_RESOLVE
|
|
205
458
|
},
|
|
206
459
|
};
|
|
207
460
|
return new Proxy({}, handler);
|
|
208
461
|
}
|
|
462
|
+
// START_CONTRACT: makeBatch
|
|
463
|
+
// PURPOSE: Собирает Proxy-батч над общей очередью планов — свойства-классы копят цепочки, run() исполняет всю очередь одной транзакцией.
|
|
464
|
+
// INPUTS: { ctx: Ctx; queue: BatchPlan[] - общая очередь планов батча }
|
|
465
|
+
// OUTPUTS: { Batch - Proxy с run/discard/size и свойствами-классами }
|
|
466
|
+
// SIDE_EFFECTS: run() исполняет и очищает очередь через M-WRITE.executeBatch; discard() очищает очередь
|
|
467
|
+
// LINKS: M-CHAIN, V-M-CHAIN
|
|
468
|
+
// END_CONTRACT: makeBatch
|
|
209
469
|
function makeBatch(ctx, queue) {
|
|
210
470
|
const handler = {
|
|
211
471
|
get(_t, prop) {
|
|
212
472
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
213
473
|
return undefined;
|
|
214
474
|
switch (prop) {
|
|
475
|
+
// START_BLOCK_BATCH_QUEUE
|
|
215
476
|
case 'run':
|
|
216
477
|
return async () => {
|
|
217
478
|
const plans = queue.splice(0, queue.length);
|
|
@@ -223,10 +484,10 @@ function makeBatch(ctx, queue) {
|
|
|
223
484
|
return () => void queue.splice(0, queue.length);
|
|
224
485
|
case 'size':
|
|
225
486
|
return () => queue.length;
|
|
487
|
+
// END_BLOCK_BATCH_QUEUE
|
|
226
488
|
}
|
|
227
489
|
if (ctx.registry.has(prop)) {
|
|
228
|
-
|
|
229
|
-
return (filter) => makeChain(ctx, [{ name: String(prop), cls, filter: normalizeFilter(filter) }], {}, { queue });
|
|
490
|
+
return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, { queue }, NO_NODES);
|
|
230
491
|
}
|
|
231
492
|
ctx.registry.resolve(prop);
|
|
232
493
|
return undefined;
|
|
@@ -234,6 +495,13 @@ function makeBatch(ctx, queue) {
|
|
|
234
495
|
};
|
|
235
496
|
return new Proxy({}, handler);
|
|
236
497
|
}
|
|
498
|
+
// START_CONTRACT: makeDb
|
|
499
|
+
// PURPOSE: Фабрика корневого фасада EntityDb над Ctx — классы как стартовые шаги, транзакции (begin/commit/rollback/lock), батчи, watch, служебные таблицы/auth/acl, entity()-старт.
|
|
500
|
+
// INPUTS: { ctx: Ctx - контекст соединения/схемы (или TxCtx внутри begin()); root?: RootState - общее состояние батчей }
|
|
501
|
+
// OUTPUTS: { EntityDb - Proxy-фасад }
|
|
502
|
+
// SIDE_EFFECTS: begin() открывает транзакцию (M-TX.beginTx); watch() ставит LISTEN на канал = имя схемы (ctx.pgSchema); close() закрывает соединение; лениво поднимает M-TABLES/M-AUTH/M-ACL
|
|
503
|
+
// LINKS: M-CHAIN, V-M-CHAIN
|
|
504
|
+
// END_CONTRACT: makeDb
|
|
237
505
|
export function makeDb(ctx, root) {
|
|
238
506
|
const state = root ?? { batches: new Map() };
|
|
239
507
|
let tables; // лениво, на ctx этого фасада (работает и в tr)
|
|
@@ -243,6 +511,7 @@ export function makeDb(ctx, root) {
|
|
|
243
511
|
get(_t, prop) {
|
|
244
512
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
245
513
|
return undefined;
|
|
514
|
+
// START_BLOCK_TABLES_AUTH_ACL
|
|
246
515
|
if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules') {
|
|
247
516
|
tables ??= makeTables(ctx);
|
|
248
517
|
return tables[prop];
|
|
@@ -257,7 +526,9 @@ export function makeDb(ctx, root) {
|
|
|
257
526
|
acl ??= makeAcl(tables, ctx.registry);
|
|
258
527
|
return acl;
|
|
259
528
|
}
|
|
529
|
+
// END_BLOCK_TABLES_AUTH_ACL
|
|
260
530
|
switch (prop) {
|
|
531
|
+
// START_BLOCK_TX_LOCK
|
|
261
532
|
case 'begin':
|
|
262
533
|
return async () => makeDb(await beginTx(ctx), state);
|
|
263
534
|
case 'commit':
|
|
@@ -280,6 +551,8 @@ export function makeDb(ctx, root) {
|
|
|
280
551
|
};
|
|
281
552
|
case 'lock':
|
|
282
553
|
return (...keys) => lock(ctx, ...keys);
|
|
554
|
+
// END_BLOCK_TX_LOCK
|
|
555
|
+
// START_BLOCK_BATCH_OPEN
|
|
283
556
|
case 'batch':
|
|
284
557
|
return (name) => {
|
|
285
558
|
let q = state.batches.get(name);
|
|
@@ -289,6 +562,8 @@ export function makeDb(ctx, root) {
|
|
|
289
562
|
}
|
|
290
563
|
return makeBatch(ctx, q);
|
|
291
564
|
};
|
|
565
|
+
// END_BLOCK_BATCH_OPEN
|
|
566
|
+
// START_BLOCK_WATCH_LISTEN
|
|
292
567
|
case 'watch':
|
|
293
568
|
return async (a, b, c) => {
|
|
294
569
|
const cls = typeof a === 'string' ? ctx.registry.resolve(a).id : undefined;
|
|
@@ -328,19 +603,27 @@ export function makeDb(ctx, root) {
|
|
|
328
603
|
const { unlisten } = await ctx.sql.listen(ctx.pgSchema, listener, onlisten);
|
|
329
604
|
return unlisten;
|
|
330
605
|
};
|
|
606
|
+
// END_BLOCK_WATCH_LISTEN
|
|
607
|
+
// START_BLOCK_CLOSE_META
|
|
331
608
|
case 'close':
|
|
332
609
|
return () => ctx.sql.end();
|
|
333
610
|
case 'registry':
|
|
334
611
|
return ctx.registry;
|
|
335
612
|
case 'sql':
|
|
336
613
|
return ctx.sql;
|
|
614
|
+
// END_BLOCK_CLOSE_META
|
|
615
|
+
}
|
|
616
|
+
// START_BLOCK_ENTITY_START
|
|
617
|
+
if (prop === 'entity') {
|
|
618
|
+
// старт пути с готового паттерна/строки: db.entity(pos).…
|
|
619
|
+
return (x) => makeChain(ctx, [], {}, undefined, NO_NODES).entity(x);
|
|
337
620
|
}
|
|
338
621
|
if (ctx.registry.has(prop)) {
|
|
339
|
-
|
|
340
|
-
return (filter) => makeChain(ctx, [{ name: String(prop), cls, filter: normalizeFilter(filter) }], {});
|
|
622
|
+
return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, undefined, NO_NODES);
|
|
341
623
|
}
|
|
342
624
|
ctx.registry.resolve(prop);
|
|
343
625
|
return undefined;
|
|
626
|
+
// END_BLOCK_ENTITY_START
|
|
344
627
|
},
|
|
345
628
|
};
|
|
346
629
|
return new Proxy({}, handler);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* letopis: dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
|
|
3
|
+
*
|
|
4
|
+
* const db = await connect({ dsn, schema: 'booking' })
|
|
5
|
+
* await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run()
|
|
6
|
+
*/
|
|
1
7
|
import { type EntityDb } from './chain.js';
|
|
2
8
|
import type { ConnectOpts } from './types.js';
|
|
3
9
|
export declare function connect(opts: ConnectOpts): Promise<EntityDb>;
|
|
@@ -5,6 +11,7 @@ export declare function connect(opts: ConnectOpts): Promise<EntityDb>;
|
|
|
5
11
|
export declare function cursorOf(row: import('./types.js').Row, field?: string): import('./types.js').Cursor;
|
|
6
12
|
export { up } from './up.js';
|
|
7
13
|
export type { UpOpts } from './up.js';
|
|
14
|
+
export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
|
|
8
15
|
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
|
|
9
16
|
export type { Row, Path, Filter, ChainMods, Cursor, ConnectOpts, QueryEvent, Account, Credential, Resource, Rule, AclOp, AclDecision, } from './types.js';
|
|
10
17
|
export type { EntityDb, EntityTx, Chain, Batch, WatchEvent, WatchOpts } from './chain.js';
|
package/dist/index.js
CHANGED
|
@@ -4,11 +4,38 @@
|
|
|
4
4
|
* const db = await connect({ dsn, schema: 'booking' })
|
|
5
5
|
* await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run()
|
|
6
6
|
*/
|
|
7
|
+
// FILE: lib/src/index.ts
|
|
8
|
+
// VERSION: 1.0.0
|
|
9
|
+
// START_MODULE_CONTRACT
|
|
10
|
+
// PURPOSE: Публичная точка входа пакета — открыть соединение (реестр, опциональный ACL, сборка db) и ре-экспорт публичной поверхности; хелпер курсора.
|
|
11
|
+
// SCOPE: connect, cursorOf + barrel-реэкспорты
|
|
12
|
+
// DEPENDS: M-SCHEMA, M-CHAIN, M-TABLES, M-ACL, M-SQL, M-TYPES, M-UP
|
|
13
|
+
// LINKS: M-CONNECT, V-M-CONNECT
|
|
14
|
+
// ROLE: RUNTIME
|
|
15
|
+
// MAP_MODE: EXPORTS
|
|
16
|
+
// END_MODULE_CONTRACT
|
|
17
|
+
//
|
|
18
|
+
// START_MODULE_MAP
|
|
19
|
+
// connect - открывает pg-соединение, грузит реестр, опц. включает ACL, собирает EntityDb
|
|
20
|
+
// cursorOf - курсор keyset-пагинации из последней строки страницы
|
|
21
|
+
// NOTE (re-exports) - up/UpOpts←M-UP; uuidv5/uuidv7/LETOPIS_NS←M-UUID; ops←M-OPS; totpCode/AuthApi/AuthResult←M-AUTH; AclApi←M-ACL; ValidationError←M-WRITE; Registry←M-SCHEMA; Session-типы←M-SESSIONS; базовые типы←M-TYPES; типы chain/tables
|
|
22
|
+
// END_MODULE_MAP
|
|
23
|
+
//
|
|
24
|
+
// START_CHANGE_SUMMARY
|
|
25
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
26
|
+
// END_CHANGE_SUMMARY
|
|
7
27
|
import postgres from 'postgres';
|
|
8
28
|
import { loadRegistry } from './schema.js';
|
|
9
29
|
import { makeDb } from './chain.js';
|
|
10
30
|
import { makeTables } from './tables.js';
|
|
11
31
|
import { compileEnforcer } from './acl.js';
|
|
32
|
+
// START_CONTRACT: connect
|
|
33
|
+
// PURPOSE: Открыть соединение: postgres-пул, реестр схемы, System-аккаунт, опциональный ACL — и собрать EntityDb.
|
|
34
|
+
// INPUTS: { opts: ConnectOpts { dsn, schema, partition?='entity', max?=10, account?, owner?, enforceAcl?, enforceAccount?, onQuery?, slowMs? } }
|
|
35
|
+
// OUTPUTS: { Promise<EntityDb> - dot-цепочный db }
|
|
36
|
+
// SIDE_EFFECTS: открывает пул postgres (timestamptz строкой), читает реестр и System-аккаунт; при enforceAcl без account бросает 'letopis: enforceAcl requires connect({ account })', при ненайденном — 'letopis: enforceAcl — account "…" not found', иначе читает resources/rules и компилит aclDecide; NB: ESM-цикл M-CONNECT↔M-UP (up ре-экспортится здесь)
|
|
37
|
+
// LINKS: M-CONNECT, V-M-CONNECT, M-SCHEMA, M-CHAIN, M-TABLES, M-ACL, M-SQL
|
|
38
|
+
// END_CONTRACT: connect
|
|
12
39
|
export async function connect(opts) {
|
|
13
40
|
// timestamptz — строкой (JS Date режет микросекунды → ломал бы asOf/cursorOf по updated)
|
|
14
41
|
const sql = postgres(opts.dsn, {
|
|
@@ -32,6 +59,7 @@ export async function connect(opts) {
|
|
|
32
59
|
onQuery: opts.onQuery,
|
|
33
60
|
slowMs: opts.slowMs,
|
|
34
61
|
};
|
|
62
|
+
// START_BLOCK_ENFORCE_ACL
|
|
35
63
|
// enforceAcl: правила и категории субъекта фиксируются на connect (перечитка — новый connect)
|
|
36
64
|
if (opts.enforceAcl) {
|
|
37
65
|
if (!opts.account)
|
|
@@ -46,9 +74,17 @@ export async function connect(opts) {
|
|
|
46
74
|
throw new Error(`letopis: enforceAcl — account "${opts.account}" not found`);
|
|
47
75
|
ctx.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, registry);
|
|
48
76
|
}
|
|
77
|
+
// END_BLOCK_ENFORCE_ACL
|
|
49
78
|
return makeDb(ctx);
|
|
50
79
|
}
|
|
51
80
|
/** Курсор keyset-пагинации из последней строки страницы (field как в .sort(), вложенные пути поддержаны). */
|
|
81
|
+
// START_CONTRACT: cursorOf
|
|
82
|
+
// PURPOSE: Собрать курсор keyset-пагинации из последней строки страницы.
|
|
83
|
+
// INPUTS: { row: Row - строка результата; field?: string='updated' - поле сортировки как в .sort() (вложенные пути 'data.*' поддержаны) }
|
|
84
|
+
// OUTPUTS: { Cursor - { v, id } для следующей страницы }
|
|
85
|
+
// SIDE_EFFECTS: none
|
|
86
|
+
// LINKS: M-CONNECT, V-M-CONNECT, M-TYPES
|
|
87
|
+
// END_CONTRACT: cursorOf
|
|
52
88
|
export function cursorOf(row, field = 'updated') {
|
|
53
89
|
const v = field === 'updated'
|
|
54
90
|
? row.updated
|
|
@@ -57,6 +93,8 @@ export function cursorOf(row, field = 'updated') {
|
|
|
57
93
|
}
|
|
58
94
|
// одна точка входа: контейнер + готовность + схема + connect
|
|
59
95
|
export { up } from './up.js';
|
|
96
|
+
// генерация id (attributes.id): формула v5 открыта — id считается ДО создания
|
|
97
|
+
export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
|
|
60
98
|
// операторы фильтров
|
|
61
99
|
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
|
|
62
100
|
export { totpCode } from './auth.js';
|