pomegranate-db 0.1.1 → 1.1.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
@@ -12,6 +12,10 @@
12
12
  Build powerful React and React Native apps that scale from hundreds to tens of thousands of records and remain <em>fast</em> ⚡️
13
13
  </p>
14
14
 
15
+ <p align="center">
16
+ <a href="https://snack.expo.dev/@bobbyquantum/pomegranate-snack">Try the live Expo Snack demo</a>
17
+ </p>
18
+
15
19
  ---
16
20
 
17
21
  ### ⚡️ Instant Launch
@@ -110,12 +114,58 @@ function PostList() {
110
114
  }
111
115
  ```
112
116
 
117
+ ## Installation
118
+
119
+ ```sh
120
+ npm install pomegranate-db
121
+ ```
122
+
123
+ Install adapter-specific peers only when you use those entry points:
124
+
125
+ - `pomegranate-db` -> `react`
126
+ - `pomegranate-db/expo` -> `react`, `expo-sqlite`
127
+ - `pomegranate-db/encryption` -> no extra peers, uses Web Crypto
128
+ - `pomegranate-db/encryption/node` -> Node.js only
129
+ - `pomegranate-db/encryption/react-native` -> React Native / Expo Web Crypto runtime
130
+ - `pomegranate-db/op-sqlite` -> `@op-engineering/op-sqlite`
131
+ - `pomegranate-db/native-sqlite` -> React Native app with the bundled native module
132
+
133
+ ## Entry Points
134
+
135
+ PomegranateDB ships a small set of explicit subpath exports for common setups:
136
+
137
+ ```ts
138
+ import { Database, LokiAdapter } from 'pomegranate-db'
139
+ import { createExpoSQLiteDriver } from 'pomegranate-db/expo'
140
+ import { EncryptingAdapter } from 'pomegranate-db/encryption'
141
+ import { nodeCryptoProvider } from 'pomegranate-db/encryption/node'
142
+ import { createOpSQLiteDriver } from 'pomegranate-db/op-sqlite'
143
+ import { createNativeSQLiteDriver } from 'pomegranate-db/native-sqlite'
144
+ ```
145
+
146
+ The root package intentionally excludes encryption exports so Expo Snack can install
147
+ `pomegranate-db` without resolving Node's `crypto` module. Import encryption through
148
+ the explicit `./encryption`, `./encryption/node`, or `./encryption/react-native`
149
+ subpaths instead.
150
+
151
+ ## Migrations
152
+
153
+ Manual migrations are supported across Loki and SQLite adapters with these step types:
154
+
155
+ - `createTable`
156
+ - `addColumn`
157
+ - `destroyTable`
158
+ - `sql`
159
+
160
+ Use `sql` for targeted backfills such as setting a new column value on existing rows.
161
+ Schema diff generation is not automated yet, so migration steps are still authored manually.
162
+
113
163
  ## Next Steps
114
164
 
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
165
+ - [Installation](https://bobbyquantum.github.io/pomegranate/installation) — add PomegranateDB to your project
166
+ - [Schema & Models](https://bobbyquantum.github.io/pomegranate/schema) — define your data model
167
+ - [CRUD Operations](https://bobbyquantum.github.io/pomegranate/crud) — create, read, update, delete
168
+ - [React Hooks](https://bobbyquantum.github.io/pomegranate/react-hooks) — reactive UI integration
119
169
 
120
170
  ## License
121
171
 
@@ -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).
@@ -123,6 +123,41 @@ class LokiExecutor {
123
123
  throw new Error(`Collection "${table}" not found`);
124
124
  return col;
125
125
  }
126
+ _getMetadataCollection() {
127
+ return this._getCollection('__pomegranate_metadata');
128
+ }
129
+ _setSchemaVersion(version) {
130
+ const metaCollection = this._getMetadataCollection();
131
+ const existing = metaCollection.findOne({ key: 'schema_version' });
132
+ if (existing) {
133
+ existing.value = String(version);
134
+ metaCollection.update(existing);
135
+ }
136
+ else {
137
+ metaCollection.insert({ key: 'schema_version', value: String(version) });
138
+ }
139
+ this._schemaVersion = version;
140
+ }
141
+ _addColumn(table, column, columnType, isOptional = false) {
142
+ const col = this._getCollection(table);
143
+ const defaultValue = getDefaultValueForMigrationColumn(columnType, isOptional);
144
+ for (const doc of col.find()) {
145
+ if (!(column in doc)) {
146
+ doc[column] = defaultValue;
147
+ col.update(doc);
148
+ }
149
+ }
150
+ }
151
+ _executeSqlMigration(query) {
152
+ const statement = parseLokiMigrationSql(query);
153
+ const col = this._getCollection(statement.table);
154
+ for (const doc of col.find()) {
155
+ if (matchesLokiMigrationWhere(doc, statement.where)) {
156
+ doc[statement.column] = statement.value;
157
+ col.update(doc);
158
+ }
159
+ }
160
+ }
126
161
  /**
127
162
  * Persist to storage adapter.
128
163
  * No-op when: no persistence configured, or saveStrategy is 'auto' (timer handles it).
@@ -335,20 +370,35 @@ class LokiExecutor {
335
370
  }
336
371
  // ─── Migration ──────────────────────────────────────────────────────
337
372
  async migrate(migrations) {
338
- for (const migration of migrations) {
373
+ const applicable = migrations
374
+ .filter((migration) => migration.fromVersion >= this._schemaVersion)
375
+ .toSorted((a, b) => a.fromVersion - b.fromVersion);
376
+ for (const migration of applicable) {
339
377
  for (const step of migration.steps) {
340
378
  switch (step.type) {
341
379
  case 'createTable':
342
380
  if (!this._db.getCollection(step.schema.name)) {
343
- this._db.addCollection(step.schema.name, { unique: ['id'] });
381
+ const indices = step.schema.columns.filter((c) => c.isIndexed).map((c) => c.name);
382
+ this._db.addCollection(step.schema.name, {
383
+ unique: ['id'],
384
+ indices: ['_status', ...indices],
385
+ });
344
386
  }
345
387
  break;
388
+ case 'addColumn':
389
+ this._addColumn(step.table, step.column, step.columnType, step.isOptional);
390
+ break;
346
391
  case 'destroyTable':
347
392
  this._db.removeCollection(step.table);
348
393
  break;
394
+ case 'sql':
395
+ this._executeSqlMigration(step.query);
396
+ break;
349
397
  }
350
398
  }
399
+ this._setSchemaVersion(migration.toVersion);
351
400
  }
401
+ await this._save();
352
402
  }
353
403
  // ─── Reset ──────────────────────────────────────────────────────────
354
404
  async reset() {
@@ -451,6 +501,96 @@ function operatorToLoki(op, value) {
451
501
  throw new Error(`Unknown operator: ${op}`);
452
502
  }
453
503
  }
504
+ function getDefaultValueForMigrationColumn(columnType, isOptional) {
505
+ if (isOptional) {
506
+ return null;
507
+ }
508
+ switch (columnType.trim().toLowerCase()) {
509
+ case 'bool':
510
+ case 'boolean':
511
+ return 0;
512
+ case 'date':
513
+ case 'datetime':
514
+ case 'float':
515
+ case 'int':
516
+ case 'integer':
517
+ case 'number':
518
+ case 'numeric':
519
+ case 'real':
520
+ return 0;
521
+ case 'string':
522
+ return '';
523
+ }
524
+ return '';
525
+ }
526
+ function parseLokiMigrationSql(query) {
527
+ const match = query.match(/^\s*UPDATE\s+"([^"]+)"\s+SET\s+"([^"]+)"\s*=\s*(.+?)(?:\s+WHERE\s+(.+?))?\s*;?\s*$/i);
528
+ if (!match) {
529
+ throw new Error(`Unsupported Loki migration SQL: ${query}`);
530
+ }
531
+ const [, table, column, rawValue, rawWhere] = match;
532
+ return {
533
+ table,
534
+ column,
535
+ value: parseSqlLiteral(rawValue),
536
+ where: rawWhere ? parseSqlWhere(rawWhere) : undefined,
537
+ };
538
+ }
539
+ function parseSqlWhere(whereClause) {
540
+ const isNotNull = whereClause.match(/^"([^"]+)"\s+IS\s+NOT\s+NULL$/i);
541
+ if (isNotNull) {
542
+ return { type: 'isNotNull', column: isNotNull[1] };
543
+ }
544
+ const isNull = whereClause.match(/^"([^"]+)"\s+IS\s+NULL$/i);
545
+ if (isNull) {
546
+ return { type: 'isNull', column: isNull[1] };
547
+ }
548
+ const eq = whereClause.match(/^"([^"]+)"\s*=\s*(.+)$/i);
549
+ if (eq) {
550
+ return { type: 'eq', column: eq[1], value: parseSqlLiteral(eq[2]) };
551
+ }
552
+ const neq = whereClause.match(/^"([^"]+)"\s*(?:!=|<>)\s*(.+)$/i);
553
+ if (neq) {
554
+ return { type: 'neq', column: neq[1], value: parseSqlLiteral(neq[2]) };
555
+ }
556
+ throw new Error(`Unsupported Loki migration WHERE clause: ${whereClause}`);
557
+ }
558
+ function parseSqlLiteral(value) {
559
+ const trimmed = value.trim();
560
+ if (/^null$/i.test(trimmed)) {
561
+ return null;
562
+ }
563
+ if (/^true$/i.test(trimmed)) {
564
+ return true;
565
+ }
566
+ if (/^false$/i.test(trimmed)) {
567
+ return false;
568
+ }
569
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
570
+ return Number(trimmed);
571
+ }
572
+ const stringMatch = trimmed.match(/^'(.*)'$/s);
573
+ if (stringMatch) {
574
+ return stringMatch[1].replaceAll("''", "'");
575
+ }
576
+ throw new Error(`Unsupported Loki migration SQL literal: ${value}`);
577
+ }
578
+ function matchesLokiMigrationWhere(doc, where) {
579
+ if (!where) {
580
+ return true;
581
+ }
582
+ const value = doc[where.column];
583
+ switch (where.type) {
584
+ case 'isNull':
585
+ return value == null;
586
+ case 'isNotNull':
587
+ return value != null;
588
+ case 'eq':
589
+ return value === where.value;
590
+ case 'neq':
591
+ return value !== where.value;
592
+ }
593
+ }
454
594
  // ─── Strip LokiJS internal metadata ($loki, meta) ────────────────────────
455
595
  function stripLokiMeta(docs) {
456
596
  return docs.map(stripLokiMetaSingle);
@@ -16,7 +16,8 @@
16
16
  */
17
17
  import type { StorageAdapter } from '../adapters/types';
18
18
  import type { EncryptionConfig } from '../adapters/types';
19
- import type { ModelSchema, RawRecord } from '../schema/types';
19
+ import type { ModelSchema } from '../schema/types';
20
+ import type { SyncConfig, SyncLog, SyncState } from '../sync/types';
20
21
  import { Collection } from '../collection/Collection';
21
22
  import type { Model } from '../model/Model';
22
23
  import type { ModelStatic, ModelDatabaseRef } from '../model/Model';
@@ -38,6 +39,9 @@ export type DatabaseEvent = {
38
39
  type: 'sync_started';
39
40
  } | {
40
41
  type: 'sync_completed';
42
+ } | {
43
+ type: 'sync_failed';
44
+ error: string;
41
45
  } | {
42
46
  type: 'reset';
43
47
  };
@@ -51,6 +55,8 @@ export declare class Database implements ModelDatabaseRef {
51
55
  private _writeQueue;
52
56
  private _isProcessingQueue;
53
57
  private _events$;
58
+ private _syncState$;
59
+ private _syncLog$;
54
60
  private _schemaVersion;
55
61
  constructor(config: DatabaseConfig);
56
62
  /**
@@ -90,35 +96,28 @@ export declare class Database implements ModelDatabaseRef {
90
96
  batch(operations: BatchOperation[]): Promise<void>;
91
97
  /** @internal used by Model */
92
98
  _batch(operations: BatchOperation[]): Promise<void>;
99
+ /** @internal Find a record by table+id for relation resolution */
100
+ _findById(table: string, id: string): Promise<Model | null>;
101
+ /** @internal Observe a record by table+id for relation resolution */
102
+ _observeById(table: string, id: string): Observable<Model | null>;
103
+ /** @internal Fetch related records for has-many relation */
104
+ _fetchRelated(table: string, foreignKey: string, id: string): Promise<Model[]>;
105
+ /** @internal Observe related records for has-many relation */
106
+ _observeRelated(table: string, foreignKey: string, id: string): Observable<Model[]>;
93
107
  /**
94
108
  * Run a sync cycle.
95
109
  * See sync/index.ts for the full implementation.
96
110
  */
97
- sync(opts: {
98
- pullChanges: (params: {
99
- lastPulledAt: number | null;
100
- }) => Promise<{
101
- changes: Record<string, {
102
- created: RawRecord[];
103
- updated: RawRecord[];
104
- deleted: string[];
105
- }>;
106
- timestamp: number;
107
- }>;
108
- pushChanges: (params: {
109
- changes: Record<string, {
110
- created: RawRecord[];
111
- updated: RawRecord[];
112
- deleted: string[];
113
- }>;
114
- lastPulledAt: number;
115
- }) => Promise<void>;
116
- }): Promise<void>;
111
+ sync(opts: SyncConfig): Promise<void>;
117
112
  /**
118
113
  * Completely reset the database — drops all data.
119
114
  */
120
115
  reset(): Promise<void>;
121
116
  get events$(): Observable<DatabaseEvent>;
117
+ get syncState$(): Observable<SyncState>;
118
+ get syncLog$(): Observable<SyncLog | null>;
119
+ observeSyncState(): Observable<SyncState>;
120
+ observeSyncLog(): Observable<SyncLog | null>;
122
121
  close(): Promise<void>;
123
122
  private _ensureInitialized;
124
123
  /**
@@ -63,6 +63,8 @@ class Database {
63
63
  _writeQueue = [];
64
64
  _isProcessingQueue = false;
65
65
  _events$ = new Subject_1.Subject();
66
+ _syncState$ = new Subject_1.BehaviorSubject('idle');
67
+ _syncLog$ = new Subject_1.BehaviorSubject(null);
66
68
  _schemaVersion;
67
69
  constructor(config) {
68
70
  this.config = config;
@@ -219,6 +221,45 @@ class Database {
219
221
  async _batch(operations) {
220
222
  await this._adapter.batch(operations);
221
223
  }
224
+ // ─── Relation Resolution (RelationDatabaseRef) ─────────────────────
225
+ /** @internal Find a record by table+id for relation resolution */
226
+ async _findById(table, id) {
227
+ const collection = this._collections.get(table);
228
+ if (!collection) {
229
+ throw new Error(`No collection registered for table "${table}"`);
230
+ }
231
+ return collection.findById(id);
232
+ }
233
+ /** @internal Observe a record by table+id for relation resolution */
234
+ _observeById(table, id) {
235
+ const collection = this._collections.get(table);
236
+ if (!collection) {
237
+ throw new Error(`No collection registered for table "${table}"`);
238
+ }
239
+ return collection.observeById(id);
240
+ }
241
+ /** @internal Fetch related records for has-many relation */
242
+ async _fetchRelated(table, foreignKey, id) {
243
+ const collection = this._collections.get(table);
244
+ if (!collection) {
245
+ throw new Error(`No collection registered for table "${table}"`);
246
+ }
247
+ const qb = collection.query((q) => {
248
+ q.where(foreignKey, 'eq', id);
249
+ });
250
+ return collection.fetch(qb);
251
+ }
252
+ /** @internal Observe related records for has-many relation */
253
+ _observeRelated(table, foreignKey, id) {
254
+ const collection = this._collections.get(table);
255
+ if (!collection) {
256
+ throw new Error(`No collection registered for table "${table}"`);
257
+ }
258
+ const qb = collection.query((q) => {
259
+ q.where(foreignKey, 'eq', id);
260
+ });
261
+ return collection.observeQuery(qb);
262
+ }
222
263
  // ─── Sync ──────────────────────────────────────────────────────────
223
264
  /**
224
265
  * Run a sync cycle.
@@ -226,9 +267,25 @@ class Database {
226
267
  */
227
268
  async sync(opts) {
228
269
  this._ensureInitialized();
270
+ this._events$.next({ type: 'sync_started' });
229
271
  // Import sync dynamically to keep the module boundary clean
230
272
  const { performSync } = await Promise.resolve().then(() => __importStar(require('../sync')));
231
- await performSync(this, opts);
273
+ try {
274
+ await performSync(this, opts, {
275
+ onStateChange: (state) => {
276
+ this._syncState$.next(state);
277
+ },
278
+ onLogChange: (log) => {
279
+ this._syncLog$.next(log);
280
+ },
281
+ });
282
+ this._events$.next({ type: 'sync_completed' });
283
+ }
284
+ catch (error) {
285
+ const message = error instanceof Error ? error.message : String(error);
286
+ this._events$.next({ type: 'sync_failed', error: message });
287
+ throw error;
288
+ }
232
289
  }
233
290
  // ─── Reset ──────────────────────────────────────────────────────────
234
291
  /**
@@ -245,6 +302,18 @@ class Database {
245
302
  get events$() {
246
303
  return this._events$;
247
304
  }
305
+ get syncState$() {
306
+ return this._syncState$;
307
+ }
308
+ get syncLog$() {
309
+ return this._syncLog$;
310
+ }
311
+ observeSyncState() {
312
+ return this._syncState$;
313
+ }
314
+ observeSyncLog() {
315
+ return this._syncLog$;
316
+ }
248
317
  // ─── Close ──────────────────────────────────────────────────────────
249
318
  async close() {
250
319
  await this._adapter.close();
@@ -1,20 +1,32 @@
1
1
  /**
2
2
  * Encryption layer — transparent encrypt/decrypt for storage adapters.
3
3
  *
4
- * Wraps a StorageAdapter, encrypting record values before writes
5
- * and decrypting after reads. Uses AES-GCM (Web Crypto API or Node crypto).
6
- *
7
- * The encryption is transparent to the model/collection layer.
8
- * Only user-data columns are encrypted; id, _status, _changed are stored in plaintext
9
- * so the adapter can still query by them.
4
+ * This shared entry only depends on Web Crypto primitives so it stays safe for
5
+ * Expo Snack and React Native bundles. Node-specific crypto support lives in
6
+ * `pomegranate-db/encryption/node`.
10
7
  */
11
8
  import type { StorageAdapter, Migration } from '../adapters/types';
12
9
  import type { QueryDescriptor, SearchDescriptor, BatchOperation } from '../query/types';
13
10
  import type { DatabaseSchema, RawRecord } from '../schema/types';
11
+ export interface EncryptionProvider {
12
+ readonly name: string;
13
+ readonly supportsAuthTag: boolean;
14
+ randomBytes(length: number): Promise<Uint8Array> | Uint8Array;
15
+ encrypt(key: Uint8Array, iv: Uint8Array, plaintext: Uint8Array): Promise<{
16
+ ciphertext: Uint8Array;
17
+ tag?: Uint8Array;
18
+ }> | {
19
+ ciphertext: Uint8Array;
20
+ tag?: Uint8Array;
21
+ };
22
+ decrypt(key: Uint8Array, iv: Uint8Array, ciphertext: Uint8Array, tag?: Uint8Array): Promise<Uint8Array> | Uint8Array;
23
+ }
24
+ export declare const webCryptoProvider: EncryptionProvider;
14
25
  export declare class EncryptionManager {
15
26
  private _key;
16
27
  private _keyProvider;
17
- constructor(keyProvider: () => Promise<Uint8Array>);
28
+ private _provider;
29
+ constructor(keyProvider: () => Promise<Uint8Array>, provider?: EncryptionProvider);
18
30
  getKey(): Promise<Uint8Array>;
19
31
  /** Encrypt a string value */
20
32
  encrypt(plaintext: string): Promise<string>;
@@ -28,7 +40,7 @@ export declare class EncryptionManager {
28
40
  export declare class EncryptingAdapter implements StorageAdapter {
29
41
  private _inner;
30
42
  private _encryption;
31
- constructor(inner: StorageAdapter, keyProvider: () => Promise<Uint8Array>);
43
+ constructor(inner: StorageAdapter, keyProvider: () => Promise<Uint8Array>, provider?: EncryptionProvider);
32
44
  initialize(schema: DatabaseSchema): Promise<void>;
33
45
  find(query: QueryDescriptor): Promise<RawRecord[]>;
34
46
  count(query: QueryDescriptor): Promise<number>;
@@ -2,24 +2,59 @@
2
2
  /**
3
3
  * Encryption layer — transparent encrypt/decrypt for storage adapters.
4
4
  *
5
- * Wraps a StorageAdapter, encrypting record values before writes
6
- * and decrypting after reads. Uses AES-GCM (Web Crypto API or Node crypto).
7
- *
8
- * The encryption is transparent to the model/collection layer.
9
- * Only user-data columns are encrypted; id, _status, _changed are stored in plaintext
10
- * so the adapter can still query by them.
5
+ * This shared entry only depends on Web Crypto primitives so it stays safe for
6
+ * Expo Snack and React Native bundles. Node-specific crypto support lives in
7
+ * `pomegranate-db/encryption/node`.
11
8
  */
12
9
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.EncryptingAdapter = exports.EncryptionManager = void 0;
14
- const nodeCrypto_1 = require("./nodeCrypto");
10
+ exports.EncryptingAdapter = exports.EncryptionManager = exports.webCryptoProvider = void 0;
11
+ function getWebCrypto() {
12
+ if (globalThis.crypto === undefined || globalThis.crypto.subtle === undefined) {
13
+ throw new Error('Web Crypto API is not available in this runtime. '
14
+ + 'Import pomegranate-db/encryption/node in Node.js environments '
15
+ + 'without globalThis.crypto.subtle.');
16
+ }
17
+ return globalThis.crypto;
18
+ }
19
+ exports.webCryptoProvider = {
20
+ name: 'web-crypto',
21
+ supportsAuthTag: false,
22
+ randomBytes(length) {
23
+ if (globalThis.crypto?.getRandomValues) {
24
+ const buf = new Uint8Array(length);
25
+ globalThis.crypto.getRandomValues(buf);
26
+ return buf;
27
+ }
28
+ // Last resort for non-cryptographic test environments.
29
+ const buf = new Uint8Array(length);
30
+ for (let i = 0; i < length; i++) {
31
+ buf[i] = Math.floor(Math.random() * 256);
32
+ }
33
+ return buf;
34
+ },
35
+ async encrypt(key, iv, plaintext) {
36
+ const crypto = getWebCrypto();
37
+ const cryptoKey = await crypto.subtle.importKey('raw', key.buffer, { name: 'AES-GCM' }, false, ['encrypt']);
38
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv.buffer }, cryptoKey, plaintext.buffer);
39
+ return { ciphertext: new Uint8Array(encrypted) };
40
+ },
41
+ async decrypt(key, iv, ciphertext) {
42
+ const crypto = getWebCrypto();
43
+ const cryptoKey = await crypto.subtle.importKey('raw', key.buffer, { name: 'AES-GCM' }, false, ['decrypt']);
44
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv.buffer }, cryptoKey, ciphertext.buffer);
45
+ return new Uint8Array(decrypted);
46
+ },
47
+ };
15
48
  // ─── Columns that are never encrypted ──────────────────────────────────
16
49
  const PLAINTEXT_COLUMNS = new Set(['id', '_status', '_changed']);
17
50
  // ─── Encryption Manager ───────────────────────────────────────────────
18
51
  class EncryptionManager {
19
52
  _key = null;
20
53
  _keyProvider;
21
- constructor(keyProvider) {
54
+ _provider;
55
+ constructor(keyProvider, provider = exports.webCryptoProvider) {
22
56
  this._keyProvider = keyProvider;
57
+ this._provider = provider;
23
58
  }
24
59
  async getKey() {
25
60
  if (!this._key) {
@@ -30,57 +65,34 @@ class EncryptionManager {
30
65
  /** Encrypt a string value */
31
66
  async encrypt(plaintext) {
32
67
  const key = await this.getKey();
33
- const iv = await randomBytes(12);
68
+ const iv = await this._provider.randomBytes(12);
34
69
  const encoder = new TextEncoder();
35
70
  const data = encoder.encode(plaintext);
36
- if (globalThis.crypto !== undefined && globalThis.crypto.subtle) {
37
- // Web Crypto API
38
- const cryptoKey = await globalThis.crypto.subtle.importKey('raw', key.buffer, { name: 'AES-GCM' }, false, ['encrypt']);
39
- const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv.buffer }, cryptoKey, data.buffer);
40
- return encodeBase64(iv) + ':' + encodeBase64(new Uint8Array(encrypted));
41
- }
42
- // Fallback: Node.js crypto
43
- if (nodeCrypto_1.isNodeCryptoAvailable) {
44
- try {
45
- const cipher = (0, nodeCrypto_1.createCipheriv)('aes-256-gcm', key, iv);
46
- const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
47
- const tag = cipher.getAuthTag();
48
- return encodeBase64(iv) + ':' + encodeBase64(encrypted) + ':' + encodeBase64(tag);
49
- }
50
- catch {
51
- throw new Error('No crypto implementation available for encryption');
71
+ try {
72
+ const { ciphertext, tag } = await this._provider.encrypt(key, iv, data);
73
+ if (this._provider.supportsAuthTag && tag) {
74
+ return encodeBase64(iv) + ':' + encodeBase64(ciphertext) + ':' + encodeBase64(tag);
52
75
  }
76
+ return encodeBase64(iv) + ':' + encodeBase64(ciphertext);
77
+ }
78
+ catch {
79
+ throw new Error('No crypto implementation available for encryption');
53
80
  }
54
- throw new Error('No crypto implementation available for encryption');
55
81
  }
56
82
  /** Decrypt a string value */
57
83
  async decrypt(ciphertext) {
58
84
  const key = await this.getKey();
59
85
  const parts = ciphertext.split(':');
60
- if (globalThis.crypto !== undefined && globalThis.crypto.subtle) {
61
- // Web Crypto API
86
+ try {
62
87
  const iv = decodeBase64(parts[0]);
63
88
  const data = decodeBase64(parts[1]);
64
- const cryptoKey = await globalThis.crypto.subtle.importKey('raw', key.buffer, { name: 'AES-GCM' }, false, ['decrypt']);
65
- const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv.buffer }, cryptoKey, data.buffer);
89
+ const tag = parts[2] ? decodeBase64(parts[2]) : undefined;
90
+ const decrypted = await this._provider.decrypt(key, iv, data, tag);
66
91
  return new TextDecoder().decode(decrypted);
67
92
  }
68
- // Fallback: Node.js crypto
69
- if (nodeCrypto_1.isNodeCryptoAvailable) {
70
- try {
71
- const iv = decodeBase64(parts[0]);
72
- const data = decodeBase64(parts[1]);
73
- const tag = decodeBase64(parts[2]);
74
- const decipher = (0, nodeCrypto_1.createDecipheriv)('aes-256-gcm', key, iv);
75
- decipher.setAuthTag(tag);
76
- const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
77
- return new TextDecoder().decode(decrypted);
78
- }
79
- catch (error) {
80
- throw new Error(`Decryption failed: ${error}`, { cause: error });
81
- }
93
+ catch (error) {
94
+ throw new Error(`Decryption failed: ${error}`, { cause: error });
82
95
  }
83
- throw new Error('No crypto implementation available for decryption');
84
96
  }
85
97
  }
86
98
  exports.EncryptionManager = EncryptionManager;
@@ -92,9 +104,9 @@ exports.EncryptionManager = EncryptionManager;
92
104
  class EncryptingAdapter {
93
105
  _inner;
94
106
  _encryption;
95
- constructor(inner, keyProvider) {
107
+ constructor(inner, keyProvider, provider = exports.webCryptoProvider) {
96
108
  this._inner = inner;
97
- this._encryption = new EncryptionManager(keyProvider);
109
+ this._encryption = new EncryptionManager(keyProvider, provider);
98
110
  }
99
111
  async initialize(schema) {
100
112
  await this._inner.initialize(schema);
@@ -230,26 +242,6 @@ class EncryptingAdapter {
230
242
  }
231
243
  }
232
244
  exports.EncryptingAdapter = EncryptingAdapter;
233
- // ─── Utility functions ──────────────────────────────────────────────────
234
- async function randomBytes(length) {
235
- if (globalThis.crypto !== undefined && globalThis.crypto.getRandomValues) {
236
- const buf = new Uint8Array(length);
237
- globalThis.crypto.getRandomValues(buf);
238
- return buf;
239
- }
240
- if (nodeCrypto_1.isNodeCryptoAvailable) {
241
- try {
242
- return (0, nodeCrypto_1.randomBytesNode)(length);
243
- }
244
- catch { /* fall through to Math.random */ }
245
- }
246
- // Last resort: Math.random (NOT cryptographically secure)
247
- const buf = new Uint8Array(length);
248
- for (let i = 0; i < length; i++) {
249
- buf[i] = Math.floor(Math.random() * 256);
250
- }
251
- return buf;
252
- }
253
245
  function encodeBase64(data) {
254
246
  if (typeof Buffer !== 'undefined') {
255
247
  return Buffer.from(data).toString('base64');
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Node.js-specific crypto provider for environments that do not expose
3
+ * `globalThis.crypto.subtle`.
4
+ */
5
+ import type { EncryptionProvider } from './index';
6
+ export declare const nodeCryptoProvider: EncryptionProvider;
7
+ export { EncryptingAdapter, EncryptionManager } from './index';
8
+ export type { EncryptionProvider } from './index';