@fireflydb/expo-driver 0.0.6

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.
@@ -0,0 +1,83 @@
1
+ // Expo secure-storage driver. Wraps expo-secure-store, which is iOS Keychain /
2
+ // Android EncryptedSharedPreferences under the hood. Stores the 32-byte
3
+ // Ed25519 device seed (and any future binary secrets) by base64-encoding at
4
+ // the boundary, since SecureStore's value type is string.
5
+
6
+ import * as SecureStore from 'expo-secure-store';
7
+ import type { SecureStorageDriver } from '@fireflydb/core';
8
+
9
+ export interface ExpoSecureStorageOptions {
10
+ /**
11
+ * Optional namespace prefix for all keys. Useful when multiple FireflyDB
12
+ * databases coexist in one app — pass e.g. `firefly.<dbName>`. Defaults to
13
+ * an empty prefix; callers are responsible for collision avoidance.
14
+ */
15
+ prefix?: string;
16
+
17
+ /**
18
+ * Forwarded to SecureStore.setItemAsync. Defaults to
19
+ * WHEN_UNLOCKED_THIS_DEVICE_ONLY (iOS) / no biometric prompt (Android).
20
+ */
21
+ keychainAccessible?: SecureStore.KeychainAccessibilityConstant;
22
+ }
23
+
24
+ export class ExpoSecureStorageDriver implements SecureStorageDriver {
25
+ private readonly prefix: string;
26
+ private readonly storeOpts: SecureStore.SecureStoreOptions;
27
+
28
+ constructor(opts: ExpoSecureStorageOptions = {}) {
29
+ this.prefix = opts.prefix ?? '';
30
+ this.storeOpts = {};
31
+ if (opts.keychainAccessible !== undefined) {
32
+ this.storeOpts.keychainAccessible = opts.keychainAccessible;
33
+ }
34
+ }
35
+
36
+ async get(key: string): Promise<Uint8Array | null> {
37
+ const raw = await SecureStore.getItemAsync(this.namespaced(key), this.storeOpts);
38
+ if (raw === null) return null;
39
+ return base64Decode(raw);
40
+ }
41
+
42
+ async set(key: string, value: Uint8Array): Promise<void> {
43
+ await SecureStore.setItemAsync(
44
+ this.namespaced(key),
45
+ base64Encode(value),
46
+ this.storeOpts,
47
+ );
48
+ }
49
+
50
+ async delete(key: string): Promise<void> {
51
+ await SecureStore.deleteItemAsync(this.namespaced(key), this.storeOpts);
52
+ }
53
+
54
+ private namespaced(key: string): string {
55
+ // SecureStore allows alphanumerics + . - _; replace anything else.
56
+ const safe = `${this.prefix}${this.prefix ? '.' : ''}${key}`.replace(
57
+ /[^A-Za-z0-9._-]/g,
58
+ '_',
59
+ );
60
+ return safe;
61
+ }
62
+ }
63
+
64
+ function base64Encode(bytes: Uint8Array): string {
65
+ // RN runtimes (Hermes, JSC) all expose globalThis.btoa via core-js polyfills
66
+ // since RN 0.74. We avoid Buffer to stay platform-portable.
67
+ let binary = '';
68
+ for (let i = 0; i < bytes.length; i++) {
69
+ binary += String.fromCharCode(bytes[i]!);
70
+ }
71
+ // eslint-disable-next-line no-undef
72
+ return btoa(binary);
73
+ }
74
+
75
+ function base64Decode(s: string): Uint8Array {
76
+ // eslint-disable-next-line no-undef
77
+ const binary = atob(s);
78
+ const out = new Uint8Array(binary.length);
79
+ for (let i = 0; i < binary.length; i++) {
80
+ out[i] = binary.charCodeAt(i);
81
+ }
82
+ return out;
83
+ }
@@ -0,0 +1,322 @@
1
+ // Expo SQLite driver. Wires an APP-OWNED expo-sqlite connection into
2
+ // FireflyDB, with libfirefly loaded as a SQLite extension.
3
+ //
4
+ // The app opens the connection and passes it in: libfirefly's CRDT triggers
5
+ // and per-connection device_id live on that ONE connection, and the app's own
6
+ // reads/writes must run on the SAME handle to be captured and synced — a
7
+ // second connection to the file would lack the extension, so the
8
+ // trigger-invoked firefly_* functions wouldn't exist there. The driver
9
+ // borrows the handle for the client's lifetime and never closes it (matches
10
+ // the Node driver's SetMaxOpenConns(1) discipline).
11
+ //
12
+ // SQL strings and call patterns mirror NodeSqliteDb in @fireflydb/node-test-driver.
13
+
14
+ import type * as SQLite from 'expo-sqlite';
15
+ import type {
16
+ ApplyResult,
17
+ ChangeSubscription,
18
+ OpenOpts,
19
+ PeerStatus,
20
+ SqliteDb,
21
+ SqliteDriver,
22
+ } from '@fireflydb/core';
23
+ import { encodeKeysU32 } from '@fireflydb/core';
24
+
25
+ import FireflyClientModule, { subscribeToChanges } from '../FireflyClientModule';
26
+
27
+ export interface ExpoSqliteDriverOptions {
28
+ /**
29
+ * The app-opened expo-sqlite connection (e.g. `await
30
+ * SQLite.openDatabaseAsync('app.db')`). The driver enforces its setup
31
+ * invariants on it — WAL + foreign_keys pragmas, loading libfirefly (don't
32
+ * load it yourself) — but ownership stays with the app: `client.close()`
33
+ * detaches without closing, so the connection survives the client (sign-out
34
+ * teardown, per-user client swaps). Closing it is the app's job.
35
+ *
36
+ * `FireflyClientConfig.dbPath` is not used to open and may be omitted —
37
+ * the handle already knows its file. If supplied, it must name the same
38
+ * database file; the driver throws on a mismatch.
39
+ */
40
+ db: SQLite.SQLiteDatabase;
41
+ /**
42
+ * Override the libfirefly path. If absent we ask the native module, which
43
+ * resolves to the bundled libfirefly.dylib (iOS) / libfirefly.so (Android).
44
+ */
45
+ extensionPath?: string;
46
+ }
47
+
48
+ // Handles this module has already loaded libfirefly onto. The same handle can
49
+ // pass through open() more than once (a new FireflyClient over the same
50
+ // connection after close()); re-running the extension init is not something
51
+ // we rely on being safe, so load exactly once per handle.
52
+ const extensionLoaded = new WeakSet<SQLite.SQLiteDatabase>();
53
+
54
+ export class ExpoSqliteDriver implements SqliteDriver {
55
+ constructor(private readonly opts: ExpoSqliteDriverOptions) {}
56
+
57
+ async open(opts: OpenOpts): Promise<SqliteDb> {
58
+ const extPath =
59
+ opts.extensionPath ??
60
+ this.opts.extensionPath ??
61
+ FireflyClientModule.getLibraryPath();
62
+ const entryPoint = FireflyClientModule.getEntryPoint();
63
+
64
+ const db = this.opts.db;
65
+ assertSameDatabase(opts.path, db);
66
+
67
+ // Ensure WAL for concurrent readers; mirrors NodeSqliteDriver. Idempotent,
68
+ // so safe to re-apply on the app's connection.
69
+ await db.execAsync('PRAGMA journal_mode = WAL');
70
+ await db.execAsync('PRAGMA foreign_keys = ON');
71
+ if (!extensionLoaded.has(db)) {
72
+ // expo-sqlite exposes loadExtensionAsync on the database handle. It calls
73
+ // exsqlite3_enable_load_extension + exsqlite3_load_extension under the
74
+ // hood — Apple bans this on the system SQLite, but expo-sqlite ships its
75
+ // own bundled SQLite where it's allowed.
76
+ await db.loadExtensionAsync(extPath, entryPoint);
77
+ extensionLoaded.add(db);
78
+ }
79
+ return new ExpoSqliteDb(db);
80
+ }
81
+ }
82
+
83
+ /** Row alias for `SELECT firefly_xxx(?) AS v`. */
84
+ type ScalarRow<T> = { v: T };
85
+
86
+ class ExpoSqliteDb implements SqliteDb {
87
+ constructor(private readonly db: SQLite.SQLiteDatabase) {}
88
+
89
+ subscribeToChanges(handler: (blob: Uint8Array) => void): ChangeSubscription {
90
+ // expo-sqlite resolves the (possibly relative) name passed to
91
+ // openDatabaseAsync to an absolute filesystem path; libfirefly's
92
+ // listener registry keys by canonical path, so we hand it the
93
+ // resolved one.
94
+ return subscribeToChanges(this.db.databasePath, handler);
95
+ }
96
+
97
+ // The libfirefly scalar functions all return BLOB; we alias the result as
98
+ // `v` so we can read by name instead of by the stringified call expression.
99
+ private async callScalarBlob(sql: string, params: unknown[]): Promise<Uint8Array> {
100
+ const aliased = aliasScalar(sql);
101
+ const row = await this.db.getFirstAsync<ScalarRow<Uint8Array | null>>(
102
+ aliased,
103
+ ...(params as SQLite.SQLiteBindValue[]),
104
+ );
105
+ if (!row || row.v === null || row.v === undefined) return new Uint8Array(0);
106
+ return ensureUint8Array(row.v);
107
+ }
108
+
109
+ private async callScalarBlobOrNull(
110
+ sql: string,
111
+ params: unknown[],
112
+ ): Promise<Uint8Array | null> {
113
+ const aliased = aliasScalar(sql);
114
+ const row = await this.db.getFirstAsync<ScalarRow<Uint8Array | null>>(
115
+ aliased,
116
+ ...(params as SQLite.SQLiteBindValue[]),
117
+ );
118
+ if (!row || row.v === null || row.v === undefined) return null;
119
+ return ensureUint8Array(row.v);
120
+ }
121
+
122
+ private async callScalarString(sql: string, params: unknown[]): Promise<string> {
123
+ const aliased = aliasScalar(sql);
124
+ const row = await this.db.getFirstAsync<ScalarRow<string>>(
125
+ aliased,
126
+ ...(params as SQLite.SQLiteBindValue[]),
127
+ );
128
+ if (!row || typeof row.v !== 'string') {
129
+ throw new Error(`expected string scalar from: ${sql}`);
130
+ }
131
+ return row.v;
132
+ }
133
+
134
+ private async callVoid(sql: string, params: unknown[]): Promise<void> {
135
+ if (params.length === 0) {
136
+ await this.db.execAsync(sql);
137
+ return;
138
+ }
139
+ await this.db.runAsync(sql, ...(params as SQLite.SQLiteBindValue[]));
140
+ }
141
+
142
+ // --- lifecycle -----------------------------------------------------------
143
+
144
+ async init(dbName: string, seed: Uint8Array): Promise<void> {
145
+ await this.callVoid('SELECT firefly_init(?, ?)', [dbName, seed]);
146
+ }
147
+
148
+ async trackTable(name: string): Promise<void> {
149
+ // 2nd arg is the "infer schema" flag (0 = explicit). Mirrors execTrack in
150
+ // the Go test harness.
151
+ await this.callVoid('SELECT firefly_track_table(?, 0)', [name]);
152
+ }
153
+
154
+ async close(): Promise<void> {
155
+ // Whoever opened the connection closes it: the handle belongs to the app
156
+ // and survives the client — this detaches without closing.
157
+ }
158
+
159
+ // --- peer management -----------------------------------------------------
160
+
161
+ async registerPeer(
162
+ peerID: string,
163
+ publicKey: Uint8Array,
164
+ kind: string,
165
+ ): Promise<void> {
166
+ await this.callVoid('SELECT firefly_register_peer(?, ?, ?)', [
167
+ peerID,
168
+ publicKey,
169
+ kind,
170
+ ]);
171
+ }
172
+
173
+ async setPeerStatus(peerID: string, status: PeerStatus): Promise<void> {
174
+ await this.callVoid('SELECT firefly_set_peer_status(?, ?)', [peerID, status]);
175
+ }
176
+
177
+ // --- MST drilldown -------------------------------------------------------
178
+
179
+ async syncStart(): Promise<string> {
180
+ return this.callScalarString('SELECT firefly_sync_start()', []);
181
+ }
182
+
183
+ async syncGossip(label: string): Promise<Uint8Array> {
184
+ return this.callScalarBlob('SELECT firefly_sync_gossip(?)', [label]);
185
+ }
186
+
187
+ async syncGetPages(label: string, request: Uint8Array): Promise<Uint8Array> {
188
+ return this.callScalarBlob('SELECT firefly_sync_get_pages(?, ?)', [
189
+ label,
190
+ request,
191
+ ]);
192
+ }
193
+
194
+ async syncEnd(label: string): Promise<void> {
195
+ await this.callVoid('SELECT firefly_sync_end(?)', [label]);
196
+ }
197
+
198
+ async reconcileStart(gossip: Uint8Array): Promise<Uint8Array | null> {
199
+ return this.callScalarBlobOrNull('SELECT firefly_reconcile_start(?)', [gossip]);
200
+ }
201
+
202
+ async reconcileRequestPages(session: Uint8Array): Promise<Uint8Array> {
203
+ return this.callScalarBlob('SELECT firefly_reconcile_request_pages(?)', [
204
+ session,
205
+ ]);
206
+ }
207
+
208
+ async reconcileContinue(
209
+ session: Uint8Array,
210
+ pages: Uint8Array,
211
+ ): Promise<Uint8Array> {
212
+ return this.callScalarBlob('SELECT firefly_reconcile_continue(?, ?)', [
213
+ session,
214
+ pages,
215
+ ]);
216
+ }
217
+
218
+ async reconcileResult(session: Uint8Array): Promise<Uint8Array> {
219
+ return this.callScalarBlob('SELECT firefly_reconcile_result(?)', [session]);
220
+ }
221
+
222
+ // --- bulk push/pull ------------------------------------------------------
223
+
224
+ async changes(keys: readonly bigint[]): Promise<Uint8Array> {
225
+ const blob = encodeKeysU32(keys);
226
+ return this.callScalarBlob('SELECT firefly_changes(?)', [blob]);
227
+ }
228
+
229
+ async apply(blob: Uint8Array): Promise<ApplyResult> {
230
+ const raw = await this.callScalarBlob('SELECT firefly_apply(?)', [blob]);
231
+ // firefly_apply's blob layout isn't parsed here — callers treat it as
232
+ // opaque (the relay's PushAck reports applied/conflict counts).
233
+ return { applied: 0, raw };
234
+ }
235
+
236
+ // --- signed-migration chain ---------------------------------------------
237
+
238
+ async migrationChainHead(): Promise<{ seq: number; hash: Uint8Array } | null> {
239
+ const row = await this.db.getFirstAsync<{
240
+ seq: number;
241
+ envelope_hash: Uint8Array | ArrayBuffer | null;
242
+ }>(
243
+ 'SELECT seq, envelope_hash FROM firefly_migration_chain ORDER BY seq DESC LIMIT 1',
244
+ );
245
+ if (!row || row.envelope_hash === null) return null;
246
+ return { seq: row.seq, hash: ensureUint8Array(row.envelope_hash) };
247
+ }
248
+
249
+ async applySignedMigration(envelope: Uint8Array): Promise<number> {
250
+ const aliased = aliasScalar('SELECT firefly_apply_signed_migration(?)');
251
+ const row = await this.db.getFirstAsync<ScalarRow<number>>(aliased, envelope);
252
+ if (!row || typeof row.v !== 'number') {
253
+ throw new Error('firefly_apply_signed_migration returned non-number');
254
+ }
255
+ return row.v;
256
+ }
257
+
258
+ // --- app-level CRUD ------------------------------------------------------
259
+
260
+ async exec(sql: string, params: readonly unknown[] = []): Promise<void> {
261
+ if (params.length === 0) {
262
+ await this.db.execAsync(sql);
263
+ return;
264
+ }
265
+ await this.db.runAsync(sql, ...(params as SQLite.SQLiteBindValue[]));
266
+ }
267
+
268
+ async query<T = Record<string, unknown>>(
269
+ sql: string,
270
+ params: readonly unknown[] = [],
271
+ ): Promise<T[]> {
272
+ const rows = await this.db.getAllAsync<T>(
273
+ sql,
274
+ ...(params as SQLite.SQLiteBindValue[]),
275
+ );
276
+ return rows;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * When the client config names a database file, the provided connection must
282
+ * be open on that file — dbName/secure-storage scoping and the change-listener
283
+ * registry all assume config and connection agree. Compares basenames (the
284
+ * config path may be a bare name while expo-sqlite reports the resolved
285
+ * absolute path); skipped when the config omits dbPath (the handle is the
286
+ * source of truth) and for in-memory / pathless handles, which have nothing
287
+ * to compare.
288
+ */
289
+ function assertSameDatabase(
290
+ path: string | undefined,
291
+ db: SQLite.SQLiteDatabase,
292
+ ): void {
293
+ if (path === undefined || path === '' || path === ':memory:') return;
294
+ const actual = db.databasePath;
295
+ if (!actual || actual === ':memory:') return;
296
+ const expected = path.slice(path.lastIndexOf('/') + 1);
297
+ const actualName = actual.slice(actual.lastIndexOf('/') + 1);
298
+ if (actualName !== expected) {
299
+ throw new Error(
300
+ `ExpoSqliteDriver: the provided connection is open on "${actual}", but ` +
301
+ `the client config names "${path}" — these must be the same database file`,
302
+ );
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Rewrite `SELECT firefly_xxx(?, ?)` to `SELECT firefly_xxx(?, ?) AS v` so
308
+ * getFirstAsync's row object has a stable column key. We only ever do this on
309
+ * pre-known SQL strings written inside this file, so no parser is needed —
310
+ * append before any trailing semicolon.
311
+ */
312
+ function aliasScalar(sql: string): string {
313
+ const trimmed = sql.replace(/;\s*$/, '');
314
+ return `${trimmed} AS v`;
315
+ }
316
+
317
+ function ensureUint8Array(v: unknown): Uint8Array {
318
+ if (v instanceof Uint8Array) return v;
319
+ if (v instanceof ArrayBuffer) return new Uint8Array(v);
320
+ if (typeof v === 'string') return new TextEncoder().encode(v);
321
+ throw new Error(`expected blob scalar, got ${typeof v}`);
322
+ }
@@ -0,0 +1,166 @@
1
+ // Expo / React Native WebSocket driver. Wraps the global WebSocket exposed by
2
+ // React Native, which accepts a 3rd-arg options object containing `headers`
3
+ // (the spec WebSocket constructor doesn't, but RN's polyfill does — that's
4
+ // how we pass the JWT bearer to the relay).
5
+ //
6
+ // Mirrors NodeWebSocketDriver from @fireflydb/node-test-driver.
7
+
8
+ import type { WebSocketDriver, WsConn } from '@fireflydb/core';
9
+ import { WsCloseError } from '@fireflydb/core';
10
+
11
+ export class ExpoWebSocketDriver implements WebSocketDriver {
12
+ async connect(url: string, headers: Record<string, string>): Promise<WsConn> {
13
+ // RN's WebSocket constructor: new WebSocket(url, protocols?, options?)
14
+ // where options.headers is forwarded to the upgrade request. Standard DOM
15
+ // WebSocket has no such overload — this is RN-specific. We cast through
16
+ // `any` to express the third arg.
17
+ const ws = new (WebSocket as unknown as RNWebSocketCtor)(url, undefined, {
18
+ headers,
19
+ });
20
+ ws.binaryType = 'arraybuffer';
21
+ await new Promise<void>((resolve, reject) => {
22
+ const onOpen = () => {
23
+ ws.removeEventListener('error', onErr as EventListener);
24
+ resolve();
25
+ };
26
+ const onErr = (ev: Event) => {
27
+ ws.removeEventListener('open', onOpen);
28
+ const err = (ev as ErrorEvent).message
29
+ ? new Error((ev as ErrorEvent).message)
30
+ : new Error('WebSocket error before open');
31
+ reject(err);
32
+ };
33
+ ws.addEventListener('open', onOpen, { once: true });
34
+ ws.addEventListener('error', onErr as EventListener, { once: true });
35
+ });
36
+ return new ExpoWsConn(ws);
37
+ }
38
+ }
39
+
40
+ interface RNWebSocketCtor {
41
+ new (
42
+ url: string,
43
+ protocols?: string | string[] | undefined,
44
+ options?: { headers?: Record<string, string> },
45
+ ): WebSocket;
46
+ }
47
+
48
+ class ExpoWsConn implements WsConn {
49
+ private readonly queue: Uint8Array[] = [];
50
+ private waiter: ((value: IteratorResult<Uint8Array>) => void) | null = null;
51
+ private rejecter: ((err: Error) => void) | null = null;
52
+ private closed = false;
53
+ private closeErr: Error | null = null;
54
+
55
+ constructor(private readonly ws: WebSocket) {
56
+ ws.addEventListener('message', (ev) => {
57
+ const data = (ev as MessageEvent).data;
58
+ // binaryType=arraybuffer means binary frames arrive as ArrayBuffer; text
59
+ // frames come as string. Protocol is binary-only; ignore strings.
60
+ if (typeof data === 'string') return;
61
+ if (data instanceof ArrayBuffer) {
62
+ this.deliver(new Uint8Array(data));
63
+ } else if (data instanceof Uint8Array) {
64
+ this.deliver(data);
65
+ }
66
+ });
67
+ ws.addEventListener('close', (ev) => {
68
+ const ce = ev as CloseEvent;
69
+ // Clean close (1000 / 1005 = no code) ends the iterator without error.
70
+ if (ce.code === 1000 || ce.code === 1005) {
71
+ this.finishClean();
72
+ } else {
73
+ this.finishError(new WsCloseError(ce.code, ce.reason ?? ''));
74
+ }
75
+ });
76
+ ws.addEventListener('error', () => {
77
+ // RN error events don't carry a useful message; surface a generic err.
78
+ // The close event that follows usually carries the real reason.
79
+ if (!this.closed) {
80
+ this.finishError(new Error('WebSocket error'));
81
+ }
82
+ });
83
+ }
84
+
85
+ private deliver(buf: Uint8Array): void {
86
+ if (this.waiter) {
87
+ const w = this.waiter;
88
+ this.waiter = null;
89
+ this.rejecter = null;
90
+ w({ value: buf, done: false });
91
+ return;
92
+ }
93
+ this.queue.push(buf);
94
+ }
95
+
96
+ private finishClean(): void {
97
+ this.closed = true;
98
+ if (this.waiter) {
99
+ const w = this.waiter;
100
+ this.waiter = null;
101
+ this.rejecter = null;
102
+ w({ value: undefined, done: true });
103
+ }
104
+ }
105
+
106
+ private finishError(err: Error): void {
107
+ this.closed = true;
108
+ this.closeErr = err;
109
+ if (this.rejecter) {
110
+ const r = this.rejecter;
111
+ this.waiter = null;
112
+ this.rejecter = null;
113
+ r(err);
114
+ }
115
+ }
116
+
117
+ async send(bytes: Uint8Array): Promise<void> {
118
+ // RN's WebSocket.send accepts ArrayBuffer / Blob / string. Hermes treats
119
+ // Uint8Array.buffer as ArrayBuffer, but we slice to be safe in case the
120
+ // view doesn't span the whole buffer.
121
+ const ab =
122
+ bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
123
+ ? bytes.buffer
124
+ : bytes.slice().buffer;
125
+ this.ws.send(ab as ArrayBuffer);
126
+ }
127
+
128
+ recv(): AsyncIterableIterator<Uint8Array> {
129
+ const self = this;
130
+ return {
131
+ [Symbol.asyncIterator]() {
132
+ return this;
133
+ },
134
+ next(): Promise<IteratorResult<Uint8Array>> {
135
+ if (self.queue.length > 0) {
136
+ return Promise.resolve({ value: self.queue.shift()!, done: false });
137
+ }
138
+ if (self.closed) {
139
+ if (self.closeErr) return Promise.reject(self.closeErr);
140
+ return Promise.resolve({ value: undefined, done: true });
141
+ }
142
+ return new Promise<IteratorResult<Uint8Array>>((resolve, reject) => {
143
+ self.waiter = resolve;
144
+ self.rejecter = reject;
145
+ });
146
+ },
147
+ return(): Promise<IteratorResult<Uint8Array>> {
148
+ return Promise.resolve({ value: undefined, done: true });
149
+ },
150
+ };
151
+ }
152
+
153
+ async close(code?: number, reason?: string): Promise<void> {
154
+ if (this.ws.readyState === 3 /* CLOSED */) return;
155
+ return new Promise<void>((resolve) => {
156
+ const onClose = () => resolve();
157
+ this.ws.addEventListener('close', onClose, { once: true });
158
+ try {
159
+ this.ws.close(code ?? 1000, reason ?? '');
160
+ } catch {
161
+ this.ws.removeEventListener('close', onClose);
162
+ resolve();
163
+ }
164
+ });
165
+ }
166
+ }
package/src/index.ts ADDED
@@ -0,0 +1,120 @@
1
+ // Public API of @fireflydb/expo.
2
+ //
3
+ // `createFireflyClient(config)` returns a FireflyClient pre-wired with the
4
+ // Expo drivers (expo-sqlite, expo-secure-store, RN WebSocket). Apps still need
5
+ // to provide a TokenProvider (their OIDC flow) since auth is app-specific.
6
+
7
+ // Side-effect import: installs globalThis.crypto.getRandomValues using
8
+ // expo-crypto. Required because Hermes doesn't ship Web Crypto and
9
+ // @noble/ed25519 needs getRandomValues for device key generation. See
10
+ // polyfill.ts for details.
11
+ import './polyfill';
12
+
13
+ import {
14
+ FireflyClient,
15
+ type FireflyClientConfig,
16
+ type TokenProvider,
17
+ } from '@fireflydb/core';
18
+
19
+ import {
20
+ ExpoSqliteDriver,
21
+ type ExpoSqliteDriverOptions,
22
+ } from './drivers/sqlite';
23
+ import {
24
+ ExpoSecureStorageDriver,
25
+ type ExpoSecureStorageOptions,
26
+ } from './drivers/secureStorage';
27
+ import { ExpoWebSocketDriver } from './drivers/websocket';
28
+
29
+ export interface CreateFireflyClientConfig
30
+ extends Omit<FireflyClientConfig, 'drivers'> {
31
+ /** Provides JWTs to the relay; integrators bring their own OIDC flow. */
32
+ token: TokenProvider;
33
+ /**
34
+ * SQLite driver options. `db` (required) is the app-opened expo-sqlite
35
+ * connection the client runs on — the app's own queries must use the same
36
+ * handle so they hit libfirefly's CRDT triggers and sync. `extensionPath`
37
+ * optionally overrides the libfirefly binary (rare).
38
+ */
39
+ sqliteOptions: ExpoSqliteDriverOptions;
40
+ /** Forwarded to expo-secure-store. */
41
+ secureStorageOptions?: ExpoSecureStorageOptions;
42
+ }
43
+
44
+ /**
45
+ * Build a FireflyClient pre-wired with Expo drivers. Call `init()` on the
46
+ * returned client before `sync()`.
47
+ */
48
+ export function createFireflyClient(
49
+ cfg: CreateFireflyClientConfig,
50
+ ): FireflyClient {
51
+ const {
52
+ token,
53
+ sqliteOptions,
54
+ secureStorageOptions,
55
+ ...rest
56
+ } = cfg;
57
+ return new FireflyClient({
58
+ ...rest,
59
+ drivers: {
60
+ sqlite: new ExpoSqliteDriver(sqliteOptions),
61
+ websocket: new ExpoWebSocketDriver(),
62
+ secureStorage: new ExpoSecureStorageDriver(secureStorageOptions ?? {}),
63
+ token,
64
+ },
65
+ });
66
+ }
67
+
68
+ export {
69
+ subscribeToChanges,
70
+ type FireflyChangePayload,
71
+ } from './FireflyClientModule';
72
+
73
+ export {
74
+ ExpoSqliteDriver,
75
+ type ExpoSqliteDriverOptions,
76
+ } from './drivers/sqlite';
77
+ export {
78
+ ExpoSecureStorageDriver,
79
+ type ExpoSecureStorageOptions,
80
+ } from './drivers/secureStorage';
81
+ export { ExpoWebSocketDriver } from './drivers/websocket';
82
+
83
+ // Re-export everything app code typically needs from @fireflydb/core so
84
+ // consumers don't need a separate dependency on it.
85
+ export {
86
+ FireflyClient,
87
+ type FireflyClientConfig,
88
+ type TokenProvider,
89
+ type SecureStorageDriver,
90
+ type SqliteDriver,
91
+ type SqliteDb,
92
+ type ApplyResult,
93
+ type PeerStatus,
94
+ type WebSocketDriver,
95
+ type WsConn,
96
+ WsCloseError,
97
+ WS_CLOSE_TRY_AGAIN_LATER,
98
+ isTryAgainLater,
99
+ type SyncResult,
100
+ type TrustedPeer,
101
+ type ChangeEvent,
102
+ type ChangeHandler,
103
+ type ChangeSubscription,
104
+ loadOrCreateDeviceKey,
105
+ deriveDeviceKey,
106
+ generateDeviceKey,
107
+ type DeviceKeyPair,
108
+ } from '@fireflydb/core';
109
+
110
+ // Multi-pod routing surface, for apps that drive the cross-pod 307 follow over HTTP themselves.
111
+ export {
112
+ NotOwnerRedirect,
113
+ OwnerRouter,
114
+ RedirectLoopError,
115
+ FireflySqlClient,
116
+ RestoringError,
117
+ decodeNotOwner,
118
+ type NotOwner,
119
+ type SqlClientConfig,
120
+ } from '@fireflydb/core';