@njeybe/nativ 2.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/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/bin/cli.js +31 -0
- package/dist/commands/bench.d.ts +8 -0
- package/dist/commands/bench.js +52 -0
- package/dist/commands/db.d.ts +22 -0
- package/dist/commands/db.js +415 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +169 -0
- package/dist/commands/hook.d.ts +31 -0
- package/dist/commands/hook.js +105 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +144 -0
- package/dist/commands/mcp.d.ts +5 -0
- package/dist/commands/mcp.js +16 -0
- package/dist/commands/setup.d.ts +17 -0
- package/dist/commands/setup.js +51 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/task.d.ts +83 -0
- package/dist/commands/task.js +807 -0
- package/dist/commands/test-gen.d.ts +14 -0
- package/dist/commands/test-gen.js +52 -0
- package/dist/commands/triage.d.ts +70 -0
- package/dist/commands/triage.js +343 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +112 -0
- package/dist/commands/validate.d.ts +1 -0
- package/dist/commands/validate.js +89 -0
- package/dist/commands/verify.d.ts +8 -0
- package/dist/commands/verify.js +65 -0
- package/dist/commands/worktree.d.ts +47 -0
- package/dist/commands/worktree.js +396 -0
- package/dist/core/benchmark.d.ts +57 -0
- package/dist/core/benchmark.js +772 -0
- package/dist/core/enforcement.d.ts +80 -0
- package/dist/core/enforcement.js +271 -0
- package/dist/core/legacy-templates.d.ts +24 -0
- package/dist/core/legacy-templates.js +60 -0
- package/dist/core/lock-manager.d.ts +54 -0
- package/dist/core/lock-manager.js +260 -0
- package/dist/core/root-resolver.d.ts +32 -0
- package/dist/core/root-resolver.js +149 -0
- package/dist/core/setup-assets.d.ts +123 -0
- package/dist/core/setup-assets.js +438 -0
- package/dist/core/telemetry.d.ts +156 -0
- package/dist/core/telemetry.js +416 -0
- package/dist/core/test-generator-types.d.ts +227 -0
- package/dist/core/test-generator-types.js +179 -0
- package/dist/core/test-generator.d.ts +34 -0
- package/dist/core/test-generator.js +1690 -0
- package/dist/core/tier1-liaison.d.ts +139 -0
- package/dist/core/tier1-liaison.js +320 -0
- package/dist/core/verifier.d.ts +52 -0
- package/dist/core/verifier.js +249 -0
- package/dist/core/version.d.ts +5 -0
- package/dist/core/version.js +14 -0
- package/dist/core/worktree-merge.d.ts +30 -0
- package/dist/core/worktree-merge.js +98 -0
- package/dist/db/data-engine.d.ts +46 -0
- package/dist/db/data-engine.js +802 -0
- package/dist/db/diff.d.ts +39 -0
- package/dist/db/diff.js +420 -0
- package/dist/db/env-parser.d.ts +86 -0
- package/dist/db/env-parser.js +549 -0
- package/dist/db/introspector.d.ts +21 -0
- package/dist/db/introspector.js +835 -0
- package/dist/db/types.d.ts +217 -0
- package/dist/db/types.js +9 -0
- package/dist/governor/circuit-breaker.d.ts +100 -0
- package/dist/governor/circuit-breaker.js +231 -0
- package/dist/governor/evaluator.d.ts +67 -0
- package/dist/governor/evaluator.js +851 -0
- package/dist/governor/index.d.ts +6 -0
- package/dist/governor/index.js +6 -0
- package/dist/governor/rules/api-rules.d.ts +2 -0
- package/dist/governor/rules/api-rules.js +140 -0
- package/dist/governor/rules/db-rules.d.ts +2 -0
- package/dist/governor/rules/db-rules.js +180 -0
- package/dist/governor/store.d.ts +33 -0
- package/dist/governor/store.js +84 -0
- package/dist/governor/types.d.ts +64 -0
- package/dist/governor/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +358 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +309 -0
- package/dist/providers/claude-api.d.ts +5 -0
- package/dist/providers/claude-api.js +75 -0
- package/dist/providers/claude-cli.d.ts +13 -0
- package/dist/providers/claude-cli.js +134 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +78 -0
- package/dist/providers/index.d.ts +5 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/registry.d.ts +38 -0
- package/dist/providers/registry.js +121 -0
- package/dist/providers/types.d.ts +74 -0
- package/dist/providers/types.js +25 -0
- package/dist/runner/agent-supervisor.d.ts +363 -0
- package/dist/runner/agent-supervisor.js +1672 -0
- package/dist/scanner/context-builder.d.ts +2 -0
- package/dist/scanner/context-builder.js +29 -0
- package/dist/scanner/detector.d.ts +2 -0
- package/dist/scanner/detector.js +760 -0
- package/dist/scanner/types.d.ts +80 -0
- package/dist/scanner/types.js +50 -0
- package/dist/server/studio-server.d.ts +39 -0
- package/dist/server/studio-server.js +1931 -0
- package/dist/server/studio-ui.d.ts +15 -0
- package/dist/server/studio-ui.js +5321 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +18 -0
- package/plugin/.mcp.json +10 -0
- package/plugin/agents/architect.md +28 -0
- package/plugin/agents/verifier.md +28 -0
- package/plugin/agents/worker.md +26 -0
- package/plugin/hooks/hooks.json +28 -0
- package/plugin/skills/nativ/SKILL.md +75 -0
- package/templates/.claudeignore +45 -0
- package/templates/AGENTS.md +67 -0
- package/templates/CLAUDE.md +32 -0
- package/templates/GEMINI.md +66 -0
- package/templates/claude-agents/architect.md +28 -0
- package/templates/claude-agents/verifier.md +28 -0
- package/templates/claude-agents/worker.md +26 -0
- package/templates/dot-ai/api_contracts.json +66 -0
- package/templates/dot-ai/context.md +38 -0
- package/templates/dot-ai/db_schema.json +39 -0
- package/templates/dot-ai/master_plan.json +28 -0
- package/templates/dot-ai/subagents/backend.md +11 -0
- package/templates/dot-ai/subagents/database.md +10 -0
- package/templates/dot-ai/subagents/db-migration.md +11 -0
- package/templates/dot-ai/subagents/devops-agent.md +11 -0
- package/templates/dot-ai/subagents/flutter-developer.md +11 -0
- package/templates/dot-ai/subagents/frontend.md +126 -0
- package/templates/dot-ai/subagents/qa-tester.md +13 -0
- package/templates/dot-ai/subagents/security-auditor.md +11 -0
- package/templates/dot-ai/ui_specs.md +78 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
import { detectEngine, maskConnectionString, MASK, parseFirestoreUrl, sqlitePathFromUrl } from './env-parser.js';
|
|
2
|
+
/** Validates entity and identifier names to guard against SQL injection in DDL/identifiers. */
|
|
3
|
+
function assertValidIdentifier(name, label = 'Identifier') {
|
|
4
|
+
if (!name || typeof name !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(name.trim())) {
|
|
5
|
+
throw new Error(`Invalid ${label}: "${name}" contains disallowed characters`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Removes raw credentials or passwords from driver error messages. */
|
|
9
|
+
function sanitizeError(err, url) {
|
|
10
|
+
let message = err instanceof Error ? err.message : String(err);
|
|
11
|
+
if (!message)
|
|
12
|
+
message = 'Unknown database error';
|
|
13
|
+
message = message.split(url).join(maskConnectionString(url));
|
|
14
|
+
try {
|
|
15
|
+
const password = new URL(url).password;
|
|
16
|
+
if (password) {
|
|
17
|
+
message = message.split(password).join(MASK);
|
|
18
|
+
const decoded = decodeURIComponent(password);
|
|
19
|
+
if (decoded)
|
|
20
|
+
message = message.split(decoded).join(MASK);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Non-URL (e.g. SQLite path)
|
|
25
|
+
}
|
|
26
|
+
return new Error(message);
|
|
27
|
+
}
|
|
28
|
+
/** Recursively serializes driver-specific objects (BSON ObjectId, Dates, Buffers, Timestamps) to pure JSON. */
|
|
29
|
+
export function serializeValue(val) {
|
|
30
|
+
if (val === null || val === undefined)
|
|
31
|
+
return null;
|
|
32
|
+
if (typeof val === 'bigint')
|
|
33
|
+
return Number(val);
|
|
34
|
+
if (val instanceof Date)
|
|
35
|
+
return val.toISOString();
|
|
36
|
+
if (val instanceof Uint8Array || Buffer.isBuffer(val)) {
|
|
37
|
+
const buf = Buffer.from(val);
|
|
38
|
+
if (buf.length <= 512 * 1024) {
|
|
39
|
+
return `data:application/octet-stream;base64,${buf.toString('base64')}`;
|
|
40
|
+
}
|
|
41
|
+
return `[Binary data ${buf.length} bytes]`;
|
|
42
|
+
}
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
const anyVal = val;
|
|
45
|
+
if (anyVal._bsontype === 'ObjectId' || (anyVal.constructor && anyVal.constructor.name === 'ObjectId')) {
|
|
46
|
+
return anyVal.toString();
|
|
47
|
+
}
|
|
48
|
+
if (anyVal._bsontype === 'Decimal128') {
|
|
49
|
+
return parseFloat(anyVal.toString());
|
|
50
|
+
}
|
|
51
|
+
if (typeof anyVal.toDate === 'function') {
|
|
52
|
+
try {
|
|
53
|
+
return anyVal.toDate().toISOString();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(val)) {
|
|
60
|
+
return val.map(serializeValue);
|
|
61
|
+
}
|
|
62
|
+
if (typeof val === 'object') {
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const [k, v] of Object.entries(val)) {
|
|
65
|
+
out[k] = serializeValue(v);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
return val;
|
|
70
|
+
}
|
|
71
|
+
function serializeRow(row) {
|
|
72
|
+
const result = {};
|
|
73
|
+
for (const [key, value] of Object.entries(row)) {
|
|
74
|
+
result[key] = serializeValue(value);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
// ─── PostgreSQL Driver ─────────────────────────────────────────────────────────
|
|
79
|
+
async function postgresFetch(url, entity, options) {
|
|
80
|
+
assertValidIdentifier(entity, 'table name');
|
|
81
|
+
const pg = (await import('pg')).default;
|
|
82
|
+
const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 8000, statement_timeout: 15000 });
|
|
83
|
+
await client.connect();
|
|
84
|
+
try {
|
|
85
|
+
// 1. Detect primary key
|
|
86
|
+
const pkRes = await client.query(`SELECT kcu.column_name
|
|
87
|
+
FROM information_schema.table_constraints tc
|
|
88
|
+
JOIN information_schema.key_column_usage kcu
|
|
89
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
|
|
90
|
+
WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
|
|
91
|
+
LIMIT 1`, [entity]);
|
|
92
|
+
const primaryKey = pkRes.rows[0]?.column_name ?? 'id';
|
|
93
|
+
// 2. Detect searchable text columns
|
|
94
|
+
const colRes = await client.query(`SELECT column_name, data_type
|
|
95
|
+
FROM information_schema.columns
|
|
96
|
+
WHERE table_schema = current_schema() AND table_name = $1`, [entity]);
|
|
97
|
+
const allCols = colRes.rows.map((r) => r.column_name);
|
|
98
|
+
// 3. Build search clause
|
|
99
|
+
const search = options.search?.trim();
|
|
100
|
+
let whereClause = '';
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
|
+
const params = [];
|
|
103
|
+
if (search && allCols.length > 0) {
|
|
104
|
+
const searchConditions = allCols.map((col) => `"${col.replace(/"/g, '""')}"::text ILIKE $1`);
|
|
105
|
+
whereClause = ` WHERE (${searchConditions.join(' OR ')})`;
|
|
106
|
+
params.push(`%${search}%`);
|
|
107
|
+
}
|
|
108
|
+
// 4. Count total matching rows
|
|
109
|
+
const countQuery = `SELECT COUNT(*) AS total FROM "${entity.replace(/"/g, '""')}"${whereClause}`;
|
|
110
|
+
const countRes = await client.query(countQuery, params);
|
|
111
|
+
const totalCount = parseInt(countRes.rows[0]?.total || '0', 10);
|
|
112
|
+
// 5. Pagination and sorting
|
|
113
|
+
const limit = Math.max(1, Math.min(100, options.limit ?? 25));
|
|
114
|
+
const page = Math.max(1, options.page ?? 1);
|
|
115
|
+
const offset = (page - 1) * limit;
|
|
116
|
+
const totalPages = Math.ceil(totalCount / limit) || 1;
|
|
117
|
+
let sortCol = primaryKey;
|
|
118
|
+
if (options.sort && allCols.includes(options.sort)) {
|
|
119
|
+
sortCol = options.sort;
|
|
120
|
+
}
|
|
121
|
+
const order = options.order === 'desc' ? 'DESC' : 'ASC';
|
|
122
|
+
const dataParams = [...params, limit, offset];
|
|
123
|
+
const dataQuery = `SELECT * FROM "${entity.replace(/"/g, '""')}"${whereClause} ORDER BY "${sortCol.replace(/"/g, '""')}" ${order} LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`;
|
|
124
|
+
const dataRes = await client.query(dataQuery, dataParams);
|
|
125
|
+
return {
|
|
126
|
+
entity,
|
|
127
|
+
entityType: 'table',
|
|
128
|
+
primaryKey,
|
|
129
|
+
page,
|
|
130
|
+
limit,
|
|
131
|
+
totalCount,
|
|
132
|
+
totalPages,
|
|
133
|
+
rows: dataRes.rows.map(serializeRow),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await client.end().catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function postgresInsert(url, entity, record) {
|
|
141
|
+
assertValidIdentifier(entity, 'table name');
|
|
142
|
+
const pg = (await import('pg')).default;
|
|
143
|
+
const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 8000, statement_timeout: 15000 });
|
|
144
|
+
await client.connect();
|
|
145
|
+
try {
|
|
146
|
+
const keys = Object.keys(record);
|
|
147
|
+
if (keys.length === 0) {
|
|
148
|
+
const res = await client.query(`INSERT INTO "${entity.replace(/"/g, '""')}" DEFAULT VALUES RETURNING *`);
|
|
149
|
+
const row = res.rows[0] ?? {};
|
|
150
|
+
const insertedId = row.id ?? 1;
|
|
151
|
+
return { success: true, insertedId, record: serializeRow(row) };
|
|
152
|
+
}
|
|
153
|
+
for (const k of keys)
|
|
154
|
+
assertValidIdentifier(k, 'column name');
|
|
155
|
+
const cols = keys.map((k) => `"${k.replace(/"/g, '""')}"`).join(', ');
|
|
156
|
+
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
|
|
157
|
+
const values = keys.map((k) => record[k]);
|
|
158
|
+
const query = `INSERT INTO "${entity.replace(/"/g, '""')}" (${cols}) VALUES (${placeholders}) RETURNING *`;
|
|
159
|
+
const res = await client.query(query, values);
|
|
160
|
+
const row = res.rows[0] ?? {};
|
|
161
|
+
const insertedId = row.id ?? row._id ?? 'unknown';
|
|
162
|
+
return { success: true, insertedId, record: serializeRow(row) };
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await client.end().catch(() => undefined);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function postgresUpdate(url, entity, primaryKey, updates) {
|
|
169
|
+
assertValidIdentifier(entity, 'table name');
|
|
170
|
+
const pg = (await import('pg')).default;
|
|
171
|
+
const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 8000, statement_timeout: 15000 });
|
|
172
|
+
await client.connect();
|
|
173
|
+
try {
|
|
174
|
+
const pkRes = await client.query(`SELECT kcu.column_name
|
|
175
|
+
FROM information_schema.table_constraints tc
|
|
176
|
+
JOIN information_schema.key_column_usage kcu
|
|
177
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
|
|
178
|
+
WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
|
|
179
|
+
LIMIT 1`, [entity]);
|
|
180
|
+
const pkCol = pkRes.rows[0]?.column_name ?? 'id';
|
|
181
|
+
const keys = Object.keys(updates).filter((k) => k !== pkCol);
|
|
182
|
+
if (keys.length === 0) {
|
|
183
|
+
throw new Error('No updatable columns provided');
|
|
184
|
+
}
|
|
185
|
+
for (const k of keys)
|
|
186
|
+
assertValidIdentifier(k, 'column name');
|
|
187
|
+
const setClause = keys.map((k, i) => `"${k.replace(/"/g, '""')}" = $${i + 1}`).join(', ');
|
|
188
|
+
const values = keys.map((k) => updates[k]);
|
|
189
|
+
values.push(primaryKey);
|
|
190
|
+
const query = `UPDATE "${entity.replace(/"/g, '""')}" SET ${setClause} WHERE "${pkCol.replace(/"/g, '""')}" = $${values.length} RETURNING *`;
|
|
191
|
+
const res = await client.query(query, values);
|
|
192
|
+
if (res.rowCount === 0) {
|
|
193
|
+
throw new Error(`Record with primary key "${primaryKey}" was not found in ${entity}`);
|
|
194
|
+
}
|
|
195
|
+
return { success: true, updatedCount: res.rowCount ?? 1, record: serializeRow(res.rows[0]) };
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await client.end().catch(() => undefined);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function postgresDelete(url, entity, primaryKey) {
|
|
202
|
+
assertValidIdentifier(entity, 'table name');
|
|
203
|
+
const pg = (await import('pg')).default;
|
|
204
|
+
const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 8000, statement_timeout: 15000 });
|
|
205
|
+
await client.connect();
|
|
206
|
+
try {
|
|
207
|
+
const pkRes = await client.query(`SELECT kcu.column_name
|
|
208
|
+
FROM information_schema.table_constraints tc
|
|
209
|
+
JOIN information_schema.key_column_usage kcu
|
|
210
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
|
|
211
|
+
WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
|
|
212
|
+
LIMIT 1`, [entity]);
|
|
213
|
+
const pkCol = pkRes.rows[0]?.column_name ?? 'id';
|
|
214
|
+
const query = `DELETE FROM "${entity.replace(/"/g, '""')}" WHERE "${pkCol.replace(/"/g, '""')}" = $1`;
|
|
215
|
+
const res = await client.query(query, [primaryKey]);
|
|
216
|
+
if (res.rowCount === 0) {
|
|
217
|
+
throw new Error(`Record with primary key "${primaryKey}" was not found in ${entity}`);
|
|
218
|
+
}
|
|
219
|
+
return { success: true, deletedCount: res.rowCount ?? 1 };
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
await client.end().catch(() => undefined);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// ─── MySQL Driver ─────────────────────────────────────────────────────────────
|
|
226
|
+
async function mysqlFetch(url, entity, options) {
|
|
227
|
+
assertValidIdentifier(entity, 'table name');
|
|
228
|
+
const mysql = await import('mysql2/promise');
|
|
229
|
+
const conn = await mysql.createConnection({ uri: url, connectTimeout: 8000 });
|
|
230
|
+
try {
|
|
231
|
+
// 1. Detect primary key
|
|
232
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
233
|
+
const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
|
|
234
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
235
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
|
|
236
|
+
LIMIT 1`, [entity]));
|
|
237
|
+
const primaryKey = pkRows[0]?.COLUMN_NAME ?? 'id';
|
|
238
|
+
// 2. Detect column names
|
|
239
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
240
|
+
const [colRows] = (await conn.query(`SELECT COLUMN_NAME
|
|
241
|
+
FROM information_schema.COLUMNS
|
|
242
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, [entity]));
|
|
243
|
+
const allCols = colRows.map((r) => r.COLUMN_NAME);
|
|
244
|
+
// 3. Search clause
|
|
245
|
+
const search = options.search?.trim();
|
|
246
|
+
let whereClause = '';
|
|
247
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
248
|
+
const params = [];
|
|
249
|
+
if (search && allCols.length > 0) {
|
|
250
|
+
const searchConditions = allCols.map((col) => `\`${col.replace(/`/g, '``')}\` LIKE ?`);
|
|
251
|
+
whereClause = ` WHERE (${searchConditions.join(' OR ')})`;
|
|
252
|
+
for (let i = 0; i < allCols.length; i++)
|
|
253
|
+
params.push(`%${search}%`);
|
|
254
|
+
}
|
|
255
|
+
// 4. Count total
|
|
256
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
257
|
+
const [countRows] = (await conn.query(`SELECT COUNT(*) AS total FROM \`${entity.replace(/`/g, '``')}\`${whereClause}`, params));
|
|
258
|
+
const totalCount = parseInt(countRows[0]?.total || '0', 10);
|
|
259
|
+
const limit = Math.max(1, Math.min(100, options.limit ?? 25));
|
|
260
|
+
const page = Math.max(1, options.page ?? 1);
|
|
261
|
+
const offset = (page - 1) * limit;
|
|
262
|
+
const totalPages = Math.ceil(totalCount / limit) || 1;
|
|
263
|
+
let sortCol = primaryKey;
|
|
264
|
+
if (options.sort && allCols.includes(options.sort)) {
|
|
265
|
+
sortCol = options.sort;
|
|
266
|
+
}
|
|
267
|
+
const order = options.order === 'desc' ? 'DESC' : 'ASC';
|
|
268
|
+
const dataParams = [...params, limit, offset];
|
|
269
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
270
|
+
const [dataRows] = (await conn.query(`SELECT * FROM \`${entity.replace(/`/g, '``')}\`${whereClause} ORDER BY \`${sortCol.replace(/`/g, '``')}\` ${order} LIMIT ? OFFSET ?`, dataParams));
|
|
271
|
+
return {
|
|
272
|
+
entity,
|
|
273
|
+
entityType: 'table',
|
|
274
|
+
primaryKey,
|
|
275
|
+
page,
|
|
276
|
+
limit,
|
|
277
|
+
totalCount,
|
|
278
|
+
totalPages,
|
|
279
|
+
rows: dataRows.map(serializeRow),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
await conn.end().catch(() => undefined);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function mysqlInsert(url, entity, record) {
|
|
287
|
+
assertValidIdentifier(entity, 'table name');
|
|
288
|
+
const mysql = await import('mysql2/promise');
|
|
289
|
+
const conn = await mysql.createConnection({ uri: url, connectTimeout: 8000 });
|
|
290
|
+
try {
|
|
291
|
+
const keys = Object.keys(record);
|
|
292
|
+
for (const k of keys)
|
|
293
|
+
assertValidIdentifier(k, 'column name');
|
|
294
|
+
const cols = keys.map((k) => `\`${k.replace(/`/g, '``')}\``).join(', ');
|
|
295
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
296
|
+
const values = keys.map((k) => record[k]);
|
|
297
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
298
|
+
const [res] = (await conn.query(`INSERT INTO \`${entity.replace(/`/g, '``')}\` (${cols}) VALUES (${placeholders})`, values));
|
|
299
|
+
const insertedId = res.insertId ?? 1;
|
|
300
|
+
return { success: true, insertedId, record: serializeRow({ ...record, id: insertedId }) };
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
await conn.end().catch(() => undefined);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function mysqlUpdate(url, entity, primaryKey, updates) {
|
|
307
|
+
assertValidIdentifier(entity, 'table name');
|
|
308
|
+
const mysql = await import('mysql2/promise');
|
|
309
|
+
const conn = await mysql.createConnection({ uri: url, connectTimeout: 8000 });
|
|
310
|
+
try {
|
|
311
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
312
|
+
const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
|
|
313
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
314
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
|
|
315
|
+
LIMIT 1`, [entity]));
|
|
316
|
+
const pkCol = pkRows[0]?.COLUMN_NAME ?? 'id';
|
|
317
|
+
const keys = Object.keys(updates).filter((k) => k !== pkCol);
|
|
318
|
+
if (keys.length === 0)
|
|
319
|
+
throw new Error('No updatable columns provided');
|
|
320
|
+
for (const k of keys)
|
|
321
|
+
assertValidIdentifier(k, 'column name');
|
|
322
|
+
const setClause = keys.map((k) => `\`${k.replace(/`/g, '``')}\` = ?`).join(', ');
|
|
323
|
+
const values = [...keys.map((k) => updates[k]), primaryKey];
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
325
|
+
const [res] = (await conn.query(`UPDATE \`${entity.replace(/`/g, '``')}\` SET ${setClause} WHERE \`${pkCol.replace(/`/g, '``')}\` = ?`, values));
|
|
326
|
+
if (res.affectedRows === 0) {
|
|
327
|
+
throw new Error(`Record with primary key "${primaryKey}" was not found in ${entity}`);
|
|
328
|
+
}
|
|
329
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
330
|
+
const [updatedRows] = (await conn.query(`SELECT * FROM \`${entity.replace(/`/g, '``')}\` WHERE \`${pkCol.replace(/`/g, '``')}\` = ? LIMIT 1`, [primaryKey]));
|
|
331
|
+
return { success: true, updatedCount: res.affectedRows, record: serializeRow(updatedRows[0] || updates) };
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
await conn.end().catch(() => undefined);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function mysqlDelete(url, entity, primaryKey) {
|
|
338
|
+
assertValidIdentifier(entity, 'table name');
|
|
339
|
+
const mysql = await import('mysql2/promise');
|
|
340
|
+
const conn = await mysql.createConnection({ uri: url, connectTimeout: 8000 });
|
|
341
|
+
try {
|
|
342
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
343
|
+
const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
|
|
344
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
345
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
|
|
346
|
+
LIMIT 1`, [entity]));
|
|
347
|
+
const pkCol = pkRows[0]?.COLUMN_NAME ?? 'id';
|
|
348
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
349
|
+
const [res] = (await conn.query(`DELETE FROM \`${entity.replace(/`/g, '``')}\` WHERE \`${pkCol.replace(/`/g, '``')}\` = ?`, [primaryKey]));
|
|
350
|
+
if (res.affectedRows === 0) {
|
|
351
|
+
throw new Error(`Record with primary key "${primaryKey}" was not found in ${entity}`);
|
|
352
|
+
}
|
|
353
|
+
return { success: true, deletedCount: res.affectedRows };
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
await conn.end().catch(() => undefined);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// ─── SQLite Driver (node:sqlite) ──────────────────────────────────────────────
|
|
360
|
+
async function sqliteFetch(url, entity, options) {
|
|
361
|
+
assertValidIdentifier(entity, 'table name');
|
|
362
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
363
|
+
const filePath = sqlitePathFromUrl(url);
|
|
364
|
+
const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath, { readOnly: true });
|
|
365
|
+
try {
|
|
366
|
+
const ident = `"${entity.replace(/"/g, '""')}"`;
|
|
367
|
+
const cols = db.prepare(`PRAGMA table_info(${ident})`).all();
|
|
368
|
+
const pkCol = cols.find((c) => Number(c.pk) > 0)?.name ?? 'id';
|
|
369
|
+
const allCols = cols.map((c) => c.name);
|
|
370
|
+
const search = options.search?.trim();
|
|
371
|
+
let whereClause = '';
|
|
372
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
373
|
+
const params = [];
|
|
374
|
+
if (search && allCols.length > 0) {
|
|
375
|
+
const conditions = allCols.map((c) => `"${c.replace(/"/g, '""')}" LIKE ?`);
|
|
376
|
+
whereClause = ` WHERE (${conditions.join(' OR ')})`;
|
|
377
|
+
for (let i = 0; i < allCols.length; i++)
|
|
378
|
+
params.push(`%${search}%`);
|
|
379
|
+
}
|
|
380
|
+
const countRow = db.prepare(`SELECT COUNT(*) AS total FROM ${ident}${whereClause}`).get(...params);
|
|
381
|
+
const totalCount = Number(countRow?.total ?? 0);
|
|
382
|
+
const limit = Math.max(1, Math.min(100, options.limit ?? 25));
|
|
383
|
+
const page = Math.max(1, options.page ?? 1);
|
|
384
|
+
const offset = (page - 1) * limit;
|
|
385
|
+
const totalPages = Math.ceil(totalCount / limit) || 1;
|
|
386
|
+
let sortCol = pkCol;
|
|
387
|
+
if (options.sort && allCols.includes(options.sort)) {
|
|
388
|
+
sortCol = options.sort;
|
|
389
|
+
}
|
|
390
|
+
const order = options.order === 'desc' ? 'DESC' : 'ASC';
|
|
391
|
+
const dataRows = db
|
|
392
|
+
.prepare(`SELECT * FROM ${ident}${whereClause} ORDER BY "${sortCol.replace(/"/g, '""')}" ${order} LIMIT ? OFFSET ?`)
|
|
393
|
+
.all(...params, limit, offset);
|
|
394
|
+
return {
|
|
395
|
+
entity,
|
|
396
|
+
entityType: 'table',
|
|
397
|
+
primaryKey: pkCol,
|
|
398
|
+
page,
|
|
399
|
+
limit,
|
|
400
|
+
totalCount,
|
|
401
|
+
totalPages,
|
|
402
|
+
rows: dataRows.map(serializeRow),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
db.close();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function sqliteInsert(url, entity, record) {
|
|
410
|
+
assertValidIdentifier(entity, 'table name');
|
|
411
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
412
|
+
const filePath = sqlitePathFromUrl(url);
|
|
413
|
+
const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath);
|
|
414
|
+
try {
|
|
415
|
+
const ident = `"${entity.replace(/"/g, '""')}"`;
|
|
416
|
+
const keys = Object.keys(record);
|
|
417
|
+
for (const k of keys)
|
|
418
|
+
assertValidIdentifier(k, 'column name');
|
|
419
|
+
let insertedId = 1;
|
|
420
|
+
let insertedRow = record;
|
|
421
|
+
if (keys.length === 0) {
|
|
422
|
+
const res = db.prepare(`INSERT INTO ${ident} DEFAULT VALUES RETURNING *`).get();
|
|
423
|
+
if (res) {
|
|
424
|
+
insertedRow = res;
|
|
425
|
+
insertedId = res.id ?? 1;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
const cols = keys.map((k) => `"${k.replace(/"/g, '""')}"`).join(', ');
|
|
430
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
431
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
432
|
+
const values = keys.map((k) => record[k]);
|
|
433
|
+
try {
|
|
434
|
+
const res = db.prepare(`INSERT INTO ${ident} (${cols}) VALUES (${placeholders}) RETURNING *`).get(...values);
|
|
435
|
+
if (res) {
|
|
436
|
+
insertedRow = res;
|
|
437
|
+
insertedId = res.id ?? 1;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// Fallback for older SQLite versions without RETURNING
|
|
442
|
+
const info = db.prepare(`INSERT INTO ${ident} (${cols}) VALUES (${placeholders})`).run(...values);
|
|
443
|
+
insertedId = Number(info.lastInsertRowid ?? 1);
|
|
444
|
+
insertedRow = { ...record, id: insertedId };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return { success: true, insertedId, record: serializeRow(insertedRow) };
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
db.close();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async function sqliteUpdate(url, entity, primaryKey, updates) {
|
|
454
|
+
assertValidIdentifier(entity, 'table name');
|
|
455
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
456
|
+
const filePath = sqlitePathFromUrl(url);
|
|
457
|
+
const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath);
|
|
458
|
+
try {
|
|
459
|
+
const ident = `"${entity.replace(/"/g, '""')}"`;
|
|
460
|
+
const cols = db.prepare(`PRAGMA table_info(${ident})`).all();
|
|
461
|
+
const pkCol = cols.find((c) => Number(c.pk) > 0)?.name ?? 'id';
|
|
462
|
+
const keys = Object.keys(updates).filter((k) => k !== pkCol);
|
|
463
|
+
if (keys.length === 0)
|
|
464
|
+
throw new Error('No updatable columns provided');
|
|
465
|
+
for (const k of keys)
|
|
466
|
+
assertValidIdentifier(k, 'column name');
|
|
467
|
+
const setClause = keys.map((k) => `"${k.replace(/"/g, '""')}" = ?`).join(', ');
|
|
468
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
469
|
+
const values = [...keys.map((k) => updates[k]), primaryKey];
|
|
470
|
+
const stmt = db.prepare(`UPDATE ${ident} SET ${setClause} WHERE "${pkCol.replace(/"/g, '""')}" = ?`);
|
|
471
|
+
const info = stmt.run(...values);
|
|
472
|
+
const changes = Number(info.changes ?? 0);
|
|
473
|
+
if (changes === 0) {
|
|
474
|
+
throw new Error(`Record with primary key "${primaryKey}" was not found in ${entity}`);
|
|
475
|
+
}
|
|
476
|
+
const updatedRow = db.prepare(`SELECT * FROM ${ident} WHERE "${pkCol.replace(/"/g, '""')}" = ? LIMIT 1`).get(primaryKey);
|
|
477
|
+
return { success: true, updatedCount: changes, record: serializeRow(updatedRow || updates) };
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
db.close();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async function sqliteDelete(url, entity, primaryKey) {
|
|
484
|
+
assertValidIdentifier(entity, 'table name');
|
|
485
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
486
|
+
const filePath = sqlitePathFromUrl(url);
|
|
487
|
+
const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath);
|
|
488
|
+
try {
|
|
489
|
+
const ident = `"${entity.replace(/"/g, '""')}"`;
|
|
490
|
+
const cols = db.prepare(`PRAGMA table_info(${ident})`).all();
|
|
491
|
+
const pkCol = cols.find((c) => Number(c.pk) > 0)?.name ?? 'id';
|
|
492
|
+
const stmt = db.prepare(`DELETE FROM ${ident} WHERE "${pkCol.replace(/"/g, '""')}" = ?`);
|
|
493
|
+
const info = stmt.run(primaryKey);
|
|
494
|
+
const changes = Number(info.changes ?? 0);
|
|
495
|
+
if (changes === 0) {
|
|
496
|
+
throw new Error(`Record with primary key "${primaryKey}" was not found in ${entity}`);
|
|
497
|
+
}
|
|
498
|
+
return { success: true, deletedCount: changes };
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
db.close();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// ─── MongoDB Driver ────────────────────────────────────────────────────────────
|
|
505
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
506
|
+
function resolveMongoId(id, ObjectIdClass) {
|
|
507
|
+
if (ObjectIdClass && typeof id === 'string' && ObjectIdClass.isValid(id)) {
|
|
508
|
+
try {
|
|
509
|
+
return new ObjectIdClass(id);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
// fallback to raw id
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return id;
|
|
516
|
+
}
|
|
517
|
+
async function mongoFetch(url, entity, options) {
|
|
518
|
+
assertValidIdentifier(entity, 'collection name');
|
|
519
|
+
const { MongoClient } = await import('mongodb');
|
|
520
|
+
const client = new MongoClient(url, { serverSelectionTimeoutMS: 8000, connectTimeoutMS: 8000, appName: 'nativ-data' });
|
|
521
|
+
await client.connect();
|
|
522
|
+
try {
|
|
523
|
+
const db = client.db();
|
|
524
|
+
const col = db.collection(entity);
|
|
525
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
526
|
+
const filter = {};
|
|
527
|
+
const search = options.search?.trim();
|
|
528
|
+
if (search) {
|
|
529
|
+
// Find sampled docs to get string keys for search
|
|
530
|
+
const sample = await col.find({}).limit(10).toArray();
|
|
531
|
+
const keys = new Set();
|
|
532
|
+
for (const d of sample) {
|
|
533
|
+
for (const [k, v] of Object.entries(d)) {
|
|
534
|
+
if (typeof v === 'string')
|
|
535
|
+
keys.add(k);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (keys.size > 0) {
|
|
539
|
+
filter.$or = [...keys].map((k) => ({ [k]: { $regex: search, $options: 'i' } }));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const totalCount = await col.countDocuments(filter);
|
|
543
|
+
const limit = Math.max(1, Math.min(100, options.limit ?? 25));
|
|
544
|
+
const page = Math.max(1, options.page ?? 1);
|
|
545
|
+
const offset = (page - 1) * limit;
|
|
546
|
+
const totalPages = Math.ceil(totalCount / limit) || 1;
|
|
547
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
548
|
+
const sortObj = {};
|
|
549
|
+
const sortCol = options.sort || '_id';
|
|
550
|
+
sortObj[sortCol] = options.order === 'desc' ? -1 : 1;
|
|
551
|
+
const docs = await col.find(filter).sort(sortObj).skip(offset).limit(limit).toArray();
|
|
552
|
+
return {
|
|
553
|
+
entity,
|
|
554
|
+
entityType: 'collection',
|
|
555
|
+
primaryKey: '_id',
|
|
556
|
+
page,
|
|
557
|
+
limit,
|
|
558
|
+
totalCount,
|
|
559
|
+
totalPages,
|
|
560
|
+
rows: docs.map((d) => serializeRow(d)),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
await client.close().catch(() => undefined);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function mongoInsert(url, entity, record) {
|
|
568
|
+
assertValidIdentifier(entity, 'collection name');
|
|
569
|
+
const { MongoClient } = await import('mongodb');
|
|
570
|
+
const client = new MongoClient(url, { serverSelectionTimeoutMS: 8000, connectTimeoutMS: 8000, appName: 'nativ-data' });
|
|
571
|
+
await client.connect();
|
|
572
|
+
try {
|
|
573
|
+
const db = client.db();
|
|
574
|
+
const col = db.collection(entity);
|
|
575
|
+
const res = await col.insertOne(record);
|
|
576
|
+
const insertedId = String(res.insertedId);
|
|
577
|
+
return { success: true, insertedId, record: serializeRow({ ...record, _id: res.insertedId }) };
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
await client.close().catch(() => undefined);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
async function mongoUpdate(url, entity, primaryKey, updates) {
|
|
584
|
+
assertValidIdentifier(entity, 'collection name');
|
|
585
|
+
const { MongoClient, ObjectId } = await import('mongodb');
|
|
586
|
+
const client = new MongoClient(url, { serverSelectionTimeoutMS: 8000, connectTimeoutMS: 8000, appName: 'nativ-data' });
|
|
587
|
+
await client.connect();
|
|
588
|
+
try {
|
|
589
|
+
const db = client.db();
|
|
590
|
+
const col = db.collection(entity);
|
|
591
|
+
const filterId = resolveMongoId(primaryKey, ObjectId);
|
|
592
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
593
|
+
const { _id, id, ...cleanUpdates } = updates;
|
|
594
|
+
if (Object.keys(cleanUpdates).length === 0)
|
|
595
|
+
throw new Error('No updatable fields provided');
|
|
596
|
+
const res = await col.updateOne({ _id: filterId }, { $set: cleanUpdates });
|
|
597
|
+
if (res.matchedCount === 0) {
|
|
598
|
+
throw new Error(`Document with _id "${primaryKey}" was not found in collection ${entity}`);
|
|
599
|
+
}
|
|
600
|
+
const updatedDoc = await col.findOne({ _id: filterId });
|
|
601
|
+
return { success: true, updatedCount: res.modifiedCount, record: serializeRow(updatedDoc || cleanUpdates) };
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
await client.close().catch(() => undefined);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async function mongoDelete(url, entity, primaryKey) {
|
|
608
|
+
assertValidIdentifier(entity, 'collection name');
|
|
609
|
+
const { MongoClient, ObjectId } = await import('mongodb');
|
|
610
|
+
const client = new MongoClient(url, { serverSelectionTimeoutMS: 8000, connectTimeoutMS: 8000, appName: 'nativ-data' });
|
|
611
|
+
await client.connect();
|
|
612
|
+
try {
|
|
613
|
+
const db = client.db();
|
|
614
|
+
const col = db.collection(entity);
|
|
615
|
+
const filterId = resolveMongoId(primaryKey, ObjectId);
|
|
616
|
+
const res = await col.deleteOne({ _id: filterId });
|
|
617
|
+
if (res.deletedCount === 0) {
|
|
618
|
+
throw new Error(`Document with _id "${primaryKey}" was not found in collection ${entity}`);
|
|
619
|
+
}
|
|
620
|
+
return { success: true, deletedCount: res.deletedCount };
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
await client.close().catch(() => undefined);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// ─── Firestore Driver ─────────────────────────────────────────────────────────
|
|
627
|
+
async function firestoreFetch(url, entity, options) {
|
|
628
|
+
assertValidIdentifier(entity, 'collection name');
|
|
629
|
+
const config = parseFirestoreUrl(url);
|
|
630
|
+
const { Firestore } = await import('@google-cloud/firestore');
|
|
631
|
+
const firestore = new Firestore({
|
|
632
|
+
projectId: config.projectId,
|
|
633
|
+
...(config.databaseId ? { databaseId: config.databaseId } : {}),
|
|
634
|
+
...(config.emulatorHost ? { host: config.emulatorHost, ssl: false } : {}),
|
|
635
|
+
});
|
|
636
|
+
const col = firestore.collection(entity);
|
|
637
|
+
const countSnap = await col.count().get().catch(() => null);
|
|
638
|
+
const totalCount = countSnap ? countSnap.data().count : 0;
|
|
639
|
+
const limit = Math.max(1, Math.min(100, options.limit ?? 25));
|
|
640
|
+
const page = Math.max(1, options.page ?? 1);
|
|
641
|
+
const offset = (page - 1) * limit;
|
|
642
|
+
const totalPages = Math.ceil(totalCount / limit) || 1;
|
|
643
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
644
|
+
let query = col;
|
|
645
|
+
if (options.sort && options.sort !== 'id') {
|
|
646
|
+
query = query.orderBy(options.sort, options.order === 'desc' ? 'desc' : 'asc');
|
|
647
|
+
}
|
|
648
|
+
if (offset > 0) {
|
|
649
|
+
query = query.offset(offset);
|
|
650
|
+
}
|
|
651
|
+
query = query.limit(limit);
|
|
652
|
+
const snapshot = await query.get();
|
|
653
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
654
|
+
const rows = snapshot.docs.map((d) => serializeRow({ id: d.id, ...d.data() }));
|
|
655
|
+
return {
|
|
656
|
+
entity,
|
|
657
|
+
entityType: 'collection',
|
|
658
|
+
primaryKey: 'id',
|
|
659
|
+
page,
|
|
660
|
+
limit,
|
|
661
|
+
totalCount,
|
|
662
|
+
totalPages,
|
|
663
|
+
rows,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
async function firestoreInsert(url, entity, record) {
|
|
667
|
+
assertValidIdentifier(entity, 'collection name');
|
|
668
|
+
const config = parseFirestoreUrl(url);
|
|
669
|
+
const { Firestore } = await import('@google-cloud/firestore');
|
|
670
|
+
const firestore = new Firestore({
|
|
671
|
+
projectId: config.projectId,
|
|
672
|
+
...(config.databaseId ? { databaseId: config.databaseId } : {}),
|
|
673
|
+
...(config.emulatorHost ? { host: config.emulatorHost, ssl: false } : {}),
|
|
674
|
+
});
|
|
675
|
+
const col = firestore.collection(entity);
|
|
676
|
+
const { id, ...data } = record;
|
|
677
|
+
const docRef = id ? col.doc(String(id)) : col.doc();
|
|
678
|
+
await docRef.set(data);
|
|
679
|
+
return { success: true, insertedId: docRef.id, record: serializeRow({ id: docRef.id, ...data }) };
|
|
680
|
+
}
|
|
681
|
+
async function firestoreUpdate(url, entity, primaryKey, updates) {
|
|
682
|
+
assertValidIdentifier(entity, 'collection name');
|
|
683
|
+
const config = parseFirestoreUrl(url);
|
|
684
|
+
const { Firestore } = await import('@google-cloud/firestore');
|
|
685
|
+
const firestore = new Firestore({
|
|
686
|
+
projectId: config.projectId,
|
|
687
|
+
...(config.databaseId ? { databaseId: config.databaseId } : {}),
|
|
688
|
+
...(config.emulatorHost ? { host: config.emulatorHost, ssl: false } : {}),
|
|
689
|
+
});
|
|
690
|
+
const col = firestore.collection(entity);
|
|
691
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
692
|
+
const { id, ...data } = updates;
|
|
693
|
+
if (Object.keys(data).length === 0)
|
|
694
|
+
throw new Error('No updatable fields provided');
|
|
695
|
+
const docRef = col.doc(String(primaryKey));
|
|
696
|
+
await docRef.update(data);
|
|
697
|
+
const updatedSnap = await docRef.get();
|
|
698
|
+
return { success: true, updatedCount: 1, record: serializeRow({ id: docRef.id, ...updatedSnap.data() }) };
|
|
699
|
+
}
|
|
700
|
+
async function firestoreDelete(url, entity, primaryKey) {
|
|
701
|
+
assertValidIdentifier(entity, 'collection name');
|
|
702
|
+
const config = parseFirestoreUrl(url);
|
|
703
|
+
const { Firestore } = await import('@google-cloud/firestore');
|
|
704
|
+
const firestore = new Firestore({
|
|
705
|
+
projectId: config.projectId,
|
|
706
|
+
...(config.databaseId ? { databaseId: config.databaseId } : {}),
|
|
707
|
+
...(config.emulatorHost ? { host: config.emulatorHost, ssl: false } : {}),
|
|
708
|
+
});
|
|
709
|
+
const col = firestore.collection(entity);
|
|
710
|
+
const docRef = col.doc(String(primaryKey));
|
|
711
|
+
await docRef.delete();
|
|
712
|
+
return { success: true, deletedCount: 1 };
|
|
713
|
+
}
|
|
714
|
+
// ─── Unified Data Engine Facade ───────────────────────────────────────────────
|
|
715
|
+
export async function fetchData(url, entity, options = {}) {
|
|
716
|
+
const engine = detectEngine(url);
|
|
717
|
+
try {
|
|
718
|
+
switch (engine) {
|
|
719
|
+
case 'postgresql':
|
|
720
|
+
return await postgresFetch(url, entity, options);
|
|
721
|
+
case 'mysql':
|
|
722
|
+
return await mysqlFetch(url, entity, options);
|
|
723
|
+
case 'sqlite':
|
|
724
|
+
return await sqliteFetch(url, entity, options);
|
|
725
|
+
case 'mongodb':
|
|
726
|
+
return await mongoFetch(url, entity, options);
|
|
727
|
+
case 'firestore':
|
|
728
|
+
return await firestoreFetch(url, entity, options);
|
|
729
|
+
default:
|
|
730
|
+
throw new Error(`Unsupported database engine for live data queries: "${engine}"`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
throw sanitizeError(err, url);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
export async function insertRecord(url, entity, record) {
|
|
738
|
+
const engine = detectEngine(url);
|
|
739
|
+
try {
|
|
740
|
+
switch (engine) {
|
|
741
|
+
case 'postgresql':
|
|
742
|
+
return await postgresInsert(url, entity, record);
|
|
743
|
+
case 'mysql':
|
|
744
|
+
return await mysqlInsert(url, entity, record);
|
|
745
|
+
case 'sqlite':
|
|
746
|
+
return await sqliteInsert(url, entity, record);
|
|
747
|
+
case 'mongodb':
|
|
748
|
+
return await mongoInsert(url, entity, record);
|
|
749
|
+
case 'firestore':
|
|
750
|
+
return await firestoreInsert(url, entity, record);
|
|
751
|
+
default:
|
|
752
|
+
throw new Error(`Unsupported database engine for insertions: "${engine}"`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
throw sanitizeError(err, url);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
export async function updateRecord(url, entity, primaryKey, updates) {
|
|
760
|
+
const engine = detectEngine(url);
|
|
761
|
+
try {
|
|
762
|
+
switch (engine) {
|
|
763
|
+
case 'postgresql':
|
|
764
|
+
return await postgresUpdate(url, entity, primaryKey, updates);
|
|
765
|
+
case 'mysql':
|
|
766
|
+
return await mysqlUpdate(url, entity, primaryKey, updates);
|
|
767
|
+
case 'sqlite':
|
|
768
|
+
return await sqliteUpdate(url, entity, primaryKey, updates);
|
|
769
|
+
case 'mongodb':
|
|
770
|
+
return await mongoUpdate(url, entity, primaryKey, updates);
|
|
771
|
+
case 'firestore':
|
|
772
|
+
return await firestoreUpdate(url, entity, primaryKey, updates);
|
|
773
|
+
default:
|
|
774
|
+
throw new Error(`Unsupported database engine for updates: "${engine}"`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
throw sanitizeError(err, url);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
export async function deleteRecord(url, entity, primaryKey) {
|
|
782
|
+
const engine = detectEngine(url);
|
|
783
|
+
try {
|
|
784
|
+
switch (engine) {
|
|
785
|
+
case 'postgresql':
|
|
786
|
+
return await postgresDelete(url, entity, primaryKey);
|
|
787
|
+
case 'mysql':
|
|
788
|
+
return await mysqlDelete(url, entity, primaryKey);
|
|
789
|
+
case 'sqlite':
|
|
790
|
+
return await sqliteDelete(url, entity, primaryKey);
|
|
791
|
+
case 'mongodb':
|
|
792
|
+
return await mongoDelete(url, entity, primaryKey);
|
|
793
|
+
case 'firestore':
|
|
794
|
+
return await firestoreDelete(url, entity, primaryKey);
|
|
795
|
+
default:
|
|
796
|
+
throw new Error(`Unsupported database engine for deletions: "${engine}"`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
throw sanitizeError(err, url);
|
|
801
|
+
}
|
|
802
|
+
}
|