letopis 1.4.1 → 1.4.2
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 +5 -2
- package/AGENT-CHEATSHEET.md +5 -2
- package/CHANGELOG.md +140 -1
- package/README.en.md +66 -15
- package/README.md +62 -12
- package/dist/admin.js +11 -2
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +2 -1
- package/dist/chain.js +9 -3
- package/dist/cli.js +10 -2
- package/dist/errors.js +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.js +57 -9
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.js +20 -7
- package/dist/mcp.js +4 -2
- package/dist/model.js +7 -1
- package/dist/project.d.ts +9 -0
- package/dist/project.js +45 -11
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +6 -5
- package/dist/sql.js +100 -23
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +12 -3
- package/dist/types.d.ts +8 -1
- package/dist/up.js +5 -0
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +24 -7
- package/dist/write.js +23 -9
- package/package.json +8 -4
- package/sql/60-write.sql +9 -0
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +2 -1
- package/sql/88-derived.sql +2 -1
- package/sql/90-time.sql +106 -28
package/dist/admin.js
CHANGED
|
@@ -29,8 +29,17 @@ export async function maintain(a, opts = {}) {
|
|
|
29
29
|
// запуск по расписанию остановлен (close()): следующая пачка не начинается, отчёт — с more: true
|
|
30
30
|
if (i > 0 && opts[MAINTAIN_STOP]?.())
|
|
31
31
|
break;
|
|
32
|
-
// блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null
|
|
33
|
-
|
|
32
|
+
// блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null.
|
|
33
|
+
// Истёкшие сессии и коды — только в первой пачке (план 2.0, 7.3 №4); схема, функции которой ещё не обновил up()
|
|
34
|
+
// (нет третьего аргумента, 42883), — прежний вызов: он чистит в каждой пачке
|
|
35
|
+
const call = (cleanup) => runTx(a.txc, (q) => q.unsafe(cleanup === null
|
|
36
|
+
? `select ${a.S}.maintain($1::text::timestamptz, $2::int) as r`
|
|
37
|
+
: `select ${a.S}.maintain($1::text::timestamptz, $2::int, $3::boolean) as r`, (cleanup === null ? [now, opts.limit ?? 10_000] : [now, opts.limit ?? 10_000, cleanup])));
|
|
38
|
+
const [{ r }] = await call(i === 0).catch((e) => {
|
|
39
|
+
if (e.code === '42883')
|
|
40
|
+
return call(null);
|
|
41
|
+
throw e;
|
|
42
|
+
});
|
|
34
43
|
if (r === null) {
|
|
35
44
|
if (i === 0)
|
|
36
45
|
return { ...report, skipped: true };
|
package/dist/cache.js
CHANGED
|
@@ -85,7 +85,8 @@ export class ResultCache {
|
|
|
85
85
|
return undefined;
|
|
86
86
|
}
|
|
87
87
|
this.st.hits++;
|
|
88
|
-
|
|
88
|
+
// копия: правка выданного ответа не портит запись кэша (план 2.0, 7.2 №5)
|
|
89
|
+
return structuredClone(e.value);
|
|
89
90
|
}
|
|
90
91
|
/** Положить ответ, если с момента снимка не было сигналов о его классах и сессии. */
|
|
91
92
|
set(key, value, classes, session, tok, ttlMs, sessionExpires) {
|
|
@@ -100,7 +101,7 @@ export class ResultCache {
|
|
|
100
101
|
}
|
|
101
102
|
if (this.map.size >= this.max)
|
|
102
103
|
this.map.delete(this.map.keys().next().value);
|
|
103
|
-
this.map.set(key, { value, classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
|
|
104
|
+
this.map.set(key, { value: structuredClone(value), classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
|
|
104
105
|
this.st.stores++;
|
|
105
106
|
return true;
|
|
106
107
|
}
|
package/dist/chain.d.ts
CHANGED
|
@@ -122,7 +122,8 @@ export interface ChainCore {
|
|
|
122
122
|
/**
|
|
123
123
|
* Постраничный вывод по курсору: ответ начинается строго после строки, на которой кончилась прошлая страница, в
|
|
124
124
|
* порядке `sort()` (без `sort()` — ошибка `invalid_query`). Курсор этой строки даёт `cursorOf(строка, поле)` с тем же
|
|
125
|
-
* полем, что в `sort()`.
|
|
125
|
+
* полем, что в `sort()`. Листает `rows()`, `ids()`, `versions()` и `paths()` (курсор пути — `cursorOf(путь, поле)`);
|
|
126
|
+
* `count()`, агрегаты, `diff()` и `blame()` курсор не учитывают.
|
|
126
127
|
*/
|
|
127
128
|
after(cursor: Cursor): Chain;
|
|
128
129
|
/**
|
package/dist/chain.js
CHANGED
|
@@ -244,8 +244,11 @@ const rowOf = (r) => (r.done ? r.row : toRow(r.row));
|
|
|
244
244
|
/** Значение слота: id, список id, строка ответа или ленивая цепочка. */
|
|
245
245
|
function slotValue(v) {
|
|
246
246
|
const peek = peekPlan(v);
|
|
247
|
-
if (peek)
|
|
247
|
+
if (peek) {
|
|
248
|
+
// модификатор сразу после глагола вложенной цепочки — тот же отказ, что у терминала (план 2.0, 7.2 №3)
|
|
249
|
+
checkAfterVerb(peek.steps, peek.mods);
|
|
248
250
|
return { plan: peek.steps, mods: peek.mods };
|
|
251
|
+
}
|
|
249
252
|
if (v === null || typeof v === 'string')
|
|
250
253
|
return v;
|
|
251
254
|
if (Array.isArray(v))
|
|
@@ -398,9 +401,12 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
|
398
401
|
case 'paths':
|
|
399
402
|
return async () => {
|
|
400
403
|
checkAfterVerb(steps, mods);
|
|
401
|
-
// q === null — пути из результата глагола: строки уже
|
|
404
|
+
// q === null — пути из результата глагола: строки уже готовы. Ключи — в порядке шагов (jsonb хранит их
|
|
405
|
+
// по длине), узел последнего шага — последним: по нему cursorOf(путь, поле) берёт курсор (план 2.0, 7.1 №3)
|
|
402
406
|
const { q, res } = await read(ctx, steps, mods, 'paths');
|
|
403
|
-
|
|
407
|
+
if (q === null)
|
|
408
|
+
return res.map((r) => r.path);
|
|
409
|
+
return res.map((r) => Object.fromEntries(q.keys.map((k) => [k, toRow(r.path[k])])));
|
|
404
410
|
};
|
|
405
411
|
case 'rows':
|
|
406
412
|
return async () => {
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { resolve } from 'node:path';
|
|
|
24
24
|
import { connect, describeText } from './index.js';
|
|
25
25
|
import { syncModels, generateTypes } from './sync.js';
|
|
26
26
|
import { import021 } from './import.js';
|
|
27
|
+
import { LetopisError } from './errors.js';
|
|
27
28
|
const USAGE = `letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
|
|
28
29
|
letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
|
|
29
30
|
letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
|
|
@@ -35,6 +36,8 @@ letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <ст
|
|
|
35
36
|
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
|
|
36
37
|
letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
|
|
37
38
|
(или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)`;
|
|
39
|
+
/** Команды CLI: остальное — USAGE с кодом 2 ещё до подключения. */
|
|
40
|
+
const COMMANDS = new Set(['sync', 'types', 'load', 'verify', 'maintain', 'describe', 'import', 'mcp']);
|
|
38
41
|
const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
|
|
39
42
|
export async function main(argv) {
|
|
40
43
|
const { values, positionals } = parseArgs({
|
|
@@ -58,7 +61,12 @@ export async function main(argv) {
|
|
|
58
61
|
values.schema ??= process.env.LETOPIS_SCHEMA || undefined;
|
|
59
62
|
values.token ??= process.env.LETOPIS_TOKEN || undefined;
|
|
60
63
|
}
|
|
61
|
-
|
|
64
|
+
// --help — всегда код 0; неизвестная команда — USAGE до подключения к базе (план 2.0, 7.5 №4)
|
|
65
|
+
if (values.help) {
|
|
66
|
+
console.log(USAGE);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (!cmd || !COMMANDS.has(cmd) || !values.dsn || !values.schema) {
|
|
62
70
|
console.log(USAGE);
|
|
63
71
|
return cmd ? 2 : 0;
|
|
64
72
|
}
|
|
@@ -117,7 +125,7 @@ export async function main(argv) {
|
|
|
117
125
|
return 2;
|
|
118
126
|
}
|
|
119
127
|
if (values.trust)
|
|
120
|
-
throw new
|
|
128
|
+
throw new LetopisError('removed', '--trust снят в 1.0.0 — запрос по целям вне загрузки стоит 0,1–1,3 % её времени');
|
|
121
129
|
// несколько файлов — одна загрузка и одна транзакция; в файле — объект на строку
|
|
122
130
|
const rows = [];
|
|
123
131
|
for (const f of files) {
|
package/dist/errors.js
CHANGED
|
@@ -17,7 +17,7 @@ export const REMOVED_IN = '1.0.0';
|
|
|
17
17
|
/** Охранник снесённого API (план, этап 3, п. 11): понятная ошибка с версией и заменой. */
|
|
18
18
|
export function removed(name, replacement) {
|
|
19
19
|
// «опция …» — женского рода; методы и свойства — мужского
|
|
20
|
-
const verb = name.startsWith('опция ') ? 'снята' : 'снят';
|
|
20
|
+
const verb = name.startsWith('опция ') ? 'снята' : / и /.test(name) ? 'сняты' : 'снят';
|
|
21
21
|
return new LetopisError('removed', `${name} ${verb} в ${REMOVED_IN} — ${replacement}`);
|
|
22
22
|
}
|
|
23
23
|
/** Данные не проходят описание класса (`invalid_data`). */
|
package/dist/index.d.ts
CHANGED
|
@@ -170,8 +170,7 @@ export interface DbCore {
|
|
|
170
170
|
owner?: string;
|
|
171
171
|
tenant?: string;
|
|
172
172
|
}): Promise<Db>;
|
|
173
|
-
/** Явная транзакция `read committed`: те же цепочки, `commit()`, `rollback()`, `lock()`, `forUpdate()
|
|
174
|
-
/** Явная транзакция; `reason` — причина изменения для всех операций блока (план 1.1). */
|
|
173
|
+
/** Явная транзакция `read committed`: те же цепочки, `commit()`, `rollback()`, `lock()`, `forUpdate()`; `reason` — причина изменения для всех операций блока (план 1.1). */
|
|
175
174
|
begin(opts?: {
|
|
176
175
|
reason?: string;
|
|
177
176
|
}): Promise<DbTx>;
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { LetopisError, fromDbError, removed } from './errors.js';
|
|
|
12
12
|
import { Registry } from './registry.js';
|
|
13
13
|
import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
|
|
14
14
|
import { checkReason } from './write.js';
|
|
15
|
-
import { registerDb } from './internal.js';
|
|
15
|
+
import { limits, registerDb } from './internal.js';
|
|
16
16
|
import { makeAuth } from './auth.js';
|
|
17
17
|
import { makeAcl } from './acl.js';
|
|
18
18
|
import { idOf as idOfClass } from './uuid.js';
|
|
@@ -120,13 +120,23 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
120
120
|
throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
|
|
121
121
|
const reg = scope.registry();
|
|
122
122
|
const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
|
|
123
|
-
return new Subscription({
|
|
124
|
-
S: sh.S, exec: poolExecutor(txc), classes,
|
|
123
|
+
return sh.track(new Subscription({
|
|
124
|
+
S: sh.S, exec: poolExecutor(txc), classes, onClose: (s) => void sh.subs.delete(s),
|
|
125
|
+
// не бросает: класс удалили — список, снятый при watch(); иначе ошибка в общем разборе сигналов остановила бы
|
|
126
|
+
// доставку другим подписчикам
|
|
127
|
+
...(o.classes ? { classesOf: () => {
|
|
128
|
+
try {
|
|
129
|
+
return [...new Set(o.classes.flatMap((c) => scope.registry().family(c).map((x) => x.name)))];
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return classes;
|
|
133
|
+
}
|
|
134
|
+
} } : {}),
|
|
125
135
|
...(sh.listen ? {
|
|
126
136
|
onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
|
|
127
137
|
tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
|
|
128
138
|
} : {}),
|
|
129
|
-
}, o);
|
|
139
|
+
}, o));
|
|
130
140
|
},
|
|
131
141
|
cache: {
|
|
132
142
|
clear: () => sh.cache?.clear(),
|
|
@@ -152,7 +162,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
152
162
|
const tenant = r.tenant;
|
|
153
163
|
const registry = await sh.registryFor(tenant, scoped);
|
|
154
164
|
if (registry)
|
|
155
|
-
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
|
|
165
|
+
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
|
|
156
166
|
if (attempt === 3)
|
|
157
167
|
throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
|
|
158
168
|
[r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
@@ -246,6 +256,10 @@ export async function connect(opts) {
|
|
|
246
256
|
const owner = opts.owner ?? 'letopis_owner';
|
|
247
257
|
try {
|
|
248
258
|
// роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
|
|
259
|
+
// роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
|
|
260
|
+
const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
|
|
261
|
+
if (!known.ok)
|
|
262
|
+
throw new LetopisError('invalid_data', `connect: роли владельца ${owner} в базе нет — проверьте опцию owner (по умолчанию letopis_owner)`);
|
|
249
263
|
const [role] = await sql `select r.rolsuper, r.rolbypassrls,
|
|
250
264
|
pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
|
|
251
265
|
from pg_roles r where r.rolname = current_user`;
|
|
@@ -316,8 +330,18 @@ export async function connect(opts) {
|
|
|
316
330
|
return null;
|
|
317
331
|
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
318
332
|
}, { read: true });
|
|
319
|
-
// реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого
|
|
333
|
+
// реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора. Порядок карты —
|
|
334
|
+
// давность обращения: сверх limits.asRegistries вытесняется самый давний (план 2.0, 7.4 №8 — иначе процесс,
|
|
335
|
+
// который ходит от имени многих арендаторов, копил бы реестры и перечитывал их все на каждый сигнал классов)
|
|
320
336
|
const others = new Map();
|
|
337
|
+
let latest = null;
|
|
338
|
+
const keep = (tenant, entry) => {
|
|
339
|
+
latest = tenant;
|
|
340
|
+
others.delete(tenant);
|
|
341
|
+
others.set(tenant, entry);
|
|
342
|
+
while (others.size > Math.max(1, limits.asRegistries))
|
|
343
|
+
others.delete(others.keys().next().value);
|
|
344
|
+
};
|
|
321
345
|
const loading = new Map();
|
|
322
346
|
// токены классов (режим opaque) — по именам всех реестров подключения
|
|
323
347
|
const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
|
|
@@ -343,7 +367,7 @@ export async function connect(opts) {
|
|
|
343
367
|
const registryFor = async (tenant, txc) => {
|
|
344
368
|
const known = others.get(tenant);
|
|
345
369
|
if (known) {
|
|
346
|
-
known.txc
|
|
370
|
+
keep(tenant, { registry: known.registry, txc });
|
|
347
371
|
return known.registry;
|
|
348
372
|
}
|
|
349
373
|
// одновременные as() нового арендатора получают один реестр
|
|
@@ -355,7 +379,7 @@ export async function connect(opts) {
|
|
|
355
379
|
return null;
|
|
356
380
|
const registry = new Registry();
|
|
357
381
|
registry.load(rows);
|
|
358
|
-
|
|
382
|
+
keep(tenant, { registry, txc });
|
|
359
383
|
tokens();
|
|
360
384
|
return registry;
|
|
361
385
|
})().finally(() => loading.delete(tenant));
|
|
@@ -363,6 +387,20 @@ export async function connect(opts) {
|
|
|
363
387
|
}
|
|
364
388
|
return pending;
|
|
365
389
|
};
|
|
390
|
+
// фасад db.as() берёт реестр через карту: свежий реестр арендатора, если его перечитал другой as(); вытесненный —
|
|
391
|
+
// обратно в карту (его снова обновляют сигналы) и перечитать фоном: пока он был вне карты, сигналы его не трогали
|
|
392
|
+
const registryOf = (tenant, registry, txc) => {
|
|
393
|
+
const cur = others.get(tenant);
|
|
394
|
+
if (cur) {
|
|
395
|
+
if (latest !== tenant)
|
|
396
|
+
keep(tenant, cur);
|
|
397
|
+
return cur.registry;
|
|
398
|
+
}
|
|
399
|
+
keep(tenant, { registry, txc });
|
|
400
|
+
void loadClasses(txc, tenant).then((rows) => { if (rows)
|
|
401
|
+
registry.load(rows); }).catch(() => { }).finally(tokens);
|
|
402
|
+
return registry;
|
|
403
|
+
};
|
|
366
404
|
// ключ сигналов (режимы tenant и opaque) — сессии с правом letopis.signals; без права процесс
|
|
367
405
|
// работает без ключа: арендатора (а в opaque — и класса) в сигнале не различает
|
|
368
406
|
const fetchKey = async () => {
|
|
@@ -468,8 +506,9 @@ export async function connect(opts) {
|
|
|
468
506
|
stamper.unref?.();
|
|
469
507
|
}
|
|
470
508
|
let stopMaintain = null;
|
|
509
|
+
const subs = new Set();
|
|
471
510
|
const shared = {
|
|
472
|
-
sql, schema: opts.schema, S, notifier, passes, root, registryFor, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
|
|
511
|
+
sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
|
|
473
512
|
admin: Boolean(bypass && (role.rolsuper || role.can_set)),
|
|
474
513
|
ownerRole: owner,
|
|
475
514
|
cache,
|
|
@@ -477,7 +516,16 @@ export async function connect(opts) {
|
|
|
477
516
|
allTenants: Boolean(bypass),
|
|
478
517
|
signals,
|
|
479
518
|
listen,
|
|
519
|
+
subs,
|
|
520
|
+
track: (s) => {
|
|
521
|
+
subs.add(s);
|
|
522
|
+
return s;
|
|
523
|
+
},
|
|
480
524
|
async close() {
|
|
525
|
+
// живые подписки закрываются: иначе с чужим пулом (connect({ sql })) они опрашивали бы журнал и после close()
|
|
526
|
+
// (план 2.0, 7.4 №8)
|
|
527
|
+
for (const s of [...subs])
|
|
528
|
+
s.close();
|
|
481
529
|
// идущий maintain() по расписанию доделывает пачку: пул закрывается после неё, а не посреди транзакции
|
|
482
530
|
await stopMaintain?.();
|
|
483
531
|
resync.stop();
|
package/dist/internal.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export interface DbInternals {
|
|
|
10
10
|
/** Фасад явной транзакции db.begin(). */
|
|
11
11
|
readonly explicit: boolean;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
|
|
15
|
+
* 7.4 №8). Тест меняет предел, чтобы проверить вытеснение без тысячи арендаторов.
|
|
16
|
+
*/
|
|
17
|
+
export declare const limits: {
|
|
18
|
+
asRegistries: number;
|
|
19
|
+
};
|
|
13
20
|
/** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
|
|
14
21
|
export declare function registerDb(db: object, info: DbInternals): void;
|
|
15
22
|
/** Свойства фасада; undefined — объект не фасад connect() этого пакета. */
|
package/dist/internal.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* фасад db обходит RLS, не полагаясь на то, что сообщает о себе сам вызывающий.
|
|
5
5
|
*/
|
|
6
6
|
const facades = new WeakMap();
|
|
7
|
+
/**
|
|
8
|
+
* Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
|
|
9
|
+
* 7.4 №8). Тест меняет предел, чтобы проверить вытеснение без тысячи арендаторов.
|
|
10
|
+
*/
|
|
11
|
+
export const limits = { asRegistries: 1000 };
|
|
7
12
|
/** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
|
|
8
13
|
export function registerDb(db, info) {
|
|
9
14
|
facades.set(db, info);
|
package/dist/load.js
CHANGED
|
@@ -26,6 +26,7 @@ import { v5Id } from './uuid.js';
|
|
|
26
26
|
import { applyDefaults } from './validate.js';
|
|
27
27
|
import { isInc } from './write.js';
|
|
28
28
|
import { ACL_CLASS_NAMES } from './types.js';
|
|
29
|
+
import { isConnectionError, release, rollbackAfter } from './tx.js';
|
|
29
30
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
30
31
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
31
32
|
/** Классы, строки которых первая заливка сохраняет и возвращает: описания классов, аккаунты, членства, права. */
|
|
@@ -568,14 +569,15 @@ export async function load(env, input, opts = {}) {
|
|
|
568
569
|
const classesTouched = new Set();
|
|
569
570
|
// существующие объекты — только в режимах skip и update, одним запросом на пачку и до проверок:
|
|
570
571
|
// пропущенный объект для ссылок на него — вне загрузки (его сверит запрос в конце)
|
|
571
|
-
|
|
572
|
+
// живые строки пачки: id → класс (existing: 'update' правит только объект того же класса)
|
|
573
|
+
const live = new Map();
|
|
572
574
|
const past = new Map();
|
|
573
575
|
if (existing !== 'error' && !opts.withHistory) {
|
|
574
576
|
for (let b = 0; b < portion.length; b += batch) {
|
|
575
577
|
const ids = portion.slice(b, b + batch).map((i) => p1[i].id);
|
|
576
578
|
const lock = existing === 'update' ? ' order by e.id for no key update' : '';
|
|
577
|
-
for (const r of await conn.unsafe(`select e.id from ${S}.entity e where e.id = any ($1::uuid[])${lock}`, [ids]))
|
|
578
|
-
live.
|
|
579
|
+
for (const r of await conn.unsafe(`select e.id, e.class from ${S}.entity e where e.id = any ($1::uuid[])${lock}`, [ids]))
|
|
580
|
+
live.set(r.id, r.class);
|
|
579
581
|
for (const r of await conn.unsafe(`select distinct on (l.id) l.id, l.rev, l.hash, l.at::text as at from ${S}.log l
|
|
580
582
|
where l.id = any ($1::uuid[]) order by l.id, l.rev desc`, [ids]))
|
|
581
583
|
past.set(r.id, r);
|
|
@@ -690,12 +692,13 @@ export async function load(env, input, opts = {}) {
|
|
|
690
692
|
}
|
|
691
693
|
catch (e) {
|
|
692
694
|
failed = e;
|
|
695
|
+
// обрыв: в мёртвое соединение не пишут и в пул его не возвращают (план 2.0, 7.4 №7)
|
|
693
696
|
if (!ini)
|
|
694
|
-
await conn
|
|
697
|
+
await rollbackAfter(conn, e);
|
|
695
698
|
}
|
|
696
699
|
finally {
|
|
697
700
|
if (!ini)
|
|
698
|
-
|
|
701
|
+
release(conn, failed);
|
|
699
702
|
}
|
|
700
703
|
if (failed) {
|
|
701
704
|
const err = failed;
|
|
@@ -848,6 +851,13 @@ export async function load(env, input, opts = {}) {
|
|
|
848
851
|
if (!sh || issues.any)
|
|
849
852
|
continue;
|
|
850
853
|
if (live.has(id)) {
|
|
854
|
+
// строка с тем же id, но другого класса (класс без ключа после reclass, повторный импорт по $ext): правка
|
|
855
|
+
// прошла бы мимо триггеров с данными, проверенными по чужой схеме, — exists (план 2.0, 7.2 №4)
|
|
856
|
+
const was = live.get(id);
|
|
857
|
+
if (was !== cls.name) {
|
|
858
|
+
issues.add({ line: i + 1, code: 'exists', message: `объект с этим id уже есть в классе ${was}: строка класса ${cls.name} его не правит`, id, class: cls.name });
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
851
861
|
updates.push({ id, data: sh.data, links: sh.links, tags: sh.tags ?? null, line: i + 1 });
|
|
852
862
|
continue;
|
|
853
863
|
}
|
|
@@ -1068,6 +1078,7 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1068
1078
|
const ident = (n) => `"${n.replace(/"/g, '""')}"`;
|
|
1069
1079
|
const conn = await env.sql.reserve();
|
|
1070
1080
|
const q = (text, params = []) => conn.unsafe(text, params);
|
|
1081
|
+
let initialFailure = null;
|
|
1071
1082
|
try {
|
|
1072
1083
|
await q('begin isolation level read committed');
|
|
1073
1084
|
await q(`set local role ${ident(env.ownerRole)}`);
|
|
@@ -1177,10 +1188,12 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1177
1188
|
return out;
|
|
1178
1189
|
}
|
|
1179
1190
|
catch (e) {
|
|
1180
|
-
|
|
1191
|
+
initialFailure = e;
|
|
1192
|
+
if (!isConnectionError(e))
|
|
1193
|
+
await q('rollback').catch(() => { });
|
|
1181
1194
|
throw e instanceof LetopisError ? e : fromDbError(e);
|
|
1182
1195
|
}
|
|
1183
1196
|
finally {
|
|
1184
|
-
|
|
1197
|
+
release(conn, initialFailure);
|
|
1185
1198
|
}
|
|
1186
1199
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -78,7 +78,7 @@ const describeQuery = (o) => [
|
|
|
78
78
|
'Чтение JSON-цепочкой, без SQL. Старт: class (класс, алиас или роль) и filter, id или ids.',
|
|
79
79
|
'steps — переходы по связям: [{ step: "Роль или Класс", filter? | id? | ids? }].',
|
|
80
80
|
'Фильтр: { поле: значение } — равенство; вложенные поля — вложенным объектом; конец — { Роль: id };',
|
|
81
|
-
'операторы — { поле: { $gt: 5 } }: $ne, $gt, $gte, $lt, $lte, $between [a, b], $in [..], $like, $ilike, $starts, $ends, $has, $hasAny [..], $hasAll [..], $exists true|false, $isNull true, $not; ИЛИ — { $or: [фильтр, …] }.',
|
|
81
|
+
'операторы — { поле: { $gt: 5 } }: $ne, $gt, $gte, $lt, $lte, $between [a, b], $in [..], $like, $ilike, $starts, $ends (% и _ в значении $starts и $ends — шаблон LIKE, не экранируются), $has, $hasAny [..], $hasAll [..], $exists true|false, $isNull true, $not; ИЛИ — { $or: [фильтр, …] }.',
|
|
82
82
|
'Модификаторы: asOf (момент ISO — срез на дату), withDeleted, sort { field: "at" | "rev" | "id" | "data.<путь>", dir }, limit, cursor (из прошлого ответа).',
|
|
83
83
|
'terminal: rows (по умолчанию), first, ids, count, sum, avg, min, max, countBy — с field "data.<путь>".',
|
|
84
84
|
`Ответ — не больше ${o.maxRows} строк и ${o.maxBytes} байт; дальше — cursor. Без sort строки идут по id. Только чтение.`,
|
|
@@ -522,7 +522,9 @@ async function query(db, a, o) {
|
|
|
522
522
|
const got = await chain.limit(want + 1).rows();
|
|
523
523
|
const page = got.slice(0, fit(got.slice(0, want), o));
|
|
524
524
|
const more = got.length > page.length;
|
|
525
|
-
const
|
|
525
|
+
const last = more && page.length ? cursorOf(page[page.length - 1], field) : undefined;
|
|
526
|
+
// курсор MCP — { f, d, v, id }: списки rows и ids листаются без rev (он разводит только версии)
|
|
527
|
+
const next = last ? encodeCursor({ f: field, d: dir, v: last.v, id: last.id }) : undefined;
|
|
526
528
|
const body = terminal === 'ids' ? { ids: page.map((r) => r.id) } : { rows: page };
|
|
527
529
|
return ok({ ...body, count: page.length, more, ...(next ? { cursor: next } : {}) });
|
|
528
530
|
}
|
package/dist/model.js
CHANGED
|
@@ -64,6 +64,8 @@ export function many(target, opts = {}) {
|
|
|
64
64
|
return new End(targets, { many: true, required: (opts.min ?? 0) > 0, min: opts.min, max: opts.max, onDelete: opts.onDelete });
|
|
65
65
|
}
|
|
66
66
|
const NAME = /^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$/;
|
|
67
|
+
/** Ключи полной формы описания класса (ClassDef). */
|
|
68
|
+
const DEF_KEYS = new Set(['alias', 'extends', 'abstract', 'ends', 'fields', 'key', 'onDelete', 'history', 'ownerDefault', 'cache', 'meta', 'derivedBy', 'aggregate']);
|
|
67
69
|
function isZod(v) {
|
|
68
70
|
return typeof v === 'object' && v !== null && '_zod' in v;
|
|
69
71
|
}
|
|
@@ -94,7 +96,11 @@ function makeModel(kind, name, defArg, optsArg) {
|
|
|
94
96
|
throw new LetopisError('invalid_class', `имя класса ${name} недопустимо`);
|
|
95
97
|
// короткая форма: hub(name, { поле: z…, … }, { key, meta, … })
|
|
96
98
|
let def;
|
|
97
|
-
|
|
99
|
+
// { fields: z.object(…) } без других ключей описания — полная форма, а не класс с единственным полем fields (план 2.0,
|
|
100
|
+
// 7.5 №2); короткая форма — все значения Zod и хотя бы один ключ вне описания класса
|
|
101
|
+
const fullForm = !!defArg && typeof defArg === 'object' && defArg.fields instanceof z.ZodObject
|
|
102
|
+
&& Object.keys(defArg).every((k) => DEF_KEYS.has(k));
|
|
103
|
+
const isShape = !fullForm && defArg && typeof defArg === 'object' && Object.values(defArg).length > 0 && Object.values(defArg).every(isZod);
|
|
98
104
|
if (optsArg !== undefined || isShape) {
|
|
99
105
|
def = { ...optsArg, fields: (defArg ?? {}) };
|
|
100
106
|
}
|
package/dist/project.d.ts
CHANGED
|
@@ -195,6 +195,10 @@ export declare class Projector {
|
|
|
195
195
|
/** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
|
|
196
196
|
private blind;
|
|
197
197
|
private stale;
|
|
198
|
+
/** Когда состав читался в последний раз: без LISTEN сигнала о новых классах нет (план 2.0, 7.4 №6). */
|
|
199
|
+
private resolvedAt;
|
|
200
|
+
/** Участники, которых чекпоинт ещё не знает: им начальная перестройка пачками (tenantWork). */
|
|
201
|
+
private newcomers;
|
|
198
202
|
/** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
|
|
199
203
|
private tenantsPending;
|
|
200
204
|
private running;
|
|
@@ -208,6 +212,11 @@ export declare class Projector {
|
|
|
208
212
|
/** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
|
|
209
213
|
private readonly lockHits;
|
|
210
214
|
private readonly only;
|
|
215
|
+
/**
|
|
216
|
+
* Перечитать состав: по сигналу классов, а без LISTEN (сигналов нет) — не реже раза в 5 с, как реестр подключения в
|
|
217
|
+
* режиме none, и в начале run() (план 2.0, 7.4 №6): иначе арендатор, вступивший после запуска, не участвовал бы.
|
|
218
|
+
*/
|
|
219
|
+
private due;
|
|
211
220
|
constructor(env: ProjectorEnv, name: string, def: ProjectorOptions);
|
|
212
221
|
/**
|
|
213
222
|
* Догнать журнал и следовать за ним до stop(); { once: true } — только догнать. Первый запуск (проекции
|