letopis 0.5.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/CHANGELOG.md +77 -0
- package/README.md +799 -0
- package/dist/chain.d.ts +126 -0
- package/dist/chain.js +278 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +43 -0
- package/dist/ops.d.ts +42 -0
- package/dist/ops.js +43 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.js +133 -0
- package/dist/sql.d.ts +92 -0
- package/dist/sql.js +480 -0
- package/dist/tables.d.ts +81 -0
- package/dist/tables.js +148 -0
- package/dist/tx.d.ts +13 -0
- package/dist/tx.js +31 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +5 -0
- package/dist/write.d.ts +60 -0
- package/dist/write.js +302 -0
- package/package.json +46 -0
package/dist/write.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Запись: терминалы .set(data) / .delete() на цепочке + исполнение батчей.
|
|
3
|
+
*
|
|
4
|
+
* Связи задаются dot-цепочкой: НЕ-последние шаги (контекст) резолвятся в ровно одну
|
|
5
|
+
* сущность каждый и дают:
|
|
6
|
+
* - containment-фильтр целей при UPDATE/DELETE (links ⊇ {Класс: id}),
|
|
7
|
+
* - links создаваемой строки при INSERT.
|
|
8
|
+
*
|
|
9
|
+
* db.Организация(org).Сотрудник().set({ name: 'Вася' }) // INSERT + links {Org}
|
|
10
|
+
* tr.Сотрудник(s).Окно(w).Запись(b).занятость().set({ id, kind }) // UPSERT по data.id
|
|
11
|
+
* db.Клиент(c).Запись({ status: 'created' }).set({ status: 'confirmed' }) // UPDATE найденных
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql } from './sql.js';
|
|
15
|
+
export class ValidationError extends Error {
|
|
16
|
+
issues;
|
|
17
|
+
constructor(cls, issues) {
|
|
18
|
+
super(`letopis: validation failed for "${cls}": ${issues.map((i) => `${i.field} — ${i.message ?? 'invalid'}`).join('; ')}`);
|
|
19
|
+
this.issues = issues;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function toRow(r) {
|
|
23
|
+
return {
|
|
24
|
+
id: r.id,
|
|
25
|
+
class: r.class,
|
|
26
|
+
data: r.data ?? {},
|
|
27
|
+
links: r.links ?? {},
|
|
28
|
+
tags: r.tags ?? [],
|
|
29
|
+
account: r.account,
|
|
30
|
+
owner: r.owner,
|
|
31
|
+
updated: r.updated instanceof Date ? r.updated.toISOString() : r.updated,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Прочитать актуальные строки по цепочке. */
|
|
35
|
+
export async function readRows(ctx, steps, mods = {}) {
|
|
36
|
+
const q = buildRead(ctx, steps, mods, 'rows');
|
|
37
|
+
const res = await runQuery(ctx, q.text, q.params, 'rows', steps.map((s) => s.cls.id));
|
|
38
|
+
return res.map((r) => toRow(r.row));
|
|
39
|
+
}
|
|
40
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date);
|
|
41
|
+
/**
|
|
42
|
+
* Deep-merge патча в базу: меняются ТОЛЬКО указанные листья.
|
|
43
|
+
* Вложенные plain-объекты сливаются рекурсивно; массивы/скаляры/null — заменяются.
|
|
44
|
+
*/
|
|
45
|
+
export function deepMerge(base, patch) {
|
|
46
|
+
const out = { ...base };
|
|
47
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
48
|
+
if (v === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
out[k] = isPlainObject(v) && isPlainObject(out[k])
|
|
51
|
+
? deepMerge(out[k], v)
|
|
52
|
+
: v;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** Валидация данных по fastest-validator (строгая, + id) и концов LINK. */
|
|
57
|
+
function validate(cls, id, data, links) {
|
|
58
|
+
if (cls.abstract)
|
|
59
|
+
throw new Error(`letopis: class "${cls.id}" is abstract`);
|
|
60
|
+
const merged = { ...data, id };
|
|
61
|
+
const res = cls.check(merged);
|
|
62
|
+
if (res !== true)
|
|
63
|
+
throw new ValidationError(cls.id, res);
|
|
64
|
+
delete merged.id;
|
|
65
|
+
if (cls.category === 'LINK') {
|
|
66
|
+
let polymorphic = 0;
|
|
67
|
+
const present = new Set(Object.keys(links));
|
|
68
|
+
for (const end of cls.links) {
|
|
69
|
+
if (end === 'Entity')
|
|
70
|
+
polymorphic++;
|
|
71
|
+
else if (present.has(end))
|
|
72
|
+
present.delete(end);
|
|
73
|
+
else
|
|
74
|
+
throw new Error(`letopis: link "${cls.id}" requires end "${end}"`);
|
|
75
|
+
}
|
|
76
|
+
if (present.size < polymorphic) {
|
|
77
|
+
throw new Error(`letopis: link "${cls.id}" requires ${polymorphic} polymorphic end(s) (any class), got ${present.size}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return merged;
|
|
81
|
+
}
|
|
82
|
+
/** INSERT одной строки с ретраем коллизии PK по updated (23505). */
|
|
83
|
+
async function insertOne(ctx, a) {
|
|
84
|
+
// jsonb-параметры — JS-объектами (postgres.js сериализует сам)
|
|
85
|
+
const params = [
|
|
86
|
+
ctx.partition, a.id, a.cls.id, a.data, a.links, a.tags,
|
|
87
|
+
a.account, a.owner, a.prevUpdated, a.deleted,
|
|
88
|
+
];
|
|
89
|
+
for (let attempt = 1;; attempt++) {
|
|
90
|
+
try {
|
|
91
|
+
const res = await runQuery(ctx, insertSql(ctx.pgSchema), params, 'insert', [a.cls.id]);
|
|
92
|
+
return toRow(res[0]);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (e.code === '23505' && attempt < 3)
|
|
96
|
+
continue;
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Различие форм фильтра в записи:
|
|
103
|
+
* Класс() — БЕЗ аргумента → INSERT новой сущности;
|
|
104
|
+
* Класс({}) — пустой объект → «все найденные» (UPDATE в границах контекста).
|
|
105
|
+
*/
|
|
106
|
+
const noFilter = (f) => f === undefined;
|
|
107
|
+
/** Entity.account NOT NULL: модификатор → connect() → System-аккаунт; иначе ошибка. */
|
|
108
|
+
function resolveAccount(ctx, step) {
|
|
109
|
+
if (ctx.enforceAccount && ctx.account && step.accountFilter && step.accountFilter !== ctx.account) {
|
|
110
|
+
throw new Error(`letopis: enforceAccount is on — writes are pinned to account ${ctx.account}`);
|
|
111
|
+
}
|
|
112
|
+
const acc = step.accountFilter ?? ctx.account ?? ctx.systemAccount;
|
|
113
|
+
if (!acc) {
|
|
114
|
+
throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / connect({account}) or seed a System account (db/seed.auth.sql)');
|
|
115
|
+
}
|
|
116
|
+
return acc;
|
|
117
|
+
}
|
|
118
|
+
/** Значение тегов из модификатора .tags(): строка/массив; операторы в записи не годятся. */
|
|
119
|
+
function tagsValue(step) {
|
|
120
|
+
const t = step.tagsFilter;
|
|
121
|
+
if (t === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (typeof t === 'string')
|
|
124
|
+
return [t];
|
|
125
|
+
if (Array.isArray(t))
|
|
126
|
+
return t;
|
|
127
|
+
throw new Error('letopis: .tags() before set() accepts string | string[]');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Контекст-шаги (все, кроме последнего) → {КлассId: id}.
|
|
131
|
+
* id-фильтр берётся как есть (без запроса); иной фильтр обязан дать ровно одну сущность.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveContext(ctx, steps) {
|
|
134
|
+
const links = {};
|
|
135
|
+
for (const step of steps) {
|
|
136
|
+
let id;
|
|
137
|
+
if (typeof step.filter === 'string') {
|
|
138
|
+
id = step.filter;
|
|
139
|
+
}
|
|
140
|
+
else if (!noFilter(step.filter) || step.tagsFilter !== undefined || step.accountFilter || step.ownerFilter) {
|
|
141
|
+
const rows = await readRows(ctx, [step], { limit: 2 });
|
|
142
|
+
if (rows.length !== 1) {
|
|
143
|
+
throw new Error(`letopis: context step "${step.name}" must resolve to exactly one entity (got ${rows.length})`);
|
|
144
|
+
}
|
|
145
|
+
id = rows[0].id;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
throw new Error(`letopis: context step "${step.name}" needs an id or a unique filter`);
|
|
149
|
+
}
|
|
150
|
+
links[step.cls.id] = id;
|
|
151
|
+
}
|
|
152
|
+
return links;
|
|
153
|
+
}
|
|
154
|
+
/** Шаг поиска целей: фильтр последнего шага + containment контекст-связей. */
|
|
155
|
+
function targetStep(target, contextLinks, explicitId) {
|
|
156
|
+
return {
|
|
157
|
+
...target,
|
|
158
|
+
filter: explicitId ?? target.filter,
|
|
159
|
+
linksFilter: Object.keys(contextLinks).length ? contextLinks : undefined,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* .set(data):
|
|
164
|
+
* - пустой фильтр последнего шага и нет data.id → INSERT (links = контекст);
|
|
165
|
+
* - id (фильтр-строка или data.id) → UPSERT: есть → новая версия (deep-merge), нет → создать;
|
|
166
|
+
* - фильтр-объект → новая версия каждого найденного (в границах контекста); пусто → [].
|
|
167
|
+
*/
|
|
168
|
+
export async function setOp(ctx, steps, mods, data, extraLinks = {}) {
|
|
169
|
+
const target = steps[steps.length - 1];
|
|
170
|
+
const cls = target.cls;
|
|
171
|
+
// контекст-шаги: и фильтр целей, и значения; довешенные extraLinks — ТОЛЬКО значения
|
|
172
|
+
const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
|
|
173
|
+
const writeLinks = { ...contextLinks, ...extraLinks };
|
|
174
|
+
const dataId = typeof data.id === 'string' ? data.id : undefined;
|
|
175
|
+
const filterId = typeof target.filter === 'string' ? target.filter : undefined;
|
|
176
|
+
const explicitId = filterId ?? dataId;
|
|
177
|
+
const insertMode = noFilter(target.filter); // Класс() без аргумента
|
|
178
|
+
const found = insertMode && !explicitId
|
|
179
|
+
? []
|
|
180
|
+
: await readRows(ctx, [targetStep(target, contextLinks, explicitId)], mods);
|
|
181
|
+
if (found.length) {
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const row of found) {
|
|
184
|
+
const mergedData = deepMerge(row.data, data); // только указанные листья
|
|
185
|
+
const mergedLinks = { ...row.links, ...writeLinks }; // контекст + довешенные
|
|
186
|
+
const full = validate(cls, row.id, mergedData, mergedLinks);
|
|
187
|
+
out.push(await insertOne(ctx, {
|
|
188
|
+
id: row.id, cls, data: full, links: mergedLinks,
|
|
189
|
+
tags: tagsValue(target) ?? row.tags, account: row.account, owner: row.owner,
|
|
190
|
+
prevUpdated: row.updated, deleted: null,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
// не найдено: INSERT только при явном id или Класс() без аргумента
|
|
196
|
+
if (!explicitId && !insertMode)
|
|
197
|
+
return [];
|
|
198
|
+
const id = explicitId ?? randomUUID();
|
|
199
|
+
const account = resolveAccount(ctx, target);
|
|
200
|
+
const owner = target.ownerFilter ?? ctx.owner ?? account;
|
|
201
|
+
const full = validate(cls, id, data, writeLinks);
|
|
202
|
+
return [
|
|
203
|
+
await insertOne(ctx, {
|
|
204
|
+
id, cls, data: full, links: writeLinks,
|
|
205
|
+
tags: tagsValue(target) ?? [], account, owner, prevUpdated: null, deleted: null,
|
|
206
|
+
}),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* .anonymize(fields): GDPR-затирание — новая версия с '[erased]' в указанных string-полях
|
|
211
|
+
* + тег 'anonymized'. Только string-поля (по Schema); история сохраняется (см. README).
|
|
212
|
+
*/
|
|
213
|
+
export async function anonymizeOp(ctx, steps, mods, fields) {
|
|
214
|
+
const target = steps[steps.length - 1];
|
|
215
|
+
const cls = target.cls;
|
|
216
|
+
for (const f of fields) {
|
|
217
|
+
if (cls.fieldTypes.get(f)?.kind !== 'string') {
|
|
218
|
+
throw new Error(`letopis: anonymize() erases string fields only; "${f}" is ${cls.fieldTypes.get(f)?.kind ?? 'unknown'}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
|
|
222
|
+
const found = await readRows(ctx, [targetStep(target, contextLinks)], mods);
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const row of found) {
|
|
225
|
+
const patch = Object.fromEntries(fields.filter((f) => f in row.data).map((f) => [f, '[erased]']));
|
|
226
|
+
const mergedData = deepMerge(row.data, patch);
|
|
227
|
+
const full = validate(cls, row.id, mergedData, row.links);
|
|
228
|
+
out.push(await insertOne(ctx, {
|
|
229
|
+
id: row.id, cls, data: full, links: row.links,
|
|
230
|
+
tags: [...new Set([...row.tags, 'anonymized'])],
|
|
231
|
+
account: row.account, owner: row.owner, prevUpdated: row.updated, deleted: null,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
/** Транзакционная обёртка: в tr исполняем как есть, вне — sql.begin. */
|
|
237
|
+
async function inTransaction(ctx, fn) {
|
|
238
|
+
if (ctx.inTx)
|
|
239
|
+
return fn(ctx);
|
|
240
|
+
return (await ctx.sql.begin(async (tsql) => fn({ ...ctx, sql: tsql, inTx: true })));
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* .delete(): цели = фильтр последнего шага + контекст-связи; серверное удаление.
|
|
244
|
+
* Триггер entity_delete: tombstone + рекурсивный каскад (+advisory-lock).
|
|
245
|
+
* Возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
|
|
246
|
+
*/
|
|
247
|
+
export async function delOp(ctx, steps, mods) {
|
|
248
|
+
const target = steps[steps.length - 1];
|
|
249
|
+
const contextLinks = await resolveContext(ctx, steps.slice(0, -1));
|
|
250
|
+
const targets = await readRows(ctx, [targetStep(target, contextLinks)], mods);
|
|
251
|
+
if (!targets.length)
|
|
252
|
+
return [];
|
|
253
|
+
const params = [ctx.partition, target.cls.id, ...targets.map((r) => r.id)];
|
|
254
|
+
return inTransaction(ctx, async (c) => {
|
|
255
|
+
const closure = await runQuery(c, closureSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
|
|
256
|
+
await runQuery(c, deleteSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
|
|
257
|
+
return closure.map((r) => ({ ...toRow(r), $deleted: true }));
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const isPureInsert = (op) => op.kind === 'set' &&
|
|
261
|
+
op.steps.length === 1 &&
|
|
262
|
+
noFilter(op.steps[0].filter) &&
|
|
263
|
+
typeof op.data?.id !== 'string' &&
|
|
264
|
+
!Object.keys(op.extraLinks ?? {}).length;
|
|
265
|
+
export async function executeBatch(ctx, queue) {
|
|
266
|
+
if (!queue.length)
|
|
267
|
+
return [];
|
|
268
|
+
return inTransaction(ctx, async (c) => {
|
|
269
|
+
const results = new Array(queue.length);
|
|
270
|
+
let i = 0;
|
|
271
|
+
while (i < queue.length) {
|
|
272
|
+
const op = queue[i];
|
|
273
|
+
if (isPureInsert(op)) {
|
|
274
|
+
const step = op.steps[0];
|
|
275
|
+
const cls = step.cls;
|
|
276
|
+
let j = i;
|
|
277
|
+
while (j < queue.length && isPureInsert(queue[j]) && queue[j].steps[0].cls.id === cls.id)
|
|
278
|
+
j++;
|
|
279
|
+
const group = queue.slice(i, j);
|
|
280
|
+
const params = [];
|
|
281
|
+
for (const g of group) {
|
|
282
|
+
const s = g.steps[0];
|
|
283
|
+
const id = randomUUID();
|
|
284
|
+
const full = validate(cls, id, g.data ?? {}, {});
|
|
285
|
+
const account = resolveAccount(c, s);
|
|
286
|
+
params.push(c.partition, id, cls.id, full, {}, tagsValue(s) ?? [], account, s.ownerFilter ?? c.owner ?? account);
|
|
287
|
+
}
|
|
288
|
+
const res = await runQuery(c, multiInsertSql(c.pgSchema, group.length), params, 'insert', [cls.id]);
|
|
289
|
+
for (let k = 0; k < group.length; k++)
|
|
290
|
+
results[i + k] = [toRow(res[k])];
|
|
291
|
+
i = j;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
results[i] =
|
|
295
|
+
op.kind === 'set'
|
|
296
|
+
? await setOp(c, op.steps, op.mods, op.data ?? {}, op.extraLinks)
|
|
297
|
+
: await delOp(c, op.steps, op.mods);
|
|
298
|
+
i++;
|
|
299
|
+
}
|
|
300
|
+
return results;
|
|
301
|
+
});
|
|
302
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "letopis",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"timescaledb",
|
|
7
|
+
"postgres",
|
|
8
|
+
"append-only",
|
|
9
|
+
"immutable",
|
|
10
|
+
"versioning",
|
|
11
|
+
"audit",
|
|
12
|
+
"temporal",
|
|
13
|
+
"asof",
|
|
14
|
+
"event-sourcing",
|
|
15
|
+
"booking",
|
|
16
|
+
"graph",
|
|
17
|
+
"jsonb"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "dist/index.js",
|
|
22
|
+
"types": "dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": ["dist", "README.md", "CHANGELOG.md"],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "tsx --test --test-concurrency=1 --test-force-exit test/*.test.ts",
|
|
34
|
+
"bench": "tsx bench/history.bench.mjs",
|
|
35
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"fastest-validator": "^1.19.0",
|
|
39
|
+
"postgres": "^3.4.5"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.10.0",
|
|
43
|
+
"tsx": "^4.19.0",
|
|
44
|
+
"typescript": "^5.7.0"
|
|
45
|
+
}
|
|
46
|
+
}
|