letopis 0.5.0 → 0.13.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 +171 -0
- package/README.md +2280 -211
- 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 +51 -26
- package/dist/chain.js +143 -74
- package/dist/index.d.ts +9 -3
- package/dist/index.js +24 -3
- package/dist/schema.js +11 -0
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.js +48 -0
- package/dist/sql.d.ts +23 -1
- package/dist/sql.js +167 -55
- package/dist/tx.d.ts +1 -1
- package/dist/tx.js +16 -1
- package/dist/types.d.ts +44 -1
- package/dist/up.d.ts +48 -0
- package/dist/up.js +209 -0
- package/dist/write.d.ts +18 -12
- package/dist/write.js +247 -36
- package/docker/Dockerfile +17 -0
- package/docker/start.sh +4 -0
- package/package.json +16 -2
- package/sql/ddl.sql +362 -0
- package/sql/seed.auth.sql +46 -0
- package/sql/seed.booking.sql +64 -0
package/dist/chain.d.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dot-цепочки: db.Сотрудник({name:'Вася'}).навык().Услуга().
|
|
2
|
+
* Dot-цепочки: db.Сотрудник({name:'Вася'}).навык().Услуга().run()
|
|
3
3
|
* db и цепочка — Proxy над реестром классов (id и alias работают одинаково).
|
|
4
4
|
*
|
|
5
5
|
* Выборка настраивается модификаторами: .limit(n) .offset(n) .sort(field, dir?)
|
|
6
6
|
* Колонки — модификаторами: .tags(…) .account(…) .owner(…); ключ путей — .alias(…).
|
|
7
|
-
* Запись: .set(data)
|
|
7
|
+
* Запись: .set(data) / .delete({confirm}) / .anonymize(fields) — ЗВЕНЬЯ (возвращают
|
|
8
|
+
* цепочку, продолжение — от результата); исполняет ТЕРМИНАЛ, весь план одной транзакцией.
|
|
8
9
|
*/
|
|
9
10
|
import type { Row, Path, Filter } from './types.js';
|
|
10
11
|
import { type Ctx } from './sql.js';
|
|
11
|
-
import { type
|
|
12
|
+
import { type BatchPlan } from './write.js';
|
|
12
13
|
import { type Tables } from './tables.js';
|
|
14
|
+
import { type AuthApi } from './auth.js';
|
|
15
|
+
import { type AclApi } from './acl.js';
|
|
13
16
|
import type { Registry } from './schema.js';
|
|
14
17
|
export interface ChainCore {
|
|
15
|
-
/** Пути: [{шаг1: Row, шаг2: Row, …}, …] — все узлы каждого
|
|
16
|
-
|
|
18
|
+
/** Пути: [{шаг1: Row, шаг2: Row, …}, …] — все узлы каждого варианта (бывший execute). */
|
|
19
|
+
run(): Promise<Path[]>;
|
|
17
20
|
/** Уникальные сущности последнего шага. */
|
|
18
21
|
rows(): Promise<Row[]>;
|
|
19
22
|
first(): Promise<Row | null>;
|
|
@@ -39,8 +42,11 @@ export interface ChainCore {
|
|
|
39
42
|
min(field: string): Promise<unknown>;
|
|
40
43
|
max(field: string): Promise<unknown>;
|
|
41
44
|
countBy(field: string): Promise<Record<string, number>>;
|
|
42
|
-
/**
|
|
43
|
-
|
|
45
|
+
/**
|
|
46
|
+
* GDPR-звено: новая версия с затёртыми string-полями ('[erased]') + тег 'anonymized'.
|
|
47
|
+
* История остаётся. Возвращает цепочку — исполняет терминал; продолжение — от затёртых версий.
|
|
48
|
+
*/
|
|
49
|
+
anonymize(fields: string[]): Chain;
|
|
44
50
|
/** Переименовать ключ ТЕКУЩЕГО шага в выводе путей. */
|
|
45
51
|
alias(name: string): Chain;
|
|
46
52
|
/** Колонка tags: строка | string[] (все) | has/hasAny/hasAll. В set() — значение тегов. */
|
|
@@ -54,23 +60,29 @@ export interface ChainCore {
|
|
|
54
60
|
id: string;
|
|
55
61
|
}): Chain;
|
|
56
62
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* const z = db.занятость(id).set({kind}); z.Сотрудник(s); z.Окно(w); await z
|
|
60
|
-
* Исполняется ОДНИМ INSERT при первом await (промис кешируется).
|
|
61
|
-
* Пустой фильтр без data.id → INSERT; id (фильтр или data.id) → UPSERT;
|
|
62
|
-
* фильтр-объект → версия каждого найденного.
|
|
63
|
+
* Значение связи для записи БЕЗ участия в фильтре целей (в отличие от контекст-шага):
|
|
64
|
+
* tr.Запись(b).позиция({}).link('Сотрудник', новый).set({}).rows() // перевесить исполнителя
|
|
63
65
|
*/
|
|
64
|
-
|
|
65
|
-
/** Серверный tombstone + каскад; возвращает всё удалённое с $deleted: true. */
|
|
66
|
-
delete(): Promise<Row[]>;
|
|
67
|
-
}
|
|
68
|
-
/** Ленивый билдер записи: довешивание связей + thenable (исполнение при await). */
|
|
69
|
-
export type SetChain = PromiseLike<Row[]> & {
|
|
70
|
-
[className: string]: (target: string | {
|
|
66
|
+
link(className: string, target: string | {
|
|
71
67
|
id: string;
|
|
72
|
-
})
|
|
73
|
-
|
|
68
|
+
}): Chain;
|
|
69
|
+
/**
|
|
70
|
+
* Записать в шаг, к которому приклеен точкой (ЗВЕНО — вернёт цепочку, исполняет терминал):
|
|
71
|
+
* await db.Организация(org).Сотрудник().set({ name: 'Вася' }).rows()
|
|
72
|
+
* Связи — контекст-шагами до set(). Пустой фильтр без data.id → INSERT;
|
|
73
|
+
* id (фильтр или data.id) → UPSERT; фильтр-объект → версия каждого найденного.
|
|
74
|
+
* Продолжение цепочки — от записанных строк (fan-out при множестве).
|
|
75
|
+
*/
|
|
76
|
+
set(data?: Record<string, unknown>): Chain;
|
|
77
|
+
/**
|
|
78
|
+
* Удаление-звено. { confirm: true } — серверный tombstone + каскад (продолжение — от
|
|
79
|
+
* затомбстоуненных, $deleted: true); БЕЗ confirm — превью: терминал вернёт кандидатов
|
|
80
|
+
* (цели + каскад), БД не тронута.
|
|
81
|
+
*/
|
|
82
|
+
delete(opts?: {
|
|
83
|
+
confirm?: boolean;
|
|
84
|
+
}): Chain;
|
|
85
|
+
}
|
|
74
86
|
export type Chain = ChainCore & {
|
|
75
87
|
[className: string]: (filter?: Filter) => Chain;
|
|
76
88
|
};
|
|
@@ -79,13 +91,16 @@ export interface DbCore {
|
|
|
79
91
|
begin(): Promise<EntityTx>;
|
|
80
92
|
commit(tr: EntityTx): Promise<void>;
|
|
81
93
|
rollback(tr: EntityTx): Promise<void>;
|
|
82
|
-
/** Именованный батч: те же цепочки,
|
|
94
|
+
/** Именованный батч: те же цепочки, планы записи копятся до run(). */
|
|
83
95
|
batch(name: string): Batch;
|
|
84
96
|
/**
|
|
85
97
|
* Realtime: события каждой вставленной версии (insert/update/delete-tombstone).
|
|
86
98
|
* watch(cb) — все классы; watch('Запись', cb) — один. Возврат — stop-функция.
|
|
99
|
+
* LISTEN-соединение переживает обрывы (re-listen автоматом), но NOTIFY за время
|
|
100
|
+
* разрыва потеряны — opts.onReconnect зовётся после восстановления: дочитайте пропущенное.
|
|
87
101
|
*/
|
|
88
|
-
watch(
|
|
102
|
+
watch(cb: (e: WatchEvent) => void, opts?: WatchOpts): Promise<() => void>;
|
|
103
|
+
watch(cls: string, cb: (e: WatchEvent) => void, opts?: WatchOpts): Promise<() => void>;
|
|
89
104
|
close(): Promise<void>;
|
|
90
105
|
registry: Registry;
|
|
91
106
|
/** Голый postgres-клиент (тесты, EXPLAIN). */
|
|
@@ -95,6 +110,10 @@ export interface DbCore {
|
|
|
95
110
|
credentials: Tables['credentials'];
|
|
96
111
|
resources: Tables['resources'];
|
|
97
112
|
rules: Tables['rules'];
|
|
113
|
+
/** Вход по кредам (пароль/api-key/key-secret/внешние identity) + сессии в Redis. */
|
|
114
|
+
auth: AuthApi;
|
|
115
|
+
/** ACL по Resource/Rule: check(эндпоинт) / checkData(класс, READ|WRITE|DELETE) / reload. */
|
|
116
|
+
acl: AclApi;
|
|
98
117
|
}
|
|
99
118
|
export type EntityDb = DbCore & {
|
|
100
119
|
[className: string]: (filter?: Filter) => Chain;
|
|
@@ -112,15 +131,21 @@ export interface WatchEvent {
|
|
|
112
131
|
updated: string;
|
|
113
132
|
deleted: boolean;
|
|
114
133
|
}
|
|
134
|
+
/** Опции watch(): реакция на восстановление LISTEN-соединения. */
|
|
135
|
+
export interface WatchOpts {
|
|
136
|
+
/** Зовётся после каждого re-listen (не на первом подключении): NOTIFY за разрыв потеряны. */
|
|
137
|
+
onReconnect?: () => void;
|
|
138
|
+
}
|
|
115
139
|
export type Batch = {
|
|
116
|
-
execute
|
|
140
|
+
/** Исполнить всю очередь планов одной транзакцией (бывший execute). */
|
|
141
|
+
run(): Promise<Row[][]>;
|
|
117
142
|
discard(): void;
|
|
118
143
|
size(): number;
|
|
119
144
|
} & {
|
|
120
145
|
[className: string]: (filter?: Filter) => Chain;
|
|
121
146
|
};
|
|
122
147
|
interface RootState {
|
|
123
|
-
batches: Map<string,
|
|
148
|
+
batches: Map<string, BatchPlan[]>;
|
|
124
149
|
}
|
|
125
150
|
export declare function makeDb(ctx: Ctx, root?: RootState): EntityDb;
|
|
126
151
|
export {};
|
package/dist/chain.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { buildRead, runQuery } from './sql.js';
|
|
2
|
-
import {
|
|
1
|
+
import { buildRead, runQuery, leafType } 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 &&
|
|
@@ -33,65 +35,79 @@ async function runRows(ctx, steps, mods) {
|
|
|
33
35
|
return d != null ? { ...row, $depth: d } : row;
|
|
34
36
|
});
|
|
35
37
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* Ленивый билдер set(): класс-вызовы довешивают связи (мутирует), await исполняет один раз.
|
|
39
|
-
* run() — исполнение (сразу setOp или отложенно через батч); links — общая ссылка,
|
|
40
|
-
* которую батч читает на execute().
|
|
41
|
-
*/
|
|
42
|
-
function makeSetChain(registry, links, run) {
|
|
43
|
-
let done;
|
|
44
|
-
const handler = {
|
|
45
|
-
get(_t, prop) {
|
|
46
|
-
if (typeof prop === 'symbol')
|
|
47
|
-
return undefined;
|
|
48
|
-
if (prop === 'then' || prop === 'catch' || prop === 'finally') {
|
|
49
|
-
done ??= run();
|
|
50
|
-
const m = done[prop];
|
|
51
|
-
return m.bind(done);
|
|
52
|
-
}
|
|
53
|
-
if (registry.has(prop)) {
|
|
54
|
-
const cls = registry.resolve(prop);
|
|
55
|
-
return (target) => {
|
|
56
|
-
if (done)
|
|
57
|
-
throw new Error(`letopis: set() already executed — link "${prop}" before the first await`);
|
|
58
|
-
links[cls.id] = idOf(target);
|
|
59
|
-
return proxy;
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
registry.resolve(prop);
|
|
63
|
-
return undefined;
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
const proxy = new Proxy({}, handler);
|
|
67
|
-
return proxy;
|
|
68
|
-
}
|
|
69
|
-
function makeChain(ctx, steps, mods, batchQueue) {
|
|
38
|
+
function makeChain(ctx, steps, mods, batch) {
|
|
39
|
+
const hasOps = steps.some((s) => s.op);
|
|
70
40
|
const withLast = (patch) => {
|
|
41
|
+
const last = steps[steps.length - 1];
|
|
42
|
+
if (last.op) {
|
|
43
|
+
throw new Error(`letopis: step modifier after ${last.op.kind}() — add a class step first`);
|
|
44
|
+
}
|
|
71
45
|
const next = steps.slice();
|
|
72
|
-
next[next.length - 1] = { ...
|
|
73
|
-
return makeChain(ctx, next, mods,
|
|
46
|
+
next[next.length - 1] = { ...last, ...patch };
|
|
47
|
+
return makeChain(ctx, next, mods, batch);
|
|
48
|
+
};
|
|
49
|
+
const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batch);
|
|
50
|
+
/** Операция-звено: op на последний шаг (op уже есть → self-шаг «те же сущности»); mods сбрасываются. */
|
|
51
|
+
const withOp = (op) => {
|
|
52
|
+
const last = steps[steps.length - 1];
|
|
53
|
+
const next = last.op
|
|
54
|
+
? [...steps, { name: last.name, cls: last.cls, self: true, op }]
|
|
55
|
+
: [...steps.slice(0, -1), { ...last, op }];
|
|
56
|
+
if (batch) {
|
|
57
|
+
if (batch.entry)
|
|
58
|
+
batch.entry.steps = next;
|
|
59
|
+
else {
|
|
60
|
+
batch.entry = { steps: next };
|
|
61
|
+
batch.queue.push(batch.entry);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return makeChain(ctx, next, {}, batch);
|
|
65
|
+
};
|
|
66
|
+
/** Терминал: план (есть операции) — runPlan одной транзакцией; иначе прямое чтение. */
|
|
67
|
+
const guardBatch = () => {
|
|
68
|
+
if (batch && hasOps) {
|
|
69
|
+
throw new Error('letopis: plan is queued in the batch — call batch.run()');
|
|
70
|
+
}
|
|
74
71
|
};
|
|
75
|
-
const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, batchQueue);
|
|
76
72
|
const handler = {
|
|
77
73
|
get(_t, prop) {
|
|
78
74
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
79
75
|
return undefined;
|
|
80
76
|
switch (prop) {
|
|
77
|
+
case 'run':
|
|
78
|
+
return () => {
|
|
79
|
+
guardBatch();
|
|
80
|
+
return hasOps ? runPlan(ctx, steps, mods, 'paths') : runPaths(ctx, steps, mods);
|
|
81
|
+
};
|
|
81
82
|
case 'execute':
|
|
82
|
-
|
|
83
|
+
throw new Error('letopis: execute() renamed to run() (0.11.0)');
|
|
83
84
|
case 'rows':
|
|
84
|
-
return () =>
|
|
85
|
+
return () => {
|
|
86
|
+
guardBatch();
|
|
87
|
+
return hasOps ? runPlan(ctx, steps, mods, 'rows') : runRows(ctx, steps, mods);
|
|
88
|
+
};
|
|
85
89
|
case 'first':
|
|
86
|
-
return async () =>
|
|
90
|
+
return async () => {
|
|
91
|
+
guardBatch();
|
|
92
|
+
const rows = hasOps
|
|
93
|
+
? (await runPlan(ctx, steps, mods, 'rows'))
|
|
94
|
+
: await runRows(ctx, steps, { ...mods, limit: 1 });
|
|
95
|
+
return rows[0] ?? null;
|
|
96
|
+
};
|
|
87
97
|
case 'ids':
|
|
88
98
|
return async () => {
|
|
99
|
+
guardBatch();
|
|
100
|
+
if (hasOps)
|
|
101
|
+
return runPlan(ctx, steps, mods, 'ids');
|
|
89
102
|
const q = buildRead(ctx, steps, mods, 'ids');
|
|
90
103
|
const res = await runQuery(ctx, q.text, q.params, 'ids', clsOf(steps));
|
|
91
104
|
return res.map((r) => r.id);
|
|
92
105
|
};
|
|
93
106
|
case 'count':
|
|
94
107
|
return async () => {
|
|
108
|
+
guardBatch();
|
|
109
|
+
if (hasOps)
|
|
110
|
+
return runPlan(ctx, steps, mods, 'count');
|
|
95
111
|
const q = buildRead(ctx, steps, mods, 'count');
|
|
96
112
|
const res = await runQuery(ctx, q.text, q.params, 'count', clsOf(steps));
|
|
97
113
|
return res[0].n;
|
|
@@ -111,21 +127,44 @@ function makeChain(ctx, steps, mods, batchQueue) {
|
|
|
111
127
|
case 'min':
|
|
112
128
|
case 'max':
|
|
113
129
|
return async (field) => {
|
|
114
|
-
|
|
115
|
-
const
|
|
130
|
+
guardBatch();
|
|
131
|
+
const aggMods = { ...mods, aggFn: prop, aggField: field };
|
|
132
|
+
const res = hasOps
|
|
133
|
+
? (await runPlan(ctx, steps, aggMods, 'agg'))
|
|
134
|
+
: await (async () => {
|
|
135
|
+
const q = buildRead(ctx, steps, aggMods, 'agg');
|
|
136
|
+
return runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
|
|
137
|
+
})();
|
|
116
138
|
const v = res[0]?.v;
|
|
117
|
-
|
|
139
|
+
if (v === null || v === undefined)
|
|
140
|
+
return null;
|
|
141
|
+
if (typeof v !== 'string')
|
|
142
|
+
return v;
|
|
143
|
+
if (prop === 'sum' || prop === 'avg')
|
|
144
|
+
return Number(v);
|
|
145
|
+
// min/max: postgres.js отдаёт numeric строкой — приводим, если лист числовой по Schema
|
|
146
|
+
const leaf = leafType(steps[steps.length - 1].cls, field.slice(5).split('.'));
|
|
147
|
+
return leaf?.kind === 'number' ? Number(v) : v;
|
|
118
148
|
};
|
|
119
149
|
case 'countBy':
|
|
120
150
|
return async (field) => {
|
|
121
|
-
|
|
122
|
-
const
|
|
151
|
+
guardBatch();
|
|
152
|
+
const aggMods = { ...mods, aggFn: 'countBy', aggField: field };
|
|
153
|
+
const res = hasOps
|
|
154
|
+
? (await runPlan(ctx, steps, aggMods, 'agg'))
|
|
155
|
+
: await (async () => {
|
|
156
|
+
const q = buildRead(ctx, steps, aggMods, 'agg');
|
|
157
|
+
return runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
|
|
158
|
+
})();
|
|
123
159
|
return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
|
|
124
160
|
};
|
|
125
161
|
case 'after':
|
|
126
162
|
return (cursor) => withMods({ after: cursor });
|
|
127
163
|
case 'versions':
|
|
128
164
|
return async () => {
|
|
165
|
+
guardBatch();
|
|
166
|
+
if (hasOps)
|
|
167
|
+
return runPlan(ctx, steps, mods, 'versions');
|
|
129
168
|
const q = buildRead(ctx, steps, mods, 'versions');
|
|
130
169
|
const res = await runQuery(ctx, q.text, q.params, 'versions', clsOf(steps));
|
|
131
170
|
return res.map((r) => {
|
|
@@ -134,8 +173,12 @@ function makeChain(ctx, steps, mods, batchQueue) {
|
|
|
134
173
|
return raw.deleted ? { ...row, $deleted: true } : row;
|
|
135
174
|
});
|
|
136
175
|
};
|
|
176
|
+
case 'set':
|
|
177
|
+
return (data = {}) => withOp({ kind: 'set', data, mods });
|
|
178
|
+
case 'delete':
|
|
179
|
+
return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
|
|
137
180
|
case 'anonymize':
|
|
138
|
-
return (fields) =>
|
|
181
|
+
return (fields) => withOp({ kind: 'anonymize', fields, mods });
|
|
139
182
|
case 'alias':
|
|
140
183
|
return (name) => withLast({ aliasKey: name });
|
|
141
184
|
case 'tags':
|
|
@@ -144,30 +187,18 @@ function makeChain(ctx, steps, mods, batchQueue) {
|
|
|
144
187
|
return (v) => withLast({ accountFilter: typeof v === 'object' ? v.id : v });
|
|
145
188
|
case 'owner':
|
|
146
189
|
return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
|
|
147
|
-
case '
|
|
148
|
-
return (
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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);
|
|
190
|
+
case 'link':
|
|
191
|
+
return (clsName, target) => {
|
|
192
|
+
const linkCls = ctx.registry.resolve(clsName);
|
|
193
|
+
const last = steps[steps.length - 1];
|
|
194
|
+
return withLast({
|
|
195
|
+
extraLinks: { ...(last.extraLinks ?? {}), [linkCls.id]: typeof target === 'object' ? target.id : target },
|
|
196
|
+
});
|
|
166
197
|
};
|
|
167
198
|
}
|
|
168
199
|
if (ctx.registry.has(prop)) {
|
|
169
200
|
const cls = ctx.registry.resolve(prop);
|
|
170
|
-
return (filter) => makeChain(ctx, [...steps, { name: prop, cls, filter: normalizeFilter(filter) }], mods,
|
|
201
|
+
return (filter) => makeChain(ctx, [...steps, { name: prop, cls, filter: normalizeFilter(filter) }], mods, batch);
|
|
171
202
|
}
|
|
172
203
|
ctx.registry.resolve(prop); // неизвестный класс — понятная ошибка со списком
|
|
173
204
|
return undefined;
|
|
@@ -181,11 +212,13 @@ function makeBatch(ctx, queue) {
|
|
|
181
212
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
182
213
|
return undefined;
|
|
183
214
|
switch (prop) {
|
|
184
|
-
case '
|
|
215
|
+
case 'run':
|
|
185
216
|
return async () => {
|
|
186
|
-
const
|
|
187
|
-
return executeBatch(ctx,
|
|
217
|
+
const plans = queue.splice(0, queue.length);
|
|
218
|
+
return executeBatch(ctx, plans);
|
|
188
219
|
};
|
|
220
|
+
case 'execute':
|
|
221
|
+
throw new Error('letopis: batch.execute() renamed to batch.run() (0.11.0)');
|
|
189
222
|
case 'discard':
|
|
190
223
|
return () => void queue.splice(0, queue.length);
|
|
191
224
|
case 'size':
|
|
@@ -193,7 +226,7 @@ function makeBatch(ctx, queue) {
|
|
|
193
226
|
}
|
|
194
227
|
if (ctx.registry.has(prop)) {
|
|
195
228
|
const cls = ctx.registry.resolve(prop);
|
|
196
|
-
return (filter) => makeChain(ctx, [{ name: String(prop), cls, filter: normalizeFilter(filter) }], {}, queue);
|
|
229
|
+
return (filter) => makeChain(ctx, [{ name: String(prop), cls, filter: normalizeFilter(filter) }], {}, { queue });
|
|
197
230
|
}
|
|
198
231
|
ctx.registry.resolve(prop);
|
|
199
232
|
return undefined;
|
|
@@ -204,6 +237,8 @@ function makeBatch(ctx, queue) {
|
|
|
204
237
|
export function makeDb(ctx, root) {
|
|
205
238
|
const state = root ?? { batches: new Map() };
|
|
206
239
|
let tables; // лениво, на ctx этого фасада (работает и в tr)
|
|
240
|
+
let auth;
|
|
241
|
+
let acl;
|
|
207
242
|
const handler = {
|
|
208
243
|
get(_t, prop) {
|
|
209
244
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
@@ -212,6 +247,16 @@ export function makeDb(ctx, root) {
|
|
|
212
247
|
tables ??= makeTables(ctx);
|
|
213
248
|
return tables[prop];
|
|
214
249
|
}
|
|
250
|
+
if (prop === 'auth') {
|
|
251
|
+
tables ??= makeTables(ctx);
|
|
252
|
+
auth ??= makeAuth(tables);
|
|
253
|
+
return auth;
|
|
254
|
+
}
|
|
255
|
+
if (prop === 'acl') {
|
|
256
|
+
tables ??= makeTables(ctx);
|
|
257
|
+
acl ??= makeAcl(tables, ctx.registry);
|
|
258
|
+
return acl;
|
|
259
|
+
}
|
|
215
260
|
switch (prop) {
|
|
216
261
|
case 'begin':
|
|
217
262
|
return async () => makeDb(await beginTx(ctx), state);
|
|
@@ -245,18 +290,42 @@ export function makeDb(ctx, root) {
|
|
|
245
290
|
return makeBatch(ctx, q);
|
|
246
291
|
};
|
|
247
292
|
case 'watch':
|
|
248
|
-
return async (a, b) => {
|
|
293
|
+
return async (a, b, c) => {
|
|
249
294
|
const cls = typeof a === 'string' ? ctx.registry.resolve(a).id : undefined;
|
|
250
295
|
const cb = typeof a === 'function' ? a : b;
|
|
296
|
+
const opts = (typeof a === 'function' ? b : c) ?? {};
|
|
251
297
|
const listener = (payload) => {
|
|
252
298
|
const e = JSON.parse(payload);
|
|
253
299
|
if (e.partition !== ctx.partition)
|
|
254
300
|
return;
|
|
255
301
|
if (cls && e.class !== cls)
|
|
256
302
|
return;
|
|
303
|
+
// enforceAcl: отдаём только при БЕЗУСЛОВНОМ allow — payload не содержит
|
|
304
|
+
// owner/account/data, предикат строки проверить нечем (id — тоже утечка)
|
|
305
|
+
if (ctx.aclDecide) {
|
|
306
|
+
try {
|
|
307
|
+
const d = ctx.aclDecide(ctx.registry.resolve(e.class), 'READ');
|
|
308
|
+
if (!d.allow || d.filter)
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
257
315
|
cb(e);
|
|
258
316
|
};
|
|
259
|
-
|
|
317
|
+
// postgres.js: dedicated LISTEN-соединение само переподключается (backoff) и
|
|
318
|
+
// повторяет LISTEN; onlisten зовётся на первом connect И на каждом re-listen.
|
|
319
|
+
// NOTIFY за время разрыва потеряны — onReconnect даёт точку дочитать пропущенное.
|
|
320
|
+
let first = true;
|
|
321
|
+
const onlisten = () => {
|
|
322
|
+
if (first) {
|
|
323
|
+
first = false;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
opts.onReconnect?.();
|
|
327
|
+
};
|
|
328
|
+
const { unlisten } = await ctx.sql.listen(ctx.pgSchema, listener, onlisten);
|
|
260
329
|
return unlisten;
|
|
261
330
|
};
|
|
262
331
|
case 'close':
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
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';
|
|
6
8
|
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';
|
|
9
|
+
export type { Row, Path, Filter, ChainMods, Cursor, ConnectOpts, QueryEvent, Account, Credential, Resource, Rule, AclOp, AclDecision, } from './types.js';
|
|
10
|
+
export type { EntityDb, EntityTx, Chain, Batch, WatchEvent, WatchOpts } from './chain.js';
|
|
9
11
|
export type { Tables, AccountsApi, CredentialsApi, ResourcesApi, RulesApi } from './tables.js';
|
|
12
|
+
export { totpCode } from './auth.js';
|
|
13
|
+
export type { AuthApi, AuthResult } from './auth.js';
|
|
14
|
+
export type { AclApi } from './acl.js';
|
|
15
|
+
export type { SessionStore, Sessions, Session } from './sessions.js';
|
|
10
16
|
export { ValidationError } from './write.js';
|
|
11
17
|
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,33 @@ 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';
|
|
40
60
|
// операторы фильтров
|
|
41
61
|
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, } from './ops.js';
|
|
62
|
+
export { totpCode } from './auth.js';
|
|
42
63
|
export { ValidationError } from './write.js';
|
|
43
64
|
export { Registry } from './schema.js';
|
package/dist/schema.js
CHANGED
|
@@ -62,6 +62,17 @@ export function fieldTypeOf(attr) {
|
|
|
62
62
|
const t = attr.type;
|
|
63
63
|
if (t === 'record')
|
|
64
64
|
return { kind: 'record', value: fieldTypeOf(attr.value ?? 'any') };
|
|
65
|
+
if (t === 'object') {
|
|
66
|
+
// вложенная структура: {type:'object', props:{…}} — типы листьев любой глубины
|
|
67
|
+
const props = attr.props ?? attr.properties;
|
|
68
|
+
if (props && typeof props === 'object') {
|
|
69
|
+
const m = new Map();
|
|
70
|
+
for (const [k, v] of Object.entries(props))
|
|
71
|
+
m.set(k, fieldTypeOf(v));
|
|
72
|
+
return { kind: 'object', props: m };
|
|
73
|
+
}
|
|
74
|
+
return { kind: 'any' };
|
|
75
|
+
}
|
|
65
76
|
if (t === 'array')
|
|
66
77
|
return { kind: 'array' };
|
|
67
78
|
if (t === 'number')
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Минимальный срез Redis-клиента (ioredis подходит как есть). */
|
|
2
|
+
export interface SessionStore {
|
|
3
|
+
set(key: string, value: string, mode: 'EX', ttlSec: number): Promise<unknown>;
|
|
4
|
+
get(key: string): Promise<string | null>;
|
|
5
|
+
del(...keys: string[]): Promise<unknown>;
|
|
6
|
+
sadd(key: string, ...members: string[]): Promise<unknown>;
|
|
7
|
+
srem(key: string, ...members: string[]): Promise<unknown>;
|
|
8
|
+
smembers(key: string): Promise<string[]>;
|
|
9
|
+
expire(key: string, ttlSec: number): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface Session {
|
|
12
|
+
account: string;
|
|
13
|
+
meta: Record<string, unknown>;
|
|
14
|
+
created: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Sessions {
|
|
17
|
+
/** Новая сессия → токен (отдаётся один раз; в Redis — только его sha256). Default ttl 7 суток. */
|
|
18
|
+
start(account: string | {
|
|
19
|
+
id: string;
|
|
20
|
+
}, opts?: {
|
|
21
|
+
ttlSec?: number;
|
|
22
|
+
meta?: Record<string, unknown>;
|
|
23
|
+
}): Promise<string>;
|
|
24
|
+
/** null — нет/просрочена/отозвана. */
|
|
25
|
+
check(token: string): Promise<Session | null>;
|
|
26
|
+
revoke(token: string): Promise<boolean>;
|
|
27
|
+
/** Гасит все сессии аккаунта, возвращает сколько было в индексе. */
|
|
28
|
+
revokeAll(account: string | {
|
|
29
|
+
id: string;
|
|
30
|
+
}): Promise<number>;
|
|
31
|
+
}
|
|
32
|
+
export declare function makeSessions(store: SessionStore): Sessions;
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Сессии во внешнем KV (Redis): db.auth.sessions(store).
|
|
3
|
+
* Клиент НЕ входит в зависимости — инжектируется пользователем (интерфейс ioredis-совместим).
|
|
4
|
+
* В store лежит только sha256-хэш токена: дамп Redis не раскрывает действующие токены.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
8
|
+
const accId = (a) => (typeof a === 'object' ? a.id : a);
|
|
9
|
+
const DEFAULT_TTL = 7 * 24 * 3600;
|
|
10
|
+
export function makeSessions(store) {
|
|
11
|
+
const kTok = (h) => `sess:${h}`;
|
|
12
|
+
// индекс аккаунта без TTL: протухшие хэши безвредны, полная чистка — revokeAll
|
|
13
|
+
const kAcc = (id) => `sess:acc:${id}`;
|
|
14
|
+
return {
|
|
15
|
+
async start(account, opts = {}) {
|
|
16
|
+
const id = accId(account);
|
|
17
|
+
const token = randomBytes(32).toString('hex');
|
|
18
|
+
const h = sha256(token);
|
|
19
|
+
const ttl = opts.ttlSec ?? DEFAULT_TTL;
|
|
20
|
+
const payload = { account: id, meta: opts.meta ?? {}, created: new Date().toISOString() };
|
|
21
|
+
await store.set(kTok(h), JSON.stringify(payload), 'EX', ttl);
|
|
22
|
+
await store.sadd(kAcc(id), h);
|
|
23
|
+
return token;
|
|
24
|
+
},
|
|
25
|
+
async check(token) {
|
|
26
|
+
const raw = await store.get(kTok(sha256(token)));
|
|
27
|
+
return raw ? JSON.parse(raw) : null;
|
|
28
|
+
},
|
|
29
|
+
async revoke(token) {
|
|
30
|
+
const h = sha256(token);
|
|
31
|
+
const raw = await store.get(kTok(h));
|
|
32
|
+
if (!raw)
|
|
33
|
+
return false;
|
|
34
|
+
const { account } = JSON.parse(raw);
|
|
35
|
+
await store.del(kTok(h));
|
|
36
|
+
await store.srem(kAcc(account), h);
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
async revokeAll(account) {
|
|
40
|
+
const id = accId(account);
|
|
41
|
+
const hs = await store.smembers(kAcc(id));
|
|
42
|
+
if (hs.length)
|
|
43
|
+
await store.del(...hs.map(kTok));
|
|
44
|
+
await store.del(kAcc(id));
|
|
45
|
+
return hs.length;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|