1import { Query } from "@distilled.cloud/core/query";
2import {
3 Railway as RailwayApi,
4 type SandboxCheckpoint as RailwaySandboxCheckpoint,
5} from "@distilled.cloud/railway";
6import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
7import * as Provider from "@/Provider";
8import * as Railway from "@/Railway";
9import { State, type UpdatingReourceState } from "@/State";
10import * as Test from "@/Test/Alchemy";
11import { expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import * as Result from "effect/Result";
14import { suitePartition } from "./suiteProject.ts";
15
16const { test } = Test.make({ providers: Railway.providers() });
17
18const Base = Effect.gen(function* () {
19 const { environment } = yield* suitePartition;
20 const box = yield* Railway.Sandbox("Source", {
21 environment,
22 idleTimeoutMinutes: 5,
23 });
24 return { environment, box };
25});
26
27const listLive = Query.fn((environmentId: string) =>
28 RailwayApi.sandboxCheckpoints({ environmentId }).pipe(
29 Query.map((checkpoint) => ({
30 id: checkpoint.id,
31 key: checkpoint.key,
32 createdAt: checkpoint.createdAt,
33 environmentId: checkpoint.environmentId,
34 })),
35 ),
36);
37
38const checkpointFields = <E>(
39 checkpoint: Query<RailwaySandboxCheckpoint, E>,
40) => ({
41 id: checkpoint.id,
42 key: checkpoint.key,
43 createdAt: checkpoint.createdAt,
44});
45
46const renameSandboxCheckpoint = Query.fn(
47 (input: { environmentId: string; id: string; name: string }) =>
48 checkpointFields(RailwayApi.sandboxCheckpointRename(input)),
49);
50
51const createSandboxCheckpoint = Query.fn(
52 (input: { environmentId: string; sandboxId: string; name: string }) =>
53 checkpointFields(RailwayApi.sandboxCheckpointCreate(input)),
54);
55
56const deleteSandboxCheckpoint = Query.fn(
57 (input: { environmentId: string; id: string }) =>
58 RailwayApi.sandboxCheckpointDelete(input),
59);
60
61const Snapshot = (name?: string, restore = false) =>
62 Effect.gen(function* () {
63 const { environment, box } = yield* Base;
64 const checkpoint = yield* Railway.SandboxCheckpoint("Snapshot", {
65 sandbox: box,
66 name,
67 });
68 const restored = restore
69 ? yield* Railway.Sandbox("Restored", {
70 environment,
71 idleTimeoutMinutes: 5,
72 template: { name: checkpoint.key },
73 })
74 : undefined;
75 return { environment, box, checkpoint, restored };
76 });
77
78test.provider(
79 "capture, list, rename, restore, and delete a sandbox checkpoint",
80 (stack) =>
81 Effect.gen(function* () {
82 yield* stack.destroy();
83 const base = yield* stack.deploy(Base);
84 const written = yield* Railway.execSandbox({
85 sandboxId: base.box.sandboxId,
86 environmentId: base.box.environmentId,
87 command: "echo checkpoint-content > /tmp/alchemy-checkpoint.txt",
88 timeoutSec: 10,
89 });
90 expect(written.exitCode).toBe(0);
91
92 const created = yield* stack.deploy(Snapshot());
93 const original = created.checkpoint;
94 expect(original.sandboxId).toBe(base.box.sandboxId);
95 expect(original.key).toBe(original.name);
96 expect(original.name.length).toBeGreaterThan(0);
97 const live = yield* listLive(base.box.environmentId);
98 expect(
99 live.find((item) => item.id === original.sandboxCheckpointId),
100 ).toEqual({
101 id: original.sandboxCheckpointId,
102 key: original.key,
103 createdAt: original.createdAt,
104 environmentId: original.environmentId,
105 });
106 const provider = yield* Provider.findProvider(Railway.SandboxCheckpoint);
107 expect(
108 (yield* provider.list()).some(
109 (item) =>
110 item.sandboxCheckpointId === original.sandboxCheckpointId &&
111 item.environmentId === original.environmentId,
112 ),
113 ).toBe(true);
114
115 const unchanged = yield* stack.deploy(Snapshot());
116 expect(unchanged.checkpoint).toEqual(original);
117 const renamed = yield* stack.deploy(
118 Snapshot("restorable-checkpoint", true),
119 );
120 expect(renamed.checkpoint.name).toBe("restorable-checkpoint");
121 expect(renamed.checkpoint.createdAt).toBe(original.createdAt);
122 const renamedLive = yield* listLive(base.box.environmentId);
123 expect(renamedLive.some((item) => item.key === original.key)).toBe(false);
124 expect(
125 renamedLive.find((item) => item.key === renamed.checkpoint.key)?.id,
126 ).toBe(renamed.checkpoint.sandboxCheckpointId);
127 expect(renamed.restored).toBeDefined();
128 const restored = yield* Railway.execSandbox({
129 sandboxId: renamed.restored!.sandboxId,
130 environmentId: base.box.environmentId,
131 command: "cat /tmp/alchemy-checkpoint.txt",
132 timeoutSec: 10,
133 });
134 expect(restored.exitCode).toBe(0);
135 expect(restored.stdout.trim()).toBe("checkpoint-content");
136
137 const resetName = yield* stack.deploy(Snapshot());
138 expect(resetName.checkpoint.key).toBe(original.key);
139 expect(resetName.checkpoint.createdAt).toBe(original.createdAt);
140
141 yield* stack.deploy(Base);
142 expect(yield* listLive(base.box.environmentId)).toEqual([]);
143 yield* stack.destroy();
144 }),
145 {
146 tags: [
147 "provider:railway",
148 "provider:railway:project",
149 "provider:railway:projectenvironment",
150 "provider:railway:sandbox",
151 "provider:railway:sandboxcheckpoint",
152 "live",
153 ],
154 timeout: 120_000,
155 },
156);
157
158test.provider(
159 "replace a checkpoint when its source changes without removing either source",
160 (stack) =>
161 Effect.gen(function* () {
162 yield* stack.destroy();
163 const program = (second: boolean) =>
164 Effect.gen(function* () {
165 const { environment, box } = yield* Base;
166 const other = yield* Railway.Sandbox("Other", {
167 environment,
168 idleTimeoutMinutes: 5,
169 });
170 const checkpoint = yield* Railway.SandboxCheckpoint("Snapshot", {
171 sandbox: second ? other : box,
172 name: "replace-source-checkpoint",
173 });
174 return { box, other, checkpoint };
175 });
176 const before = yield* stack.deploy(program(false));
177 const after = yield* stack.deploy(program(true));
178 expect(after.box.sandboxId).toBe(before.box.sandboxId);
179 expect(after.other.sandboxId).toBe(before.other.sandboxId);
180 expect(after.checkpoint.sandboxId).toBe(after.other.sandboxId);
181 expect(after.checkpoint.createdAt).not.toBe(before.checkpoint.createdAt);
182 const live = yield* listLive(after.box.environmentId);
183 expect(live).toHaveLength(1);
184 expect(live[0]?.createdAt).toBe(after.checkpoint.createdAt);
185 yield* stack.deploy(Base);
186 expect(yield* listLive(after.box.environmentId)).toEqual([]);
187 yield* stack.destroy();
188 }),
189 {
190 tags: [
191 "provider:railway",
192 "provider:railway:project",
193 "provider:railway:projectenvironment",
194 "provider:railway:sandbox",
195 "provider:railway:sandboxcheckpoint",
196 "live",
197 ],
198 timeout: 120_000,
199 },
200);
201
202test.provider(
203 "recover interrupted renames without adopting or deleting a foreign capture",
204 (stack) =>
205 Effect.gen(function* () {
206 yield* stack.destroy();
207 const state = yield* yield* State;
208 const provider = yield* Provider.findProvider(Railway.SandboxCheckpoint);
209 const key = { stack: stack.name, stage: stack.stage, fqn: "Snapshot" };
210 for (const recapture of [false, true]) {
211 const created = yield* stack.deploy(Snapshot("before-rename"));
212 const persisted = yield* state.get(key);
213 if (
214 persisted?.status !== "created" &&
215 persisted?.status !== "updated"
216 ) {
217 return yield* Effect.fail(
218 new Error("Expected a stable checkpoint row"),
219 );
220 }
221 const attempted: Railway.SandboxCheckpointProps = {
222 sandbox: {
223 sandboxId: created.box.sandboxId,
224 environmentId: created.box.environmentId,
225 },
226 name: "attempted-rename",
227 };
228 // Apply commits attempted props before invoking the rename mutation.
229 yield* state.set({
230 ...key,
231 value: {
232 ...persisted,
233 status: "updating",
234 props: attempted,
235 old: persisted,
236 } satisfies UpdatingReourceState,
237 });
238 const renamed = yield* renameSandboxCheckpoint({
239 environmentId: created.box.environmentId,
240 id: created.checkpoint.sandboxCheckpointId,
241 name: attempted.name!,
242 });
243 expect(renamed.createdAt).toBe(created.checkpoint.createdAt);
244 let live = renamed;
245 if (recapture) {
246 yield* deleteSandboxCheckpoint({
247 environmentId: created.box.environmentId,
248 id: renamed.id,
249 });
250 live = yield* createSandboxCheckpoint({
251 environmentId: created.box.environmentId,
252 sandboxId: created.box.sandboxId,
253 name: attempted.name!,
254 });
255 expect(live.createdAt).not.toBe(created.checkpoint.createdAt);
256 }
257 // The successful rename's returned attributes were never persisted.
258 const read = yield* Effect.result(
259 provider.read!({
260 id: "Snapshot",
261 fqn: "Snapshot",
262 instanceId: persisted.instanceId,
263 olds: attempted,
264 output: created.checkpoint,
265 }),
266 );
267 if (recapture) {
268 expect(Result.isFailure(read)).toBe(true);
269 if (Result.isFailure(read)) {
270 expect(read.failure).toBeInstanceOf(OwnedBySomeoneElse);
271 }
272 } else {
273 expect(Result.isSuccess(read)).toBe(true);
274 if (Result.isSuccess(read)) {
275 expect(read.success).toMatchObject({
276 sandboxCheckpointId: renamed.id,
277 key: renamed.key,
278 createdAt: created.checkpoint.createdAt,
279 });
280 }
281 }
282 // Deletion must recover directly from attempted props and stale attrs.
283 yield* stack.deploy(Base);
284 const remaining = yield* listLive(created.box.environmentId);
285 if (recapture) {
286 expect(remaining).toHaveLength(1);
287 expect(remaining[0]?.id).toBe(live.id);
288 expect(remaining[0]?.createdAt).toBe(live.createdAt);
289 yield* deleteSandboxCheckpoint({
290 environmentId: created.box.environmentId,
291 id: live.id,
292 });
293 } else {
294 expect(remaining).toEqual([]);
295 }
296 expect(yield* listLive(created.box.environmentId)).toEqual([]);
297 }
298 yield* stack.destroy();
299 }),
300 {
301 tags: [
302 "provider:railway",
303 "provider:railway:project",
304 "provider:railway:projectenvironment",
305 "provider:railway:sandbox",
306 "provider:railway:sandboxcheckpoint",
307 "live",
308 ],
309 timeout: 120_000,
310 },
311);
312
313test.provider(
314 "require adoption and preserve a foreign recapture with the same checkpoint name",
315 (stack) =>
316 Effect.gen(function* () {
317 yield* stack.destroy();
318 const base = yield* stack.deploy(Base);
319 const foreign = yield* createSandboxCheckpoint({
320 environmentId: base.box.environmentId,
321 sandboxId: base.box.sandboxId,
322 name: "foreign-checkpoint",
323 });
324 const program = (allowAdoption: boolean, name = "foreign-checkpoint") =>
325 Effect.gen(function* () {
326 const { box } = yield* Base;
327 return yield* Railway.SandboxCheckpoint("Snapshot", {
328 sandbox: box,
329 name,
330 }).pipe(adopt(allowAdoption));
331 });
332 const denied = yield* Effect.result(stack.deploy(program(false)));
333 expect(Result.isFailure(denied)).toBe(true);
334 if (Result.isFailure(denied)) {
335 expect(denied.failure).toBeInstanceOf(OwnedBySomeoneElse);
336 }
337 expect((yield* listLive(base.box.environmentId))[0]?.createdAt).toBe(
338 foreign.createdAt,
339 );
340
341 const adopted = yield* stack.deploy(program(true));
342 expect(adopted.sandboxCheckpointId).toBe(foreign.id);
343 expect(adopted.createdAt).toBe(foreign.createdAt);
344 const occupied = yield* createSandboxCheckpoint({
345 environmentId: base.box.environmentId,
346 sandboxId: base.box.sandboxId,
347 name: "occupied-checkpoint",
348 });
349 const collision = yield* Effect.result(
350 stack.deploy(program(false, "occupied-checkpoint")),
351 );
352 expect(Result.isFailure(collision)).toBe(true);
353 const afterCollision = yield* listLive(base.box.environmentId);
354 expect(afterCollision).toHaveLength(2);
355 expect(
356 afterCollision.find((item) => item.id === occupied.id)?.createdAt,
357 ).toBe(occupied.createdAt);
358 expect(
359 afterCollision.find((item) => item.id === foreign.id)?.createdAt,
360 ).toBe(foreign.createdAt);
361 yield* deleteSandboxCheckpoint({
362 environmentId: base.box.environmentId,
363 id: occupied.id,
364 });
365 yield* stack.deploy(program(false));
366 yield* deleteSandboxCheckpoint({
367 environmentId: base.box.environmentId,
368 id: foreign.id,
369 });
370 const replacement = yield* createSandboxCheckpoint({
371 environmentId: base.box.environmentId,
372 sandboxId: base.box.sandboxId,
373 name: "foreign-checkpoint",
374 });
375 expect(replacement.createdAt).not.toBe(adopted.createdAt);
376 const provider = yield* Provider.findProvider(Railway.SandboxCheckpoint);
377 const read = yield* Effect.result(
378 provider.read!({
379 id: "Snapshot",
380 fqn: "Snapshot",
381 instanceId: "unused",
382 olds: {
383 sandbox: {
384 sandboxId: base.box.sandboxId,
385 environmentId: base.box.environmentId,
386 },
387 name: "foreign-checkpoint",
388 },
389 output: adopted,
390 }),
391 );
392 expect(Result.isFailure(read)).toBe(true);
393 if (Result.isFailure(read)) {
394 expect(read.failure).toBeInstanceOf(OwnedBySomeoneElse);
395 }
396
397 yield* stack.deploy(Base);
398 const preserved = yield* listLive(base.box.environmentId);
399 expect(preserved).toHaveLength(1);
400 expect(preserved[0]?.id).toBe(replacement.id);
401 expect(preserved[0]?.createdAt).toBe(replacement.createdAt);
402 yield* deleteSandboxCheckpoint({
403 environmentId: base.box.environmentId,
404 id: replacement.id,
405 });
406 expect(yield* listLive(base.box.environmentId)).toEqual([]);
407 yield* stack.destroy();
408 }),
409 {
410 tags: [
411 "provider:railway",
412 "provider:railway:project",
413 "provider:railway:projectenvironment",
414 "provider:railway:sandbox",
415 "provider:railway:sandboxcheckpoint",
416 "live",
417 ],
418 timeout: 120_000,
419 },
420);