local-lite 0.0.5 → 0.0.7

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.
@@ -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
  });
@@ -3,15 +3,6 @@ export interface DBExecutable {
3
3
  }
4
4
  /**
5
5
  * Detects the appropriate SQLite column type affinity from a JavaScript runtime value.
6
- *
7
- * Mapping:
8
- * - boolean -> INTEGER (0 or 1)
9
- * - integer -> INTEGER
10
- * - float/double -> REAL
11
- * - string -> TEXT
12
- * - object/array -> TEXT (JSON formatted)
13
- * - Uint8Array -> BLOB
14
- * - null/undefined -> TEXT (flexible SQLite type affinity)
15
6
  */
16
7
  export declare function detectSQLiteType(value: any): 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB';
17
8
  /**
@@ -24,18 +15,16 @@ export declare class SchemaManager {
24
15
  private knownTables;
25
16
  private knownColumns;
26
17
  private knownIndexes;
27
- /**
28
- * Resets the schema cache (useful for testing or full database resets).
29
- */
30
18
  clearCache(): void;
31
- /**
32
- * Checks whether a column is known to exist on the specified table.
33
- */
34
19
  hasColumn(tableName: string, column: string): boolean;
35
20
  /**
36
21
  * Introspects table columns via PRAGMA table_info if not previously cached.
37
22
  */
38
23
  ensureTableIntrospected(db: DBExecutable, tableName: string): Promise<void>;
24
+ /**
25
+ * Automatically creates single-column and composite FK+deleted indexes for given columns.
26
+ */
27
+ private autoIndexColumns;
39
28
  /**
40
29
  * Ensures that the target table exists in SQLite with the specified primary key.
41
30
  */
@@ -6,36 +6,14 @@ exports.formatValueForStorage = formatValueForStorage;
6
6
  const sql_js_1 = require("../utils/sql.js");
7
7
  /**
8
8
  * Detects the appropriate SQLite column type affinity from a JavaScript runtime value.
9
- *
10
- * Mapping:
11
- * - boolean -> INTEGER (0 or 1)
12
- * - integer -> INTEGER
13
- * - float/double -> REAL
14
- * - string -> TEXT
15
- * - object/array -> TEXT (JSON formatted)
16
- * - Uint8Array -> BLOB
17
- * - null/undefined -> TEXT (flexible SQLite type affinity)
18
9
  */
19
10
  function detectSQLiteType(value) {
20
- if (value === null || value === undefined) {
21
- return 'TEXT';
22
- }
23
- const type = typeof value;
24
- if (type === 'boolean') {
11
+ if (typeof value === 'boolean')
25
12
  return 'INTEGER';
26
- }
27
- if (type === 'number') {
13
+ if (typeof value === 'number')
28
14
  return Number.isInteger(value) ? 'INTEGER' : 'REAL';
29
- }
30
- if (type === 'string') {
31
- return 'TEXT';
32
- }
33
- if (value instanceof Uint8Array) {
15
+ if (value instanceof Uint8Array)
34
16
  return 'BLOB';
35
- }
36
- if (type === 'object') {
37
- return 'TEXT';
38
- }
39
17
  return 'TEXT';
40
18
  }
41
19
  /**
@@ -44,32 +22,23 @@ function detectSQLiteType(value) {
44
22
  * - Plain objects and arrays are JSON serialized
45
23
  */
46
24
  function formatValueForStorage(value) {
47
- if (value === null || value === undefined) {
25
+ if (value === null || value === undefined)
48
26
  return null;
49
- }
50
- if (typeof value === 'boolean') {
27
+ if (typeof value === 'boolean')
51
28
  return value ? 1 : 0;
52
- }
53
- if (typeof value === 'object' && !(value instanceof Uint8Array)) {
29
+ if (typeof value === 'object' && !(value instanceof Uint8Array))
54
30
  return JSON.stringify(value);
55
- }
56
31
  return value;
57
32
  }
58
33
  class SchemaManager {
59
34
  knownTables = new Set();
60
35
  knownColumns = new Set();
61
36
  knownIndexes = new Set();
62
- /**
63
- * Resets the schema cache (useful for testing or full database resets).
64
- */
65
37
  clearCache() {
66
38
  this.knownTables.clear();
67
39
  this.knownColumns.clear();
68
40
  this.knownIndexes.clear();
69
41
  }
70
- /**
71
- * Checks whether a column is known to exist on the specified table.
72
- */
73
42
  hasColumn(tableName, column) {
74
43
  return this.knownColumns.has(`${tableName}:${column.toLowerCase()}`);
75
44
  }
@@ -84,57 +53,54 @@ class SchemaManager {
84
53
  const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
85
54
  try {
86
55
  const tableInfo = await db.execute(`PRAGMA table_info(${safeTable});`);
56
+ const cols = [];
87
57
  if (Array.isArray(tableInfo)) {
88
58
  for (const col of tableInfo) {
89
- if (col && col.name) {
59
+ if (col?.name) {
90
60
  this.knownColumns.add(`${tableName}:${String(col.name).toLowerCase()}`);
61
+ cols.push(String(col.name));
91
62
  }
92
63
  }
93
64
  }
94
- // Auto-create high performance single and composite indexes for foreign keys (_id) and soft-deletes (deleted)
95
- const hasDeleted = this.knownColumns.has(`${tableName}:deleted`);
96
- for (const entry of this.knownColumns) {
97
- if (!entry.startsWith(`${tableName}:`))
98
- continue;
99
- const colName = entry.slice(tableName.length + 1);
100
- if (colName.endsWith('_id') && colName !== 'id') {
101
- const fkIndexKey = `${tableName}:${colName}`;
102
- if (!this.knownIndexes.has(fkIndexKey)) {
103
- this.knownIndexes.add(fkIndexKey);
104
- try {
105
- const idxName = (0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${colName}`);
106
- const safeFk = (0, sql_js_1.escapeIdentifier)(colName);
107
- await db.execute(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${safeTable} (${safeFk});`);
108
- }
109
- catch { }
65
+ await this.autoIndexColumns(db, tableName, safeTable, cols);
66
+ }
67
+ catch { }
68
+ }
69
+ /**
70
+ * Automatically creates single-column and composite FK+deleted indexes for given columns.
71
+ */
72
+ async autoIndexColumns(db, tableName, safeTable, columns) {
73
+ const hasDeleted = this.knownColumns.has(`${tableName}:deleted`);
74
+ for (const key of columns) {
75
+ const lower = key.toLowerCase();
76
+ if (lower.endsWith('_id') || lower === 'deleted' || lower === 'created_at' || lower === 'updated_at') {
77
+ const indexKey = `${tableName}:${lower}`;
78
+ if (!this.knownIndexes.has(indexKey)) {
79
+ this.knownIndexes.add(indexKey);
80
+ try {
81
+ await db.execute(`CREATE INDEX IF NOT EXISTS ${(0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${key}`)} ON ${safeTable} (${(0, sql_js_1.escapeIdentifier)(key)});`);
110
82
  }
111
- if (hasDeleted) {
112
- const compositeKey = `${tableName}:${colName}_deleted`;
113
- if (!this.knownIndexes.has(compositeKey)) {
114
- this.knownIndexes.add(compositeKey);
115
- try {
116
- const idxName = (0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${colName}_del`);
117
- const safeFk = (0, sql_js_1.escapeIdentifier)(colName);
118
- const safeDel = (0, sql_js_1.escapeIdentifier)('deleted');
119
- await db.execute(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${safeTable} (${safeFk}, ${safeDel});`);
120
- }
121
- catch { }
122
- }
83
+ catch { }
84
+ }
85
+ }
86
+ if (hasDeleted && lower.endsWith('_id') && lower !== 'id') {
87
+ const compositeKey = `${tableName}:${lower}_deleted`;
88
+ if (!this.knownIndexes.has(compositeKey)) {
89
+ this.knownIndexes.add(compositeKey);
90
+ try {
91
+ await db.execute(`CREATE INDEX IF NOT EXISTS ${(0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${lower}_del`)} ON ${safeTable} (${(0, sql_js_1.escapeIdentifier)(key)}, "deleted");`);
123
92
  }
93
+ catch { }
124
94
  }
125
95
  }
126
96
  }
127
- catch {
128
- // Ignore
129
- }
130
97
  }
131
98
  /**
132
99
  * Ensures that the target table exists in SQLite with the specified primary key.
133
100
  */
134
101
  async ensureTable(db, tableName, primaryKey = 'id') {
135
- if (this.knownTables.has(tableName)) {
102
+ if (this.knownTables.has(tableName))
136
103
  return;
137
- }
138
104
  const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
139
105
  const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
140
106
  await db.execute(`CREATE TABLE IF NOT EXISTS ${safeTable} (${safePk} TEXT PRIMARY KEY);`);
@@ -147,102 +113,46 @@ class SchemaManager {
147
113
  */
148
114
  async ensureColumns(db, tableName, payload, primaryKey = 'id') {
149
115
  await this.ensureTable(db, tableName, primaryKey);
150
- if (!payload || typeof payload !== 'object') {
116
+ if (!payload || typeof payload !== 'object')
151
117
  return {};
152
- }
153
118
  const keys = Object.keys(payload);
154
- if (keys.length === 0) {
119
+ if (keys.length === 0)
155
120
  return {};
156
- }
157
121
  const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
158
122
  let cacheMiss = false;
159
123
  for (const key of keys) {
160
- if (key === primaryKey)
161
- continue;
162
- const cacheKey = `${tableName}:${key.toLowerCase()}`;
163
- if (!this.knownColumns.has(cacheKey)) {
124
+ if (key !== primaryKey && !this.knownColumns.has(`${tableName}:${key.toLowerCase()}`)) {
164
125
  cacheMiss = true;
165
126
  break;
166
127
  }
167
128
  }
168
129
  if (cacheMiss) {
169
- // Query PRAGMA table_info to discover existing columns from SQLite
170
130
  try {
171
131
  const tableInfo = await db.execute(`PRAGMA table_info(${safeTable});`);
172
132
  if (Array.isArray(tableInfo)) {
173
133
  for (const col of tableInfo) {
174
- if (col && col.name) {
134
+ if (col?.name) {
175
135
  this.knownColumns.add(`${tableName}:${String(col.name).toLowerCase()}`);
176
136
  }
177
137
  }
178
138
  }
179
139
  }
180
- catch (err) {
181
- // Fallback for mocks or custom drivers where PRAGMA table_info might not be supported
182
- }
183
- // Check for columns that still do not exist, and alter table
140
+ catch { }
184
141
  for (const key of keys) {
185
142
  if (key === primaryKey)
186
143
  continue;
187
144
  const cacheKey = `${tableName}:${key.toLowerCase()}`;
188
145
  if (!this.knownColumns.has(cacheKey)) {
189
146
  const detectedType = detectSQLiteType(payload[key]);
190
- const safeCol = (0, sql_js_1.escapeIdentifier)(key);
191
147
  try {
192
- await db.execute(`ALTER TABLE ${safeTable} ADD COLUMN ${safeCol} ${detectedType};`);
193
- }
194
- catch (err) {
195
- // Ignore if column already exists (e.g. concurrent race)
196
- const msg = String(err?.message || err || '');
197
- if (!msg.toLowerCase().includes('duplicate column')) {
198
- // Ignore or rethrow
199
- }
148
+ await db.execute(`ALTER TABLE ${safeTable} ADD COLUMN ${(0, sql_js_1.escapeIdentifier)(key)} ${detectedType};`);
200
149
  }
150
+ catch { }
201
151
  this.knownColumns.add(cacheKey);
202
152
  }
203
- // Auto-index foreign keys and common query columns
204
- const lowerKey = key.toLowerCase();
205
- if (lowerKey.endsWith('_id') ||
206
- lowerKey === 'deleted' ||
207
- lowerKey === 'created_at' ||
208
- lowerKey === 'updated_at') {
209
- const indexKey = `${tableName}:${lowerKey}`;
210
- if (!this.knownIndexes.has(indexKey)) {
211
- this.knownIndexes.add(indexKey);
212
- try {
213
- const idxName = (0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${key}`);
214
- const safeCol = (0, sql_js_1.escapeIdentifier)(key);
215
- await db.execute(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${safeTable} (${safeCol});`);
216
- }
217
- catch {
218
- // Ignore if indexes are not supported by adapter or already exist
219
- }
220
- }
221
- }
222
- }
223
- }
224
- // Auto-create high performance composite index for foreign keys with soft-deletion
225
- if (this.knownColumns.has(`${tableName}:deleted`)) {
226
- for (const key of keys) {
227
- const lowerKey = key.toLowerCase();
228
- if (lowerKey.endsWith('_id') && lowerKey !== 'id') {
229
- const compositeKey = `${tableName}:${lowerKey}_deleted`;
230
- if (!this.knownIndexes.has(compositeKey)) {
231
- this.knownIndexes.add(compositeKey);
232
- try {
233
- const idxName = (0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${lowerKey}_del`);
234
- const safeFk = (0, sql_js_1.escapeIdentifier)(key);
235
- const safeDel = (0, sql_js_1.escapeIdentifier)('deleted');
236
- await db.execute(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${safeTable} (${safeFk}, ${safeDel});`);
237
- }
238
- catch {
239
- // Ignore
240
- }
241
- }
242
- }
243
153
  }
154
+ await this.autoIndexColumns(db, tableName, safeTable, keys);
244
155
  }
245
- // Format all values for SQLite storage
246
156
  const formatted = {};
247
157
  for (const key of keys) {
248
158
  formatted[key] = formatValueForStorage(payload[key]);
@@ -611,9 +611,12 @@ class SyncWorker {
611
611
  const isLastChunk = i + DELTA_CHUNK_SIZE >= totalChanges;
612
612
  const queuedEvents = [];
613
613
  await this.db.transaction(async (tx) => {
614
+ // Pre-fetch active pending outbox records in 1 query for this chunk to eliminate N+1 bridge roundtrips
615
+ const pendingOutboxMap = await outbox_store_js_1.OutboxStore.getActivePendingMap(tx);
614
616
  for (const change of chunk) {
615
- // 1. Check for pending local mutations for this (table_name, row_id)
616
- const localPendingRecord = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, change.tableName, change.rowId);
617
+ const outboxKey = `${change.tableName}:${change.rowId}`;
618
+ // 1. Check in-memory map for pending local mutations for this (table_name, row_id)
619
+ const localPendingRecord = pendingOutboxMap.get(outboxKey);
617
620
  // 2. Conflict resolution if there is a pending local change
618
621
  if (localPendingRecord) {
619
622
  // Only load the current local row when a resolver actually needs it.
@@ -640,11 +643,13 @@ class SyncWorker {
640
643
  if (resolution === 'apply-remote') {
641
644
  // Remote change wins: remove the superseded local pending outbox mutation
642
645
  await outbox_store_js_1.OutboxStore.deleteRecord(tx, localPendingRecord.id);
646
+ pendingOutboxMap.delete(outboxKey);
643
647
  }
644
648
  else if (typeof resolution === 'object' && resolution.mergePayload) {
645
649
  // Merge scenario
646
650
  change.payload = resolution.mergePayload;
647
651
  await outbox_store_js_1.OutboxStore.updatePayload(tx, localPendingRecord.id, resolution.mergePayload);
652
+ localPendingRecord.payload = resolution.mergePayload;
648
653
  }
649
654
  }
650
655
  // 3. Apply remote change to local database