letopis 0.20.3 → 1.0.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.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +369 -551
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -250
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -351
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -600
  89. package/sql/seed.auth.sql +0 -73
package/dist/watch.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Подписка на изменения (план, этап 6, п. 5): `for await (const ev of db.watch({ from }))`.
3
+ *
4
+ * «now» — голова в момент подписки: курсор с горизонта и снимок, чтобы не отдать транзакции,
5
+ * зафиксированные до подписки, но ещё не ушедшие за горизонт.
6
+ *
7
+ * Курсор — (tx, seq) записи журнала: события читаются только ниже горизонта завершённых
8
+ * транзакций (pg_snapshot_xmin) — транзакцию с меньшим номером, зафиксированную позже, курсор уже
9
+ * не обгонит, поэтому событий нет ни потерянных, ни повторных. Поток стоит, пока открыта более
10
+ * старая пишущая транзакция (горизонт общий для кластера); метрика — lag(). Чтение идёт под RLS
11
+ * журнала: каждый видит только своё. Сигнал базы с именем класса только будит подписку (чужие
12
+ * классы — не будят); без LISTEN и на случай потери сигнала — запасной опрос.
13
+ *
14
+ * Курсор подписчика старше яруса all политики хранения его классов — cursor_expired: события могли
15
+ * проредиться, нужно перечитать текущее состояние и подписаться заново.
16
+ */
17
+ import { LetopisError } from './errors.js';
18
+ import { toRow } from './sql.js';
19
+ /** Курсор события: «tx:seq[:время][~снимок]». */
20
+ const encode = (p) => `${p.tx}:${p.seq}${p.at ? `:${p.at}` : ''}${p.snap ? `~${p.snap}` : ''}`;
21
+ function decode(c) {
22
+ const [head, snap] = c.split('~');
23
+ const m = /^(\d+):(\d+)(?::(.+))?$/.exec(head);
24
+ if (!m || (snap !== undefined && !/^\d+:\d+:[\d,]*$/.test(snap))) {
25
+ throw new LetopisError('invalid_query', `watch: курсор ${c} — ожидается курсор события`);
26
+ }
27
+ return { tx: m[1], seq: m[2], at: m[3] ?? null, ...(snap ? { snap } : {}) };
28
+ }
29
+ /** Снимок ещё нужен: курсор не дошёл до его xmax. */
30
+ const keepSnap = (snap, tx) => snap && BigInt(tx) < BigInt(snap.split(':')[1]) ? snap : undefined;
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+ export class Subscription {
33
+ ctx;
34
+ opts;
35
+ pos = null;
36
+ buf = [];
37
+ closed = false;
38
+ wake = null;
39
+ dirty = true;
40
+ off = null;
41
+ timer = null;
42
+ failure = null;
43
+ batch;
44
+ pollMs;
45
+ /** 'now' — голова журнала в момент подписки (запрос уходит сразу). */
46
+ start = null;
47
+ constructor(ctx, opts) {
48
+ this.ctx = ctx;
49
+ this.opts = opts;
50
+ this.batch = opts.batch ?? 500;
51
+ this.pollMs = opts.pollMs ?? 1000;
52
+ const from = opts.from ?? 'now';
53
+ if (from === 'start')
54
+ this.pos = { tx: '0', seq: '0', at: null };
55
+ else if (from !== 'now')
56
+ this.pos = decode(from);
57
+ else {
58
+ this.start = this.head();
59
+ this.start.catch(() => { });
60
+ }
61
+ if (ctx.onSignal) {
62
+ const watched = ctx.classes ? new Set(ctx.classes) : null;
63
+ this.off = ctx.onSignal((name) => {
64
+ if (name === '' || name.startsWith('session:'))
65
+ return;
66
+ if (watched && name !== '*' && !watched.has(name))
67
+ return;
68
+ this.dirty = true;
69
+ this.wake?.();
70
+ });
71
+ }
72
+ this.timer = setInterval(() => {
73
+ this.dirty = true;
74
+ this.wake?.();
75
+ }, this.pollMs);
76
+ this.timer.unref?.();
77
+ }
78
+ /** Курсор последнего выданного события (или начальной точки). */
79
+ get cursor() {
80
+ return this.pos ? encode(this.pos) : null;
81
+ }
82
+ async head() {
83
+ const [r] = await this.ctx.exec.run((q) => q.unsafe(`select tx::text as tx, seq::text as seq, snap::text as snap from ${this.ctx.S}.watch_head()`), { read: true });
84
+ return { tx: r.tx, seq: r.seq, at: null, snap: r.snap };
85
+ }
86
+ /** Следующая пачка событий ниже горизонта; пусто — горизонт ещё не дошёл или событий нет. */
87
+ async fetch() {
88
+ if (!this.pos)
89
+ this.pos = await (this.start ?? this.head());
90
+ const p = this.pos;
91
+ const rows = await this.ctx.exec.run((q) => q.unsafe(`select l.tx::text as tx, l.seq::text as seq, jsonb_build_object('id', l.id, 'rev', l.rev, 'class', l.class, 'tenant', l.tenant, `
92
+ + `'owner', l.owner, 'links', l.links, 'data', l.data, 'tags', l.tags, 'at', l.at, 'author', l.author, 'agent', l.agent, `
93
+ + `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row `
94
+ + `from ${this.ctx.S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
95
+ for (const r of rows) {
96
+ const row = toRow(r.row);
97
+ const snap = keepSnap(p.snap, r.tx);
98
+ this.pos = { tx: r.tx, seq: r.seq, at: row.at, ...(snap ? { snap } : {}) };
99
+ this.buf.push({ cursor: encode(this.pos), row, id: row.id, class: row.class, op: row.op, rev: row.rev, at: row.at });
100
+ }
101
+ return rows.length;
102
+ }
103
+ async next() {
104
+ let empty = 0;
105
+ for (;;) {
106
+ if (this.failure)
107
+ throw this.failure;
108
+ if (this.buf.length)
109
+ return { value: this.buf.shift(), done: false };
110
+ if (this.closed)
111
+ return { value: undefined, done: true };
112
+ if (this.dirty) {
113
+ try {
114
+ const n = await this.fetch();
115
+ if (n) {
116
+ empty = 0;
117
+ continue;
118
+ }
119
+ }
120
+ catch (e) {
121
+ this.failure = e;
122
+ this.close();
123
+ throw e;
124
+ }
125
+ // сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной опрос
126
+ if (++empty < 40) {
127
+ await sleep(Math.min(25 * empty, 250));
128
+ continue;
129
+ }
130
+ this.dirty = false;
131
+ empty = 0;
132
+ }
133
+ await new Promise((r) => {
134
+ this.wake = () => {
135
+ this.wake = null;
136
+ r();
137
+ };
138
+ });
139
+ }
140
+ }
141
+ /**
142
+ * Отставание, мс: сколько ждёт самое старое событие, которое уже зафиксировано, но ещё не ушло за
143
+ * горизонт (его держит более старая открытая транзакция); 0 — ждать нечего.
144
+ */
145
+ async lag() {
146
+ const p = this.pos ?? (await (this.start ?? this.head()));
147
+ const [r] = await this.ctx.exec.run((q) => q.unsafe(`select coalesce(extract(epoch from clock_timestamp() - min(l.at)) * 1000, 0)::float8 as ms from ${this.ctx.S}.log l `
148
+ + `where (l.tx, l.seq) > ($1::text::xid8, $2::bigint) and l.tx >= pg_snapshot_xmin(pg_current_snapshot())`
149
+ + `${this.ctx.classes ? ' and l.class = any ($3::text[])' : ''}`, (this.ctx.classes ? [p.tx, p.seq, this.ctx.classes] : [p.tx, p.seq])), { read: true });
150
+ return Number(r?.ms ?? 0);
151
+ }
152
+ close() {
153
+ if (this.closed)
154
+ return;
155
+ this.closed = true;
156
+ this.off?.();
157
+ if (this.timer)
158
+ clearInterval(this.timer);
159
+ this.wake?.();
160
+ }
161
+ async return() {
162
+ this.close();
163
+ return { value: undefined, done: true };
164
+ }
165
+ [Symbol.asyncIterator]() {
166
+ return this;
167
+ }
168
+ }
package/dist/write.d.ts CHANGED
@@ -1,82 +1,125 @@
1
- import type { Row, ChainMods } from './types.js';
2
- import { type Ctx, type Step } from './sql.js';
3
- export declare class ValidationError extends Error {
4
- issues: {
5
- field: string;
6
- message?: string;
7
- }[];
8
- constructor(cls: string, issues: {
9
- field: string;
10
- message?: string;
11
- }[]);
1
+ import type { ClassInfo, Registry } from './registry.js';
2
+ import { type AggSpec, type BuiltQuery, type ReadMode, type Step } from './sql.js';
3
+ import type { Tx } from './tx.js';
4
+ import type { ChainMods, Row } from './types.js';
5
+ /** Метка приращения; Symbol.for — маркер узнаёт и вторая копия пакета в процессе. */
6
+ export declare const INC: unique symbol;
7
+ export interface IncMarker {
8
+ readonly [INC]: true;
9
+ readonly by: number;
10
+ readonly start?: number;
12
11
  }
13
- interface RawRow {
14
- partition: string;
15
- id: string;
16
- class: string;
17
- data: Record<string, unknown>;
18
- links: Record<string, string> | null;
19
- tags: string[] | null;
20
- account: string;
21
- owner: string;
22
- updated: string | Date;
23
- deleted: string | Date | null;
24
- }
25
- export declare function toRow(r: RawRow): Row;
26
- /** Прочитать актуальные строки по цепочке. */
27
- export declare function readRows(ctx: Ctx, steps: Step[], mods?: ChainMods): Promise<Row[]>;
28
- /**
29
- * Deep-merge патча в базу: меняются ТОЛЬКО указанные листья.
30
- * Вложенные plain-объекты сливаются рекурсивно; массивы/скаляры/null — заменяются.
31
- */
32
- export declare function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
33
- /**
34
- * .create(data) — «чтобы сущность существовала»:
35
- * - id не задан → INSERT (id по IdGen класса; links = концы из пути + слоты);
36
- * - id известен (Класс(id) / data.id / вычислен v5): есть → новая версия (deep-merge),
37
- * нет → INSERT с этим id (идемпотентный create, REST-PUT семантика);
38
- * - фильтр-объект / pivot — ошибка: create не ищет, это update().
39
- */
40
- export declare function createOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
41
- /**
42
- * .update(data?) — новая версия КАЖДОГО найденного путём (deep-merge листьев);
43
- * цели: Класс() ≡ Класс({}) — все в границах контекста, id/фильтр/pivot — как в чтении.
44
- * Не найдено → [] — update НИКОГДА не создаёт.
45
- */
46
- export declare function updateOp(ctx: Ctx, steps: Step[], mods: ChainMods, data: Record<string, unknown>): Promise<Row[]>;
47
12
  /**
48
- * .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
49
- * + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).
13
+ * Приращение в update: `update({ balance: inc(-300) })` — величина прибавляется в базе, внутри
14
+ * оператора, над свежей строкой (функция inc). Поля нет — ошибка; `{ start }` — начать с него.
50
15
  */
51
- export declare function anonymizeOp(ctx: Ctx, steps: Step[], mods: ChainMods, fields: string[]): Promise<Row[]>;
52
- /**
53
- * .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
54
- * confirm: true — серверное удаление (триггер entity_delete: tombstone + рекурсивный
55
- * каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
56
- * Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
57
- */
58
- export declare function delOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
16
+ export declare function inc(by: number, opts?: {
17
+ start?: number;
18
+ }): IncMarker;
19
+ export declare const isInc: (v: unknown) => v is IncMarker;
20
+ export type PlanOp = {
21
+ kind: 'create';
22
+ data: Record<string, unknown>;
23
+ } | {
24
+ kind: 'update';
25
+ data: Record<string, unknown>;
26
+ rev?: number;
27
+ } | {
28
+ kind: 'upsert';
29
+ data: Record<string, unknown>;
30
+ } | {
31
+ kind: 'delete';
32
+ confirm: boolean;
33
+ } | {
34
+ kind: 'anonymize';
35
+ fields: string[];
36
+ } | {
37
+ kind: 'reclass';
38
+ cls: string;
39
+ data?: Record<string, unknown>;
40
+ } | {
41
+ kind: 'restore';
42
+ } | {
43
+ kind: 'purge';
44
+ confirm: boolean;
45
+ } | {
46
+ kind: 'rekey';
47
+ data: Record<string, unknown>;
48
+ };
49
+ /** Значение слота: id, список id, строка ответа или ленивая цепочка (в той же транзакции). */
50
+ export type SlotValue = string | string[] | {
51
+ id: string;
52
+ } | null | {
53
+ plan: Step[];
54
+ mods: ChainMods;
55
+ };
56
+ export interface Slot {
57
+ /** Роль (конец) записываемого класса. */
58
+ role: string;
59
+ op: 'set' | 'unset' | 'add' | 'remove';
60
+ value: SlotValue;
61
+ }
62
+ /** Строка ответа записи (результат upsert — $upsert, превью удаления — $action — поля Row). */
63
+ export type WriteRow = Row;
64
+ export interface IncSpec {
65
+ path: string[];
66
+ by: number;
67
+ start?: number;
68
+ }
69
+ /** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
70
+ export declare function splitPatch(patch: Record<string, unknown>): {
71
+ rest: Record<string, unknown>;
72
+ incs: IncSpec[];
73
+ };
74
+ /** Всё, что проверяется без базы, — до отправки первого оператора (§2.5). */
75
+ export declare function precheckPlan(reg: Registry, steps: Step[]): void;
76
+ export interface WriteCtx {
77
+ reg: Registry;
78
+ schema: string;
79
+ q: Tx;
80
+ /** Арендатор сессии: для id «уже существует». */
81
+ tenant: string | null;
82
+ /** Владелец новых строк по умолчанию (db.as(…, { owner })). */
83
+ owner?: string;
84
+ }
85
+ /** Чтение в транзакции плана — тот же построитель, что у цепочек. */
86
+ export declare function readIn<T>(w: WriteCtx, steps: Step[], mods: ChainMods, mode: ReadMode, agg?: AggSpec): Promise<{
87
+ q: BuiltQuery;
88
+ res: T[];
89
+ }>;
90
+ /** Роль слота: имя роли записываемого класса или класс, который принимает ровно одна роль. */
91
+ export declare function slotRole(reg: Registry, owner: ClassInfo, name: string): string;
92
+ interface Segment {
93
+ /** Шаги сегмента; последний несёт глагол. */
94
+ steps: Step[];
95
+ }
96
+ /** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
97
+ export declare function splitPlan(steps: Step[]): {
98
+ segments: Segment[];
99
+ tail: Step[];
100
+ };
101
+ /** Результат плана: строки последнего глагола или чтение хвоста от них. */
102
+ export type PlanResult = {
103
+ kind: 'rows';
104
+ rows: WriteRow[];
105
+ key: string;
106
+ } | {
107
+ kind: 'read';
108
+ q: BuiltQuery | null;
109
+ res: unknown[];
110
+ };
59
111
  /**
60
- * .purge({confirm}): ФИЗИЧЕСКИЙ hard-erase — сносит логически удалённые цели + всё поддерево (все версии).
61
- * Вся логика — в серверной purge() (двухфазность: живую цель не трогает; замыкание по links; SET LOCAL
62
- * отключает entity_delete → плоский снос без TM_SelfModified). JS лишь резолвит цели и зовёт функцию.
63
- * confirm: false → dry-превью (что сотрётся, БД цела); true → снос, возвращает снесённое ($deleted).
64
- * gate: DELETE-право на класс цели (aclWrite) — поддерево уже было DELETE-авторизовано при мягком удалении.
65
- * В отличие от .delete() (tombstone, обратимо) — необратимо, историю НЕ сохраняет.
112
+ * Исполнить план в транзакции w.q: сегменты по порядку, продолжение — от результата предыдущего
113
+ * глагола (create и upsert — по строке, остальные — от всех строк сразу); хвост — чтение от
114
+ * результата последнего глагола тем же построителем, что у цепочек.
66
115
  */
67
- export declare function purgeOp(ctx: Ctx, steps: Step[], mods: ChainMods, confirm: boolean): Promise<Row[]>;
68
- export type PlanMode = 'rows' | 'ids' | 'count' | 'paths' | 'versions' | 'agg';
116
+ export declare function execPlan(w: WriteCtx, steps: Step[], mods: ChainMods, mode: ReadMode, agg?: AggSpec): Promise<PlanResult>;
69
117
  /**
70
- * Исполнить план целиком: все операции + финальное чтение — одна транзакция
71
- * (внутренняя, с ретраем transient; внутри db.begin() — транзакция пользователя).
72
- * Продолжение после операции: fan-out — каждый следующий сегмент исполняется от каждой
73
- * строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
74
- * После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
118
+ * Очередь планов одной транзакцией. Подряд идущие простые вставки одного класса с ключом — одна
119
+ * многострочная вставка, подряд идущие upsert — тоже (повтор id режет группу: один оператор не
120
+ * меняет строку дважды); результат сопоставляется со входом по вычисленному id. Вставки класса без
121
+ * ключа уходят однострочными операторами одним конвейером: порядок строк в returning
122
+ * многострочной вставки не гарантирован, сопоставить не по чему.
75
123
  */
76
- export declare function runPlan(ctx: Ctx, steps: Step[], mods: ChainMods, mode: PlanMode): Promise<unknown>;
77
- /** План в очереди батча; steps обновляется по мере роста цепочки. */
78
- export interface BatchPlan {
79
- steps: Step[];
80
- }
81
- export declare function executeBatch(ctx: Ctx, queue: BatchPlan[]): Promise<Row[][]>;
124
+ export declare function execBatch(w: WriteCtx, plans: Step[][]): Promise<WriteRow[][]>;
82
125
  export {};