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 +54 -4
- package/dist/adapters/loki/worker/LokiExecutor.d.ts +4 -0
- package/dist/adapters/loki/worker/LokiExecutor.js +142 -2
- package/dist/database/Database.d.ts +20 -21
- package/dist/database/Database.js +70 -1
- package/dist/encryption/index.d.ts +20 -8
- package/dist/encryption/index.js +60 -68
- package/dist/encryption/node.d.ts +8 -0
- package/dist/encryption/node.js +33 -0
- package/dist/encryption/react-native.d.ts +8 -0
- package/dist/encryption/react-native.js +14 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -5
- package/dist/model/Model.d.ts +25 -2
- package/dist/model/Model.js +47 -7
- package/dist/model/Relation.d.ts +37 -0
- package/dist/model/Relation.js +69 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +4 -1
- package/dist/observable/Subject.js +4 -0
- package/dist/schema/builder.d.ts +6 -6
- package/dist/schema/builder.js +7 -7
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/types.d.ts +47 -8
- package/dist/sync/sync.d.ts +7 -2
- package/dist/sync/sync.js +101 -62
- package/package.json +17 -2
- package/dist/encryption/nodeCrypto.d.ts +0 -18
- package/dist/encryption/nodeCrypto.js +0 -25
- package/dist/encryption/nodeCrypto.native.d.ts +0 -13
- package/dist/encryption/nodeCrypto.native.js +0 -26
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/
|
|
116
|
-
- [Schema & Models](https://bobbyquantum.github.io/pomegranate/
|
|
117
|
-
- [CRUD Operations](https://bobbyquantum.github.io/pomegranate/
|
|
118
|
-
- [React Hooks](https://bobbyquantum.github.io/pomegranate/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
* and
|
|
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
|
-
|
|
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>;
|
package/dist/encryption/index.js
CHANGED
|
@@ -2,24 +2,59 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Encryption layer — transparent encrypt/decrypt for storage adapters.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* and
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
61
|
-
// Web Crypto API
|
|
86
|
+
try {
|
|
62
87
|
const iv = decodeBase64(parts[0]);
|
|
63
88
|
const data = decodeBase64(parts[1]);
|
|
64
|
-
const
|
|
65
|
-
const decrypted = await
|
|
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
|
-
|
|
69
|
-
|
|
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';
|