@livestore/adapter-web 0.4.0-dev.2 → 0.4.0-dev.20

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 (61) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/in-memory/in-memory-adapter.d.ts +15 -5
  3. package/dist/in-memory/in-memory-adapter.d.ts.map +1 -1
  4. package/dist/in-memory/in-memory-adapter.js +29 -15
  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 +14 -3
  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 +15 -0
  11. package/dist/web-worker/client-session/persisted-adapter.d.ts.map +1 -1
  12. package/dist/web-worker/client-session/persisted-adapter.js +67 -46
  13. package/dist/web-worker/client-session/persisted-adapter.js.map +1 -1
  14. package/dist/web-worker/client-session/sqlite-loader.d.ts +2 -0
  15. package/dist/web-worker/client-session/sqlite-loader.d.ts.map +1 -0
  16. package/dist/web-worker/client-session/sqlite-loader.js +16 -0
  17. package/dist/web-worker/client-session/sqlite-loader.js.map +1 -0
  18. package/dist/web-worker/common/persisted-sqlite.d.ts +23 -7
  19. package/dist/web-worker/common/persisted-sqlite.d.ts.map +1 -1
  20. package/dist/web-worker/common/persisted-sqlite.js +114 -76
  21. package/dist/web-worker/common/persisted-sqlite.js.map +1 -1
  22. package/dist/web-worker/common/shutdown-channel.d.ts +3 -2
  23. package/dist/web-worker/common/shutdown-channel.d.ts.map +1 -1
  24. package/dist/web-worker/common/shutdown-channel.js +2 -2
  25. package/dist/web-worker/common/shutdown-channel.js.map +1 -1
  26. package/dist/web-worker/common/worker-disconnect-channel.d.ts +2 -6
  27. package/dist/web-worker/common/worker-disconnect-channel.d.ts.map +1 -1
  28. package/dist/web-worker/common/worker-disconnect-channel.js +3 -2
  29. package/dist/web-worker/common/worker-disconnect-channel.js.map +1 -1
  30. package/dist/web-worker/common/worker-schema.d.ts +103 -58
  31. package/dist/web-worker/common/worker-schema.d.ts.map +1 -1
  32. package/dist/web-worker/common/worker-schema.js +48 -36
  33. package/dist/web-worker/common/worker-schema.js.map +1 -1
  34. package/dist/web-worker/leader-worker/make-leader-worker.d.ts +4 -2
  35. package/dist/web-worker/leader-worker/make-leader-worker.d.ts.map +1 -1
  36. package/dist/web-worker/leader-worker/make-leader-worker.js +47 -21
  37. package/dist/web-worker/leader-worker/make-leader-worker.js.map +1 -1
  38. package/dist/web-worker/shared-worker/make-shared-worker.d.ts +2 -1
  39. package/dist/web-worker/shared-worker/make-shared-worker.d.ts.map +1 -1
  40. package/dist/web-worker/shared-worker/make-shared-worker.js +65 -49
  41. package/dist/web-worker/shared-worker/make-shared-worker.js.map +1 -1
  42. package/dist/web-worker/vite-dev-polyfill.js +1 -0
  43. package/dist/web-worker/vite-dev-polyfill.js.map +1 -1
  44. package/package.json +8 -9
  45. package/src/in-memory/in-memory-adapter.ts +36 -20
  46. package/src/web-worker/ambient.d.ts +7 -24
  47. package/src/web-worker/client-session/client-session-devtools.ts +18 -3
  48. package/src/web-worker/client-session/persisted-adapter.ts +112 -59
  49. package/src/web-worker/client-session/sqlite-loader.ts +19 -0
  50. package/src/web-worker/common/persisted-sqlite.ts +219 -113
  51. package/src/web-worker/common/shutdown-channel.ts +10 -3
  52. package/src/web-worker/common/worker-disconnect-channel.ts +10 -3
  53. package/src/web-worker/common/worker-schema.ts +62 -35
  54. package/src/web-worker/leader-worker/make-leader-worker.ts +58 -33
  55. package/src/web-worker/shared-worker/make-shared-worker.ts +95 -75
  56. package/src/web-worker/vite-dev-polyfill.ts +1 -0
  57. package/dist/opfs-utils.d.ts +0 -5
  58. package/dist/opfs-utils.d.ts.map +0 -1
  59. package/dist/opfs-utils.js +0 -43
  60. package/dist/opfs-utils.js.map +0 -1
  61. package/src/opfs-utils.ts +0 -61
@@ -1,143 +1,249 @@
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 } from '@livestore/sqlite-wasm/browser'
4
- import { Effect, Schedule, Schema } from '@livestore/utils/effect'
5
-
6
- import * as OpfsUtils from '../../opfs-utils.ts'
3
+ import {
4
+ decodeAccessHandlePoolFilename,
5
+ HEADER_OFFSET_DATA,
6
+ type WebDatabaseMetadataOpfs,
7
+ } from '@livestore/sqlite-wasm/browser'
8
+ import { isDevEnv } from '@livestore/utils'
9
+ import { Chunk, Effect, Option, Order, Schedule, Schema, Stream } from '@livestore/utils/effect'
10
+ import { Opfs, type WebError } from '@livestore/utils/effect/browser'
7
11
  import type * as WorkerSchema from './worker-schema.ts'
8
12
 
9
13
  export class PersistedSqliteError extends Schema.TaggedError<PersistedSqliteError>()('PersistedSqliteError', {
10
- cause: Schema.Defect,
14
+ message: Schema.String,
15
+ cause: Schema.optional(Schema.Defect),
11
16
  }) {}
12
17
 
13
- export const readPersistedAppDbFromClientSession = ({
14
- storageOptions,
15
- storeId,
16
- schema,
17
- }: {
18
+ export const readPersistedStateDbFromClientSession: (args: {
18
19
  storageOptions: WorkerSchema.StorageType
19
20
  storeId: string
20
21
  schema: LiveStoreSchema
21
- }) =>
22
- Effect.promise(async () => {
23
- const directory = sanitizeOpfsDir(storageOptions.directory, storeId)
24
- const sahPoolOpaqueDir = await OpfsUtils.getDirHandle(directory).catch(() => undefined)
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
+ )
25
58
 
26
- if (sahPoolOpaqueDir === undefined) {
27
- return undefined
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
+ })
28
63
  }
29
64
 
30
- const tryGetDbFile = async (fileHandle: FileSystemFileHandle) => {
31
- const file = await fileHandle.getFile()
32
- const fileName = await decodeSAHPoolFilename(file)
33
- return fileName ? { fileName, file } : undefined
34
- }
65
+ const stateDbBuffer = yield* Effect.promise(() =>
66
+ stateDbFileOption.value.file.slice(HEADER_OFFSET_DATA).arrayBuffer(),
67
+ )
35
68
 
36
- const getAllFiles = async (asyncIterator: AsyncIterable<FileSystemHandle>): Promise<FileSystemFileHandle[]> => {
37
- const results: FileSystemFileHandle[] = []
38
- for await (const value of asyncIterator) {
39
- if (value.kind === 'file') {
40
- results.push(value as FileSystemFileHandle)
41
- }
42
- }
43
- return results
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
+ })
44
75
  }
45
76
 
46
- const files = await getAllFiles(sahPoolOpaqueDir.values())
47
-
48
- const fileResults = await Promise.all(files.map(tryGetDbFile))
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
+ )
85
+
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
+ )
100
+
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 === '/') {
106
+ return `livestore-${storeId}@${liveStoreStorageFormatVersion}`
107
+ }
49
108
 
50
- const appDbFileName = `/${getStateDbFileName(schema)}`
109
+ if (directory.includes('/')) {
110
+ return yield* new PersistedSqliteError({
111
+ message: `Nested directories are not yet supported ('${directory}')`,
112
+ })
113
+ }
51
114
 
52
- const dbFileRes = fileResults.find((_) => _?.fileName === appDbFileName)
53
- // console.debug('fileResults', fileResults, 'dbFileRes', dbFileRes)
115
+ return `${directory}@${liveStoreStorageFormatVersion}`
116
+ })
54
117
 
55
- if (dbFileRes !== undefined) {
56
- const data = await dbFileRes.file.slice(HEADER_OFFSET_DATA).arrayBuffer()
57
- // console.debug('readPersistedAppDbFromClientSession', data.byteLength, data)
118
+ export const getStateDbFileName = (schema: LiveStoreSchema) => {
119
+ const schemaHashSuffix =
120
+ schema.state.sqlite.migrations.strategy === 'manual' ? 'fixed' : schema.state.sqlite.hash.toString()
121
+ return `state${schemaHashSuffix}.db`
122
+ }
58
123
 
59
- // Given the SAH pool always eagerly creates files with empty non-header data,
60
- // we want to return undefined if the file exists but is empty
61
- if (data.byteLength === 0) {
62
- return undefined
63
- }
124
+ export const MAX_ARCHIVED_STATE_DBS_IN_DEV = 3
125
+ export const ARCHIVE_DIR_NAME = 'archive'
126
+
127
+ /**
128
+ * Cleanup old state database files after successful migration.
129
+ * This prevents OPFS file pool capacity from being exhausted by accumulated schema files.
130
+ *
131
+ * @param vfs - The AccessHandlePoolVFS instance for safe file operations
132
+ * @param currentSchema - Current schema (to avoid deleting the active database)
133
+ */
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
+ }
64
170
 
65
- return new Uint8Array(data)
66
- }
171
+ const isDev = isDevEnv()
172
+ const currentDbFileName = getStateDbFileName(currentSchema)
173
+ const currentPath = `/${currentDbFileName}`
67
174
 
68
- return undefined
69
- }).pipe(
70
- Effect.logWarnIfTakesLongerThan({
71
- duration: 1000,
72
- label: '@livestore/adapter-web:readPersistedAppDbFromClientSession',
73
- }),
74
- Effect.withPerformanceMeasure('@livestore/adapter-web:readPersistedAppDbFromClientSession'),
75
- Effect.withSpan('@livestore/adapter-web:readPersistedAppDbFromClientSession'),
175
+ const allPaths = yield* Effect.sync(() => vfs.getTrackedFilePaths())
176
+ const oldStateDbPaths = allPaths.filter(
177
+ (path) => path.startsWith('/state') && path.endsWith('.db') && path !== currentPath,
76
178
  )
77
179
 
78
- export const resetPersistedDataFromClientSession = ({
79
- storageOptions,
80
- storeId,
81
- }: {
82
- storageOptions: WorkerSchema.StorageType
83
- storeId: string
84
- }) =>
85
- Effect.gen(function* () {
86
- const directory = sanitizeOpfsDir(storageOptions.directory, storeId)
87
- yield* opfsDeleteAbs(directory)
88
- }).pipe(
89
- Effect.retry({
90
- schedule: Schedule.exponentialBackoff10Sec,
91
- }),
92
- Effect.withSpan('@livestore/adapter-web:resetPersistedDataFromClientSession'),
93
- )
180
+ if (oldStateDbPaths.length === 0) {
181
+ yield* Effect.logDebug('No old database files found')
182
+ return
183
+ }
94
184
 
95
- const opfsDeleteAbs = (absPath: string) =>
96
- Effect.promise(async () => {
97
- // Get the root directory handle
98
- const root = await OpfsUtils.rootHandlePromise
99
-
100
- // Split the absolute path to traverse directories
101
- const pathParts = absPath.split('/').filter((part) => part.length)
102
-
103
- try {
104
- // Traverse to the target file handle
105
- let currentDir = root
106
- for (let i = 0; i < pathParts.length - 1; i++) {
107
- currentDir = await currentDir.getDirectoryHandle(pathParts[i]!)
108
- }
109
-
110
- // Delete the file
111
- await currentDir.removeEntry(pathParts.at(-1)!, { recursive: true })
112
- } catch (error) {
113
- if (error instanceof DOMException && error.name === 'NotFoundError') {
114
- // Can ignore as it's already been deleted or not there in the first place
115
- return
116
- } else {
117
- throw error
118
- }
185
+ const absoluteArchiveDirName = `${opfsDirectory}/${ARCHIVE_DIR_NAME}`
186
+ if (isDev && !(yield* Opfs.exists(absoluteArchiveDirName))) yield* Opfs.makeDirectory(absoluteArchiveDirName)
187
+
188
+ for (const path of oldStateDbPaths) {
189
+ const fileName = path.startsWith('/') ? path.slice(1) : path
190
+
191
+ if (isDev) {
192
+ const archiveFileData = yield* vfs.readFilePayload(fileName)
193
+
194
+ const archiveFileName = `${Date.now()}-${fileName}`
195
+
196
+ yield* Opfs.writeFile(`${opfsDirectory}/archive/${archiveFileName}`, new Uint8Array(archiveFileData))
119
197
  }
120
- }).pipe(
121
- UnexpectedError.mapToUnexpectedError,
122
- Effect.withSpan('@livestore/adapter-web:worker:opfsDeleteFile', { attributes: { absFilePath: absPath } }),
123
- )
124
198
 
125
- export const sanitizeOpfsDir = (directory: string | undefined, storeId: string) => {
126
- // Root dir should be `''` not `/`
127
- if (directory === undefined || directory === '' || directory === '/')
128
- return `livestore-${storeId}@${liveStoreStorageFormatVersion}`
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}`,
210
+ })
211
+ }
129
212
 
130
- if (directory.includes('/')) {
131
- throw new Error(
132
- `@livestore/adapter-web:worker:sanitizeOpfsDir: Nested directories are not yet supported ('${directory}')`,
133
- )
213
+ yield* Effect.logDebug(`Deleted old state database file: ${fileName}`)
134
214
  }
135
215
 
136
- return `${directory}@${liveStoreStorageFormatVersion}`
137
- }
216
+ if (isDev) {
217
+ yield* pruneArchiveDirectory({
218
+ archiveDirectory: absoluteArchiveDirName,
219
+ keep: MAX_ARCHIVED_STATE_DBS_IN_DEV,
220
+ })
221
+ }
222
+ })
138
223
 
139
- export const getStateDbFileName = (schema: LiveStoreSchema) => {
140
- const schemaHashSuffix =
141
- schema.state.sqlite.migrations.strategy === 'manual' ? 'fixed' : schema.state.sqlite.hash.toString()
142
- return `state${schemaHashSuffix}.db`
143
- }
224
+ const pruneArchiveDirectory = Effect.fn('@livestore/adapter-web:pruneArchiveDirectory')(function* ({
225
+ archiveDirectory,
226
+ keep,
227
+ }: {
228
+ archiveDirectory: string
229
+ keep: number
230
+ }) {
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,
242
+ )
243
+
244
+ if (filesToDelete.length === 0) return
245
+
246
+ yield* Effect.forEach(filesToDelete, ({ name }) => Opfs.Opfs.removeEntry(archiveDirHandle, name))
247
+
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
  })
@@ -4,8 +4,9 @@ import {
4
4
  LeaderAheadError,
5
5
  liveStoreVersion,
6
6
  MigrationsReport,
7
+ SyncBackend,
7
8
  SyncState,
8
- UnexpectedError,
9
+ UnknownError,
9
10
  } from '@livestore/common'
10
11
  import { EventSequenceNumber, LiveStoreEvent } from '@livestore/common/schema'
11
12
  import * as WebmeshWorker from '@livestore/devtools-web-common/worker'
@@ -48,7 +49,7 @@ export class LeaderWorkerOuterInitialMessage extends Schema.TaggedRequest<Leader
48
49
  {
49
50
  payload: { port: Transferable.MessagePort, storeId: Schema.String, clientId: Schema.String },
50
51
  success: Schema.Void,
51
- failure: UnexpectedError,
52
+ failure: UnknownError,
52
53
  },
53
54
  ) {}
54
55
 
@@ -64,10 +65,10 @@ export class LeaderWorkerInnerInitialMessage extends Schema.TaggedRequest<Leader
64
65
  storeId: Schema.String,
65
66
  clientId: Schema.String,
66
67
  debugInstanceId: Schema.String,
67
- syncPayload: Schema.UndefinedOr(Schema.JsonValue),
68
+ syncPayloadEncoded: Schema.UndefinedOr(Schema.JsonValue),
68
69
  },
69
70
  success: Schema.Void,
70
- failure: UnexpectedError,
71
+ failure: UnknownError,
71
72
  },
72
73
  ) {}
73
74
 
@@ -76,7 +77,7 @@ export class LeaderWorkerInnerBootStatusStream extends Schema.TaggedRequest<Lead
76
77
  {
77
78
  payload: {},
78
79
  success: BootStatus,
79
- failure: UnexpectedError,
80
+ failure: UnknownError,
80
81
  },
81
82
  ) {}
82
83
 
@@ -84,27 +85,27 @@ export class LeaderWorkerInnerPushToLeader extends Schema.TaggedRequest<LeaderWo
84
85
  'PushToLeader',
85
86
  {
86
87
  payload: {
87
- batch: Schema.Array(LiveStoreEvent.AnyEncoded),
88
+ batch: Schema.Array(Schema.typeSchema(LiveStoreEvent.Client.Encoded)),
88
89
  },
89
- success: Schema.Void,
90
- failure: Schema.Union(UnexpectedError, LeaderAheadError),
90
+ success: Schema.Void as Schema.Schema<void>,
91
+ failure: Schema.Union(UnknownError, LeaderAheadError),
91
92
  },
92
93
  ) {}
93
94
 
94
95
  export class LeaderWorkerInnerPullStream extends Schema.TaggedRequest<LeaderWorkerInnerPullStream>()('PullStream', {
95
96
  payload: {
96
- cursor: EventSequenceNumber.EventSequenceNumber,
97
+ cursor: Schema.typeSchema(EventSequenceNumber.Client.Composite),
97
98
  },
98
99
  success: Schema.Struct({
99
100
  payload: SyncState.PayloadUpstream,
100
101
  }),
101
- failure: UnexpectedError,
102
+ failure: UnknownError,
102
103
  }) {}
103
104
 
104
105
  export class LeaderWorkerInnerExport extends Schema.TaggedRequest<LeaderWorkerInnerExport>()('Export', {
105
106
  payload: {},
106
107
  success: Transferable.Uint8Array as Schema.Schema<Uint8Array<ArrayBuffer>>,
107
- failure: UnexpectedError,
108
+ failure: UnknownError,
108
109
  }) {}
109
110
 
110
111
  export class LeaderWorkerInnerExportEventlog extends Schema.TaggedRequest<LeaderWorkerInnerExportEventlog>()(
@@ -112,7 +113,7 @@ export class LeaderWorkerInnerExportEventlog extends Schema.TaggedRequest<Leader
112
113
  {
113
114
  payload: {},
114
115
  success: Transferable.Uint8Array as Schema.Schema<Uint8Array<ArrayBuffer>>,
115
- failure: UnexpectedError,
116
+ failure: UnknownError,
116
117
  },
117
118
  ) {}
118
119
 
@@ -124,7 +125,7 @@ export class LeaderWorkerInnerGetRecreateSnapshot extends Schema.TaggedRequest<L
124
125
  snapshot: Transferable.Uint8Array as Schema.Schema<Uint8Array<ArrayBuffer>>,
125
126
  migrationsReport: MigrationsReport,
126
127
  }),
127
- failure: UnexpectedError,
128
+ failure: UnknownError,
128
129
  },
129
130
  ) {}
130
131
 
@@ -132,8 +133,8 @@ export class LeaderWorkerInnerGetLeaderHead extends Schema.TaggedRequest<LeaderW
132
133
  'GetLeaderHead',
133
134
  {
134
135
  payload: {},
135
- success: EventSequenceNumber.EventSequenceNumber,
136
- failure: UnexpectedError,
136
+ success: Schema.typeSchema(EventSequenceNumber.Client.Composite),
137
+ failure: UnknownError,
137
138
  },
138
139
  ) {}
139
140
 
@@ -142,14 +143,41 @@ export class LeaderWorkerInnerGetLeaderSyncState extends Schema.TaggedRequest<Le
142
143
  {
143
144
  payload: {},
144
145
  success: SyncState.SyncState,
145
- failure: UnexpectedError,
146
+ failure: UnknownError,
147
+ },
148
+ ) {}
149
+
150
+ export class LeaderWorkerInnerSyncStateStream extends Schema.TaggedRequest<LeaderWorkerInnerSyncStateStream>()(
151
+ 'SyncStateStream',
152
+ {
153
+ payload: {},
154
+ success: SyncState.SyncState,
155
+ failure: UnknownError,
156
+ },
157
+ ) {}
158
+
159
+ export class LeaderWorkerInnerGetNetworkStatus extends Schema.TaggedRequest<LeaderWorkerInnerGetNetworkStatus>()(
160
+ 'GetNetworkStatus',
161
+ {
162
+ payload: {},
163
+ success: SyncBackend.NetworkStatus,
164
+ failure: UnknownError,
165
+ },
166
+ ) {}
167
+
168
+ export class LeaderWorkerInnerNetworkStatusStream extends Schema.TaggedRequest<LeaderWorkerInnerNetworkStatusStream>()(
169
+ 'NetworkStatusStream',
170
+ {
171
+ payload: {},
172
+ success: SyncBackend.NetworkStatus,
173
+ failure: UnknownError,
146
174
  },
147
175
  ) {}
148
176
 
149
177
  export class LeaderWorkerInnerShutdown extends Schema.TaggedRequest<LeaderWorkerInnerShutdown>()('Shutdown', {
150
178
  payload: {},
151
179
  success: Schema.Void,
152
- failure: UnexpectedError,
180
+ failure: UnknownError,
153
181
  }) {}
154
182
 
155
183
  export class LeaderWorkerInnerExtraDevtoolsMessage extends Schema.TaggedRequest<LeaderWorkerInnerExtraDevtoolsMessage>()(
@@ -159,7 +187,7 @@ export class LeaderWorkerInnerExtraDevtoolsMessage extends Schema.TaggedRequest<
159
187
  message: Devtools.Leader.MessageToApp,
160
188
  },
161
189
  success: Schema.Void,
162
- failure: UnexpectedError,
190
+ failure: UnknownError,
163
191
  },
164
192
  ) {}
165
193
 
@@ -173,40 +201,36 @@ export const LeaderWorkerInnerRequest = Schema.Union(
173
201
  LeaderWorkerInnerGetRecreateSnapshot,
174
202
  LeaderWorkerInnerGetLeaderHead,
175
203
  LeaderWorkerInnerGetLeaderSyncState,
204
+ LeaderWorkerInnerSyncStateStream,
205
+ LeaderWorkerInnerGetNetworkStatus,
206
+ LeaderWorkerInnerNetworkStatusStream,
176
207
  LeaderWorkerInnerShutdown,
177
208
  LeaderWorkerInnerExtraDevtoolsMessage,
178
209
  WebmeshWorker.Schema.CreateConnection,
179
210
  )
180
211
  export type LeaderWorkerInnerRequest = typeof LeaderWorkerInnerRequest.Type
181
212
 
182
- export class SharedWorkerInitialMessagePayloadFromClientSession extends Schema.TaggedStruct('FromClientSession', {
183
- initialMessage: LeaderWorkerInnerInitialMessage,
184
- }) {}
185
-
186
- export class SharedWorkerInitialMessage extends Schema.TaggedRequest<SharedWorkerInitialMessage>()('InitialMessage', {
187
- payload: {
188
- payload: Schema.Union(SharedWorkerInitialMessagePayloadFromClientSession, Schema.TaggedStruct('FromWebBridge', {})),
189
- // To guard against scenarios where a client session is already running a newer version of LiveStore
190
- // We should probably find a better way to handle those cases once they become more common.
191
- liveStoreVersion: Schema.Literal(liveStoreVersion),
192
- },
193
- success: Schema.Void,
194
- failure: UnexpectedError,
195
- }) {}
196
-
197
213
  export class SharedWorkerUpdateMessagePort extends Schema.TaggedRequest<SharedWorkerUpdateMessagePort>()(
198
214
  'UpdateMessagePort',
199
215
  {
200
216
  payload: {
201
217
  port: Transferable.MessagePort,
218
+ // Version gate to prevent mixed LiveStore builds talking to the same SharedWorker
219
+ liveStoreVersion: Schema.Literal(liveStoreVersion),
220
+ /**
221
+ * Initial configuration for the leader worker. This replaces the previous
222
+ * two-phase SharedWorker handshake and is sent under the tab lock by the
223
+ * elected leader. Subsequent calls can omit changes and will simply rebind
224
+ * the port (join) without reinitializing the store.
225
+ */
226
+ initial: LeaderWorkerInnerInitialMessage,
202
227
  },
203
228
  success: Schema.Void,
204
- failure: UnexpectedError,
229
+ failure: UnknownError,
205
230
  },
206
231
  ) {}
207
232
 
208
233
  export class SharedWorkerRequest extends Schema.Union(
209
- SharedWorkerInitialMessage,
210
234
  SharedWorkerUpdateMessagePort,
211
235
 
212
236
  // Proxied requests
@@ -218,6 +242,9 @@ export class SharedWorkerRequest extends Schema.Union(
218
242
  LeaderWorkerInnerExportEventlog,
219
243
  LeaderWorkerInnerGetLeaderHead,
220
244
  LeaderWorkerInnerGetLeaderSyncState,
245
+ LeaderWorkerInnerSyncStateStream,
246
+ LeaderWorkerInnerGetNetworkStatus,
247
+ LeaderWorkerInnerNetworkStatusStream,
221
248
  LeaderWorkerInnerShutdown,
222
249
  LeaderWorkerInnerExtraDevtoolsMessage,
223
250