@livestore/adapter-expo 0.4.0-dev.8 → 0.4.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/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import './polyfill.ts'
2
+ import * as ExpoApplication from 'expo-application'
3
+ import * as SQLite from 'expo-sqlite'
4
+ import * as RN from 'react-native'
2
5
 
3
6
  import {
4
7
  type Adapter,
@@ -10,10 +13,15 @@ import {
10
13
  liveStoreStorageFormatVersion,
11
14
  makeClientSession,
12
15
  type SyncOptions,
13
- UnexpectedError,
16
+ UnknownError,
14
17
  } from '@livestore/common'
15
18
  import type { DevtoolsOptions, LeaderSqliteDb } from '@livestore/common/leader-thread'
16
- import { Eventlog, LeaderThreadCtx, makeLeaderThreadLayer } from '@livestore/common/leader-thread'
19
+ import {
20
+ Eventlog,
21
+ LeaderThreadCtx,
22
+ makeLeaderThreadLayer,
23
+ streamEventsWithSyncState,
24
+ } from '@livestore/common/leader-thread'
17
25
  import type { LiveStoreSchema } from '@livestore/common/schema'
18
26
  import { LiveStoreEvent } from '@livestore/common/schema'
19
27
  import { shouldNeverHappen } from '@livestore/utils'
@@ -30,9 +38,6 @@ import {
30
38
  SubscriptionRef,
31
39
  } from '@livestore/utils/effect'
32
40
  import * as Webmesh from '@livestore/webmesh'
33
- import * as ExpoApplication from 'expo-application'
34
- import * as SQLite from 'expo-sqlite'
35
- import * as RN from 'react-native'
36
41
 
37
42
  import type { MakeExpoSqliteDb } from './make-sqlite-db.ts'
38
43
  import { makeSqliteDb } from './make-sqlite-db.ts'
@@ -42,7 +47,12 @@ export type MakeDbOptions = {
42
47
  sync?: SyncOptions
43
48
  storage?: {
44
49
  /**
45
- * Relative to expo-sqlite's default directory
50
+ * The directory where the database file is located.
51
+ * @default SQLite.defaultDatabaseDirectory
52
+ */
53
+ directory?: string
54
+ /**
55
+ * Sub-directory relative to the configured `directory` (or expo-sqlite's default directory if not specified).
46
56
  *
47
57
  * Example of a resulting path for `subDirectory: 'my-app'`:
48
58
  * `/data/Containers/Data/Application/<APP_UUID>/Documents/ExponentExperienceData/@<USERNAME>/<APPNAME>/SQLite/my-app/<STORE_ID>/livestore-eventlog@3.db`
@@ -62,26 +72,63 @@ export type MakeDbOptions = {
62
72
  resetPersistence?: boolean
63
73
  }
64
74
 
65
- declare global {
66
- var RN$Bridgeless: boolean | undefined
67
- }
68
-
69
- const IS_NEW_ARCH = globalThis.RN$Bridgeless === true
75
+ // Expo Go with the New Architecture enables Fabric and TurboModules, but may not run in "bridgeless" mode.
76
+ // Rely on Fabric/TurboModules feature detection instead of RN$Bridgeless.
77
+ const IS_NEW_ARCH =
78
+ // Fabric global – set when the new renderer is enabled
79
+ Boolean((globalThis as any).nativeFabricUIManager) ||
80
+ // TurboModule proxy – indicates new arch TurboModules
81
+ Boolean((globalThis as any).__turboModuleProxy)
70
82
 
83
+ /**
84
+ * Creates a persisted LiveStore adapter for Expo/React Native applications.
85
+ *
86
+ * This adapter stores data in SQLite databases on the device filesystem, providing
87
+ * persistence across app restarts. It supports optional sync backends for multi-device
88
+ * synchronization.
89
+ *
90
+ * **Requirements:**
91
+ * - React Native New Architecture (Fabric) must be enabled
92
+ * - Expo SDK 51+ recommended
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * import { makePersistedAdapter } from '@livestore/adapter-expo'
97
+ * import { makeWsSync } from '@livestore/sync-cf/client'
98
+ *
99
+ * const adapter = makePersistedAdapter({
100
+ * sync: {
101
+ * backend: makeWsSync({ url: 'wss://api.example.com/sync' }),
102
+ * },
103
+ * storage: {
104
+ * subDirectory: 'my-app',
105
+ * },
106
+ * })
107
+ * ```
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * // Minimal setup without sync
112
+ * const adapter = makePersistedAdapter()
113
+ * ```
114
+ *
115
+ * @see https://livestore.dev/docs/reference/adapters/expo for detailed setup guide
116
+ */
71
117
  // TODO refactor with leader-thread code from `@livestore/common/leader-thread`
72
118
  export const makePersistedAdapter =
73
119
  (options: MakeDbOptions = {}): Adapter =>
74
120
  (adapterArgs) =>
75
121
  Effect.gen(function* () {
76
122
  if (IS_NEW_ARCH === false) {
77
- return yield* UnexpectedError.make({
123
+ return yield* UnknownError.make({
78
124
  cause: new Error(
79
125
  'The LiveStore Expo adapter requires the new React Native architecture (aka Fabric). See https://docs.expo.dev/guides/new-architecture',
80
126
  ),
81
127
  })
82
128
  }
83
129
 
84
- const { schema, shutdown, devtoolsEnabled, storeId, bootStatusQueue, syncPayload } = adapterArgs
130
+ const { schema, shutdown, devtoolsEnabled, storeId, bootStatusQueue, syncPayloadEncoded, syncPayloadSchema } =
131
+ adapterArgs
85
132
 
86
133
  const {
87
134
  storage,
@@ -106,7 +153,7 @@ export const makePersistedAdapter =
106
153
  yield* shutdownChannel.listen.pipe(
107
154
  Stream.flatten(),
108
155
  Stream.tap((cause) =>
109
- shutdown(cause._tag === 'LiveStore.IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
156
+ shutdown(cause._tag === 'IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
110
157
  ),
111
158
  Stream.runDrain,
112
159
  Effect.interruptible,
@@ -114,7 +161,7 @@ export const makePersistedAdapter =
114
161
  Effect.forkScoped,
115
162
  )
116
163
 
117
- const devtoolsUrl = getDevtoolsUrl().toString()
164
+ const devtoolsUrl = devtoolsEnabled === true ? getDevtoolsUrl().toString() : 'ws://127.0.0.1:4242'
118
165
 
119
166
  const { leaderThread, initialSnapshot } = yield* makeLeaderThread({
120
167
  storeId,
@@ -125,11 +172,15 @@ export const makePersistedAdapter =
125
172
  storage: storage ?? {},
126
173
  devtoolsEnabled,
127
174
  bootStatusQueue,
128
- syncPayload,
175
+ syncPayloadEncoded,
176
+ syncPayloadSchema,
129
177
  devtoolsUrl,
130
178
  })
131
179
 
132
- const sqliteDb = yield* makeSqliteDb({ _tag: 'in-memory' })
180
+ const sqliteDb = yield* Effect.acquireRelease(
181
+ makeSqliteDb({ _tag: 'in-memory' }),
182
+ (db) => Effect.try(() => db.close()).pipe(Effect.ignoreLogged)
183
+ )
133
184
  sqliteDb.import(initialSnapshot)
134
185
 
135
186
  const clientSession = yield* makeClientSession({
@@ -142,7 +193,7 @@ export const makePersistedAdapter =
142
193
  sqliteDb,
143
194
  webmeshMode: 'proxy',
144
195
  connectWebmeshNode: Effect.fnUntraced(function* ({ webmeshNode }) {
145
- if (devtoolsEnabled) {
196
+ if (devtoolsEnabled === true) {
146
197
  yield* Webmesh.connectViaWebSocket({
147
198
  node: webmeshNode,
148
199
  url: devtoolsUrl,
@@ -157,10 +208,11 @@ export const makePersistedAdapter =
157
208
 
158
209
  return () => {}
159
210
  },
211
+ origin: undefined,
160
212
  })
161
213
 
162
214
  return clientSession
163
- }).pipe(UnexpectedError.mapToUnexpectedError, Effect.provide(FetchHttpClient.layer), Effect.tapCauseLogPretty)
215
+ }).pipe(UnknownError.mapToUnknownError, Effect.provide(FetchHttpClient.layer), Effect.tapCauseLogPretty)
164
216
 
165
217
  const makeLeaderThread = ({
166
218
  storeId,
@@ -171,7 +223,8 @@ const makeLeaderThread = ({
171
223
  storage,
172
224
  devtoolsEnabled,
173
225
  bootStatusQueue: bootStatusQueueClientSession,
174
- syncPayload,
226
+ syncPayloadEncoded,
227
+ syncPayloadSchema,
175
228
  devtoolsUrl,
176
229
  }: {
177
230
  storeId: string
@@ -180,11 +233,13 @@ const makeLeaderThread = ({
180
233
  makeSqliteDb: MakeExpoSqliteDb
181
234
  syncOptions: SyncOptions | undefined
182
235
  storage: {
236
+ directory?: string
183
237
  subDirectory?: string
184
238
  }
185
239
  devtoolsEnabled: boolean
186
240
  bootStatusQueue: Queue.Queue<BootStatus>
187
- syncPayload: Schema.JsonValue | undefined
241
+ syncPayloadEncoded: Schema.JsonValue | undefined
242
+ syncPayloadSchema: Schema.Schema<any> | undefined
188
243
  devtoolsUrl: string
189
244
  }) =>
190
245
  Effect.gen(function* () {
@@ -194,8 +249,14 @@ const makeLeaderThread = ({
194
249
  schema,
195
250
  })
196
251
 
197
- const dbState = yield* makeSqliteDb({ _tag: 'file', databaseName: stateDatabaseName, directory })
198
- const dbEventlog = yield* makeSqliteDb({ _tag: 'file', databaseName: eventlogDatabaseName, directory })
252
+ const dbState = yield* Effect.acquireRelease(
253
+ makeSqliteDb({ _tag: 'file', databaseName: stateDatabaseName, directory }),
254
+ (db) => Effect.try(() => db.close()).pipe(Effect.ignoreLogged),
255
+ )
256
+ const dbEventlog = yield* Effect.acquireRelease(
257
+ makeSqliteDb({ _tag: 'file', databaseName: eventlogDatabaseName, directory }),
258
+ (db) => Effect.try(() => db.close()).pipe(Effect.ignoreLogged),
259
+ )
199
260
 
200
261
  const devtoolsOptions = yield* makeDevtoolsOptions({
201
262
  devtoolsEnabled,
@@ -218,7 +279,8 @@ const makeLeaderThread = ({
218
279
  shutdownChannel: yield* makeShutdownChannel(storeId),
219
280
  storeId,
220
281
  syncOptions,
221
- syncPayload,
282
+ syncPayloadEncoded,
283
+ syncPayloadSchema,
222
284
  }).pipe(Layer.provideMerge(FetchHttpClient.layer)),
223
285
  )
224
286
 
@@ -230,6 +292,7 @@ const makeLeaderThread = ({
230
292
  extraIncomingMessagesQueue,
231
293
  initialState,
232
294
  bootStatusQueue,
295
+ networkStatus,
233
296
  } = yield* LeaderThreadCtx
234
297
 
235
298
  const bootStatusFiber = yield* Queue.takeBetween(bootStatusQueue, 1, 1000).pipe(
@@ -253,16 +316,26 @@ const makeLeaderThread = ({
253
316
  push: (batch) =>
254
317
  syncProcessor
255
318
  .push(
256
- batch.map((item) => new LiveStoreEvent.EncodedWithMeta(item)),
319
+ batch.map((item) => new LiveStoreEvent.Client.EncodedWithMeta(item)),
257
320
  { waitForProcessing: true },
258
- )
259
- .pipe(Effect.provide(layer), Effect.scoped),
321
+ ),
322
+ stream: (options) =>
323
+ streamEventsWithSyncState({
324
+ dbEventlog,
325
+ syncState: syncProcessor.syncState,
326
+ options,
327
+ }),
328
+ },
329
+ initialState: {
330
+ leaderHead: initialLeaderHead,
331
+ migrationsReport: initialState.migrationsReport,
332
+ storageMode: 'persisted',
260
333
  },
261
- initialState: { leaderHead: initialLeaderHead, migrationsReport: initialState.migrationsReport },
262
334
  export: Effect.sync(() => db.export()),
263
335
  getEventlogData: Effect.sync(() => dbEventlog.export()),
264
- getSyncState: syncProcessor.syncState,
336
+ syncState: syncProcessor.syncState,
265
337
  sendDevtoolsMessage: (message) => extraIncomingMessagesQueue.offer(message),
338
+ networkStatus,
266
339
  })
267
340
 
268
341
  const initialSnapshot = db.export()
@@ -277,12 +350,14 @@ const resolveExpoPersistencePaths = ({
277
350
  schema,
278
351
  }: {
279
352
  storeId: string
280
- storage: { subDirectory?: string } | undefined
281
353
  schema: LiveStoreSchema
354
+ storage: { directory?: string; subDirectory?: string } | undefined
282
355
  }) => {
283
- const subDirectory = storage?.subDirectory ? `${storage.subDirectory.replace(/\/$/, '')}/` : ''
356
+ const subDirectory = storage?.subDirectory !== undefined ? `${storage.subDirectory.replace(/\/$/, '')}/` : ''
284
357
  const pathJoin = (...paths: string[]) => paths.join('/').replaceAll(/\/+/g, '/')
285
- const directory = pathJoin(SQLite.defaultDatabaseDirectory, subDirectory, storeId)
358
+ const directoryBasePath = storage?.directory ?? SQLite.defaultDatabaseDirectory
359
+
360
+ const directory = pathJoin(directoryBasePath, subDirectory, storeId)
286
361
 
287
362
  const schemaHashSuffix =
288
363
  schema.state.sqlite.migrations.strategy === 'manual' ? 'fixed' : schema.state.sqlite.hash.toString()
@@ -313,7 +388,7 @@ const resetExpoPersistence = ({
313
388
  SQLite.deleteDatabaseSync(eventlogDatabaseName, directory)
314
389
  },
315
390
  catch: (cause) =>
316
- new UnexpectedError({
391
+ new UnknownError({
317
392
  cause,
318
393
  note: `@livestore/adapter-expo: Failed to reset persistence for store ${storeId}`,
319
394
  }),
@@ -336,7 +411,7 @@ const makeDevtoolsOptions = ({
336
411
  dbEventlog: LeaderSqliteDb
337
412
  storeId: string
338
413
  clientId: string
339
- }): Effect.Effect<DevtoolsOptions, UnexpectedError, Scope.Scope> =>
414
+ }): Effect.Effect<DevtoolsOptions, UnknownError, Scope.Scope> =>
340
415
  Effect.sync(() => {
341
416
  if (devtoolsEnabled === false) {
342
417
  return {
@@ -1,3 +1,5 @@
1
+ import * as SQLite from 'expo-sqlite'
2
+
1
3
  import {
2
4
  type MakeSqliteDb,
3
5
  type PersistenceInfo,
@@ -9,11 +11,6 @@ import {
9
11
  import { EventSequenceNumber } from '@livestore/common/schema'
10
12
  import { ensureUint8ArrayBuffer, shouldNeverHappen } from '@livestore/utils'
11
13
  import { Effect } from '@livestore/utils/effect'
12
- // TODO remove `expo-file-system` dependency once expo-sqlite supports `import`
13
- // // @ts-expect-error package misses `exports`
14
- // import * as ExpoFs from 'expo-file-system/src/next'
15
- // import * as ExpoFs from 'expo-file-system'
16
- import * as SQLite from 'expo-sqlite'
17
14
 
18
15
  type Metadata = {
19
16
  _tag: 'file'
@@ -80,7 +77,7 @@ const makeSqliteDb_ = <TMetadata extends Metadata>({
80
77
  _tag: 'SqliteDb',
81
78
  debug: {
82
79
  // Setting initially to root but will be set to correct value shortly after
83
- head: EventSequenceNumber.ROOT,
80
+ head: EventSequenceNumber.Client.ROOT,
84
81
  },
85
82
  prepare: (queryStr) => {
86
83
  try {
package/src/polyfill.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  if (typeof Array.prototype.toSorted === 'undefined') {
8
8
  Array.prototype.toSorted = function (compareFn) {
9
- return this.sort(compareFn)
9
+ // oxlint-disable-next-line unicorn/no-array-sort -- this is the toSorted polyfill itself
10
+ return this.slice().sort(compareFn)
10
11
  }
11
12
  }