@livestore/adapter-web 0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f
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/.eslintrc.cjs +6 -0
- package/README.md +12 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/common/connection.d.ts +7 -0
- package/dist/common/connection.d.ts.map +1 -0
- package/dist/common/connection.js +25 -0
- package/dist/common/connection.js.map +1 -0
- package/dist/devtools-bridge/background-browser-channel.d.ts +9 -0
- package/dist/devtools-bridge/background-browser-channel.d.ts.map +1 -0
- package/dist/devtools-bridge/background-browser-channel.js +31 -0
- package/dist/devtools-bridge/background-browser-channel.js.map +1 -0
- package/dist/devtools-bridge/background-message.d.ts +75 -0
- package/dist/devtools-bridge/background-message.d.ts.map +1 -0
- package/dist/devtools-bridge/background-message.js +53 -0
- package/dist/devtools-bridge/background-message.js.map +1 -0
- package/dist/devtools-bridge/bridge-shared.d.ts +14 -0
- package/dist/devtools-bridge/bridge-shared.d.ts.map +1 -0
- package/dist/devtools-bridge/bridge-shared.js +67 -0
- package/dist/devtools-bridge/bridge-shared.js.map +1 -0
- package/dist/devtools-bridge/browser-extension-bridge.d.ts +3 -0
- package/dist/devtools-bridge/browser-extension-bridge.d.ts.map +1 -0
- package/dist/devtools-bridge/browser-extension-bridge.js +59 -0
- package/dist/devtools-bridge/browser-extension-bridge.js.map +1 -0
- package/dist/devtools-bridge/iframe-message.d.ts +16 -0
- package/dist/devtools-bridge/iframe-message.d.ts.map +1 -0
- package/dist/devtools-bridge/iframe-message.js +11 -0
- package/dist/devtools-bridge/iframe-message.js.map +1 -0
- package/dist/devtools-bridge/index.d.ts +6 -0
- package/dist/devtools-bridge/index.d.ts.map +1 -0
- package/dist/devtools-bridge/index.js +5 -0
- package/dist/devtools-bridge/index.js.map +1 -0
- package/dist/devtools-bridge/web-bridge.d.ts +31 -0
- package/dist/devtools-bridge/web-bridge.d.ts.map +1 -0
- package/dist/devtools-bridge/web-bridge.js +131 -0
- package/dist/devtools-bridge/web-bridge.js.map +1 -0
- package/dist/in-memory/index.d.ts +4 -0
- package/dist/in-memory/index.d.ts.map +1 -0
- package/dist/in-memory/index.js +50 -0
- package/dist/in-memory/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/opfs-utils.d.ts +5 -0
- package/dist/opfs-utils.d.ts.map +1 -0
- package/dist/opfs-utils.js +43 -0
- package/dist/opfs-utils.js.map +1 -0
- package/dist/web-worker/client-session/client-session-devtools.d.ts +7 -0
- package/dist/web-worker/client-session/client-session-devtools.d.ts.map +1 -0
- package/dist/web-worker/client-session/client-session-devtools.js +107 -0
- package/dist/web-worker/client-session/client-session-devtools.js.map +1 -0
- package/dist/web-worker/client-session/index.d.ts +41 -0
- package/dist/web-worker/client-session/index.d.ts.map +1 -0
- package/dist/web-worker/client-session/index.js +299 -0
- package/dist/web-worker/client-session/index.js.map +1 -0
- package/dist/web-worker/client-session/trim-batch.d.ts +4 -0
- package/dist/web-worker/client-session/trim-batch.d.ts.map +1 -0
- package/dist/web-worker/client-session/trim-batch.js +13 -0
- package/dist/web-worker/client-session/trim-batch.js.map +1 -0
- package/dist/web-worker/client-session/trim-batch.test.d.ts +2 -0
- package/dist/web-worker/client-session/trim-batch.test.d.ts.map +1 -0
- package/dist/web-worker/client-session/trim-batch.test.js +38 -0
- package/dist/web-worker/client-session/trim-batch.test.js.map +1 -0
- package/dist/web-worker/common/persisted-sqlite.d.ts +23 -0
- package/dist/web-worker/common/persisted-sqlite.d.ts.map +1 -0
- package/dist/web-worker/common/persisted-sqlite.js +92 -0
- package/dist/web-worker/common/persisted-sqlite.js.map +1 -0
- package/dist/web-worker/common/shutdown-channel.d.ts +7 -0
- package/dist/web-worker/common/shutdown-channel.d.ts.map +1 -0
- package/dist/web-worker/common/shutdown-channel.js +7 -0
- package/dist/web-worker/common/shutdown-channel.js.map +1 -0
- package/dist/web-worker/common/worker-schema.d.ts +226 -0
- package/dist/web-worker/common/worker-schema.d.ts.map +1 -0
- package/dist/web-worker/common/worker-schema.js +176 -0
- package/dist/web-worker/common/worker-schema.js.map +1 -0
- package/dist/web-worker/leader-worker/make-leader-worker.d.ts +15 -0
- package/dist/web-worker/leader-worker/make-leader-worker.d.ts.map +1 -0
- package/dist/web-worker/leader-worker/make-leader-worker.js +144 -0
- package/dist/web-worker/leader-worker/make-leader-worker.js.map +1 -0
- package/dist/web-worker/shared-worker/make-shared-worker.d.ts +2 -0
- package/dist/web-worker/shared-worker/make-shared-worker.d.ts.map +1 -0
- package/dist/web-worker/shared-worker/make-shared-worker.js +160 -0
- package/dist/web-worker/shared-worker/make-shared-worker.js.map +1 -0
- package/dist/web-worker/vite-dev-polyfill.d.ts +2 -0
- package/dist/web-worker/vite-dev-polyfill.d.ts.map +1 -0
- package/dist/web-worker/vite-dev-polyfill.js +37 -0
- package/dist/web-worker/vite-dev-polyfill.js.map +1 -0
- package/package.json +78 -0
- package/src/common/connection.ts +32 -0
- package/src/devtools-bridge/background-browser-channel.ts +57 -0
- package/src/devtools-bridge/background-message.ts +42 -0
- package/src/devtools-bridge/bridge-shared.ts +97 -0
- package/src/devtools-bridge/browser-extension-bridge.ts +64 -0
- package/src/devtools-bridge/iframe-message.ts +9 -0
- package/src/devtools-bridge/index.ts +9 -0
- package/src/devtools-bridge/web-bridge.ts +169 -0
- package/src/in-memory/index.ts +66 -0
- package/src/index.ts +3 -0
- package/src/opfs-utils.ts +61 -0
- package/src/web-worker/ambient.d.ts +37 -0
- package/src/web-worker/client-session/client-session-devtools.ts +167 -0
- package/src/web-worker/client-session/index.ts +537 -0
- package/src/web-worker/client-session/trim-batch.test.ts +48 -0
- package/src/web-worker/client-session/trim-batch.ts +15 -0
- package/src/web-worker/common/persisted-sqlite.ts +136 -0
- package/src/web-worker/common/shutdown-channel.ts +8 -0
- package/src/web-worker/common/worker-schema.ts +206 -0
- package/src/web-worker/leader-worker/make-leader-worker.ts +276 -0
- package/src/web-worker/shared-worker/make-shared-worker.ts +300 -0
- package/src/web-worker/vite-dev-polyfill.ts +36 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { liveStoreStorageFormatVersion } from '@livestore/common'
|
|
2
|
+
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
3
|
+
import { decodeSAHPoolFilename, HEADER_OFFSET_DATA } from '@livestore/sqlite-wasm/browser'
|
|
4
|
+
import { Effect, Schema } from '@livestore/utils/effect'
|
|
5
|
+
|
|
6
|
+
import * as OpfsUtils from '../../opfs-utils.js'
|
|
7
|
+
import type * as WorkerSchema from './worker-schema.js'
|
|
8
|
+
|
|
9
|
+
export class PersistedSqliteError extends Schema.TaggedError<PersistedSqliteError>()('PersistedSqliteError', {
|
|
10
|
+
cause: Schema.Defect,
|
|
11
|
+
}) {}
|
|
12
|
+
|
|
13
|
+
export const readPersistedAppDbFromClientSession = ({
|
|
14
|
+
storageOptions,
|
|
15
|
+
storeId,
|
|
16
|
+
schema,
|
|
17
|
+
}: {
|
|
18
|
+
storageOptions: WorkerSchema.StorageType
|
|
19
|
+
storeId: string
|
|
20
|
+
schema: LiveStoreSchema
|
|
21
|
+
}) =>
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
return yield* Effect.promise(async () => {
|
|
24
|
+
const directory = sanitizeOpfsDir(storageOptions.directory, storeId)
|
|
25
|
+
const sahPoolOpaqueDir = await OpfsUtils.getDirHandle(directory).catch(() => undefined)
|
|
26
|
+
|
|
27
|
+
if (sahPoolOpaqueDir === undefined) {
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tryGetDbFile = async (fileHandle: FileSystemFileHandle) => {
|
|
32
|
+
const file = await fileHandle.getFile()
|
|
33
|
+
const fileName = await decodeSAHPoolFilename(file)
|
|
34
|
+
return fileName ? { fileName, file } : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const getAllFiles = async (asyncIterator: AsyncIterable<FileSystemHandle>): Promise<FileSystemFileHandle[]> => {
|
|
38
|
+
const results: FileSystemFileHandle[] = []
|
|
39
|
+
for await (const value of asyncIterator) {
|
|
40
|
+
if (value.kind === 'file') {
|
|
41
|
+
results.push(value as FileSystemFileHandle)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return results
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const files = await getAllFiles(sahPoolOpaqueDir.values())
|
|
48
|
+
|
|
49
|
+
const fileResults = await Promise.all(files.map(tryGetDbFile))
|
|
50
|
+
|
|
51
|
+
const appDbFileName = '/' + getAppDbFileName(schema)
|
|
52
|
+
|
|
53
|
+
const dbFileRes = fileResults.find((_) => _?.fileName === appDbFileName)
|
|
54
|
+
// console.debug('fileResults', fileResults, 'dbFileRes', dbFileRes)
|
|
55
|
+
|
|
56
|
+
if (dbFileRes !== undefined) {
|
|
57
|
+
const data = await dbFileRes.file.slice(HEADER_OFFSET_DATA).arrayBuffer()
|
|
58
|
+
// console.debug('readPersistedAppDbFromClientSession', data.byteLength, data)
|
|
59
|
+
|
|
60
|
+
// Given the SAH pool always eagerly creates files with empty non-header data,
|
|
61
|
+
// we want to return undefined if the file exists but is empty
|
|
62
|
+
if (data.byteLength === 0) {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return new Uint8Array(data)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return undefined
|
|
70
|
+
})
|
|
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(Effect.withSpan('@livestore/adapter-web:resetPersistedDataFromClientSession'))
|
|
91
|
+
|
|
92
|
+
const opfsDeleteAbs = (absPath: string) =>
|
|
93
|
+
Effect.promise(async () => {
|
|
94
|
+
// Get the root directory handle
|
|
95
|
+
const root = await OpfsUtils.rootHandlePromise
|
|
96
|
+
|
|
97
|
+
// Split the absolute path to traverse directories
|
|
98
|
+
const pathParts = absPath.split('/').filter((part) => part.length)
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// Traverse to the target file handle
|
|
102
|
+
let currentDir = root
|
|
103
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
104
|
+
currentDir = await currentDir.getDirectoryHandle(pathParts[i]!)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Delete the file
|
|
108
|
+
await currentDir.removeEntry(pathParts.at(-1)!, { recursive: true })
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error instanceof DOMException && error.name === 'NotFoundError') {
|
|
111
|
+
// Can ignore as it's already been deleted or not there in the first place
|
|
112
|
+
return
|
|
113
|
+
} else {
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}).pipe(Effect.withSpan('@livestore/adapter-web:worker:opfsDeleteFile', { attributes: { absFilePath: absPath } }))
|
|
118
|
+
|
|
119
|
+
export const sanitizeOpfsDir = (directory: string | undefined, storeId: string) => {
|
|
120
|
+
// Root dir should be `''` not `/`
|
|
121
|
+
if (directory === undefined || directory === '' || directory === '/')
|
|
122
|
+
return `livestore-${storeId}@${liveStoreStorageFormatVersion}`
|
|
123
|
+
|
|
124
|
+
if (directory.includes('/')) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`@livestore/adapter-web:worker:sanitizeOpfsDir: Nested directories are not yet supported ('${directory}')`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `${directory}@${liveStoreStorageFormatVersion}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const getAppDbFileName = (schema: LiveStoreSchema) => {
|
|
134
|
+
const schemaHashSuffix = schema.migrationOptions.strategy === 'manual' ? 'fixed' : schema.hash.toString()
|
|
135
|
+
return `app${schemaHashSuffix}.db`
|
|
136
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ShutdownChannel } from '@livestore/common/leader-thread'
|
|
2
|
+
import { WebChannel } from '@livestore/utils/effect'
|
|
3
|
+
|
|
4
|
+
export const makeShutdownChannel = (storeId: string) =>
|
|
5
|
+
WebChannel.broadcastChannel({
|
|
6
|
+
channelName: `livestore.shutdown.${storeId}`,
|
|
7
|
+
schema: ShutdownChannel.All,
|
|
8
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BootStatus,
|
|
3
|
+
Devtools,
|
|
4
|
+
InvalidPushError,
|
|
5
|
+
MigrationsReport,
|
|
6
|
+
NetworkStatus,
|
|
7
|
+
SyncState,
|
|
8
|
+
UnexpectedError,
|
|
9
|
+
} from '@livestore/common'
|
|
10
|
+
import { EventId, MutationEvent } from '@livestore/common/schema'
|
|
11
|
+
import * as WebMeshWorker from '@livestore/devtools-web-common/worker'
|
|
12
|
+
import { Schema, Transferable } from '@livestore/utils/effect'
|
|
13
|
+
|
|
14
|
+
export const StorageTypeOpfs = Schema.Struct({
|
|
15
|
+
type: Schema.Literal('opfs'),
|
|
16
|
+
/**
|
|
17
|
+
* Default is `livestore-${storeId}`
|
|
18
|
+
*
|
|
19
|
+
* When providing this option, make sure to include the `storeId` in the path to avoid
|
|
20
|
+
* conflicts with other LiveStore apps.
|
|
21
|
+
*/
|
|
22
|
+
directory: Schema.optional(Schema.String),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type StorageTypeOpfs = typeof StorageTypeOpfs.Type
|
|
26
|
+
|
|
27
|
+
// export const StorageTypeIndexeddb = Schema.Struct({
|
|
28
|
+
// type: Schema.Literal('indexeddb'),
|
|
29
|
+
// /** @default "livestore" */
|
|
30
|
+
// databaseName: Schema.optionalWith(Schema.String, { default: () => 'livestore' }),
|
|
31
|
+
// /** @default "livestore-" */
|
|
32
|
+
// storeNamePrefix: Schema.optionalWith(Schema.String, { default: () => 'livestore-' }),
|
|
33
|
+
// })
|
|
34
|
+
|
|
35
|
+
export const StorageType = Schema.Union(
|
|
36
|
+
StorageTypeOpfs,
|
|
37
|
+
// StorageTypeIndexeddb
|
|
38
|
+
)
|
|
39
|
+
export type StorageType = typeof StorageType.Type
|
|
40
|
+
export type StorageTypeEncoded = typeof StorageType.Encoded
|
|
41
|
+
|
|
42
|
+
// export const SyncBackendOptions = Schema.Union(SyncBackendOptionsWebsocket)
|
|
43
|
+
export const SyncBackendOptions = Schema.Record({ key: Schema.String, value: Schema.JsonValue })
|
|
44
|
+
export type SyncBackendOptions = Record<string, Schema.JsonValue>
|
|
45
|
+
|
|
46
|
+
export namespace LeaderWorkerOuter {
|
|
47
|
+
export class InitialMessage extends Schema.TaggedRequest<InitialMessage>()('InitialMessage', {
|
|
48
|
+
payload: { port: Transferable.MessagePort, storeId: Schema.String, clientId: Schema.String },
|
|
49
|
+
success: Schema.Void,
|
|
50
|
+
failure: UnexpectedError,
|
|
51
|
+
}) {}
|
|
52
|
+
|
|
53
|
+
export class Request extends Schema.Union(InitialMessage) {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// TODO unify this code with schema from node adapter
|
|
57
|
+
export namespace LeaderWorkerInner {
|
|
58
|
+
export class InitialMessage extends Schema.TaggedRequest<InitialMessage>()('InitialMessage', {
|
|
59
|
+
payload: {
|
|
60
|
+
storageOptions: StorageType,
|
|
61
|
+
devtoolsEnabled: Schema.Boolean,
|
|
62
|
+
storeId: Schema.String,
|
|
63
|
+
clientId: Schema.String,
|
|
64
|
+
debugInstanceId: Schema.String,
|
|
65
|
+
},
|
|
66
|
+
success: Schema.Void,
|
|
67
|
+
failure: UnexpectedError,
|
|
68
|
+
}) {}
|
|
69
|
+
|
|
70
|
+
export class BootStatusStream extends Schema.TaggedRequest<BootStatusStream>()('BootStatusStream', {
|
|
71
|
+
payload: {},
|
|
72
|
+
success: BootStatus,
|
|
73
|
+
failure: UnexpectedError,
|
|
74
|
+
}) {}
|
|
75
|
+
|
|
76
|
+
export class PushToLeader extends Schema.TaggedRequest<PushToLeader>()('PushToLeader', {
|
|
77
|
+
payload: {
|
|
78
|
+
batch: Schema.Array(MutationEvent.AnyEncoded),
|
|
79
|
+
},
|
|
80
|
+
success: Schema.Void,
|
|
81
|
+
failure: Schema.Union(UnexpectedError, InvalidPushError),
|
|
82
|
+
}) {}
|
|
83
|
+
|
|
84
|
+
export class PullStream extends Schema.TaggedRequest<PullStream>()('PullStream', {
|
|
85
|
+
payload: {
|
|
86
|
+
cursor: EventId.EventId,
|
|
87
|
+
},
|
|
88
|
+
success: Schema.Struct({
|
|
89
|
+
payload: SyncState.PayloadUpstream,
|
|
90
|
+
remaining: Schema.Number,
|
|
91
|
+
}),
|
|
92
|
+
failure: UnexpectedError,
|
|
93
|
+
}) {}
|
|
94
|
+
|
|
95
|
+
export class Export extends Schema.TaggedRequest<Export>()('Export', {
|
|
96
|
+
payload: {},
|
|
97
|
+
success: Transferable.Uint8Array,
|
|
98
|
+
failure: UnexpectedError,
|
|
99
|
+
}) {}
|
|
100
|
+
|
|
101
|
+
export class ExportMutationlog extends Schema.TaggedRequest<ExportMutationlog>()('ExportMutationlog', {
|
|
102
|
+
payload: {},
|
|
103
|
+
success: Transferable.Uint8Array,
|
|
104
|
+
failure: UnexpectedError,
|
|
105
|
+
}) {}
|
|
106
|
+
|
|
107
|
+
export class GetRecreateSnapshot extends Schema.TaggedRequest<GetRecreateSnapshot>()('GetRecreateSnapshot', {
|
|
108
|
+
payload: {},
|
|
109
|
+
success: Schema.Struct({
|
|
110
|
+
snapshot: Transferable.Uint8Array,
|
|
111
|
+
migrationsReport: MigrationsReport,
|
|
112
|
+
}),
|
|
113
|
+
failure: UnexpectedError,
|
|
114
|
+
}) {}
|
|
115
|
+
|
|
116
|
+
export class GetLeaderHead extends Schema.TaggedRequest<GetLeaderHead>()('GetLeaderHead', {
|
|
117
|
+
payload: {},
|
|
118
|
+
success: EventId.EventId,
|
|
119
|
+
failure: UnexpectedError,
|
|
120
|
+
}) {}
|
|
121
|
+
|
|
122
|
+
export class GetLeaderSyncState extends Schema.TaggedRequest<GetLeaderSyncState>()('GetLeaderSyncState', {
|
|
123
|
+
payload: {},
|
|
124
|
+
success: SyncState.SyncState,
|
|
125
|
+
failure: UnexpectedError,
|
|
126
|
+
}) {}
|
|
127
|
+
|
|
128
|
+
export class NetworkStatusStream extends Schema.TaggedRequest<NetworkStatusStream>()('NetworkStatusStream', {
|
|
129
|
+
payload: {},
|
|
130
|
+
success: NetworkStatus,
|
|
131
|
+
failure: UnexpectedError,
|
|
132
|
+
}) {}
|
|
133
|
+
|
|
134
|
+
export class Shutdown extends Schema.TaggedRequest<Shutdown>()('Shutdown', {
|
|
135
|
+
payload: {},
|
|
136
|
+
success: Schema.Void,
|
|
137
|
+
failure: UnexpectedError,
|
|
138
|
+
}) {}
|
|
139
|
+
|
|
140
|
+
export class ExtraDevtoolsMessage extends Schema.TaggedRequest<ExtraDevtoolsMessage>()('ExtraDevtoolsMessage', {
|
|
141
|
+
payload: {
|
|
142
|
+
message: Devtools.Leader.MessageToApp,
|
|
143
|
+
},
|
|
144
|
+
success: Schema.Void,
|
|
145
|
+
failure: UnexpectedError,
|
|
146
|
+
}) {}
|
|
147
|
+
|
|
148
|
+
export const Request = Schema.Union(
|
|
149
|
+
InitialMessage,
|
|
150
|
+
BootStatusStream,
|
|
151
|
+
PushToLeader,
|
|
152
|
+
PullStream,
|
|
153
|
+
Export,
|
|
154
|
+
ExportMutationlog,
|
|
155
|
+
GetRecreateSnapshot,
|
|
156
|
+
GetLeaderHead,
|
|
157
|
+
GetLeaderSyncState,
|
|
158
|
+
NetworkStatusStream,
|
|
159
|
+
Shutdown,
|
|
160
|
+
ExtraDevtoolsMessage,
|
|
161
|
+
WebMeshWorker.Schema.CreateConnection,
|
|
162
|
+
)
|
|
163
|
+
export type Request = typeof Request.Type
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export namespace SharedWorker {
|
|
167
|
+
export class InitialMessagePayloadFromClientSession extends Schema.TaggedStruct('FromClientSession', {
|
|
168
|
+
initialMessage: LeaderWorkerInner.InitialMessage,
|
|
169
|
+
}) {}
|
|
170
|
+
|
|
171
|
+
export class InitialMessage extends Schema.TaggedRequest<InitialMessage>()('InitialMessage', {
|
|
172
|
+
payload: {
|
|
173
|
+
payload: Schema.Union(InitialMessagePayloadFromClientSession, Schema.TaggedStruct('FromWebBridge', {})),
|
|
174
|
+
},
|
|
175
|
+
success: Schema.Void,
|
|
176
|
+
failure: UnexpectedError,
|
|
177
|
+
}) {}
|
|
178
|
+
|
|
179
|
+
export class UpdateMessagePort extends Schema.TaggedRequest<UpdateMessagePort>()('UpdateMessagePort', {
|
|
180
|
+
payload: {
|
|
181
|
+
port: Transferable.MessagePort,
|
|
182
|
+
},
|
|
183
|
+
success: Schema.Void,
|
|
184
|
+
failure: UnexpectedError,
|
|
185
|
+
}) {}
|
|
186
|
+
|
|
187
|
+
export class Request extends Schema.Union(
|
|
188
|
+
InitialMessage,
|
|
189
|
+
UpdateMessagePort,
|
|
190
|
+
|
|
191
|
+
// Proxied requests
|
|
192
|
+
LeaderWorkerInner.BootStatusStream,
|
|
193
|
+
LeaderWorkerInner.PushToLeader,
|
|
194
|
+
LeaderWorkerInner.PullStream,
|
|
195
|
+
LeaderWorkerInner.Export,
|
|
196
|
+
LeaderWorkerInner.GetRecreateSnapshot,
|
|
197
|
+
LeaderWorkerInner.ExportMutationlog,
|
|
198
|
+
LeaderWorkerInner.GetLeaderHead,
|
|
199
|
+
LeaderWorkerInner.GetLeaderSyncState,
|
|
200
|
+
LeaderWorkerInner.NetworkStatusStream,
|
|
201
|
+
LeaderWorkerInner.Shutdown,
|
|
202
|
+
LeaderWorkerInner.ExtraDevtoolsMessage,
|
|
203
|
+
|
|
204
|
+
WebMeshWorker.Schema.CreateConnection,
|
|
205
|
+
) {}
|
|
206
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { NetworkStatus, SqliteDb, SyncOptions } from '@livestore/common'
|
|
2
|
+
import { Devtools, UnexpectedError } from '@livestore/common'
|
|
3
|
+
import type { DevtoolsOptions } from '@livestore/common/leader-thread'
|
|
4
|
+
import {
|
|
5
|
+
configureConnection,
|
|
6
|
+
getClientHeadFromDb,
|
|
7
|
+
LeaderThreadCtx,
|
|
8
|
+
makeLeaderThreadLayer,
|
|
9
|
+
} from '@livestore/common/leader-thread'
|
|
10
|
+
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
11
|
+
import { MutationEvent } from '@livestore/common/schema'
|
|
12
|
+
import { makeChannelForConnectedMeshNode } from '@livestore/devtools-web-common/web-channel'
|
|
13
|
+
import * as WebMeshWorker from '@livestore/devtools-web-common/worker'
|
|
14
|
+
import { sqliteDbFactory } from '@livestore/sqlite-wasm/browser'
|
|
15
|
+
import { loadSqlite3Wasm } from '@livestore/sqlite-wasm/load-wasm'
|
|
16
|
+
import { isDevEnv, LS_DEV } from '@livestore/utils'
|
|
17
|
+
import type { HttpClient, Scope, WorkerError } from '@livestore/utils/effect'
|
|
18
|
+
import {
|
|
19
|
+
BrowserWorkerRunner,
|
|
20
|
+
Effect,
|
|
21
|
+
FetchHttpClient,
|
|
22
|
+
identity,
|
|
23
|
+
Layer,
|
|
24
|
+
Logger,
|
|
25
|
+
LogLevel,
|
|
26
|
+
OtelTracer,
|
|
27
|
+
Scheduler,
|
|
28
|
+
Stream,
|
|
29
|
+
TaskTracing,
|
|
30
|
+
WorkerRunner,
|
|
31
|
+
} from '@livestore/utils/effect'
|
|
32
|
+
import type * as otel from '@opentelemetry/api'
|
|
33
|
+
|
|
34
|
+
import * as OpfsUtils from '../../opfs-utils.js'
|
|
35
|
+
import { getAppDbFileName, sanitizeOpfsDir } from '../common/persisted-sqlite.js'
|
|
36
|
+
import { makeShutdownChannel } from '../common/shutdown-channel.js'
|
|
37
|
+
import * as WorkerSchema from '../common/worker-schema.js'
|
|
38
|
+
|
|
39
|
+
export type WorkerOptions = {
|
|
40
|
+
schema: LiveStoreSchema
|
|
41
|
+
sync?: SyncOptions
|
|
42
|
+
otelOptions?: {
|
|
43
|
+
tracer?: otel.Tracer
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (isDevEnv()) {
|
|
48
|
+
globalThis.__debugLiveStoreUtils = {
|
|
49
|
+
opfs: OpfsUtils,
|
|
50
|
+
blobUrl: (buffer: Uint8Array) => URL.createObjectURL(new Blob([buffer], { type: 'application/octet-stream' })),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const makeWorker = (options: WorkerOptions) => {
|
|
55
|
+
makeWorkerEffect(options).pipe(Effect.runFork)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const makeWorkerEffect = (options: WorkerOptions) => {
|
|
59
|
+
const TracingLive = options.otelOptions?.tracer
|
|
60
|
+
? Layer.unwrapEffect(Effect.map(OtelTracer.make, Layer.setTracer)).pipe(
|
|
61
|
+
Layer.provideMerge(Layer.succeed(OtelTracer.OtelTracer, options.otelOptions.tracer)),
|
|
62
|
+
)
|
|
63
|
+
: undefined
|
|
64
|
+
|
|
65
|
+
return makeWorkerRunnerOuter(options).pipe(
|
|
66
|
+
Layer.provide(BrowserWorkerRunner.layer),
|
|
67
|
+
Layer.launch,
|
|
68
|
+
Effect.scoped,
|
|
69
|
+
Effect.tapCauseLogPretty,
|
|
70
|
+
Effect.annotateLogs({ thread: self.name }),
|
|
71
|
+
Effect.provide(Logger.prettyWithThread(self.name)),
|
|
72
|
+
Effect.provide(FetchHttpClient.layer),
|
|
73
|
+
LS_DEV ? TaskTracing.withAsyncTaggingTracing((name) => (console as any).createTask(name)) : identity,
|
|
74
|
+
TracingLive ? Effect.provide(TracingLive) : identity,
|
|
75
|
+
// We're using this custom scheduler to improve op batching behaviour and reduce the overhead
|
|
76
|
+
// of the Effect fiber runtime given we have different tradeoffs on a worker thread.
|
|
77
|
+
// Despite the "message channel" name, is has nothing to do with the `incomingRequestsPort` above.
|
|
78
|
+
Effect.withScheduler(Scheduler.messageChannel()),
|
|
79
|
+
// We're increasing the Effect ops limit here to allow for larger chunks of operations at a time
|
|
80
|
+
Effect.withMaxOpsBeforeYield(4096),
|
|
81
|
+
Logger.withMinimumLogLevel(LogLevel.Debug),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const makeWorkerRunnerOuter = (
|
|
86
|
+
workerOptions: WorkerOptions,
|
|
87
|
+
): Layer.Layer<never, WorkerError.WorkerError, WorkerRunner.PlatformRunner | HttpClient.HttpClient> =>
|
|
88
|
+
WorkerRunner.layerSerialized(WorkerSchema.LeaderWorkerOuter.InitialMessage, {
|
|
89
|
+
// Port coming from client session and forwarded via the shared worker
|
|
90
|
+
InitialMessage: ({ port: incomingRequestsPort, storeId, clientId }) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
yield* makeWorkerRunnerInner(workerOptions).pipe(
|
|
93
|
+
Layer.provide(BrowserWorkerRunner.layerMessagePort(incomingRequestsPort)),
|
|
94
|
+
Layer.launch,
|
|
95
|
+
Effect.scoped,
|
|
96
|
+
Effect.withSpan('@livestore/adapter-web:worker:wrapper:InitialMessage:innerFiber'),
|
|
97
|
+
Effect.tapCauseLogPretty,
|
|
98
|
+
Effect.provide(WebMeshWorker.CacheService.layer({ nodeName: `leader-${storeId}-${clientId}` })),
|
|
99
|
+
Effect.forkScoped,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return Layer.empty
|
|
103
|
+
}).pipe(Effect.withSpan('@livestore/adapter-web:worker:wrapper:InitialMessage'), Layer.unwrapScoped),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const makeWorkerRunnerInner = ({ schema, sync: syncOptions }: WorkerOptions) =>
|
|
107
|
+
WorkerRunner.layerSerialized(WorkerSchema.LeaderWorkerInner.Request, {
|
|
108
|
+
InitialMessage: ({ storageOptions, storeId, clientId, devtoolsEnabled, debugInstanceId }) =>
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
const sqlite3 = yield* Effect.promise(() => loadSqlite3Wasm())
|
|
111
|
+
const makeSqliteDb = sqliteDbFactory({ sqlite3 })
|
|
112
|
+
const runtime = yield* Effect.runtime<never>()
|
|
113
|
+
|
|
114
|
+
const makeDb = (kind: 'app' | 'mutationlog') =>
|
|
115
|
+
makeSqliteDb({
|
|
116
|
+
_tag: 'opfs',
|
|
117
|
+
opfsDirectory: sanitizeOpfsDir(storageOptions.directory, storeId),
|
|
118
|
+
fileName: kind === 'app' ? getAppDbFileName(schema) : 'mutationlog.db',
|
|
119
|
+
configureDb: (db) =>
|
|
120
|
+
configureConnection(db, {
|
|
121
|
+
// The persisted databases use the AccessHandlePoolVFS which always uses a single database connection.
|
|
122
|
+
// Multiple connections are not supported. This means that we can use the exclusive locking mode to
|
|
123
|
+
// avoid unnecessary system calls and enable the use of the WAL journal mode without the use of shared memory.
|
|
124
|
+
// TODO bring back exclusive locking mode when `WAL` is working properly
|
|
125
|
+
// lockingMode: 'EXCLUSIVE',
|
|
126
|
+
foreignKeys: true,
|
|
127
|
+
}).pipe(Effect.provide(runtime), Effect.runSync),
|
|
128
|
+
}).pipe(Effect.acquireRelease((db) => Effect.try(() => db.close()).pipe(Effect.ignoreLogged)))
|
|
129
|
+
|
|
130
|
+
// Might involve some async work, so we're running them concurrently
|
|
131
|
+
const [dbReadModel, dbMutationLog] = yield* Effect.all([makeDb('app'), makeDb('mutationlog')], {
|
|
132
|
+
concurrency: 2,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const devtoolsOptions = yield* makeDevtoolsOptions({ devtoolsEnabled, dbReadModel, dbMutationLog })
|
|
136
|
+
const shutdownChannel = yield* makeShutdownChannel(storeId)
|
|
137
|
+
|
|
138
|
+
return makeLeaderThreadLayer({
|
|
139
|
+
schema,
|
|
140
|
+
storeId,
|
|
141
|
+
clientId,
|
|
142
|
+
makeSqliteDb,
|
|
143
|
+
syncOptions,
|
|
144
|
+
dbReadModel,
|
|
145
|
+
dbMutationLog,
|
|
146
|
+
devtoolsOptions,
|
|
147
|
+
shutdownChannel,
|
|
148
|
+
})
|
|
149
|
+
}).pipe(
|
|
150
|
+
Effect.tapCauseLogPretty,
|
|
151
|
+
UnexpectedError.mapToUnexpectedError,
|
|
152
|
+
Effect.withPerformanceMeasure('@livestore/adapter-web:worker:InitialMessage'),
|
|
153
|
+
Effect.withSpan('@livestore/adapter-web:worker:InitialMessage'),
|
|
154
|
+
Effect.annotateSpans({ debugInstanceId }),
|
|
155
|
+
Layer.unwrapScoped,
|
|
156
|
+
),
|
|
157
|
+
GetRecreateSnapshot: () =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const workerCtx = yield* LeaderThreadCtx
|
|
160
|
+
|
|
161
|
+
// NOTE we can only return the cached snapshot once as it's transferred (i.e. disposed), so we need to set it to undefined
|
|
162
|
+
// const cachedSnapshot =
|
|
163
|
+
// result._tag === 'Recreate' ? yield* Ref.getAndSet(result.snapshotRef, undefined) : undefined
|
|
164
|
+
|
|
165
|
+
// return cachedSnapshot ?? workerCtx.db.export()
|
|
166
|
+
|
|
167
|
+
const snapshot = workerCtx.dbReadModel.export()
|
|
168
|
+
return { snapshot, migrationsReport: workerCtx.initialState.migrationsReport }
|
|
169
|
+
}).pipe(
|
|
170
|
+
UnexpectedError.mapToUnexpectedError,
|
|
171
|
+
Effect.withSpan('@livestore/adapter-web:worker:GetRecreateSnapshot'),
|
|
172
|
+
),
|
|
173
|
+
PullStream: ({ cursor }) =>
|
|
174
|
+
Effect.gen(function* () {
|
|
175
|
+
const { connectedClientSessionPullQueues } = yield* LeaderThreadCtx
|
|
176
|
+
const pullQueue = yield* connectedClientSessionPullQueues.makeQueue(cursor)
|
|
177
|
+
return Stream.fromQueue(pullQueue)
|
|
178
|
+
}).pipe(
|
|
179
|
+
Stream.unwrapScoped,
|
|
180
|
+
// For debugging purposes
|
|
181
|
+
// Stream.tapLogWithLabel('@livestore/adapter-web:worker:PullStream'),
|
|
182
|
+
),
|
|
183
|
+
PushToLeader: ({ batch }) =>
|
|
184
|
+
Effect.andThen(LeaderThreadCtx, ({ syncProcessor }) =>
|
|
185
|
+
syncProcessor.push(
|
|
186
|
+
batch.map((mutationEvent) => new MutationEvent.EncodedWithMeta(mutationEvent)),
|
|
187
|
+
// We'll wait in order to keep back pressure on the client session
|
|
188
|
+
{ waitForProcessing: true },
|
|
189
|
+
),
|
|
190
|
+
).pipe(Effect.uninterruptible, Effect.withSpan('@livestore/adapter-web:worker:PushToLeader')),
|
|
191
|
+
Export: () =>
|
|
192
|
+
Effect.andThen(LeaderThreadCtx, (_) => _.dbReadModel.export()).pipe(
|
|
193
|
+
UnexpectedError.mapToUnexpectedError,
|
|
194
|
+
Effect.withSpan('@livestore/adapter-web:worker:Export'),
|
|
195
|
+
),
|
|
196
|
+
ExportMutationlog: () =>
|
|
197
|
+
Effect.andThen(LeaderThreadCtx, (_) => _.dbMutationLog.export()).pipe(
|
|
198
|
+
UnexpectedError.mapToUnexpectedError,
|
|
199
|
+
Effect.withSpan('@livestore/adapter-web:worker:ExportMutationlog'),
|
|
200
|
+
),
|
|
201
|
+
BootStatusStream: () =>
|
|
202
|
+
Effect.andThen(LeaderThreadCtx, (_) => Stream.fromQueue(_.bootStatusQueue)).pipe(Stream.unwrap),
|
|
203
|
+
GetLeaderHead: () =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const workerCtx = yield* LeaderThreadCtx
|
|
206
|
+
return getClientHeadFromDb(workerCtx.dbMutationLog)
|
|
207
|
+
}).pipe(UnexpectedError.mapToUnexpectedError, Effect.withSpan('@livestore/adapter-web:worker:GetLeaderHead')),
|
|
208
|
+
GetLeaderSyncState: () =>
|
|
209
|
+
Effect.gen(function* () {
|
|
210
|
+
const workerCtx = yield* LeaderThreadCtx
|
|
211
|
+
return yield* workerCtx.syncProcessor.syncState
|
|
212
|
+
}).pipe(
|
|
213
|
+
UnexpectedError.mapToUnexpectedError,
|
|
214
|
+
Effect.withSpan('@livestore/adapter-web:worker:GetLeaderSyncState'),
|
|
215
|
+
),
|
|
216
|
+
NetworkStatusStream: () =>
|
|
217
|
+
Effect.gen(function* (_) {
|
|
218
|
+
const ctx = yield* LeaderThreadCtx
|
|
219
|
+
|
|
220
|
+
if (ctx.syncBackend === undefined) {
|
|
221
|
+
return Stream.make<[NetworkStatus]>({ isConnected: false, timestampMs: Date.now(), latchClosed: false })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return Stream.zipLatest(
|
|
225
|
+
ctx.syncBackend.isConnected.changes,
|
|
226
|
+
ctx.devtools.enabled ? ctx.devtools.syncBackendLatchState.changes : Stream.make({ latchClosed: false }),
|
|
227
|
+
).pipe(Stream.map(([isConnected, { latchClosed }]) => ({ isConnected, timestampMs: Date.now(), latchClosed })))
|
|
228
|
+
}).pipe(Stream.unwrap),
|
|
229
|
+
Shutdown: () =>
|
|
230
|
+
Effect.gen(function* () {
|
|
231
|
+
yield* Effect.logDebug('[@livestore/adapter-web:worker] Shutdown')
|
|
232
|
+
|
|
233
|
+
// Buy some time for Otel to flush
|
|
234
|
+
// TODO find a cleaner way to do this
|
|
235
|
+
yield* Effect.sleep(300)
|
|
236
|
+
}).pipe(UnexpectedError.mapToUnexpectedError, Effect.withSpan('@livestore/adapter-web:worker:Shutdown')),
|
|
237
|
+
ExtraDevtoolsMessage: ({ message }) =>
|
|
238
|
+
Effect.andThen(LeaderThreadCtx, (_) => _.extraIncomingMessagesQueue.offer(message)).pipe(
|
|
239
|
+
UnexpectedError.mapToUnexpectedError,
|
|
240
|
+
Effect.withSpan('@livestore/adapter-web:worker:ExtraDevtoolsMessage'),
|
|
241
|
+
),
|
|
242
|
+
'DevtoolsWebCommon.CreateConnection': WebMeshWorker.CreateConnection,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const makeDevtoolsOptions = ({
|
|
246
|
+
devtoolsEnabled,
|
|
247
|
+
dbReadModel,
|
|
248
|
+
dbMutationLog,
|
|
249
|
+
}: {
|
|
250
|
+
devtoolsEnabled: boolean
|
|
251
|
+
dbReadModel: SqliteDb
|
|
252
|
+
dbMutationLog: SqliteDb
|
|
253
|
+
}): Effect.Effect<DevtoolsOptions, UnexpectedError, Scope.Scope | WebMeshWorker.CacheService> =>
|
|
254
|
+
Effect.gen(function* () {
|
|
255
|
+
if (devtoolsEnabled === false) {
|
|
256
|
+
return { enabled: false }
|
|
257
|
+
}
|
|
258
|
+
const { node } = yield* WebMeshWorker.CacheService
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
enabled: true,
|
|
262
|
+
makeBootContext: Effect.gen(function* () {
|
|
263
|
+
return {
|
|
264
|
+
devtoolsWebChannel: yield* makeChannelForConnectedMeshNode({
|
|
265
|
+
node,
|
|
266
|
+
target: `devtools`,
|
|
267
|
+
schema: { listen: Devtools.Leader.MessageToApp, send: Devtools.Leader.MessageFromApp },
|
|
268
|
+
}),
|
|
269
|
+
persistenceInfo: {
|
|
270
|
+
readModel: dbReadModel.metadata.persistenceInfo,
|
|
271
|
+
mutationLog: dbMutationLog.metadata.persistenceInfo,
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
}),
|
|
275
|
+
}
|
|
276
|
+
})
|