letopis 1.4.0 → 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 +15 -7
- package/AGENT-CHEATSHEET.md +19 -11
- package/CHANGELOG.md +299 -1
- package/README.en.md +180 -62
- package/README.md +165 -55
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +53 -18
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +92 -15
- package/dist/chain.js +56 -7
- package/dist/cli.js +16 -2
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +73 -45
- package/dist/index.js +69 -14
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.d.ts +10 -3
- package/dist/load.js +39 -11
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +8 -6
- package/dist/model.d.ts +4 -1
- package/dist/model.js +7 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +39 -11
- package/dist/project.js +75 -15
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +8 -4
- package/dist/sql.js +110 -25
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +22 -4
- package/dist/types.d.ts +21 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +48 -25
- package/dist/up.js +94 -41
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +29 -5
- package/dist/watch.js +62 -13
- package/dist/write.d.ts +3 -2
- package/dist/write.js +52 -19
- package/package.json +8 -4
- package/sql/10-core.sql +6 -1
- package/sql/60-write.sql +47 -18
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +41 -3
- package/sql/88-derived.sql +3 -1
- package/sql/90-time.sql +118 -30
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +1 -1
- package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/project.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LetopisError, fromDbError } from './errors.js';
|
|
2
|
+
import { release, rollbackAfter } from './tx.js';
|
|
2
3
|
import { toRow } from './sql.js';
|
|
3
4
|
import { wakes } from './watch.js';
|
|
4
5
|
import { schemaRevision, SYSTEM_ID } from './up.js';
|
|
@@ -67,6 +68,10 @@ function change(row, cursor, rebuild = false) {
|
|
|
67
68
|
* значениями (22…) и пределами размера (54…). Сбой окружения (блокировки, отмена запроса, связь) — не причина
|
|
68
69
|
* выводить арендатора: пачка откатывается целиком, повтор начнётся с того же курсора.
|
|
69
70
|
*/
|
|
71
|
+
/** «1 прохода», «2 проходов», «21 прохода» — число проходов в родительном падеже («в течение …»). */
|
|
72
|
+
function passesGen(n) {
|
|
73
|
+
return `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'прохода' : 'проходов'}`;
|
|
74
|
+
}
|
|
70
75
|
/** Ожидание чужой блокировки строки дольше lock_timeout (55P03): сбой окружения, проход повторяется. */
|
|
71
76
|
function lockWait(e) {
|
|
72
77
|
return e?.code === '55P03';
|
|
@@ -161,17 +166,32 @@ class Out {
|
|
|
161
166
|
if (key !== null)
|
|
162
167
|
this.folds.set(key, d);
|
|
163
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Строка для delete и get. У класса с ключом её называют полями ключа: id такой строки — v5 от ключа, и по одному id
|
|
171
|
+
* (строкой или { $id }) база искала бы строку с пустым ключом — get отвечал бы null, а delete выводил арендатора с
|
|
172
|
+
* id_mismatch. Это ошибка кода handle, а не данных: invalid_data сразу, пачка откатывается целиком.
|
|
173
|
+
*/
|
|
174
|
+
target(cls, tenant, keyOrId) {
|
|
175
|
+
const row = this.rowOf(keyOrId);
|
|
176
|
+
const key = this.p.keyOf(tenant, cls);
|
|
177
|
+
if (key.length && Object.keys(row).every((n) => n === '$id')) {
|
|
178
|
+
throw new LetopisError('invalid_data', `проектор ${this.p.name}: у класса ${cls} есть ключ (${key.join(', ')}) — строку называют полями ключа, например { ${key.map((k) => `${k}: …`).join(', ')} }, а не id`);
|
|
179
|
+
}
|
|
180
|
+
return row;
|
|
181
|
+
}
|
|
164
182
|
delete(cls, keyOrId, opts) {
|
|
165
183
|
const tenant = this.check(cls, opts?.tenant);
|
|
184
|
+
const row = this.target(cls, tenant, keyOrId);
|
|
166
185
|
this.folds.clear();
|
|
167
|
-
this.ops.push({ op: 'delete', class: cls, tenant, row
|
|
186
|
+
this.ops.push({ op: 'delete', class: cls, tenant, row });
|
|
168
187
|
}
|
|
169
188
|
async get(cls, keyOrId, opts) {
|
|
170
189
|
const tenant = this.check(cls, opts?.tenant);
|
|
190
|
+
const row = this.target(cls, tenant, keyOrId);
|
|
171
191
|
await this.flush();
|
|
172
192
|
const S = this.p.env.S;
|
|
173
193
|
const [r] = await this.db(() => this.q.unsafe(`select ${rowJson('e')} as row from ${S}.entity e
|
|
174
|
-
where e.id = ${S}.derived_id($1, $2::uuid, $3::text::jsonb) and e.class = $1 and e.tenant = $2::uuid`, [cls, tenant, JSON.stringify(
|
|
194
|
+
where e.id = ${S}.derived_id($1, $2::uuid, $3::text::jsonb) and e.class = $1 and e.tenant = $2::uuid`, [cls, tenant, JSON.stringify(row)]));
|
|
175
195
|
return r ? toRow(r.row) : null;
|
|
176
196
|
}
|
|
177
197
|
async flush() {
|
|
@@ -202,6 +222,10 @@ export class Projector {
|
|
|
202
222
|
/** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
|
|
203
223
|
blind = new Map();
|
|
204
224
|
stale = true;
|
|
225
|
+
/** Когда состав читался в последний раз: без LISTEN сигнала о новых классах нет (план 2.0, 7.4 №6). */
|
|
226
|
+
resolvedAt = 0;
|
|
227
|
+
/** Участники, которых чекпоинт ещё не знает: им начальная перестройка пачками (tenantWork). */
|
|
228
|
+
newcomers = [];
|
|
205
229
|
/** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
|
|
206
230
|
tenantsPending = true;
|
|
207
231
|
running = false;
|
|
@@ -215,6 +239,13 @@ export class Projector {
|
|
|
215
239
|
/** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
|
|
216
240
|
lockHits = new Map();
|
|
217
241
|
only;
|
|
242
|
+
/**
|
|
243
|
+
* Перечитать состав: по сигналу классов, а без LISTEN (сигналов нет) — не реже раза в 5 с, как реестр подключения в
|
|
244
|
+
* режиме none, и в начале run() (план 2.0, 7.4 №6): иначе арендатор, вступивший после запуска, не участвовал бы.
|
|
245
|
+
*/
|
|
246
|
+
due() {
|
|
247
|
+
return this.stale || (!this.env.onSignal && Date.now() - this.resolvedAt >= 5000);
|
|
248
|
+
}
|
|
218
249
|
constructor(env, name, def) {
|
|
219
250
|
this.env = env;
|
|
220
251
|
this.def = def;
|
|
@@ -267,6 +298,8 @@ export class Projector {
|
|
|
267
298
|
throw new LetopisError('invalid_query', `проектор ${this.name} уже запущен в этом процессе`);
|
|
268
299
|
this.running = true;
|
|
269
300
|
this.stopped = false;
|
|
301
|
+
if (!this.env.onSignal)
|
|
302
|
+
this.stale = true;
|
|
270
303
|
const rep = emptyReport();
|
|
271
304
|
try {
|
|
272
305
|
for (;;) {
|
|
@@ -371,7 +404,7 @@ export class Projector {
|
|
|
371
404
|
}
|
|
372
405
|
async status() {
|
|
373
406
|
return this.tx(async (q) => {
|
|
374
|
-
if (this.
|
|
407
|
+
if (this.due())
|
|
375
408
|
await this.resolve(q);
|
|
376
409
|
const [r] = await q.unsafe(`select s.meta from ${this.env.S}.secret s
|
|
377
410
|
where s.kind = 'PROJECTOR' and s.ident = $1 and s.account = $2::uuid and s.deleted_at is null`, [this.name, SYSTEM_ID]);
|
|
@@ -412,6 +445,7 @@ export class Projector {
|
|
|
412
445
|
/** Транзакция под ролью владельца на отдельном соединении пула. */
|
|
413
446
|
async tx(fn) {
|
|
414
447
|
const q = await this.env.sql.reserve();
|
|
448
|
+
let failure = null;
|
|
415
449
|
try {
|
|
416
450
|
await q.unsafe('begin isolation level read committed');
|
|
417
451
|
await q.unsafe(`set local role ${ident(this.env.ownerRole)}`);
|
|
@@ -425,11 +459,13 @@ export class Projector {
|
|
|
425
459
|
return out;
|
|
426
460
|
}
|
|
427
461
|
catch (e) {
|
|
428
|
-
|
|
462
|
+
// обрыв: rollback и возврат мёртвого соединения в пул роняли процесс (план 2.0, 7.4 №7)
|
|
463
|
+
failure = e;
|
|
464
|
+
await rollbackAfter(q, e);
|
|
429
465
|
throw fromDbError(e);
|
|
430
466
|
}
|
|
431
467
|
finally {
|
|
432
|
-
|
|
468
|
+
release(q, failure);
|
|
433
469
|
}
|
|
434
470
|
}
|
|
435
471
|
async lock(q) {
|
|
@@ -525,6 +561,7 @@ export class Projector {
|
|
|
525
561
|
this.scopes = scopes;
|
|
526
562
|
this.tenants = tenants;
|
|
527
563
|
this.stale = false;
|
|
564
|
+
this.resolvedAt = Date.now();
|
|
528
565
|
}
|
|
529
566
|
/** Одна пачка журнала. */
|
|
530
567
|
async step(rep) {
|
|
@@ -533,7 +570,7 @@ export class Projector {
|
|
|
533
570
|
rep.skipped++;
|
|
534
571
|
return 'busy';
|
|
535
572
|
}
|
|
536
|
-
if (this.
|
|
573
|
+
if (this.due())
|
|
537
574
|
await this.resolve(q);
|
|
538
575
|
const meta = await this.state(q);
|
|
539
576
|
if (!meta.cursor || meta.state === 'rebuilding')
|
|
@@ -541,6 +578,12 @@ export class Projector {
|
|
|
541
578
|
if (meta.version !== this.def.version)
|
|
542
579
|
throw this.versionError(meta);
|
|
543
580
|
this.tenantsPending = Object.values(meta.retenant ?? {}).some((v) => !v.done);
|
|
581
|
+
// участники, которых чекпоинт не знает (вступили после проекции): начальную перестройку пачками начнёт tenantWork,
|
|
582
|
+
// их изменения до неё учтёт она же (план 2.0, 7.4 №5)
|
|
583
|
+
const known = new Set(meta.tenants ?? this.tenants);
|
|
584
|
+
this.newcomers = this.tenants.filter((x) => !known.has(x) && !meta.failed?.[x] && !meta.retenant?.[x]);
|
|
585
|
+
if (this.newcomers.length)
|
|
586
|
+
this.tenantsPending = true;
|
|
544
587
|
const cur = dec(meta.cursor);
|
|
545
588
|
const rows = await q.unsafe(`select l.tx::text as tx, l.seq::text as seq, ${rowJson('l')} as row
|
|
546
589
|
from ${this.env.S}.watch_read($1::text::xid8, $2::bigint, null, $3::text[], $4::int, $5::text::pg_snapshot, true) l`, [cur.tx, cur.seq, [...this.from, '#reset'], this.batch, cur.snap ?? null]);
|
|
@@ -573,6 +616,8 @@ export class Projector {
|
|
|
573
616
|
const snap = keepSnap(cur.snap, r.tx);
|
|
574
617
|
pos = { tx: r.tx, seq: r.seq, ...(snap ? { snap } : {}) };
|
|
575
618
|
const row = toRow(r.row);
|
|
619
|
+
if (this.newcomers.includes(row.tenant))
|
|
620
|
+
continue;
|
|
576
621
|
const leaves = this.leaves(row, failed);
|
|
577
622
|
if (!leaves && !this.ours(row, failed))
|
|
578
623
|
continue;
|
|
@@ -602,7 +647,8 @@ export class Projector {
|
|
|
602
647
|
await this.apply(q, changes, rep, failed);
|
|
603
648
|
}
|
|
604
649
|
const caught = rows.length < this.batch;
|
|
605
|
-
|
|
650
|
+
// состав — известные нынешние участники: выбывший из участников, вернувшись, снова новичок
|
|
651
|
+
const next = withFailed({ ...meta, cursor: enc(pos), tenants: this.tenants.filter((x) => known.has(x)) }, failed);
|
|
606
652
|
if (!rows.length) {
|
|
607
653
|
// пусто: отметить время прохода, но не чаще раза в минуту — иначе каждый опрос писал бы чекпоинт
|
|
608
654
|
if (meta.at && Date.now() - Date.parse(meta.at) < 60_000)
|
|
@@ -751,7 +797,7 @@ export class Projector {
|
|
|
751
797
|
failed[tenant] = {
|
|
752
798
|
cursor: list[0].cursor || start,
|
|
753
799
|
code: '55P03',
|
|
754
|
-
message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — ${hits}
|
|
800
|
+
message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — в течение ${passesGen(hits)} подряд дольше lockTimeout`,
|
|
755
801
|
};
|
|
756
802
|
continue;
|
|
757
803
|
}
|
|
@@ -808,10 +854,17 @@ export class Projector {
|
|
|
808
854
|
const sc = this.scopes.get(row.tenant);
|
|
809
855
|
return sc !== undefined && !failed?.[row.tenant] && row.op === 'reclass' && !row.moved && !sc.from.has(row.class) && row.class !== '#reset';
|
|
810
856
|
}
|
|
811
|
-
/**
|
|
857
|
+
/**
|
|
858
|
+
* Входит ли класс `cls` в классы `into` проектора у арендатора `tenant` — сами эти классы или их потомки. Служебный
|
|
859
|
+
* метод: им `out` проверяет, что `handle` пишет только в эти классы.
|
|
860
|
+
*/
|
|
812
861
|
intoOf(tenant, cls) {
|
|
813
862
|
return this.scopes.get(tenant)?.into.has(cls) ?? false;
|
|
814
863
|
}
|
|
864
|
+
/** Поля ключа класса into арендатора (пусто — класс без ключа). */
|
|
865
|
+
keyOf(tenant, cls) {
|
|
866
|
+
return this.scopes.get(tenant)?.keys.get(cls) ?? [];
|
|
867
|
+
}
|
|
815
868
|
/**
|
|
816
869
|
* Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
|
|
817
870
|
* (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
|
|
@@ -876,7 +929,8 @@ export class Projector {
|
|
|
876
929
|
// выбывший посреди перестройки — без производных строк: прежние пачки построили их лишь частично
|
|
877
930
|
if (Object.keys(failed).length)
|
|
878
931
|
await this.clear(q, Object.keys(failed));
|
|
879
|
-
await this.advance(q, meta.cursor, withFailed({ cursor: enc(head), at: await this.now(q), version: this.def.version, state: 'running'
|
|
932
|
+
await this.advance(q, meta.cursor, withFailed({ cursor: enc(head), at: await this.now(q), version: this.def.version, state: 'running',
|
|
933
|
+
tenants: [...this.tenants] }, failed));
|
|
880
934
|
rep.rebuilds++;
|
|
881
935
|
});
|
|
882
936
|
}
|
|
@@ -918,7 +972,7 @@ export class Projector {
|
|
|
918
972
|
const r = await this.tx(async (q) => {
|
|
919
973
|
if (!(await this.lock(q)))
|
|
920
974
|
return busy();
|
|
921
|
-
if (this.
|
|
975
|
+
if (this.due())
|
|
922
976
|
await this.resolve(q);
|
|
923
977
|
const meta = await this.state(q);
|
|
924
978
|
const rb = meta.rebuild;
|
|
@@ -936,7 +990,7 @@ export class Projector {
|
|
|
936
990
|
const real = rows.filter((r) => r.row);
|
|
937
991
|
if (!real.length) {
|
|
938
992
|
await this.advance(q, meta.cursor, withFailed({ cursor: rb.start, at: await this.now(q), version: this.def.version, state: 'running',
|
|
939
|
-
chunks: [...rb.chunks, { upto: LAST_ID, snap: rows[0].snap }] }, failed));
|
|
993
|
+
chunks: [...rb.chunks, { upto: LAST_ID, snap: rows[0].snap }], tenants: [...this.tenants] }, failed));
|
|
940
994
|
rep.rebuilds++;
|
|
941
995
|
return 'done';
|
|
942
996
|
}
|
|
@@ -1064,7 +1118,7 @@ export class Projector {
|
|
|
1064
1118
|
const resync = { ...(meta.resync ?? {}) };
|
|
1065
1119
|
delete resync[tenant];
|
|
1066
1120
|
const next = { ...meta, retenant: { ...(meta.retenant ?? {}), [tenant]: { last: null, chunks: [] } },
|
|
1067
|
-
since: { ...(meta.since ?? {}), [tenant]: now }, resync };
|
|
1121
|
+
since: { ...(meta.since ?? {}), [tenant]: now }, resync, tenants: [...new Set([...(meta.tenants ?? this.tenants), tenant])] };
|
|
1068
1122
|
if (!Object.keys(resync).length)
|
|
1069
1123
|
delete next.resync;
|
|
1070
1124
|
await this.advance(q, meta.cursor, withFailed(next, failed));
|
|
@@ -1094,7 +1148,7 @@ export class Projector {
|
|
|
1094
1148
|
rep.skipped++;
|
|
1095
1149
|
return 'busy';
|
|
1096
1150
|
}
|
|
1097
|
-
if (this.
|
|
1151
|
+
if (this.due())
|
|
1098
1152
|
await this.resolve(q);
|
|
1099
1153
|
const meta = await this.state(q);
|
|
1100
1154
|
const rt = meta.retenant?.[tenant];
|
|
@@ -1151,6 +1205,12 @@ export class Projector {
|
|
|
1151
1205
|
if (!meta.cursor || meta.state === 'rebuilding' || meta.version !== this.def.version)
|
|
1152
1206
|
return false;
|
|
1153
1207
|
let more = false;
|
|
1208
|
+
// новые участники — начальная перестройка пачками и без onExpired (план 2.0, 7.4 №5)
|
|
1209
|
+
const newcomers = this.newcomers;
|
|
1210
|
+
this.newcomers = [];
|
|
1211
|
+
for (const t of newcomers)
|
|
1212
|
+
if ((await this.startTenant(rep, t, true)) === 'ok')
|
|
1213
|
+
more = true;
|
|
1154
1214
|
if (start) {
|
|
1155
1215
|
for (const [t, f] of Object.entries(meta.failed ?? {})) {
|
|
1156
1216
|
if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
|
|
@@ -1159,7 +1219,7 @@ export class Projector {
|
|
|
1159
1219
|
more = true;
|
|
1160
1220
|
}
|
|
1161
1221
|
}
|
|
1162
|
-
const fresh = start ? await this.tx(async (q) => this.state(q)) : meta;
|
|
1222
|
+
const fresh = start || newcomers.length ? await this.tx(async (q) => this.state(q)) : meta;
|
|
1163
1223
|
for (const [t, v] of Object.entries(fresh.retenant ?? {})) {
|
|
1164
1224
|
if (v.done)
|
|
1165
1225
|
continue;
|
package/dist/registry.d.ts
CHANGED
|
@@ -68,7 +68,10 @@ export declare class Registry {
|
|
|
68
68
|
all(): ClassInfo[];
|
|
69
69
|
/** Класс и все его потомки (семейство). */
|
|
70
70
|
family(name: string): ClassInfo[];
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Тип листа — поля в конце пути `path` — у класса `cls`, по его JSON Schema (`type` и `format`). По этому типу
|
|
73
|
+
* библиотека сравнивает значения поля в фильтрах, сортировке и итогах.
|
|
74
|
+
*/
|
|
72
75
|
leafType(cls: string, path: readonly string[]): LeafType;
|
|
73
76
|
}
|
|
74
77
|
export {};
|
package/dist/registry.js
CHANGED
|
@@ -70,7 +70,10 @@ export class Registry {
|
|
|
70
70
|
const root = this.resolve(name).name;
|
|
71
71
|
return this.all().filter((c) => c.ancestors.includes(root));
|
|
72
72
|
}
|
|
73
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Тип листа — поля в конце пути `path` — у класса `cls`, по его JSON Schema (`type` и `format`). По этому типу
|
|
75
|
+
* библиотека сравнивает значения поля в фильтрах, сортировке и итогах.
|
|
76
|
+
*/
|
|
74
77
|
leafType(cls, path) {
|
|
75
78
|
let node = this.resolve(cls).schema;
|
|
76
79
|
for (const p of path) {
|
package/dist/signals.d.ts
CHANGED
|
@@ -57,10 +57,6 @@ export declare class Signals {
|
|
|
57
57
|
/** Разобрать нагрузку сигнала или элемент letopis.changed своей транзакции. */
|
|
58
58
|
parse(p: string): Signal;
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
61
|
-
* Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
|
|
62
|
-
* канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
|
|
63
|
-
*/
|
|
64
60
|
/**
|
|
65
61
|
* Ведро жетонов: count вызовов подряд — сразу, дальше не чаще раза в ms (жетон возвращается раз в ms); вызовы
|
|
66
62
|
* в ожидании сливаются в один. Короткая серия проходит без задержки, заливка — с заданной частотой.
|
|
@@ -68,6 +64,10 @@ export declare class Signals {
|
|
|
68
64
|
export declare function bucket(fn: () => void, count: number, ms: number): (() => void) & {
|
|
69
65
|
stop(): void;
|
|
70
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
|
|
69
|
+
* канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
|
|
70
|
+
*/
|
|
71
71
|
export declare function throttle(fn: () => void, ms: number): (() => void) & {
|
|
72
72
|
stop(): void;
|
|
73
73
|
};
|
package/dist/signals.js
CHANGED
|
@@ -104,10 +104,6 @@ export class Signals {
|
|
|
104
104
|
return cls ? { kind: 'class', cls, tenant } : { kind: 'unknown', tenant };
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
-
/**
|
|
108
|
-
* Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
|
|
109
|
-
* канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
|
|
110
|
-
*/
|
|
111
107
|
/**
|
|
112
108
|
* Ведро жетонов: count вызовов подряд — сразу, дальше не чаще раза в ms (жетон возвращается раз в ms); вызовы
|
|
113
109
|
* в ожидании сливаются в один. Короткая серия проходит без задержки, заливка — с заданной частотой.
|
|
@@ -140,6 +136,10 @@ export function bucket(fn, count, ms) {
|
|
|
140
136
|
},
|
|
141
137
|
});
|
|
142
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Не чаще раза в ms: первый вызов — сразу, вызовы внутри окна — один раз в его конце. Так заливка
|
|
141
|
+
* канала сигналами (в том числе поддельными) не превращается в поток перечитываний реестра.
|
|
142
|
+
*/
|
|
143
143
|
export function throttle(fn, ms) {
|
|
144
144
|
let last = -Infinity;
|
|
145
145
|
let timer = null;
|
package/dist/sql.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClassInfo, LeafType, Registry } from './registry.js';
|
|
2
|
-
import { type ChainMods, type Cursor, type Filter, type Row } from './types.js';
|
|
2
|
+
import { type ChainMods, type Cursor, type Filter, type Path, type Row } from './types.js';
|
|
3
3
|
export interface Step {
|
|
4
4
|
/** Имя, как вызвано в цепочке (класс, алиас или роль). */
|
|
5
5
|
name: string;
|
|
@@ -37,7 +37,7 @@ export interface AggSpec {
|
|
|
37
37
|
export interface BuiltQuery {
|
|
38
38
|
text: string;
|
|
39
39
|
params: unknown[];
|
|
40
|
-
/** Ключи шагов в путях (алиасы, повторы — _2, _3)
|
|
40
|
+
/** Ключи шагов в путях (алиасы, повторы — _2, _3) в порядке шагов; ключ узла последнего шага — последний. */
|
|
41
41
|
keys: string[];
|
|
42
42
|
/** Тип листа агрегата (min/max числового поля приводятся к числу). */
|
|
43
43
|
aggLeaf?: LeafType;
|
|
@@ -62,8 +62,12 @@ export interface Hop {
|
|
|
62
62
|
* хаб → связь — назад; связь → хаб и хаб → хаб — вперёд; нет нужного — другое направление.
|
|
63
63
|
*/
|
|
64
64
|
export declare function resolveHop(reg: Registry, prev: Step, next: Step): Hop;
|
|
65
|
-
/**
|
|
66
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Курсор постраничного вывода для `after()`: значение поля сортировки, id и номер версии последней строки страницы.
|
|
67
|
+
* Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`. Для пути из `paths()` — то же по его
|
|
68
|
+
* последнему узлу (узлу последнего шага) и id всех узлов: курсор отделяет пути с общим последним узлом.
|
|
69
|
+
*/
|
|
70
|
+
export declare function cursorOf(row: Row | Path, field: string): Cursor;
|
|
67
71
|
export interface BuildOptions {
|
|
68
72
|
/**
|
|
69
73
|
* Режим сверки (тест find-ids-parity): вместо функций владельца find_* — те же запросы прямо
|
package/dist/sql.js
CHANGED
|
@@ -295,7 +295,7 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
295
295
|
const id = idOfValue(value) ?? fail(`конец ${key}: ожидался id, строка ответа или null`);
|
|
296
296
|
out.ends.push(id);
|
|
297
297
|
const ph = p.push(id);
|
|
298
|
-
out.frags.push((a) =>
|
|
298
|
+
out.frags.push((a) => `${SCHEMA_MARK}.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
|
|
299
299
|
continue;
|
|
300
300
|
}
|
|
301
301
|
if (isOp(value)) {
|
|
@@ -422,22 +422,60 @@ function cursorParams(c, sort) {
|
|
|
422
422
|
}
|
|
423
423
|
return { v: value, id };
|
|
424
424
|
}
|
|
425
|
-
/**
|
|
425
|
+
/** rev курсора: в versions() отделяет версии одного объекта с равным полем сортировки (план 2.0, 7.1 №4). */
|
|
426
|
+
function cursorRev(c) {
|
|
427
|
+
const rev = c.rev;
|
|
428
|
+
if (rev === undefined)
|
|
429
|
+
return undefined;
|
|
430
|
+
if (!Number.isInteger(rev) || rev < 1)
|
|
431
|
+
return fail(`after(cursor): rev курсора — номер версии (целое ≥ 1), а не ${shown(rev)}`);
|
|
432
|
+
return rev;
|
|
433
|
+
}
|
|
434
|
+
/** id узлов пути из курсора paths() по ключам узлов (план 2.0, 7.1 №3); курсор без path — undefined. */
|
|
435
|
+
function cursorPath(c, keys) {
|
|
436
|
+
const path = c.path;
|
|
437
|
+
if (path === undefined)
|
|
438
|
+
return undefined;
|
|
439
|
+
return keys.map((k) => {
|
|
440
|
+
const id = typeof path === 'object' && path !== null ? path[k] : undefined;
|
|
441
|
+
if (typeof id !== 'string' || !UUID_RE.test(id))
|
|
442
|
+
return fail(`after(cursor): в курсоре пути нет id узла ${k} — нужен cursorOf(путь, поле) из paths() этой же цепочки`);
|
|
443
|
+
return id;
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const isPath = (x) => typeof x.id !== 'string'
|
|
447
|
+
&& Object.values(x).length > 0 && Object.values(x).every((n) => typeof n === 'object' && n !== null && typeof n.id === 'string');
|
|
448
|
+
/**
|
|
449
|
+
* Курсор постраничного вывода для `after()`: значение поля сортировки, id и номер версии последней строки страницы.
|
|
450
|
+
* Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`. Для пути из `paths()` — то же по его
|
|
451
|
+
* последнему узлу (узлу последнего шага) и id всех узлов: курсор отделяет пути с общим последним узлом.
|
|
452
|
+
*/
|
|
426
453
|
export function cursorOf(row, field) {
|
|
454
|
+
if (isPath(row)) {
|
|
455
|
+
const keys = Object.keys(row);
|
|
456
|
+
const { v, id } = cursorOf(row[keys[keys.length - 1]], field);
|
|
457
|
+
return { v, id, path: Object.fromEntries(keys.map((k) => [k, row[k].id])) };
|
|
458
|
+
}
|
|
427
459
|
const f = field === 'updated' ? 'at' : field;
|
|
460
|
+
const rev = typeof row.rev === 'number' ? { rev: row.rev } : {};
|
|
428
461
|
if (f === 'at' || f === 'rev' || f === 'id')
|
|
429
|
-
return { v: row[f], id: row.id };
|
|
462
|
+
return { v: row[f], id: row.id, ...rev };
|
|
430
463
|
if (f.startsWith('data.')) {
|
|
431
464
|
let v = row.data;
|
|
432
465
|
for (const k of f.slice(5).split('.'))
|
|
433
466
|
v = v?.[k];
|
|
434
|
-
return { v: (v ?? null), id: row.id };
|
|
467
|
+
return { v: (v ?? null), id: row.id, ...rev };
|
|
435
468
|
}
|
|
436
469
|
return fail(`cursorOf: поле 'at', 'rev', 'id' или 'data.<путь>', а не '${field}'`);
|
|
437
470
|
}
|
|
438
471
|
// ---------------------------------------------------------------------------
|
|
439
472
|
// Построение запроса
|
|
440
473
|
// ---------------------------------------------------------------------------
|
|
474
|
+
/**
|
|
475
|
+
* Метка схемы во фрагментах, собранных до имени схемы: символ NUL не бывает в тексте PostgreSQL, поэтому замена не
|
|
476
|
+
* заденет литералы — имя поля вида a@S@b прежде портилось (план 2.0, 7.1 №5).
|
|
477
|
+
*/
|
|
478
|
+
const SCHEMA_MARK = '\u0000S\u0000';
|
|
441
479
|
const COLS = ['id', 'rev', 'class', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason', 'moved'];
|
|
442
480
|
/** Колонки журнала, выданные приложению: все, кроме changed (ревью №1 точки А плана 1.1, 10-core.sql). */
|
|
443
481
|
const LOG_COLS = ['seq', 'tx', 'id', 'rev', 'class', 'cv', 'tenant', 'owner', 'links', 'data', 'tags', 'at', 'author', 'agent', 'op', 'reason',
|
|
@@ -523,7 +561,8 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
523
561
|
return `(select v.id, v.rev from ${S}.entity v where v.class = any (${clsPh})${dataCond('v')}${endsCond('v.ends')}`
|
|
524
562
|
+ `${ids ? ` and v.id = any (${ids})` : ''})`;
|
|
525
563
|
}
|
|
526
|
-
|
|
564
|
+
// по классу: id-кандидаты классов шага, последняя версия — любого класса (как find_ids, план 2.0, 7.1 №1)
|
|
565
|
+
return `(select v.id, v.rev from ${versionsAt(at, ids ? `l.id = any (${ids})` : `l.id in (select c.id from ${S}.log c where c.class = any (${clsPh}))`)} v `
|
|
527
566
|
+ `where v.class = any (${clsPh}) and (v.op = 'delete') = ${isDead} and v.op not in ('purge', 'trim', 'reset')${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)})`;
|
|
528
567
|
};
|
|
529
568
|
const entityPart = (src, depth = 'null::int', from = `${S}.entity x`) => `select ${colsOf('x')}, ${depth} as depth from ${from} where ${where('x', src)}`;
|
|
@@ -609,10 +648,13 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
609
648
|
+ `where (v.op = 'delete') = ${isDead} and v.op not in ('purge', 'trim', 'reset')${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)}${rolesCond('v', 's.src')})`;
|
|
610
649
|
};
|
|
611
650
|
ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
|
|
612
|
-
|
|
651
|
+
// пары переходов — по одной на (источник, цель): объект, который ссылается на несколько объектов прошлого шага,
|
|
652
|
+
// встречается в них несколько раз; строка узла — одна на id, иначе rows(), count() и агрегаты его удваивали
|
|
653
|
+
// (entityByIds повторы отбрасывает сам, срез asOf() и надгробия withDeleted() — через distinct здесь)
|
|
654
|
+
parts.push(cur ? entityByIds(`select q.id from p${i}a q`) : logPart(`select distinct q.id, q.rev from p${i}a q`));
|
|
613
655
|
if (dead) {
|
|
614
656
|
ctes.push(`p${i}d as (select q.src, q.id, q.rev from ${hopCall(atPh, true)} q)`);
|
|
615
|
-
parts.push(logPart(`select q.id, q.rev from p${i}d q`));
|
|
657
|
+
parts.push(logPart(`select distinct q.id, q.rev from p${i}d q`));
|
|
616
658
|
ctes.push(`p${i} as (select src, id from p${i}a union all select src, id from p${i}d)`);
|
|
617
659
|
}
|
|
618
660
|
else {
|
|
@@ -646,32 +688,62 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
646
688
|
const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
|
|
647
689
|
const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
|
|
648
690
|
const dir = mods.desc ? ' desc' : '';
|
|
649
|
-
|
|
650
|
-
const
|
|
691
|
+
/** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
|
|
692
|
+
const orderBy = (a, more = []) => (sort ? ` order by ${[sort.expr(a), `${a}.id`, ...more].map((x) => `${x}${dir}`).join(', ')}` : '');
|
|
693
|
+
/**
|
|
694
|
+
* «Строго после курсора» в порядке order by: поле sort, id строки и разделители равных из курсора — rev версии
|
|
695
|
+
* (versions()) или id прочих узлов пути (paths()). Без них страница начиналась бы после всех строк с тем же полем
|
|
696
|
+
* и id: терялись бы другие версии объекта и другие пути к тому же узлу. Курсор без rev или path сравнивается по
|
|
697
|
+
* началу порядка, как прежде.
|
|
698
|
+
*/
|
|
699
|
+
const after = (a, ties = () => []) => {
|
|
651
700
|
if (!mods.after)
|
|
652
701
|
return undefined;
|
|
653
702
|
if (!sort)
|
|
654
703
|
fail('after(cursor) требует sort(поле)');
|
|
655
704
|
const c = cursorParams(mods.after, sort);
|
|
656
|
-
|
|
657
|
-
|
|
705
|
+
const t = ties(mods.after);
|
|
706
|
+
const lt = mods.desc ? '<' : '>';
|
|
658
707
|
// Поле данных бывает null (или его нет): order by ставит такие строки в конец при возрастании и в
|
|
659
708
|
// начало при убывании, а сравнение строк с null даёт null — одно сравнение их теряло, курсор null
|
|
660
709
|
// давал пустую страницу. Здесь null — значение после всех прочих (asc) или перед ними (desc); текст
|
|
661
710
|
// один на оба вида курсора (параметр — с явным приведением: он стоит и в is null).
|
|
711
|
+
const v = `${p.push(c.v)}${sort.nullable ? sort.pcast || '::text' : sort.pcast}`;
|
|
712
|
+
const cols = [`${a}.id`, ...t.map((x) => x.col)];
|
|
713
|
+
const vals = [`${p.push(c.id)}::uuid`, ...t.map((x) => `${p.push(x.val)}${x.cast}`)];
|
|
714
|
+
if (!sort.nullable)
|
|
715
|
+
return `(${sort.expr(a)}, ${cols.join(', ')}) ${lt} (${v}, ${vals.join(', ')})`;
|
|
662
716
|
const e = `(${sort.expr(a)})`;
|
|
663
|
-
const
|
|
664
|
-
const id = `${p.push(c.id)}::uuid`;
|
|
717
|
+
const rest = cols.length === 1 ? `${cols[0]} ${lt} ${vals[0]}` : `(${cols.join(', ')}) ${lt} (${vals.join(', ')})`;
|
|
665
718
|
return mods.desc
|
|
666
|
-
? `((${e}, ${
|
|
667
|
-
: `((${e}, ${
|
|
719
|
+
? `((${e}, ${cols.join(', ')}) < (${v}, ${vals.join(', ')}) or (${v} is null and (${e} is not null or ${rest})))`
|
|
720
|
+
: `((${e}, ${cols.join(', ')}) > (${v}, ${vals.join(', ')}) or (${e} is null and (${v} is not null or ${rest})))`;
|
|
668
721
|
};
|
|
722
|
+
// ключи пути — в порядке шагов, узел последнего шага (его строки отдают rows()) — последним: по нему cursorOf(путь)
|
|
723
|
+
// берёт поле и id курсора, по прочим — разделители путей к одному узлу (план 2.0, 7.1 №3)
|
|
724
|
+
const others = keyed.filter((k) => k.node !== lastNode);
|
|
725
|
+
const pathKeys = [...others, ...keyed.filter((k) => k.node === lastNode)].map((k) => k.key);
|
|
669
726
|
const limitOffset = () => (mods.limit !== undefined ? ` limit ${p.push(mods.limit)}::bigint` : '') + (mods.offset !== undefined ? ` offset ${p.push(mods.offset)}::bigint` : '');
|
|
670
|
-
// versions({ follow: true }):
|
|
727
|
+
// versions({ follow: true }): отрезки истории объектов шага по переносам id (reclass, rekey) в обе стороны — версии
|
|
728
|
+
// id с номерами (lo, hi]. Надгробие переноса кончает отрезок, первая версия на новом id начинает: объект, заведённый
|
|
729
|
+
// на прежнем id после переноса, в историю не попадает (план 2.0, 7.1 №2). Тот же обход — history_segments в
|
|
730
|
+
// lib/sql/75-history.sql (diff и blame); здесь он в самом запросе, чтобы работал и на схеме до up() этой версии.
|
|
671
731
|
if (mode === 'versions' && mods.follow) {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
732
|
+
// границы отрезка с версией r: строки переноса id — через парную строку по индексу log_moved (moved друг на друга),
|
|
733
|
+
// без прохода по версиям id
|
|
734
|
+
const bounds = (i, r) => `cross join lateral (select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < ${r}), 0), `
|
|
735
|
+
+ `coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= ${r}), 0)) as lo, `
|
|
736
|
+
+ `least(min(x.rev) filter (where x.op = 'delete' and x.rev >= ${r}), min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > ${r})) as hi `
|
|
737
|
+
+ `from ${S}.log m join ${S}.log x on x.moved = m.id and x.id = ${i} where m.moved = ${i}) b`;
|
|
738
|
+
ctes.push(`f(id, lo, hi, n) as (select z.id, b.lo, b.hi, 0 from ${ents} z ${bounds('z.id', 'z.rev')} `
|
|
739
|
+
+ `union select g.id, b.lo, b.hi, g.n from f cross join lateral (`
|
|
740
|
+
+ `select a.moved as id, (select t.rev from ${S}.log t where t.id = a.moved and t.op = 'delete' and t.moved = f.id `
|
|
741
|
+
+ `order by abs(t.seq - a.seq) limit 1) as r, f.n - 1 as n from ${S}.log a `
|
|
742
|
+
+ `where f.n <= 0 and a.id = f.id and a.rev = f.lo + 1 and a.moved is not null and a.op <> 'delete' `
|
|
743
|
+
+ `union all select t.moved, (select a.rev from ${S}.log a where a.id = t.moved and a.op <> 'delete' and a.moved = f.id `
|
|
744
|
+
+ `order by abs(a.seq - t.seq) limit 1), f.n + 1 from ${S}.log t `
|
|
745
|
+
+ `where f.n >= 0 and t.id = f.id and t.rev = f.hi and t.op = 'delete' and t.moved is not null) g ${bounds('g.id', 'g.r')} `
|
|
746
|
+
+ `where g.r is not null and abs(g.n) <= 1000)`);
|
|
675
747
|
}
|
|
676
748
|
const withClause = `with ${mode === 'versions' && mods.follow ? 'recursive ' : ''}${ctes.join(',\n')}\n`;
|
|
677
749
|
let text;
|
|
@@ -680,7 +752,12 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
680
752
|
case 'paths': {
|
|
681
753
|
const obj = keyed.map(({ key, node }) => `${lit(key)}::text, to_jsonb(h${node})`).join(', ');
|
|
682
754
|
const h = `h${lastNode}`;
|
|
683
|
-
|
|
755
|
+
const ties = (c) => {
|
|
756
|
+
const ids = cursorPath(c, others.map((k) => k.key));
|
|
757
|
+
return ids ? others.map((k, j) => ({ col: `h${k.node}.id`, val: ids[j], cast: '::uuid' })) : [];
|
|
758
|
+
};
|
|
759
|
+
text = `${withClause}select jsonb_build_object(${obj}) as path ${from}${whereOf([...tail, after(h, ties)])}`
|
|
760
|
+
+ `${orderBy(h, others.map((k) => `h${k.node}.id`))}${limitOffset()}`;
|
|
684
761
|
break;
|
|
685
762
|
}
|
|
686
763
|
case 'rows':
|
|
@@ -697,11 +774,17 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
697
774
|
// с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
|
|
698
775
|
// без sort: с follow — по времени через все связанные id, без follow — по id и номеру версии
|
|
699
776
|
const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : mods.follow ? 'v.at, v.id, v.rev' : 'v.id, v.rev';
|
|
777
|
+
// курсор — поле sort, id и номер версии: версии одного объекта с равным полем не теряются (план 2.0, 7.1 №4)
|
|
778
|
+
const cond = whereOf([after('v', (c) => {
|
|
779
|
+
const rev = cursorRev(c);
|
|
780
|
+
return rev === undefined ? [] : [{ col: 'v.rev', val: rev, cast: '::int' }];
|
|
781
|
+
})]);
|
|
700
782
|
text = mods.follow
|
|
701
|
-
? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from
|
|
702
|
-
+ `where l.id
|
|
783
|
+
? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from (select distinct f.id, f.lo, f.hi from f) s `
|
|
784
|
+
+ `cross join lateral (select ${colsOf('l')} from ${S}.log l where l.id = s.id and l.rev > s.lo and l.rev <= coalesce(s.hi, 2147483647)) l) v`
|
|
785
|
+
+ `${cond} order by ${order}${limitOffset()}`
|
|
703
786
|
: `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
704
|
-
+ `where l.id in (select z.id from ${ents} z)) v order by ${order}${limitOffset()}`;
|
|
787
|
+
+ `where l.id in (select z.id from ${ents} z)) v${cond} order by ${order}${limitOffset()}`;
|
|
705
788
|
break;
|
|
706
789
|
}
|
|
707
790
|
case 'diff':
|
|
@@ -728,12 +811,14 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
728
811
|
}
|
|
729
812
|
else {
|
|
730
813
|
const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
|
|
731
|
-
|
|
814
|
+
// у boolean в PostgreSQL нет min и max; false меньше true: наименьшее — «все ли true», наибольшее — «есть ли true»
|
|
815
|
+
const fn = aggLeaf === 'boolean' && agg.fn === 'min' ? 'bool_and' : aggLeaf === 'boolean' && agg.fn === 'max' ? 'bool_or' : agg.fn;
|
|
816
|
+
text = `${withClause}select ${fn}((${acc})${cast}) as v from ${ents} z`;
|
|
732
817
|
}
|
|
733
818
|
break;
|
|
734
819
|
}
|
|
735
820
|
}
|
|
736
|
-
return { text: text.replaceAll(
|
|
821
|
+
return { text: text.replaceAll(SCHEMA_MARK, S), params: p.list, keys: pathKeys, aggLeaf };
|
|
737
822
|
}
|
|
738
823
|
/** Строка из to_jsonb(узла): служебные поля в $deleted / $depth, пустые необязательные — без ключа. */
|
|
739
824
|
export function toRow(raw) {
|
package/dist/sync.js
CHANGED
|
@@ -77,8 +77,12 @@ export function schemaToTs(s, indent = '') {
|
|
|
77
77
|
return JSON.stringify(s.const);
|
|
78
78
|
if (Array.isArray(s.enum))
|
|
79
79
|
return s.enum.map((v) => JSON.stringify(v)).join(' | ');
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const branches = (s.anyOf ?? s.oneOf);
|
|
81
|
+
// ветки из одних required у объекта с полями — условие, а не форма: тип — сам объект (план 2.0, 7.5 №1)
|
|
82
|
+
const onlyRequired = Array.isArray(branches) && s.properties !== undefined
|
|
83
|
+
&& branches.every((b) => Object.keys(b).every((k) => k === 'required'));
|
|
84
|
+
if (Array.isArray(branches) && !onlyRequired)
|
|
85
|
+
return branches.map((b) => schemaToTs(b, indent)).join(' | ');
|
|
82
86
|
const types = Array.isArray(s.type) ? s.type : [s.type];
|
|
83
87
|
return types.map((t) => {
|
|
84
88
|
switch (t) {
|
|
@@ -110,12 +114,15 @@ export function generateTypes(registry, opts = {}) {
|
|
|
110
114
|
for (const c of registry.all().sort((a, b) => a.name.localeCompare(b.name))) {
|
|
111
115
|
if (!opts.includeSystem && c.tenant === '2eba6d0a-1edd-4bb6-a85a-a703dab49035')
|
|
112
116
|
continue;
|
|
113
|
-
|
|
117
|
+
// идентификатор TypeScript — буквы Unicode, цифры, _ и $: кириллические имена классов допустимы (план 2.0, 7.5 №1)
|
|
118
|
+
if (!/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(c.name))
|
|
114
119
|
throw new LetopisError('invalid_class', `types: имя класса ${c.name} не годится для идентификатора TypeScript`);
|
|
115
120
|
// additionalProperties: false верхнего уровня в тип не переносится — лишние ключи и так ошибка типов
|
|
116
121
|
const { additionalProperties: _closed, ...open } = c.schema;
|
|
117
122
|
void _closed;
|
|
118
|
-
|
|
123
|
+
// объектный литерал — интерфейс; класс без полей, union и открытая схема — псевдоним типа (иначе неверный TypeScript)
|
|
124
|
+
const data = schemaToTs(open);
|
|
125
|
+
out.push(/^\{[\s\S]*\}$/.test(data) && !/\}\s*&/.test(data) ? `export interface ${c.name}Data ${data}` : `export type ${c.name}Data = ${data};`);
|
|
119
126
|
const ends = Object.entries(c.ends).map(([role, e]) => ` ${JSON.stringify(role)}${e.required || (e.min ?? 0) > 0 ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
|
|
120
127
|
out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`, '');
|
|
121
128
|
}
|