@procedures/snapshot 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +737 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/index.d.ts +16 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +17 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/module.d.ts +21 -0
  10. package/dist/module.d.ts.map +1 -0
  11. package/dist/module.js +117 -0
  12. package/dist/module.js.map +1 -0
  13. package/dist/procedures/snapshot/archive-io.d.ts +12 -0
  14. package/dist/procedures/snapshot/archive-io.d.ts.map +1 -0
  15. package/dist/procedures/snapshot/archive-io.js +29 -0
  16. package/dist/procedures/snapshot/archive-io.js.map +1 -0
  17. package/dist/procedures/snapshot/commands.d.ts +15 -0
  18. package/dist/procedures/snapshot/commands.d.ts.map +1 -0
  19. package/dist/procedures/snapshot/commands.js +32 -0
  20. package/dist/procedures/snapshot/commands.js.map +1 -0
  21. package/dist/procedures/snapshot/create.d.ts +12 -0
  22. package/dist/procedures/snapshot/create.d.ts.map +1 -0
  23. package/dist/procedures/snapshot/create.js +279 -0
  24. package/dist/procedures/snapshot/create.js.map +1 -0
  25. package/dist/procedures/snapshot/delete.d.ts +12 -0
  26. package/dist/procedures/snapshot/delete.d.ts.map +1 -0
  27. package/dist/procedures/snapshot/delete.js +31 -0
  28. package/dist/procedures/snapshot/delete.js.map +1 -0
  29. package/dist/procedures/snapshot/diff.d.ts +12 -0
  30. package/dist/procedures/snapshot/diff.d.ts.map +1 -0
  31. package/dist/procedures/snapshot/diff.js +113 -0
  32. package/dist/procedures/snapshot/diff.js.map +1 -0
  33. package/dist/procedures/snapshot/index.d.ts +9 -0
  34. package/dist/procedures/snapshot/index.d.ts.map +1 -0
  35. package/dist/procedures/snapshot/index.js +9 -0
  36. package/dist/procedures/snapshot/index.js.map +1 -0
  37. package/dist/procedures/snapshot/list.d.ts +12 -0
  38. package/dist/procedures/snapshot/list.d.ts.map +1 -0
  39. package/dist/procedures/snapshot/list.js +57 -0
  40. package/dist/procedures/snapshot/list.js.map +1 -0
  41. package/dist/procedures/snapshot/names.d.ts +10 -0
  42. package/dist/procedures/snapshot/names.d.ts.map +1 -0
  43. package/dist/procedures/snapshot/names.js +20 -0
  44. package/dist/procedures/snapshot/names.js.map +1 -0
  45. package/dist/procedures/snapshot/restore.d.ts +12 -0
  46. package/dist/procedures/snapshot/restore.d.ts.map +1 -0
  47. package/dist/procedures/snapshot/restore.js +131 -0
  48. package/dist/procedures/snapshot/restore.js.map +1 -0
  49. package/dist/procedures/snapshot/s3-lookup.d.ts +39 -0
  50. package/dist/procedures/snapshot/s3-lookup.d.ts.map +1 -0
  51. package/dist/procedures/snapshot/s3-lookup.js +57 -0
  52. package/dist/procedures/snapshot/s3-lookup.js.map +1 -0
  53. package/dist/register.d.ts +7 -0
  54. package/dist/register.d.ts.map +1 -0
  55. package/dist/register.js +9 -0
  56. package/dist/register.js.map +1 -0
  57. package/dist/types.d.ts +193 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +168 -0
  60. package/dist/types.js.map +1 -0
  61. package/package.json +87 -3
  62. package/src/index.ts +51 -0
  63. package/src/module.ts +155 -0
  64. package/src/procedures/snapshot/archive-io.ts +31 -0
  65. package/src/procedures/snapshot/commands.ts +34 -0
  66. package/src/procedures/snapshot/create.ts +354 -0
  67. package/src/procedures/snapshot/delete.ts +40 -0
  68. package/src/procedures/snapshot/diff.ts +147 -0
  69. package/src/procedures/snapshot/index.ts +9 -0
  70. package/src/procedures/snapshot/list.ts +80 -0
  71. package/src/procedures/snapshot/names.ts +24 -0
  72. package/src/procedures/snapshot/restore.ts +166 -0
  73. package/src/procedures/snapshot/s3-lookup.ts +88 -0
  74. package/src/register.ts +11 -0
  75. package/src/types.ts +381 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * snapshot.create procedure
3
+ *
4
+ * Create an environment snapshot and upload to S3.
5
+ */
6
+
7
+ import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync, statSync } from "node:fs";
8
+ import { open } from "node:fs/promises";
9
+ import { join, basename, dirname, resolve } from "node:path";
10
+ import { tmpdir, hostname } from "node:os";
11
+ import { randomUUID } from "node:crypto";
12
+ import * as tar from "tar";
13
+ import type { ProcedureContext } from "@procedures/client";
14
+ import { assertSnapshotName, isExcluded } from "./names.js";
15
+ import { readPart, sha256File } from "./archive-io.js";
16
+ import { gitText, pnpmVersion } from "./commands.js";
17
+ import type {
18
+ SnapshotCreateInput,
19
+ SnapshotCreateOutput,
20
+ SnapshotMetadata,
21
+ RepositoryInfo,
22
+ SnapshotPreset,
23
+ } from "../../types.js";
24
+
25
+ /** The size of each part of a multipart upload (the S3 minimum) */
26
+ const PART_SIZE = 5 * 1024 * 1024;
27
+
28
+ /**
29
+ * Create a snapshot of the environment
30
+ */
31
+ export async function snapshotCreate(
32
+ input: SnapshotCreateInput,
33
+ ctx: ProcedureContext
34
+ ): Promise<SnapshotCreateOutput> {
35
+ // A plain name: it goes into S3 keys and the snapshot id (a "/" made ids that restore,
36
+ // diff and delete never found: deep dive DATA-1, DATA-16)
37
+ assertSnapshotName("name", input.name);
38
+ const id = `${input.name}-${Date.now()}-${randomUUID().slice(0, 8)}`;
39
+ const workDir = mkdtempSync(join(tmpdir(), "snapshot-"));
40
+
41
+ try {
42
+
43
+ // Get repository paths to snapshot
44
+ const repoPaths = input.paths || (await getEcosystemPaths(ctx));
45
+
46
+ // Gather repository info
47
+ const repositories = await Promise.all(
48
+ repoPaths.map((path) => getRepositoryInfo(path, ctx))
49
+ );
50
+
51
+ // Get environment info
52
+ const environment = await getEnvironmentInfo(ctx.signal);
53
+
54
+ // Create metadata
55
+ const metadata: SnapshotMetadata = {
56
+ id,
57
+ name: input.name,
58
+ preset: input.preset,
59
+ createdAt: new Date().toISOString(),
60
+ environment,
61
+ repositories,
62
+ checksum: "", // Will be calculated after archive creation
63
+ archiveSize: 0,
64
+ description: input.description,
65
+ };
66
+
67
+ // Write metadata to work directory
68
+ const metadataPath = join(workDir, "metadata.json");
69
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
70
+
71
+ // Create archive based on preset
72
+ const archivePath = join(workDir, `${id}.tar.gz`);
73
+ await createArchive(archivePath, repoPaths, input.preset, workDir);
74
+
75
+ // Calculate checksum (a stream: the archive is never in memory as a whole)
76
+ const checksum = await sha256File(archivePath);
77
+ const archiveSize = statSync(archivePath).size;
78
+
79
+ // Update metadata with checksum
80
+ metadata.checksum = checksum;
81
+ metadata.archiveSize = archiveSize;
82
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
83
+
84
+ // Upload to S3
85
+ const uploadStart = Date.now();
86
+ const s3Key = `snapshots/${input.name}/${id}.tar.gz`;
87
+
88
+ // Use multipart upload for large files (> 5MB). The parts are read from the file, one at a
89
+ // time (deep dive DATA-19).
90
+ if (archiveSize > PART_SIZE) {
91
+ await multipartUpload(ctx, input.bucket, s3Key, archivePath, archiveSize);
92
+ } else {
93
+ await ctx.client.call(["s3", "upload"], {
94
+ bucket: input.bucket,
95
+ key: s3Key,
96
+ body: readFileSync(archivePath).toString("base64"),
97
+ base64: true,
98
+ contentType: "application/gzip",
99
+ metadata: {
100
+ snapshotId: id,
101
+ snapshotName: input.name,
102
+ preset: input.preset,
103
+ checksum,
104
+ },
105
+ });
106
+ }
107
+
108
+ // Also upload metadata separately for quick listing
109
+ await ctx.client.call(["s3", "upload"], {
110
+ bucket: input.bucket,
111
+ key: `snapshots/${input.name}/${id}.metadata.json`,
112
+ body: JSON.stringify(metadata, null, 2),
113
+ contentType: "application/json",
114
+ });
115
+
116
+ const uploadDuration = Date.now() - uploadStart;
117
+
118
+ return {
119
+ id,
120
+ location: `s3://${input.bucket}/${s3Key}`,
121
+ metadata,
122
+ uploadDuration,
123
+ };
124
+ } finally {
125
+ // Cleanup work directory
126
+ if (existsSync(workDir)) {
127
+ rmSync(workDir, { recursive: true, force: true });
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get ecosystem repository paths from manifest
134
+ */
135
+ async function getEcosystemPaths(_ctx: ProcedureContext): Promise<string[]> {
136
+ // TODO: Read from ecosystem manifest
137
+ // For now, return current working directory
138
+ return [process.cwd()];
139
+ }
140
+
141
+ /**
142
+ * Get repository info for a path
143
+ */
144
+ async function getRepositoryInfo(
145
+ repoPath: string,
146
+ ctx: ProcedureContext
147
+ ): Promise<RepositoryInfo> {
148
+ try {
149
+ const result = await ctx.client.call<
150
+ { cwd?: string },
151
+ { branch: string; ahead: number; behind: number; files: unknown[]; clean: boolean }
152
+ >(["git", "status"], { cwd: repoPath });
153
+
154
+ // The commit hash ("" for a repository with no commit) and the remote URL (none without "origin")
155
+ const commit = (await gitText(["rev-parse", "HEAD"], repoPath, ctx.signal)) ?? "";
156
+ const remoteUrl = await gitText(["remote", "get-url", "origin"], repoPath, ctx.signal);
157
+
158
+ // Get stash count
159
+ let stashCount = 0;
160
+ try {
161
+ const stashList = await ctx.client.call<
162
+ { cwd?: string },
163
+ { stashes: unknown[]; count: number }
164
+ >(["git", "stash", "list"], { cwd: repoPath });
165
+ stashCount = stashList.count;
166
+ } catch {
167
+ // No stashes
168
+ }
169
+
170
+ // Get package name from package.json
171
+ let name = basename(repoPath);
172
+ try {
173
+ const pkgPath = join(repoPath, "package.json");
174
+ if (existsSync(pkgPath)) {
175
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
176
+ name = pkg.name || name;
177
+ }
178
+ } catch {
179
+ // Use directory name
180
+ }
181
+
182
+ return {
183
+ path: repoPath,
184
+ name,
185
+ branch: result.branch,
186
+ commit,
187
+ dirty: !result.clean,
188
+ stashCount,
189
+ remoteUrl,
190
+ ahead: result.ahead,
191
+ behind: result.behind,
192
+ };
193
+ } catch (error) {
194
+ // Not a git repo or error
195
+ return {
196
+ path: repoPath,
197
+ name: basename(repoPath),
198
+ branch: "",
199
+ commit: "",
200
+ dirty: false,
201
+ stashCount: 0,
202
+ ahead: 0,
203
+ behind: 0,
204
+ };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get environment info
210
+ */
211
+ async function getEnvironmentInfo(signal?: AbortSignal): Promise<SnapshotMetadata["environment"]> {
212
+ return {
213
+ os: process.platform,
214
+ nodeVersion: process.version,
215
+ pnpmVersion: await pnpmVersion(signal),
216
+ username: process.env["USER"] || process.env["USERNAME"] || "unknown",
217
+ hostname: hostname(),
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Create tar archive based on preset
223
+ */
224
+ async function createArchive(
225
+ archivePath: string,
226
+ repoPaths: string[],
227
+ preset: SnapshotPreset,
228
+ workDir: string
229
+ ): Promise<void> {
230
+ // Only archive repositories that actually exist on disk.
231
+ const existingRepos = repoPaths.filter((p) => existsSync(p));
232
+
233
+ // tar.create accepts a single `cwd`, and each entry is stored relative to it.
234
+ // To store each repository as a top-level `<basename>/...` entry we must run
235
+ // with `cwd` set to the directory that contains the repositories (the
236
+ // ecosystem root), not the fresh temp `workDir` the repos are not inside.
237
+ const parents = new Set(existingRepos.map((p) => resolve(dirname(p))));
238
+ if (parents.size > 1) {
239
+ throw new Error(
240
+ `snapshot.create requires all repositories to share a parent directory; got multiple roots: ${[
241
+ ...parents,
242
+ ].join(", ")}`
243
+ );
244
+ }
245
+ const cwd = existingRepos.length > 0 ? resolve(dirname(existingRepos[0]!)) : workDir;
246
+
247
+ // Create gzip options based on preset
248
+ const gzipOpts = { level: preset === "heavy" ? 6 : 9 };
249
+
250
+ // Build exclude patterns based on preset
251
+ const excludePatterns: string[] = [];
252
+
253
+ if (preset === "light") {
254
+ // Exclude node_modules and pnpm store
255
+ excludePatterns.push("node_modules", ".pnpm-store", "dist", "*.log");
256
+ } else if (preset === "medium") {
257
+ // Include node_modules but exclude pnpm store
258
+ excludePatterns.push(".pnpm-store", "*.log");
259
+ }
260
+ // heavy preset includes everything
261
+
262
+ await tar.create(
263
+ {
264
+ gzip: gzipOpts,
265
+ file: archivePath,
266
+ cwd,
267
+ // Whole path segments, and "*.ext" for file endings. (Before, `path.includes("dist")`
268
+ // left out src/distance.ts, and "*.log" matched nothing: deep dive DATA-3.)
269
+ filter: (path) => !isExcluded(path, excludePatterns),
270
+ },
271
+ existingRepos.map((p) => basename(p))
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Multipart upload for large files. Each part is read from the file when it goes up.
277
+ */
278
+ async function multipartUpload(
279
+ ctx: ProcedureContext,
280
+ bucket: string,
281
+ key: string,
282
+ archivePath: string,
283
+ archiveSize: number
284
+ ): Promise<void> {
285
+ // Initialize multipart upload
286
+ const initResult = await ctx.client.call<
287
+ { bucket: string; key: string; contentType?: string },
288
+ { uploadId: string }
289
+ >(["s3", "multipart", "init"], {
290
+ bucket,
291
+ key,
292
+ contentType: "application/gzip",
293
+ });
294
+
295
+ const uploadId = initResult.uploadId;
296
+ const parts: Array<{ etag: string; partNumber: number }> = [];
297
+ const file = await open(archivePath, "r");
298
+
299
+ try {
300
+ // Upload parts
301
+ let partNumber = 1;
302
+ for (let offset = 0; offset < archiveSize; offset += PART_SIZE) {
303
+ const chunk = await readPart(file, offset, PART_SIZE);
304
+
305
+ const partResult = await ctx.client.call<
306
+ { bucket: string; key: string; uploadId: string; partNumber: number; body: string },
307
+ { etag: string; partNumber: number }
308
+ >(["s3", "multipart", "upload"], {
309
+ bucket,
310
+ key,
311
+ uploadId,
312
+ partNumber,
313
+ body: chunk.toString("base64"),
314
+ });
315
+
316
+ parts.push({
317
+ etag: partResult.etag,
318
+ partNumber: partResult.partNumber,
319
+ });
320
+
321
+ partNumber++;
322
+ }
323
+
324
+ // Complete multipart upload
325
+ await ctx.client.call(["s3", "multipart", "complete"], {
326
+ bucket,
327
+ key,
328
+ uploadId,
329
+ parts,
330
+ });
331
+ } catch (error) {
332
+ // Abort on failure. The error of the upload stays the error: before, a failed abort
333
+ // replaced it.
334
+ try {
335
+ await ctx.client.call(["s3", "multipart", "abort"], {
336
+ bucket,
337
+ key,
338
+ uploadId,
339
+ });
340
+ } catch (abortError) {
341
+ throw new Error(
342
+ `${messageOf(error)} (the abort of upload ${uploadId} also failed: ${messageOf(abortError)})`,
343
+ { cause: error }
344
+ );
345
+ }
346
+ throw error;
347
+ } finally {
348
+ await file.close();
349
+ }
350
+ }
351
+
352
+ function messageOf(error: unknown): string {
353
+ return error instanceof Error ? error.message : String(error);
354
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * snapshot.delete procedure
3
+ *
4
+ * Delete a snapshot from S3.
5
+ */
6
+
7
+ import type { ProcedureContext } from "@procedures/client";
8
+ import type { SnapshotDeleteInput, SnapshotDeleteOutput } from "../../types.js";
9
+ import { listAllObjects, findSnapshotKeys } from "./s3-lookup.js";
10
+
11
+ /**
12
+ * Delete a snapshot from S3
13
+ */
14
+ export async function snapshotDelete(
15
+ input: SnapshotDeleteInput,
16
+ ctx: ProcedureContext
17
+ ): Promise<SnapshotDeleteOutput> {
18
+ // Find all files for this snapshot ID (paginated listing, exact basename
19
+ // match). Exact matching is critical here: a substring match could delete a
20
+ // different snapshot whose id contains this id as a substring.
21
+ const objects = await listAllObjects(ctx, input.bucket, "snapshots/");
22
+ const snapshotKeys = findSnapshotKeys(objects, input.id);
23
+
24
+ if (snapshotKeys.length === 0) {
25
+ throw new Error(`Snapshot not found: ${input.id}`);
26
+ }
27
+
28
+ // Delete each file
29
+ for (const key of snapshotKeys) {
30
+ await ctx.client.call(["s3", "delete"], {
31
+ bucket: input.bucket,
32
+ key,
33
+ });
34
+ }
35
+
36
+ return {
37
+ deleted: true,
38
+ id: input.id,
39
+ };
40
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * snapshot.diff procedure
3
+ *
4
+ * Compare current state against a snapshot.
5
+ */
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { basename } from "node:path";
9
+ import { gitText } from "./commands.js";
10
+ import type { ProcedureContext } from "@procedures/client";
11
+ import type {
12
+ SnapshotDiffInput,
13
+ SnapshotDiffOutput,
14
+ SnapshotDiffRepository,
15
+ SnapshotMetadata,
16
+ } from "../../types.js";
17
+ import { listAllObjects, findSnapshotKey } from "./s3-lookup.js";
18
+
19
+ /**
20
+ * Compare current state against a snapshot
21
+ */
22
+ export async function snapshotDiff(
23
+ input: SnapshotDiffInput,
24
+ ctx: ProcedureContext
25
+ ): Promise<SnapshotDiffOutput> {
26
+ // Download snapshot metadata (paginated listing, exact basename match).
27
+ const objects = await listAllObjects(ctx, input.bucket, "snapshots/");
28
+ const metadataKey = findSnapshotKey(objects, input.id, ".metadata.json");
29
+
30
+ if (!metadataKey) {
31
+ throw new Error(`Snapshot not found: ${input.id}`);
32
+ }
33
+
34
+ const metadataResult = await ctx.client.call<
35
+ { bucket: string; key: string; encoding?: string },
36
+ { body: string }
37
+ >(["s3", "download"], {
38
+ bucket: input.bucket,
39
+ key: metadataKey,
40
+ encoding: "utf8",
41
+ });
42
+
43
+ const snapshotMetadata: SnapshotMetadata = JSON.parse(metadataResult.body);
44
+
45
+ // Compare each repository
46
+ const repositories: SnapshotDiffRepository[] = [];
47
+ let totalFilesChanged = 0;
48
+ let reposChanged = 0;
49
+
50
+ const pathsToCheck = input.paths || snapshotMetadata.repositories.map((r) => r.path);
51
+
52
+ for (const repoPath of pathsToCheck) {
53
+ const snapshotRepo = snapshotMetadata.repositories.find(
54
+ (r) => r.path === repoPath || r.name === basename(repoPath)
55
+ );
56
+
57
+ if (!snapshotRepo) continue;
58
+
59
+ const diff: SnapshotDiffRepository = {
60
+ path: repoPath,
61
+ filesChanged: 0,
62
+ newStashes: 0,
63
+ };
64
+
65
+ if (!existsSync(repoPath)) {
66
+ // Repository was deleted
67
+ diff.filesChanged = -1; // Indicates missing
68
+ repositories.push(diff);
69
+ reposChanged++;
70
+ continue;
71
+ }
72
+
73
+ try {
74
+ // Get current branch (a failure is an error of this repository: the catch below)
75
+ const currentBranch = await gitText(["rev-parse", "--abbrev-ref", "HEAD"], repoPath, ctx.signal);
76
+ if (currentBranch === undefined) throw new Error(`git cannot read the branch of ${repoPath}`);
77
+
78
+ if (currentBranch !== snapshotRepo.branch) {
79
+ diff.branchDiff = {
80
+ current: currentBranch,
81
+ snapshot: snapshotRepo.branch,
82
+ };
83
+ }
84
+
85
+ // Get current commit
86
+ const currentCommit = await gitText(["rev-parse", "HEAD"], repoPath, ctx.signal);
87
+ if (currentCommit === undefined) throw new Error(`git cannot read the commit of ${repoPath}`);
88
+
89
+ if (currentCommit !== snapshotRepo.commit) {
90
+ diff.commitDiff = {
91
+ current: currentCommit,
92
+ snapshot: snapshotRepo.commit,
93
+ };
94
+
95
+ // Count files changed since snapshot commit.
96
+ // The commit comes from snapshot metadata downloaded from S3: accept only a hex
97
+ // object name, and pass it as an argument (no shell), so it cannot inject a command.
98
+ try {
99
+ if (!/^[0-9a-f]{7,64}$/i.test(snapshotRepo.commit)) {
100
+ throw new Error(`Not a commit hash: ${snapshotRepo.commit}`);
101
+ }
102
+ const diffStat = await gitText(["diff", "--stat", `${snapshotRepo.commit}..HEAD`], repoPath, ctx.signal);
103
+ if (diffStat === undefined) throw new Error(`git cannot compare ${snapshotRepo.commit} with HEAD`);
104
+ // Count lines that represent files (contain |)
105
+ diff.filesChanged = diffStat.split("\n").filter((l) => l.includes("|")).length;
106
+ totalFilesChanged += diff.filesChanged;
107
+ } catch {
108
+ // Snapshot commit may not exist locally
109
+ diff.filesChanged = -1;
110
+ }
111
+ }
112
+
113
+ // Check for new stashes
114
+ const stashResult = await ctx.client.call<
115
+ { cwd?: string },
116
+ { count: number }
117
+ >(["git", "stash", "list"], { cwd: repoPath });
118
+
119
+ diff.newStashes = stashResult.count - snapshotRepo.stashCount;
120
+
121
+ if (
122
+ diff.branchDiff ||
123
+ diff.commitDiff ||
124
+ diff.filesChanged > 0 ||
125
+ diff.newStashes > 0
126
+ ) {
127
+ reposChanged++;
128
+ }
129
+ } catch {
130
+ // Not a git repo or error
131
+ diff.filesChanged = -1;
132
+ reposChanged++;
133
+ }
134
+
135
+ repositories.push(diff);
136
+ }
137
+
138
+ return {
139
+ snapshotMetadata,
140
+ repositories,
141
+ summary: {
142
+ reposChanged,
143
+ totalFilesChanged,
144
+ isMatch: reposChanged === 0,
145
+ },
146
+ };
147
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Snapshot Procedures Index
3
+ */
4
+
5
+ export { snapshotCreate } from "./create.js";
6
+ export { snapshotList } from "./list.js";
7
+ export { snapshotRestore } from "./restore.js";
8
+ export { snapshotDiff } from "./diff.js";
9
+ export { snapshotDelete } from "./delete.js";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * snapshot.list procedure
3
+ *
4
+ * List available snapshots in S3 bucket.
5
+ */
6
+
7
+ import type { ProcedureContext } from "@procedures/client";
8
+ import type {
9
+ SnapshotListInput,
10
+ SnapshotListOutput,
11
+ SnapshotListEntry,
12
+ SnapshotMetadata,
13
+ } from "../../types.js";
14
+ import { listAllObjects } from "./s3-lookup.js";
15
+
16
+ /**
17
+ * List snapshots in S3 bucket
18
+ */
19
+ export async function snapshotList(
20
+ input: SnapshotListInput,
21
+ ctx: ProcedureContext
22
+ ): Promise<SnapshotListOutput> {
23
+ const prefix = input.prefix
24
+ ? `snapshots/${input.prefix}`
25
+ : "snapshots/";
26
+
27
+ // List the metadata files of all pages. Sort them by the time of the write (newest first),
28
+ // then cut. Before, the procedure read one page (S3 lists by key, not by time) and cut it
29
+ // before the sort, so it returned the oldest snapshots (deep dive DATA-16).
30
+ const objects = await listAllObjects(ctx, input.bucket, prefix);
31
+ const metadataKeys = objects
32
+ .filter((obj) => obj.key.endsWith(".metadata.json"))
33
+ .sort((a, b) => timeOf(b.lastModified) - timeOf(a.lastModified))
34
+ .slice(0, input.maxResults);
35
+
36
+ // Fetch metadata for each snapshot
37
+ const snapshots: SnapshotListEntry[] = [];
38
+
39
+ for (const obj of metadataKeys) {
40
+ try {
41
+ const downloadResult = await ctx.client.call<
42
+ { bucket: string; key: string; encoding?: string },
43
+ { body: string }
44
+ >(["s3", "download"], {
45
+ bucket: input.bucket,
46
+ key: obj.key,
47
+ encoding: "utf8",
48
+ });
49
+
50
+ const metadata: SnapshotMetadata = JSON.parse(downloadResult.body);
51
+
52
+ snapshots.push({
53
+ id: metadata.id,
54
+ name: metadata.name,
55
+ preset: metadata.preset,
56
+ createdAt: metadata.createdAt,
57
+ size: metadata.archiveSize,
58
+ os: metadata.environment.os,
59
+ });
60
+ } catch {
61
+ // Skip invalid metadata
62
+ }
63
+ }
64
+
65
+ // Sort by creation date (newest first)
66
+ snapshots.sort((a, b) =>
67
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
68
+ );
69
+
70
+ return {
71
+ snapshots,
72
+ count: snapshots.length,
73
+ };
74
+ }
75
+
76
+ /** A time in milliseconds. A missing or invalid time is 0, so it sorts last. */
77
+ function timeOf(iso: string | undefined): number {
78
+ const time = iso ? Date.parse(iso) : NaN;
79
+ return Number.isNaN(time) ? 0 : time;
80
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Names and exclusion rules of snapshots.
3
+ */
4
+
5
+ /** A snapshot name or id: letters, digits, ".", "_" and "-", and not ".." (it goes into paths and S3 keys). */
6
+ const SNAPSHOT_NAME = /^[A-Za-z0-9._-]+$/;
7
+
8
+ export function assertSnapshotName(field: string, value: string): void {
9
+ if (!SNAPSHOT_NAME.test(value) || value.includes("..")) {
10
+ throw new Error(`Invalid snapshot ${field}: ${JSON.stringify(value)} (use letters, digits, ".", "_" and "-")`);
11
+ }
12
+ }
13
+
14
+ /**
15
+ * True when an archive entry is excluded: a pattern matches a whole path segment ("dist"
16
+ * excludes `dist/` but not `src/distance.ts`), or, for "*.ext", the end of the file name.
17
+ */
18
+ export function isExcluded(entryPath: string, patterns: readonly string[]): boolean {
19
+ const segments = entryPath.split(/[\\/]/).filter((segment) => segment.length > 0);
20
+ const last = segments[segments.length - 1] ?? "";
21
+ return patterns.some((pattern) =>
22
+ pattern.startsWith("*.") ? last.endsWith(pattern.slice(1)) : segments.includes(pattern),
23
+ );
24
+ }