@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/README.md +533 -0
- package/index.js +10 -12
- package/package.json +16 -6
- package/src/Checker.js +188 -0
- package/src/FS.js +768 -0
- package/src/Logger.js +307 -0
- package/src/SQLite.js +449 -0
- package/src/Utils.js +325 -0
- package/src/checker.js +0 -76
- package/src/cli.js +0 -86
- package/src/fs.js +0 -656
- package/src/localStorage.js +0 -293
- package/src/logger.js +0 -243
- package/src/utils.js +0 -225
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
|
+
};
|