odant-work 1.0.12 → 1.0.13
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/#system/certs/russian_trusted_root_ca.pem +33 -0
- package/#system/mail.json.example +9 -0
- package/#system/package-manifest.json +986 -0
- package/#system/sandbox.json +19 -0
- package/#system/settings.json.example +3 -0
- package/package.json +1 -1
- package/run.mjs +78 -11
- package/scripts/check-ignored-code.mjs +25 -1
- package/sources/data/api.js +1071 -0
- package/sources/data/choices.js +125 -0
- package/sources/data/classes.js +412 -0
- package/sources/data/db.js +383 -0
- package/sources/data/errors.js +15 -0
- package/sources/data/expr.js +456 -0
- package/sources/data/objects.js +950 -0
- package/sources/data/posting.js +657 -0
- package/sources/data/print.js +524 -0
- package/sources/data/progress.md +526 -0
- package/sources/data/readme.md +77 -0
- package/sources/data/reports.js +1033 -0
- package/sources/data/schema.js +586 -0
- package/sources/data/scope.js +281 -0
- package/sources/data/sql-inspector.js +892 -0
- package/sources/data/time.js +67 -0
- package/sources/data/workspace.js +152 -0
- package/sources/host/auto-updater.js +52 -10
- package/.vscode/launch.json +0 -32
- package/.vscode/settings.json +0 -2
- package/.vscode/tasks.json +0 -65
- package/ORGANIZATIONS//320/221/320/270/320/267/320/275/320/265/321/201 /320/230/320/275/321/202/320/265/321/200 /320/241/320/276/321/204/321/202/orders/$structure/video/IMG_8524.MP4 +0 -0
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Инспектор и редактор SQLite-баз WORK (`sources/data/sql-inspector.js`).
|
|
3
|
+
*
|
|
4
|
+
* Позволяет администратору (`ADMIN`) просматривать структуру (таблицы, колонки,
|
|
5
|
+
* индексы, физические и логические связи ER) и содержимое всех SQL-баз:
|
|
6
|
+
* - на уровне организации (`$base`) — изолированной БД `<Организация>/$base/#system/work.db`;
|
|
7
|
+
* - на уровне сервера (`$server`) — прикладной БД `./.data/work.db`, индексной БД `./.index/work.db`
|
|
8
|
+
* и баз организаций `/ORGANIZATIONS/*`.
|
|
9
|
+
* Поддерживает фильтрацию, поиск, переходы по внешним ключам, редактирование, добавление и удаление строк.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { dataStore, resolveBasePath, markClassesDirty } from './api.js';
|
|
14
|
+
import { baseDbFile, tx, bumpDataGeneration } from './db.js';
|
|
15
|
+
import { DataError } from './errors.js';
|
|
16
|
+
import { indexDb } from '../host/index-db.js';
|
|
17
|
+
|
|
18
|
+
/** Человекочитаемые описания таблиц WORK и индекса. */
|
|
19
|
+
export const TABLE_DESCRIPTIONS = Object.freeze({
|
|
20
|
+
classes: 'Кэш классов дерева WORK и их собственных метаданных (class.js)',
|
|
21
|
+
class_tree: 'Транзитивное замыкание иерархии классов (ancestor → descendant)',
|
|
22
|
+
objects: 'Объекты справочников ($catalog) и документы операций ($operation)',
|
|
23
|
+
object_versions: 'Версии объектов и история изменений во времени (valid_from)',
|
|
24
|
+
refs: 'Ссылки между объектами (поля Link и строки табличных частей)',
|
|
25
|
+
objects_fts: 'Полнотекстовый индекс объектов (FTS5 unicode61)',
|
|
26
|
+
entries: 'Журнал двойных проводок (связка Дебет ↔ Кредит по операциям)',
|
|
27
|
+
entry_analytics: 'Субконто (аналитики) проводок журнала по сторонам Дт и Кт',
|
|
28
|
+
account_entries: 'Записи регистров по классам счетов ($account) со своими аналитиками',
|
|
29
|
+
logs: 'Журнал событий, чатов подразделений, классов и личных кабинетов',
|
|
30
|
+
doc_links: 'Подшивка документов (связи «Основание → Следствие» между операциями)',
|
|
31
|
+
settings: 'Системные параметры и настройки базы данных (в т.ч. дата закрытия периода)',
|
|
32
|
+
// Таблицы .index/work.db
|
|
33
|
+
rag_docs: 'RAG: проиндексированные документы и файлы дерева',
|
|
34
|
+
rag_contents: 'RAG: дедуплицированное текстовое содержимое по SHA-256 hash',
|
|
35
|
+
rag_chunks: 'RAG: текстовые фрагменты (чанки) документов',
|
|
36
|
+
rag_vectors: 'RAG: эмбеддинги (векторы) чанков для семантического поиска',
|
|
37
|
+
rag_chunks_fts: 'RAG: полнотекстовый индекс чанков (FTS5)',
|
|
38
|
+
rag_grants: 'RAG: права видимости областей для фильтрации поиска',
|
|
39
|
+
rag_classes: 'RAG: реестр проиндексированных классов',
|
|
40
|
+
agent_usage: 'ИИ-агент: учёт расхода токенов и стоимости ходов по дням и задачам',
|
|
41
|
+
agent_schedules: 'ИИ-агент: расписания автоматического запуска задач (.task)',
|
|
42
|
+
feed_index: 'Индекс лент событий и обратных ссылок доступа',
|
|
43
|
+
node_nonces: 'Федерация узлов (/NODES): одноразовые nonce подписанных запросов',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Логические связи между таблицами схемы WORK и индекса
|
|
48
|
+
* (в дополнение к физическим PRAGMA foreign_key_list).
|
|
49
|
+
*/
|
|
50
|
+
export const LOGICAL_RELATIONS = Object.freeze([
|
|
51
|
+
{ fromTable: 'classes', fromColumn: 'parent', toTable: 'classes', toColumn: 'cid', label: 'Родительский класс' },
|
|
52
|
+
{ fromTable: 'class_tree', fromColumn: 'ancestor', toTable: 'classes', toColumn: 'cid', label: 'Класс-предок' },
|
|
53
|
+
{ fromTable: 'class_tree', fromColumn: 'descendant', toTable: 'classes', toColumn: 'cid', label: 'Класс-потомок' },
|
|
54
|
+
{ fromTable: 'objects', fromColumn: 'cid', toTable: 'classes', toColumn: 'cid', label: 'Класс объекта' },
|
|
55
|
+
{ fromTable: 'objects', fromColumn: 'unit', toTable: 'classes', toColumn: 'cid', label: 'Подразделение' },
|
|
56
|
+
{ fromTable: 'objects', fromColumn: 'ext_of', toTable: 'objects', toColumn: 'id', label: 'Базовый объект ($virtual)' },
|
|
57
|
+
{ fromTable: 'object_versions', fromColumn: 'id', toTable: 'objects', toColumn: 'id', label: 'Объект версии' },
|
|
58
|
+
{ fromTable: 'refs', fromColumn: 'obj_id', toTable: 'objects', toColumn: 'id', label: 'Объект-источник' },
|
|
59
|
+
{ fromTable: 'refs', fromColumn: 'ref_id', toTable: 'objects', toColumn: 'id', label: 'Целевой объект' },
|
|
60
|
+
{ fromTable: 'refs', fromColumn: 'ref_cid', toTable: 'classes', toColumn: 'cid', label: 'Целевой класс' },
|
|
61
|
+
{ fromTable: 'entries', fromColumn: 'op_id', toTable: 'objects', toColumn: 'id', label: 'Операция проводки' },
|
|
62
|
+
{ fromTable: 'entries', fromColumn: 'op_cid', toTable: 'classes', toColumn: 'cid', label: 'Класс операции' },
|
|
63
|
+
{ fromTable: 'entries', fromColumn: 'debit_cid', toTable: 'classes', toColumn: 'cid', label: 'Счёт дебета' },
|
|
64
|
+
{ fromTable: 'entries', fromColumn: 'credit_cid', toTable: 'classes', toColumn: 'cid', label: 'Счёт кредита' },
|
|
65
|
+
{ fromTable: 'entries', fromColumn: 'unit', toTable: 'classes', toColumn: 'cid', label: 'Подразделение' },
|
|
66
|
+
{ fromTable: 'entry_analytics', fromColumn: 'entry_id', toTable: 'entries', toColumn: 'id', label: 'Проводка журнала' },
|
|
67
|
+
{ fromTable: 'entry_analytics', fromColumn: 'ref_id', toTable: 'objects', toColumn: 'id', label: 'Объект субконто' },
|
|
68
|
+
{ fromTable: 'entry_analytics', fromColumn: 'ref_cid', toTable: 'classes', toColumn: 'cid', label: 'Класс субконто' },
|
|
69
|
+
{ fromTable: 'account_entries', fromColumn: 'entry_id', toTable: 'entries', toColumn: 'id', label: 'Проводка журнала' },
|
|
70
|
+
{ fromTable: 'account_entries', fromColumn: 'account_cid', toTable: 'classes', toColumn: 'cid', label: 'Счёт записи' },
|
|
71
|
+
{ fromTable: 'account_entries', fromColumn: 'corr_cid', toTable: 'classes', toColumn: 'cid', label: 'Корр. счёт' },
|
|
72
|
+
{ fromTable: 'account_entries', fromColumn: 'op_id', toTable: 'objects', toColumn: 'id', label: 'Операция проводки' },
|
|
73
|
+
{ fromTable: 'account_entries', fromColumn: 'op_cid', toTable: 'classes', toColumn: 'cid', label: 'Класс операции' },
|
|
74
|
+
{ fromTable: 'account_entries', fromColumn: 'unit', toTable: 'classes', toColumn: 'cid', label: 'Подразделение' },
|
|
75
|
+
{ fromTable: 'doc_links', fromColumn: 'source_id', toTable: 'objects', toColumn: 'id', label: 'Документ-основание' },
|
|
76
|
+
{ fromTable: 'doc_links', fromColumn: 'target_id', toTable: 'objects', toColumn: 'id', label: 'Документ-следствие' },
|
|
77
|
+
// Индексная БД (.index/work.db)
|
|
78
|
+
{ fromTable: 'rag_docs', fromColumn: 'hash', toTable: 'rag_contents', toColumn: 'hash', label: 'Контент по SHA-256' },
|
|
79
|
+
{ fromTable: 'rag_chunks', fromColumn: 'doc_path', toTable: 'rag_docs', toColumn: 'path', label: 'Документ фрагмента' },
|
|
80
|
+
{ fromTable: 'rag_vectors', fromColumn: 'chunk_id', toTable: 'rag_chunks', toColumn: 'id', label: 'Фрагмент вектора' },
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const FTS_SHADOW_SUFFIXES = ['_data', '_idx', '_content', '_docsize', '_config'];
|
|
84
|
+
|
|
85
|
+
function escapeIdent(ident) {
|
|
86
|
+
return String(ident ?? '').replace(/"/g, '""');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fileSizeSafe(filePath) {
|
|
90
|
+
try {
|
|
91
|
+
return fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function inspectDbQuickStats(db, absFile) {
|
|
99
|
+
const sizeBytes = fileSizeSafe(absFile);
|
|
100
|
+
const walBytes = fileSizeSafe(absFile + '-wal');
|
|
101
|
+
let userVersion = 0;
|
|
102
|
+
let journalMode = '';
|
|
103
|
+
let tableCount = 0;
|
|
104
|
+
if (db) {
|
|
105
|
+
try {
|
|
106
|
+
userVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version) || 0;
|
|
107
|
+
journalMode = String(db.prepare('PRAGMA journal_mode').get()?.journal_mode || '');
|
|
108
|
+
const rows = db.prepare(`
|
|
109
|
+
SELECT name, sql FROM sqlite_master
|
|
110
|
+
WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'
|
|
111
|
+
`).all();
|
|
112
|
+
const ftsRoots = new Set(
|
|
113
|
+
rows.filter(r => /^CREATE\s+VIRTUAL\s+TABLE/i.test(String(r.sql || ''))).map(r => r.name)
|
|
114
|
+
);
|
|
115
|
+
tableCount = rows.filter(r => {
|
|
116
|
+
for (const root of ftsRoots) {
|
|
117
|
+
if (FTS_SHADOW_SUFFIXES.some(suf => r.name === root + suf))
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}).length;
|
|
122
|
+
}
|
|
123
|
+
catch { /* ignore */ }
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
exists: fs.existsSync(absFile),
|
|
127
|
+
sizeBytes,
|
|
128
|
+
walBytes,
|
|
129
|
+
totalBytes: sizeBytes + walBytes,
|
|
130
|
+
userVersion,
|
|
131
|
+
journalMode,
|
|
132
|
+
tableCount,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Проверить права ADMIN на элементе вызова.
|
|
138
|
+
*/
|
|
139
|
+
async function assertAdminAccess(item, params = {}) {
|
|
140
|
+
if (item && typeof item.assertAccess === 'function')
|
|
141
|
+
await item.assertAccess(params, 'ADMIN');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Список доступных SQL-баз для текущего контекста (`$server` или `$base`).
|
|
146
|
+
*/
|
|
147
|
+
export async function listSqlDatabases(item, params = {}) {
|
|
148
|
+
await assertAdminAccess(item, params);
|
|
149
|
+
const rootDir = process.cwd();
|
|
150
|
+
const itemBasePath = resolveBasePath(item);
|
|
151
|
+
|
|
152
|
+
// Если открыта конкретная организация ($base) — показываем строго её изолированную БД
|
|
153
|
+
if (itemBasePath) {
|
|
154
|
+
const orgName = itemBasePath.split('/').filter(Boolean).pop() || itemBasePath;
|
|
155
|
+
const absFile = baseDbFile(rootDir, itemBasePath);
|
|
156
|
+
const relFile = path.relative(rootDir, absFile).replace(/\\/g, '/');
|
|
157
|
+
const db = await dataStore(itemBasePath);
|
|
158
|
+
const stats = inspectDbQuickStats(db, absFile);
|
|
159
|
+
return {
|
|
160
|
+
scope: 'base',
|
|
161
|
+
basePath: itemBasePath,
|
|
162
|
+
databases: [
|
|
163
|
+
{
|
|
164
|
+
id: `base:${itemBasePath}`,
|
|
165
|
+
label: `${orgName} (work.db)`,
|
|
166
|
+
kind: 'base',
|
|
167
|
+
basePath: itemBasePath,
|
|
168
|
+
file: relFile,
|
|
169
|
+
...stats,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// На уровне корневого сервера WORK ($server) доступны серверные БД и базы всех организаций
|
|
176
|
+
const databases = [];
|
|
177
|
+
|
|
178
|
+
// 1. Прикладная БД сервера (.data/work.db)
|
|
179
|
+
const serverDataFile = baseDbFile(rootDir, '');
|
|
180
|
+
const serverDataDb = await dataStore('');
|
|
181
|
+
databases.push({
|
|
182
|
+
id: 'data',
|
|
183
|
+
label: 'WORK — Прикладная БД (.data/work.db)',
|
|
184
|
+
kind: 'server-data',
|
|
185
|
+
basePath: '',
|
|
186
|
+
file: path.relative(rootDir, serverDataFile).replace(/\\/g, '/'),
|
|
187
|
+
...inspectDbQuickStats(serverDataDb, serverDataFile),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 2. Индексная БД сервера (.index/work.db)
|
|
191
|
+
const indexFile = path.resolve(rootDir, '.index', 'work.db');
|
|
192
|
+
const idxDb = await indexDb();
|
|
193
|
+
databases.push({
|
|
194
|
+
id: 'index',
|
|
195
|
+
label: 'WORK — Индексная БД (.index/work.db)',
|
|
196
|
+
kind: 'server-index',
|
|
197
|
+
basePath: '',
|
|
198
|
+
file: path.relative(rootDir, indexFile).replace(/\\/g, '/'),
|
|
199
|
+
...inspectDbQuickStats(idxDb, indexFile),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// 3. Изолированные БД всех организаций в /ORGANIZATIONS
|
|
203
|
+
const orgsDir = path.resolve(rootDir, 'ORGANIZATIONS');
|
|
204
|
+
if (fs.existsSync(orgsDir)) {
|
|
205
|
+
const entries = fs.readdirSync(orgsDir, { withFileTypes: true })
|
|
206
|
+
.filter(d => d.isDirectory() && !d.name.startsWith('$') && !d.name.startsWith('.') && !d.name.startsWith('#'))
|
|
207
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
|
208
|
+
for (const d of entries) {
|
|
209
|
+
const baseMetaDir = path.join(orgsDir, d.name, '$base');
|
|
210
|
+
if (!fs.existsSync(baseMetaDir))
|
|
211
|
+
continue;
|
|
212
|
+
const bp = `/ORGANIZATIONS/${d.name}`;
|
|
213
|
+
const absFile = baseDbFile(rootDir, bp);
|
|
214
|
+
const relFile = path.relative(rootDir, absFile).replace(/\\/g, '/');
|
|
215
|
+
const db = fs.existsSync(absFile) ? await dataStore(bp) : null;
|
|
216
|
+
databases.push({
|
|
217
|
+
id: `base:${bp}`,
|
|
218
|
+
label: `${d.name} (work.db)`,
|
|
219
|
+
kind: 'base',
|
|
220
|
+
basePath: bp,
|
|
221
|
+
file: relFile,
|
|
222
|
+
...inspectDbQuickStats(db, absFile),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
scope: 'server',
|
|
229
|
+
basePath: '',
|
|
230
|
+
databases,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Открыть целевую БД по контексту элемента и параметру `dbId`.
|
|
236
|
+
* Гарантирует, что из контекста `$base` нельзя обратиться к чужой БД.
|
|
237
|
+
*/
|
|
238
|
+
export async function resolveSqlDatabase(item, params = {}) {
|
|
239
|
+
await assertAdminAccess(item, params);
|
|
240
|
+
const rootDir = process.cwd();
|
|
241
|
+
const itemBasePath = resolveBasePath(item);
|
|
242
|
+
if (itemBasePath) {
|
|
243
|
+
const db = await dataStore(itemBasePath);
|
|
244
|
+
const absFile = baseDbFile(rootDir, itemBasePath);
|
|
245
|
+
return {
|
|
246
|
+
db,
|
|
247
|
+
dbId: `base:${itemBasePath}`,
|
|
248
|
+
kind: 'base',
|
|
249
|
+
basePath: itemBasePath,
|
|
250
|
+
file: path.relative(rootDir, absFile).replace(/\\/g, '/'),
|
|
251
|
+
absFile,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const reqId = String(params?.dbId || params?.db || 'data').trim();
|
|
256
|
+
if (reqId === 'index') {
|
|
257
|
+
const db = await indexDb();
|
|
258
|
+
if (!db)
|
|
259
|
+
throw new DataError('Индексная БД (.index/work.db) недоступна');
|
|
260
|
+
const absFile = path.resolve(rootDir, '.index', 'work.db');
|
|
261
|
+
return {
|
|
262
|
+
db,
|
|
263
|
+
dbId: 'index',
|
|
264
|
+
kind: 'server-index',
|
|
265
|
+
basePath: '',
|
|
266
|
+
file: path.relative(rootDir, absFile).replace(/\\/g, '/'),
|
|
267
|
+
absFile,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (reqId.startsWith('base:')) {
|
|
271
|
+
const bp = reqId.slice('base:'.length).trim();
|
|
272
|
+
if (!/^\/(ORGANIZATIONS|BASES)\/[^/]+$/.test(bp))
|
|
273
|
+
throw new DataError(`Недопустимый путь базы организации: ${bp}`);
|
|
274
|
+
const db = await dataStore(bp);
|
|
275
|
+
const absFile = baseDbFile(rootDir, bp);
|
|
276
|
+
return {
|
|
277
|
+
db,
|
|
278
|
+
dbId: `base:${bp}`,
|
|
279
|
+
kind: 'base',
|
|
280
|
+
basePath: bp,
|
|
281
|
+
file: path.relative(rootDir, absFile).replace(/\\/g, '/'),
|
|
282
|
+
absFile,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const db = await dataStore('');
|
|
287
|
+
const absFile = baseDbFile(rootDir, '');
|
|
288
|
+
return {
|
|
289
|
+
db,
|
|
290
|
+
dbId: 'data',
|
|
291
|
+
kind: 'server-data',
|
|
292
|
+
basePath: '',
|
|
293
|
+
file: path.relative(rootDir, absFile).replace(/\\/g, '/'),
|
|
294
|
+
absFile,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Собрать метаданные таблиц, колонок, индексов и связей выбранной БД.
|
|
300
|
+
*/
|
|
301
|
+
export function inspectDatabaseSchema(db) {
|
|
302
|
+
const rawObjects = db.prepare(`
|
|
303
|
+
SELECT name, type, sql
|
|
304
|
+
FROM sqlite_master
|
|
305
|
+
WHERE type IN ('table', 'view')
|
|
306
|
+
AND name NOT LIKE 'sqlite_%'
|
|
307
|
+
ORDER BY name
|
|
308
|
+
`).all();
|
|
309
|
+
|
|
310
|
+
const ftsRoots = new Set(
|
|
311
|
+
rawObjects
|
|
312
|
+
.filter(r => /^CREATE\s+VIRTUAL\s+TABLE/i.test(String(r.sql || '')))
|
|
313
|
+
.map(r => r.name)
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const tableMap = new Map();
|
|
317
|
+
const tables = [];
|
|
318
|
+
|
|
319
|
+
for (const obj of rawObjects) {
|
|
320
|
+
const name = String(obj.name);
|
|
321
|
+
const ddl = String(obj.sql || '');
|
|
322
|
+
const isVirtual = /^CREATE\s+VIRTUAL\s+TABLE/i.test(ddl);
|
|
323
|
+
const withoutRowid = /\bWITHOUT\s+ROWID\b/i.test(ddl);
|
|
324
|
+
let isShadow = false;
|
|
325
|
+
let shadowOf = '';
|
|
326
|
+
for (const root of ftsRoots) {
|
|
327
|
+
if (FTS_SHADOW_SUFFIXES.some(suf => name === root + suf)) {
|
|
328
|
+
isShadow = true;
|
|
329
|
+
shadowOf = root;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let columns = [];
|
|
335
|
+
try {
|
|
336
|
+
columns = db.prepare(`PRAGMA table_info("${escapeIdent(name)}")`).all().map(c => ({
|
|
337
|
+
cid: Number(c.cid),
|
|
338
|
+
name: String(c.name),
|
|
339
|
+
colType: String(c.type || 'TEXT'),
|
|
340
|
+
notnull: Boolean(c.notnull),
|
|
341
|
+
dfltValue: c.dflt_value ?? null,
|
|
342
|
+
pk: Number(c.pk) || 0,
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
columns = [];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const pkColumns = columns
|
|
350
|
+
.filter(c => c.pk > 0)
|
|
351
|
+
.sort((a, b) => a.pk - b.pk)
|
|
352
|
+
.map(c => c.name);
|
|
353
|
+
|
|
354
|
+
const hasRowid = !withoutRowid && obj.type === 'table';
|
|
355
|
+
|
|
356
|
+
let indexes = [];
|
|
357
|
+
try {
|
|
358
|
+
const idxList = db.prepare(`PRAGMA index_list("${escapeIdent(name)}")`).all();
|
|
359
|
+
indexes = idxList.map(idx => {
|
|
360
|
+
let idxCols = [];
|
|
361
|
+
try {
|
|
362
|
+
idxCols = db.prepare(`PRAGMA index_info("${escapeIdent(idx.name)}")`)
|
|
363
|
+
.all()
|
|
364
|
+
.map(ic => String(ic.name || ''))
|
|
365
|
+
.filter(Boolean);
|
|
366
|
+
}
|
|
367
|
+
catch { /* ignore */ }
|
|
368
|
+
return {
|
|
369
|
+
name: String(idx.name),
|
|
370
|
+
unique: Boolean(idx.unique),
|
|
371
|
+
origin: String(idx.origin || ''),
|
|
372
|
+
columns: idxCols,
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
indexes = [];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let rowCount = 0;
|
|
381
|
+
try {
|
|
382
|
+
rowCount = Number(db.prepare(`SELECT count(*) AS n FROM "${escapeIdent(name)}"`).get()?.n) || 0;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
rowCount = 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const tableInfo = {
|
|
389
|
+
name,
|
|
390
|
+
objectType: String(obj.type), // 'table' | 'view'
|
|
391
|
+
isVirtual,
|
|
392
|
+
withoutRowid,
|
|
393
|
+
isShadow,
|
|
394
|
+
shadowOf,
|
|
395
|
+
hasRowid,
|
|
396
|
+
pkColumns,
|
|
397
|
+
rowCount,
|
|
398
|
+
description: TABLE_DESCRIPTIONS[name] || (isShadow ? `Служебная таблица FTS5 (${shadowOf})` : ''),
|
|
399
|
+
sql: ddl,
|
|
400
|
+
columns,
|
|
401
|
+
indexes,
|
|
402
|
+
};
|
|
403
|
+
tableMap.set(name, tableInfo);
|
|
404
|
+
tables.push(tableInfo);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Собираем физические и логические связи между таблицами
|
|
408
|
+
const relations = [];
|
|
409
|
+
const seenRel = new Set();
|
|
410
|
+
|
|
411
|
+
const addRelation = (rel) => {
|
|
412
|
+
if (!tableMap.has(rel.fromTable) || !tableMap.has(rel.toTable))
|
|
413
|
+
return;
|
|
414
|
+
const fromTbl = tableMap.get(rel.fromTable);
|
|
415
|
+
const toTbl = tableMap.get(rel.toTable);
|
|
416
|
+
if (!fromTbl.columns.some(c => c.name === rel.fromColumn))
|
|
417
|
+
return;
|
|
418
|
+
if (!toTbl.columns.some(c => c.name === rel.toColumn) && rel.toColumn !== 'rowid')
|
|
419
|
+
return;
|
|
420
|
+
const key = `${rel.fromTable}.${rel.fromColumn}->${rel.toTable}.${rel.toColumn}`;
|
|
421
|
+
if (seenRel.has(key))
|
|
422
|
+
return;
|
|
423
|
+
seenRel.add(key);
|
|
424
|
+
relations.push(rel);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
for (const tbl of tables) {
|
|
428
|
+
if (tbl.isShadow || tbl.isVirtual)
|
|
429
|
+
continue;
|
|
430
|
+
try {
|
|
431
|
+
const fks = db.prepare(`PRAGMA foreign_key_list("${escapeIdent(tbl.name)}")`).all();
|
|
432
|
+
for (const fk of fks) {
|
|
433
|
+
addRelation({
|
|
434
|
+
fromTable: tbl.name,
|
|
435
|
+
fromColumn: String(fk.from),
|
|
436
|
+
toTable: String(fk.table),
|
|
437
|
+
toColumn: String(fk.to || 'id'),
|
|
438
|
+
label: 'FOREIGN KEY',
|
|
439
|
+
kind: 'fk',
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch { /* ignore */ }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (const rel of LOGICAL_RELATIONS) {
|
|
447
|
+
addRelation({ ...rel, kind: 'logical' });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return { tables, relations };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Проверить существование таблицы и вернуть её описание из схемы.
|
|
455
|
+
*/
|
|
456
|
+
function getValidatedTable(db, tableName) {
|
|
457
|
+
const clean = String(tableName ?? '').trim();
|
|
458
|
+
if (!clean)
|
|
459
|
+
throw new DataError('Не указано имя таблицы');
|
|
460
|
+
const schema = inspectDatabaseSchema(db);
|
|
461
|
+
const tbl = schema.tables.find(t => t.name === clean);
|
|
462
|
+
if (!tbl)
|
|
463
|
+
throw new DataError(`Таблица «${clean}» не найдена в базе данных`);
|
|
464
|
+
return { tbl, schema };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Преобразовать значения строки для безопасной JSON-передачи (обработка BLOB / Uint8Array).
|
|
469
|
+
*/
|
|
470
|
+
function serializeRow(row) {
|
|
471
|
+
if (!row || typeof row !== 'object')
|
|
472
|
+
return row;
|
|
473
|
+
const out = {};
|
|
474
|
+
for (const [k, v] of Object.entries(row)) {
|
|
475
|
+
if (v instanceof Uint8Array || Buffer.isBuffer(v)) {
|
|
476
|
+
out[k] = `[BLOB: ${v.byteLength} байт]`;
|
|
477
|
+
}
|
|
478
|
+
else if (typeof v === 'bigint') {
|
|
479
|
+
out[k] = Number.isSafeInteger(Number(v)) ? Number(v) : String(v);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
out[k] = v;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Получить структуру выбранной БД (`sql_schema`).
|
|
490
|
+
*/
|
|
491
|
+
export async function getSqlSchema(item, params = {}) {
|
|
492
|
+
const resolved = await resolveSqlDatabase(item, params);
|
|
493
|
+
const stats = inspectDbQuickStats(resolved.db, resolved.absFile);
|
|
494
|
+
const { tables, relations } = inspectDatabaseSchema(resolved.db);
|
|
495
|
+
return {
|
|
496
|
+
dbId: resolved.dbId,
|
|
497
|
+
kind: resolved.kind,
|
|
498
|
+
basePath: resolved.basePath,
|
|
499
|
+
file: resolved.file,
|
|
500
|
+
...stats,
|
|
501
|
+
tables,
|
|
502
|
+
relations,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Выборка записей таблицы с поиском, фильтрами по колонкам, сортировкой и пагинацией (`sql_rows`).
|
|
508
|
+
*/
|
|
509
|
+
export async function querySqlRows(item, params = {}) {
|
|
510
|
+
const resolved = await resolveSqlDatabase(item, params);
|
|
511
|
+
const { db } = resolved;
|
|
512
|
+
const { tbl, schema } = getValidatedTable(db, params.table);
|
|
513
|
+
|
|
514
|
+
const colNames = new Set(tbl.columns.map(c => c.name));
|
|
515
|
+
const useRowidKey = tbl.pkColumns.length === 0 && tbl.hasRowid;
|
|
516
|
+
const keyColumns = tbl.pkColumns.length > 0 ? tbl.pkColumns : (tbl.hasRowid ? ['__rowid__'] : tbl.columns.map(c => c.name));
|
|
517
|
+
|
|
518
|
+
const limit = Math.min(Math.max(Number(params.limit) || 50, 1), 1000);
|
|
519
|
+
const offset = Math.max(Number(params.offset) || 0, 0);
|
|
520
|
+
const search = String(params.search ?? '').trim();
|
|
521
|
+
|
|
522
|
+
let filters = params.filters;
|
|
523
|
+
if (typeof filters === 'string' && filters.trim()) {
|
|
524
|
+
try { filters = JSON.parse(filters); } catch { filters = null; }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const whereParts = [];
|
|
528
|
+
const bindArgs = [];
|
|
529
|
+
|
|
530
|
+
if (filters && typeof filters === 'object' && !Array.isArray(filters)) {
|
|
531
|
+
for (const [col, val] of Object.entries(filters)) {
|
|
532
|
+
if (val === undefined || val === null || val === '')
|
|
533
|
+
continue;
|
|
534
|
+
if (col === '__rowid__' && tbl.hasRowid) {
|
|
535
|
+
whereParts.push('rowid = ?');
|
|
536
|
+
bindArgs.push(Number(val));
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!colNames.has(col))
|
|
540
|
+
continue;
|
|
541
|
+
const strVal = String(val);
|
|
542
|
+
if (strVal.startsWith('exact:')) {
|
|
543
|
+
whereParts.push(`CAST("${escapeIdent(col)}" AS TEXT) = ?`);
|
|
544
|
+
bindArgs.push(strVal.slice('exact:'.length));
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
whereParts.push(`CAST("${escapeIdent(col)}" AS TEXT) LIKE ?`);
|
|
548
|
+
bindArgs.push(`%${strVal}%`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (search) {
|
|
554
|
+
if (tbl.isVirtual && colNames.has('text')) {
|
|
555
|
+
whereParts.push(`CAST("text" AS TEXT) LIKE ?`);
|
|
556
|
+
bindArgs.push(`%${search}%`);
|
|
557
|
+
}
|
|
558
|
+
else if (tbl.columns.length > 0) {
|
|
559
|
+
const orParts = tbl.columns.map(c => `CAST("${escapeIdent(c.name)}" AS TEXT) LIKE ?`);
|
|
560
|
+
whereParts.push(`(${orParts.join(' OR ')})`);
|
|
561
|
+
for (let i = 0; i < tbl.columns.length; i++)
|
|
562
|
+
bindArgs.push(`%${search}%`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
|
567
|
+
|
|
568
|
+
const sortCol = String(params.sortCol ?? '').trim();
|
|
569
|
+
const sortDir = String(params.sortDir ?? 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
570
|
+
let orderSql = '';
|
|
571
|
+
if (sortCol && (colNames.has(sortCol) || (sortCol === '__rowid__' && tbl.hasRowid))) {
|
|
572
|
+
const expr = sortCol === '__rowid__' ? 'rowid' : `"${escapeIdent(sortCol)}"`;
|
|
573
|
+
orderSql = `ORDER BY ${expr} ${sortDir}`;
|
|
574
|
+
}
|
|
575
|
+
else if (tbl.pkColumns.length > 0) {
|
|
576
|
+
orderSql = `ORDER BY ${tbl.pkColumns.map(c => `"${escapeIdent(c)}" ASC`).join(', ')}`;
|
|
577
|
+
}
|
|
578
|
+
else if (tbl.hasRowid) {
|
|
579
|
+
orderSql = 'ORDER BY rowid ASC';
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const selectPrefix = useRowidKey || tbl.hasRowid ? 'rowid AS "__rowid__", *' : '*';
|
|
583
|
+
const total = Number(
|
|
584
|
+
db.prepare(`SELECT count(*) AS n FROM "${escapeIdent(tbl.name)}" ${whereSql}`).get(...bindArgs)?.n
|
|
585
|
+
) || 0;
|
|
586
|
+
|
|
587
|
+
const rawRows = db.prepare(
|
|
588
|
+
`SELECT ${selectPrefix} FROM "${escapeIdent(tbl.name)}" ${whereSql} ${orderSql} LIMIT ? OFFSET ?`
|
|
589
|
+
).all(...bindArgs, limit, offset);
|
|
590
|
+
|
|
591
|
+
const rows = rawRows.map(serializeRow);
|
|
592
|
+
const outgoingRelations = schema.relations.filter(r => r.fromTable === tbl.name);
|
|
593
|
+
const incomingRelations = schema.relations.filter(r => r.toTable === tbl.name);
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
dbId: resolved.dbId,
|
|
597
|
+
table: tbl.name,
|
|
598
|
+
keyColumns,
|
|
599
|
+
pkColumns: tbl.pkColumns,
|
|
600
|
+
hasRowid: tbl.hasRowid,
|
|
601
|
+
withoutRowid: tbl.withoutRowid,
|
|
602
|
+
isVirtual: tbl.isVirtual,
|
|
603
|
+
columns: tbl.columns,
|
|
604
|
+
outgoingRelations,
|
|
605
|
+
incomingRelations,
|
|
606
|
+
total,
|
|
607
|
+
limit,
|
|
608
|
+
offset,
|
|
609
|
+
rows,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Сформировать SQL-условие `WHERE` для точной идентификации одной строки по её ключу (`pk`).
|
|
615
|
+
*/
|
|
616
|
+
function buildPkWhere(tbl, pkInput) {
|
|
617
|
+
if (!pkInput || typeof pkInput !== 'object')
|
|
618
|
+
throw new DataError('Не передан первичный ключ записи (pk)');
|
|
619
|
+
|
|
620
|
+
const colNames = new Set(tbl.columns.map(c => c.name));
|
|
621
|
+
const whereParts = [];
|
|
622
|
+
const args = [];
|
|
623
|
+
|
|
624
|
+
if (tbl.pkColumns.length > 0) {
|
|
625
|
+
for (const pkCol of tbl.pkColumns) {
|
|
626
|
+
if (!(pkCol in pkInput) || pkInput[pkCol] === undefined)
|
|
627
|
+
throw new DataError(`Не указано значение ключевой колонки «${pkCol}»`);
|
|
628
|
+
const v = pkInput[pkCol];
|
|
629
|
+
if (v === null) {
|
|
630
|
+
whereParts.push(`"${escapeIdent(pkCol)}" IS NULL`);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
whereParts.push(`"${escapeIdent(pkCol)}" = ?`);
|
|
634
|
+
args.push(v);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
else if (tbl.hasRowid && pkInput.__rowid__ !== undefined && pkInput.__rowid__ !== null) {
|
|
639
|
+
whereParts.push('rowid = ?');
|
|
640
|
+
args.push(Number(pkInput.__rowid__));
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
// Fallback: по всем переданным колонкам таблицы
|
|
644
|
+
for (const [k, v] of Object.entries(pkInput)) {
|
|
645
|
+
if (!colNames.has(k))
|
|
646
|
+
continue;
|
|
647
|
+
if (v === null) {
|
|
648
|
+
whereParts.push(`"${escapeIdent(k)}" IS NULL`);
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
whereParts.push(`"${escapeIdent(k)}" = ?`);
|
|
652
|
+
args.push(v);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!whereParts.length)
|
|
658
|
+
throw new DataError('Невозможно определить первичный ключ строки для модификации');
|
|
659
|
+
|
|
660
|
+
return { whereSql: whereParts.join(' AND '), args };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Нормализовать значение колонки перед записью в SQLite.
|
|
665
|
+
*/
|
|
666
|
+
function coerceColumnValue(colDef, rawVal) {
|
|
667
|
+
if (rawVal === null || rawVal === undefined)
|
|
668
|
+
return null;
|
|
669
|
+
const t = String(colDef?.colType || '').toUpperCase();
|
|
670
|
+
// Для известных JSON-колонок проверяем валидность JSON
|
|
671
|
+
if (['body', 'own', 'analytics'].includes(colDef?.name) && typeof rawVal === 'string' && rawVal.trim()) {
|
|
672
|
+
try {
|
|
673
|
+
JSON.parse(rawVal);
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
throw new DataError(`Поле «${colDef.name}» содержит невалидный JSON: ${err.message}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (typeof rawVal === 'object') {
|
|
680
|
+
return JSON.stringify(rawVal);
|
|
681
|
+
}
|
|
682
|
+
if ((t.includes('INT') || t.includes('REAL') || t.includes('NUM')) && typeof rawVal === 'string') {
|
|
683
|
+
const trimmed = rawVal.trim();
|
|
684
|
+
if (trimmed === '')
|
|
685
|
+
return colDef?.notnull ? 0 : null;
|
|
686
|
+
const n = Number(trimmed);
|
|
687
|
+
if (Number.isFinite(n))
|
|
688
|
+
return t.includes('INT') ? Math.trunc(n) : n;
|
|
689
|
+
}
|
|
690
|
+
return rawVal;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function parseBodyPayload(params = {}) {
|
|
694
|
+
let b = params.post ?? params.body;
|
|
695
|
+
if (Buffer.isBuffer(b))
|
|
696
|
+
b = b.toString('utf-8');
|
|
697
|
+
if (typeof b === 'string' && b.trim()) {
|
|
698
|
+
try { b = JSON.parse(b); }
|
|
699
|
+
catch { throw new DataError('Невалидный JSON тела запроса'); }
|
|
700
|
+
}
|
|
701
|
+
return (b && typeof b === 'object') ? { ...params, ...b } : { ...params };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function notifyDbChanged(db, tableName) {
|
|
705
|
+
try { bumpDataGeneration(db); } catch { /* ignore */ }
|
|
706
|
+
if (tableName === 'classes' || tableName === 'class_tree') {
|
|
707
|
+
try { markClassesDirty(); } catch { /* ignore */ }
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Обновить существующую запись таблицы (`sql_update_row`).
|
|
713
|
+
*/
|
|
714
|
+
export async function updateSqlRow(item, params = {}) {
|
|
715
|
+
const p = parseBodyPayload(params);
|
|
716
|
+
const resolved = await resolveSqlDatabase(item, p);
|
|
717
|
+
const { db } = resolved;
|
|
718
|
+
const { tbl } = getValidatedTable(db, p.table);
|
|
719
|
+
|
|
720
|
+
if (tbl.objectType === 'view')
|
|
721
|
+
throw new DataError(`Представление (VIEW) «${tbl.name}» доступно только для чтения`);
|
|
722
|
+
|
|
723
|
+
const pk = typeof p.pk === 'string' ? JSON.parse(p.pk) : p.pk;
|
|
724
|
+
const values = typeof p.values === 'string' ? JSON.parse(p.values) : p.values;
|
|
725
|
+
if (!values || typeof values !== 'object')
|
|
726
|
+
throw new DataError('Не переданы обновляемые значения (values)');
|
|
727
|
+
|
|
728
|
+
const colMap = new Map(tbl.columns.map(c => [c.name, c]));
|
|
729
|
+
const setParts = [];
|
|
730
|
+
const setArgs = [];
|
|
731
|
+
|
|
732
|
+
for (const [colName, rawVal] of Object.entries(values)) {
|
|
733
|
+
if (colName === '__rowid__' || !colMap.has(colName))
|
|
734
|
+
continue;
|
|
735
|
+
if (typeof rawVal === 'string' && /^\[BLOB:\s*\d+\s*байт\]$/.test(rawVal))
|
|
736
|
+
continue;
|
|
737
|
+
const colDef = colMap.get(colName);
|
|
738
|
+
setParts.push(`"${escapeIdent(colName)}" = ?`);
|
|
739
|
+
setArgs.push(coerceColumnValue(colDef, rawVal));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (!setParts.length)
|
|
743
|
+
throw new DataError('Нет полей для обновления');
|
|
744
|
+
|
|
745
|
+
const { whereSql, args: whereArgs } = buildPkWhere(tbl, pk);
|
|
746
|
+
|
|
747
|
+
let changes = 0;
|
|
748
|
+
const runUpdate = () => {
|
|
749
|
+
const res = db.prepare(
|
|
750
|
+
`UPDATE "${escapeIdent(tbl.name)}" SET ${setParts.join(', ')} WHERE ${whereSql}`
|
|
751
|
+
).run(...setArgs, ...whereArgs);
|
|
752
|
+
changes = Number(res?.changes) || 0;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
if (resolved.kind === 'server-index') {
|
|
756
|
+
db.exec('BEGIN');
|
|
757
|
+
try {
|
|
758
|
+
runUpdate();
|
|
759
|
+
db.exec('COMMIT');
|
|
760
|
+
}
|
|
761
|
+
catch (e) {
|
|
762
|
+
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
|
|
763
|
+
throw e;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
tx(db, runUpdate);
|
|
768
|
+
notifyDbChanged(db, tbl.name);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return { ok: true, changes, table: tbl.name };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Добавить новую запись в таблицу (`sql_insert_row`).
|
|
776
|
+
*/
|
|
777
|
+
export async function insertSqlRow(item, params = {}) {
|
|
778
|
+
const p = parseBodyPayload(params);
|
|
779
|
+
const resolved = await resolveSqlDatabase(item, p);
|
|
780
|
+
const { db } = resolved;
|
|
781
|
+
const { tbl } = getValidatedTable(db, p.table);
|
|
782
|
+
|
|
783
|
+
if (tbl.objectType === 'view')
|
|
784
|
+
throw new DataError(`Представление (VIEW) «${tbl.name}» доступно только для чтения`);
|
|
785
|
+
|
|
786
|
+
const values = typeof p.values === 'string' ? JSON.parse(p.values) : p.values;
|
|
787
|
+
if (!values || typeof values !== 'object')
|
|
788
|
+
throw new DataError('Не переданы значения для вставки (values)');
|
|
789
|
+
|
|
790
|
+
const colMap = new Map(tbl.columns.map(c => [c.name, c]));
|
|
791
|
+
const cols = [];
|
|
792
|
+
const placeholders = [];
|
|
793
|
+
const args = [];
|
|
794
|
+
|
|
795
|
+
for (const [colName, rawVal] of Object.entries(values)) {
|
|
796
|
+
if (colName === '__rowid__' || !colMap.has(colName))
|
|
797
|
+
continue;
|
|
798
|
+
const colDef = colMap.get(colName);
|
|
799
|
+
// Если автоинкрементный INTEGER PK не заполнен — пропускаем, чтобы SQLite сам назначил id
|
|
800
|
+
if (colDef.pk > 0 && (rawVal === '' || rawVal === null || rawVal === undefined) && /INT/i.test(colDef.colType))
|
|
801
|
+
continue;
|
|
802
|
+
if ((rawVal === '' || rawVal === undefined) && colDef.dfltValue !== null)
|
|
803
|
+
continue;
|
|
804
|
+
cols.push(`"${escapeIdent(colName)}"`);
|
|
805
|
+
placeholders.push('?');
|
|
806
|
+
args.push(coerceColumnValue(colDef, rawVal));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (!cols.length)
|
|
810
|
+
throw new DataError('Не заполнено ни одно поле новой записи');
|
|
811
|
+
|
|
812
|
+
let lastInsertRowid = null;
|
|
813
|
+
const runInsert = () => {
|
|
814
|
+
const res = db.prepare(
|
|
815
|
+
`INSERT INTO "${escapeIdent(tbl.name)}" (${cols.join(', ')}) VALUES (${placeholders.join(', ')})`
|
|
816
|
+
).run(...args);
|
|
817
|
+
lastInsertRowid = res?.lastInsertRowid !== undefined ? Number(res.lastInsertRowid) : null;
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
if (resolved.kind === 'server-index') {
|
|
821
|
+
db.exec('BEGIN');
|
|
822
|
+
try {
|
|
823
|
+
runInsert();
|
|
824
|
+
db.exec('COMMIT');
|
|
825
|
+
}
|
|
826
|
+
catch (e) {
|
|
827
|
+
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
|
|
828
|
+
throw e;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
tx(db, runInsert);
|
|
833
|
+
notifyDbChanged(db, tbl.name);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return { ok: true, table: tbl.name, lastInsertRowid };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Удалить одну или несколько записей из таблицы по первичным ключам (`sql_delete_row`).
|
|
841
|
+
*/
|
|
842
|
+
export async function deleteSqlRows(item, params = {}) {
|
|
843
|
+
const p = parseBodyPayload(params);
|
|
844
|
+
const resolved = await resolveSqlDatabase(item, p);
|
|
845
|
+
const { db } = resolved;
|
|
846
|
+
const { tbl } = getValidatedTable(db, p.table);
|
|
847
|
+
|
|
848
|
+
if (tbl.objectType === 'view')
|
|
849
|
+
throw new DataError(`Представление (VIEW) «${tbl.name}» доступно только для чтения`);
|
|
850
|
+
|
|
851
|
+
let pks = p.pks ?? (p.pk ? [p.pk] : null);
|
|
852
|
+
if (typeof pks === 'string')
|
|
853
|
+
pks = JSON.parse(pks);
|
|
854
|
+
if (!Array.isArray(pks) || !pks.length)
|
|
855
|
+
throw new DataError('Не переданы ключи удаляемых записей (pks)');
|
|
856
|
+
|
|
857
|
+
let deleted = 0;
|
|
858
|
+
const runDelete = () => {
|
|
859
|
+
for (const pk of pks) {
|
|
860
|
+
const { whereSql, args } = buildPkWhere(tbl, pk);
|
|
861
|
+
const res = db.prepare(`DELETE FROM "${escapeIdent(tbl.name)}" WHERE ${whereSql}`).run(...args);
|
|
862
|
+
deleted += Number(res?.changes) || 0;
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
if (resolved.kind === 'server-index') {
|
|
867
|
+
db.exec('BEGIN');
|
|
868
|
+
try {
|
|
869
|
+
runDelete();
|
|
870
|
+
db.exec('COMMIT');
|
|
871
|
+
}
|
|
872
|
+
catch (e) {
|
|
873
|
+
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
|
|
874
|
+
throw e;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
tx(db, runDelete);
|
|
879
|
+
notifyDbChanged(db, tbl.name);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return { ok: true, deleted, table: tbl.name };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export const SQL_INSPECTOR_METHODS = Object.freeze({
|
|
886
|
+
sql_databases: { level: 'admin', fn: (item, p) => listSqlDatabases(item, p) },
|
|
887
|
+
sql_schema: { level: 'admin', fn: (item, p) => getSqlSchema(item, p) },
|
|
888
|
+
sql_rows: { level: 'admin', fn: (item, p) => querySqlRows(item, p) },
|
|
889
|
+
sql_update_row: { level: 'admin', fn: (item, p) => updateSqlRow(item, p) },
|
|
890
|
+
sql_insert_row: { level: 'admin', fn: (item, p) => insertSqlRow(item, p) },
|
|
891
|
+
sql_delete_row: { level: 'admin', fn: (item, p) => deleteSqlRows(item, p) },
|
|
892
|
+
});
|