@livestore/adapter-web 0.4.0-dev.21 → 0.4.0-dev.22

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 (36) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/in-memory/in-memory-adapter.d.ts.map +1 -1
  3. package/dist/in-memory/in-memory-adapter.js +5 -1
  4. package/dist/in-memory/in-memory-adapter.js.map +1 -1
  5. package/dist/index.d.ts +11 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +11 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/single-tab/mod.d.ts +15 -0
  10. package/dist/single-tab/mod.d.ts.map +1 -0
  11. package/dist/single-tab/mod.js +15 -0
  12. package/dist/single-tab/mod.js.map +1 -0
  13. package/dist/single-tab/single-tab-adapter.d.ts +108 -0
  14. package/dist/single-tab/single-tab-adapter.d.ts.map +1 -0
  15. package/dist/single-tab/single-tab-adapter.js +279 -0
  16. package/dist/single-tab/single-tab-adapter.js.map +1 -0
  17. package/dist/web-worker/client-session/persisted-adapter.d.ts +18 -0
  18. package/dist/web-worker/client-session/persisted-adapter.d.ts.map +1 -1
  19. package/dist/web-worker/client-session/persisted-adapter.js +72 -5
  20. package/dist/web-worker/client-session/persisted-adapter.js.map +1 -1
  21. package/dist/web-worker/common/persisted-sqlite.js +1 -1
  22. package/dist/web-worker/common/persisted-sqlite.js.map +1 -1
  23. package/dist/web-worker/common/worker-schema.d.ts +8 -4
  24. package/dist/web-worker/common/worker-schema.d.ts.map +1 -1
  25. package/dist/web-worker/leader-worker/make-leader-worker.d.ts +1 -1
  26. package/dist/web-worker/leader-worker/make-leader-worker.d.ts.map +1 -1
  27. package/dist/web-worker/leader-worker/make-leader-worker.js +43 -9
  28. package/dist/web-worker/leader-worker/make-leader-worker.js.map +1 -1
  29. package/package.json +6 -6
  30. package/src/in-memory/in-memory-adapter.ts +5 -1
  31. package/src/index.ts +15 -1
  32. package/src/single-tab/mod.ts +15 -0
  33. package/src/single-tab/single-tab-adapter.ts +517 -0
  34. package/src/web-worker/client-session/persisted-adapter.ts +87 -6
  35. package/src/web-worker/common/persisted-sqlite.ts +1 -1
  36. package/src/web-worker/leader-worker/make-leader-worker.ts +59 -10
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Single-tab adapter for browsers without SharedWorker support (e.g. Android Chrome).
3
+ *
4
+ * This adapter is a fallback for browsers that don't support the SharedWorker API.
5
+ * It provides the same OPFS persistence as the regular persisted adapter, but without
6
+ * multi-tab synchronization capabilities.
7
+ *
8
+ * **IMPORTANT**: This code is intended to be removed once SharedWorker support is
9
+ * available in Android Chrome. Track progress at:
10
+ * - LiveStore issue: https://github.com/livestorejs/livestore/issues/321
11
+ * - Chromium bug: https://issues.chromium.org/issues/40290702
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import type { Adapter, BootWarningReason, ClientSession, LockStatus } from '@livestore/common'
17
+ import {
18
+ IntentionalShutdownCause,
19
+ makeClientSession,
20
+ StoreInterrupted,
21
+ sessionChangesetMetaTable,
22
+ UnknownError,
23
+ } from '@livestore/common'
24
+ import { EventSequenceNumber } from '@livestore/common/schema'
25
+ import { sqliteDbFactory } from '@livestore/sqlite-wasm/browser'
26
+ import { shouldNeverHappen, tryAsFunctionAndNew } from '@livestore/utils'
27
+ import {
28
+ Cause,
29
+ Effect,
30
+ Exit,
31
+ Fiber,
32
+ Layer,
33
+ ParseResult,
34
+ Queue,
35
+ Schema,
36
+ Stream,
37
+ Subscribable,
38
+ SubscriptionRef,
39
+ Worker,
40
+ WorkerError,
41
+ } from '@livestore/utils/effect'
42
+ import { BrowserWorker, Opfs, WebError } from '@livestore/utils/effect/browser'
43
+ import { nanoid } from '@livestore/utils/nanoid'
44
+ import { loadSqlite3 } from '../web-worker/client-session/sqlite-loader.ts'
45
+ import {
46
+ readPersistedStateDbFromClientSession,
47
+ resetPersistedDataFromClientSession,
48
+ } from '../web-worker/common/persisted-sqlite.ts'
49
+ import { makeShutdownChannel } from '../web-worker/common/shutdown-channel.ts'
50
+ import * as WorkerSchema from '../web-worker/common/worker-schema.ts'
51
+
52
+ /**
53
+ * Options for the single-tab adapter.
54
+ *
55
+ * This adapter is designed for browsers without SharedWorker support (e.g. Android Chrome).
56
+ * It provides OPFS persistence but without multi-tab synchronization.
57
+ *
58
+ * @see https://github.com/livestorejs/livestore/issues/321
59
+ * @see https://issues.chromium.org/issues/40290702
60
+ */
61
+ export type SingleTabAdapterOptions = {
62
+ /**
63
+ * The dedicated web worker that runs the LiveStore leader thread.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * import LiveStoreWorker from './livestore.worker.ts?worker'
68
+ *
69
+ * const adapter = makeSingleTabAdapter({
70
+ * worker: LiveStoreWorker,
71
+ * storage: { type: 'opfs' },
72
+ * })
73
+ * ```
74
+ */
75
+ worker: ((options: { name: string }) => globalThis.Worker) | (new (options: { name: string }) => globalThis.Worker)
76
+
77
+ /**
78
+ * Storage configuration. Currently only OPFS is supported.
79
+ */
80
+ storage: WorkerSchema.StorageTypeEncoded
81
+
82
+ /**
83
+ * Warning: This will reset both the app and eventlog database.
84
+ * This should only be used during development.
85
+ *
86
+ * @default false
87
+ */
88
+ resetPersistence?: boolean
89
+
90
+ /**
91
+ * By default the adapter will initially generate a random clientId (via `nanoid(5)`),
92
+ * store it in `localStorage` and restore it for subsequent client sessions.
93
+ */
94
+ clientId?: string
95
+
96
+ /**
97
+ * By default the adapter will initially generate a random sessionId (via `nanoid(5)`),
98
+ * store it in `sessionStorage` and restore it for subsequent client sessions in the same tab.
99
+ */
100
+ sessionId?: string
101
+
102
+ experimental?: {
103
+ /**
104
+ * When set to `true`, the adapter will always start with a snapshot from the leader
105
+ * instead of trying to load a snapshot from storage.
106
+ *
107
+ * @default false
108
+ */
109
+ disableFastPath?: boolean
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Creates a single-tab web adapter with OPFS persistence.
115
+ *
116
+ * **This adapter is a fallback for browsers without SharedWorker support** (notably Android Chrome).
117
+ * It provides the same persistence capabilities as `makePersistedAdapter`, but without multi-tab
118
+ * synchronization. Each browser tab runs its own independent leader worker.
119
+ *
120
+ * In most cases, you should use `makePersistedAdapter` instead, which automatically falls back
121
+ * to this adapter when SharedWorker is unavailable.
122
+ *
123
+ * **Limitations**:
124
+ * - No multi-tab synchronization (each tab operates independently)
125
+ * - No devtools support (requires SharedWorker)
126
+ * - Opening multiple tabs with the same storeId may cause data conflicts
127
+ *
128
+ * @see https://github.com/livestorejs/livestore/issues/321 - LiveStore tracking issue
129
+ * @see https://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * import { makeSingleTabAdapter } from '@livestore/adapter-web'
134
+ * import LiveStoreWorker from './livestore.worker.ts?worker'
135
+ *
136
+ * // Only use this directly if you specifically need single-tab mode.
137
+ * // Prefer makePersistedAdapter which auto-detects SharedWorker support.
138
+ * const adapter = makeSingleTabAdapter({
139
+ * worker: LiveStoreWorker,
140
+ * storage: { type: 'opfs' },
141
+ * })
142
+ * ```
143
+ */
144
+ export const makeSingleTabAdapter =
145
+ (options: SingleTabAdapterOptions): Adapter =>
146
+ (adapterArgs) =>
147
+ Effect.gen(function* () {
148
+ const { schema, storeId, bootStatusQueue, shutdown, syncPayloadEncoded } = adapterArgs
149
+ // Note: devtoolsEnabled is ignored in single-tab mode (devtools require SharedWorker)
150
+
151
+ yield* ensureBrowserRequirements
152
+
153
+ yield* Queue.offer(bootStatusQueue, { stage: 'loading' })
154
+
155
+ const sqlite3 = yield* Effect.promise(() => loadSqlite3())
156
+
157
+ const storageOptions = yield* Schema.decode(WorkerSchema.StorageType)(options.storage)
158
+
159
+ const shutdownChannel = yield* makeShutdownChannel(storeId)
160
+
161
+ // Check OPFS availability early and notify user if storage is unavailable (e.g. private browsing)
162
+ const opfsWarning = yield* checkOpfsAvailability
163
+ if (opfsWarning !== undefined) {
164
+ yield* Effect.logWarning('[@livestore/adapter-web:single-tab] OPFS unavailable', opfsWarning)
165
+ }
166
+
167
+ if (options.resetPersistence === true && opfsWarning === undefined) {
168
+ yield* shutdownChannel.send(IntentionalShutdownCause.make({ reason: 'adapter-reset' }))
169
+ yield* resetPersistedDataFromClientSession({ storageOptions, storeId })
170
+ } else if (options.resetPersistence === true) {
171
+ yield* Effect.logWarning(
172
+ '[@livestore/adapter-web:single-tab] Skipping persistence reset because storage is unavailable',
173
+ opfsWarning,
174
+ )
175
+ }
176
+
177
+ // Fast-path: try to load snapshot directly from storage
178
+ const dataFromFile =
179
+ options.experimental?.disableFastPath === true || opfsWarning !== undefined
180
+ ? undefined
181
+ : yield* readPersistedStateDbFromClientSession({ storageOptions, storeId, schema }).pipe(
182
+ Effect.tapError((error) =>
183
+ Effect.logDebug('[@livestore/adapter-web:single-tab] Could not read persisted state db', error, {
184
+ storeId,
185
+ }),
186
+ ),
187
+ Effect.orElseSucceed(() => undefined),
188
+ )
189
+
190
+ const clientId = options.clientId ?? getPersistedId(`clientId:${storeId}`, 'local')
191
+ const sessionId = options.sessionId ?? getPersistedId(`sessionId:${storeId}`, 'session')
192
+
193
+ yield* shutdownChannel.listen.pipe(
194
+ Stream.flatten(),
195
+ Stream.tap((cause) =>
196
+ shutdown(cause._tag === 'LiveStore.IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
197
+ ),
198
+ Stream.runDrain,
199
+ Effect.interruptible,
200
+ Effect.tapCauseLogPretty,
201
+ Effect.forkScoped,
202
+ )
203
+
204
+ // In single-tab mode, we always have the lock (we're always the leader)
205
+ const lockStatus = yield* SubscriptionRef.make<LockStatus>('has-lock')
206
+
207
+ // Create MessageChannel for direct communication with the dedicated worker
208
+ const mc = new MessageChannel()
209
+
210
+ // Create the dedicated worker directly (no SharedWorker proxy)
211
+ const worker = tryAsFunctionAndNew(options.worker, { name: `livestore-worker-${storeId}-${sessionId}` })
212
+
213
+ // Set up communication with the dedicated worker via the outer protocol
214
+ const _dedicatedWorkerFiber = yield* Worker.makeSerialized<WorkerSchema.LeaderWorkerOuterRequest>({
215
+ initialMessage: () => new WorkerSchema.LeaderWorkerOuterInitialMessage({ port: mc.port1, storeId, clientId }),
216
+ }).pipe(
217
+ Effect.provide(BrowserWorker.layer(() => worker)),
218
+ UnknownError.mapToUnknownError,
219
+ Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
220
+ Effect.withSpan('@livestore/adapter-web:single-tab:setupDedicatedWorker'),
221
+ Effect.tapCauseLogPretty,
222
+ Effect.forkScoped,
223
+ )
224
+
225
+ // Set up the inner worker communication via port2 (like SharedWorker would do)
226
+ // BrowserWorker.layer accepts a MessagePort as well as a Worker
227
+ const innerWorkerContext = yield* Layer.build(BrowserWorker.layer(() => mc.port2 as unknown as globalThis.Worker))
228
+ const innerWorkerFiber = yield* Worker.makePoolSerialized<WorkerSchema.LeaderWorkerInnerRequest>({
229
+ size: 1,
230
+ concurrency: 100,
231
+ initialMessage: () =>
232
+ new WorkerSchema.LeaderWorkerInnerInitialMessage({
233
+ storageOptions,
234
+ storeId,
235
+ clientId,
236
+ // Devtools disabled in single-tab mode (requires SharedWorker)
237
+ devtoolsEnabled: false,
238
+ debugInstanceId: adapterArgs.debugInstanceId,
239
+ syncPayloadEncoded,
240
+ }),
241
+ }).pipe(
242
+ Effect.provide(innerWorkerContext),
243
+ Effect.tapCauseLogPretty,
244
+ UnknownError.mapToUnknownError,
245
+ Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
246
+ Effect.withSpan('@livestore/adapter-web:single-tab:setupInnerWorker'),
247
+ Effect.forkScoped,
248
+ )
249
+
250
+ // Helper to run requests against the worker
251
+ const runInWorker = <TReq extends WorkerSchema.LeaderWorkerInnerRequest>(
252
+ req: TReq,
253
+ ): TReq extends Schema.WithResult<infer A, infer _I, infer E, infer _EI, infer R>
254
+ ? Effect.Effect<A, UnknownError | E, R>
255
+ : never =>
256
+ Fiber.join(innerWorkerFiber).pipe(
257
+ Effect.flatMap((worker) => worker.executeEffect(req) as any),
258
+ Effect.logWarnIfTakesLongerThan({
259
+ label: `@livestore/adapter-web:single-tab:runInWorker:${req._tag}`,
260
+ duration: 2000,
261
+ }),
262
+ Effect.withSpan(`@livestore/adapter-web:single-tab:runInWorker:${req._tag}`),
263
+ Effect.mapError((cause) =>
264
+ Schema.is(UnknownError)(cause)
265
+ ? cause
266
+ : ParseResult.isParseError(cause) || Schema.is(WorkerError.WorkerError)(cause)
267
+ ? new UnknownError({ cause })
268
+ : cause,
269
+ ),
270
+ Effect.catchAllDefect((cause) => new UnknownError({ cause })),
271
+ ) as any
272
+
273
+ const runInWorkerStream = <TReq extends WorkerSchema.LeaderWorkerInnerRequest>(
274
+ req: TReq,
275
+ ): TReq extends Schema.WithResult<infer A, infer _I, infer _E, infer _EI, infer R>
276
+ ? Stream.Stream<A, UnknownError, R>
277
+ : never =>
278
+ Effect.gen(function* () {
279
+ const innerWorker = yield* Fiber.join(innerWorkerFiber)
280
+ return innerWorker.execute(req as any).pipe(
281
+ Stream.mapError((cause) =>
282
+ Schema.is(UnknownError)(cause)
283
+ ? cause
284
+ : ParseResult.isParseError(cause) || Schema.is(WorkerError.WorkerError)(cause)
285
+ ? new UnknownError({ cause })
286
+ : cause,
287
+ ),
288
+ Stream.withSpan(`@livestore/adapter-web:single-tab:runInWorkerStream:${req._tag}`),
289
+ )
290
+ }).pipe(Stream.unwrap) as any
291
+
292
+ // Forward boot status from worker
293
+ const bootStatusFiber = yield* runInWorkerStream(new WorkerSchema.LeaderWorkerInnerBootStatusStream()).pipe(
294
+ Stream.tap((_) => Queue.offer(bootStatusQueue, _)),
295
+ Stream.runDrain,
296
+ Effect.tapErrorCause((cause) =>
297
+ Cause.isInterruptedOnly(cause) ? Effect.void : shutdown(Exit.failCause(cause)),
298
+ ),
299
+ Effect.interruptible,
300
+ Effect.tapCauseLogPretty,
301
+ Effect.forkScoped,
302
+ )
303
+
304
+ yield* Queue.awaitShutdown(bootStatusQueue).pipe(
305
+ Effect.andThen(Fiber.interrupt(bootStatusFiber)),
306
+ Effect.tapCauseLogPretty,
307
+ Effect.forkScoped,
308
+ )
309
+
310
+ // Get initial snapshot (either from fast-path or from worker)
311
+ const initialResult =
312
+ dataFromFile === undefined
313
+ ? yield* runInWorker(new WorkerSchema.LeaderWorkerInnerGetRecreateSnapshot()).pipe(
314
+ Effect.map(({ snapshot, migrationsReport }) => ({
315
+ _tag: 'from-leader-worker' as const,
316
+ snapshot,
317
+ migrationsReport,
318
+ })),
319
+ )
320
+ : { _tag: 'fast-path' as const, snapshot: dataFromFile }
321
+
322
+ const migrationsReport =
323
+ initialResult._tag === 'from-leader-worker' ? initialResult.migrationsReport : { migrations: [] }
324
+
325
+ const makeSqliteDb = sqliteDbFactory({ sqlite3 })
326
+ const sqliteDb = yield* makeSqliteDb({ _tag: 'in-memory' })
327
+
328
+ sqliteDb.import(initialResult.snapshot)
329
+
330
+ const numberOfTables =
331
+ sqliteDb.select<{ count: number }>(`select count(*) as count from sqlite_master`)[0]?.count ?? 0
332
+ if (numberOfTables === 0) {
333
+ return yield* UnknownError.make({
334
+ cause: `Encountered empty or corrupted database`,
335
+ payload: { snapshotByteLength: initialResult.snapshot.byteLength, storageOptions: options.storage },
336
+ })
337
+ }
338
+
339
+ // Restore leader head from SESSION_CHANGESET_META_TABLE
340
+ const initialLeaderHeadRes = sqliteDb.select(
341
+ sessionChangesetMetaTable
342
+ .select('seqNumClient', 'seqNumGlobal', 'seqNumRebaseGeneration')
343
+ .orderBy([
344
+ { col: 'seqNumGlobal', direction: 'desc' },
345
+ { col: 'seqNumClient', direction: 'desc' },
346
+ ])
347
+ .first(),
348
+ )
349
+
350
+ const initialLeaderHead = initialLeaderHeadRes
351
+ ? EventSequenceNumber.Client.Composite.make({
352
+ global: initialLeaderHeadRes.seqNumGlobal,
353
+ client: initialLeaderHeadRes.seqNumClient,
354
+ rebaseGeneration: initialLeaderHeadRes.seqNumRebaseGeneration,
355
+ })
356
+ : EventSequenceNumber.Client.ROOT
357
+
358
+ yield* Effect.addFinalizer((ex) =>
359
+ Effect.gen(function* () {
360
+ if (
361
+ Exit.isFailure(ex) &&
362
+ Exit.isInterrupted(ex) === false &&
363
+ Schema.is(IntentionalShutdownCause)(Cause.squash(ex.cause)) === false &&
364
+ Schema.is(StoreInterrupted)(Cause.squash(ex.cause)) === false
365
+ ) {
366
+ yield* Effect.logError('[@livestore/adapter-web:single-tab] client-session shutdown', ex.cause)
367
+ } else {
368
+ yield* Effect.logDebug('[@livestore/adapter-web:single-tab] client-session shutdown', ex)
369
+ }
370
+ }).pipe(Effect.tapCauseLogPretty, Effect.orDie),
371
+ )
372
+
373
+ const leaderThread: ClientSession['leaderThread'] = {
374
+ export: runInWorker(new WorkerSchema.LeaderWorkerInnerExport()).pipe(
375
+ Effect.timeout(10_000),
376
+ UnknownError.mapToUnknownError,
377
+ Effect.withSpan('@livestore/adapter-web:single-tab:export'),
378
+ ),
379
+
380
+ events: {
381
+ pull: ({ cursor }) =>
382
+ runInWorkerStream(new WorkerSchema.LeaderWorkerInnerPullStream({ cursor })).pipe(Stream.orDie),
383
+ push: (batch) =>
384
+ runInWorker(new WorkerSchema.LeaderWorkerInnerPushToLeader({ batch })).pipe(
385
+ Effect.withSpan('@livestore/adapter-web:single-tab:pushToLeader', {
386
+ attributes: { batchSize: batch.length },
387
+ }),
388
+ ),
389
+ stream: (options) =>
390
+ runInWorkerStream(new WorkerSchema.LeaderWorkerInnerStreamEvents(options)).pipe(
391
+ Stream.withSpan('@livestore/adapter-web:single-tab:streamEvents'),
392
+ Stream.orDie,
393
+ ),
394
+ },
395
+
396
+ initialState: {
397
+ leaderHead: initialLeaderHead,
398
+ migrationsReport,
399
+ storageMode: opfsWarning === undefined ? 'persisted' : 'in-memory',
400
+ },
401
+
402
+ getEventlogData: runInWorker(new WorkerSchema.LeaderWorkerInnerExportEventlog()).pipe(
403
+ Effect.timeout(10_000),
404
+ UnknownError.mapToUnknownError,
405
+ Effect.withSpan('@livestore/adapter-web:single-tab:getEventlogData'),
406
+ ),
407
+
408
+ syncState: Subscribable.make({
409
+ get: runInWorker(new WorkerSchema.LeaderWorkerInnerGetLeaderSyncState()).pipe(
410
+ UnknownError.mapToUnknownError,
411
+ Effect.withSpan('@livestore/adapter-web:single-tab:getLeaderSyncState'),
412
+ ),
413
+ changes: runInWorkerStream(new WorkerSchema.LeaderWorkerInnerSyncStateStream()).pipe(Stream.orDie),
414
+ }),
415
+
416
+ sendDevtoolsMessage: (_message) =>
417
+ // Devtools not supported in single-tab mode
418
+ Effect.void,
419
+
420
+ networkStatus: Subscribable.make({
421
+ get: runInWorker(new WorkerSchema.LeaderWorkerInnerGetNetworkStatus()).pipe(Effect.orDie),
422
+ changes: runInWorkerStream(new WorkerSchema.LeaderWorkerInnerNetworkStatusStream()).pipe(Stream.orDie),
423
+ }),
424
+ }
425
+
426
+ const clientSession = yield* makeClientSession({
427
+ ...adapterArgs,
428
+ sqliteDb,
429
+ lockStatus,
430
+ clientId,
431
+ sessionId,
432
+ isLeader: true, // Always leader in single-tab mode
433
+ leaderThread,
434
+ webmeshMode: 'direct',
435
+ origin: globalThis.location?.origin,
436
+ // No webmesh connection in single-tab mode (devtools disabled)
437
+ connectWebmeshNode: () => Effect.void,
438
+ registerBeforeUnload: (onBeforeUnload) => {
439
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
440
+ window.addEventListener('beforeunload', onBeforeUnload)
441
+ return () => window.removeEventListener('beforeunload', onBeforeUnload)
442
+ }
443
+ return () => {}
444
+ },
445
+ })
446
+
447
+ return clientSession
448
+ }).pipe(Effect.provide(Opfs.Opfs.Default), UnknownError.mapToUnknownError)
449
+
450
+ /** Persists clientId/sessionId to storage */
451
+ const getPersistedId = (key: string, storageType: 'session' | 'local') => {
452
+ const makeId = () => nanoid(5)
453
+
454
+ const storage =
455
+ typeof window === 'undefined'
456
+ ? undefined
457
+ : storageType === 'session'
458
+ ? sessionStorage
459
+ : storageType === 'local'
460
+ ? localStorage
461
+ : shouldNeverHappen(`[@livestore/adapter-web] Invalid storage type: ${storageType}`)
462
+
463
+ if (storage === undefined) {
464
+ return makeId()
465
+ }
466
+
467
+ const fullKey = `livestore:${key}`
468
+ const storedKey = storage.getItem(fullKey)
469
+
470
+ if (storedKey) return storedKey
471
+
472
+ const newKey = makeId()
473
+ storage.setItem(fullKey, newKey)
474
+
475
+ return newKey
476
+ }
477
+
478
+ const ensureBrowserRequirements = Effect.gen(function* () {
479
+ const validate = (condition: boolean, label: string) =>
480
+ Effect.gen(function* () {
481
+ if (condition) {
482
+ return yield* UnknownError.make({
483
+ cause: `[@livestore/adapter-web] Browser not supported. The LiveStore web adapter needs '${label}' to work properly`,
484
+ })
485
+ }
486
+ })
487
+
488
+ yield* Effect.all([
489
+ validate(typeof navigator === 'undefined', 'navigator'),
490
+ validate(navigator.locks === undefined, 'navigator.locks'),
491
+ validate(navigator.storage === undefined, 'navigator.storage'),
492
+ validate(crypto.randomUUID === undefined, 'crypto.randomUUID'),
493
+ validate(typeof window === 'undefined', 'window'),
494
+ validate(typeof sessionStorage === 'undefined', 'sessionStorage'),
495
+ ])
496
+ })
497
+
498
+ /**
499
+ * Attempts to access OPFS and returns a warning if unavailable.
500
+ */
501
+ const checkOpfsAvailability = Effect.gen(function* () {
502
+ const opfs = yield* Opfs.Opfs
503
+ return yield* opfs.getRootDirectoryHandle.pipe(
504
+ Effect.as(undefined),
505
+ Effect.catchAll((error) => {
506
+ const reason: BootWarningReason =
507
+ Schema.is(WebError.SecurityError)(error) || Schema.is(WebError.NotAllowedError)(error)
508
+ ? 'private-browsing'
509
+ : 'storage-unavailable'
510
+ const message =
511
+ reason === 'private-browsing'
512
+ ? 'Storage unavailable in private browsing mode. LiveStore will continue without persistence.'
513
+ : 'Storage access denied. LiveStore will continue without persistence.'
514
+ return Effect.succeed({ reason, message } as const)
515
+ }),
516
+ )
517
+ })
@@ -1,4 +1,4 @@
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
4
  liveStoreVersion,
@@ -12,7 +12,7 @@ import {
12
12
  // import LiveStoreSharedWorker from '@livestore/adapter-web/internal-shared-worker?sharedworker'
13
13
  import { EventSequenceNumber } from '@livestore/common/schema'
14
14
  import { sqliteDbFactory } from '@livestore/sqlite-wasm/browser'
15
- import { isDevEnv, shouldNeverHappen, tryAsFunctionAndNew } from '@livestore/utils'
15
+ import { isDevEnv, omitUndefineds, shouldNeverHappen, tryAsFunctionAndNew } from '@livestore/utils'
16
16
  import {
17
17
  Cause,
18
18
  Deferred,
@@ -29,8 +29,9 @@ import {
29
29
  Worker,
30
30
  WorkerError,
31
31
  } from '@livestore/utils/effect'
32
- import { BrowserWorker, Opfs, WebLock } from '@livestore/utils/effect/browser'
32
+ import { BrowserWorker, Opfs, WebError, WebLock } from '@livestore/utils/effect/browser'
33
33
  import { nanoid } from '@livestore/utils/nanoid'
34
+ import { makeSingleTabAdapter } from '../../single-tab/single-tab-adapter.ts'
34
35
  import {
35
36
  readPersistedStateDbFromClientSession,
36
37
  resetPersistedDataFromClientSession,
@@ -41,6 +42,16 @@ import * as WorkerSchema from '../common/worker-schema.ts'
41
42
  import { connectWebmeshNodeClientSession } from './client-session-devtools.ts'
42
43
  import { loadSqlite3 } from './sqlite-loader.ts'
43
44
 
45
+ /**
46
+ * Checks if SharedWorker API is available in the current browser context.
47
+ *
48
+ * Returns false on Android Chrome and other browsers without SharedWorker support.
49
+ *
50
+ * @see https://github.com/livestorejs/livestore/issues/321
51
+ * @see https://issues.chromium.org/issues/40290702
52
+ */
53
+ export const canUseSharedWorker = (): boolean => typeof SharedWorker !== 'undefined'
54
+
44
55
  if (isDevEnv()) {
45
56
  globalThis.__debugLiveStoreUtils = {
46
57
  ...globalThis.__debugLiveStoreUtils,
@@ -120,6 +131,15 @@ export type WebAdapterOptions = {
120
131
  * Creates a web adapter with persistent storage (currently only supports OPFS).
121
132
  * Requires both a web worker and a shared worker.
122
133
  *
134
+ * On browsers without SharedWorker support (e.g. Android Chrome), this adapter
135
+ * automatically falls back to single-tab mode. In single-tab mode:
136
+ * - Each tab runs independently with its own leader worker
137
+ * - Multi-tab synchronization is not available
138
+ * - Devtools are not supported
139
+ *
140
+ * @see https://github.com/livestorejs/livestore/issues/321 - SharedWorker tracking issue
141
+ * @see https://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug
142
+ *
123
143
  * @example
124
144
  * ```ts
125
145
  * import { makePersistedAdapter } from '@livestore/adapter-web'
@@ -137,6 +157,26 @@ export const makePersistedAdapter =
137
157
  (options: WebAdapterOptions): Adapter =>
138
158
  (adapterArgs) =>
139
159
  Effect.gen(function* () {
160
+ // Check SharedWorker availability first and fall back to single-tab mode if unavailable
161
+ if (!canUseSharedWorker()) {
162
+ yield* Effect.logWarning(
163
+ '[@livestore/adapter-web] SharedWorker unavailable (e.g. Android Chrome). ' +
164
+ 'Falling back to single-tab mode. Multi-tab synchronization and devtools are disabled. ' +
165
+ 'See: https://github.com/livestorejs/livestore/issues/321',
166
+ )
167
+
168
+ return yield* makeSingleTabAdapter({
169
+ worker: options.worker,
170
+ storage: options.storage,
171
+ ...omitUndefineds({
172
+ resetPersistence: options.resetPersistence,
173
+ clientId: options.clientId,
174
+ sessionId: options.sessionId,
175
+ experimental: options.experimental,
176
+ }),
177
+ })(adapterArgs)
178
+ }
179
+
140
180
  const {
141
181
  schema,
142
182
  storeId,
@@ -168,10 +208,21 @@ export const makePersistedAdapter =
168
208
 
169
209
  const shutdownChannel = yield* makeShutdownChannel(storeId)
170
210
 
171
- if (options.resetPersistence === true) {
211
+ // Check OPFS availability early and notify user if storage is unavailable (e.g. private browsing)
212
+ const opfsWarning = yield* checkOpfsAvailability
213
+ if (opfsWarning !== undefined) {
214
+ yield* Effect.logWarning('[@livestore/adapter-web:client-session] OPFS unavailable', opfsWarning)
215
+ }
216
+
217
+ if (options.resetPersistence === true && opfsWarning === undefined) {
172
218
  yield* shutdownChannel.send(IntentionalShutdownCause.make({ reason: 'adapter-reset' }))
173
219
 
174
220
  yield* resetPersistedDataFromClientSession({ storageOptions, storeId })
221
+ } else if (options.resetPersistence === true) {
222
+ yield* Effect.logWarning(
223
+ '[@livestore/adapter-web:client-session] Skipping persistence reset because storage is unavailable',
224
+ opfsWarning,
225
+ )
175
226
  }
176
227
 
177
228
  // Note on fast-path booting:
@@ -181,7 +232,7 @@ export const makePersistedAdapter =
181
232
  // We need to be extra careful though to not run into any race conditions or inconsistencies.
182
233
  // TODO also verify persisted data
183
234
  const dataFromFile =
184
- options.experimental?.disableFastPath === true
235
+ options.experimental?.disableFastPath === true || opfsWarning !== undefined
185
236
  ? undefined
186
237
  : yield* readPersistedStateDbFromClientSession({ storageOptions, storeId, schema }).pipe(
187
238
  Effect.tapError((error) =>
@@ -480,7 +531,11 @@ export const makePersistedAdapter =
480
531
  ),
481
532
  },
482
533
 
483
- initialState: { leaderHead: initialLeaderHead, migrationsReport },
534
+ initialState: {
535
+ leaderHead: initialLeaderHead,
536
+ migrationsReport,
537
+ storageMode: opfsWarning === undefined ? 'persisted' : 'in-memory',
538
+ },
484
539
 
485
540
  getEventlogData: runInWorker(new WorkerSchema.LeaderWorkerInnerExportEventlog()).pipe(
486
541
  Effect.timeout(10_000),
@@ -584,3 +639,29 @@ const ensureBrowserRequirements = Effect.gen(function* () {
584
639
  validate(typeof sessionStorage === 'undefined', 'sessionStorage'),
585
640
  ])
586
641
  })
642
+
643
+ /**
644
+ * Attempts to access OPFS and returns a warning if unavailable.
645
+ *
646
+ * Common failure scenarios:
647
+ * - Safari/Firefox private browsing: SecurityError or NotAllowedError
648
+ * - Permission denied: NotAllowedError
649
+ * - Quota exceeded: QuotaExceededError
650
+ */
651
+ const checkOpfsAvailability = Effect.gen(function* () {
652
+ const opfs = yield* Opfs.Opfs
653
+ return yield* opfs.getRootDirectoryHandle.pipe(
654
+ Effect.as(undefined),
655
+ Effect.catchAll((error) => {
656
+ const reason: BootWarningReason =
657
+ Schema.is(WebError.SecurityError)(error) || Schema.is(WebError.NotAllowedError)(error)
658
+ ? 'private-browsing'
659
+ : 'storage-unavailable'
660
+ const message =
661
+ reason === 'private-browsing'
662
+ ? 'Storage unavailable in private browsing mode. LiveStore will continue without persistence.'
663
+ : 'Storage access denied. LiveStore will continue without persistence.'
664
+ return Effect.succeed({ reason, message } as const)
665
+ }),
666
+ )
667
+ })
@@ -88,7 +88,7 @@ export const resetPersistedDataFromClientSession = Effect.fn(
88
88
  )(
89
89
  function* ({ storageOptions, storeId }: { storageOptions: WorkerSchema.StorageType; storeId: string }) {
90
90
  const directory = yield* sanitizeOpfsDir(storageOptions.directory, storeId)
91
- yield* Opfs.remove(directory).pipe(
91
+ yield* Opfs.remove(directory, { recursive: true }).pipe(
92
92
  // We ignore NotFoundError here as it may not exist or have already been deleted
93
93
  Effect.catchTag('@livestore/utils/Web/NotFoundError', () => Effect.void),
94
94
  )