sqlite-zod-orm 3.10.0 → 3.11.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,61 @@ 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
+ having(conditions) {
4469
+ for (const [field, value] of Object.entries(conditions)) {
4470
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
4471
+ for (const [opKey, operand] of Object.entries(value)) {
4472
+ const sqlOp = OPERATOR_MAP[opKey];
4473
+ if (!sqlOp)
4474
+ throw new Error(`Unsupported having operator: '${opKey}'`);
4475
+ this.iqo.having.push({ field, operator: sqlOp, value: operand });
4476
+ }
4477
+ } else {
4478
+ this.iqo.having.push({ field, operator: "=", value });
4479
+ }
4480
+ }
4481
+ return this;
4482
+ }
4483
+ sum(field) {
4484
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4485
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT COALESCE(SUM("${field}"), 0) as val FROM`);
4486
+ const results = this.executor(aggSql, params, true);
4487
+ return results[0]?.val ?? 0;
4488
+ }
4489
+ avg(field) {
4490
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4491
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT AVG("${field}") as val FROM`);
4492
+ const results = this.executor(aggSql, params, true);
4493
+ return results[0]?.val ?? 0;
4494
+ }
4495
+ min(field) {
4496
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4497
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MIN("${field}") as val FROM`);
4498
+ const results = this.executor(aggSql, params, true);
4499
+ return results[0]?.val ?? null;
4500
+ }
4501
+ max(field) {
4502
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
4503
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MAX("${field}") as val FROM`);
4504
+ const results = this.executor(aggSql, params, true);
4505
+ return results[0]?.val ?? null;
4506
+ }
4507
+ paginate(page = 1, perPage = 20) {
4508
+ const total = this.count();
4509
+ const pages = Math.ceil(total / perPage);
4510
+ this.iqo.limit = perPage;
4511
+ this.iqo.offset = (page - 1) * perPage;
4512
+ const data = this.all();
4513
+ return { data, total, page, perPage, pages };
4514
+ }
4436
4515
  then(onfulfilled, onrejected) {
4437
4516
  try {
4438
4517
  const result = this.all();
@@ -4638,6 +4717,8 @@ function executeProxyQuery(schemas, callback, executor) {
4638
4717
  function createQueryBuilder(ctx, entityName, initialCols) {
4639
4718
  const schema = ctx.schemas[entityName];
4640
4719
  const executor = (sql, params, raw) => {
4720
+ if (ctx.debug)
4721
+ console.log("[satidb]", sql, params);
4641
4722
  const rows = ctx.db.query(sql).all(...params);
4642
4723
  if (raw)
4643
4724
  return rows;
@@ -4707,6 +4788,9 @@ function createQueryBuilder(ctx, entityName, initialCols) {
4707
4788
  const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, eagerLoader);
4708
4789
  if (initialCols.length > 0)
4709
4790
  builder.select(...initialCols);
4791
+ if (ctx.softDeletes) {
4792
+ builder.where({ deletedAt: { $isNull: true } });
4793
+ }
4710
4794
  return builder;
4711
4795
  }
4712
4796
 
@@ -4771,6 +4855,14 @@ function buildWhereClause(conditions, tablePrefix) {
4771
4855
  values.push(transformForStorage({ v: operand[0] }).v, transformForStorage({ v: operand[1] }).v);
4772
4856
  continue;
4773
4857
  }
4858
+ if (operator === "$isNull") {
4859
+ parts.push(`${fieldName} IS NULL`);
4860
+ continue;
4861
+ }
4862
+ if (operator === "$isNotNull") {
4863
+ parts.push(`${fieldName} IS NOT NULL`);
4864
+ continue;
4865
+ }
4774
4866
  const sqlOp = { $gt: ">", $gte: ">=", $lt: "<", $lte: "<=", $ne: "!=" }[operator];
4775
4867
  if (!sqlOp)
4776
4868
  throw new Error(`Unsupported operator '${operator}' on '${key}'`);
@@ -4807,9 +4899,16 @@ function insert(ctx, entityName, data) {
4807
4899
  const schema = ctx.schemas[entityName];
4808
4900
  const validatedData = asZodObject(schema).passthrough().parse(data);
4809
4901
  const transformed = transformForStorage(validatedData);
4902
+ if (ctx.timestamps) {
4903
+ const now = new Date().toISOString();
4904
+ transformed.createdAt = now;
4905
+ transformed.updatedAt = now;
4906
+ }
4810
4907
  const columns = Object.keys(transformed);
4811
4908
  const quotedCols = columns.map((c) => `"${c}"`);
4812
4909
  const sql = columns.length === 0 ? `INSERT INTO "${entityName}" DEFAULT VALUES` : `INSERT INTO "${entityName}" (${quotedCols.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`;
4910
+ if (ctx.debug)
4911
+ console.log("[satidb]", sql, Object.values(transformed));
4813
4912
  const result = ctx.db.query(sql).run(...Object.values(transformed));
4814
4913
  const newEntity = getById(ctx, entityName, result.lastInsertRowid);
4815
4914
  if (!newEntity)
@@ -4820,10 +4919,16 @@ function update(ctx, entityName, id, data) {
4820
4919
  const schema = ctx.schemas[entityName];
4821
4920
  const validatedData = asZodObject(schema).partial().parse(data);
4822
4921
  const transformed = transformForStorage(validatedData);
4823
- if (Object.keys(transformed).length === 0)
4922
+ if (Object.keys(transformed).length === 0 && !ctx.timestamps)
4824
4923
  return getById(ctx, entityName, id);
4924
+ if (ctx.timestamps) {
4925
+ transformed.updatedAt = new Date().toISOString();
4926
+ }
4825
4927
  const setClause = Object.keys(transformed).map((key) => `"${key}" = ?`).join(", ");
4826
- ctx.db.query(`UPDATE "${entityName}" SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
4928
+ const sql = `UPDATE "${entityName}" SET ${setClause} WHERE id = ?`;
4929
+ if (ctx.debug)
4930
+ console.log("[satidb]", sql, [...Object.values(transformed), id]);
4931
+ ctx.db.query(sql).run(...Object.values(transformed), id);
4827
4932
  return getById(ctx, entityName, id);
4828
4933
  }
4829
4934
  function updateWhere(ctx, entityName, data, conditions) {
@@ -4894,6 +4999,11 @@ function insertMany(ctx, entityName, rows) {
4894
4999
  for (const data of rows) {
4895
5000
  const validatedData = zodSchema.parse(data);
4896
5001
  const transformed = transformForStorage(validatedData);
5002
+ if (ctx.timestamps) {
5003
+ const now = new Date().toISOString();
5004
+ transformed.createdAt = now;
5005
+ transformed.updatedAt = now;
5006
+ }
4897
5007
  const columns = Object.keys(transformed);
4898
5008
  const quotedCols = columns.map((c) => `"${c}"`);
4899
5009
  const sql = columns.length === 0 ? `INSERT INTO "${entityName}" DEFAULT VALUES` : `INSERT INTO "${entityName}" (${quotedCols.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`;
@@ -4944,6 +5054,9 @@ function attachMethods(ctx, entityName, entity) {
4944
5054
  class _Database {
4945
5055
  db;
4946
5056
  _reactive;
5057
+ _timestamps;
5058
+ _softDeletes;
5059
+ _debug;
4947
5060
  schemas;
4948
5061
  relationships;
4949
5062
  options;
@@ -4959,6 +5072,9 @@ class _Database {
4959
5072
  this.schemas = schemas;
4960
5073
  this.options = options;
4961
5074
  this._reactive = options.reactive !== false;
5075
+ this._timestamps = options.timestamps === true;
5076
+ this._softDeletes = options.softDeletes === true;
5077
+ this._debug = options.debug === true;
4962
5078
  this._pollInterval = options.pollInterval ?? 100;
4963
5079
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
4964
5080
  this._ctx = {
@@ -4966,7 +5082,10 @@ class _Database {
4966
5082
  schemas: this.schemas,
4967
5083
  relationships: this.relationships,
4968
5084
  attachMethods: (name, entity) => attachMethods(this._ctx, name, entity),
4969
- buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix)
5085
+ buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
5086
+ debug: this._debug,
5087
+ timestamps: this._timestamps,
5088
+ softDeletes: this._softDeletes
4970
5089
  };
4971
5090
  this.initializeTables();
4972
5091
  if (this._reactive)
@@ -4986,8 +5105,14 @@ class _Database {
4986
5105
  },
4987
5106
  upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
4988
5107
  delete: (id) => {
4989
- if (typeof id === "number")
5108
+ if (typeof id === "number") {
5109
+ if (this._softDeletes) {
5110
+ const now = new Date().toISOString();
5111
+ this.db.run(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`, now, id);
5112
+ return;
5113
+ }
4990
5114
  return deleteEntity(this._ctx, entityName, id);
5115
+ }
4991
5116
  return createDeleteBuilder(this._ctx, entityName);
4992
5117
  },
4993
5118
  select: (...cols) => createQueryBuilder(this._ctx, entityName, cols),
@@ -5003,6 +5128,13 @@ class _Database {
5003
5128
  for (const [entityName, schema] of Object.entries(this.schemas)) {
5004
5129
  const storableFields = getStorableFields(schema);
5005
5130
  const columnDefs = storableFields.map((f) => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
5131
+ if (this._timestamps) {
5132
+ columnDefs.push('"createdAt" TEXT');
5133
+ columnDefs.push('"updatedAt" TEXT');
5134
+ }
5135
+ if (this._softDeletes) {
5136
+ columnDefs.push('"deletedAt" TEXT');
5137
+ }
5006
5138
  const constraints = [];
5007
5139
  const belongsToRels = this.relationships.filter((rel) => rel.type === "belongs-to" && rel.from === entityName);
5008
5140
  for (const rel of belongsToRels) {
@@ -5127,7 +5259,21 @@ class _Database {
5127
5259
  this.db.close();
5128
5260
  }
5129
5261
  query(callback) {
5130
- return executeProxyQuery(this.schemas, callback, (sql, params) => this.db.query(sql).all(...params));
5262
+ return executeProxyQuery(this.schemas, callback, (sql, params) => {
5263
+ if (this._debug)
5264
+ console.log("[satidb]", sql, params);
5265
+ return this.db.query(sql).all(...params);
5266
+ });
5267
+ }
5268
+ raw(sql, ...params) {
5269
+ if (this._debug)
5270
+ console.log("[satidb]", sql, params);
5271
+ return this.db.query(sql).all(...params);
5272
+ }
5273
+ exec(sql, ...params) {
5274
+ if (this._debug)
5275
+ console.log("[satidb]", sql, params);
5276
+ this.db.run(sql, ...params);
5131
5277
  }
5132
5278
  }
5133
5279
  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.11.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,92 @@ 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
+ * Add HAVING conditions (used after groupBy for aggregate filtering).
318
+ *
319
+ * ```ts
320
+ * db.orders.select('user_id').groupBy('user_id')
321
+ * .having({ 'COUNT(*)': { $gt: 5 } })
322
+ * .raw().all()
323
+ * ```
324
+ */
325
+ having(conditions: Record<string, any>): this {
326
+ for (const [field, value] of Object.entries(conditions)) {
327
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
328
+ for (const [opKey, operand] of Object.entries(value)) {
329
+ const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
330
+ if (!sqlOp) throw new Error(`Unsupported having operator: '${opKey}'`);
331
+ this.iqo.having.push({ field, operator: sqlOp as WhereCondition['operator'], value: operand });
332
+ }
333
+ } else {
334
+ this.iqo.having.push({ field, operator: '=', value });
335
+ }
336
+ }
337
+ return this;
338
+ }
339
+
340
+ // ---------- Aggregate Methods ----------
341
+
342
+ /** Returns the SUM of a numeric column. */
343
+ sum(field: keyof T & string): number {
344
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
345
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT COALESCE(SUM("${field}"), 0) as val FROM`);
346
+ const results = this.executor(aggSql, params, true);
347
+ return (results[0] as any)?.val ?? 0;
348
+ }
349
+
350
+ /** Returns the AVG of a numeric column. */
351
+ avg(field: keyof T & string): number {
352
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
353
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT AVG("${field}") as val FROM`);
354
+ const results = this.executor(aggSql, params, true);
355
+ return (results[0] as any)?.val ?? 0;
356
+ }
357
+
358
+ /** Returns the MIN of a column. */
359
+ min(field: keyof T & string): number | string | null {
360
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
361
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MIN("${field}") as val FROM`);
362
+ const results = this.executor(aggSql, params, true);
363
+ return (results[0] as any)?.val ?? null;
364
+ }
365
+
366
+ /** Returns the MAX of a column. */
367
+ max(field: keyof T & string): number | string | null {
368
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
369
+ const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MAX("${field}") as val FROM`);
370
+ const results = this.executor(aggSql, params, true);
371
+ return (results[0] as any)?.val ?? null;
372
+ }
373
+
374
+ /** Paginate results. Returns { data, total, page, perPage, pages }. */
375
+ paginate(page: number = 1, perPage: number = 20): { data: T[]; total: number; page: number; perPage: number; pages: number } {
376
+ const total = this.count();
377
+ const pages = Math.ceil(total / perPage);
378
+ this.iqo.limit = perPage;
379
+ this.iqo.offset = (page - 1) * perPage;
380
+ const data = this.all();
381
+ return { data, total, page, perPage, pages };
382
+ }
383
+
296
384
 
297
385
 
298
386
  // ---------- 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
  }
@@ -147,6 +163,13 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
147
163
  for (const data of rows) {
148
164
  const validatedData = zodSchema.parse(data);
149
165
  const transformed = transformForStorage(validatedData);
166
+
167
+ if (ctx.timestamps) {
168
+ const now = new Date().toISOString();
169
+ transformed.createdAt = now;
170
+ transformed.updatedAt = now;
171
+ }
172
+
150
173
  const columns = Object.keys(transformed);
151
174
  const quotedCols = columns.map(c => `"${c}"`);
152
175
  const sql = columns.length === 0
package/src/database.ts CHANGED
@@ -41,6 +41,9 @@ type Listener = {
41
41
  class _Database<Schemas extends SchemaMap> {
42
42
  private db: SqliteDatabase;
43
43
  private _reactive: boolean;
44
+ private _timestamps: boolean;
45
+ private _softDeletes: boolean;
46
+ private _debug: boolean;
44
47
  private schemas: Schemas;
45
48
  private relationships: Relationship[];
46
49
  private options: DatabaseOptions;
@@ -67,6 +70,9 @@ class _Database<Schemas extends SchemaMap> {
67
70
  this.schemas = schemas;
68
71
  this.options = options;
69
72
  this._reactive = options.reactive !== false; // default true
73
+ this._timestamps = options.timestamps === true;
74
+ this._softDeletes = options.softDeletes === true;
75
+ this._debug = options.debug === true;
70
76
  this._pollInterval = options.pollInterval ?? 100;
71
77
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
72
78
 
@@ -77,6 +83,9 @@ class _Database<Schemas extends SchemaMap> {
77
83
  relationships: this.relationships,
78
84
  attachMethods: (name, entity) => attachMethods(this._ctx, name, entity),
79
85
  buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
86
+ debug: this._debug,
87
+ timestamps: this._timestamps,
88
+ softDeletes: this._softDeletes,
80
89
  };
81
90
 
82
91
  this.initializeTables();
@@ -96,7 +105,15 @@ class _Database<Schemas extends SchemaMap> {
96
105
  },
97
106
  upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
98
107
  delete: ((id?: any) => {
99
- if (typeof id === 'number') return deleteEntity(this._ctx, entityName, id);
108
+ if (typeof id === 'number') {
109
+ if (this._softDeletes) {
110
+ // Soft delete: set deletedAt instead of removing
111
+ const now = new Date().toISOString();
112
+ this.db.run(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`, now, id);
113
+ return;
114
+ }
115
+ return deleteEntity(this._ctx, entityName, id);
116
+ }
100
117
  return createDeleteBuilder(this._ctx, entityName);
101
118
  }) as any,
102
119
  select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
@@ -117,6 +134,17 @@ class _Database<Schemas extends SchemaMap> {
117
134
  for (const [entityName, schema] of Object.entries(this.schemas)) {
118
135
  const storableFields = getStorableFields(schema);
119
136
  const columnDefs = storableFields.map(f => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
137
+
138
+ // Add timestamp columns
139
+ if (this._timestamps) {
140
+ columnDefs.push('"createdAt" TEXT');
141
+ columnDefs.push('"updatedAt" TEXT');
142
+ }
143
+ // Add soft delete column
144
+ if (this._softDeletes) {
145
+ columnDefs.push('"deletedAt" TEXT');
146
+ }
147
+
120
148
  const constraints: string[] = [];
121
149
 
122
150
  const belongsToRels = this.relationships.filter(
@@ -306,9 +334,28 @@ class _Database<Schemas extends SchemaMap> {
306
334
  return executeProxyQuery(
307
335
  this.schemas,
308
336
  callback as any,
309
- (sql: string, params: any[]) => this.db.query(sql).all(...params) as T[],
337
+ (sql: string, params: any[]) => {
338
+ if (this._debug) console.log('[satidb]', sql, params);
339
+ return this.db.query(sql).all(...params) as T[];
340
+ },
310
341
  );
311
342
  }
343
+
344
+ // =========================================================================
345
+ // Raw SQL
346
+ // =========================================================================
347
+
348
+ /** Execute a raw SQL query and return results. */
349
+ public raw<T = any>(sql: string, ...params: any[]): T[] {
350
+ if (this._debug) console.log('[satidb]', sql, params);
351
+ return this.db.query(sql).all(...params) as T[];
352
+ }
353
+
354
+ /** Execute a raw SQL statement (INSERT/UPDATE/DELETE) without returning rows. */
355
+ public exec(sql: string, ...params: any[]): void {
356
+ if (this._debug) console.log('[satidb]', sql, params);
357
+ this.db.run(sql, ...params);
358
+ }
312
359
  }
313
360
 
314
361
  // =============================================================================
package/src/helpers.ts CHANGED
@@ -13,6 +13,7 @@ import { transformForStorage } from './schema';
13
13
  * - Operators: `{ age: { $gt: 18 } }`
14
14
  * - $in: `{ status: { $in: ['active', 'pending'] } }`
15
15
  * - $or: `{ $or: [{ name: 'Alice' }, { name: 'Bob' }] }`
16
+ * - $isNull / $isNotNull: `{ deletedAt: { $isNull: true } }`
16
17
  */
17
18
  export function buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] } {
18
19
  const parts: string[] = [];
@@ -73,6 +74,16 @@ export function buildWhereClause(conditions: Record<string, any>, tablePrefix?:
73
74
  continue;
74
75
  }
75
76
 
77
+ if (operator === '$isNull') {
78
+ parts.push(`${fieldName} IS NULL`);
79
+ continue;
80
+ }
81
+
82
+ if (operator === '$isNotNull') {
83
+ parts.push(`${fieldName} IS NOT NULL`);
84
+ continue;
85
+ }
86
+
76
87
  const sqlOp = ({ $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=' } as Record<string, string>)[operator];
77
88
  if (!sqlOp) throw new Error(`Unsupported operator '${operator}' on '${key}'`);
78
89
  parts.push(`${fieldName} ${sqlOp} ?`);
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ export type { DatabaseType } from './database';
8
8
 
9
9
  export type {
10
10
  SchemaMap, DatabaseOptions, Relationship,
11
- EntityAccessor, TypedAccessors, AugmentedEntity, UpdateBuilder,
11
+ EntityAccessor, TypedAccessors, AugmentedEntity, UpdateBuilder, DeleteBuilder,
12
12
  InferSchema, EntityData, IndexDef, ChangeEvent,
13
13
  ProxyColumns, ColumnRef,
14
14
  } from './types';
package/src/iqo.ts CHANGED
@@ -12,11 +12,11 @@ import { type ASTNode, compileAST } from './ast';
12
12
  // =============================================================================
13
13
 
14
14
  export type OrderDirection = 'asc' | 'desc';
15
- export type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in' | '$like' | '$notIn' | '$between';
15
+ export type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in' | '$like' | '$notIn' | '$between' | '$isNull' | '$isNotNull';
16
16
 
17
17
  export interface WhereCondition {
18
18
  field: string;
19
- operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN' | 'LIKE' | 'NOT IN' | 'BETWEEN';
19
+ operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN' | 'LIKE' | 'NOT IN' | 'BETWEEN' | 'IS NULL' | 'IS NOT NULL';
20
20
  value: any;
21
21
  }
22
22
 
@@ -34,11 +34,13 @@ export interface IQO {
34
34
  whereAST: ASTNode | null;
35
35
  joins: JoinClause[];
36
36
  groupBy: string[];
37
+ having: WhereCondition[];
37
38
  limit: number | null;
38
39
  offset: number | null;
39
40
  orderBy: { field: string; direction: OrderDirection }[];
40
41
  includes: string[];
41
42
  raw: boolean;
43
+ distinct: boolean;
42
44
  }
43
45
 
44
46
  export const OPERATOR_MAP: Record<WhereOperator, string> = {
@@ -51,6 +53,8 @@ export const OPERATOR_MAP: Record<WhereOperator, string> = {
51
53
  $like: 'LIKE',
52
54
  $notIn: 'NOT IN',
53
55
  $between: 'BETWEEN',
56
+ $isNull: 'IS NULL',
57
+ $isNotNull: 'IS NOT NULL',
54
58
  };
55
59
 
56
60
  export function transformValueForStorage(value: any): any {
@@ -81,7 +85,7 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
81
85
  }
82
86
  }
83
87
 
84
- let sql = `SELECT ${selectParts.join(', ')} FROM ${tableName}`;
88
+ let sql = `SELECT ${iqo.distinct ? 'DISTINCT ' : ''}${selectParts.join(', ')} FROM ${tableName}`;
85
89
 
86
90
  // JOIN clauses
87
91
  for (const j of iqo.joins) {
@@ -119,6 +123,10 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
119
123
  const [min, max] = w.value as [any, any];
120
124
  whereParts.push(`${qualify(w.field)} BETWEEN ? AND ?`);
121
125
  params.push(transformValueForStorage(min), transformValueForStorage(max));
126
+ } else if (w.operator === 'IS NULL') {
127
+ whereParts.push(`${qualify(w.field)} IS NULL`);
128
+ } else if (w.operator === 'IS NOT NULL') {
129
+ whereParts.push(`${qualify(w.field)} IS NOT NULL`);
122
130
  } else {
123
131
  whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
124
132
  params.push(transformValueForStorage(w.value));
@@ -159,6 +167,24 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
159
167
  sql += ` GROUP BY ${iqo.groupBy.join(', ')}`;
160
168
  }
161
169
 
170
+ // HAVING
171
+ if (iqo.having && iqo.having.length > 0) {
172
+ const havingParts: string[] = [];
173
+ for (const h of iqo.having) {
174
+ if (h.operator === 'IS NULL') {
175
+ havingParts.push(`${h.field} IS NULL`);
176
+ } else if (h.operator === 'IS NOT NULL') {
177
+ havingParts.push(`${h.field} IS NOT NULL`);
178
+ } else {
179
+ havingParts.push(`${h.field} ${h.operator} ?`);
180
+ params.push(transformValueForStorage(h.value));
181
+ }
182
+ }
183
+ if (havingParts.length > 0) {
184
+ sql += ` HAVING ${havingParts.join(' AND ')}`;
185
+ }
186
+ }
187
+
162
188
  // ORDER BY
163
189
  if (iqo.orderBy.length > 0) {
164
190
  const parts = iqo.orderBy.map(o => `${o.field} ${o.direction.toUpperCase()}`);
package/src/query.ts CHANGED
@@ -39,6 +39,7 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
39
39
  const schema = ctx.schemas[entityName]!;
40
40
 
41
41
  const executor = (sql: string, params: any[], raw: boolean): any[] => {
42
+ if (ctx.debug) console.log('[satidb]', sql, params);
42
43
  const rows = ctx.db.query(sql).all(...params);
43
44
  if (raw) return rows;
44
45
  return rows.map((row: any) => ctx.attachMethods(entityName, transformFromStorage(row, schema)));
@@ -132,5 +133,11 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
132
133
 
133
134
  const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, eagerLoader);
134
135
  if (initialCols.length > 0) builder.select(...initialCols);
136
+
137
+ // Auto-filter soft-deleted rows unless withTrashed() is called
138
+ if (ctx.softDeletes) {
139
+ builder.where({ deletedAt: { $isNull: true } });
140
+ }
141
+
135
142
  return builder;
136
143
  }
package/src/types.ts CHANGED
@@ -19,7 +19,6 @@ export const asZodObject = (s: z.ZodType<any>) => s as unknown as z.ZodObject<an
19
19
  /** Index definition: single column or composite columns */
20
20
  export type IndexDef = string | string[];
21
21
 
22
- /** Options for the Database constructor */
23
22
  export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
24
23
  indexes?: Record<string, IndexDef[]>;
25
24
  /**
@@ -42,6 +41,24 @@ export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
42
41
  * `.on()` will throw. Default: `true`.
43
42
  */
44
43
  reactive?: boolean;
44
+ /**
45
+ * Auto-add `createdAt` and `updatedAt` TEXT columns to every table.
46
+ * `createdAt` is set on insert, `updatedAt` on insert + update.
47
+ * Default: `false`.
48
+ */
49
+ timestamps?: boolean;
50
+ /**
51
+ * Enable soft deletes. Adds a `deletedAt` TEXT column to every table.
52
+ * `delete()` sets `deletedAt` instead of removing the row.
53
+ * Use `.withTrashed()` on queries to include soft-deleted rows.
54
+ * Default: `false`.
55
+ */
56
+ softDeletes?: boolean;
57
+ /**
58
+ * Log every SQL query to the console. Useful for debugging.
59
+ * Default: `false`.
60
+ */
61
+ debug?: boolean;
45
62
  };
46
63
 
47
64
  export type Relationship = {