letopis 0.5.0 → 0.16.0

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