@superblocksteam/workspace-sync 0.0.0-stage → 2.0.0-SNAPSHOT.e3d0ac5dd38ba62e

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 (123) hide show
  1. package/LICENSE.txt +87 -0
  2. package/README.md +51 -2
  3. package/dist/atomic-write.d.ts +10 -0
  4. package/dist/atomic-write.d.ts.map +1 -0
  5. package/dist/atomic-write.js +35 -0
  6. package/dist/atomic-write.js.map +1 -0
  7. package/dist/branch-write-guard.d.ts +32 -0
  8. package/dist/branch-write-guard.d.ts.map +1 -0
  9. package/dist/branch-write-guard.js +38 -0
  10. package/dist/branch-write-guard.js.map +1 -0
  11. package/dist/control.d.ts +237 -0
  12. package/dist/control.d.ts.map +1 -0
  13. package/dist/control.js +242 -0
  14. package/dist/control.js.map +1 -0
  15. package/dist/dbfs-transport.d.ts +54 -0
  16. package/dist/dbfs-transport.d.ts.map +1 -0
  17. package/dist/dbfs-transport.js +21 -0
  18. package/dist/dbfs-transport.js.map +1 -0
  19. package/dist/download.d.ts +54 -0
  20. package/dist/download.d.ts.map +1 -0
  21. package/dist/download.js +475 -0
  22. package/dist/download.js.map +1 -0
  23. package/dist/draft-helpers.d.ts +127 -0
  24. package/dist/draft-helpers.d.ts.map +1 -0
  25. package/dist/draft-helpers.js +840 -0
  26. package/dist/draft-helpers.js.map +1 -0
  27. package/dist/draft-journal-store.d.ts +30 -0
  28. package/dist/draft-journal-store.d.ts.map +1 -0
  29. package/dist/draft-journal-store.js +7 -0
  30. package/dist/draft-journal-store.js.map +1 -0
  31. package/dist/draft-state-report-metrics.d.ts +5 -0
  32. package/dist/draft-state-report-metrics.d.ts.map +1 -0
  33. package/dist/draft-state-report-metrics.js +36 -0
  34. package/dist/draft-state-report-metrics.js.map +1 -0
  35. package/dist/file-draft-journal-store.d.ts +26 -0
  36. package/dist/file-draft-journal-store.d.ts.map +1 -0
  37. package/dist/file-draft-journal-store.js +123 -0
  38. package/dist/file-draft-journal-store.js.map +1 -0
  39. package/dist/flush-scheduler.d.ts +24 -0
  40. package/dist/flush-scheduler.d.ts.map +1 -0
  41. package/dist/flush-scheduler.js +36 -0
  42. package/dist/flush-scheduler.js.map +1 -0
  43. package/dist/hash-cache.d.ts +16 -0
  44. package/dist/hash-cache.d.ts.map +1 -0
  45. package/dist/hash-cache.js +62 -0
  46. package/dist/hash-cache.js.map +1 -0
  47. package/dist/hash-dir-tree.d.ts +10 -0
  48. package/dist/hash-dir-tree.d.ts.map +1 -0
  49. package/dist/hash-dir-tree.js +53 -0
  50. package/dist/hash-dir-tree.js.map +1 -0
  51. package/dist/index.d.ts +27 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +27 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/list-dir.d.ts +31 -0
  56. package/dist/list-dir.d.ts.map +1 -0
  57. package/dist/list-dir.js +153 -0
  58. package/dist/list-dir.js.map +1 -0
  59. package/dist/logger.d.ts +16 -0
  60. package/dist/logger.d.ts.map +1 -0
  61. package/dist/logger.js +16 -0
  62. package/dist/logger.js.map +1 -0
  63. package/dist/native-draft-session.d.ts +28 -0
  64. package/dist/native-draft-session.d.ts.map +1 -0
  65. package/dist/native-draft-session.js +154 -0
  66. package/dist/native-draft-session.js.map +1 -0
  67. package/dist/rpc-client.d.ts +7 -0
  68. package/dist/rpc-client.d.ts.map +1 -0
  69. package/dist/rpc-client.js +2 -0
  70. package/dist/rpc-client.js.map +1 -0
  71. package/dist/safe-directory-reader.d.ts +28 -0
  72. package/dist/safe-directory-reader.d.ts.map +1 -0
  73. package/dist/safe-directory-reader.js +136 -0
  74. package/dist/safe-directory-reader.js.map +1 -0
  75. package/dist/server-tree.d.ts +56 -0
  76. package/dist/server-tree.d.ts.map +1 -0
  77. package/dist/server-tree.js +179 -0
  78. package/dist/server-tree.js.map +1 -0
  79. package/dist/session-draft-journal-store.d.ts +42 -0
  80. package/dist/session-draft-journal-store.d.ts.map +1 -0
  81. package/dist/session-draft-journal-store.js +177 -0
  82. package/dist/session-draft-journal-store.js.map +1 -0
  83. package/dist/snapshot/take-snapshot.d.ts +41 -0
  84. package/dist/snapshot/take-snapshot.d.ts.map +1 -0
  85. package/dist/snapshot/take-snapshot.js +244 -0
  86. package/dist/snapshot/take-snapshot.js.map +1 -0
  87. package/dist/snapshot/types.d.ts +25 -0
  88. package/dist/snapshot/types.d.ts.map +1 -0
  89. package/dist/snapshot/types.js +3 -0
  90. package/dist/snapshot/types.js.map +1 -0
  91. package/dist/snapshot/upload-snapshot.d.ts +33 -0
  92. package/dist/snapshot/upload-snapshot.d.ts.map +1 -0
  93. package/dist/snapshot/upload-snapshot.js +113 -0
  94. package/dist/snapshot/upload-snapshot.js.map +1 -0
  95. package/dist/socket-dbfs-transport.d.ts +32 -0
  96. package/dist/socket-dbfs-transport.d.ts.map +1 -0
  97. package/dist/socket-dbfs-transport.js +129 -0
  98. package/dist/socket-dbfs-transport.js.map +1 -0
  99. package/dist/sync-credential-provider.d.ts +27 -0
  100. package/dist/sync-credential-provider.d.ts.map +1 -0
  101. package/dist/sync-credential-provider.js +22 -0
  102. package/dist/sync-credential-provider.js.map +1 -0
  103. package/dist/sync-engine.d.ts +141 -0
  104. package/dist/sync-engine.d.ts.map +1 -0
  105. package/dist/sync-engine.js +447 -0
  106. package/dist/sync-engine.js.map +1 -0
  107. package/dist/sync-paths.d.ts +61 -0
  108. package/dist/sync-paths.d.ts.map +1 -0
  109. package/dist/sync-paths.js +107 -0
  110. package/dist/sync-paths.js.map +1 -0
  111. package/dist/sync-telemetry.d.ts +12 -0
  112. package/dist/sync-telemetry.d.ts.map +1 -0
  113. package/dist/sync-telemetry.js +33 -0
  114. package/dist/sync-telemetry.js.map +1 -0
  115. package/dist/testing.d.ts +81 -0
  116. package/dist/testing.d.ts.map +1 -0
  117. package/dist/testing.js +192 -0
  118. package/dist/testing.js.map +1 -0
  119. package/dist/workspace-state-store.d.ts +52 -0
  120. package/dist/workspace-state-store.d.ts.map +1 -0
  121. package/dist/workspace-state-store.js +45 -0
  122. package/dist/workspace-state-store.js.map +1 -0
  123. package/package.json +60 -3
@@ -0,0 +1,840 @@
1
+ import * as fsp from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { createPatch } from "diff";
4
+ import { writeFileAtomic } from "./atomic-write.js";
5
+ import { SafeDirectoryReader } from "./safe-directory-reader.js";
6
+ const EDIT_EXT = ".sbswap.edit";
7
+ const ADD_EXT = ".sbswap.add";
8
+ const DELETE_EXT = ".sbswap.delete";
9
+ const MARKER_EXT = {
10
+ add: ADD_EXT,
11
+ delete: DELETE_EXT,
12
+ edit: EDIT_EXT,
13
+ };
14
+ /**
15
+ * Gets the marker file path for a given file path and app root directory.
16
+ * Marker files are stored in .superblocks/drafts/ with the same relative structure.
17
+ *
18
+ * @param filePath - The absolute path to the file.
19
+ * @param appRootDirPath - The absolute path to the app root directory.
20
+ * @param markerType - The type of marker file ('.sbswap.edit' or '.sbswap.add').
21
+ * @returns The absolute path to the marker file.
22
+ */
23
+ function getMarkerFilePath(filePath, appRootDirPath, markerType) {
24
+ const relativePath = path.relative(appRootDirPath, filePath);
25
+ return path.join(getDraftsDirectoryPath(appRootDirPath), `${relativePath}${markerType}`);
26
+ }
27
+ /**
28
+ * Gets the drafts directory path for the app root.
29
+ *
30
+ * @param appRootDirPath - The absolute path to the app root directory.
31
+ * @returns The absolute path to the drafts directory.
32
+ */
33
+ function getDraftsDirectoryPath(appRootDirPath) {
34
+ return path.join(appRootDirPath, ".superblocks", "drafts");
35
+ }
36
+ async function getMarkerState(filePath, appRootDirPath) {
37
+ const editMarkerPath = getMarkerFilePath(filePath, appRootDirPath, EDIT_EXT);
38
+ const addMarkerPath = getMarkerFilePath(filePath, appRootDirPath, ADD_EXT);
39
+ const deleteMarkerPath = getMarkerFilePath(filePath, appRootDirPath, DELETE_EXT);
40
+ let state = "NEW";
41
+ try {
42
+ await fsp.access(filePath);
43
+ state = "FILE_EXISTS";
44
+ }
45
+ catch {
46
+ //
47
+ }
48
+ try {
49
+ await fsp.access(editMarkerPath);
50
+ state = "EDIT_MARKER";
51
+ }
52
+ catch {
53
+ //
54
+ }
55
+ try {
56
+ await fsp.access(addMarkerPath);
57
+ state = "ADD_MARKER";
58
+ }
59
+ catch {
60
+ //
61
+ }
62
+ try {
63
+ await fsp.access(deleteMarkerPath);
64
+ state = "DELETE_MARKER";
65
+ }
66
+ catch {
67
+ //
68
+ }
69
+ return state;
70
+ }
71
+ /**
72
+ * Creates a draft edit or addition marker file only.
73
+ *
74
+ * @param filePath - The absolute path to the file for which to create the respective marker.
75
+ * @param appRootDirPath - The absolute path to the app root directory.
76
+ */
77
+ export async function createDraftMarkerOnly(filePath, appRootDirPath) {
78
+ const state = await getMarkerState(filePath, appRootDirPath);
79
+ switch (state) {
80
+ case "FILE_EXISTS": {
81
+ const editMarkerPath = getMarkerFilePath(filePath, appRootDirPath, EDIT_EXT);
82
+ // Ensure the drafts directory structure exists
83
+ const editMarkerDir = path.dirname(editMarkerPath);
84
+ await fsp.mkdir(editMarkerDir, { recursive: true });
85
+ // Copy original file to .sbswap.edit marker in drafts directory
86
+ await fsp.copyFile(filePath, editMarkerPath);
87
+ return;
88
+ }
89
+ case "NEW": {
90
+ const addMarkerPath = getMarkerFilePath(filePath, appRootDirPath, ADD_EXT);
91
+ // Ensure the drafts directory structure exists
92
+ const addMarkerDir = path.dirname(addMarkerPath);
93
+ await fsp.mkdir(addMarkerDir, { recursive: true });
94
+ // Create marker to indicate this is a draft addition
95
+ await fsp.writeFile(addMarkerPath, "");
96
+ return;
97
+ }
98
+ case "EDIT_MARKER":
99
+ case "ADD_MARKER":
100
+ default:
101
+ // we already have a marker file, nothing to do
102
+ break;
103
+ }
104
+ }
105
+ /**
106
+ * Creates a draft edit or addition based on the presence of an existing file.
107
+ * This makes the draft content visible to the build system while preserving the original.
108
+ *
109
+ * @param filePath - The absolute path to the file to edit or add.
110
+ * @param draftContent - The content of the draft edit or addition.
111
+ * @param appRootDirPath - The absolute path to the app root directory.
112
+ * @returns A promise that resolves to "edit" if the file already exists, or "add" if the file does not exist.
113
+ */
114
+ export async function createDraft(filePath, draftContent, appRootDirPath) {
115
+ const state = await getMarkerState(filePath, appRootDirPath);
116
+ switch (state) {
117
+ case "ADD_MARKER": {
118
+ // This is an iteration on an existing draft addition - just update content
119
+ const dirPath = path.dirname(filePath);
120
+ await fsp.mkdir(dirPath, { recursive: true });
121
+ await fsp.writeFile(filePath, draftContent);
122
+ return "add";
123
+ }
124
+ case "EDIT_MARKER": {
125
+ // This is an iteration on an existing draft edit - just update content
126
+ const dirPath = path.dirname(filePath);
127
+ await fsp.mkdir(dirPath, { recursive: true });
128
+ await fsp.writeFile(filePath, draftContent);
129
+ return "edit";
130
+ }
131
+ case "DELETE_MARKER": {
132
+ // File was previously deleted as a draft, but now we're writing to it again
133
+ // Convert the DELETE marker to an EDIT marker to preserve the original backup
134
+ const deleteMarkerPath = getMarkerFilePath(filePath, appRootDirPath, DELETE_EXT);
135
+ const editMarkerPath = getMarkerFilePath(filePath, appRootDirPath, EDIT_EXT);
136
+ try {
137
+ // Move the backup from .sbswap.delete to .sbswap.edit
138
+ await fsp.rename(deleteMarkerPath, editMarkerPath);
139
+ }
140
+ catch {
141
+ // If marker doesn't exist or rename fails, continue anyway
142
+ // The file will be treated as a new draft edit without a backup
143
+ }
144
+ // Write new content to the file
145
+ const dirPath = path.dirname(filePath);
146
+ await fsp.mkdir(dirPath, { recursive: true });
147
+ await fsp.writeFile(filePath, draftContent);
148
+ return "edit";
149
+ }
150
+ case "FILE_EXISTS": {
151
+ // This is a new draft edit
152
+ await createDraftEdit(filePath, draftContent, appRootDirPath);
153
+ return "edit";
154
+ }
155
+ case "NEW": {
156
+ // This is a new draft addition
157
+ await createDraftAdd(filePath, draftContent, appRootDirPath);
158
+ return "add";
159
+ }
160
+ }
161
+ }
162
+ /**
163
+ * Creates a draft edit by copying the original file to the drafts directory and writing draft content to the original location.
164
+ * This makes the draft content visible to the build system while preserving the original.
165
+ * If a draft edit already exists, this will just update the draft content.
166
+ *
167
+ * @param filePath - The absolute path to the file to edit.
168
+ * @param draftContent - The content of the draft edit.
169
+ * @param appRootDirPath - The absolute path to the app root directory.
170
+ * @returns A promise that resolves when the draft edit is created.
171
+ */
172
+ export async function createDraftEdit(filePath, draftContent, appRootDirPath) {
173
+ await new DraftEdit(filePath, getMarkerFilePath(filePath, appRootDirPath, EDIT_EXT)).apply(draftContent);
174
+ }
175
+ class DraftEdit {
176
+ filePath;
177
+ markerPath;
178
+ constructor(filePath, markerPath) {
179
+ this.filePath = filePath;
180
+ this.markerPath = markerPath;
181
+ }
182
+ async apply(content) {
183
+ const file = await this.openOriginal();
184
+ try {
185
+ const status = await file.stat();
186
+ if (!status.isFile())
187
+ throw new Error(`Cannot create draft edit: original is not a regular file: ${this.filePath}`);
188
+ await this.preserveOriginal(await file.readFile(), status.mode);
189
+ await this.writeDraft(file, Buffer.isBuffer(content) ? content : Buffer.from(content));
190
+ }
191
+ finally {
192
+ await file.close();
193
+ }
194
+ }
195
+ async openOriginal() {
196
+ try {
197
+ return await fsp.open(this.filePath, fsp.constants.O_RDWR |
198
+ fsp.constants.O_NOFOLLOW |
199
+ fsp.constants.O_NONBLOCK);
200
+ }
201
+ catch (error) {
202
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
203
+ throw new Error(`Cannot create draft edit: original file does not exist: ${this.filePath}`);
204
+ throw error;
205
+ }
206
+ }
207
+ async preserveOriginal(content, mode) {
208
+ const directory = path.dirname(this.markerPath);
209
+ await fsp.mkdir(directory, { recursive: true });
210
+ const temporary = await fsp.mkdtemp(path.join(directory, ".draft-"));
211
+ try {
212
+ const original = path.join(temporary, "original");
213
+ await fsp.writeFile(original, content, { flag: "wx" });
214
+ await fsp.chmod(original, mode);
215
+ try {
216
+ await fsp.link(original, this.markerPath);
217
+ }
218
+ catch (error) {
219
+ if (!(error instanceof Error &&
220
+ "code" in error &&
221
+ error.code === "EEXIST"))
222
+ throw error;
223
+ }
224
+ }
225
+ finally {
226
+ await fsp.rm(temporary, { recursive: true, force: true });
227
+ }
228
+ }
229
+ async writeDraft(file, content) {
230
+ let offset = 0;
231
+ while (offset < content.length) {
232
+ const { bytesWritten } = await file.writev([content.subarray(offset)], offset);
233
+ if (!bytesWritten)
234
+ throw new Error("Cannot create draft edit: write made no progress");
235
+ offset += bytesWritten;
236
+ }
237
+ await file.truncate(content.length);
238
+ }
239
+ }
240
+ /**
241
+ * Creates a draft addition by writing new file content and creating a .sbswap.add marker.
242
+ * This makes the new file visible to the build system while marking it as a draft addition.
243
+ * If a draft addition already exists, this will just update the content.
244
+ *
245
+ * @param filePath - The absolute path where the new file should be created.
246
+ * @param content - The content of the new file.
247
+ * @param appRootDirPath - The absolute path to the app root directory.
248
+ * @returns A promise that resolves when the draft addition is created.
249
+ */
250
+ export async function createDraftAdd(filePath, content, appRootDirPath) {
251
+ const addMarkerPath = getMarkerFilePath(filePath, appRootDirPath, ADD_EXT);
252
+ // Check if this is an iteration on an existing draft addition
253
+ try {
254
+ await fsp.access(addMarkerPath);
255
+ // Marker exists, this is an iteration - just update content
256
+ const dirPath = path.dirname(filePath);
257
+ await fsp.mkdir(dirPath, { recursive: true });
258
+ await fsp.writeFile(filePath, content);
259
+ return;
260
+ }
261
+ catch {
262
+ // Marker doesn't exist, proceed with normal draft addition creation
263
+ }
264
+ // Check if file already exists (and it's not a draft)
265
+ try {
266
+ await fsp.access(filePath);
267
+ // If we get here, the file exists, which is not allowed for new draft additions
268
+ throw new Error(`Cannot create draft add: file already exists: ${filePath}`);
269
+ }
270
+ catch (accessError) {
271
+ // If access fails, the file doesn't exist, which is what we want
272
+ if (accessError instanceof Error &&
273
+ accessError.message.includes("Cannot create draft add")) {
274
+ throw accessError; // Re-throw our own error
275
+ }
276
+ // Otherwise, continue - the file doesn't exist, which is expected
277
+ }
278
+ // Ensure directory exists for the new file
279
+ const dirPath = path.dirname(filePath);
280
+ await fsp.mkdir(dirPath, { recursive: true });
281
+ // Ensure the drafts directory structure exists
282
+ const addMarkerDir = path.dirname(addMarkerPath);
283
+ await fsp.mkdir(addMarkerDir, { recursive: true });
284
+ // Write new file content
285
+ await fsp.writeFile(filePath, content);
286
+ // Create marker to indicate this is a draft addition
287
+ await fsp.writeFile(addMarkerPath, "");
288
+ }
289
+ /**
290
+ * Writes a marker for a file that already holds its draft content, leaving the
291
+ * file untouched. `original` is the pre-draft content of an edit or a delete.
292
+ */
293
+ export async function writeDraftMarker(relativePath, appRootDirPath, kind, original = Buffer.alloc(0)) {
294
+ const markerPath = getMarkerFilePath(path.join(appRootDirPath, relativePath), appRootDirPath, MARKER_EXT[kind]);
295
+ await fsp.mkdir(path.dirname(markerPath), { recursive: true });
296
+ const existing = await fsp.lstat(markerPath).catch((error) => {
297
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
298
+ return undefined;
299
+ throw error;
300
+ });
301
+ if (existing?.isFile() && (await draftMarkerMatches(markerPath, original)))
302
+ return;
303
+ await writeFileAtomic(markerPath, original, {
304
+ mode: 0o666,
305
+ preserveMode: existing?.isFile() ? existing.mode & 0o777 : undefined,
306
+ });
307
+ }
308
+ async function draftMarkerMatches(markerPath, original) {
309
+ const file = await fsp
310
+ .open(markerPath, fsp.constants.O_RDONLY |
311
+ fsp.constants.O_NOFOLLOW |
312
+ fsp.constants.O_NONBLOCK)
313
+ .catch((error) => {
314
+ if (error instanceof Error &&
315
+ "code" in error &&
316
+ (error.code === "ENOENT" ||
317
+ error.code === "ELOOP" ||
318
+ error.code === "EACCES"))
319
+ return undefined;
320
+ throw error;
321
+ });
322
+ if (!file)
323
+ return false;
324
+ try {
325
+ const status = await file.stat();
326
+ if (!status.isFile() || status.size !== original.length)
327
+ return false;
328
+ const content = Buffer.alloc(original.length + 1);
329
+ let offset = 0;
330
+ while (offset < content.length) {
331
+ const { bytesRead } = await file.read(content, offset, content.length - offset);
332
+ if (!bytesRead)
333
+ break;
334
+ offset += bytesRead;
335
+ }
336
+ return (offset === original.length && content.subarray(0, offset).equals(original));
337
+ }
338
+ finally {
339
+ await file.close();
340
+ }
341
+ }
342
+ export class DraftMarkers {
343
+ root;
344
+ constructor(root) {
345
+ this.root = root;
346
+ }
347
+ async prepare() {
348
+ await this.assertSafePath("");
349
+ await fsp.mkdir(getDraftsDirectoryPath(this.root), { recursive: true });
350
+ }
351
+ async originals(targets, before, maxBytes, maxFiles) {
352
+ const originals = new Map([...before].filter(([relative]) => this.withinTargets(relative, targets)));
353
+ const reader = await SafeDirectoryReader.open(this.root);
354
+ let bytes = 0;
355
+ try {
356
+ for (const markerPath of await this.markerFiles(targets, maxFiles)) {
357
+ const extension = Object.values(MARKER_EXT).find((extension) => markerPath.endsWith(extension));
358
+ if (!extension)
359
+ continue;
360
+ const relative = path
361
+ .relative(getDraftsDirectoryPath(this.root), markerPath)
362
+ .slice(0, -extension.length);
363
+ if (extension === ADD_EXT) {
364
+ originals.delete(relative);
365
+ }
366
+ else {
367
+ const original = await reader.readFile(path.relative(this.root, markerPath), maxBytes - bytes);
368
+ if (!original)
369
+ throw new Error("Draft marker disappeared during snapshot");
370
+ bytes += original.length;
371
+ originals.set(relative, original);
372
+ }
373
+ }
374
+ }
375
+ finally {
376
+ await reader.close();
377
+ }
378
+ return originals;
379
+ }
380
+ async assertSafePath(relative) {
381
+ let directory = this.root;
382
+ for (const segment of [
383
+ ".superblocks",
384
+ "drafts",
385
+ ...relative.split(path.sep).slice(0, -1),
386
+ ]) {
387
+ directory = path.join(directory, segment);
388
+ const stats = await fsp.lstat(directory).catch((error) => {
389
+ if (error.code === "ENOENT")
390
+ return undefined;
391
+ throw error;
392
+ });
393
+ if (stats?.isSymbolicLink())
394
+ throw new Error("Draft marker path cannot traverse symbolic links");
395
+ }
396
+ for (const extension of Object.values(MARKER_EXT)) {
397
+ const stats = await fsp
398
+ .lstat(getMarkerFilePath(path.join(this.root, relative), this.root, extension))
399
+ .catch((error) => {
400
+ if (error.code === "ENOENT")
401
+ return undefined;
402
+ throw error;
403
+ });
404
+ if (stats?.isSymbolicLink())
405
+ throw new Error("Draft marker path cannot traverse symbolic links");
406
+ }
407
+ }
408
+ async markerFiles(targets, maxFiles) {
409
+ const files = new Set();
410
+ const seen = new Set();
411
+ const visit = async (absolute) => {
412
+ if (seen.has(absolute))
413
+ return;
414
+ seen.add(absolute);
415
+ if (seen.size > maxFiles)
416
+ throw new Error("Native filesystem mutation exceeds the draft snapshot limit");
417
+ const stats = await fsp.lstat(absolute).catch((error) => {
418
+ if (error.code === "ENOENT")
419
+ return undefined;
420
+ throw error;
421
+ });
422
+ if (!stats)
423
+ return;
424
+ if (stats.isSymbolicLink())
425
+ throw new Error("Draft marker path cannot traverse symbolic links");
426
+ if (stats.isDirectory()) {
427
+ for (const entry of await fsp.readdir(absolute))
428
+ await visit(path.join(absolute, entry));
429
+ }
430
+ else if (stats.isFile()) {
431
+ files.add(absolute);
432
+ }
433
+ else {
434
+ throw new Error("Draft marker path must contain regular files");
435
+ }
436
+ };
437
+ for (const target of targets) {
438
+ await this.assertSafePath(target);
439
+ let parent = getDraftsDirectoryPath(this.root);
440
+ for (const segment of target.split(path.sep).slice(0, -1)) {
441
+ parent = path.join(parent, segment);
442
+ const stats = await fsp.lstat(parent).catch((error) => {
443
+ if (error.code === "ENOENT")
444
+ return undefined;
445
+ throw error;
446
+ });
447
+ if (stats?.isSymbolicLink())
448
+ throw new Error("Draft marker path cannot traverse symbolic links");
449
+ }
450
+ await visit(path.join(getDraftsDirectoryPath(this.root), target));
451
+ for (const extension of Object.values(MARKER_EXT)) {
452
+ await visit(getMarkerFilePath(path.join(this.root, target), this.root, extension));
453
+ }
454
+ }
455
+ return files;
456
+ }
457
+ async reconcile(relative, original, current) {
458
+ await this.assertSafePath(relative);
459
+ const unchanged = original === undefined
460
+ ? current === undefined
461
+ : current !== undefined && original.equals(current);
462
+ const kind = unchanged
463
+ ? undefined
464
+ : original === undefined
465
+ ? "add"
466
+ : current === undefined
467
+ ? "delete"
468
+ : "edit";
469
+ if (kind)
470
+ await writeDraftMarker(relative, this.root, kind, original);
471
+ for (const [candidate, extension] of Object.entries(MARKER_EXT)) {
472
+ if (candidate !== kind)
473
+ await retireMarker(relative, extension, this.root);
474
+ }
475
+ }
476
+ withinTargets(relative, targets) {
477
+ return targets.some((target) => relative === target || relative.startsWith(`${target}${path.sep}`));
478
+ }
479
+ }
480
+ /**
481
+ * Creates a draft deletion by backing up the file content and removing it from the workspace.
482
+ *
483
+ * @param filePath - The absolute path to the file to delete.
484
+ * @param appRootDirPath - The absolute path to the app root directory.
485
+ * @returns A promise that resolves when the draft deletion is created.
486
+ */
487
+ export async function createDraftDelete(filePath, appRootDirPath) {
488
+ const deleteMarkerPath = getMarkerFilePath(filePath, appRootDirPath, DELETE_EXT);
489
+ // Check if file exists
490
+ try {
491
+ await fsp.access(filePath);
492
+ }
493
+ catch {
494
+ throw new Error(`Cannot create draft delete: file does not exist: ${filePath}`);
495
+ }
496
+ // Check if this is an iteration on an existing draft deletion
497
+ try {
498
+ await fsp.access(deleteMarkerPath);
499
+ // Marker exists - file is already deleted, nothing to do
500
+ return;
501
+ }
502
+ catch {
503
+ // Marker doesn't exist, proceed with normal draft deletion
504
+ }
505
+ // Ensure the drafts directory structure exists
506
+ const deleteMarkerDir = path.dirname(deleteMarkerPath);
507
+ await fsp.mkdir(deleteMarkerDir, { recursive: true });
508
+ // Copy original file to .sbswap.delete marker in drafts directory
509
+ await fsp.copyFile(filePath, deleteMarkerPath);
510
+ // Delete the file from the workspace
511
+ await fsp.unlink(filePath);
512
+ }
513
+ /**
514
+ * Recursively checks if any draft files exist in the drafts directory.
515
+ * Looks for .sbswap.edit, .sbswap.add, and .sbswap.delete marker files.
516
+ *
517
+ * @param appRootDirPath - The absolute path to the app root directory.
518
+ * @returns A promise that resolves to true if any draft files are found, false otherwise.
519
+ */
520
+ export async function hasSwapFiles(appRootDirPath) {
521
+ const draftsDir = getDraftsDirectoryPath(appRootDirPath);
522
+ try {
523
+ await fsp.access(draftsDir);
524
+ }
525
+ catch {
526
+ // Drafts directory doesn't exist, so no draft files
527
+ return false;
528
+ }
529
+ return await hasSwapFilesInDirectory(draftsDir, appRootDirPath);
530
+ }
531
+ /**
532
+ * {@link hasSwapFiles} for decisions that clear the server's draft flag: only
533
+ * a missing drafts directory means no drafts. A directory or entry that
534
+ * cannot be read counts as a draft, so an unreadable tree never reads as
535
+ * reviewed.
536
+ */
537
+ export async function hasDraftsFailClosed(appRootDirPath) {
538
+ const draftsDir = getDraftsDirectoryPath(appRootDirPath);
539
+ try {
540
+ await fsp.lstat(draftsDir);
541
+ }
542
+ catch (error) {
543
+ return !isMissing(error);
544
+ }
545
+ return await hasSwapFilesInDirectory(draftsDir, appRootDirPath, true);
546
+ }
547
+ /**
548
+ * Throws unless the drafts directory is gone. Accept, reject and clear all
549
+ * end by removing it, and their helpers swallow removal and read errors, so
550
+ * this is the check that they finished.
551
+ */
552
+ export async function assertDraftsSettled(appRootDirPath, action) {
553
+ try {
554
+ await fsp.lstat(getDraftsDirectoryPath(appRootDirPath));
555
+ }
556
+ catch (error) {
557
+ if (isMissing(error))
558
+ return;
559
+ }
560
+ throw new Error(`Draft ${action} left draft markers in place`);
561
+ }
562
+ function isMissing(error) {
563
+ return error?.code === "ENOENT";
564
+ }
565
+ /**
566
+ * Helper function to recursively check for swap files with actual changes in a directory.
567
+ * Only returns true for files that have meaningful content differences, not just marker files.
568
+ *
569
+ * @param directoryPath - The path to the directory to scan for draft files.
570
+ * @param appRootDirPath - The absolute path to the app root directory.
571
+ * @returns A promise that resolves to true if any draft files with changes are found, false otherwise.
572
+ */
573
+ async function hasSwapFilesInDirectory(directoryPath, appRootDirPath, failClosed = false) {
574
+ try {
575
+ const entries = await fsp.readdir(directoryPath, { withFileTypes: true });
576
+ const draftsDir = getDraftsDirectoryPath(appRootDirPath);
577
+ for (const entry of entries) {
578
+ if (entry.isFile()) {
579
+ const swapFilePath = path.join(directoryPath, entry.name);
580
+ if (entry.name.endsWith(EDIT_EXT)) {
581
+ const markerRelativePath = path.relative(draftsDir, swapFilePath);
582
+ const originalRelativePath = markerRelativePath.slice(0, -EDIT_EXT.length);
583
+ const originalPath = path.join(appRootDirPath, originalRelativePath);
584
+ try {
585
+ // Should work ok for files up to ~100 MB
586
+ const [draftContent, originalContent] = await Promise.all([
587
+ fsp.readFile(swapFilePath, "utf8"),
588
+ fsp.readFile(originalPath, "utf8"),
589
+ ]);
590
+ if (draftContent !== originalContent) {
591
+ return true;
592
+ }
593
+ }
594
+ catch {
595
+ // If we can't read files, assume it's a change to be safe
596
+ return true;
597
+ }
598
+ }
599
+ else if (entry.name.endsWith(ADD_EXT)) {
600
+ return true;
601
+ }
602
+ else if (entry.name.endsWith(DELETE_EXT)) {
603
+ return true;
604
+ }
605
+ }
606
+ if (entry.isDirectory()) {
607
+ const subdirPath = path.join(directoryPath, entry.name);
608
+ if (await hasSwapFilesInDirectory(subdirPath, appRootDirPath, failClosed)) {
609
+ return true;
610
+ }
611
+ }
612
+ }
613
+ return false;
614
+ }
615
+ catch (error) {
616
+ // If directory doesn't exist or can't be read, no draft files (fail
617
+ // closed: only a missing directory)
618
+ return failClosed && !isMissing(error);
619
+ }
620
+ }
621
+ /**
622
+ * Discards all draft changes by removing draft additions, then restoring the
623
+ * originals of edits and deletions. Each marker is retired once its step
624
+ * succeeds, so a retry after a failed restore resumes where it stopped.
625
+ *
626
+ * @param appRootDirPath - The absolute path to the app root directory.
627
+ * @returns A promise that resolves when all drafts have been discarded.
628
+ */
629
+ export async function discardAllDrafts(appRootDirPath) {
630
+ const { edits, additions, deletions } = await getDraftInfo(appRootDirPath);
631
+ // Additions go first: a draft can replace an original file with a directory, or the reverse.
632
+ for (const relativePath of additions) {
633
+ await fsp.rm(path.join(appRootDirPath, relativePath), { force: true });
634
+ await retireMarker(relativePath, ADD_EXT, appRootDirPath);
635
+ }
636
+ for (const relativePath of edits) {
637
+ await restoreOriginal(relativePath, EDIT_EXT, appRootDirPath);
638
+ await retireMarker(relativePath, EDIT_EXT, appRootDirPath);
639
+ }
640
+ for (const relativePath of deletions) {
641
+ await restoreOriginal(relativePath, DELETE_EXT, appRootDirPath);
642
+ await retireMarker(relativePath, DELETE_EXT, appRootDirPath);
643
+ }
644
+ await cleanUpDraftsDirectory(appRootDirPath);
645
+ }
646
+ async function retireMarker(relativePath, markerType, appRootDirPath) {
647
+ await fsp.rm(getMarkerFilePath(path.join(appRootDirPath, relativePath), appRootDirPath, markerType), { force: true });
648
+ }
649
+ async function restoreOriginal(relativePath, markerType, appRootDirPath) {
650
+ const filePath = path.join(appRootDirPath, relativePath);
651
+ const dirPath = path.dirname(filePath);
652
+ await fsp.mkdir(dirPath, { recursive: true });
653
+ const realRelativeDirPath = path.relative(await fsp.realpath(appRootDirPath), await fsp.realpath(dirPath));
654
+ if (realRelativeDirPath === ".." ||
655
+ realRelativeDirPath.startsWith(`..${path.sep}`) ||
656
+ path.isAbsolute(realRelativeDirPath)) {
657
+ throw new Error(`Cannot restore a file outside the app root: ${relativePath}`);
658
+ }
659
+ // Clear the path first, so the copy does not follow a symlink or hit an emptied directory.
660
+ const stats = await fsp.lstat(filePath).catch(() => undefined);
661
+ if (stats?.isDirectory()) {
662
+ await removeEmptyDirectories(filePath);
663
+ }
664
+ else if (stats) {
665
+ await fsp.unlink(filePath);
666
+ }
667
+ await fsp.copyFile(getMarkerFilePath(filePath, appRootDirPath, markerType), filePath);
668
+ }
669
+ async function removeEmptyDirectories(dirPath) {
670
+ for (const entry of await fsp.readdir(dirPath, { withFileTypes: true })) {
671
+ if (!entry.isDirectory()) {
672
+ throw new Error(`Cannot restore a file over a directory: ${dirPath}`);
673
+ }
674
+ await removeEmptyDirectories(path.join(dirPath, entry.name));
675
+ }
676
+ await fsp.rmdir(dirPath);
677
+ }
678
+ export async function cleanUpDraftsDirectory(appRootDirPath) {
679
+ const draftsDir = getDraftsDirectoryPath(appRootDirPath);
680
+ try {
681
+ await fsp.rm(draftsDir, { recursive: true });
682
+ }
683
+ catch {
684
+ // Ignore cleanup errors
685
+ }
686
+ }
687
+ /**
688
+ * Commits all draft changes by removing .sbswap.edit and .sbswap.add markers.
689
+ * This leaves the current filesystem state as the committed state.
690
+ *
691
+ * @param appRootDirPath - The absolute path to the app root directory.
692
+ * @returns A promise that resolves when all drafts have been committed.
693
+ */
694
+ export async function commitAllDrafts(appRootDirPath) {
695
+ const draftsDir = getDraftsDirectoryPath(appRootDirPath);
696
+ try {
697
+ await fsp.access(draftsDir);
698
+ }
699
+ catch {
700
+ // Drafts directory doesn't exist, nothing to commit
701
+ return;
702
+ }
703
+ // Simply remove the entire drafts directory
704
+ try {
705
+ await fsp.rm(draftsDir, { recursive: true });
706
+ }
707
+ catch {
708
+ // Ignore deletion errors
709
+ }
710
+ }
711
+ /**
712
+ * Gets information about all draft files in the drafts directory.
713
+ *
714
+ * @param appRootDirPath - The absolute path to the app root directory.
715
+ * @returns A promise that resolves to information about draft files.
716
+ */
717
+ export async function getDraftInfo(appRootDirPath) {
718
+ const edits = [];
719
+ const additions = [];
720
+ const deletions = [];
721
+ const draftsDir = getDraftsDirectoryPath(appRootDirPath);
722
+ try {
723
+ await fsp.access(draftsDir);
724
+ }
725
+ catch {
726
+ // Drafts directory doesn't exist, no drafts
727
+ return { edits, additions, deletions };
728
+ }
729
+ async function scanDirectory(currentPath) {
730
+ try {
731
+ const entries = await fsp.readdir(currentPath, { withFileTypes: true });
732
+ for (const entry of entries) {
733
+ const fullPath = path.join(currentPath, entry.name);
734
+ if (entry.isFile()) {
735
+ if (entry.name.endsWith(EDIT_EXT)) {
736
+ const markerRelativePath = path.relative(draftsDir, fullPath);
737
+ const originalRelativePath = markerRelativePath.slice(0, -EDIT_EXT.length);
738
+ edits.push(originalRelativePath);
739
+ }
740
+ else if (entry.name.endsWith(ADD_EXT)) {
741
+ const markerRelativePath = path.relative(draftsDir, fullPath);
742
+ const addedFileRelativePath = markerRelativePath.slice(0, -ADD_EXT.length);
743
+ additions.push(addedFileRelativePath);
744
+ }
745
+ else if (entry.name.endsWith(DELETE_EXT)) {
746
+ const markerRelativePath = path.relative(draftsDir, fullPath);
747
+ const deletedFileRelativePath = markerRelativePath.slice(0, -DELETE_EXT.length);
748
+ deletions.push(deletedFileRelativePath);
749
+ }
750
+ }
751
+ if (entry.isDirectory()) {
752
+ await scanDirectory(fullPath);
753
+ }
754
+ }
755
+ }
756
+ catch {
757
+ // Ignore errors reading directories
758
+ }
759
+ }
760
+ await scanDirectory(draftsDir);
761
+ return { edits, additions, deletions };
762
+ }
763
+ /**
764
+ * Generates a git-like diff showing all draft changes.
765
+ *
766
+ * @param appRootDirPath - The absolute path to the app root directory.
767
+ * @param specificPaths - An optional array of relative paths to limit the diff to.
768
+ * @returns A promise that resolves to a git-like diff string.
769
+ */
770
+ export async function generateDraftDiff(appRootDirPath, specificPaths = []) {
771
+ const draftsDir = getDraftsDirectoryPath(appRootDirPath);
772
+ try {
773
+ await fsp.access(draftsDir);
774
+ }
775
+ catch {
776
+ return "No draft changes found.";
777
+ }
778
+ const diffLines = [];
779
+ let { edits, additions, deletions } = await getDraftInfo(appRootDirPath);
780
+ edits = edits.filter((edit) => specificPaths.length === 0 ||
781
+ specificPaths.some((path) => edit.startsWith(path)));
782
+ additions = additions.filter((add) => specificPaths.length === 0 ||
783
+ specificPaths.some((path) => add.startsWith(path)));
784
+ deletions = deletions.filter((del) => specificPaths.length === 0 ||
785
+ specificPaths.some((path) => del.startsWith(path)));
786
+ if (edits.length === 0 && additions.length === 0 && deletions.length === 0) {
787
+ return "No draft changes found.";
788
+ }
789
+ // Process draft edits
790
+ for (const editPath of edits) {
791
+ const originalPath = path.join(appRootDirPath, editPath);
792
+ const markerPath = getMarkerFilePath(originalPath, appRootDirPath, EDIT_EXT);
793
+ try {
794
+ const currentContent = await fsp.readFile(originalPath, "utf8");
795
+ const originalContent = await fsp.readFile(markerPath, "utf8");
796
+ const patch = createPatch(editPath, originalContent, currentContent);
797
+ diffLines.push(patch);
798
+ diffLines.push(""); // Empty line between files
799
+ }
800
+ catch {
801
+ // Skip files that can't be read
802
+ continue;
803
+ }
804
+ }
805
+ // Process draft additions
806
+ for (const addPath of additions) {
807
+ const filePath = path.join(appRootDirPath, addPath);
808
+ try {
809
+ const content = await fsp.readFile(filePath, "utf8");
810
+ const patch = createPatch(addPath, "", content);
811
+ diffLines.push(patch);
812
+ diffLines.push(""); // Empty line between files
813
+ }
814
+ catch {
815
+ // Skip files that can't be read
816
+ continue;
817
+ }
818
+ }
819
+ // Process draft deletions
820
+ // note: this is pretty verbose showing each line as deleted but it matches how we do diffs for added files
821
+ for (const deletePath of deletions) {
822
+ const markerPath = getMarkerFilePath(path.join(appRootDirPath, deletePath), appRootDirPath, DELETE_EXT);
823
+ try {
824
+ const originalContent = await fsp.readFile(markerPath, "utf8");
825
+ const patch = createPatch(deletePath, originalContent, "");
826
+ diffLines.push(patch);
827
+ diffLines.push(""); // Empty line between files
828
+ }
829
+ catch {
830
+ // Skip files that can't be read
831
+ continue;
832
+ }
833
+ }
834
+ // Remove trailing empty line
835
+ if (diffLines[diffLines.length - 1] === "") {
836
+ diffLines.pop();
837
+ }
838
+ return diffLines.join("\n");
839
+ }
840
+ //# sourceMappingURL=draft-helpers.js.map