sqlite-zod-orm 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4149,7 +4149,9 @@ var OPERATOR_MAP = {
4149
4149
  $in: "IN",
4150
4150
  $like: "LIKE",
4151
4151
  $notIn: "NOT IN",
4152
- $between: "BETWEEN"
4152
+ $between: "BETWEEN",
4153
+ $isNull: "IS NULL",
4154
+ $isNotNull: "IS NOT NULL"
4153
4155
  };
4154
4156
  function transformValueForStorage(value) {
4155
4157
  if (value instanceof Date)
@@ -4173,7 +4175,7 @@ function compileIQO(tableName, iqo) {
4173
4175
  selectParts.push(`${j.table}.*`);
4174
4176
  }
4175
4177
  }
4176
- let sql = `SELECT ${selectParts.join(", ")} FROM ${tableName}`;
4178
+ let sql = `SELECT ${iqo.distinct ? "DISTINCT " : ""}${selectParts.join(", ")} FROM ${tableName}`;
4177
4179
  for (const j of iqo.joins) {
4178
4180
  sql += ` JOIN ${j.table} ON ${tableName}.${j.fromCol} = ${j.table}.${j.toCol}`;
4179
4181
  }
@@ -4206,6 +4208,10 @@ function compileIQO(tableName, iqo) {
4206
4208
  const [min, max] = w.value;
4207
4209
  whereParts.push(`${qualify(w.field)} BETWEEN ? AND ?`);
4208
4210
  params.push(transformValueForStorage(min), transformValueForStorage(max));
4211
+ } else if (w.operator === "IS NULL") {
4212
+ whereParts.push(`${qualify(w.field)} IS NULL`);
4213
+ } else if (w.operator === "IS NOT NULL") {
4214
+ whereParts.push(`${qualify(w.field)} IS NOT NULL`);
4209
4215
  } else {
4210
4216
  whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
4211
4217
  params.push(transformValueForStorage(w.value));
@@ -4241,6 +4247,22 @@ function compileIQO(tableName, iqo) {
4241
4247
  if (iqo.groupBy.length > 0) {
4242
4248
  sql += ` GROUP BY ${iqo.groupBy.join(", ")}`;
4243
4249
  }
4250
+ if (iqo.having && iqo.having.length > 0) {
4251
+ const havingParts = [];
4252
+ for (const h of iqo.having) {
4253
+ if (h.operator === "IS NULL") {
4254
+ havingParts.push(`${h.field} IS NULL`);
4255
+ } else if (h.operator === "IS NOT NULL") {
4256
+ havingParts.push(`${h.field} IS NOT NULL`);
4257
+ } else {
4258
+ havingParts.push(`${h.field} ${h.operator} ?`);
4259
+ params.push(transformValueForStorage(h.value));
4260
+ }
4261
+ }
4262
+ if (havingParts.length > 0) {
4263
+ sql += ` HAVING ${havingParts.join(" AND ")}`;
4264
+ }
4265
+ }
4244
4266
  if (iqo.orderBy.length > 0) {
4245
4267
  const parts = iqo.orderBy.map((o) => `${o.field} ${o.direction.toUpperCase()}`);
4246
4268
  sql += ` ORDER BY ${parts.join(", ")}`;
@@ -4274,11 +4296,13 @@ class QueryBuilder {
4274
4296
  whereAST: null,
4275
4297
  joins: [],
4276
4298
  groupBy: [],
4299
+ having: [],
4277
4300
  limit: null,
4278
4301
  offset: null,
4279
4302
  orderBy: [],
4280
4303
  includes: [],
4281
- raw: false
4304
+ raw: false,
4305
+ distinct: false
4282
4306
  };
4283
4307
  }
4284
4308
  select(...cols) {
@@ -4433,6 +4457,66 @@ class QueryBuilder {
4433
4457
  this.iqo.groupBy.push(...fields);
4434
4458
  return this;
4435
4459
  }
4460
+ distinct() {
4461
+ this.iqo.distinct = true;
4462
+ return this;
4463
+ }
4464
+ withTrashed() {
4465
+ this.iqo.wheres = this.iqo.wheres.filter((w) => !(w.field === "deletedAt" && w.operator === "IS NULL"));
4466
+ return this;
4467
+ }
4468
+ onlyTrashed() {
4469
+ this.iqo.wheres = this.iqo.wheres.filter((w) => !(w.field === "deletedAt" && w.operator === "IS NULL"));
4470
+ this.iqo.wheres.push({ field: "deletedAt", operator: "IS NOT NULL", value: null });
4471
+ return this;
4472
+ }
4473
+ having(conditions) {
4474
+ for (const [field, value] of Object.entries(conditions)) {
4475
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
4476
+ for (const [opKey, operand] of Object.entries(value)) {
4477
+ const sqlOp = OPERATOR_MAP[opKey];
4478
+ if (!sqlOp)
4479
+ throw new Error(`Unsupported having operator: '${opKey}'`);
4480
+ this.iqo.having.push({ field, operator: sqlOp, value: operand });
4481
+ }
4482
+ } else {
4483
+ this.iqo.having.push({ field, operator: "=", value });
4484
+ }
4485
+ }
4486
+ return this;
4487
+ }
4488
+ sum(field) {
4489
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4490
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT COALESCE(SUM("${field}"), 0) as val FROM`);
4491
+ const results = this.executor(aggSql, params, true);
4492
+ return results[0]?.val ?? 0;
4493
+ }
4494
+ avg(field) {
4495
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4496
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT AVG("${field}") as val FROM`);
4497
+ const results = this.executor(aggSql, params, true);
4498
+ return results[0]?.val ?? 0;
4499
+ }
4500
+ min(field) {
4501
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4502
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MIN("${field}") as val FROM`);
4503
+ const results = this.executor(aggSql, params, true);
4504
+ return results[0]?.val ?? null;
4505
+ }
4506
+ max(field) {
4507
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4508
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MAX("${field}") as val FROM`);
4509
+ const results = this.executor(aggSql, params, true);
4510
+ return results[0]?.val ?? null;
4511
+ }
4512
+ paginate(page = 1, perPage = 20) {
4513
+ const total = this.count();
4514
+ const pages = Math.ceil(total / perPage);
4515
+ this.iqo.limit = perPage;
4516
+ this.iqo.offset = (page - 1) * perPage;
4517
+ const data = this.all();
4518
+ return { data, total, page, perPage, pages };
4519
+ }
4436
4520
  then(onfulfilled, onrejected) {
4437
4521
  try {
4438
4522
  const result = this.all();
@@ -4638,6 +4722,8 @@ function executeProxyQuery(schemas, callback, executor) {
4638
4722
  function createQueryBuilder(ctx, entityName, initialCols) {
4639
4723
  const schema = ctx.schemas[entityName];
4640
4724
  const executor = (sql, params, raw) => {
4725
+ if (ctx.debug)
4726
+ console.log("[satidb]", sql, params);
4641
4727
  const rows = ctx.db.query(sql).all(...params);
4642
4728
  if (raw)
4643
4729
  return rows;
@@ -4707,6 +4793,9 @@ function createQueryBuilder(ctx, entityName, initialCols) {
4707
4793
  const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, eagerLoader);
4708
4794
  if (initialCols.length > 0)
4709
4795
  builder.select(...initialCols);
4796
+ if (ctx.softDeletes) {
4797
+ builder.where({ deletedAt: { $isNull: true } });
4798
+ }
4710
4799
  return builder;
4711
4800
  }
4712
4801
 
@@ -4771,6 +4860,14 @@ function buildWhereClause(conditions, tablePrefix) {
4771
4860
  values.push(transformForStorage({ v: operand[0] }).v, transformForStorage({ v: operand[1] }).v);
4772
4861
  continue;
4773
4862
  }
4863
+ if (operator === "$isNull") {
4864
+ parts.push(`${fieldName} IS NULL`);
4865
+ continue;
4866
+ }
4867
+ if (operator === "$isNotNull") {
4868
+ parts.push(`${fieldName} IS NOT NULL`);
4869
+ continue;
4870
+ }
4774
4871
  const sqlOp = { $gt: ">", $gte: ">=", $lt: "<", $lte: "<=", $ne: "!=" }[operator];
4775
4872
  if (!sqlOp)
4776
4873
  throw new Error(`Unsupported operator '${operator}' on '${key}'`);
@@ -4807,9 +4904,16 @@ function insert(ctx, entityName, data) {
4807
4904
  const schema = ctx.schemas[entityName];
4808
4905
  const validatedData = asZodObject(schema).passthrough().parse(data);
4809
4906
  const transformed = transformForStorage(validatedData);
4907
+ if (ctx.timestamps) {
4908
+ const now = new Date().toISOString();
4909
+ transformed.createdAt = now;
4910
+ transformed.updatedAt = now;
4911
+ }
4810
4912
  const columns = Object.keys(transformed);
4811
4913
  const quotedCols = columns.map((c) => `"${c}"`);
4812
4914
  const sql = columns.length === 0 ? `INSERT INTO "${entityName}" DEFAULT VALUES` : `INSERT INTO "${entityName}" (${quotedCols.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`;
4915
+ if (ctx.debug)
4916
+ console.log("[satidb]", sql, Object.values(transformed));
4813
4917
  const result = ctx.db.query(sql).run(...Object.values(transformed));
4814
4918
  const newEntity = getById(ctx, entityName, result.lastInsertRowid);
4815
4919
  if (!newEntity)
@@ -4820,10 +4924,16 @@ function update(ctx, entityName, id, data) {
4820
4924
  const schema = ctx.schemas[entityName];
4821
4925
  const validatedData = asZodObject(schema).partial().parse(data);
4822
4926
  const transformed = transformForStorage(validatedData);
4823
- if (Object.keys(transformed).length === 0)
4927
+ if (Object.keys(transformed).length === 0 && !ctx.timestamps)
4824
4928
  return getById(ctx, entityName, id);
4929
+ if (ctx.timestamps) {
4930
+ transformed.updatedAt = new Date().toISOString();
4931
+ }
4825
4932
  const setClause = Object.keys(transformed).map((key) => `"${key}" = ?`).join(", ");
4826
- ctx.db.query(`UPDATE "${entityName}" SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
4933
+ const sql = `UPDATE "${entityName}" SET ${setClause} WHERE id = ?`;
4934
+ if (ctx.debug)
4935
+ console.log("[satidb]", sql, [...Object.values(transformed), id]);
4936
+ ctx.db.query(sql).run(...Object.values(transformed), id);
4827
4937
  return getById(ctx, entityName, id);
4828
4938
  }
4829
4939
  function updateWhere(ctx, entityName, data, conditions) {
@@ -4870,7 +4980,18 @@ function deleteWhere(ctx, entityName, conditions) {
4870
4980
  const { clause, values } = ctx.buildWhereClause(conditions);
4871
4981
  if (!clause)
4872
4982
  throw new Error("delete().where() requires at least one condition");
4873
- const result = ctx.db.query(`DELETE FROM "${entityName}" ${clause}`).run(...values);
4983
+ if (ctx.softDeletes) {
4984
+ const now = new Date().toISOString();
4985
+ const sql2 = `UPDATE "${entityName}" SET "deletedAt" = ? ${clause}`;
4986
+ if (ctx.debug)
4987
+ console.log("[satidb]", sql2, [now, ...values]);
4988
+ const result2 = ctx.db.query(sql2).run(now, ...values);
4989
+ return result2.changes ?? 0;
4990
+ }
4991
+ const sql = `DELETE FROM "${entityName}" ${clause}`;
4992
+ if (ctx.debug)
4993
+ console.log("[satidb]", sql, values);
4994
+ const result = ctx.db.query(sql).run(...values);
4874
4995
  return result.changes ?? 0;
4875
4996
  }
4876
4997
  function createDeleteBuilder(ctx, entityName) {
@@ -4894,6 +5015,11 @@ function insertMany(ctx, entityName, rows) {
4894
5015
  for (const data of rows) {
4895
5016
  const validatedData = zodSchema.parse(data);
4896
5017
  const transformed = transformForStorage(validatedData);
5018
+ if (ctx.timestamps) {
5019
+ const now = new Date().toISOString();
5020
+ transformed.createdAt = now;
5021
+ transformed.updatedAt = now;
5022
+ }
4897
5023
  const columns = Object.keys(transformed);
4898
5024
  const quotedCols = columns.map((c) => `"${c}"`);
4899
5025
  const sql = columns.length === 0 ? `INSERT INTO "${entityName}" DEFAULT VALUES` : `INSERT INTO "${entityName}" (${quotedCols.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`;
@@ -4944,6 +5070,9 @@ function attachMethods(ctx, entityName, entity) {
4944
5070
  class _Database {
4945
5071
  db;
4946
5072
  _reactive;
5073
+ _timestamps;
5074
+ _softDeletes;
5075
+ _debug;
4947
5076
  schemas;
4948
5077
  relationships;
4949
5078
  options;
@@ -4959,6 +5088,9 @@ class _Database {
4959
5088
  this.schemas = schemas;
4960
5089
  this.options = options;
4961
5090
  this._reactive = options.reactive !== false;
5091
+ this._timestamps = options.timestamps === true;
5092
+ this._softDeletes = options.softDeletes === true;
5093
+ this._debug = options.debug === true;
4962
5094
  this._pollInterval = options.pollInterval ?? 100;
4963
5095
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
4964
5096
  this._ctx = {
@@ -4966,7 +5098,10 @@ class _Database {
4966
5098
  schemas: this.schemas,
4967
5099
  relationships: this.relationships,
4968
5100
  attachMethods: (name, entity) => attachMethods(this._ctx, name, entity),
4969
- buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix)
5101
+ buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
5102
+ debug: this._debug,
5103
+ timestamps: this._timestamps,
5104
+ softDeletes: this._softDeletes
4970
5105
  };
4971
5106
  this.initializeTables();
4972
5107
  if (this._reactive)
@@ -4974,6 +5109,8 @@ class _Database {
4974
5109
  this.runMigrations();
4975
5110
  if (options.indexes)
4976
5111
  this.createIndexes(options.indexes);
5112
+ if (options.unique)
5113
+ this.createUniqueConstraints(options.unique);
4977
5114
  for (const entityName of Object.keys(schemas)) {
4978
5115
  const key = entityName;
4979
5116
  const accessor = {
@@ -4986,10 +5123,23 @@ class _Database {
4986
5123
  },
4987
5124
  upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
4988
5125
  delete: (id) => {
4989
- if (typeof id === "number")
5126
+ if (typeof id === "number") {
5127
+ if (this._softDeletes) {
5128
+ const now = new Date().toISOString();
5129
+ this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
5130
+ return;
5131
+ }
4990
5132
  return deleteEntity(this._ctx, entityName, id);
5133
+ }
4991
5134
  return createDeleteBuilder(this._ctx, entityName);
4992
5135
  },
5136
+ restore: (id) => {
5137
+ if (!this._softDeletes)
5138
+ throw new Error("restore() requires softDeletes: true");
5139
+ if (this._debug)
5140
+ console.log("[satidb]", `UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`, [id]);
5141
+ this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
5142
+ },
4993
5143
  select: (...cols) => createQueryBuilder(this._ctx, entityName, cols),
4994
5144
  on: (event, callback) => {
4995
5145
  return this._registerListener(entityName, event, callback);
@@ -5003,6 +5153,13 @@ class _Database {
5003
5153
  for (const [entityName, schema] of Object.entries(this.schemas)) {
5004
5154
  const storableFields = getStorableFields(schema);
5005
5155
  const columnDefs = storableFields.map((f) => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
5156
+ if (this._timestamps) {
5157
+ columnDefs.push('"createdAt" TEXT');
5158
+ columnDefs.push('"updatedAt" TEXT');
5159
+ }
5160
+ if (this._softDeletes) {
5161
+ columnDefs.push('"deletedAt" TEXT');
5162
+ }
5006
5163
  const constraints = [];
5007
5164
  const belongsToRels = this.relationships.filter((rel) => rel.type === "belongs-to" && rel.from === entityName);
5008
5165
  for (const rel of belongsToRels) {
@@ -5062,6 +5219,14 @@ class _Database {
5062
5219
  }
5063
5220
  }
5064
5221
  }
5222
+ createUniqueConstraints(unique) {
5223
+ for (const [tableName, groups] of Object.entries(unique)) {
5224
+ for (const cols of groups) {
5225
+ const idxName = `uq_${tableName}_${cols.join("_")}`;
5226
+ this.db.run(`CREATE UNIQUE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map((c) => `"${c}"`).join(", ")})`);
5227
+ }
5228
+ }
5229
+ }
5065
5230
  _registerListener(table, event, callback) {
5066
5231
  if (!this._reactive) {
5067
5232
  throw new Error("Change listeners are disabled. Set { reactive: true } (or omit it) in Database options to enable .on().");
@@ -5117,7 +5282,7 @@ class _Database {
5117
5282
  }
5118
5283
  this._changeWatermark = change.id;
5119
5284
  }
5120
- this.db.run('DELETE FROM "_changes" WHERE id <= ?', this._changeWatermark);
5285
+ this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
5121
5286
  }
5122
5287
  transaction(callback) {
5123
5288
  return this.db.transaction(callback)();
@@ -5127,7 +5292,27 @@ class _Database {
5127
5292
  this.db.close();
5128
5293
  }
5129
5294
  query(callback) {
5130
- return executeProxyQuery(this.schemas, callback, (sql, params) => this.db.query(sql).all(...params));
5295
+ return executeProxyQuery(this.schemas, callback, (sql, params) => {
5296
+ if (this._debug)
5297
+ console.log("[satidb]", sql, params);
5298
+ return this.db.query(sql).all(...params);
5299
+ });
5300
+ }
5301
+ raw(sql, ...params) {
5302
+ if (this._debug)
5303
+ console.log("[satidb]", sql, params);
5304
+ return this.db.query(sql).all(...params);
5305
+ }
5306
+ exec(sql, ...params) {
5307
+ if (this._debug)
5308
+ console.log("[satidb]", sql, params);
5309
+ this.db.run(sql, ...params);
5310
+ }
5311
+ tables() {
5312
+ return Object.keys(this.schemas);
5313
+ }
5314
+ columns(tableName) {
5315
+ return this.db.query(`PRAGMA table_info("${tableName}")`).all();
5131
5316
  }
5132
5317
  }
5133
5318
  var Database = _Database;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.10.0",
3
+ "version": "3.12.0",
4
4
  "description": "Type-safe SQLite ORM for Bun — Zod schemas, fluent queries, auto relationships, zero SQL",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/builder.ts CHANGED
@@ -57,11 +57,13 @@ export class QueryBuilder<T extends Record<string, any>> {
57
57
  whereAST: null,
58
58
  joins: [],
59
59
  groupBy: [],
60
+ having: [],
60
61
  limit: null,
61
62
  offset: null,
62
63
  orderBy: [],
63
64
  includes: [],
64
65
  raw: false,
66
+ distinct: false,
65
67
  };
66
68
  }
67
69
 
@@ -293,6 +295,105 @@ export class QueryBuilder<T extends Record<string, any>> {
293
295
  return this;
294
296
  }
295
297
 
298
+ /** Return only distinct rows. */
299
+ distinct(): this {
300
+ this.iqo.distinct = true;
301
+ return this;
302
+ }
303
+
304
+ /**
305
+ * Include soft-deleted rows in query results.
306
+ * Only relevant when `softDeletes: true` is set in Database options.
307
+ */
308
+ withTrashed(): this {
309
+ // Remove the auto-injected `deletedAt IS NULL` filter
310
+ this.iqo.wheres = this.iqo.wheres.filter(
311
+ w => !(w.field === 'deletedAt' && w.operator === 'IS NULL')
312
+ );
313
+ return this;
314
+ }
315
+
316
+ /**
317
+ * Return only soft-deleted rows.
318
+ * Only relevant when `softDeletes: true` is set in Database options.
319
+ */
320
+ onlyTrashed(): this {
321
+ // Remove the auto-injected `deletedAt IS NULL` and add `deletedAt IS NOT NULL`
322
+ this.iqo.wheres = this.iqo.wheres.filter(
323
+ w => !(w.field === 'deletedAt' && w.operator === 'IS NULL')
324
+ );
325
+ this.iqo.wheres.push({ field: 'deletedAt', operator: 'IS NOT NULL', value: null });
326
+ return this;
327
+ }
328
+
329
+ /**
330
+ * Add HAVING conditions (used after groupBy for aggregate filtering).
331
+ *
332
+ * ```ts
333
+ * db.orders.select('user_id').groupBy('user_id')
334
+ * .having({ 'COUNT(*)': { $gt: 5 } })
335
+ * .raw().all()
336
+ * ```
337
+ */
338
+ having(conditions: Record<string, any>): this {
339
+ for (const [field, value] of Object.entries(conditions)) {
340
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
341
+ for (const [opKey, operand] of Object.entries(value)) {
342
+ const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
343
+ if (!sqlOp) throw new Error(`Unsupported having operator: '${opKey}'`);
344
+ this.iqo.having.push({ field, operator: sqlOp as WhereCondition['operator'], value: operand });
345
+ }
346
+ } else {
347
+ this.iqo.having.push({ field, operator: '=', value });
348
+ }
349
+ }
350
+ return this;
351
+ }
352
+
353
+ // ---------- Aggregate Methods ----------
354
+
355
+ /** Returns the SUM of a numeric column. */
356
+ sum(field: keyof T & string): number {
357
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
358
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT COALESCE(SUM("${field}"), 0) as val FROM`);
359
+ const results = this.executor(aggSql, params, true);
360
+ return (results[0] as any)?.val ?? 0;
361
+ }
362
+
363
+ /** Returns the AVG of a numeric column. */
364
+ avg(field: keyof T & string): number {
365
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
366
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT AVG("${field}") as val FROM`);
367
+ const results = this.executor(aggSql, params, true);
368
+ return (results[0] as any)?.val ?? 0;
369
+ }
370
+
371
+ /** Returns the MIN of a column. */
372
+ min(field: keyof T & string): number | string | null {
373
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
374
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MIN("${field}") as val FROM`);
375
+ const results = this.executor(aggSql, params, true);
376
+ return (results[0] as any)?.val ?? null;
377
+ }
378
+
379
+ /** Returns the MAX of a column. */
380
+ max(field: keyof T & string): number | string | null {
381
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
382
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MAX("${field}") as val FROM`);
383
+ const results = this.executor(aggSql, params, true);
384
+ return (results[0] as any)?.val ?? null;
385
+ }
386
+
387
+ /** Paginate results. Returns { data, total, page, perPage, pages }. */
388
+ paginate(page: number = 1, perPage: number = 20): { data: T[]; total: number; page: number; perPage: number; pages: number } {
389
+ const total = this.count();
390
+ const pages = Math.ceil(total / perPage);
391
+ this.iqo.limit = perPage;
392
+ this.iqo.offset = (page - 1) * perPage;
393
+ const data = this.all();
394
+ return { data, total, page, perPage, pages };
395
+ }
396
+
296
397
 
297
398
 
298
399
  // ---------- Thenable (async/await support) ----------
package/src/context.ts CHANGED
@@ -22,4 +22,13 @@ export interface DatabaseContext {
22
22
 
23
23
  /** Build a WHERE clause from a conditions object. */
24
24
  buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] };
25
+
26
+ /** Whether to log SQL to console. */
27
+ debug: boolean;
28
+
29
+ /** Whether tables have createdAt/updatedAt columns. */
30
+ timestamps: boolean;
31
+
32
+ /** Whether soft deletes are enabled (deletedAt column). */
33
+ softDeletes: boolean;
25
34
  }
package/src/crud.ts CHANGED
@@ -42,6 +42,14 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
42
42
  const schema = ctx.schemas[entityName]!;
43
43
  const validatedData = asZodObject(schema).passthrough().parse(data);
44
44
  const transformed = transformForStorage(validatedData);
45
+
46
+ // Auto-inject timestamps
47
+ if (ctx.timestamps) {
48
+ const now = new Date().toISOString();
49
+ transformed.createdAt = now;
50
+ transformed.updatedAt = now;
51
+ }
52
+
45
53
  const columns = Object.keys(transformed);
46
54
 
47
55
  const quotedCols = columns.map(c => `"${c}"`);
@@ -49,6 +57,7 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
49
57
  ? `INSERT INTO "${entityName}" DEFAULT VALUES`
50
58
  : `INSERT INTO "${entityName}" (${quotedCols.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
51
59
 
60
+ if (ctx.debug) console.log('[satidb]', sql, Object.values(transformed));
52
61
  const result = ctx.db.query(sql).run(...Object.values(transformed));
53
62
  const newEntity = getById(ctx, entityName, result.lastInsertRowid as number);
54
63
  if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
@@ -60,10 +69,17 @@ export function update<T extends Record<string, any>>(ctx: DatabaseContext, enti
60
69
  const schema = ctx.schemas[entityName]!;
61
70
  const validatedData = asZodObject(schema).partial().parse(data);
62
71
  const transformed = transformForStorage(validatedData);
63
- if (Object.keys(transformed).length === 0) return getById(ctx, entityName, id);
72
+ if (Object.keys(transformed).length === 0 && !ctx.timestamps) return getById(ctx, entityName, id);
73
+
74
+ // Auto-update timestamp
75
+ if (ctx.timestamps) {
76
+ transformed.updatedAt = new Date().toISOString();
77
+ }
64
78
 
65
79
  const setClause = Object.keys(transformed).map(key => `"${key}" = ?`).join(', ');
66
- ctx.db.query(`UPDATE "${entityName}" SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
80
+ const sql = `UPDATE "${entityName}" SET ${setClause} WHERE id = ?`;
81
+ if (ctx.debug) console.log('[satidb]', sql, [...Object.values(transformed), id]);
82
+ ctx.db.query(sql).run(...Object.values(transformed), id);
67
83
 
68
84
  return getById(ctx, entityName, id);
69
85
  }
@@ -118,11 +134,23 @@ export function deleteEntity(ctx: DatabaseContext, entityName: string, id: numbe
118
134
  ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
119
135
  }
120
136
 
121
- /** Delete all rows matching the given conditions. Returns the number of rows deleted. */
137
+ /** Delete all rows matching the given conditions. Returns the number of rows affected. */
122
138
  export function deleteWhere(ctx: DatabaseContext, entityName: string, conditions: Record<string, any>): number {
123
139
  const { clause, values } = ctx.buildWhereClause(conditions);
124
140
  if (!clause) throw new Error('delete().where() requires at least one condition');
125
- const result = ctx.db.query(`DELETE FROM "${entityName}" ${clause}`).run(...values);
141
+
142
+ if (ctx.softDeletes) {
143
+ // Soft delete: set deletedAt instead of removing rows
144
+ const now = new Date().toISOString();
145
+ const sql = `UPDATE "${entityName}" SET "deletedAt" = ? ${clause}`;
146
+ if (ctx.debug) console.log('[satidb]', sql, [now, ...values]);
147
+ const result = ctx.db.query(sql).run(now, ...values);
148
+ return (result as any).changes ?? 0;
149
+ }
150
+
151
+ const sql = `DELETE FROM "${entityName}" ${clause}`;
152
+ if (ctx.debug) console.log('[satidb]', sql, values);
153
+ const result = ctx.db.query(sql).run(...values);
126
154
  return (result as any).changes ?? 0;
127
155
  }
128
156
 
@@ -147,6 +175,13 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
147
175
  for (const data of rows) {
148
176
  const validatedData = zodSchema.parse(data);
149
177
  const transformed = transformForStorage(validatedData);
178
+
179
+ if (ctx.timestamps) {
180
+ const now = new Date().toISOString();
181
+ transformed.createdAt = now;
182
+ transformed.updatedAt = now;
183
+ }
184
+
150
185
  const columns = Object.keys(transformed);
151
186
  const quotedCols = columns.map(c => `"${c}"`);
152
187
  const sql = columns.length === 0