@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/in-memory/in-memory-adapter.d.ts +2 -2
- package/dist/in-memory/in-memory-adapter.d.ts.map +1 -1
- package/dist/in-memory/in-memory-adapter.js +6 -5
- package/dist/in-memory/in-memory-adapter.js.map +1 -1
- package/dist/web-worker/client-session/client-session-devtools.d.ts +1 -1
- package/dist/web-worker/client-session/client-session-devtools.d.ts.map +1 -1
- package/dist/web-worker/client-session/client-session-devtools.js +3 -2
- package/dist/web-worker/client-session/client-session-devtools.js.map +1 -1
- package/dist/web-worker/client-session/persisted-adapter.d.ts.map +1 -1
- package/dist/web-worker/client-session/persisted-adapter.js +27 -23
- package/dist/web-worker/client-session/persisted-adapter.js.map +1 -1
- package/dist/web-worker/common/persisted-sqlite.d.ts +11 -29
- package/dist/web-worker/common/persisted-sqlite.d.ts.map +1 -1
- package/dist/web-worker/common/persisted-sqlite.js +70 -184
- package/dist/web-worker/common/persisted-sqlite.js.map +1 -1
- package/dist/web-worker/common/shutdown-channel.d.ts +3 -2
- package/dist/web-worker/common/shutdown-channel.d.ts.map +1 -1
- package/dist/web-worker/common/shutdown-channel.js +2 -2
- package/dist/web-worker/common/shutdown-channel.js.map +1 -1
- package/dist/web-worker/common/worker-disconnect-channel.d.ts +2 -6
- package/dist/web-worker/common/worker-disconnect-channel.d.ts.map +1 -1
- package/dist/web-worker/common/worker-disconnect-channel.js +3 -2
- package/dist/web-worker/common/worker-disconnect-channel.js.map +1 -1
- package/dist/web-worker/common/worker-schema.d.ts +69 -46
- package/dist/web-worker/common/worker-schema.d.ts.map +1 -1
- package/dist/web-worker/common/worker-schema.js +20 -20
- package/dist/web-worker/common/worker-schema.js.map +1 -1
- package/dist/web-worker/leader-worker/make-leader-worker.d.ts.map +1 -1
- package/dist/web-worker/leader-worker/make-leader-worker.js +19 -16
- package/dist/web-worker/leader-worker/make-leader-worker.js.map +1 -1
- package/dist/web-worker/shared-worker/make-shared-worker.d.ts.map +1 -1
- package/dist/web-worker/shared-worker/make-shared-worker.js +10 -9
- package/dist/web-worker/shared-worker/make-shared-worker.js.map +1 -1
- package/package.json +7 -8
- package/src/in-memory/in-memory-adapter.ts +8 -16
- package/src/web-worker/ambient.d.ts +0 -20
- package/src/web-worker/client-session/client-session-devtools.ts +3 -2
- package/src/web-worker/client-session/persisted-adapter.ts +35 -27
- package/src/web-worker/common/persisted-sqlite.ts +186 -299
- package/src/web-worker/common/shutdown-channel.ts +10 -3
- package/src/web-worker/common/worker-disconnect-channel.ts +10 -3
- package/src/web-worker/common/worker-schema.ts +21 -21
- package/src/web-worker/leader-worker/make-leader-worker.ts +22 -23
- package/src/web-worker/shared-worker/make-shared-worker.ts +14 -15
- package/dist/opfs-utils.d.ts +0 -7
- package/dist/opfs-utils.d.ts.map +0 -1
- package/dist/opfs-utils.js +0 -43
- package/dist/opfs-utils.js.map +0 -1
- package/src/opfs-utils.ts +0 -61
|
@@ -1,142 +1,119 @@
|
|
|
1
|
-
import { liveStoreStorageFormatVersion
|
|
1
|
+
import { liveStoreStorageFormatVersion } from '@livestore/common'
|
|
2
2
|
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 = (
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
171
|
+
const isDev = isDevEnv()
|
|
172
|
+
const currentDbFileName = getStateDbFileName(currentSchema)
|
|
173
|
+
const currentPath = `/${currentDbFileName}`
|
|
191
174
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
180
|
+
if (oldStateDbPaths.length === 0) {
|
|
181
|
+
yield* Effect.logDebug('No old database files found')
|
|
182
|
+
return
|
|
183
|
+
}
|
|
198
184
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
fileName,
|
|
208
|
-
archiveDirHandle,
|
|
209
|
-
})
|
|
188
|
+
for (const path of oldStateDbPaths) {
|
|
189
|
+
const fileName = path.startsWith('/') ? path.slice(1) : path
|
|
210
190
|
|
|
211
|
-
|
|
212
|
-
|
|
191
|
+
if (isDev) {
|
|
192
|
+
const archiveFileData = yield* vfs.readFilePayload(fileName)
|
|
213
193
|
|
|
214
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
228
|
+
archiveDirectory: string
|
|
312
229
|
keep: number
|
|
313
230
|
}) {
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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 = (
|
|
5
|
-
|
|
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 = (
|
|
7
|
-
|
|
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
|
})
|