local-lite 0.0.4 → 0.0.6

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 (36) hide show
  1. package/dist/adapters/local/better-sqlite3.d.ts +4 -1
  2. package/dist/adapters/local/better-sqlite3.js +34 -14
  3. package/dist/adapters/local/expo-sqlite.d.ts +10 -2
  4. package/dist/adapters/local/expo-sqlite.js +36 -20
  5. package/dist/adapters/local/node-sqlite.d.ts +4 -1
  6. package/dist/adapters/local/node-sqlite.js +29 -12
  7. package/dist/adapters/remote/firestore.js +39 -32
  8. package/dist/adapters/remote/supabase.d.ts +7 -2
  9. package/dist/adapters/remote/supabase.js +192 -85
  10. package/dist/config/types.d.ts +1 -1
  11. package/dist/core/client.d.ts +52 -15
  12. package/dist/core/client.js +195 -208
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/index.js +1 -0
  15. package/dist/core/outbox-store.d.ts +5 -0
  16. package/dist/core/outbox-store.js +29 -4
  17. package/dist/core/query-builder.d.ts +120 -0
  18. package/dist/core/query-builder.js +441 -0
  19. package/dist/core/query.js +28 -32
  20. package/dist/core/schema-manager.d.ts +4 -15
  21. package/dist/core/schema-manager.js +48 -103
  22. package/dist/core/sync-worker.js +7 -2
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/react/SyncInspector.js +0 -70
  26. package/dist/react/index.d.ts +39 -0
  27. package/dist/react/index.js +232 -34
  28. package/dist/react/status.d.ts +5 -0
  29. package/dist/react/status.js +20 -12
  30. package/dist/schema/generator.d.ts +49 -0
  31. package/dist/schema/generator.js +209 -0
  32. package/dist/schema/index.d.ts +1 -0
  33. package/dist/schema/index.js +17 -0
  34. package/dist/utils/uuid.d.ts +1 -1
  35. package/dist/utils/uuid.js +7 -16
  36. package/package.json +7 -2
@@ -0,0 +1,120 @@
1
+ import { LocalFirstClient, TransactionalClient } from './client.js';
2
+ import { QueryOptions, WhereFilter } from './types.js';
3
+ type ClientLike = LocalFirstClient | TransactionalClient;
4
+ /**
5
+ * Escapes an identifier or compound identifier (e.g. 'table.column' or 'table.col as alias').
6
+ */
7
+ export declare function formatColumnIdentifier(col: string): string;
8
+ export interface JoinDefinition {
9
+ type: 'INNER' | 'LEFT' | 'CROSS';
10
+ table: string;
11
+ firstCol: string;
12
+ secondCol: string;
13
+ }
14
+ export interface AggregateSpec {
15
+ where?: WhereFilter;
16
+ count?: boolean;
17
+ sum?: string[];
18
+ avg?: string[];
19
+ min?: string[];
20
+ max?: string[];
21
+ }
22
+ export interface AggregateResult {
23
+ count?: number;
24
+ sum?: Record<string, number>;
25
+ avg?: Record<string, number | null>;
26
+ min?: Record<string, any>;
27
+ max?: Record<string, any>;
28
+ }
29
+ /**
30
+ * Fluent, type-safe SQL Query Builder for local-first SQLite operations.
31
+ * Allows writing expressive JavaScript code that compiles and executes high-performance SQL under the hood.
32
+ */
33
+ export declare class QueryBuilder<T = any> {
34
+ private client;
35
+ private tableName;
36
+ private selectCols;
37
+ private joins;
38
+ private whereConditions;
39
+ private orderColumn?;
40
+ private orderDirection;
41
+ private limitCount?;
42
+ private offsetCount?;
43
+ private groupByCols;
44
+ private includeDeleted;
45
+ constructor(client: ClientLike, tableName: string);
46
+ /**
47
+ * Specifies which columns to select.
48
+ * Supports column aliases (e.g. ['id', 'contracts.rent_amount AS rent', 'houses.house_number']).
49
+ */
50
+ select(...columns: Array<string | string[]>): this;
51
+ /**
52
+ * Inner joins another SQLite table on a matching column expression.
53
+ */
54
+ join(table: string, firstCol: string, secondCol: string): this;
55
+ /**
56
+ * Left joins another SQLite table on a matching column expression.
57
+ */
58
+ leftJoin(table: string, firstCol: string, secondCol: string): this;
59
+ /**
60
+ * Adds WHERE criteria to filter results.
61
+ * Supports:
62
+ * - where({ status: 'ACTIVE' })
63
+ * - where('status', 'ACTIVE')
64
+ * - where('end_date', '>=', today)
65
+ */
66
+ where(columnOrFilter: string | WhereFilter, operatorOrValue?: any, value?: any): this;
67
+ /**
68
+ * Orders query results by column and direction.
69
+ */
70
+ orderBy(column: string, direction?: 'asc' | 'desc' | 'ASC' | 'DESC'): this;
71
+ /**
72
+ * Groups rows by one or more columns for aggregation.
73
+ */
74
+ groupBy(...columns: Array<string | string[]>): this;
75
+ /**
76
+ * Limits the number of returned records.
77
+ */
78
+ limit(count: number): this;
79
+ /**
80
+ * Offsets the starting record position.
81
+ */
82
+ offset(count: number): this;
83
+ /**
84
+ * Includes soft-deleted rows in results.
85
+ */
86
+ withDeleted(include?: boolean): this;
87
+ /**
88
+ * Applies standard QueryOptions (select, where, orderBy, limit, offset, includeDeleted) to this query.
89
+ */
90
+ applyOptions(options?: QueryOptions): this;
91
+ /**
92
+ * Compiles the current builder state into an executable SQL string and parameter list.
93
+ * Uses module-scoped compileComparison / compileWhereConditions helpers (defined once).
94
+ */
95
+ toSQL(): {
96
+ sql: string;
97
+ params: any[];
98
+ };
99
+ /**
100
+ * Executes the compiled SQL query against SQLite and returns rows.
101
+ */
102
+ execute(): Promise<T[]>;
103
+ /**
104
+ * Returns the first matching record or null if not found.
105
+ */
106
+ first(): Promise<T | null>;
107
+ /**
108
+ * Executes a COUNT(*) in SQLite and returns the numeric count directly.
109
+ */
110
+ count(): Promise<number>;
111
+ /**
112
+ * Executes a SUM(column) in SQLite and returns the numeric total directly.
113
+ */
114
+ sum(column: string): Promise<number>;
115
+ /**
116
+ * Executes an AVG(column) in SQLite and returns the average directly.
117
+ */
118
+ avg(column: string): Promise<number | null>;
119
+ }
120
+ export {};
@@ -0,0 +1,441 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QueryBuilder = void 0;
4
+ exports.formatColumnIdentifier = formatColumnIdentifier;
5
+ /**
6
+ * Escapes an identifier or compound identifier (e.g. 'table.column' or 'table.col as alias').
7
+ */
8
+ function formatColumnIdentifier(col) {
9
+ const trimmed = col.trim();
10
+ if (trimmed === '*')
11
+ return '*';
12
+ // Handle "expression AS alias"
13
+ const asMatch = trimmed.match(/^(.+?)\s+(?:AS|as)\s+([A-Za-z0-9_"]+)$/i);
14
+ if (asMatch) {
15
+ const [, expr, rawAlias] = asMatch;
16
+ const unquotedAlias = rawAlias.startsWith('"') && rawAlias.endsWith('"')
17
+ ? rawAlias.slice(1, -1)
18
+ : rawAlias;
19
+ const cleanAlias = `"${unquotedAlias.replace(/"/g, '""')}"`;
20
+ if (expr.includes('(') && expr.endsWith(')')) {
21
+ return `${expr} AS ${cleanAlias}`;
22
+ }
23
+ return `${formatColumnIdentifier(expr)} AS ${cleanAlias}`;
24
+ }
25
+ // Handle function calls without alias (e.g. COUNT(*))
26
+ if (trimmed.includes('(') && trimmed.endsWith(')')) {
27
+ return trimmed;
28
+ }
29
+ // Handle "table.column"
30
+ if (trimmed.includes('.')) {
31
+ return trimmed
32
+ .split('.')
33
+ .map((part) => `"${part.replace(/"/g, '""')}"`)
34
+ .join('.');
35
+ }
36
+ return `"${trimmed.replace(/"/g, '""')}"`;
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Module-scoped SQL compilation helpers (defined once, reused on every query)
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Compiles a single column comparison value into one or more SQL fragments.
43
+ * Supports null, boolean, scalar, and operator-object forms ($eq, $gt, $in, …).
44
+ */
45
+ function compileComparison(safeCol, val, params) {
46
+ if (val === null) {
47
+ return `${safeCol} IS NULL`;
48
+ }
49
+ if (typeof val === 'boolean') {
50
+ params.push(val ? 1 : 0);
51
+ return `${safeCol} = ?`;
52
+ }
53
+ if (typeof val === 'string' || typeof val === 'number') {
54
+ params.push(val);
55
+ return `${safeCol} = ?`;
56
+ }
57
+ if (typeof val === 'object' && !Array.isArray(val)) {
58
+ const ops = val;
59
+ const subClauses = [];
60
+ const eqVal = ops.$eq !== undefined ? ops.$eq : ops.eq;
61
+ if (eqVal !== undefined) {
62
+ subClauses.push(`${safeCol} = ?`);
63
+ params.push(typeof eqVal === 'boolean' ? (eqVal ? 1 : 0) : eqVal);
64
+ }
65
+ const neVal = ops.$ne !== undefined ? ops.$ne : ops.ne;
66
+ if (neVal !== undefined) {
67
+ subClauses.push(`${safeCol} != ?`);
68
+ params.push(typeof neVal === 'boolean' ? (neVal ? 1 : 0) : neVal);
69
+ }
70
+ const gtVal = ops.$gt !== undefined ? ops.$gt : ops.gt;
71
+ if (gtVal !== undefined) {
72
+ subClauses.push(`${safeCol} > ?`);
73
+ params.push(gtVal);
74
+ }
75
+ const gteVal = ops.$gte !== undefined ? ops.$gte : ops.gte;
76
+ if (gteVal !== undefined) {
77
+ subClauses.push(`${safeCol} >= ?`);
78
+ params.push(gteVal);
79
+ }
80
+ const ltVal = ops.$lt !== undefined ? ops.$lt : ops.lt;
81
+ if (ltVal !== undefined) {
82
+ subClauses.push(`${safeCol} < ?`);
83
+ params.push(ltVal);
84
+ }
85
+ const lteVal = ops.$lte !== undefined ? ops.$lte : ops.lte;
86
+ if (lteVal !== undefined) {
87
+ subClauses.push(`${safeCol} <= ?`);
88
+ params.push(lteVal);
89
+ }
90
+ const inVal = ops.$in !== undefined ? ops.$in : ops.in;
91
+ if (Array.isArray(inVal) && inVal.length > 0) {
92
+ const placeholders = inVal.map(() => '?').join(', ');
93
+ subClauses.push(`${safeCol} IN (${placeholders})`);
94
+ params.push(...inVal);
95
+ }
96
+ const ninVal = ops.$nin !== undefined ? ops.$nin : ops.nin;
97
+ if (Array.isArray(ninVal) && ninVal.length > 0) {
98
+ const placeholders = ninVal.map(() => '?').join(', ');
99
+ subClauses.push(`${safeCol} NOT IN (${placeholders})`);
100
+ params.push(...ninVal);
101
+ }
102
+ const containsVal = ops.$contains !== undefined ? ops.$contains : ops.contains;
103
+ if (containsVal !== undefined) {
104
+ subClauses.push(`${safeCol} LIKE ?`);
105
+ params.push(`%${containsVal}%`);
106
+ }
107
+ const startsWithVal = ops.$startsWith !== undefined ? ops.$startsWith : ops.startsWith;
108
+ if (startsWithVal !== undefined) {
109
+ subClauses.push(`${safeCol} LIKE ?`);
110
+ params.push(`${startsWithVal}%`);
111
+ }
112
+ const endsWithVal = ops.$endsWith !== undefined ? ops.$endsWith : ops.endsWith;
113
+ if (endsWithVal !== undefined) {
114
+ subClauses.push(`${safeCol} LIKE ?`);
115
+ params.push(`%${endsWithVal}`);
116
+ }
117
+ const nullVal = ops.$null !== undefined ? ops.$null : ops.null;
118
+ if (nullVal !== undefined) {
119
+ subClauses.push(nullVal ? `${safeCol} IS NULL` : `${safeCol} IS NOT NULL`);
120
+ }
121
+ if (subClauses.length > 0) {
122
+ return subClauses.join(' AND ');
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ /**
128
+ * Recursively compiles a where-conditions object (including $or / $and) into SQL clauses.
129
+ */
130
+ function compileWhereConditions(where, params) {
131
+ const clauses = [];
132
+ // Handle $or — compile each branch, join with OR
133
+ if (Array.isArray(where.$or) && where.$or.length > 0) {
134
+ const orClauses = [];
135
+ for (const sub of where.$or) {
136
+ if (typeof sub === 'object' && sub !== null) {
137
+ const subList = compileWhereConditions(sub, params);
138
+ if (subList.length > 0) {
139
+ orClauses.push(`(${subList.join(' AND ')})`);
140
+ }
141
+ }
142
+ }
143
+ if (orClauses.length > 0) {
144
+ clauses.push(`(${orClauses.join(' OR ')})`);
145
+ }
146
+ }
147
+ // Handle $and — compile each branch, join with AND
148
+ if (Array.isArray(where.$and) && where.$and.length > 0) {
149
+ const andClauses = [];
150
+ for (const sub of where.$and) {
151
+ if (typeof sub === 'object' && sub !== null) {
152
+ const subList = compileWhereConditions(sub, params);
153
+ if (subList.length > 0) {
154
+ andClauses.push(`(${subList.join(' AND ')})`);
155
+ }
156
+ }
157
+ }
158
+ if (andClauses.length > 0) {
159
+ clauses.push(`(${andClauses.join(' AND ')})`);
160
+ }
161
+ }
162
+ // Handle regular column conditions
163
+ for (const [col, val] of Object.entries(where)) {
164
+ if (col === '$or' || col === '$and')
165
+ continue;
166
+ const safeCol = formatColumnIdentifier(col);
167
+ const clause = compileComparison(safeCol, val, params);
168
+ if (clause) {
169
+ clauses.push(clause);
170
+ }
171
+ }
172
+ return clauses;
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ /**
176
+ * Fluent, type-safe SQL Query Builder for local-first SQLite operations.
177
+ * Allows writing expressive JavaScript code that compiles and executes high-performance SQL under the hood.
178
+ */
179
+ class QueryBuilder {
180
+ client;
181
+ tableName;
182
+ selectCols = ['*'];
183
+ joins = [];
184
+ whereConditions = {};
185
+ orderColumn;
186
+ orderDirection = 'ASC';
187
+ limitCount;
188
+ offsetCount;
189
+ groupByCols = [];
190
+ includeDeleted = false;
191
+ constructor(client, tableName) {
192
+ this.client = client;
193
+ this.tableName = tableName;
194
+ }
195
+ /**
196
+ * Specifies which columns to select.
197
+ * Supports column aliases (e.g. ['id', 'contracts.rent_amount AS rent', 'houses.house_number']).
198
+ */
199
+ select(...columns) {
200
+ const flat = columns.flat();
201
+ if (flat.length > 0) {
202
+ this.selectCols = flat;
203
+ }
204
+ return this;
205
+ }
206
+ /**
207
+ * Inner joins another SQLite table on a matching column expression.
208
+ */
209
+ join(table, firstCol, secondCol) {
210
+ this.joins.push({ type: 'INNER', table, firstCol, secondCol });
211
+ return this;
212
+ }
213
+ /**
214
+ * Left joins another SQLite table on a matching column expression.
215
+ */
216
+ leftJoin(table, firstCol, secondCol) {
217
+ this.joins.push({ type: 'LEFT', table, firstCol, secondCol });
218
+ return this;
219
+ }
220
+ /**
221
+ * Adds WHERE criteria to filter results.
222
+ * Supports:
223
+ * - where({ status: 'ACTIVE' })
224
+ * - where('status', 'ACTIVE')
225
+ * - where('end_date', '>=', today)
226
+ */
227
+ where(columnOrFilter, operatorOrValue, value) {
228
+ if (typeof columnOrFilter === 'string') {
229
+ if (value !== undefined) {
230
+ const rawOp = String(operatorOrValue).toLowerCase().trim();
231
+ const opKey = rawOp === '>=' ? '$gte' :
232
+ rawOp === '<=' ? '$lte' :
233
+ rawOp === '>' ? '$gt' :
234
+ rawOp === '<' ? '$lt' :
235
+ rawOp === '!=' || rawOp === '<>' ? '$ne' :
236
+ rawOp === '=' || rawOp === '==' ? '$eq' :
237
+ rawOp === 'like' ? '$contains' : '$eq';
238
+ const existing = typeof this.whereConditions[columnOrFilter] === 'object' &&
239
+ this.whereConditions[columnOrFilter] !== null
240
+ ? this.whereConditions[columnOrFilter]
241
+ : {};
242
+ this.whereConditions[columnOrFilter] = { ...existing, [opKey]: value };
243
+ }
244
+ else {
245
+ this.whereConditions[columnOrFilter] = operatorOrValue;
246
+ }
247
+ }
248
+ else if (columnOrFilter && typeof columnOrFilter === 'object') {
249
+ this.whereConditions = { ...this.whereConditions, ...columnOrFilter };
250
+ }
251
+ return this;
252
+ }
253
+ /**
254
+ * Orders query results by column and direction.
255
+ */
256
+ orderBy(column, direction = 'ASC') {
257
+ this.orderColumn = column;
258
+ this.orderDirection = direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
259
+ return this;
260
+ }
261
+ /**
262
+ * Groups rows by one or more columns for aggregation.
263
+ */
264
+ groupBy(...columns) {
265
+ this.groupByCols = columns.flat();
266
+ return this;
267
+ }
268
+ /**
269
+ * Limits the number of returned records.
270
+ */
271
+ limit(count) {
272
+ this.limitCount = count;
273
+ return this;
274
+ }
275
+ /**
276
+ * Offsets the starting record position.
277
+ */
278
+ offset(count) {
279
+ this.offsetCount = count;
280
+ return this;
281
+ }
282
+ /**
283
+ * Includes soft-deleted rows in results.
284
+ */
285
+ withDeleted(include = true) {
286
+ this.includeDeleted = include;
287
+ return this;
288
+ }
289
+ /**
290
+ * Applies standard QueryOptions (select, where, orderBy, limit, offset, includeDeleted) to this query.
291
+ */
292
+ applyOptions(options) {
293
+ if (!options)
294
+ return this;
295
+ if (options.select)
296
+ this.select(options.select);
297
+ if (options.where && typeof options.where === 'object')
298
+ this.where(options.where);
299
+ if (options.orderBy)
300
+ this.orderBy(options.orderBy, options.order || 'ASC');
301
+ if (typeof options.limit === 'number')
302
+ this.limit(options.limit);
303
+ if (typeof options.offset === 'number')
304
+ this.offset(options.offset);
305
+ if (options.includeDeleted)
306
+ this.withDeleted(true);
307
+ return this;
308
+ }
309
+ /**
310
+ * Compiles the current builder state into an executable SQL string and parameter list.
311
+ * Uses module-scoped compileComparison / compileWhereConditions helpers (defined once).
312
+ */
313
+ toSQL() {
314
+ const params = [];
315
+ const projections = this.selectCols.map(formatColumnIdentifier).join(', ');
316
+ const fromTable = `"${this.tableName.replace(/"/g, '""')}"`;
317
+ let sql = `SELECT ${projections} FROM ${fromTable}`;
318
+ // Append JOINs
319
+ for (const j of this.joins) {
320
+ const joinTable = `"${j.table.replace(/"/g, '""')}"`;
321
+ const col1 = formatColumnIdentifier(j.firstCol);
322
+ const col2 = formatColumnIdentifier(j.secondCol);
323
+ sql += ` ${j.type} JOIN ${joinTable} ON ${col1} = ${col2}`;
324
+ }
325
+ // Build WHERE clauses (delegates to module-scoped helpers)
326
+ const whereClauses = [];
327
+ if (this.whereConditions && typeof this.whereConditions === 'object') {
328
+ whereClauses.push(...compileWhereConditions(this.whereConditions, params));
329
+ }
330
+ // Push down soft-deletion check on primary table if deleted column exists and not explicitly overridden
331
+ const schemaManager = 'schemaManager' in this.client
332
+ ? this.client.schemaManager
333
+ : this.client.parent?.schemaManager;
334
+ if (!this.includeDeleted && schemaManager?.hasColumn(this.tableName, 'deleted')) {
335
+ const delCol = this.tableName.includes('.')
336
+ ? `${formatColumnIdentifier(this.tableName)}."deleted"`
337
+ : `"${this.tableName.replace(/"/g, '""')}"."deleted"`;
338
+ whereClauses.push(`(${delCol} IS NULL OR (${delCol} != 1 AND ${delCol} != 'true'))`);
339
+ }
340
+ if (whereClauses.length > 0) {
341
+ sql += ` WHERE ${whereClauses.join(' AND ')}`;
342
+ }
343
+ // GROUP BY
344
+ if (this.groupByCols.length > 0) {
345
+ sql += ` GROUP BY ${this.groupByCols.map(formatColumnIdentifier).join(', ')}`;
346
+ }
347
+ // ORDER BY
348
+ if (this.orderColumn) {
349
+ sql += ` ORDER BY ${formatColumnIdentifier(this.orderColumn)} ${this.orderDirection}`;
350
+ }
351
+ // LIMIT & OFFSET
352
+ if (typeof this.limitCount === 'number' && this.limitCount > 0) {
353
+ sql += ` LIMIT ?`;
354
+ params.push(this.limitCount);
355
+ if (typeof this.offsetCount === 'number' && this.offsetCount > 0) {
356
+ sql += ` OFFSET ?`;
357
+ params.push(this.offsetCount);
358
+ }
359
+ }
360
+ return { sql, params };
361
+ }
362
+ /**
363
+ * Executes the compiled SQL query against SQLite and returns rows.
364
+ */
365
+ async execute() {
366
+ const schemaManager = 'schemaManager' in this.client
367
+ ? this.client.schemaManager
368
+ : this.client.parent?.schemaManager;
369
+ const db = 'db' in this.client ? this.client.db : this.client.tx;
370
+ if (schemaManager && db) {
371
+ await schemaManager.ensureTableIntrospected(db, this.tableName);
372
+ for (const j of this.joins) {
373
+ await schemaManager.ensureTableIntrospected(db, j.table);
374
+ }
375
+ }
376
+ const { sql, params } = this.toSQL();
377
+ try {
378
+ const rawRows = await this.client.query(sql, params);
379
+ const hydrator = 'hydrateRow' in this.client
380
+ ? this.client.hydrateRow.bind(this.client)
381
+ : this.client.parent?.hydrateRow?.bind(this.client.parent);
382
+ if (hydrator && rawRows && rawRows.length > 0) {
383
+ return rawRows.map((r) => hydrator(this.tableName, r));
384
+ }
385
+ return rawRows;
386
+ }
387
+ catch (err) {
388
+ const msg = String(err?.message || err || '');
389
+ if (msg.includes('no such table') || msg.includes('no such column')) {
390
+ return [];
391
+ }
392
+ throw err;
393
+ }
394
+ }
395
+ /**
396
+ * Returns the first matching record or null if not found.
397
+ */
398
+ async first() {
399
+ const originalLimit = this.limitCount;
400
+ this.limitCount = 1;
401
+ const rows = await this.execute();
402
+ this.limitCount = originalLimit;
403
+ return rows.length > 0 ? rows[0] : null;
404
+ }
405
+ /**
406
+ * Executes a COUNT(*) in SQLite and returns the numeric count directly.
407
+ */
408
+ async count() {
409
+ const originalSelect = this.selectCols;
410
+ this.selectCols = ['COUNT(*) AS _count'];
411
+ const rows = await this.execute();
412
+ this.selectCols = originalSelect;
413
+ const countVal = rows[0]?._count;
414
+ return typeof countVal === 'number' ? countVal : Number(countVal) || 0;
415
+ }
416
+ /**
417
+ * Executes a SUM(column) in SQLite and returns the numeric total directly.
418
+ */
419
+ async sum(column) {
420
+ const originalSelect = this.selectCols;
421
+ const safeCol = formatColumnIdentifier(column);
422
+ this.selectCols = [`COALESCE(SUM(${safeCol}), 0) AS _sum`];
423
+ const rows = await this.execute();
424
+ this.selectCols = originalSelect;
425
+ const sumVal = rows[0]?._sum;
426
+ return typeof sumVal === 'number' ? sumVal : Number(sumVal) || 0;
427
+ }
428
+ /**
429
+ * Executes an AVG(column) in SQLite and returns the average directly.
430
+ */
431
+ async avg(column) {
432
+ const originalSelect = this.selectCols;
433
+ const safeCol = formatColumnIdentifier(column);
434
+ this.selectCols = [`AVG(${safeCol}) AS _avg`];
435
+ const rows = await this.execute();
436
+ this.selectCols = originalSelect;
437
+ const avgVal = rows[0]?._avg;
438
+ return avgVal === null || avgVal === undefined ? null : Number(avgVal) || 0;
439
+ }
440
+ }
441
+ exports.QueryBuilder = QueryBuilder;
@@ -48,26 +48,14 @@ function matchesField(fieldValue, filter) {
48
48
  if (ops.$lte !== undefined && !(fieldValue <= ops.$lte)) {
49
49
  return false;
50
50
  }
51
- if (ops.$contains !== undefined) {
52
- const str = fieldValue === null || fieldValue === undefined ? '' : String(fieldValue).toLowerCase();
53
- const search = String(ops.$contains).toLowerCase();
54
- if (!str.includes(search)) {
51
+ if (ops.$contains !== undefined || ops.$startsWith !== undefined || ops.$endsWith !== undefined) {
52
+ const str = fieldValue == null ? '' : String(fieldValue).toLowerCase();
53
+ if (ops.$contains !== undefined && !str.includes(String(ops.$contains).toLowerCase()))
55
54
  return false;
56
- }
57
- }
58
- if (ops.$startsWith !== undefined) {
59
- const str = fieldValue === null || fieldValue === undefined ? '' : String(fieldValue).toLowerCase();
60
- const prefix = String(ops.$startsWith).toLowerCase();
61
- if (!str.startsWith(prefix)) {
55
+ if (ops.$startsWith !== undefined && !str.startsWith(String(ops.$startsWith).toLowerCase()))
62
56
  return false;
63
- }
64
- }
65
- if (ops.$endsWith !== undefined) {
66
- const str = fieldValue === null || fieldValue === undefined ? '' : String(fieldValue).toLowerCase();
67
- const suffix = String(ops.$endsWith).toLowerCase();
68
- if (!str.endsWith(suffix)) {
57
+ if (ops.$endsWith !== undefined && !str.endsWith(String(ops.$endsWith).toLowerCase()))
69
58
  return false;
70
- }
71
59
  }
72
60
  if (ops.$null !== undefined) {
73
61
  const isNull = fieldValue === null || fieldValue === undefined;
@@ -131,18 +119,28 @@ function hasExplicitDeletedFilter(where) {
131
119
  function applyQueryOptions(rows, options) {
132
120
  if (!rows || rows.length === 0)
133
121
  return [];
134
- let result = [...rows];
135
- // 0. Automatic soft-delete filtering: omit deleted records unless includeDeleted is true
136
- // or the where filter explicitly targets 'deleted'.
137
- if (!options?.includeDeleted && !hasExplicitDeletedFilter(options?.where)) {
122
+ const needsDeleteFilter = !options?.includeDeleted && !hasExplicitDeletedFilter(options?.where);
123
+ const needsWhere = Boolean(options?.where);
124
+ const needsSort = Boolean(options?.orderBy);
125
+ const needsSlice = (options?.offset !== undefined && options.offset > 0) ||
126
+ (options?.limit !== undefined && options.limit > 0);
127
+ const needsSelect = Array.isArray(options?.select) && options.select.length > 0;
128
+ // Fast-path: nothing to do
129
+ if (!needsDeleteFilter && !needsWhere && !needsSort && !needsSlice && !needsSelect) {
130
+ return rows;
131
+ }
132
+ let result = rows;
133
+ // 0. Soft-delete filtering
134
+ if (needsDeleteFilter) {
138
135
  result = result.filter((row) => !isDeletedRecord(row));
139
136
  }
140
137
  // 1. Where filtering
141
- if (options?.where) {
138
+ if (needsWhere) {
142
139
  result = result.filter((row) => matchesWhere(row, options.where));
143
140
  }
144
141
  // 2. Order by
145
- if (options?.orderBy) {
142
+ if (needsSort) {
143
+ result = [...result]; // copy only before sort mutation
146
144
  const key = options.orderBy;
147
145
  const isAsc = (options.order || 'asc').toLowerCase() === 'asc';
148
146
  result.sort((a, b) => {
@@ -164,21 +162,19 @@ function applyQueryOptions(rows, options) {
164
162
  });
165
163
  }
166
164
  // 3. Offset and Limit
167
- if (options?.offset !== undefined && options.offset > 0) {
168
- result = result.slice(options.offset);
169
- }
170
- if (options?.limit !== undefined && options.limit > 0) {
171
- result = result.slice(0, options.limit);
165
+ if (needsSlice) {
166
+ const offset = options?.offset && options.offset > 0 ? options.offset : 0;
167
+ const limit = options?.limit && options.limit > 0 ? options.limit : undefined;
168
+ result = limit !== undefined ? result.slice(offset, offset + limit) : result.slice(offset);
172
169
  }
173
- // 4. Column Projection
174
- if (options?.select && Array.isArray(options.select) && options.select.length > 0) {
170
+ // 4. Column projection
171
+ if (needsSelect) {
175
172
  const fields = options.select;
176
173
  result = result.map((row) => {
177
174
  const projected = {};
178
175
  for (const field of fields) {
179
- if (field in row) {
176
+ if (field in row)
180
177
  projected[field] = row[field];
181
- }
182
178
  }
183
179
  return projected;
184
180
  });