@ix-xs/node-comfort 1.0.57 → 1.1.1

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/src/SQLite.js ADDED
@@ -0,0 +1,449 @@
1
+ const fs = require("./FS");
2
+ const checker = require("./Checker");
3
+ const originalEmitWarning = process.emitWarning;
4
+
5
+ process.emitWarning = (warning, ...args) => {
6
+ if (
7
+ (warning &&
8
+ warning.name === "ExperimentalWarning" &&
9
+ warning.message.includes("SQLite")) ||
10
+ (typeof warning === "string" &&
11
+ warning.startsWith("SQLite is an experimental feature"))
12
+ ) {
13
+ return;
14
+ }
15
+ return originalEmitWarning.call(process, warning, ...args);
16
+ };
17
+
18
+ const { DatabaseSync } = require("node:sqlite");
19
+
20
+ /**
21
+ * Lightweight SQLite wrapper around the built-in `node:sqlite` module.
22
+ *
23
+ * Features:
24
+ * - Ensures the database file and its parent folder exist.
25
+ * - Enables WAL journaling and foreign keys by default.
26
+ * - Keeps an in-memory registry of table schemas ("models").
27
+ * - Provides simple helpers for table creation and CRUD-like operations.
28
+ *
29
+ * @example
30
+ * const { SQLite } = require("@ix-xs/node-comfort");
31
+ * const db = new SQLite(); // ./db.sqlite
32
+ *
33
+ * await db.createTable({
34
+ * name: "users",
35
+ * options: {
36
+ * id: { type: "INTEGER", primaryKey: true, autoincrement: true },
37
+ * name: { type: "TEXT", notNull: true },
38
+ * role: { type: "TEXT", values: ["admin", "user"], defaultValue: "user" },
39
+ * },
40
+ * });
41
+ *
42
+ * await db.set("users", { name: "John" });
43
+ * const user = await db.get("users", { name: "John" });
44
+ */
45
+ module.exports = class SQLite {
46
+ /** @type {DatabaseSync} */
47
+ #$;
48
+ /** @type {Map<string, { name: string, options: Record<string, any> }>} */
49
+ #models = new Map();
50
+
51
+ /**
52
+ * Creates a new SQLite instance.
53
+ *
54
+ * By default, the database file is created at `./db.sqlite` relative
55
+ * to the current working directory. You can pass a custom path if needed.
56
+ *
57
+ * @example
58
+ * // Default path: "./db.sqlite"
59
+ * const db = new SQLite();
60
+ *
61
+ * @example
62
+ * // Custom path in a subfolder:
63
+ * const db = new SQLite("data/myapp.sqlite");
64
+ *
65
+ * @param {string} [path] - Path to the SQLite database file.
66
+ */
67
+ constructor(path = fs.createPath("db.sqlite").replaceAll("\\", "/")) {
68
+ if (path.includes("/")) {
69
+ const parts = path.split("/");
70
+ const file = parts[parts.length - 1];
71
+ const folder = fs.createPath(path.slice(0, -file.length));
72
+ fs.createFolder(folder);
73
+ }
74
+
75
+ this.#$ = new DatabaseSync(path);
76
+ this.#exec("PRAGMA journal_mode = WAL");
77
+ this.#exec("PRAGMA foreign_keys = ON");
78
+ }
79
+
80
+ /**
81
+ * Executes a SQL statement with optional parameters using `run()`.
82
+ * @private
83
+ * @param {string} sql - SQL statement.
84
+ * @param {any[]|any} [params=[]] - Parameters to bind to the statement.
85
+ * @returns {{ changes: number, lastInsertRowid: bigint|number }} Run result metadata.
86
+ */
87
+ #exec(sql, params = []) {
88
+ const stmt = this.#$.prepare(sql);
89
+ const result = checker.isArray(params)
90
+ ? stmt.run(...params)
91
+ : stmt.run(params);
92
+
93
+ return {
94
+ changes: result.changes,
95
+ lastInsertRowid: result.lastInsertRowid,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Builds a CREATE TABLE statement from a model definition.
101
+ *
102
+ * Column names and table names are used as-is; you should avoid unsafe names.
103
+ *
104
+ * @private
105
+ * @param {string} tableName
106
+ * @param {Record<string, {
107
+ * type: "INTEGER"|"TEXT"|"REAL"|"BLOB"|"NUMERIC",
108
+ * primaryKey?: boolean,
109
+ * autoincrement?: boolean,
110
+ * notNull?: boolean,
111
+ * unique?: boolean,
112
+ * defaultValue?: any,
113
+ * values?: any[]
114
+ * }>} options
115
+ * @returns {string}
116
+ */
117
+ #buildCreateSQL(tableName, options) {
118
+ const columns = Object.entries(options)
119
+ .map(([col, def]) => {
120
+ let colDef = `${col} ${def.type}`;
121
+
122
+ if (def.primaryKey) {
123
+ colDef += " PRIMARY KEY";
124
+ if (def.autoincrement) colDef += " AUTOINCREMENT";
125
+ }
126
+ if (def.notNull) colDef += " NOT NULL";
127
+ if (def.unique) colDef += " UNIQUE";
128
+ if (def.defaultValue !== undefined) {
129
+ colDef += ` DEFAULT ${this.#toSQLValue(def.defaultValue)}`;
130
+ }
131
+ if (def.values?.length) {
132
+ colDef += ` CHECK(${col} IN (${def.values
133
+ .map((v) => this.#toSQLValue(v))
134
+ .join(", ")}))`;
135
+ }
136
+
137
+ return colDef;
138
+ })
139
+ .join(", ");
140
+
141
+ return `CREATE TABLE IF NOT EXISTS ${tableName} (${columns})`;
142
+ }
143
+
144
+ /**
145
+ * Registers a model in memory and ensures the table exists.
146
+ * @private
147
+ * @param {string} tableName
148
+ * @param {Record<string, any>} options
149
+ */
150
+ #defineModel(tableName, options) {
151
+ if (this.#models.has(tableName)) return;
152
+
153
+ const createSQL = this.#buildCreateSQL(tableName, options);
154
+ this.#exec(createSQL);
155
+ this.#models.set(tableName, { name: tableName, options });
156
+ }
157
+
158
+ /**
159
+ * Builds a WHERE clause and parameters from an object.
160
+ * Keys are used as column names; values are bound with `?` placeholders.
161
+ * @private
162
+ * @param {Record<string, any>} [where]
163
+ * @returns {{ where: string, params: any[] }}
164
+ */
165
+ #buildWhere(where = {}) {
166
+ if (!where || Object.keys(where).length === 0) {
167
+ return { where: "", params: [] };
168
+ }
169
+
170
+ const conditions = [];
171
+ const params = [];
172
+
173
+ for (const [key, value] of Object.entries(where)) {
174
+ conditions.push(`${key} = ?`);
175
+ params.push(value);
176
+ }
177
+
178
+ return {
179
+ where: `WHERE ${conditions.join(" AND ")}`,
180
+ params,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Returns a default ORDER BY clause for a table.
186
+ * Uses the first defined column in the model, or "id DESC" as fallback.
187
+ * @private
188
+ * @param {string} tableName
189
+ * @returns {string}
190
+ */
191
+ #getOrderBy(tableName) {
192
+ const model = this.#models.get(tableName);
193
+ if (!model) return "id DESC";
194
+ const firstCol = Object.keys(model.options)[0];
195
+ return `${firstCol} DESC`;
196
+ }
197
+
198
+ /**
199
+ * Converts a JS value to a SQL literal for DEFAULT / CHECK clauses.
200
+ * @private
201
+ * @param {any} value
202
+ * @returns {string}
203
+ */
204
+ #toSQLValue(value) {
205
+ if (value === null) return "NULL";
206
+ if (typeof value === "number") return String(value);
207
+ if (typeof value === "bigint") return String(value);
208
+ if (typeof value === "boolean") return value ? "1" : "0";
209
+ if (typeof value === "string") return `'${value.replace(/'/g, "''")}'`;
210
+
211
+ return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
212
+ }
213
+
214
+ /**
215
+ * Creates (or ensures) a table exists for a given model definition.
216
+ *
217
+ * @example
218
+ * await db.createTable({
219
+ * name: "sessions",
220
+ * options: {
221
+ * id: { type: "TEXT", primaryKey: true },
222
+ * data: { type: "TEXT" },
223
+ * },
224
+ * });
225
+ *
226
+ * @param {{ name: string, options: Record<string, {
227
+ * type: "INTEGER"|"TEXT"|"REAL"|"BLOB"|"NUMERIC",
228
+ * primaryKey?: boolean,
229
+ * autoincrement?: boolean,
230
+ * notNull?: boolean,
231
+ * unique?: boolean,
232
+ * defaultValue?: any,
233
+ * values?: any[]
234
+ * }> }} table
235
+ * @returns {Promise<{ ok: true } | { error: string }>}
236
+ */
237
+ async createTable(table) {
238
+ try {
239
+ this.#defineModel(table.name, table.options);
240
+ return { ok: true };
241
+ } catch (error) {
242
+ return { error: error.message };
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Drops a table and removes its model definition.
248
+ *
249
+ * @param {string} tableName
250
+ * @returns {Promise<{ ok: true } | { error: string }>}
251
+ */
252
+ async deleteTable(tableName) {
253
+ try {
254
+ if (!this.#models.has(tableName)) {
255
+ return { error: `Table ${tableName} doesn't exist` };
256
+ }
257
+
258
+ this.#exec(`DROP TABLE IF EXISTS ${tableName}`);
259
+ this.#models.delete(tableName);
260
+ return { ok: true };
261
+ } catch (error) {
262
+ return { error: error.message };
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Deletes all rows from a table.
268
+ *
269
+ * @param {string} tableName
270
+ * @returns {Promise<{ ok: true } | { error: string }>}
271
+ */
272
+ async clearTable(tableName) {
273
+ try {
274
+ if (!this.#models.has(tableName)) {
275
+ return { error: `Table ${tableName} doesn't exist` };
276
+ }
277
+
278
+ this.#exec(`DELETE FROM ${tableName}`);
279
+ return { ok: true };
280
+ } catch (error) {
281
+ return { error: error.message };
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Gets a single row matching a WHERE clause.
287
+ *
288
+ * Returns:
289
+ * - `undefined` if no row matches,
290
+ * - the row object if found,
291
+ * - `{ error }` if a database error occurred.
292
+ *
293
+ * @param {string} tableName
294
+ * @param {Record<string, any>} where
295
+ * @returns {Promise<object|undefined|{ error: string }>}
296
+ */
297
+ async get(tableName, where) {
298
+ try {
299
+ if (!this.#models.has(tableName)) return undefined;
300
+
301
+ const whereClause = this.#buildWhere(where);
302
+ const sql = `SELECT * FROM ${tableName} ${whereClause.where} LIMIT 1`;
303
+ const stmt = this.#$.prepare(sql);
304
+ const record = stmt.get(...whereClause.params);
305
+
306
+ return record || undefined;
307
+ } catch (error) {
308
+ return { error: error.message };
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Gets multiple rows matching a WHERE clause.
314
+ *
315
+ * Returns:
316
+ * - an empty array if the table is not defined,
317
+ * - an array of row objects,
318
+ * - `{ error }` if a database error occurred.
319
+ *
320
+ * @param {string} tableName
321
+ * @param {Record<string, any>} [where={}]
322
+ * @param {number} [limit=50]
323
+ * @param {number} [offset=0]
324
+ * @returns {Promise<object[] | { error: string }>}
325
+ */
326
+ async getAll(tableName, where = {}, limit = 50, offset = 0) {
327
+ try {
328
+ if (!this.#models.has(tableName)) return [];
329
+
330
+ const whereClause = this.#buildWhere(where);
331
+ const sql = `SELECT * FROM ${tableName} ${whereClause.where} ORDER BY ${this.#getOrderBy(
332
+ tableName,
333
+ )} LIMIT ? OFFSET ?`;
334
+ const stmt = this.#$.prepare(sql);
335
+ const records = stmt.all(...whereClause.params, limit, offset);
336
+ return records;
337
+ } catch (error) {
338
+ return { error: error.message };
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Inserts or replaces a row in a table.
344
+ *
345
+ * If `where` is provided (or `data.id` exists), it will first load the existing row and
346
+ * merge it with `data`, then perform an `INSERT OR REPLACE`. This lets you do partial updates.
347
+ *
348
+ * @example
349
+ * await db.set("users", { id: 1, name: "Jane" });
350
+ *
351
+ * @param {string} tableName
352
+ * @param {Record<string, any>} data
353
+ * @param {Record<string, any>|null} [where=null]
354
+ * @returns {Promise<{ ok: true } | { error: string }>}
355
+ */
356
+ async set(tableName, data, where = null) {
357
+ try {
358
+ if (!this.#models.has(tableName)) {
359
+ return { error: `Table ${tableName} doesn't exist` };
360
+ }
361
+
362
+ const lookup = where ?? (data.id ? { id: data.id } : null);
363
+ let finalData = data;
364
+
365
+ if (lookup) {
366
+ const existing = await this.get(tableName, lookup);
367
+
368
+ if (existing && !existing.error) {
369
+ finalData = { ...existing, ...data };
370
+ }
371
+ }
372
+
373
+ const columns = Object.keys(finalData);
374
+ const placeholders = columns.map(() => "?").join(", ");
375
+ const sql = `INSERT OR REPLACE INTO ${tableName} (${columns.join(
376
+ ", ",
377
+ )}) VALUES (${placeholders})`;
378
+
379
+ const stmt = this.#$.prepare(sql);
380
+ stmt.run(...Object.values(finalData));
381
+
382
+ return { ok: true };
383
+ } catch (error) {
384
+ return { error: error.message };
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Deletes rows matching a WHERE clause.
390
+ *
391
+ * @param {string} tableName
392
+ * @param {Record<string, any>} where
393
+ * @returns {Promise<{ ok: true, changes: number } | { error: string }>}
394
+ */
395
+ async delete(tableName, where) {
396
+ try {
397
+ if (!this.#models.has(tableName)) {
398
+ return { error: `Table ${tableName} doesn't exist` };
399
+ }
400
+
401
+ const whereClause = this.#buildWhere(where);
402
+ const sql = `DELETE FROM ${tableName} ${whereClause.where}`;
403
+ const stmt = this.#$.prepare(sql);
404
+ const result = stmt.run(...whereClause.params);
405
+ return { ok: true, changes: result.changes };
406
+ } catch (error) {
407
+ return { error: error.message };
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Counts rows matching a WHERE clause.
413
+ *
414
+ * Returns:
415
+ * - the numeric count,
416
+ * - or `{ error }` if a database error occurred.
417
+ *
418
+ * @param {string} tableName
419
+ * @param {Record<string, any>} [where={}]
420
+ * @returns {Promise<number | { error: string }>}
421
+ */
422
+ async count(tableName, where = {}) {
423
+ try {
424
+ if (!this.#models.has(tableName)) {
425
+ return { error: `Table ${tableName} doesn't exist` };
426
+ }
427
+
428
+ const whereClause = this.#buildWhere(where);
429
+ const sql = `SELECT COUNT(*) as total FROM ${tableName} ${whereClause.where}`;
430
+ const stmt = this.#$.prepare(sql);
431
+ const result = stmt.get(...whereClause.params);
432
+ return result.total;
433
+ } catch (error) {
434
+ return { error: error.message };
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Closes the underlying SQLite connection (if the runtime supports it).
440
+ *
441
+ * @example
442
+ * db.close();
443
+ */
444
+ close() {
445
+ if (this.#$ && typeof this.#$.close === "function") {
446
+ this.#$.close();
447
+ }
448
+ }
449
+ };