import path from 'node:path' import type { UnknownError } from '@livestore/common' import { Console, Effect, FileSystem, type HttpClient, Schema, type Scope } from '@livestore/utils/effect' import { Cli } from '@livestore/utils/node' import * as SyncOps from '../sync-operations.ts' const jsonStringifyPretty = Schema.encodeSync(Schema.parseJson({ space: 2 })) const jsonParse = Schema.decodeUnknownSync(Schema.parseJson()) const LARGE_EVENT_WARNING_THRESHOLD = 100_000 /** * Export events from the sync backend to a JSON file. */ const exportEvents = ({ configPath, storeId, clientId, outputPath, }: { configPath: string storeId: string clientId: string outputPath: string }): Effect.Effect< void, SyncOps.ExportError | SyncOps.ConnectionError | UnknownError, FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope > => Effect.gen(function* () { yield* Console.log(`Connecting to sync backend...`) const result = yield* SyncOps.pullEventsFromSyncBackend({ configPath, storeId, clientId }) yield* Console.log(`✓ Connected to sync backend: ${result.backendName}`) yield* Console.log(`Pulled ${result.eventCount} events`) if (result.eventCount > LARGE_EVENT_WARNING_THRESHOLD) { yield* Console.log( `Warning: exporting ${result.eventCount} events may consume significant memory. Consider exporting on a machine with enough RAM.`, ) } const fs = yield* FileSystem.FileSystem const absOutputPath = path.isAbsolute(outputPath) === true ? outputPath : path.resolve(process.cwd(), outputPath) yield* fs.writeFileString(absOutputPath, jsonStringifyPretty(result.data)).pipe( Effect.mapError( (cause) => new SyncOps.ExportError({ cause, note: `Failed to write export file: ${String(cause)}`, }), ), ) yield* Console.log(`Exported ${result.eventCount} events to ${absOutputPath}`) }).pipe(Effect.withSpan('cli:export')) /** * Import events from a JSON file to the sync backend. */ const importEvents = ({ configPath, storeId, clientId, inputPath, force, dryRun, }: { configPath: string storeId: string clientId: string inputPath: string force: boolean dryRun: boolean }): Effect.Effect< void, SyncOps.ImportError | SyncOps.ConnectionError | UnknownError, FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const absInputPath = path.isAbsolute(inputPath) === true ? inputPath : path.resolve(process.cwd(), inputPath) const exists = yield* fs.exists(absInputPath).pipe( Effect.mapError( (cause) => new SyncOps.ImportError({ cause, note: `Failed to check file existence: ${String(cause)}`, }), ), ) if (exists === false) { return yield* new SyncOps.ImportError({ cause: new Error(`File not found: ${absInputPath}`), note: `Import file does not exist at ${absInputPath}`, }) } yield* Console.log(`Reading import file...`) const fileContent = yield* fs.readFileString(absInputPath).pipe( Effect.mapError( (cause) => new SyncOps.ImportError({ cause, note: `Failed to read import file: ${String(cause)}`, }), ), ) const parsedContent = yield* Effect.try({ try: () => jsonParse(fileContent), catch: (error) => new SyncOps.ImportError({ cause: new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`), note: `Invalid JSON in import file: ${error instanceof Error ? error.message : String(error)}`, }), }) /** Validate export file format before proceeding */ const validation = yield* SyncOps.validateExportData({ data: parsedContent, targetStoreId: storeId }) if (validation.storeIdMismatch === true) { if (force !== true) { return yield* new SyncOps.ImportError({ cause: new Error(`Store ID mismatch: file has '${validation.sourceStoreId}', expected '${storeId}'`), note: `The export file was created for a different store. Use --force to import anyway.`, }) } yield* Console.log( `Store ID mismatch: file has '${validation.sourceStoreId}', importing to '${storeId}' (--force)`, ) } yield* Console.log(`Found ${validation.eventCount} events in export file`) if (validation.eventCount > LARGE_EVENT_WARNING_THRESHOLD) { yield* Console.log( `Warning: importing ${validation.eventCount} events may consume significant memory. Ensure the machine has enough RAM.`, ) } if (dryRun === true) { yield* Console.log(`Dry run - validating import file...`) yield* Console.log(`Dry run complete. ${validation.eventCount} events would be imported.`) return } yield* Console.log(`Checking for existing events...`) yield* Console.log(`Connecting to sync backend...`) yield* Console.log(`Pushing events to sync backend...`) const result = yield* SyncOps.pushEventsToSyncBackend({ configPath, storeId, clientId, data: parsedContent, force, dryRun: false, onProgress: (pushed, total) => Console.log(` Pushed ${pushed}/${total} events`), }) yield* Console.log(`✓ Connected to sync backend: ${result.backendName ?? 'unknown'}`) yield* Console.log(`Successfully imported ${result.eventCount} events`) }).pipe(Effect.withSpan('cli:import')) export const exportCommand = Cli.Command.make( 'export', { config: Cli.Options.text('config').pipe( Cli.Options.withAlias('c'), Cli.Options.withDescription('Path to the config module that exports schema and syncBackend'), ), storeId: Cli.Options.text('store-id').pipe( Cli.Options.withAlias('i'), Cli.Options.withDescription('Store identifier'), ), clientId: Cli.Options.text('client-id').pipe( Cli.Options.withDefault('cli-export'), Cli.Options.withDescription('Client identifier for the sync connection'), ), output: Cli.Args.text({ name: 'file' }).pipe(Cli.Args.withDescription('Output JSON file path')), }, Effect.fn(function* ({ config, storeId, clientId, output, }: { config: string storeId: string clientId: string output: string }) { yield* Console.log(`Exporting events from LiveStore...`) yield* Console.log(` Config: ${config}`) yield* Console.log(` Store ID: ${storeId}`) yield* Console.log(` Output: ${output}`) yield* Console.log('') yield* exportEvents({ configPath: config, storeId, clientId, outputPath: output, }).pipe(Effect.scoped) }), ).pipe( Cli.Command.withDescription( 'Export all events from the sync backend to a JSON file. Useful for backup and migration.', ), ) export const importCommand = Cli.Command.make( 'import', { config: Cli.Options.text('config').pipe( Cli.Options.withAlias('c'), Cli.Options.withDescription('Path to the config module that exports schema and syncBackend'), ), storeId: Cli.Options.text('store-id').pipe( Cli.Options.withAlias('i'), Cli.Options.withDescription('Store identifier'), ), clientId: Cli.Options.text('client-id').pipe( Cli.Options.withDefault('cli-import'), Cli.Options.withDescription('Client identifier for the sync connection'), ), force: Cli.Options.boolean('force').pipe( Cli.Options.withAlias('f'), Cli.Options.withDefault(false), Cli.Options.withDescription('Force import even if store ID does not match'), ), dryRun: Cli.Options.boolean('dry-run').pipe( Cli.Options.withDefault(false), Cli.Options.withDescription('Validate the import file without actually importing'), ), input: Cli.Args.text({ name: 'file' }).pipe(Cli.Args.withDescription('Input JSON file to import')), }, Effect.fn(function* ({ config, storeId, clientId, force, dryRun, input, }: { config: string storeId: string clientId: string force: boolean dryRun: boolean input: string }) { yield* Console.log(`Importing events to LiveStore...`) yield* Console.log(` Config: ${config}`) yield* Console.log(` Store ID: ${storeId}`) yield* Console.log(` Input: ${input}`) if (force === true) yield* Console.log(` Force: enabled`) if (dryRun === true) yield* Console.log(` Dry run: enabled`) yield* Console.log('') yield* importEvents({ configPath: config, storeId, clientId, inputPath: input, force, dryRun, }).pipe(Effect.scoped) }), ).pipe( Cli.Command.withDescription('Import events from a JSON file to the sync backend. The sync backend must be empty.'), ) export const syncCommand = Cli.Command.make('sync').pipe( Cli.Command.withSubcommands([exportCommand, importCommand]), Cli.Command.withDescription('Import and export events from the sync backend'), )