@livestore/adapter-web 0.4.0-dev.18 → 0.4.0-dev.19

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 (50) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/in-memory/in-memory-adapter.d.ts +2 -2
  3. package/dist/in-memory/in-memory-adapter.d.ts.map +1 -1
  4. package/dist/in-memory/in-memory-adapter.js +6 -5
  5. package/dist/in-memory/in-memory-adapter.js.map +1 -1
  6. package/dist/web-worker/client-session/client-session-devtools.d.ts +1 -1
  7. package/dist/web-worker/client-session/client-session-devtools.d.ts.map +1 -1
  8. package/dist/web-worker/client-session/client-session-devtools.js +3 -2
  9. package/dist/web-worker/client-session/client-session-devtools.js.map +1 -1
  10. package/dist/web-worker/client-session/persisted-adapter.d.ts.map +1 -1
  11. package/dist/web-worker/client-session/persisted-adapter.js +27 -23
  12. package/dist/web-worker/client-session/persisted-adapter.js.map +1 -1
  13. package/dist/web-worker/common/persisted-sqlite.d.ts +11 -29
  14. package/dist/web-worker/common/persisted-sqlite.d.ts.map +1 -1
  15. package/dist/web-worker/common/persisted-sqlite.js +70 -184
  16. package/dist/web-worker/common/persisted-sqlite.js.map +1 -1
  17. package/dist/web-worker/common/shutdown-channel.d.ts +3 -2
  18. package/dist/web-worker/common/shutdown-channel.d.ts.map +1 -1
  19. package/dist/web-worker/common/shutdown-channel.js +2 -2
  20. package/dist/web-worker/common/shutdown-channel.js.map +1 -1
  21. package/dist/web-worker/common/worker-disconnect-channel.d.ts +2 -6
  22. package/dist/web-worker/common/worker-disconnect-channel.d.ts.map +1 -1
  23. package/dist/web-worker/common/worker-disconnect-channel.js +3 -2
  24. package/dist/web-worker/common/worker-disconnect-channel.js.map +1 -1
  25. package/dist/web-worker/common/worker-schema.d.ts +69 -46
  26. package/dist/web-worker/common/worker-schema.d.ts.map +1 -1
  27. package/dist/web-worker/common/worker-schema.js +20 -20
  28. package/dist/web-worker/common/worker-schema.js.map +1 -1
  29. package/dist/web-worker/leader-worker/make-leader-worker.d.ts.map +1 -1
  30. package/dist/web-worker/leader-worker/make-leader-worker.js +19 -16
  31. package/dist/web-worker/leader-worker/make-leader-worker.js.map +1 -1
  32. package/dist/web-worker/shared-worker/make-shared-worker.d.ts.map +1 -1
  33. package/dist/web-worker/shared-worker/make-shared-worker.js +10 -9
  34. package/dist/web-worker/shared-worker/make-shared-worker.js.map +1 -1
  35. package/package.json +7 -8
  36. package/src/in-memory/in-memory-adapter.ts +8 -16
  37. package/src/web-worker/ambient.d.ts +0 -20
  38. package/src/web-worker/client-session/client-session-devtools.ts +3 -2
  39. package/src/web-worker/client-session/persisted-adapter.ts +35 -27
  40. package/src/web-worker/common/persisted-sqlite.ts +186 -299
  41. package/src/web-worker/common/shutdown-channel.ts +10 -3
  42. package/src/web-worker/common/worker-disconnect-channel.ts +10 -3
  43. package/src/web-worker/common/worker-schema.ts +21 -21
  44. package/src/web-worker/leader-worker/make-leader-worker.ts +22 -23
  45. package/src/web-worker/shared-worker/make-shared-worker.ts +14 -15
  46. package/dist/opfs-utils.d.ts +0 -7
  47. package/dist/opfs-utils.d.ts.map +0 -1
  48. package/dist/opfs-utils.js +0 -43
  49. package/dist/opfs-utils.js.map +0 -1
  50. package/src/opfs-utils.ts +0 -61
@@ -1,142 +1,119 @@
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'
3
+ import {
4
+ decodeAccessHandlePoolFilename,
5
+ HEADER_OFFSET_DATA,
6
+ type WebDatabaseMetadataOpfs,
7
+ } from '@livestore/sqlite-wasm/browser'
4
8
  import { isDevEnv } from '@livestore/utils'
5
- import { Effect, Schedule, Schema } from '@livestore/utils/effect'
6
-
7
- import * as OpfsUtils from '../../opfs-utils.ts'
9
+ import { Chunk, Effect, Option, Order, Schedule, Schema, Stream } from '@livestore/utils/effect'
10
+ import { Opfs, type WebError } from '@livestore/utils/effect/browser'
8
11
  import type * as WorkerSchema from './worker-schema.ts'
9
12
 
10
13
  export class PersistedSqliteError extends Schema.TaggedError<PersistedSqliteError>()('PersistedSqliteError', {
11
14
  message: Schema.String,
12
- cause: Schema.Defect,
15
+ cause: Schema.optional(Schema.Defect),
13
16
  }) {}
14
17
 
15
- export const readPersistedAppDbFromClientSession = ({
16
- storageOptions,
17
- storeId,
18
- schema,
19
- }: {
18
+ export const readPersistedStateDbFromClientSession: (args: {
20
19
  storageOptions: WorkerSchema.StorageType
21
20
  storeId: string
22
21
  schema: LiveStoreSchema
23
- }) =>
24
- Effect.promise(async () => {
25
- const directory = sanitizeOpfsDir(storageOptions.directory, storeId)
26
- const sahPoolOpaqueDir = await OpfsUtils.getDirHandle(directory).catch(() => undefined)
27
-
28
- if (sahPoolOpaqueDir === undefined) {
29
- return undefined
30
- }
31
-
32
- const tryGetDbFile = async (fileHandle: FileSystemFileHandle) => {
33
- const file = await fileHandle.getFile()
34
- const fileName = await decodeSAHPoolFilename(file)
35
- return fileName ? { fileName, file } : undefined
36
- }
22
+ }) => Effect.Effect<
23
+ Uint8Array<ArrayBuffer>,
24
+ // All the following errors could actually happen:
25
+ | PersistedSqliteError
26
+ | WebError.UnknownError
27
+ | WebError.TypeError
28
+ | WebError.NotFoundError
29
+ | WebError.NotAllowedError
30
+ | WebError.TypeMismatchError
31
+ | WebError.SecurityError
32
+ | Opfs.OpfsError,
33
+ Opfs.Opfs
34
+ > = Effect.fn('@livestore/adapter-web:readPersistedStateDbFromClientSession')(
35
+ function* ({ storageOptions, storeId, schema }) {
36
+ const accessHandlePoolDirString = yield* sanitizeOpfsDir(storageOptions.directory, storeId)
37
+
38
+ const accessHandlePoolDirHandle = yield* Opfs.getDirectoryHandleByPath(accessHandlePoolDirString)
39
+
40
+ const stateDbFileName = `/${getStateDbFileName(schema)}`
41
+
42
+ const handlesStream = yield* Opfs.Opfs.values(accessHandlePoolDirHandle)
43
+
44
+ const stateDbFileOption = yield* handlesStream.pipe(
45
+ Stream.filter((handle): handle is FileSystemFileHandle => handle.kind === 'file'),
46
+ Stream.mapEffect(
47
+ (fileHandle) =>
48
+ Effect.gen(function* () {
49
+ const file = yield* Opfs.Opfs.getFile(fileHandle)
50
+ const fileName = yield* Effect.promise(() => decodeAccessHandlePoolFilename(file))
51
+ return { file, fileName }
52
+ }),
53
+ { concurrency: 'unbounded' },
54
+ ),
55
+ Stream.find(({ fileName }) => fileName === stateDbFileName),
56
+ Stream.runHead,
57
+ )
37
58
 
38
- const getAllFiles = async (asyncIterator: AsyncIterable<FileSystemHandle>): Promise<FileSystemFileHandle[]> => {
39
- const results: FileSystemFileHandle[] = []
40
- for await (const value of asyncIterator) {
41
- if (value.kind === 'file') {
42
- results.push(value as FileSystemFileHandle)
43
- }
44
- }
45
- return results
59
+ if (Option.isNone(stateDbFileOption)) {
60
+ return yield* new PersistedSqliteError({
61
+ message: `State database file not found in client session (expected '${stateDbFileName}' in '${accessHandlePoolDirString}')`,
62
+ })
46
63
  }
47
64
 
48
- const files = await getAllFiles(sahPoolOpaqueDir.values())
49
-
50
- const fileResults = await Promise.all(files.map(tryGetDbFile))
51
-
52
- const appDbFileName = `/${getStateDbFileName(schema)}`
53
-
54
- const dbFileRes = fileResults.find((_) => _?.fileName === appDbFileName)
55
- // console.debug('fileResults', fileResults, 'dbFileRes', dbFileRes)
56
-
57
- if (dbFileRes !== undefined) {
58
- const data = await dbFileRes.file.slice(HEADER_OFFSET_DATA).arrayBuffer()
59
- // console.debug('readPersistedAppDbFromClientSession', data.byteLength, data)
60
-
61
- // Given the SAH pool always eagerly creates files with empty non-header data,
62
- // we want to return undefined if the file exists but is empty
63
- if (data.byteLength === 0) {
64
- return undefined
65
- }
65
+ const stateDbBuffer = yield* Effect.promise(() =>
66
+ stateDbFileOption.value.file.slice(HEADER_OFFSET_DATA).arrayBuffer(),
67
+ )
66
68
 
67
- return new Uint8Array(data)
69
+ // Given the access handle pool always eagerly creates files with empty non-header data,
70
+ // we want to return undefined if the file exists but is empty
71
+ if (stateDbBuffer.byteLength === 0) {
72
+ return yield* new PersistedSqliteError({
73
+ message: `State database file is empty in client session (expected '${stateDbFileName}' in '${accessHandlePoolDirString}')`,
74
+ })
68
75
  }
69
76
 
70
- return undefined
71
- }).pipe(
72
- Effect.logWarnIfTakesLongerThan({
73
- duration: 1000,
74
- label: '@livestore/adapter-web:readPersistedAppDbFromClientSession',
75
- }),
76
- Effect.withPerformanceMeasure('@livestore/adapter-web:readPersistedAppDbFromClientSession'),
77
- Effect.withSpan('@livestore/adapter-web:readPersistedAppDbFromClientSession'),
78
- )
79
-
80
- export const resetPersistedDataFromClientSession = ({
81
- storageOptions,
82
- storeId,
83
- }: {
84
- storageOptions: WorkerSchema.StorageType
85
- storeId: string
86
- }) =>
87
- Effect.gen(function* () {
88
- const directory = sanitizeOpfsDir(storageOptions.directory, storeId)
89
- yield* opfsDeleteAbs(directory)
90
- }).pipe(
91
- Effect.retry({
92
- schedule: Schedule.exponentialBackoff10Sec,
93
- }),
94
- Effect.withSpan('@livestore/adapter-web:resetPersistedDataFromClientSession'),
95
- )
77
+ return new Uint8Array(stateDbBuffer)
78
+ },
79
+ Effect.logWarnIfTakesLongerThan({
80
+ duration: 1000,
81
+ label: '@livestore/adapter-web:readPersistedStateDbFromClientSession',
82
+ }),
83
+ Effect.withPerformanceMeasure('@livestore/adapter-web:readPersistedStateDbFromClientSession'),
84
+ )
96
85
 
97
- const opfsDeleteAbs = (absPath: string) =>
98
- Effect.promise(async () => {
99
- // Get the root directory handle
100
- const root = await OpfsUtils.rootHandlePromise
101
-
102
- // Split the absolute path to traverse directories
103
- const pathParts = absPath.split('/').filter((part) => part.length)
104
-
105
- try {
106
- // Traverse to the target file handle
107
- let currentDir = root
108
- for (let i = 0; i < pathParts.length - 1; i++) {
109
- currentDir = await currentDir.getDirectoryHandle(pathParts[i]!)
110
- }
111
-
112
- // Delete the file
113
- await currentDir.removeEntry(pathParts.at(-1)!, { recursive: true })
114
- } catch (error) {
115
- if (error instanceof DOMException && error.name === 'NotFoundError') {
116
- // Can ignore as it's already been deleted or not there in the first place
117
- return
118
- } else {
119
- throw error
120
- }
121
- }
122
- }).pipe(
123
- UnexpectedError.mapToUnexpectedError,
124
- Effect.withSpan('@livestore/adapter-web:worker:opfsDeleteFile', { attributes: { absFilePath: absPath } }),
125
- )
86
+ export const resetPersistedDataFromClientSession = Effect.fn(
87
+ '@livestore/adapter-web:resetPersistedDataFromClientSession',
88
+ )(
89
+ function* ({ storageOptions, storeId }: { storageOptions: WorkerSchema.StorageType; storeId: string }) {
90
+ const directory = yield* sanitizeOpfsDir(storageOptions.directory, storeId)
91
+ yield* Opfs.remove(directory).pipe(
92
+ // We ignore NotFoundError here as it may not exist or have already been deleted
93
+ Effect.catchTag('@livestore/utils/Web/NotFoundError', () => Effect.void),
94
+ )
95
+ },
96
+ Effect.retry({
97
+ schedule: Schedule.exponentialBackoff10Sec,
98
+ }),
99
+ )
126
100
 
127
- export const sanitizeOpfsDir = (directory: string | undefined, storeId: string) => {
128
- // Root dir should be `''` not `/`
129
- if (directory === undefined || directory === '' || directory === '/')
101
+ export const sanitizeOpfsDir = Effect.fn('@livestore/adapter-web:sanitizeOpfsDir')(function* (
102
+ directory: string | undefined,
103
+ storeId: string,
104
+ ) {
105
+ if (directory === undefined || directory === '' || directory === '/') {
130
106
  return `livestore-${storeId}@${liveStoreStorageFormatVersion}`
107
+ }
131
108
 
132
109
  if (directory.includes('/')) {
133
- throw new Error(
134
- `@livestore/adapter-web:worker:sanitizeOpfsDir: Nested directories are not yet supported ('${directory}')`,
135
- )
110
+ return yield* new PersistedSqliteError({
111
+ message: `Nested directories are not yet supported ('${directory}')`,
112
+ })
136
113
  }
137
114
 
138
115
  return `${directory}@${liveStoreStorageFormatVersion}`
139
- }
116
+ })
140
117
 
141
118
  export const getStateDbFileName = (schema: LiveStoreSchema) => {
142
119
  const schemaHashSuffix =
@@ -145,6 +122,7 @@ export const getStateDbFileName = (schema: LiveStoreSchema) => {
145
122
  }
146
123
 
147
124
  export const MAX_ARCHIVED_STATE_DBS_IN_DEV = 3
125
+ export const ARCHIVE_DIR_NAME = 'archive'
148
126
 
149
127
  /**
150
128
  * Cleanup old state database files after successful migration.
@@ -153,210 +131,119 @@ export const MAX_ARCHIVED_STATE_DBS_IN_DEV = 3
153
131
  * @param vfs - The AccessHandlePoolVFS instance for safe file operations
154
132
  * @param currentSchema - Current schema (to avoid deleting the active database)
155
133
  */
156
- export const cleanupOldStateDbFiles = Effect.fn('@livestore/adapter-web:cleanupOldStateDbFiles')(
157
- function* ({
158
- vfs,
159
- currentSchema,
160
- opfsDirectory,
161
- }: {
162
- vfs: WebDatabaseMetadataOpfs['vfs']
163
- currentSchema: LiveStoreSchema
164
- opfsDirectory: string
165
- }) {
166
- // Only cleanup for auto migration strategy because:
167
- // - Auto strategy: Creates new database files per schema change (e.g., state123.db, state456.db)
168
- // which accumulate over time and can exhaust OPFS file pool capacity
169
- // - Manual strategy: Always reuses the same database file (statefixed.db) across schema changes,
170
- // so there are never multiple old files to clean up
171
- if (currentSchema.state.sqlite.migrations.strategy === 'manual') {
172
- yield* Effect.logDebug('Skipping state db cleanup - manual migration strategy uses fixed filename')
173
- return
174
- }
175
-
176
- const isDev = isDevEnv()
177
- const currentDbFileName = getStateDbFileName(currentSchema)
178
- const currentPath = `/${currentDbFileName}`
179
-
180
- const allPaths = yield* Effect.sync(() => vfs.getTrackedFilePaths())
181
- const oldStateDbPaths = allPaths.filter(
182
- (path) => path.startsWith('/state') && path.endsWith('.db') && path !== currentPath,
183
- )
184
-
185
- if (oldStateDbPaths.length === 0) {
186
- yield* Effect.logDebug('State db cleanup completed: no old database files found')
187
- return
188
- }
134
+ export const cleanupOldStateDbFiles: (options: {
135
+ vfs: WebDatabaseMetadataOpfs['vfs']
136
+ currentSchema: LiveStoreSchema
137
+ opfsDirectory: string
138
+ }) => Effect.Effect<
139
+ void,
140
+ // All the following errors could actually happen:
141
+ | WebError.AbortError
142
+ | WebError.DataCloneError
143
+ | WebError.EvalError
144
+ | WebError.InvalidModificationError
145
+ | WebError.InvalidStateError
146
+ | WebError.NoModificationAllowedError
147
+ | WebError.NotAllowedError
148
+ | WebError.NotFoundError
149
+ | WebError.QuotaExceededError
150
+ | WebError.RangeError
151
+ | WebError.ReferenceError
152
+ | WebError.SecurityError
153
+ | WebError.TypeError
154
+ | WebError.TypeMismatchError
155
+ | WebError.URIError
156
+ | WebError.UnknownError
157
+ | Opfs.OpfsError
158
+ | PersistedSqliteError,
159
+ Opfs.Opfs
160
+ > = Effect.fn('@livestore/adapter-web:cleanupOldStateDbFiles')(function* ({ vfs, currentSchema, opfsDirectory }) {
161
+ // Only cleanup for auto migration strategy because:
162
+ // - Auto strategy: Creates new database files per schema change (e.g., state123.db, state456.db)
163
+ // which accumulate over time and can exhaust OPFS file pool capacity
164
+ // - Manual strategy: Always reuses the same database file (statefixed.db) across schema changes,
165
+ // so there are never multiple old files to clean up
166
+ if (currentSchema.state.sqlite.migrations.strategy === 'manual') {
167
+ yield* Effect.logDebug('Skipping state db cleanup - manual migration strategy uses fixed filename')
168
+ return
169
+ }
189
170
 
190
- yield* Effect.logDebug(`Found ${oldStateDbPaths.length} old state database file(s) to clean up`)
171
+ const isDev = isDevEnv()
172
+ const currentDbFileName = getStateDbFileName(currentSchema)
173
+ const currentPath = `/${currentDbFileName}`
191
174
 
192
- let deletedCount = 0
193
- const archivedFileNames: string[] = []
194
- let archiveDirHandle: FileSystemDirectoryHandle | undefined
175
+ const allPaths = yield* Effect.sync(() => vfs.getTrackedFilePaths())
176
+ const oldStateDbPaths = allPaths.filter(
177
+ (path) => path.startsWith('/state') && path.endsWith('.db') && path !== currentPath,
178
+ )
195
179
 
196
- for (const path of oldStateDbPaths) {
197
- const fileName = path.startsWith('/') ? path.slice(1) : path
180
+ if (oldStateDbPaths.length === 0) {
181
+ yield* Effect.logDebug('No old database files found')
182
+ return
183
+ }
198
184
 
199
- if (isDev) {
200
- archiveDirHandle = yield* Effect.tryPromise({
201
- try: () => OpfsUtils.getDirHandle(`${opfsDirectory}/archive`, { create: true }),
202
- catch: (cause) => new ArchiveStateDbError({ message: 'Failed to ensure archive directory', cause }),
203
- })
185
+ const absoluteArchiveDirName = `${opfsDirectory}/${ARCHIVE_DIR_NAME}`
186
+ if (isDev && !(yield* Opfs.exists(absoluteArchiveDirName))) yield* Opfs.makeDirectory(absoluteArchiveDirName)
204
187
 
205
- const archivedFileName = yield* archiveStateDbFile({
206
- vfs,
207
- fileName,
208
- archiveDirHandle,
209
- })
188
+ for (const path of oldStateDbPaths) {
189
+ const fileName = path.startsWith('/') ? path.slice(1) : path
210
190
 
211
- archivedFileNames.push(archivedFileName)
212
- }
191
+ if (isDev) {
192
+ const archiveFileData = yield* vfs.readFilePayload(fileName)
213
193
 
214
- const vfsResultCode = yield* Effect.try({
215
- try: () => vfs.jDelete(fileName, 0),
216
- catch: (cause) => new SqliteVfsError({ operation: 'jDelete', fileName, cause }),
217
- })
194
+ const archiveFileName = `${Date.now()}-${fileName}`
218
195
 
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 SqliteVfsError({
223
- operation: 'jDelete',
224
- fileName,
225
- vfsResultCode,
226
- })
227
- }
228
-
229
- deletedCount++
230
- yield* Effect.logDebug(`Successfully deleted old state database file: ${fileName}`)
196
+ yield* Opfs.writeFile(`${opfsDirectory}/archive/${archiveFileName}`, new Uint8Array(archiveFileData))
231
197
  }
232
198
 
233
- if (isDev && archiveDirHandle !== undefined) {
234
- const pruneResult = yield* pruneArchiveDir({
235
- archiveDirHandle,
236
- keep: MAX_ARCHIVED_STATE_DBS_IN_DEV,
199
+ const vfsResultCode = yield* Effect.try({
200
+ try: () => vfs.jDelete(fileName, 0),
201
+ catch: (cause) =>
202
+ new PersistedSqliteError({ message: `Failed to delete old state database file: ${fileName}`, cause }),
203
+ })
204
+
205
+ // 0 indicates a successful result in SQLite.
206
+ // See https://www.sqlite.org/c3ref/c_abort.html
207
+ if (vfsResultCode !== 0) {
208
+ return yield* new PersistedSqliteError({
209
+ message: `Failed to delete old state database file: ${fileName}, got result code: ${vfsResultCode}`,
237
210
  })
238
-
239
- yield* Effect.logDebug(
240
- `State db cleanup completed: archived ${archivedFileNames.length} file(s); removed ${deletedCount} old database file(s) from active pool; archive retained ${pruneResult.retained.length} file(s)`,
241
- )
242
- } else {
243
- yield* Effect.logDebug(`State db cleanup completed: removed ${deletedCount} old database file(s)`)
244
211
  }
245
- },
246
- Effect.mapError(
247
- (error) =>
248
- new PersistedSqliteError({
249
- message: 'Failed to clean up old state database file(s)',
250
- cause: error,
251
- }),
252
- ),
253
- )
254
212
 
255
- const archiveStateDbFile = Effect.fn('@livestore/adapter-web:archiveStateDbFile')(function* ({
256
- vfs,
257
- fileName,
258
- archiveDirHandle,
259
- }: {
260
- vfs: WebDatabaseMetadataOpfs['vfs']
261
- fileName: string
262
- archiveDirHandle: FileSystemDirectoryHandle
263
- }) {
264
- const stateDbBuffer = vfs.readFilePayload(fileName)
265
-
266
- const archiveFileName = `${Date.now()}-${fileName}`
267
-
268
- const archiveFileHandle = yield* Effect.tryPromise({
269
- try: () => archiveDirHandle.getFileHandle(archiveFileName, { create: true }),
270
- catch: (cause) =>
271
- new ArchiveStateDbError({
272
- message: 'Failed to open archive file handle',
273
- fileName: archiveFileName,
274
- cause,
275
- }),
276
- })
277
-
278
- const archiveFileAccessHandle = yield* Effect.acquireRelease(
279
- Effect.tryPromise({
280
- try: () => archiveFileHandle.createSyncAccessHandle(),
281
- catch: (cause) =>
282
- new ArchiveStateDbError({
283
- message: 'Failed to create sync access handle for archived file',
284
- fileName: archiveFileName,
285
- cause,
286
- }),
287
- }),
288
- (handle) => Effect.sync(() => handle.close()).pipe(Effect.ignoreLogged),
289
- )
213
+ yield* Effect.logDebug(`Deleted old state database file: ${fileName}`)
214
+ }
290
215
 
291
- yield* Effect.try({
292
- try: () => {
293
- archiveFileAccessHandle.write(stateDbBuffer)
294
- archiveFileAccessHandle.flush()
295
- },
296
- catch: (cause) =>
297
- new ArchiveStateDbError({
298
- message: 'Failed to write archived state database',
299
- fileName: archiveFileName,
300
- cause,
301
- }),
302
- })
303
-
304
- return archiveFileName
305
- }, Effect.scoped)
306
-
307
- const pruneArchiveDir = Effect.fn('@livestore/adapter-web:pruneArchiveDir')(function* ({
308
- archiveDirHandle,
216
+ if (isDev) {
217
+ yield* pruneArchiveDirectory({
218
+ archiveDirectory: absoluteArchiveDirName,
219
+ keep: MAX_ARCHIVED_STATE_DBS_IN_DEV,
220
+ })
221
+ }
222
+ })
223
+
224
+ const pruneArchiveDirectory = Effect.fn('@livestore/adapter-web:pruneArchiveDirectory')(function* ({
225
+ archiveDirectory,
309
226
  keep,
310
227
  }: {
311
- archiveDirHandle: FileSystemDirectoryHandle
228
+ archiveDirectory: string
312
229
  keep: number
313
230
  }) {
314
- const files = yield* Effect.tryPromise({
315
- try: async () => {
316
- const result: { name: string; lastModified: number }[] = []
317
-
318
- for await (const entry of archiveDirHandle.values()) {
319
- if (entry.kind !== 'file') continue
320
- const fileHandle = await archiveDirHandle.getFileHandle(entry.name)
321
- const file = await fileHandle.getFile()
322
- result.push({ name: entry.name, lastModified: file.lastModified })
323
- }
324
-
325
- return result.sort((a, b) => b.lastModified - a.lastModified)
326
- },
327
- catch: (cause) => new ArchiveStateDbError({ message: 'Failed to enumerate archived state databases', cause }),
328
- })
329
-
330
- const retained = files.slice(0, keep)
331
- const toDelete = files.slice(keep)
332
-
333
- yield* Effect.forEach(toDelete, ({ name }) =>
334
- Effect.tryPromise({
335
- try: () => archiveDirHandle.removeEntry(name),
336
- catch: (cause) =>
337
- new ArchiveStateDbError({
338
- message: 'Failed to delete archived state database',
339
- fileName: name,
340
- cause,
341
- }),
342
- }),
231
+ const archiveDirHandle = yield* Opfs.getDirectoryHandleByPath(archiveDirectory)
232
+ const handlesStream = yield* Opfs.Opfs.values(archiveDirHandle)
233
+ const filesWithMetadata = yield* handlesStream.pipe(
234
+ Stream.filter((handle): handle is FileSystemFileHandle => handle.kind === 'file'),
235
+ Stream.mapEffect((fileHandle) => Opfs.getMetadata(fileHandle)),
236
+ Stream.runCollect,
237
+ )
238
+ const filesToDelete = filesWithMetadata.pipe(
239
+ Chunk.sort(Order.mapInput(Order.number, (entry: { lastModified: number }) => entry.lastModified)),
240
+ Chunk.drop(keep),
241
+ Chunk.toReadonlyArray,
343
242
  )
344
243
 
345
- return {
346
- retained,
347
- deleted: toDelete,
348
- }
349
- })
244
+ if (filesToDelete.length === 0) return
350
245
 
351
- export class ArchiveStateDbError extends Schema.TaggedError<ArchiveStateDbError>()('ArchiveStateDbError', {
352
- message: Schema.String,
353
- fileName: Schema.optional(Schema.String),
354
- cause: Schema.Defect,
355
- }) {}
246
+ yield* Effect.forEach(filesToDelete, ({ name }) => Opfs.Opfs.removeEntry(archiveDirHandle, name))
356
247
 
357
- export class SqliteVfsError extends Schema.TaggedError<SqliteVfsError>()('SqliteVfsError', {
358
- operation: Schema.String,
359
- fileName: Schema.String,
360
- vfsResultCode: Schema.optional(Schema.Number),
361
- cause: Schema.optional(Schema.Defect),
362
- }) {}
248
+ yield* Effect.logDebug(`Pruned ${filesToDelete.length} old database file(s) from archive directory`)
249
+ })
@@ -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
  })