Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/client/createSorokitClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createSorokitClient } from "./createSorokitClient";
import type { SorokitClient } from "./createSorokitClient";

Expand Down Expand Up @@ -72,3 +72,31 @@ describe("SorokitClient type/runtime shape", () => {
}
});
});

describe("stellar-sdk version compatibility check wiring (#865)", () => {
it("logs a warning when config.stellarSdkVersion diverges, and does not fail client creation", () => {
const warn = vi.fn();
const logger = { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() };

const result = createSorokitClient({
network: "testnet",
logger,
stellarSdkVersion: "99.0.0",
});

expect(result.status).toBe("ok");
expect(warn).toHaveBeenCalledTimes(1);
const [message] = warn.mock.calls[0]!;
expect(message).toContain("99.0.0");
});

it("does not log anything when stellarSdkVersion is omitted", () => {
const warn = vi.fn();
const logger = { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() };

const result = createSorokitClient({ network: "testnet", logger });

expect(result.status).toBe("ok");
expect(warn).not.toHaveBeenCalled();
});
});
18 changes: 18 additions & 0 deletions src/client/createSorokitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ import {
import { ok, err, SorokitErrorCode } from "../shared/response";
import type { SorokitResult } from "../shared/response";
import type { LogLevel, SorokitLogger } from "../shared/logger";
import { checkStellarSdkVersionCompatibility } from "./sdkVersionCompatibility";
import { wrapCache } from "../shared/cache";
import type { MainnetSafetyOptions } from "../shared/mainnetSafety";
import type { SorokitCache } from "../shared/cache";
Expand Down Expand Up @@ -403,6 +404,17 @@ export interface SorokitClientConfig {
accountStorageAdapter?: AccountStorageAdapter;
/** Custom WalletAccountManager instance */
accountManager?: WalletAccountManager;
/**
* The installed `@stellar/stellar-sdk` version, for a compatibility
* warning against the version this SDK was built against (#865). Omit to
* skip the check (the default — this SDK cannot reliably read the
* installed version itself; see `sdkVersionCompatibility.ts`'s doc
* comment for why). Obtain it from your own application's module
* resolution, e.g.
* `require("@stellar/stellar-sdk/package.json").version` in a plain
* Node/CJS entry point.
*/
stellarSdkVersion?: string;
}

// ─── Client interface ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1431,6 +1443,12 @@ export function createSorokitClient(
...(config.logPrefix !== undefined ? { prefix: config.logPrefix } : {}),
});
const logger = createTracedLogger(baseLogger, { traceId });

// #865: warn (never fail client creation) when the caller-supplied
// installed @stellar/stellar-sdk version diverges from what this SDK was
// built against. No-op when config.stellarSdkVersion is omitted.
checkStellarSdkVersionCompatibility(config.stellarSdkVersion, { logger });

const safetyLogger = createTracedLogger(
config.logger ??
createLogger({
Expand Down
121 changes: 121 additions & 0 deletions src/client/sdkVersionCompatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from "vitest";
import {
checkStellarSdkVersionCompatibility,
compareStellarSdkVersion,
parseSemverCore,
SUPPORTED_STELLAR_SDK_VERSION,
} from "./sdkVersionCompatibility";
import type { SorokitLogger } from "../shared/logger";

describe("parseSemverCore (#865)", () => {
it("parses a plain major.minor.patch string", () => {
expect(parseSemverCore("13.3.0")).toEqual({ major: 13, minor: 3, patch: 0 });
});

it("ignores a prerelease/build suffix", () => {
expect(parseSemverCore("13.3.0-beta.1")).toEqual({ major: 13, minor: 3, patch: 0 });
expect(parseSemverCore("13.3.0+build.5")).toEqual({ major: 13, minor: 3, patch: 0 });
});

it("returns null for an unparseable string", () => {
expect(parseSemverCore("not-a-version")).toBeNull();
expect(parseSemverCore("")).toBeNull();
expect(parseSemverCore("v13.3.0")).toBeNull();
});
});

describe("compareStellarSdkVersion (#865)", () => {
it("reports compatible for an exact match against the default supported version", () => {
expect(compareStellarSdkVersion(SUPPORTED_STELLAR_SDK_VERSION)).toEqual({ status: "compatible" });
});

it("reports compatible for a matching patch within the same minor", () => {
expect(compareStellarSdkVersion("13.3.0", "13.3.0")).toEqual({ status: "compatible" });
});

it("reports unknown when no installed version is supplied", () => {
const result = compareStellarSdkVersion(undefined);
expect(result.status).toBe("unknown");
});

it("reports unknown for an unparseable installed version", () => {
const result = compareStellarSdkVersion("not-a-version");
expect(result.status).toBe("unknown");
});

it("reports incompatible with kind major-mismatch across a major version boundary", () => {
const result = compareStellarSdkVersion("14.0.0", "13.3.0");
expect(result).toMatchObject({
status: "incompatible",
kind: "major-mismatch",
installedVersion: "14.0.0",
supportedVersion: "13.3.0",
});
});

it("reports incompatible with kind major-mismatch for an older major version too", () => {
const result = compareStellarSdkVersion("12.9.9", "13.3.0");
expect(result).toMatchObject({ status: "incompatible", kind: "major-mismatch" });
});

it("reports incompatible with kind older-than-supported for an older patch in the same major", () => {
const result = compareStellarSdkVersion("13.3.0", "13.4.0");
expect(result).toMatchObject({ status: "incompatible", kind: "older-than-supported" });

const olderMinor = compareStellarSdkVersion("13.2.9", "13.3.0");
expect(olderMinor).toMatchObject({ status: "incompatible", kind: "older-than-supported" });
});

it("reports incompatible with kind newer-minor for a newer minor/patch in the same major", () => {
const result = compareStellarSdkVersion("13.4.0", "13.3.0");
expect(result).toMatchObject({ status: "incompatible", kind: "newer-minor" });

const newerPatch = compareStellarSdkVersion("13.3.1", "13.3.0");
expect(newerPatch).toMatchObject({ status: "incompatible", kind: "newer-minor" });
});
});

describe("checkStellarSdkVersionCompatibility (#865)", () => {
function makeLogger(): SorokitLogger & { warn: ReturnType<typeof vi.fn> } {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}

it("does not log anything for a compatible version", () => {
const logger = makeLogger();
const result = checkStellarSdkVersionCompatibility("13.3.0", { logger, supportedVersion: "13.3.0" });
expect(result.status).toBe("compatible");
expect(logger.warn).not.toHaveBeenCalled();
});

it("logs a warning with structured context for an incompatible version", () => {
const logger = makeLogger();
const result = checkStellarSdkVersionCompatibility("14.0.0", { logger, supportedVersion: "13.3.0" });

expect(result.status).toBe("incompatible");
expect(logger.warn).toHaveBeenCalledTimes(1);
const [message, meta] = logger.warn.mock.calls[0]!;
expect(message).toContain("14.0.0");
expect(message).toContain("13.3.0");
expect(meta).toMatchObject({
installedVersion: "14.0.0",
supportedVersion: "13.3.0",
kind: "major-mismatch",
});
});

it("does not throw and does not log when no logger is supplied", () => {
expect(() => checkStellarSdkVersionCompatibility("14.0.0", { supportedVersion: "13.3.0" })).not.toThrow();
});

it("does not log anything when no installed version is supplied (opt-in check)", () => {
const logger = makeLogger();
const result = checkStellarSdkVersionCompatibility(undefined, { logger });
expect(result.status).toBe("unknown");
expect(logger.warn).not.toHaveBeenCalled();
});
});
175 changes: 175 additions & 0 deletions src/client/sdkVersionCompatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import type { SorokitLogger } from "../shared/logger";

/**
* The @stellar/stellar-sdk version this SDK was built and tested against.
* Keep in sync with package.json's own peerDependencies entry (currently
* pinned to an exact version, not a range): both must change together when
* this SDK adopts a new stellar-sdk release.
*/
export const SUPPORTED_STELLAR_SDK_VERSION = "13.3.0";

export interface ParsedVersion {
major: number;
minor: number;
patch: number;
}

/**
* Parses a `major.minor.patch` version string, ignoring any
* prerelease/build metadata suffix (e.g. `13.3.0-beta.1` parses the same as
* `13.3.0`). Returns `null` for anything that doesn't start with three
* dot-separated non-negative integers.
*/
export function parseSemverCore(version: string): ParsedVersion | null {
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}

export type StellarSdkCompatibility =
| { status: "compatible" }
| { status: "unknown"; reason: string }
| {
status: "incompatible";
kind: "major-mismatch" | "newer-minor" | "older-than-supported";
installedVersion: string;
supportedVersion: string;
message: string;
};

/**
* Compares an installed `@stellar/stellar-sdk` version against the version
* this SDK was built against (#865).
*
* This SDK's package.json pins an exact peer version rather than a range,
* so npm itself normally catches a mismatch at install time, but that
* protection is silently bypassed by `--legacy-peer-deps` (which this
* repo's own CI pipeline uses) or by a nested duplicate install of a
* different major version elsewhere in the dependency tree. This check
* catches both at runtime instead of letting a consumer hit confusing,
* version-skew-caused failures with no clear diagnostic.
*
* A major version difference is always `incompatible` (the installed
* version's types/API shape cannot be assumed compatible across a major
* bump). An installed version strictly older than supported is also
* `incompatible` (this SDK may call APIs that don't exist yet in the older
* version). An installed minor/patch newer than supported within the same
* major is `incompatible` with kind `newer-minor` but framed as a
* lower-severity warning by the caller (see `checkStellarSdkVersionCompatibility`),
* since same-major newer releases are usually additive, not breaking.
*
* There is deliberately no attempt here to read the installed version from
* `node_modules` automatically: @stellar/stellar-sdk's package.json is not
* reachable through its own `exports` map (`./package.json` is absent from
* it), so `require("@stellar/stellar-sdk/package.json")` throws under
* strict Node ESM/CJS resolution and an equivalent dynamic `import()`
* fails identically; confirmed directly against the installed package,
* not assumed. The caller supplies `installedVersion` however suits their
* own module resolution (e.g. `require("@stellar/stellar-sdk/package.json").version`
* works in a plain Node/CJS entry point even though it is not usable from
* inside this bundled SDK itself, or a build-time-injected constant for
* bundled browser code).
*/
export function compareStellarSdkVersion(
installedVersion: string | undefined,
supportedVersion: string = SUPPORTED_STELLAR_SDK_VERSION,
): StellarSdkCompatibility {
if (!installedVersion) {
return {
status: "unknown",
reason: "No installed @stellar/stellar-sdk version was supplied to check against.",
};
}

const installed = parseSemverCore(installedVersion);
const supported = parseSemverCore(supportedVersion);
if (!installed || !supported) {
return {
status: "unknown",
reason: `Could not parse version "${installedVersion}" or "${supportedVersion}" as major.minor.patch.`,
};
}

if (installed.major !== supported.major) {
return {
status: "incompatible",
kind: "major-mismatch",
installedVersion,
supportedVersion,
message:
`Installed @stellar/stellar-sdk@${installedVersion} has a different major version than the ` +
`@stellar/stellar-sdk@${supportedVersion} this SDK was built against. A major version ` +
`difference may change exported types, class shapes, or method signatures this SDK relies on.`,
};
}

if (
installed.minor < supported.minor ||
(installed.minor === supported.minor && installed.patch < supported.patch)
) {
return {
status: "incompatible",
kind: "older-than-supported",
installedVersion,
supportedVersion,
message:
`Installed @stellar/stellar-sdk@${installedVersion} is older than the ` +
`@stellar/stellar-sdk@${supportedVersion} this SDK was built against. This SDK may call an ` +
`API that does not exist yet in the installed version.`,
};
}

if (
installed.minor > supported.minor ||
(installed.minor === supported.minor && installed.patch > supported.patch)
) {
return {
status: "incompatible",
kind: "newer-minor",
installedVersion,
supportedVersion,
message:
`Installed @stellar/stellar-sdk@${installedVersion} is newer than the ` +
`@stellar/stellar-sdk@${supportedVersion} this SDK was built against. Same-major releases are ` +
`usually additive, so this is unlikely to break anything, but has not been tested against ` +
`this SDK's test suite.`,
};
}

return { status: "compatible" };
}

/**
* Runs `compareStellarSdkVersion` and logs a warning for any non-compatible
* result (#865). Does not throw: a version mismatch is a diagnostic aid,
* not a hard failure, since many mismatches (especially `newer-minor`) are
* harmless in practice.
*
* @example
* import { createRequire } from "node:module";
* const require = createRequire(import.meta.url);
* // Works from a plain application entry point's own module resolution,
* // even though it cannot be done from inside this SDK (see
* // compareStellarSdkVersion's own doc comment for why).
* const installedVersion = require("@stellar/stellar-sdk/package.json").version;
*
* checkStellarSdkVersionCompatibility(installedVersion, { logger: myLogger });
*/
export function checkStellarSdkVersionCompatibility(
installedVersion: string | undefined,
options: { supportedVersion?: string; logger?: SorokitLogger } = {},
): StellarSdkCompatibility {
const result = compareStellarSdkVersion(installedVersion, options.supportedVersion);
if (result.status === "incompatible") {
options.logger?.warn(result.message, {
installedVersion: result.installedVersion,
supportedVersion: result.supportedVersion,
kind: result.kind,
});
}
return result;
}
Loading
Loading