pomegranate-db 0.1.0 → 1.0.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/README.md CHANGED
@@ -110,12 +110,58 @@ function PostList() {
110
110
  }
111
111
  ```
112
112
 
113
+ ## Installation
114
+
115
+ ```sh
116
+ npm install pomegranate-db
117
+ ```
118
+
119
+ Install adapter-specific peers only when you use those entry points:
120
+
121
+ - `pomegranate-db` -> `react`
122
+ - `pomegranate-db/expo` -> `react`, `expo-sqlite`
123
+ - `pomegranate-db/encryption` -> no extra peers, uses Web Crypto
124
+ - `pomegranate-db/encryption/node` -> Node.js only
125
+ - `pomegranate-db/encryption/react-native` -> React Native / Expo Web Crypto runtime
126
+ - `pomegranate-db/op-sqlite` -> `@op-engineering/op-sqlite`
127
+ - `pomegranate-db/native-sqlite` -> React Native app with the bundled native module
128
+
129
+ ## Entry Points
130
+
131
+ PomegranateDB ships a small set of explicit subpath exports for common setups:
132
+
133
+ ```ts
134
+ import { Database, LokiAdapter } from 'pomegranate-db'
135
+ import { createExpoSQLiteDriver } from 'pomegranate-db/expo'
136
+ import { EncryptingAdapter } from 'pomegranate-db/encryption'
137
+ import { nodeCryptoProvider } from 'pomegranate-db/encryption/node'
138
+ import { createOpSQLiteDriver } from 'pomegranate-db/op-sqlite'
139
+ import { createNativeSQLiteDriver } from 'pomegranate-db/native-sqlite'
140
+ ```
141
+
142
+ The root package intentionally excludes encryption exports so Expo Snack can install
143
+ `pomegranate-db` without resolving Node's `crypto` module. Import encryption through
144
+ the explicit `./encryption`, `./encryption/node`, or `./encryption/react-native`
145
+ subpaths instead.
146
+
147
+ ## Migrations
148
+
149
+ Manual migrations are supported across Loki and SQLite adapters with these step types:
150
+
151
+ - `createTable`
152
+ - `addColumn`
153
+ - `destroyTable`
154
+ - `sql`
155
+
156
+ Use `sql` for targeted backfills such as setting a new column value on existing rows.
157
+ Schema diff generation is not automated yet, so migration steps are still authored manually.
158
+
113
159
  ## Next Steps
114
160
 
115
- - [Installation](https://bobbyquantum.github.io/pomegranate/docs/installation) — add PomegranateDB to your project
116
- - [Schema & Models](https://bobbyquantum.github.io/pomegranate/docs/schema) — define your data model
117
- - [CRUD Operations](https://bobbyquantum.github.io/pomegranate/docs/crud) — create, read, update, delete
118
- - [React Hooks](https://bobbyquantum.github.io/pomegranate/docs/react-hooks) — reactive UI integration
161
+ - [Installation](https://bobbyquantum.github.io/pomegranate/installation) — add PomegranateDB to your project
162
+ - [Schema & Models](https://bobbyquantum.github.io/pomegranate/schema) — define your data model
163
+ - [CRUD Operations](https://bobbyquantum.github.io/pomegranate/crud) — create, read, update, delete
164
+ - [React Hooks](https://bobbyquantum.github.io/pomegranate/react-hooks) — reactive UI integration
119
165
 
120
166
  ## License
121
167
 
@@ -6,24 +6,42 @@
6
6
  * using the Expo managed SQLite library instead of requiring
7
7
  * react-native-quick-sqlite or op-sqlite.
8
8
  *
9
+ * Supports both **async** and **sync** modes:
10
+ * - `preferSync: false` (default) — uses async APIs (runAsync, getAllAsync).
11
+ * Works on all platforms including web (wa-sqlite / OPFS).
12
+ * - `preferSync: true` — uses synchronous JSI APIs (runSync, getAllSync).
13
+ * Faster on native (no Promise overhead) but NOT available on web.
14
+ * Falls back to async automatically on web.
15
+ *
9
16
  * Usage:
10
17
  * import { createExpoSQLiteDriver } from 'pomegranate-db/expo';
11
18
  * import { SQLiteAdapter } from 'pomegranate-db';
12
19
  *
13
- * const adapter = new SQLiteAdapter({
14
- * databaseName: 'myapp',
15
- * driver: createExpoSQLiteDriver(),
16
- * });
20
+ * // Async (default works everywhere)
21
+ * const driver = createExpoSQLiteDriver();
22
+ *
23
+ * // Sync (native-only, falls back to async on web)
24
+ * const driverSync = createExpoSQLiteDriver({ preferSync: true });
17
25
  */
18
26
  import type { SQLiteDriver } from '../sqlite/SQLiteAdapter';
19
27
  export interface ExpoSQLiteDriverConfig {
20
28
  /**
21
- * Options passed to expo-sqlite's openDatabaseAsync.
29
+ * Options passed to expo-sqlite's openDatabaseAsync/openDatabaseSync.
22
30
  * @default {}
23
31
  */
24
32
  openOptions?: {
25
33
  enableChangeListener?: boolean;
26
34
  };
35
+ /**
36
+ * When true, use synchronous JSI calls (runSync, getAllSync, etc.)
37
+ * for better performance on native platforms.
38
+ *
39
+ * On web (wa-sqlite), sync methods are not available — the driver
40
+ * will automatically fall back to async mode.
41
+ *
42
+ * @default false
43
+ */
44
+ preferSync?: boolean;
27
45
  }
28
46
  /**
29
47
  * Create a SQLiteDriver backed by expo-sqlite.
@@ -7,14 +7,22 @@
7
7
  * using the Expo managed SQLite library instead of requiring
8
8
  * react-native-quick-sqlite or op-sqlite.
9
9
  *
10
+ * Supports both **async** and **sync** modes:
11
+ * - `preferSync: false` (default) — uses async APIs (runAsync, getAllAsync).
12
+ * Works on all platforms including web (wa-sqlite / OPFS).
13
+ * - `preferSync: true` — uses synchronous JSI APIs (runSync, getAllSync).
14
+ * Faster on native (no Promise overhead) but NOT available on web.
15
+ * Falls back to async automatically on web.
16
+ *
10
17
  * Usage:
11
18
  * import { createExpoSQLiteDriver } from 'pomegranate-db/expo';
12
19
  * import { SQLiteAdapter } from 'pomegranate-db';
13
20
  *
14
- * const adapter = new SQLiteAdapter({
15
- * databaseName: 'myapp',
16
- * driver: createExpoSQLiteDriver(),
17
- * });
21
+ * // Async (default works everywhere)
22
+ * const driver = createExpoSQLiteDriver();
23
+ *
24
+ * // Sync (native-only, falls back to async on web)
25
+ * const driverSync = createExpoSQLiteDriver({ preferSync: true });
18
26
  */
19
27
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
28
  if (k2 === undefined) k2 = k;
@@ -51,6 +59,27 @@ var __importStar = (this && this.__importStar) || (function () {
51
59
  })();
52
60
  Object.defineProperty(exports, "__esModule", { value: true });
53
61
  exports.createExpoSQLiteDriver = createExpoSQLiteDriver;
62
+ // ─── Helpers ──────────────────────────────────────────────────────────────
63
+ /**
64
+ * Replace `?` placeholders in SQL with literal values.
65
+ *
66
+ * This is used for `execAsync(multiStatement)` which doesn't accept bindings.
67
+ * Safe because all values come from our own SQL generators with known types.
68
+ */
69
+ function inlineBindings(sql, bindings) {
70
+ let index = 0;
71
+ return sql.replaceAll('?', () => {
72
+ const val = bindings[index++];
73
+ if (val === null || val === undefined)
74
+ return 'NULL';
75
+ if (typeof val === 'number')
76
+ return String(val);
77
+ if (typeof val === 'boolean')
78
+ return val ? '1' : '0';
79
+ return `'${String(val).replaceAll("'", "''")}'`;
80
+ });
81
+ }
82
+ // ─── Driver Factory ───────────────────────────────────────────────────────
54
83
  /**
55
84
  * Create a SQLiteDriver backed by expo-sqlite.
56
85
  *
@@ -60,6 +89,48 @@ exports.createExpoSQLiteDriver = createExpoSQLiteDriver;
60
89
  function createExpoSQLiteDriver(config) {
61
90
  let db = null;
62
91
  let expoSQLite = null;
92
+ // Whether we're actually using sync mode (resolved after open)
93
+ let useSync = false;
94
+ // ── Statement cache (sync mode only) ──────────────────────────────────
95
+ // Caches compiled sqlite3_stmt handles keyed by SQL string.
96
+ // Avoids re-calling sqlite3_prepare_v2 for repeated SQL (e.g. 1000 INSERTs
97
+ // with the same template). This mirrors NativeSQLite's cachedPrepare().
98
+ const stmtCache = new Map();
99
+ const MAX_CACHE_SIZE = 50;
100
+ function getCachedStmt(database, sql) {
101
+ if (!database.prepareSync)
102
+ return null;
103
+ let stmt = stmtCache.get(sql);
104
+ if (stmt)
105
+ return stmt;
106
+ // Evict oldest entry if cache is full
107
+ if (stmtCache.size >= MAX_CACHE_SIZE) {
108
+ const firstKey = stmtCache.keys().next().value;
109
+ const evicted = stmtCache.get(firstKey);
110
+ stmtCache.delete(firstKey);
111
+ try {
112
+ evicted?.finalizeSync();
113
+ }
114
+ catch { /* already finalized */ }
115
+ }
116
+ try {
117
+ stmt = database.prepareSync(sql);
118
+ stmtCache.set(sql, stmt);
119
+ return stmt;
120
+ }
121
+ catch {
122
+ return null; // Fall back to runSync/execSync
123
+ }
124
+ }
125
+ function clearStmtCache() {
126
+ for (const stmt of stmtCache.values()) {
127
+ try {
128
+ stmt.finalizeSync();
129
+ }
130
+ catch { /* ignore */ }
131
+ }
132
+ stmtCache.clear();
133
+ }
63
134
  // Lazily import expo-sqlite so this module can be imported
64
135
  // without expo-sqlite being installed (e.g. in tests).
65
136
  async function getExpoSQLite() {
@@ -83,26 +154,86 @@ function createExpoSQLiteDriver(config) {
83
154
  return {
84
155
  async open(name) {
85
156
  const sqlite = await getExpoSQLite();
86
- db = await sqlite.openDatabaseAsync(name.endsWith('.db') ? name : `${name}.db`, config?.openOptions);
157
+ const dbName = name.endsWith('.db') ? name : `${name}.db`;
158
+ // Try sync open if preferred and available
159
+ if (config?.preferSync && typeof sqlite.openDatabaseSync === 'function') {
160
+ try {
161
+ db = sqlite.openDatabaseSync(dbName, config?.openOptions);
162
+ useSync = true;
163
+ }
164
+ catch {
165
+ // openDatabaseSync not supported (e.g. web) — fall through
166
+ }
167
+ }
168
+ // Async fallback (or default path)
169
+ if (!db) {
170
+ db = await sqlite.openDatabaseAsync(dbName, config?.openOptions);
171
+ useSync = false;
172
+ }
87
173
  // Enable WAL mode for better performance (may not be supported on web/wa-sqlite)
88
174
  try {
89
- await db.execAsync('PRAGMA journal_mode = WAL');
175
+ if (useSync && db.execSync) {
176
+ db.execSync('PRAGMA journal_mode = WAL');
177
+ db.execSync('PRAGMA synchronous = NORMAL');
178
+ db.execSync('PRAGMA cache_size = -8000');
179
+ db.execSync('PRAGMA temp_store = MEMORY');
180
+ db.execSync('PRAGMA busy_timeout = 5000');
181
+ }
182
+ else {
183
+ await db.execAsync('PRAGMA journal_mode = WAL');
184
+ await db.execAsync('PRAGMA synchronous = NORMAL');
185
+ await db.execAsync('PRAGMA cache_size = -8000');
186
+ await db.execAsync('PRAGMA temp_store = MEMORY');
187
+ await db.execAsync('PRAGMA busy_timeout = 5000');
188
+ }
90
189
  }
91
190
  catch {
92
- // WAL not supported on this platform (e.g. web wa-sqlite), continue without it
191
+ // PRAGMAs not supported on this platform (e.g. web wa-sqlite), continue without them
93
192
  }
94
193
  },
95
194
  async execute(sql, bindings) {
96
195
  const database = requireDb();
97
- if (bindings && bindings.length > 0) {
98
- await database.runAsync(sql, ...bindings);
196
+ // DDL statements invalidate cached prepared statements
197
+ if (stmtCache.size > 0 && /^\s*(DROP|CREATE|ALTER)\s/i.test(sql)) {
198
+ clearStmtCache();
199
+ }
200
+ if (useSync && database.runSync) {
201
+ if (bindings && bindings.length > 0) {
202
+ const stmt = getCachedStmt(database, sql);
203
+ if (stmt) {
204
+ stmt.executeSync(bindings);
205
+ }
206
+ else {
207
+ database.runSync(sql, ...bindings);
208
+ }
209
+ }
210
+ else if (database.execSync) {
211
+ database.execSync(sql);
212
+ }
213
+ else {
214
+ database.runSync(sql);
215
+ }
99
216
  }
100
217
  else {
101
- await database.execAsync(sql);
218
+ if (bindings && bindings.length > 0) {
219
+ await database.runAsync(sql, ...bindings);
220
+ }
221
+ else {
222
+ await database.execAsync(sql);
223
+ }
102
224
  }
103
225
  },
104
226
  async query(sql, bindings) {
105
227
  const database = requireDb();
228
+ if (useSync && database.getAllSync) {
229
+ // Use database.getAllSync directly (no statement cache for queries).
230
+ // The bulk row transfer in getAllSync is faster than iterating via
231
+ // a prepared statement's getAllSync for large result sets.
232
+ if (bindings && bindings.length > 0) {
233
+ return database.getAllSync(sql, ...bindings);
234
+ }
235
+ return database.getAllSync(sql);
236
+ }
106
237
  if (bindings && bindings.length > 0) {
107
238
  return database.getAllAsync(sql, ...bindings);
108
239
  }
@@ -110,46 +241,212 @@ function createExpoSQLiteDriver(config) {
110
241
  },
111
242
  async executeInTransaction(fn) {
112
243
  const database = requireDb();
113
- // expo-sqlite's withExclusiveTransactionAsync is not supported on web.
114
- // Fall back to manual BEGIN/COMMIT/ROLLBACK for web compatibility.
244
+ // Sync transaction path (native only)
245
+ if (useSync && database.withTransactionSync) {
246
+ try {
247
+ database.withTransactionSync(() => {
248
+ // NOTE: fn() returns a Promise but our sync transaction
249
+ // callback is synchronous. The inner operations will also
250
+ // be sync (since useSync=true), so the await is a no-op.
251
+ // We run the promise synchronously via the micro-task trick.
252
+ let error;
253
+ let done = false;
254
+ fn().then(() => { done = true; }, (error_) => { error = error_; done = true; });
255
+ // In sync mode all awaits inside fn() resolve immediately
256
+ // (they're wrapping synchronous calls), so done should be true.
257
+ if (!done) {
258
+ throw new Error('ExpoSQLiteDriver: async operations inside sync transaction are not supported. ' +
259
+ 'Use preferSync: false for mixed async/sync workloads.');
260
+ }
261
+ if (error)
262
+ throw error;
263
+ });
264
+ return;
265
+ }
266
+ catch (error) {
267
+ if (error instanceof Error && error.message.includes('not supported on web')) {
268
+ // Fall through to async
269
+ }
270
+ else {
271
+ throw error;
272
+ }
273
+ }
274
+ }
275
+ // Async transaction path
115
276
  if (typeof database.withExclusiveTransactionAsync === 'function') {
116
277
  try {
117
278
  await database.withExclusiveTransactionAsync(async (_txn) => {
118
- // expo-sqlite's exclusive transaction scopes all queries
119
- // on this database connection to the transaction, so we
120
- // can just call fn() which uses the same `db` reference.
121
279
  await fn();
122
280
  });
123
281
  return;
124
282
  }
125
- catch (e) {
126
- // On web, withExclusiveTransactionAsync throws even though the method exists.
127
- // Detect this and fall through to manual transaction handling.
128
- if (e instanceof Error && e.message.includes('not supported on web')) {
283
+ catch (error) {
284
+ if (error instanceof Error && error.message.includes('not supported on web')) {
129
285
  // Fall through to manual transaction below
130
286
  }
131
287
  else {
132
- throw e;
288
+ throw error;
133
289
  }
134
290
  }
135
291
  }
136
292
  // Manual transaction fallback (web, or platforms without exclusive transactions)
137
- await database.execAsync('BEGIN TRANSACTION');
138
- try {
139
- await fn();
140
- await database.execAsync('COMMIT');
293
+ if (useSync && database.execSync) {
294
+ database.execSync('BEGIN TRANSACTION');
295
+ try {
296
+ await fn();
297
+ database.execSync('COMMIT');
298
+ }
299
+ catch (error) {
300
+ database.execSync('ROLLBACK');
301
+ throw error;
302
+ }
141
303
  }
142
- catch (e) {
143
- await database.execAsync('ROLLBACK');
144
- throw e;
304
+ else {
305
+ await database.execAsync('BEGIN TRANSACTION');
306
+ try {
307
+ await fn();
308
+ await database.execAsync('COMMIT');
309
+ }
310
+ catch (error) {
311
+ await database.execAsync('ROLLBACK');
312
+ throw error;
313
+ }
145
314
  }
146
315
  },
147
316
  async close() {
148
317
  if (db) {
149
- await db.closeAsync();
318
+ clearStmtCache();
319
+ if (useSync && db.closeSync) {
320
+ db.closeSync();
321
+ }
322
+ else {
323
+ await db.closeAsync();
324
+ }
150
325
  db = null;
151
326
  }
152
327
  },
328
+ // ── Raw sync/async for benchmarking ──────────────────────────────────
329
+ executeSync(sql, bindings) {
330
+ const database = requireDb();
331
+ if (!database.runSync) {
332
+ throw new Error('ExpoSQLiteDriver: sync API not available (web platform). ' +
333
+ 'Set preferSync: true and run on native.');
334
+ }
335
+ if (bindings && bindings.length > 0) {
336
+ const stmt = getCachedStmt(database, sql);
337
+ if (stmt) {
338
+ stmt.executeSync(bindings);
339
+ }
340
+ else {
341
+ database.runSync(sql, ...bindings);
342
+ }
343
+ }
344
+ else if (database.execSync) {
345
+ database.execSync(sql);
346
+ }
347
+ else {
348
+ database.runSync(sql);
349
+ }
350
+ },
351
+ async executeAsync(sql, bindings) {
352
+ const database = requireDb();
353
+ if (bindings && bindings.length > 0) {
354
+ await database.runAsync(sql, ...bindings);
355
+ }
356
+ else {
357
+ await database.execAsync(sql);
358
+ }
359
+ },
360
+ // ── Batch without transaction wrapping ──────────────────────────────
361
+ // Uses execAsync with concatenated SQL to send all commands in a
362
+ // single native call. This avoids per-statement async bridge overhead
363
+ // which is the main bottleneck for expo-sqlite async mode.
364
+ //
365
+ // Values are inlined using SQLite literal escaping. This is safe
366
+ // because all values come from our own SQL generators (insertSQL,
367
+ // updateSQL, deleteSQL) with known types.
368
+ async executeBatchNoTx(commands) {
369
+ const database = requireDb();
370
+ if (commands.length === 0)
371
+ return;
372
+ // For sync mode, use cached prepared statements when possible
373
+ if (useSync && database.runSync) {
374
+ for (const [sql, bindings] of commands) {
375
+ if (bindings && bindings.length > 0) {
376
+ const stmt = getCachedStmt(database, sql);
377
+ if (stmt) {
378
+ stmt.executeSync(bindings);
379
+ }
380
+ else {
381
+ database.runSync(sql, ...bindings);
382
+ }
383
+ }
384
+ else if (database.execSync) {
385
+ database.execSync(sql);
386
+ }
387
+ else {
388
+ database.runSync(sql);
389
+ }
390
+ }
391
+ return;
392
+ }
393
+ // Async mode: build a single SQL string with all statements.
394
+ // This sends one message across the async bridge instead of N.
395
+ const parts = [];
396
+ for (const [sql, bindings] of commands) {
397
+ if (!bindings || bindings.length === 0) {
398
+ parts.push(sql);
399
+ }
400
+ else {
401
+ parts.push(inlineBindings(sql, bindings));
402
+ }
403
+ }
404
+ await database.execAsync(parts.join(';\n'));
405
+ },
406
+ // ── Batch with transaction wrapping (for use outside writeTransaction) ──
407
+ async executeBatch(commands) {
408
+ const database = requireDb();
409
+ if (commands.length === 0)
410
+ return;
411
+ // For sync mode, use sync transaction with cached statements
412
+ if (useSync && database.runSync && database.execSync) {
413
+ database.execSync('BEGIN TRANSACTION');
414
+ try {
415
+ for (const [sql, bindings] of commands) {
416
+ if (bindings && bindings.length > 0) {
417
+ const stmt = getCachedStmt(database, sql);
418
+ if (stmt) {
419
+ stmt.executeSync(bindings);
420
+ }
421
+ else {
422
+ database.runSync(sql, ...bindings);
423
+ }
424
+ }
425
+ else {
426
+ database.execSync(sql);
427
+ }
428
+ }
429
+ database.execSync('COMMIT');
430
+ }
431
+ catch (error) {
432
+ database.execSync('ROLLBACK');
433
+ throw error;
434
+ }
435
+ return;
436
+ }
437
+ // Async mode: concatenate with BEGIN/COMMIT wrapping
438
+ const parts = ['BEGIN TRANSACTION'];
439
+ for (const [sql, bindings] of commands) {
440
+ if (!bindings || bindings.length === 0) {
441
+ parts.push(sql);
442
+ }
443
+ else {
444
+ parts.push(inlineBindings(sql, bindings));
445
+ }
446
+ }
447
+ parts.push('COMMIT');
448
+ await database.execAsync(parts.join(';\n'));
449
+ },
153
450
  };
154
451
  }
155
452
  //# sourceMappingURL=ExpoSQLiteDriver.js.map
@@ -56,6 +56,10 @@ export declare class LokiExecutor {
56
56
  initialize(schema: DatabaseSchema): Promise<void>;
57
57
  private _createLokiDb;
58
58
  private _getCollection;
59
+ private _getMetadataCollection;
60
+ private _setSchemaVersion;
61
+ private _addColumn;
62
+ private _executeSqlMigration;
59
63
  /**
60
64
  * Persist to storage adapter.
61
65
  * No-op when: no persistence configured, or saveStrategy is 'auto' (timer handles it).