letopis 2.0.0 → 3.0.1
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.md +491 -414
- package/CHANGELOG.md +361 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +969 -210
- package/api-contract.json +385 -0
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +7 -1
- package/dist/admin.js +16 -9
- package/dist/auth.d.ts +47 -11
- package/dist/auth.js +129 -15
- package/dist/chain.d.ts +52 -25
- package/dist/chain.js +445 -64
- package/dist/cli.js +72 -20
- package/dist/errors.d.ts +23 -6
- package/dist/errors.js +52 -13
- package/dist/index.d.ts +32 -15
- package/dist/index.js +209 -68
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +10 -6
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +654 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +34 -16
- package/dist/messages/access.d.ts +10 -0
- package/dist/messages/access.js +13 -0
- package/dist/messages/api.d.ts +76 -0
- package/dist/messages/api.js +109 -7
- package/dist/messages/boot.d.ts +5 -0
- package/dist/messages/boot.js +11 -4
- package/dist/messages/codes.js +4 -4
- package/dist/messages/index.d.ts +5 -2
- package/dist/messages/index.js +5 -2
- package/dist/messages/log.d.ts +232 -0
- package/dist/messages/log.js +105 -0
- package/dist/messages/mcp.js +7 -7
- package/dist/messages/model.d.ts +40 -1
- package/dist/messages/model.js +52 -3
- package/dist/messages/query.d.ts +10 -0
- package/dist/messages/query.js +13 -0
- package/dist/messages/sql-core.d.ts +15 -1
- package/dist/messages/sql-core.js +20 -3
- package/dist/messages/sql-time.d.ts +15 -0
- package/dist/messages/sql-time.js +23 -0
- package/dist/messages/write.d.ts +52 -0
- package/dist/messages/write.js +77 -6
- package/dist/model.d.ts +10 -2
- package/dist/model.js +28 -6
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +6 -0
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +4 -5
- package/dist/sync.js +53 -4
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +18 -14
- package/dist/types.d.ts +91 -8
- package/dist/types.js +8 -3
- package/dist/up.d.ts +8 -5
- package/dist/up.js +71 -23
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +9 -1
- package/dist/validate.js +24 -7
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +13 -0
- package/dist/write.d.ts +84 -3
- package/dist/write.js +427 -40
- package/llms.txt +38 -17
- package/package.json +12 -6
- package/sql/20-context.sql +11 -3
- package/sql/30-validate.sql +51 -10
- package/sql/40-class.sql +21 -1
- package/sql/60-write.sql +18 -6
- package/sql/80-auth.sql +88 -17
- package/sql/90-time.sql +34 -6
- package/sql/99-revision.sql +5 -1
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +5 -1
package/dist/sql.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { lerr, lmsg, similar } from './errors.js';
|
|
21
21
|
import { isOp } from './ops.js';
|
|
22
22
|
import { OP } from './types.js';
|
|
23
|
+
import { PG_UUID_RE, UUID_RE } from './uuid.js';
|
|
23
24
|
let nodeSeq = 0;
|
|
24
25
|
/** Уникальная метка узла пути. */
|
|
25
26
|
export const nextNodeKey = () => ++nodeSeq;
|
|
@@ -162,12 +163,33 @@ function leafOf(reg, classes, path) {
|
|
|
162
163
|
}
|
|
163
164
|
return 'jsonb';
|
|
164
165
|
}
|
|
165
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Тип поля у классов шага не один (потомок переопределил тип: Base.price — число, Child.price — строка; у другой цели
|
|
168
|
+
* полиморфного конца поля нет или оно другого типа): приведение к типу листа должно быть безопасным — значение, которое
|
|
169
|
+
* к нему не приводится, сравнивается и сортируется как null, а не роняет весь запрос ошибкой приведения.
|
|
170
|
+
*/
|
|
171
|
+
function leafMixed(reg, classes, path, leaf) {
|
|
172
|
+
return !!CAST[leaf] && classes.some((c) => reg.leafType(c.name, path) !== leaf);
|
|
173
|
+
}
|
|
174
|
+
/** Имя типа для pg_input_is_valid по типу листа. */
|
|
175
|
+
const PG_TYPE = {
|
|
176
|
+
text: 'text', jsonb: 'jsonb', numeric: 'numeric', boolean: 'boolean', timestamptz: 'timestamptz', date: 'date', uuid: 'uuid',
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Значение поля, приведённое к типу листа (скобки обязательны: '::' сильнее '->>'). safe — тип поля в семействе
|
|
180
|
+
* разный (leafMixed): то, что не приводится, — null.
|
|
181
|
+
*/
|
|
182
|
+
function castLeaf(expr, leaf, safe) {
|
|
183
|
+
const cast = CAST[leaf];
|
|
184
|
+
if (!cast)
|
|
185
|
+
return expr;
|
|
186
|
+
return safe ? `(case when pg_input_is_valid(${expr}, ${lit(PG_TYPE[leaf])}) then (${expr})${cast} end)` : `(${expr})${cast}`;
|
|
187
|
+
}
|
|
188
|
+
function compileOp(path, leaf, o, p, safe = false) {
|
|
166
189
|
const lhs = accessor(path);
|
|
167
190
|
const cast = CAST[leaf];
|
|
168
191
|
const pc = PCAST[leaf];
|
|
169
|
-
|
|
170
|
-
const L = cast ? (a) => `(${lhs(a)})${cast}` : lhs;
|
|
192
|
+
const L = cast ? (a) => castLeaf(lhs(a), leaf, safe) : lhs;
|
|
171
193
|
const args = o.args;
|
|
172
194
|
switch (o[OP]) {
|
|
173
195
|
case 'ne':
|
|
@@ -228,10 +250,10 @@ function compileOp(path, leaf, o, p) {
|
|
|
228
250
|
return (a) => `${lhs(a)} is null`;
|
|
229
251
|
case 'not': {
|
|
230
252
|
if (isOp(args[0])) {
|
|
231
|
-
const inner = compileOp(path, leaf, args[0], p);
|
|
253
|
+
const inner = compileOp(path, leaf, args[0], p, safe);
|
|
232
254
|
return (a) => `not coalesce(${inner(a)}, false)`;
|
|
233
255
|
}
|
|
234
|
-
return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p);
|
|
256
|
+
return compileOp(path, leaf, { [OP]: 'ne', args: [args[0]] }, p, safe);
|
|
235
257
|
}
|
|
236
258
|
default:
|
|
237
259
|
throw lerr('query.unknown_op', { op: String(o[OP]) });
|
|
@@ -266,10 +288,22 @@ function compileTags(v, p) {
|
|
|
266
288
|
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !isOp(v);
|
|
267
289
|
/** Объект-строка ответа как значение: берётся id. */
|
|
268
290
|
const idOfValue = (v) => typeof v === 'string' ? v : isPlain(v) && typeof v.id === 'string' ? v.id : undefined;
|
|
291
|
+
/**
|
|
292
|
+
* id фильтра шага, `{ id }` или конца — uuid, который примет приведение базы (PG_UUID_RE): иначе invalid_query до запроса,
|
|
293
|
+
* а не сырая ошибка PostgreSQL 22P02 (у null — TypeError; план 2.1, A7).
|
|
294
|
+
*/
|
|
295
|
+
function idCheck(v, where) {
|
|
296
|
+
if (typeof v === 'string' && PG_UUID_RE.test(v))
|
|
297
|
+
return v;
|
|
298
|
+
throw lerr('query.bad_uuid', { where, value: shown(v) });
|
|
299
|
+
}
|
|
269
300
|
function compileFilter(reg, classes, filter, p) {
|
|
270
301
|
const out = { ends: [], frags: [] };
|
|
271
302
|
if (filter === undefined)
|
|
272
303
|
return out;
|
|
304
|
+
const at = `${classes[0]?.name ?? ''}(…)`;
|
|
305
|
+
if (filter === null)
|
|
306
|
+
idCheck(filter, at);
|
|
273
307
|
// or(…): дизъюнкция под-фильтров — только перепроверка
|
|
274
308
|
if (isOp(filter)) {
|
|
275
309
|
if (filter[OP] !== 'or')
|
|
@@ -283,11 +317,11 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
283
317
|
return out;
|
|
284
318
|
}
|
|
285
319
|
if (typeof filter === 'string') {
|
|
286
|
-
out.ids = [filter];
|
|
320
|
+
out.ids = [idCheck(filter, at)];
|
|
287
321
|
return out;
|
|
288
322
|
}
|
|
289
323
|
if (Array.isArray(filter)) {
|
|
290
|
-
out.ids = filter.map((x) => idOfValue(x) ?? refuse('query.id_list'));
|
|
324
|
+
out.ids = filter.map((x) => idCheck(idOfValue(x) ?? refuse('query.id_list'), at));
|
|
291
325
|
return out;
|
|
292
326
|
}
|
|
293
327
|
const props = (classes[0]?.schema.properties ?? {});
|
|
@@ -303,7 +337,8 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
303
337
|
continue;
|
|
304
338
|
const at = [...path, k];
|
|
305
339
|
if (isOp(v)) {
|
|
306
|
-
|
|
340
|
+
const t = leafOf(reg, classes, at);
|
|
341
|
+
out.frags.push(compileOp(at, t, v, p, leafMixed(reg, classes, at, t)));
|
|
307
342
|
hadOps = true;
|
|
308
343
|
}
|
|
309
344
|
else if (isPlain(v)) {
|
|
@@ -324,10 +359,11 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
324
359
|
if (value === undefined)
|
|
325
360
|
continue;
|
|
326
361
|
if (key === 'id') {
|
|
362
|
+
const w = `${classes[0]?.name ?? ''}({ id })`;
|
|
327
363
|
if (typeof value === 'string')
|
|
328
|
-
out.ids = [value];
|
|
364
|
+
out.ids = [idCheck(value, w)];
|
|
329
365
|
else if (Array.isArray(value))
|
|
330
|
-
out.ids = value.map((x) => idOfValue(x) ?? refuse('query.filter_id_list'));
|
|
366
|
+
out.ids = value.map((x) => idCheck(idOfValue(x) ?? refuse('query.filter_id_list'), w));
|
|
331
367
|
else
|
|
332
368
|
throw lerr('query.filter_id');
|
|
333
369
|
continue;
|
|
@@ -338,7 +374,7 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
338
374
|
out.frags.push((a) => `coalesce(${a}.links->${role}, 'null'::jsonb) in ('null'::jsonb, '[]'::jsonb)`);
|
|
339
375
|
continue;
|
|
340
376
|
}
|
|
341
|
-
const id = idOfValue(value) ?? refuse('query.end_value', { role: key });
|
|
377
|
+
const id = idCheck(idOfValue(value) ?? refuse('query.end_value', { role: key }), `${classes[0]?.name ?? ''}({ ${key} })`);
|
|
342
378
|
out.ends.push(id);
|
|
343
379
|
const ph = p.push(id);
|
|
344
380
|
out.frags.push((a) => `${SCHEMA_MARK}.role_has(${a}.links, array[${role}], ${ph}::uuid)`);
|
|
@@ -347,7 +383,8 @@ function compileFilter(reg, classes, filter, p) {
|
|
|
347
383
|
// поле данных: не описано ни в одном классе шага — invalid_query с похожими (план 2.0, этап 3, п. 3)
|
|
348
384
|
checkField(classes, key, `${classes[0]?.name ?? ''}({ ${key} })`, true);
|
|
349
385
|
if (isOp(value)) {
|
|
350
|
-
|
|
386
|
+
const t = leafOf(reg, classes, [key]);
|
|
387
|
+
out.frags.push(compileOp([key], t, value, p, leafMixed(reg, classes, [key], t)));
|
|
351
388
|
continue;
|
|
352
389
|
}
|
|
353
390
|
if (isPlain(value)) {
|
|
@@ -379,7 +416,7 @@ function asFrags(cf, p) {
|
|
|
379
416
|
}
|
|
380
417
|
return frags;
|
|
381
418
|
}
|
|
382
|
-
function sortSpec(order, leaf) {
|
|
419
|
+
function sortSpec(order, leaf, mixed) {
|
|
383
420
|
const f = order === 'updated' ? 'at' : order;
|
|
384
421
|
const col = { field: order, nullable: false };
|
|
385
422
|
if (f === 'at')
|
|
@@ -391,12 +428,16 @@ function sortSpec(order, leaf) {
|
|
|
391
428
|
if (f.startsWith('data.')) {
|
|
392
429
|
const path = f.slice(5).split('.');
|
|
393
430
|
const t = leaf(path);
|
|
394
|
-
|
|
431
|
+
const safe = mixed(path, t);
|
|
432
|
+
return { expr: (a) => castLeaf(accessor(path)(a), t, safe), pcast: PCAST[t], field: order, type: t, nullable: true, safe };
|
|
395
433
|
}
|
|
434
|
+
// имя поля данных без префикса (sort('code')) — подсказка «data.code?»: принять его молча нельзя — колонки at, rev, id
|
|
435
|
+
// и поле данных с тем же именем разошлись бы, а курсор cursorOf(строка, 'code') — с ними
|
|
436
|
+
if (/^[\p{L}_$][\p{L}\p{N}_$]*(\.[\p{L}\p{N}_$]+)*$/u.test(order))
|
|
437
|
+
throw lerr('query.sort_field_data', { field: order });
|
|
396
438
|
throw lerr('query.sort_field', { field: order });
|
|
397
439
|
}
|
|
398
|
-
/** Формы значений курсора — как у форматов uuid, date и date-time валидатора (validate.ts). */
|
|
399
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
440
|
+
/** Формы значений курсора — как у форматов uuid (UUID_RE, uuid.ts), date и date-time валидатора (validate.ts). */
|
|
400
441
|
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
401
442
|
const TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?(?:Z|[+-](\d{2}):(\d{2}))$/;
|
|
402
443
|
/** Дата (и время), которую примет приведение базы ::date / ::timestamptz: форма и смысл полей. */
|
|
@@ -468,7 +509,10 @@ function cursorParams(c, sort) {
|
|
|
468
509
|
const { v, id } = c;
|
|
469
510
|
if (typeof id !== 'string' || !UUID_RE.test(id))
|
|
470
511
|
throw lerr('query.cursor_id', { id: shown(id) });
|
|
471
|
-
|
|
512
|
+
let value = v === null && sort.nullable ? null : cursorValue(v, sort.type);
|
|
513
|
+
// строка потомка с другим типом поля (курсор её значения) стоит среди null — как в castLeaf
|
|
514
|
+
if (value === undefined && sort.safe && v !== undefined && typeof v !== 'object')
|
|
515
|
+
value = null;
|
|
472
516
|
if (value === undefined) {
|
|
473
517
|
// у поля данных подходит и курсор null — своя запись, а не склейка « или null»
|
|
474
518
|
throw lerr(sort.nullable ? 'query.cursor_value_nullable' : 'query.cursor_value', { value: shown(v), field: sort.field, expect: lmsg(CURSOR_EXPECT[sort.type]) });
|
|
@@ -599,7 +643,9 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
599
643
|
const dataP = lazy(() => (cf.eq ? `${p.push(cf.eq)}::jsonb` : 'null'));
|
|
600
644
|
const endsP = lazy(() => (cf.ends.length ? `${p.push(cf.ends)}::uuid[]` : 'null'));
|
|
601
645
|
const idsP = lazy(() => (cf.ids ? `${p.push(cf.ids)}::uuid[]` : 'null'));
|
|
602
|
-
|
|
646
|
+
// надгробия узла: withDeleted() этого шага (план 2.1, A9) или versions() по явным id последнего шага — история
|
|
647
|
+
// удалённого объекта (план 2.1, A10)
|
|
648
|
+
const dead = step.withDeleted === true || (mode === 'versions' && i === lastNode && cf.ids !== undefined && step.deepMax === undefined);
|
|
603
649
|
const dataCond = (v) => (cf.eq ? ` and ${v}.data @> ${dataP()}` : '');
|
|
604
650
|
const endsCond = (col) => (cf.ends.length ? ` and ${col} @> ${endsP()}` : '');
|
|
605
651
|
/** Версии журнала: последняя версия каждого id не позже момента. */
|
|
@@ -717,15 +763,19 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
717
763
|
joins.push(`join p${i} on p${i}.src = h${a}.id join n${i} h${i} on h${i}.id = p${i}.id`);
|
|
718
764
|
}
|
|
719
765
|
// ключи шагов в путях
|
|
766
|
+
// повтор имени — суффикс _2, _3…, свободный среди всех уже выданных ключей: алиас x_2 и второй шаг x не
|
|
767
|
+
// совпадают (x, x_2, x → x, x_2, x_3), иначе узел терялся бы в jsonb_build_object
|
|
720
768
|
const keyed = [];
|
|
721
|
-
const
|
|
769
|
+
const used = new Set();
|
|
722
770
|
steps.forEach((s, i) => {
|
|
723
771
|
if (s.pivotKey !== undefined)
|
|
724
772
|
return;
|
|
725
773
|
const base = s.aliasKey ?? s.name;
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
774
|
+
let key = base;
|
|
775
|
+
for (let n = 2; used.has(key); n++)
|
|
776
|
+
key = `${base}_${n}`;
|
|
777
|
+
used.add(key);
|
|
778
|
+
keyed.push({ key, node: i });
|
|
729
779
|
});
|
|
730
780
|
const from = `from n0 h0${joins.length ? ' ' + joins.join(' ') : ''}`;
|
|
731
781
|
const whereOf = (conds) => {
|
|
@@ -738,9 +788,10 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
738
788
|
const single = tail.length === 0 && !steps.some((st) => st.pivotKey !== undefined);
|
|
739
789
|
const ents = single ? `n${lastNode}` : `(select z0.* from n${lastNode} z0 where z0.id in (select h${lastNode}.id ${from}${whereOf(tail)}))`;
|
|
740
790
|
const leaf = (path) => leafOf(reg, rowsNode(lastNode), path);
|
|
791
|
+
const mixed = (path, t) => leafMixed(reg, rowsNode(lastNode), path, t);
|
|
741
792
|
if (mods.order?.startsWith('data.'))
|
|
742
793
|
checkField(rowsNode(lastNode), topField(mods.order), `sort('${mods.order}')`, false);
|
|
743
|
-
const sort = mods.order ? sortSpec(mods.order, leaf) : undefined;
|
|
794
|
+
const sort = mods.order ? sortSpec(mods.order, leaf, mixed) : undefined;
|
|
744
795
|
const dir = mods.desc ? ' desc' : '';
|
|
745
796
|
/** Порядок: поле sort, id строки и разделители равных (id прочих узлов пути). */
|
|
746
797
|
const orderBy = (a, more = []) => (sort ? ` order by ${[sort.expr(a), `${a}.id`, ...more].map((x) => `${x}${dir}`).join(', ')}` : '');
|
|
@@ -828,10 +879,11 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
828
879
|
text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1${limitOffset()}`;
|
|
829
880
|
}
|
|
830
881
|
else {
|
|
831
|
-
const
|
|
882
|
+
const t = agg.fn === 'sum' || agg.fn === 'avg' ? 'numeric' : aggLeaf;
|
|
832
883
|
// у boolean в PostgreSQL нет min и max; false меньше true: наименьшее — «все ли true», наибольшее — «есть ли true»
|
|
833
884
|
const fn = aggLeaf === 'boolean' && agg.fn === 'min' ? 'bool_and' : aggLeaf === 'boolean' && agg.fn === 'max' ? 'bool_or' : agg.fn;
|
|
834
|
-
|
|
885
|
+
// тип поля в семействе разный — значения, которые не приводятся к типу листа, агрегат пропускает (как null)
|
|
886
|
+
text = `${withClause}select ${fn}(${castLeaf(acc, t, aggLeaf === t && mixed(path, t))}) as v from ${ents} z`;
|
|
835
887
|
}
|
|
836
888
|
break;
|
|
837
889
|
}
|
package/dist/sync.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface SyncResult {
|
|
|
17
17
|
unchanged: string[];
|
|
18
18
|
/** Классы системного арендатора: их меняет только установка. */
|
|
19
19
|
skipped: string[];
|
|
20
|
-
/** Правила refine, которых нет в
|
|
20
|
+
/** Правила refine, которых нет в базе (преобразования Zod syncModels отклоняет — model.transform_unsupported). */
|
|
21
21
|
lost: LostRule[];
|
|
22
22
|
}
|
|
23
23
|
export declare function syncModels(db: DbCore, models: readonly AnyModel[], opts?: {
|
|
@@ -27,10 +27,9 @@ export declare function syncModels(db: DbCore, models: readonly AnyModel[], opts
|
|
|
27
27
|
export declare function schemaToTs(s: JsonSchema | undefined, indent?: string): string;
|
|
28
28
|
/**
|
|
29
29
|
* Объявления `<Класс>Data` и `<Класс>Links` для классов реестра и `<Класс>Model` — имя, алиас, концы с классами целей
|
|
30
|
-
* и
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* запись каталога model.types_header.
|
|
30
|
+
* и предки, — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`. Классы арендатора
|
|
31
|
+
* System (системные и классы сидов пакета) — только с includeSystem (`letopis types --system`); без него системные
|
|
32
|
+
* классы даёт встроенный набор SystemModels. Первая строка — пометка «Сгенерировано letopis types — не править руками.»
|
|
34
33
|
*/
|
|
35
34
|
export declare function generateTypes(registry: Registry, opts?: {
|
|
36
35
|
includeSystem?: boolean;
|
package/dist/sync.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lerr, lmsg } from './errors.js';
|
|
2
2
|
import { internalsOf } from './internal.js';
|
|
3
|
+
import { NOLOG, traced } from './log.js';
|
|
3
4
|
import { RESERVED_CLASS_NAMES } from './types.js';
|
|
4
5
|
const ROLLBACK = Symbol('sync-dry-run');
|
|
5
6
|
/** Канонический JSON: ключи по алфавиту — сравнение описаний без учёта порядка ключей. */
|
|
@@ -11,6 +12,14 @@ function canon(v) {
|
|
|
11
12
|
return JSON.stringify(v);
|
|
12
13
|
}
|
|
13
14
|
export async function syncModels(db, models, opts = {}) {
|
|
15
|
+
// событие sync (логирование 3.0): логгер — фасада, которым пишут классы
|
|
16
|
+
return traced(internalsOf(db)?.log ?? NOLOG, 'sync', { op: 'syncModels' }, () => syncCore(db, models, opts), (r) => ({
|
|
17
|
+
rows: r.created.length + r.updated.length,
|
|
18
|
+
detail: { applied: r.applied, models: models.length, created: r.created, updated: r.updated, unchanged: r.unchanged.length,
|
|
19
|
+
skipped: r.skipped, lost: r.lost.length },
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
async function syncCore(db, models, opts) {
|
|
14
23
|
// хаб с именем метода цепочки или корня db (модель собрана мимо hub()) — отказ до записи (план 2.0, этап 4, п. 2;
|
|
15
24
|
// 7.10 №3); связь с таким именем отклоняет база
|
|
16
25
|
for (const m of models) {
|
|
@@ -18,6 +27,13 @@ export async function syncModels(db, models, opts = {}) {
|
|
|
18
27
|
throw lerr('model.sync_hub_name_reserved', { name: m.name });
|
|
19
28
|
}
|
|
20
29
|
const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
|
|
30
|
+
// преобразования Zod (.trim(), .toLowerCase(), .transform(), z.preprocess …) база и валидатор letopis не исполняют —
|
|
31
|
+
// данные записались бы как есть: отказ до записи (план 2.1, A11); refine остаётся в отчёте lost
|
|
32
|
+
const transforms = res.lost.filter((l) => l.kind !== 'refine');
|
|
33
|
+
if (transforms.length) {
|
|
34
|
+
throw lerr('model.transform_unsupported', { rules: transforms.map((l) => `${l.path} (${l.kind})`).join(', ') }, { detail: { rules: transforms } });
|
|
35
|
+
}
|
|
36
|
+
checkEndRoles(db.registry, models);
|
|
21
37
|
const S = `"${db.schema.replace(/"/g, '""')}"`;
|
|
22
38
|
// предок раньше потомка: потомка класса, которого ещё нет, база отклоняет. Круг родителей (модель, собранная мимо
|
|
23
39
|
// hub()) так не упорядочить — отказ до записи с самим кругом (хвост ревью доработок после 1.2.0: раньше обход молча
|
|
@@ -75,6 +91,40 @@ export async function syncModels(db, models, opts = {}) {
|
|
|
75
91
|
await db.refresh();
|
|
76
92
|
return res;
|
|
77
93
|
}
|
|
94
|
+
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
95
|
+
/** Набор классов конца — как class_set() базы: список без повторов по порядку. */
|
|
96
|
+
const classSet = (c) => [...new Set(typeof c === 'string' ? [c] : c)].sort().join(', ');
|
|
97
|
+
/**
|
|
98
|
+
* Имя роли конца — одно на арендатора: в любом другом классе (своём или системном) роль с тем же именем обязана вести
|
|
99
|
+
* на тот же набор классов (база: sql-write.end_other_class в 60-write.sql, проверка ends при записи Class). Системные
|
|
100
|
+
* классы занимают роли User и Tenant (member → Account), Group и Object (rule → Resource). Проверка — до записи, по
|
|
101
|
+
* реестру и самим моделям, с понятным текстом; база проверяет всё равно.
|
|
102
|
+
*/
|
|
103
|
+
function checkEndRoles(reg, models) {
|
|
104
|
+
const names = new Set(models.map((m) => m.name));
|
|
105
|
+
const seen = new Map();
|
|
106
|
+
for (const c of reg.all()) {
|
|
107
|
+
if (names.has(c.name))
|
|
108
|
+
continue;
|
|
109
|
+
for (const [role, e] of Object.entries(c.ends)) {
|
|
110
|
+
if (!seen.has(role) || c.tenant === SYSTEM_ID)
|
|
111
|
+
seen.set(role, { cls: c.name, set: classSet(e.class), system: c.tenant === SYSTEM_ID });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const m of models) {
|
|
115
|
+
for (const [role, e] of Object.entries(m.describe().ends ?? {})) {
|
|
116
|
+
const mine = classSet(e.class);
|
|
117
|
+
const other = seen.get(role);
|
|
118
|
+
if (other && other.set !== mine) {
|
|
119
|
+
if (other.system)
|
|
120
|
+
throw lerr('model.end_role_system', { end: role, model: m.name, cls: other.cls, target: other.set });
|
|
121
|
+
throw lerr('model.end_role_taken', { end: role, model: m.name, mine, cls: other.cls, target: other.set });
|
|
122
|
+
}
|
|
123
|
+
if (!other)
|
|
124
|
+
seen.set(role, { cls: m.name, set: mine, system: false });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
78
128
|
/** Тип TypeScript по JSON Schema подмножества. */
|
|
79
129
|
export function schemaToTs(s, indent = '') {
|
|
80
130
|
if (!s || Object.keys(s).length === 0)
|
|
@@ -116,10 +166,9 @@ export function schemaToTs(s, indent = '') {
|
|
|
116
166
|
}
|
|
117
167
|
/**
|
|
118
168
|
* Объявления `<Класс>Data` и `<Класс>Links` для классов реестра и `<Класс>Model` — имя, алиас, концы с классами целей
|
|
119
|
-
* и
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* запись каталога model.types_header.
|
|
169
|
+
* и предки, — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`. Классы арендатора
|
|
170
|
+
* System (системные и классы сидов пакета) — только с includeSystem (`letopis types --system`); без него системные
|
|
171
|
+
* классы даёт встроенный набор SystemModels. Первая строка — пометка «Сгенерировано letopis types — не править руками.»
|
|
123
172
|
*/
|
|
124
173
|
export function generateTypes(registry, opts = {}) {
|
|
125
174
|
const out = [`// ${lmsg('model.types_header')}`, ''];
|
package/dist/tx.d.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* записи. Неотправленное (ошибка отправки) остаётся в очереди до следующего интервала.
|
|
24
24
|
*/
|
|
25
25
|
import type postgres from 'postgres';
|
|
26
|
+
import { type Log } from './log.js';
|
|
26
27
|
type Sql = postgres.Sql<Record<string, unknown>>;
|
|
27
28
|
export type Tx = postgres.TransactionSql<Record<string, unknown>>;
|
|
28
29
|
/** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
|
|
@@ -54,11 +55,12 @@ export declare class Notifier {
|
|
|
54
55
|
private channel;
|
|
55
56
|
private intervalMs;
|
|
56
57
|
private onError;
|
|
58
|
+
private log;
|
|
57
59
|
private queue;
|
|
58
60
|
private timer;
|
|
59
61
|
private stopped;
|
|
60
62
|
private flushing;
|
|
61
|
-
constructor(sql: Sql, channel: string, intervalMs: number, onError?: (e: unknown) => void);
|
|
63
|
+
constructor(sql: Sql, channel: string, intervalMs: number, onError?: (e: unknown) => void, log?: Log);
|
|
62
64
|
/** Добавить нагрузки из letopis.changed (строка через запятую). */
|
|
63
65
|
add(changed: string | null | undefined): void;
|
|
64
66
|
private schedule;
|
|
@@ -108,6 +110,8 @@ export interface TxContext {
|
|
|
108
110
|
* результатов сбрасывает свои ответы сразу (§2.12); raw — запись через db.sql.
|
|
109
111
|
*/
|
|
110
112
|
onChanged?: (names: string[], raw: boolean) => void;
|
|
113
|
+
/** Логгер (lib/src/log.ts): повторы (tx.retry), транзакции db.begin() (tx.begin, tx.commit, tx.rollback). */
|
|
114
|
+
log?: Log;
|
|
111
115
|
}
|
|
112
116
|
/**
|
|
113
117
|
* Операция одной транзакцией с контекстом сессии и повтором при транзиентных ошибках и конфликте
|
|
@@ -145,7 +149,10 @@ export declare class ExplicitTx implements Executor {
|
|
|
145
149
|
private failure;
|
|
146
150
|
/** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
|
|
147
151
|
private raw;
|
|
152
|
+
/** Время открытия — для ms у tx.commit и tx.rollback. */
|
|
153
|
+
private readonly t0;
|
|
148
154
|
private constructor();
|
|
155
|
+
private get log();
|
|
149
156
|
static open(ctx: TxContext, renewed?: boolean): Promise<ExplicitTx>;
|
|
150
157
|
/** Транзакция ещё открыта (не зафиксирована и не откачена). */
|
|
151
158
|
get active(): boolean;
|
|
@@ -156,6 +163,8 @@ export declare class ExplicitTx implements Executor {
|
|
|
156
163
|
/** Advisory-блокировка до конца транзакции (отдельное от блокировок семейств пространство ключей). */
|
|
157
164
|
lock(...keys: (string | number)[]): Promise<void>;
|
|
158
165
|
commit(): Promise<void>;
|
|
166
|
+
/** Событие отката (tx.rollback) и ошибка, которой он вызван. */
|
|
167
|
+
private rolledBack;
|
|
159
168
|
rollback(): Promise<void>;
|
|
160
169
|
}
|
|
161
170
|
export {};
|
package/dist/tx.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LetopisError, fromDbError, lerr, lmsg } from './errors.js';
|
|
2
|
+
import { NOLOG } from './log.js';
|
|
2
3
|
/** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
|
|
3
4
|
export function isTransient(e) {
|
|
4
5
|
const code = e.code;
|
|
@@ -56,15 +57,17 @@ export class Notifier {
|
|
|
56
57
|
channel;
|
|
57
58
|
intervalMs;
|
|
58
59
|
onError;
|
|
60
|
+
log;
|
|
59
61
|
queue = new Set();
|
|
60
62
|
timer = null;
|
|
61
63
|
stopped = false;
|
|
62
64
|
flushing = Promise.resolve();
|
|
63
|
-
constructor(sql, channel, intervalMs, onError = () => { }) {
|
|
65
|
+
constructor(sql, channel, intervalMs, onError = () => { }, log = NOLOG) {
|
|
64
66
|
this.sql = sql;
|
|
65
67
|
this.channel = channel;
|
|
66
68
|
this.intervalMs = intervalMs;
|
|
67
69
|
this.onError = onError;
|
|
70
|
+
this.log = log;
|
|
68
71
|
}
|
|
69
72
|
/** Добавить нагрузки из letopis.changed (строка через запятую). */
|
|
70
73
|
add(changed) {
|
|
@@ -89,7 +92,10 @@ export class Notifier {
|
|
|
89
92
|
return this.flushing;
|
|
90
93
|
const payloads = [...this.queue];
|
|
91
94
|
this.queue.clear();
|
|
92
|
-
this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() =>
|
|
95
|
+
this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() => {
|
|
96
|
+
if (this.log.wants('signal.send'))
|
|
97
|
+
this.log.emit('signal.send', { rows: payloads.length });
|
|
98
|
+
}, (e) => {
|
|
93
99
|
// не отправлено (обрыв связи, отказ базы) — обратно в очередь: уйдёт следующим интервалом
|
|
94
100
|
// (в 1.0.1 такие сигналы терялись, и кэш других процессов устаревал до срока)
|
|
95
101
|
for (const c of payloads)
|
|
@@ -204,6 +210,11 @@ export async function runTx(ctx, fn, opts = {}) {
|
|
|
204
210
|
if (bodyDone && !opts.read && dead)
|
|
205
211
|
throw commitUnknown(e);
|
|
206
212
|
if (attempt <= retries && (isTransient(e) || isLogConflict(e))) {
|
|
213
|
+
const log = ctx.log ?? NOLOG;
|
|
214
|
+
if (log.wants('tx.retry')) {
|
|
215
|
+
log.emit('tx.retry', { detail: { attempt, of: retries, sqlstate: e.code,
|
|
216
|
+
...(isLogConflict(e) ? { conflict: 'log_id_rev' } : {}) } });
|
|
217
|
+
}
|
|
207
218
|
await retryDelay(attempt);
|
|
208
219
|
continue;
|
|
209
220
|
}
|
|
@@ -243,10 +254,15 @@ export class ExplicitTx {
|
|
|
243
254
|
failure = null;
|
|
244
255
|
/** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
|
|
245
256
|
raw = false;
|
|
257
|
+
/** Время открытия — для ms у tx.commit и tx.rollback. */
|
|
258
|
+
t0 = performance.now();
|
|
246
259
|
constructor(ctx, conn) {
|
|
247
260
|
this.ctx = ctx;
|
|
248
261
|
this.conn = conn;
|
|
249
262
|
}
|
|
263
|
+
get log() {
|
|
264
|
+
return this.ctx.log ?? NOLOG;
|
|
265
|
+
}
|
|
250
266
|
static async open(ctx, renewed = false) {
|
|
251
267
|
if (!renewed && ctx.before)
|
|
252
268
|
await ctx.before(ctx);
|
|
@@ -267,6 +283,9 @@ export class ExplicitTx {
|
|
|
267
283
|
return ExplicitTx.open(ctx, true);
|
|
268
284
|
throw le;
|
|
269
285
|
}
|
|
286
|
+
const log = ctx.log ?? NOLOG;
|
|
287
|
+
if (log.wants('tx.begin'))
|
|
288
|
+
log.emit('tx.begin', { op: 'begin', detail: { isolation: 'read committed', ...(ctx.reason ? { reason: ctx.reason } : {}) } });
|
|
270
289
|
return new ExplicitTx(ctx, conn);
|
|
271
290
|
}
|
|
272
291
|
/** Транзакция ещё открыта (не зафиксирована и не откачена). */
|
|
@@ -307,6 +326,7 @@ export class ExplicitTx {
|
|
|
307
326
|
await this.conn.unsafe('rollback').catch(() => { });
|
|
308
327
|
release(this.conn, this.broken);
|
|
309
328
|
this.failure = this.broken;
|
|
329
|
+
this.rolledBack('error', this.broken);
|
|
310
330
|
throw this.broken;
|
|
311
331
|
}
|
|
312
332
|
let changed = null;
|
|
@@ -331,18 +351,40 @@ export class ExplicitTx {
|
|
|
331
351
|
catch (e) {
|
|
332
352
|
this.failure ??= isConnectionError(e) ? commitUnknown(e) : fromDbError(e);
|
|
333
353
|
release(this.conn, e);
|
|
354
|
+
this.rolledBack('error', this.failure);
|
|
334
355
|
throw this.failure;
|
|
335
356
|
}
|
|
336
357
|
this.conn.release();
|
|
358
|
+
// транзакция с записью (write, delete, restore, db.sql, права, креды) — tx.commit уровня info: это и есть
|
|
359
|
+
// доказательство фиксации её событий записи (у них то же поле tx); без записи — debug, как прежде
|
|
360
|
+
const writes = this.log.writes ?? 0;
|
|
361
|
+
const level = writes > 0 ? 'info' : 'debug';
|
|
362
|
+
if (this.log.level(level)) {
|
|
363
|
+
this.log.emit('tx.commit', { op: 'commit', level, ms: performance.now() - this.t0,
|
|
364
|
+
detail: { writes, ...(changed ? { signals: changed.split(',').filter(Boolean).length } : {}) } });
|
|
365
|
+
}
|
|
337
366
|
this.ctx.notifier?.add(changed);
|
|
338
367
|
if (changed)
|
|
339
368
|
this.ctx.onChanged?.(changedList(changed), this.raw);
|
|
340
369
|
this.ctx.after?.();
|
|
341
370
|
}
|
|
371
|
+
/** Событие отката (tx.rollback) и ошибка, которой он вызван. */
|
|
372
|
+
rolledBack(reason, err) {
|
|
373
|
+
const log = this.log;
|
|
374
|
+
if (!log.enabled)
|
|
375
|
+
return;
|
|
376
|
+
if (err !== undefined)
|
|
377
|
+
log.error(err, { op: 'commit', ms: performance.now() - this.t0 });
|
|
378
|
+
if (log.wants('tx.rollback')) {
|
|
379
|
+
log.emit('tx.rollback', { op: reason === 'error' ? 'commit' : 'rollback', ms: performance.now() - this.t0,
|
|
380
|
+
detail: { reason, writes: log.writes ?? 0, ...(this.broken ? { broken: true } : {}) } });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
342
383
|
async rollback() {
|
|
343
384
|
if (this.done)
|
|
344
385
|
return;
|
|
345
386
|
this.done = true;
|
|
387
|
+
this.rolledBack('explicit');
|
|
346
388
|
try {
|
|
347
389
|
// соединение, оборванное ошибкой, не трогаем: postgres.js уронил бы процесс записью в закрытый сокет
|
|
348
390
|
if (!isConnectionError(this.broken))
|
package/dist/typed.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - переходы цепочки выводятся из концов, как в resolveHop (lib/src/sql.ts): от класса C — роли его семейства (C и
|
|
7
7
|
* потомков) и классы, связанные с семейством C концом в любую сторону (роль, имя которой совпало с именем класса,
|
|
8
8
|
* во время выполнения — шаг класса, так же и здесь);
|
|
9
|
-
* -
|
|
9
|
+
* - шаги ролей после глагола — только роли класса C: `set(id | строка | цепочка)`, `add` и `remove` — у множественного конца, `unset` —
|
|
10
10
|
* у необязательного;
|
|
11
11
|
* - поля фильтров, `or()` и агрегатов `sum`, `avg`, `min`, `max`, `countBy` — поля класса.
|
|
12
12
|
* Чего типы не знают: возврат к узлу связи из пути (pivot) от класса, с которым связь не связана концом, и роль чужого
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { ChainCore, ChainRef } from './chain.js';
|
|
18
18
|
import type { Cursor, Op, OP, Path, Row, Scalar } from './types.js';
|
|
19
|
-
import type { IncMarker } from './write.js';
|
|
19
|
+
import type { DeleteOptions, IncMarker, RestoreOptions } from './write.js';
|
|
20
20
|
/**
|
|
21
21
|
* То, что цепочкам нужно от модели: имя, алиас и типы для вывода — данные строки, концы строки, концы класса (роль →
|
|
22
22
|
* `{ targets: имена классов целей; many; required }`) и имена класса и его предков. Модели letopis/model и объявления
|
|
@@ -214,11 +214,11 @@ export type StepsOf<M extends readonly AnyModelLike[]> = {
|
|
|
214
214
|
} & {
|
|
215
215
|
[K in AllOf<M> as AliasOf<K>]: StepFnOf<K, M>;
|
|
216
216
|
};
|
|
217
|
-
/** Значение
|
|
217
|
+
/** Значение шага роли одиночного конца (`.Роль.set(…)`): id, строка ответа или цепочка. */
|
|
218
218
|
type SlotOne = string | {
|
|
219
219
|
readonly id: string;
|
|
220
220
|
} | ChainRef;
|
|
221
|
-
/** Значение
|
|
221
|
+
/** Значение шага роли множественного конца (`.Роль.add(…)`, `.remove(…)`): ещё и список id или строк. */
|
|
222
222
|
type SlotMany = SlotOne | readonly (string | {
|
|
223
223
|
readonly id: string;
|
|
224
224
|
})[];
|
|
@@ -248,7 +248,7 @@ type HopsOf<C, M extends readonly AnyModelLike[], All = AllOf<M>> = {
|
|
|
248
248
|
type Retyped = 'paths' | 'rows' | 'first' | 'versions' | 'limit' | 'offset' | 'sort' | 'asOf' | 'after' | 'withDeleted' | 'deep' | 'exact' | 'alias' | 'tags' | 'owner' | 'create' | 'update' | 'upsert' | 'delete' | 'forUpdate' | 'restore' | 'cache' | 'sum' | 'avg' | 'min' | 'max' | 'countBy';
|
|
249
249
|
/**
|
|
250
250
|
* Цепочка с текущим классом C (у шага-роли с несколькими целями — их союз): терминалы отдают RowOf<C>, шаги — переходы
|
|
251
|
-
* из концов,
|
|
251
|
+
* из концов, шаги ролей после глагола (`.Роль.set(…)`) — роли C, агрегаты — поля C.
|
|
252
252
|
*/
|
|
253
253
|
export type TypedChain<C, M extends readonly AnyModelLike[]> = Omit<ChainCore, Retyped> & {
|
|
254
254
|
paths(): Promise<Path[]>;
|
|
@@ -290,16 +290,20 @@ export type TypedChain<C, M extends readonly AnyModelLike[]> = Omit<ChainCore, R
|
|
|
290
290
|
upsert(data: Partial<DataOf<C>>, opts?: {
|
|
291
291
|
reason?: string;
|
|
292
292
|
}): TypedChain<C, M>;
|
|
293
|
-
/**
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Без опций — предпросмотр. `{ confirm: true, expect: предпросмотр }` — удалить ровно показанное (цели сужены фильтром, id, `tags()`,
|
|
295
|
+
* `owner()` или `limit()`); `{ confirm: true }` без `expect` — `invalid_query`;
|
|
296
|
+
* осознанно удалить текущий состав без сверки — `{ confirm: true, all: true }`.
|
|
297
|
+
*/
|
|
298
|
+
delete(opts?: DeleteOptions): TypedChain<C, M>;
|
|
299
299
|
forUpdate(): TypedChain<C, M>;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
/**
|
|
301
|
+
* `{ expect }` — надгробия `{ id, rev }`: восстановить ровно то, что ушло удалением; расхождение — отказ по смыслу
|
|
302
|
+
* (`write.restore_already`, `write.restore_id_taken`, `write.restore_stale`). `{ expect, partial: true }` — что можно, а
|
|
303
|
+
* остальное — строки `$action: 'skip'` с причиной `$why`. Без `expect` — `invalid_query` (`write.restore_without_expect`);
|
|
304
|
+
* осознанно вернуть все удалённые цели шага без сверки — `{ all: true }`.
|
|
305
|
+
*/
|
|
306
|
+
restore(opts: RestoreOptions): TypedChain<C, M>;
|
|
303
307
|
cache(opts?: {
|
|
304
308
|
ttl?: number;
|
|
305
309
|
}): TypedChain<C, M>;
|