@livestore/adapter-expo 0.4.0-dev.2 → 0.4.0-dev.21

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
@@ -5,19 +5,35 @@ import {
5
5
  type BootStatus,
6
6
  ClientSessionLeaderThreadProxy,
7
7
  Devtools,
8
+ IntentionalShutdownCause,
8
9
  type LockStatus,
9
10
  liveStoreStorageFormatVersion,
10
11
  makeClientSession,
11
12
  type SyncOptions,
12
- UnexpectedError,
13
+ UnknownError,
13
14
  } from '@livestore/common'
14
15
  import type { DevtoolsOptions, LeaderSqliteDb } from '@livestore/common/leader-thread'
15
- import { Eventlog, LeaderThreadCtx, makeLeaderThreadLayer } from '@livestore/common/leader-thread'
16
+ import {
17
+ Eventlog,
18
+ LeaderThreadCtx,
19
+ makeLeaderThreadLayer,
20
+ streamEventsWithSyncState,
21
+ } from '@livestore/common/leader-thread'
16
22
  import type { LiveStoreSchema } from '@livestore/common/schema'
17
23
  import { LiveStoreEvent } from '@livestore/common/schema'
18
24
  import { shouldNeverHappen } from '@livestore/utils'
19
25
  import type { Schema, Scope } from '@livestore/utils/effect'
20
- import { Effect, Exit, FetchHttpClient, Fiber, Layer, Queue, Stream, SubscriptionRef } from '@livestore/utils/effect'
26
+ import {
27
+ Effect,
28
+ Exit,
29
+ FetchHttpClient,
30
+ Fiber,
31
+ Layer,
32
+ Queue,
33
+ Schedule,
34
+ Stream,
35
+ SubscriptionRef,
36
+ } from '@livestore/utils/effect'
21
37
  import * as Webmesh from '@livestore/webmesh'
22
38
  import * as ExpoApplication from 'expo-application'
23
39
  import * as SQLite from 'expo-sqlite'
@@ -43,30 +59,79 @@ export type MakeDbOptions = {
43
59
  clientId?: string
44
60
  /** @default 'static' */
45
61
  sessionId?: string
62
+ /**
63
+ * Warning: This will reset both the app and eventlog database. This should only be used during development.
64
+ *
65
+ * @default false
66
+ */
67
+ resetPersistence?: boolean
46
68
  }
47
69
 
48
- declare global {
49
- var RN$Bridgeless: boolean | undefined
50
- }
51
-
52
- const IS_NEW_ARCH = globalThis.RN$Bridgeless === true
70
+ // Expo Go with the New Architecture enables Fabric and TurboModules, but may not run in "bridgeless" mode.
71
+ // Rely on Fabric/TurboModules feature detection instead of RN$Bridgeless.
72
+ const IS_NEW_ARCH =
73
+ // Fabric global – set when the new renderer is enabled
74
+ Boolean((globalThis as any).nativeFabricUIManager) ||
75
+ // TurboModule proxy – indicates new arch TurboModules
76
+ Boolean((globalThis as any).__turboModuleProxy)
53
77
 
78
+ /**
79
+ * Creates a persisted LiveStore adapter for Expo/React Native applications.
80
+ *
81
+ * This adapter stores data in SQLite databases on the device filesystem, providing
82
+ * persistence across app restarts. It supports optional sync backends for multi-device
83
+ * synchronization.
84
+ *
85
+ * **Requirements:**
86
+ * - React Native New Architecture (Fabric) must be enabled
87
+ * - Expo SDK 51+ recommended
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { makePersistedAdapter } from '@livestore/adapter-expo'
92
+ * import { makeWsSync } from '@livestore/sync-cf/client'
93
+ *
94
+ * const adapter = makePersistedAdapter({
95
+ * sync: {
96
+ * backend: makeWsSync({ url: 'wss://api.example.com/sync' }),
97
+ * },
98
+ * storage: {
99
+ * subDirectory: 'my-app',
100
+ * },
101
+ * })
102
+ * ```
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * // Minimal setup without sync
107
+ * const adapter = makePersistedAdapter()
108
+ * ```
109
+ *
110
+ * @see https://livestore.dev/docs/reference/adapters/expo for detailed setup guide
111
+ */
54
112
  // TODO refactor with leader-thread code from `@livestore/common/leader-thread`
55
113
  export const makePersistedAdapter =
56
114
  (options: MakeDbOptions = {}): Adapter =>
57
115
  (adapterArgs) =>
58
116
  Effect.gen(function* () {
59
117
  if (IS_NEW_ARCH === false) {
60
- return yield* UnexpectedError.make({
118
+ return yield* UnknownError.make({
61
119
  cause: new Error(
62
120
  'The LiveStore Expo adapter requires the new React Native architecture (aka Fabric). See https://docs.expo.dev/guides/new-architecture',
63
121
  ),
64
122
  })
65
123
  }
66
124
 
67
- const { schema, shutdown, devtoolsEnabled, storeId, bootStatusQueue, syncPayload } = adapterArgs
125
+ const { schema, shutdown, devtoolsEnabled, storeId, bootStatusQueue, syncPayloadEncoded, syncPayloadSchema } =
126
+ adapterArgs
68
127
 
69
- const { storage, clientId = yield* getDeviceId, sessionId = 'static', sync: syncOptions } = options
128
+ const {
129
+ storage,
130
+ clientId = yield* getDeviceId,
131
+ sessionId = 'static',
132
+ sync: syncOptions,
133
+ resetPersistence = false,
134
+ } = options
70
135
 
71
136
  yield* Queue.offer(bootStatusQueue, { stage: 'loading' })
72
137
 
@@ -74,6 +139,12 @@ export const makePersistedAdapter =
74
139
 
75
140
  const shutdownChannel = yield* makeShutdownChannel(storeId)
76
141
 
142
+ if (resetPersistence === true) {
143
+ yield* shutdownChannel.send(IntentionalShutdownCause.make({ reason: 'adapter-reset' }))
144
+
145
+ yield* resetExpoPersistence({ storeId, storage, schema })
146
+ }
147
+
77
148
  yield* shutdownChannel.listen.pipe(
78
149
  Stream.flatten(),
79
150
  Stream.tap((cause) =>
@@ -85,7 +156,7 @@ export const makePersistedAdapter =
85
156
  Effect.forkScoped,
86
157
  )
87
158
 
88
- const devtoolsUrl = getDevtoolsUrl().toString()
159
+ const devtoolsUrl = devtoolsEnabled ? getDevtoolsUrl().toString() : 'ws://127.0.0.1:4242'
89
160
 
90
161
  const { leaderThread, initialSnapshot } = yield* makeLeaderThread({
91
162
  storeId,
@@ -96,7 +167,8 @@ export const makePersistedAdapter =
96
167
  storage: storage ?? {},
97
168
  devtoolsEnabled,
98
169
  bootStatusQueue,
99
- syncPayload,
170
+ syncPayloadEncoded,
171
+ syncPayloadSchema,
100
172
  devtoolsUrl,
101
173
  })
102
174
 
@@ -128,10 +200,11 @@ export const makePersistedAdapter =
128
200
 
129
201
  return () => {}
130
202
  },
203
+ origin: undefined,
131
204
  })
132
205
 
133
206
  return clientSession
134
- }).pipe(UnexpectedError.mapToUnexpectedError, Effect.provide(FetchHttpClient.layer), Effect.tapCauseLogPretty)
207
+ }).pipe(UnknownError.mapToUnknownError, Effect.provide(FetchHttpClient.layer), Effect.tapCauseLogPretty)
135
208
 
136
209
  const makeLeaderThread = ({
137
210
  storeId,
@@ -142,7 +215,8 @@ const makeLeaderThread = ({
142
215
  storage,
143
216
  devtoolsEnabled,
144
217
  bootStatusQueue: bootStatusQueueClientSession,
145
- syncPayload,
218
+ syncPayloadEncoded,
219
+ syncPayloadSchema,
146
220
  devtoolsUrl,
147
221
  }: {
148
222
  storeId: string
@@ -155,21 +229,19 @@ const makeLeaderThread = ({
155
229
  }
156
230
  devtoolsEnabled: boolean
157
231
  bootStatusQueue: Queue.Queue<BootStatus>
158
- syncPayload: Schema.JsonValue | undefined
232
+ syncPayloadEncoded: Schema.JsonValue | undefined
233
+ syncPayloadSchema: Schema.Schema<any> | undefined
159
234
  devtoolsUrl: string
160
235
  }) =>
161
236
  Effect.gen(function* () {
162
- const subDirectory = storage.subDirectory ? `${storage.subDirectory.replace(/\/$/, '')}/` : ''
163
- const pathJoin = (...paths: string[]) => paths.join('/').replaceAll(/\/+/g, '/')
164
- const directory = pathJoin(SQLite.defaultDatabaseDirectory, subDirectory, storeId)
165
-
166
- const schemaHashSuffix =
167
- schema.state.sqlite.migrations.strategy === 'manual' ? 'fixed' : schema.state.sqlite.hash.toString()
168
- const stateDatabaseName = `${'livestore-'}${schemaHashSuffix}@${liveStoreStorageFormatVersion}.db`
169
- const dbEventlogPath = `${'livestore-'}eventlog@${liveStoreStorageFormatVersion}.db`
237
+ const { directory, stateDatabaseName, eventlogDatabaseName } = resolveExpoPersistencePaths({
238
+ storeId,
239
+ storage,
240
+ schema,
241
+ })
170
242
 
171
243
  const dbState = yield* makeSqliteDb({ _tag: 'file', databaseName: stateDatabaseName, directory })
172
- const dbEventlog = yield* makeSqliteDb({ _tag: 'file', databaseName: dbEventlogPath, directory })
244
+ const dbEventlog = yield* makeSqliteDb({ _tag: 'file', databaseName: eventlogDatabaseName, directory })
173
245
 
174
246
  const devtoolsOptions = yield* makeDevtoolsOptions({
175
247
  devtoolsEnabled,
@@ -192,7 +264,8 @@ const makeLeaderThread = ({
192
264
  shutdownChannel: yield* makeShutdownChannel(storeId),
193
265
  storeId,
194
266
  syncOptions,
195
- syncPayload,
267
+ syncPayloadEncoded,
268
+ syncPayloadSchema,
196
269
  }).pipe(Layer.provideMerge(FetchHttpClient.layer)),
197
270
  )
198
271
 
@@ -204,6 +277,7 @@ const makeLeaderThread = ({
204
277
  extraIncomingMessagesQueue,
205
278
  initialState,
206
279
  bootStatusQueue,
280
+ networkStatus,
207
281
  } = yield* LeaderThreadCtx
208
282
 
209
283
  const bootStatusFiber = yield* Queue.takeBetween(bootStatusQueue, 1, 1000).pipe(
@@ -227,16 +301,23 @@ const makeLeaderThread = ({
227
301
  push: (batch) =>
228
302
  syncProcessor
229
303
  .push(
230
- batch.map((item) => new LiveStoreEvent.EncodedWithMeta(item)),
304
+ batch.map((item) => new LiveStoreEvent.Client.EncodedWithMeta(item)),
231
305
  { waitForProcessing: true },
232
306
  )
233
307
  .pipe(Effect.provide(layer), Effect.scoped),
308
+ stream: (options) =>
309
+ streamEventsWithSyncState({
310
+ dbEventlog,
311
+ syncState: syncProcessor.syncState,
312
+ options,
313
+ }),
234
314
  },
235
315
  initialState: { leaderHead: initialLeaderHead, migrationsReport: initialState.migrationsReport },
236
316
  export: Effect.sync(() => db.export()),
237
317
  getEventlogData: Effect.sync(() => dbEventlog.export()),
238
- getSyncState: syncProcessor.syncState,
318
+ syncState: syncProcessor.syncState,
239
319
  sendDevtoolsMessage: (message) => extraIncomingMessagesQueue.offer(message),
320
+ networkStatus,
240
321
  })
241
322
 
242
323
  const initialSnapshot = db.export()
@@ -245,6 +326,57 @@ const makeLeaderThread = ({
245
326
  }).pipe(Effect.provide(layer))
246
327
  })
247
328
 
329
+ const resolveExpoPersistencePaths = ({
330
+ storeId,
331
+ storage,
332
+ schema,
333
+ }: {
334
+ storeId: string
335
+ storage: { subDirectory?: string } | undefined
336
+ schema: LiveStoreSchema
337
+ }) => {
338
+ const subDirectory = storage?.subDirectory ? `${storage.subDirectory.replace(/\/$/, '')}/` : ''
339
+ const pathJoin = (...paths: string[]) => paths.join('/').replaceAll(/\/+/g, '/')
340
+ const directory = pathJoin(SQLite.defaultDatabaseDirectory, subDirectory, storeId)
341
+
342
+ const schemaHashSuffix =
343
+ schema.state.sqlite.migrations.strategy === 'manual' ? 'fixed' : schema.state.sqlite.hash.toString()
344
+ const stateDatabaseName = `livestore-${schemaHashSuffix}@${liveStoreStorageFormatVersion}.db`
345
+ const eventlogDatabaseName = `livestore-eventlog@${liveStoreStorageFormatVersion}.db`
346
+
347
+ return { directory, stateDatabaseName, eventlogDatabaseName }
348
+ }
349
+
350
+ const resetExpoPersistence = ({
351
+ storeId,
352
+ storage,
353
+ schema,
354
+ }: {
355
+ storeId: string
356
+ storage: MakeDbOptions['storage']
357
+ schema: LiveStoreSchema
358
+ }) =>
359
+ Effect.try({
360
+ try: () => {
361
+ const { directory, stateDatabaseName, eventlogDatabaseName } = resolveExpoPersistencePaths({
362
+ storeId,
363
+ storage,
364
+ schema,
365
+ })
366
+
367
+ SQLite.deleteDatabaseSync(stateDatabaseName, directory)
368
+ SQLite.deleteDatabaseSync(eventlogDatabaseName, directory)
369
+ },
370
+ catch: (cause) =>
371
+ new UnknownError({
372
+ cause,
373
+ note: `@livestore/adapter-expo: Failed to reset persistence for store ${storeId}`,
374
+ }),
375
+ }).pipe(
376
+ Effect.retry({ schedule: Schedule.exponentialBackoff10Sec }),
377
+ Effect.withSpan('@livestore/adapter-expo:resetPersistence', { attributes: { storeId } }),
378
+ )
379
+
248
380
  const makeDevtoolsOptions = ({
249
381
  devtoolsEnabled,
250
382
  devtoolsUrl,
@@ -259,7 +391,7 @@ const makeDevtoolsOptions = ({
259
391
  dbEventlog: LeaderSqliteDb
260
392
  storeId: string
261
393
  clientId: string
262
- }): Effect.Effect<DevtoolsOptions, UnexpectedError, Scope.Scope> =>
394
+ }): Effect.Effect<DevtoolsOptions, UnknownError, Scope.Scope> =>
263
395
  Effect.sync(() => {
264
396
  if (devtoolsEnabled === false) {
265
397
  return {
@@ -9,10 +9,6 @@ import {
9
9
  import { EventSequenceNumber } from '@livestore/common/schema'
10
10
  import { ensureUint8ArrayBuffer, shouldNeverHappen } from '@livestore/utils'
11
11
  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
12
  import * as SQLite from 'expo-sqlite'
17
13
 
18
14
  type Metadata = {
@@ -80,7 +76,7 @@ const makeSqliteDb_ = <TMetadata extends Metadata>({
80
76
  _tag: 'SqliteDb',
81
77
  debug: {
82
78
  // Setting initially to root but will be set to correct value shortly after
83
- head: EventSequenceNumber.ROOT,
79
+ head: EventSequenceNumber.Client.ROOT,
84
80
  },
85
81
  prepare: (queryStr) => {
86
82
  try {