letopis 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +14 -2
- package/AGENT-CHEATSHEET.md +14 -2
- package/CHANGELOG.md +162 -0
- package/README.en.md +132 -23
- package/README.md +122 -23
- package/dist/admin.d.ts +4 -0
- package/dist/admin.js +6 -0
- package/dist/cache.d.ts +33 -9
- package/dist/cache.js +81 -32
- package/dist/chain.d.ts +29 -7
- package/dist/chain.js +61 -12
- package/dist/cli.js +8 -5
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +11 -3
- package/dist/index.js +122 -18
- package/dist/load.d.ts +63 -0
- package/dist/load.js +293 -37
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +17 -2
- package/dist/tx.d.ts +9 -7
- package/dist/tx.js +15 -5
- package/dist/typed.d.ts +21 -6
- package/dist/types.d.ts +76 -3
- package/dist/types.js +7 -2
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/watch.d.ts +11 -2
- package/dist/watch.js +24 -6
- package/dist/write.d.ts +10 -1
- package/dist/write.js +32 -5
- package/llms.txt +4 -1
- package/package.json +2 -1
- package/sql/10-core.sql +8 -1
- package/sql/60-write.sql +96 -34
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +3 -3
- package/sql/90-time.sql +45 -9
- package/sql/95-seed.system.sql +12 -4
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +1 -0
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/dist/cache.js
CHANGED
|
@@ -6,23 +6,34 @@
|
|
|
6
6
|
* запрос разным пользователям отдаёт разные строки. Сброс:
|
|
7
7
|
* - свои записи — сразу после фиксации, по классам из letopis.changed (туда попадают и классы
|
|
8
8
|
* каскада, unset, смены класса и ключа); запись через db.sql — весь кэш;
|
|
9
|
-
* - чужие записи — по сигналу базы
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* - чужие записи — по сигналу базы (нагрузка по режиму установки, signals.ts): класс в арендаторе
|
|
10
|
+
* сигнала (ответ помнит токен арендатора запроса; сигнал System и процесс без ключа — во всех), в режиме
|
|
11
|
+
* opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class) и «*» —
|
|
12
|
+
* весь кэш; `session:<16 hex>` — ответы погашенной сессии, `session:*` — все;
|
|
13
|
+
* - гонка запроса и сигнала: счётчики сигналов по классам, парам «класс, арендатор» и сессиям
|
|
14
|
+
* снимаются до запроса, ответ кладётся, только если они не изменились;
|
|
13
15
|
* - (пере)подключение слушателя сигналов — весь кэш: сигналы за время обрыва потеряны; без слушателя
|
|
14
16
|
* кэш выключен (с ttlOnly — работает только по сроку);
|
|
15
17
|
* - срок (по умолчанию 60 с) — предохранитель; ответы истёкшей сессии не используются.
|
|
16
18
|
* Внутри db.begin() и в db.sql кэш не работает.
|
|
17
19
|
*/
|
|
18
20
|
import { createHash } from 'node:crypto';
|
|
21
|
+
import { ACL_CLASS_NAMES } from './types.js';
|
|
22
|
+
import { Signals } from './signals.js';
|
|
19
23
|
/** Классы, строки которых меняют права и схему: их сигнал сбрасывает весь кэш. */
|
|
20
|
-
export const ACL_CLASSES = new Set(
|
|
24
|
+
export const ACL_CLASSES = new Set(ACL_CLASS_NAMES);
|
|
25
|
+
/** Счётчиков сигналов в каждой карте не больше: имена в нагрузке присылает любая роль базы. */
|
|
26
|
+
const EPOCH_MAX = 100_000;
|
|
21
27
|
/** Начало sha256 токена — так база называет сессию в сигнале session:<16 hex>. */
|
|
22
28
|
export const sessionTag = (token) => token ? createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16) : '';
|
|
23
29
|
export class ResultCache {
|
|
30
|
+
sig;
|
|
31
|
+
allTenants;
|
|
24
32
|
map = new Map();
|
|
25
33
|
classEpoch = new Map();
|
|
34
|
+
/** Сигналы класса в арендаторе: по паре и по классу в любом арендаторе (для ответов без арендатора). */
|
|
35
|
+
pairEpoch = new Map();
|
|
36
|
+
anyEpoch = new Map();
|
|
26
37
|
sessionEpoch = new Map();
|
|
27
38
|
epoch = 0;
|
|
28
39
|
listening = false;
|
|
@@ -30,7 +41,13 @@ export class ResultCache {
|
|
|
30
41
|
ttlMs;
|
|
31
42
|
ttlOnly;
|
|
32
43
|
max;
|
|
33
|
-
|
|
44
|
+
/**
|
|
45
|
+
* sig — режим сигналов и ключ подключения (разбор нагрузки, токены арендаторов); allTenants —
|
|
46
|
+
* подключение видит строки всех арендаторов (обходит RLS): ответ сбрасывается сигналом из любого.
|
|
47
|
+
*/
|
|
48
|
+
constructor(opts = {}, sig = new Signals(), allTenants = false) {
|
|
49
|
+
this.sig = sig;
|
|
50
|
+
this.allTenants = allTenants;
|
|
34
51
|
this.ttlMs = (opts.ttl ?? 60) * 1000;
|
|
35
52
|
this.ttlOnly = opts.ttlOnly === true;
|
|
36
53
|
this.max = opts.max ?? 10_000;
|
|
@@ -43,14 +60,19 @@ export class ResultCache {
|
|
|
43
60
|
key(text, params, who) {
|
|
44
61
|
return JSON.stringify([text, params, who.token ?? '', who.account ?? '', who.tenant ?? '']);
|
|
45
62
|
}
|
|
46
|
-
/** Снимок счётчиков
|
|
47
|
-
token(classes, session) {
|
|
63
|
+
/** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
|
|
64
|
+
token(classes, session, tenantId) {
|
|
65
|
+
const tenant = this.allTenants ? null : this.sig.tenantToken(tenantId);
|
|
48
66
|
return {
|
|
49
67
|
epoch: this.epoch,
|
|
50
|
-
classes: classes.map((c) => [c, this.classEpoch.get(c) ?? 0]),
|
|
68
|
+
classes: classes.map((c) => [c, this.classEpoch.get(c) ?? 0, this.pairCount(c, tenant)]),
|
|
51
69
|
session: this.sessionEpoch.get(session) ?? 0,
|
|
70
|
+
tenant,
|
|
52
71
|
};
|
|
53
72
|
}
|
|
73
|
+
pairCount(cls, tenant) {
|
|
74
|
+
return (tenant === null ? this.anyEpoch.get(cls) : this.pairEpoch.get(`${cls}@${tenant}`)) ?? 0;
|
|
75
|
+
}
|
|
54
76
|
get(key) {
|
|
55
77
|
if (!this.enabled)
|
|
56
78
|
return undefined;
|
|
@@ -71,70 +93,97 @@ export class ResultCache {
|
|
|
71
93
|
return false;
|
|
72
94
|
const stale = tok.epoch !== this.epoch
|
|
73
95
|
|| tok.session !== (this.sessionEpoch.get(session) ?? 0)
|
|
74
|
-
|| tok.classes.some(([c, n]) => (this.classEpoch.get(c) ?? 0) !== n);
|
|
96
|
+
|| tok.classes.some(([c, n, m]) => (this.classEpoch.get(c) ?? 0) !== n || this.pairCount(c, tok.tenant) !== m);
|
|
75
97
|
if (stale) {
|
|
76
98
|
this.st.raced++;
|
|
77
99
|
return false;
|
|
78
100
|
}
|
|
79
101
|
if (this.map.size >= this.max)
|
|
80
102
|
this.map.delete(this.map.keys().next().value);
|
|
81
|
-
this.map.set(key, { value, classes: [...classes], session, expires: Date.now() + ttlMs, sessionExpires });
|
|
103
|
+
this.map.set(key, { value, classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
|
|
82
104
|
this.st.stores++;
|
|
83
105
|
return true;
|
|
84
106
|
}
|
|
85
|
-
/** Сбросить ответы, читавшие эти
|
|
107
|
+
/** Сбросить ответы, читавшие эти классы (во всех арендаторах). */
|
|
86
108
|
invalidate(classes) {
|
|
87
109
|
const set = new Set(classes);
|
|
88
110
|
if (!set.size)
|
|
89
111
|
return;
|
|
90
112
|
for (const c of set)
|
|
91
|
-
this.
|
|
113
|
+
this.bump(this.classEpoch, c);
|
|
92
114
|
for (const [k, e] of this.map)
|
|
93
115
|
if (e.classes.some((c) => set.has(c)))
|
|
94
116
|
this.map.delete(k);
|
|
95
117
|
}
|
|
118
|
+
/** Сбросить ответы класса в арендаторе (токен) и ответы без арендатора; null — во всех. */
|
|
119
|
+
invalidateIn(cls, tenant) {
|
|
120
|
+
if (tenant === null)
|
|
121
|
+
return this.invalidate([cls]);
|
|
122
|
+
this.bump(this.pairEpoch, `${cls}@${tenant}`);
|
|
123
|
+
this.bump(this.anyEpoch, cls);
|
|
124
|
+
for (const [key, e] of this.map)
|
|
125
|
+
if ((e.tenant === null || e.tenant === tenant) && e.classes.includes(cls))
|
|
126
|
+
this.map.delete(key);
|
|
127
|
+
}
|
|
96
128
|
/** Сбросить ответы сессии (тег — начало sha256 токена). */
|
|
97
129
|
invalidateSession(tag) {
|
|
98
|
-
this.
|
|
130
|
+
this.bump(this.sessionEpoch, tag);
|
|
99
131
|
for (const [k, e] of this.map)
|
|
100
132
|
if (e.session === tag)
|
|
101
133
|
this.map.delete(k);
|
|
102
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Счётчик сигналов по ключу (класс, пара «класс, арендатор», сессия). Ключи приходят в нагрузке сигнала, а её
|
|
137
|
+
* может прислать любая роль базы: при переполнении карта очищается, и снимки в полёте устаревают целиком
|
|
138
|
+
* (epoch), — иначе заливка разными именами копила бы счётчики без предела (ревью №1 точки А плана 1.1).
|
|
139
|
+
*/
|
|
140
|
+
bump(m, k) {
|
|
141
|
+
if (m.size >= EPOCH_MAX && !m.has(k)) {
|
|
142
|
+
m.clear();
|
|
143
|
+
this.epoch++;
|
|
144
|
+
}
|
|
145
|
+
m.set(k, (m.get(k) ?? 0) + 1);
|
|
146
|
+
}
|
|
103
147
|
/** Весь кэш. */
|
|
104
148
|
clear() {
|
|
105
149
|
this.epoch++;
|
|
106
150
|
this.map.clear();
|
|
107
151
|
}
|
|
152
|
+
/** Сброс по разобранному сигналу. */
|
|
153
|
+
apply(s) {
|
|
154
|
+
switch (s.kind) {
|
|
155
|
+
case 'session':
|
|
156
|
+
return this.invalidateSession(s.tag);
|
|
157
|
+
case 'class':
|
|
158
|
+
return ACL_CLASSES.has(s.cls) ? this.clear() : this.invalidateIn(s.cls, s.tenant);
|
|
159
|
+
case 'unknown':
|
|
160
|
+
// класса нет в реестрах процесса — и ответов с ним нет
|
|
161
|
+
return;
|
|
162
|
+
default:
|
|
163
|
+
// *, session:*, opaque без ключа, нагрузка не по режиму
|
|
164
|
+
return this.clear();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
108
167
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
168
|
+
* Нагрузки из letopis.changed своей транзакции (те же, что уйдут сигналом). raw — запись через db.sql:
|
|
169
|
+
* весь кэш.
|
|
111
170
|
*/
|
|
112
171
|
changed(names, raw = false) {
|
|
113
|
-
const classes = [];
|
|
114
172
|
let any = false;
|
|
115
173
|
for (const n of names) {
|
|
116
174
|
if (!n)
|
|
117
175
|
continue;
|
|
118
176
|
any = true;
|
|
119
|
-
if (
|
|
120
|
-
this.
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (n.startsWith('session:'))
|
|
124
|
-
this.invalidateSession(n.slice(8));
|
|
125
|
-
else
|
|
126
|
-
classes.push(n);
|
|
177
|
+
if (!raw)
|
|
178
|
+
this.apply(this.sig.parse(n));
|
|
127
179
|
}
|
|
128
|
-
if (raw && any)
|
|
180
|
+
if (raw && any)
|
|
129
181
|
this.clear();
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
this.invalidate(classes);
|
|
133
182
|
}
|
|
134
|
-
/** Сигнал от
|
|
135
|
-
signal(
|
|
183
|
+
/** Сигнал от базы: нагрузка или уже разобранный. */
|
|
184
|
+
signal(s) {
|
|
136
185
|
this.st.signals++;
|
|
137
|
-
this.
|
|
186
|
+
this.apply(typeof s === 'string' ? this.sig.parse(s) : s);
|
|
138
187
|
}
|
|
139
188
|
/** Слушатель (пере)подключился: сигналы за время обрыва потеряны — весь кэш. */
|
|
140
189
|
listen() {
|
package/dist/chain.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ResultCache } from './cache.js';
|
|
|
2
2
|
import type { ClassInfo, Registry } from './registry.js';
|
|
3
3
|
import { type Step } from './sql.js';
|
|
4
4
|
import type { Executor } from './tx.js';
|
|
5
|
-
import type { ChainMods, Cursor, Filter, Path, QueryEvent, Row } from './types.js';
|
|
5
|
+
import type { BlameOptions, BlameResult, ChainMods, Cursor, DiffOptions, DiffResult, Filter, Path, QueryEvent, Row } from './types.js';
|
|
6
6
|
export interface ChainCtx {
|
|
7
7
|
readonly reg: Registry;
|
|
8
8
|
readonly schema: string;
|
|
@@ -16,6 +16,9 @@ export interface ChainCtx {
|
|
|
16
16
|
readonly slowMs?: number;
|
|
17
17
|
/** Цепочка из батча: план копится в очереди до batch.run(). */
|
|
18
18
|
readonly batch?: BatchRef;
|
|
19
|
+
/** Причина изменения по умолчанию для глаголов (db.batch(имя, { reason })) и та, что стоит в транзакции (db.begin({ reason })). */
|
|
20
|
+
readonly reason?: string;
|
|
21
|
+
readonly txReason?: string;
|
|
19
22
|
/** Кэш результатов подключения и тот, кто спрашивает (ключ ответа, §2.12). */
|
|
20
23
|
readonly cache?: {
|
|
21
24
|
store: ResultCache;
|
|
@@ -61,6 +64,10 @@ export interface ChainCore {
|
|
|
61
64
|
versions(opts?: {
|
|
62
65
|
follow?: boolean;
|
|
63
66
|
}): Promise<Row[]>;
|
|
67
|
+
/** Что изменилось между двумя версиями или моментами (по умолчанию — предыдущая версия → последняя), план 1.1. */
|
|
68
|
+
diff(opts?: DiffOptions): Promise<DiffResult[]>;
|
|
69
|
+
/** Кто, когда и почему последним изменил каждое поле версии (по умолчанию текущей), план 1.1. */
|
|
70
|
+
blame(opts?: BlameOptions): Promise<BlameResult[]>;
|
|
64
71
|
/** Агрегаты по полю последнего шага ('data.<путь>') — по сущностям, считает база. */
|
|
65
72
|
sum(field: string): Promise<number | null>;
|
|
66
73
|
avg(field: string): Promise<number | null>;
|
|
@@ -92,31 +99,46 @@ export interface ChainCore {
|
|
|
92
99
|
/** Вставить узел: ленивая цепочка-паттерн или строка ответа; та же переменная повторно — возврат к её узлу. */
|
|
93
100
|
entity(x: Chain | Row): Chain;
|
|
94
101
|
/** Создать объект: концы — из пути и слотов; занятый ключ — ошибка exists с id. */
|
|
95
|
-
create(data?: Record<string, unknown
|
|
102
|
+
create(data?: Record<string, unknown>, opts?: {
|
|
103
|
+
reason?: string;
|
|
104
|
+
}): Chain;
|
|
96
105
|
/** Новая версия каждой цели шага: слияние патча в базе; inc(n) — приращение; { rev } — строгий режим. */
|
|
97
106
|
update(patch?: Record<string, unknown>, opts?: {
|
|
98
107
|
rev?: number;
|
|
108
|
+
reason?: string;
|
|
99
109
|
}): Chain;
|
|
100
110
|
/** Создать или заменить целиком (класс с ключом); строка ответа несёт $upsert. */
|
|
101
|
-
upsert(data: Record<string, unknown
|
|
111
|
+
upsert(data: Record<string, unknown>, opts?: {
|
|
112
|
+
reason?: string;
|
|
113
|
+
}): Chain;
|
|
102
114
|
/** Удалить цели шага с каскадом; без { confirm: true } — превью замыкания ($action). */
|
|
103
115
|
delete(opts?: {
|
|
104
116
|
confirm?: boolean;
|
|
117
|
+
reason?: string;
|
|
105
118
|
}): Chain;
|
|
106
119
|
/** Затереть строковые поля ('[erased]') и пометить тегом anonymized. */
|
|
107
|
-
anonymize(fields: string[]
|
|
120
|
+
anonymize(fields: string[], opts?: {
|
|
121
|
+
reason?: string;
|
|
122
|
+
}): Chain;
|
|
108
123
|
/** Сменить класс: у класса с ключом — новый id и перевод ссылок. */
|
|
109
|
-
reclass(cls: string, data?: Record<string, unknown
|
|
124
|
+
reclass(cls: string, data?: Record<string, unknown>, opts?: {
|
|
125
|
+
reason?: string;
|
|
126
|
+
}): Chain;
|
|
110
127
|
/** Чтение с блокировкой строк последнего шага до конца db.begin() (rows, first, ids). */
|
|
111
128
|
forUpdate(): Chain;
|
|
112
129
|
/** Восстановить удалённые объекты шага: последний снимок новой версией (id прежний). */
|
|
113
|
-
restore(
|
|
130
|
+
restore(opts?: {
|
|
131
|
+
reason?: string;
|
|
132
|
+
}): Chain;
|
|
114
133
|
/** Стереть историю удалённых объектов шага; без { confirm: true } — превью ($versions). */
|
|
115
134
|
purge(opts?: {
|
|
116
135
|
confirm?: boolean;
|
|
136
|
+
reason?: string;
|
|
117
137
|
}): Chain;
|
|
118
138
|
/** Новое значение ключа: строка с новым id, ссылки переведены (поля — данные, роли — концы). */
|
|
119
|
-
rekey(patch: Record<string, unknown
|
|
139
|
+
rekey(patch: Record<string, unknown>, opts?: {
|
|
140
|
+
reason?: string;
|
|
141
|
+
}): Chain;
|
|
120
142
|
/** Кэш результатов (§2.12): ответ хранится в памяти процесса ttl секунд (по умолчанию 60). */
|
|
121
143
|
cache(opts?: {
|
|
122
144
|
ttl?: number;
|
package/dist/chain.js
CHANGED
|
@@ -102,7 +102,7 @@ async function exec(ctx, q, mode, steps, mods = {}) {
|
|
|
102
102
|
if (hit)
|
|
103
103
|
return hit;
|
|
104
104
|
const tag = sessionTag(who.token);
|
|
105
|
-
const tok = store.token(classes, tag);
|
|
105
|
+
const tok = store.token(classes, tag, who.tenant);
|
|
106
106
|
const t0 = performance.now();
|
|
107
107
|
const S = `"${ctx.schema.replace(/"/g, '""')}"`;
|
|
108
108
|
const [res, exp] = await ctx.exec.run((tx) => Promise.all([
|
|
@@ -139,7 +139,7 @@ async function read(ctx, steps, mods, mode, agg) {
|
|
|
139
139
|
async function plan(ctx, steps, mods, mode, agg) {
|
|
140
140
|
precheckPlan(ctx.reg, steps);
|
|
141
141
|
const t0 = performance.now();
|
|
142
|
-
const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, steps, mods, mode, agg));
|
|
142
|
+
const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, steps, mods, mode, agg));
|
|
143
143
|
if (r.kind === 'read') {
|
|
144
144
|
report(ctx, mode === 'countPaths' ? 'count' : mode, steps, t0, r.res.length);
|
|
145
145
|
if (r.q === null) {
|
|
@@ -196,6 +196,28 @@ async function lockedRead(ctx, steps, mods, mode) {
|
|
|
196
196
|
report(ctx, mode, steps, t0, res.length);
|
|
197
197
|
return { q: rowsQ, res: mode === 'ids' ? res.map((r) => ({ id: r.row.id })) : res };
|
|
198
198
|
}
|
|
199
|
+
/** Причины для плана: по умолчанию у глаголов (у батча — своя) и та, что стоит в транзакции. */
|
|
200
|
+
const reasons = (ctx) => ({ reason: ctx.reason ?? ctx.txReason ?? '', txReason: ctx.txReason ?? '' });
|
|
201
|
+
/** Опция reason глагола (план 1.1, §2.1): проверяет precheckPlan. */
|
|
202
|
+
const reasonOf = (o) => (o?.reason !== undefined ? { reason: o.reason } : {});
|
|
203
|
+
/** diff({ from, to }): номер версии (целое ≥ 1) или момент (ISO-строка, Date). */
|
|
204
|
+
function versionRef(v, what, moments = true) {
|
|
205
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 2147483647)
|
|
206
|
+
return v;
|
|
207
|
+
if (moments && v instanceof Date && !Number.isNaN(+v))
|
|
208
|
+
return v.toISOString();
|
|
209
|
+
if (moments && typeof v === 'string' && !Number.isNaN(Date.parse(v)))
|
|
210
|
+
return v;
|
|
211
|
+
throw new LetopisError('invalid_query', `${what}: номер версии (целое ≥ 1)${moments ? ' или момент (ISO-строка, Date)' : ''}`);
|
|
212
|
+
}
|
|
213
|
+
/** blame({ at }): момент (ISO-строка, Date). */
|
|
214
|
+
function momentOf(v, what) {
|
|
215
|
+
if (v instanceof Date && !Number.isNaN(+v))
|
|
216
|
+
return v.toISOString();
|
|
217
|
+
if (typeof v === 'string' && !Number.isNaN(Date.parse(v)))
|
|
218
|
+
return v;
|
|
219
|
+
throw new LetopisError('invalid_query', `${what}: момент — ISO-строка или Date`);
|
|
220
|
+
}
|
|
199
221
|
/** Строка ответа: из чтения — to_jsonb узла, из плана — готовая строка записи. */
|
|
200
222
|
const rowOf = (r) => (r.done ? r.row : toRow(r.row));
|
|
201
223
|
/** Значение слота: id, список id, строка ответа или ленивая цепочка. */
|
|
@@ -283,6 +305,10 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
|
283
305
|
const last = steps[steps.length - 1];
|
|
284
306
|
if (!last)
|
|
285
307
|
throw new LetopisError('invalid_query', `${op.kind}() — нужен шаг класса: db.Класс(…).${op.kind}(…)`);
|
|
308
|
+
// батч: причина db.batch(имя, { reason }) — при постановке в очередь, а не при run(): очередь общая по имени, и
|
|
309
|
+
// run() может позвать фасад без причины или с другой (ревью №1 точки А плана 1.1)
|
|
310
|
+
if (ctx.batch && op.reason === undefined && ctx.reason !== undefined)
|
|
311
|
+
op = { ...op, reason: ctx.reason };
|
|
286
312
|
const next = last.op
|
|
287
313
|
? [...steps, { name: last.name, cls: last.cls, role: last.role, aliasKey: last.aliasKey, self: true, op, opMods: mods, nodeKey: nextNodeKey() }]
|
|
288
314
|
: [...steps.slice(0, -1), { ...last, op, opMods: mods }];
|
|
@@ -364,6 +390,29 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
|
364
390
|
const m = opts?.follow ? { ...mods, follow: true } : mods;
|
|
365
391
|
return (await read(ctx, steps, m, 'versions')).res.map((r) => toRow(r.row));
|
|
366
392
|
};
|
|
393
|
+
case 'diff':
|
|
394
|
+
return async (opts = {}) => {
|
|
395
|
+
warnNoHistory(steps, 'diff');
|
|
396
|
+
const hist = {
|
|
397
|
+
...(opts.from !== undefined ? { from: versionRef(opts.from, 'diff({ from })') } : {}),
|
|
398
|
+
...(opts.to !== undefined ? { to: versionRef(opts.to, 'diff({ to })') } : {}),
|
|
399
|
+
...(opts.follow ? { follow: true } : {}),
|
|
400
|
+
};
|
|
401
|
+
return (await read(ctx, steps, { ...mods, hist }, 'diff')).res.map((r) => r.row);
|
|
402
|
+
};
|
|
403
|
+
case 'blame':
|
|
404
|
+
return async (opts = {}) => {
|
|
405
|
+
warnNoHistory(steps, 'blame');
|
|
406
|
+
if (opts.limit !== undefined)
|
|
407
|
+
versionRef(opts.limit, 'blame({ limit })', false);
|
|
408
|
+
const hist = {
|
|
409
|
+
...(opts.at !== undefined ? { at: momentOf(opts.at, 'blame({ at })') } : {}),
|
|
410
|
+
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
411
|
+
};
|
|
412
|
+
// null — объекта на момент at не было (или он не виден)
|
|
413
|
+
return (await read(ctx, steps, { ...mods, hist }, 'blame')).res
|
|
414
|
+
.map((r) => r.row).filter((x) => x !== null);
|
|
415
|
+
};
|
|
367
416
|
case 'sum':
|
|
368
417
|
case 'avg':
|
|
369
418
|
case 'min':
|
|
@@ -404,17 +453,17 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
|
404
453
|
throw removed('account()', 'арендатор сессии и auth.switch() (этап 5)');
|
|
405
454
|
// глаголы записи — звенья плана (этап 4)
|
|
406
455
|
case 'create':
|
|
407
|
-
return (data = {}) => withOp({ kind: 'create', data });
|
|
456
|
+
return (data = {}, opts) => withOp({ kind: 'create', data, ...reasonOf(opts) });
|
|
408
457
|
case 'update':
|
|
409
|
-
return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}) });
|
|
458
|
+
return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}), ...reasonOf(opts) });
|
|
410
459
|
case 'upsert':
|
|
411
|
-
return (data = {}) => withOp({ kind: 'upsert', data });
|
|
460
|
+
return (data = {}, opts) => withOp({ kind: 'upsert', data, ...reasonOf(opts) });
|
|
412
461
|
case 'delete':
|
|
413
|
-
return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true });
|
|
462
|
+
return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, ...reasonOf(opts) });
|
|
414
463
|
case 'anonymize':
|
|
415
|
-
return (fields) => withOp({ kind: 'anonymize', fields });
|
|
464
|
+
return (fields, opts) => withOp({ kind: 'anonymize', fields, ...reasonOf(opts) });
|
|
416
465
|
case 'reclass':
|
|
417
|
-
return (cls, data) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}) });
|
|
466
|
+
return (cls, data, opts) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}), ...reasonOf(opts) });
|
|
418
467
|
case 'set':
|
|
419
468
|
case 'unset':
|
|
420
469
|
case 'add':
|
|
@@ -423,11 +472,11 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
|
423
472
|
case 'forUpdate':
|
|
424
473
|
return () => withMods({ forUpdate: true });
|
|
425
474
|
case 'restore':
|
|
426
|
-
return () => withOp({ kind: 'restore' });
|
|
475
|
+
return (opts) => withOp({ kind: 'restore', ...reasonOf(opts) });
|
|
427
476
|
case 'purge':
|
|
428
|
-
return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true });
|
|
477
|
+
return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, ...reasonOf(opts) });
|
|
429
478
|
case 'rekey':
|
|
430
|
-
return (patch) => withOp({ kind: 'rekey', data: patch ?? {} });
|
|
479
|
+
return (patch, opts) => withOp({ kind: 'rekey', data: patch ?? {}, ...reasonOf(opts) });
|
|
431
480
|
case 'cache':
|
|
432
481
|
return (opts) => {
|
|
433
482
|
if (opts?.ttl !== undefined && !(opts.ttl > 0))
|
|
@@ -459,7 +508,7 @@ export function makeBatch(ctx, queue) {
|
|
|
459
508
|
precheckPlan(ctx.reg, p);
|
|
460
509
|
if (!plans.length)
|
|
461
510
|
return [];
|
|
462
|
-
return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, plans));
|
|
511
|
+
return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, plans));
|
|
463
512
|
};
|
|
464
513
|
case 'execute':
|
|
465
514
|
throw removed('batch.execute()', 'batch.run()');
|
package/dist/cli.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* letopis sync <модуль моделей> --dsn … --schema v2.x (--token … | --api-key …) [--apply]
|
|
7
7
|
* letopis types --dsn … --schema v2.x (--token … | --api-key …) [--out types.d.ts]
|
|
8
8
|
* letopis load <файлы NDJSON…> --dsn … --schema v2.x (--token … | --api-key …) [--class C]
|
|
9
|
-
* [--duplicates error|first|last] [--existing error|skip|update] [--commit-every N] [--verify]
|
|
9
|
+
* [--duplicates error|first|last] [--existing error|skip|update] [--commit-every N] [--verify] [--initial]
|
|
10
10
|
* letopis verify --dsn … --schema v2.x [--token … | --api-key …] [--data] — код 1 при находках
|
|
11
11
|
* letopis maintain --dsn … --schema v2.x [--token … | --api-key …] [--batches N]
|
|
12
12
|
* letopis describe --dsn … --schema v2.x (--token … | --api-key …) [--format json|text] [--out файл]
|
|
13
13
|
* letopis import --from <строка 0.21> --from-schema v1.x --dsn … --schema v2.x [--tenant <имя>] [--keyless C]…
|
|
14
|
-
* [--rename Старое=Новое]… [--update] [--commit-every N] [--partition p] — код 1, если verify нашёл расхождения
|
|
14
|
+
* [--rename Старое=Новое]… [--update] [--commit-every N] [--partition p] [--initial] — код 1, если verify нашёл расхождения
|
|
15
|
+
* --initial — первая заливка (план 1.1, §2.3): пустая схема, одна транзакция, индексы и триггеры — после загрузки
|
|
15
16
|
*/
|
|
16
17
|
import { parseArgs } from 'node:util';
|
|
17
18
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
@@ -24,12 +25,12 @@ import { import021 } from './import.js';
|
|
|
24
25
|
const USAGE = `letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
|
|
25
26
|
letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
|
|
26
27
|
letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
|
|
27
|
-
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify]
|
|
28
|
+
[--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify] [--initial]
|
|
28
29
|
letopis verify --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--data]
|
|
29
30
|
letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
|
|
30
31
|
letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
|
|
31
32
|
letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
|
|
32
|
-
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>]`;
|
|
33
|
+
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]`;
|
|
33
34
|
const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
|
|
34
35
|
export async function main(argv) {
|
|
35
36
|
const { values, positionals } = parseArgs({
|
|
@@ -43,6 +44,7 @@ export async function main(argv) {
|
|
|
43
44
|
format: { type: 'string' },
|
|
44
45
|
from: { type: 'string' }, 'from-schema': { type: 'string' }, tenant: { type: 'string' }, partition: { type: 'string' },
|
|
45
46
|
keyless: { type: 'string', multiple: true }, rename: { type: 'string', multiple: true }, update: { type: 'boolean' },
|
|
47
|
+
initial: { type: 'boolean' },
|
|
46
48
|
},
|
|
47
49
|
});
|
|
48
50
|
const [cmd, arg] = positionals;
|
|
@@ -68,7 +70,7 @@ export async function main(argv) {
|
|
|
68
70
|
const r = await import021({
|
|
69
71
|
from: values.from, fromSchema: values['from-schema'], dsn: values.dsn, schema: values.schema, tenant: values.tenant,
|
|
70
72
|
partition: values.partition, keyless: values.keyless, rename, update: values.update,
|
|
71
|
-
commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined,
|
|
73
|
+
commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined, initial: values.initial,
|
|
72
74
|
});
|
|
73
75
|
console.log(JSON.stringify(r, null, 2));
|
|
74
76
|
return r.verify.ok ? 0 : 1;
|
|
@@ -116,6 +118,7 @@ export async function main(argv) {
|
|
|
116
118
|
existing: values.existing,
|
|
117
119
|
commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined,
|
|
118
120
|
verify: values.verify,
|
|
121
|
+
initial: values.initial,
|
|
119
122
|
});
|
|
120
123
|
console.log(JSON.stringify(r));
|
|
121
124
|
return 0;
|
package/dist/import.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface ImportOptions {
|
|
|
46
46
|
update?: boolean;
|
|
47
47
|
/** Порции загрузчика (§2.6, п. 8). */
|
|
48
48
|
commitEvery?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Первая заливка (план 1.1, §2.3): весь импорт — одной транзакцией в пустую схему, индексы и триггеры —
|
|
51
|
+
* после загрузки; несовместимо с update и commitEvery. Строки, уже лежащие в схеме (системный сид:
|
|
52
|
+
* ресурсы, правила), пропускаются, как без флага.
|
|
53
|
+
*/
|
|
54
|
+
initial?: boolean;
|
|
49
55
|
/** Без сообщений в консоль. */
|
|
50
56
|
quiet?: boolean;
|
|
51
57
|
}
|