@elmoorx/edge-db 2.0.0-alpha.25
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/LICENSE +21 -0
- package/README.md +33 -0
- package/package.json +38 -0
- package/src/index.ts +459 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wafra Framework
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @wafra/edge-db
|
|
2
|
+
|
|
3
|
+
> Edge database: global replication, eventual consistency, low latency reads
|
|
4
|
+
|
|
5
|
+
Part of the [Wafra Framework](https://github.com/wafra/framework) — Build fast. Run anywhere. Stay secure.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @wafra/edge-db
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { /* exports */ } from '@wafra/edge-db';
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- Zero external dependencies
|
|
22
|
+
- Full TypeScript support
|
|
23
|
+
- Tree-shakeable
|
|
24
|
+
- Edge-runtime compatible
|
|
25
|
+
- Arabic/RTL friendly
|
|
26
|
+
|
|
27
|
+
## Documentation
|
|
28
|
+
|
|
29
|
+
See [https://wafra.dev/docs/edge-db](https://wafra.dev/docs/edge-db)
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
MIT © Wafra Framework
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elmoorx/edge-db",
|
|
3
|
+
"version": "2.0.0-alpha.25",
|
|
4
|
+
"description": "Edge database: global replication, eventual consistency, low latency reads",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@elmoorx/runtime": "^1.0.0"
|
|
13
|
+
},
|
|
14
|
+
"optionalDependencies": {
|
|
15
|
+
"@libsql/client": "^0.6.0",
|
|
16
|
+
"better-sqlite3": "^11.0.0"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Wafra Framework",
|
|
20
|
+
"homepage": "https://wafra.dev/packages/edge-db",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/wafra/framework",
|
|
24
|
+
"directory": "packages/edge-db"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/wafra/framework/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"wafra",
|
|
31
|
+
"framework",
|
|
32
|
+
"edge-db"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./src/index.ts"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wafra/edge-db — Edge Databases (v2.0)
|
|
3
|
+
* ============================================
|
|
4
|
+
* Built-in database for Wafra apps. SQLite at the edge with CRDT sync.
|
|
5
|
+
* Works on Cloudflare Workers (D1), Vercel Edge (libSQL), Deno (SQLite).
|
|
6
|
+
*
|
|
7
|
+
* import { db, query, mutation } from "@wafra/edge-db";
|
|
8
|
+
*
|
|
9
|
+
* // Reactive query — auto-updates when data changes
|
|
10
|
+
* const users = query('SELECT * FROM users WHERE active = ?', [true]);
|
|
11
|
+
*
|
|
12
|
+
* // Mutation — triggers revalidation of affected queries
|
|
13
|
+
* await mutation('INSERT INTO users (name) VALUES (?)', ['Alice']);
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - SQLite-compatible SQL
|
|
17
|
+
* - CRDT-based sync (offline-first)
|
|
18
|
+
* - Reactive queries (auto re-run on data change)
|
|
19
|
+
* - Schema migrations
|
|
20
|
+
* - Type-safe queries with TypeScript generics
|
|
21
|
+
* - Edge-optimized: queries < 5ms at any edge location
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ============ TYPES ============
|
|
25
|
+
|
|
26
|
+
export interface DBSchema {
|
|
27
|
+
[tableName: string]: {
|
|
28
|
+
[columnName: string]: "TEXT" | "INTEGER" | "REAL" | "BLOB" | "BOOLEAN" | "DATE";
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QueryResult<T = Record<string, unknown>> {
|
|
33
|
+
rows: T[];
|
|
34
|
+
rowCount: number;
|
|
35
|
+
executionTimeMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MutationResult<T = Record<string, unknown>> {
|
|
39
|
+
insertedId?: number | string;
|
|
40
|
+
affectedRows: number;
|
|
41
|
+
rows: T[];
|
|
42
|
+
executionTimeMs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============ DATABASE CONNECTION ============
|
|
46
|
+
|
|
47
|
+
export interface EdgeDBConfig {
|
|
48
|
+
// Cloudflare D1 binding name
|
|
49
|
+
d1Binding?: string;
|
|
50
|
+
// Vercel Edge libSQL URL
|
|
51
|
+
libsqlUrl?: string;
|
|
52
|
+
// Deno SQLite file path
|
|
53
|
+
denoSqlitePath?: string;
|
|
54
|
+
// Node SQLite path (better-sqlite3)
|
|
55
|
+
nodeSqlitePath?: string;
|
|
56
|
+
// Schema (for migrations)
|
|
57
|
+
schema?: DBSchema;
|
|
58
|
+
// Auto-migrate on connect
|
|
59
|
+
autoMigrate?: boolean;
|
|
60
|
+
// Enable CRDT sync
|
|
61
|
+
sync?: boolean;
|
|
62
|
+
// Sync endpoint
|
|
63
|
+
syncEndpoint?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let dbConfig: EdgeDBConfig | null = null;
|
|
67
|
+
let dbConnection: any = null;
|
|
68
|
+
const queryCache = new Map<string, { result: QueryResult; timestamp: number }>();
|
|
69
|
+
const queryListeners = new Map<string, Set<() => void>>();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Initialize the database connection.
|
|
73
|
+
*
|
|
74
|
+
* await db.connect({
|
|
75
|
+
* d1Binding: "DB",
|
|
76
|
+
* schema: {
|
|
77
|
+
* users: { id: "INTEGER", name: "TEXT", email: "TEXT" },
|
|
78
|
+
* posts: { id: "INTEGER", title: "TEXT", body: "TEXT" },
|
|
79
|
+
* },
|
|
80
|
+
* sync: true,
|
|
81
|
+
* syncEndpoint: "https://sync.wafra.dev",
|
|
82
|
+
* });
|
|
83
|
+
*/
|
|
84
|
+
export async function connect(config: EdgeDBConfig): Promise<void> {
|
|
85
|
+
dbConfig = config;
|
|
86
|
+
|
|
87
|
+
// Detect environment and connect
|
|
88
|
+
if (config.d1Binding) {
|
|
89
|
+
dbConnection = (globalThis as any)[config.d1Binding];
|
|
90
|
+
} else if (config.libsqlUrl) {
|
|
91
|
+
const { createClient } = await import("@libsql/client");
|
|
92
|
+
dbConnection = createClient({ url: config.libsqlUrl });
|
|
93
|
+
} else if (config.denoSqlitePath) {
|
|
94
|
+
const { Database } = await import("node:sqlite");
|
|
95
|
+
dbConnection = new Database(config.denoSqlitePath);
|
|
96
|
+
} else if (config.nodeSqlitePath) {
|
|
97
|
+
const Database = (await import("better-sqlite3")).default;
|
|
98
|
+
dbConnection = new Database(config.nodeSqlitePath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Run migrations
|
|
102
|
+
if (config.autoMigrate !== false && config.schema) {
|
|
103
|
+
await migrate(config.schema);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Start sync
|
|
107
|
+
if (config.sync && config.syncEndpoint) {
|
|
108
|
+
startSync(config.syncEndpoint);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run a SQL query (read-only). Result is cached and reactive.
|
|
114
|
+
*/
|
|
115
|
+
export async function query<T = Record<string, unknown>>(
|
|
116
|
+
sql: string,
|
|
117
|
+
params: unknown[] = [],
|
|
118
|
+
opts: { ttl?: number; reactive?: boolean } = {}
|
|
119
|
+
): Promise<QueryResult<T>> {
|
|
120
|
+
if (!dbConnection) throw new Error("Database not connected. Call connect() first.");
|
|
121
|
+
|
|
122
|
+
const cacheKey = `${sql}:${JSON.stringify(params)}`;
|
|
123
|
+
const ttl = opts.ttl ?? 5000; // 5s default
|
|
124
|
+
|
|
125
|
+
// Check cache
|
|
126
|
+
const cached = queryCache.get(cacheKey);
|
|
127
|
+
if (cached && Date.now() - cached.timestamp < ttl) {
|
|
128
|
+
return cached.result as QueryResult<T>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Execute
|
|
132
|
+
const start = performance.now();
|
|
133
|
+
const result = await executeQuery<T>(sql, params);
|
|
134
|
+
const executionTimeMs = performance.now() - start;
|
|
135
|
+
|
|
136
|
+
const queryResult: QueryResult<T> = {
|
|
137
|
+
rows: result,
|
|
138
|
+
rowCount: result.length,
|
|
139
|
+
executionTimeMs,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Cache
|
|
143
|
+
queryCache.set(cacheKey, { result: queryResult, timestamp: Date.now() });
|
|
144
|
+
|
|
145
|
+
return queryResult;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run a SQL mutation (INSERT/UPDATE/DELETE). Invalidates cache.
|
|
150
|
+
*/
|
|
151
|
+
export async function mutation<T = Record<string, unknown>>(
|
|
152
|
+
sql: string,
|
|
153
|
+
params: unknown[] = []
|
|
154
|
+
): Promise<MutationResult<T>> {
|
|
155
|
+
if (!dbConnection) throw new Error("Database not connected.");
|
|
156
|
+
|
|
157
|
+
const start = performance.now();
|
|
158
|
+
const result = await executeMutation<T>(sql, params);
|
|
159
|
+
const executionTimeMs = performance.now() - start;
|
|
160
|
+
|
|
161
|
+
// Invalidate cache for affected tables
|
|
162
|
+
invalidateCache(sql);
|
|
163
|
+
|
|
164
|
+
// Notify reactive listeners
|
|
165
|
+
notifyListeners(sql);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
insertedId: result.insertedId,
|
|
169
|
+
affectedRows: result.affectedRows,
|
|
170
|
+
rows: result.rows || [],
|
|
171
|
+
executionTimeMs,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run a transaction.
|
|
177
|
+
*/
|
|
178
|
+
export async function transaction<T>(
|
|
179
|
+
fn: (tx: { query: typeof query; mutation: typeof mutation }) => Promise<T>
|
|
180
|
+
): Promise<T> {
|
|
181
|
+
await executeMutation("BEGIN TRANSACTION", []);
|
|
182
|
+
try {
|
|
183
|
+
const result = await fn({ query, mutation });
|
|
184
|
+
await executeMutation("COMMIT", []);
|
|
185
|
+
return result;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
await executeMutation("ROLLBACK", []);
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ============ REACTIVE QUERIES ============
|
|
193
|
+
|
|
194
|
+
import { $state, $effect, $batch } from "@wafra/runtime";
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* useQuery — reactive query that auto-updates when data changes.
|
|
198
|
+
*
|
|
199
|
+
* const { data, error, loading, refetch } = useQuery(
|
|
200
|
+
* 'SELECT * FROM users WHERE active = ?',
|
|
201
|
+
* [true]
|
|
202
|
+
* );
|
|
203
|
+
*/
|
|
204
|
+
export function useQuery<T = Record<string, unknown>>(
|
|
205
|
+
sql: string,
|
|
206
|
+
params: unknown[] = [],
|
|
207
|
+
opts: { ttl?: number; refreshInterval?: number } = {}
|
|
208
|
+
) {
|
|
209
|
+
const data = $state<T[] | null>(null);
|
|
210
|
+
const error = $state<Error | null>(null);
|
|
211
|
+
const loading = $state(true);
|
|
212
|
+
|
|
213
|
+
const execute = async () => {
|
|
214
|
+
loading.set(true);
|
|
215
|
+
error.set(null);
|
|
216
|
+
try {
|
|
217
|
+
const result = await query<T>(sql, params, { ttl: opts.ttl });
|
|
218
|
+
data.set(result.rows);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
error.set(err as Error);
|
|
221
|
+
} finally {
|
|
222
|
+
loading.set(false);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Initial fetch
|
|
227
|
+
$effect(() => { execute(); });
|
|
228
|
+
|
|
229
|
+
// Refresh interval
|
|
230
|
+
if (opts.refreshInterval) {
|
|
231
|
+
$effect(() => {
|
|
232
|
+
const id = setInterval(execute, opts.refreshInterval!);
|
|
233
|
+
return () => clearInterval(id);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Subscribe to data changes (invalidation)
|
|
238
|
+
const cacheKey = `${sql}:${JSON.stringify(params)}`;
|
|
239
|
+
$effect(() => {
|
|
240
|
+
const listener = () => execute();
|
|
241
|
+
if (!queryListeners.has(cacheKey)) queryListeners.set(cacheKey, new Set());
|
|
242
|
+
queryListeners.get(cacheKey)!.add(listener);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return { data, error, loading, refetch: execute };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* useMutation — reactive mutation.
|
|
250
|
+
*
|
|
251
|
+
* const { trigger, isMutating } = useMutation(
|
|
252
|
+
* 'INSERT INTO users (name) VALUES (?)'
|
|
253
|
+
* );
|
|
254
|
+
* await trigger(['Alice']);
|
|
255
|
+
*/
|
|
256
|
+
export function useMutationDB<T = Record<string, unknown>>(
|
|
257
|
+
sql: string
|
|
258
|
+
) {
|
|
259
|
+
const isMutating = $state(false);
|
|
260
|
+
const error = $state<Error | null>(null);
|
|
261
|
+
const data = $state<MutationResult<T> | null>(null);
|
|
262
|
+
|
|
263
|
+
const trigger = async (params: unknown[]) => {
|
|
264
|
+
isMutating.set(true);
|
|
265
|
+
error.set(null);
|
|
266
|
+
try {
|
|
267
|
+
const result = await mutation<T>(sql, params);
|
|
268
|
+
data.set(result);
|
|
269
|
+
return result;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
error.set(err as Error);
|
|
272
|
+
throw err;
|
|
273
|
+
} finally {
|
|
274
|
+
isMutating.set(false);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return { trigger, isMutating, error, data };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ============ SCHEMA + MIGRATIONS ============
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Define a schema and create tables.
|
|
285
|
+
*/
|
|
286
|
+
export async function migrate(schema: DBSchema): Promise<void> {
|
|
287
|
+
for (const [tableName, columns] of Object.entries(schema)) {
|
|
288
|
+
const columnDefs = Object.entries(columns).map(([name, type]) => {
|
|
289
|
+
if (name === "id") return `${name} ${type} PRIMARY KEY AUTOINCREMENT`;
|
|
290
|
+
return `${name} ${type}`;
|
|
291
|
+
});
|
|
292
|
+
const sql = `CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`;
|
|
293
|
+
await executeMutation(sql, []);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============ CRDT SYNC ============
|
|
298
|
+
|
|
299
|
+
let syncInterval: ReturnType<typeof setInterval> | null = null;
|
|
300
|
+
let pendingChanges: { table: string; id: string; operation: "insert" | "update" | "delete"; data?: unknown }[] = [];
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Start CRDT sync with the sync endpoint.
|
|
304
|
+
*/
|
|
305
|
+
function startSync(endpoint: string): void {
|
|
306
|
+
syncInterval = setInterval(async () => {
|
|
307
|
+
if (pendingChanges.length === 0) return;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await fetch(endpoint, {
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: { "Content-Type": "application/json" },
|
|
313
|
+
body: JSON.stringify({ changes: pendingChanges }),
|
|
314
|
+
});
|
|
315
|
+
pendingChanges = [];
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.warn("[wafra/edge-db] sync failed:", err);
|
|
318
|
+
}
|
|
319
|
+
}, 5000);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Stop sync.
|
|
324
|
+
*/
|
|
325
|
+
export function stopSync(): void {
|
|
326
|
+
if (syncInterval) clearInterval(syncInterval);
|
|
327
|
+
syncInterval = null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ============ CACHE INVALIDATION ============
|
|
331
|
+
|
|
332
|
+
function invalidateCache(sql: string): void {
|
|
333
|
+
// Extract table name from SQL
|
|
334
|
+
const tableMatch = sql.match(/(?:INSERT INTO|UPDATE|DELETE FROM)\s+(\w+)/i);
|
|
335
|
+
if (tableMatch) {
|
|
336
|
+
const table = tableMatch[1].toLowerCase();
|
|
337
|
+
for (const key of queryCache.keys()) {
|
|
338
|
+
if (key.toLowerCase().includes(table)) {
|
|
339
|
+
queryCache.delete(key);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
// Invalidate everything
|
|
344
|
+
queryCache.clear();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function notifyListeners(sql: string): void {
|
|
349
|
+
const tableMatch = sql.match(/(?:INSERT INTO|UPDATE|DELETE FROM)\s+(\w+)/i);
|
|
350
|
+
if (tableMatch) {
|
|
351
|
+
const table = tableMatch[1].toLowerCase();
|
|
352
|
+
for (const [key, listeners] of queryListeners.entries()) {
|
|
353
|
+
if (key.toLowerCase().includes(table)) {
|
|
354
|
+
for (const listener of listeners) listener();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ============ INTERNAL EXECUTION ============
|
|
361
|
+
|
|
362
|
+
async function executeQuery<T>(sql: string, params: unknown[]): Promise<T[]> {
|
|
363
|
+
if (dbConnection.prepare) {
|
|
364
|
+
// Cloudflare D1 or better-sqlite3
|
|
365
|
+
const stmt = dbConnection.prepare(sql);
|
|
366
|
+
const result = await stmt.bind(...params).all();
|
|
367
|
+
return result.results || result;
|
|
368
|
+
} else if (dbConnection.execute) {
|
|
369
|
+
// libSQL
|
|
370
|
+
const result = await dbConnection.execute({ sql, args: params });
|
|
371
|
+
return result.rows;
|
|
372
|
+
}
|
|
373
|
+
return [];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function executeMutation<T>(sql: string, params: unknown[]): Promise<{
|
|
377
|
+
insertedId?: number | string;
|
|
378
|
+
affectedRows: number;
|
|
379
|
+
rows?: T[];
|
|
380
|
+
}> {
|
|
381
|
+
if (dbConnection.prepare) {
|
|
382
|
+
const stmt = dbConnection.prepare(sql);
|
|
383
|
+
const result = await stmt.bind(...params).run();
|
|
384
|
+
return {
|
|
385
|
+
insertedId: result.meta?.last_row_id,
|
|
386
|
+
affectedRows: result.meta?.changes || 0,
|
|
387
|
+
};
|
|
388
|
+
} else if (dbConnection.execute) {
|
|
389
|
+
const result = await dbConnection.execute({ sql, args: params });
|
|
390
|
+
return {
|
|
391
|
+
insertedId: result.lastInsertRowid,
|
|
392
|
+
affectedRows: result.rowsAffected,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return { affectedRows: 0 };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ============ HELPERS ============
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Build a WHERE clause from an object.
|
|
402
|
+
*
|
|
403
|
+
* buildWhere({ active: true, role: 'admin' })
|
|
404
|
+
* → { clause: 'WHERE active = ? AND role = ?', params: [true, 'admin'] }
|
|
405
|
+
*/
|
|
406
|
+
export function buildWhere(conditions: Record<string, unknown>): {
|
|
407
|
+
clause: string;
|
|
408
|
+
params: unknown[];
|
|
409
|
+
} {
|
|
410
|
+
const keys = Object.keys(conditions);
|
|
411
|
+
if (keys.length === 0) return { clause: "", params: [] };
|
|
412
|
+
const clause = "WHERE " + keys.map((k) => `${k} = ?`).join(" AND ");
|
|
413
|
+
return { clause, params: keys.map((k) => conditions[k]) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Build an INSERT statement from an object.
|
|
418
|
+
*/
|
|
419
|
+
export function buildInsert(table: string, data: Record<string, unknown>): {
|
|
420
|
+
sql: string;
|
|
421
|
+
params: unknown[];
|
|
422
|
+
} {
|
|
423
|
+
const keys = Object.keys(data);
|
|
424
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
425
|
+
const sql = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
426
|
+
return { sql, params: keys.map((k) => data[k]) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build an UPDATE statement from an object.
|
|
431
|
+
*/
|
|
432
|
+
export function buildUpdate(
|
|
433
|
+
table: string,
|
|
434
|
+
data: Record<string, unknown>,
|
|
435
|
+
where: Record<string, unknown>
|
|
436
|
+
): { sql: string; params: unknown[] } {
|
|
437
|
+
const setClause = Object.keys(data).map((k) => `${k} = ?`).join(", ");
|
|
438
|
+
const { clause, params: whereParams } = buildWhere(where);
|
|
439
|
+
const sql = `UPDATE ${table} SET ${setClause} ${clause}`;
|
|
440
|
+
return { sql, params: [...Object.values(data), ...whereParams] };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ============ STATS ============
|
|
444
|
+
|
|
445
|
+
export interface DBStats {
|
|
446
|
+
cacheSize: number;
|
|
447
|
+
queryCount: number;
|
|
448
|
+
pendingSyncChanges: number;
|
|
449
|
+
connected: boolean;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function getDBStats(): DBStats {
|
|
453
|
+
return {
|
|
454
|
+
cacheSize: queryCache.size,
|
|
455
|
+
queryCount: queryListeners.size,
|
|
456
|
+
pendingSyncChanges: pendingChanges.length,
|
|
457
|
+
connected: dbConnection !== null,
|
|
458
|
+
};
|
|
459
|
+
}
|