@livestore/cli 0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb → 0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livestore/cli",
3
- "version": "0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb",
3
+ "version": "0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -11,17 +11,17 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@effect/ai-openai": "0.32.0",
14
- "@livestore/adapter-node": "0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb",
15
- "@livestore/common": "0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb",
16
- "@livestore/sync-cf": "0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb",
17
- "@livestore/livestore": "0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb",
18
- "@livestore/peer-deps": "0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb",
19
- "@livestore/utils": "0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb"
14
+ "@livestore/adapter-node": "0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c",
15
+ "@livestore/livestore": "0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c",
16
+ "@livestore/common": "0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c",
17
+ "@livestore/peer-deps": "0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c",
18
+ "@livestore/sync-cf": "0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c",
19
+ "@livestore/utils": "0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "24.10.1",
23
23
  "typescript": "5.9.2",
24
- "@livestore/utils-dev": "0.0.0-snapshot-4cf07cafbf9184702de7451fafd5c71207dd9bfb"
24
+ "@livestore/utils-dev": "0.0.0-snapshot-d8085caf44a1e13adef4e8c199343a8cbca2e45c"
25
25
  },
26
26
  "files": [
27
27
  "package.json",
package/src/cli.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Cli } from '@livestore/utils/node'
2
+ import { syncCommand } from './commands/import-export.ts'
2
3
  import { mcpCommand } from './commands/mcp.ts'
3
4
  import { createCommand } from './commands/new-project.ts'
4
5
 
5
6
  export const command = Cli.Command.make('livestore', {
6
7
  verbose: Cli.Options.boolean('verbose').pipe(Cli.Options.withDefault(false)),
7
- }).pipe(Cli.Command.withSubcommands([mcpCommand, createCommand]))
8
+ }).pipe(Cli.Command.withSubcommands([mcpCommand, createCommand, syncCommand]))
@@ -0,0 +1,530 @@
1
+ import path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import type { SyncBackend } from '@livestore/common'
4
+ import { UnknownError } from '@livestore/common'
5
+ import { isLiveStoreSchema, LiveStoreEvent } from '@livestore/common/schema'
6
+ import { shouldNeverHappen } from '@livestore/utils'
7
+ import {
8
+ Cause,
9
+ Console,
10
+ Effect,
11
+ FileSystem,
12
+ type HttpClient,
13
+ KeyValueStore,
14
+ Layer,
15
+ Option,
16
+ Schema,
17
+ type Scope,
18
+ Stream,
19
+ } from '@livestore/utils/effect'
20
+ import { Cli } from '@livestore/utils/node'
21
+
22
+ /** Connection timeout for sync backend ping (5 seconds) */
23
+ const CONNECTION_TIMEOUT_MS = 5000
24
+
25
+ /**
26
+ * Schema for the export file format.
27
+ * Contains metadata about the export and an array of events in global encoded format.
28
+ */
29
+ const ExportFileSchema = Schema.Struct({
30
+ /** Format version for future compatibility */
31
+ version: Schema.Literal(1),
32
+ /** Store identifier */
33
+ storeId: Schema.String,
34
+ /** ISO timestamp of when the export was created */
35
+ exportedAt: Schema.String,
36
+ /** Total number of events in the export */
37
+ eventCount: Schema.Number,
38
+ /** Array of events in global encoded format */
39
+ events: Schema.Array(LiveStoreEvent.Global.Encoded),
40
+ })
41
+
42
+ type ExportFile = typeof ExportFileSchema.Type
43
+
44
+ class ConnectionError extends Schema.TaggedError<ConnectionError>()('ConnectionError', {
45
+ cause: Schema.Defect,
46
+ note: Schema.String,
47
+ }) {}
48
+
49
+ class ExportError extends Schema.TaggedError<ExportError>()('ExportError', {
50
+ cause: Schema.Defect,
51
+ note: Schema.String,
52
+ }) {}
53
+
54
+ class ImportError extends Schema.TaggedError<ImportError>()('ImportError', {
55
+ cause: Schema.Defect,
56
+ note: Schema.String,
57
+ }) {}
58
+
59
+ /**
60
+ * Creates a sync backend connection from a user module and verifies connectivity.
61
+ * This is a simplified version of the MCP runtime that only creates the sync backend.
62
+ */
63
+ const makeSyncBackend = ({
64
+ storePath,
65
+ storeId,
66
+ clientId,
67
+ }: {
68
+ storePath: string
69
+ storeId: string
70
+ clientId: string
71
+ }): Effect.Effect<
72
+ SyncBackend.SyncBackend,
73
+ UnknownError | ConnectionError,
74
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
75
+ > =>
76
+ Effect.gen(function* () {
77
+ const abs = path.isAbsolute(storePath) ? storePath : path.resolve(process.cwd(), storePath)
78
+
79
+ const fs = yield* FileSystem.FileSystem
80
+ const exists = yield* fs.exists(abs).pipe(UnknownError.mapToUnknownError)
81
+ if (!exists) {
82
+ return yield* Effect.fail(
83
+ UnknownError.make({
84
+ cause: `Store module not found at ${abs}`,
85
+ note: 'Make sure the path points to a valid LiveStore module',
86
+ }),
87
+ )
88
+ }
89
+
90
+ const mod = yield* Effect.tryPromise({
91
+ try: () => import(pathToFileURL(abs).href),
92
+ catch: (cause) =>
93
+ UnknownError.make({
94
+ cause,
95
+ note: `Failed to import module at ${abs}`,
96
+ }),
97
+ })
98
+
99
+ const schema = (mod as any)?.schema
100
+ if (!isLiveStoreSchema(schema)) {
101
+ return yield* Effect.fail(
102
+ UnknownError.make({
103
+ cause: `Module at ${abs} must export a valid LiveStore 'schema'`,
104
+ note: `Ex: export { schema } from './src/livestore/schema.ts'`,
105
+ }),
106
+ )
107
+ }
108
+
109
+ const syncBackendConstructor = (mod as any)?.syncBackend
110
+ if (typeof syncBackendConstructor !== 'function') {
111
+ return yield* Effect.fail(
112
+ UnknownError.make({
113
+ cause: `Module at ${abs} must export a 'syncBackend' constructor`,
114
+ note: `Ex: export const syncBackend = makeWsSync({ url })`,
115
+ }),
116
+ )
117
+ }
118
+
119
+ const syncPayloadSchemaExport = (mod as any)?.syncPayloadSchema
120
+ const syncPayloadSchema =
121
+ syncPayloadSchemaExport === undefined
122
+ ? Schema.JsonValue
123
+ : Schema.isSchema(syncPayloadSchemaExport)
124
+ ? (syncPayloadSchemaExport as Schema.Schema<any>)
125
+ : shouldNeverHappen(
126
+ `Exported 'syncPayloadSchema' from ${abs} must be an Effect Schema (received ${typeof syncPayloadSchemaExport}).`,
127
+ )
128
+
129
+ const syncPayloadExport = (mod as any)?.syncPayload
130
+ const syncPayload = yield* (
131
+ syncPayloadExport === undefined
132
+ ? Effect.succeed<unknown>(undefined)
133
+ : Schema.decodeUnknown(syncPayloadSchema)(syncPayloadExport)
134
+ ).pipe(UnknownError.mapToUnknownError)
135
+
136
+ /** Simple in-memory key-value store for sync backend state */
137
+ const kvStore: { backendId: string | undefined } = { backendId: undefined }
138
+
139
+ const syncBackend = yield* (syncBackendConstructor as SyncBackend.SyncBackendConstructor)({
140
+ storeId,
141
+ clientId,
142
+ payload: syncPayload,
143
+ }).pipe(
144
+ Effect.provide(
145
+ Layer.succeed(
146
+ KeyValueStore.KeyValueStore,
147
+ KeyValueStore.makeStringOnly({
148
+ get: (_key) => Effect.succeed(Option.fromNullable(kvStore.backendId)),
149
+ set: (_key, value) =>
150
+ Effect.sync(() => {
151
+ kvStore.backendId = value
152
+ }),
153
+ clear: Effect.dieMessage('Not implemented'),
154
+ remove: () => Effect.dieMessage('Not implemented'),
155
+ size: Effect.dieMessage('Not implemented'),
156
+ }),
157
+ ),
158
+ ),
159
+ UnknownError.mapToUnknownError,
160
+ )
161
+
162
+ /** Connect to the sync backend */
163
+ yield* syncBackend.connect.pipe(
164
+ Effect.mapError(
165
+ (cause) =>
166
+ new ConnectionError({
167
+ cause,
168
+ note: `Failed to connect to sync backend: ${cause._tag === 'IsOfflineError' ? 'Backend is offline or unreachable' : String(cause)}`,
169
+ }),
170
+ ),
171
+ )
172
+
173
+ /** Verify connectivity with a ping (with timeout) */
174
+ yield* syncBackend.ping.pipe(
175
+ Effect.timeout(CONNECTION_TIMEOUT_MS),
176
+ Effect.catchAll((cause) => {
177
+ if (Cause.isTimeoutException(cause)) {
178
+ return Effect.fail(
179
+ new ConnectionError({
180
+ cause,
181
+ note: `Connection timeout: Sync backend did not respond within ${CONNECTION_TIMEOUT_MS}ms`,
182
+ }),
183
+ )
184
+ }
185
+ return Effect.fail(
186
+ new ConnectionError({
187
+ cause,
188
+ note: `Failed to ping sync backend: ${cause._tag === 'IsOfflineError' ? 'Backend is offline or unreachable' : String(cause)}`,
189
+ }),
190
+ )
191
+ }),
192
+ )
193
+
194
+ yield* Console.log(`✓ Connected to sync backend: ${syncBackend.metadata.name}`)
195
+
196
+ return syncBackend
197
+ })
198
+
199
+ /**
200
+ * Export events from the sync backend to a JSON file.
201
+ */
202
+ const exportEvents = ({
203
+ storePath,
204
+ storeId,
205
+ clientId,
206
+ outputPath,
207
+ }: {
208
+ storePath: string
209
+ storeId: string
210
+ clientId: string
211
+ outputPath: string
212
+ }): Effect.Effect<
213
+ void,
214
+ ExportError | UnknownError | ConnectionError,
215
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
216
+ > =>
217
+ Effect.gen(function* () {
218
+ yield* Console.log(`Connecting to sync backend...`)
219
+
220
+ const syncBackend = yield* makeSyncBackend({ storePath, storeId, clientId })
221
+
222
+ yield* Console.log(`Pulling events from sync backend...`)
223
+
224
+ const events: LiveStoreEvent.Global.Encoded[] = []
225
+
226
+ yield* syncBackend.pull(Option.none(), { live: false }).pipe(
227
+ Stream.tap((item) =>
228
+ Effect.sync(() => {
229
+ for (const { eventEncoded } of item.batch) {
230
+ events.push(eventEncoded)
231
+ }
232
+ }),
233
+ ),
234
+ Stream.takeUntil((item) => item.pageInfo._tag === 'NoMore'),
235
+ Stream.runDrain,
236
+ Effect.mapError(
237
+ (cause) =>
238
+ new ExportError({
239
+ cause,
240
+ note: `Failed to pull events from sync backend: ${cause}`,
241
+ }),
242
+ ),
243
+ )
244
+
245
+ yield* Console.log(`Pulled ${events.length} events`)
246
+
247
+ const exportData: ExportFile = {
248
+ version: 1,
249
+ storeId,
250
+ exportedAt: new Date().toISOString(),
251
+ eventCount: events.length,
252
+ events,
253
+ }
254
+
255
+ const fs = yield* FileSystem.FileSystem
256
+ const absOutputPath = path.isAbsolute(outputPath) ? outputPath : path.resolve(process.cwd(), outputPath)
257
+
258
+ yield* fs.writeFileString(absOutputPath, JSON.stringify(exportData, null, 2)).pipe(
259
+ Effect.mapError(
260
+ (cause) =>
261
+ new ExportError({
262
+ cause,
263
+ note: `Failed to write export file: ${cause}`,
264
+ }),
265
+ ),
266
+ )
267
+
268
+ yield* Console.log(`Exported ${events.length} events to ${absOutputPath}`)
269
+ }).pipe(Effect.withSpan('cli:export'))
270
+
271
+ /**
272
+ * Import events from a JSON file to the sync backend.
273
+ */
274
+ const importEvents = ({
275
+ storePath,
276
+ storeId,
277
+ clientId,
278
+ inputPath,
279
+ force,
280
+ dryRun,
281
+ }: {
282
+ storePath: string
283
+ storeId: string
284
+ clientId: string
285
+ inputPath: string
286
+ force: boolean
287
+ dryRun: boolean
288
+ }): Effect.Effect<
289
+ void,
290
+ ImportError | UnknownError | ConnectionError,
291
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
292
+ > =>
293
+ Effect.gen(function* () {
294
+ const fs = yield* FileSystem.FileSystem
295
+ const absInputPath = path.isAbsolute(inputPath) ? inputPath : path.resolve(process.cwd(), inputPath)
296
+
297
+ const exists = yield* fs.exists(absInputPath).pipe(UnknownError.mapToUnknownError)
298
+ if (!exists) {
299
+ return yield* Effect.fail(
300
+ new ImportError({
301
+ cause: new Error(`File not found: ${absInputPath}`),
302
+ note: `Import file does not exist at ${absInputPath}`,
303
+ }),
304
+ )
305
+ }
306
+
307
+ yield* Console.log(`Reading import file...`)
308
+
309
+ const fileContent = yield* fs.readFileString(absInputPath).pipe(
310
+ Effect.mapError(
311
+ (cause) =>
312
+ new ImportError({
313
+ cause,
314
+ note: `Failed to read import file: ${cause}`,
315
+ }),
316
+ ),
317
+ )
318
+
319
+ const parsedContent = yield* Effect.try({
320
+ try: () => JSON.parse(fileContent),
321
+ catch: (error) =>
322
+ new ImportError({
323
+ cause: new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`),
324
+ note: `Invalid JSON in import file: ${error instanceof Error ? error.message : String(error)}`,
325
+ }),
326
+ })
327
+
328
+ const exportData = yield* Schema.decodeUnknown(ExportFileSchema)(parsedContent).pipe(
329
+ Effect.mapError(
330
+ (cause) =>
331
+ new ImportError({
332
+ cause: new Error(`Invalid export file format: ${cause}`),
333
+ note: `Invalid export file format: ${cause}`,
334
+ }),
335
+ ),
336
+ )
337
+
338
+ if (exportData.storeId !== storeId) {
339
+ if (!force) {
340
+ return yield* Effect.fail(
341
+ new ImportError({
342
+ cause: new Error(`Store ID mismatch: file has '${exportData.storeId}', expected '${storeId}'`),
343
+ note: `The export file was created for a different store. Use --force to import anyway.`,
344
+ }),
345
+ )
346
+ }
347
+ yield* Console.log(`Store ID mismatch: file has '${exportData.storeId}', importing to '${storeId}' (--force)`)
348
+ }
349
+
350
+ yield* Console.log(`Found ${exportData.events.length} events in export file`)
351
+
352
+ if (dryRun) {
353
+ yield* Console.log(`Dry run - validating import file...`)
354
+ yield* Console.log(`Dry run complete. ${exportData.events.length} events would be imported.`)
355
+ return
356
+ }
357
+
358
+ yield* Console.log(`Connecting to sync backend...`)
359
+
360
+ const syncBackend = yield* makeSyncBackend({ storePath, storeId, clientId })
361
+
362
+ /** Check if events already exist by pulling from the backend first */
363
+ yield* Console.log(`Checking for existing events...`)
364
+
365
+ let existingEventCount = 0
366
+ yield* syncBackend.pull(Option.none(), { live: false }).pipe(
367
+ Stream.tap((item) =>
368
+ Effect.sync(() => {
369
+ existingEventCount += item.batch.length
370
+ }),
371
+ ),
372
+ Stream.takeUntil((item) => item.pageInfo._tag === 'NoMore'),
373
+ Stream.runDrain,
374
+ Effect.mapError(
375
+ (cause) =>
376
+ new ImportError({
377
+ cause,
378
+ note: `Failed to check existing events: ${cause}`,
379
+ }),
380
+ ),
381
+ )
382
+
383
+ if (existingEventCount > 0) {
384
+ return yield* Effect.fail(
385
+ new ImportError({
386
+ cause: new Error(`Sync backend already contains ${existingEventCount} events`),
387
+ note: `Cannot import into a non-empty sync backend. The sync backend must be empty.`,
388
+ }),
389
+ )
390
+ }
391
+
392
+ yield* Console.log(`Pushing ${exportData.events.length} events to sync backend...`)
393
+
394
+ /** Push events in batches of 100 (sync backend constraint) */
395
+ const batchSize = 100
396
+ let pushed = 0
397
+
398
+ for (let i = 0; i < exportData.events.length; i += batchSize) {
399
+ const batch = exportData.events.slice(i, i + batchSize)
400
+
401
+ yield* syncBackend.push(batch).pipe(
402
+ Effect.mapError(
403
+ (cause) =>
404
+ new ImportError({
405
+ cause,
406
+ note: `Failed to push events at position ${i}: ${cause}`,
407
+ }),
408
+ ),
409
+ )
410
+
411
+ pushed += batch.length
412
+ yield* Console.log(` Pushed ${pushed}/${exportData.events.length} events`)
413
+ }
414
+
415
+ yield* Console.log(`Successfully imported ${exportData.events.length} events`)
416
+ }).pipe(Effect.withSpan('cli:import'))
417
+
418
+ export const exportCommand = Cli.Command.make(
419
+ 'export',
420
+ {
421
+ store: Cli.Options.text('store').pipe(
422
+ Cli.Options.withAlias('s'),
423
+ Cli.Options.withDescription('Path to the store module that exports schema and syncBackend'),
424
+ ),
425
+ storeId: Cli.Options.text('store-id').pipe(
426
+ Cli.Options.withAlias('i'),
427
+ Cli.Options.withDescription('Store identifier'),
428
+ ),
429
+ clientId: Cli.Options.text('client-id').pipe(
430
+ Cli.Options.withDefault('cli-export'),
431
+ Cli.Options.withDescription('Client identifier for the sync connection'),
432
+ ),
433
+ output: Cli.Args.text({ name: 'file' }).pipe(Cli.Args.withDescription('Output JSON file path')),
434
+ },
435
+ Effect.fn(function* ({
436
+ store,
437
+ storeId,
438
+ clientId,
439
+ output,
440
+ }: {
441
+ store: string
442
+ storeId: string
443
+ clientId: string
444
+ output: string
445
+ }) {
446
+ yield* Console.log(`Exporting events from LiveStore...`)
447
+ yield* Console.log(` Store: ${store}`)
448
+ yield* Console.log(` Store ID: ${storeId}`)
449
+ yield* Console.log(` Output: ${output}`)
450
+ yield* Console.log('')
451
+
452
+ yield* exportEvents({
453
+ storePath: store,
454
+ storeId,
455
+ clientId,
456
+ outputPath: output,
457
+ }).pipe(Effect.scoped)
458
+ }),
459
+ ).pipe(
460
+ Cli.Command.withDescription(
461
+ 'Export all events from the sync backend to a JSON file. Useful for backup and migration.',
462
+ ),
463
+ )
464
+
465
+ export const importCommand = Cli.Command.make(
466
+ 'import',
467
+ {
468
+ store: Cli.Options.text('store').pipe(
469
+ Cli.Options.withAlias('s'),
470
+ Cli.Options.withDescription('Path to the store module that exports schema and syncBackend'),
471
+ ),
472
+ storeId: Cli.Options.text('store-id').pipe(
473
+ Cli.Options.withAlias('i'),
474
+ Cli.Options.withDescription('Store identifier'),
475
+ ),
476
+ clientId: Cli.Options.text('client-id').pipe(
477
+ Cli.Options.withDefault('cli-import'),
478
+ Cli.Options.withDescription('Client identifier for the sync connection'),
479
+ ),
480
+ force: Cli.Options.boolean('force').pipe(
481
+ Cli.Options.withAlias('f'),
482
+ Cli.Options.withDefault(false),
483
+ Cli.Options.withDescription('Force import even if store ID does not match'),
484
+ ),
485
+ dryRun: Cli.Options.boolean('dry-run').pipe(
486
+ Cli.Options.withDefault(false),
487
+ Cli.Options.withDescription('Validate the import file without actually importing'),
488
+ ),
489
+ input: Cli.Args.text({ name: 'file' }).pipe(Cli.Args.withDescription('Input JSON file to import')),
490
+ },
491
+ Effect.fn(function* ({
492
+ store,
493
+ storeId,
494
+ clientId,
495
+ force,
496
+ dryRun,
497
+ input,
498
+ }: {
499
+ store: string
500
+ storeId: string
501
+ clientId: string
502
+ force: boolean
503
+ dryRun: boolean
504
+ input: string
505
+ }) {
506
+ yield* Console.log(`Importing events to LiveStore...`)
507
+ yield* Console.log(` Store: ${store}`)
508
+ yield* Console.log(` Store ID: ${storeId}`)
509
+ yield* Console.log(` Input: ${input}`)
510
+ if (force) yield* Console.log(` Force: enabled`)
511
+ if (dryRun) yield* Console.log(` Dry run: enabled`)
512
+ yield* Console.log('')
513
+
514
+ yield* importEvents({
515
+ storePath: store,
516
+ storeId,
517
+ clientId,
518
+ inputPath: input,
519
+ force,
520
+ dryRun,
521
+ }).pipe(Effect.scoped)
522
+ }),
523
+ ).pipe(
524
+ Cli.Command.withDescription('Import events from a JSON file to the sync backend. The sync backend must be empty.'),
525
+ )
526
+
527
+ export const syncCommand = Cli.Command.make('sync').pipe(
528
+ Cli.Command.withSubcommands([exportCommand, importCommand]),
529
+ Cli.Command.withDescription('Import and export events from the sync backend'),
530
+ )
@@ -63,13 +63,13 @@ export const syncPayload = { authToken: process.env.LIVESTORE_SYNC_AUTH_TOKEN ??
63
63
 
64
64
  Connect parameters:
65
65
  {
66
- "storePath": "<path-to-your-mcp-module>.ts",
66
+ "storePath": "livestore-cli.config.ts",
67
67
  "storeId": "<store-id>"
68
68
  }
69
69
 
70
70
  Optional identifiers to group client state on the server:
71
71
  {
72
- "storePath": "<path-to-your-mcp-module>.ts",
72
+ "storePath": "livestore-cli.config.ts",
73
73
  "storeId": "<store-id>",
74
74
  "clientId": "<client-id>",
75
75
  "sessionId": "<session-id>"