@livestore/adapter-web 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.
Files changed (74) hide show
  1. package/README.md +5 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/in-memory/in-memory-adapter.d.ts +49 -5
  4. package/dist/in-memory/in-memory-adapter.d.ts.map +1 -1
  5. package/dist/in-memory/in-memory-adapter.js +77 -20
  6. package/dist/in-memory/in-memory-adapter.js.map +1 -1
  7. package/dist/index.d.ts +11 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +11 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/single-tab/mod.d.ts +15 -0
  12. package/dist/single-tab/mod.d.ts.map +1 -0
  13. package/dist/single-tab/mod.js +15 -0
  14. package/dist/single-tab/mod.js.map +1 -0
  15. package/dist/single-tab/single-tab-adapter.d.ts +108 -0
  16. package/dist/single-tab/single-tab-adapter.d.ts.map +1 -0
  17. package/dist/single-tab/single-tab-adapter.js +271 -0
  18. package/dist/single-tab/single-tab-adapter.js.map +1 -0
  19. package/dist/web-worker/client-session/client-session-devtools.d.ts +2 -2
  20. package/dist/web-worker/client-session/client-session-devtools.d.ts.map +1 -1
  21. package/dist/web-worker/client-session/client-session-devtools.js +20 -9
  22. package/dist/web-worker/client-session/client-session-devtools.js.map +1 -1
  23. package/dist/web-worker/client-session/persisted-adapter.d.ts +18 -0
  24. package/dist/web-worker/client-session/persisted-adapter.d.ts.map +1 -1
  25. package/dist/web-worker/client-session/persisted-adapter.js +141 -67
  26. package/dist/web-worker/client-session/persisted-adapter.js.map +1 -1
  27. package/dist/web-worker/client-session/sqlite-loader.d.ts +2 -0
  28. package/dist/web-worker/client-session/sqlite-loader.d.ts.map +1 -0
  29. package/dist/web-worker/client-session/sqlite-loader.js +16 -0
  30. package/dist/web-worker/client-session/sqlite-loader.js.map +1 -0
  31. package/dist/web-worker/common/persisted-sqlite.d.ts +13 -20
  32. package/dist/web-worker/common/persisted-sqlite.d.ts.map +1 -1
  33. package/dist/web-worker/common/persisted-sqlite.js +95 -102
  34. package/dist/web-worker/common/persisted-sqlite.js.map +1 -1
  35. package/dist/web-worker/common/shutdown-channel.d.ts +3 -2
  36. package/dist/web-worker/common/shutdown-channel.d.ts.map +1 -1
  37. package/dist/web-worker/common/shutdown-channel.js +2 -2
  38. package/dist/web-worker/common/shutdown-channel.js.map +1 -1
  39. package/dist/web-worker/common/worker-disconnect-channel.d.ts +2 -6
  40. package/dist/web-worker/common/worker-disconnect-channel.d.ts.map +1 -1
  41. package/dist/web-worker/common/worker-disconnect-channel.js +3 -2
  42. package/dist/web-worker/common/worker-disconnect-channel.js.map +1 -1
  43. package/dist/web-worker/common/worker-schema.d.ts +152 -58
  44. package/dist/web-worker/common/worker-schema.d.ts.map +1 -1
  45. package/dist/web-worker/common/worker-schema.js +55 -37
  46. package/dist/web-worker/common/worker-schema.js.map +1 -1
  47. package/dist/web-worker/leader-worker/make-leader-worker.d.ts +5 -3
  48. package/dist/web-worker/leader-worker/make-leader-worker.d.ts.map +1 -1
  49. package/dist/web-worker/leader-worker/make-leader-worker.js +99 -38
  50. package/dist/web-worker/leader-worker/make-leader-worker.js.map +1 -1
  51. package/dist/web-worker/shared-worker/make-shared-worker.d.ts +2 -1
  52. package/dist/web-worker/shared-worker/make-shared-worker.d.ts.map +1 -1
  53. package/dist/web-worker/shared-worker/make-shared-worker.js +62 -52
  54. package/dist/web-worker/shared-worker/make-shared-worker.js.map +1 -1
  55. package/package.json +56 -18
  56. package/src/in-memory/in-memory-adapter.ts +92 -26
  57. package/src/index.ts +15 -1
  58. package/src/single-tab/mod.ts +15 -0
  59. package/src/single-tab/single-tab-adapter.ts +499 -0
  60. package/src/web-worker/ambient.d.ts +7 -24
  61. package/src/web-worker/client-session/client-session-devtools.ts +32 -18
  62. package/src/web-worker/client-session/persisted-adapter.ts +199 -103
  63. package/src/web-worker/client-session/sqlite-loader.ts +19 -0
  64. package/src/web-worker/common/persisted-sqlite.ts +215 -170
  65. package/src/web-worker/common/shutdown-channel.ts +10 -3
  66. package/src/web-worker/common/worker-disconnect-channel.ts +10 -3
  67. package/src/web-worker/common/worker-schema.ts +78 -38
  68. package/src/web-worker/leader-worker/make-leader-worker.ts +149 -71
  69. package/src/web-worker/shared-worker/make-shared-worker.ts +78 -90
  70. package/dist/opfs-utils.d.ts +0 -5
  71. package/dist/opfs-utils.d.ts.map +0 -1
  72. package/dist/opfs-utils.js +0 -43
  73. package/dist/opfs-utils.js.map +0 -1
  74. package/src/opfs-utils.ts +0 -61
@@ -1,53 +1,62 @@
1
- import type { Adapter, ClientSession, LockStatus } from '@livestore/common'
1
+ import type { Adapter, BootWarningReason, ClientSession, LockStatus } from '@livestore/common'
2
2
  import {
3
3
  IntentionalShutdownCause,
4
+ isWorkerTransportError,
4
5
  liveStoreVersion,
5
6
  makeClientSession,
6
7
  StoreInterrupted,
7
8
  sessionChangesetMetaTable,
8
- UnexpectedError,
9
+ UnknownError,
9
10
  } from '@livestore/common'
10
11
  // TODO bring back - this currently doesn't work due to https://github.com/vitejs/vite/issues/8427
11
12
  // NOTE We're using a non-relative import here for Vite to properly resolve the import during app builds
12
13
  // import LiveStoreSharedWorker from '@livestore/adapter-web/internal-shared-worker?sharedworker'
13
14
  import { EventSequenceNumber } from '@livestore/common/schema'
14
15
  import { sqliteDbFactory } from '@livestore/sqlite-wasm/browser'
15
- import { loadSqlite3Wasm } from '@livestore/sqlite-wasm/load-wasm'
16
- import { isDevEnv, shouldNeverHappen, tryAsFunctionAndNew } from '@livestore/utils'
16
+ import { isDevEnv, omitUndefineds, shouldNeverHappen, tryAsFunctionAndNew } from '@livestore/utils'
17
17
  import {
18
- BrowserWorker,
19
18
  Cause,
20
19
  Deferred,
21
20
  Effect,
22
21
  Exit,
23
22
  Fiber,
24
23
  Layer,
25
- ParseResult,
24
+ Option,
26
25
  Queue,
27
26
  Schema,
28
27
  Stream,
28
+ Subscribable,
29
29
  SubscriptionRef,
30
- WebLock,
31
30
  Worker,
32
- WorkerError,
33
31
  } from '@livestore/utils/effect'
32
+ import { BrowserWorker, Opfs, WebError, WebLock } from '@livestore/utils/effect/browser'
34
33
  import { nanoid } from '@livestore/utils/nanoid'
35
34
 
36
- import * as OpfsUtils from '../../opfs-utils.ts'
37
- import { readPersistedAppDbFromClientSession, resetPersistedDataFromClientSession } from '../common/persisted-sqlite.ts'
35
+ import { makeSingleTabAdapter } from '../../single-tab/single-tab-adapter.ts'
36
+ import {
37
+ readPersistedStateDbFromClientSession,
38
+ resetPersistedDataFromClientSession,
39
+ } from '../common/persisted-sqlite.ts'
38
40
  import { makeShutdownChannel } from '../common/shutdown-channel.ts'
39
41
  import { DedicatedWorkerDisconnectBroadcast, makeWorkerDisconnectChannel } from '../common/worker-disconnect-channel.ts'
40
42
  import * as WorkerSchema from '../common/worker-schema.ts'
41
43
  import { connectWebmeshNodeClientSession } from './client-session-devtools.ts'
44
+ import { loadSqlite3 } from './sqlite-loader.ts'
42
45
 
43
- // NOTE we're starting to initialize the sqlite wasm binary here to speed things up
44
- const sqlite3Promise = loadSqlite3Wasm()
46
+ /**
47
+ * Checks if SharedWorker API is available in the current browser context.
48
+ *
49
+ * Returns false on Android Chrome and other browsers without SharedWorker support.
50
+ *
51
+ * @see https://github.com/livestorejs/livestore/issues/321
52
+ * @see https://issues.chromium.org/issues/40290702
53
+ */
54
+ export const canUseSharedWorker = (): boolean => typeof SharedWorker !== 'undefined'
45
55
 
46
- if (isDevEnv()) {
56
+ if (isDevEnv() === true) {
47
57
  globalThis.__debugLiveStoreUtils = {
48
- opfs: OpfsUtils,
49
- runSync: (effect: Effect.Effect<any, any, never>) => Effect.runSync(effect),
50
- runFork: (effect: Effect.Effect<any, any, never>) => Effect.runFork(effect),
58
+ ...globalThis.__debugLiveStoreUtils,
59
+ opfs: Opfs.debugUtils,
51
60
  }
52
61
  }
53
62
 
@@ -123,6 +132,15 @@ export type WebAdapterOptions = {
123
132
  * Creates a web adapter with persistent storage (currently only supports OPFS).
124
133
  * Requires both a web worker and a shared worker.
125
134
  *
135
+ * On browsers without SharedWorker support (e.g. Android Chrome), this adapter
136
+ * automatically falls back to single-tab mode. In single-tab mode:
137
+ * - Each tab runs independently with its own leader worker
138
+ * - Multi-tab synchronization is not available
139
+ * - Devtools are not supported
140
+ *
141
+ * @see https://github.com/livestorejs/livestore/issues/321 - SharedWorker tracking issue
142
+ * @see https://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug
143
+ *
126
144
  * @example
127
145
  * ```ts
128
146
  * import { makePersistedAdapter } from '@livestore/adapter-web'
@@ -140,13 +158,49 @@ export const makePersistedAdapter =
140
158
  (options: WebAdapterOptions): Adapter =>
141
159
  (adapterArgs) =>
142
160
  Effect.gen(function* () {
143
- const { schema, storeId, devtoolsEnabled, debugInstanceId, bootStatusQueue, shutdown, syncPayload } = adapterArgs
161
+ // Check SharedWorker availability first and fall back to single-tab mode if unavailable
162
+ if (canUseSharedWorker() === false) {
163
+ yield* Effect.logWarning(
164
+ '[@livestore/adapter-web] SharedWorker unavailable (e.g. Android Chrome). ' +
165
+ 'Falling back to single-tab mode. Multi-tab synchronization and devtools are disabled. ' +
166
+ 'See: https://github.com/livestorejs/livestore/issues/321',
167
+ )
168
+
169
+ return yield* makeSingleTabAdapter({
170
+ worker: options.worker,
171
+ storage: options.storage,
172
+ ...omitUndefineds({
173
+ resetPersistence: options.resetPersistence,
174
+ clientId: options.clientId,
175
+ sessionId: options.sessionId,
176
+ experimental: options.experimental,
177
+ }),
178
+ })(adapterArgs)
179
+ }
180
+
181
+ const {
182
+ schema,
183
+ storeId,
184
+ devtoolsEnabled,
185
+ debugInstanceId,
186
+ bootStatusQueue,
187
+ shutdown,
188
+ syncPayloadSchema: _syncPayloadSchema,
189
+ syncPayloadEncoded,
190
+ } = adapterArgs
191
+
192
+ // NOTE: The schema travels with the worker bundle (developers call
193
+ // `makeWorker({ schema, syncPayloadSchema })`). We only keep the
194
+ // destructured value here to document availability on the client session
195
+ // side—structured cloning the Effect schema into the worker is not
196
+ // possible, so we intentionally do not forward it.
197
+ void _syncPayloadSchema
144
198
 
145
199
  yield* ensureBrowserRequirements
146
200
 
147
201
  yield* Queue.offer(bootStatusQueue, { stage: 'loading' })
148
202
 
149
- const sqlite3 = yield* Effect.promise(() => sqlite3Promise)
203
+ const sqlite3 = yield* Effect.promise(() => loadSqlite3())
150
204
 
151
205
  const LIVESTORE_TAB_LOCK = `livestore-tab-lock-${storeId}`
152
206
  const LIVESTORE_SHARED_WORKER_TERMINATION_LOCK = `livestore-shared-worker-termination-lock-${storeId}`
@@ -155,10 +209,21 @@ export const makePersistedAdapter =
155
209
 
156
210
  const shutdownChannel = yield* makeShutdownChannel(storeId)
157
211
 
158
- if (options.resetPersistence === true) {
212
+ // Check OPFS availability early and notify user if storage is unavailable (e.g. private browsing)
213
+ const opfsWarning = yield* checkOpfsAvailability
214
+ if (opfsWarning !== undefined) {
215
+ yield* Effect.logWarning('[@livestore/adapter-web:client-session] OPFS unavailable', opfsWarning)
216
+ }
217
+
218
+ if (options.resetPersistence === true && opfsWarning === undefined) {
159
219
  yield* shutdownChannel.send(IntentionalShutdownCause.make({ reason: 'adapter-reset' }))
160
220
 
161
221
  yield* resetPersistedDataFromClientSession({ storageOptions, storeId })
222
+ } else if (options.resetPersistence === true) {
223
+ yield* Effect.logWarning(
224
+ '[@livestore/adapter-web:client-session] Skipping persistence reset because storage is unavailable',
225
+ opfsWarning,
226
+ )
162
227
  }
163
228
 
164
229
  // Note on fast-path booting:
@@ -168,9 +233,17 @@ export const makePersistedAdapter =
168
233
  // We need to be extra careful though to not run into any race conditions or inconsistencies.
169
234
  // TODO also verify persisted data
170
235
  const dataFromFile =
171
- options.experimental?.disableFastPath === true
236
+ options.experimental?.disableFastPath === true || opfsWarning !== undefined
172
237
  ? undefined
173
- : yield* readPersistedAppDbFromClientSession({ storageOptions, storeId, schema })
238
+ : yield* readPersistedStateDbFromClientSession({ storageOptions, storeId, schema }).pipe(
239
+ Effect.tapError((error) =>
240
+ Effect.logDebug('[@livestore/adapter-web:client-session] Could not read persisted state db', error, {
241
+ storeId,
242
+ }),
243
+ ),
244
+ // If we get any error here, we return `undefined` to fall back to the slow path
245
+ Effect.orElseSucceed(() => undefined),
246
+ )
174
247
 
175
248
  // The same across all client sessions (i.e. tabs, windows)
176
249
  const clientId = options.clientId ?? getPersistedId(`clientId:${storeId}`, 'local')
@@ -182,7 +255,7 @@ export const makePersistedAdapter =
182
255
  yield* shutdownChannel.listen.pipe(
183
256
  Stream.flatten(),
184
257
  Stream.tap((cause) =>
185
- shutdown(cause._tag === 'LiveStore.IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
258
+ shutdown(cause._tag === 'IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
186
259
  ),
187
260
  Stream.runDrain,
188
261
  Effect.interruptible,
@@ -192,7 +265,7 @@ export const makePersistedAdapter =
192
265
 
193
266
  const sharedWebWorker = tryAsFunctionAndNew(options.sharedWorker, { name: `livestore-shared-worker-${storeId}` })
194
267
 
195
- if (options.experimental?.awaitSharedWorkerTermination) {
268
+ if (options.experimental?.awaitSharedWorkerTermination === true) {
196
269
  // Relying on the lock being available is currently the only mechanism we're aware of
197
270
  // to know whether the shared worker has terminated.
198
271
  yield* Effect.addFinalizer(() => WebLock.waitForLock(LIVESTORE_SHARED_WORKER_TERMINATION_LOCK))
@@ -202,25 +275,10 @@ export const makePersistedAdapter =
202
275
  const sharedWorkerFiber = yield* Worker.makePoolSerialized<typeof WorkerSchema.SharedWorkerRequest.Type>({
203
276
  size: 1,
204
277
  concurrency: 100,
205
- initialMessage: () =>
206
- new WorkerSchema.SharedWorkerInitialMessage({
207
- liveStoreVersion,
208
- payload: {
209
- _tag: 'FromClientSession',
210
- initialMessage: new WorkerSchema.LeaderWorkerInnerInitialMessage({
211
- storageOptions,
212
- storeId,
213
- clientId,
214
- devtoolsEnabled,
215
- debugInstanceId,
216
- syncPayload,
217
- }),
218
- },
219
- }),
220
278
  }).pipe(
221
279
  Effect.provide(sharedWorkerContext),
222
280
  Effect.tapCauseLogPretty,
223
- UnexpectedError.mapToUnexpectedError,
281
+ Effect.orDie,
224
282
  Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
225
283
  Effect.withSpan('@livestore/adapter-web:client-session:setupSharedWorker'),
226
284
  Effect.forkScoped,
@@ -232,7 +290,7 @@ export const makePersistedAdapter =
232
290
  //
233
291
  // Sorry for this pun ...
234
292
  let gotLocky = yield* WebLock.tryGetDeferredLock(lockDeferred, LIVESTORE_TAB_LOCK)
235
- const lockStatus = yield* SubscriptionRef.make<LockStatus>(gotLocky ? 'has-lock' : 'no-lock')
293
+ const lockStatus = yield* SubscriptionRef.make<LockStatus>(gotLocky === true ? 'has-lock' : 'no-lock')
236
294
 
237
295
  // Ideally we can come up with a simpler implementation that doesn't require this
238
296
  const waitForSharedWorkerInitialized = yield* Deferred.make<void>()
@@ -262,7 +320,7 @@ export const makePersistedAdapter =
262
320
  initialMessage: () => new WorkerSchema.LeaderWorkerOuterInitialMessage({ port: mc.port1, storeId, clientId }),
263
321
  }).pipe(
264
322
  Effect.provide(BrowserWorker.layer(() => worker)),
265
- UnexpectedError.mapToUnexpectedError,
323
+ UnknownError.mapToUnknownError,
266
324
  Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
267
325
  Effect.withSpan('@livestore/adapter-web:client-session:setupDedicatedWorker'),
268
326
  Effect.tapCauseLogPretty,
@@ -272,10 +330,25 @@ export const makePersistedAdapter =
272
330
  yield* workerDisconnectChannel.send(DedicatedWorkerDisconnectBroadcast.make({}))
273
331
 
274
332
  const sharedWorker = yield* Fiber.join(sharedWorkerFiber)
275
- yield* sharedWorker.executeEffect(new WorkerSchema.SharedWorkerUpdateMessagePort({ port: mc.port2 })).pipe(
276
- UnexpectedError.mapToUnexpectedError,
277
- Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
278
- )
333
+ yield* sharedWorker
334
+ .executeEffect(
335
+ new WorkerSchema.SharedWorkerUpdateMessagePort({
336
+ port: mc.port2,
337
+ liveStoreVersion,
338
+ initial: new WorkerSchema.LeaderWorkerInnerInitialMessage({
339
+ storageOptions,
340
+ storeId,
341
+ clientId,
342
+ devtoolsEnabled,
343
+ debugInstanceId,
344
+ syncPayloadEncoded,
345
+ }),
346
+ }),
347
+ )
348
+ .pipe(
349
+ UnknownError.mapToUnknownError,
350
+ Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
351
+ )
279
352
 
280
353
  yield* Deferred.succeed(waitForSharedWorkerInitialized, undefined)
281
354
 
@@ -302,15 +375,14 @@ export const makePersistedAdapter =
302
375
  yield* runLocked.pipe(Effect.interruptible, Effect.tapCauseLogPretty, Effect.forkScoped)
303
376
  }
304
377
 
305
- const runInWorker = <TReq extends typeof WorkerSchema.SharedWorkerRequest.Type>(
306
- req: TReq,
307
- ): TReq extends Schema.WithResult<infer A, infer _I, infer E, infer _EI, infer R>
308
- ? Effect.Effect<A, UnexpectedError | E, R>
309
- : never =>
378
+ const runInWorker = <A, I, E, EI, R>(
379
+ req: WorkerSchema.SharedWorkerRequest & Schema.WithResult<A, I, E, EI, R>,
380
+ ): Effect.Effect<A, E, R> =>
310
381
  Fiber.join(sharedWorkerFiber).pipe(
311
382
  // NOTE we need to wait for the shared worker to be initialized before we can send requests to it
312
383
  Effect.tap(() => waitForSharedWorkerInitialized),
313
- Effect.flatMap((worker) => worker.executeEffect(req) as any),
384
+ Effect.flatMap((worker) => worker.executeEffect(req)),
385
+ Effect.catchIf(isWorkerTransportError, (e) => Effect.die(e)),
314
386
  // NOTE we want to treat worker requests as atomic and therefore not allow them to be interrupted
315
387
  // Interruption usually only happens during leader re-election or store shutdown
316
388
  // Effect.uninterruptible,
@@ -319,40 +391,24 @@ export const makePersistedAdapter =
319
391
  duration: 2000,
320
392
  }),
321
393
  Effect.withSpan(`@livestore/adapter-web:client-session:runInWorker:${req._tag}`),
322
- Effect.mapError((cause) =>
323
- Schema.is(UnexpectedError)(cause)
324
- ? cause
325
- : ParseResult.isParseError(cause) || Schema.is(WorkerError.WorkerError)(cause)
326
- ? new UnexpectedError({ cause })
327
- : cause,
328
- ),
329
- Effect.catchAllDefect((cause) => new UnexpectedError({ cause })),
330
- ) as any
331
-
332
- const runInWorkerStream = <TReq extends typeof WorkerSchema.SharedWorkerRequest.Type>(
333
- req: TReq,
334
- ): TReq extends Schema.WithResult<infer A, infer _I, infer _E, infer _EI, infer R>
335
- ? Stream.Stream<A, UnexpectedError, R>
336
- : never =>
394
+ )
395
+
396
+ const runInWorkerStream = <A, I, E, EI, R>(
397
+ req: WorkerSchema.SharedWorkerRequest & Schema.WithResult<A, I, E, EI, R>,
398
+ ): Stream.Stream<A, E, R> =>
337
399
  Effect.gen(function* () {
338
400
  const sharedWorker = yield* Fiber.join(sharedWorkerFiber)
339
- return sharedWorker.execute(req as any).pipe(
340
- Stream.mapError((cause) =>
341
- Schema.is(UnexpectedError)(cause)
342
- ? cause
343
- : ParseResult.isParseError(cause) || Schema.is(WorkerError.WorkerError)(cause)
344
- ? new UnexpectedError({ cause })
345
- : cause,
346
- ),
401
+ return sharedWorker.execute(req).pipe(
402
+ Stream.refineOrDie((e) => isWorkerTransportError(e) === true ? Option.none() : Option.some(e)),
347
403
  Stream.withSpan(`@livestore/adapter-web:client-session:runInWorkerStream:${req._tag}`),
348
404
  )
349
- }).pipe(Stream.unwrap) as any
405
+ }).pipe(Stream.unwrap)
350
406
 
351
407
  const bootStatusFiber = yield* runInWorkerStream(new WorkerSchema.LeaderWorkerInnerBootStatusStream()).pipe(
352
408
  Stream.tap((_) => Queue.offer(bootStatusQueue, _)),
353
409
  Stream.runDrain,
354
410
  Effect.tapErrorCause((cause) =>
355
- Cause.isInterruptedOnly(cause) ? Effect.void : shutdown(Exit.failCause(cause)),
411
+ Cause.isInterruptedOnly(cause) === true ? Effect.void : shutdown(Exit.failCause(cause)),
356
412
  ),
357
413
  Effect.interruptible,
358
414
  Effect.tapCauseLogPretty,
@@ -389,7 +445,7 @@ export const makePersistedAdapter =
389
445
  const numberOfTables =
390
446
  sqliteDb.select<{ count: number }>(`select count(*) as count from sqlite_master`)[0]?.count ?? 0
391
447
  if (numberOfTables === 0) {
392
- return yield* UnexpectedError.make({
448
+ return yield* UnknownError.make({
393
449
  cause: `Encountered empty or corrupted database`,
394
450
  payload: { snapshotByteLength: initialResult.snapshot.byteLength, storageOptions: options.storage },
395
451
  })
@@ -407,20 +463,21 @@ export const makePersistedAdapter =
407
463
  .first(),
408
464
  )
409
465
 
410
- const initialLeaderHead = initialLeaderHeadRes
411
- ? EventSequenceNumber.make({
412
- global: initialLeaderHeadRes.seqNumGlobal,
413
- client: initialLeaderHeadRes.seqNumClient,
414
- rebaseGeneration: initialLeaderHeadRes.seqNumRebaseGeneration,
415
- })
416
- : EventSequenceNumber.ROOT
466
+ const initialLeaderHead =
467
+ initialLeaderHeadRes !== undefined
468
+ ? EventSequenceNumber.Client.Composite.make({
469
+ global: initialLeaderHeadRes.seqNumGlobal,
470
+ client: initialLeaderHeadRes.seqNumClient,
471
+ rebaseGeneration: initialLeaderHeadRes.seqNumRebaseGeneration,
472
+ })
473
+ : EventSequenceNumber.Client.ROOT
417
474
 
418
475
  // console.debug('[@livestore/adapter-web:client-session] initialLeaderHead', initialLeaderHead)
419
476
 
420
477
  yield* Effect.addFinalizer((ex) =>
421
478
  Effect.gen(function* () {
422
479
  if (
423
- Exit.isFailure(ex) &&
480
+ Exit.isFailure(ex) === true &&
424
481
  Exit.isInterrupted(ex) === false &&
425
482
  Schema.is(IntentionalShutdownCause)(Cause.squash(ex.cause)) === false &&
426
483
  Schema.is(StoreInterrupted)(Cause.squash(ex.cause)) === false
@@ -430,7 +487,7 @@ export const makePersistedAdapter =
430
487
  yield* Effect.logDebug('[@livestore/adapter-web:client-session] client-session shutdown', gotLocky, ex)
431
488
  }
432
489
 
433
- if (gotLocky) {
490
+ if (gotLocky === true) {
434
491
  yield* Deferred.succeed(lockDeferred, undefined)
435
492
  }
436
493
  }).pipe(Effect.tapCauseLogPretty, Effect.orDie),
@@ -438,8 +495,7 @@ export const makePersistedAdapter =
438
495
 
439
496
  const leaderThread: ClientSession['leaderThread'] = {
440
497
  export: runInWorker(new WorkerSchema.LeaderWorkerInnerExport()).pipe(
441
- Effect.timeout(10_000),
442
- UnexpectedError.mapToUnexpectedError,
498
+ Effect.timeoutOrDie(10_000),
443
499
  Effect.withSpan('@livestore/adapter-web:client-session:export'),
444
500
  ),
445
501
 
@@ -452,26 +508,39 @@ export const makePersistedAdapter =
452
508
  attributes: { batchSize: batch.length },
453
509
  }),
454
510
  ),
511
+ stream: (options) =>
512
+ runInWorkerStream(new WorkerSchema.LeaderWorkerInnerStreamEvents(options)).pipe(
513
+ Stream.withSpan('@livestore/adapter-web:client-session:streamEvents'),
514
+ Stream.orDie,
515
+ ),
455
516
  },
456
517
 
457
- initialState: { leaderHead: initialLeaderHead, migrationsReport },
518
+ initialState: {
519
+ leaderHead: initialLeaderHead,
520
+ migrationsReport,
521
+ storageMode: opfsWarning === undefined ? 'persisted' : 'in-memory',
522
+ },
458
523
 
459
524
  getEventlogData: runInWorker(new WorkerSchema.LeaderWorkerInnerExportEventlog()).pipe(
460
- Effect.timeout(10_000),
461
- UnexpectedError.mapToUnexpectedError,
525
+ Effect.timeoutOrDie(10_000),
462
526
  Effect.withSpan('@livestore/adapter-web:client-session:getEventlogData'),
463
527
  ),
464
528
 
465
- getSyncState: runInWorker(new WorkerSchema.LeaderWorkerInnerGetLeaderSyncState()).pipe(
466
- UnexpectedError.mapToUnexpectedError,
467
- Effect.withSpan('@livestore/adapter-web:client-session:getLeaderSyncState'),
468
- ),
529
+ syncState: Subscribable.make({
530
+ get: runInWorker(new WorkerSchema.LeaderWorkerInnerGetLeaderSyncState()).pipe(
531
+ Effect.withSpan('@livestore/adapter-web:client-session:getLeaderSyncState'),
532
+ ),
533
+ changes: runInWorkerStream(new WorkerSchema.LeaderWorkerInnerSyncStateStream()).pipe(Stream.orDie),
534
+ }),
469
535
 
470
536
  sendDevtoolsMessage: (message) =>
471
537
  runInWorker(new WorkerSchema.LeaderWorkerInnerExtraDevtoolsMessage({ message })).pipe(
472
- UnexpectedError.mapToUnexpectedError,
473
538
  Effect.withSpan('@livestore/adapter-web:client-session:devtoolsMessageForLeader'),
474
539
  ),
540
+ networkStatus: Subscribable.make({
541
+ get: runInWorker(new WorkerSchema.LeaderWorkerInnerGetNetworkStatus()),
542
+ changes: runInWorkerStream(new WorkerSchema.LeaderWorkerInnerNetworkStatusStream()),
543
+ }),
475
544
  }
476
545
 
477
546
  const sharedWorker = yield* Fiber.join(sharedWorkerFiber)
@@ -482,10 +551,11 @@ export const makePersistedAdapter =
482
551
  lockStatus,
483
552
  clientId,
484
553
  sessionId,
485
- // isLeader: gotLocky, // TODO update when leader is changing
486
- isLeader: true,
554
+ isLeader: gotLocky,
487
555
  leaderThread,
488
556
  webmeshMode: 'direct',
557
+ // Can be undefined in Node.js
558
+ origin: globalThis.location?.origin,
489
559
  connectWebmeshNode: ({ sessionInfo, webmeshNode }) =>
490
560
  connectWebmeshNodeClientSession({ webmeshNode, sessionInfo, sharedWorker, devtoolsEnabled, schema }),
491
561
  registerBeforeUnload: (onBeforeUnload) => {
@@ -499,7 +569,7 @@ export const makePersistedAdapter =
499
569
  })
500
570
 
501
571
  return clientSession
502
- }).pipe(UnexpectedError.mapToUnexpectedError)
572
+ }).pipe(Effect.provide(Opfs.Opfs.Default), UnknownError.mapToUnknownError)
503
573
 
504
574
  // NOTE for `local` storage we could also use the eventlog db to store the data
505
575
  const getPersistedId = (key: string, storageType: 'session' | 'local') => {
@@ -512,7 +582,7 @@ const getPersistedId = (key: string, storageType: 'session' | 'local') => {
512
582
  ? sessionStorage
513
583
  : storageType === 'local'
514
584
  ? localStorage
515
- : shouldNeverHappen(`[@livestore/adapter-web] Invalid storage type: ${storageType}`)
585
+ : shouldNeverHappen(`[@livestore/adapter-web] Invalid storage type: ${String(storageType)}`)
516
586
 
517
587
  // in case of a worker, we need the id of the parent window, to keep the id consistent
518
588
  // we also need to handle the case where there are multiple workers being spawned by the same window
@@ -523,7 +593,7 @@ const getPersistedId = (key: string, storageType: 'session' | 'local') => {
523
593
  const fullKey = `livestore:${key}`
524
594
  const storedKey = storage.getItem(fullKey)
525
595
 
526
- if (storedKey) return storedKey
596
+ if (storedKey !== null) return storedKey
527
597
 
528
598
  const newKey = makeId()
529
599
  storage.setItem(fullKey, newKey)
@@ -534,8 +604,8 @@ const getPersistedId = (key: string, storageType: 'session' | 'local') => {
534
604
  const ensureBrowserRequirements = Effect.gen(function* () {
535
605
  const validate = (condition: boolean, label: string) =>
536
606
  Effect.gen(function* () {
537
- if (condition) {
538
- return yield* UnexpectedError.make({
607
+ if (condition === true) {
608
+ return yield* UnknownError.make({
539
609
  cause: `[@livestore/adapter-web] Browser not supported. The LiveStore web adapter needs '${label}' to work properly`,
540
610
  })
541
611
  }
@@ -550,3 +620,29 @@ const ensureBrowserRequirements = Effect.gen(function* () {
550
620
  validate(typeof sessionStorage === 'undefined', 'sessionStorage'),
551
621
  ])
552
622
  })
623
+
624
+ /**
625
+ * Attempts to access OPFS and returns a warning if unavailable.
626
+ *
627
+ * Common failure scenarios:
628
+ * - Safari/Firefox private browsing: SecurityError or NotAllowedError
629
+ * - Permission denied: NotAllowedError
630
+ * - Quota exceeded: QuotaExceededError
631
+ */
632
+ const checkOpfsAvailability = Effect.gen(function* () {
633
+ const opfs = yield* Opfs.Opfs
634
+ return yield* opfs.getRootDirectoryHandle.pipe(
635
+ Effect.as(undefined),
636
+ Effect.catchAll((error) => {
637
+ const reason: BootWarningReason =
638
+ Schema.is(WebError.SecurityError)(error) === true || Schema.is(WebError.NotAllowedError)(error) === true
639
+ ? 'private-browsing'
640
+ : 'storage-unavailable'
641
+ const message =
642
+ reason === 'private-browsing'
643
+ ? 'Storage unavailable in private browsing mode. LiveStore will continue without persistence.'
644
+ : 'Storage access denied. LiveStore will continue without persistence.'
645
+ return Effect.succeed({ reason, message } as const)
646
+ }),
647
+ )
648
+ })
@@ -0,0 +1,19 @@
1
+ import { loadSqlite3Wasm } from '@livestore/sqlite-wasm/load-wasm'
2
+
3
+ /**
4
+ * Browser sessions benefit from downloading and compiling the wasm binary as soon as
5
+ * possible to hide network and IO latency behind the rest of the boot process. We kick
6
+ * that work off eagerly on the client while still returning the shared promise.
7
+ *
8
+ * The Cloudflare / Workerd runtime has stricter rules: async fetches during module
9
+ * evaluation are blocked, so we defer loading until the worker asks for it.
10
+ */
11
+ const isServerRuntime = String(import.meta.env?.SSR) === 'true' || typeof window === 'undefined'
12
+
13
+ let sqlite3Promise: ReturnType<typeof loadSqlite3Wasm> | undefined
14
+
15
+ if (isServerRuntime === false) {
16
+ sqlite3Promise = loadSqlite3Wasm()
17
+ }
18
+
19
+ export const loadSqlite3 = () => (isServerRuntime === true ? loadSqlite3Wasm() : (sqlite3Promise ?? loadSqlite3Wasm()))