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/sync.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Синхронизация моделей с базой и генерация типов (план, этап 2, п. 7).
3
+ *
4
+ * syncModels: описания моделей сравниваются со строками Class арендатора сессии; все изменения
5
+ * выполняются одной транзакцией. Сухой прогон откатывает её в конце, поэтому ужесточение с
6
+ * мешающими строками отклоняет сама база (`tightening_conflict` с перечнем) уже в сухом прогоне.
7
+ * generateTypes: объявления TypeScript для классов из реестра — в том числе созданных данными.
8
+ */
9
+ import type { Db } from './index.js';
10
+ import type { AnyModel, LostRule } from './model.js';
11
+ import type { JsonSchema } from './jsonschema.js';
12
+ import type { Registry } from './registry.js';
13
+ export interface SyncResult {
14
+ applied: boolean;
15
+ created: string[];
16
+ updated: string[];
17
+ unchanged: string[];
18
+ /** Классы системного арендатора: их меняет только установка. */
19
+ skipped: string[];
20
+ /** Правила refine, которых нет в базе. */
21
+ lost: LostRule[];
22
+ }
23
+ export declare function syncModels(db: Db, models: readonly AnyModel[], opts?: {
24
+ apply?: boolean;
25
+ }): Promise<SyncResult>;
26
+ /** Тип TypeScript по JSON Schema подмножества. */
27
+ export declare function schemaToTs(s: JsonSchema | undefined, indent?: string): string;
28
+ /** Объявления `<Класс>Data` и `<Класс>Links` для классов реестра (кроме системных). */
29
+ export declare function generateTypes(registry: Registry, opts?: {
30
+ includeSystem?: boolean;
31
+ }): string;
package/dist/sync.js ADDED
@@ -0,0 +1,108 @@
1
+ import { LetopisError } from './errors.js';
2
+ const ROLLBACK = Symbol('sync-dry-run');
3
+ /** Канонический JSON: ключи по алфавиту — сравнение описаний без учёта порядка ключей. */
4
+ function canon(v) {
5
+ if (Array.isArray(v))
6
+ return `[${v.map(canon).join(',')}]`;
7
+ if (v && typeof v === 'object')
8
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
9
+ return JSON.stringify(v);
10
+ }
11
+ export async function syncModels(db, models, opts = {}) {
12
+ const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
13
+ const S = `"${db.schema.replace(/"/g, '""')}"`;
14
+ // предок раньше потомка
15
+ const ordered = [];
16
+ const visit = (m) => {
17
+ if (ordered.includes(m))
18
+ return;
19
+ if (m.parent && models.includes(m.parent))
20
+ visit(m.parent);
21
+ ordered.push(m);
22
+ };
23
+ models.forEach(visit);
24
+ try {
25
+ await db.transaction(async (tx) => {
26
+ for (const m of ordered) {
27
+ const desc = m.describe();
28
+ const [cur] = await tx.unsafe(`select tenant = ${S}.ctx_tenant() as own, data - 'resolved' as data from ${S}.entity
29
+ where class = 'Class' and data->>'name' = $1 and tenant in (${S}.ctx_tenant(), '2eba6d0a-1edd-4bb6-a85a-a703dab49035')`, [m.name]);
30
+ if (cur && !cur.own) {
31
+ res.skipped.push(m.name);
32
+ }
33
+ else if (!cur) {
34
+ await tx.unsafe(`insert into ${S}.entity (class, data) values ('Class', $1::jsonb)`, [desc]);
35
+ res.created.push(m.name);
36
+ }
37
+ else if (canon(cur.data) === canon(desc)) {
38
+ res.unchanged.push(m.name);
39
+ }
40
+ else {
41
+ await tx.unsafe(`update ${S}.entity set data = $2::jsonb where class = 'Class' and tenant = ${S}.ctx_tenant() and data->>'name' = $1`, [m.name, desc]);
42
+ res.updated.push(m.name);
43
+ }
44
+ }
45
+ if (!opts.apply)
46
+ throw ROLLBACK;
47
+ });
48
+ }
49
+ catch (e) {
50
+ if (e !== ROLLBACK)
51
+ throw e;
52
+ }
53
+ if (opts.apply)
54
+ await db.refresh();
55
+ return res;
56
+ }
57
+ /** Тип TypeScript по JSON Schema подмножества. */
58
+ export function schemaToTs(s, indent = '') {
59
+ if (!s || Object.keys(s).length === 0)
60
+ return 'unknown';
61
+ if (s.const !== undefined)
62
+ return JSON.stringify(s.const);
63
+ if (Array.isArray(s.enum))
64
+ return s.enum.map((v) => JSON.stringify(v)).join(' | ');
65
+ if (Array.isArray(s.anyOf) || Array.isArray(s.oneOf))
66
+ return (s.anyOf ?? s.oneOf).map((b) => schemaToTs(b, indent)).join(' | ');
67
+ const types = Array.isArray(s.type) ? s.type : [s.type];
68
+ return types.map((t) => {
69
+ switch (t) {
70
+ case 'string': return 'string';
71
+ case 'integer':
72
+ case 'number': return 'number';
73
+ case 'boolean': return 'boolean';
74
+ case 'null': return 'null';
75
+ case 'array': return `Array<${schemaToTs(s.items, indent)}>`;
76
+ case 'object': {
77
+ const props = (s.properties ?? {});
78
+ const req = new Set((s.required ?? []));
79
+ const inner = indent + ' ';
80
+ const lines = Object.entries(props).map(([k, v]) => `${inner}${JSON.stringify(k)}${req.has(k) ? '' : '?'}: ${schemaToTs(v, inner)};`);
81
+ if (s.additionalProperties && typeof s.additionalProperties === 'object') {
82
+ const key = s.propertyNames && Array.isArray(s.propertyNames.enum) ? s.propertyNames.enum.map((x) => JSON.stringify(x)).join(' | ') : 'string';
83
+ const rec = `Partial<Record<${key}, ${schemaToTs(s.additionalProperties, inner)}>>`;
84
+ return lines.length ? `{\n${lines.join('\n')}\n${indent}} & ${rec}` : rec;
85
+ }
86
+ return lines.length ? `{\n${lines.join('\n')}\n${indent}}` : 'Record<string, unknown>';
87
+ }
88
+ default: return 'unknown';
89
+ }
90
+ }).join(' | ');
91
+ }
92
+ /** Объявления `<Класс>Data` и `<Класс>Links` для классов реестра (кроме системных). */
93
+ export function generateTypes(registry, opts = {}) {
94
+ const out = ['// Сгенерировано letopis types — не править руками.', ''];
95
+ for (const c of registry.all().sort((a, b) => a.name.localeCompare(b.name))) {
96
+ if (!opts.includeSystem && c.tenant === '2eba6d0a-1edd-4bb6-a85a-a703dab49035')
97
+ continue;
98
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(c.name))
99
+ throw new LetopisError('invalid_class', `types: имя класса ${c.name} не годится для идентификатора TypeScript`);
100
+ // additionalProperties: false верхнего уровня в тип не переносится — лишние ключи и так ошибка типов
101
+ const { additionalProperties: _closed, ...open } = c.schema;
102
+ void _closed;
103
+ out.push(`export interface ${c.name}Data ${schemaToTs(open)}`);
104
+ const ends = Object.entries(c.ends).map(([role, e]) => ` ${JSON.stringify(role)}${e.required || (e.min ?? 0) > 0 ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
105
+ out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`, '');
106
+ }
107
+ return out.join('\n');
108
+ }
package/dist/tx.d.ts CHANGED
@@ -1,13 +1,134 @@
1
1
  /**
2
- * Транзакции: db.begin() → tr (тот же API на выделенном соединении),
3
- * db.commit(tr) / db.rollback(tr) / tr.commit() / tr.rollback().
4
- * tr.lock(...) — pg_advisory_xact_lock (сериализация гонок, напр. двойная бронь).
2
+ * Клиентский контекст (план, §2.5, §2.9, этап 2, п. 5; этап 4, п. 9; этап 5): каждая операция
3
+ * библиотеки — транзакция `read committed`, первые операторы которой выставляют `letopis.token` (и при
4
+ * необходимости `letopis.account`, `letopis.tenant`, `letopis.reason`) через set_config(…, true) —
5
+ * настройки живут до конца транзакции, соединение в пуле чужой токен не унесёт.
6
+ *
7
+ * Пропуск прав (`letopis.acl`, этап 5): подписанный базой план прав актора. Без него база считает
8
+ * план на каждый запрос (сотни микросекунд); с ним — проверяет подпись. Пропуск кэшируется на
9
+ * подключение по токену, актору и арендатору; нет пропуска — первая же операция берёт его тем же
10
+ * конвейером. Устаревший пропуск база не принимает и считает план сама — кэш влияет на скорость,
11
+ * а не на права; библиотека сбрасывает его по сигналам изменений прав и через минуту.
12
+ *
13
+ * Исполнитель (Executor) — общий вход цепочек: операция из пула — отдельная транзакция с повтором
14
+ * при 40P01, 40001 и конфликте журнала (план повторяется целиком); явная транзакция db.begin() —
15
+ * одно зарезервированное соединение, повторы — дело приложения. Любая ошибка внутри явной
16
+ * транзакции её ломает: rollback() проходит, commit() откатывает, возвращает соединение и бросает
17
+ * исходную ошибку — при каждом вызове. Обрыв связи во время COMMIT — commit_unknown, без повтора.
18
+ *
19
+ * Сигналы (решение 9 точки А): транзакции библиотеки помечены `letopis.notify = 'deferred'` —
20
+ * триггер не шлёт NOTIFY, а копит имена изменённых классов в `letopis.changed`; после фиксации
21
+ * их забирает нотификатор процесса и раз в интервал (по умолчанию 100 мс) отправляет одной
22
+ * транзакцией без данных: такая фиксация не ждёт диск и не задерживает чужие записи.
5
23
  */
6
- import { type Ctx } from './sql.js';
7
- export interface TxCtx extends Ctx {
24
+ import type postgres from 'postgres';
25
+ type Sql = postgres.Sql<Record<string, unknown>>;
26
+ export type Tx = postgres.TransactionSql<Record<string, unknown>>;
27
+ /** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
28
+ export declare function isTransient(e: unknown): boolean;
29
+ /** Конфликт пары «id, номер версии» в журнале — параллельные удаление и создание одного id (§2.5). */
30
+ export declare function isLogConflict(e: unknown): boolean;
31
+ /** Обрыв связи: сокет закрыт или серверный процесс завершён (FATAL) — исход отправленного неизвестен. */
32
+ export declare function isConnectionError(e: unknown): boolean;
33
+ export declare const RETRIES = 3;
34
+ export declare const retryDelay: (attempt: number) => Promise<void>;
35
+ /** Нотификатор процесса: копит имена классов и отправляет их пачкой раз в интервал. */
36
+ export declare class Notifier {
37
+ private sql;
38
+ private channel;
39
+ private intervalMs;
40
+ private onError;
41
+ private queue;
42
+ private timer;
43
+ private stopped;
44
+ private flushing;
45
+ constructor(sql: Sql, channel: string, intervalMs: number, onError?: (e: unknown) => void);
46
+ /** Добавить классы из letopis.changed (строка через запятую). */
47
+ add(changed: string | null | undefined): void;
48
+ /** Отправить накопленное сейчас (вызывается по таймеру и при закрытии). */
49
+ flush(): Promise<void>;
50
+ close(): Promise<void>;
51
+ }
52
+ /** Кэш подписанных пропусков прав одного подключения: по токену, актору и арендатору. */
53
+ export declare class AclPasses {
54
+ readonly schema: string;
55
+ private ttlMs;
56
+ private max;
57
+ private map;
58
+ /** schema — имя схемы в кавычках (для вызова acl_token). */
59
+ constructor(schema: string, ttlMs?: number, max?: number);
60
+ private key;
61
+ get(c: TxContext): string | null;
62
+ set(c: TxContext, pass: string | null | undefined): void;
63
+ /** Права изменились (сигнал строк прав, auth.switch): все пропуска — заново. */
64
+ clear(): void;
65
+ }
66
+ export interface TxContext {
67
+ sql: Sql;
68
+ token: string | null;
69
+ notifier: Notifier | null;
70
+ /** Имперсонация: аккаунт, от имени которого действует сервис (letopis.account, этап 5). */
71
+ account?: string;
72
+ /** Рабочий арендатор (letopis.tenant): собственный или по членству; база проверяет членство. */
73
+ tenant?: string;
74
+ reason?: string;
75
+ /** Кэш пропусков прав подключения (letopis.acl). */
76
+ passes?: AclPasses;
77
+ /** После успешной операции (продление сессии раз в минуту). */
78
+ after?: () => void;
79
+ /**
80
+ * После фиксации записи: имена из letopis.changed (классы, session:<тег>) — кэш результатов
81
+ * сбрасывает свои ответы сразу (§2.12); raw — запись через db.sql.
82
+ */
83
+ onChanged?: (names: string[], raw: boolean) => void;
84
+ }
85
+ /**
86
+ * Операция одной транзакцией с контекстом сессии и повтором при транзиентных ошибках и конфликте
87
+ * журнала. Ошибки базы переводятся в LetopisError. read — операция только читает: сигналы не собираются.
88
+ */
89
+ export declare function runTx<T>(ctx: TxContext, fn: (tx: Tx) => Promise<T>, opts?: {
90
+ retries?: number;
91
+ read?: boolean;
92
+ raw?: boolean;
93
+ }): Promise<T>;
94
+ /** Вход цепочек: где исполнить операцию — отдельной транзакцией из пула или в явной транзакции. */
95
+ export interface Executor {
96
+ /** Операция одной транзакцией; в явной транзакции — в ней, без повторов. raw — сырой SQL (db.sql). */
97
+ run<T>(fn: (tx: Tx) => Promise<T>, opts?: {
98
+ read?: boolean;
99
+ raw?: boolean;
100
+ }): Promise<T>;
101
+ /** Внутри db.begin(). */
102
+ readonly explicit: boolean;
103
+ }
104
+ /** Исполнитель пула: каждая операция — своя транзакция с повторами. */
105
+ export declare function poolExecutor(ctx: TxContext): Executor;
106
+ /**
107
+ * Явная транзакция db.begin() (этап 4, п. 9): одно зарезервированное соединение, read committed.
108
+ * Ошибка любого запроса ломает транзакцию (правило §2.5): дальше каждый вызов бросает исходную
109
+ * ошибку, rollback() проходит, commit() откатывает и бросает её же. Ответ ROLLBACK на COMMIT
110
+ * (PostgreSQL так фиксирует сломанную транзакцию) — тоже ошибка.
111
+ */
112
+ export declare class ExplicitTx implements Executor {
113
+ private ctx;
114
+ private conn;
115
+ readonly explicit = true;
116
+ private broken;
117
+ private done;
118
+ private failure;
119
+ /** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
120
+ private raw;
121
+ private constructor();
122
+ static open(ctx: TxContext): Promise<ExplicitTx>;
123
+ /** Транзакция ещё открыта (не зафиксирована и не откачена). */
124
+ get active(): boolean;
125
+ run<T>(fn: (tx: Tx) => Promise<T>, opts?: {
126
+ read?: boolean;
127
+ raw?: boolean;
128
+ }): Promise<T>;
129
+ /** Advisory-блокировка до конца транзакции (отдельное от блокировок семейств пространство ключей). */
130
+ lock(...keys: (string | number)[]): Promise<void>;
8
131
  commit(): Promise<void>;
9
132
  rollback(): Promise<void>;
10
133
  }
11
- export declare function beginTx(ctx: Ctx): Promise<TxCtx>;
12
- /** Advisory-lock на составной ключ; живёт до конца транзакции. */
13
- export declare function lock(ctx: Ctx, ...keys: (string | number)[]): Promise<void>;
134
+ export {};