@livestore/cli 0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c → 0.0.0-snapshot-75de2b24a1fb1df981b56b5d9ec7381ea351e95b

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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Shared sync operations for CLI and MCP.
3
+ * Contains the core logic for exporting and importing events from sync backends.
4
+ */
5
+ import path from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import type { SyncBackend } from '@livestore/common'
8
+ import { UnknownError } from '@livestore/common'
9
+ import { isLiveStoreSchema, LiveStoreEvent } from '@livestore/common/schema'
10
+ import { shouldNeverHappen } from '@livestore/utils'
11
+ import {
12
+ Cause,
13
+ Effect,
14
+ FileSystem,
15
+ type HttpClient,
16
+ KeyValueStore,
17
+ Layer,
18
+ Option,
19
+ Schema,
20
+ type Scope,
21
+ Stream,
22
+ } from '@livestore/utils/effect'
23
+
24
+ /** Connection timeout for sync backend ping (5 seconds) */
25
+ const CONNECTION_TIMEOUT_MS = 5000
26
+
27
+ /**
28
+ * Schema for the export file format.
29
+ * Contains metadata about the export and an array of events in global encoded format.
30
+ */
31
+ export const ExportFileSchema = Schema.Struct({
32
+ /** Format version for future compatibility */
33
+ version: Schema.Literal(1),
34
+ /** Store identifier */
35
+ storeId: Schema.String,
36
+ /** ISO timestamp of when the export was created */
37
+ exportedAt: Schema.String,
38
+ /** Total number of events in the export */
39
+ eventCount: Schema.Number,
40
+ /** Array of events in global encoded format */
41
+ events: Schema.Array(LiveStoreEvent.Global.Encoded),
42
+ })
43
+
44
+ export type ExportFile = typeof ExportFileSchema.Type
45
+
46
+ export class ConnectionError extends Schema.TaggedError<ConnectionError>()('ConnectionError', {
47
+ cause: Schema.Defect,
48
+ note: Schema.String,
49
+ }) {}
50
+
51
+ export class ExportError extends Schema.TaggedError<ExportError>()('ExportError', {
52
+ cause: Schema.Defect,
53
+ note: Schema.String,
54
+ }) {}
55
+
56
+ export class ImportError extends Schema.TaggedError<ImportError>()('ImportError', {
57
+ cause: Schema.Defect,
58
+ note: Schema.String,
59
+ }) {}
60
+
61
+ /**
62
+ * Creates a sync backend connection from a user module and verifies connectivity.
63
+ * This is a simplified version of the MCP runtime that only creates the sync backend.
64
+ */
65
+ export const makeSyncBackend = ({
66
+ storePath,
67
+ storeId,
68
+ clientId,
69
+ }: {
70
+ storePath: string
71
+ storeId: string
72
+ clientId: string
73
+ }): Effect.Effect<
74
+ SyncBackend.SyncBackend,
75
+ UnknownError | ConnectionError,
76
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
77
+ > =>
78
+ Effect.gen(function* () {
79
+ const abs = path.isAbsolute(storePath) ? storePath : path.resolve(process.cwd(), storePath)
80
+
81
+ const fs = yield* FileSystem.FileSystem
82
+ const exists = yield* fs.exists(abs).pipe(UnknownError.mapToUnknownError)
83
+ if (!exists) {
84
+ return yield* Effect.fail(
85
+ UnknownError.make({
86
+ cause: `Store module not found at ${abs}`,
87
+ note: 'Make sure the path points to a valid LiveStore module',
88
+ }),
89
+ )
90
+ }
91
+
92
+ const mod = yield* Effect.tryPromise({
93
+ try: () => import(pathToFileURL(abs).href),
94
+ catch: (cause) =>
95
+ UnknownError.make({
96
+ cause,
97
+ note: `Failed to import module at ${abs}`,
98
+ }),
99
+ })
100
+
101
+ const schema = (mod as any)?.schema
102
+ if (!isLiveStoreSchema(schema)) {
103
+ return yield* Effect.fail(
104
+ UnknownError.make({
105
+ cause: `Module at ${abs} must export a valid LiveStore 'schema'`,
106
+ note: `Ex: export { schema } from './src/livestore/schema.ts'`,
107
+ }),
108
+ )
109
+ }
110
+
111
+ const syncBackendConstructor = (mod as any)?.syncBackend
112
+ if (typeof syncBackendConstructor !== 'function') {
113
+ return yield* Effect.fail(
114
+ UnknownError.make({
115
+ cause: `Module at ${abs} must export a 'syncBackend' constructor`,
116
+ note: `Ex: export const syncBackend = makeWsSync({ url })`,
117
+ }),
118
+ )
119
+ }
120
+
121
+ const syncPayloadSchemaExport = (mod as any)?.syncPayloadSchema
122
+ const syncPayloadSchema =
123
+ syncPayloadSchemaExport === undefined
124
+ ? Schema.JsonValue
125
+ : Schema.isSchema(syncPayloadSchemaExport)
126
+ ? (syncPayloadSchemaExport as Schema.Schema<any>)
127
+ : shouldNeverHappen(
128
+ `Exported 'syncPayloadSchema' from ${abs} must be an Effect Schema (received ${typeof syncPayloadSchemaExport}).`,
129
+ )
130
+
131
+ const syncPayloadExport = (mod as any)?.syncPayload
132
+ const syncPayload = yield* (
133
+ syncPayloadExport === undefined
134
+ ? Effect.succeed<unknown>(undefined)
135
+ : Schema.decodeUnknown(syncPayloadSchema)(syncPayloadExport)
136
+ ).pipe(UnknownError.mapToUnknownError)
137
+
138
+ /** Simple in-memory key-value store for sync backend state */
139
+ const kvStore: { backendId: string | undefined } = { backendId: undefined }
140
+
141
+ const syncBackend = yield* (syncBackendConstructor as SyncBackend.SyncBackendConstructor)({
142
+ storeId,
143
+ clientId,
144
+ payload: syncPayload,
145
+ }).pipe(
146
+ Effect.provide(
147
+ Layer.succeed(
148
+ KeyValueStore.KeyValueStore,
149
+ KeyValueStore.makeStringOnly({
150
+ get: (_key) => Effect.succeed(Option.fromNullable(kvStore.backendId)),
151
+ set: (_key, value) =>
152
+ Effect.sync(() => {
153
+ kvStore.backendId = value
154
+ }),
155
+ clear: Effect.dieMessage('Not implemented'),
156
+ remove: () => Effect.dieMessage('Not implemented'),
157
+ size: Effect.dieMessage('Not implemented'),
158
+ }),
159
+ ),
160
+ ),
161
+ UnknownError.mapToUnknownError,
162
+ )
163
+
164
+ /** Connect to the sync backend */
165
+ yield* syncBackend.connect.pipe(
166
+ Effect.mapError(
167
+ (cause) =>
168
+ new ConnectionError({
169
+ cause,
170
+ note: `Failed to connect to sync backend: ${cause._tag === 'IsOfflineError' ? 'Backend is offline or unreachable' : String(cause)}`,
171
+ }),
172
+ ),
173
+ )
174
+
175
+ /** Verify connectivity with a ping (with timeout) */
176
+ yield* syncBackend.ping.pipe(
177
+ Effect.timeout(CONNECTION_TIMEOUT_MS),
178
+ Effect.catchAll((cause) => {
179
+ if (Cause.isTimeoutException(cause)) {
180
+ return Effect.fail(
181
+ new ConnectionError({
182
+ cause,
183
+ note: `Connection timeout: Sync backend did not respond within ${CONNECTION_TIMEOUT_MS}ms`,
184
+ }),
185
+ )
186
+ }
187
+ return Effect.fail(
188
+ new ConnectionError({
189
+ cause,
190
+ note: `Failed to ping sync backend: ${cause._tag === 'IsOfflineError' ? 'Backend is offline or unreachable' : String(cause)}`,
191
+ }),
192
+ )
193
+ }),
194
+ )
195
+
196
+ return syncBackend
197
+ })
198
+
199
+ export interface ExportResult {
200
+ storeId: string
201
+ eventCount: number
202
+ exportedAt: string
203
+ /** The export data as JSON string (for MCP) or written to file (for CLI) */
204
+ data: ExportFile
205
+ }
206
+
207
+ /**
208
+ * Core export operation - pulls all events from sync backend.
209
+ * Returns the export data structure without writing to file.
210
+ */
211
+ export const pullEventsFromSyncBackend = ({
212
+ storePath,
213
+ storeId,
214
+ clientId,
215
+ }: {
216
+ storePath: string
217
+ storeId: string
218
+ clientId: string
219
+ }): Effect.Effect<
220
+ ExportResult,
221
+ ExportError | UnknownError | ConnectionError,
222
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
223
+ > =>
224
+ Effect.gen(function* () {
225
+ const syncBackend = yield* makeSyncBackend({ storePath, storeId, clientId })
226
+
227
+ const events: LiveStoreEvent.Global.Encoded[] = []
228
+
229
+ yield* syncBackend.pull(Option.none(), { live: false }).pipe(
230
+ Stream.tap((item) =>
231
+ Effect.sync(() => {
232
+ for (const { eventEncoded } of item.batch) {
233
+ events.push(eventEncoded)
234
+ }
235
+ }),
236
+ ),
237
+ Stream.takeUntil((item) => item.pageInfo._tag === 'NoMore'),
238
+ Stream.runDrain,
239
+ Effect.mapError(
240
+ (cause) =>
241
+ new ExportError({
242
+ cause,
243
+ note: `Failed to pull events from sync backend: ${cause}`,
244
+ }),
245
+ ),
246
+ )
247
+
248
+ const exportedAt = new Date().toISOString()
249
+ const exportData: ExportFile = {
250
+ version: 1,
251
+ storeId,
252
+ exportedAt,
253
+ eventCount: events.length,
254
+ events,
255
+ }
256
+
257
+ return {
258
+ storeId,
259
+ eventCount: events.length,
260
+ exportedAt,
261
+ data: exportData,
262
+ }
263
+ }).pipe(Effect.withSpan('sync:pullEvents'))
264
+
265
+ export interface ImportResult {
266
+ storeId: string
267
+ eventCount: number
268
+ /** Whether this was a dry run */
269
+ dryRun: boolean
270
+ }
271
+
272
+ export interface ImportValidationResult {
273
+ storeId: string
274
+ eventCount: number
275
+ sourceStoreId: string
276
+ storeIdMismatch: boolean
277
+ }
278
+
279
+ /**
280
+ * Validates an export file for import.
281
+ * Returns validation info without actually importing.
282
+ */
283
+ export const validateExportData = ({
284
+ data,
285
+ targetStoreId,
286
+ }: {
287
+ data: unknown
288
+ targetStoreId: string
289
+ }): Effect.Effect<ImportValidationResult, ImportError> =>
290
+ Effect.gen(function* () {
291
+ const exportData = yield* Schema.decodeUnknown(ExportFileSchema)(data).pipe(
292
+ Effect.mapError(
293
+ (cause) =>
294
+ new ImportError({
295
+ cause: new Error(`Invalid export file format: ${cause}`),
296
+ note: `Invalid export file format: ${cause}`,
297
+ }),
298
+ ),
299
+ )
300
+
301
+ return {
302
+ storeId: targetStoreId,
303
+ eventCount: exportData.events.length,
304
+ sourceStoreId: exportData.storeId,
305
+ storeIdMismatch: exportData.storeId !== targetStoreId,
306
+ }
307
+ })
308
+
309
+ /**
310
+ * Core import operation - pushes events to sync backend.
311
+ * Validates that the backend is empty before importing.
312
+ */
313
+ export const pushEventsToSyncBackend = ({
314
+ storePath,
315
+ storeId,
316
+ clientId,
317
+ data,
318
+ force,
319
+ dryRun,
320
+ }: {
321
+ storePath: string
322
+ storeId: string
323
+ clientId: string
324
+ /** The export data to import (already parsed) */
325
+ data: unknown
326
+ force: boolean
327
+ dryRun: boolean
328
+ }): Effect.Effect<
329
+ ImportResult,
330
+ ImportError | UnknownError | ConnectionError,
331
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
332
+ > =>
333
+ Effect.gen(function* () {
334
+ const exportData = yield* Schema.decodeUnknown(ExportFileSchema)(data).pipe(
335
+ Effect.mapError(
336
+ (cause) =>
337
+ new ImportError({
338
+ cause: new Error(`Invalid export file format: ${cause}`),
339
+ note: `Invalid export file format: ${cause}`,
340
+ }),
341
+ ),
342
+ )
343
+
344
+ if (exportData.storeId !== storeId && !force) {
345
+ return yield* Effect.fail(
346
+ new ImportError({
347
+ cause: new Error(`Store ID mismatch: file has '${exportData.storeId}', expected '${storeId}'`),
348
+ note: `The export file was created for a different store. Use force option to import anyway.`,
349
+ }),
350
+ )
351
+ }
352
+
353
+ if (dryRun) {
354
+ return {
355
+ storeId,
356
+ eventCount: exportData.events.length,
357
+ dryRun: true,
358
+ }
359
+ }
360
+
361
+ const syncBackend = yield* makeSyncBackend({ storePath, storeId, clientId })
362
+
363
+ /** Check if events already exist by pulling from the backend first */
364
+ let existingEventCount = 0
365
+ yield* syncBackend.pull(Option.none(), { live: false }).pipe(
366
+ Stream.tap((item) =>
367
+ Effect.sync(() => {
368
+ existingEventCount += item.batch.length
369
+ }),
370
+ ),
371
+ Stream.takeUntil((item) => item.pageInfo._tag === 'NoMore'),
372
+ Stream.runDrain,
373
+ Effect.mapError(
374
+ (cause) =>
375
+ new ImportError({
376
+ cause,
377
+ note: `Failed to check existing events: ${cause}`,
378
+ }),
379
+ ),
380
+ )
381
+
382
+ if (existingEventCount > 0) {
383
+ return yield* Effect.fail(
384
+ new ImportError({
385
+ cause: new Error(`Sync backend already contains ${existingEventCount} events`),
386
+ note: `Cannot import into a non-empty sync backend. The sync backend must be empty.`,
387
+ }),
388
+ )
389
+ }
390
+
391
+ /** Push events in batches of 100 (sync backend constraint) */
392
+ const batchSize = 100
393
+
394
+ for (let i = 0; i < exportData.events.length; i += batchSize) {
395
+ const batch = exportData.events.slice(i, i + batchSize)
396
+
397
+ yield* syncBackend.push(batch).pipe(
398
+ Effect.mapError(
399
+ (cause) =>
400
+ new ImportError({
401
+ cause,
402
+ note: `Failed to push events at position ${i}: ${cause}`,
403
+ }),
404
+ ),
405
+ )
406
+ }
407
+
408
+ return {
409
+ storeId,
410
+ eventCount: exportData.events.length,
411
+ dryRun: false,
412
+ }
413
+ }).pipe(Effect.withSpan('sync:pushEvents'))