@ix-xs/node-comfort 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +265 -50
  2. package/package.json +2 -2
  3. package/src/SQLite.js +719 -174
package/src/SQLite.js CHANGED
@@ -12,57 +12,78 @@ process.emitWarning = (warning, ...args) => {
12
12
  ) {
13
13
  return;
14
14
  }
15
+
15
16
  return originalEmitWarning.call(process, warning, ...args);
16
17
  };
17
18
 
18
19
  const { DatabaseSync } = require("node:sqlite");
19
20
 
20
21
  /**
21
- * Lightweight SQLite wrapper around the built-in `node:sqlite` module.
22
+ * A lightweight SQLite wrapper around Node.js built-in `node:sqlite` module.
23
+ *
24
+ * Goals:
25
+ * - Keep the API small and easy to learn.
26
+ * - Support common CRUD workflows.
27
+ * - Expose raw SQL helpers when advanced control is needed.
28
+ * - Support indexes, foreign keys, composite unique constraints, and transactions.
22
29
  *
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.
30
+ * Notes:
31
+ * - Values are safely bound with `?` placeholders.
32
+ * - Table names, column names, and index names cannot be parameterized in SQLite,
33
+ * so this class validates identifiers before interpolating them into SQL.
34
+ * - WAL mode and foreign keys are enabled automatically.
28
35
  *
29
36
  * @example
30
37
  * const { SQLite } = require("@ix-xs/node-comfort");
31
- * const db = new SQLite(); // ./db.sqlite
38
+ * const db = new SQLite("db/app.sqlite");
32
39
  *
33
40
  * 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" },
41
+ * name: "User",
42
+ * columns: {
43
+ * id: { type: "INTEGER", primaryKey: true, autoincrement: true },
44
+ * email: { type: "TEXT", unique: true },
45
+ * createdAt: { type: "INTEGER", notNull: true }
39
46
  * },
47
+ * indexes: [
48
+ * { columns: ["email"], unique: true }
49
+ * ]
40
50
  * });
41
51
  *
42
- * await db.set("users", { name: "John" });
43
- * const user = await db.get("users", { name: "John" });
52
+ * await db.insert("User", {
53
+ * email: "john@example.com",
54
+ * createdAt: Date.now()
55
+ * });
56
+ *
57
+ * const user = await db.get("User", { email: "john@example.com" });
44
58
  */
45
59
  module.exports = class SQLite {
46
60
  /** @type {DatabaseSync} */
47
61
  #$;
48
- /** @type {Map<string, { name: string, options: Record<string, any> }>} */
62
+
63
+ /**
64
+ * In-memory schema registry.
65
+ * @type {Map<string, {
66
+ * name: string,
67
+ * columns: Record<string, any>,
68
+ * constraints?: any[],
69
+ * indexes?: any[]
70
+ * }>}
71
+ */
49
72
  #models = new Map();
50
73
 
51
74
  /**
52
- * Creates a new SQLite instance.
75
+ * Creates a new SQLite database connection.
53
76
  *
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.
77
+ * The database file and its parent folders are created if necessary.
78
+ * WAL journal mode and foreign key support are enabled automatically.
79
+ *
80
+ * @param {string} [path] - Path to the SQLite database file.
56
81
  *
57
82
  * @example
58
- * // Default path: "./db.sqlite"
59
83
  * const db = new SQLite();
60
84
  *
61
85
  * @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.
86
+ * const db = new SQLite("data/my-app.sqlite");
66
87
  */
67
88
  constructor(path = fs.createPath("db.sqlite").replaceAll("\\", "/")) {
68
89
  if (path.includes("/")) {
@@ -73,93 +94,89 @@ module.exports = class SQLite {
73
94
  }
74
95
 
75
96
  this.#$ = new DatabaseSync(path);
76
- this.#exec("PRAGMA journal_mode = WAL");
77
- this.#exec("PRAGMA foreign_keys = ON");
97
+ this.#run("PRAGMA journal_mode = WAL");
98
+ this.#run("PRAGMA foreign_keys = ON");
78
99
  }
79
100
 
80
101
  /**
81
- * Executes a SQL statement with optional parameters using `run()`.
102
+ * Ensures a SQL identifier is safe to interpolate.
103
+ *
104
+ * SQLite does not allow binding table or column names as parameters,
105
+ * so identifiers must be validated before being used in SQL strings.
106
+ *
107
+ * Allowed characters:
108
+ * - letters
109
+ * - digits
110
+ * - underscore
111
+ *
112
+ * The first character must be a letter or underscore.
113
+ *
82
114
  * @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.
115
+ * @param {string} name - Identifier to validate.
116
+ * @param {string} [kind="identifier"] - Human-readable identifier type for error messages.
117
+ * @returns {string}
118
+ * @throws {Error} If the identifier is invalid.
86
119
  */
87
- #exec(sql, params = []) {
88
- const stmt = this.#$.prepare(sql);
89
- const result = checker.isArray(params)
90
- ? stmt.run(...params)
91
- : stmt.run(params);
120
+ #assertIdentifier(name, kind = "identifier") {
121
+ if (typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
122
+ throw new Error(`Invalid ${kind}: "${name}"`);
123
+ }
92
124
 
93
- return {
94
- changes: result.changes,
95
- lastInsertRowid: result.lastInsertRowid,
96
- };
125
+ return name;
97
126
  }
98
127
 
99
128
  /**
100
- * Builds a CREATE TABLE statement from a model definition.
129
+ * Converts a JavaScript value into a SQL literal.
101
130
  *
102
- * Column names and table names are used as-is; you should avoid unsafe names.
131
+ * This is only used for SQL fragments that cannot be parameter-bound,
132
+ * such as `DEFAULT`, `CHECK`, or migration-time SQL generation.
103
133
  *
104
134
  * @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
135
+ * @param {any} value - Value to convert.
115
136
  * @returns {string}
116
137
  */
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})`;
138
+ #toSQLValue(value) {
139
+ if (value === null) return "NULL";
140
+ if (typeof value === "number") return String(value);
141
+ if (typeof value === "bigint") return String(value);
142
+ if (typeof value === "boolean") return value ? "1" : "0";
143
+ if (typeof value === "string") return `'${value.replace(/'/g, "''")}'`;
144
+
145
+ return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
142
146
  }
143
147
 
144
148
  /**
145
- * Registers a model in memory and ensures the table exists.
149
+ * Executes a SQL statement that modifies the database.
150
+ *
151
+ * This is the internal low-level helper used by mutation methods.
152
+ *
146
153
  * @private
147
- * @param {string} tableName
148
- * @param {Record<string, any>} options
154
+ * @param {string} sql - SQL statement.
155
+ * @param {any[]|any} [params=[]] - Parameters to bind.
156
+ * @returns {{ changes: number, lastInsertRowid: bigint|number }}
149
157
  */
150
- #defineModel(tableName, options) {
151
- if (this.#models.has(tableName)) return;
158
+ #run(sql, params = []) {
159
+ const stmt = this.#$.prepare(sql);
160
+ const result = checker.isArray(params) ? stmt.run(...params) : stmt.run(params);
152
161
 
153
- const createSQL = this.#buildCreateSQL(tableName, options);
154
- this.#exec(createSQL);
155
- this.#models.set(tableName, { name: tableName, options });
162
+ return {
163
+ changes: result.changes,
164
+ lastInsertRowid: result.lastInsertRowid,
165
+ };
156
166
  }
157
167
 
158
168
  /**
159
- * Builds a WHERE clause and parameters from an object.
160
- * Keys are used as column names; values are bound with `?` placeholders.
169
+ * Builds a `WHERE` clause from a plain object.
170
+ *
171
+ * Example:
172
+ * `{ id: 1, role: "admin" }`
173
+ * becomes:
174
+ * `WHERE id = ? AND role = ?`
175
+ *
176
+ * If the object is empty, an empty WHERE clause is returned.
177
+ *
161
178
  * @private
162
- * @param {Record<string, any>} [where]
179
+ * @param {Record<string, any>} [where={}] - Conditions.
163
180
  * @returns {{ where: string, params: any[] }}
164
181
  */
165
182
  #buildWhere(where = {}) {
@@ -171,6 +188,13 @@ module.exports = class SQLite {
171
188
  const params = [];
172
189
 
173
190
  for (const [key, value] of Object.entries(where)) {
191
+ this.#assertIdentifier(key, "column name");
192
+
193
+ if (value === null) {
194
+ conditions.push(`${key} IS NULL`);
195
+ continue;
196
+ }
197
+
174
198
  conditions.push(`${key} = ?`);
175
199
  params.push(value);
176
200
  }
@@ -182,61 +206,388 @@ module.exports = class SQLite {
182
206
  }
183
207
 
184
208
  /**
185
- * Returns a default ORDER BY clause for a table.
186
- * Uses the first defined column in the model, or "id DESC" as fallback.
209
+ * Builds the SQL fragment for a column definition.
210
+ *
211
+ * Supported column properties:
212
+ * - type
213
+ * - primaryKey
214
+ * - autoincrement
215
+ * - notNull
216
+ * - unique
217
+ * - defaultValue
218
+ * - values
219
+ * - references
220
+ * - onDelete
221
+ * - onUpdate
222
+ *
223
+ * @private
224
+ * @param {string} columnName - Column name.
225
+ * @param {Record<string, any>} def - Column definition.
226
+ * @returns {string}
227
+ */
228
+ #buildColumnSQL(columnName, def) {
229
+ this.#assertIdentifier(columnName, "column name");
230
+
231
+ if (!def?.type) {
232
+ throw new Error(`Missing "type" for column "${columnName}"`);
233
+ }
234
+
235
+ let colDef = `${columnName} ${def.type}`;
236
+
237
+ if (def.primaryKey) {
238
+ colDef += " PRIMARY KEY";
239
+ if (def.autoincrement) colDef += " AUTOINCREMENT";
240
+ }
241
+
242
+ if (def.notNull) colDef += " NOT NULL";
243
+ if (def.unique) colDef += " UNIQUE";
244
+
245
+ if (def.defaultValue !== undefined) {
246
+ colDef += ` DEFAULT ${this.#toSQLValue(def.defaultValue)}`;
247
+ }
248
+
249
+ if (def.values?.length) {
250
+ colDef += ` CHECK(${columnName} IN (${def.values
251
+ .map((value) => this.#toSQLValue(value))
252
+ .join(", ")}))`;
253
+ }
254
+
255
+ if (def.references) {
256
+ this.#assertIdentifier(def.references.table, "referenced table");
257
+ this.#assertIdentifier(def.references.column, "referenced column");
258
+
259
+ colDef += ` REFERENCES ${def.references.table}(${def.references.column})`;
260
+
261
+ if (def.onDelete) colDef += ` ON DELETE ${def.onDelete}`;
262
+ if (def.onUpdate) colDef += ` ON UPDATE ${def.onUpdate}`;
263
+ }
264
+
265
+ return colDef;
266
+ }
267
+
268
+ /**
269
+ * Builds a table-level constraint SQL fragment.
270
+ *
271
+ * Supported constraint formats:
272
+ * - `{ type: "unique", columns: ["email", "provider"] }`
273
+ * - `{ type: "primaryKey", columns: ["a", "b"] }`
274
+ * - `{ type: "check", expression: "age >= 0" }`
275
+ * - `{ type: "foreignKey", columns: ["userId"], references: { table: "User", columns: ["id"] }, onDelete: "CASCADE" }`
276
+ *
277
+ * @private
278
+ * @param {Record<string, any>} constraint - Constraint definition.
279
+ * @returns {string}
280
+ */
281
+ #buildConstraintSQL(constraint) {
282
+ if (!constraint?.type) {
283
+ throw new Error("Table constraint requires a \"type\" property");
284
+ }
285
+
286
+ if (constraint.type === "unique") {
287
+ const columns = (constraint.columns || []).map((col) =>
288
+ this.#assertIdentifier(col, "column name"),
289
+ );
290
+ if (!columns.length) throw new Error("UNIQUE constraint requires columns");
291
+ return `UNIQUE (${columns.join(", ")})`;
292
+ }
293
+
294
+ if (constraint.type === "primaryKey") {
295
+ const columns = (constraint.columns || []).map((col) =>
296
+ this.#assertIdentifier(col, "column name"),
297
+ );
298
+ if (!columns.length) throw new Error("PRIMARY KEY constraint requires columns");
299
+ return `PRIMARY KEY (${columns.join(", ")})`;
300
+ }
301
+
302
+ if (constraint.type === "check") {
303
+ if (!constraint.expression || typeof constraint.expression !== "string") {
304
+ throw new Error("CHECK constraint requires an expression");
305
+ }
306
+ return `CHECK (${constraint.expression})`;
307
+ }
308
+
309
+ if (constraint.type === "foreignKey") {
310
+ const columns = (constraint.columns || []).map((col) =>
311
+ this.#assertIdentifier(col, "column name"),
312
+ );
313
+ const refColumns = (constraint.references?.columns || []).map((col) =>
314
+ this.#assertIdentifier(col, "referenced column"),
315
+ );
316
+
317
+ if (!columns.length) throw new Error("FOREIGN KEY constraint requires columns");
318
+ if (!constraint.references?.table) {
319
+ throw new Error("FOREIGN KEY constraint requires references.table");
320
+ }
321
+ if (!refColumns.length) {
322
+ throw new Error("FOREIGN KEY constraint requires references.columns");
323
+ }
324
+
325
+ this.#assertIdentifier(constraint.references.table, "referenced table");
326
+
327
+ let sql = `FOREIGN KEY (${columns.join(", ")}) REFERENCES ${constraint.references.table} (${refColumns.join(", ")})`;
328
+
329
+ if (constraint.onDelete) sql += ` ON DELETE ${constraint.onDelete}`;
330
+ if (constraint.onUpdate) sql += ` ON UPDATE ${constraint.onUpdate}`;
331
+
332
+ return sql;
333
+ }
334
+
335
+ throw new Error(`Unsupported constraint type "${constraint.type}"`);
336
+ }
337
+
338
+ /**
339
+ * Builds a complete `CREATE TABLE` statement.
340
+ *
341
+ * @private
342
+ * @param {string} tableName - Table name.
343
+ * @param {Record<string, any>} columns - Column definitions.
344
+ * @param {Record<string, any>[]} [constraints=[]] - Table-level constraints.
345
+ * @returns {string}
346
+ */
347
+ #buildCreateSQL(tableName, columns, constraints = []) {
348
+ this.#assertIdentifier(tableName, "table name");
349
+
350
+ const columnDefs = Object.entries(columns).map(([columnName, def]) =>
351
+ this.#buildColumnSQL(columnName, def),
352
+ );
353
+
354
+ const tableConstraints = constraints.map((constraint) =>
355
+ this.#buildConstraintSQL(constraint),
356
+ );
357
+
358
+ return `CREATE TABLE IF NOT EXISTS ${tableName} (${[
359
+ ...columnDefs,
360
+ ...tableConstraints,
361
+ ].join(", ")})`;
362
+ }
363
+
364
+ /**
365
+ * Returns the default `ORDER BY` clause for a table.
366
+ *
367
+ * The first declared column is used as default. If the model is unknown,
368
+ * `"id DESC"` is used as a fallback.
369
+ *
187
370
  * @private
188
- * @param {string} tableName
371
+ * @param {string} tableName - Table name.
189
372
  * @returns {string}
190
373
  */
191
374
  #getOrderBy(tableName) {
192
375
  const model = this.#models.get(tableName);
193
376
  if (!model) return "id DESC";
194
- const firstCol = Object.keys(model.options)[0];
377
+
378
+ const firstCol = Object.keys(model.columns)[0];
195
379
  return `${firstCol} DESC`;
196
380
  }
197
381
 
198
382
  /**
199
- * Converts a JS value to a SQL literal for DEFAULT / CHECK clauses.
383
+ * Registers a table model and creates the table if necessary.
384
+ *
385
+ * If the model already exists in memory, nothing is done.
386
+ *
200
387
  * @private
201
- * @param {any} value
202
- * @returns {string}
388
+ * @param {{
389
+ * name: string,
390
+ * columns: Record<string, any>,
391
+ * constraints?: Record<string, any>[],
392
+ * indexes?: Record<string, any>[]
393
+ * }} table - Table definition.
203
394
  */
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, "''")}'`;
395
+ #defineModel(table) {
396
+ if (this.#models.has(table.name)) return;
397
+
398
+ const createSQL = this.#buildCreateSQL(
399
+ table.name,
400
+ table.columns,
401
+ table.constraints || [],
402
+ );
403
+
404
+ this.#run(createSQL);
405
+ this.#models.set(table.name, {
406
+ name: table.name,
407
+ columns: table.columns,
408
+ constraints: table.constraints || [],
409
+ indexes: table.indexes || [],
410
+ });
411
+
412
+ for (const index of table.indexes || []) {
413
+ this.createIndex(table.name, index);
414
+ }
415
+ }
210
416
 
211
- return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
417
+ /**
418
+ * Executes a raw SQL statement using `run()`.
419
+ *
420
+ * Use this for custom SQL that is not covered by the helper methods.
421
+ * This method is intended for statements such as:
422
+ * - `INSERT`
423
+ * - `UPDATE`
424
+ * - `DELETE`
425
+ * - `CREATE INDEX`
426
+ * - PRAGMA writes
427
+ *
428
+ * @param {string} sql - SQL statement.
429
+ * @param {any[]|any} [params=[]] - Parameters to bind.
430
+ * @returns {Promise<{ changes: number, lastInsertRowid: bigint|number } | { error: string }>}
431
+ */
432
+ async exec(sql, params = []) {
433
+ try {
434
+ return this.#run(sql, params);
435
+ } catch (error) {
436
+ return { error: error.message };
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Executes a raw SQL query and returns the first row.
442
+ *
443
+ * @param {string} sql - SQL query.
444
+ * @param {any[]|any} [params=[]] - Parameters to bind.
445
+ * @returns {Promise<object|undefined|{ error: string }>}
446
+ */
447
+ async queryOne(sql, params = []) {
448
+ try {
449
+ const stmt = this.#$.prepare(sql);
450
+ const row = checker.isArray(params) ? stmt.get(...params) : stmt.get(params);
451
+ return row || undefined;
452
+ } catch (error) {
453
+ return { error: error.message };
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Executes a raw SQL query and returns all rows.
459
+ *
460
+ * @param {string} sql - SQL query.
461
+ * @param {any[]|any} [params=[]] - Parameters to bind.
462
+ * @returns {Promise<object[]|{ error: string }>}
463
+ */
464
+ async queryAll(sql, params = []) {
465
+ try {
466
+ const stmt = this.#$.prepare(sql);
467
+ return checker.isArray(params) ? stmt.all(...params) : stmt.all(params);
468
+ } catch (error) {
469
+ return { error: error.message };
470
+ }
212
471
  }
213
472
 
214
473
  /**
215
- * Creates (or ensures) a table exists for a given model definition.
474
+ * Creates a table and stores its schema in memory.
475
+ *
476
+ * This method supports both the new `columns` property and the legacy
477
+ * `options` property for backward compatibility.
478
+ *
479
+ * @param {{
480
+ * name: string,
481
+ * columns?: Record<string, {
482
+ * type: "INTEGER"|"TEXT"|"REAL"|"BLOB"|"NUMERIC",
483
+ * primaryKey?: boolean,
484
+ * autoincrement?: boolean,
485
+ * notNull?: boolean,
486
+ * unique?: boolean,
487
+ * defaultValue?: any,
488
+ * values?: any[],
489
+ * references?: { table: string, column: string },
490
+ * onDelete?: string,
491
+ * onUpdate?: string
492
+ * }>,
493
+ * options?: Record<string, any>,
494
+ * constraints?: Array<
495
+ * | { type: "unique", columns: string[] }
496
+ * | { type: "primaryKey", columns: string[] }
497
+ * | { type: "check", expression: string }
498
+ * | {
499
+ * type: "foreignKey",
500
+ * columns: string[],
501
+ * references: { table: string, columns: string[] },
502
+ * onDelete?: string,
503
+ * onUpdate?: string
504
+ * }
505
+ * >,
506
+ * indexes?: Array<{
507
+ * name?: string,
508
+ * columns: string[],
509
+ * unique?: boolean
510
+ * }>
511
+ * }} table - Table definition.
512
+ * @returns {Promise<{ ok: true } | { error: string }>}
216
513
  *
217
514
  * @example
218
515
  * await db.createTable({
219
- * name: "sessions",
220
- * options: {
221
- * id: { type: "TEXT", primaryKey: true },
222
- * data: { type: "TEXT" },
516
+ * name: "Post",
517
+ * columns: {
518
+ * id: { type: "INTEGER", primaryKey: true, autoincrement: true },
519
+ * userId: {
520
+ * type: "INTEGER",
521
+ * notNull: true,
522
+ * references: { table: "User", column: "id" },
523
+ * onDelete: "CASCADE"
524
+ * },
525
+ * title: { type: "TEXT", notNull: true }
223
526
  * },
527
+ * indexes: [{ columns: ["userId"] }]
224
528
  * });
529
+ */
530
+ async createTable(table) {
531
+ try {
532
+ const columns = table.columns || table.options;
533
+
534
+ if (!columns) {
535
+ return { error: "Missing \"columns\" (or legacy \"options\") in table definition" };
536
+ }
537
+
538
+ this.#defineModel({
539
+ name: table.name,
540
+ columns,
541
+ constraints: table.constraints || [],
542
+ indexes: table.indexes || [],
543
+ });
544
+
545
+ return { ok: true };
546
+ } catch (error) {
547
+ return { error: error.message };
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Creates an index on an existing table.
225
553
  *
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
554
+ * Composite indexes are supported by passing multiple columns.
555
+ *
556
+ * @param {string} tableName - Table name.
557
+ * @param {{
558
+ * name?: string,
559
+ * columns: string[],
560
+ * unique?: boolean
561
+ * }} index - Index definition.
235
562
  * @returns {Promise<{ ok: true } | { error: string }>}
563
+ *
564
+ * @example
565
+ * await db.createIndex("UserAuthMethod", {
566
+ * columns: ["provider", "providerUserId"],
567
+ * unique: true
568
+ * });
236
569
  */
237
- async createTable(table) {
570
+ async createIndex(tableName, index) {
238
571
  try {
239
- this.#defineModel(table.name, table.options);
572
+ this.#assertIdentifier(tableName, "table name");
573
+
574
+ const columns = (index.columns || []).map((column) =>
575
+ this.#assertIdentifier(column, "column name"),
576
+ );
577
+
578
+ if (!columns.length) {
579
+ return { error: "Index requires a non-empty \"columns\" array" };
580
+ }
581
+
582
+ const indexName =
583
+ index.name ||
584
+ `idx_${tableName}_${columns.join("_")}${index.unique ? "_unique" : ""}`;
585
+
586
+ this.#assertIdentifier(indexName, "index name");
587
+
588
+ const sql = `CREATE ${index.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columns.join(", ")})`;
589
+ this.#run(sql);
590
+
240
591
  return { ok: true };
241
592
  } catch (error) {
242
593
  return { error: error.message };
@@ -246,7 +597,7 @@ module.exports = class SQLite {
246
597
  /**
247
598
  * Drops a table and removes its model definition.
248
599
  *
249
- * @param {string} tableName
600
+ * @param {string} tableName - Table name.
250
601
  * @returns {Promise<{ ok: true } | { error: string }>}
251
602
  */
252
603
  async deleteTable(tableName) {
@@ -255,8 +606,10 @@ module.exports = class SQLite {
255
606
  return { error: `Table ${tableName} doesn't exist` };
256
607
  }
257
608
 
258
- this.#exec(`DROP TABLE IF EXISTS ${tableName}`);
609
+ this.#assertIdentifier(tableName, "table name");
610
+ this.#run(`DROP TABLE IF EXISTS ${tableName}`);
259
611
  this.#models.delete(tableName);
612
+
260
613
  return { ok: true };
261
614
  } catch (error) {
262
615
  return { error: error.message };
@@ -266,7 +619,7 @@ module.exports = class SQLite {
266
619
  /**
267
620
  * Deletes all rows from a table.
268
621
  *
269
- * @param {string} tableName
622
+ * @param {string} tableName - Table name.
270
623
  * @returns {Promise<{ ok: true } | { error: string }>}
271
624
  */
272
625
  async clearTable(tableName) {
@@ -275,7 +628,9 @@ module.exports = class SQLite {
275
628
  return { error: `Table ${tableName} doesn't exist` };
276
629
  }
277
630
 
278
- this.#exec(`DELETE FROM ${tableName}`);
631
+ this.#assertIdentifier(tableName, "table name");
632
+ this.#run(`DELETE FROM ${tableName}`);
633
+
279
634
  return { ok: true };
280
635
  } catch (error) {
281
636
  return { error: error.message };
@@ -283,21 +638,18 @@ module.exports = class SQLite {
283
638
  }
284
639
 
285
640
  /**
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.
641
+ * Returns a single row matching a plain-object WHERE clause.
292
642
  *
293
- * @param {string} tableName
294
- * @param {Record<string, any>} where
643
+ * @param {string} tableName - Table name.
644
+ * @param {Record<string, any>} [where={}] - Conditions.
295
645
  * @returns {Promise<object|undefined|{ error: string }>}
296
646
  */
297
- async get(tableName, where) {
647
+ async get(tableName, where = {}) {
298
648
  try {
299
649
  if (!this.#models.has(tableName)) return undefined;
300
650
 
651
+ this.#assertIdentifier(tableName, "table name");
652
+
301
653
  const whereClause = this.#buildWhere(where);
302
654
  const sql = `SELECT * FROM ${tableName} ${whereClause.where} LIMIT 1`;
303
655
  const stmt = this.#$.prepare(sql);
@@ -310,29 +662,50 @@ module.exports = class SQLite {
310
662
  }
311
663
 
312
664
  /**
313
- * Gets multiple rows matching a WHERE clause.
665
+ * Returns multiple rows from a table.
314
666
  *
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.
667
+ * Supports:
668
+ * - exact-match filters via `where`
669
+ * - custom ordering
670
+ * - pagination via `limit` and `offset`
319
671
  *
320
- * @param {string} tableName
321
- * @param {Record<string, any>} [where={}]
322
- * @param {number} [limit=50]
323
- * @param {number} [offset=0]
672
+ * @param {string} tableName - Table name.
673
+ * @param {Record<string, any>} [where={}] - Exact-match conditions.
674
+ * @param {{
675
+ * limit?: number,
676
+ * offset?: number,
677
+ * orderBy?: string,
678
+ * direction?: "ASC"|"DESC"
679
+ * }} [options={}] - Query options.
324
680
  * @returns {Promise<object[] | { error: string }>}
681
+ *
682
+ * @example
683
+ * const posts = await db.getAll("Post", { userId: 1 }, {
684
+ * limit: 20,
685
+ * offset: 0,
686
+ * orderBy: "createdAt",
687
+ * direction: "DESC"
688
+ * });
325
689
  */
326
- async getAll(tableName, where = {}, limit = 50, offset = 0) {
690
+ async getAll(tableName, where = {}, options = {}) {
327
691
  try {
328
692
  if (!this.#models.has(tableName)) return [];
329
693
 
694
+ this.#assertIdentifier(tableName, "table name");
695
+
330
696
  const whereClause = this.#buildWhere(where);
331
- const sql = `SELECT * FROM ${tableName} ${whereClause.where} ORDER BY ${this.#getOrderBy(
332
- tableName,
333
- )} LIMIT ? OFFSET ?`;
697
+ const limit = Number.isInteger(options.limit) ? options.limit : 50;
698
+ const offset = Number.isInteger(options.offset) ? options.offset : 0;
699
+ const direction = options.direction === "ASC" ? "ASC" : "DESC";
700
+
701
+ const orderBy = options.orderBy
702
+ ? this.#assertIdentifier(options.orderBy, "column name")
703
+ : this.#getOrderBy(tableName).split(" ")[0];
704
+
705
+ const sql = `SELECT * FROM ${tableName} ${whereClause.where} ORDER BY ${orderBy} ${direction} LIMIT ? OFFSET ?`;
334
706
  const stmt = this.#$.prepare(sql);
335
707
  const records = stmt.all(...whereClause.params, limit, offset);
708
+
336
709
  return records;
337
710
  } catch (error) {
338
711
  return { error: error.message };
@@ -340,17 +713,147 @@ module.exports = class SQLite {
340
713
  }
341
714
 
342
715
  /**
343
- * Inserts or replaces a row in a table.
716
+ * Inserts a new row into a table.
717
+ *
718
+ * This method performs a plain `INSERT`.
719
+ * If a unique constraint is violated, SQLite will throw an error.
720
+ *
721
+ * @param {string} tableName - Table name.
722
+ * @param {Record<string, any>} data - Row to insert.
723
+ * @returns {Promise<{ ok: true, lastInsertRowid: bigint|number, changes: number } | { error: string }>}
724
+ */
725
+ async insert(tableName, data) {
726
+ try {
727
+ if (!this.#models.has(tableName)) {
728
+ return { error: `Table ${tableName} doesn't exist` };
729
+ }
730
+
731
+ this.#assertIdentifier(tableName, "table name");
732
+
733
+ const columns = Object.keys(data);
734
+ if (!columns.length) return { error: "Insert data cannot be empty" };
735
+
736
+ columns.forEach((column) => this.#assertIdentifier(column, "column name"));
737
+
738
+ const placeholders = columns.map(() => "?").join(", ");
739
+ const sql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
740
+
741
+ const result = this.#run(sql, Object.values(data));
742
+
743
+ return { ok: true, ...result };
744
+ } catch (error) {
745
+ return { error: error.message };
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Updates one or more rows matching a WHERE clause.
751
+ *
752
+ * This method performs a plain `UPDATE`.
753
+ *
754
+ * @param {string} tableName - Table name.
755
+ * @param {Record<string, any>} data - Fields to update.
756
+ * @param {Record<string, any>} where - Conditions used to select rows.
757
+ * @returns {Promise<{ ok: true, changes: number } | { error: string }>}
758
+ */
759
+ async update(tableName, data, where) {
760
+ try {
761
+ if (!this.#models.has(tableName)) {
762
+ return { error: `Table ${tableName} doesn't exist` };
763
+ }
764
+
765
+ if (!where || Object.keys(where).length === 0) {
766
+ return { error: "Update requires a non-empty where clause" };
767
+ }
768
+
769
+ this.#assertIdentifier(tableName, "table name");
770
+
771
+ const columns = Object.keys(data);
772
+ if (!columns.length) return { error: "Update data cannot be empty" };
773
+
774
+ columns.forEach((column) => this.#assertIdentifier(column, "column name"));
775
+
776
+ const setClause = columns.map((column) => `${column} = ?`).join(", ");
777
+ const whereClause = this.#buildWhere(where);
778
+
779
+ const sql = `UPDATE ${tableName} SET ${setClause} ${whereClause.where}`;
780
+ const result = this.#run(sql, [...Object.values(data), ...whereClause.params]);
781
+
782
+ return { ok: true, changes: result.changes };
783
+ } catch (error) {
784
+ return { error: error.message };
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Inserts or updates a row using SQLite `ON CONFLICT DO UPDATE`.
790
+ *
791
+ * This is safer than `INSERT OR REPLACE` because it does not delete
792
+ * and recreate the row.
344
793
  *
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.
794
+ * @param {string} tableName - Table name.
795
+ * @param {Record<string, any>} data - Row data.
796
+ * @param {string[]} conflict - Columns that define the conflict target.
797
+ * @returns {Promise<{ ok: true, changes: number, lastInsertRowid: bigint|number } | { error: string }>}
347
798
  *
348
799
  * @example
349
- * await db.set("users", { id: 1, name: "Jane" });
800
+ * await db.upsert("UserAuthMethod", {
801
+ * userId: 1,
802
+ * provider: "google",
803
+ * providerUserId: "123"
804
+ * }, ["provider", "providerUserId"]);
805
+ */
806
+ async upsert(tableName, data, conflict = []) {
807
+ try {
808
+ if (!this.#models.has(tableName)) {
809
+ return { error: `Table ${tableName} doesn't exist` };
810
+ }
811
+
812
+ this.#assertIdentifier(tableName, "table name");
813
+
814
+ const columns = Object.keys(data);
815
+ if (!columns.length) return { error: "Upsert data cannot be empty" };
816
+ if (!conflict.length) return { error: "Upsert requires at least one conflict column" };
817
+
818
+ columns.forEach((column) => this.#assertIdentifier(column, "column name"));
819
+ conflict.forEach((column) => this.#assertIdentifier(column, "column name"));
820
+
821
+ const placeholders = columns.map(() => "?").join(", ");
822
+ const updateColumns = columns.filter((column) => !conflict.includes(column));
823
+
824
+ const updateClause = updateColumns.length
825
+ ? updateColumns.map((column) => `${column} = excluded.${column}`).join(", ")
826
+ : `${conflict[0]} = ${conflict[0]}`;
827
+
828
+ const sql = `
829
+ INSERT INTO ${tableName} (${columns.join(", ")})
830
+ VALUES (${placeholders})
831
+ ON CONFLICT (${conflict.join(", ")})
832
+ DO UPDATE SET ${updateClause}
833
+ `.trim();
834
+
835
+ const result = this.#run(sql, Object.values(data));
836
+ return { ok: true, ...result };
837
+ } catch (error) {
838
+ return { error: error.message };
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Backward-compatible helper that inserts or updates a row.
844
+ *
845
+ * Behavior:
846
+ * - If `where` is provided, the existing row is loaded and merged with `data`,
847
+ * then updated if found or inserted if not found.
848
+ * - If `where` is not provided but `data.id` exists, lookup is performed on `id`.
849
+ * - If no existing row is found, a new row is inserted.
850
+ *
851
+ * This method is intentionally simple, but for production-grade control,
852
+ * prefer using `insert()`, `update()`, or `upsert()`.
350
853
  *
351
- * @param {string} tableName
352
- * @param {Record<string, any>} data
353
- * @param {Record<string, any>|null} [where=null]
854
+ * @param {string} tableName - Table name.
855
+ * @param {Record<string, any>} data - Row data.
856
+ * @param {Record<string, any>|null} [where=null] - Lookup condition.
354
857
  * @returns {Promise<{ ok: true } | { error: string }>}
355
858
  */
356
859
  async set(tableName, data, where = null) {
@@ -360,24 +863,25 @@ module.exports = class SQLite {
360
863
  }
361
864
 
362
865
  const lookup = where ?? (data.id ? { id: data.id } : null);
363
- let finalData = data;
364
866
 
365
- if (lookup) {
366
- const existing = await this.get(tableName, lookup);
367
-
368
- if (existing && !existing.error) {
369
- finalData = { ...existing, ...data };
370
- }
867
+ if (!lookup) {
868
+ const inserted = await this.insert(tableName, data);
869
+ if (inserted.error) return inserted;
870
+ return { ok: true };
371
871
  }
372
872
 
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})`;
873
+ const existing = await this.get(tableName, lookup);
874
+ if (existing?.error) return existing;
378
875
 
379
- const stmt = this.#$.prepare(sql);
380
- stmt.run(...Object.values(finalData));
876
+ if (existing) {
877
+ const merged = { ...existing, ...data };
878
+ const updated = await this.update(tableName, merged, lookup);
879
+ if (updated.error) return updated;
880
+ return { ok: true };
881
+ }
882
+
883
+ const inserted = await this.insert(tableName, data);
884
+ if (inserted.error) return inserted;
381
885
 
382
886
  return { ok: true };
383
887
  } catch (error) {
@@ -388,8 +892,8 @@ module.exports = class SQLite {
388
892
  /**
389
893
  * Deletes rows matching a WHERE clause.
390
894
  *
391
- * @param {string} tableName
392
- * @param {Record<string, any>} where
895
+ * @param {string} tableName - Table name.
896
+ * @param {Record<string, any>} where - Conditions.
393
897
  * @returns {Promise<{ ok: true, changes: number } | { error: string }>}
394
898
  */
395
899
  async delete(tableName, where) {
@@ -398,10 +902,16 @@ module.exports = class SQLite {
398
902
  return { error: `Table ${tableName} doesn't exist` };
399
903
  }
400
904
 
905
+ if (!where || Object.keys(where).length === 0) {
906
+ return { error: "Delete requires a non-empty where clause" };
907
+ }
908
+
909
+ this.#assertIdentifier(tableName, "table name");
910
+
401
911
  const whereClause = this.#buildWhere(where);
402
912
  const sql = `DELETE FROM ${tableName} ${whereClause.where}`;
403
- const stmt = this.#$.prepare(sql);
404
- const result = stmt.run(...whereClause.params);
913
+ const result = this.#run(sql, whereClause.params);
914
+
405
915
  return { ok: true, changes: result.changes };
406
916
  } catch (error) {
407
917
  return { error: error.message };
@@ -411,12 +921,8 @@ module.exports = class SQLite {
411
921
  /**
412
922
  * Counts rows matching a WHERE clause.
413
923
  *
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={}]
924
+ * @param {string} tableName - Table name.
925
+ * @param {Record<string, any>} [where={}] - Conditions.
420
926
  * @returns {Promise<number | { error: string }>}
421
927
  */
422
928
  async count(tableName, where = {}) {
@@ -425,10 +931,13 @@ module.exports = class SQLite {
425
931
  return { error: `Table ${tableName} doesn't exist` };
426
932
  }
427
933
 
934
+ this.#assertIdentifier(tableName, "table name");
935
+
428
936
  const whereClause = this.#buildWhere(where);
429
937
  const sql = `SELECT COUNT(*) as total FROM ${tableName} ${whereClause.where}`;
430
938
  const stmt = this.#$.prepare(sql);
431
939
  const result = stmt.get(...whereClause.params);
940
+
432
941
  return result.total;
433
942
  } catch (error) {
434
943
  return { error: error.message };
@@ -436,7 +945,43 @@ module.exports = class SQLite {
436
945
  }
437
946
 
438
947
  /**
439
- * Closes the underlying SQLite connection (if the runtime supports it).
948
+ * Runs multiple operations inside a transaction.
949
+ *
950
+ * If the callback throws, the transaction is rolled back.
951
+ * If the callback completes successfully, the transaction is committed.
952
+ *
953
+ * The callback may be synchronous or asynchronous.
954
+ *
955
+ * @template T
956
+ * @param {() => T|Promise<T>} callback - Function to execute inside the transaction.
957
+ * @returns {Promise<T | { error: string }>}
958
+ *
959
+ * @example
960
+ * const result = await db.transaction(async () => {
961
+ * await db.insert("User", { email: "a@b.com", createdAt: Date.now() });
962
+ * await db.insert("Profile", { userId: 1, nickname: "John" });
963
+ * return { ok: true };
964
+ * });
965
+ */
966
+ async transaction(callback) {
967
+ try {
968
+ this.#run("BEGIN");
969
+
970
+ const result = await callback();
971
+
972
+ this.#run("COMMIT");
973
+ return result;
974
+ } catch (error) {
975
+ try {
976
+ this.#run("ROLLBACK");
977
+ } catch {}
978
+
979
+ return { error: error.message };
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Closes the underlying SQLite connection.
440
985
  *
441
986
  * @example
442
987
  * db.close();