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/dist/chain.d.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  /**
2
- * Dot-цепочки: db.Сотрудник({name:'Вася'}).навык().Услуга().execute()
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) — связи задаются контекст-шагами цепочки; .delete().
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 BatchOp } from './write.js';
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
- execute(): Promise<Path[]>;
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
- /** GDPR: новая версия с затёртыми string-полями ('[erased]') + тег 'anonymized'. История остаётся (см. README). */
43
- anonymize(fields: string[]): Promise<Row[]>;
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
- * Записать. Связи — контекст-шагами до set() И/ИЛИ класс-вызовами после:
58
- * await db.Сотрудник(s).занятость(id).set({kind}).Окно(w)
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
- set(data?: Record<string, unknown>): SetChain;
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
- }) => SetChain;
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
- /** Именованный батч: те же цепочки, set/delete копятся до execute(). */
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(classOrCb: string | ((e: WatchEvent) => void), cb?: (e: WatchEvent) => void): Promise<() => void>;
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(): Promise<Row[][]>;
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, BatchOp[]>;
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 { setOp, delOp, anonymizeOp, executeBatch, toRow } from './write.js';
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
- const idOf = (t) => (typeof t === 'object' ? t.id : t);
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] = { ...next[next.length - 1], ...patch };
73
- return makeChain(ctx, next, mods, batchQueue);
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
- return () => runPaths(ctx, steps, mods);
83
+ throw new Error('letopis: execute() renamed to run() (0.11.0)');
83
84
  case 'rows':
84
- return () => runRows(ctx, steps, mods);
85
+ return () => {
86
+ guardBatch();
87
+ return hasOps ? runPlan(ctx, steps, mods, 'rows') : runRows(ctx, steps, mods);
88
+ };
85
89
  case 'first':
86
- return async () => (await runRows(ctx, steps, { ...mods, limit: 1 }))[0] ?? null;
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
- const q = buildRead(ctx, steps, { ...mods, aggFn: prop, aggField: field }, 'agg');
115
- const res = await runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
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
- return v === null || v === undefined ? null : typeof v === 'string' && prop !== 'min' && prop !== 'max' ? Number(v) : v;
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
- const q = buildRead(ctx, steps, { ...mods, aggFn: 'countBy', aggField: field }, 'agg');
122
- const res = await runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
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) => anonymizeOp(ctx, steps, mods, 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 'set':
148
- return (data = {}) => {
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);
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, batchQueue);
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 'execute':
215
+ case 'run':
185
216
  return async () => {
186
- const ops = queue.splice(0, queue.length);
187
- return executeBatch(ctx, ops);
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
- const { unlisten } = await ctx.sql.listen(ctx.pgSchema, listener);
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: 'Вася' }).навык().Услуга().execute()
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' ? row.updated : row.data[field.slice(5)];
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;
@@ -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
+ }