local-lite 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +361 -2
- package/dist/adapters/local/better-sqlite3.d.ts +23 -0
- package/dist/adapters/local/better-sqlite3.js +46 -0
- package/dist/adapters/local/expo-sqlite.d.ts +8 -0
- package/dist/adapters/local/expo-sqlite.js +30 -0
- package/dist/adapters/local/index.d.ts +13 -0
- package/dist/adapters/local/index.js +22 -0
- package/dist/adapters/local/libsql.d.ts +24 -0
- package/dist/adapters/local/libsql.js +87 -0
- package/dist/adapters/local/node-sqlite.d.ts +24 -0
- package/dist/adapters/local/node-sqlite.js +44 -0
- package/dist/adapters/local/op-sqlite.d.ts +26 -0
- package/dist/adapters/local/op-sqlite.js +59 -0
- package/dist/adapters/local/sql-js.d.ts +26 -0
- package/dist/adapters/local/sql-js.js +63 -0
- package/dist/adapters/network/index.d.ts +11 -0
- package/dist/adapters/network/index.js +53 -0
- package/dist/adapters/remote/convex.d.ts +71 -0
- package/dist/adapters/remote/convex.js +185 -0
- package/dist/adapters/remote/demo.d.ts +21 -0
- package/dist/adapters/remote/demo.js +46 -0
- package/dist/adapters/remote/firestore.d.ts +64 -0
- package/dist/adapters/remote/firestore.js +302 -0
- package/dist/adapters/remote/index.d.ts +10 -0
- package/dist/adapters/remote/index.js +20 -0
- package/dist/adapters/remote/supabase.d.ts +97 -0
- package/dist/adapters/remote/supabase.js +276 -0
- package/dist/config/index.d.ts +24 -0
- package/dist/config/index.js +101 -0
- package/dist/config/resolve-local.d.ts +7 -0
- package/dist/config/resolve-local.js +68 -0
- package/dist/config/resolve-remote.d.ts +7 -0
- package/dist/config/resolve-remote.js +81 -0
- package/dist/config/types.d.ts +159 -0
- package/dist/config/types.js +2 -0
- package/dist/core/client.d.ts +153 -0
- package/dist/core/client.js +445 -0
- package/dist/core/conflict.d.ts +5 -0
- package/dist/core/conflict.js +38 -0
- package/dist/core/meta-store.d.ts +28 -0
- package/dist/core/meta-store.js +64 -0
- package/dist/core/outbox-store.d.ts +76 -0
- package/dist/core/outbox-store.js +265 -0
- package/dist/core/query.d.ts +13 -0
- package/dist/core/query.js +150 -0
- package/dist/core/schema-manager.d.ts +39 -0
- package/dist/core/schema-manager.js +150 -0
- package/dist/core/sync-worker.d.ts +89 -0
- package/dist/core/sync-worker.js +473 -0
- package/dist/core/table-manager.d.ts +19 -0
- package/dist/core/table-manager.js +74 -0
- package/dist/core/types.d.ts +156 -0
- package/dist/core/types.js +2 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +34 -0
- package/dist/react/index.d.ts +50 -0
- package/dist/react/index.js +174 -0
- package/dist/reactive/event-emitter.d.ts +7 -0
- package/dist/reactive/event-emitter.js +34 -0
- package/dist/utils/logger.d.ts +51 -0
- package/dist/utils/logger.js +169 -0
- package/dist/utils/sql.d.ts +4 -0
- package/dist/utils/sql.js +9 -0
- package/dist/utils/uuid.d.ts +7 -0
- package/dist/utils/uuid.js +27 -0
- package/package.json +131 -4
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OutboxStore = void 0;
|
|
4
|
+
class OutboxStore {
|
|
5
|
+
/**
|
|
6
|
+
* Initializes the sync_outbox schema if it does not already exist.
|
|
7
|
+
*/
|
|
8
|
+
static async ensureSchema(db) {
|
|
9
|
+
await db.execute(`CREATE TABLE IF NOT EXISTS sync_outbox (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
table_name TEXT NOT NULL,
|
|
12
|
+
row_id TEXT NOT NULL,
|
|
13
|
+
operation TEXT NOT NULL,
|
|
14
|
+
payload TEXT,
|
|
15
|
+
timestamp INTEGER NOT NULL,
|
|
16
|
+
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
17
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
last_error TEXT
|
|
19
|
+
);`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Inserts a new outbox entry within an existing database transaction.
|
|
23
|
+
*/
|
|
24
|
+
static async insertRecord(tx, op) {
|
|
25
|
+
await tx.execute(`INSERT INTO sync_outbox (id, table_name, row_id, operation, payload, timestamp, status, retry_count)
|
|
26
|
+
VALUES (?, ?, ?, ?, ?, ?, 'PENDING', 0)`, [
|
|
27
|
+
op.id,
|
|
28
|
+
op.tableName,
|
|
29
|
+
op.rowId,
|
|
30
|
+
op.operation,
|
|
31
|
+
op.payload ? JSON.stringify(op.payload) : null,
|
|
32
|
+
op.timestamp,
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Inserts multiple outbox entries within an existing database transaction.
|
|
37
|
+
*/
|
|
38
|
+
static async insertRecords(tx, ops) {
|
|
39
|
+
for (const op of ops) {
|
|
40
|
+
await this.insertRecord(tx, op);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Fetches pending and eligible failed outbox records for synchronization.
|
|
45
|
+
*/
|
|
46
|
+
static async getPending(db, maxRetries = 5, limit = 50) {
|
|
47
|
+
const isInfinite = !Number.isFinite(maxRetries) || maxRetries === -1;
|
|
48
|
+
const query = isInfinite
|
|
49
|
+
? `SELECT * FROM sync_outbox
|
|
50
|
+
WHERE status = 'PENDING' OR status = 'FAILED'
|
|
51
|
+
ORDER BY timestamp ASC LIMIT ?`
|
|
52
|
+
: `SELECT * FROM sync_outbox
|
|
53
|
+
WHERE status = 'PENDING' OR (status = 'FAILED' AND retry_count < ?)
|
|
54
|
+
ORDER BY timestamp ASC LIMIT ?`;
|
|
55
|
+
const params = isInfinite ? [limit] : [maxRetries, limit];
|
|
56
|
+
const rows = await db.execute(query, params);
|
|
57
|
+
if (!rows || rows.length === 0)
|
|
58
|
+
return [];
|
|
59
|
+
return rows.map((r) => ({
|
|
60
|
+
id: r.id,
|
|
61
|
+
tableName: r.table_name,
|
|
62
|
+
rowId: r.row_id,
|
|
63
|
+
operation: r.operation,
|
|
64
|
+
payload: r.payload ? (typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) : null,
|
|
65
|
+
timestamp: Number(r.timestamp),
|
|
66
|
+
status: r.status,
|
|
67
|
+
retryCount: Number(r.retry_count || 0),
|
|
68
|
+
lastError: r.last_error,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Finds the latest pending outbox record for a given table and row.
|
|
73
|
+
*/
|
|
74
|
+
static async findPendingByRow(tx, tableName, rowId) {
|
|
75
|
+
const rows = await tx.execute(`SELECT * FROM sync_outbox
|
|
76
|
+
WHERE table_name = ? AND row_id = ? AND status != 'FAILED'
|
|
77
|
+
ORDER BY timestamp DESC LIMIT 1`, [tableName, rowId]);
|
|
78
|
+
if (!rows || rows.length === 0)
|
|
79
|
+
return undefined;
|
|
80
|
+
const r = rows[0];
|
|
81
|
+
return {
|
|
82
|
+
id: r.id,
|
|
83
|
+
tableName: r.table_name,
|
|
84
|
+
rowId: r.row_id,
|
|
85
|
+
operation: r.operation,
|
|
86
|
+
payload: r.payload ? (typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) : null,
|
|
87
|
+
timestamp: Number(r.timestamp),
|
|
88
|
+
status: r.status,
|
|
89
|
+
retryCount: Number(r.retry_count || 0),
|
|
90
|
+
lastError: r.last_error,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Marks a batch of outbox IDs as 'PROCESSING'.
|
|
95
|
+
*/
|
|
96
|
+
static async markProcessing(db, ids) {
|
|
97
|
+
if (ids.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
100
|
+
await db.execute(`UPDATE sync_outbox SET status = 'PROCESSING' WHERE id IN (${placeholders})`, ids);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resets any orphaned 'PROCESSING' records back to 'PENDING' on startup.
|
|
104
|
+
* Protects against app kills or crashes that happened mid-push.
|
|
105
|
+
*/
|
|
106
|
+
static async resetStaleProcessing(db) {
|
|
107
|
+
try {
|
|
108
|
+
await db.execute(`UPDATE sync_outbox SET status = 'PENDING' WHERE status = 'PROCESSING'`);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Table may not exist yet
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Deletes a batch of successfully pushed outbox entries.
|
|
116
|
+
*/
|
|
117
|
+
static async deleteCompleted(db, ids) {
|
|
118
|
+
if (ids.length === 0)
|
|
119
|
+
return;
|
|
120
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
121
|
+
await db.execute(`DELETE FROM sync_outbox WHERE id IN (${placeholders})`, ids);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Deletes a single outbox record within an active transaction.
|
|
125
|
+
*/
|
|
126
|
+
static async deleteRecord(tx, id) {
|
|
127
|
+
await tx.execute(`DELETE FROM sync_outbox WHERE id = ?`, [id]);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Updates an outbox record's payload (used during conflict merge).
|
|
131
|
+
*/
|
|
132
|
+
static async updatePayload(tx, id, payload) {
|
|
133
|
+
await tx.execute(`UPDATE sync_outbox SET payload = ? WHERE id = ?`, [JSON.stringify(payload), id]);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Marks a batch of outbox IDs as 'FAILED' and increments retry counters.
|
|
137
|
+
*/
|
|
138
|
+
static async markFailed(db, ids, errorMessage) {
|
|
139
|
+
if (ids.length === 0)
|
|
140
|
+
return;
|
|
141
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
142
|
+
await db.execute(`UPDATE sync_outbox
|
|
143
|
+
SET status = 'FAILED',
|
|
144
|
+
retry_count = retry_count + 1,
|
|
145
|
+
last_error = ?
|
|
146
|
+
WHERE id IN (${placeholders})`, [errorMessage, ...ids]);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Retrieves accurate counts of outbox records categorized by status.
|
|
150
|
+
*/
|
|
151
|
+
static async getCounts(db) {
|
|
152
|
+
try {
|
|
153
|
+
const rows = await db.execute(`SELECT * FROM sync_outbox`);
|
|
154
|
+
if (!rows || rows.length === 0) {
|
|
155
|
+
return { pending: 0, failed: 0, processing: 0, total: 0 };
|
|
156
|
+
}
|
|
157
|
+
let pending = 0;
|
|
158
|
+
let failed = 0;
|
|
159
|
+
let processing = 0;
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
if (row.status === 'PENDING')
|
|
162
|
+
pending++;
|
|
163
|
+
else if (row.status === 'FAILED')
|
|
164
|
+
failed++;
|
|
165
|
+
else if (row.status === 'PROCESSING')
|
|
166
|
+
processing++;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
pending,
|
|
170
|
+
failed,
|
|
171
|
+
processing,
|
|
172
|
+
total: rows.length,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return { pending: 0, failed: 0, processing: 0, total: 0 };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Re-queues failed outbox records for retry by resetting their status to PENDING and resetting retry_count.
|
|
181
|
+
* If ids are specified, only those records are reset. Otherwise, all FAILED records are reset.
|
|
182
|
+
* Returns the count of affected records.
|
|
183
|
+
*/
|
|
184
|
+
static async retryFailed(db, ids) {
|
|
185
|
+
try {
|
|
186
|
+
if (ids && ids.length > 0) {
|
|
187
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
188
|
+
await db.execute(`UPDATE sync_outbox
|
|
189
|
+
SET status = 'PENDING', retry_count = 0, last_error = NULL
|
|
190
|
+
WHERE status = 'FAILED' AND id IN (${placeholders})`, ids);
|
|
191
|
+
return ids.length;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const failedRecords = await db.execute(`SELECT id FROM sync_outbox WHERE status = 'FAILED'`);
|
|
195
|
+
const count = failedRecords?.length || 0;
|
|
196
|
+
if (count > 0) {
|
|
197
|
+
await db.execute(`UPDATE sync_outbox SET status = 'PENDING', retry_count = 0, last_error = NULL WHERE status = 'FAILED'`);
|
|
198
|
+
}
|
|
199
|
+
return count;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Permanently clears failed outbox records (removes poison-pill mutations).
|
|
208
|
+
*/
|
|
209
|
+
static async clearFailed(db, ids) {
|
|
210
|
+
try {
|
|
211
|
+
if (ids && ids.length > 0) {
|
|
212
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
213
|
+
await db.execute(`DELETE FROM sync_outbox WHERE status = 'FAILED' AND id IN (${placeholders})`, ids);
|
|
214
|
+
return ids.length;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const failedRecords = await db.execute(`SELECT id FROM sync_outbox WHERE status = 'FAILED'`);
|
|
218
|
+
const count = failedRecords?.length || 0;
|
|
219
|
+
if (count > 0) {
|
|
220
|
+
await db.execute(`DELETE FROM sync_outbox WHERE status = 'FAILED'`);
|
|
221
|
+
}
|
|
222
|
+
return count;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Retrieves all outbox records, optionally filtered by status, ordered by timestamp.
|
|
231
|
+
*/
|
|
232
|
+
static async getAllRecords(db, options) {
|
|
233
|
+
try {
|
|
234
|
+
const rows = await db.execute(`SELECT * FROM sync_outbox`);
|
|
235
|
+
if (!rows || rows.length === 0)
|
|
236
|
+
return [];
|
|
237
|
+
let mapped = rows.map((r) => ({
|
|
238
|
+
id: r.id,
|
|
239
|
+
tableName: r.table_name,
|
|
240
|
+
rowId: r.row_id,
|
|
241
|
+
operation: r.operation,
|
|
242
|
+
payload: r.payload ? (typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) : null,
|
|
243
|
+
timestamp: Number(r.timestamp),
|
|
244
|
+
status: r.status,
|
|
245
|
+
retryCount: Number(r.retry_count || 0),
|
|
246
|
+
lastError: r.last_error,
|
|
247
|
+
}));
|
|
248
|
+
if (options?.status) {
|
|
249
|
+
mapped = mapped.filter((r) => r.status === options.status);
|
|
250
|
+
}
|
|
251
|
+
mapped.sort((a, b) => b.timestamp - a.timestamp);
|
|
252
|
+
if (options?.offset && options.offset > 0) {
|
|
253
|
+
mapped = mapped.slice(options.offset);
|
|
254
|
+
}
|
|
255
|
+
if (options?.limit && options.limit > 0) {
|
|
256
|
+
mapped = mapped.slice(0, options.limit);
|
|
257
|
+
}
|
|
258
|
+
return mapped;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
exports.OutboxStore = OutboxStore;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FieldFilter, QueryOptions, WhereFilter } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checks whether a single field value matches a field filter condition.
|
|
4
|
+
*/
|
|
5
|
+
export declare function matchesField(fieldValue: any, filter: FieldFilter): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Evaluates whether a given row matches a WhereFilter.
|
|
8
|
+
*/
|
|
9
|
+
export declare function matchesWhere(row: Record<string, any>, where?: WhereFilter): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Applies in-memory filtering, sorting, offset, and limit to an array of rows.
|
|
12
|
+
*/
|
|
13
|
+
export declare function applyQueryOptions<T = any>(rows: T[], options?: QueryOptions): T[];
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.matchesField = matchesField;
|
|
4
|
+
exports.matchesWhere = matchesWhere;
|
|
5
|
+
exports.applyQueryOptions = applyQueryOptions;
|
|
6
|
+
/**
|
|
7
|
+
* Checks whether a single field value matches a field filter condition.
|
|
8
|
+
*/
|
|
9
|
+
function matchesField(fieldValue, filter) {
|
|
10
|
+
if (filter === undefined)
|
|
11
|
+
return true;
|
|
12
|
+
if (typeof filter === 'function') {
|
|
13
|
+
return filter(fieldValue);
|
|
14
|
+
}
|
|
15
|
+
// Direct primitive / reference equality
|
|
16
|
+
if (filter === null || typeof filter !== 'object') {
|
|
17
|
+
return fieldValue === filter;
|
|
18
|
+
}
|
|
19
|
+
// Operator object: $eq, $ne, $in, $nin, $gt, $gte, $lt, $lte, $contains, etc.
|
|
20
|
+
const ops = filter;
|
|
21
|
+
if (ops.$eq !== undefined && fieldValue !== ops.$eq) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (ops.$ne !== undefined && fieldValue === ops.$ne) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (ops.$in !== undefined) {
|
|
28
|
+
if (!Array.isArray(ops.$in) || !ops.$in.includes(fieldValue)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (ops.$nin !== undefined) {
|
|
33
|
+
if (Array.isArray(ops.$nin) && ops.$nin.includes(fieldValue)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (ops.$gt !== undefined && !(fieldValue > ops.$gt)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (ops.$gte !== undefined && !(fieldValue >= ops.$gte)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (ops.$lt !== undefined && !(fieldValue < ops.$lt)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (ops.$lte !== undefined && !(fieldValue <= ops.$lte)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (ops.$contains !== undefined) {
|
|
50
|
+
const str = fieldValue === null || fieldValue === undefined ? '' : String(fieldValue).toLowerCase();
|
|
51
|
+
const search = String(ops.$contains).toLowerCase();
|
|
52
|
+
if (!str.includes(search)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (ops.$startsWith !== undefined) {
|
|
57
|
+
const str = fieldValue === null || fieldValue === undefined ? '' : String(fieldValue).toLowerCase();
|
|
58
|
+
const prefix = String(ops.$startsWith).toLowerCase();
|
|
59
|
+
if (!str.startsWith(prefix)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (ops.$endsWith !== undefined) {
|
|
64
|
+
const str = fieldValue === null || fieldValue === undefined ? '' : String(fieldValue).toLowerCase();
|
|
65
|
+
const suffix = String(ops.$endsWith).toLowerCase();
|
|
66
|
+
if (!str.endsWith(suffix)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (ops.$null !== undefined) {
|
|
71
|
+
const isNull = fieldValue === null || fieldValue === undefined;
|
|
72
|
+
if (ops.$null !== isNull) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Evaluates whether a given row matches a WhereFilter.
|
|
80
|
+
*/
|
|
81
|
+
function matchesWhere(row, where) {
|
|
82
|
+
if (!where)
|
|
83
|
+
return true;
|
|
84
|
+
if (typeof where === 'function') {
|
|
85
|
+
return where(row);
|
|
86
|
+
}
|
|
87
|
+
// Handle $or combinator
|
|
88
|
+
if ('$or' in where && Array.isArray(where.$or)) {
|
|
89
|
+
const orMatches = where.$or.some((subWhere) => matchesWhere(row, subWhere));
|
|
90
|
+
if (!orMatches)
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
// Handle $and combinator
|
|
94
|
+
if ('$and' in where && Array.isArray(where.$and)) {
|
|
95
|
+
const andMatches = where.$and.every((subWhere) => matchesWhere(row, subWhere));
|
|
96
|
+
if (!andMatches)
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
// Field-level conditions
|
|
100
|
+
for (const [key, filter] of Object.entries(where)) {
|
|
101
|
+
if (key === '$or' || key === '$and')
|
|
102
|
+
continue;
|
|
103
|
+
if (!matchesField(row[key], filter)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Applies in-memory filtering, sorting, offset, and limit to an array of rows.
|
|
111
|
+
*/
|
|
112
|
+
function applyQueryOptions(rows, options) {
|
|
113
|
+
if (!rows || rows.length === 0)
|
|
114
|
+
return [];
|
|
115
|
+
let result = [...rows];
|
|
116
|
+
// 1. Where filtering
|
|
117
|
+
if (options?.where) {
|
|
118
|
+
result = result.filter((row) => matchesWhere(row, options.where));
|
|
119
|
+
}
|
|
120
|
+
// 2. Order by
|
|
121
|
+
if (options?.orderBy) {
|
|
122
|
+
const key = options.orderBy;
|
|
123
|
+
const isAsc = (options.order || 'asc').toLowerCase() === 'asc';
|
|
124
|
+
result.sort((a, b) => {
|
|
125
|
+
const valA = a[key];
|
|
126
|
+
const valB = b[key];
|
|
127
|
+
if (valA === valB)
|
|
128
|
+
return 0;
|
|
129
|
+
if (valA === null || valA === undefined)
|
|
130
|
+
return isAsc ? 1 : -1;
|
|
131
|
+
if (valB === null || valB === undefined)
|
|
132
|
+
return isAsc ? -1 : 1;
|
|
133
|
+
if (typeof valA === 'string' && typeof valB === 'string') {
|
|
134
|
+
return isAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
|
135
|
+
}
|
|
136
|
+
if (typeof valA === 'number' && typeof valB === 'number') {
|
|
137
|
+
return isAsc ? valA - valB : valB - valA;
|
|
138
|
+
}
|
|
139
|
+
return isAsc ? (valA > valB ? 1 : -1) : (valA < valB ? 1 : -1);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// 3. Offset and Limit
|
|
143
|
+
if (options?.offset !== undefined && options.offset > 0) {
|
|
144
|
+
result = result.slice(options.offset);
|
|
145
|
+
}
|
|
146
|
+
if (options?.limit !== undefined && options.limit > 0) {
|
|
147
|
+
result = result.slice(0, options.limit);
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface DBExecutable {
|
|
2
|
+
execute(query: string, params?: any[]): Promise<any[]>;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
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
|
+
*/
|
|
16
|
+
export declare function detectSQLiteType(value: any): 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB';
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes values for SQLite storage:
|
|
19
|
+
* - Booleans are converted to 1 / 0
|
|
20
|
+
* - Plain objects and arrays are JSON serialized
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatValueForStorage(value: any): any;
|
|
23
|
+
export declare class SchemaManager {
|
|
24
|
+
private knownTables;
|
|
25
|
+
private knownColumns;
|
|
26
|
+
/**
|
|
27
|
+
* Resets the schema cache (useful for testing or full database resets).
|
|
28
|
+
*/
|
|
29
|
+
clearCache(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Ensures that the target table exists in SQLite with the specified primary key.
|
|
32
|
+
*/
|
|
33
|
+
ensureTable(db: DBExecutable, tableName: string, primaryKey?: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Automatically introspects and adds missing columns to the table via ALTER TABLE.
|
|
36
|
+
* Also normalizes payload values for SQLite compatibility.
|
|
37
|
+
*/
|
|
38
|
+
ensureColumns(db: DBExecutable, tableName: string, payload: Record<string, any> | null, primaryKey?: string): Promise<Record<string, any>>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SchemaManager = void 0;
|
|
4
|
+
exports.detectSQLiteType = detectSQLiteType;
|
|
5
|
+
exports.formatValueForStorage = formatValueForStorage;
|
|
6
|
+
const sql_js_1 = require("../utils/sql.js");
|
|
7
|
+
/**
|
|
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
|
+
*/
|
|
19
|
+
function detectSQLiteType(value) {
|
|
20
|
+
if (value === null || value === undefined) {
|
|
21
|
+
return 'TEXT';
|
|
22
|
+
}
|
|
23
|
+
const type = typeof value;
|
|
24
|
+
if (type === 'boolean') {
|
|
25
|
+
return 'INTEGER';
|
|
26
|
+
}
|
|
27
|
+
if (type === 'number') {
|
|
28
|
+
return Number.isInteger(value) ? 'INTEGER' : 'REAL';
|
|
29
|
+
}
|
|
30
|
+
if (type === 'string') {
|
|
31
|
+
return 'TEXT';
|
|
32
|
+
}
|
|
33
|
+
if (value instanceof Uint8Array) {
|
|
34
|
+
return 'BLOB';
|
|
35
|
+
}
|
|
36
|
+
if (type === 'object') {
|
|
37
|
+
return 'TEXT';
|
|
38
|
+
}
|
|
39
|
+
return 'TEXT';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Normalizes values for SQLite storage:
|
|
43
|
+
* - Booleans are converted to 1 / 0
|
|
44
|
+
* - Plain objects and arrays are JSON serialized
|
|
45
|
+
*/
|
|
46
|
+
function formatValueForStorage(value) {
|
|
47
|
+
if (value === null || value === undefined) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === 'boolean') {
|
|
51
|
+
return value ? 1 : 0;
|
|
52
|
+
}
|
|
53
|
+
if (typeof value === 'object' && !(value instanceof Uint8Array)) {
|
|
54
|
+
return JSON.stringify(value);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
class SchemaManager {
|
|
59
|
+
knownTables = new Set();
|
|
60
|
+
knownColumns = new Set();
|
|
61
|
+
/**
|
|
62
|
+
* Resets the schema cache (useful for testing or full database resets).
|
|
63
|
+
*/
|
|
64
|
+
clearCache() {
|
|
65
|
+
this.knownTables.clear();
|
|
66
|
+
this.knownColumns.clear();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Ensures that the target table exists in SQLite with the specified primary key.
|
|
70
|
+
*/
|
|
71
|
+
async ensureTable(db, tableName, primaryKey = 'id') {
|
|
72
|
+
if (this.knownTables.has(tableName)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
76
|
+
const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
|
|
77
|
+
await db.execute(`CREATE TABLE IF NOT EXISTS ${safeTable} (${safePk} TEXT PRIMARY KEY);`);
|
|
78
|
+
this.knownTables.add(tableName);
|
|
79
|
+
this.knownColumns.add(`${tableName}:${primaryKey.toLowerCase()}`);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Automatically introspects and adds missing columns to the table via ALTER TABLE.
|
|
83
|
+
* Also normalizes payload values for SQLite compatibility.
|
|
84
|
+
*/
|
|
85
|
+
async ensureColumns(db, tableName, payload, primaryKey = 'id') {
|
|
86
|
+
await this.ensureTable(db, tableName, primaryKey);
|
|
87
|
+
if (!payload || typeof payload !== 'object') {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
const keys = Object.keys(payload);
|
|
91
|
+
if (keys.length === 0) {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
95
|
+
let cacheMiss = false;
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
if (key === primaryKey)
|
|
98
|
+
continue;
|
|
99
|
+
const cacheKey = `${tableName}:${key.toLowerCase()}`;
|
|
100
|
+
if (!this.knownColumns.has(cacheKey)) {
|
|
101
|
+
cacheMiss = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (cacheMiss) {
|
|
106
|
+
// Query PRAGMA table_info to discover existing columns from SQLite
|
|
107
|
+
try {
|
|
108
|
+
const tableInfo = await db.execute(`PRAGMA table_info(${safeTable});`);
|
|
109
|
+
if (Array.isArray(tableInfo)) {
|
|
110
|
+
for (const col of tableInfo) {
|
|
111
|
+
if (col && col.name) {
|
|
112
|
+
this.knownColumns.add(`${tableName}:${String(col.name).toLowerCase()}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
// Fallback for mocks or custom drivers where PRAGMA table_info might not be supported
|
|
119
|
+
}
|
|
120
|
+
// Check for columns that still do not exist, and alter table
|
|
121
|
+
for (const key of keys) {
|
|
122
|
+
if (key === primaryKey)
|
|
123
|
+
continue;
|
|
124
|
+
const cacheKey = `${tableName}:${key.toLowerCase()}`;
|
|
125
|
+
if (!this.knownColumns.has(cacheKey)) {
|
|
126
|
+
const detectedType = detectSQLiteType(payload[key]);
|
|
127
|
+
const safeCol = (0, sql_js_1.escapeIdentifier)(key);
|
|
128
|
+
try {
|
|
129
|
+
await db.execute(`ALTER TABLE ${safeTable} ADD COLUMN ${safeCol} ${detectedType};`);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
// Ignore if column already exists (e.g. concurrent race)
|
|
133
|
+
const msg = String(err?.message || err || '');
|
|
134
|
+
if (!msg.toLowerCase().includes('duplicate column')) {
|
|
135
|
+
// Ignore or rethrow
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.knownColumns.add(cacheKey);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Format all values for SQLite storage
|
|
143
|
+
const formatted = {};
|
|
144
|
+
for (const key of keys) {
|
|
145
|
+
formatted[key] = formatValueForStorage(payload[key]);
|
|
146
|
+
}
|
|
147
|
+
return formatted;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
exports.SchemaManager = SchemaManager;
|