letopis 1.0.1 → 1.2.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/AGENT-CHEATSHEET.en.md +117 -10
- package/AGENT-CHEATSHEET.md +110 -8
- package/CHANGELOG.md +739 -0
- package/README.en.md +873 -68
- package/README.md +830 -66
- package/dist/admin.d.ts +24 -4
- package/dist/admin.js +39 -9
- package/dist/cache.d.ts +33 -9
- package/dist/cache.js +81 -32
- package/dist/chain.d.ts +29 -7
- package/dist/chain.js +61 -12
- package/dist/cli.js +59 -7
- package/dist/errors.js +4 -2
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +27 -5
- package/dist/index.js +191 -29
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.d.ts +63 -0
- package/dist/load.js +331 -44
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +26 -7
- package/dist/tx.d.ts +9 -7
- package/dist/tx.js +15 -5
- package/dist/typed.d.ts +21 -6
- package/dist/types.d.ts +77 -4
- package/dist/types.js +8 -3
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +23 -2
- package/dist/watch.js +44 -7
- package/dist/write.d.ts +11 -2
- package/dist/write.js +57 -10
- package/llms.txt +9 -2
- package/package.json +13 -2
- package/sql/10-core.sql +13 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +611 -82
- package/sql/70-read.sql +33 -8
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +7 -7
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +134 -21
- package/sql/95-seed.system.sql +24 -6
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +5 -1
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/dist/types.js
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* Общие типы letopis 1.0. Файл наполняется по этапам плана (reports/PLAN-LETOPIS-1.0.md).
|
|
3
3
|
*/
|
|
4
4
|
/** Ревизия движка в базе: штамп в lib/sql/99-revision.sql должен совпадать (этап 1). */
|
|
5
|
-
export const DDL_REVISION =
|
|
5
|
+
export const DDL_REVISION = 6;
|
|
6
|
+
/**
|
|
7
|
+
* Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
|
|
8
|
+
* сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
|
|
9
|
+
*/
|
|
10
|
+
export const ACL_CLASS_NAMES = ['rule', 'Resource', 'member', 'Account', 'Class'];
|
|
6
11
|
// ---------------------------------------------------------------------------
|
|
7
12
|
// Методы цепочки — единственный источник зарезервированных имён (план, §2.14, этап 3, п. 10)
|
|
8
13
|
// ---------------------------------------------------------------------------
|
|
@@ -11,14 +16,14 @@ export const CHAIN_METHODS = [
|
|
|
11
16
|
'then', 'entity', 'paths', 'rows', 'first', 'ids', 'count', 'limit', 'offset', 'sort', 'asOf', 'withDeleted',
|
|
12
17
|
'deep', 'exact', 'sum', 'avg', 'min', 'max', 'countBy', 'after', 'versions', 'create', 'update', 'upsert', 'set',
|
|
13
18
|
'unset', 'add', 'remove', 'delete', 'anonymize', 'reclass', 'restore', 'purge', 'alias', 'tags', 'owner',
|
|
14
|
-
'forUpdate', 'cache', 'rekey', 'run', 'execute', 'account',
|
|
19
|
+
'forUpdate', 'cache', 'rekey', 'run', 'execute', 'account', 'diff', 'blame',
|
|
15
20
|
];
|
|
16
21
|
/** Свойства корня db (и транзакции db.begin()). */
|
|
17
22
|
export const DB_METHODS = [
|
|
18
23
|
'as', 'begin', 'commit', 'rollback', 'lock', 'batch', 'watch', 'close', 'registry', 'sql', 'load', 'verify',
|
|
19
24
|
'maintain', 'reset', 'describe', 'auth', 'acl', 'idOf', 'refresh', 'transaction', 'flushSignals', 'schema',
|
|
20
25
|
'token', 'tenant', 'reloadSchema', 'accounts', 'credentials', 'resources', 'rules', 'compact',
|
|
21
|
-
'trimHistory', 'rekeyClass', 'cache',
|
|
26
|
+
'trimHistory', 'rekeyClass', 'cache', 'projector', 'rebuildAggregates',
|
|
22
27
|
];
|
|
23
28
|
/** Свойства батча db.batch(name). */
|
|
24
29
|
export const BATCH_METHODS = ['discard', 'size'];
|
package/dist/up.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import postgres from 'postgres';
|
|
2
2
|
import { type PgliteOptions, type PgliteServer } from './pglite.js';
|
|
3
|
+
import { type SignalMode } from './signals.js';
|
|
3
4
|
export declare const SYSTEM_ID = "2eba6d0a-1edd-4bb6-a85a-a703dab49035";
|
|
4
5
|
export declare const SCHEMA_VERSION = "v2";
|
|
5
6
|
export interface InstallOptions {
|
|
@@ -25,6 +26,12 @@ export interface InstallOptions {
|
|
|
25
26
|
migrationsDir?: string;
|
|
26
27
|
/** Свой ключ сервиса: зарегистрировать, если такого ещё нет (вместо выпуска случайного). */
|
|
27
28
|
serviceKey?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Режим сигналов NOTIFY (план 1.1, §2.2): 'class' (по умолчанию, как в 1.0) — имена классов; 'tenant' —
|
|
31
|
+
* имя класса и токен арендатора; 'opaque' — только токены; 'none' — сигналов нет. Режим хранится в
|
|
32
|
+
* схеме: накат без опции его не меняет, reset({ all }) — сохраняет.
|
|
33
|
+
*/
|
|
34
|
+
signals?: SignalMode;
|
|
28
35
|
}
|
|
29
36
|
/** Политика хранения истории (§2.11): сроки ярусов по возрастанию, 'forever' — бессрочно. */
|
|
30
37
|
export interface HistoryPolicy {
|
|
@@ -44,6 +51,8 @@ export interface InstallResult {
|
|
|
44
51
|
serviceKey: string | null;
|
|
45
52
|
/** Применённые миграции. */
|
|
46
53
|
migrations: string[];
|
|
54
|
+
/** Режим сигналов установки после наката. */
|
|
55
|
+
signals: SignalMode;
|
|
47
56
|
}
|
|
48
57
|
export declare const quoteIdent: (name: string) => string;
|
|
49
58
|
/** Файлы ядра по номеру: `10-core.sql` … `99-revision.sql`; сиды демо-домена сюда не входят. */
|
|
@@ -88,6 +97,8 @@ export interface UpResult extends InstallResult {
|
|
|
88
97
|
pglite?: PgliteServer;
|
|
89
98
|
/** up({ tenant }): арендатор (и его первый пользователь с сессией). */
|
|
90
99
|
tenant?: TenantResult;
|
|
100
|
+
/** Предупреждения (версия сервера); без quiet каждое уже напечатано через console.warn. */
|
|
101
|
+
warnings: string[];
|
|
91
102
|
}
|
|
92
103
|
type Ping = {
|
|
93
104
|
ok: true;
|
|
@@ -100,8 +111,11 @@ type Ping = {
|
|
|
100
111
|
export declare function pgPing(dsn: string): Promise<Ping>;
|
|
101
112
|
/** Создать базу строки подключения, если её нет (гонка параллельных up() — 42P04 — не ошибка). */
|
|
102
113
|
export declare function ensureDatabase(dsn: string, log?: (m: string) => void): Promise<boolean>;
|
|
103
|
-
/**
|
|
104
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Версия сервера и кодировка базы: PostgreSQL 18+ и UTF8 (casefold и pg_unicode_fast требуют UTF8) —
|
|
116
|
+
* иначе отказ. Возвращает предупреждения: с чем установка работает, но рискует.
|
|
117
|
+
*/
|
|
118
|
+
export declare function checkServer(versionNum: number, encoding: string): string[];
|
|
105
119
|
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
106
120
|
export declare function schemaRevision(sql: postgres.Sql, schema: string): Promise<number | null>;
|
|
107
121
|
/**
|
package/dist/up.js
CHANGED
|
@@ -20,6 +20,7 @@ import { startPglite } from './pglite.js';
|
|
|
20
20
|
import { hashPassword } from './auth.js';
|
|
21
21
|
import { LetopisError } from './errors.js';
|
|
22
22
|
import { v5Id } from './uuid.js';
|
|
23
|
+
import { SIGNAL_MODES } from './signals.js';
|
|
23
24
|
export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
24
25
|
export const SCHEMA_VERSION = 'v2';
|
|
25
26
|
const SQL_DIR = fileURLToPath(new URL('../sql/', import.meta.url));
|
|
@@ -48,6 +49,9 @@ export async function install(sql, opts) {
|
|
|
48
49
|
for (const r of [owner, app])
|
|
49
50
|
if (!ROLE_NAME.test(r))
|
|
50
51
|
throw new Error(`letopis.up: bad role name "${r}"`);
|
|
52
|
+
if (opts.signals !== undefined && !SIGNAL_MODES.includes(opts.signals)) {
|
|
53
|
+
throw new Error(`letopis.up: signals — ${SIGNAL_MODES.join(', ')}, а не ${String(opts.signals)}`);
|
|
54
|
+
}
|
|
51
55
|
const [{ v }] = await sql `select current_setting('server_version_num') as v`;
|
|
52
56
|
if (Number(v) < 180000)
|
|
53
57
|
throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
|
|
@@ -81,14 +85,27 @@ export async function install(sql, opts) {
|
|
|
81
85
|
HISTORY_DEFAULT: quoteLiteral(JSON.stringify(opts.history ?? null)),
|
|
82
86
|
// методы цепочки — в тексте функции reserved_names() (план, §2.14)
|
|
83
87
|
RESERVED_NAMES: RESERVED_CLASS_NAMES.map(quoteLiteral).join(', '),
|
|
88
|
+
// режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
|
|
89
|
+
SIGNALS: 'class',
|
|
84
90
|
};
|
|
85
91
|
// Схема создаётся до set role: у владельца может не быть права CREATE в базе.
|
|
86
92
|
await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
|
|
87
93
|
const files = await engineFiles(opts.upTo);
|
|
88
94
|
const migrations = opts.migrateFrom === undefined ? [] : await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, opts.migrateFrom);
|
|
89
95
|
let serviceKey = null;
|
|
96
|
+
let signals = 'class';
|
|
90
97
|
await sql.begin(async (tx) => {
|
|
91
98
|
await tx.unsafe(`set local role ${quoteIdent(owner)}`);
|
|
99
|
+
// режим сигналов (план 1.1, §2.6): опция, иначе сохранённый в схеме (meta строки ключа), иначе class
|
|
100
|
+
let saved = null;
|
|
101
|
+
const [reg] = await tx.unsafe(`select to_regclass($1) is not null as ok`, [`${quoteIdent(opts.schema)}.secret`]);
|
|
102
|
+
if (reg?.ok) {
|
|
103
|
+
const [k] = await tx.unsafe(`select meta->>'signals' as m from ${quoteIdent(opts.schema)}.secret where id = $1::uuid and kind = 'KEY'`, [SYSTEM_ID]);
|
|
104
|
+
if (SIGNAL_MODES.includes(k?.m))
|
|
105
|
+
saved = k.m;
|
|
106
|
+
}
|
|
107
|
+
signals = opts.signals ?? saved ?? 'class';
|
|
108
|
+
vars.SIGNALS = signals;
|
|
92
109
|
// структурные изменения прежних ревизий — до повторного наката функций (этап 6, п. 10)
|
|
93
110
|
for (const m of migrations) {
|
|
94
111
|
try {
|
|
@@ -155,7 +172,7 @@ export async function install(sql, opts) {
|
|
|
155
172
|
}
|
|
156
173
|
}
|
|
157
174
|
});
|
|
158
|
-
return { schema: opts.schema, owner, app, serviceKey, migrations };
|
|
175
|
+
return { schema: opts.schema, owner, app, serviceKey, migrations, signals };
|
|
159
176
|
}
|
|
160
177
|
/** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
|
|
161
178
|
export async function migrationFiles(dir, from) {
|
|
@@ -213,12 +230,22 @@ export async function ensureDatabase(dsn, log = () => { }) {
|
|
|
213
230
|
await sql.end({ timeout: 1 });
|
|
214
231
|
}
|
|
215
232
|
}
|
|
216
|
-
/**
|
|
233
|
+
/**
|
|
234
|
+
* Версия сервера и кодировка базы: PostgreSQL 18+ и UTF8 (casefold и pg_unicode_fast требуют UTF8) —
|
|
235
|
+
* иначе отказ. Возвращает предупреждения: с чем установка работает, но рискует.
|
|
236
|
+
*/
|
|
217
237
|
export function checkServer(versionNum, encoding) {
|
|
218
238
|
if (versionNum < 180000)
|
|
219
239
|
throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${Math.floor(versionNum / 10000)} (${versionNum})`);
|
|
220
240
|
if (encoding.toUpperCase() !== 'UTF8')
|
|
221
241
|
throw new Error(`letopis.up: база в кодировке ${encoding}, нужна UTF8 (casefold и коллация pg_unicode_fast)`);
|
|
242
|
+
const warnings = [];
|
|
243
|
+
// план 1.1, этап 2: исправлено в 18.2
|
|
244
|
+
if (versionNum < 180002) {
|
|
245
|
+
warnings.push(`PostgreSQL ${Math.floor(versionNum / 10000)}.${versionNum % 10000}: до 18.2 сервер мог усечь CLOG, который ещё нужен `
|
|
246
|
+
+ 'непрочитанным сигналам NOTIFY; обновите сервер до 18.2 или новее');
|
|
247
|
+
}
|
|
248
|
+
return warnings;
|
|
222
249
|
}
|
|
223
250
|
/** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
|
|
224
251
|
export async function schemaRevision(sql, schema) {
|
|
@@ -267,7 +294,10 @@ export async function up(opts) {
|
|
|
267
294
|
try {
|
|
268
295
|
const [srv] = await sql `select current_setting('server_version_num')::int as v, pg_encoding_to_char(encoding) as enc
|
|
269
296
|
from pg_database where datname = current_database()`;
|
|
270
|
-
checkServer(srv.v, srv.enc);
|
|
297
|
+
const warnings = checkServer(srv.v, srv.enc);
|
|
298
|
+
if (!opts.quiet)
|
|
299
|
+
for (const w of warnings)
|
|
300
|
+
console.warn(`letopis.up: ${w}`);
|
|
271
301
|
if (opts.fresh) {
|
|
272
302
|
await sql.unsafe(`drop schema if exists ${quoteIdent(schema)} cascade`);
|
|
273
303
|
log(`схема ${schema} удалена (fresh)`);
|
|
@@ -290,7 +320,7 @@ export async function up(opts) {
|
|
|
290
320
|
const revision = (await schemaRevision(sql, schema)) ?? 0;
|
|
291
321
|
log(`${before === null ? 'схема поставлена' : before < DDL_REVISION ? `схема обновлена с ревизии ${before}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
|
|
292
322
|
const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
|
|
293
|
-
return { ...res, created: before === null, upgraded: before !== null && before < DDL_REVISION, revision, analyzed,
|
|
323
|
+
return { ...res, created: before === null, upgraded: before !== null && before < DDL_REVISION, revision, analyzed, warnings,
|
|
294
324
|
...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
|
|
295
325
|
}
|
|
296
326
|
catch (e) {
|
package/dist/uuid.js
CHANGED
|
@@ -5,18 +5,29 @@
|
|
|
5
5
|
* (`v5_name` в lib/sql/30-validate.sql — текстовый вид jsonb-массива): элементы через JSON.stringify,
|
|
6
6
|
* разделитель — запятая с пробелом. Совпадение закрепляет общий файл векторов lib/test/vectors/v5.json.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { hash } from 'node:crypto';
|
|
9
9
|
import { LetopisError } from './errors.js';
|
|
10
10
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
// байты пространств имён (арендаторов): в загрузке их единицы, а проверка и разбор шли на каждый id
|
|
12
|
+
const NS = new Map();
|
|
13
|
+
function nsBytes(ns) {
|
|
14
|
+
let b = NS.get(ns);
|
|
15
|
+
if (!b) {
|
|
16
|
+
if (!UUID.test(ns))
|
|
17
|
+
throw new LetopisError('invalid_data', `пространство имён v5 — не uuid: ${ns}`);
|
|
18
|
+
b = Buffer.from(ns.replace(/-/g, ''), 'hex');
|
|
19
|
+
if (NS.size >= 10_000)
|
|
20
|
+
NS.clear();
|
|
21
|
+
NS.set(ns, b);
|
|
22
|
+
}
|
|
23
|
+
return b;
|
|
24
|
+
}
|
|
11
25
|
/** RFC 4122 v5: sha1(пространство + имя); одно имя в одном пространстве — один id. */
|
|
12
26
|
export function uuidv5(name, ns) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
h[8] = (h[8] & 0x3f) | 0x80; // вариант 10xx
|
|
18
|
-
const x = h.toString('hex');
|
|
19
|
-
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
|
|
27
|
+
const x = hash('sha1', Buffer.concat([nsBytes(ns), Buffer.from(name, 'utf8')]), 'hex');
|
|
28
|
+
// версия 5 — старшая тетрада байта 6; вариант 10xx — старшие биты байта 8
|
|
29
|
+
const variant = ((parseInt(x[16], 16) & 0x3) | 0x8).toString(16);
|
|
30
|
+
return `${x.slice(0, 8)}-${x.slice(8, 12)}-5${x.slice(13, 16)}-${variant}${x.slice(17, 20)}-${x.slice(20, 32)}`;
|
|
20
31
|
}
|
|
21
32
|
/** Имя v5: JSON-массив из имени класса и значений ключа — как `jsonb_build_array(…)::text` в базе. */
|
|
22
33
|
export function v5Name(cls, values) {
|
package/dist/watch.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Executor } from './tx.js';
|
|
2
2
|
import type { Row } from './types.js';
|
|
3
|
+
import type { Signal } from './signals.js';
|
|
3
4
|
export interface WatchOptions {
|
|
4
5
|
/** Откуда: курсор события, 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
|
|
5
6
|
from?: string;
|
|
@@ -26,9 +27,17 @@ export interface WatchCtx {
|
|
|
26
27
|
exec: Executor;
|
|
27
28
|
/** Классы подписки с потомками (null — все). */
|
|
28
29
|
classes: string[] | null;
|
|
29
|
-
/** Подписка на сигналы
|
|
30
|
-
onSignal?: (fn: (
|
|
30
|
+
/** Подписка на сигналы базы (разобранные); возвращает отписку. */
|
|
31
|
+
onSignal?: (fn: (s: Signal) => void) => () => void;
|
|
32
|
+
/** Токен арендатора подписки (null — процесс без ключа или режим без токенов). */
|
|
33
|
+
tenant?: () => string | null;
|
|
31
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
|
|
37
|
+
* любой); сессии не будят; *, opaque без ключа и нагрузка не по режиму — будят. Незнакомый токен класса
|
|
38
|
+
* будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
|
|
39
|
+
*/
|
|
40
|
+
export declare function wakes(s: Signal, watched: ReadonlySet<string> | null, mine: string | null): boolean;
|
|
32
41
|
export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
33
42
|
private ctx;
|
|
34
43
|
private opts;
|
|
@@ -44,13 +53,25 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
44
53
|
private readonly pollMs;
|
|
45
54
|
/** 'now' — голова журнала в момент подписки (запрос уходит сразу). */
|
|
46
55
|
private start;
|
|
56
|
+
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
57
|
+
private queue;
|
|
47
58
|
constructor(ctx: WatchCtx, opts: WatchOptions);
|
|
48
59
|
/** Курсор последнего выданного события (или начальной точки). */
|
|
49
60
|
get cursor(): string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
|
|
63
|
+
* у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
|
|
64
|
+
*/
|
|
65
|
+
ready(): Promise<void>;
|
|
50
66
|
private head;
|
|
51
67
|
/** Следующая пачка событий ниже горизонта; пусто — горизонт ещё не дошёл или событий нет. */
|
|
52
68
|
private fetch;
|
|
69
|
+
/**
|
|
70
|
+
* Следующее событие. Вызовы идут по очереди: параллельные next() получают разные события, без двойных. Вызов,
|
|
71
|
+
* брошенный по таймауту (Promise.race), всё равно получит своё событие — держите его обещание.
|
|
72
|
+
*/
|
|
53
73
|
next(): Promise<IteratorResult<WatchEvent>>;
|
|
74
|
+
private take;
|
|
54
75
|
/**
|
|
55
76
|
* Отставание, мс: сколько ждёт самое старое событие, которое уже зафиксировано, но ещё не ушло за
|
|
56
77
|
* горизонт (его держит более старая открытая транзакция); 0 — ждать нечего.
|
package/dist/watch.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* транзакций (pg_snapshot_xmin) — транзакцию с меньшим номером, зафиксированную позже, курсор уже
|
|
9
9
|
* не обгонит, поэтому событий нет ни потерянных, ни повторных. Поток стоит, пока открыта более
|
|
10
10
|
* старая пишущая транзакция (горизонт общий для кластера); метрика — lag(). Чтение идёт под RLS
|
|
11
|
-
* журнала: каждый видит только своё. Сигнал базы
|
|
12
|
-
*
|
|
11
|
+
* журнала: каждый видит только своё. Сигнал базы только будит подписку: свои классы и свой арендатор
|
|
12
|
+
* (чужие — не будят; процесс без ключа сигналов арендатора не различает, в режиме opaque — и класса);
|
|
13
|
+
* без LISTEN и на случай потери сигнала — запасной опрос.
|
|
13
14
|
*
|
|
14
15
|
* Курсор подписчика старше яруса all политики хранения его классов — cursor_expired: события могли
|
|
15
16
|
* проредиться, нужно перечитать текущее состояние и подписаться заново.
|
|
@@ -28,6 +29,25 @@ function decode(c) {
|
|
|
28
29
|
}
|
|
29
30
|
/** Снимок ещё нужен: курсор не дошёл до его xmax. */
|
|
30
31
|
const keepSnap = (snap, tx) => snap && BigInt(tx) < BigInt(snap.split(':')[1]) ? snap : undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
|
|
34
|
+
* любой); сессии не будят; *, opaque без ключа и нагрузка не по режиму — будят. Незнакомый токен класса
|
|
35
|
+
* будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
|
|
36
|
+
*/
|
|
37
|
+
export function wakes(s, watched, mine) {
|
|
38
|
+
const ours = (t) => t === null || mine === null || t === mine;
|
|
39
|
+
switch (s.kind) {
|
|
40
|
+
case 'session':
|
|
41
|
+
case 'sessions':
|
|
42
|
+
return false;
|
|
43
|
+
case 'class':
|
|
44
|
+
return (!watched || watched.has(s.cls)) && ours(s.tenant);
|
|
45
|
+
case 'unknown':
|
|
46
|
+
return !watched && ours(s.tenant);
|
|
47
|
+
default:
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
31
51
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
52
|
export class Subscription {
|
|
33
53
|
ctx;
|
|
@@ -44,6 +64,8 @@ export class Subscription {
|
|
|
44
64
|
pollMs;
|
|
45
65
|
/** 'now' — голова журнала в момент подписки (запрос уходит сразу). */
|
|
46
66
|
start = null;
|
|
67
|
+
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
68
|
+
queue = Promise.resolve();
|
|
47
69
|
constructor(ctx, opts) {
|
|
48
70
|
this.ctx = ctx;
|
|
49
71
|
this.opts = opts;
|
|
@@ -60,10 +82,8 @@ export class Subscription {
|
|
|
60
82
|
}
|
|
61
83
|
if (ctx.onSignal) {
|
|
62
84
|
const watched = ctx.classes ? new Set(ctx.classes) : null;
|
|
63
|
-
this.off = ctx.onSignal((
|
|
64
|
-
if (
|
|
65
|
-
return;
|
|
66
|
-
if (watched && name !== '*' && !watched.has(name))
|
|
85
|
+
this.off = ctx.onSignal((s) => {
|
|
86
|
+
if (!wakes(s, watched, ctx.tenant?.() ?? null))
|
|
67
87
|
return;
|
|
68
88
|
this.dirty = true;
|
|
69
89
|
this.wake?.();
|
|
@@ -79,6 +99,14 @@ export class Subscription {
|
|
|
79
99
|
get cursor() {
|
|
80
100
|
return this.pos ? encode(this.pos) : null;
|
|
81
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
|
|
104
|
+
* у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
|
|
105
|
+
*/
|
|
106
|
+
async ready() {
|
|
107
|
+
if (!this.pos && this.start)
|
|
108
|
+
this.pos = await this.start;
|
|
109
|
+
}
|
|
82
110
|
async head() {
|
|
83
111
|
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
112
|
return { tx: r.tx, seq: r.seq, at: null, snap: r.snap };
|
|
@@ -100,7 +128,16 @@ export class Subscription {
|
|
|
100
128
|
}
|
|
101
129
|
return rows.length;
|
|
102
130
|
}
|
|
103
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Следующее событие. Вызовы идут по очереди: параллельные next() получают разные события, без двойных. Вызов,
|
|
133
|
+
* брошенный по таймауту (Promise.race), всё равно получит своё событие — держите его обещание.
|
|
134
|
+
*/
|
|
135
|
+
next() {
|
|
136
|
+
const p = this.queue.then(() => this.take(), () => this.take());
|
|
137
|
+
this.queue = p.catch(() => undefined);
|
|
138
|
+
return p;
|
|
139
|
+
}
|
|
140
|
+
async take() {
|
|
104
141
|
let empty = 0;
|
|
105
142
|
for (;;) {
|
|
106
143
|
if (this.failure)
|
package/dist/write.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ClassInfo, type Registry } from './registry.js';
|
|
2
2
|
import { type AggSpec, type BuiltQuery, type ReadMode, type Step } from './sql.js';
|
|
3
3
|
import type { Tx } from './tx.js';
|
|
4
4
|
import type { ChainMods, Row } from './types.js';
|
|
@@ -17,7 +17,8 @@ export declare function inc(by: number, opts?: {
|
|
|
17
17
|
start?: number;
|
|
18
18
|
}): IncMarker;
|
|
19
19
|
export declare const isInc: (v: unknown) => v is IncMarker;
|
|
20
|
-
|
|
20
|
+
/** Глагол плана; reason — причина изменения этого глагола (план 1.1, §2.1). */
|
|
21
|
+
export type PlanOp = ({
|
|
21
22
|
kind: 'create';
|
|
22
23
|
data: Record<string, unknown>;
|
|
23
24
|
} | {
|
|
@@ -45,7 +46,11 @@ export type PlanOp = {
|
|
|
45
46
|
} | {
|
|
46
47
|
kind: 'rekey';
|
|
47
48
|
data: Record<string, unknown>;
|
|
49
|
+
}) & {
|
|
50
|
+
reason?: string;
|
|
48
51
|
};
|
|
52
|
+
/** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
|
|
53
|
+
export declare function checkReason(v: unknown, where: string): string;
|
|
49
54
|
/** Значение слота: id, список id, строка ответа или ленивая цепочка (в той же транзакции). */
|
|
50
55
|
export type SlotValue = string | string[] | {
|
|
51
56
|
id: string;
|
|
@@ -81,6 +86,10 @@ export interface WriteCtx {
|
|
|
81
86
|
tenant: string | null;
|
|
82
87
|
/** Владелец новых строк по умолчанию (db.as(…, { owner })). */
|
|
83
88
|
owner?: string;
|
|
89
|
+
/** Причина по умолчанию для глаголов плана и та, что стоит в транзакции; cur — выставленная сейчас. */
|
|
90
|
+
reason?: string;
|
|
91
|
+
txReason?: string;
|
|
92
|
+
cur?: string;
|
|
84
93
|
}
|
|
85
94
|
/** Чтение в транзакции плана — тот же построитель, что у цепочек. */
|
|
86
95
|
export declare function readIn<T>(w: WriteCtx, steps: Step[], mods: ChainMods, mode: ReadMode, agg?: AggSpec): Promise<{
|
package/dist/write.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { LetopisError, ValidationError } from './errors.js';
|
|
21
21
|
import { isOp } from './ops.js';
|
|
22
|
+
import { aggregateFields } from './registry.js';
|
|
22
23
|
import { accepts, buildRead, nextNodeKey, resolveHop, stepClasses, toRow } from './sql.js';
|
|
23
24
|
import { idOf } from './uuid.js';
|
|
24
25
|
import { applyDefaults } from './validate.js';
|
|
@@ -35,6 +36,13 @@ export function inc(by, opts = {}) {
|
|
|
35
36
|
return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
|
|
36
37
|
}
|
|
37
38
|
export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
|
|
39
|
+
/** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
|
|
40
|
+
export function checkReason(v, where) {
|
|
41
|
+
if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
|
|
42
|
+
throw new LetopisError('invalid_query', `${where}: reason — строка до 1000 символов`);
|
|
43
|
+
}
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
38
46
|
const fail = (code, msg, detail) => {
|
|
39
47
|
throw new LetopisError(code, msg, detail);
|
|
40
48
|
};
|
|
@@ -108,13 +116,26 @@ function checkFullData(cls, data, verb, full = true) {
|
|
|
108
116
|
scan(x, [...path, k]);
|
|
109
117
|
};
|
|
110
118
|
scan(data, []);
|
|
119
|
+
// агрегатные поля считает база (план 1.1, §2.4): переданное значение — aggregate_field до отправки, а для ранней
|
|
120
|
+
// проверки поле — 0, как его поставит база
|
|
121
|
+
const obj = data;
|
|
122
|
+
const agg = aggregateFields(cls);
|
|
123
|
+
const given = agg.filter((f) => f in obj);
|
|
124
|
+
if (given.length)
|
|
125
|
+
fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — их не передают`);
|
|
111
126
|
// описания системных классов проверяет только база (метасхема Class, class_admit)
|
|
112
127
|
if (!full || cls.name === 'Class' || cls.abstract)
|
|
113
128
|
return;
|
|
114
|
-
const issues = cls.validator()(applyDefaults(cls.schema,
|
|
129
|
+
const issues = cls.validator()(applyDefaults(cls.schema, agg.length ? { ...obj, ...Object.fromEntries(agg.map((f) => [f, 0])) } : obj));
|
|
115
130
|
if (issues.length)
|
|
116
131
|
throw new ValidationError(cls.name, issues);
|
|
117
132
|
}
|
|
133
|
+
/** Патч update не трогает агрегатные поля класса шага (план 1.1, §2.4): их считает база. */
|
|
134
|
+
function checkAggregatePatch(cls, patch) {
|
|
135
|
+
const given = aggregateFields(cls).filter((f) => f in patch);
|
|
136
|
+
if (given.length)
|
|
137
|
+
fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — update их не меняет`);
|
|
138
|
+
}
|
|
118
139
|
/** Всё, что проверяется без базы, — до отправки первого оператора (§2.5). */
|
|
119
140
|
export function precheckPlan(reg, steps) {
|
|
120
141
|
let prevOp;
|
|
@@ -126,6 +147,8 @@ export function precheckPlan(reg, steps) {
|
|
|
126
147
|
continue;
|
|
127
148
|
}
|
|
128
149
|
const verb = op.kind;
|
|
150
|
+
if (op.reason !== undefined)
|
|
151
|
+
checkReason(op.reason, `${verb}()`);
|
|
129
152
|
const m = s.opMods ?? {};
|
|
130
153
|
if (m.asOf !== undefined)
|
|
131
154
|
fail('invalid_query', `${verb}(): asOf описывает прошлое — записывать в него нельзя`);
|
|
@@ -153,6 +176,7 @@ export function precheckPlan(reg, steps) {
|
|
|
153
176
|
if (!isPlain(op.data))
|
|
154
177
|
fail('invalid_data', 'update(): патч — объект');
|
|
155
178
|
splitPatch(op.data);
|
|
179
|
+
checkAggregatePatch(s.cls, op.data);
|
|
156
180
|
if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
|
|
157
181
|
fail('invalid_query', 'update(patch, { rev }): rev — номер версии строки');
|
|
158
182
|
break;
|
|
@@ -188,6 +212,14 @@ export function precheckPlan(reg, steps) {
|
|
|
188
212
|
prevOp = op;
|
|
189
213
|
}
|
|
190
214
|
}
|
|
215
|
+
/** letopis.reason перед оператором глагола — только если причина меняется (план 1.1, §2.1). */
|
|
216
|
+
async function useReason(w, want) {
|
|
217
|
+
if (want === (w.cur ?? w.txReason ?? ''))
|
|
218
|
+
return;
|
|
219
|
+
await query(w, `select set_config('letopis.reason', $1, true)`, [want]);
|
|
220
|
+
w.cur = want;
|
|
221
|
+
}
|
|
222
|
+
const reasonFor = (w, s) => s.op?.reason ?? w.reason ?? '';
|
|
191
223
|
const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
192
224
|
/** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
|
|
193
225
|
const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
|
|
@@ -371,13 +403,16 @@ async function createOp(w, steps, opStep, upsert) {
|
|
|
371
403
|
const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
|
|
372
404
|
const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
|
|
373
405
|
const owner = opStep.ownerFilter ?? w.owner ?? null;
|
|
406
|
+
// агрегатные поля (план 1.1, §2.4): в excluded их поставил триггер вставки (0) — замена данных идёт без них, и
|
|
407
|
+
// триггер правки возвращает прежние значения
|
|
408
|
+
const agg = aggregateFields(cls);
|
|
374
409
|
const conflict = upsert
|
|
375
|
-
? ` on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags`
|
|
410
|
+
? ` on conflict (id) do update set data = excluded.data${agg.length ? ' - $7::text[]' : ''}, links = excluded.links, tags = excluded.tags`
|
|
376
411
|
: '';
|
|
377
412
|
const text = `insert into ${S}.entity (id, class, links, data, tags, owner) values ($1::uuid, $2::text, $3::jsonb, $4::jsonb, $5::text[], $6::uuid)`
|
|
378
413
|
+ `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
|
|
379
414
|
try {
|
|
380
|
-
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
|
|
415
|
+
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner, ...(upsert && agg.length ? [agg] : [])]);
|
|
381
416
|
return res.map((r) => {
|
|
382
417
|
const row = toRow(r.row);
|
|
383
418
|
if (upsert)
|
|
@@ -578,7 +613,8 @@ async function rekeyOp(w, steps, opStep) {
|
|
|
578
613
|
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [moved]);
|
|
579
614
|
return res.map((r) => toRow(r.row));
|
|
580
615
|
}
|
|
581
|
-
function opCall(w, steps, opStep) {
|
|
616
|
+
async function opCall(w, steps, opStep) {
|
|
617
|
+
await useReason(w, reasonFor(w, opStep));
|
|
582
618
|
switch (opStep.op.kind) {
|
|
583
619
|
case 'create': return createOp(w, steps, opStep, false);
|
|
584
620
|
case 'upsert': return createOp(w, steps, opStep, true);
|
|
@@ -652,9 +688,11 @@ export async function execPlan(w, steps, mods, mode, agg) {
|
|
|
652
688
|
: kind === 'purge' ? [] : last;
|
|
653
689
|
prevOp = opStep;
|
|
654
690
|
}
|
|
691
|
+
// причина глагола не переходит на следующие операторы транзакции
|
|
692
|
+
await useReason(w, w.reason ?? '');
|
|
655
693
|
const opStep = segments[segments.length - 1].steps.at(-1);
|
|
656
694
|
const key = opStep.aliasKey ?? opStep.name;
|
|
657
|
-
if (tail.length || mode === 'versions' || mode === 'agg') {
|
|
695
|
+
if (tail.length || mode === 'versions' || mode === 'agg' || mode === 'diff' || mode === 'blame') {
|
|
658
696
|
if (!start.length)
|
|
659
697
|
return { kind: 'read', q: null, res: [] };
|
|
660
698
|
const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
|
|
@@ -708,7 +746,10 @@ export async function execBatch(w, plans) {
|
|
|
708
746
|
const group = [];
|
|
709
747
|
const seen = new Set();
|
|
710
748
|
let j = i;
|
|
711
|
-
|
|
749
|
+
// одна причина на оператор: другая причина режет группу
|
|
750
|
+
const why = reasonFor(w, head);
|
|
751
|
+
while (j < plans.length && pureKind(plans[j]) === 'keyed' && plans[j][0].op.kind === verb && plans[j][0].cls.name === head.cls.name
|
|
752
|
+
&& reasonFor(w, plans[j][0]) === why) {
|
|
712
753
|
const s = plans[j][0];
|
|
713
754
|
const id = idFor(s);
|
|
714
755
|
if (!id || (verb === 'upsert' && seen.has(id)))
|
|
@@ -717,12 +758,15 @@ export async function execBatch(w, plans) {
|
|
|
717
758
|
group.push({ at: j, s, id });
|
|
718
759
|
j++;
|
|
719
760
|
}
|
|
761
|
+
await useReason(w, why);
|
|
720
762
|
const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
|
|
763
|
+
// агрегатные поля — без них, как у одиночного upsert (план 1.1, §2.4)
|
|
764
|
+
const agg = aggregateFields(head.cls);
|
|
721
765
|
const conflict = verb === 'upsert'
|
|
722
|
-
?
|
|
766
|
+
? `on conflict (id) do update set data = excluded.data${agg.length ? ' - $3::text[]' : ''}, links = excluded.links, tags = excluded.tags`
|
|
723
767
|
: 'on conflict (id) do nothing';
|
|
724
768
|
const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
|
|
725
|
-
+ `from jsonb_to_recordset($2::jsonb) as x(d jsonb, t text[], o uuid) ${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`, [head.cls.name, input]);
|
|
769
|
+
+ `from jsonb_to_recordset($2::jsonb) as x(d jsonb, t text[], o uuid) ${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`, [head.cls.name, input, ...(verb === 'upsert' && agg.length ? [agg] : [])]);
|
|
726
770
|
const byId = new Map(res.map((r) => [r.row.id, r]));
|
|
727
771
|
for (const g of group) {
|
|
728
772
|
const r = byId.get(g.id);
|
|
@@ -738,9 +782,11 @@ export async function execBatch(w, plans) {
|
|
|
738
782
|
}
|
|
739
783
|
else if (kind === 'keyless') {
|
|
740
784
|
let j = i;
|
|
741
|
-
|
|
785
|
+
const why = reasonFor(w, head);
|
|
786
|
+
while (j < plans.length && pureKind(plans[j]) === 'keyless' && reasonFor(w, plans[j][0]) === why)
|
|
742
787
|
j++;
|
|
743
|
-
// однострочные вставки без ожидания ответа каждой — postgres.js шлёт их конвейером
|
|
788
|
+
// причина — до конвейера; однострочные вставки без ожидания ответа каждой — postgres.js шлёт их конвейером
|
|
789
|
+
await useReason(w, why);
|
|
744
790
|
const rows = await Promise.all(plans.slice(i, j).map((p) => createOp(w, p, p[0], false)));
|
|
745
791
|
rows.forEach((r, k) => (out[i + k] = r));
|
|
746
792
|
i = j;
|
|
@@ -751,5 +797,6 @@ export async function execBatch(w, plans) {
|
|
|
751
797
|
i++;
|
|
752
798
|
}
|
|
753
799
|
}
|
|
800
|
+
await useReason(w, w.txReason ?? '');
|
|
754
801
|
return out;
|
|
755
802
|
}
|
package/llms.txt
CHANGED
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
> Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
|
|
4
4
|
|
|
5
|
-
Key facts for writing code with letopis 1.
|
|
5
|
+
Key facts for writing code with letopis 1.2:
|
|
6
6
|
|
|
7
|
-
- ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`.
|
|
7
|
+
- ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
|
|
8
8
|
- Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
|
|
9
9
|
- Classes are hubs (objects) and links (relations with ends), described with `hub()`/`link()` from `letopis/model` and written to the database with `syncModels()` or `letopis sync`. A class with a key gets a deterministic UUID v5 id (`db.idOf`); uniqueness is the key.
|
|
10
10
|
- Verbs return a chain; a terminal (`rows`, `first`, `ids`, `count`, …) executes the whole plan in one transaction. `create` only creates (a taken key is `exists`), `update` merges and never creates, `upsert` replaces the whole object (keyed classes only), `delete()` is a preview and `delete({ confirm: true })` deletes.
|
|
11
11
|
- Money and counters: `update({ balance: inc(n) })` adds inside the statement; `update(patch, { rev })` fails with `conflict` if the row changed; `forUpdate()` locks rows inside `db.begin()`.
|
|
12
|
+
- History: `versions()`, `diff({ from, to })` and `blame()` (who, when and why last changed each field); verbs, `db.begin()` and `db.batch()` take `{ reason }`.
|
|
13
|
+
- Bulk loading from an admin connection: `db.load(rows)`; into an empty schema `db.load(rows, { initial: true })` — one transaction, indexes and triggers rebuilt at the end.
|
|
14
|
+
- Projectors from an admin connection: `db.projector(name, { from, into, version, handle })` builds rows of derived classes (`derivedBy: name`) from the journal; each batch is one transaction and every change is applied exactly once; `run()`, `rebuild()`, `status()`. Only the projector writes derived rows: the application, raw SQL and the loader get `derived_class`. Only an administrator creates, changes and deletes derived classes (an admin connection with a tenant session, or `letopis sync` with the admin `--dsn`); for an application session it is `acl_denied`. `purge()` of a deleted source object waits until the projector has read the deletion (`projector_pending`).
|
|
15
|
+
- Aggregates in the database: the class option `aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }` (or `count: true`, `where` as a JSON Schema) keeps the field equal to the total of live source rows of the same tenant; a trigger after each statement recalculates it for the library, raw SQL, cascades and the loader; only the database writes the field (`aggregate_field`); repair with `db.rebuildAggregates(class)` on an admin connection, check with `db.verify({ data: true })`.
|
|
16
|
+
- Subscriptions: `db.watch({ from, classes })` — `await sub.ready()` before a write that must reach the stream (`from: 'now'` fixes its starting point with the first query); parallel `next()` calls get different events.
|
|
17
|
+
- NOTIFY signals are visible to any database role: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` chooses what they reveal (default `class`).
|
|
12
18
|
- Access rules are always on and deny by default; `up({ tenant })` or `createTenant()` creates a tenant with the rule "authenticated users may do everything" and its first user.
|
|
19
|
+
- MCP: `createMcpServer(db)` from `letopis/mcp` and `letopis mcp` (stdio, a user session token only) give an AI agent the tools `letopis_model`, `letopis_query` (a JSON chain, no SQL), `letopis_history` and, with `write: true`, `letopis_write`; the agent acts with the rights of one user, and a deletion needs a human confirmation through elicitation; by default `letopis_write` does not write the permission classes (`allowAclWrite`), and a session in the System tenant is refused (`allowSystemAdmin`). `structuredContent` of a tool result is `{ notice, data }` (an error — `{ notice, error }`): `notice` warns that `data` holds untrusted stored data, not instructions.
|
|
13
20
|
- Errors carry a lowercase `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, `removed` (0.21 API with its replacement), …
|
|
14
21
|
|
|
15
22
|
## Docs
|