@tsctl/cli 0.2.0
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/README.md +735 -0
- package/bin/tsctl.js +2 -0
- package/package.json +65 -0
- package/src/__tests__/analyticsrules.test.ts +303 -0
- package/src/__tests__/apikeys.test.ts +223 -0
- package/src/__tests__/apply.test.ts +245 -0
- package/src/__tests__/client.test.ts +48 -0
- package/src/__tests__/collection-advanced.test.ts +274 -0
- package/src/__tests__/config-loader.test.ts +217 -0
- package/src/__tests__/curationsets.test.ts +190 -0
- package/src/__tests__/helpers.ts +17 -0
- package/src/__tests__/import-drift.test.ts +231 -0
- package/src/__tests__/migrate-advanced.test.ts +197 -0
- package/src/__tests__/migrate.test.ts +220 -0
- package/src/__tests__/plan-new-resources.test.ts +258 -0
- package/src/__tests__/plan.test.ts +337 -0
- package/src/__tests__/presets.test.ts +97 -0
- package/src/__tests__/resources.test.ts +592 -0
- package/src/__tests__/setup.ts +77 -0
- package/src/__tests__/state.test.ts +312 -0
- package/src/__tests__/stemmingdictionaries.test.ts +111 -0
- package/src/__tests__/stopwords.test.ts +109 -0
- package/src/__tests__/synonymsets.test.ts +170 -0
- package/src/__tests__/types.test.ts +416 -0
- package/src/apply/index.ts +336 -0
- package/src/cli/index.ts +1106 -0
- package/src/client/index.ts +55 -0
- package/src/config/loader.ts +158 -0
- package/src/index.ts +45 -0
- package/src/migrate/index.ts +220 -0
- package/src/plan/index.ts +1333 -0
- package/src/resources/alias.ts +59 -0
- package/src/resources/analyticsrule.ts +134 -0
- package/src/resources/apikey.ts +203 -0
- package/src/resources/collection.ts +424 -0
- package/src/resources/curationset.ts +155 -0
- package/src/resources/index.ts +11 -0
- package/src/resources/override.ts +174 -0
- package/src/resources/preset.ts +83 -0
- package/src/resources/stemmingdictionary.ts +103 -0
- package/src/resources/stopword.ts +100 -0
- package/src/resources/synonym.ts +152 -0
- package/src/resources/synonymset.ts +144 -0
- package/src/state/index.ts +206 -0
- package/src/types/index.ts +451 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Typesense, { Client } from "typesense";
|
|
2
|
+
import type { ConnectionConfig } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
let client: Client | null = null;
|
|
5
|
+
|
|
6
|
+
export function initClient(config: ConnectionConfig): Client {
|
|
7
|
+
client = new Typesense.Client({
|
|
8
|
+
nodes: config.nodes,
|
|
9
|
+
apiKey: config.apiKey,
|
|
10
|
+
connectionTimeoutSeconds: config.connectionTimeoutSeconds ?? 10,
|
|
11
|
+
retryIntervalSeconds: config.retryIntervalSeconds ?? 0.1,
|
|
12
|
+
numRetries: config.numRetries ?? 3,
|
|
13
|
+
});
|
|
14
|
+
return client;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getClient(): Client {
|
|
18
|
+
if (!client) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"Typesense client not initialized. Call initClient() first or run 'tsctl init'"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return client;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function testConnection(): Promise<boolean> {
|
|
27
|
+
try {
|
|
28
|
+
const c = getClient();
|
|
29
|
+
await c.health.retrieve();
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getClientFromEnv(): Client {
|
|
37
|
+
const host = process.env["TYPESENSE_HOST"] || "localhost";
|
|
38
|
+
const port = parseInt(process.env["TYPESENSE_PORT"] || "8108", 10);
|
|
39
|
+
const protocol = (process.env["TYPESENSE_PROTOCOL"] || "http") as
|
|
40
|
+
| "http"
|
|
41
|
+
| "https";
|
|
42
|
+
const apiKey = process.env["TYPESENSE_API_KEY"];
|
|
43
|
+
|
|
44
|
+
if (!apiKey) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"TYPESENSE_API_KEY environment variable is required.\n" +
|
|
47
|
+
"Set it in your environment or create a .env file."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return initClient({
|
|
52
|
+
nodes: [{ host, port, protocol }],
|
|
53
|
+
apiKey,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
2
|
+
import { resolve, dirname } from "path";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { TypesenseConfigSchema, type TypesenseConfig } from "../types/index.js";
|
|
5
|
+
|
|
6
|
+
const MODULE_NAME = "tsctl";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TypeScript/ESM loader for cosmiconfig
|
|
10
|
+
*/
|
|
11
|
+
async function typeScriptLoader(filepath: string): Promise<unknown> {
|
|
12
|
+
const fileUrl = pathToFileURL(filepath).href;
|
|
13
|
+
// Add cache busting to ensure we get fresh config
|
|
14
|
+
const cacheBustedUrl = `${fileUrl}?t=${Date.now()}`;
|
|
15
|
+
const module = await import(cacheBustedUrl);
|
|
16
|
+
return module.default || module;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create the cosmiconfig explorer with all supported file formats
|
|
21
|
+
*
|
|
22
|
+
* Supported config files (in search order):
|
|
23
|
+
* - package.json ("tsctl" property)
|
|
24
|
+
* - .tsctlrc (JSON or YAML)
|
|
25
|
+
* - .tsctlrc.json
|
|
26
|
+
* - .tsctlrc.yaml / .tsctlrc.yml
|
|
27
|
+
* - .tsctlrc.js / .tsctlrc.cjs / .tsctlrc.mjs
|
|
28
|
+
* - .tsctlrc.ts / .tsctlrc.cts / .tsctlrc.mts
|
|
29
|
+
* - tsctl.config.js / tsctl.config.cjs / tsctl.config.mjs
|
|
30
|
+
* - tsctl.config.ts / tsctl.config.cts / tsctl.config.mts
|
|
31
|
+
* - tsctl.config.json
|
|
32
|
+
* - tsctl.config.yaml / tsctl.config.yml
|
|
33
|
+
* - typesense.config.js / typesense.config.cjs / typesense.config.mjs (legacy)
|
|
34
|
+
* - typesense.config.ts / typesense.config.cts / typesense.config.mts (legacy)
|
|
35
|
+
*/
|
|
36
|
+
function createExplorer() {
|
|
37
|
+
return cosmiconfig(MODULE_NAME, {
|
|
38
|
+
searchPlaces: [
|
|
39
|
+
// package.json
|
|
40
|
+
"package.json",
|
|
41
|
+
|
|
42
|
+
// RC files (JSON/YAML)
|
|
43
|
+
`.${MODULE_NAME}rc`,
|
|
44
|
+
`.${MODULE_NAME}rc.json`,
|
|
45
|
+
`.${MODULE_NAME}rc.yaml`,
|
|
46
|
+
`.${MODULE_NAME}rc.yml`,
|
|
47
|
+
|
|
48
|
+
// RC files (JS)
|
|
49
|
+
`.${MODULE_NAME}rc.js`,
|
|
50
|
+
`.${MODULE_NAME}rc.cjs`,
|
|
51
|
+
`.${MODULE_NAME}rc.mjs`,
|
|
52
|
+
|
|
53
|
+
// RC files (TS)
|
|
54
|
+
`.${MODULE_NAME}rc.ts`,
|
|
55
|
+
`.${MODULE_NAME}rc.cts`,
|
|
56
|
+
`.${MODULE_NAME}rc.mts`,
|
|
57
|
+
|
|
58
|
+
// Config files (JS)
|
|
59
|
+
`${MODULE_NAME}.config.js`,
|
|
60
|
+
`${MODULE_NAME}.config.cjs`,
|
|
61
|
+
`${MODULE_NAME}.config.mjs`,
|
|
62
|
+
|
|
63
|
+
// Config files (TS)
|
|
64
|
+
`${MODULE_NAME}.config.ts`,
|
|
65
|
+
`${MODULE_NAME}.config.cts`,
|
|
66
|
+
`${MODULE_NAME}.config.mts`,
|
|
67
|
+
|
|
68
|
+
// Config files (JSON/YAML)
|
|
69
|
+
`${MODULE_NAME}.config.json`,
|
|
70
|
+
`${MODULE_NAME}.config.yaml`,
|
|
71
|
+
`${MODULE_NAME}.config.yml`,
|
|
72
|
+
|
|
73
|
+
// Legacy config files (for backwards compatibility)
|
|
74
|
+
"typesense.config.js",
|
|
75
|
+
"typesense.config.cjs",
|
|
76
|
+
"typesense.config.mjs",
|
|
77
|
+
"typesense.config.ts",
|
|
78
|
+
"typesense.config.cts",
|
|
79
|
+
"typesense.config.mts",
|
|
80
|
+
"typesense.config.json",
|
|
81
|
+
"typesense.config.yaml",
|
|
82
|
+
"typesense.config.yml",
|
|
83
|
+
],
|
|
84
|
+
loaders: {
|
|
85
|
+
".ts": typeScriptLoader,
|
|
86
|
+
".cts": typeScriptLoader,
|
|
87
|
+
".mts": typeScriptLoader,
|
|
88
|
+
".mjs": typeScriptLoader,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Find the config file in the given directory or parent directories
|
|
95
|
+
*/
|
|
96
|
+
export async function findConfigFile(startDir: string = process.cwd()): Promise<string | null> {
|
|
97
|
+
const explorer = createExplorer();
|
|
98
|
+
const result = await explorer.search(startDir);
|
|
99
|
+
return result?.filepath || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Load and validate a config file
|
|
104
|
+
*
|
|
105
|
+
* @param configPath - Optional path to config file. If not provided, searches for config.
|
|
106
|
+
* @returns Validated TypesenseConfig
|
|
107
|
+
*/
|
|
108
|
+
export async function loadConfig(configPath?: string): Promise<TypesenseConfig> {
|
|
109
|
+
const explorer = createExplorer();
|
|
110
|
+
|
|
111
|
+
let result;
|
|
112
|
+
|
|
113
|
+
if (configPath) {
|
|
114
|
+
// Load specific file
|
|
115
|
+
const resolvedPath = resolve(configPath);
|
|
116
|
+
result = await explorer.load(resolvedPath);
|
|
117
|
+
} else {
|
|
118
|
+
// Search for config
|
|
119
|
+
result = await explorer.search();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!result || result.isEmpty) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"No config file found. Create one of the following:\n" +
|
|
125
|
+
" - tsctl.config.ts (recommended)\n" +
|
|
126
|
+
" - tsctl.config.js\n" +
|
|
127
|
+
" - tsctl.config.json\n" +
|
|
128
|
+
" - tsctl.config.yaml\n" +
|
|
129
|
+
" - .tsctlrc\n" +
|
|
130
|
+
" - \"tsctl\" property in package.json\n" +
|
|
131
|
+
"\nOr specify a config file with --config"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// Validate with Zod
|
|
137
|
+
const validated = TypesenseConfigSchema.parse(result.config);
|
|
138
|
+
return validated;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof Error && error.name === "ZodError") {
|
|
141
|
+
throw new Error(`Invalid config in ${result.filepath}:\n${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Failed to parse config file: ${result.filepath}\n${error instanceof Error ? error.message : String(error)}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get the directory containing the config file
|
|
151
|
+
*/
|
|
152
|
+
export async function getConfigDir(configPath?: string): Promise<string> {
|
|
153
|
+
const configFile = configPath ? resolve(configPath) : await findConfigFile();
|
|
154
|
+
if (!configFile) {
|
|
155
|
+
return process.cwd();
|
|
156
|
+
}
|
|
157
|
+
return dirname(configFile);
|
|
158
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Main entry point for library usage
|
|
2
|
+
// Users can import { defineConfig } from "@tsctl/cli"
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
defineConfig,
|
|
6
|
+
// Types
|
|
7
|
+
type TypesenseConfig,
|
|
8
|
+
type CollectionConfig,
|
|
9
|
+
type AliasConfig,
|
|
10
|
+
type SynonymConfig,
|
|
11
|
+
type SynonymSetConfig,
|
|
12
|
+
type SynonymSetItem,
|
|
13
|
+
type OverrideConfig,
|
|
14
|
+
type CurationSetConfig,
|
|
15
|
+
type CurationItem,
|
|
16
|
+
type StopwordSetConfig,
|
|
17
|
+
type PresetConfig,
|
|
18
|
+
type StemmingDictionaryConfig,
|
|
19
|
+
type StemmingWord,
|
|
20
|
+
type AnalyticsRuleConfig,
|
|
21
|
+
type ApiKeyConfig,
|
|
22
|
+
type Field,
|
|
23
|
+
type FieldType,
|
|
24
|
+
type ConnectionConfig,
|
|
25
|
+
// Schemas (for validation)
|
|
26
|
+
TypesenseConfigSchema,
|
|
27
|
+
CollectionConfigSchema,
|
|
28
|
+
AliasConfigSchema,
|
|
29
|
+
SynonymConfigSchema,
|
|
30
|
+
SynonymSetConfigSchema,
|
|
31
|
+
SynonymSetItemSchema,
|
|
32
|
+
OverrideConfigSchema,
|
|
33
|
+
CurationSetConfigSchema,
|
|
34
|
+
CurationItemSchema,
|
|
35
|
+
CurationRuleSchema,
|
|
36
|
+
StopwordSetConfigSchema,
|
|
37
|
+
PresetConfigSchema,
|
|
38
|
+
StemmingDictionaryConfigSchema,
|
|
39
|
+
StemmingWordSchema,
|
|
40
|
+
AnalyticsRuleConfigSchema,
|
|
41
|
+
ApiKeyConfigSchema,
|
|
42
|
+
FieldSchema,
|
|
43
|
+
FieldTypeSchema,
|
|
44
|
+
ConnectionConfigSchema,
|
|
45
|
+
} from "./types/index.js";
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import {
|
|
4
|
+
getCollection,
|
|
5
|
+
createCollection,
|
|
6
|
+
deleteCollection,
|
|
7
|
+
listCollections,
|
|
8
|
+
} from "../resources/collection.js";
|
|
9
|
+
import { upsertAlias, getAlias } from "../resources/alias.js";
|
|
10
|
+
import type { CollectionConfig, AliasConfig } from "../types/index.js";
|
|
11
|
+
|
|
12
|
+
export interface MigrationPlan {
|
|
13
|
+
alias: string;
|
|
14
|
+
currentCollection: string | null;
|
|
15
|
+
newCollection: string;
|
|
16
|
+
newCollectionConfig: CollectionConfig;
|
|
17
|
+
steps: MigrationStep[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MigrationStep {
|
|
21
|
+
action: "create_collection" | "switch_alias" | "delete_old_collection";
|
|
22
|
+
description: string;
|
|
23
|
+
target: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MigrationResult {
|
|
27
|
+
success: boolean;
|
|
28
|
+
newCollectionName: string;
|
|
29
|
+
oldCollectionName: string | null;
|
|
30
|
+
aliasName: string;
|
|
31
|
+
errors: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate a versioned collection name
|
|
36
|
+
*/
|
|
37
|
+
export function generateVersionedName(baseName: string): string {
|
|
38
|
+
const timestamp = Date.now();
|
|
39
|
+
return `${baseName}_${timestamp}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract the base name from a versioned collection name
|
|
44
|
+
*/
|
|
45
|
+
export function extractBaseName(name: string): string {
|
|
46
|
+
// Try to extract base name by removing _<timestamp> suffix
|
|
47
|
+
const match = name.match(/^(.+)_\d{13}$/);
|
|
48
|
+
return match ? match[1]! : name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Find all versions of a collection by base name
|
|
53
|
+
*/
|
|
54
|
+
export async function findCollectionVersions(baseName: string): Promise<CollectionConfig[]> {
|
|
55
|
+
const allCollections = await listCollections();
|
|
56
|
+
return allCollections.filter(
|
|
57
|
+
(c) => c.name === baseName || c.name.startsWith(`${baseName}_`)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Plan a blue/green migration
|
|
63
|
+
*/
|
|
64
|
+
export async function planMigration(
|
|
65
|
+
aliasName: string,
|
|
66
|
+
newCollectionConfig: CollectionConfig
|
|
67
|
+
): Promise<MigrationPlan> {
|
|
68
|
+
const steps: MigrationStep[] = [];
|
|
69
|
+
|
|
70
|
+
// Get current alias target
|
|
71
|
+
const currentAlias = await getAlias(aliasName);
|
|
72
|
+
const currentCollection = currentAlias?.collection || null;
|
|
73
|
+
|
|
74
|
+
// Generate new collection name with timestamp
|
|
75
|
+
const baseName = extractBaseName(newCollectionConfig.name);
|
|
76
|
+
const newCollectionName = generateVersionedName(baseName);
|
|
77
|
+
|
|
78
|
+
// Update config with versioned name
|
|
79
|
+
const versionedConfig: CollectionConfig = {
|
|
80
|
+
...newCollectionConfig,
|
|
81
|
+
name: newCollectionName,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Step 1: Create new collection
|
|
85
|
+
steps.push({
|
|
86
|
+
action: "create_collection",
|
|
87
|
+
description: `Create new collection '${newCollectionName}'`,
|
|
88
|
+
target: newCollectionName,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Step 2: Switch alias (user should index data between step 1 and 2)
|
|
92
|
+
steps.push({
|
|
93
|
+
action: "switch_alias",
|
|
94
|
+
description: `Switch alias '${aliasName}' to '${newCollectionName}'`,
|
|
95
|
+
target: aliasName,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Step 3: Delete old collection (optional)
|
|
99
|
+
if (currentCollection) {
|
|
100
|
+
steps.push({
|
|
101
|
+
action: "delete_old_collection",
|
|
102
|
+
description: `Delete old collection '${currentCollection}'`,
|
|
103
|
+
target: currentCollection,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
alias: aliasName,
|
|
109
|
+
currentCollection,
|
|
110
|
+
newCollection: newCollectionName,
|
|
111
|
+
newCollectionConfig: versionedConfig,
|
|
112
|
+
steps,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Execute a migration step
|
|
118
|
+
*/
|
|
119
|
+
export async function executeMigrationStep(
|
|
120
|
+
plan: MigrationPlan,
|
|
121
|
+
step: MigrationStep
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
switch (step.action) {
|
|
124
|
+
case "create_collection":
|
|
125
|
+
await createCollection(plan.newCollectionConfig);
|
|
126
|
+
break;
|
|
127
|
+
|
|
128
|
+
case "switch_alias":
|
|
129
|
+
await upsertAlias({
|
|
130
|
+
name: plan.alias,
|
|
131
|
+
collection: plan.newCollection,
|
|
132
|
+
});
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case "delete_old_collection":
|
|
136
|
+
if (plan.currentCollection) {
|
|
137
|
+
await deleteCollection(plan.currentCollection);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Execute a full migration
|
|
145
|
+
*/
|
|
146
|
+
export async function executeMigration(
|
|
147
|
+
plan: MigrationPlan,
|
|
148
|
+
options: {
|
|
149
|
+
skipDelete?: boolean;
|
|
150
|
+
onStep?: (step: MigrationStep, index: number) => void;
|
|
151
|
+
} = {}
|
|
152
|
+
): Promise<MigrationResult> {
|
|
153
|
+
const result: MigrationResult = {
|
|
154
|
+
success: true,
|
|
155
|
+
newCollectionName: plan.newCollection,
|
|
156
|
+
oldCollectionName: plan.currentCollection,
|
|
157
|
+
aliasName: plan.alias,
|
|
158
|
+
errors: [],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
162
|
+
const step = plan.steps[i]!;
|
|
163
|
+
|
|
164
|
+
// Skip delete step if requested
|
|
165
|
+
if (step.action === "delete_old_collection" && options.skipDelete) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (options.onStep) {
|
|
170
|
+
options.onStep(step, i);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await executeMigrationStep(plan, step);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
result.success = false;
|
|
177
|
+
result.errors.push(
|
|
178
|
+
`Step ${i + 1} failed: ${error instanceof Error ? error.message : String(error)}`
|
|
179
|
+
);
|
|
180
|
+
break; // Stop on first error
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Format migration plan for display
|
|
189
|
+
*/
|
|
190
|
+
export function formatMigrationPlan(plan: MigrationPlan): string {
|
|
191
|
+
const lines: string[] = [];
|
|
192
|
+
|
|
193
|
+
lines.push(chalk.bold("\nMigration Plan:\n"));
|
|
194
|
+
|
|
195
|
+
lines.push(` Alias: ${chalk.cyan(plan.alias)}`);
|
|
196
|
+
lines.push(
|
|
197
|
+
` Current collection: ${plan.currentCollection ? chalk.yellow(plan.currentCollection) : chalk.gray("(none)")}`
|
|
198
|
+
);
|
|
199
|
+
lines.push(` New collection: ${chalk.green(plan.newCollection)}`);
|
|
200
|
+
|
|
201
|
+
lines.push(chalk.bold("\nSteps:\n"));
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
204
|
+
const step = plan.steps[i]!;
|
|
205
|
+
const icon =
|
|
206
|
+
step.action === "create_collection"
|
|
207
|
+
? chalk.green("+")
|
|
208
|
+
: step.action === "switch_alias"
|
|
209
|
+
? chalk.blue("→")
|
|
210
|
+
: chalk.red("-");
|
|
211
|
+
|
|
212
|
+
lines.push(` ${i + 1}. ${icon} ${step.description}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
lines.push(chalk.yellow("\n⚠️ Important:"));
|
|
216
|
+
lines.push(chalk.yellow(" Index your data to the new collection before switching the alias."));
|
|
217
|
+
lines.push(chalk.yellow(" Use --skip-delete to keep the old collection for rollback."));
|
|
218
|
+
|
|
219
|
+
return lines.join("\n");
|
|
220
|
+
}
|