@livestore/adapter-web 0.4.0-dev.9 → 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 +11 -29
  32. package/dist/web-worker/common/persisted-sqlite.d.ts.map +1 -1
  33. package/dist/web-worker/common/persisted-sqlite.js +87 -188
  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 +98 -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 +200 -298
  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 +148 -71
  69. package/src/web-worker/shared-worker/make-shared-worker.ts +78 -90
  70. package/dist/opfs-utils.d.ts +0 -7
  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,142 +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'
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'
9
+ import { Chunk, Effect, Option, Order, Schedule, Schema, Stream } from '@livestore/utils/effect'
10
+ import { Opfs, type WebError } from '@livestore/utils/effect/browser'
6
11
 
7
- import * as OpfsUtils from '../../opfs-utils.ts'
8
12
  import type * as WorkerSchema from './worker-schema.ts'
9
13
 
10
- export class PersistedSqliteError extends Schema.TaggedError<PersistedSqliteError>()('PersistedSqliteError', {
14
+ export class PersistedSqliteError extends Schema.TaggedError<PersistedSqliteError>('~@livestore/adapter-web/PersistedSqliteError')('PersistedSqliteError', {
11
15
  message: Schema.String,
12
- cause: Schema.Defect,
16
+ cause: Schema.optional(Schema.Defect),
13
17
  }) {}
14
18
 
15
- export const readPersistedAppDbFromClientSession = ({
16
- storageOptions,
17
- storeId,
18
- schema,
19
- }: {
19
+ export const readPersistedStateDbFromClientSession: (args: {
20
20
  storageOptions: WorkerSchema.StorageType
21
21
  storeId: string
22
22
  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
- }
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
+ )
37
59
 
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
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
+ })
46
64
  }
47
65
 
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
- }
66
+ const stateDbBuffer = yield* Effect.promise(() =>
67
+ stateDbFileOption.value.file.slice(HEADER_OFFSET_DATA).arrayBuffer(),
68
+ )
66
69
 
67
- 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
+ })
68
76
  }
69
77
 
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
- )
96
-
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
- }
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
+ )
111
86
 
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
- )
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
+ )
126
101
 
127
- export const sanitizeOpfsDir = (directory: string | undefined, storeId: string) => {
128
- // Root dir should be `''` not `/`
129
- 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 === '/') {
130
107
  return `livestore-${storeId}@${liveStoreStorageFormatVersion}`
108
+ }
131
109
 
132
- if (directory.includes('/')) {
133
- throw new Error(
134
- `@livestore/adapter-web:worker:sanitizeOpfsDir: Nested directories are not yet supported ('${directory}')`,
135
- )
110
+ if (directory.includes('/') === true) {
111
+ return yield* new PersistedSqliteError({
112
+ message: `Nested directories are not yet supported ('${directory}')`,
113
+ })
136
114
  }
137
115
 
138
116
  return `${directory}@${liveStoreStorageFormatVersion}`
139
- }
117
+ })
140
118
 
141
119
  export const getStateDbFileName = (schema: LiveStoreSchema) => {
142
120
  const schemaHashSuffix =
@@ -145,6 +123,7 @@ export const getStateDbFileName = (schema: LiveStoreSchema) => {
145
123
  }
146
124
 
147
125
  export const MAX_ARCHIVED_STATE_DBS_IN_DEV = 3
126
+ export const ARCHIVE_DIR_NAME = 'archive'
148
127
 
149
128
  /**
150
129
  * Cleanup old state database files after successful migration.
@@ -153,210 +132,133 @@ export const MAX_ARCHIVED_STATE_DBS_IN_DEV = 3
153
132
  * @param vfs - The AccessHandlePoolVFS instance for safe file operations
154
133
  * @param currentSchema - Current schema (to avoid deleting the active database)
155
134
  */
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
- )
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
+ }
184
171
 
185
- if (oldStateDbPaths.length === 0) {
186
- yield* Effect.logDebug('State db cleanup completed: no old database files found')
187
- return
188
- }
172
+ const isDev = isDevEnv()
173
+ const currentDbFileName = getStateDbFileName(currentSchema)
174
+ const currentPath = `/${currentDbFileName}`
189
175
 
190
- yield* Effect.logDebug(`Found ${oldStateDbPaths.length} old state database file(s) to clean up`)
176
+ const allPaths = yield* Effect.sync(() => vfs.getTrackedFilePaths())
177
+ const oldStateDbPaths = allPaths.filter(
178
+ (path) => path.startsWith('/state') && path.endsWith('.db') && path !== currentPath,
179
+ )
191
180
 
192
- let deletedCount = 0
193
- const archivedFileNames: string[] = []
194
- let archiveDirHandle: FileSystemDirectoryHandle | undefined
181
+ if (oldStateDbPaths.length === 0) {
182
+ yield* Effect.logDebug('No old database files found')
183
+ return
184
+ }
195
185
 
196
- for (const path of oldStateDbPaths) {
197
- const fileName = path.startsWith('/') ? path.slice(1) : path
186
+ const absoluteArchiveDirName = `${opfsDirectory}/${ARCHIVE_DIR_NAME}`
187
+ if (isDev === true && (yield* Opfs.exists(absoluteArchiveDirName)) === false)
188
+ yield* Opfs.makeDirectory(absoluteArchiveDirName)
198
189
 
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
- })
190
+ for (const path of oldStateDbPaths) {
191
+ const fileName = path.startsWith('/') === true ? path.slice(1) : path
204
192
 
205
- const archivedFileName = yield* archiveStateDbFile({
206
- vfs,
207
- fileName,
208
- archiveDirHandle,
209
- })
193
+ if (isDev === true) {
194
+ const archiveFileData = yield* vfs.readFilePayload(fileName)
210
195
 
211
- archivedFileNames.push(archivedFileName)
212
- }
196
+ const archiveFileName = `${Date.now()}-${fileName}`
197
+ const archivePath = `${opfsDirectory}/archive/${archiveFileName}`
198
+ const archiveData = new Uint8Array(archiveFileData)
213
199
 
214
- const vfsResultCode = yield* Effect.try({
215
- try: () => vfs.jDelete(fileName, 0),
216
- catch: (cause) => new SqliteVfsError({ operation: 'jDelete', fileName, cause }),
217
- })
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
218
205
 
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
- })
206
+ if (supportsCreateWritable === true) {
207
+ yield* Opfs.writeFile(archivePath, archiveData)
208
+ } else {
209
+ yield* Opfs.syncWriteFile(archivePath, archiveData)
227
210
  }
228
-
229
- deletedCount++
230
- yield* Effect.logDebug(`Successfully deleted old state database file: ${fileName}`)
231
211
  }
232
212
 
233
- if (isDev && archiveDirHandle !== undefined) {
234
- const pruneResult = yield* pruneArchiveDir({
235
- archiveDirHandle,
236
- keep: MAX_ARCHIVED_STATE_DBS_IN_DEV,
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}`,
237
224
  })
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
225
  }
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
226
 
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
- )
227
+ yield* Effect.logDebug(`Deleted old state database file: ${fileName}`)
228
+ }
229
+
230
+ if (isDev === true) {
231
+ yield* pruneArchiveDirectory({
232
+ archiveDirectory: absoluteArchiveDirName,
233
+ keep: MAX_ARCHIVED_STATE_DBS_IN_DEV,
234
+ })
235
+ }
236
+ })
290
237
 
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,
238
+ const pruneArchiveDirectory = Effect.fn('@livestore/adapter-web:pruneArchiveDirectory')(function* ({
239
+ archiveDirectory,
309
240
  keep,
310
241
  }: {
311
- archiveDirHandle: FileSystemDirectoryHandle
242
+ archiveDirectory: string
312
243
  keep: number
313
244
  }) {
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
- }),
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,
343
257
  )
344
258
 
345
- return {
346
- retained,
347
- deleted: toDelete,
348
- }
349
- })
259
+ if (filesToDelete.length === 0) return
350
260
 
351
- export class ArchiveStateDbError extends Schema.TaggedError<ArchiveStateDbError>()('ArchiveStateDbError', {
352
- message: Schema.String,
353
- fileName: Schema.optional(Schema.String),
354
- cause: Schema.Defect,
355
- }) {}
261
+ yield* Effect.forEach(filesToDelete, ({ name }) => Opfs.Opfs.removeEntry(archiveDirHandle, name))
356
262
 
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
- }) {}
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
  })