@livestore/adapter-expo 0.4.0-dev.9 → 0.5.0-dev.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
@@ -9,13 +9,19 @@ import {
9
9
  type LockStatus,
10
10
  liveStoreStorageFormatVersion,
11
11
  makeClientSession,
12
+ StateHead,
12
13
  type SyncOptions,
13
- UnexpectedError,
14
+ UnknownError,
14
15
  } from '@livestore/common'
15
16
  import type { DevtoolsOptions, LeaderSqliteDb } from '@livestore/common/leader-thread'
16
- import { Eventlog, LeaderThreadCtx, makeLeaderThreadLayer } from '@livestore/common/leader-thread'
17
+ import {
18
+ Eventlog,
19
+ LeaderThreadCtx,
20
+ makeLeaderThreadLayer,
21
+ streamEventsWithSyncState,
22
+ } from '@livestore/common/leader-thread'
17
23
  import type { LiveStoreSchema } from '@livestore/common/schema'
18
- import { LiveStoreEvent } from '@livestore/common/schema'
24
+ import { getStateDbBaseName, LiveStoreEvent } from '@livestore/common/schema'
19
25
  import { shouldNeverHappen } from '@livestore/utils'
20
26
  import type { Schema, Scope } from '@livestore/utils/effect'
21
27
  import {
@@ -42,7 +48,12 @@ export type MakeDbOptions = {
42
48
  sync?: SyncOptions
43
49
  storage?: {
44
50
  /**
45
- * Relative to expo-sqlite's default directory
51
+ * The directory where the database file is located.
52
+ * @default SQLite.defaultDatabaseDirectory
53
+ */
54
+ directory?: string
55
+ /**
56
+ * Sub-directory relative to the configured `directory` (or expo-sqlite's default directory if not specified).
46
57
  *
47
58
  * Example of a resulting path for `subDirectory: 'my-app'`:
48
59
  * `/data/Containers/Data/Application/<APP_UUID>/Documents/ExponentExperienceData/@<USERNAME>/<APPNAME>/SQLite/my-app/<STORE_ID>/livestore-eventlog@3.db`
@@ -62,26 +73,63 @@ export type MakeDbOptions = {
62
73
  resetPersistence?: boolean
63
74
  }
64
75
 
65
- declare global {
66
- var RN$Bridgeless: boolean | undefined
67
- }
68
-
69
- const IS_NEW_ARCH = globalThis.RN$Bridgeless === true
76
+ // Expo Go with the New Architecture enables Fabric and TurboModules, but may not run in "bridgeless" mode.
77
+ // Rely on Fabric/TurboModules feature detection instead of RN$Bridgeless.
78
+ const IS_NEW_ARCH =
79
+ // Fabric global – set when the new renderer is enabled
80
+ Boolean((globalThis as any).nativeFabricUIManager) ||
81
+ // TurboModule proxy – indicates new arch TurboModules
82
+ Boolean((globalThis as any).__turboModuleProxy)
70
83
 
84
+ /**
85
+ * Creates a persisted LiveStore adapter for Expo/React Native applications.
86
+ *
87
+ * This adapter stores data in SQLite databases on the device filesystem, providing
88
+ * persistence across app restarts. It supports optional sync backends for multi-device
89
+ * synchronization.
90
+ *
91
+ * **Requirements:**
92
+ * - React Native New Architecture (Fabric) must be enabled
93
+ * - Expo SDK 51+ recommended
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * import { makePersistedAdapter } from '@livestore/adapter-expo'
98
+ * import { makeWsSync } from '@livestore/sync-cf/client'
99
+ *
100
+ * const adapter = makePersistedAdapter({
101
+ * sync: {
102
+ * backend: makeWsSync({ url: 'wss://api.example.com/sync' }),
103
+ * },
104
+ * storage: {
105
+ * subDirectory: 'my-app',
106
+ * },
107
+ * })
108
+ * ```
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * // Minimal setup without sync
113
+ * const adapter = makePersistedAdapter()
114
+ * ```
115
+ *
116
+ * @see https://livestore.dev/docs/reference/adapters/expo for detailed setup guide
117
+ */
71
118
  // TODO refactor with leader-thread code from `@livestore/common/leader-thread`
72
119
  export const makePersistedAdapter =
73
120
  (options: MakeDbOptions = {}): Adapter =>
74
121
  (adapterArgs) =>
75
122
  Effect.gen(function* () {
76
123
  if (IS_NEW_ARCH === false) {
77
- return yield* UnexpectedError.make({
124
+ return yield* UnknownError.make({
78
125
  cause: new Error(
79
126
  'The LiveStore Expo adapter requires the new React Native architecture (aka Fabric). See https://docs.expo.dev/guides/new-architecture',
80
127
  ),
81
128
  })
82
129
  }
83
130
 
84
- const { schema, shutdown, devtoolsEnabled, storeId, bootStatusQueue, syncPayload } = adapterArgs
131
+ const { schema, shutdown, devtoolsEnabled, storeId, bootStatusQueue, syncPayloadEncoded, syncPayloadSchema } =
132
+ adapterArgs
85
133
 
86
134
  const {
87
135
  storage,
@@ -104,9 +152,9 @@ export const makePersistedAdapter =
104
152
  }
105
153
 
106
154
  yield* shutdownChannel.listen.pipe(
107
- Stream.flatten(),
155
+ Stream.mapEffect(Effect.fromResult),
108
156
  Stream.tap((cause) =>
109
- shutdown(cause._tag === 'LiveStore.IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
157
+ shutdown(cause._tag === 'IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
110
158
  ),
111
159
  Stream.runDrain,
112
160
  Effect.interruptible,
@@ -114,7 +162,7 @@ export const makePersistedAdapter =
114
162
  Effect.forkScoped,
115
163
  )
116
164
 
117
- const devtoolsUrl = getDevtoolsUrl().toString()
165
+ const devtoolsUrl = devtoolsEnabled === true ? getDevtoolsUrl().toString() : 'ws://127.0.0.1:4242'
118
166
 
119
167
  const { leaderThread, initialSnapshot } = yield* makeLeaderThread({
120
168
  storeId,
@@ -125,11 +173,14 @@ export const makePersistedAdapter =
125
173
  storage: storage ?? {},
126
174
  devtoolsEnabled,
127
175
  bootStatusQueue,
128
- syncPayload,
176
+ syncPayloadEncoded,
177
+ syncPayloadSchema,
129
178
  devtoolsUrl,
130
179
  })
131
180
 
132
- const sqliteDb = yield* makeSqliteDb({ _tag: 'in-memory' })
181
+ const sqliteDb = yield* Effect.acquireRelease(makeSqliteDb({ _tag: 'in-memory' }), (db) =>
182
+ Effect.try(() => db.close()).pipe(Effect.ignore),
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.Json | undefined
242
+ syncPayloadSchema: Schema.Decoder<Schema.Json> | 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.ignore),
255
+ )
256
+ const dbEventlog = yield* Effect.acquireRelease(
257
+ makeSqliteDb({ _tag: 'file', databaseName: eventlogDatabaseName, directory }),
258
+ (db) => Effect.try(() => db.close()).pipe(Effect.ignore),
259
+ )
199
260
 
200
261
  const devtoolsOptions = yield* makeDevtoolsOptions({
201
262
  devtoolsEnabled,
@@ -206,7 +267,7 @@ const makeLeaderThread = ({
206
267
  clientId,
207
268
  })
208
269
 
209
- const layer = yield* Layer.memoize(
270
+ const layer = yield* Layer.build(
210
271
  makeLeaderThreadLayer({
211
272
  clientId,
212
273
  dbState,
@@ -218,8 +279,9 @@ const makeLeaderThread = ({
218
279
  shutdownChannel: yield* makeShutdownChannel(storeId),
219
280
  storeId,
220
281
  syncOptions,
221
- syncPayload,
222
- }).pipe(Layer.provideMerge(FetchHttpClient.layer)),
282
+ syncPayloadEncoded,
283
+ syncPayloadSchema,
284
+ }).pipe(Layer.provide(StateHead.layer({ dbState })), Layer.provideMerge(FetchHttpClient.layer)),
223
285
  )
224
286
 
225
287
  return yield* Effect.gen(function* () {
@@ -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(
@@ -239,7 +302,7 @@ const makeLeaderThread = ({
239
302
  Effect.forkScoped,
240
303
  )
241
304
 
242
- yield* Queue.awaitShutdown(bootStatusQueueClientSession).pipe(
305
+ yield* Queue.await(bootStatusQueueClientSession).pipe(
243
306
  Effect.andThen(Fiber.interrupt(bootStatusFiber)),
244
307
  Effect.tapCauseLogPretty,
245
308
  Effect.forkScoped,
@@ -251,18 +314,24 @@ const makeLeaderThread = ({
251
314
  events: {
252
315
  pull: ({ cursor }) => syncProcessor.pull({ cursor }),
253
316
  push: (batch) =>
254
- syncProcessor
255
- .push(
256
- batch.map((item) => new LiveStoreEvent.EncodedWithMeta(item)),
257
- { waitForProcessing: true },
258
- )
259
- .pipe(Effect.provide(layer), Effect.scoped),
317
+ syncProcessor.push(batch.map((item) => new LiveStoreEvent.Client.EncodedWithMeta(item))),
318
+ stream: (options) =>
319
+ streamEventsWithSyncState({
320
+ dbEventlog,
321
+ syncState: syncProcessor.syncState,
322
+ options,
323
+ }),
324
+ },
325
+ initialState: {
326
+ leaderHead: initialLeaderHead,
327
+ migrationsReport: initialState.migrationsReport,
328
+ storageMode: 'persisted',
260
329
  },
261
- initialState: { leaderHead: initialLeaderHead, migrationsReport: initialState.migrationsReport },
262
330
  export: Effect.sync(() => db.export()),
263
331
  getEventlogData: Effect.sync(() => dbEventlog.export()),
264
- getSyncState: syncProcessor.syncState,
265
- sendDevtoolsMessage: (message) => extraIncomingMessagesQueue.offer(message),
332
+ syncState: syncProcessor.syncState,
333
+ sendDevtoolsMessage: (message) => Queue.offer(extraIncomingMessagesQueue, message),
334
+ networkStatus,
266
335
  })
267
336
 
268
337
  const initialSnapshot = db.export()
@@ -277,16 +346,16 @@ const resolveExpoPersistencePaths = ({
277
346
  schema,
278
347
  }: {
279
348
  storeId: string
280
- storage: { subDirectory?: string } | undefined
281
349
  schema: LiveStoreSchema
350
+ storage: { directory?: string; subDirectory?: string } | undefined
282
351
  }) => {
283
- const subDirectory = storage?.subDirectory ? `${storage.subDirectory.replace(/\/$/, '')}/` : ''
352
+ const subDirectory = storage?.subDirectory !== undefined ? `${storage.subDirectory.replace(/\/$/, '')}/` : ''
284
353
  const pathJoin = (...paths: string[]) => paths.join('/').replaceAll(/\/+/g, '/')
285
- const directory = pathJoin(SQLite.defaultDatabaseDirectory, subDirectory, storeId)
354
+ const directoryBasePath = storage?.directory ?? SQLite.defaultDatabaseDirectory
355
+
356
+ const directory = pathJoin(directoryBasePath, subDirectory, storeId)
286
357
 
287
- const schemaHashSuffix =
288
- schema.state.sqlite.migrations.strategy === 'manual' ? 'fixed' : schema.state.sqlite.hash.toString()
289
- const stateDatabaseName = `livestore-${schemaHashSuffix}@${liveStoreStorageFormatVersion}.db`
358
+ const stateDatabaseName = `${getStateDbBaseName(schema)}@${liveStoreStorageFormatVersion}.db`
290
359
  const eventlogDatabaseName = `livestore-eventlog@${liveStoreStorageFormatVersion}.db`
291
360
 
292
361
  return { directory, stateDatabaseName, eventlogDatabaseName }
@@ -313,7 +382,7 @@ const resetExpoPersistence = ({
313
382
  SQLite.deleteDatabaseSync(eventlogDatabaseName, directory)
314
383
  },
315
384
  catch: (cause) =>
316
- new UnexpectedError({
385
+ new UnknownError({
317
386
  cause,
318
387
  note: `@livestore/adapter-expo: Failed to reset persistence for store ${storeId}`,
319
388
  }),
@@ -336,7 +405,7 @@ const makeDevtoolsOptions = ({
336
405
  dbEventlog: LeaderSqliteDb
337
406
  storeId: string
338
407
  clientId: string
339
- }): Effect.Effect<DevtoolsOptions, UnexpectedError, Scope.Scope> =>
408
+ }): Effect.Effect<DevtoolsOptions, UnknownError, Scope.Scope> =>
340
409
  Effect.sync(() => {
341
410
  if (devtoolsEnabled === false) {
342
411
  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 {
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
  }