1/**
2 * Bytes on S3, compute on Cloudflare: the Worker and the Repo DO reach the
3 * bucket through the S3 bindings with an identity Alchemy minted. A push
4 * that spills, compaction into an S3 pack, and a clone back under
5 * `fsck --strict`. Every byte of the clone came back through S3.
6 */
7import * as AWS from "@/AWS";
8import * as Cloudflare from "@/Cloudflare";
9import { GitApi } from "@/Git/Api.ts";
10import * as Test from "@/Test/Alchemy";
11import { expect } from "alchemy-test";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import { MinimumLogLevel } from "effect/References";
17import * as Schedule from "effect/Schedule";
18import * as Stream from "effect/Stream";
19import * as HttpClient from "effect/http/HttpClient";
20import * as HttpClientRequest from "effect/http/HttpClientRequest";
21import * as HttpApiClient from "effect/http-api/HttpApiClient";
22import * as ChildProcess from "effect/process/ChildProcess";
23import { makeS3TestStack, TEST_SECRET } from "./fixtures/s3-stack.ts";
24
25const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
26 providers: Layer.mergeAll(AWS.providers(), Cloudflare.providers()),
27});
28
29const logLevel = Effect.provideService(
30 MinimumLogLevel,
31 process.env.DEBUG ? "Debug" : "Info",
32);
33
34const Stack = makeS3TestStack("GitBlobStoreS3Stack");
35
36const makeClient = (url: string, token: string) =>
37 HttpApiClient.make(GitApi, {
38 baseUrl: url,
39 transformClient: HttpClient.mapRequest((request) =>
40 request.pipe(HttpClientRequest.bearerToken(token)),
41 ),
42 });
43
44const edgeRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
45 effect.pipe(
46 Effect.timeout("10 seconds"),
47 Effect.retry({
48 while: (error: unknown) =>
49 (error as { _tag?: string })._tag === "TimeoutError" ||
50 (error as { _tag?: string })._tag === "HttpClientError",
51 schedule: Schedule.spaced("1500 millis"),
52 times: 20,
53 }),
54 );
55
56class ShellError extends Data.TaggedError("ShellError")<{
57 readonly script: string;
58 readonly exitCode: number;
59 readonly stdout: string;
60 readonly stderr: string;
61}> {
62 override get message(): string {
63 return `shell script exited ${this.exitCode}\n--- script ---\n${this.script}\n--- stdout ---\n${this.stdout}\n--- stderr ---\n${this.stderr}`;
64 }
65}
66
67const sh = Effect.fn(function* (cwd: string, script: string) {
68 const handle = yield* ChildProcess.make("sh", ["-ec", script], {
69 cwd,
70 env: {
71 GIT_TERMINAL_PROMPT: "0",
72 GIT_CONFIG_NOSYSTEM: "1",
73 GIT_CONFIG_GLOBAL: "/dev/null",
74 GIT_AUTHOR_NAME: "S3 Bot",
75 GIT_AUTHOR_EMAIL: "s3@example.com",
76 GIT_COMMITTER_NAME: "S3 Bot",
77 GIT_COMMITTER_EMAIL: "s3@example.com",
78 GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z",
79 GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z",
80 },
81 extendEnv: true,
82 });
83 const [exitCode, stdout, stderr] = yield* Effect.all(
84 [
85 handle.exitCode,
86 Stream.mkString(Stream.decodeText(handle.stdout)),
87 Stream.mkString(Stream.decodeText(handle.stderr)),
88 ],
89 { concurrency: 3 },
90 );
91 if (exitCode !== 0) {
92 return yield* new ShellError({ script, exitCode, stdout, stderr });
93 }
94 return { stdout: stdout.trim(), stderr };
95}, Effect.timeout("180 seconds"));
96
97const stack = beforeAll(
98 deploy(Stack).pipe(
99 Effect.tap(({ url }) =>
100 Effect.gen(function* () {
101 yield* Effect.logInfo(`git-service (S3 bytes) deployed at ${url}`);
102 const admin = yield* makeClient(url, TEST_SECRET);
103 yield* admin.repos.list({ query: {} }).pipe(
104 edgeRetry,
105 Effect.retry({
106 schedule: Schedule.spaced("1500 millis"),
107 times: 40,
108 }),
109 );
110 }),
111 ),
112 logLevel,
113 ),
114);
115afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack).pipe(logLevel));
116
117test(
118 "a spilling push lands in S3, compacts into an S3 pack, and clones back under fsck --strict",
119 Effect.gen(function* () {
120 const { url } = yield* stack;
121 const admin = yield* makeClient(url, TEST_SECRET);
122 const owner = "s3";
123 const name = "bytes";
124 yield* admin.repos.delete({ params: { owner, repo: name } }).pipe(
125 Effect.catchTag("RepoNotFound", () => Effect.void),
126 edgeRetry,
127 );
128 yield* admin.repos.create({ payload: { owner, name } }).pipe(edgeRetry);
129 const parsed = new URL(url);
130 const remote = `${parsed.protocol}//x:${TEST_SECRET}@${parsed.host}/${owner}/${name}.git`;
131 const fs = yield* FileSystem.FileSystem;
132 const dir = yield* fs.makeTempDirectory({ prefix: "git-s3-" });
133 // ~14 MiB of incompressible blobs: a body that spills to the store.
134 yield* sh(
135 dir,
136 `git init -q -b main src && cd src
137 for i in $(seq 1 14); do head -c 1000000 /dev/urandom > bin-$i.dat; done
138 git add -A && git commit -qm base
139 git push -q ${remote} main`,
140 );
141 // Compact now; the pack is written to the store.
142 yield* admin.repos
143 .compact({ params: { owner, repo: name } })
144 .pipe(edgeRetry);
145 const repo = yield* admin.repos.get({ params: { owner, repo: name } }).pipe(
146 edgeRetry,
147 Effect.repeat({
148 schedule: Schedule.spaced("3 seconds"),
149 until: (r) => r.objects.loose === 0,
150 times: 30,
151 }),
152 );
153 expect(repo.objects.loose).toBe(0);
154 expect(repo.objects.packed).toBeGreaterThan(0);
155 const back = yield* sh(
156 dir,
157 `git clone -q ${remote} back && cd back && git fsck --strict && git rev-parse HEAD`,
158 );
159 const head = yield* sh(dir, "cd src && git rev-parse HEAD");
160 expect(back.stdout.trim().split("\n").pop()).toBe(head.stdout.trim());
161 yield* Effect.logInfo(`objects: ${JSON.stringify(repo.objects)}`);
162 }),
163 {
164 tags: [
165 "provider:aws",
166 "provider:aws:s3",
167 "provider:cloudflare",
168 "provider:cloudflare:r2",
169 "provider:cloudflare:worker",
170 "live",
171 ],
172 timeout: 600_000,
173 },
174);