letopis 0.16.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 +107 -5
- package/README.md +602 -542
- package/dist/acl.js +62 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +126 -0
- package/dist/chain.js +112 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +36 -0
- package/dist/ops.js +45 -0
- package/dist/schema.js +73 -0
- package/dist/sessions.js +66 -0
- package/dist/sql.js +90 -1
- package/dist/tables.js +37 -0
- package/dist/tx.js +36 -0
- package/dist/types.js +46 -0
- package/dist/up.js +83 -0
- package/dist/uuid.js +36 -0
- package/dist/write.js +148 -2
- package/docker/Dockerfile +23 -0
- package/docker/start.sh +14 -0
- package/package.json +1 -1
- package/sql/ddl.sql +71 -1
- package/sql/seed.auth.sql +27 -0
- package/sql/seed.booking.sql +60 -18
package/dist/chain.js
CHANGED
|
@@ -5,6 +5,13 @@ 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' &&
|
|
@@ -20,10 +27,24 @@ const clsOf = (steps) => steps.map((s) => s.cls.id);
|
|
|
20
27
|
export const PLAN = Symbol('letopis.plan');
|
|
21
28
|
const peekPlan = (x) => x !== null && typeof x === 'object' ? x[PLAN] : undefined;
|
|
22
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
|
|
23
37
|
function findEnd(owner, slot) {
|
|
24
38
|
return owner.links.find((e) => e.classes.includes(slot.id) || (!owner.strictEnds && e.classes.includes('Entity') && slot.category === 'HUB'));
|
|
25
39
|
}
|
|
26
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
|
|
27
48
|
function slotValue(slotCls, v) {
|
|
28
49
|
if (typeof v === 'string')
|
|
29
50
|
return v;
|
|
@@ -44,6 +65,13 @@ function slotValue(slotCls, v) {
|
|
|
44
65
|
throw new Error(`letopis: slot "${slotCls.id}" accepts an id, a Row or a chain`);
|
|
45
66
|
}
|
|
46
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
|
|
47
75
|
function pivotNodeOf(steps, cls) {
|
|
48
76
|
for (let i = steps.length - 1; i >= 0; i--) {
|
|
49
77
|
const s = steps[i];
|
|
@@ -54,7 +82,15 @@ function pivotNodeOf(steps, cls) {
|
|
|
54
82
|
}
|
|
55
83
|
return undefined;
|
|
56
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
|
|
57
92
|
async function runPaths(ctx, steps, mods) {
|
|
93
|
+
// START_BLOCK_RUN_PATHS
|
|
58
94
|
const q = buildRead(ctx, steps, mods, 'paths');
|
|
59
95
|
const res = await runQuery(ctx, q.text, q.params, 'paths', clsOf(steps));
|
|
60
96
|
return res.map((r) => {
|
|
@@ -63,8 +99,17 @@ async function runPaths(ctx, steps, mods) {
|
|
|
63
99
|
out[k] = toRow(v);
|
|
64
100
|
return out;
|
|
65
101
|
});
|
|
102
|
+
// END_BLOCK_RUN_PATHS
|
|
66
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
|
|
67
111
|
async function runRows(ctx, steps, mods) {
|
|
112
|
+
// START_BLOCK_RUN_ROWS
|
|
68
113
|
const q = buildRead(ctx, steps, mods, 'rows');
|
|
69
114
|
const res = await runQuery(ctx, q.text, q.params, 'rows', clsOf(steps));
|
|
70
115
|
return res.map((r) => {
|
|
@@ -72,13 +117,22 @@ async function runRows(ctx, steps, mods) {
|
|
|
72
117
|
const d = r.row.depth;
|
|
73
118
|
return d != null ? { ...row, $depth: d } : row;
|
|
74
119
|
});
|
|
120
|
+
// END_BLOCK_RUN_ROWS
|
|
75
121
|
}
|
|
76
122
|
const NO_NODES = new Map();
|
|
77
123
|
/**
|
|
78
124
|
* Свойство-класс (вызов = шаг/pivot; без скобок = слот). ownerSteps undefined — корень
|
|
79
125
|
* (db/batch): слот без владельца — ошибка.
|
|
80
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
|
|
81
134
|
function classProp(ctx, name, cls, ownerSteps, mods, batch, nodes, applySlot) {
|
|
135
|
+
// START_BLOCK_CLASS_STEP
|
|
82
136
|
const step = (filter) => {
|
|
83
137
|
const prev = ownerSteps?.[ownerSteps.length - 1];
|
|
84
138
|
// повтор LINK-класса в текущем паттерне — возврат к узлу (pivot)
|
|
@@ -92,6 +146,8 @@ function classProp(ctx, name, cls, ownerSteps, mods, batch, nodes, applySlot) {
|
|
|
92
146
|
const next = { name, cls, filter: normalizeFilter(filter), nodeKey: nextNodeKey() };
|
|
93
147
|
return makeChain(ctx, [...(ownerSteps ?? []), next], ownerSteps ? mods : {}, batch, nodes);
|
|
94
148
|
};
|
|
149
|
+
// END_BLOCK_CLASS_STEP
|
|
150
|
+
// START_BLOCK_LINK_SLOT
|
|
95
151
|
const slotGuard = () => {
|
|
96
152
|
if (!ownerSteps?.length || !applySlot) {
|
|
97
153
|
throw new Error(`letopis: slot "${name}" needs an owner step — start with a class call: db.Класс(…).${name}.set(…)`);
|
|
@@ -132,9 +188,18 @@ function classProp(ctx, name, cls, ownerSteps, mods, batch, nodes, applySlot) {
|
|
|
132
188
|
throw new Error(`letopis: "${name}" without call is a link slot (set/unset); for navigation call ${name}(…)`);
|
|
133
189
|
},
|
|
134
190
|
});
|
|
191
|
+
// END_BLOCK_LINK_SLOT
|
|
135
192
|
}
|
|
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
|
|
136
200
|
function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
137
201
|
const hasOps = steps.some((s) => s.op);
|
|
202
|
+
// START_BLOCK_STEP_MUTATORS
|
|
138
203
|
const withLast = (patch) => {
|
|
139
204
|
const last = steps[steps.length - 1];
|
|
140
205
|
if (last.op) {
|
|
@@ -145,6 +210,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
145
210
|
return makeChain(ctx, next, mods, batch, nodes);
|
|
146
211
|
};
|
|
147
212
|
const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batch, nodes);
|
|
213
|
+
// END_BLOCK_STEP_MUTATORS
|
|
214
|
+
// START_BLOCK_WRITE_DISPATCH
|
|
148
215
|
/** Обновить план в батче (регистрируется на первой операции, дальше мутируется). */
|
|
149
216
|
const syncBatch = (next) => {
|
|
150
217
|
if (!batch)
|
|
@@ -188,6 +255,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
188
255
|
syncBatch(next);
|
|
189
256
|
return makeChain(ctx, next, mods, batch, nodes);
|
|
190
257
|
};
|
|
258
|
+
// END_BLOCK_WRITE_DISPATCH
|
|
259
|
+
// START_BLOCK_ENTITY_EMBED
|
|
191
260
|
/** entity(x): вклейка узла/паттерна; та же переменная повторно — возврат к её узлу. */
|
|
192
261
|
const entityStep = (x) => {
|
|
193
262
|
const plan = peekPlan(x);
|
|
@@ -220,6 +289,7 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
220
289
|
}
|
|
221
290
|
throw new Error('letopis: entity() accepts a lazy chain or a Row');
|
|
222
291
|
};
|
|
292
|
+
// END_BLOCK_ENTITY_EMBED
|
|
223
293
|
/** Терминал: план (есть операции) — runPlan одной транзакцией; иначе прямое чтение. */
|
|
224
294
|
const guardBatch = () => {
|
|
225
295
|
if (batch && hasOps) {
|
|
@@ -233,6 +303,7 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
233
303
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
234
304
|
return undefined;
|
|
235
305
|
switch (prop) {
|
|
306
|
+
// START_BLOCK_READ_TERMINALS
|
|
236
307
|
case 'entity':
|
|
237
308
|
return entityStep;
|
|
238
309
|
case 'run':
|
|
@@ -273,6 +344,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
273
344
|
const res = await runQuery(ctx, q.text, q.params, 'count', clsOf(steps));
|
|
274
345
|
return res[0].n;
|
|
275
346
|
};
|
|
347
|
+
// END_BLOCK_READ_TERMINALS
|
|
348
|
+
// START_BLOCK_READ_MODIFIERS
|
|
276
349
|
case 'limit':
|
|
277
350
|
return (n) => withMods({ limit: n });
|
|
278
351
|
case 'offset':
|
|
@@ -283,6 +356,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
283
356
|
return (t) => withMods({ asOf: t instanceof Date ? t.toISOString() : t });
|
|
284
357
|
case 'deep':
|
|
285
358
|
return (max = 32) => withLast({ deepMax: max });
|
|
359
|
+
// END_BLOCK_READ_MODIFIERS
|
|
360
|
+
// START_BLOCK_AGGREGATIONS
|
|
286
361
|
case 'sum':
|
|
287
362
|
case 'avg':
|
|
288
363
|
case 'min':
|
|
@@ -319,6 +394,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
319
394
|
})();
|
|
320
395
|
return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
|
|
321
396
|
};
|
|
397
|
+
// END_BLOCK_AGGREGATIONS
|
|
398
|
+
// START_BLOCK_VERSIONS_CURSOR
|
|
322
399
|
case 'after':
|
|
323
400
|
return (cursor) => withMods({ after: cursor });
|
|
324
401
|
case 'versions':
|
|
@@ -334,6 +411,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
334
411
|
return raw.deleted ? { ...row, $deleted: true } : row;
|
|
335
412
|
});
|
|
336
413
|
};
|
|
414
|
+
// END_BLOCK_VERSIONS_CURSOR
|
|
415
|
+
// START_BLOCK_WRITE_TERMINALS
|
|
337
416
|
case 'create':
|
|
338
417
|
return (data = {}) => {
|
|
339
418
|
const last = steps[steps.length - 1];
|
|
@@ -355,6 +434,8 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
355
434
|
return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
|
|
356
435
|
case 'anonymize':
|
|
357
436
|
return (fields) => withOp({ kind: 'anonymize', fields, mods });
|
|
437
|
+
// END_BLOCK_WRITE_TERMINALS
|
|
438
|
+
// START_BLOCK_COLUMN_MODS
|
|
358
439
|
case 'alias':
|
|
359
440
|
return (name) => withLast({ aliasKey: name });
|
|
360
441
|
case 'tags':
|
|
@@ -365,22 +446,33 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
365
446
|
return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
|
|
366
447
|
case 'link':
|
|
367
448
|
throw new Error('letopis: .link() removed (0.15.0) — use the link slot: .Класс.set(target)');
|
|
449
|
+
// END_BLOCK_COLUMN_MODS
|
|
368
450
|
}
|
|
451
|
+
// START_BLOCK_CLASS_RESOLVE
|
|
369
452
|
if (ctx.registry.has(prop)) {
|
|
370
453
|
return classProp(ctx, prop, ctx.registry.resolve(prop), steps, mods, batch, nodes, applySlot);
|
|
371
454
|
}
|
|
372
455
|
ctx.registry.resolve(prop); // неизвестный класс — понятная ошибка со списком
|
|
373
456
|
return undefined;
|
|
457
|
+
// END_BLOCK_CLASS_RESOLVE
|
|
374
458
|
},
|
|
375
459
|
};
|
|
376
460
|
return new Proxy({}, handler);
|
|
377
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
|
|
378
469
|
function makeBatch(ctx, queue) {
|
|
379
470
|
const handler = {
|
|
380
471
|
get(_t, prop) {
|
|
381
472
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
382
473
|
return undefined;
|
|
383
474
|
switch (prop) {
|
|
475
|
+
// START_BLOCK_BATCH_QUEUE
|
|
384
476
|
case 'run':
|
|
385
477
|
return async () => {
|
|
386
478
|
const plans = queue.splice(0, queue.length);
|
|
@@ -392,6 +484,7 @@ function makeBatch(ctx, queue) {
|
|
|
392
484
|
return () => void queue.splice(0, queue.length);
|
|
393
485
|
case 'size':
|
|
394
486
|
return () => queue.length;
|
|
487
|
+
// END_BLOCK_BATCH_QUEUE
|
|
395
488
|
}
|
|
396
489
|
if (ctx.registry.has(prop)) {
|
|
397
490
|
return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, { queue }, NO_NODES);
|
|
@@ -402,6 +495,13 @@ function makeBatch(ctx, queue) {
|
|
|
402
495
|
};
|
|
403
496
|
return new Proxy({}, handler);
|
|
404
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
|
|
405
505
|
export function makeDb(ctx, root) {
|
|
406
506
|
const state = root ?? { batches: new Map() };
|
|
407
507
|
let tables; // лениво, на ctx этого фасада (работает и в tr)
|
|
@@ -411,6 +511,7 @@ export function makeDb(ctx, root) {
|
|
|
411
511
|
get(_t, prop) {
|
|
412
512
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
413
513
|
return undefined;
|
|
514
|
+
// START_BLOCK_TABLES_AUTH_ACL
|
|
414
515
|
if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules') {
|
|
415
516
|
tables ??= makeTables(ctx);
|
|
416
517
|
return tables[prop];
|
|
@@ -425,7 +526,9 @@ export function makeDb(ctx, root) {
|
|
|
425
526
|
acl ??= makeAcl(tables, ctx.registry);
|
|
426
527
|
return acl;
|
|
427
528
|
}
|
|
529
|
+
// END_BLOCK_TABLES_AUTH_ACL
|
|
428
530
|
switch (prop) {
|
|
531
|
+
// START_BLOCK_TX_LOCK
|
|
429
532
|
case 'begin':
|
|
430
533
|
return async () => makeDb(await beginTx(ctx), state);
|
|
431
534
|
case 'commit':
|
|
@@ -448,6 +551,8 @@ export function makeDb(ctx, root) {
|
|
|
448
551
|
};
|
|
449
552
|
case 'lock':
|
|
450
553
|
return (...keys) => lock(ctx, ...keys);
|
|
554
|
+
// END_BLOCK_TX_LOCK
|
|
555
|
+
// START_BLOCK_BATCH_OPEN
|
|
451
556
|
case 'batch':
|
|
452
557
|
return (name) => {
|
|
453
558
|
let q = state.batches.get(name);
|
|
@@ -457,6 +562,8 @@ export function makeDb(ctx, root) {
|
|
|
457
562
|
}
|
|
458
563
|
return makeBatch(ctx, q);
|
|
459
564
|
};
|
|
565
|
+
// END_BLOCK_BATCH_OPEN
|
|
566
|
+
// START_BLOCK_WATCH_LISTEN
|
|
460
567
|
case 'watch':
|
|
461
568
|
return async (a, b, c) => {
|
|
462
569
|
const cls = typeof a === 'string' ? ctx.registry.resolve(a).id : undefined;
|
|
@@ -496,13 +603,17 @@ export function makeDb(ctx, root) {
|
|
|
496
603
|
const { unlisten } = await ctx.sql.listen(ctx.pgSchema, listener, onlisten);
|
|
497
604
|
return unlisten;
|
|
498
605
|
};
|
|
606
|
+
// END_BLOCK_WATCH_LISTEN
|
|
607
|
+
// START_BLOCK_CLOSE_META
|
|
499
608
|
case 'close':
|
|
500
609
|
return () => ctx.sql.end();
|
|
501
610
|
case 'registry':
|
|
502
611
|
return ctx.registry;
|
|
503
612
|
case 'sql':
|
|
504
613
|
return ctx.sql;
|
|
614
|
+
// END_BLOCK_CLOSE_META
|
|
505
615
|
}
|
|
616
|
+
// START_BLOCK_ENTITY_START
|
|
506
617
|
if (prop === 'entity') {
|
|
507
618
|
// старт пути с готового паттерна/строки: db.entity(pos).…
|
|
508
619
|
return (x) => makeChain(ctx, [], {}, undefined, NO_NODES).entity(x);
|
|
@@ -512,6 +623,7 @@ export function makeDb(ctx, root) {
|
|
|
512
623
|
}
|
|
513
624
|
ctx.registry.resolve(prop);
|
|
514
625
|
return undefined;
|
|
626
|
+
// END_BLOCK_ENTITY_START
|
|
515
627
|
},
|
|
516
628
|
};
|
|
517
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>;
|
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
|
package/dist/ops.js
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
* Операторы фильтров. Symbol-tagged объекты — не пересекаются с данными.
|
|
3
3
|
* Касты по типам полей делает sql.ts на основе Schema.attributes.
|
|
4
4
|
*/
|
|
5
|
+
//
|
|
6
|
+
// FILE: lib/src/ops.ts
|
|
7
|
+
// VERSION: 1.0.0
|
|
8
|
+
// START_MODULE_CONTRACT
|
|
9
|
+
// PURPOSE: Билдеры операторов фильтра, создающие узлы Op для DSL фильтров.
|
|
10
|
+
// SCOPE: type guard isOp + конструкторы операторов сравнения / списков / строк / массивов / наличия / логики (ne…or) поверх локальной фабрики op().
|
|
11
|
+
// DEPENDS: M-TYPES
|
|
12
|
+
// LINKS: M-OPS, V-M-OPS
|
|
13
|
+
// ROLE: RUNTIME
|
|
14
|
+
// MAP_MODE: EXPORTS
|
|
15
|
+
// END_MODULE_CONTRACT
|
|
16
|
+
//
|
|
17
|
+
// START_MODULE_MAP
|
|
18
|
+
// op - (локальный) фабрика узла Op: { [OP]: name, args }.
|
|
19
|
+
// isOp - type guard: значение является узлом Op (содержит символ OP).
|
|
20
|
+
// ne - ≠ (IS DISTINCT FROM).
|
|
21
|
+
// gt - >.
|
|
22
|
+
// gte - ≥.
|
|
23
|
+
// lt - <.
|
|
24
|
+
// lte - ≤.
|
|
25
|
+
// between - a ≤ x ≤ b.
|
|
26
|
+
// inList - значение из списка (IN).
|
|
27
|
+
// like - LIKE (шаблон с % и _).
|
|
28
|
+
// ilike - ILIKE (без регистра).
|
|
29
|
+
// starts - начинается с (LIKE s%).
|
|
30
|
+
// ends - заканчивается на (LIKE %s).
|
|
31
|
+
// has - массив-поле содержит значение.
|
|
32
|
+
// hasAny - массив-поле содержит хотя бы одно из.
|
|
33
|
+
// hasAll - массив-поле содержит все.
|
|
34
|
+
// exists - поле присутствует (true) / отсутствует (false) в data.
|
|
35
|
+
// isNull - поле NULL или отсутствует.
|
|
36
|
+
// not - НЕ-условие по полю (скаляр → «не равно»).
|
|
37
|
+
// or - ИЛИ на уровне фильтра шага.
|
|
38
|
+
// END_MODULE_MAP
|
|
39
|
+
//
|
|
40
|
+
// START_CHANGE_SUMMARY
|
|
41
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
42
|
+
// END_CHANGE_SUMMARY
|
|
5
43
|
import { OP } from './types.js';
|
|
6
44
|
const op = (name, ...args) => ({ [OP]: name, args });
|
|
7
45
|
export const isOp = (v) => typeof v === 'object' && v !== null && OP in v;
|
|
@@ -39,5 +77,12 @@ export const exists = (yes = true) => op('exists', yes);
|
|
|
39
77
|
export const isNull = () => op('isNull');
|
|
40
78
|
/** НЕ-условие по полю: not(ilike('%тест%')); скаляр → «не равно» */
|
|
41
79
|
export const not = (v) => op('not', v);
|
|
80
|
+
// START_CONTRACT: or
|
|
81
|
+
// PURPOSE: Логическое ИЛИ на уровне фильтра шага — объединяет несколько фильтров-объектов в один узел Op.
|
|
82
|
+
// INPUTS: { filters: Record<string, unknown>[] - варьируемое число фильтров-объектов (каждый — свой набор условий) }
|
|
83
|
+
// OUTPUTS: { Op - узел { [OP]: 'or', args: [filters] } }
|
|
84
|
+
// SIDE_EFFECTS: none (чистая фабрика через op())
|
|
85
|
+
// LINKS: M-OPS, V-M-OPS
|
|
86
|
+
// END_CONTRACT: or
|
|
42
87
|
/** ИЛИ на уровне фильтра шага: db.Запись(or({status:'created'}, {status:'confirmed'})) */
|
|
43
88
|
export const or = (...filters) => op('or', filters);
|
package/dist/schema.js
CHANGED
|
@@ -7,6 +7,27 @@
|
|
|
7
7
|
* - скомпилированный fastest-validator per class;
|
|
8
8
|
* - карта типов полей (для SQL-кастов фильтров).
|
|
9
9
|
*/
|
|
10
|
+
//
|
|
11
|
+
// FILE: lib/src/schema.ts
|
|
12
|
+
// VERSION: 1.0.0
|
|
13
|
+
// START_MODULE_CONTRACT
|
|
14
|
+
// PURPOSE: Строит реестр классов из таблицы Schema — резолв наследования/link-ends, типы полей, компиляция валидаторов.
|
|
15
|
+
// SCOPE: Registry (add/find/resolve/has), loadRegistry, fieldTypeOf; локальные parseEnd/parseIdGen/buildDef.
|
|
16
|
+
// DEPENDS: M-TYPES
|
|
17
|
+
// LINKS: M-SCHEMA, V-M-SCHEMA
|
|
18
|
+
// ROLE: RUNTIME
|
|
19
|
+
// MAP_MODE: EXPORTS
|
|
20
|
+
// END_MODULE_CONTRACT
|
|
21
|
+
//
|
|
22
|
+
// START_MODULE_MAP
|
|
23
|
+
// Registry - реестр классов (add/find/resolve/has, all)
|
|
24
|
+
// fieldTypeOf - attribute-спека fastest-validator → FieldType (для SQL-кастов)
|
|
25
|
+
// loadRegistry - загрузка Schema-строк партиции → Registry со скомпилированными check
|
|
26
|
+
// END_MODULE_MAP
|
|
27
|
+
//
|
|
28
|
+
// START_CHANGE_SUMMARY
|
|
29
|
+
// LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
|
|
30
|
+
// END_CHANGE_SUMMARY
|
|
10
31
|
import { createRequire } from 'node:module';
|
|
11
32
|
const Validator = createRequire(import.meta.url)('fastest-validator');
|
|
12
33
|
const v = new Validator({ useNewCustomCheckerFunction: true });
|
|
@@ -22,6 +43,13 @@ export class Registry {
|
|
|
22
43
|
find(name) {
|
|
23
44
|
return this.byName.get(name);
|
|
24
45
|
}
|
|
46
|
+
// START_CONTRACT: Registry.resolve
|
|
47
|
+
// PURPOSE: Вернуть класс по id или alias, иначе бросить ошибку со списком известных.
|
|
48
|
+
// INPUTS: { name: string - id или alias класса }
|
|
49
|
+
// OUTPUTS: { ClassDef - найденный класс }
|
|
50
|
+
// SIDE_EFFECTS: none
|
|
51
|
+
// LINKS: M-SCHEMA, V-M-SCHEMA
|
|
52
|
+
// END_CONTRACT: Registry.resolve
|
|
25
53
|
/** Класс по id или alias; иначе понятная ошибка со списком. */
|
|
26
54
|
resolve(name) {
|
|
27
55
|
const def = this.byName.get(name);
|
|
@@ -35,6 +63,13 @@ export class Registry {
|
|
|
35
63
|
return this.byName.has(name);
|
|
36
64
|
}
|
|
37
65
|
}
|
|
66
|
+
// START_CONTRACT: fieldTypeOf
|
|
67
|
+
// PURPOSE: Свести attribute-спеку fastest-validator (строка/массив/объект) к FieldType.
|
|
68
|
+
// INPUTS: { attr: unknown - правило поля из attributes }
|
|
69
|
+
// OUTPUTS: { FieldType - вид поля (number/date/boolean/string/array/record/object/any) }
|
|
70
|
+
// SIDE_EFFECTS: none (рекурсивно по вложенным props/value)
|
|
71
|
+
// LINKS: M-SCHEMA, V-M-SCHEMA, type-FieldType
|
|
72
|
+
// END_CONTRACT: fieldTypeOf
|
|
38
73
|
/** Тип поля из fastest-validator DSL — для каста в SQL. */
|
|
39
74
|
export function fieldTypeOf(attr) {
|
|
40
75
|
if (typeof attr === 'string') {
|
|
@@ -94,6 +129,14 @@ export function fieldTypeOf(attr) {
|
|
|
94
129
|
* Элемент Schema.links: объект-конец v2 (jsonb) либо legacy-строка 'Org'
|
|
95
130
|
* (включая JSON-текст в text[] у старых схем). Возвращает [конец, isV2].
|
|
96
131
|
*/
|
|
132
|
+
// START_CONTRACT: parseEnd
|
|
133
|
+
// PURPOSE: Разобрать один элемент Schema.links в LinkEnd (объект-конец v2 или legacy-строка).
|
|
134
|
+
// INPUTS: { cls: string - id класса; raw: string|object - сырой конец из jsonb/text[] }
|
|
135
|
+
// OUTPUTS: { [LinkEnd, boolean] - конец и признак v2-формы (для strictEnds) }
|
|
136
|
+
// SIDE_EFFECTS: none
|
|
137
|
+
// ERRORS: malformed link end (bad JSON); link end without classes
|
|
138
|
+
// LINKS: M-SCHEMA, V-M-SCHEMA
|
|
139
|
+
// END_CONTRACT: parseEnd
|
|
97
140
|
function parseEnd(cls, raw) {
|
|
98
141
|
let o;
|
|
99
142
|
if (typeof raw === 'string') {
|
|
@@ -119,6 +162,14 @@ function parseEnd(cls, raw) {
|
|
|
119
162
|
* attributes.id: правило валидации + (объектом) спецификация генерации.
|
|
120
163
|
* generate/from — ключи letopis, вырезаются из правила перед компиляцией валидатора.
|
|
121
164
|
*/
|
|
165
|
+
// START_CONTRACT: parseIdGen
|
|
166
|
+
// PURPOSE: Отделить спецификацию генерации id (generate/from) от правила валидации attributes.id.
|
|
167
|
+
// INPUTS: { cls: string - id класса; attributes: object - слитые attributes класса }
|
|
168
|
+
// OUTPUTS: { { idGen: IdGen; attrs: object } - версия генерации id и очищенное правило }
|
|
169
|
+
// SIDE_EFFECTS: none
|
|
170
|
+
// ERRORS: generate must be 4|5|7; generate:5 needs "from"; from is for generate:5 only
|
|
171
|
+
// LINKS: M-SCHEMA, V-M-SCHEMA
|
|
172
|
+
// END_CONTRACT: parseIdGen
|
|
122
173
|
function parseIdGen(cls, attributes) {
|
|
123
174
|
const raw = attributes.id;
|
|
124
175
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
@@ -142,7 +193,16 @@ function parseIdGen(cls, attributes) {
|
|
|
142
193
|
}
|
|
143
194
|
return { idGen: { version: generate }, attrs };
|
|
144
195
|
}
|
|
196
|
+
// START_CONTRACT: buildDef
|
|
197
|
+
// PURPOSE: Собрать ClassDef из строки Schema — цепочка ancestors, наследование attributes, id-gen, компиляция check, типы полей, link-ends.
|
|
198
|
+
// INPUTS: { row: SchemaRow - строка класса; byId: Map<string,SchemaRow> - все классы партиции }
|
|
199
|
+
// OUTPUTS: { ClassDef - полностью разрешённое определение класса }
|
|
200
|
+
// SIDE_EFFECTS: none (компилирует fastest-validator)
|
|
201
|
+
// ERRORS: id v5 can't depend on optional end; id "from" source is neither end nor field
|
|
202
|
+
// LINKS: M-SCHEMA, V-M-SCHEMA, type-ClassDef
|
|
203
|
+
// END_CONTRACT: buildDef
|
|
145
204
|
function buildDef(row, byId) {
|
|
205
|
+
// START_BLOCK_INHERIT_ANCESTORS
|
|
146
206
|
// цепочка наследования: из БД или walk по ancestor
|
|
147
207
|
let ancestors = row.ancestors ?? [];
|
|
148
208
|
if (!ancestors.length) {
|
|
@@ -161,6 +221,7 @@ function buildDef(row, byId) {
|
|
|
161
221
|
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
162
222
|
Object.assign(merged, byId.get(ancestors[i])?.attributes ?? {});
|
|
163
223
|
}
|
|
224
|
+
// END_BLOCK_INHERIT_ANCESTORS
|
|
164
225
|
// attributes.id: спецификация генерации (generate/from) отделяется от правила валидации
|
|
165
226
|
const { idGen, attrs } = parseIdGen(row.id, merged);
|
|
166
227
|
// fastest-validator: валидируем {id, ...data}; СТРОГО — лишние поля запрещены
|
|
@@ -187,6 +248,7 @@ function buildDef(row, byId) {
|
|
|
187
248
|
strictEnds = true;
|
|
188
249
|
return end;
|
|
189
250
|
});
|
|
251
|
+
// START_BLOCK_ID_V5_VALIDATE
|
|
190
252
|
// id v5: каждый источник from — обязательный конец links (класс или полное имя союза
|
|
191
253
|
// 'Service|Complex') ЛИБО поле data
|
|
192
254
|
if (idGen.version === 5) {
|
|
@@ -203,6 +265,7 @@ function buildDef(row, byId) {
|
|
|
203
265
|
throw new Error(`letopis: class "${row.id}" — id "from" source "${f}" is neither a link end nor a data field`);
|
|
204
266
|
}
|
|
205
267
|
}
|
|
268
|
+
// END_BLOCK_ID_V5_VALIDATE
|
|
206
269
|
return {
|
|
207
270
|
id: row.id,
|
|
208
271
|
alias: row.alias,
|
|
@@ -221,13 +284,23 @@ function buildDef(row, byId) {
|
|
|
221
284
|
fieldTypes,
|
|
222
285
|
};
|
|
223
286
|
}
|
|
287
|
+
// START_CONTRACT: loadRegistry
|
|
288
|
+
// PURPOSE: Прочитать классы партиции из таблицы Schema и построить Registry со скомпилированными check.
|
|
289
|
+
// INPUTS: { sql: postgres.Sql; pgSchema: string; partition: string }
|
|
290
|
+
// OUTPUTS: { Promise<Registry> - реестр всех классов партиции }
|
|
291
|
+
// SIDE_EFFECTS: SELECT из "<pgSchema>"."Schema"
|
|
292
|
+
// ERRORS: schema has no classes for partition
|
|
293
|
+
// LINKS: M-SCHEMA, V-M-SCHEMA, M-DDL
|
|
294
|
+
// END_CONTRACT: loadRegistry
|
|
224
295
|
export async function loadRegistry(sql, pgSchema, partition) {
|
|
296
|
+
// START_BLOCK_LOAD_QUERY
|
|
225
297
|
const ident = `"${pgSchema.replace(/"/g, '""')}"`;
|
|
226
298
|
const rows = (await sql.unsafe(`SELECT id, alias, category, ancestor, attributes, links, meta, "order", ancestors, descendants
|
|
227
299
|
FROM ${ident}."Schema" WHERE partition = $1 ORDER BY category, "order"`, [partition]));
|
|
228
300
|
if (!rows.length) {
|
|
229
301
|
throw new Error(`letopis: schema "${pgSchema}" has no classes for partition "${partition}"`);
|
|
230
302
|
}
|
|
303
|
+
// END_BLOCK_LOAD_QUERY
|
|
231
304
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
232
305
|
const registry = new Registry();
|
|
233
306
|
for (const row of rows)
|