1import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
2import {
3 Railway,
4 type SandboxCheckpoint as RailwaySandboxCheckpoint,
5} from "@distilled.cloud/railway";
6import * as Effect from "effect/Effect";
7import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts";
8import { isResolved } from "../Diff.ts";
9import * as Provider from "../Provider.ts";
10import { Resource } from "../Resource.ts";
11import { waitUntilDeleted } from "./GraphQL.ts";
12import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
13import { ownedProjects, projectEnvironmentIds } from "./Project.ts";
14import type { Providers } from "./Providers.ts";
15import type { SandboxIdentity } from "./Sandbox.ts";
16
17const checkpointFields = <E>(
18 checkpoint: Query<RailwaySandboxCheckpoint, E>,
19) => ({
20 id: checkpoint.id,
21 key: checkpoint.key,
22 environmentId: checkpoint.environmentId,
23 createdAt: checkpoint.createdAt,
24});
25
26type CloudCheckpoint = UnwrapPlan<ReturnType<typeof checkpointFields>>;
27
28export interface SandboxCheckpointProps {
29 /**
30 * Running sandbox to capture. Accepts a Railway.Sandbox or a
31 * `{ sandboxId, environmentId }` identity. Changing either ID replaces
32 * the checkpoint; the old checkpoint is deleted first when reusing its name.
33 */
34 sandbox: SandboxIdentity;
35 /**
36 * Checkpoint name, unique within the environment. Defaults to an
37 * instance-specific physical name. Changing it renames the existing
38 * snapshot without capturing the source again. Removing it restores
39 * the generated name.
40 */
41 name?: string;
42}
43
44export type SandboxCheckpoint = Resource<
45 "Railway.SandboxCheckpoint",
46 SandboxCheckpointProps,
47 {
48 /** Railway checkpoint ID. May change when the checkpoint is renamed. */
49 sandboxCheckpointId: string;
50 /** Environment that stores the snapshot. */
51 environmentId: string;
52 /** Source sandbox ID, or undefined for checkpoints discovered by listing. */
53 sandboxId: string | undefined;
54 /** Checkpoint name, usable as Sandbox's `template.name`. */
55 name: string;
56 /** Railway checkpoint key, identical to the checkpoint name. */
57 key: string;
58 /** RFC3339 capture timestamp; also distinguishes recaptures under the same ID. */
59 createdAt: string;
60 },
61 never,
62 Providers
63>;
64
65/**
66 * A managed, named snapshot of a running Railway sandbox's disk. Capture is
67 * synchronous: the checkpoint is bootable when deployment completes. Files
68 * survive restoration, but running processes and memory do not.
69 *
70 * The snapshot is captured once, not on every deploy. Changing the source
71 * replaces it; changing the name renames it. Existing explicit names require
72 * adoption. Persisted ID and capture time protect against deleting or
73 * overwriting a different capture that later occupies the same name.
74 * Interrupted renames are recovered from the persisted attempted name and
75 * original capture time. Recovery never searches arbitrary names by timestamp;
76 * losing either piece of evidence requires explicit operator recovery.
77 * Railway exposes no conditional mutations, so concurrent external writers
78 * must not mutate the same checkpoint while Alchemy is reconciling it.
79 *
80 * ### Capture a sandbox
81 * **Example:** Managed snapshot
82 * ```typescript
83 * const box = yield* Railway.Sandbox("Box", { environment: site });
84 * const checkpoint = yield* Railway.SandboxCheckpoint("Prepared", {
85 * sandbox: box,
86 * });
87 * ```
88 *
89 * ### Restore a checkpoint
90 * **Example:** Boot an independent sandbox
91 * ```typescript
92 * const restored = yield* Railway.Sandbox("Restored", {
93 * environment: site,
94 * template: { name: checkpoint.key },
95 * });
96 * ```
97 *
98 * ### Adopt an existing checkpoint
99 * **Example:** Manage a named snapshot without recapturing it
100 * ```typescript
101 * const checkpoint = yield* Railway.SandboxCheckpoint("Prepared", {
102 * sandbox: box,
103 * name: "after-deps",
104 * }).pipe(Alchemy.adopt(true));
105 * ```
106 *
107 * @see https://docs.railway.com/sandboxes#checkpoints
108 *
109 * @resource
110 * @product Sandbox
111 */
112export const SandboxCheckpoint = Resource<SandboxCheckpoint>(
113 "Railway.SandboxCheckpoint",
114);
115
116const listCheckpoints = Query.fn((environmentId: string) =>
117 Railway.sandboxCheckpoints({ environmentId }).pipe(
118 Query.map(checkpointFields),
119 ),
120);
121
122const sandboxCheckpointCreate = Query.fn(
123 (input: { environmentId: string; sandboxId: string; name: string }) =>
124 checkpointFields(Railway.sandboxCheckpointCreate(input)),
125);
126
127const sandboxCheckpointRename = Query.fn(
128 (input: { environmentId: string; id: string; name: string }) =>
129 checkpointFields(Railway.sandboxCheckpointRename(input)),
130);
131
132/** Railway answers a missing checkpoint with `false`, not an error. */
133const sandboxCheckpointDelete = Query.fn(
134 (input: { environmentId: string; id: string }) =>
135 Railway.sandboxCheckpointDelete(input),
136);
137
138const toAttrs = (
139 checkpoint: CloudCheckpoint,
140 sandboxId?: string,
141): SandboxCheckpoint["Attributes"] => ({
142 sandboxCheckpointId: checkpoint.id,
143 environmentId: checkpoint.environmentId,
144 sandboxId,
145 name: checkpoint.key,
146 key: checkpoint.key,
147 createdAt: checkpoint.createdAt,
148});
149
150const sameCapture = (
151 checkpoint: CloudCheckpoint,
152 output: SandboxCheckpoint["Attributes"],
153) =>
154 checkpoint.id === output.sandboxCheckpointId &&
155 checkpoint.createdAt === output.createdAt;
156
157const findRecordedCheckpoint = Effect.fn(function* (
158 id: string,
159 props: SandboxCheckpointProps | undefined,
160 output: SandboxCheckpoint["Attributes"],
161 items: readonly CloudCheckpoint[],
162) {
163 const byId = items.find((item) => item.id === output.sandboxCheckpointId);
164 if (byId !== undefined) return byId;
165 if (
166 props?.sandbox?.environmentId !== output.environmentId ||
167 props?.sandbox?.sandboxId !== output.sandboxId
168 ) {
169 return undefined;
170 }
171 // Updating rows retain attempted props alongside the pre-rename attributes.
172 const attemptedName = props?.name ?? (yield* createRailwayName(id));
173 return items.find((item) => item.key === attemptedName);
174});
175
176const conflict = (id: string, name: string) =>
177 new OwnedBySomeoneElse({
178 message: `Sandbox checkpoint ${name} belongs to another capture; adopt it explicitly or choose a different name`,
179 resourceType: "Railway.SandboxCheckpoint",
180 logicalId: id,
181 physicalName: name,
182 });
183
184export const SandboxCheckpointProvider = () =>
185 Provider.succeed(SandboxCheckpoint, {
186 stables: ["environmentId", "sandboxId"],
187 nuke: { dependsOn: ["Railway.Project", "Railway.Environment"] },
188
189 diff: Effect.fn(function* ({ news, output }) {
190 if (!isResolved(news) || output === undefined) return;
191 if (
192 news.sandbox.sandboxId !== output.sandboxId ||
193 news.sandbox.environmentId !== output.environmentId
194 ) {
195 return {
196 action: "replace" as const,
197 deleteFirst:
198 news.sandbox.environmentId === output.environmentId &&
199 news.name === output.name,
200 };
201 }
202 }),
203
204 read: Effect.fn(function* ({ id, olds, output }) {
205 const environmentId =
206 output?.environmentId ?? olds?.sandbox?.environmentId;
207 if (environmentId === undefined) return;
208 const items = yield* listCheckpoints(environmentId);
209 if (output !== undefined) {
210 const found = yield* findRecordedCheckpoint(id, olds, output, items);
211 if (found === undefined) return;
212 if (found.createdAt !== output.createdAt) {
213 return yield* conflict(id, found.key);
214 }
215 return toAttrs(found, output.sandboxId);
216 }
217 const generatedName = yield* createRailwayName(id);
218 const name = olds?.name ?? generatedName;
219 const found = items.find((item) => item.key === name);
220 if (found === undefined) return;
221 const attrs = toAttrs(found, olds?.sandbox?.sandboxId);
222 return found.key === generatedName ? attrs : Unowned(attrs);
223 }),
224
225 list: Effect.fn(function* () {
226 const projects = yield* ownedProjects();
227 const rows = yield* Effect.forEach(projects, (project) =>
228 Effect.gen(function* () {
229 const environments = yield* projectEnvironmentIds(project);
230 return (yield* Effect.forEach(environments, (environmentId) =>
231 listCheckpoints(environmentId).pipe(
232 Effect.map((items) =>
233 items
234 .filter((item) => matchesAlchemyPhysicalName(item.key))
235 .map((item) => toAttrs(item)),
236 ),
237 ),
238 )).flat();
239 }),
240 );
241 return rows.flat();
242 }),
243
244 reconcile: Effect.fn(function* ({ id, news, output }) {
245 const { sandboxId, environmentId } = news.sandbox;
246 const generatedName = yield* createRailwayName(id);
247 const name = news.name ?? generatedName;
248 const items = yield* listCheckpoints(environmentId);
249 let current = output
250 ? items.find((item) => item.id === output.sandboxCheckpointId)
251 : items.find((item) => item.key === name);
252 if (
253 current !== undefined &&
254 (output ? !sameCapture(current, output) : current.key !== generatedName)
255 ) {
256 return yield* conflict(id, current.key);
257 }
258 const target = items.find((item) => item.key === name);
259 if (target !== undefined && target.id !== current?.id) {
260 // Rename can succeed before state persistence; capture time survives it.
261 if (
262 output !== undefined &&
263 current === undefined &&
264 target.createdAt === output.createdAt
265 ) {
266 current = target;
267 } else {
268 return yield* conflict(id, name);
269 }
270 }
271 if (current === undefined) {
272 current = yield* sandboxCheckpointCreate({
273 environmentId,
274 sandboxId,
275 name,
276 });
277 }
278 if (current.key !== name) {
279 current = yield* sandboxCheckpointRename({
280 environmentId,
281 id: current.id,
282 name,
283 });
284 }
285 return toAttrs(current, sandboxId);
286 }),
287
288 delete: Effect.fn(function* ({ id, olds, output }) {
289 const items = yield* listCheckpoints(output.environmentId);
290 const current = yield* findRecordedCheckpoint(id, olds, output, items);
291 if (current === undefined || current.createdAt !== output.createdAt)
292 return;
293 yield* sandboxCheckpointDelete({
294 environmentId: output.environmentId,
295 id: current.id,
296 });
297 yield* waitUntilDeleted(
298 "SandboxCheckpoint",
299 current.id,
300 listCheckpoints(output.environmentId).pipe(
301 Effect.map(
302 (rows) =>
303 !rows.some(
304 (item) =>
305 item.id === current.id &&
306 item.createdAt === current.createdAt,
307 ),
308 ),
309 ),
310 10,
311 );
312 }),
313 });