@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.
- package/README.md +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/in-memory/in-memory-adapter.d.ts +49 -5
- package/dist/in-memory/in-memory-adapter.d.ts.map +1 -1
- package/dist/in-memory/in-memory-adapter.js +77 -20
- package/dist/in-memory/in-memory-adapter.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/single-tab/mod.d.ts +15 -0
- package/dist/single-tab/mod.d.ts.map +1 -0
- package/dist/single-tab/mod.js +15 -0
- package/dist/single-tab/mod.js.map +1 -0
- package/dist/single-tab/single-tab-adapter.d.ts +108 -0
- package/dist/single-tab/single-tab-adapter.d.ts.map +1 -0
- package/dist/single-tab/single-tab-adapter.js +271 -0
- package/dist/single-tab/single-tab-adapter.js.map +1 -0
- package/dist/web-worker/client-session/client-session-devtools.d.ts +2 -2
- package/dist/web-worker/client-session/client-session-devtools.d.ts.map +1 -1
- package/dist/web-worker/client-session/client-session-devtools.js +20 -9
- package/dist/web-worker/client-session/client-session-devtools.js.map +1 -1
- package/dist/web-worker/client-session/persisted-adapter.d.ts +18 -0
- package/dist/web-worker/client-session/persisted-adapter.d.ts.map +1 -1
- package/dist/web-worker/client-session/persisted-adapter.js +141 -67
- package/dist/web-worker/client-session/persisted-adapter.js.map +1 -1
- package/dist/web-worker/client-session/sqlite-loader.d.ts +2 -0
- package/dist/web-worker/client-session/sqlite-loader.d.ts.map +1 -0
- package/dist/web-worker/client-session/sqlite-loader.js +16 -0
- package/dist/web-worker/client-session/sqlite-loader.js.map +1 -0
- 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 +87 -188
- 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 +152 -58
- package/dist/web-worker/common/worker-schema.d.ts.map +1 -1
- package/dist/web-worker/common/worker-schema.js +55 -37
- package/dist/web-worker/common/worker-schema.js.map +1 -1
- package/dist/web-worker/leader-worker/make-leader-worker.d.ts +5 -3
- package/dist/web-worker/leader-worker/make-leader-worker.d.ts.map +1 -1
- package/dist/web-worker/leader-worker/make-leader-worker.js +98 -38
- package/dist/web-worker/leader-worker/make-leader-worker.js.map +1 -1
- package/dist/web-worker/shared-worker/make-shared-worker.d.ts +2 -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 +62 -52
- package/dist/web-worker/shared-worker/make-shared-worker.js.map +1 -1
- package/package.json +56 -18
- package/src/in-memory/in-memory-adapter.ts +92 -26
- package/src/index.ts +15 -1
- package/src/single-tab/mod.ts +15 -0
- package/src/single-tab/single-tab-adapter.ts +499 -0
- package/src/web-worker/ambient.d.ts +7 -24
- package/src/web-worker/client-session/client-session-devtools.ts +32 -18
- package/src/web-worker/client-session/persisted-adapter.ts +199 -103
- package/src/web-worker/client-session/sqlite-loader.ts +19 -0
- package/src/web-worker/common/persisted-sqlite.ts +200 -298
- 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 +78 -38
- package/src/web-worker/leader-worker/make-leader-worker.ts +148 -71
- package/src/web-worker/shared-worker/make-shared-worker.ts +78 -90
- 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,120 @@
|
|
|
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'
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
}
|
|
66
|
+
const stateDbBuffer = yield* Effect.promise(() =>
|
|
67
|
+
stateDbFileOption.value.file.slice(HEADER_OFFSET_DATA).arrayBuffer(),
|
|
68
|
+
)
|
|
66
69
|
|
|
67
|
-
|
|
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
|
|
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
|
-
)
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 = (
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
172
|
+
const isDev = isDevEnv()
|
|
173
|
+
const currentDbFileName = getStateDbFileName(currentSchema)
|
|
174
|
+
const currentPath = `/${currentDbFileName}`
|
|
189
175
|
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
181
|
+
if (oldStateDbPaths.length === 0) {
|
|
182
|
+
yield* Effect.logDebug('No old database files found')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
195
185
|
|
|
196
|
-
|
|
197
|
-
|
|
186
|
+
const absoluteArchiveDirName = `${opfsDirectory}/${ARCHIVE_DIR_NAME}`
|
|
187
|
+
if (isDev === true && (yield* Opfs.exists(absoluteArchiveDirName)) === false)
|
|
188
|
+
yield* Opfs.makeDirectory(absoluteArchiveDirName)
|
|
198
189
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
fileName,
|
|
208
|
-
archiveDirHandle,
|
|
209
|
-
})
|
|
193
|
+
if (isDev === true) {
|
|
194
|
+
const archiveFileData = yield* vfs.readFilePayload(fileName)
|
|
210
195
|
|
|
211
|
-
|
|
212
|
-
}
|
|
196
|
+
const archiveFileName = `${Date.now()}-${fileName}`
|
|
197
|
+
const archivePath = `${opfsDirectory}/archive/${archiveFileName}`
|
|
198
|
+
const archiveData = new Uint8Array(archiveFileData)
|
|
213
199
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
242
|
+
archiveDirectory: string
|
|
312
243
|
keep: number
|
|
313
244
|
}) {
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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 = (
|
|
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
|
})
|