@livestore/cli 0.0.0-snapshot-f32dde17378384e071007a6d0baf04b92066d7d5 → 0.0.0-snapshot-0f27d343553b9bb260543bf20de36d216f53c5d8

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 (50) hide show
  1. package/dist/__tests__/fixtures/mock-config.d.ts +56 -0
  2. package/dist/__tests__/fixtures/mock-config.d.ts.map +1 -0
  3. package/dist/__tests__/fixtures/mock-config.js +88 -0
  4. package/dist/__tests__/fixtures/mock-config.js.map +1 -0
  5. package/dist/__tests__/sync-operations.test.d.ts +2 -0
  6. package/dist/__tests__/sync-operations.test.d.ts.map +1 -0
  7. package/dist/__tests__/sync-operations.test.js +167 -0
  8. package/dist/__tests__/sync-operations.test.js.map +1 -0
  9. package/dist/cli.d.ts +15 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +2 -1
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/import-export.d.ts +34 -0
  14. package/dist/commands/import-export.d.ts.map +1 -0
  15. package/dist/commands/import-export.js +133 -0
  16. package/dist/commands/import-export.js.map +1 -0
  17. package/dist/commands/mcp-coach.d.ts +2 -2
  18. package/dist/commands/mcp-coach.d.ts.map +1 -1
  19. package/dist/commands/mcp-tool-handlers.d.ts +5 -1
  20. package/dist/commands/mcp-tool-handlers.d.ts.map +1 -1
  21. package/dist/commands/mcp-tool-handlers.js +42 -4
  22. package/dist/commands/mcp-tool-handlers.js.map +1 -1
  23. package/dist/commands/mcp-tools-defs.d.ts +31 -1
  24. package/dist/commands/mcp-tools-defs.d.ts.map +1 -1
  25. package/dist/commands/mcp-tools-defs.js +87 -5
  26. package/dist/commands/mcp-tools-defs.js.map +1 -1
  27. package/dist/commands/new-project.d.ts +1 -1
  28. package/dist/mcp-runtime/runtime.d.ts +4 -3
  29. package/dist/mcp-runtime/runtime.d.ts.map +1 -1
  30. package/dist/mcp-runtime/runtime.js +20 -53
  31. package/dist/mcp-runtime/runtime.js.map +1 -1
  32. package/dist/module-loader.d.ts +22 -0
  33. package/dist/module-loader.d.ts.map +1 -0
  34. package/dist/module-loader.js +75 -0
  35. package/dist/module-loader.js.map +1 -0
  36. package/dist/sync-operations.d.ts +121 -0
  37. package/dist/sync-operations.d.ts.map +1 -0
  38. package/dist/sync-operations.js +180 -0
  39. package/dist/sync-operations.js.map +1 -0
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +15 -8
  42. package/src/__tests__/fixtures/mock-config.ts +104 -0
  43. package/src/__tests__/sync-operations.test.ts +230 -0
  44. package/src/cli.ts +2 -1
  45. package/src/commands/import-export.ts +278 -0
  46. package/src/commands/mcp-tool-handlers.ts +50 -5
  47. package/src/commands/mcp-tools-defs.ts +92 -4
  48. package/src/mcp-runtime/runtime.ts +32 -65
  49. package/src/module-loader.ts +93 -0
  50. package/src/sync-operations.ts +360 -0
@@ -0,0 +1,360 @@
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 type { SyncBackend } from '@livestore/common'
6
+ import { UnknownError } from '@livestore/common'
7
+ import { LiveStoreEvent } from '@livestore/common/schema'
8
+ import {
9
+ Cause,
10
+ Chunk,
11
+ Effect,
12
+ type FileSystem,
13
+ type HttpClient,
14
+ KeyValueStore,
15
+ Option,
16
+ Schema,
17
+ type Scope,
18
+ Stream,
19
+ SubscriptionRef,
20
+ } from '@livestore/utils/effect'
21
+
22
+ import { loadModuleConfig } from './module-loader.ts'
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
+ configPath,
67
+ storeId,
68
+ clientId,
69
+ }: {
70
+ /** Absolute or cwd-relative path to a module exporting `schema` and `syncBackend`. */
71
+ configPath: string
72
+ /** Identifier to scope the backend connection. */
73
+ storeId: string
74
+ /** Client identifier used when establishing the sync connection. */
75
+ clientId: string
76
+ }): Effect.Effect<
77
+ SyncBackend.SyncBackend,
78
+ UnknownError | ConnectionError,
79
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
80
+ > =>
81
+ Effect.gen(function* () {
82
+ const { syncBackendConstructor, syncPayload } = yield* loadModuleConfig({ configPath })
83
+
84
+ const syncBackend = yield* (syncBackendConstructor as SyncBackend.SyncBackendConstructor)({
85
+ storeId,
86
+ clientId,
87
+ /** syncPayload is validated against syncPayloadSchema by loadModuleConfig */
88
+ payload: syncPayload as Schema.JsonValue | undefined,
89
+ }).pipe(Effect.provide(KeyValueStore.layerMemory), UnknownError.mapToUnknownError)
90
+
91
+ /** Connect to the sync backend */
92
+ yield* syncBackend.connect.pipe(
93
+ Effect.mapError(
94
+ (cause) =>
95
+ new ConnectionError({
96
+ cause,
97
+ note: `Failed to connect to sync backend: ${cause._tag === 'IsOfflineError' ? 'Backend is offline or unreachable' : String(cause)}`,
98
+ }),
99
+ ),
100
+ )
101
+
102
+ /** Verify connectivity with a ping (with timeout) */
103
+ yield* syncBackend.ping.pipe(
104
+ Effect.timeout(CONNECTION_TIMEOUT_MS),
105
+ Effect.catchAll((cause) => {
106
+ if (Cause.isTimeoutException(cause)) {
107
+ return Effect.fail(
108
+ new ConnectionError({
109
+ cause,
110
+ note: `Connection timeout: Sync backend did not respond within ${CONNECTION_TIMEOUT_MS}ms`,
111
+ }),
112
+ )
113
+ }
114
+ return Effect.fail(
115
+ new ConnectionError({
116
+ cause,
117
+ note: `Failed to ping sync backend: ${cause._tag === 'IsOfflineError' ? 'Backend is offline or unreachable' : String(cause)}`,
118
+ }),
119
+ )
120
+ }),
121
+ )
122
+
123
+ return syncBackend
124
+ })
125
+
126
+ const releaseSyncBackend = (syncBackend: SyncBackend.SyncBackend): Effect.Effect<void, never> => {
127
+ const maybeDisconnect = (syncBackend as { disconnect?: Effect.Effect<void, never> }).disconnect
128
+ const releaseEffect = maybeDisconnect ?? SubscriptionRef.set(syncBackend.isConnected, false)
129
+ return releaseEffect.pipe(Effect.orElse(() => Effect.void))
130
+ }
131
+
132
+ export interface ExportResult {
133
+ storeId: string
134
+ eventCount: number
135
+ exportedAt: string
136
+ backendName: string
137
+ /** The export data as JSON string (for MCP) or written to file (for CLI) */
138
+ data: ExportFile
139
+ }
140
+
141
+ /**
142
+ * Core export operation - pulls all events from sync backend.
143
+ * Returns the export data structure without writing to file.
144
+ */
145
+ export const pullEventsFromSyncBackend = ({
146
+ configPath,
147
+ storeId,
148
+ clientId,
149
+ }: {
150
+ configPath: string
151
+ storeId: string
152
+ clientId: string
153
+ }): Effect.Effect<
154
+ ExportResult,
155
+ ExportError | UnknownError | ConnectionError,
156
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
157
+ > =>
158
+ Effect.acquireUseRelease(
159
+ makeSyncBackend({ configPath, storeId, clientId }),
160
+ (syncBackend) =>
161
+ Effect.gen(function* () {
162
+ const backendName = syncBackend.metadata.name
163
+
164
+ const batchesChunk = yield* syncBackend.pull(Option.none(), { live: false }).pipe(
165
+ Stream.takeUntil((item) => item.pageInfo._tag === 'NoMore'),
166
+ Stream.runCollect,
167
+ Effect.mapError(
168
+ (cause) =>
169
+ new ExportError({
170
+ cause,
171
+ note: `Failed to pull events from sync backend: ${cause}`,
172
+ }),
173
+ ),
174
+ )
175
+
176
+ const events = Chunk.toReadonlyArray(batchesChunk)
177
+ .flatMap((item) => item.batch)
178
+ .map((item) => item.eventEncoded)
179
+
180
+ const exportedAt = new Date().toISOString()
181
+ const exportData: ExportFile = {
182
+ version: 1,
183
+ storeId,
184
+ exportedAt,
185
+ eventCount: events.length,
186
+ events,
187
+ }
188
+
189
+ return {
190
+ storeId,
191
+ eventCount: events.length,
192
+ exportedAt,
193
+ backendName,
194
+ data: exportData,
195
+ }
196
+ }),
197
+ releaseSyncBackend,
198
+ ).pipe(Effect.withSpan('sync:pullEvents'))
199
+
200
+ export interface ImportResult {
201
+ storeId: string
202
+ eventCount: number
203
+ /** Whether this was a dry run */
204
+ dryRun: boolean
205
+ backendName?: string
206
+ }
207
+
208
+ export interface ImportValidationResult {
209
+ storeId: string
210
+ eventCount: number
211
+ sourceStoreId: string
212
+ storeIdMismatch: boolean
213
+ }
214
+
215
+ /**
216
+ * Validates an export file for import.
217
+ * Returns validation info without actually importing.
218
+ */
219
+ export const validateExportData = ({
220
+ data,
221
+ targetStoreId,
222
+ }: {
223
+ data: unknown
224
+ targetStoreId: string
225
+ }): Effect.Effect<ImportValidationResult, ImportError> =>
226
+ Effect.gen(function* () {
227
+ const exportData = yield* Schema.decodeUnknown(ExportFileSchema)(data).pipe(
228
+ Effect.mapError(
229
+ (cause) =>
230
+ new ImportError({
231
+ cause: new Error(`Invalid export file format: ${cause}`),
232
+ note: `Invalid export file format: ${cause}`,
233
+ }),
234
+ ),
235
+ )
236
+
237
+ return {
238
+ storeId: targetStoreId,
239
+ eventCount: exportData.events.length,
240
+ sourceStoreId: exportData.storeId,
241
+ storeIdMismatch: exportData.storeId !== targetStoreId,
242
+ }
243
+ })
244
+
245
+ /**
246
+ * Core import operation - pushes events to sync backend.
247
+ * Validates that the backend is empty before importing.
248
+ */
249
+ export const pushEventsToSyncBackend = ({
250
+ configPath,
251
+ storeId,
252
+ clientId,
253
+ data,
254
+ force,
255
+ dryRun,
256
+ onProgress,
257
+ }: {
258
+ configPath: string
259
+ storeId: string
260
+ clientId: string
261
+ /** The export data to import (already parsed) */
262
+ data: unknown
263
+ force: boolean
264
+ dryRun: boolean
265
+ onProgress?: (pushed: number, total: number) => Effect.Effect<void, never>
266
+ }): Effect.Effect<
267
+ ImportResult,
268
+ ImportError | UnknownError | ConnectionError,
269
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
270
+ > =>
271
+ Effect.acquireUseRelease(
272
+ makeSyncBackend({ configPath, storeId, clientId }),
273
+ (syncBackend) =>
274
+ Effect.gen(function* () {
275
+ const exportData = yield* Schema.decodeUnknown(ExportFileSchema)(data).pipe(
276
+ Effect.mapError(
277
+ (cause) =>
278
+ new ImportError({
279
+ cause: new Error(`Invalid export file format: ${cause}`),
280
+ note: `Invalid export file format: ${cause}`,
281
+ }),
282
+ ),
283
+ )
284
+
285
+ if (exportData.storeId !== storeId && !force) {
286
+ return yield* new ImportError({
287
+ cause: new Error(`Store ID mismatch: file has '${exportData.storeId}', expected '${storeId}'`),
288
+ note: `The export file was created for a different store. Use force option to import anyway.`,
289
+ })
290
+ }
291
+
292
+ if (dryRun) {
293
+ return {
294
+ storeId,
295
+ eventCount: exportData.events.length,
296
+ dryRun: true,
297
+ }
298
+ }
299
+
300
+ const backendName = syncBackend.metadata.name
301
+
302
+ /** Check if events already exist by pulling from the backend first (short-circuit on first non-empty batch) */
303
+ const existingBatchOption = yield* syncBackend.pull(Option.none(), { live: false }).pipe(
304
+ Stream.filter((item) => item.batch.length > 0),
305
+ Stream.runHead,
306
+ Effect.mapError(
307
+ (cause) =>
308
+ new ImportError({
309
+ cause,
310
+ note: `Failed to check existing events: ${cause}`,
311
+ }),
312
+ ),
313
+ )
314
+
315
+ if (Option.isSome(existingBatchOption)) {
316
+ const existingBatch = existingBatchOption.value
317
+ const estimatedCount =
318
+ existingBatch.pageInfo._tag === 'MoreKnown'
319
+ ? existingBatch.batch.length + existingBatch.pageInfo.remaining
320
+ : existingBatch.batch.length
321
+
322
+ return yield* new ImportError({
323
+ cause: new Error(`Sync backend already contains at least ${estimatedCount} events`),
324
+ note: `Cannot import into a non-empty sync backend. The sync backend must be empty.`,
325
+ })
326
+ }
327
+
328
+ /** Push events in batches of 100 (sync backend constraint) */
329
+ const batchSize = 100
330
+ const total = exportData.events.length
331
+ let pushed = 0
332
+
333
+ for (let i = 0; i < exportData.events.length; i += batchSize) {
334
+ const batch = exportData.events.slice(i, i + batchSize)
335
+
336
+ yield* syncBackend.push(batch).pipe(
337
+ Effect.mapError(
338
+ (cause) =>
339
+ new ImportError({
340
+ cause,
341
+ note: `Failed to push events at position ${i}: ${cause}`,
342
+ }),
343
+ ),
344
+ )
345
+
346
+ pushed += batch.length
347
+ if (onProgress) {
348
+ yield* onProgress(pushed, total)
349
+ }
350
+ }
351
+
352
+ return {
353
+ storeId,
354
+ eventCount: exportData.events.length,
355
+ dryRun: false,
356
+ backendName,
357
+ }
358
+ }),
359
+ releaseSyncBackend,
360
+ ).pipe(Effect.withSpan('sync:pushEvents'))