letopis 2.0.0 → 3.0.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.md +484 -414
- package/CHANGELOG.md +321 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +786 -183
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +3 -0
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +2 -1
- package/dist/admin.js +6 -4
- package/dist/auth.d.ts +18 -3
- package/dist/auth.js +75 -6
- package/dist/chain.d.ts +38 -17
- package/dist/chain.js +434 -63
- package/dist/cli.js +28 -6
- package/dist/errors.d.ts +19 -1
- package/dist/errors.js +46 -6
- package/dist/index.d.ts +10 -4
- package/dist/index.js +136 -46
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +9 -5
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.js +26 -11
- package/dist/messages/api.d.ts +66 -0
- package/dist/messages/api.js +90 -1
- package/dist/messages/boot.js +4 -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 +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/model.d.ts +24 -1
- package/dist/messages/model.js +34 -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 +47 -0
- package/dist/messages/write.js +63 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +23 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +1 -1
- package/dist/sync.js +50 -0
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +14 -10
- package/dist/types.d.ts +82 -4
- package/dist/types.js +6 -1
- package/dist/up.d.ts +3 -3
- package/dist/up.js +57 -17
- 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 +78 -1
- package/dist/write.js +357 -39
- package/llms.txt +37 -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/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/messages/write.d.ts
CHANGED
|
@@ -140,6 +140,53 @@ export declare const write: {
|
|
|
140
140
|
code: "not_deleted";
|
|
141
141
|
ru: string;
|
|
142
142
|
};
|
|
143
|
+
'write.restore_id_taken': {
|
|
144
|
+
code: "not_deleted";
|
|
145
|
+
ru: string;
|
|
146
|
+
hint: string;
|
|
147
|
+
};
|
|
148
|
+
'write.restore_already': {
|
|
149
|
+
code: "not_deleted";
|
|
150
|
+
ru: string;
|
|
151
|
+
hint: string;
|
|
152
|
+
};
|
|
153
|
+
'write.restore_stale': {
|
|
154
|
+
code: "conflict";
|
|
155
|
+
ru: string;
|
|
156
|
+
hint: string;
|
|
157
|
+
};
|
|
158
|
+
'write.partial_without_expect': {
|
|
159
|
+
code: "invalid_query";
|
|
160
|
+
ru: string;
|
|
161
|
+
};
|
|
162
|
+
'write.bad_expect': {
|
|
163
|
+
code: "invalid_query";
|
|
164
|
+
ru: string;
|
|
165
|
+
};
|
|
166
|
+
'write.expect_without_confirm': {
|
|
167
|
+
code: "invalid_query";
|
|
168
|
+
ru: string;
|
|
169
|
+
};
|
|
170
|
+
'write.preview_changed': {
|
|
171
|
+
code: "conflict";
|
|
172
|
+
ru: string;
|
|
173
|
+
hint: string;
|
|
174
|
+
};
|
|
175
|
+
'write.link_in_data': {
|
|
176
|
+
code: "invalid_data";
|
|
177
|
+
ru: string;
|
|
178
|
+
hint: string;
|
|
179
|
+
};
|
|
180
|
+
'write.confirm_without_expect': {
|
|
181
|
+
code: "invalid_query";
|
|
182
|
+
ru: string;
|
|
183
|
+
hint: string;
|
|
184
|
+
};
|
|
185
|
+
'write.restore_without_expect': {
|
|
186
|
+
code: "invalid_query";
|
|
187
|
+
ru: string;
|
|
188
|
+
hint: string;
|
|
189
|
+
};
|
|
143
190
|
'write.commit_unknown': {
|
|
144
191
|
code: "commit_unknown";
|
|
145
192
|
ru: string;
|
package/dist/messages/write.js
CHANGED
|
@@ -177,6 +177,69 @@ export const write = {
|
|
|
177
177
|
code: 'not_deleted',
|
|
178
178
|
ru: 'объект не удалён: {verb}() — только для удалённых',
|
|
179
179
|
},
|
|
180
|
+
// restore() живого объекта, который уже удаляли: за последним надгробием — create (id занял новый объект с тем же
|
|
181
|
+
// ключом); ids — такие id; detail.ids, с expect — ещё detail.stale (все цели, которые не восстановить, с why)
|
|
182
|
+
'write.restore_id_taken': {
|
|
183
|
+
code: 'not_deleted',
|
|
184
|
+
ru: '{verb}(): id {ids} занят новым объектом с тем же ключом — create() по ключу удалённого берёт его id, восстанавливать нечего; ничего не восстановлено',
|
|
185
|
+
hint: 'объект с этим ключом уже живёт: прочитайте его (db.Класс(id).first()); перед create() по ключу удалённого проверьте db.Класс({ ключ }).withDeleted(); восстановить остальное — restore({ expect, partial: true })',
|
|
186
|
+
},
|
|
187
|
+
// restore() живого объекта, который уже восстановили: за последним надгробием — restore (второй раунд 2.1, A2: другой
|
|
188
|
+
// запрос успел первым); ids — такие id; detail.ids, с expect — ещё detail.stale
|
|
189
|
+
'write.restore_already': {
|
|
190
|
+
code: 'not_deleted',
|
|
191
|
+
ru: '{verb}(): {ids} уже восстановлены — объекты живые, восстанавливать нечего; ничего не восстановлено',
|
|
192
|
+
hint: 'их вернул другой запрос: прочитайте их (db.Класс(id).first()); восстановить остальное — restore({ expect, partial: true })',
|
|
193
|
+
},
|
|
194
|
+
// restore({ expect }): последняя версия id не та, что в expect, и это не «уже восстановлено» и не «id занят» — удалён
|
|
195
|
+
// снова, не удалялся, гонка; ничего не восстановлено. ids — такие id; detail.ids — они же, detail.stale — все цели,
|
|
196
|
+
// которые не восстановить: { id, rev, expected, after, deleted, live, why }
|
|
197
|
+
'write.restore_stale': {
|
|
198
|
+
code: 'conflict',
|
|
199
|
+
ru: 'restore({ expect }): {ids} уже не те, что удаляли — после этого удаления их меняли (что было — err.detail.stale: after, deleted, live); ничего не восстановлено',
|
|
200
|
+
hint: 'надгробия — из ответа delete() или withDeleted(), их { id, rev } — в expect; расхождение значит, что вещь уже у другого — сообщить пользователю, а не восстанавливать; восстановить то, что можно, — restore({ expect, partial: true })',
|
|
201
|
+
},
|
|
202
|
+
// restore({ partial: true }) без expect: пропускать не по чему
|
|
203
|
+
'write.partial_without_expect': {
|
|
204
|
+
code: 'invalid_query',
|
|
205
|
+
ru: 'restore({ partial: true }) — только с { expect }: пропуск считается по показанным надгробиям',
|
|
206
|
+
},
|
|
207
|
+
// restore({ expect }), delete({ expect }): expect — не список строк { id, rev }; verb — глагол
|
|
208
|
+
'write.bad_expect': {
|
|
209
|
+
code: 'invalid_query',
|
|
210
|
+
ru: '{verb}({ expect }): expect — список строк { id, rev } (строки ответа)',
|
|
211
|
+
},
|
|
212
|
+
// delete({ expect }) без confirm: превью сравнивать не с чем
|
|
213
|
+
'write.expect_without_confirm': {
|
|
214
|
+
code: 'invalid_query',
|
|
215
|
+
ru: 'delete({ expect }) — только с { confirm: true }: без него delete() и есть превью',
|
|
216
|
+
},
|
|
217
|
+
// delete({ confirm: true, expect }): превью в транзакции удаления не совпало с показанным; detail.preview — новое превью
|
|
218
|
+
'write.preview_changed': {
|
|
219
|
+
code: 'conflict',
|
|
220
|
+
ru: 'delete({ confirm: true, expect }): превью изменилось с тех пор, как его показали — ничего не удалено',
|
|
221
|
+
hint: 'показать пользователю новое превью (err.detail.preview) и удалить с ним: delete({ confirm: true, expect: err.detail.preview })',
|
|
222
|
+
},
|
|
223
|
+
// write.ts, checkFullData и checkPatchEnds (3.0, вместо api.validation_failed_end): лишний ключ данных — конец класса
|
|
224
|
+
// (имя роли или класса на конце); keys — такие ключи, role — роль первого из них. Нарушения ValidationError дописывает
|
|
225
|
+
// к тексту сам
|
|
226
|
+
'write.link_in_data': {
|
|
227
|
+
code: 'invalid_data',
|
|
228
|
+
ru: '{verb}() у {cls}: {keys} — связь, а не поле данных; конец задают путь цепочки или слот после глагола: .{role}.set(id)',
|
|
229
|
+
hint: 'конец (связь) — не поле данных: путь цепочки (db.Владелец(id).Класс().create(…)) или слот после глагола: db.Класс.create({ … }).Роль.set(id); у конца-списка — .Роль.add(id) / .Роль.remove(id), снять — .Роль.unset()',
|
|
230
|
+
},
|
|
231
|
+
// 3.0: delete({ confirm: true }) без expect и без all — удаление не сверено с показанным превью
|
|
232
|
+
'write.confirm_without_expect': {
|
|
233
|
+
code: 'invalid_query',
|
|
234
|
+
ru: 'delete({ confirm: true }) без { expect }: удаление не сверено с превью, которое видел пользователь — ничего не удалено',
|
|
235
|
+
hint: 'передайте expect из превью: const p = await db.Класс(id).delete().rows(); показать p пользователю; await db.Класс(id).delete({ confirm: true, expect: p }).rows(). Осознанно удалить текущий состав без сверки — delete({ confirm: true, all: true })',
|
|
236
|
+
},
|
|
237
|
+
// 3.0: restore() без expect и без all — и для явных id: последнее надгробие могло смениться с тех пор, как его видели
|
|
238
|
+
'write.restore_without_expect': {
|
|
239
|
+
code: 'invalid_query',
|
|
240
|
+
ru: 'restore() без { expect }: восстановление не сверено с надгробиями, которые видел пользователь — ничего не восстановлено',
|
|
241
|
+
hint: 'передайте expect — надгробия из ответа delete() или из db.Класс(id).withDeleted().rows(): restore({ expect }); восстановить то, что можно, — restore({ expect, partial: true }); осознанно вернуть все удалённые цели шага без сверки — restore({ all: true })',
|
|
242
|
+
},
|
|
180
243
|
// tx.ts — tx.ts:commitUnknown: связь оборвалась во время commit
|
|
181
244
|
'write.commit_unknown': {
|
|
182
245
|
code: 'commit_unknown',
|
package/dist/model.d.ts
CHANGED
|
@@ -210,7 +210,11 @@ type ModelOf<N extends string, D, S extends z.ZodRawShape> = Model<N, DataOf<Ext
|
|
|
210
210
|
*/
|
|
211
211
|
export declare function hub<N extends string, S extends z.ZodRawShape, const O extends ClassOpts>(name: N, shape: S, opts: O): ModelOf<N, O, S>;
|
|
212
212
|
export declare function hub<N extends string, const D extends ClassDef<z.ZodRawShape, EndsInput, AnyModel | undefined>>(name: N, def?: D): ModelOf<N, D, ShapeOf<D['fields']>>;
|
|
213
|
+
/** Короткая форма без опций: hub('Имя', { поле: z… }) — поля класса (полная форма { fields: z.object(…) } — выше). */
|
|
214
|
+
export declare function hub<N extends string, S extends z.ZodRawShape>(name: N, shape: S): ModelOf<N, {}, S>;
|
|
213
215
|
/** Связь — отношение между объектами со своими данными (бронь, навык). */
|
|
214
216
|
export declare function link<N extends string, S extends z.ZodRawShape, const O extends ClassOpts>(name: N, shape: S, opts: O): ModelOf<N, O, S>;
|
|
215
217
|
export declare function link<N extends string, const D extends ClassDef<z.ZodRawShape, EndsInput, AnyModel | undefined>>(name: N, def?: D): ModelOf<N, D, ShapeOf<D['fields']>>;
|
|
218
|
+
/** Короткая форма без опций: link('Имя', { поле: z… }) — поля класса (полная форма { fields: z.object(…) } — выше). */
|
|
219
|
+
export declare function link<N extends string, S extends z.ZodRawShape>(name: N, shape: S): ModelOf<N, {}, S>;
|
|
216
220
|
export { normalizeSchema, translatePattern, type JsonSchema } from './jsonschema.js';
|
package/dist/model.js
CHANGED
|
@@ -73,14 +73,17 @@ const DEF_KEYS = new Set(['alias', 'extends', 'abstract', 'ends', 'fields', 'key
|
|
|
73
73
|
function isZod(v) {
|
|
74
74
|
return typeof v === 'object' && v !== null && '_zod' in v;
|
|
75
75
|
}
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Пути правил Zod, которых нет в JSON Schema: refine/superRefine (`refine`) и преобразования — `overwrite` (`.trim()`,
|
|
78
|
+
* `.toLowerCase()`, `.toUpperCase()`, `.normalize()` …) и `transform` (`.transform()`, `z.preprocess()`, `.pipe()`).
|
|
79
|
+
* Преобразования база и валидатор letopis не исполняют: их syncModels отклоняет (план 2.1, A11).
|
|
80
|
+
*/
|
|
77
81
|
function collectLost(schema, path, out) {
|
|
78
82
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
83
|
const def = schema._zod.def;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
+
collectChecks(schema, path, out);
|
|
85
|
+
if (def.type === 'transform')
|
|
86
|
+
out.push({ path, kind: 'transform' });
|
|
84
87
|
if (def.type === 'object')
|
|
85
88
|
for (const [k, v] of Object.entries(def.shape))
|
|
86
89
|
collectLost(v, `${path}.${k}`, out);
|
|
@@ -95,6 +98,17 @@ function collectLost(schema, path, out) {
|
|
|
95
98
|
if (def.innerType)
|
|
96
99
|
collectLost(def.innerType, path, out);
|
|
97
100
|
}
|
|
101
|
+
/** Проверки самой схемы (без вложенных): refine/superRefine/check — `refine`, `overwrite` — преобразование. */
|
|
102
|
+
function collectChecks(schema, path, out) {
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
for (const c of schema._zod.def.checks ?? []) {
|
|
105
|
+
const check = c?._zod?.def?.check;
|
|
106
|
+
if (check === 'custom')
|
|
107
|
+
out.push({ path, kind: 'refine' });
|
|
108
|
+
else if (check === 'overwrite')
|
|
109
|
+
out.push({ path, kind: 'overwrite' });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
98
112
|
function makeModel(kind, name, defArg, optsArg) {
|
|
99
113
|
if (!NAME.test(name))
|
|
100
114
|
throw lerr('model.class_name_invalid', { name });
|
|
@@ -222,6 +236,10 @@ function makeModel(kind, name, defArg, optsArg) {
|
|
|
222
236
|
},
|
|
223
237
|
lostRules() {
|
|
224
238
|
const out = [];
|
|
239
|
+
// проверки z.object(…).refine/superRefine/check — на уровне объекта (между полями): в описание класса идёт
|
|
240
|
+
// только shape, поэтому они тоже потеряны — путь — имя класса
|
|
241
|
+
if (fields instanceof z.ZodObject)
|
|
242
|
+
collectChecks(fields, name, out);
|
|
225
243
|
for (const [k, v] of Object.entries(ownShape))
|
|
226
244
|
collectLost(v, `${name}.${k}`, out);
|
|
227
245
|
return out;
|
package/dist/sql.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface Step {
|
|
|
18
18
|
tagsFilter?: unknown;
|
|
19
19
|
ownerFilter?: string;
|
|
20
20
|
deepMax?: number;
|
|
21
|
+
/** withDeleted(): узел шага — живые и надгробия удалённых (модификатор шага, план 2.1, A9). */
|
|
22
|
+
withDeleted?: true;
|
|
21
23
|
/** Звено плана записи (этап 4): глагол на этом шаге; построитель чтения его не видит. */
|
|
22
24
|
op?: import('./write.js').PlanOp;
|
|
23
25
|
/** Модификаторы цепочки на момент глагола: limit и sort сужают цели записи. */
|
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?: {
|
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)
|
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 {};
|