@livestore/adapter-expo 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/dist/.tsbuildinfo +1 -0
- package/dist/common.d.ts +13 -0
- package/dist/common.d.ts.map +1 -0
- package/dist/common.js +100 -0
- package/dist/common.js.map +1 -0
- package/dist/devtools.d.ts +22 -0
- package/dist/devtools.d.ts.map +1 -0
- package/dist/devtools.js +177 -0
- package/dist/devtools.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +161 -0
- package/dist/index.js.map +1 -0
- package/package.json +33 -0
- package/src/common.ts +115 -0
- package/src/devtools.ts +292 -0
- package/src/index.ts +248 -0
- package/tsconfig.json +10 -0
package/src/common.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { PreparedStatement, SqliteDb } from '@livestore/common'
|
|
2
|
+
import { base64, shouldNeverHappen } from '@livestore/utils'
|
|
3
|
+
import { Effect } from '@livestore/utils/effect'
|
|
4
|
+
import * as ExpoFS from 'expo-file-system'
|
|
5
|
+
import type * as SQLite from 'expo-sqlite'
|
|
6
|
+
|
|
7
|
+
export const makeSqliteDb = (db: SQLite.SQLiteDatabase): SqliteDb => {
|
|
8
|
+
const stmts: PreparedStatement[] = []
|
|
9
|
+
|
|
10
|
+
const sqliteDb: SqliteDb<any> = {
|
|
11
|
+
metadata: { fileName: db.databasePath },
|
|
12
|
+
_tag: 'SqliteDb',
|
|
13
|
+
prepare: (queryStr) => {
|
|
14
|
+
try {
|
|
15
|
+
const dbStmt = db.prepareSync(queryStr)
|
|
16
|
+
const stmt = {
|
|
17
|
+
execute: (bindValues) => {
|
|
18
|
+
// console.log('execute', queryStr, bindValues)
|
|
19
|
+
const res = dbStmt.executeSync(bindValues ?? ([] as any))
|
|
20
|
+
res.resetSync()
|
|
21
|
+
return () => res.changes
|
|
22
|
+
},
|
|
23
|
+
select: (bindValues) => {
|
|
24
|
+
const res = dbStmt.executeSync(bindValues ?? ([] as any))
|
|
25
|
+
try {
|
|
26
|
+
return res.getAllSync() as any
|
|
27
|
+
} finally {
|
|
28
|
+
res.resetSync()
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
finalize: () => dbStmt.finalizeSync(),
|
|
32
|
+
sql: queryStr,
|
|
33
|
+
} satisfies PreparedStatement
|
|
34
|
+
stmts.push(stmt)
|
|
35
|
+
return stmt
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error(`Error preparing statement: ${queryStr}`, e)
|
|
38
|
+
return shouldNeverHappen(`Error preparing statement: ${queryStr}`)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
execute: (queryStr, bindValues) => {
|
|
42
|
+
const stmt = db.prepareSync(queryStr)
|
|
43
|
+
try {
|
|
44
|
+
const res = stmt.executeSync(bindValues ?? ([] as any))
|
|
45
|
+
return () => res.changes
|
|
46
|
+
} finally {
|
|
47
|
+
stmt.finalizeSync()
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
export: () => {
|
|
51
|
+
return db.serializeSync()
|
|
52
|
+
},
|
|
53
|
+
select: (queryStr, bindValues) => {
|
|
54
|
+
const stmt = sqliteDb.prepare(queryStr)
|
|
55
|
+
const res = stmt.select(bindValues)
|
|
56
|
+
stmt.finalize()
|
|
57
|
+
return res as any
|
|
58
|
+
},
|
|
59
|
+
// TODO
|
|
60
|
+
destroy: () => {},
|
|
61
|
+
close: () => {
|
|
62
|
+
for (const stmt of stmts) {
|
|
63
|
+
stmt.finalize()
|
|
64
|
+
}
|
|
65
|
+
return db.closeSync()
|
|
66
|
+
},
|
|
67
|
+
import: () => {
|
|
68
|
+
throw new Error('Not implemented')
|
|
69
|
+
// TODO properly implement this as it seems to require importing to a temporary in-memory db,
|
|
70
|
+
// save it to a file, and then reopen the DB from that file? (see `overwriteDbFile` below)
|
|
71
|
+
},
|
|
72
|
+
session: () => {
|
|
73
|
+
return {
|
|
74
|
+
changeset: () => new Uint8Array(),
|
|
75
|
+
finish: () => {},
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
makeChangeset: (data) => {
|
|
79
|
+
return {
|
|
80
|
+
invert: () => {
|
|
81
|
+
return sqliteDb.makeChangeset(data)
|
|
82
|
+
},
|
|
83
|
+
apply: () => {
|
|
84
|
+
// TODO
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
} satisfies SqliteDb
|
|
89
|
+
|
|
90
|
+
return sqliteDb
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type DbPairRef = {
|
|
94
|
+
current:
|
|
95
|
+
| {
|
|
96
|
+
db: SQLite.SQLiteDatabase
|
|
97
|
+
sqliteDb: SqliteDb
|
|
98
|
+
}
|
|
99
|
+
| undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const getDbFilePath = (dbName: string) => {
|
|
103
|
+
return `${ExpoFS.documentDirectory}SQLite/${dbName}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const overwriteDbFile = (dbName: string, data: Uint8Array) =>
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const path = getDbFilePath(dbName)
|
|
109
|
+
|
|
110
|
+
yield* Effect.promise(() => ExpoFS.deleteAsync(path, { idempotent: true }))
|
|
111
|
+
|
|
112
|
+
// TODO avoid converting to string once the ExpoFS API supports binary data
|
|
113
|
+
const b64String = base64.encode(data)
|
|
114
|
+
yield* Effect.promise(() => ExpoFS.writeAsStringAsync(path, b64String, { encoding: ExpoFS.EncodingType.Base64 }))
|
|
115
|
+
})
|
package/src/devtools.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import type { ClientSession, ConnectDevtoolsToStore } from '@livestore/common'
|
|
3
|
+
import {
|
|
4
|
+
Devtools,
|
|
5
|
+
IntentionalShutdownCause,
|
|
6
|
+
liveStoreVersion,
|
|
7
|
+
MUTATION_LOG_META_TABLE,
|
|
8
|
+
SCHEMA_META_TABLE,
|
|
9
|
+
SCHEMA_MUTATIONS_META_TABLE,
|
|
10
|
+
UnexpectedError,
|
|
11
|
+
} from '@livestore/common'
|
|
12
|
+
import type { PullQueueItem } from '@livestore/common/leader-thread'
|
|
13
|
+
import type { LiveStoreSchema, MutationEvent } from '@livestore/common/schema'
|
|
14
|
+
import { makeExpoDevtoolsChannel } from '@livestore/devtools-expo-common/web-channel'
|
|
15
|
+
import type { ParseResult, Scope } from '@livestore/utils/effect'
|
|
16
|
+
import { Cause, Effect, Queue, Schema, Stream, SubscriptionRef, WebChannel } from '@livestore/utils/effect'
|
|
17
|
+
import * as SQLite from 'expo-sqlite'
|
|
18
|
+
|
|
19
|
+
import type { DbPairRef } from './common.js'
|
|
20
|
+
import { makeSqliteDb, overwriteDbFile } from './common.js'
|
|
21
|
+
|
|
22
|
+
export type BootedDevtools = {
|
|
23
|
+
onMutation: ({
|
|
24
|
+
mutationEventEncoded,
|
|
25
|
+
}: {
|
|
26
|
+
mutationEventEncoded: MutationEvent.AnyEncoded
|
|
27
|
+
}) => Effect.Effect<void, UnexpectedError, never>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const bootDevtools = ({
|
|
31
|
+
connectDevtoolsToStore,
|
|
32
|
+
clientSession,
|
|
33
|
+
schema,
|
|
34
|
+
shutdown,
|
|
35
|
+
dbRef,
|
|
36
|
+
dbMutationLogRef,
|
|
37
|
+
incomingSyncMutationsQueue,
|
|
38
|
+
}: {
|
|
39
|
+
connectDevtoolsToStore: ConnectDevtoolsToStore
|
|
40
|
+
clientSession: ClientSession
|
|
41
|
+
schema: LiveStoreSchema
|
|
42
|
+
dbRef: DbPairRef
|
|
43
|
+
dbMutationLogRef: DbPairRef
|
|
44
|
+
shutdown: (cause: Cause.Cause<UnexpectedError | IntentionalShutdownCause>) => Effect.Effect<void>
|
|
45
|
+
incomingSyncMutationsQueue: Queue.Queue<PullQueueItem>
|
|
46
|
+
}): Effect.Effect<BootedDevtools, UnexpectedError | ParseResult.ParseError, Scope.Scope> =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const appHostId = 'expo'
|
|
49
|
+
const isLeader = true
|
|
50
|
+
|
|
51
|
+
const expoDevtoolsChannel = yield* makeExpoDevtoolsChannel({
|
|
52
|
+
listenSchema: Schema.Union(Devtools.MessageToApp, Devtools.MessageToApp),
|
|
53
|
+
sendSchema: Schema.Union(Devtools.MessageFromApp, Devtools.MessageFromApp),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const isConnected = yield* SubscriptionRef.make(false)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Used to forward messages from `expoDevtoolsChannel` to a "filtered" `storeDevtoolsChannel`
|
|
60
|
+
* which is expected by the `connectDevtoolsToStore` function.
|
|
61
|
+
*/
|
|
62
|
+
const storeDevtoolsChannelProxy = yield* WebChannel.queueChannelProxy({
|
|
63
|
+
schema: { listen: Devtools.MessageToApp, send: Devtools.MessageFromApp },
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
yield* storeDevtoolsChannelProxy.sendQueue.pipe(
|
|
67
|
+
Stream.fromQueue,
|
|
68
|
+
Stream.tap((msg) => expoDevtoolsChannel.send(msg)),
|
|
69
|
+
Stream.runDrain,
|
|
70
|
+
Effect.forkScoped,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const getDatabaseName = (db: DbPairRef) =>
|
|
74
|
+
db.current!.db.databasePath.slice(db.current!.db.databasePath.lastIndexOf('/') + 1)
|
|
75
|
+
|
|
76
|
+
yield* expoDevtoolsChannel.listen.pipe(
|
|
77
|
+
Stream.flatten(),
|
|
78
|
+
Stream.tap((decodedEvent) =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
if (Schema.is(Devtools.MessageToApp)(decodedEvent)) {
|
|
81
|
+
yield* storeDevtoolsChannelProxy.listenQueue.pipe(Queue.offer(decodedEvent))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// if (decodedEvent._tag === 'LSD.DevtoolsReady') {
|
|
86
|
+
// if ((yield* isConnected.get) === false) {
|
|
87
|
+
// // yield* expoDevtoolsChannel.send(Devtools.AppHostReady.make({ appHostId, liveStoreVersion, isLeader }))
|
|
88
|
+
// }
|
|
89
|
+
|
|
90
|
+
// return
|
|
91
|
+
// }
|
|
92
|
+
|
|
93
|
+
// if (decodedEvent._tag === 'LSD.DevtoolsConnected') {
|
|
94
|
+
// if (yield* isConnected.get) {
|
|
95
|
+
// console.warn('devtools already connected')
|
|
96
|
+
// return
|
|
97
|
+
// }
|
|
98
|
+
|
|
99
|
+
// yield* connectDevtoolsToStore(storeDevtoolsChannelProxy.webChannel).pipe(
|
|
100
|
+
// Effect.tapCauseLogPretty,
|
|
101
|
+
// Effect.forkScoped,
|
|
102
|
+
// )
|
|
103
|
+
|
|
104
|
+
// yield* SubscriptionRef.set(isConnected, true)
|
|
105
|
+
// return
|
|
106
|
+
// }
|
|
107
|
+
|
|
108
|
+
// if (decodedEvent._tag === 'LSD.Disconnect') {
|
|
109
|
+
// yield* SubscriptionRef.set(isConnected, false)
|
|
110
|
+
|
|
111
|
+
// // yield* disconnect
|
|
112
|
+
|
|
113
|
+
// // TODO is there a better place for this?
|
|
114
|
+
// yield* expoDevtoolsChannel.send(Devtools.AppHostReady.make({ appHostId, liveStoreVersion, isLeader }))
|
|
115
|
+
|
|
116
|
+
// return
|
|
117
|
+
// }
|
|
118
|
+
|
|
119
|
+
const { requestId } = decodedEvent
|
|
120
|
+
const reqPayload = { requestId, appHostId, liveStoreVersion }
|
|
121
|
+
|
|
122
|
+
switch (decodedEvent._tag) {
|
|
123
|
+
case 'LSD.Ping': {
|
|
124
|
+
yield* expoDevtoolsChannel.send(Devtools.Pong.make({ ...reqPayload }))
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
case 'LSD.Leader.SnapshotReq': {
|
|
128
|
+
const data = yield* clientSession.leaderThread.export
|
|
129
|
+
|
|
130
|
+
yield* expoDevtoolsChannel.send(Devtools.SnapshotRes.make({ snapshot: data!, ...reqPayload }))
|
|
131
|
+
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
case 'LSD.Leader.LoadDatabaseFileReq': {
|
|
135
|
+
const { data } = decodedEvent
|
|
136
|
+
|
|
137
|
+
let tableNames: Set<string>
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const tmpExpoDb = SQLite.deserializeDatabaseSync(data)
|
|
141
|
+
const tmpDb = makeSqliteDb(tmpExpoDb)
|
|
142
|
+
const tableNameResults = tmpDb.select<{ name: string }>(
|
|
143
|
+
`select name from sqlite_master where type = 'table'`,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
tableNames = new Set(tableNameResults.map((_) => _.name))
|
|
147
|
+
|
|
148
|
+
tmpExpoDb.closeSync()
|
|
149
|
+
} catch (e) {
|
|
150
|
+
yield* expoDevtoolsChannel.send(
|
|
151
|
+
Devtools.LoadDatabaseFileRes.make({ ...reqPayload, status: 'unsupported-file' }),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
console.error(e)
|
|
155
|
+
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (tableNames.has(MUTATION_LOG_META_TABLE)) {
|
|
160
|
+
// yield* SubscriptionRef.set(shutdownStateSubRef, 'shutting-down')
|
|
161
|
+
|
|
162
|
+
dbMutationLogRef.current!.db.closeSync()
|
|
163
|
+
|
|
164
|
+
yield* overwriteDbFile(getDatabaseName(dbMutationLogRef), data)
|
|
165
|
+
|
|
166
|
+
dbMutationLogRef.current = undefined
|
|
167
|
+
|
|
168
|
+
dbRef.current!.db.closeSync()
|
|
169
|
+
SQLite.deleteDatabaseSync(getDatabaseName(dbRef))
|
|
170
|
+
} else if (tableNames.has(SCHEMA_META_TABLE) && tableNames.has(SCHEMA_MUTATIONS_META_TABLE)) {
|
|
171
|
+
// yield* SubscriptionRef.set(shutdownStateSubRef, 'shutting-down')
|
|
172
|
+
|
|
173
|
+
// yield* db.import(data)
|
|
174
|
+
|
|
175
|
+
dbRef.current!.db.closeSync()
|
|
176
|
+
|
|
177
|
+
yield* overwriteDbFile(getDatabaseName(dbRef), data)
|
|
178
|
+
} else {
|
|
179
|
+
yield* expoDevtoolsChannel.send(
|
|
180
|
+
Devtools.LoadDatabaseFileRes.make({ ...reqPayload, status: 'unsupported-database' }),
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
yield* expoDevtoolsChannel.send(Devtools.LoadDatabaseFileRes.make({ ...reqPayload, status: 'ok' }))
|
|
186
|
+
|
|
187
|
+
yield* shutdown(Cause.fail(IntentionalShutdownCause.make({ reason: 'devtools-import' })))
|
|
188
|
+
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
case 'LSD.Leader.ResetAllDataReq': {
|
|
192
|
+
const { mode } = decodedEvent
|
|
193
|
+
|
|
194
|
+
dbRef.current!.db.closeSync()
|
|
195
|
+
SQLite.deleteDatabaseSync(getDatabaseName(dbRef))
|
|
196
|
+
|
|
197
|
+
if (mode === 'all-data') {
|
|
198
|
+
dbMutationLogRef.current!.db.closeSync()
|
|
199
|
+
SQLite.deleteDatabaseSync(getDatabaseName(dbMutationLogRef))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
yield* expoDevtoolsChannel.send(Devtools.ResetAllDataRes.make({ ...reqPayload }))
|
|
203
|
+
|
|
204
|
+
yield* shutdown(Cause.fail(IntentionalShutdownCause.make({ reason: 'devtools-reset' })))
|
|
205
|
+
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
case 'LSD.Leader.DatabaseFileInfoReq': {
|
|
209
|
+
const dbSizeQuery = `SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size();`
|
|
210
|
+
const dbFileSize = dbRef.current!.db.prepareSync(dbSizeQuery).executeSync<any>().getFirstSync()!
|
|
211
|
+
.size as number
|
|
212
|
+
const mutationLogFileSize = dbMutationLogRef
|
|
213
|
+
.current!.db.prepareSync(dbSizeQuery)
|
|
214
|
+
.executeSync<any>()
|
|
215
|
+
.getFirstSync()!.size as number
|
|
216
|
+
|
|
217
|
+
yield* expoDevtoolsChannel.send(
|
|
218
|
+
Devtools.DatabaseFileInfoRes.make({
|
|
219
|
+
readModel: { fileSize: dbFileSize, persistenceInfo: { fileName: 'livestore.db' } },
|
|
220
|
+
mutationLog: {
|
|
221
|
+
fileSize: mutationLogFileSize,
|
|
222
|
+
persistenceInfo: { fileName: 'livestore-mutationlog.db' },
|
|
223
|
+
},
|
|
224
|
+
...reqPayload,
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
case 'LSD.Leader.MutationLogReq': {
|
|
231
|
+
const mutationLog = yield* clientSession.leaderThread.getMutationLogData
|
|
232
|
+
|
|
233
|
+
yield* expoDevtoolsChannel.send(Devtools.MutationLogRes.make({ mutationLog, ...reqPayload }))
|
|
234
|
+
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
case 'LSD.Leader.RunMutationReq': {
|
|
238
|
+
const { mutationEventEncoded: mutationEventEncoded_ } = decodedEvent
|
|
239
|
+
const mutationDef = schema.mutations.get(mutationEventEncoded_.mutation)!
|
|
240
|
+
// const nextMutationEventIdPair = clientSession.mutations.nextMutationEventIdPair({
|
|
241
|
+
// clientOnly: mutationDef.options.clientOnly,
|
|
242
|
+
// })
|
|
243
|
+
|
|
244
|
+
// const mutationEventEncoded = new MutationEvent.EncodedWithMeta({
|
|
245
|
+
// ...mutationEventEncoded_,
|
|
246
|
+
// // ...nextMutationEventIdPair,
|
|
247
|
+
// })
|
|
248
|
+
|
|
249
|
+
// const mutationEventDecoded = yield* Schema.decode(mutationEventSchema)(mutationEventEncoded)
|
|
250
|
+
// yield* Queue.offer(incomingSyncMutationsQueue, {
|
|
251
|
+
// payload: { _tag: 'upstream-advance', newEvents: [mutationEventEncoded] },
|
|
252
|
+
// remaining: 0,
|
|
253
|
+
// })
|
|
254
|
+
|
|
255
|
+
// const mutationDef =
|
|
256
|
+
// schema.mutations.get(mutationEventEncoded.mutation) ??
|
|
257
|
+
// shouldNeverHappen(`Unknown mutation: ${mutationEventEncoded.mutation}`)
|
|
258
|
+
|
|
259
|
+
// yield* clientSession.mutations.push([mutationEventEncoded])
|
|
260
|
+
|
|
261
|
+
yield* expoDevtoolsChannel.send(Devtools.RunMutationRes.make({ ...reqPayload }))
|
|
262
|
+
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
case 'LSD.Leader.SyncingInfoReq': {
|
|
266
|
+
const syncingInfo = Devtools.SyncingInfo.make({
|
|
267
|
+
enabled: false,
|
|
268
|
+
metadata: {},
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
yield* expoDevtoolsChannel.send(Devtools.SyncingInfoRes.make({ syncingInfo, ...reqPayload }))
|
|
272
|
+
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
Stream.runDrain,
|
|
279
|
+
Effect.tapCauseLogPretty,
|
|
280
|
+
Effect.forkScoped,
|
|
281
|
+
)
|
|
282
|
+
// yield* expoDevtoolsChannel.send(Devtools.AppHostReady.make({ appHostId, isLeader, liveStoreVersion }))
|
|
283
|
+
|
|
284
|
+
const onMutation = ({ mutationEventEncoded }: { mutationEventEncoded: MutationEvent.AnyEncoded }) =>
|
|
285
|
+
expoDevtoolsChannel
|
|
286
|
+
.send(Devtools.MutationBroadcast.make({ mutationEventEncoded, liveStoreVersion }))
|
|
287
|
+
.pipe(UnexpectedError.mapToUnexpectedError)
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
onMutation,
|
|
291
|
+
}
|
|
292
|
+
}).pipe(Effect.withSpan('@livestore/adapter-expo:bootDevtools'))
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Adapter, ClientSession, LockStatus, PreparedBindValues } from '@livestore/common'
|
|
2
|
+
import {
|
|
3
|
+
getExecArgsFromMutation,
|
|
4
|
+
initializeSingletonTables,
|
|
5
|
+
liveStoreStorageFormatVersion,
|
|
6
|
+
migrateDb,
|
|
7
|
+
migrateTable,
|
|
8
|
+
rehydrateFromMutationLog,
|
|
9
|
+
sql,
|
|
10
|
+
UnexpectedError,
|
|
11
|
+
} from '@livestore/common'
|
|
12
|
+
import type { PullQueueItem } from '@livestore/common/leader-thread'
|
|
13
|
+
import type { MutationLogMetaRow } from '@livestore/common/schema'
|
|
14
|
+
import { EventId, MUTATION_LOG_META_TABLE, MutationEvent, mutationLogMetaTable } from '@livestore/common/schema'
|
|
15
|
+
import { insertRowPrepared, makeBindValues } from '@livestore/common/sql-queries'
|
|
16
|
+
import { casesHandled, shouldNeverHappen } from '@livestore/utils'
|
|
17
|
+
import { Effect, Option, Queue, Schema, Stream, SubscriptionRef } from '@livestore/utils/effect'
|
|
18
|
+
import * as SQLite from 'expo-sqlite'
|
|
19
|
+
|
|
20
|
+
import { makeSqliteDb } from './common.js'
|
|
21
|
+
import type { BootedDevtools } from './devtools.js'
|
|
22
|
+
import { bootDevtools } from './devtools.js'
|
|
23
|
+
|
|
24
|
+
export type MakeDbOptions = {
|
|
25
|
+
fileNamePrefix?: string
|
|
26
|
+
subDirectory?: string
|
|
27
|
+
// syncBackend?: TODO
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// TODO refactor with leader-thread code from `@livestore/common/leader-thread`
|
|
31
|
+
export const makeAdapter =
|
|
32
|
+
(options?: MakeDbOptions): Adapter =>
|
|
33
|
+
({ schema, connectDevtoolsToStore, shutdown, devtoolsEnabled }) =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const { fileNamePrefix, subDirectory } = options ?? {}
|
|
36
|
+
const migrationOptions = schema.migrationOptions
|
|
37
|
+
const subDirectoryPath = subDirectory ? subDirectory.replace(/\/$/, '') + '/' : ''
|
|
38
|
+
const fullDbFilePath = `${subDirectoryPath}${fileNamePrefix ?? 'livestore-'}${schema.hash}@${liveStoreStorageFormatVersion}.db`
|
|
39
|
+
const db = SQLite.openDatabaseSync(fullDbFilePath)
|
|
40
|
+
|
|
41
|
+
const dbRef = { current: { db, sqliteDb: makeSqliteDb(db) } }
|
|
42
|
+
|
|
43
|
+
const dbWasEmptyWhenOpened = dbRef.current.sqliteDb.select('SELECT 1 FROM sqlite_master').length === 0
|
|
44
|
+
|
|
45
|
+
const dbMutationLog = SQLite.openDatabaseSync(
|
|
46
|
+
`${subDirectory ?? ''}${fileNamePrefix ?? 'livestore-'}mutationlog@${liveStoreStorageFormatVersion}.db`,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const dbMutationLogRef = { current: { db: dbMutationLog, sqliteDb: makeSqliteDb(dbMutationLog) } }
|
|
50
|
+
|
|
51
|
+
const dbMutationLogWasEmptyWhenOpened =
|
|
52
|
+
dbMutationLogRef.current.sqliteDb.select('SELECT 1 FROM sqlite_master').length === 0
|
|
53
|
+
|
|
54
|
+
yield* Effect.addFinalizer(() =>
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
// Ignoring in case the database is already closed
|
|
57
|
+
yield* Effect.try(() => db.closeSync()).pipe(Effect.ignore)
|
|
58
|
+
yield* Effect.try(() => dbMutationLog.closeSync()).pipe(Effect.ignore)
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if (dbMutationLogWasEmptyWhenOpened) {
|
|
63
|
+
yield* migrateTable({
|
|
64
|
+
db: dbMutationLogRef.current.sqliteDb,
|
|
65
|
+
behaviour: 'create-if-not-exists',
|
|
66
|
+
tableAst: mutationLogMetaTable.sqliteDef.ast,
|
|
67
|
+
skipMetaTable: true,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (dbWasEmptyWhenOpened) {
|
|
72
|
+
yield* migrateDb({ db: dbRef.current.sqliteDb, schema })
|
|
73
|
+
|
|
74
|
+
initializeSingletonTables(schema, dbRef.current.sqliteDb)
|
|
75
|
+
|
|
76
|
+
switch (migrationOptions.strategy) {
|
|
77
|
+
case 'from-mutation-log': {
|
|
78
|
+
// TODO bring back
|
|
79
|
+
// yield* rehydrateFromMutationLog({
|
|
80
|
+
// db: dbRef.current.sqliteDb,
|
|
81
|
+
// logDb: dbMutationLogRef.current.sqliteDb,
|
|
82
|
+
// schema,
|
|
83
|
+
// migrationOptions,
|
|
84
|
+
// onProgress: () => Effect.void,
|
|
85
|
+
// })
|
|
86
|
+
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
case 'hard-reset': {
|
|
90
|
+
// This is already the case by note doing anything now
|
|
91
|
+
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
case 'manual': {
|
|
95
|
+
// const migrateFn = migrationStrategy.migrate
|
|
96
|
+
console.warn('Manual migration strategy not implemented yet')
|
|
97
|
+
|
|
98
|
+
// TODO figure out a way to get previous database file to pass to the migration function
|
|
99
|
+
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
default: {
|
|
103
|
+
casesHandled(migrationOptions)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const mutationLogExclude =
|
|
109
|
+
migrationOptions.strategy === 'from-mutation-log'
|
|
110
|
+
? (migrationOptions.excludeMutations ?? new Set(['livestore.RawSql']))
|
|
111
|
+
: new Set(['livestore.RawSql'])
|
|
112
|
+
|
|
113
|
+
const mutationEventSchema = MutationEvent.makeMutationEventSchema(schema)
|
|
114
|
+
const mutationDefSchemaHashMap = new Map(
|
|
115
|
+
[...schema.mutations.entries()].map(([k, v]) => [k, Schema.hash(v.schema)] as const),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const newMutationLogStmt = dbMutationLogRef.current.sqliteDb.prepare(
|
|
119
|
+
insertRowPrepared({ tableName: MUTATION_LOG_META_TABLE, columns: mutationLogMetaTable.sqliteDef.columns }),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const lockStatus = SubscriptionRef.make<LockStatus>('has-lock').pipe(Effect.runSync)
|
|
123
|
+
|
|
124
|
+
const incomingSyncMutationsQueue = yield* Queue.unbounded<PullQueueItem>().pipe(
|
|
125
|
+
Effect.acquireRelease(Queue.shutdown),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const initialMutationEventIdSchema = mutationLogMetaTable.schema.pipe(
|
|
129
|
+
Schema.pick('idGlobal', 'idClient'),
|
|
130
|
+
Schema.transform(EventId.EventId, {
|
|
131
|
+
encode: (_) => ({ idGlobal: _.global, idClient: _.client }),
|
|
132
|
+
decode: (_) => EventId.make({ global: _.idGlobal, client: _.idClient }),
|
|
133
|
+
strict: false,
|
|
134
|
+
}),
|
|
135
|
+
Schema.Array,
|
|
136
|
+
Schema.headOrElse(() => EventId.make({ global: 0, client: 0 })),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const initialMutationEventId = yield* Schema.decode(initialMutationEventIdSchema)(
|
|
140
|
+
dbMutationLogRef.current.sqliteDb.select(
|
|
141
|
+
sql`SELECT idGlobal, idClient FROM ${MUTATION_LOG_META_TABLE} ORDER BY idGlobal DESC, idClient DESC LIMIT 1`,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
let devtools: BootedDevtools | undefined
|
|
146
|
+
|
|
147
|
+
const clientSession = {
|
|
148
|
+
devtools: { enabled: false },
|
|
149
|
+
lockStatus,
|
|
150
|
+
// Expo doesn't support multiple client sessions, so we just use a fixed session id
|
|
151
|
+
clientId: 'expo',
|
|
152
|
+
sessionId: 'expo',
|
|
153
|
+
leaderThread: {
|
|
154
|
+
mutations: {
|
|
155
|
+
pull: Stream.fromQueue(incomingSyncMutationsQueue),
|
|
156
|
+
push: (batch): Effect.Effect<void, UnexpectedError> =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
for (const mutationEventEncoded of batch) {
|
|
159
|
+
if (migrationOptions.strategy !== 'from-mutation-log') return
|
|
160
|
+
|
|
161
|
+
const mutation = mutationEventEncoded.mutation
|
|
162
|
+
const mutationDef =
|
|
163
|
+
schema.mutations.get(mutation) ?? shouldNeverHappen(`Unknown mutation: ${mutation}`)
|
|
164
|
+
|
|
165
|
+
const execArgsArr = getExecArgsFromMutation({
|
|
166
|
+
mutationDef,
|
|
167
|
+
mutationEvent: { decoded: undefined, encoded: mutationEventEncoded },
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// write to mutation_log
|
|
171
|
+
if (
|
|
172
|
+
mutationLogExclude.has(mutation) === false &&
|
|
173
|
+
execArgsArr.some((_) => _.statementSql.includes('__livestore')) === false
|
|
174
|
+
) {
|
|
175
|
+
const mutationDefSchemaHash =
|
|
176
|
+
mutationDefSchemaHashMap.get(mutation) ?? shouldNeverHappen(`Unknown mutation: ${mutation}`)
|
|
177
|
+
|
|
178
|
+
const argsJson = JSON.stringify(mutationEventEncoded.args ?? {})
|
|
179
|
+
const mutationLogRowValues = {
|
|
180
|
+
idGlobal: mutationEventEncoded.id.global,
|
|
181
|
+
idClient: mutationEventEncoded.id.client,
|
|
182
|
+
mutation: mutationEventEncoded.mutation,
|
|
183
|
+
argsJson,
|
|
184
|
+
schemaHash: mutationDefSchemaHash,
|
|
185
|
+
syncMetadataJson: Option.none(),
|
|
186
|
+
parentIdGlobal: mutationEventEncoded.parentId.global,
|
|
187
|
+
parentIdClient: mutationEventEncoded.parentId.client,
|
|
188
|
+
} satisfies MutationLogMetaRow
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
newMutationLogStmt.execute(
|
|
192
|
+
makeBindValues({
|
|
193
|
+
columns: mutationLogMetaTable.sqliteDef.columns,
|
|
194
|
+
values: mutationLogRowValues,
|
|
195
|
+
variablePrefix: '$',
|
|
196
|
+
}) as PreparedBindValues,
|
|
197
|
+
)
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error('Error writing to mutation_log', e, mutationLogRowValues)
|
|
200
|
+
debugger
|
|
201
|
+
throw e
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
// console.debug('livestore-webworker: skipping mutation log write', mutation, statementSql, bindValues)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
yield* devtools?.onMutation({ mutationEventEncoded }) ?? Effect.void
|
|
208
|
+
}
|
|
209
|
+
}),
|
|
210
|
+
},
|
|
211
|
+
initialState: {
|
|
212
|
+
migrationsReport: {
|
|
213
|
+
migrations: [],
|
|
214
|
+
},
|
|
215
|
+
leaderHead: initialMutationEventId,
|
|
216
|
+
},
|
|
217
|
+
export: Effect.sync(() => dbRef.current.sqliteDb.export()),
|
|
218
|
+
getMutationLogData: Effect.sync(() => dbMutationLogRef.current.sqliteDb.export()),
|
|
219
|
+
networkStatus: SubscriptionRef.make({ isConnected: false, timestampMs: Date.now(), latchClosed: false }).pipe(
|
|
220
|
+
Effect.runSync,
|
|
221
|
+
),
|
|
222
|
+
sendDevtoolsMessage: () => Effect.dieMessage('Not implemented'),
|
|
223
|
+
getSyncState: Effect.dieMessage('Not implemented'),
|
|
224
|
+
},
|
|
225
|
+
shutdown: () => Effect.dieMessage('TODO implement shutdown'),
|
|
226
|
+
sqliteDb: dbRef.current.sqliteDb,
|
|
227
|
+
} satisfies ClientSession
|
|
228
|
+
|
|
229
|
+
if (devtoolsEnabled) {
|
|
230
|
+
devtools = yield* bootDevtools({
|
|
231
|
+
connectDevtoolsToStore,
|
|
232
|
+
clientSession,
|
|
233
|
+
schema,
|
|
234
|
+
dbRef,
|
|
235
|
+
dbMutationLogRef,
|
|
236
|
+
shutdown,
|
|
237
|
+
incomingSyncMutationsQueue,
|
|
238
|
+
}).pipe(
|
|
239
|
+
Effect.tapCauseLogPretty,
|
|
240
|
+
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return clientSession
|
|
245
|
+
}).pipe(
|
|
246
|
+
Effect.mapError((cause) => (cause._tag === 'LiveStore.UnexpectedError' ? cause : new UnexpectedError({ cause }))),
|
|
247
|
+
Effect.tapCauseLogPretty,
|
|
248
|
+
)
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
|
7
|
+
},
|
|
8
|
+
"include": ["./src"],
|
|
9
|
+
"references": [{ "path": "../common" } ,{ "path": "../utils" }, { "path": "../devtools-expo-common" }]
|
|
10
|
+
}
|