diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 3e47ec0b..82e50a52 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,21 +1,69 @@ # Migration Guides for StellarSplit SDK -Comprehensive guide for migrating across major versions of the StellarSplit SDK. +Comprehensive guide for migrating across major versions of the **`@stellar-split/sdk`**, including breaking change catalogs, step-by-step instructions, working code examples, troubleshooting playbooks, and automated migration validation & progress tracking. --- -## Semantic Versioning Principles +## Table of Contents + +1. [Semantic Versioning & Compatibility Matrix](#1-semantic-versioning--compatibility-matrix) +2. [Breaking Changes Catalog](#2-breaking-changes-catalog) +3. [Step-by-Step Migration: v0.x to v1.x](#3-step-by-step-migration-v0x-to-v1x) +4. [Step-by-Step Migration: v1.x to v2.x](#4-step-by-step-migration-v1x-to-v2x) +5. [Working Code Examples (Before & After)](#5-working-code-examples-before--after) +6. [Troubleshooting Guide](#6-troubleshooting-guide) +7. [Automated Migration Validation & Progress Tracking](#7-automated-migration-validation--progress-tracking) + +--- + +## 1. Semantic Versioning & Compatibility Matrix StellarSplit follows strict Semantic Versioning (`MAJOR.MINOR.PATCH`): - **MAJOR**: Incompatible API changes, signature removals, altered default behavior. - **MINOR**: Backwards-compatible functionality additions, non-breaking features. - **PATCH**: Backwards-compatible bug fixes, internal performance optimizations. +| SDK Version | Node.js Runtime | `@stellar/stellar-sdk` | Token Amount Type | Wallet Interface | Package Scope | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **v0.9.x** | `>=16.0.0` | `^11.x` | `number` | Ad-hoc connectors | `split-sdk` | +| **v1.x** | `>=16.0.0` | `^12.x` | `number` / `string` | Connector classes | `@stellar-split/sdk` | +| **v2.x** | `>=18.0.0` | `^13.3.0` | **`bigint`** | **`WalletAdapter` / `Signer`** | **`@stellar-split/sdk`** (with subpath exports) | + --- -## Migrating from v1.x to v2.x +## 2. Breaking Changes Catalog + +| ID | Upgrade Path | Category | Breaking Change Summary | Impact | +| :--- | :--- | :--- | :--- | :--- | +| **`BC-101`** | `v0.x -> v1.0.0` | Client Init | Replaced `legacyClient()` / `createLegacySplitClient()` with `new StellarSplitClient(config)`. | High | +| **`BC-102`** | `v0.x -> v1.0.0` | Error Handling | Replaced generic `Error` throws with typed `StellarSplitError` subclasses and type guards. | Medium | +| **`BC-201`** | `v1.x -> v2.0.0` | Wallet Adapter | Unified `FreighterConnector.requestAccess()` under `FreighterAdapter.connect()` and `Signer`. | High | +| **`BC-202`** | `v1.x -> v2.0.0` | Numeric Precision | All stroop/token amounts (`amount`, `totalAmount`, `feeCeiling`) require `bigint` instead of `number`. | High | +| **`BC-203`** | `v1.x -> v2.0.0` | Network Config | Explicit `NetworkPreset` (`getNetworkPreset(NetworkEnvironment.TESTNET)`) required on client init. | Medium | +| **`BC-204`** | `v1.x -> v2.0.0` | Telemetry & Plugins | Removed `config.onLog`; use `client.use(LoggingPlugin)` and `config.telemetryHooks`. | Medium | +| **`BC-205`** | `v1.x -> v2.0.0` | Subpath Exports | Blocked `/dist/*` deep imports; use `@stellar-split/sdk/utils`, `/ui`, `/react`, `/testing`, `/mock`, `/telemetry`, `/lazy`. | Medium | + +--- + +## 3. Step-by-Step Migration: v0.x to v1.x + +### Step 1: Replace `legacyClient` with `StellarSplitClient` (`BC-101`) +Instantiate `StellarSplitClient` with an explicit configuration object containing your Soroban `contractId` and network endpoints. + +### Step 2: Adopt Typed `StellarSplitError` Guards (`BC-102`) +Replace substring checks on `error.message` with deterministic type guards (`isInvoiceNotFoundError`, `isDeadlinePassedError`, `isInvoiceFullyFundedError`). + +--- + +## 4. Step-by-Step Migration: v1.x to v2.x + +### Step 1: Update Package Imports to Official Subpath Exports (`BC-205`) +Replace any `split-sdk/dist/...` imports with `@stellar-split/sdk` or its subpath entrypoints (`@stellar-split/sdk/utils`, `@stellar-split/sdk/react`, `@stellar-split/sdk/ui`). -### 1. Unified Wallet Adapter Interface +### Step 2: Configure Explicit `NetworkEnvironment` Presets (`BC-203`) +Spread `getNetworkPreset(NetworkEnvironment.TESTNET)` (or `MAINNET` / `FUTURENET`) into `new StellarSplitClient({ ... })`. + +### Step 3: Unified Wallet Adapter Interface (`BC-201`) In v1.x, each wallet connector used a distinct proprietary contract. In v2.x, all connectors adhere to the common `WalletAdapter` interface. **v1.x:** @@ -27,12 +75,12 @@ await wallet.requestAccess(); **v2.x:** ```typescript -import { FreighterAdapter } from 'split-sdk'; +import { FreighterAdapter } from '@stellar-split/sdk'; const wallet = new FreighterAdapter(); const address = await wallet.connect(); ``` -### 2. BigInt Strictness for Token Amounts +### Step 4: BigInt Strictness for Token Amounts (`BC-202`) All stroop/amount calculations have moved from `number` to `bigint` to prevent floating-point precision loss. **v1.x:** @@ -45,11 +93,139 @@ client.createInvoice({ amount: 1000000 }); client.createInvoice({ amount: 1000000n }); ``` +### Step 5: Migrate `onLog` to `PluginRegistry` & `TelemetryHooks` (`BC-204`) +Attach `LoggingPlugin` via `client.use(LoggingPlugin)` and pass `telemetryHooks` in `StellarSplitClientConfig`. + --- -## Migration Checklist +## 5. Working Code Examples (Before & After) + +### 5.1 Complete End-to-End Invoice Creation & Payment + +**Before (v1.x):** +```typescript +import { StellarSplitClient, FreighterConnector } from "split-sdk"; +import { parseAmount } from "split-sdk/dist/utils"; + +const wallet = new FreighterConnector(); +await wallet.requestAccess(); + +const client = new StellarSplitClient({ + contractId: "CCONTRACT1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + onLog: (msg) => console.log(msg), +}); + +await client.createInvoice({ + amount: 25000000, // 2.5 XLM as JS number + recipients: [{ address: "GABC...", shareBps: 10000 }], +}); +``` + +**After (v2.x):** +```typescript +import { + StellarSplitClient, + NetworkEnvironment, + getNetworkPreset, + LoggingPlugin, + isDeadlinePassedError, +} from "@stellar-split/sdk"; +import { parseAmount, formatAmount } from "@stellar-split/sdk/utils"; + +const client = new StellarSplitClient({ + ...getNetworkPreset(NetworkEnvironment.TESTNET), + contractId: "CCONTRACT1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + telemetryHooks: { + onCallStart: ({ method, traceId }) => console.debug(`[start] ${method} (${traceId})`), + onCallEnd: ({ method, durationMs, success }) => + console.debug(`[end] ${method} ${durationMs}ms ok=${success}`), + }, +}); + +client.use(LoggingPlugin); + +const stroopAmount: bigint = parseAmount("2.5"); // 25000000n +console.log("Formatted amount:", formatAmount(stroopAmount)); // "2.5000000" + +try { + await client.createInvoice({ + amount: stroopAmount, + recipients: [{ address: "GABC...", shareBps: 10000 }], + }); +} catch (err) { + if (isDeadlinePassedError(err)) { + console.error("Invoice deadline has already passed:", err.code); + } +} +``` + +--- + +## 6. Troubleshooting Guide + +### Issue 1: `TypeError: Cannot mix BigInt and other types, use explicit conversions` (`TS-001`) +- **Cause**: Application code multiplies or adds a `number` (e.g. `100`) to an SDK `bigint` amount field. +- **Solution**: Convert numeric constants to `bigint` literals (`100n`) and use `parseAmount()` / `formatAmount()` from `@stellar-split/sdk/utils` at UI boundaries. + +### Issue 2: `Property 'requestAccess' does not exist on type 'FreighterAdapter'` (`TS-002`) +- **Cause**: Calling the removed v1 `requestAccess()` method on a v2 wallet adapter. +- **Solution**: Call `const address = await wallet.connect();`. + +### Issue 3: `UnknownNetworkError` during Client Initialization (`TS-003`) +- **Cause**: Missing `horizonUrl`, `rpcUrl`, or `networkPassphrase` when initializing `StellarSplitClient` or calling `switchNetwork()`. +- **Solution**: Spread `...getNetworkPreset(NetworkEnvironment.TESTNET)` into the client configuration or validate custom presets with `isNetworkPreset(preset)`. + +### Issue 4: `ERR_PACKAGE_PATH_NOT_EXPORTED` on `/dist/*` Imports (`TS-004`) +- **Cause**: Node.js ESM or bundler rejecting unexported deep paths like `@stellar-split/sdk/dist/utils`. +- **Solution**: Replace with `@stellar-split/sdk/utils`. + +--- + +## 7. Automated Migration Validation & Progress Tracking + +`@stellar-split/sdk` includes programmatic migration validation (`validateSdkMigration`), interactive progress tracking (`MigrationProgressTracker`), and a CLI AST breaking-change detector (`stellar-split-sdk-migrate`). + +### 7.1 Validating Configurations, Payloads & Code Snippets + +```typescript +import { + validateSdkMigration, + MigrationProgressTracker, +} from "../src/migration/versionMigrationGuide.js"; + +const validation = validateSdkMigration({ + targetVersion: "2.0.0", + config: { + contractId: "CCONTRACT1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + rpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }, + invoicePayload: { + amount: 1000000n, + }, +}); + +console.log("Migration valid:", validation.valid, "Score:", validation.score); +``` + +### 7.2 Tracking Step-by-Step Upgrade Progress + +```typescript +const tracker = new MigrationProgressTracker("v1-to-v2"); + +tracker.completeStep("step-v2-1-imports", "Updated all subpath imports"); +tracker.completeStep("step-v2-2-network", "Configured TESTNET preset"); +tracker.startStep("step-v2-3-wallets"); + +const summary = tracker.getSummary(); +console.log(`Progress: ${summary.percentComplete}% (${summary.completedSteps}/${summary.totalSteps})`); +``` + +### 7.3 Migration Checklist 1. [ ] Audit existing `process.env` references for new SDK environment config schemas. 2. [ ] Replace deprecated wallet connect methods with the unified `WalletAdapter.connect()`. 3. [ ] Convert numerical Stroop balance inputs to `bigint`. -4. [ ] Run full test suite with `npm test`. +4. [ ] Validate configuration and payloads with `validateSdkMigration()`. +5. [ ] Run full test suite with `npm test`. diff --git a/src/migration/versionMigrationGuide.ts b/src/migration/versionMigrationGuide.ts new file mode 100644 index 00000000..64b06fb2 --- /dev/null +++ b/src/migration/versionMigrationGuide.ts @@ -0,0 +1,630 @@ +/** + * SDK Version Migration Guide, Validator & Progress Tracker (#1200). + * + * Provides structured breaking-change definitions, step-by-step migration plans, + * executable code examples, troubleshooting playbooks, configuration/code + * migration validation, and stateful progress tracking across SDK versions. + */ + +import { compareVersions, parseVersion } from "../versionManager.js"; + +export type MigrationPathId = "v0-to-v1" | "v1-to-v2" | "v0-to-v2"; + +export type BreakingChangeCategory = + | "client-initialization" + | "wallet-adapter" + | "numeric-precision" + | "network-config" + | "error-handling" + | "telemetry-plugins" + | "webhook-security" + | "subpath-exports"; + +export interface BreakingChangeDescriptor { + id: string; + fromVersion: string; + toVersion: string; + category: BreakingChangeCategory; + title: string; + summary: string; + impact: "high" | "medium" | "low"; + beforeCode: string; + afterCode: string; + detectionPattern?: RegExp; +} + +export interface MigrationStepDescriptor { + id: string; + order: number; + pathId: "v0-to-v1" | "v1-to-v2"; + title: string; + description: string; + breakingChangeIds: string[]; + beforeExample: string; + afterExample: string; + verificationCommand: string; +} + +export interface TroubleshootingEntry { + id: string; + symptom: string; + errorCodeOrMessage: string; + affectedVersions: string; + rootCause: string; + resolutionSteps: string[]; + fixSnippet: string; +} + +export interface MigrationValidationInput { + targetVersion?: string; + config?: Record; + invoicePayload?: Record; + codeSnippet?: string; +} + +export interface MigrationValidationIssue { + id: string; + severity: "error" | "warning"; + breakingChangeId: string; + fieldOrPattern: string; + message: string; + remediation: string; +} + +export interface MigrationValidationResult { + valid: boolean; + targetVersion: string; + score: number; + issues: MigrationValidationIssue[]; + warnings: MigrationValidationIssue[]; +} + +export type MigrationStepStatus = + | "pending" + | "in_progress" + | "completed" + | "skipped" + | "blocked"; + +export interface MigrationStepProgress { + stepId: string; + title: string; + order: number; + status: MigrationStepStatus; + startedAt?: number; + completedAt?: number; + notes?: string; + blockerReason?: string; +} + +export interface MigrationProgressSummary { + pathId: MigrationPathId; + fromVersion: string; + toVersion: string; + totalSteps: number; + completedSteps: number; + inProgressSteps: number; + pendingSteps: number; + skippedSteps: number; + blockedSteps: number; + percentComplete: number; + isComplete: boolean; + nextStep?: MigrationStepProgress; + steps: MigrationStepProgress[]; +} + +export const BREAKING_CHANGES: readonly BreakingChangeDescriptor[] = [ + { + id: "BC-101", + fromVersion: "0.9.0", + toVersion: "1.0.0", + category: "client-initialization", + title: "Replaced `createLegacySplitClient` with `StellarSplitClient` class", + summary: + "In v1.0.0, factory helpers `createLegacySplitClient` and `legacyClient` were replaced by the unified `StellarSplitClient` class constructor.", + impact: "high", + beforeCode: `import { legacyClient } from "@stellar-split/sdk";\nconst client = legacyClient("https://soroban-testnet.stellar.org");`, + afterCode: `import { StellarSplitClient, NetworkEnvironment, getNetworkPreset } from "@stellar-split/sdk";\nconst client = new StellarSplitClient({\n ...getNetworkPreset(NetworkEnvironment.TESTNET),\n contractId: "CCONTRACT...",\n});`, + detectionPattern: /\b(legacyClient|createLegacySplitClient)\b/, + }, + { + id: "BC-102", + fromVersion: "0.9.0", + toVersion: "1.0.0", + category: "error-handling", + title: "Structured `StellarSplitError` hierarchy replaces generic `Error`", + summary: + "All SDK errors now extend `StellarSplitError` with machine-readable `code` properties and type-guard helpers (`isInvoiceNotFoundError`, `isDeadlinePassedError`).", + impact: "medium", + beforeCode: `try {\n await client.pay(params);\n} catch (err: any) {\n if (err.message.includes("not found")) { /* ... */ }\n}`, + afterCode: `import { isInvoiceNotFoundError } from "@stellar-split/sdk";\ntry {\n await client.pay(params);\n} catch (err) {\n if (isInvoiceNotFoundError(err)) { /* ... */ }\n}`, + detectionPattern: /err\.message\.includes\(["']not found["']\)/, + }, + { + id: "BC-201", + fromVersion: "1.0.0", + toVersion: "2.0.0", + category: "wallet-adapter", + title: "Unified `WalletAdapter` and `Signer` Interface", + summary: + "In v1.x, each wallet connector used a proprietary method (`requestAccess()`, `enable()`). In v2.0.0, all connectors implement `WalletAdapter.connect()` and `Signer.signTransaction()`.", + impact: "high", + beforeCode: `import { FreighterConnector } from "split-sdk";\nconst wallet = new FreighterConnector();\nawait wallet.requestAccess();`, + afterCode: `import { FreighterAdapter } from "@stellar-split/sdk";\nconst wallet = new FreighterAdapter();\nconst address = await wallet.connect();`, + detectionPattern: /\b(FreighterConnector|requestAccess)\s*\(/, + }, + { + id: "BC-202", + fromVersion: "1.0.0", + toVersion: "2.0.0", + category: "numeric-precision", + title: "Strict `bigint` Requirement for Stroop & Token Amounts", + summary: + "All stroop/amount fields (`amount`, `totalAmount`, `feeCeiling`) moved from `number` to `bigint` in v2.0.0 to eliminate IEEE-754 floating-point precision loss.", + impact: "high", + beforeCode: `await client.createInvoice({\n amount: 1000000,\n recipients: [{ address: "G...", shareBps: 10000 }],\n});`, + afterCode: `import { parseAmount } from "@stellar-split/sdk/utils";\nawait client.createInvoice({\n amount: parseAmount("0.1"), // 1000000n\n recipients: [{ address: "G...", shareBps: 10000 }],\n});`, + detectionPattern: /\b(amount|totalAmount)\s*:\s*\d+(?!n)\b/, + }, + { + id: "BC-203", + fromVersion: "1.0.0", + toVersion: "2.0.0", + category: "network-config", + title: "Explicit `NetworkEnvironment` Presets and Passphrase Enforcement", + summary: + "v2.0.0 requires a complete `NetworkPreset` (`horizonUrl`, `rpcUrl`, `networkPassphrase`) or a built-in `NetworkEnvironment` preset instead of an implicit testnet fallback.", + impact: "medium", + beforeCode: `const client = new StellarSplitClient({ contractId: "C..." });`, + afterCode: `import { StellarSplitClient, NetworkEnvironment, getNetworkPreset } from "@stellar-split/sdk";\nconst client = new StellarSplitClient({\n ...getNetworkPreset(NetworkEnvironment.TESTNET),\n contractId: "C...",\n});`, + }, + { + id: "BC-204", + fromVersion: "1.0.0", + toVersion: "2.0.0", + category: "telemetry-plugins", + title: "Migrated Inline `onLog` Callback to `PluginRegistry` & `TelemetryHooks`", + summary: + "The deprecated `onLog` client option was replaced by `client.use(LoggingPlugin)` and structured `telemetryHooks` (`onCallStart`, `onCallEnd`, `onError`, `onProfile`).", + impact: "medium", + beforeCode: `const client = new StellarSplitClient({\n contractId: "C...",\n onLog: (msg) => console.log(msg),\n});`, + afterCode: `import { StellarSplitClient, LoggingPlugin } from "@stellar-split/sdk";\nconst client = new StellarSplitClient({\n contractId: "C...",\n telemetryHooks: {\n onCallEnd: ({ method, durationMs }) => console.log(method, durationMs),\n },\n});\nclient.use(LoggingPlugin);`, + detectionPattern: /\bonLog\s*:/, + }, + { + id: "BC-205", + fromVersion: "1.0.0", + toVersion: "2.0.0", + category: "subpath-exports", + title: "Scoped Package Name `@stellar-split/sdk` & Explicit Subpath Exports", + summary: + "Deep imports into `dist/` or `src/` are blocked by package `exports`. Consumers must import from `@stellar-split/sdk` or official subpaths (`/ui`, `/react`, `/testing`, `/mock`, `/telemetry`, `/utils`, `/lazy`).", + impact: "medium", + beforeCode: `import { parseAmount } from "split-sdk/dist/utils";`, + afterCode: `import { parseAmount } from "@stellar-split/sdk/utils";`, + detectionPattern: /from\s+["'](?:@stellar-split\/sdk|split-sdk)\/(?:dist|src)\//, + }, +]; + +export const MIGRATION_STEPS: readonly MigrationStepDescriptor[] = [ + { + id: "step-v1-1-client", + order: 1, + pathId: "v0-to-v1", + title: "Replace legacy factory functions with `StellarSplitClient`", + description: + "Update package imports from `legacyClient` / `createLegacySplitClient` to instantiate `new StellarSplitClient(config)` with explicit contract and network configuration.", + breakingChangeIds: ["BC-101"], + beforeExample: `import { legacyClient } from "@stellar-split/sdk";\nconst sdk = legacyClient("https://soroban-testnet.stellar.org");`, + afterExample: `import { StellarSplitClient, NetworkEnvironment, getNetworkPreset } from "@stellar-split/sdk";\nconst sdk = new StellarSplitClient({\n ...getNetworkPreset(NetworkEnvironment.TESTNET),\n contractId: process.env.STELLAR_SPLIT_CONTRACT_ID!,\n});`, + verificationCommand: "npx tsc --noEmit", + }, + { + id: "step-v1-2-errors", + order: 2, + pathId: "v0-to-v1", + title: "Adopt typed `StellarSplitError` subclasses and type guards", + description: + "Replace fragile string matching on `error.message` with `StellarSplitError` `code` checks or built-in type guards (`isInvoiceNotFoundError`, `isDeadlinePassedError`).", + breakingChangeIds: ["BC-102"], + beforeExample: `catch (e: any) { if (e.message.includes("expired")) handleExpired(); }`, + afterExample: `import { isDeadlinePassedError } from "@stellar-split/sdk";\ncatch (e) { if (isDeadlinePassedError(e)) handleExpired(); }`, + verificationCommand: "npm test", + }, + { + id: "step-v2-1-imports", + order: 1, + pathId: "v1-to-v2", + title: "Update package imports to `@stellar-split/sdk` subpath exports", + description: + "Replace `split-sdk` or internal `/dist/*` deep imports with `@stellar-split/sdk` and its documented subpaths (`/utils`, `/ui`, `/react`, `/testing`, `/mock`, `/telemetry`, `/lazy`).", + breakingChangeIds: ["BC-205"], + beforeExample: `import { parseAmount } from "split-sdk/dist/utils";`, + afterExample: `import { parseAmount } from "@stellar-split/sdk/utils";`, + verificationCommand: "npx tsc --noEmit", + }, + { + id: "step-v2-2-network", + order: 2, + pathId: "v1-to-v2", + title: "Configure explicit `NetworkEnvironment` presets", + description: + "Supply `horizonUrl`, `rpcUrl`, and `networkPassphrase` via `getNetworkPreset(NetworkEnvironment.TESTNET | MAINNET | FUTURENET)` when constructing `StellarSplitClient`.", + breakingChangeIds: ["BC-203"], + beforeExample: `const client = new StellarSplitClient({ contractId });`, + afterExample: `const client = new StellarSplitClient({\n ...getNetworkPreset(NetworkEnvironment.TESTNET),\n contractId,\n});`, + verificationCommand: "npx vitest run test/network-environment.test.ts", + }, + { + id: "step-v2-3-wallets", + order: 3, + pathId: "v1-to-v2", + title: "Migrate wallet connectors to unified `WalletAdapter` interface", + description: + "Replace `FreighterConnector.requestAccess()` and wallet-specific calls with `FreighterAdapter.connect()` and the common `Signer` interface.", + breakingChangeIds: ["BC-201"], + beforeExample: `const wallet = new FreighterConnector();\nawait wallet.requestAccess();`, + afterExample: `const wallet = new FreighterAdapter();\nconst publicKey = await wallet.connect();`, + verificationCommand: "npx vitest run test/freighterAdapter.test.ts", + }, + { + id: "step-v2-4-bigint", + order: 4, + pathId: "v1-to-v2", + title: "Convert numerical stroop amounts to `bigint`", + description: + "Convert all `number` token amounts in `createInvoice`, `pay`, and fee configurations to `bigint` literals (`1000000n`) or `parseAmount(\"0.1\")`.", + breakingChangeIds: ["BC-202"], + beforeExample: `await client.createInvoice({ amount: 1000000 });`, + afterExample: `await client.createInvoice({ amount: 1000000n });`, + verificationCommand: "npx vitest run test/parseAmount.test.ts", + }, + { + id: "step-v2-5-telemetry", + order: 5, + pathId: "v1-to-v2", + title: "Migrate `onLog` callbacks to `PluginRegistry` and `TelemetryHooks`", + description: + "Register `LoggingPlugin` / `MetricsPlugin` via `client.use()` and move APM instrumentation to `telemetryHooks`.", + breakingChangeIds: ["BC-204"], + beforeExample: `new StellarSplitClient({ contractId, onLog: console.log });`, + afterExample: `const client = new StellarSplitClient({ contractId, ...getNetworkPreset(NetworkEnvironment.TESTNET), telemetryHooks: { onCallEnd: console.debug } });\nclient.use(LoggingPlugin);`, + verificationCommand: "npx vitest run test/telemetryHooks.test.ts test/plugin.test.ts", + }, +]; + +export const TROUBLESHOOTING_CATALOG: readonly TroubleshootingEntry[] = [ + { + id: "TS-001", + symptom: "TypeError: Cannot mix BigInt and other types, use explicit conversions", + errorCodeOrMessage: "Cannot mix BigInt and other types", + affectedVersions: ">=2.0.0", + rootCause: + "Invoice `amount`, `paidAmount`, and `fee` values are returned as `bigint` in v2.x, whereas application code is performing arithmetic with JS `number`.", + resolutionSteps: [ + "Use `parseAmount(displayString)` from `@stellar-split/sdk/utils` when converting user input to stroops.", + "Use `formatAmount(stroopBigInt)` when rendering amounts in the UI.", + "Perform all basis-point math using `bigint` literals (e.g., `(amount * 250n) / 10000n`).", + ], + fixSnippet: `import { parseAmount, formatAmount } from "@stellar-split/sdk/utils";\nconst raw = parseAmount("25.50"); // 255000000n\nconst fee = (raw * 100n) / 10000n;\nconst label = formatAmount(raw - fee);`, + }, + { + id: "TS-002", + symptom: "Property 'requestAccess' does not exist on type 'FreighterAdapter'", + errorCodeOrMessage: "requestAccess is not a function", + affectedVersions: ">=2.0.0", + rootCause: + "Wallet connectors were unified under the `WalletAdapter` contract in v2.0.0.", + resolutionSteps: [ + "Replace `FreighterConnector` with `FreighterAdapter`.", + "Call `await wallet.connect()` instead of `await wallet.requestAccess()`.", + ], + fixSnippet: `const wallet = new FreighterAdapter();\nconst publicKey = await wallet.connect();`, + }, + { + id: "TS-003", + symptom: "UnknownNetworkError thrown during client initialization or network switch", + errorCodeOrMessage: "UnknownNetworkError", + affectedVersions: ">=1.1.0", + rootCause: + "An invalid network environment string or incomplete custom `NetworkPreset` (missing `horizonUrl`, `rpcUrl`, or `networkPassphrase`) was passed.", + resolutionSteps: [ + "Use `getNetworkPreset(NetworkEnvironment.TESTNET)` or `NetworkEnvironment.MAINNET` for standard networks.", + "When using `NetworkEnvironment.CUSTOM`, provide all three fields (`horizonUrl`, `rpcUrl`, `networkPassphrase`) and verify with `isNetworkPreset(preset)`.", + ], + fixSnippet: `import { NetworkEnvironment, getNetworkPreset } from "@stellar-split/sdk";\nconst preset = getNetworkPreset(NetworkEnvironment.TESTNET);`, + }, + { + id: "TS-004", + symptom: "ERR_PACKAGE_PATH_NOT_EXPORTED when importing internal SDK modules", + errorCodeOrMessage: "ERR_PACKAGE_PATH_NOT_EXPORTED", + affectedVersions: ">=2.0.0", + rootCause: + "`package.json` enforces strict `exports` subpaths and blocks deep imports into `/dist/*` or `/src/*`.", + resolutionSteps: [ + "Import core symbols from `@stellar-split/sdk`.", + "Import utilities from `@stellar-split/sdk/utils`, React hooks from `@stellar-split/sdk/react`, and mocks from `@stellar-split/sdk/mock`.", + ], + fixSnippet: `import { StellarSplitClient } from "@stellar-split/sdk";\nimport { parseAmount } from "@stellar-split/sdk/utils";`, + }, +]; + +/** + * Returns breaking changes applicable when upgrading between `fromVersion` and `toVersion`. + */ +export function getBreakingChangesForUpgrade( + fromVersion: string, + toVersion: string, +): BreakingChangeDescriptor[] { + parseVersion(fromVersion); + parseVersion(toVersion); + return BREAKING_CHANGES.filter( + (bc) => + compareVersions(bc.fromVersion, fromVersion) >= 0 && + compareVersions(bc.toVersion, toVersion) <= 0, + ); +} + +/** + * Looks up troubleshooting guidance matching an error message, code, or symptom keyword. + */ +export function findTroubleshootingGuide(query: string): TroubleshootingEntry[] { + const q = query.trim().toLowerCase(); + if (!q) return [...TROUBLESHOOTING_CATALOG]; + return TROUBLESHOOTING_CATALOG.filter( + (entry) => + entry.id.toLowerCase() === q || + entry.symptom.toLowerCase().includes(q) || + entry.errorCodeOrMessage.toLowerCase().includes(q) || + entry.rootCause.toLowerCase().includes(q), + ); +} + +/** + * Validates consumer configuration, invoice payloads, and/or code snippets + * against the target SDK version's migration requirements. + */ +export function validateSdkMigration( + input: MigrationValidationInput, +): MigrationValidationResult { + const targetVersion = input.targetVersion ?? "2.0.0"; + parseVersion(targetVersion); + + const issues: MigrationValidationIssue[] = []; + const warnings: MigrationValidationIssue[] = []; + + if (input.config) { + const cfg = input.config; + if ("onLog" in cfg) { + issues.push({ + id: "VAL-CFG-ONLOG", + severity: "error", + breakingChangeId: "BC-204", + fieldOrPattern: "config.onLog", + message: "`config.onLog` was removed in v2.0.0. Use `client.use(LoggingPlugin)` or `config.telemetryHooks`.", + remediation: "Replace `onLog` with `telemetryHooks: { onCallEnd, onError }` or `client.use(LoggingPlugin)`.", + }); + } + + if (!cfg.contractId || typeof cfg.contractId !== "string") { + issues.push({ + id: "VAL-CFG-CONTRACT", + severity: "error", + breakingChangeId: "BC-101", + fieldOrPattern: "config.contractId", + message: "`config.contractId` is required by `StellarSplitClient`.", + remediation: "Provide a valid Soroban contract address in `config.contractId`.", + }); + } + + const hasPresetFields = + typeof cfg.rpcUrl === "string" && + typeof cfg.horizonUrl === "string" && + typeof cfg.networkPassphrase === "string"; + if (!hasPresetFields && !cfg.network) { + warnings.push({ + id: "VAL-CFG-NETWORK", + severity: "warning", + breakingChangeId: "BC-203", + fieldOrPattern: "config.rpcUrl / config.networkPassphrase", + message: + "Incomplete network configuration; spread `getNetworkPreset(NetworkEnvironment.TESTNET)` into client config.", + remediation: + "Spread `...getNetworkPreset(NetworkEnvironment.TESTNET)` into `new StellarSplitClient({ ... })`.", + }); + } + } + + if (input.invoicePayload) { + const payload = input.invoicePayload; + for (const amountField of ["amount", "totalAmount", "feeCeiling"] as const) { + if (amountField in payload && typeof payload[amountField] === "number") { + issues.push({ + id: `VAL-PAYLOAD-${amountField.toUpperCase()}`, + severity: "error", + breakingChangeId: "BC-202", + fieldOrPattern: `invoicePayload.${amountField}`, + message: `Field \`${amountField}\` must be a \`bigint\` in v2.0.0, received \`number\` (${String(payload[amountField])}).`, + remediation: `Use \`BigInt(${String(payload[amountField])})\` or \`parseAmount()\` from \`@stellar-split/sdk/utils\`.`, + }); + } + } + } + + if (input.codeSnippet) { + for (const bc of BREAKING_CHANGES) { + if (bc.detectionPattern && bc.detectionPattern.test(input.codeSnippet)) { + issues.push({ + id: `VAL-CODE-${bc.id}`, + severity: "error", + breakingChangeId: bc.id, + fieldOrPattern: bc.title, + message: bc.summary, + remediation: bc.afterCode, + }); + } + } + } + + const deduction = issues.length * 25 + warnings.length * 10; + const score = Math.max(0, 100 - deduction); + + return { + valid: issues.length === 0, + targetVersion, + score, + issues, + warnings, + }; +} + +/** + * Tracks step-by-step migration execution, blockers, and completion percentage. + */ +export class MigrationProgressTracker { + private readonly pathId: MigrationPathId; + private readonly fromVersion: string; + private readonly toVersion: string; + private readonly steps: Map = new Map(); + + constructor(pathId: MigrationPathId = "v1-to-v2") { + this.pathId = pathId; + if (pathId === "v0-to-v1") { + this.fromVersion = "0.9.0"; + this.toVersion = "1.0.0"; + } else if (pathId === "v1-to-v2") { + this.fromVersion = "1.0.0"; + this.toVersion = "2.0.0"; + } else { + this.fromVersion = "0.9.0"; + this.toVersion = "2.0.0"; + } + + const applicableSteps = MIGRATION_STEPS.filter((s) => + pathId === "v0-to-v2" ? true : s.pathId === pathId, + ); + + applicableSteps.forEach((step, idx) => { + this.steps.set(step.id, { + stepId: step.id, + title: step.title, + order: idx + 1, + status: "pending", + }); + }); + } + + startStep(stepId: string): MigrationStepProgress { + const step = this.getStepOrThrow(stepId); + step.status = "in_progress"; + step.startedAt = Date.now(); + step.blockerReason = undefined; + return { ...step }; + } + + completeStep(stepId: string, notes?: string): MigrationStepProgress { + const step = this.getStepOrThrow(stepId); + if (!step.startedAt) step.startedAt = Date.now(); + step.status = "completed"; + step.completedAt = Date.now(); + step.blockerReason = undefined; + if (notes !== undefined) step.notes = notes; + return { ...step }; + } + + skipStep(stepId: string, notes: string): MigrationStepProgress { + const step = this.getStepOrThrow(stepId); + step.status = "skipped"; + step.completedAt = Date.now(); + step.notes = notes; + return { ...step }; + } + + blockStep(stepId: string, blockerReason: string): MigrationStepProgress { + const step = this.getStepOrThrow(stepId); + step.status = "blocked"; + step.blockerReason = blockerReason; + return { ...step }; + } + + validateAndCompleteStep( + stepId: string, + input: MigrationValidationInput, + ): { step: MigrationStepProgress; validation: MigrationValidationResult } { + this.startStep(stepId); + const validation = validateSdkMigration(input); + if (validation.valid) { + const completed = this.completeStep( + stepId, + `Validated cleanly (score=${validation.score})`, + ); + return { step: completed, validation }; + } + const blocked = this.blockStep( + stepId, + validation.issues.map((i) => i.message).join("; "), + ); + return { step: blocked, validation }; + } + + getSummary(): MigrationProgressSummary { + const allSteps = Array.from(this.steps.values()).sort((a, b) => a.order - b.order); + const totalSteps = allSteps.length; + const completedSteps = allSteps.filter((s) => s.status === "completed").length; + const skippedSteps = allSteps.filter((s) => s.status === "skipped").length; + const inProgressSteps = allSteps.filter((s) => s.status === "in_progress").length; + const blockedSteps = allSteps.filter((s) => s.status === "blocked").length; + const pendingSteps = allSteps.filter((s) => s.status === "pending").length; + + const doneCount = completedSteps + skippedSteps; + const percentComplete = + totalSteps === 0 ? 100 : Math.round((doneCount / totalSteps) * 100); + const nextStep = allSteps.find( + (s) => s.status === "in_progress" || s.status === "pending" || s.status === "blocked", + ); + + return { + pathId: this.pathId, + fromVersion: this.fromVersion, + toVersion: this.toVersion, + totalSteps, + completedSteps, + inProgressSteps, + pendingSteps, + skippedSteps, + blockedSteps, + percentComplete, + isComplete: doneCount === totalSteps && totalSteps > 0, + nextStep: nextStep ? { ...nextStep } : undefined, + steps: allSteps.map((s) => ({ ...s })), + }; + } + + toJSON(): string { + return JSON.stringify(this.getSummary(), null, 2); + } + + static fromJSON(serialized: string): MigrationProgressTracker { + const parsed = JSON.parse(serialized) as MigrationProgressSummary; + const tracker = new MigrationProgressTracker(parsed.pathId); + for (const saved of parsed.steps) { + const target = tracker.steps.get(saved.stepId); + if (target) { + target.status = saved.status; + target.startedAt = saved.startedAt; + target.completedAt = saved.completedAt; + target.notes = saved.notes; + target.blockerReason = saved.blockerReason; + } + } + return tracker; + } + + private getStepOrThrow(stepId: string): MigrationStepProgress { + const step = this.steps.get(stepId); + if (!step) { + throw new Error(`Unknown migration step ID: ${stepId}`); + } + return step; + } +} diff --git a/test/versionMigrationGuide.test.ts b/test/versionMigrationGuide.test.ts new file mode 100644 index 00000000..ea8cb35a --- /dev/null +++ b/test/versionMigrationGuide.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import { + BREAKING_CHANGES, + MIGRATION_STEPS, + TROUBLESHOOTING_CATALOG, + getBreakingChangesForUpgrade, + findTroubleshootingGuide, + validateSdkMigration, + MigrationProgressTracker, +} from "../src/migration/versionMigrationGuide.js"; +import { parseAmount, formatAmount } from "../src/utils.js"; +import { + NetworkEnvironment, + getNetworkPreset, + isNetworkPreset, +} from "../src/config.js"; +import { PluginRegistry, LoggingPlugin } from "../src/plugin.js"; +import { migrate, VersionTracker } from "../src/versionManager.js"; + +describe("SDK Version Migration Guides, Validation & Progress Tracking (#1200)", () => { + const repoRoot = path.resolve(__dirname, ".."); + const migrationMdPath = path.join(repoRoot, "docs", "MIGRATION.md"); + + it("identifies breaking changes across v0.x -> v1.0.0 and v1.0.0 -> v2.0.0", () => { + const v0ToV1 = getBreakingChangesForUpgrade("0.9.0", "1.0.0"); + expect(v0ToV1.map((c) => c.id)).toEqual(["BC-101", "BC-102"]); + + const v1ToV2 = getBreakingChangesForUpgrade("1.0.0", "2.0.0"); + expect(v1ToV2.map((c) => c.id)).toEqual([ + "BC-201", + "BC-202", + "BC-203", + "BC-204", + "BC-205", + ]); + + const fullUpgrade = getBreakingChangesForUpgrade("0.9.0", "2.0.0"); + expect(fullUpgrade.length).toBe(BREAKING_CHANGES.length); + }); + + it("provides clear, ordered migration steps with before/after examples and verification commands", () => { + const v1Steps = MIGRATION_STEPS.filter((s) => s.pathId === "v0-to-v1"); + const v2Steps = MIGRATION_STEPS.filter((s) => s.pathId === "v1-to-v2"); + + expect(v1Steps.map((s) => s.order)).toEqual([1, 2]); + expect(v2Steps.map((s) => s.order)).toEqual([1, 2, 3, 4, 5]); + + for (const step of MIGRATION_STEPS) { + expect(step.beforeExample.length).toBeGreaterThan(10); + expect(step.afterExample.length).toBeGreaterThan(10); + expect(step.verificationCommand.length).toBeGreaterThan(3); + } + }); + + it("executes the migrated v2 code examples (BigInt amounts, network presets, plugins, and state migration)", () => { + // 1. BigInt stroop conversion example + const stroops = parseAmount("2.5"); + expect(typeof stroops).toBe("bigint"); + expect(stroops).toBe(25_000_000n); + expect(formatAmount(stroops)).toBe("2.5000000"); + + // 2. NetworkEnvironment preset example + const preset = getNetworkPreset(NetworkEnvironment.TESTNET); + expect(isNetworkPreset(preset)).toBe(true); + expect(preset.rpcUrl).toContain("soroban-testnet.stellar.org"); + + // 3. PluginRegistry example + const registry = new PluginRegistry(); + registry.use(LoggingPlugin); + expect(registry.getPlugins()).toContain("LoggingPlugin"); + + // 4. State migration & VersionTracker example + const versionTracker = new VersionTracker("1.0.0"); + const migratedState = migrate( + { amount: 1000000, legacyWallet: "FreighterConnector" }, + "1.0.0", + "2.0.0", + [ + { + from: "1.0.0", + to: "2.0.0", + description: "Convert amount to BigInt string representation and upgrade wallet adapter", + apply: (state) => ({ + ...state, + amount: BigInt(state.amount as number), + walletAdapter: "FreighterAdapter", + }), + }, + ], + ); + versionTracker.update("2.0.0"); + expect(migratedState.amount).toBe(1000000n); + expect(migratedState.walletAdapter).toBe("FreighterAdapter"); + expect(versionTracker.version).toBe("2.0.0"); + expect(versionTracker.transitions).toHaveLength(1); + }); + + it("provides searchable troubleshooting entries by error code, message, or symptom", () => { + expect(TROUBLESHOOTING_CATALOG.length).toBeGreaterThanOrEqual(4); + + const bigIntHits = findTroubleshootingGuide("Cannot mix BigInt"); + expect(bigIntHits).toHaveLength(1); + expect(bigIntHits[0].id).toBe("TS-001"); + expect(bigIntHits[0].resolutionSteps.length).toBeGreaterThanOrEqual(2); + + const walletHits = findTroubleshootingGuide("requestAccess"); + expect(walletHits).toHaveLength(1); + expect(walletHits[0].id).toBe("TS-002"); + + const exportHits = findTroubleshootingGuide("ERR_PACKAGE_PATH_NOT_EXPORTED"); + expect(exportHits).toHaveLength(1); + expect(exportHits[0].id).toBe("TS-004"); + }); + + it("accurately validates legacy vs. migrated configurations, payloads, and code snippets", () => { + const legacyCheck = validateSdkMigration({ + targetVersion: "2.0.0", + config: { + onLog: () => {}, + }, + invoicePayload: { + amount: 1000000, + }, + codeSnippet: ` + import { parseAmount } from "split-sdk/dist/utils"; + const wallet = new FreighterConnector(); + await wallet.requestAccess(); + `, + }); + + expect(legacyCheck.valid).toBe(false); + expect(legacyCheck.issues.map((i) => i.breakingChangeId)).toEqual( + expect.arrayContaining(["BC-101", "BC-201", "BC-202", "BC-204", "BC-205"]), + ); + + const migratedCheck = validateSdkMigration({ + targetVersion: "2.0.0", + config: { + ...getNetworkPreset(NetworkEnvironment.TESTNET), + contractId: "CCONTRACT1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + }, + invoicePayload: { + amount: 1000000n, + }, + codeSnippet: ` + import { FreighterAdapter } from "@stellar-split/sdk"; + import { parseAmount } from "@stellar-split/sdk/utils"; + const wallet = new FreighterAdapter(); + const address = await wallet.connect(); + `, + }); + + expect(migratedCheck.valid).toBe(true); + expect(migratedCheck.issues).toHaveLength(0); + expect(migratedCheck.warnings).toHaveLength(0); + expect(migratedCheck.score).toBe(100); + }); + + it("tracks migration progress across steps, blockers, and JSON persistence", () => { + const tracker = new MigrationProgressTracker("v1-to-v2"); + expect(tracker.getSummary().totalSteps).toBe(5); + expect(tracker.getSummary().percentComplete).toBe(0); + expect(tracker.getSummary().isComplete).toBe(false); + + tracker.completeStep("step-v2-1-imports", "Replaced deep imports"); + tracker.completeStep("step-v2-2-network", "Added TESTNET preset"); + tracker.skipStep("step-v2-3-wallets", "Backend service uses keypair signer"); + + // Validate step 4 with an invalid number amount first -> blocks step + const failedStep = tracker.validateAndCompleteStep("step-v2-4-bigint", { + invoicePayload: { amount: 5000 }, + }); + expect(failedStep.step.status).toBe("blocked"); + expect(tracker.getSummary().blockedSteps).toBe(1); + + // Fix payload to bigint and re-validate -> completes step + const passedStep = tracker.validateAndCompleteStep("step-v2-4-bigint", { + invoicePayload: { amount: 5000n }, + }); + expect(passedStep.step.status).toBe("completed"); + + // Complete final step + tracker.completeStep("step-v2-5-telemetry", "Enabled LoggingPlugin"); + + const finalSummary = tracker.getSummary(); + expect(finalSummary.percentComplete).toBe(100); + expect(finalSummary.isComplete).toBe(true); + expect(finalSummary.completedSteps).toBe(4); + expect(finalSummary.skippedSteps).toBe(1); + + // Round-trip JSON persistence + const restored = MigrationProgressTracker.fromJSON(tracker.toJSON()); + expect(restored.getSummary().percentComplete).toBe(100); + expect(restored.getSummary().isComplete).toBe(true); + }); + + it("verifies docs/MIGRATION.md contains all required sections", () => { + expect(fs.existsSync(migrationMdPath)).toBe(true); + const md = fs.readFileSync(migrationMdPath, "utf8"); + + expect(md).toContain("## 1. Semantic Versioning & Compatibility Matrix"); + expect(md).toContain("## 2. Breaking Changes Catalog"); + expect(md).toContain("## 3. Step-by-Step Migration: v0.x to v1.x"); + expect(md).toContain("## 4. Step-by-Step Migration: v1.x to v2.x"); + expect(md).toContain("## 5. Working Code Examples (Before & After)"); + expect(md).toContain("## 6. Troubleshooting Guide"); + expect(md).toContain("## 7. Automated Migration Validation & Progress Tracking"); + }); +});