Skip to content

Commit dab2957

Browse files
committed
fix(webapp): count per-org basins as S2 being configured
Gating v2 on the global basin alone would have degraded every run to v1 on a deployment that provisions a basin per organization and sets no global one, even though S2 is fully working there. `resolveStreamBasin` already resolves run, session and organization basins ahead of the global setting, so either source now satisfies the basin requirement. Splits the pure resolver out from the env lookup so the version matrix can be tested without reaching for `env.server`.
1 parent 2eb19be commit dab2957

3 files changed

Lines changed: 101 additions & 60 deletions

File tree

.server-changes/streams-version-s2-guard.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: fix
44
---
55

6-
Stop creating runs against a realtime streams backend the deployment cannot serve.
6+
Runs no longer end up with realtime streams that cannot be read or written.

apps/webapp/app/services/realtime/v1StreamsGlobal.server.ts

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -96,27 +96,51 @@ function streamPrefixFor(environment: AuthenticatedEnvironment, basin: string):
9696
return segments.join("/");
9797
}
9898

99+
export type RealtimeStreamsVersionConfig = {
100+
defaultVersion: "v1" | "v2";
101+
basin?: string;
102+
accessToken?: string;
103+
skipAccessTokens: boolean;
104+
perOrgBasinsEnabled: boolean;
105+
};
106+
99107
/**
100-
* Resolve the streams version to stamp on a run, falling back to
101-
* `REALTIME_STREAMS_DEFAULT_VERSION` when the caller expresses no preference.
108+
* Resolve the streams version to stamp on a run, falling back to the
109+
* deployment default when the caller expresses no preference.
110+
*
111+
* v2 is only ever returned when S2 can actually serve it. A run stamped v2 on a
112+
* deployment without S2 is unusable: `getRealtimeStreamInstance` throws for the
113+
* life of the run, and no read or write against its streams can succeed. v1 is
114+
* a working backend, so an unsatisfiable v2 degrades to it.
102115
*
103-
* v2 is only ever returned when S2 is actually configured. A run stamped v2 on
104-
* a deployment without S2 is unusable: `getRealtimeStreamInstance` throws for
105-
* the life of the run, and no read or write against its streams can succeed.
106-
* v1 is a working backend, so an unsatisfiable v2 degrades to it.
116+
* A basin can come from the global setting or from per-org provisioning, so
117+
* either satisfies the basin requirement. This mirrors {@link resolveStreamBasin},
118+
* which resolves run, session and organization basins ahead of the global one.
107119
*/
108-
export function determineRealtimeStreamsVersion(streamVersion?: string): "v1" | "v2" {
109-
const requested = streamVersion ?? env.REALTIME_STREAMS_DEFAULT_VERSION;
120+
export function resolveRealtimeStreamsVersion(
121+
streamVersion: string | undefined,
122+
config: RealtimeStreamsVersionConfig
123+
): "v1" | "v2" {
124+
const requested = streamVersion ?? config.defaultVersion;
110125

111-
if (
112-
requested === "v2" &&
113-
env.REALTIME_STREAMS_S2_BASIN &&
114-
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
115-
) {
116-
return "v2";
126+
if (requested !== "v2") {
127+
return "v1";
117128
}
118129

119-
return "v1";
130+
const hasCredentials = Boolean(config.accessToken) || config.skipAccessTokens;
131+
const hasBasin = Boolean(config.basin) || config.perOrgBasinsEnabled;
132+
133+
return hasCredentials && hasBasin ? "v2" : "v1";
134+
}
135+
136+
export function determineRealtimeStreamsVersion(streamVersion?: string): "v1" | "v2" {
137+
return resolveRealtimeStreamsVersion(streamVersion, {
138+
defaultVersion: env.REALTIME_STREAMS_DEFAULT_VERSION,
139+
basin: env.REALTIME_STREAMS_S2_BASIN,
140+
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN,
141+
skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true",
142+
perOrgBasinsEnabled: env.REALTIME_STREAMS_PER_ORG_BASINS_ENABLED === "true",
143+
});
120144
}
121145

122146
const s2RealtimeStreamsCache = singleton(
Lines changed: 61 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,87 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
resolveRealtimeStreamsVersion,
4+
type RealtimeStreamsVersionConfig,
5+
} from "~/services/realtime/v1StreamsGlobal.server";
26

3-
const envMock = vi.hoisted(() => ({
4-
REALTIME_STREAMS_DEFAULT_VERSION: "v1" as "v1" | "v2",
5-
REALTIME_STREAMS_S2_BASIN: undefined as string | undefined,
6-
REALTIME_STREAMS_S2_ACCESS_TOKEN: undefined as string | undefined,
7-
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: "false",
8-
}));
7+
const NO_S2: RealtimeStreamsVersionConfig = {
8+
defaultVersion: "v1",
9+
basin: undefined,
10+
accessToken: undefined,
11+
skipAccessTokens: false,
12+
perOrgBasinsEnabled: false,
13+
};
914

10-
vi.mock("~/env.server", () => ({ env: envMock }));
15+
const GLOBAL_BASIN: RealtimeStreamsVersionConfig = {
16+
...NO_S2,
17+
basin: "a-basin",
18+
accessToken: "a-token",
19+
};
1120

12-
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
21+
const PER_ORG_BASINS: RealtimeStreamsVersionConfig = {
22+
...NO_S2,
23+
accessToken: "a-token",
24+
perOrgBasinsEnabled: true,
25+
};
1326

14-
function configureS2() {
15-
envMock.REALTIME_STREAMS_S2_BASIN = "a-basin";
16-
envMock.REALTIME_STREAMS_S2_ACCESS_TOKEN = "a-token";
17-
}
18-
19-
beforeEach(() => {
20-
envMock.REALTIME_STREAMS_DEFAULT_VERSION = "v1";
21-
envMock.REALTIME_STREAMS_S2_BASIN = undefined;
22-
envMock.REALTIME_STREAMS_S2_ACCESS_TOKEN = undefined;
23-
envMock.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS = "false";
24-
});
27+
describe("resolveRealtimeStreamsVersion", () => {
28+
it("honours an explicit v2 when a global basin is configured", () => {
29+
expect(resolveRealtimeStreamsVersion("v2", GLOBAL_BASIN)).toBe("v2");
30+
});
2531

26-
describe("determineRealtimeStreamsVersion", () => {
27-
it("honours an explicit v2 when S2 is configured", () => {
28-
configureS2();
29-
expect(determineRealtimeStreamsVersion("v2")).toBe("v2");
32+
it("honours an explicit v2 when only per-org basins are configured", () => {
33+
expect(resolveRealtimeStreamsVersion("v2", PER_ORG_BASINS)).toBe("v2");
3034
});
3135

32-
it("accepts a skip-tokens deployment as configured", () => {
33-
envMock.REALTIME_STREAMS_S2_BASIN = "a-basin";
34-
envMock.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS = "true";
35-
expect(determineRealtimeStreamsVersion("v2")).toBe("v2");
36+
it("accepts a skip-tokens deployment as credentialed", () => {
37+
expect(
38+
resolveRealtimeStreamsVersion("v2", {
39+
...NO_S2,
40+
basin: "a-basin",
41+
skipAccessTokens: true,
42+
})
43+
).toBe("v2");
3644
});
3745

3846
it("degrades an explicit v2 to v1 when S2 is not configured", () => {
39-
expect(determineRealtimeStreamsVersion("v2")).toBe("v1");
47+
expect(resolveRealtimeStreamsVersion("v2", NO_S2)).toBe("v1");
4048
});
4149

4250
it("falls back to the default version when the caller expresses no preference", () => {
43-
configureS2();
44-
envMock.REALTIME_STREAMS_DEFAULT_VERSION = "v2";
45-
expect(determineRealtimeStreamsVersion()).toBe("v2");
51+
expect(
52+
resolveRealtimeStreamsVersion(undefined, { ...GLOBAL_BASIN, defaultVersion: "v2" })
53+
).toBe("v2");
4654
});
4755

4856
it("degrades a v2 default to v1 when S2 is not configured", () => {
49-
envMock.REALTIME_STREAMS_DEFAULT_VERSION = "v2";
50-
expect(determineRealtimeStreamsVersion()).toBe("v1");
57+
expect(resolveRealtimeStreamsVersion(undefined, { ...NO_S2, defaultVersion: "v2" })).toBe("v1");
58+
});
59+
60+
it("keeps a v2 default on v2 when only per-org basins are configured", () => {
61+
expect(
62+
resolveRealtimeStreamsVersion(undefined, { ...PER_ORG_BASINS, defaultVersion: "v2" })
63+
).toBe("v2");
64+
});
65+
66+
it("requires credentials, not just a basin", () => {
67+
const basinOnly = { ...NO_S2, basin: "a-basin", defaultVersion: "v2" as const };
68+
expect(resolveRealtimeStreamsVersion(undefined, basinOnly)).toBe("v1");
69+
expect(resolveRealtimeStreamsVersion("v2", basinOnly)).toBe("v1");
5170
});
5271

53-
it("requires a basin, not just a token", () => {
54-
envMock.REALTIME_STREAMS_S2_ACCESS_TOKEN = "a-token";
55-
envMock.REALTIME_STREAMS_DEFAULT_VERSION = "v2";
56-
expect(determineRealtimeStreamsVersion()).toBe("v1");
57-
expect(determineRealtimeStreamsVersion("v2")).toBe("v1");
72+
it("requires a basin, not just credentials", () => {
73+
const tokenOnly = { ...NO_S2, accessToken: "a-token", defaultVersion: "v2" as const };
74+
expect(resolveRealtimeStreamsVersion(undefined, tokenOnly)).toBe("v1");
75+
expect(resolveRealtimeStreamsVersion("v2", tokenOnly)).toBe("v1");
5876
});
5977

6078
it("keeps an explicit v1 on v1 even where S2 is available", () => {
61-
configureS2();
62-
envMock.REALTIME_STREAMS_DEFAULT_VERSION = "v2";
63-
expect(determineRealtimeStreamsVersion("v1")).toBe("v1");
79+
expect(resolveRealtimeStreamsVersion("v1", { ...GLOBAL_BASIN, defaultVersion: "v2" })).toBe(
80+
"v1"
81+
);
6482
});
6583

6684
it("treats an unrecognised version as v1", () => {
67-
configureS2();
68-
expect(determineRealtimeStreamsVersion("v3")).toBe("v1");
85+
expect(resolveRealtimeStreamsVersion("v3", GLOBAL_BASIN)).toBe("v1");
6986
});
7087
});

0 commit comments

Comments
 (0)