@kne/fastify-app-manager 0.1.1 → 0.1.3
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/README.md +82 -19
- package/index.js +29 -1
- package/libs/controllers/app.js +32 -4
- package/libs/controllers/dbops.js +268 -0
- package/libs/services/app.js +247 -48
- package/libs/services/bootstrap.js +10 -20
- package/libs/services/dbops.js +817 -0
- package/libs/utils/dbIdentity.js +118 -0
- package/libs/utils/entryInject.js +2 -1
- package/libs/utils/env.js +110 -10
- package/libs/utils/logFiles.js +19 -5
- package/libs/utils/migrate.js +10 -5
- package/libs/utils/sqlQueryGuard.js +72 -0
- package/package.json +2 -2
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
const fp = require('fastify-plugin');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const AdmZip = require('adm-zip');
|
|
5
|
+
const { createSequelizeFromEnv, SQL_MIGRATIONS_TABLE } = require('../utils/migrate');
|
|
6
|
+
const { resolveAppDbEnv, hasAppDbConfig } = require('../utils/env');
|
|
7
|
+
const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix } = require('../utils/dbIdentity');
|
|
8
|
+
const { assertReadOnlyQuerySql } = require('../utils/sqlQueryGuard');
|
|
9
|
+
|
|
10
|
+
module.exports = fp(async (fastify, options) => {
|
|
11
|
+
const { models } = fastify[options.name];
|
|
12
|
+
const Sequelize = fastify.sequelize.Sequelize;
|
|
13
|
+
const QueryTypes = Sequelize.QueryTypes;
|
|
14
|
+
|
|
15
|
+
const httpError = (statusCode, message) => {
|
|
16
|
+
if (fastify.httpErrors) {
|
|
17
|
+
if (statusCode === 404) throw fastify.httpErrors.notFound(message);
|
|
18
|
+
if (statusCode === 409) throw fastify.httpErrors.conflict(message);
|
|
19
|
+
if (statusCode === 400) throw fastify.httpErrors.badRequest(message);
|
|
20
|
+
if (statusCode === 403) throw fastify.httpErrors.forbidden(message);
|
|
21
|
+
}
|
|
22
|
+
const err = new Error(message);
|
|
23
|
+
err.statusCode = statusCode;
|
|
24
|
+
throw err;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const getByName = async name => {
|
|
28
|
+
const app = await models.app.findOne({ where: { name } });
|
|
29
|
+
if (!app) {
|
|
30
|
+
httpError(404, `app ${name} not found`);
|
|
31
|
+
}
|
|
32
|
+
return app;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const resolveEnvForApp = app =>
|
|
36
|
+
resolveAppDbEnv({
|
|
37
|
+
passthroughEnvKeys: options.passthroughEnvKeys,
|
|
38
|
+
appEnv: app.env || {},
|
|
39
|
+
defaultAppDb: options.defaultAppDb,
|
|
40
|
+
appName: app.name
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Prefer host fastify-sequelize named connection for shared default apps DB
|
|
45
|
+
* (reuses host dialect drivers like pg). Dedicated app DBs use host Sequelize ctor.
|
|
46
|
+
*/
|
|
47
|
+
const withSequelize = async (app, fn) => {
|
|
48
|
+
const env = resolveEnvForApp(app);
|
|
49
|
+
const scope = resolveDbScope(app.env || {}, app.options || {});
|
|
50
|
+
const connectionName = options.defaultAppDbConnection;
|
|
51
|
+
|
|
52
|
+
if (scope === 'shared' && connectionName && typeof fastify.sequelize.connection === 'function' && fastify.sequelize.listConnections?.().includes(connectionName)) {
|
|
53
|
+
const conn = fastify.sequelize.connection(connectionName);
|
|
54
|
+
return fn(conn.instance, env);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sequelize = createSequelizeFromEnv(env, Sequelize);
|
|
58
|
+
try {
|
|
59
|
+
await sequelize.authenticate();
|
|
60
|
+
return await fn(sequelize, env);
|
|
61
|
+
} finally {
|
|
62
|
+
await sequelize.close().catch(() => {});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const listAllTables = async sequelize => {
|
|
67
|
+
const qi = sequelize.getQueryInterface();
|
|
68
|
+
const tables = await qi.showAllTables();
|
|
69
|
+
return (tables || []).map(t => (typeof t === 'string' ? t : t.tableName || t.table_name || String(t))).filter(Boolean);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const describeTableSafe = async (sequelize, table) => {
|
|
73
|
+
const qi = sequelize.getQueryInterface();
|
|
74
|
+
const desc = await qi.describeTable(table);
|
|
75
|
+
const columns = Object.entries(desc || {}).map(([name, meta]) => ({
|
|
76
|
+
name,
|
|
77
|
+
type: meta.type,
|
|
78
|
+
primaryKey: !!meta.primaryKey,
|
|
79
|
+
allowNull: meta.allowNull !== false,
|
|
80
|
+
defaultValue: meta.defaultValue
|
|
81
|
+
}));
|
|
82
|
+
const primaryKey = columns.filter(c => c.primaryKey).map(c => c.name);
|
|
83
|
+
return { table, columns, primaryKey };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const getOwnedTables = (app, allTables) => {
|
|
87
|
+
const scope = resolveDbScope(app.env || {}, app.options || {});
|
|
88
|
+
const existing = new Set(allTables);
|
|
89
|
+
if (scope === 'dedicated') {
|
|
90
|
+
return allTables.filter(t => !isSystemTable(t));
|
|
91
|
+
}
|
|
92
|
+
const owned = normalizeOwnedTables(app.options?.ownedTables);
|
|
93
|
+
return owned.filter(t => existing.has(t));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const assertTableOwned = (app, allTables, table) => {
|
|
97
|
+
const owned = getOwnedTables(app, allTables);
|
|
98
|
+
if (!owned.includes(table)) {
|
|
99
|
+
httpError(403, `table ${table} is not owned by app ${app.name}`);
|
|
100
|
+
}
|
|
101
|
+
return owned;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const quoteId = (sequelize, name) => sequelize.getQueryInterface().quoteIdentifier(name);
|
|
105
|
+
|
|
106
|
+
const DEFAULT_SOFT_DELETE_FIELD = 'deleted_at';
|
|
107
|
+
|
|
108
|
+
const resolveSoftDeleteField = app => {
|
|
109
|
+
const fromOptions = app.options?.softDeleteField;
|
|
110
|
+
if (typeof fromOptions === 'string' && fromOptions.trim()) {
|
|
111
|
+
return fromOptions.trim();
|
|
112
|
+
}
|
|
113
|
+
return DEFAULT_SOFT_DELETE_FIELD;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const columnExists = (meta, field) => meta.columns.some(c => c.name === field);
|
|
117
|
+
|
|
118
|
+
const softDeleteNowSql = dialect => {
|
|
119
|
+
if (dialect === 'sqlite') {
|
|
120
|
+
return `datetime('now')`;
|
|
121
|
+
}
|
|
122
|
+
return 'CURRENT_TIMESTAMP';
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const listTables = async ({ name, scope } = {}) => {
|
|
126
|
+
const app = await getByName(name);
|
|
127
|
+
return withSequelize(app, async (sequelize, env) => {
|
|
128
|
+
const all = await listAllTables(sequelize);
|
|
129
|
+
const dbScope = resolveDbScope(app.env || {}, app.options || {});
|
|
130
|
+
let tables;
|
|
131
|
+
if (scope === 'all') {
|
|
132
|
+
if (dbScope !== 'dedicated') {
|
|
133
|
+
httpError(403, 'scope=all is only allowed for dedicated app databases');
|
|
134
|
+
}
|
|
135
|
+
tables = all.filter(t => !isSystemTable(t));
|
|
136
|
+
} else {
|
|
137
|
+
tables = getOwnedTables(app, all);
|
|
138
|
+
}
|
|
139
|
+
const pageData = [];
|
|
140
|
+
for (const table of tables) {
|
|
141
|
+
try {
|
|
142
|
+
pageData.push(await describeTableSafe(sequelize, table));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
pageData.push({ table, columns: [], primaryKey: [], error: e.message });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
dbScope,
|
|
149
|
+
softDeleteField: resolveSoftDeleteField(app),
|
|
150
|
+
connection: {
|
|
151
|
+
dialect: env.DB_DIALECT || sequelize.getDialect?.() || null,
|
|
152
|
+
host: env.DB_HOST || null,
|
|
153
|
+
port: env.DB_PORT || null,
|
|
154
|
+
database: env.DB_DATABASE || env.DB_STORAGE || null,
|
|
155
|
+
hasCustomDb: hasAppDbConfig(app.env || {})
|
|
156
|
+
},
|
|
157
|
+
ownedTables: normalizeOwnedTables(app.options?.ownedTables),
|
|
158
|
+
pageData,
|
|
159
|
+
totalCount: pageData.length
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const registerTables = async ({ name, tables = [], mode = 'union' } = {}) => {
|
|
165
|
+
const app = await getByName(name);
|
|
166
|
+
const next = mode === 'replace' ? normalizeOwnedTables(tables) : normalizeOwnedTables([...(app.options?.ownedTables || []), ...tables]);
|
|
167
|
+
await app.update({
|
|
168
|
+
options: Object.assign({}, app.options || {}, {
|
|
169
|
+
ownedTables: next,
|
|
170
|
+
dbScope: resolveDbScope(app.env || {}, app.options || {})
|
|
171
|
+
})
|
|
172
|
+
});
|
|
173
|
+
await app.reload();
|
|
174
|
+
return {
|
|
175
|
+
ownedTables: next,
|
|
176
|
+
dbScope: resolveDbScope(app.env || {}, app.options || {})
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const unregisterTables = async ({ name, tables = [] } = {}) => {
|
|
181
|
+
const app = await getByName(name);
|
|
182
|
+
const removeSet = new Set(normalizeOwnedTables(tables));
|
|
183
|
+
const next = normalizeOwnedTables(app.options?.ownedTables).filter(t => !removeSet.has(t));
|
|
184
|
+
await app.update({
|
|
185
|
+
options: Object.assign({}, app.options || {}, { ownedTables: next })
|
|
186
|
+
});
|
|
187
|
+
await app.reload();
|
|
188
|
+
return { ownedTables: next };
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const buildWhere = (sequelize, primaryKey, pk, filter) => {
|
|
192
|
+
const parts = [];
|
|
193
|
+
const replacements = {};
|
|
194
|
+
let i = 0;
|
|
195
|
+
const addEq = (col, val) => {
|
|
196
|
+
const key = `p${i++}`;
|
|
197
|
+
parts.push(`${quoteId(sequelize, col)} = :${key}`);
|
|
198
|
+
replacements[key] = val;
|
|
199
|
+
};
|
|
200
|
+
const addOp = (col, op, val) => {
|
|
201
|
+
if (val == null || val === '') {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const key = `p${i++}`;
|
|
205
|
+
parts.push(`${quoteId(sequelize, col)} ${op} :${key}`);
|
|
206
|
+
replacements[key] = val;
|
|
207
|
+
};
|
|
208
|
+
const addRange = (col, range) => {
|
|
209
|
+
if (!range || typeof range !== 'object') {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (Object.prototype.hasOwnProperty.call(range, '$gte')) {
|
|
213
|
+
addOp(col, '>=', range.$gte);
|
|
214
|
+
}
|
|
215
|
+
if (Object.prototype.hasOwnProperty.call(range, '$gt')) {
|
|
216
|
+
addOp(col, '>', range.$gt);
|
|
217
|
+
}
|
|
218
|
+
if (Object.prototype.hasOwnProperty.call(range, '$lte')) {
|
|
219
|
+
addOp(col, '<=', range.$lte);
|
|
220
|
+
}
|
|
221
|
+
if (Object.prototype.hasOwnProperty.call(range, '$lt')) {
|
|
222
|
+
addOp(col, '<', range.$lt);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const isRangeObject = v => v && typeof v === 'object' && !Array.isArray(v) && ['$gte', '$gt', '$lte', '$lt'].some(k => Object.prototype.hasOwnProperty.call(v, k));
|
|
226
|
+
|
|
227
|
+
if (pk && typeof pk === 'object') {
|
|
228
|
+
for (const [k, v] of Object.entries(pk)) {
|
|
229
|
+
addEq(k, v);
|
|
230
|
+
}
|
|
231
|
+
} else if (pk != null && primaryKey.length === 1) {
|
|
232
|
+
addEq(primaryKey[0], pk);
|
|
233
|
+
}
|
|
234
|
+
if (filter && typeof filter === 'object') {
|
|
235
|
+
for (const [k, v] of Object.entries(filter)) {
|
|
236
|
+
if (isRangeObject(v)) {
|
|
237
|
+
addRange(k, v);
|
|
238
|
+
} else if (Array.isArray(v) && v.length === 2) {
|
|
239
|
+
addRange(k, { $gte: v[0], $lte: v[1] });
|
|
240
|
+
} else {
|
|
241
|
+
addEq(k, v);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { whereSql: parts.length ? ` WHERE ${parts.join(' AND ')}` : '', replacements, nextParamIndex: i };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Columns eligible for keyword LIKE (string-like SQL types). */
|
|
249
|
+
const isFuzzySearchColumn = col => {
|
|
250
|
+
const t = String(col?.type || '')
|
|
251
|
+
.toLowerCase()
|
|
252
|
+
.replace(/\s+/g, ' ');
|
|
253
|
+
if (/\b(boolean|bool|bit)\b/.test(t) || t === 'tinyint(1)') {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
if (/\bjsonb?\b/.test(t)) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
if (/\b(timestamp|timestamptz|datetime)\b/.test(t)) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
if (/\bdate\b/.test(t) && !/\btime\b/.test(t)) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
if (/\b(int|integer|bigint|smallint|serial|bigserial|decimal|numeric|real|double|float|money)\b/.test(t)) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
if (/\b(blob|bytea|binary|varbinary)\b/.test(t)) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const appendKeywordWhere = (sequelize, { whereSql, replacements, nextParamIndex }, columns, softField, keyword) => {
|
|
275
|
+
const kw = keyword == null ? '' : String(keyword).trim();
|
|
276
|
+
if (!kw) {
|
|
277
|
+
return { whereSql, replacements };
|
|
278
|
+
}
|
|
279
|
+
const cols = (columns || []).filter(c => c?.name && c.name !== softField && isFuzzySearchColumn(c));
|
|
280
|
+
if (!cols.length) {
|
|
281
|
+
return { whereSql, replacements };
|
|
282
|
+
}
|
|
283
|
+
// strip LIKE wildcards from user input
|
|
284
|
+
const safe = kw.replace(/[%_\\]/g, '');
|
|
285
|
+
if (!safe) {
|
|
286
|
+
return { whereSql, replacements };
|
|
287
|
+
}
|
|
288
|
+
let i = nextParamIndex || 0;
|
|
289
|
+
const key = `p${i}`;
|
|
290
|
+
const likeParts = cols.map(c => `${quoteId(sequelize, c.name)} LIKE :${key}`);
|
|
291
|
+
const clause = `(${likeParts.join(' OR ')})`;
|
|
292
|
+
const nextReplacements = Object.assign({}, replacements, { [key]: `%${safe}%` });
|
|
293
|
+
const nextWhere = whereSql ? `${whereSql} AND ${clause}` : ` WHERE ${clause}`;
|
|
294
|
+
return { whereSql: nextWhere, replacements: nextReplacements };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const listRows = async ({ name, table, currentPage = 1, perPage = 20, filter, includeDeleted, sort, keyword } = {}) => {
|
|
298
|
+
const app = await getByName(name);
|
|
299
|
+
const showDeleted = includeDeleted === true || includeDeleted === 'true' || includeDeleted === 1;
|
|
300
|
+
return withSequelize(app, async sequelize => {
|
|
301
|
+
const all = await listAllTables(sequelize);
|
|
302
|
+
assertTableOwned(app, all, table);
|
|
303
|
+
const meta = await describeTableSafe(sequelize, table);
|
|
304
|
+
const colNames = new Set(meta.columns.map(c => c.name));
|
|
305
|
+
if (filter) {
|
|
306
|
+
for (const key of Object.keys(filter)) {
|
|
307
|
+
if (!colNames.has(key)) {
|
|
308
|
+
httpError(400, `unknown filter column: ${key}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const softField = resolveSoftDeleteField(app);
|
|
313
|
+
const qTable = quoteId(sequelize, table);
|
|
314
|
+
let { whereSql, replacements } = appendKeywordWhere(sequelize, buildWhere(sequelize, meta.primaryKey, null, filter), meta.columns, softField, keyword);
|
|
315
|
+
let finalWhere = whereSql;
|
|
316
|
+
if (!showDeleted && columnExists(meta, softField)) {
|
|
317
|
+
const softClause = `${quoteId(sequelize, softField)} IS NULL`;
|
|
318
|
+
finalWhere = whereSql ? `${whereSql} AND ${softClause}` : ` WHERE ${softClause}`;
|
|
319
|
+
}
|
|
320
|
+
let orderSql = '';
|
|
321
|
+
const sortList = Array.isArray(sort) ? sort : [];
|
|
322
|
+
if (sortList.length) {
|
|
323
|
+
const parts = [];
|
|
324
|
+
for (const item of sortList) {
|
|
325
|
+
const col = item?.name || item?.field;
|
|
326
|
+
if (!col || !colNames.has(col)) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const dir = String(item.sort || item.order || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
330
|
+
parts.push(`${quoteId(sequelize, col)} ${dir}`);
|
|
331
|
+
}
|
|
332
|
+
if (parts.length) {
|
|
333
|
+
orderSql = ` ORDER BY ${parts.join(', ')}`;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const countRows = await sequelize.query(`SELECT COUNT(*) AS cnt FROM ${qTable}${finalWhere}`, {
|
|
337
|
+
replacements,
|
|
338
|
+
type: QueryTypes.SELECT
|
|
339
|
+
});
|
|
340
|
+
const totalCount = Number(countRows[0]?.cnt || 0);
|
|
341
|
+
const offset = Math.max(0, (Number(currentPage) - 1) * Number(perPage));
|
|
342
|
+
const limit = Math.max(1, Number(perPage));
|
|
343
|
+
const pageData = await sequelize.query(`SELECT * FROM ${qTable}${finalWhere}${orderSql} LIMIT ${limit} OFFSET ${offset}`, { replacements, type: QueryTypes.SELECT });
|
|
344
|
+
return {
|
|
345
|
+
pageData,
|
|
346
|
+
totalCount,
|
|
347
|
+
primaryKey: meta.primaryKey,
|
|
348
|
+
columns: meta.columns,
|
|
349
|
+
softDeleteField: softField,
|
|
350
|
+
softDeleteSupported: columnExists(meta, softField)
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const getRow = async ({ name, table, id, pk } = {}) => {
|
|
356
|
+
const app = await getByName(name);
|
|
357
|
+
return withSequelize(app, async sequelize => {
|
|
358
|
+
const all = await listAllTables(sequelize);
|
|
359
|
+
assertTableOwned(app, all, table);
|
|
360
|
+
const meta = await describeTableSafe(sequelize, table);
|
|
361
|
+
if (!meta.primaryKey.length && !pk) {
|
|
362
|
+
httpError(400, 'table has no primary key; pass pk object');
|
|
363
|
+
}
|
|
364
|
+
const qTable = quoteId(sequelize, table);
|
|
365
|
+
const { whereSql, replacements } = buildWhere(sequelize, meta.primaryKey, pk || id, null);
|
|
366
|
+
if (!whereSql) {
|
|
367
|
+
httpError(400, 'primary key is required');
|
|
368
|
+
}
|
|
369
|
+
const rows = await sequelize.query(`SELECT * FROM ${qTable}${whereSql} LIMIT 1`, {
|
|
370
|
+
replacements,
|
|
371
|
+
type: QueryTypes.SELECT
|
|
372
|
+
});
|
|
373
|
+
return { data: rows[0] || null, primaryKey: meta.primaryKey };
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const saveRow = async ({ name, table, data = {}, autoGenerate } = {}) => {
|
|
378
|
+
const app = await getByName(name);
|
|
379
|
+
return withSequelize(app, async sequelize => {
|
|
380
|
+
const all = await listAllTables(sequelize);
|
|
381
|
+
assertTableOwned(app, all, table);
|
|
382
|
+
const meta = await describeTableSafe(sequelize, table);
|
|
383
|
+
if (!meta.primaryKey.length) {
|
|
384
|
+
httpError(400, 'cannot write to table without primary key');
|
|
385
|
+
}
|
|
386
|
+
const colNames = new Set(meta.columns.map(c => c.name));
|
|
387
|
+
const payload = {};
|
|
388
|
+
for (const [k, v] of Object.entries(data)) {
|
|
389
|
+
if (colNames.has(k)) {
|
|
390
|
+
payload[k] = v;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Snowflake for empty primary keys on insert.
|
|
394
|
+
// autoGenerate[field] === false → leave empty (UI requires manual input).
|
|
395
|
+
// true / omitted → fill with host sequelize.generateId().
|
|
396
|
+
for (const field of meta.primaryKey) {
|
|
397
|
+
if (!colNames.has(field)) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const empty = payload[field] == null || payload[field] === '';
|
|
401
|
+
if (!empty) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const explicit = autoGenerate && typeof autoGenerate === 'object' ? autoGenerate[field] : undefined;
|
|
405
|
+
if (explicit === false) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (typeof fastify.sequelize.generateId !== 'function') {
|
|
409
|
+
httpError(400, 'snowflake generateId is not available on host sequelize');
|
|
410
|
+
}
|
|
411
|
+
payload[field] = String(fastify.sequelize.generateId());
|
|
412
|
+
}
|
|
413
|
+
const hasAllPk = meta.primaryKey.every(k => payload[k] != null && payload[k] !== '');
|
|
414
|
+
const qTable = quoteId(sequelize, table);
|
|
415
|
+
if (hasAllPk) {
|
|
416
|
+
const { whereSql, replacements } = buildWhere(sequelize, meta.primaryKey, Object.fromEntries(meta.primaryKey.map(k => [k, payload[k]])), null);
|
|
417
|
+
const existing = await sequelize.query(`SELECT 1 AS ok FROM ${qTable}${whereSql} LIMIT 1`, {
|
|
418
|
+
replacements,
|
|
419
|
+
type: QueryTypes.SELECT
|
|
420
|
+
});
|
|
421
|
+
if (existing.length) {
|
|
422
|
+
const sets = [];
|
|
423
|
+
const upd = { ...replacements };
|
|
424
|
+
let i = 0;
|
|
425
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
426
|
+
if (meta.primaryKey.includes(k)) continue;
|
|
427
|
+
const key = `u${i++}`;
|
|
428
|
+
sets.push(`${quoteId(sequelize, k)} = :${key}`);
|
|
429
|
+
upd[key] = v;
|
|
430
|
+
}
|
|
431
|
+
if (sets.length) {
|
|
432
|
+
await sequelize.query(`UPDATE ${qTable} SET ${sets.join(', ')}${whereSql}`, {
|
|
433
|
+
replacements: upd
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const rows = await sequelize.query(`SELECT * FROM ${qTable}${whereSql} LIMIT 1`, {
|
|
437
|
+
replacements,
|
|
438
|
+
type: QueryTypes.SELECT
|
|
439
|
+
});
|
|
440
|
+
return { data: rows[0], action: 'update' };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const cols = Object.keys(payload);
|
|
444
|
+
if (!cols.length) {
|
|
445
|
+
httpError(400, 'no valid columns to insert');
|
|
446
|
+
}
|
|
447
|
+
const colSql = cols.map(c => quoteId(sequelize, c)).join(', ');
|
|
448
|
+
const placeholders = cols.map((_, idx) => `:i${idx}`).join(', ');
|
|
449
|
+
const replacements = {};
|
|
450
|
+
cols.forEach((c, idx) => {
|
|
451
|
+
replacements[`i${idx}`] = payload[c];
|
|
452
|
+
});
|
|
453
|
+
await sequelize.query(`INSERT INTO ${qTable} (${colSql}) VALUES (${placeholders})`, { replacements });
|
|
454
|
+
if (hasAllPk) {
|
|
455
|
+
const { whereSql, replacements: pkRep } = buildWhere(sequelize, meta.primaryKey, Object.fromEntries(meta.primaryKey.map(k => [k, payload[k]])), null);
|
|
456
|
+
const rows = await sequelize.query(`SELECT * FROM ${qTable}${whereSql} LIMIT 1`, {
|
|
457
|
+
replacements: pkRep,
|
|
458
|
+
type: QueryTypes.SELECT
|
|
459
|
+
});
|
|
460
|
+
return { data: rows[0] || payload, action: 'insert' };
|
|
461
|
+
}
|
|
462
|
+
return { data: payload, action: 'insert' };
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const removeRow = async ({ name, table, id, pk, hard = false } = {}) => {
|
|
467
|
+
const app = await getByName(name);
|
|
468
|
+
return withSequelize(app, async sequelize => {
|
|
469
|
+
const all = await listAllTables(sequelize);
|
|
470
|
+
assertTableOwned(app, all, table);
|
|
471
|
+
const meta = await describeTableSafe(sequelize, table);
|
|
472
|
+
if (!meta.primaryKey.length) {
|
|
473
|
+
httpError(400, 'cannot delete from table without primary key');
|
|
474
|
+
}
|
|
475
|
+
const qTable = quoteId(sequelize, table);
|
|
476
|
+
const { whereSql, replacements } = buildWhere(sequelize, meta.primaryKey, pk || id, null);
|
|
477
|
+
if (!whereSql) {
|
|
478
|
+
httpError(400, 'primary key is required');
|
|
479
|
+
}
|
|
480
|
+
if (hard === true) {
|
|
481
|
+
await sequelize.query(`DELETE FROM ${qTable}${whereSql}`, { replacements });
|
|
482
|
+
return { ok: true, mode: 'hard' };
|
|
483
|
+
}
|
|
484
|
+
const softField = resolveSoftDeleteField(app);
|
|
485
|
+
if (!columnExists(meta, softField)) {
|
|
486
|
+
httpError(400, `soft delete requires column "${softField}"; add the column or set options.softDeleteField`);
|
|
487
|
+
}
|
|
488
|
+
const dialect = sequelize.getDialect?.() || 'postgres';
|
|
489
|
+
await sequelize.query(`UPDATE ${qTable} SET ${quoteId(sequelize, softField)} = ${softDeleteNowSql(dialect)}${whereSql}`, { replacements });
|
|
490
|
+
return { ok: true, mode: 'soft', softDeleteField: softField };
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const restoreRow = async ({ name, table, id, pk } = {}) => {
|
|
495
|
+
const app = await getByName(name);
|
|
496
|
+
return withSequelize(app, async sequelize => {
|
|
497
|
+
const all = await listAllTables(sequelize);
|
|
498
|
+
assertTableOwned(app, all, table);
|
|
499
|
+
const meta = await describeTableSafe(sequelize, table);
|
|
500
|
+
if (!meta.primaryKey.length) {
|
|
501
|
+
httpError(400, 'cannot restore row in table without primary key');
|
|
502
|
+
}
|
|
503
|
+
const softField = resolveSoftDeleteField(app);
|
|
504
|
+
if (!columnExists(meta, softField)) {
|
|
505
|
+
httpError(400, `restore requires column "${softField}"; add the column or set options.softDeleteField`);
|
|
506
|
+
}
|
|
507
|
+
const qTable = quoteId(sequelize, table);
|
|
508
|
+
const { whereSql, replacements } = buildWhere(sequelize, meta.primaryKey, pk || id, null);
|
|
509
|
+
if (!whereSql) {
|
|
510
|
+
httpError(400, 'primary key is required');
|
|
511
|
+
}
|
|
512
|
+
await sequelize.query(`UPDATE ${qTable} SET ${quoteId(sequelize, softField)} = NULL${whereSql}`, { replacements });
|
|
513
|
+
return { ok: true, softDeleteField: softField };
|
|
514
|
+
});
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const runQuery = async ({ name, sql, replacements = {}, maxRows } = {}) => {
|
|
518
|
+
const app = await getByName(name);
|
|
519
|
+
const limit = Math.min(Math.max(1, Number(maxRows) || options.dbQueryMaxRows || 500), options.dbQueryMaxRows || 500);
|
|
520
|
+
const checked = assertReadOnlyQuerySql(sql);
|
|
521
|
+
return withSequelize(app, async sequelize => {
|
|
522
|
+
const scope = resolveDbScope(app.env || {}, app.options || {});
|
|
523
|
+
if (scope === 'shared') {
|
|
524
|
+
const all = await listAllTables(sequelize);
|
|
525
|
+
const owned = new Set(getOwnedTables(app, all));
|
|
526
|
+
if (!checked.tables.length) {
|
|
527
|
+
httpError(400, 'unable to resolve table names from sql in shared db; use rows API or qualify FROM/JOIN tables');
|
|
528
|
+
}
|
|
529
|
+
for (const t of checked.tables) {
|
|
530
|
+
if (!owned.has(t)) {
|
|
531
|
+
httpError(403, `sql references table not owned by app: ${t}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const rows = await sequelize.query(checked.sql, {
|
|
536
|
+
replacements: replacements || {},
|
|
537
|
+
type: QueryTypes.SELECT
|
|
538
|
+
});
|
|
539
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
540
|
+
const truncated = list.length > limit;
|
|
541
|
+
const sliced = truncated ? list.slice(0, limit) : list;
|
|
542
|
+
const columns = sliced.length ? Object.keys(sliced[0]) : [];
|
|
543
|
+
return { columns, rows: sliced, rowCount: sliced.length, truncated };
|
|
544
|
+
});
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const exportData = async ({ name, tables, mode = 'json' } = {}) => {
|
|
548
|
+
const app = await getByName(name);
|
|
549
|
+
const env = resolveEnvForApp(app);
|
|
550
|
+
const scope = resolveDbScope(app.env || {}, app.options || {});
|
|
551
|
+
|
|
552
|
+
if (mode === 'file') {
|
|
553
|
+
if (scope !== 'dedicated' || (env.DB_DIALECT || 'sqlite') !== 'sqlite') {
|
|
554
|
+
httpError(400, 'mode=file is only supported for dedicated sqlite databases');
|
|
555
|
+
}
|
|
556
|
+
const storage = env.DB_STORAGE || env.DB_HOST;
|
|
557
|
+
if (!storage || !(await fs.pathExists(storage))) {
|
|
558
|
+
httpError(400, 'sqlite storage file not found');
|
|
559
|
+
}
|
|
560
|
+
const exportDir = path.join(app.rootPath, 'exports');
|
|
561
|
+
await fs.ensureDir(exportDir);
|
|
562
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
563
|
+
const dest = path.join(exportDir, `export-${stamp}.sqlite`);
|
|
564
|
+
await fs.copy(storage, dest);
|
|
565
|
+
return { mode: 'file', path: dest, tables: [], rowCounts: {} };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return withSequelize(app, async sequelize => {
|
|
569
|
+
const all = await listAllTables(sequelize);
|
|
570
|
+
let target = getOwnedTables(app, all);
|
|
571
|
+
if (Array.isArray(tables) && tables.length) {
|
|
572
|
+
const wanted = normalizeOwnedTables(tables);
|
|
573
|
+
target = target.filter(t => wanted.includes(t));
|
|
574
|
+
}
|
|
575
|
+
const exportDir = path.join(app.rootPath, 'exports');
|
|
576
|
+
await fs.ensureDir(exportDir);
|
|
577
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
578
|
+
const zipPath = path.join(exportDir, `export-${stamp}.zip`);
|
|
579
|
+
const zip = new AdmZip();
|
|
580
|
+
const rowCounts = {};
|
|
581
|
+
const manifest = { app: name, exportedAt: new Date().toISOString(), tables: [] };
|
|
582
|
+
|
|
583
|
+
for (const table of target) {
|
|
584
|
+
const qTable = quoteId(sequelize, table);
|
|
585
|
+
const rows = await sequelize.query(`SELECT * FROM ${qTable}`, { type: QueryTypes.SELECT });
|
|
586
|
+
rowCounts[table] = rows.length;
|
|
587
|
+
manifest.tables.push(table);
|
|
588
|
+
zip.addFile(`${table}.json`, Buffer.from(JSON.stringify(rows, null, 2), 'utf8'));
|
|
589
|
+
}
|
|
590
|
+
zip.addFile('manifest.json', Buffer.from(JSON.stringify(manifest, null, 2), 'utf8'));
|
|
591
|
+
zip.writeZip(zipPath);
|
|
592
|
+
return { mode: 'json', path: zipPath, tables: manifest.tables, rowCounts };
|
|
593
|
+
});
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const buildDropSql = (sequelize, table) => {
|
|
597
|
+
const q = quoteId(sequelize, table);
|
|
598
|
+
return `DROP TABLE IF EXISTS ${q};`;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const cleanup = async ({ name, dryRun = false, removeSqliteFile = false } = {}) => {
|
|
602
|
+
const app = await getByName(name);
|
|
603
|
+
const env = resolveEnvForApp(app);
|
|
604
|
+
const scope = resolveDbScope(app.env || {}, app.options || {});
|
|
605
|
+
const result = {
|
|
606
|
+
dropped: [],
|
|
607
|
+
skipped: [],
|
|
608
|
+
failed: [],
|
|
609
|
+
sql: [],
|
|
610
|
+
manualRequired: false,
|
|
611
|
+
dbScope: scope
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
await withSequelize(app, async sequelize => {
|
|
615
|
+
const all = await listAllTables(sequelize);
|
|
616
|
+
const owned = getOwnedTables(app, all);
|
|
617
|
+
if (!owned.length) {
|
|
618
|
+
result.skipped.push({ reason: 'no owned tables' });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
for (const table of owned) {
|
|
622
|
+
const dropSql = buildDropSql(sequelize, table);
|
|
623
|
+
if (dryRun) {
|
|
624
|
+
result.sql.push(dropSql);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
await sequelize.query(dropSql);
|
|
629
|
+
result.dropped.push(table);
|
|
630
|
+
} catch (e) {
|
|
631
|
+
result.failed.push({ table, error: e.message });
|
|
632
|
+
result.sql.push(dropSql);
|
|
633
|
+
result.manualRequired = true;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (all.includes(SQL_MIGRATIONS_TABLE)) {
|
|
638
|
+
const migrationNames = normalizeOwnedTables(app.options?.migrationNames);
|
|
639
|
+
if (migrationNames.length && !dryRun) {
|
|
640
|
+
for (const mig of migrationNames) {
|
|
641
|
+
try {
|
|
642
|
+
await sequelize.query(`DELETE FROM ${quoteId(sequelize, SQL_MIGRATIONS_TABLE)} WHERE name = :name`, {
|
|
643
|
+
replacements: { name: mig }
|
|
644
|
+
});
|
|
645
|
+
} catch (e) {
|
|
646
|
+
result.sql.push(`-- failed to delete migration row ${mig}: ${e.message}\nDELETE FROM ${SQL_MIGRATIONS_TABLE} WHERE name = '${mig.replace(/'/g, "''")}';`);
|
|
647
|
+
result.manualRequired = true;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
} else if (!migrationNames.length) {
|
|
651
|
+
result.sql.push(`-- Review ${SQL_MIGRATIONS_TABLE} manually; do not delete other apps' migration rows in a shared database.`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (scope === 'dedicated' && (env.DB_DIALECT || 'sqlite') !== 'sqlite') {
|
|
656
|
+
result.sql.push(`-- Optional (run manually if this database is exclusive to the app):\n-- DROP DATABASE ${env.DB_DATABASE || '<database>'};`);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
if (!dryRun && removeSqliteFile && scope === 'dedicated' && (env.DB_DIALECT || 'sqlite') === 'sqlite' && !result.manualRequired) {
|
|
661
|
+
const storage = env.DB_STORAGE || env.DB_HOST;
|
|
662
|
+
if (storage) {
|
|
663
|
+
try {
|
|
664
|
+
await fs.remove(storage);
|
|
665
|
+
result.dropped.push(`file:${storage}`);
|
|
666
|
+
} catch (e) {
|
|
667
|
+
result.failed.push({ table: `file:${storage}`, error: e.message });
|
|
668
|
+
result.sql.push(`-- rm ${storage}`);
|
|
669
|
+
result.manualRequired = true;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (dryRun) {
|
|
675
|
+
result.manualRequired = result.sql.length > 0;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (!dryRun && !result.failed.length && result.dropped.length) {
|
|
679
|
+
const remaining = normalizeOwnedTables(app.options?.ownedTables).filter(t => !result.dropped.includes(t));
|
|
680
|
+
await app.update({
|
|
681
|
+
options: Object.assign({}, app.options || {}, { ownedTables: remaining })
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return result;
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* After migrations / process ready, merge newly appeared tables into ownedTables (shared scope only).
|
|
690
|
+
* Prefer tables matching the app table prefix so shared-DB apps do not claim others' unprefixed tables.
|
|
691
|
+
*/
|
|
692
|
+
const mergeOwnedTablesFromDiff = async (app, beforeTables, afterTables) => {
|
|
693
|
+
if (resolveDbScope(app.env || {}, app.options || {}) === 'dedicated') {
|
|
694
|
+
return { added: [] };
|
|
695
|
+
}
|
|
696
|
+
const before = new Set(beforeTables || []);
|
|
697
|
+
const prefix = resolveTablePrefix(app.name, app.env || {});
|
|
698
|
+
const added = (afterTables || []).filter(t => {
|
|
699
|
+
if (before.has(t) || isSystemTable(t)) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
if (prefix && !String(t).startsWith(prefix)) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
return true;
|
|
706
|
+
});
|
|
707
|
+
if (!added.length) {
|
|
708
|
+
return { added: [] };
|
|
709
|
+
}
|
|
710
|
+
await app.reload();
|
|
711
|
+
const next = normalizeOwnedTables([...(app.options?.ownedTables || []), ...added]);
|
|
712
|
+
await app.update({
|
|
713
|
+
options: Object.assign({}, app.options || {}, { ownedTables: next, dbScope: 'shared' })
|
|
714
|
+
});
|
|
715
|
+
return { added, ownedTables: next };
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Claim tables whose names start with the app's DB_TABLE_PREFIX / computed prefix (shared only).
|
|
720
|
+
*/
|
|
721
|
+
const claimOwnedTablesByPrefix = async app => {
|
|
722
|
+
if (resolveDbScope(app.env || {}, app.options || {}) === 'dedicated') {
|
|
723
|
+
return { added: [], prefix: null, ownedTables: normalizeOwnedTables(app.options?.ownedTables) };
|
|
724
|
+
}
|
|
725
|
+
const prefix = resolveTablePrefix(app.name, app.env || {});
|
|
726
|
+
return withSequelize(app, async sequelize => {
|
|
727
|
+
const all = (await listAllTables(sequelize)).filter(t => !isSystemTable(t));
|
|
728
|
+
const matched = all.filter(t => String(t).startsWith(prefix));
|
|
729
|
+
await app.reload();
|
|
730
|
+
const prev = normalizeOwnedTables(app.options?.ownedTables);
|
|
731
|
+
const prevSet = new Set(prev);
|
|
732
|
+
const added = matched.filter(t => !prevSet.has(t));
|
|
733
|
+
if (!added.length) {
|
|
734
|
+
return { added: [], prefix, ownedTables: prev };
|
|
735
|
+
}
|
|
736
|
+
const next = normalizeOwnedTables([...prev, ...matched]);
|
|
737
|
+
const nextEnv = app.env?.DB_TABLE_PREFIX === prefix ? app.env : Object.assign({}, app.env || {}, { DB_TABLE_PREFIX: prefix });
|
|
738
|
+
await app.update({
|
|
739
|
+
env: nextEnv,
|
|
740
|
+
options: Object.assign({}, app.options || {}, { ownedTables: next, dbScope: 'shared' })
|
|
741
|
+
});
|
|
742
|
+
return { added, prefix, ownedTables: next };
|
|
743
|
+
});
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* 扫描库中表并登记归属。共享库:优先领取本应用前缀表;无前缀命中时再领取未被其它应用登记的表。独立库:登记全部业务表。
|
|
748
|
+
*/
|
|
749
|
+
const syncOwnedFromDatabase = async ({ name } = {}) => {
|
|
750
|
+
const app = await getByName(name);
|
|
751
|
+
const Op = Sequelize.Op;
|
|
752
|
+
return withSequelize(app, async sequelize => {
|
|
753
|
+
const all = (await listAllTables(sequelize)).filter(t => !isSystemTable(t));
|
|
754
|
+
const scope = resolveDbScope(app.env || {}, app.options || {});
|
|
755
|
+
let candidates = all;
|
|
756
|
+
if (scope === 'shared') {
|
|
757
|
+
const prefix = resolveTablePrefix(app.name, app.env || {});
|
|
758
|
+
const prefixed = all.filter(t => String(t).startsWith(prefix));
|
|
759
|
+
if (prefixed.length) {
|
|
760
|
+
candidates = prefixed;
|
|
761
|
+
} else {
|
|
762
|
+
const others = await models.app.findAll({
|
|
763
|
+
where: { name: { [Op.ne]: name } }
|
|
764
|
+
});
|
|
765
|
+
const taken = new Set();
|
|
766
|
+
others.forEach(row => {
|
|
767
|
+
normalizeOwnedTables(row.options?.ownedTables).forEach(t => taken.add(t));
|
|
768
|
+
});
|
|
769
|
+
candidates = all.filter(t => !taken.has(t));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const prev = new Set(normalizeOwnedTables(app.options?.ownedTables));
|
|
773
|
+
const claimed = candidates.filter(t => !prev.has(t));
|
|
774
|
+
const next = normalizeOwnedTables([...(app.options?.ownedTables || []), ...candidates]);
|
|
775
|
+
const patch = { ownedTables: next, dbScope: scope };
|
|
776
|
+
const updates = { options: Object.assign({}, app.options || {}, patch) };
|
|
777
|
+
if (scope === 'shared') {
|
|
778
|
+
const prefix = resolveTablePrefix(app.name, app.env || {});
|
|
779
|
+
if (!app.env?.DB_TABLE_PREFIX) {
|
|
780
|
+
updates.env = Object.assign({}, app.env || {}, { DB_TABLE_PREFIX: prefix });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
await app.update(updates);
|
|
784
|
+
return {
|
|
785
|
+
dbScope: scope,
|
|
786
|
+
ownedTables: next,
|
|
787
|
+
discovered: all,
|
|
788
|
+
claimed
|
|
789
|
+
};
|
|
790
|
+
});
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
Object.assign(fastify[options.name].services, {
|
|
794
|
+
dbops: {
|
|
795
|
+
listTables,
|
|
796
|
+
registerTables,
|
|
797
|
+
unregisterTables,
|
|
798
|
+
syncOwnedFromDatabase,
|
|
799
|
+
claimOwnedTablesByPrefix,
|
|
800
|
+
listRows,
|
|
801
|
+
getRow,
|
|
802
|
+
saveRow,
|
|
803
|
+
removeRow,
|
|
804
|
+
restoreRow,
|
|
805
|
+
runQuery,
|
|
806
|
+
exportData,
|
|
807
|
+
cleanup,
|
|
808
|
+
resolveEnvForApp,
|
|
809
|
+
getOwnedTables,
|
|
810
|
+
listAllTables,
|
|
811
|
+
withSequelize,
|
|
812
|
+
mergeOwnedTablesFromDiff,
|
|
813
|
+
resolveDbScope: app => resolveDbScope(app.env || {}, app.options || {}),
|
|
814
|
+
hasAppDbConfig
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
});
|