letopis 0.5.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 +269 -0
- package/README.md +2472 -240
- package/dist/acl.d.ts +48 -0
- package/dist/acl.js +208 -0
- package/dist/auth.d.ts +116 -0
- package/dist/auth.js +263 -0
- package/dist/chain.d.ts +81 -28
- package/dist/chain.js +312 -72
- package/dist/index.d.ts +10 -3
- package/dist/index.js +26 -3
- package/dist/schema.js +107 -4
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +43 -1
- package/dist/sql.js +238 -79
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +81 -2
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +32 -0
- package/dist/write.d.ts +29 -16
- package/dist/write.js +427 -98
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +404 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +69 -0
package/dist/chain.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { buildRead, runQuery } from './sql.js';
|
|
2
|
-
import {
|
|
1
|
+
import { buildRead, runQuery, leafType, resolveHop, nextNodeKey, } from './sql.js';
|
|
2
|
+
import { runPlan, executeBatch, toRow } from './write.js';
|
|
3
3
|
import { beginTx, lock } from './tx.js';
|
|
4
4
|
import { makeTables } from './tables.js';
|
|
5
|
+
import { makeAuth } from './auth.js';
|
|
6
|
+
import { makeAcl } from './acl.js';
|
|
5
7
|
/** Фильтр-аргумент принимает и Row/Account-объект — берётся его id. */
|
|
6
8
|
function normalizeFilter(f) {
|
|
7
9
|
if (f !== undefined &&
|
|
@@ -14,6 +16,44 @@ function normalizeFilter(f) {
|
|
|
14
16
|
return f;
|
|
15
17
|
}
|
|
16
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
|
+
}
|
|
17
57
|
async function runPaths(ctx, steps, mods) {
|
|
18
58
|
const q = buildRead(ctx, steps, mods, 'paths');
|
|
19
59
|
const res = await runQuery(ctx, q.text, q.params, 'paths', clsOf(steps));
|
|
@@ -33,65 +73,202 @@ async function runRows(ctx, steps, mods) {
|
|
|
33
73
|
return d != null ? { ...row, $depth: d } : row;
|
|
34
74
|
});
|
|
35
75
|
}
|
|
36
|
-
const
|
|
76
|
+
const NO_NODES = new Map();
|
|
37
77
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* которую батч читает на execute().
|
|
78
|
+
* Свойство-класс (вызов = шаг/pivot; без скобок = слот). ownerSteps undefined — корень
|
|
79
|
+
* (db/batch): слот без владельца — ошибка.
|
|
41
80
|
*/
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
};
|
|
52
113
|
}
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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);
|
|
60
122
|
};
|
|
61
123
|
}
|
|
62
|
-
|
|
63
|
-
|
|
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}(…)`);
|
|
64
133
|
},
|
|
65
|
-
};
|
|
66
|
-
const proxy = new Proxy({}, handler);
|
|
67
|
-
return proxy;
|
|
134
|
+
});
|
|
68
135
|
}
|
|
69
|
-
function makeChain(ctx, steps, mods,
|
|
136
|
+
function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
137
|
+
const hasOps = steps.some((s) => s.op);
|
|
70
138
|
const withLast = (patch) => {
|
|
139
|
+
const last = steps[steps.length - 1];
|
|
140
|
+
if (last.op) {
|
|
141
|
+
throw new Error(`letopis: step modifier after ${last.op.kind}() — add a class step first`);
|
|
142
|
+
}
|
|
71
143
|
const next = steps.slice();
|
|
72
|
-
next[next.length - 1] = { ...
|
|
73
|
-
return makeChain(ctx, next, mods,
|
|
144
|
+
next[next.length - 1] = { ...last, ...patch };
|
|
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
|
+
}
|
|
158
|
+
};
|
|
159
|
+
/** Операция-звено: op на последний шаг (op уже есть → self-шаг «те же сущности»); mods сбрасываются. */
|
|
160
|
+
const withOp = (op) => {
|
|
161
|
+
const last = steps[steps.length - 1];
|
|
162
|
+
const next = last.op
|
|
163
|
+
? [...steps, { name: last.name, cls: last.cls, self: true, op, nodeKey: nextNodeKey() }]
|
|
164
|
+
: [...steps.slice(0, -1), { ...last, op }];
|
|
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);
|
|
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);
|
|
220
|
+
}
|
|
221
|
+
throw new Error('letopis: entity() accepts a lazy chain or a Row');
|
|
222
|
+
};
|
|
223
|
+
/** Терминал: план (есть операции) — runPlan одной транзакцией; иначе прямое чтение. */
|
|
224
|
+
const guardBatch = () => {
|
|
225
|
+
if (batch && hasOps) {
|
|
226
|
+
throw new Error('letopis: plan is queued in the batch — call batch.run()');
|
|
227
|
+
}
|
|
74
228
|
};
|
|
75
|
-
const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batchQueue);
|
|
76
229
|
const handler = {
|
|
77
230
|
get(_t, prop) {
|
|
231
|
+
if (prop === PLAN)
|
|
232
|
+
return { steps, mods };
|
|
78
233
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
79
234
|
return undefined;
|
|
80
235
|
switch (prop) {
|
|
236
|
+
case 'entity':
|
|
237
|
+
return entityStep;
|
|
238
|
+
case 'run':
|
|
239
|
+
return () => {
|
|
240
|
+
guardBatch();
|
|
241
|
+
return hasOps ? runPlan(ctx, steps, mods, 'paths') : runPaths(ctx, steps, mods);
|
|
242
|
+
};
|
|
81
243
|
case 'execute':
|
|
82
|
-
|
|
244
|
+
throw new Error('letopis: execute() renamed to run() (0.11.0)');
|
|
83
245
|
case 'rows':
|
|
84
|
-
return () =>
|
|
246
|
+
return () => {
|
|
247
|
+
guardBatch();
|
|
248
|
+
return hasOps ? runPlan(ctx, steps, mods, 'rows') : runRows(ctx, steps, mods);
|
|
249
|
+
};
|
|
85
250
|
case 'first':
|
|
86
|
-
return async () =>
|
|
251
|
+
return async () => {
|
|
252
|
+
guardBatch();
|
|
253
|
+
const rows = hasOps
|
|
254
|
+
? (await runPlan(ctx, steps, mods, 'rows'))
|
|
255
|
+
: await runRows(ctx, steps, { ...mods, limit: 1 });
|
|
256
|
+
return rows[0] ?? null;
|
|
257
|
+
};
|
|
87
258
|
case 'ids':
|
|
88
259
|
return async () => {
|
|
260
|
+
guardBatch();
|
|
261
|
+
if (hasOps)
|
|
262
|
+
return runPlan(ctx, steps, mods, 'ids');
|
|
89
263
|
const q = buildRead(ctx, steps, mods, 'ids');
|
|
90
264
|
const res = await runQuery(ctx, q.text, q.params, 'ids', clsOf(steps));
|
|
91
265
|
return res.map((r) => r.id);
|
|
92
266
|
};
|
|
93
267
|
case 'count':
|
|
94
268
|
return async () => {
|
|
269
|
+
guardBatch();
|
|
270
|
+
if (hasOps)
|
|
271
|
+
return runPlan(ctx, steps, mods, 'count');
|
|
95
272
|
const q = buildRead(ctx, steps, mods, 'count');
|
|
96
273
|
const res = await runQuery(ctx, q.text, q.params, 'count', clsOf(steps));
|
|
97
274
|
return res[0].n;
|
|
@@ -111,21 +288,44 @@ function makeChain(ctx, steps, mods, batchQueue) {
|
|
|
111
288
|
case 'min':
|
|
112
289
|
case 'max':
|
|
113
290
|
return async (field) => {
|
|
114
|
-
|
|
115
|
-
const
|
|
291
|
+
guardBatch();
|
|
292
|
+
const aggMods = { ...mods, aggFn: prop, aggField: field };
|
|
293
|
+
const res = hasOps
|
|
294
|
+
? (await runPlan(ctx, steps, aggMods, 'agg'))
|
|
295
|
+
: await (async () => {
|
|
296
|
+
const q = buildRead(ctx, steps, aggMods, 'agg');
|
|
297
|
+
return runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
|
|
298
|
+
})();
|
|
116
299
|
const v = res[0]?.v;
|
|
117
|
-
|
|
300
|
+
if (v === null || v === undefined)
|
|
301
|
+
return null;
|
|
302
|
+
if (typeof v !== 'string')
|
|
303
|
+
return v;
|
|
304
|
+
if (prop === 'sum' || prop === 'avg')
|
|
305
|
+
return Number(v);
|
|
306
|
+
// min/max: postgres.js отдаёт numeric строкой — приводим, если лист числовой по Schema
|
|
307
|
+
const leaf = leafType(steps[steps.length - 1].cls, field.slice(5).split('.'));
|
|
308
|
+
return leaf?.kind === 'number' ? Number(v) : v;
|
|
118
309
|
};
|
|
119
310
|
case 'countBy':
|
|
120
311
|
return async (field) => {
|
|
121
|
-
|
|
122
|
-
const
|
|
312
|
+
guardBatch();
|
|
313
|
+
const aggMods = { ...mods, aggFn: 'countBy', aggField: field };
|
|
314
|
+
const res = hasOps
|
|
315
|
+
? (await runPlan(ctx, steps, aggMods, 'agg'))
|
|
316
|
+
: await (async () => {
|
|
317
|
+
const q = buildRead(ctx, steps, aggMods, 'agg');
|
|
318
|
+
return runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
|
|
319
|
+
})();
|
|
123
320
|
return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
|
|
124
321
|
};
|
|
125
322
|
case 'after':
|
|
126
323
|
return (cursor) => withMods({ after: cursor });
|
|
127
324
|
case 'versions':
|
|
128
325
|
return async () => {
|
|
326
|
+
guardBatch();
|
|
327
|
+
if (hasOps)
|
|
328
|
+
return runPlan(ctx, steps, mods, 'versions');
|
|
129
329
|
const q = buildRead(ctx, steps, mods, 'versions');
|
|
130
330
|
const res = await runQuery(ctx, q.text, q.params, 'versions', clsOf(steps));
|
|
131
331
|
return res.map((r) => {
|
|
@@ -134,8 +334,27 @@ function makeChain(ctx, steps, mods, batchQueue) {
|
|
|
134
334
|
return raw.deleted ? { ...row, $deleted: true } : row;
|
|
135
335
|
});
|
|
136
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 });
|
|
352
|
+
case 'set':
|
|
353
|
+
throw new Error('letopis: set() split into create()/update() (0.16.0) — create() inserts, update() versions what the path finds');
|
|
354
|
+
case 'delete':
|
|
355
|
+
return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
|
|
137
356
|
case 'anonymize':
|
|
138
|
-
return (fields) =>
|
|
357
|
+
return (fields) => withOp({ kind: 'anonymize', fields, mods });
|
|
139
358
|
case 'alias':
|
|
140
359
|
return (name) => withLast({ aliasKey: name });
|
|
141
360
|
case 'tags':
|
|
@@ -144,30 +363,11 @@ function makeChain(ctx, steps, mods, batchQueue) {
|
|
|
144
363
|
return (v) => withLast({ accountFilter: typeof v === 'object' ? v.id : v });
|
|
145
364
|
case 'owner':
|
|
146
365
|
return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
|
|
147
|
-
case '
|
|
148
|
-
|
|
149
|
-
const extraLinks = {};
|
|
150
|
-
if (batchQueue) {
|
|
151
|
-
const op = { kind: 'set', steps, mods, data, extraLinks };
|
|
152
|
-
batchQueue.push(op);
|
|
153
|
-
const index = batchQueue.length - 1;
|
|
154
|
-
// билдер для довешивания связей; await возвращает № в очереди
|
|
155
|
-
return makeSetChain(ctx.registry, extraLinks, async () => index);
|
|
156
|
-
}
|
|
157
|
-
return makeSetChain(ctx.registry, extraLinks, () => setOp(ctx, steps, mods, data, extraLinks));
|
|
158
|
-
};
|
|
159
|
-
case 'delete':
|
|
160
|
-
return () => {
|
|
161
|
-
if (batchQueue) {
|
|
162
|
-
batchQueue.push({ kind: 'delete', steps, mods });
|
|
163
|
-
return batchQueue.length - 1;
|
|
164
|
-
}
|
|
165
|
-
return delOp(ctx, steps, mods);
|
|
166
|
-
};
|
|
366
|
+
case 'link':
|
|
367
|
+
throw new Error('letopis: .link() removed (0.15.0) — use the link slot: .Класс.set(target)');
|
|
167
368
|
}
|
|
168
369
|
if (ctx.registry.has(prop)) {
|
|
169
|
-
|
|
170
|
-
return (filter) => makeChain(ctx, [...steps, { name: prop, cls, filter: normalizeFilter(filter) }], mods, batchQueue);
|
|
370
|
+
return classProp(ctx, prop, ctx.registry.resolve(prop), steps, mods, batch, nodes, applySlot);
|
|
171
371
|
}
|
|
172
372
|
ctx.registry.resolve(prop); // неизвестный класс — понятная ошибка со списком
|
|
173
373
|
return undefined;
|
|
@@ -181,19 +381,20 @@ function makeBatch(ctx, queue) {
|
|
|
181
381
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
182
382
|
return undefined;
|
|
183
383
|
switch (prop) {
|
|
184
|
-
case '
|
|
384
|
+
case 'run':
|
|
185
385
|
return async () => {
|
|
186
|
-
const
|
|
187
|
-
return executeBatch(ctx,
|
|
386
|
+
const plans = queue.splice(0, queue.length);
|
|
387
|
+
return executeBatch(ctx, plans);
|
|
188
388
|
};
|
|
389
|
+
case 'execute':
|
|
390
|
+
throw new Error('letopis: batch.execute() renamed to batch.run() (0.11.0)');
|
|
189
391
|
case 'discard':
|
|
190
392
|
return () => void queue.splice(0, queue.length);
|
|
191
393
|
case 'size':
|
|
192
394
|
return () => queue.length;
|
|
193
395
|
}
|
|
194
396
|
if (ctx.registry.has(prop)) {
|
|
195
|
-
|
|
196
|
-
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);
|
|
197
398
|
}
|
|
198
399
|
ctx.registry.resolve(prop);
|
|
199
400
|
return undefined;
|
|
@@ -204,6 +405,8 @@ function makeBatch(ctx, queue) {
|
|
|
204
405
|
export function makeDb(ctx, root) {
|
|
205
406
|
const state = root ?? { batches: new Map() };
|
|
206
407
|
let tables; // лениво, на ctx этого фасада (работает и в tr)
|
|
408
|
+
let auth;
|
|
409
|
+
let acl;
|
|
207
410
|
const handler = {
|
|
208
411
|
get(_t, prop) {
|
|
209
412
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
@@ -212,6 +415,16 @@ export function makeDb(ctx, root) {
|
|
|
212
415
|
tables ??= makeTables(ctx);
|
|
213
416
|
return tables[prop];
|
|
214
417
|
}
|
|
418
|
+
if (prop === 'auth') {
|
|
419
|
+
tables ??= makeTables(ctx);
|
|
420
|
+
auth ??= makeAuth(tables);
|
|
421
|
+
return auth;
|
|
422
|
+
}
|
|
423
|
+
if (prop === 'acl') {
|
|
424
|
+
tables ??= makeTables(ctx);
|
|
425
|
+
acl ??= makeAcl(tables, ctx.registry);
|
|
426
|
+
return acl;
|
|
427
|
+
}
|
|
215
428
|
switch (prop) {
|
|
216
429
|
case 'begin':
|
|
217
430
|
return async () => makeDb(await beginTx(ctx), state);
|
|
@@ -245,18 +458,42 @@ export function makeDb(ctx, root) {
|
|
|
245
458
|
return makeBatch(ctx, q);
|
|
246
459
|
};
|
|
247
460
|
case 'watch':
|
|
248
|
-
return async (a, b) => {
|
|
461
|
+
return async (a, b, c) => {
|
|
249
462
|
const cls = typeof a === 'string' ? ctx.registry.resolve(a).id : undefined;
|
|
250
463
|
const cb = typeof a === 'function' ? a : b;
|
|
464
|
+
const opts = (typeof a === 'function' ? b : c) ?? {};
|
|
251
465
|
const listener = (payload) => {
|
|
252
466
|
const e = JSON.parse(payload);
|
|
253
467
|
if (e.partition !== ctx.partition)
|
|
254
468
|
return;
|
|
255
469
|
if (cls && e.class !== cls)
|
|
256
470
|
return;
|
|
471
|
+
// enforceAcl: отдаём только при БЕЗУСЛОВНОМ allow — payload не содержит
|
|
472
|
+
// owner/account/data, предикат строки проверить нечем (id — тоже утечка)
|
|
473
|
+
if (ctx.aclDecide) {
|
|
474
|
+
try {
|
|
475
|
+
const d = ctx.aclDecide(ctx.registry.resolve(e.class), 'READ');
|
|
476
|
+
if (!d.allow || d.filter)
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
257
483
|
cb(e);
|
|
258
484
|
};
|
|
259
|
-
|
|
485
|
+
// postgres.js: dedicated LISTEN-соединение само переподключается (backoff) и
|
|
486
|
+
// повторяет LISTEN; onlisten зовётся на первом connect И на каждом re-listen.
|
|
487
|
+
// NOTIFY за время разрыва потеряны — onReconnect даёт точку дочитать пропущенное.
|
|
488
|
+
let first = true;
|
|
489
|
+
const onlisten = () => {
|
|
490
|
+
if (first) {
|
|
491
|
+
first = false;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
opts.onReconnect?.();
|
|
495
|
+
};
|
|
496
|
+
const { unlisten } = await ctx.sql.listen(ctx.pgSchema, listener, onlisten);
|
|
260
497
|
return unlisten;
|
|
261
498
|
};
|
|
262
499
|
case 'close':
|
|
@@ -266,9 +503,12 @@ export function makeDb(ctx, root) {
|
|
|
266
503
|
case 'sql':
|
|
267
504
|
return ctx.sql;
|
|
268
505
|
}
|
|
506
|
+
if (prop === 'entity') {
|
|
507
|
+
// старт пути с готового паттерна/строки: db.entity(pos).…
|
|
508
|
+
return (x) => makeChain(ctx, [], {}, undefined, NO_NODES).entity(x);
|
|
509
|
+
}
|
|
269
510
|
if (ctx.registry.has(prop)) {
|
|
270
|
-
|
|
271
|
-
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);
|
|
272
512
|
}
|
|
273
513
|
ctx.registry.resolve(prop);
|
|
274
514
|
return undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { type EntityDb } from './chain.js';
|
|
2
2
|
import type { ConnectOpts } from './types.js';
|
|
3
3
|
export declare function connect(opts: ConnectOpts): Promise<EntityDb>;
|
|
4
|
-
/** Курсор keyset-пагинации из последней строки страницы (field как в .sort()). */
|
|
4
|
+
/** Курсор keyset-пагинации из последней строки страницы (field как в .sort(), вложенные пути поддержаны). */
|
|
5
5
|
export declare function cursorOf(row: import('./types.js').Row, field?: string): import('./types.js').Cursor;
|
|
6
|
+
export { up } from './up.js';
|
|
7
|
+
export type { UpOpts } from './up.js';
|
|
8
|
+
export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
|
|
6
9
|
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
|
|
7
|
-
export type { Row, Path, Filter, ChainMods, Cursor, ConnectOpts, QueryEvent, Account, Credential, Resource, Rule, } from './types.js';
|
|
8
|
-
export type { EntityDb, EntityTx, Chain, Batch } from './chain.js';
|
|
10
|
+
export type { Row, Path, Filter, ChainMods, Cursor, ConnectOpts, QueryEvent, Account, Credential, Resource, Rule, AclOp, AclDecision, } from './types.js';
|
|
11
|
+
export type { EntityDb, EntityTx, Chain, Batch, WatchEvent, WatchOpts } from './chain.js';
|
|
9
12
|
export type { Tables, AccountsApi, CredentialsApi, ResourcesApi, RulesApi } from './tables.js';
|
|
13
|
+
export { totpCode } from './auth.js';
|
|
14
|
+
export type { AuthApi, AuthResult } from './auth.js';
|
|
15
|
+
export type { AclApi } from './acl.js';
|
|
16
|
+
export type { SessionStore, Sessions, Session } from './sessions.js';
|
|
10
17
|
export { ValidationError } from './write.js';
|
|
11
18
|
export { Registry } from './schema.js';
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* letopis: dot-цепочки над append-only Entity-хранилищем (TimescaleDB).
|
|
3
3
|
*
|
|
4
4
|
* const db = await connect({ dsn, schema: 'booking' })
|
|
5
|
-
* await db.Сотрудник({ name: 'Вася' }).навык().Услуга().
|
|
5
|
+
* await db.Сотрудник({ name: 'Вася' }).навык().Услуга().run()
|
|
6
6
|
*/
|
|
7
7
|
import postgres from 'postgres';
|
|
8
8
|
import { loadRegistry } from './schema.js';
|
|
9
9
|
import { makeDb } from './chain.js';
|
|
10
|
+
import { makeTables } from './tables.js';
|
|
11
|
+
import { compileEnforcer } from './acl.js';
|
|
10
12
|
export async function connect(opts) {
|
|
11
13
|
// timestamptz — строкой (JS Date режет микросекунды → ломал бы asOf/cursorOf по updated)
|
|
12
14
|
const sql = postgres(opts.dsn, {
|
|
@@ -30,14 +32,35 @@ export async function connect(opts) {
|
|
|
30
32
|
onQuery: opts.onQuery,
|
|
31
33
|
slowMs: opts.slowMs,
|
|
32
34
|
};
|
|
35
|
+
// enforceAcl: правила и категории субъекта фиксируются на connect (перечитка — новый connect)
|
|
36
|
+
if (opts.enforceAcl) {
|
|
37
|
+
if (!opts.account)
|
|
38
|
+
throw new Error('letopis: enforceAcl requires connect({ account })');
|
|
39
|
+
const tables = makeTables(ctx);
|
|
40
|
+
const [account, resources, rules] = await Promise.all([
|
|
41
|
+
tables.accounts.get(opts.account),
|
|
42
|
+
tables.resources.find(),
|
|
43
|
+
tables.rules.find({ enabled: true }),
|
|
44
|
+
]);
|
|
45
|
+
if (!account)
|
|
46
|
+
throw new Error(`letopis: enforceAcl — account "${opts.account}" not found`);
|
|
47
|
+
ctx.aclDecide = compileEnforcer({ resources, rules }, account.id, account.categories, registry);
|
|
48
|
+
}
|
|
33
49
|
return makeDb(ctx);
|
|
34
50
|
}
|
|
35
|
-
/** Курсор keyset-пагинации из последней строки страницы (field как в .sort()). */
|
|
51
|
+
/** Курсор keyset-пагинации из последней строки страницы (field как в .sort(), вложенные пути поддержаны). */
|
|
36
52
|
export function cursorOf(row, field = 'updated') {
|
|
37
|
-
const v = field === 'updated'
|
|
53
|
+
const v = field === 'updated'
|
|
54
|
+
? row.updated
|
|
55
|
+
: field.slice(5).split('.').reduce((o, k) => o?.[k], row.data);
|
|
38
56
|
return { v, id: row.id };
|
|
39
57
|
}
|
|
58
|
+
// одна точка входа: контейнер + готовность + схема + connect
|
|
59
|
+
export { up } from './up.js';
|
|
60
|
+
// генерация id (attributes.id): формула v5 открыта — id считается ДО создания
|
|
61
|
+
export { uuidv5, uuidv7, LETOPIS_NS } from './uuid.js';
|
|
40
62
|
// операторы фильтров
|
|
41
63
|
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
|
|
64
|
+
export { totpCode } from './auth.js';
|
|
42
65
|
export { ValidationError } from './write.js';
|
|
43
66
|
export { Registry } from './schema.js';
|