Skip to content

Commit fc0d8e0

Browse files
committed
refactor: first effect vertical slice
1 parent 0db03b8 commit fc0d8e0

11 files changed

Lines changed: 361 additions & 116 deletions

File tree

apps/code/src/main/di/container.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,6 @@ import type { ExternalAppsPreferences } from "@posthog/workspace-server/services
167167
import { foldersModule } from "@posthog/workspace-server/services/folders/folders.module";
168168
import { GitService } from "@posthog/workspace-server/services/git/service";
169169
import { TaskPrStatusService } from "@posthog/workspace-server/services/git/task-pr-status";
170-
import { githubReleasesModule } from "@posthog/workspace-server/services/github-releases/github-releases.module";
171170
import {
172171
HANDOFF_GIT_GATEWAY,
173172
HANDOFF_LOG_GATEWAY,
@@ -607,7 +606,6 @@ container.load(posthogPluginModule);
607606
container.bind(MAIN_POSTHOG_PLUGIN_SERVICE).toService(POSTHOG_PLUGIN_SERVICE);
608607
container.load(skillsModule);
609608
container.load(skillsMarketplaceModule);
610-
container.load(githubReleasesModule);
611609
container.load(onboardingImportModule);
612610
container.load(claudeCliSessionsModule);
613611
container.load(additionalDirectoriesModule);

packages/host-router/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@posthog/workspace-client": "workspace:*",
2626
"@posthog/workspace-server": "workspace:*",
2727
"@trpc/client": "catalog:",
28+
"effect": "catalog:",
2829
"zod": "^4.4.3"
2930
},
3031
"peerDependencies": {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { GitHubReleases } from "@posthog/workspace-server/services/github-releases/github-releases";
2+
import { type Effect, Layer, ManagedRuntime } from "effect";
3+
4+
/**
5+
* Every converted Effect service's live layer goes here. This is the single
6+
* place the host's Effect services are composed; it grows as more services
7+
* migrate off Inversify.
8+
*/
9+
const AppLayer = Layer.mergeAll(GitHubReleases.Live);
10+
11+
/** One shared runtime for the whole host process — services are singletons. */
12+
const appRuntime = ManagedRuntime.make(AppLayer);
13+
14+
/**
15+
* Runs an Effect through the shared host runtime and hands tRPC back a Promise.
16+
* The tRPC request's abort signal is forwarded, so a cancelled request
17+
* interrupts the fiber and releases its resources.
18+
*/
19+
export const runEffect = <A, E>(
20+
effect: Effect.Effect<A, E, Layer.Success<typeof AppLayer>>,
21+
signal?: AbortSignal,
22+
): Promise<A> => appRuntime.runPromise(effect, { signal });
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { publicProcedure, router } from "@posthog/host-trpc/trpc";
2-
import type { GitHubReleasesService } from "@posthog/workspace-server/services/github-releases/github-releases";
3-
import { GITHUB_RELEASES_SERVICE } from "@posthog/workspace-server/services/github-releases/identifiers";
2+
import { GitHubReleases } from "@posthog/workspace-server/services/github-releases/github-releases";
43
import { listReleasesOutput } from "@posthog/workspace-server/services/github-releases/schemas";
4+
import { Effect } from "effect";
5+
import { runEffect } from "../effect-runtime";
56

67
export const githubReleasesRouter = router({
7-
list: publicProcedure
8-
.output(listReleasesOutput)
9-
.query(({ ctx }) =>
10-
ctx.container
11-
.get<GitHubReleasesService>(GITHUB_RELEASES_SERVICE)
12-
.listReleases(),
8+
list: publicProcedure.output(listReleasesOutput).query((opts) =>
9+
runEffect(
10+
Effect.flatMap(GitHubReleases, (service) => service.list()),
11+
opts.signal,
1312
),
13+
),
1414
});

packages/workspace-server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@trpc/server": "catalog:",
3434
"better-sqlite3": "^12.8.0",
3535
"drizzle-orm": "^0.45.1",
36+
"effect": "catalog:",
3637
"fflate": "^0.8.2",
3738
"hono": "catalog:",
3839
"ignore": "^7.0.5",
@@ -44,6 +45,7 @@
4445
"zod": "^4.1.12"
4546
},
4647
"devDependencies": {
48+
"@effect/vitest": "catalog:",
4749
"@inversifyjs/strongly-typed": "2.2.0",
4850
"@posthog/tsconfig": "workspace:*",
4951
"@types/better-sqlite3": "^7.6.13",

packages/workspace-server/src/services/github-releases/github-releases.module.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 72 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2-
import { GitHubReleasesService } from "./github-releases";
1+
import {
2+
afterEach,
3+
beforeEach,
4+
describe,
5+
expect,
6+
it,
7+
vi,
8+
} from "@effect/vitest";
9+
import { Duration, Effect } from "effect";
10+
import { TestClock } from "effect/testing";
11+
import { GitHubReleases } from "./github-releases";
312

413
const sampleReleases = [
514
{
@@ -31,68 +40,74 @@ const sampleReleases = [
3140
},
3241
];
3342

34-
describe("GitHubReleasesService", () => {
35-
let fetchMock: ReturnType<typeof vi.fn>;
43+
const okResponse = (body: unknown) =>
44+
({ ok: true, status: 200, json: async () => body }) as unknown as Response;
3645

37-
beforeEach(() => {
38-
fetchMock = vi.fn(async () => ({
39-
ok: true,
40-
status: 200,
41-
json: async () => sampleReleases,
42-
}));
43-
vi.stubGlobal("fetch", fetchMock);
44-
});
46+
let fetchMock: ReturnType<typeof vi.fn>;
4547

46-
afterEach(() => {
47-
vi.unstubAllGlobals();
48-
});
48+
beforeEach(() => {
49+
fetchMock = vi.fn(async () => okResponse(sampleReleases));
50+
vi.stubGlobal("fetch", fetchMock);
51+
});
52+
53+
afterEach(() => {
54+
vi.unstubAllGlobals();
55+
});
4956

50-
it("maps releases, strips the v prefix and drops drafts", async () => {
51-
const service = new GitHubReleasesService();
52-
const { releases } = await service.listReleases();
57+
describe("GitHubReleases", () => {
58+
it.effect("maps releases, strips the v prefix and drops drafts", () =>
59+
Effect.gen(function* () {
60+
const { list } = yield* GitHubReleases;
61+
const { releases } = yield* list();
5362

54-
expect(releases).toHaveLength(2);
55-
expect(releases[0]).toEqual({
56-
version: "1.2.0",
57-
name: "v1.2.0",
58-
notes: "## Notes\n- thing",
59-
date: "2026-06-20T00:00:00Z",
60-
isPrerelease: false,
61-
htmlUrl: "https://github.com/PostHog/code/releases/tag/v1.2.0",
62-
});
63-
// empty name falls back to the tag; null body becomes an empty string
64-
expect(releases[1]).toMatchObject({
65-
version: "1.1.0",
66-
name: "v1.1.0",
67-
notes: "",
68-
isPrerelease: true,
69-
});
70-
});
63+
expect(releases).toHaveLength(2);
64+
expect(releases[0]).toEqual({
65+
version: "1.2.0",
66+
name: "v1.2.0",
67+
notes: "## Notes\n- thing",
68+
date: "2026-06-20T00:00:00Z",
69+
isPrerelease: false,
70+
htmlUrl: "https://github.com/PostHog/code/releases/tag/v1.2.0",
71+
});
72+
// empty name falls back to the tag; null body becomes an empty string
73+
expect(releases[1]).toMatchObject({
74+
version: "1.1.0",
75+
name: "v1.1.0",
76+
notes: "",
77+
isPrerelease: true,
78+
});
79+
}).pipe(Effect.provide(GitHubReleases.Live)),
80+
);
7181

72-
it("caches results within the TTL", async () => {
73-
const service = new GitHubReleasesService();
74-
await service.listReleases();
75-
await service.listReleases();
76-
expect(fetchMock).toHaveBeenCalledTimes(1);
77-
});
82+
it.effect("caches results within the TTL", () =>
83+
Effect.gen(function* () {
84+
const { list } = yield* GitHubReleases;
85+
yield* list();
86+
yield* list();
87+
expect(fetchMock).toHaveBeenCalledTimes(1);
88+
}).pipe(Effect.provide(GitHubReleases.Live)),
89+
);
7890

79-
it("throws on non-ok responses", async () => {
80-
fetchMock.mockResolvedValueOnce({ ok: false, status: 503 });
81-
const service = new GitHubReleasesService();
82-
await expect(service.listReleases()).rejects.toThrow();
83-
});
91+
it.effect("fails on a non-ok response when there is no cache", () =>
92+
Effect.gen(function* () {
93+
fetchMock.mockResolvedValueOnce({ ok: false, status: 503 });
94+
const { list } = yield* GitHubReleases;
95+
const error = yield* Effect.flip(list());
96+
expect(error._tag).toBe("GitHubReleasesError");
97+
}).pipe(Effect.provide(GitHubReleases.Live)),
98+
);
8499

85-
it("serves stale cache when a later refetch fails", async () => {
86-
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0);
87-
const service = new GitHubReleasesService();
88-
const first = await service.listReleases();
100+
it.effect("serves stale cache when a later refetch fails", () =>
101+
Effect.gen(function* () {
102+
const { list } = yield* GitHubReleases;
103+
const first = yield* list();
89104

90-
nowSpy.mockReturnValue(11 * 60_000);
91-
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 });
92-
const second = await service.listReleases();
105+
yield* TestClock.adjust(Duration.millis(11 * 60_000));
106+
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 });
107+
const second = yield* list();
93108

94-
expect(second).toEqual(first);
95-
expect(fetchMock).toHaveBeenCalledTimes(2);
96-
nowSpy.mockRestore();
97-
});
109+
expect(second).toEqual(first);
110+
expect(fetchMock).toHaveBeenCalledTimes(2);
111+
}).pipe(Effect.provide(GitHubReleases.Live)),
112+
);
98113
});
Lines changed: 84 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,97 @@
1-
import { injectable } from "inversify";
1+
import { Clock, Context, Effect, Layer, Ref, Result, Schema } from "effect";
22
import { githubReleasesApiResponse, type ListReleasesOutput } from "./schemas";
33

44
const RELEASES_URL =
55
"https://api.github.com/repos/PostHog/code/releases?per_page=30";
66
const CACHE_TTL_MS = 10 * 60_000;
77
const FETCH_TIMEOUT_MS = 10_000;
88

9-
@injectable()
10-
export class GitHubReleasesService {
11-
private cache: { fetchedAt: number; data: ListReleasesOutput } | null = null;
9+
export class GitHubReleasesError extends Schema.TaggedErrorClass<GitHubReleasesError>()(
10+
"GitHubReleasesError",
11+
{ cause: Schema.Defect() },
12+
) {}
1213

13-
async listReleases(): Promise<ListReleasesOutput> {
14-
if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
15-
return this.cache.data;
16-
}
14+
interface CachedReleases {
15+
readonly fetchedAt: number;
16+
readonly data: ListReleasesOutput;
17+
}
1718

18-
try {
19-
const response = await fetch(RELEASES_URL, {
19+
const fetchReleases = Effect.fn("GitHubReleases.fetch")(function* () {
20+
const response = yield* Effect.tryPromise({
21+
try: () =>
22+
fetch(RELEASES_URL, {
2023
headers: { Accept: "application/vnd.github+json" },
2124
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
22-
});
23-
if (!response.ok) {
24-
throw new Error(`GitHub releases fetch failed: ${response.status}`);
25-
}
26-
27-
const parsed = githubReleasesApiResponse.parse(await response.json());
28-
const releases = parsed
29-
.filter((release) => !release.draft)
30-
.map((release) => ({
31-
version: release.tag_name.replace(/^v/, ""),
32-
name:
33-
release.name && release.name.length > 0
34-
? release.name
35-
: release.tag_name,
36-
notes: release.body ?? "",
37-
date: release.published_at,
38-
isPrerelease: release.prerelease,
39-
htmlUrl: release.html_url,
40-
}));
41-
42-
const data: ListReleasesOutput = { releases };
43-
this.cache = { fetchedAt: Date.now(), data };
44-
return data;
45-
} catch (error) {
46-
if (this.cache) {
47-
return this.cache.data;
48-
}
49-
throw error;
50-
}
25+
}),
26+
catch: (cause) => new GitHubReleasesError({ cause }),
27+
});
28+
29+
if (!response.ok) {
30+
return yield* Effect.fail(
31+
new GitHubReleasesError({
32+
cause: new Error(`GitHub releases fetch failed: ${response.status}`),
33+
}),
34+
);
5135
}
36+
37+
const json = yield* Effect.tryPromise({
38+
try: () => response.json(),
39+
catch: (cause) => new GitHubReleasesError({ cause }),
40+
});
41+
const parsed = yield* Effect.try({
42+
try: () => githubReleasesApiResponse.parse(json),
43+
catch: (cause) => new GitHubReleasesError({ cause }),
44+
});
45+
46+
const releases = parsed
47+
.filter((release) => !release.draft)
48+
.map((release) => ({
49+
version: release.tag_name.replace(/^v/, ""),
50+
name:
51+
release.name && release.name.length > 0
52+
? release.name
53+
: release.tag_name,
54+
notes: release.body ?? "",
55+
date: release.published_at,
56+
isPrerelease: release.prerelease,
57+
htmlUrl: release.html_url,
58+
}));
59+
60+
return { releases } satisfies ListReleasesOutput;
61+
});
62+
63+
type ReleasesCache = Ref.Ref<CachedReleases | null>;
64+
65+
const listReleases = Effect.fn("GitHubReleases.list")(function* (
66+
cache: ReleasesCache,
67+
) {
68+
const now = yield* Clock.currentTimeMillis;
69+
const cached = yield* Ref.get(cache);
70+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
71+
return cached.data;
72+
}
73+
74+
const result = yield* Effect.result(fetchReleases());
75+
if (Result.isSuccess(result)) {
76+
yield* Ref.set(cache, { fetchedAt: now, data: result.success });
77+
return result.success;
78+
}
79+
80+
// The refresh failed: serve the last good copy if we have one, else fail.
81+
if (cached) {
82+
return cached.data;
83+
}
84+
return yield* Effect.fail(result.failure);
85+
});
86+
87+
export class GitHubReleases extends Context.Service<GitHubReleases>()(
88+
"GitHubReleases",
89+
{
90+
make: Effect.gen(function* () {
91+
const cache = yield* Ref.make<CachedReleases | null>(null);
92+
return { list: () => listReleases(cache) };
93+
}),
94+
},
95+
) {
96+
static Live = Layer.effect(this, this.make);
5297
}

packages/workspace-server/src/services/github-releases/identifiers.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

0 commit comments

Comments
 (0)