letopis 1.2.0 → 1.4.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 +12 -7
- package/AGENT-CHEATSHEET.md +12 -7
- package/CHANGELOG.md +213 -0
- package/README.en.md +70 -21
- package/README.md +59 -18
- package/dist/index.js +27 -7
- package/dist/project.d.ts +25 -3
- package/dist/project.js +122 -21
- package/dist/sync.js +16 -1
- package/dist/up.d.ts +17 -0
- package/dist/up.js +64 -0
- package/dist/watch.js +28 -3
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/sql/10-core.sql +3 -1
- package/sql/50-acl.sql +5 -1
- package/sql/60-write.sql +166 -25
- package/sql/88-derived.sql +83 -3
- package/sql/90-time.sql +113 -22
- package/sql/99-revision.sql +3 -2
- package/sql/README.md +3 -1
package/dist/project.js
CHANGED
|
@@ -50,7 +50,7 @@ function withFailed(meta, failed) {
|
|
|
50
50
|
delete next.failed;
|
|
51
51
|
return next;
|
|
52
52
|
}
|
|
53
|
-
const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0 });
|
|
53
|
+
const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0, locked: [] });
|
|
54
54
|
function change(row, cursor, rebuild = false) {
|
|
55
55
|
return {
|
|
56
56
|
cursor, id: row.id, class: row.class, tenant: row.tenant, op: rebuild ? 'create' : row.op, rev: row.rev, at: row.at,
|
|
@@ -67,6 +67,10 @@ function change(row, cursor, rebuild = false) {
|
|
|
67
67
|
* значениями (22…) и пределами размера (54…). Сбой окружения (блокировки, отмена запроса, связь) — не причина
|
|
68
68
|
* выводить арендатора: пачка откатывается целиком, повтор начнётся с того же курсора.
|
|
69
69
|
*/
|
|
70
|
+
/** Ожидание чужой блокировки строки дольше lock_timeout (55P03): сбой окружения, проход повторяется. */
|
|
71
|
+
function lockWait(e) {
|
|
72
|
+
return e?.code === '55P03';
|
|
73
|
+
}
|
|
70
74
|
function tenantFault(e) {
|
|
71
75
|
if (e instanceof LetopisError)
|
|
72
76
|
return true;
|
|
@@ -198,12 +202,18 @@ export class Projector {
|
|
|
198
202
|
/** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
|
|
199
203
|
blind = new Map();
|
|
200
204
|
stale = true;
|
|
205
|
+
/** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
|
|
206
|
+
tenantsPending = true;
|
|
201
207
|
running = false;
|
|
202
208
|
stopped = false;
|
|
203
209
|
wake = null;
|
|
204
210
|
off = null;
|
|
205
211
|
batch;
|
|
206
212
|
pollMs;
|
|
213
|
+
lockMs;
|
|
214
|
+
lockRetries;
|
|
215
|
+
/** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
|
|
216
|
+
lockHits = new Map();
|
|
207
217
|
only;
|
|
208
218
|
constructor(env, name, def) {
|
|
209
219
|
this.env = env;
|
|
@@ -228,6 +238,12 @@ export class Projector {
|
|
|
228
238
|
this.only = def.tenants ? new Set(def.tenants.map((x) => x.toLowerCase())) : null;
|
|
229
239
|
this.batch = def.batch ?? 500;
|
|
230
240
|
this.pollMs = def.pollMs ?? 1000;
|
|
241
|
+
this.lockMs = def.lockTimeout ?? 5000;
|
|
242
|
+
if (!Number.isInteger(this.lockMs) || this.lockMs < 0)
|
|
243
|
+
throw new LetopisError('invalid_data', `проектор ${name}: lockTimeout — целое число мс, 0 и больше`);
|
|
244
|
+
this.lockRetries = def.lockRetries ?? 0;
|
|
245
|
+
if (!Number.isInteger(this.lockRetries) || this.lockRetries < 0)
|
|
246
|
+
throw new LetopisError('invalid_data', `проектор ${name}: lockRetries — целое число, 0 и больше`);
|
|
231
247
|
if (!Number.isInteger(this.batch) || this.batch < 1 || this.batch > 10_000)
|
|
232
248
|
throw new LetopisError('invalid_data', `проектор ${name}: batch — от 1 до 10 000`);
|
|
233
249
|
if (env.onSignal) {
|
|
@@ -261,18 +277,37 @@ export class Projector {
|
|
|
261
277
|
r = await this.step(rep);
|
|
262
278
|
}
|
|
263
279
|
catch (e) {
|
|
264
|
-
if (
|
|
280
|
+
if (lockWait(e)) {
|
|
281
|
+
rep.skipped++;
|
|
282
|
+
r = 'busy';
|
|
283
|
+
}
|
|
284
|
+
else if (e instanceof LetopisError && e.code === 'cursor_expired' && this.def.onExpired === 'rebuild') {
|
|
285
|
+
r = 'rebuild';
|
|
286
|
+
}
|
|
287
|
+
else
|
|
265
288
|
throw e;
|
|
266
|
-
r = 'rebuild';
|
|
267
289
|
}
|
|
268
290
|
// курсор истёк у части арендаторов: с onExpired: 'rebuild' они перестраиваются сами, пачками (доработки после
|
|
269
291
|
// точки Б). Состояние — в чекпоинте, поэтому перестройку продолжает run() любого процесса, а занятая
|
|
270
|
-
// блокировка — только повтор на следующем проходе; ошибка данных выводит арендатора с её кодом, без
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
292
|
+
// блокировка — только повтор на следующем проходе; ошибка данных выводит арендатора с её кодом, без повторов.
|
|
293
|
+
// Начатую перестройку арендатора пачками — и прерванную rebuild({ tenant, chunked: true }) — продвигает run()
|
|
294
|
+
// и без onExpired (ревью безопасности №3): иначе хвост бессрочно пропускал бы изменения арендатора с id выше
|
|
295
|
+
// последней пачки. Начинает перестройку выбывших по ярусу только onExpired: 'rebuild'. Блокировку проектора держит
|
|
296
|
+
// другой процесс (busy) — перестройку продвинет он: чтение чекпоинта ждало бы его пачку
|
|
297
|
+
const auto = this.def.onExpired === 'rebuild';
|
|
298
|
+
try {
|
|
299
|
+
if (r !== 'rebuild' && r !== 'busy' && (auto || this.tenantsPending) && (await this.tenantWork(rep, auto)))
|
|
300
|
+
r = 'more';
|
|
301
|
+
// пачку перестройки держит другой процесс того же проектора — подождать и продолжить
|
|
302
|
+
if (r === 'rebuild')
|
|
303
|
+
r = (await this.rebuildNow(rep, 'auto')) === 'busy' ? 'busy' : 'more';
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
if (!lockWait(e))
|
|
307
|
+
throw e;
|
|
308
|
+
rep.skipped++;
|
|
309
|
+
r = 'busy';
|
|
310
|
+
}
|
|
276
311
|
if (r === 'more')
|
|
277
312
|
continue;
|
|
278
313
|
if (opts.once)
|
|
@@ -306,16 +341,28 @@ export class Projector {
|
|
|
306
341
|
return rep;
|
|
307
342
|
}
|
|
308
343
|
await this.startTenant(rep, tenant, false);
|
|
344
|
+
const started = rep.rebuilds;
|
|
309
345
|
for (;;) {
|
|
310
|
-
|
|
346
|
+
// занятая строка (lockTimeout) — как занятая блокировка проектора: подождать и повторить пачку
|
|
347
|
+
const r = await this.tenantChunk(rep, tenant).catch((e) => {
|
|
348
|
+
if (!lockWait(e))
|
|
349
|
+
throw e;
|
|
350
|
+
rep.skipped++;
|
|
351
|
+
return 'busy';
|
|
352
|
+
});
|
|
311
353
|
if (r === 'busy')
|
|
312
354
|
await new Promise((res) => setTimeout(res, 20));
|
|
313
355
|
if (r !== 'done')
|
|
314
356
|
continue;
|
|
315
|
-
// ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор
|
|
316
|
-
|
|
357
|
+
// ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор выбывший; чекпоинт держит
|
|
358
|
+
// пачка run() другого процесса дольше lockTimeout — подождать
|
|
359
|
+
const f = (await this.untilFree(rep, () => this.tx(async (q) => this.state(q)))).failed?.[tenant];
|
|
317
360
|
if (f)
|
|
318
361
|
throw new LetopisError(f.code, f.message);
|
|
362
|
+
// последнюю пачку мог записать run() другого процесса (он продвигает начатые перестройки): перестройка этого
|
|
363
|
+
// вызова всё равно завершена
|
|
364
|
+
if (rep.rebuilds === started)
|
|
365
|
+
rep.rebuilds++;
|
|
319
366
|
return rep;
|
|
320
367
|
}
|
|
321
368
|
}
|
|
@@ -369,6 +416,10 @@ export class Projector {
|
|
|
369
416
|
await q.unsafe('begin isolation level read committed');
|
|
370
417
|
await q.unsafe(`set local role ${ident(this.env.ownerRole)}`);
|
|
371
418
|
await q.unsafe(`set local timezone = 'UTC'`);
|
|
419
|
+
// чужая блокировка строки (транзакция любого арендатора держит итог или цель) не держит пачку всех арендаторов
|
|
420
|
+
// дольше lockTimeout (хвост ревью безопасности №3): 55P03 — проход пропускается, курсор остаётся
|
|
421
|
+
if (this.lockMs > 0)
|
|
422
|
+
await q.unsafe(`set local lock_timeout = ${this.lockMs}`);
|
|
372
423
|
const out = await fn(q);
|
|
373
424
|
await q.unsafe('commit');
|
|
374
425
|
return out;
|
|
@@ -489,6 +540,7 @@ export class Projector {
|
|
|
489
540
|
return 'rebuild';
|
|
490
541
|
if (meta.version !== this.def.version)
|
|
491
542
|
throw this.versionError(meta);
|
|
543
|
+
this.tenantsPending = Object.values(meta.retenant ?? {}).some((v) => !v.done);
|
|
492
544
|
const cur = dec(meta.cursor);
|
|
493
545
|
const rows = await q.unsafe(`select l.tx::text as tx, l.seq::text as seq, ${rowJson('l')} as row
|
|
494
546
|
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]);
|
|
@@ -682,6 +734,27 @@ export class Projector {
|
|
|
682
734
|
catch (e) {
|
|
683
735
|
// причина — первая ошибка базы группы (следом за ней база отвечает лишь «транзакция прервана»)
|
|
684
736
|
const cause = out.aborted ?? e;
|
|
737
|
+
// строку арендатора держит чужая транзакция дольше lockTimeout (хвост ревью доработок после 1.2.0): отчёт
|
|
738
|
+
// называет арендатора; с lockRetries после стольких проходов подряд он выбывает, и пачка идёт для остальных,
|
|
739
|
+
// иначе откатывается вся пачка и проход повторяется
|
|
740
|
+
if (failed && lockWait(cause)) {
|
|
741
|
+
if (!rep.locked.includes(tenant))
|
|
742
|
+
rep.locked.push(tenant);
|
|
743
|
+
const hits = (this.lockHits.get(tenant) ?? 0) + 1;
|
|
744
|
+
if (this.lockRetries === 0 || hits < this.lockRetries) {
|
|
745
|
+
this.lockHits.set(tenant, hits);
|
|
746
|
+
throw e;
|
|
747
|
+
}
|
|
748
|
+
this.lockHits.delete(tenant);
|
|
749
|
+
await q.unsafe('rollback to savepoint letopis_projector');
|
|
750
|
+
await q.unsafe('release savepoint letopis_projector');
|
|
751
|
+
failed[tenant] = {
|
|
752
|
+
cursor: list[0].cursor || start,
|
|
753
|
+
code: '55P03',
|
|
754
|
+
message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — ${hits} проходов подряд дольше lockTimeout`,
|
|
755
|
+
};
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
685
758
|
if (!failed || !out.owns(e) || !tenantFault(cause))
|
|
686
759
|
throw e;
|
|
687
760
|
await q.unsafe('rollback to savepoint letopis_projector');
|
|
@@ -696,6 +769,7 @@ export class Projector {
|
|
|
696
769
|
}
|
|
697
770
|
if (failed)
|
|
698
771
|
await q.unsafe('release savepoint letopis_projector');
|
|
772
|
+
this.lockHits.delete(tenant);
|
|
699
773
|
rep.created += out.stats.created;
|
|
700
774
|
rep.updated += out.stats.updated;
|
|
701
775
|
rep.deleted += out.stats.deleted;
|
|
@@ -961,6 +1035,9 @@ export class Projector {
|
|
|
961
1035
|
*/
|
|
962
1036
|
async startTenant(rep, tenant, auto) {
|
|
963
1037
|
for (;;) {
|
|
1038
|
+
// строку арендатора (его производные строки удаляются) держит чужая транзакция дольше lockTimeout — как занятая
|
|
1039
|
+
// блокировка проектора: run() повторит на следующем проходе, rebuild() подождёт (хвост ревью доработок после 1.2.0:
|
|
1040
|
+
// rebuild({ tenant, chunked: true }) отдавал сырую 55P03)
|
|
964
1041
|
const r = await this.tx(async (q) => {
|
|
965
1042
|
if (!(await this.lock(q)))
|
|
966
1043
|
return 'busy';
|
|
@@ -992,6 +1069,12 @@ export class Projector {
|
|
|
992
1069
|
delete next.resync;
|
|
993
1070
|
await this.advance(q, meta.cursor, withFailed(next, failed));
|
|
994
1071
|
return 'ok';
|
|
1072
|
+
}).catch((e) => {
|
|
1073
|
+
if (!lockWait(e))
|
|
1074
|
+
throw e;
|
|
1075
|
+
if (!auto)
|
|
1076
|
+
rep.skipped++;
|
|
1077
|
+
return 'busy';
|
|
995
1078
|
});
|
|
996
1079
|
if (r !== 'busy' || auto) {
|
|
997
1080
|
if (r === 'busy')
|
|
@@ -1059,29 +1142,47 @@ export class Projector {
|
|
|
1059
1142
|
});
|
|
1060
1143
|
}
|
|
1061
1144
|
/**
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1145
|
+
* Перестройка арендаторов пачками — проход run(): с start (onExpired: 'rebuild') начинает её у каждого выбывшего по
|
|
1146
|
+
* cursor_expired, затем продвигает на пачку каждую начатую — автоматическую и прерванную rebuild({ tenant, chunked:
|
|
1147
|
+
* true }). true — работа ещё осталась.
|
|
1064
1148
|
*/
|
|
1065
|
-
async tenantWork(rep) {
|
|
1149
|
+
async tenantWork(rep, start) {
|
|
1066
1150
|
const meta = await this.tx(async (q) => this.state(q));
|
|
1067
1151
|
if (!meta.cursor || meta.state === 'rebuilding' || meta.version !== this.def.version)
|
|
1068
1152
|
return false;
|
|
1069
1153
|
let more = false;
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1154
|
+
if (start) {
|
|
1155
|
+
for (const [t, f] of Object.entries(meta.failed ?? {})) {
|
|
1156
|
+
if (f.code !== 'cursor_expired' || this.blind.has(t) || !this.scopes.has(t))
|
|
1157
|
+
continue;
|
|
1158
|
+
if ((await this.startTenant(rep, t, true)) === 'ok')
|
|
1159
|
+
more = true;
|
|
1160
|
+
}
|
|
1075
1161
|
}
|
|
1076
|
-
const fresh = await this.tx(async (q) => this.state(q));
|
|
1162
|
+
const fresh = start ? await this.tx(async (q) => this.state(q)) : meta;
|
|
1077
1163
|
for (const [t, v] of Object.entries(fresh.retenant ?? {})) {
|
|
1078
1164
|
if (v.done)
|
|
1079
1165
|
continue;
|
|
1080
1166
|
if ((await this.tenantChunk(rep, t)) === 'more')
|
|
1081
1167
|
more = true;
|
|
1082
1168
|
}
|
|
1169
|
+
this.tenantsPending = more;
|
|
1083
1170
|
return more;
|
|
1084
1171
|
}
|
|
1172
|
+
/** Повторять fn, пока строку держит чужая транзакция дольше lockTimeout (55P03): пауза 20 мс, проход — в skipped. */
|
|
1173
|
+
async untilFree(rep, fn) {
|
|
1174
|
+
for (;;) {
|
|
1175
|
+
try {
|
|
1176
|
+
return await fn();
|
|
1177
|
+
}
|
|
1178
|
+
catch (e) {
|
|
1179
|
+
if (!lockWait(e))
|
|
1180
|
+
throw e;
|
|
1181
|
+
rep.skipped++;
|
|
1182
|
+
}
|
|
1183
|
+
await new Promise((res) => setTimeout(res, 20));
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1085
1186
|
/** Ждать сигнал источников, остановку или запасной опрос. */
|
|
1086
1187
|
sleep() {
|
|
1087
1188
|
return new Promise((resolve) => {
|
package/dist/sync.js
CHANGED
|
@@ -11,13 +11,28 @@ function canon(v) {
|
|
|
11
11
|
export async function syncModels(db, models, opts = {}) {
|
|
12
12
|
const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
|
|
13
13
|
const S = `"${db.schema.replace(/"/g, '""')}"`;
|
|
14
|
-
// предок раньше
|
|
14
|
+
// предок раньше потомка; класс-источник агрегата раньше цели: агрегат над классом, которого ещё нет, база отклоняет.
|
|
15
|
+
// Круг (агрегат над собственным потомком, цикл агрегатов) так не упорядочить — отказ до записи с самим кругом (хвост
|
|
16
|
+
// ревью доработок после 1.2.0: раньше обход молча ставил потомка раньше предка)
|
|
15
17
|
const ordered = [];
|
|
18
|
+
const path = [];
|
|
16
19
|
const visit = (m) => {
|
|
17
20
|
if (ordered.includes(m))
|
|
18
21
|
return;
|
|
22
|
+
const at = path.indexOf(m);
|
|
23
|
+
if (at >= 0) {
|
|
24
|
+
throw new LetopisError('invalid_class', `syncModels: модели зависят друг от друга по кругу: ${[...path.slice(at), m].map((x) => x.name).join(' → ')} — `
|
|
25
|
+
+ 'родителя и источник агрегата база ждёт раньше класса');
|
|
26
|
+
}
|
|
27
|
+
path.push(m);
|
|
19
28
|
if (m.parent && models.includes(m.parent))
|
|
20
29
|
visit(m.parent);
|
|
30
|
+
for (const a of Object.values(m.describe().aggregate ?? {})) {
|
|
31
|
+
const src = models.find((x) => x.name === a.of);
|
|
32
|
+
if (src)
|
|
33
|
+
visit(src);
|
|
34
|
+
}
|
|
35
|
+
path.pop();
|
|
21
36
|
ordered.push(m);
|
|
22
37
|
};
|
|
23
38
|
models.forEach(visit);
|
package/dist/up.d.ts
CHANGED
|
@@ -57,6 +57,23 @@ export interface InstallResult {
|
|
|
57
57
|
export declare const quoteIdent: (name: string) => string;
|
|
58
58
|
/** Файлы ядра по номеру: `10-core.sql` … `99-revision.sql`; сиды демо-домена сюда не входят. */
|
|
59
59
|
export declare function engineFiles(upTo?: number): Promise<string[]>;
|
|
60
|
+
/** Штамп движка: версия пакета и хэш файлов ядра (`99-revision.sql` пишет его в комментарий схемы). */
|
|
61
|
+
export interface EngineStamp {
|
|
62
|
+
version: string;
|
|
63
|
+
sql: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
|
|
67
|
+
* сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
|
|
68
|
+
*/
|
|
69
|
+
export declare function engineStamp(): Promise<EngineStamp>;
|
|
70
|
+
/** Сравнение версий semver (метаданные сборки не учитываются): меньше нуля, ноль, больше нуля. */
|
|
71
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
72
|
+
/**
|
|
73
|
+
* Штамп схемы (комментарий схемы) против файлов ядра пакета — текст предупреждения или null. Ревизия та же, а
|
|
74
|
+
* функции поставила другая версия движка: после обновления пакета не накатили up() (так было при 1.1 → 1.2).
|
|
75
|
+
*/
|
|
76
|
+
export declare function stampDrift(schema: string, comment: string): Promise<string | null>;
|
|
60
77
|
export declare function substitute(text: string, vars: Record<string, string>): string;
|
|
61
78
|
export declare function install(sql: postgres.Sql, opts: InstallOptions): Promise<InstallResult>;
|
|
62
79
|
/** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
|
package/dist/up.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* выполняет ANALYZE только для таблиц без статистики.
|
|
12
12
|
*/
|
|
13
13
|
import { readFile, readdir } from 'node:fs/promises';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { join, resolve } from 'node:path';
|
|
16
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
@@ -34,6 +35,65 @@ export async function engineFiles(upTo = 99) {
|
|
|
34
35
|
const files = (await readdir(SQL_DIR)).filter((f) => /^\d{2}-[a-z0-9.-]+\.sql$/.test(f));
|
|
35
36
|
return files.filter((f) => Number(f.slice(0, 2)) <= upTo).sort();
|
|
36
37
|
}
|
|
38
|
+
let stamp;
|
|
39
|
+
/**
|
|
40
|
+
* Штамп движка этого пакета. Хэш — по исходному тексту файлов ядра до подстановок (переводы строк — LF): одна и та же
|
|
41
|
+
* сборка даёт один штамп при любых опциях установки, а версия пакета без правок SQL штамп не меняет.
|
|
42
|
+
*/
|
|
43
|
+
export async function engineStamp() {
|
|
44
|
+
if (stamp)
|
|
45
|
+
return stamp;
|
|
46
|
+
const h = createHash('sha256');
|
|
47
|
+
for (const f of await engineFiles())
|
|
48
|
+
h.update(`${f}\n${(await readFile(join(SQL_DIR, f), 'utf8')).replace(/\r\n/g, '\n')}\n`);
|
|
49
|
+
const version = createRequire(import.meta.url)('../package.json').version;
|
|
50
|
+
return (stamp = { version, sql: h.digest('hex').slice(0, 16) });
|
|
51
|
+
}
|
|
52
|
+
/** Сравнение версий semver (метаданные сборки не учитываются): меньше нуля, ноль, больше нуля. */
|
|
53
|
+
export function compareVersions(a, b) {
|
|
54
|
+
const parse = (v) => {
|
|
55
|
+
const [core, pre] = v.split('+')[0].split(/-(.*)/s);
|
|
56
|
+
return { core: core.split('.').map(Number), pre: pre ? pre.split('.') : [] };
|
|
57
|
+
};
|
|
58
|
+
const x = parse(a);
|
|
59
|
+
const y = parse(b);
|
|
60
|
+
for (let i = 0; i < 3; i++)
|
|
61
|
+
if ((x.core[i] ?? 0) !== (y.core[i] ?? 0))
|
|
62
|
+
return (x.core[i] ?? 0) - (y.core[i] ?? 0);
|
|
63
|
+
if (!x.pre.length || !y.pre.length)
|
|
64
|
+
return y.pre.length - x.pre.length; // выпуск старше своих предварительных версий
|
|
65
|
+
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
|
66
|
+
const p = x.pre[i];
|
|
67
|
+
const q = y.pre[i];
|
|
68
|
+
if (p === undefined || q === undefined)
|
|
69
|
+
return p === undefined ? -1 : 1;
|
|
70
|
+
if (p === q)
|
|
71
|
+
continue;
|
|
72
|
+
const pn = /^\d+$/.test(p);
|
|
73
|
+
const qn = /^\d+$/.test(q);
|
|
74
|
+
if (pn && qn)
|
|
75
|
+
return Number(p) - Number(q);
|
|
76
|
+
if (pn !== qn)
|
|
77
|
+
return pn ? -1 : 1;
|
|
78
|
+
return p < q ? -1 : 1;
|
|
79
|
+
}
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Штамп схемы (комментарий схемы) против файлов ядра пакета — текст предупреждения или null. Ревизия та же, а
|
|
84
|
+
* функции поставила другая версия движка: после обновления пакета не накатили up() (так было при 1.1 → 1.2).
|
|
85
|
+
*/
|
|
86
|
+
export async function stampDrift(schema, comment) {
|
|
87
|
+
const code = await engineStamp().catch(() => null); // файлов ядра рядом со сборкой нет — сверять не с чем
|
|
88
|
+
if (!code || /\bsql=([0-9a-f]+)/.exec(comment)?.[1] === code.sql)
|
|
89
|
+
return null;
|
|
90
|
+
const was = /\bengine=(\S+)/.exec(comment)?.[1];
|
|
91
|
+
if (was && compareVersions(was, code.version) > 0) {
|
|
92
|
+
return `letopis: схему ${schema} обновила letopis ${was}, библиотека старее (${code.version}) — обновите пакет: up() этой версией вернул бы прежние функции`;
|
|
93
|
+
}
|
|
94
|
+
const by = !was ? 'версия до 1.4.0' : was === code.version ? `другая сборка letopis ${was}` : `letopis ${was}`;
|
|
95
|
+
return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: накатите up({ schema: '${schema}' })`;
|
|
96
|
+
}
|
|
37
97
|
export function substitute(text, vars) {
|
|
38
98
|
return text.replace(/@([A-Z_]+)@/g, (m, k) => {
|
|
39
99
|
if (!(k in vars))
|
|
@@ -88,6 +148,10 @@ export async function install(sql, opts) {
|
|
|
88
148
|
// режим сигналов — после set role: сохранённый режим лежит в строке ключа secret владельца
|
|
89
149
|
SIGNALS: 'class',
|
|
90
150
|
};
|
|
151
|
+
// штамп движка — в комментарий схемы (99-revision.sql): по нему connect() узнаёт схему, не накатанную этой версией
|
|
152
|
+
const st = await engineStamp();
|
|
153
|
+
vars.ENGINE_VERSION = st.version;
|
|
154
|
+
vars.ENGINE_SQL = st.sql;
|
|
91
155
|
// Схема создаётся до set role: у владельца может не быть права CREATE в базе.
|
|
92
156
|
await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
|
|
93
157
|
const files = await engineFiles(opts.upTo);
|
package/dist/watch.js
CHANGED
|
@@ -116,10 +116,35 @@ export class Subscription {
|
|
|
116
116
|
if (!this.pos)
|
|
117
117
|
this.pos = await (this.start ?? this.head());
|
|
118
118
|
const p = this.pos;
|
|
119
|
-
const
|
|
119
|
+
const cols = `l.tx::text as tx, l.seq::text as seq, jsonb_build_object('id', l.id, 'rev', l.rev, 'class', l.class, 'tenant', l.tenant, `
|
|
120
120
|
+ `'owner', l.owner, 'links', l.links, 'data', l.data, 'tags', l.tags, 'at', l.at, 'author', l.author, 'agent', l.agent, `
|
|
121
|
-
+ `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row
|
|
122
|
-
|
|
121
|
+
+ `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row`;
|
|
122
|
+
// уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
|
|
123
|
+
// версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
|
|
124
|
+
// оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
|
|
125
|
+
// (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом
|
|
126
|
+
const S = this.ctx.S;
|
|
127
|
+
const both = await this.ctx.exec.run((q) => q.unsafe(`with r as (select l.* from ${S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l),
|
|
128
|
+
u as (select r.tx, r.seq from r where (select count(*) from r) = $5 order by r.tx desc, r.seq desc limit 1)
|
|
129
|
+
select 0 as src, ${cols} from r l
|
|
130
|
+
union all
|
|
131
|
+
select 1 as src, ${cols} from ${S}.watch_leaving($1::text::xid8, $2::bigint, $4::text[], $5::int, $6::text::pg_snapshot,
|
|
132
|
+
(select tx from u), (select seq from u)) l`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
|
|
133
|
+
const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
|
|
134
|
+
const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
|
|
135
|
+
const read = both.filter((r) => r.src === 0).sort(order);
|
|
136
|
+
const away = both.filter((r) => r.src === 1).sort(order);
|
|
137
|
+
let rows = read;
|
|
138
|
+
{
|
|
139
|
+
const upto = read.length === this.batch ? read[read.length - 1] : null;
|
|
140
|
+
if (away.length) {
|
|
141
|
+
let bound = upto;
|
|
142
|
+
const lastAway = away[away.length - 1];
|
|
143
|
+
if (away.length === this.batch && (!bound || before(lastAway, bound)))
|
|
144
|
+
bound = lastAway;
|
|
145
|
+
rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
123
148
|
for (const r of rows) {
|
|
124
149
|
const row = toRow(r.row);
|
|
125
150
|
const snap = keepSnap(p.snap, r.tx);
|
package/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
|
|
4
4
|
|
|
5
|
-
Key facts for writing code with letopis 1.
|
|
5
|
+
Key facts for writing code with letopis 1.4:
|
|
6
6
|
|
|
7
7
|
- ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
|
|
8
8
|
- Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"postgres",
|
|
@@ -101,6 +101,7 @@
|
|
|
101
101
|
"@types/node": "^22.20.5",
|
|
102
102
|
"letopis-0": "npm:letopis@0.21.0",
|
|
103
103
|
"letopis-1": "npm:letopis@1.0.1",
|
|
104
|
+
"letopis-11": "npm:letopis@1.1.0",
|
|
104
105
|
"tsx": "^4.23.15",
|
|
105
106
|
"typescript": "~5.9.3",
|
|
106
107
|
"zod": "4.6.5"
|
package/sql/10-core.sql
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
revoke all on schema @SCHEMA@ from public;
|
|
7
7
|
grant usage on schema @SCHEMA@ to @APP@;
|
|
8
8
|
|
|
9
|
-
-- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}.
|
|
9
|
+
-- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}. Тело — SQL-стандарта (return): имена связываются
|
|
10
|
+
-- при создании и от search_path сеанса не зависят, а колонку ends вычисляет роль приложения. Вариант на plpgsql быстрее
|
|
11
|
+
-- на 17 % (бенч gencols), но его имена разрешались бы при исполнении (ревью безопасности доработок после 1.2.0)
|
|
10
12
|
create or replace function @SCHEMA@.link_ids(links jsonb) returns uuid[]
|
|
11
13
|
language sql immutable parallel safe
|
|
12
14
|
return (
|
package/sql/50-acl.sql
CHANGED
|
@@ -228,9 +228,13 @@ begin
|
|
|
228
228
|
and jsonb_typeof(x.pattern->'class') <> 'string', false) as bad) m
|
|
229
229
|
where x.deny or not m.bad
|
|
230
230
|
), cl as (
|
|
231
|
-
|
|
231
|
+
-- один класс на имя — тот, что строки арендатора находят по имени (class_row): свой, иначе System. Одноимённый
|
|
232
|
+
-- класс System, заведённый после класса арендатора, в план не входит: его предки и алиас давали бы права на строки
|
|
233
|
+
-- класса арендатора (хвост ревью доработок после 1.2.0)
|
|
234
|
+
select distinct on (c.data->>'name') c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
|
|
232
235
|
from @SCHEMA@.entity c
|
|
233
236
|
where c.class = 'Class' and c.tenant in (p_tenant, '@SYSTEM@'::uuid)
|
|
237
|
+
order by c.data->>'name', c.tenant = '@SYSTEM@'::uuid
|
|
234
238
|
), k as (
|
|
235
239
|
select cl.name,
|
|
236
240
|
array(select jsonb_array_elements_text(cl.anc))
|