@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,141 +1,120 @@
1
- import { liveStoreStorageFormatVersion, UnexpectedError } from '@livestore/common'
1
+ import { liveStoreStorageFormatVersion } from '@livestore/common'
2
2
  import type { LiveStoreSchema } from '@livestore/common/schema'
3
- import { decodeSAHPoolFilename, HEADER_OFFSET_DATA, type WebDatabaseMetadataOpfs } from '@livestore/sqlite-wasm/browser'
4
- import { Effect, Schedule, Schema } from '@livestore/utils/effect'
3
+ import {
4
+ decodeAccessHandlePoolFilename,
5
+ HEADER_OFFSET_DATA,
6
+ type WebDatabaseMetadataOpfs,
7
+ } from '@livestore/sqlite-wasm/browser'
8
+ import { isDevEnv } from '@livestore/utils'
9
+ import { Chunk, Effect, Option, Order, Schedule, Schema, Stream } from '@livestore/utils/effect'
10
+ import { Opfs, type WebError } from '@livestore/utils/effect/browser'
5
11
 
6
- import * as OpfsUtils from '../../opfs-utils.ts'
7
12
  import type * as WorkerSchema from './worker-schema.ts'
8
13
 
9
- export class PersistedSqliteError extends Schema.TaggedError<PersistedSqliteError>()('PersistedSqliteError', {
14
+ export class PersistedSqliteError extends Schema.TaggedError<PersistedSqliteError>('~@livestore/adapter-web/PersistedSqliteError')('PersistedSqliteError', {
10
15
  message: Schema.String,
11
- cause: Schema.Defect,
16
+ cause: Schema.optional(Schema.Defect),
12
17
  }) {}
13
18
 
14
- export const readPersistedAppDbFromClientSession = ({
15
- storageOptions,
16
- storeId,
17
- schema,
18
- }: {
19
+ export const readPersistedStateDbFromClientSession: (args: {
19
20
  storageOptions: WorkerSchema.StorageType
20
21
  storeId: string
21
22
  schema: LiveStoreSchema
22
- }) =>
23
- Effect.promise(async () => {
24
- const directory = sanitizeOpfsDir(storageOptions.directory, storeId)
25
- const sahPoolOpaqueDir = await OpfsUtils.getDirHandle(directory).catch(() => undefined)
26
-
27
- if (sahPoolOpaqueDir === undefined) {
28
- return undefined
29
- }
30
-
31
- const tryGetDbFile = async (fileHandle: FileSystemFileHandle) => {
32
- const file = await fileHandle.getFile()
33
- const fileName = await decodeSAHPoolFilename(file)
34
- return fileName ? { fileName, file } : undefined
35
- }
23
+ }) => Effect.Effect<
24
+ Uint8Array<ArrayBuffer>,
25
+ // All the following errors could actually happen:
26
+ | PersistedSqliteError
27
+ | WebError.UnknownError
28
+ | WebError.TypeError
29
+ | WebError.NotFoundError
30
+ | WebError.NotAllowedError
31
+ | WebError.TypeMismatchError
32
+ | WebError.SecurityError
33
+ | Opfs.OpfsError,
34
+ Opfs.Opfs
35
+ > = Effect.fn('@livestore/adapter-web:readPersistedStateDbFromClientSession')(
36
+ function* ({ storageOptions, storeId, schema }) {
37
+ const accessHandlePoolDirString = yield* sanitizeOpfsDir(storageOptions.directory, storeId)
38
+
39
+ const accessHandlePoolDirHandle = yield* Opfs.getDirectoryHandleByPath(accessHandlePoolDirString)
40
+
41
+ const stateDbFileName = `/${getStateDbFileName(schema)}`
42
+
43
+ const handlesStream = yield* Opfs.Opfs.values(accessHandlePoolDirHandle)
44
+
45
+ const stateDbFileOption = yield* handlesStream.pipe(
46
+ Stream.filter((handle): handle is FileSystemFileHandle => handle.kind === 'file'),
47
+ Stream.mapEffect(
48
+ (fileHandle) =>
49
+ Effect.gen(function* () {
50
+ const file = yield* Opfs.Opfs.getFile(fileHandle)
51
+ const fileName = yield* Effect.promise(() => decodeAccessHandlePoolFilename(file))
52
+ return { file, fileName }
53
+ }),
54
+ { concurrency: 'unbounded' },
55
+ ),
56
+ Stream.find(({ fileName }) => fileName === stateDbFileName),
57
+ Stream.runHead,
58
+ )
36
59
 
37
- const getAllFiles = async (asyncIterator: AsyncIterable<FileSystemHandle>): Promise<FileSystemFileHandle[]> => {
38
- const results: FileSystemFileHandle[] = []
39
- for await (const value of asyncIterator) {
40
- if (value.kind === 'file') {
41
- results.push(value as FileSystemFileHandle)
42
- }
43
- }
44
- return results
60
+ if (Option.isNone(stateDbFileOption) === true) {
61
+ return yield* new PersistedSqliteError({
62
+ message: `State database file not found in client session (expected '${stateDbFileName}' in '${accessHandlePoolDirString}')`,
63
+ })
45
64
  }
46
65
 
47
- const files = await getAllFiles(sahPoolOpaqueDir.values())
48
-
49
- const fileResults = await Promise.all(files.map(tryGetDbFile))
50
-
51
- const appDbFileName = `/${getStateDbFileName(schema)}`
52
-
53
- const dbFileRes = fileResults.find((_) => _?.fileName === appDbFileName)
54
- // console.debug('fileResults', fileResults, 'dbFileRes', dbFileRes)
55
-
56
- if (dbFileRes !== undefined) {
57
- const data = await dbFileRes.file.slice(HEADER_OFFSET_DATA).arrayBuffer()
58
- // console.debug('readPersistedAppDbFromClientSession', data.byteLength, data)
59
-
60
- // Given the SAH pool always eagerly creates files with empty non-header data,
61
- // we want to return undefined if the file exists but is empty
62
- if (data.byteLength === 0) {
63
- return undefined
64
- }
66
+ const stateDbBuffer = yield* Effect.promise(() =>
67
+ stateDbFileOption.value.file.slice(HEADER_OFFSET_DATA).arrayBuffer(),
68
+ )
65
69
 
66
- return new Uint8Array(data)
70
+ // Given the access handle pool always eagerly creates files with empty non-header data,
71
+ // we want to return undefined if the file exists but is empty
72
+ if (stateDbBuffer.byteLength === 0) {
73
+ return yield* new PersistedSqliteError({
74
+ message: `State database file is empty in client session (expected '${stateDbFileName}' in '${accessHandlePoolDirString}')`,
75
+ })
67
76
  }
68
77
 
69
- return undefined
70
- }).pipe(
71
- Effect.logWarnIfTakesLongerThan({
72
- duration: 1000,
73
- label: '@livestore/adapter-web:readPersistedAppDbFromClientSession',
74
- }),
75
- Effect.withPerformanceMeasure('@livestore/adapter-web:readPersistedAppDbFromClientSession'),
76
- Effect.withSpan('@livestore/adapter-web:readPersistedAppDbFromClientSession'),
77
- )
78
-
79
- export const resetPersistedDataFromClientSession = ({
80
- storageOptions,
81
- storeId,
82
- }: {
83
- storageOptions: WorkerSchema.StorageType
84
- storeId: string
85
- }) =>
86
- Effect.gen(function* () {
87
- const directory = sanitizeOpfsDir(storageOptions.directory, storeId)
88
- yield* opfsDeleteAbs(directory)
89
- }).pipe(
90
- Effect.retry({
91
- schedule: Schedule.exponentialBackoff10Sec,
92
- }),
93
- Effect.withSpan('@livestore/adapter-web:resetPersistedDataFromClientSession'),
94
- )
95
-
96
- const opfsDeleteAbs = (absPath: string) =>
97
- Effect.promise(async () => {
98
- // Get the root directory handle
99
- const root = await OpfsUtils.rootHandlePromise
100
-
101
- // Split the absolute path to traverse directories
102
- const pathParts = absPath.split('/').filter((part) => part.length)
103
-
104
- try {
105
- // Traverse to the target file handle
106
- let currentDir = root
107
- for (let i = 0; i < pathParts.length - 1; i++) {
108
- currentDir = await currentDir.getDirectoryHandle(pathParts[i]!)
109
- }
78
+ return new Uint8Array(stateDbBuffer)
79
+ },
80
+ Effect.logWarnIfTakesLongerThan({
81
+ duration: 1000,
82
+ label: '@livestore/adapter-web:readPersistedStateDbFromClientSession',
83
+ }),
84
+ Effect.withPerformanceMeasure('@livestore/adapter-web:readPersistedStateDbFromClientSession'),
85
+ )
110
86
 
111
- // Delete the file
112
- await currentDir.removeEntry(pathParts.at(-1)!, { recursive: true })
113
- } catch (error) {
114
- if (error instanceof DOMException && error.name === 'NotFoundError') {
115
- // Can ignore as it's already been deleted or not there in the first place
116
- return
117
- } else {
118
- throw error
119
- }
120
- }
121
- }).pipe(
122
- UnexpectedError.mapToUnexpectedError,
123
- Effect.withSpan('@livestore/adapter-web:worker:opfsDeleteFile', { attributes: { absFilePath: absPath } }),
124
- )
87
+ export const resetPersistedDataFromClientSession = Effect.fn(
88
+ '@livestore/adapter-web:resetPersistedDataFromClientSession',
89
+ )(
90
+ function* ({ storageOptions, storeId }: { storageOptions: WorkerSchema.StorageType; storeId: string }) {
91
+ const directory = yield* sanitizeOpfsDir(storageOptions.directory, storeId)
92
+ yield* Opfs.remove(directory, { recursive: true }).pipe(
93
+ // We ignore NotFoundError here as it may not exist or have already been deleted
94
+ Effect.catchTag('NotFoundError', () => Effect.void),
95
+ )
96
+ },
97
+ Effect.retry({
98
+ schedule: Schedule.exponentialBackoff10Sec,
99
+ }),
100
+ )
125
101
 
126
- export const sanitizeOpfsDir = (directory: string | undefined, storeId: string) => {
127
- // Root dir should be `''` not `/`
128
- if (directory === undefined || directory === '' || directory === '/')
102
+ export const sanitizeOpfsDir = Effect.fn('@livestore/adapter-web:sanitizeOpfsDir')(function* (
103
+ directory: string | undefined,
104
+ storeId: string,
105
+ ) {
106
+ if (directory === undefined || directory === '' || directory === '/') {
129
107
  return `livestore-${storeId}@${liveStoreStorageFormatVersion}`
108
+ }
130
109
 
131
- if (directory.includes('/')) {
132
- throw new Error(
133
- `@livestore/adapter-web:worker:sanitizeOpfsDir: Nested directories are not yet supported ('${directory}')`,
134
- )
110
+ if (directory.includes('/') === true) {
111
+ return yield* new PersistedSqliteError({
112
+ message: `Nested directories are not yet supported ('${directory}')`,
113
+ })
135
114
  }
136
115
 
137
116
  return `${directory}@${liveStoreStorageFormatVersion}`
138
- }
117
+ })
139
118
 
140
119
  export const getStateDbFileName = (schema: LiveStoreSchema) => {
141
120
  const schemaHashSuffix =
@@ -143,6 +122,9 @@ export const getStateDbFileName = (schema: LiveStoreSchema) => {
143
122
  return `state${schemaHashSuffix}.db`
144
123
  }
145
124
 
125
+ export const MAX_ARCHIVED_STATE_DBS_IN_DEV = 3
126
+ export const ARCHIVE_DIR_NAME = 'archive'
127
+
146
128
  /**
147
129
  * Cleanup old state database files after successful migration.
148
130
  * This prevents OPFS file pool capacity from being exhausted by accumulated schema files.
@@ -150,70 +132,133 @@ export const getStateDbFileName = (schema: LiveStoreSchema) => {
150
132
  * @param vfs - The AccessHandlePoolVFS instance for safe file operations
151
133
  * @param currentSchema - Current schema (to avoid deleting the active database)
152
134
  */
153
- export const cleanupOldStateDbFiles = Effect.fn('@livestore/adapter-web:cleanupOldStateDbFiles')(
154
- function* ({ vfs, currentSchema }: { vfs: WebDatabaseMetadataOpfs['vfs']; currentSchema: LiveStoreSchema }) {
155
- // Only cleanup for auto migration strategy because:
156
- // - Auto strategy: Creates new database files per schema change (e.g., state123.db, state456.db)
157
- // which accumulate over time and can exhaust OPFS file pool capacity
158
- // - Manual strategy: Always reuses the same database file (statefixed.db) across schema changes,
159
- // so there are never multiple old files to clean up
160
- if (currentSchema.state.sqlite.migrations.strategy === 'manual') {
161
- yield* Effect.logDebug('Skipping state db cleanup - manual migration strategy uses fixed filename')
162
- return
163
- }
135
+ export const cleanupOldStateDbFiles: (options: {
136
+ vfs: WebDatabaseMetadataOpfs['vfs']
137
+ currentSchema: LiveStoreSchema
138
+ opfsDirectory: string
139
+ }) => Effect.Effect<
140
+ void,
141
+ // All the following errors could actually happen:
142
+ | WebError.AbortError
143
+ | WebError.DataCloneError
144
+ | WebError.EvalError
145
+ | WebError.InvalidModificationError
146
+ | WebError.InvalidStateError
147
+ | WebError.NoModificationAllowedError
148
+ | WebError.NotAllowedError
149
+ | WebError.NotFoundError
150
+ | WebError.QuotaExceededError
151
+ | WebError.RangeError
152
+ | WebError.ReferenceError
153
+ | WebError.SecurityError
154
+ | WebError.TypeError
155
+ | WebError.TypeMismatchError
156
+ | WebError.URIError
157
+ | WebError.UnknownError
158
+ | Opfs.OpfsError
159
+ | PersistedSqliteError,
160
+ Opfs.Opfs
161
+ > = Effect.fn('@livestore/adapter-web:cleanupOldStateDbFiles')(function* ({ vfs, currentSchema, opfsDirectory }) {
162
+ // Only cleanup for auto migration strategy because:
163
+ // - Auto strategy: Creates new database files per schema change (e.g., state123.db, state456.db)
164
+ // which accumulate over time and can exhaust OPFS file pool capacity
165
+ // - Manual strategy: Always reuses the same database file (statefixed.db) across schema changes,
166
+ // so there are never multiple old files to clean up
167
+ if (currentSchema.state.sqlite.migrations.strategy === 'manual') {
168
+ yield* Effect.logDebug('Skipping state db cleanup - manual migration strategy uses fixed filename')
169
+ return
170
+ }
164
171
 
165
- const currentDbFileName = getStateDbFileName(currentSchema)
166
- const currentPath = `/${currentDbFileName}`
172
+ const isDev = isDevEnv()
173
+ const currentDbFileName = getStateDbFileName(currentSchema)
174
+ const currentPath = `/${currentDbFileName}`
167
175
 
168
- const allPaths = yield* Effect.sync(() => vfs.getTrackedFilePaths())
169
- const oldStateDbPaths = allPaths.filter(
170
- (path) => path.startsWith('/state') && path.endsWith('.db') && path !== currentPath,
171
- )
176
+ const allPaths = yield* Effect.sync(() => vfs.getTrackedFilePaths())
177
+ const oldStateDbPaths = allPaths.filter(
178
+ (path) => path.startsWith('/state') && path.endsWith('.db') && path !== currentPath,
179
+ )
172
180
 
173
- if (oldStateDbPaths.length === 0) {
174
- yield* Effect.logDebug('State db cleanup completed: no old database files found')
175
- return
176
- }
181
+ if (oldStateDbPaths.length === 0) {
182
+ yield* Effect.logDebug('No old database files found')
183
+ return
184
+ }
177
185
 
178
- yield* Effect.logDebug(`Found ${oldStateDbPaths.length} old state database file(s) to clean up`)
186
+ const absoluteArchiveDirName = `${opfsDirectory}/${ARCHIVE_DIR_NAME}`
187
+ if (isDev === true && (yield* Opfs.exists(absoluteArchiveDirName)) === false)
188
+ yield* Opfs.makeDirectory(absoluteArchiveDirName)
179
189
 
180
- let deletedCount = 0
181
- for (const path of oldStateDbPaths) {
182
- const fileName = path.startsWith('/') ? path.slice(1) : path
190
+ for (const path of oldStateDbPaths) {
191
+ const fileName = path.startsWith('/') === true ? path.slice(1) : path
183
192
 
184
- const vfsResultCode = yield* Effect.try({
185
- try: () => vfs.jDelete(fileName, 0),
186
- catch: (cause) => new SqliteVfsError({ operation: 'jDelete', fileName, cause }),
187
- })
193
+ if (isDev === true) {
194
+ const archiveFileData = yield* vfs.readFilePayload(fileName)
195
+
196
+ const archiveFileName = `${Date.now()}-${fileName}`
197
+ const archivePath = `${opfsDirectory}/archive/${archiveFileName}`
198
+ const archiveData = new Uint8Array(archiveFileData)
199
+
200
+ // Prefer writeFile (atomic) when createWritable is available (Chrome, Firefox, Safari 26+),
201
+ // fall back to syncWriteFile (non-atomic) for Safari 18.x compatibility.
202
+ // TODO: Remove feature detection and use writeFile directly when Safari >= 26 is widely available.
203
+ const supportsCreateWritable =
204
+ typeof FileSystemFileHandle !== 'undefined' && 'createWritable' in FileSystemFileHandle.prototype
188
205
 
189
- // 0 indicates a successful result in SQLite.
190
- // See https://www.sqlite.org/c3ref/c_abort.html
191
- if (vfsResultCode !== 0) {
192
- return yield* new SqliteVfsError({
193
- operation: 'jDelete',
194
- fileName,
195
- vfsResultCode,
196
- })
206
+ if (supportsCreateWritable === true) {
207
+ yield* Opfs.writeFile(archivePath, archiveData)
208
+ } else {
209
+ yield* Opfs.syncWriteFile(archivePath, archiveData)
197
210
  }
211
+ }
198
212
 
199
- deletedCount++
200
- yield* Effect.logDebug(`Successfully deleted old state database file: ${fileName}`)
213
+ const vfsResultCode = yield* Effect.try({
214
+ try: () => vfs.jDelete(fileName, 0),
215
+ catch: (cause) =>
216
+ new PersistedSqliteError({ message: `Failed to delete old state database file: ${fileName}`, cause }),
217
+ })
218
+
219
+ // 0 indicates a successful result in SQLite.
220
+ // See https://www.sqlite.org/c3ref/c_abort.html
221
+ if (vfsResultCode !== 0) {
222
+ return yield* new PersistedSqliteError({
223
+ message: `Failed to delete old state database file: ${fileName}, got result code: ${vfsResultCode}`,
224
+ })
201
225
  }
202
226
 
203
- yield* Effect.logDebug(`State db cleanup completed: removed ${deletedCount} old database file(s)`)
204
- },
205
- Effect.mapError(
206
- (error) =>
207
- new PersistedSqliteError({
208
- message: 'Failed to clean up old state database file(s)',
209
- cause: error,
210
- }),
211
- ),
212
- )
227
+ yield* Effect.logDebug(`Deleted old state database file: ${fileName}`)
228
+ }
213
229
 
214
- export class SqliteVfsError extends Schema.TaggedError<SqliteVfsError>()('SqliteVfsError', {
215
- operation: Schema.String,
216
- fileName: Schema.String,
217
- vfsResultCode: Schema.optional(Schema.Number),
218
- cause: Schema.optional(Schema.Defect),
219
- }) {}
230
+ if (isDev === true) {
231
+ yield* pruneArchiveDirectory({
232
+ archiveDirectory: absoluteArchiveDirName,
233
+ keep: MAX_ARCHIVED_STATE_DBS_IN_DEV,
234
+ })
235
+ }
236
+ })
237
+
238
+ const pruneArchiveDirectory = Effect.fn('@livestore/adapter-web:pruneArchiveDirectory')(function* ({
239
+ archiveDirectory,
240
+ keep,
241
+ }: {
242
+ archiveDirectory: string
243
+ keep: number
244
+ }) {
245
+ const archiveDirHandle = yield* Opfs.getDirectoryHandleByPath(archiveDirectory)
246
+ const handlesStream = yield* Opfs.Opfs.values(archiveDirHandle)
247
+ const filesWithMetadata = yield* handlesStream.pipe(
248
+ Stream.filter((handle): handle is FileSystemFileHandle => handle.kind === 'file'),
249
+ Stream.mapEffect((fileHandle) => Opfs.getMetadata(fileHandle)),
250
+ Stream.runCollect,
251
+ )
252
+ const filesToDelete = filesWithMetadata.pipe(
253
+ // oxlint-disable-next-line unicorn/no-array-sort -- false positive: Effect Chunk.sort is immutable, not Array#sort (https://github.com/oxc-project/oxc/issues/19110)
254
+ Chunk.sort(Order.mapInput(Order.number, (entry: { lastModified: number }) => entry.lastModified)),
255
+ Chunk.drop(keep),
256
+ Chunk.toReadonlyArray,
257
+ )
258
+
259
+ if (filesToDelete.length === 0) return
260
+
261
+ yield* Effect.forEach(filesToDelete, ({ name }) => Opfs.Opfs.removeEntry(archiveDirHandle, name))
262
+
263
+ yield* Effect.logDebug(`Pruned ${filesToDelete.length} old database file(s) from archive directory`)
264
+ })
@@ -1,8 +1,15 @@
1
1
  import { ShutdownChannel } from '@livestore/common/leader-thread'
2
- import { WebChannel } from '@livestore/utils/effect'
2
+ import type { Effect, Scope, WebChannel } from '@livestore/utils/effect'
3
+ import { WebChannelBrowser } from '@livestore/utils/effect/browser'
3
4
 
4
- export const makeShutdownChannel = (storeId: string) =>
5
- WebChannel.broadcastChannel({
5
+ export const makeShutdownChannel = (
6
+ storeId: string,
7
+ ): Effect.Effect<
8
+ WebChannel.WebChannel<typeof ShutdownChannel.All.Type, typeof ShutdownChannel.All.Type>,
9
+ never,
10
+ Scope.Scope
11
+ > =>
12
+ WebChannelBrowser.broadcastChannel({
6
13
  channelName: `livestore.shutdown.${storeId}`,
7
14
  schema: ShutdownChannel.All,
8
15
  })
@@ -1,10 +1,17 @@
1
- import { Schema, WebChannel } from '@livestore/utils/effect'
1
+ import { type Effect, Schema, type Scope, type WebChannel } from '@livestore/utils/effect'
2
+ import { WebChannelBrowser } from '@livestore/utils/effect/browser'
2
3
 
3
4
  export class DedicatedWorkerDisconnectBroadcast extends Schema.TaggedStruct('DedicatedWorkerDisconnectBroadcast', {}) {}
4
5
 
5
6
  /** Used across workers for leader election purposes */
6
- export const makeWorkerDisconnectChannel = (storeId: string) =>
7
- WebChannel.broadcastChannel({
7
+ export const makeWorkerDisconnectChannel = (
8
+ storeId: string,
9
+ ): Effect.Effect<
10
+ WebChannel.WebChannel<typeof DedicatedWorkerDisconnectBroadcast.Type, typeof DedicatedWorkerDisconnectBroadcast.Type>,
11
+ never,
12
+ Scope.Scope
13
+ > =>
14
+ WebChannelBrowser.broadcastChannel({
8
15
  channelName: `livestore.worker-disconnect.${storeId}`,
9
16
  schema: DedicatedWorkerDisconnectBroadcast,
10
17
  })