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/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,12 +83,16 @@ 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();
83
92
  if (this._reactive) this.initializeChangeTracking();
84
93
  this.runMigrations();
85
94
  if (options.indexes) this.createIndexes(options.indexes);
95
+ if (options.unique) this.createUniqueConstraints(options.unique);
86
96
 
87
97
  // Create typed entity accessors (db.users, db.posts, etc.)
88
98
  for (const entityName of Object.keys(schemas)) {
@@ -96,9 +106,22 @@ class _Database<Schemas extends SchemaMap> {
96
106
  },
97
107
  upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
98
108
  delete: ((id?: any) => {
99
- if (typeof id === 'number') return deleteEntity(this._ctx, entityName, id);
109
+ if (typeof id === 'number') {
110
+ if (this._softDeletes) {
111
+ // Soft delete: set deletedAt instead of removing
112
+ const now = new Date().toISOString();
113
+ this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
114
+ return;
115
+ }
116
+ return deleteEntity(this._ctx, entityName, id);
117
+ }
100
118
  return createDeleteBuilder(this._ctx, entityName);
101
119
  }) as any,
120
+ restore: ((id: number) => {
121
+ if (!this._softDeletes) throw new Error('restore() requires softDeletes: true');
122
+ if (this._debug) console.log('[satidb]', `UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`, [id]);
123
+ this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
124
+ }) as any,
102
125
  select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
103
126
  on: (event: ChangeEvent, callback: (row: any) => void | Promise<void>) => {
104
127
  return this._registerListener(entityName, event, callback);
@@ -117,6 +140,17 @@ class _Database<Schemas extends SchemaMap> {
117
140
  for (const [entityName, schema] of Object.entries(this.schemas)) {
118
141
  const storableFields = getStorableFields(schema);
119
142
  const columnDefs = storableFields.map(f => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
143
+
144
+ // Add timestamp columns
145
+ if (this._timestamps) {
146
+ columnDefs.push('"createdAt" TEXT');
147
+ columnDefs.push('"updatedAt" TEXT');
148
+ }
149
+ // Add soft delete column
150
+ if (this._softDeletes) {
151
+ columnDefs.push('"deletedAt" TEXT');
152
+ }
153
+
120
154
  const constraints: string[] = [];
121
155
 
122
156
  const belongsToRels = this.relationships.filter(
@@ -200,6 +234,15 @@ class _Database<Schemas extends SchemaMap> {
200
234
  }
201
235
  }
202
236
 
237
+ private createUniqueConstraints(unique: Record<string, string[][]>): void {
238
+ for (const [tableName, groups] of Object.entries(unique)) {
239
+ for (const cols of groups) {
240
+ const idxName = `uq_${tableName}_${cols.join('_')}`;
241
+ this.db.run(`CREATE UNIQUE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map(c => `"${c}"`).join(', ')})`);
242
+ }
243
+ }
244
+ }
245
+
203
246
  // =========================================================================
204
247
  // Change Listeners — db.table.on('insert' | 'update' | 'delete', cb)
205
248
  // =========================================================================
@@ -278,7 +321,7 @@ class _Database<Schemas extends SchemaMap> {
278
321
  }
279
322
 
280
323
  // Clean up consumed changes
281
- this.db.run('DELETE FROM "_changes" WHERE id <= ?', this._changeWatermark);
324
+ this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
282
325
  }
283
326
 
284
327
  // =========================================================================
@@ -306,9 +349,42 @@ class _Database<Schemas extends SchemaMap> {
306
349
  return executeProxyQuery(
307
350
  this.schemas,
308
351
  callback as any,
309
- (sql: string, params: any[]) => this.db.query(sql).all(...params) as T[],
352
+ (sql: string, params: any[]) => {
353
+ if (this._debug) console.log('[satidb]', sql, params);
354
+ return this.db.query(sql).all(...params) as T[];
355
+ },
310
356
  );
311
357
  }
358
+
359
+ // =========================================================================
360
+ // Raw SQL
361
+ // =========================================================================
362
+
363
+ /** Execute a raw SQL query and return results. */
364
+ public raw<T = any>(sql: string, ...params: any[]): T[] {
365
+ if (this._debug) console.log('[satidb]', sql, params);
366
+ return this.db.query(sql).all(...params) as T[];
367
+ }
368
+
369
+ /** Execute a raw SQL statement (INSERT/UPDATE/DELETE) without returning rows. */
370
+ public exec(sql: string, ...params: any[]): void {
371
+ if (this._debug) console.log('[satidb]', sql, params);
372
+ this.db.run(sql, ...params);
373
+ }
374
+
375
+ // =========================================================================
376
+ // Schema Introspection
377
+ // =========================================================================
378
+
379
+ /** Return the list of user-defined table names. */
380
+ public tables(): string[] {
381
+ return Object.keys(this.schemas);
382
+ }
383
+
384
+ /** Return column info for a table via PRAGMA table_info. */
385
+ public columns(tableName: string): { name: string; type: string; notnull: number; pk: number }[] {
386
+ return this.db.query(`PRAGMA table_info("${tableName}")`).all() as any[];
387
+ }
312
388
  }
313
389
 
314
390
  // =============================================================================
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,9 +19,14 @@ 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[]>;
24
+ /**
25
+ * Unique constraints per table. Each entry is an array of column groups.
26
+ * Single column: `{ users: [['email']] }` → UNIQUE INDEX.
27
+ * Compound: `{ users: [['email'], ['name', 'org_id']] }` → two UNIQUE indexes.
28
+ */
29
+ unique?: Record<string, string[][]>;
25
30
  /**
26
31
  * Declare relationships between tables.
27
32
  *
@@ -42,6 +47,24 @@ export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
42
47
  * `.on()` will throw. Default: `true`.
43
48
  */
44
49
  reactive?: boolean;
50
+ /**
51
+ * Auto-add `createdAt` and `updatedAt` TEXT columns to every table.
52
+ * `createdAt` is set on insert, `updatedAt` on insert + update.
53
+ * Default: `false`.
54
+ */
55
+ timestamps?: boolean;
56
+ /**
57
+ * Enable soft deletes. Adds a `deletedAt` TEXT column to every table.
58
+ * `delete()` sets `deletedAt` instead of removing the row.
59
+ * Use `.withTrashed()` on queries to include soft-deleted rows.
60
+ * Default: `false`.
61
+ */
62
+ softDeletes?: boolean;
63
+ /**
64
+ * Log every SQL query to the console. Useful for debugging.
65
+ * Default: `false`.
66
+ */
67
+ debug?: boolean;
45
68
  };
46
69
 
47
70
  export type Relationship = {
@@ -162,6 +185,7 @@ export type NavEntityAccessor<
162
185
  & ((data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => UpdateBuilder<NavEntity<S, R, Table>>);
163
186
  upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
164
187
  delete: ((id: number) => void) & (() => DeleteBuilder<NavEntity<S, R, Table>>);
188
+ restore: (id: number) => void;
165
189
  select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
166
190
  on: ((event: 'insert' | 'update', callback: (row: NavEntity<S, R, Table>) => void | Promise<void>) => () => void) &
167
191
  ((event: 'delete', callback: (row: { id: number }) => void | Promise<void>) => () => void);
@@ -192,6 +216,8 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
192
216
  update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
193
217
  upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
194
218
  delete: ((id: number) => void) & (() => DeleteBuilder<AugmentedEntity<S>>);
219
+ /** Undo a soft delete by setting deletedAt = null. Requires softDeletes. */
220
+ restore: (id: number) => void;
195
221
  select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
196
222
  on: ((event: 'insert' | 'update', callback: (row: AugmentedEntity<S>) => void | Promise<void>) => () => void) &
197
223
  ((event: 'delete', callback: (row: { id: number }) => void | Promise<void>) => () => void);