authorgravatar for 155588579+spiky02plateau@users.noreply.github.comspiky02plateau <155588579+spiky02plateau@users.noreply.github.com> 2026-08-24 11:59:57+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 09:59:57+00:00
log6f5c951a494e6ed0111222959a4bd9c7c8af6c23
tree043d427bae65a09f4f78f06b8f287da778cc07c4
parent229b05df042f896bbb44660c8524ffe222b85b8d
signature Signed by PGP key B5690EEEBB952194

fix(web): compare client and server versions as semver, not strings (#7579)


2 files changed, 90 insertions(+), 16 deletions(-)

apps/web/src/versionSkew.test.ts+61-11
...@@ -1,5 +1,10 @@...@@ -1,5 +1,10 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4// Pinned so the direction cases below read as fixed versions instead of
5// arithmetic on whatever version this checkout happens to be at.
6const branding = vi.hoisted(() => ({ APP_VERSION: "0.0.34" }));
7vi.mock("./branding", () => branding);
38
4import { APP_VERSION } from "./branding";9import { APP_VERSION } from "./branding";
5import {10import {
...@@ -13,19 +18,64 @@ import {...@@ -13,19 +18,64 @@ import {
13 serverUpdateGuidance,18 serverUpdateGuidance,
14} from "./versionSkew";19} from "./versionSkew";
1520
21const MISMATCH_HINT =
22 "Version mismatch. Try syncing the client and server to the same T3 Code version.";
23
16describe("versionSkew", () => {24describe("versionSkew", () => {
25 beforeEach(() => {
26 branding.APP_VERSION = "0.0.34";
27 });
28
17 it("does not warn when versions match", () => {29 it("does not warn when versions match", () => {
18 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();30 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
19 });31 });
2032
21 it("returns a mismatch when the server version differs from the client", () => {33 it("returns a mismatch when the server is behind the client", () => {
22 expect(resolveVersionMismatch("9.9.9")).toEqual({34 expect(resolveVersionMismatch("0.0.33")).toEqual({
23 clientVersion: APP_VERSION,35 clientVersion: "0.0.34",
24 serverVersion: "9.9.9",36 serverVersion: "0.0.33",
25 hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.",37 hint: MISMATCH_HINT,
26 });38 });
27 });39 });
2840
41 it("does not warn when the server is ahead of the client", () => {
42 expect(resolveVersionMismatch("0.0.35")).toBeNull();
43 expect(resolveVersionMismatch("9.9.9")).toBeNull();
44 });
45
46 it("does not warn when a nightly and a stable build share a core version", () => {
47 expect(resolveVersionMismatch("0.0.34-nightly.20260818.1124")).toBeNull();
48
49 branding.APP_VERSION = "0.0.34-nightly.20260818.1124";
50 expect(resolveVersionMismatch("0.0.34")).toBeNull();
51 });
52
53 it("treats a nightly server built past the client as ahead, not skew", () => {
54 expect(resolveVersionMismatch("0.0.35-nightly.20260818.1124")).toBeNull();
55 });
56
57 it("still warns when a nightly client outruns the server by a release", () => {
58 branding.APP_VERSION = "0.0.35-nightly.20260818.1124";
59
60 expect(resolveVersionMismatch("0.0.34")).toEqual({
61 clientVersion: "0.0.35-nightly.20260818.1124",
62 serverVersion: "0.0.34",
63 hint: MISMATCH_HINT,
64 });
65 });
66
67 it("falls back to string inequality when a version is not semver", () => {
68 expect(resolveVersionMismatch("dev")).toEqual({
69 clientVersion: "0.0.34",
70 serverVersion: "dev",
71 hint: MISMATCH_HINT,
72 });
73
74 branding.APP_VERSION = "dev";
75 expect(resolveVersionMismatch("dev")).toBeNull();
76 expect(resolveVersionMismatch("0.0.34")).toMatchObject({ serverVersion: "0.0.34" });
77 });
78
29 it("reads the server version from config descriptors", () => {79 it("reads the server version from config descriptors", () => {
30 expect(80 expect(
31 resolveServerConfigVersionMismatch({81 resolveServerConfigVersionMismatch({
...@@ -36,14 +86,14 @@ describe("versionSkew", () => {...@@ -36,14 +86,14 @@ describe("versionSkew", () => {
36 os: "darwin",86 os: "darwin",
37 arch: "arm64",87 arch: "arm64",
38 },88 },
39 serverVersion: "9.9.9",89 serverVersion: "0.0.33",
40 capabilities: {90 capabilities: {
41 repositoryIdentity: true,91 repositoryIdentity: true,
42 },92 },
43 },93 },
44 }),94 }),
45 ).toMatchObject({95 ).toMatchObject({
46 serverVersion: "9.9.9",96 serverVersion: "0.0.33",
47 });97 });
48 });98 });
4999
...@@ -70,11 +120,11 @@ describe("versionSkew", () => {...@@ -70,11 +120,11 @@ describe("versionSkew", () => {
70 ).toBe(false);120 ).toBe(false);
71 });121 });
72122
73 it("appends a hint to connection errors when versions differ", () => {123 it("appends a hint to connection errors when the server is behind", () => {
74 const mismatch = resolveVersionMismatch("9.9.9");124 const mismatch = resolveVersionMismatch("0.0.33");
75125
76 expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe(126 expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe(
77 "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.",127 `Socket closed. Hint: ${MISMATCH_HINT}`,
78 );128 );
79 });129 });
80130
apps/web/src/versionSkew.ts+29-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";1import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
2import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
34
4import { APP_VERSION } from "./branding";5import { APP_VERSION } from "./branding";
...@@ -23,16 +24,39 @@ function normalizeVersion(version: string | null | undefined): string | null {...@@ -23,16 +24,39 @@ function normalizeVersion(version: string | null | undefined): string | null {
23 return trimmed && trimmed.length > 0 ? trimmed : null;24 return trimmed && trimmed.length > 0 ? trimmed : null;
24}25}
2526
27/** Core `major.minor.patch`, dropping any prerelease or build suffix. */
28function versionCore(version: string): string {
29 return version.replace(/[-+].*$/, "");
30}
31
32/**
33 * The skew a user can act on: the connected server runs an older T3 Code than
34 * this client, so the server is the side that needs updating.
35 *
36 * Versions compare as semver on their core `major.minor.patch` only. Nightlies
37 * are `<core>-nightly.<date>.<run>` builds of the release they precede, so a
38 * stable client on a nightly server (or the reverse, at the same core) shares a
39 * wire contract and is not skew. A server *ahead* of the client is not skew
40 * either: the client is the stale side, and every consumer of this result tells
41 * the user to update their server. Versions that do not parse as semver fall
42 * back to plain string inequality.
43 */
26export function resolveVersionMismatch(44export function resolveVersionMismatch(
27 serverVersion: string | null | undefined,45 serverVersion: string | null | undefined,
28): VersionMismatch | null {46): VersionMismatch | null {
29 const normalizedClientVersion = normalizeVersion(APP_VERSION);47 const normalizedClientVersion = normalizeVersion(APP_VERSION);
30 const normalizedServerVersion = normalizeVersion(serverVersion);48 const normalizedServerVersion = normalizeVersion(serverVersion);
31 if (49 if (!normalizedClientVersion || !normalizedServerVersion) {
32 !normalizedClientVersion ||50 return null;
33 !normalizedServerVersion ||51 }
34 normalizedClientVersion === normalizedServerVersion52
35 ) {53 const clientCore = versionCore(normalizedClientVersion);
54 const serverCore = versionCore(normalizedServerVersion);
55 const serverIsBehind =
56 parseSemver(clientCore) && parseSemver(serverCore)
57 ? compareSemverVersions(serverCore, clientCore) < 0
58 : normalizedServerVersion !== normalizedClientVersion;
59 if (!serverIsBehind) {
36 return null;60 return null;
37 }61 }
3862