@hyperframes/studio-server 0.8.77 → 0.8.79

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.
package/dist/index.js CHANGED
@@ -55,9 +55,25 @@ import { readFile } from "fs/promises";
55
55
  import { join as join3 } from "path";
56
56
 
57
57
  // src/helpers/safePath.ts
58
- import { join } from "path";
59
- import { readdirSync } from "fs";
60
- import { isSafePath, resolveWithinProject } from "@hyperframes/core";
58
+ import { basename, dirname, isAbsolute, join, relative, sep } from "path";
59
+ import { readdirSync, realpathSync } from "fs";
60
+ import { resolveWithinProject } from "@hyperframes/core";
61
+ import { isSafePath, resolveWithinProject as resolveWithinProject2 } from "@hyperframes/core";
62
+ function realFilePath(filePath) {
63
+ try {
64
+ return realpathSync(filePath);
65
+ } catch {
66
+ const dir = dirname(filePath);
67
+ return dir === filePath ? filePath : join(realFilePath(dir), basename(filePath));
68
+ }
69
+ }
70
+ function pinWithinProject(base, relativePath) {
71
+ const checked = resolveWithinProject(base, relativePath);
72
+ if (!checked) return null;
73
+ const inside = relative(realFilePath(base), realFilePath(checked));
74
+ const escapes = inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
75
+ return escapes ? null : join(base, inside);
76
+ }
61
77
  var IGNORE_DIRS = /* @__PURE__ */ new Set([
62
78
  ".thumbnails",
63
79
  ".transcode-cache",
@@ -72,9 +88,18 @@ function isInHiddenOrVendorDir(relPath) {
72
88
  const segments = relPath.split("/");
73
89
  return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
74
90
  }
91
+ var UNREADABLE_DIR_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "ENOENT", "ENOTDIR"]);
75
92
  function walkDir(dir, prefix = "") {
93
+ let entries;
94
+ try {
95
+ entries = readdirSync(dir, { withFileTypes: true });
96
+ } catch (err) {
97
+ const code = err.code;
98
+ if (!prefix || !code || !UNREADABLE_DIR_CODES.has(code)) throw err;
99
+ return [];
100
+ }
76
101
  const files = [];
77
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
102
+ for (const entry of entries) {
78
103
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
79
104
  if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
80
105
  if (entry.isDirectory()) {
@@ -98,7 +123,7 @@ import {
98
123
  readFileSync,
99
124
  writeFileSync
100
125
  } from "fs";
101
- import { dirname, join as join2 } from "path";
126
+ import { dirname as dirname2, join as join2 } from "path";
102
127
 
103
128
  // src/helpers/atomicFile.ts
104
129
  import * as fs from "fs";
@@ -148,7 +173,7 @@ function stampProjectHfIds(projectDir) {
148
173
  next[file] = hash;
149
174
  }
150
175
  try {
151
- mkdirSync(dirname(recordPath), { recursive: true });
176
+ mkdirSync(dirname2(recordPath), { recursive: true });
152
177
  writeFileSync(recordPath, JSON.stringify(next));
153
178
  } catch {
154
179
  }
@@ -161,6 +186,15 @@ function openNoFollow(filePath, flags) {
161
186
  return null;
162
187
  }
163
188
  }
189
+ function isUnchanged(filePath, expected) {
190
+ const fd = openNoFollow(filePath, constants.O_RDONLY);
191
+ if (fd === null) return false;
192
+ try {
193
+ return readFileSync(fd, "utf-8") === expected;
194
+ } finally {
195
+ closeSync(fd);
196
+ }
197
+ }
164
198
  function stampFileHfIds(filePath) {
165
199
  let fd = openNoFollow(filePath, constants.O_RDWR);
166
200
  let writable = true;
@@ -179,7 +213,7 @@ function stampFileHfIds(filePath) {
179
213
  const mode = fstatSync(fd).mode;
180
214
  closeSync(fd);
181
215
  fd = null;
182
- replaceFileAtomically(filePath, normalized, mode);
216
+ if (isUnchanged(filePath, html)) replaceFileAtomically(filePath, normalized, mode);
183
217
  }
184
218
  return normalized;
185
219
  } catch (err) {
@@ -193,7 +227,7 @@ function stampFileHfIds(filePath) {
193
227
  // src/helpers/projectSignature.ts
194
228
  import { createHash as createHash2 } from "crypto";
195
229
  import { lstatSync, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
196
- import { extname, isAbsolute, relative, resolve, sep } from "path";
230
+ import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve, sep as sep2 } from "path";
197
231
  var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
198
232
  ".cjs",
199
233
  ".css",
@@ -228,18 +262,18 @@ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
228
262
  ".hyperframes/studio-motion.json"
229
263
  ];
230
264
  function affectsProjectSignature(projectDir, changedPath) {
231
- const relativePath = relative(resolve(projectDir), resolve(changedPath));
232
- if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
265
+ const relativePath = relative2(resolve(projectDir), resolve(changedPath));
266
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
233
267
  return false;
234
268
  }
235
- const segments = relativePath.split(sep);
269
+ const segments = relativePath.split(sep2);
236
270
  if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
237
271
  return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
238
272
  }
239
273
  var projectSignatureCache = /* @__PURE__ */ new Map();
240
274
  function isPathWithin(parentDir, childPath) {
241
- const childRelativePath = relative(parentDir, childPath);
242
- return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
275
+ const childRelativePath = relative2(parentDir, childPath);
276
+ return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute2(childRelativePath);
243
277
  }
244
278
  function isTextContentEligible(file, size) {
245
279
  return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
@@ -300,7 +334,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
300
334
  function createProjectFingerprint(projectDir, files) {
301
335
  const hash = createHash2("sha256");
302
336
  for (const entry of files) {
303
- hash.update(relative(projectDir, entry.file));
337
+ hash.update(relative2(projectDir, entry.file));
304
338
  hash.update("\0");
305
339
  hash.update(String(entry.size));
306
340
  hash.update("\0");
@@ -322,7 +356,7 @@ function collectProjectFiles(normalizedProjectDir) {
322
356
  function listProjectFiles(projectDir) {
323
357
  const normalizedProjectDir = resolve(projectDir);
324
358
  return collectProjectFiles(normalizedProjectDir).map((entry) => ({
325
- path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
359
+ path: relative2(normalizedProjectDir, entry.file).split(sep2).join("/"),
326
360
  size: entry.size,
327
361
  mtimeMs: entry.mtimeMs,
328
362
  ctimeMs: entry.ctimeMs
@@ -340,7 +374,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
340
374
  const normalizedProjectDir = resolve(projectDir);
341
375
  const collected = collectProjectFiles(normalizedProjectDir);
342
376
  const files = collected.filter(
343
- (entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
377
+ (entry) => !excluding.has(relative2(normalizedProjectDir, entry.file).split(sep2).join("/"))
344
378
  );
345
379
  files.sort((a, b) => a.file.localeCompare(b.file));
346
380
  const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
@@ -349,7 +383,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
349
383
  if (cached?.fingerprint === fingerprint) return cached.signature;
350
384
  const hash = createHash2("sha256");
351
385
  for (const entry of files) {
352
- const relativePath = relative(normalizedProjectDir, entry.file);
386
+ const relativePath = relative2(normalizedProjectDir, entry.file);
353
387
  hash.update(relativePath);
354
388
  hash.update("\0");
355
389
  hash.update(String(entry.size));
@@ -432,7 +466,7 @@ import {
432
466
  renameSync as renameSync2,
433
467
  readdirSync as readdirSync4
434
468
  } from "fs";
435
- import { resolve as resolve3, dirname as dirname4, join as join7 } from "path";
469
+ import { resolve as resolve3, dirname as dirname5, join as join7 } from "path";
436
470
 
437
471
  // src/helpers/mime.ts
438
472
  var MIME_TYPES = {
@@ -492,7 +526,7 @@ import {
492
526
  rmSync,
493
527
  statSync
494
528
  } from "fs";
495
- import { dirname as dirname2, join as join4 } from "path";
529
+ import { dirname as dirname3, join as join4 } from "path";
496
530
  import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
497
531
  var SAMPLE_RATE = 4e3;
498
532
  var PEAK_COUNT = 4e3;
@@ -572,7 +606,7 @@ function isWaveformCacheDirectory(cacheDir) {
572
606
  return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
573
607
  }
574
608
  function writeWaveformCache(cachePath, peaks) {
575
- const cacheDir = dirname2(cachePath);
609
+ const cacheDir = dirname3(cachePath);
576
610
  try {
577
611
  mkdirSync2(cacheDir, { mode: 448 });
578
612
  } catch (error) {
@@ -598,7 +632,7 @@ function writeWaveformCache(cachePath, peaks) {
598
632
  import { spawnSync } from "child_process";
599
633
  import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
600
634
  import { tmpdir } from "os";
601
- import { basename, join as join5 } from "path";
635
+ import { basename as basename2, join as join5 } from "path";
602
636
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
603
637
  var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
604
638
  function validateUploadedMedia(filePath, runner = spawnSync) {
@@ -636,7 +670,7 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
636
670
  }
637
671
  function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
638
672
  const tempDir = mkdtempSync2(join5(tmpdir(), "hyperframes-upload-"));
639
- const tempPath = join5(tempDir, basename(fileName));
673
+ const tempPath = join5(tempDir, basename2(fileName));
640
674
  try {
641
675
  writeFileSync3(tempPath, buffer);
642
676
  return validateUploadedMedia(tempPath, runner);
@@ -648,7 +682,7 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
648
682
  // src/helpers/backupJournal.ts
649
683
  import { mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
650
684
  import { Buffer as Buffer2 } from "buffer";
651
- import { join as join6, relative as relative2 } from "path";
685
+ import { join as join6, relative as relative3 } from "path";
652
686
  var DEFAULT_KEEP_PER_FILE = 10;
653
687
  function backupKeyForPath(path) {
654
688
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -658,7 +692,7 @@ function timestampPrefix() {
658
692
  }
659
693
  function backupPathForResponse(projectDir, backupPath) {
660
694
  if (!backupPath) return null;
661
- const rel = relative2(projectDir, backupPath);
695
+ const rel = relative3(projectDir, backupPath);
662
696
  if (!rel || rel.startsWith("..")) return null;
663
697
  return rel.split("\\").join("/");
664
698
  }
@@ -666,7 +700,7 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
666
700
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
667
701
  try {
668
702
  const content = readFileSync3(absPath);
669
- const relativePath = relative2(projectDir, absPath);
703
+ const relativePath = relative3(projectDir, absPath);
670
704
  const backupDir = join6(projectDir, ".hyperframes", "backup");
671
705
  mkdirSync3(backupDir, { recursive: true });
672
706
  const backupKey = backupKeyForPath(relativePath);
@@ -735,22 +769,38 @@ function createWriteToken(requestToken) {
735
769
  const token = requestToken?.trim();
736
770
  return token && token.length <= 200 ? token : randomUUID2();
737
771
  }
738
- function recordFileWriteReceipt(absPath, receipt) {
772
+ function recordFileWriteReceipt(filePath, receipt) {
773
+ const absPath = realFilePath(filePath);
739
774
  const now = Date.now();
740
- const current = (receipts.get(absPath) ?? []).filter(
741
- (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
742
- );
743
- current.push({ ...receipt, recordedAt: now });
744
- receipts.set(absPath, current);
775
+ for (const [path, list] of receipts) {
776
+ const live = list.filter((entry) => now - entry.recordedAt < RECEIPT_TTL_MS);
777
+ if (live.length > 0) receipts.set(path, live);
778
+ else receipts.delete(path);
779
+ }
780
+ receipts.set(absPath, [...receipts.get(absPath) ?? [], { ...receipt, recordedAt: now }]);
745
781
  }
746
- function clearFileWriteReceipt(absPath, version, writeToken) {
782
+ function clearFileWriteReceipt(filePath, version, writeToken) {
783
+ const absPath = realFilePath(filePath);
747
784
  const current = (receipts.get(absPath) ?? []).filter(
748
785
  (entry) => entry.version !== version || entry.writeToken !== writeToken
749
786
  );
750
787
  if (current.length > 0) receipts.set(absPath, current);
751
788
  else receipts.delete(absPath);
752
789
  }
753
- function identifyFileWrite(absPath, expectedVersion) {
790
+ function identifyFileWrite(filePath, expectedVersion) {
791
+ const receipt = newestReceipt(realFilePath(filePath), expectedVersion);
792
+ if (!receipt) return null;
793
+ const { path, version, writeToken } = receipt;
794
+ return { path, version, writeToken };
795
+ }
796
+ function bytesOverwrittenBy(filePath, version) {
797
+ return newestReceipt(realFilePath(filePath), version)?.overwrote;
798
+ }
799
+ function forgetOverwrittenBytes(filePath, versions) {
800
+ for (const receipt of receipts.get(realFilePath(filePath)) ?? [])
801
+ if (versions.has(receipt.version)) delete receipt.overwrote;
802
+ }
803
+ function newestReceipt(absPath, expectedVersion) {
754
804
  const now = Date.now();
755
805
  const current = (receipts.get(absPath) ?? []).filter(
756
806
  (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
@@ -761,31 +811,32 @@ function identifyFileWrite(absPath, expectedVersion) {
761
811
  for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
762
812
  if (current[i]?.version === expectedVersion) receipt = current[i];
763
813
  }
764
- if (!receipt) return null;
765
- const { path, version, writeToken } = receipt;
766
- return { path, version, writeToken };
814
+ return receipt;
767
815
  }
768
816
  var consumeFileWriteReceipt = identifyFileWrite;
769
817
 
770
818
  // src/helpers/applyFileMutations.ts
771
819
  import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
772
- function applyFileMutations(projectDir, mutations, requestToken, writeFile = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
820
+ function applyFileMutations(projectDir, mutations, requestToken, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
773
821
  const prepared = mutations.map((mutation) => ({
774
822
  ...mutation,
775
823
  before: mutation.before ?? readFileSync4(mutation.absPath, "utf-8")
776
824
  }));
777
825
  const results = [];
778
826
  const attempted = [];
827
+ for (const mutation of prepared)
828
+ assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
779
829
  try {
780
830
  for (const mutation of prepared) {
781
- results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted));
831
+ results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted));
782
832
  }
783
833
  return results;
784
834
  } catch (error) {
785
835
  const rollbackErrors = [];
786
836
  for (const mutation of attempted.reverse()) {
787
837
  try {
788
- writeFile(mutation.absPath, mutation.before, "utf-8");
838
+ if (!mutation.written || readFileSync4(mutation.absPath, "utf-8") === mutation.after)
839
+ writeFile2(mutation.absPath, mutation.before, "utf-8");
789
840
  clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
790
841
  } catch (rollbackError) {
791
842
  rollbackErrors.push(rollbackError);
@@ -800,9 +851,9 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
800
851
  throw error;
801
852
  }
802
853
  }
803
- function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted) {
854
+ function applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted) {
804
855
  const current = readFileSync4(mutation.absPath, "utf-8");
805
- assertExpectedVersion(mutation.expectedVersion, current);
856
+ assertExpectedVersion(mutation, current);
806
857
  if (mutation.after === mutation.before) {
807
858
  return {
808
859
  ...mutation,
@@ -815,15 +866,19 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
815
866
  }
816
867
  const backup = snapshotBeforeWrite(projectDir, mutation.absPath);
817
868
  if (backup.error) throw new Error(`backup failed: ${backup.error}`);
869
+ assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
818
870
  const before = current;
819
871
  const version = fileContentVersion(mutation.after);
820
872
  const writeToken = createWriteToken(requestToken);
821
- attempted.push({ ...mutation, before, version, writeToken });
822
- writeFile(mutation.absPath, mutation.after, "utf-8");
873
+ const attempt = { ...mutation, before, version, writeToken, written: false };
874
+ attempted.push(attempt);
875
+ writeFile2(mutation.absPath, mutation.after, "utf-8");
876
+ attempt.written = true;
823
877
  recordFileWriteReceipt(mutation.absPath, {
824
878
  path: mutation.sourceFile,
825
879
  version,
826
- writeToken
880
+ writeToken,
881
+ overwrote: before
827
882
  });
828
883
  return {
829
884
  ...mutation,
@@ -834,10 +889,16 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
834
889
  writeToken
835
890
  };
836
891
  }
837
- function assertExpectedVersion(expectedVersion, current) {
838
- if (expectedVersion !== void 0 && fileContentVersion(current) !== expectedVersion) {
839
- throw new Error("file changed since the timeline was read");
892
+ var FileChangedError = class extends Error {
893
+ constructor(sourceFile) {
894
+ super("file changed since the timeline was read");
895
+ this.sourceFile = sourceFile;
840
896
  }
897
+ sourceFile;
898
+ };
899
+ function assertExpectedVersion(mutation, current) {
900
+ const stale = mutation.expectedVersion === void 0 ? current !== mutation.before : fileContentVersion(current) !== mutation.expectedVersion;
901
+ if (stale) throw new FileChangedError(mutation.sourceFile);
841
902
  }
842
903
 
843
904
  // src/routes/files.ts
@@ -876,9 +937,9 @@ import { parseHTML as parseHTML2 } from "linkedom";
876
937
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
877
938
 
878
939
  // src/helpers/compositionInsertion.ts
879
- import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync } from "fs";
940
+ import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "fs";
880
941
  import { randomUUID as randomUUID3 } from "crypto";
881
- import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
942
+ import { dirname as dirname4, relative as relative4, resolve as resolve2, sep as sep3 } from "path";
882
943
  import { parseHTML } from "linkedom";
883
944
  var CompositionInsertionError = class extends Error {
884
945
  constructor(message, status) {
@@ -914,8 +975,8 @@ function canonicalProjectPath(projectDir, candidate) {
914
975
  if (!existsSync2(candidate)) {
915
976
  throw new CompositionInsertionError("Composition source was not found", 404);
916
977
  }
917
- const canonical = realpathSync(candidate);
918
- if (!isSafePath(realpathSync(projectDir), canonical)) {
978
+ const canonical = realpathSync2(candidate);
979
+ if (!isSafePath(realpathSync2(projectDir), canonical)) {
919
980
  throw new CompositionInsertionError("Composition source escapes the project", 400);
920
981
  }
921
982
  return canonical;
@@ -927,13 +988,13 @@ function validateSourcePath(sourcePath) {
927
988
  }
928
989
  function canonicalProjectFile(projectDir, sourcePath) {
929
990
  validateSourcePath(sourcePath);
930
- return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath));
991
+ return canonicalProjectPath(projectDir, resolveWithinProject2(projectDir, sourcePath));
931
992
  }
932
993
  function canonicalDependency(projectDir, ownerAbs, sourcePath) {
933
994
  validateSourcePath(sourcePath);
934
995
  return canonicalProjectPath(
935
996
  projectDir,
936
- resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname3(ownerAbs), sourcePath)))
997
+ resolveWithinProject2(projectDir, relative4(projectDir, resolve2(dirname4(ownerAbs), sourcePath)))
937
998
  );
938
999
  }
939
1000
  function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
@@ -1006,7 +1067,7 @@ function uniqueHostId(root, base) {
1006
1067
  return `${base}_${suffix}`;
1007
1068
  }
1008
1069
  function relativeSourcePath(targetAbs, sourceAbs) {
1009
- return relative3(dirname3(targetAbs), sourceAbs).split(sep2).join("/");
1070
+ return relative4(dirname4(targetAbs), sourceAbs).split(sep3).join("/");
1010
1071
  }
1011
1072
  function insertCompositionIntoSource(input) {
1012
1073
  const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
@@ -1138,7 +1199,7 @@ async function loadGsapParser() {
1138
1199
  return import("@hyperframes/parsers/gsap-parser-recast");
1139
1200
  }
1140
1201
  function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1141
- if (!isSafePath(projectDir, dirname4(lexicalPath))) return false;
1202
+ if (!isSafePath(projectDir, dirname5(lexicalPath))) return false;
1142
1203
  let stats;
1143
1204
  try {
1144
1205
  stats = lstatSync3(lexicalPath);
@@ -1146,7 +1207,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1146
1207
  return false;
1147
1208
  }
1148
1209
  if (!stats.isSymbolicLink()) return false;
1149
- const target = resolve3(dirname4(lexicalPath), readlinkSync(lexicalPath));
1210
+ const target = resolve3(dirname5(lexicalPath), readlinkSync(lexicalPath));
1150
1211
  if (!isSafePath(projectDir, target)) return false;
1151
1212
  try {
1152
1213
  statSync3(lexicalPath);
@@ -1170,7 +1231,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
1170
1231
  if (filePath.includes("\0")) {
1171
1232
  return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
1172
1233
  }
1173
- const absPath = resolveWithinProject(project.dir, filePath);
1234
+ const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject2)(project.dir, filePath);
1174
1235
  if (!absPath) {
1175
1236
  if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
1176
1237
  return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
@@ -1186,7 +1247,7 @@ function resolveProjectFile(c, adapter, opts) {
1186
1247
  return resolveProjectPath(c, adapter, "files", opts);
1187
1248
  }
1188
1249
  function resolveFileMutationContext(c, adapter, operation) {
1189
- return resolveProjectPath(c, adapter, `file-mutations/${operation}`);
1250
+ return resolveProjectPath(c, adapter, `file-mutations/${operation}`, { pin: true });
1190
1251
  }
1191
1252
  function isOptionalInteger(value) {
1192
1253
  return value === void 0 || Number.isInteger(value);
@@ -1235,11 +1296,23 @@ function foldElementPatches(originalContent, patches) {
1235
1296
  }
1236
1297
  return { content, matched };
1237
1298
  }
1238
- function commitElementPatchBatches(projectDir, batches, writeFile = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
1299
+ var PATCH_CONFLICT_ATTEMPTS = 3;
1300
+ function commitElementPatchBatches(projectDir, batches, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
1301
+ for (let attempt = 1; ; attempt += 1) {
1302
+ try {
1303
+ return foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken);
1304
+ } catch (error) {
1305
+ if (!(error instanceof FileChangedError)) throw error;
1306
+ if (attempt === PATCH_CONFLICT_ATTEMPTS)
1307
+ return { error: "conflict", sourceFile: error.sourceFile };
1308
+ }
1309
+ }
1310
+ }
1311
+ function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken) {
1239
1312
  const resolvedPaths = /* @__PURE__ */ new Set();
1240
1313
  const prepared = [];
1241
1314
  for (const batch of batches) {
1242
- const absPath = resolveWithinProject(projectDir, batch.sourceFile);
1315
+ const absPath = pinWithinProject(projectDir, batch.sourceFile);
1243
1316
  if (!absPath) return { error: "forbidden", sourceFile: batch.sourceFile };
1244
1317
  if (resolvedPaths.has(absPath)) return { error: "duplicate", sourceFile: batch.sourceFile };
1245
1318
  resolvedPaths.add(absPath);
@@ -1280,7 +1353,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile = (path, conte
1280
1353
  after
1281
1354
  })),
1282
1355
  requestToken,
1283
- writeFile
1356
+ writeFile2
1284
1357
  );
1285
1358
  const files = applied.map((file, index) => ({
1286
1359
  sourceFile: file.sourceFile,
@@ -1296,23 +1369,24 @@ function commitElementPatchBatchesWithReceipts(c, projectDir, batches) {
1296
1369
  return commitElementPatchBatches(
1297
1370
  projectDir,
1298
1371
  batches,
1299
- writeFileSync5,
1372
+ void 0,
1300
1373
  c.req.header("X-Hyperframes-Write-Token")
1301
1374
  );
1302
1375
  }
1303
- function recordMutationReceipt(c, filePath, absPath, html) {
1376
+ function writeFileWithReceipt(c, filePath, absPath, html) {
1377
+ const overwrote = readFileSync6(absPath);
1378
+ replaceFileAtomically(absPath, html, statSync3(absPath).mode);
1304
1379
  const version = fileContentVersion(html);
1305
1380
  const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
1306
- recordFileWriteReceipt(absPath, { path: filePath, version, writeToken });
1381
+ recordFileWriteReceipt(absPath, { path: filePath, version, writeToken, overwrote });
1307
1382
  return { version, writeToken };
1308
1383
  }
1309
- function writeFileWithReceipt(c, filePath, absPath, html) {
1310
- replaceFileAtomically(absPath, html, statSync3(absPath).mode);
1311
- return recordMutationReceipt(c, filePath, absPath, html);
1312
- }
1313
- function writeMutationResult(c, projectDir, filePath, absPath, html) {
1384
+ function writeMutationResult(c, projectDir, filePath, absPath, html, original) {
1314
1385
  const backup = snapshotBeforeWrite(projectDir, absPath);
1315
1386
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
1387
+ if (readFileSync6(absPath, "utf-8") !== original) {
1388
+ return c.json({ error: "file changed", conflict: true, path: filePath }, 409);
1389
+ }
1316
1390
  const { version } = writeFileWithReceipt(c, filePath, absPath, html);
1317
1391
  return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version };
1318
1392
  }
@@ -1320,7 +1394,7 @@ function writeIfChanged(c, projectDir, filePath, absPath, original, next) {
1320
1394
  if (next === original) {
1321
1395
  return c.json({ ok: true, changed: false, content: original, path: filePath });
1322
1396
  }
1323
- const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next);
1397
+ const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next, original);
1324
1398
  if (mutationResult instanceof Response) return mutationResult;
1325
1399
  const { backupPath } = mutationResult;
1326
1400
  return c.json({
@@ -1342,6 +1416,8 @@ function rejectUnsafeMutationValues(c, unsafeFields) {
1342
1416
  );
1343
1417
  }
1344
1418
  function elementPatchBatchCommitErrorResponse(c, error, sourceFile) {
1419
+ if (error === "conflict")
1420
+ return c.json({ error: "file changed", conflict: true, sourceFile }, 409);
1345
1421
  if (error === "not-found") return c.json({ error, sourceFile }, 404);
1346
1422
  if (error === "forbidden") return c.json({ error, sourceFile }, 403);
1347
1423
  return c.json({ error: "duplicate source file", sourceFile }, 400);
@@ -1354,7 +1430,7 @@ async function parseMutationBody(c) {
1354
1430
  return { target: body.target, body };
1355
1431
  }
1356
1432
  function ensureDir(filePath) {
1357
- const dir = dirname4(filePath);
1433
+ const dir = dirname5(filePath);
1358
1434
  if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
1359
1435
  }
1360
1436
  function generateCopyPath(projectDir, originalPath) {
@@ -1742,7 +1818,8 @@ async function applyGsapMutations(c, res, mutations) {
1742
1818
  res.project.dir,
1743
1819
  res.filePath,
1744
1820
  res.absPath,
1745
- newHtml
1821
+ newHtml,
1822
+ beforeHtml
1746
1823
  );
1747
1824
  if (mutationResult instanceof Response) return mutationResult;
1748
1825
  backupPath = mutationResult.backupPath;
@@ -2575,7 +2652,7 @@ function registerFileRoutes(api, adapter) {
2575
2652
  }
2576
2653
  });
2577
2654
  api.put("/projects/:id/files/*", async (c) => {
2578
- const res = await resolveProjectFile(c, adapter);
2655
+ const res = await resolveProjectFile(c, adapter, { pin: true });
2579
2656
  if ("error" in res) return res.error;
2580
2657
  const body = Buffer.from(await c.req.arrayBuffer());
2581
2658
  const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
@@ -2600,6 +2677,7 @@ function registerFileRoutes(api, adapter) {
2600
2677
  );
2601
2678
  }
2602
2679
  let backup = { backupPath: null };
2680
+ let overwrote;
2603
2681
  if (createOnly) {
2604
2682
  ensureDir(res.absPath);
2605
2683
  let fd;
@@ -2645,6 +2723,7 @@ function registerFileRoutes(api, adapter) {
2645
2723
  }
2646
2724
  try {
2647
2725
  const currentContent = readFileSync6(fd);
2726
+ overwrote = currentContent;
2648
2727
  const currentVersion = fileContentVersion(currentContent);
2649
2728
  if (expectedVersion !== currentVersion) {
2650
2729
  return c.json(
@@ -2669,7 +2748,7 @@ function registerFileRoutes(api, adapter) {
2669
2748
  }
2670
2749
  const version = fileContentVersion(body);
2671
2750
  const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
2672
- recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken });
2751
+ recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken, overwrote });
2673
2752
  c.header("ETag", version);
2674
2753
  return c.json({
2675
2754
  ok: true,
@@ -2748,6 +2827,11 @@ function registerFileRoutes(api, adapter) {
2748
2827
  }
2749
2828
  const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
2750
2829
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2830
+ const current = readFileSync6(ctx.absPath, "utf-8");
2831
+ if (current !== before) {
2832
+ const currentVersion2 = fileContentVersion(current);
2833
+ return c.json({ error: "file conflict", currentVersion: currentVersion2, currentContent: current }, 409);
2834
+ }
2751
2835
  const { version, writeToken } = writeFileWithReceipt(
2752
2836
  c,
2753
2837
  ctx.filePath,
@@ -2824,7 +2908,7 @@ function registerFileRoutes(api, adapter) {
2824
2908
  const seen = /* @__PURE__ */ new Set();
2825
2909
  const prepared = [];
2826
2910
  for (const file of files) {
2827
- const absPath = resolveWithinProject(project.dir, file.path);
2911
+ const absPath = pinWithinProject(project.dir, file.path);
2828
2912
  if (!absPath) return c.json({ error: `forbidden path: ${file.path}` }, 403);
2829
2913
  if (seen.has(absPath)) return c.json({ error: `duplicate path: ${file.path}` }, 400);
2830
2914
  seen.add(absPath);
@@ -2892,7 +2976,8 @@ function registerFileRoutes(api, adapter) {
2892
2976
  recordFileWriteReceipt(file.absPath, {
2893
2977
  path: file.path,
2894
2978
  version: fileContentVersion(file.after),
2895
- writeToken
2979
+ writeToken,
2980
+ overwrote: file.before
2896
2981
  });
2897
2982
  }
2898
2983
  } catch (error) {
@@ -2974,7 +3059,8 @@ function registerFileRoutes(api, adapter) {
2974
3059
  ctx.project.dir,
2975
3060
  ctx.filePath,
2976
3061
  ctx.absPath,
2977
- result.html
3062
+ result.html,
3063
+ originalContent
2978
3064
  );
2979
3065
  if (mutationResult instanceof Response) return mutationResult;
2980
3066
  const { version, backupPath } = mutationResult;
@@ -3001,48 +3087,54 @@ function registerFileRoutes(api, adapter) {
3001
3087
  if (unsafeFields.length > 0) {
3002
3088
  return rejectUnsafeMutationValues(c, unsafeFields);
3003
3089
  }
3004
- let originalContent;
3005
- try {
3006
- originalContent = readFileSync6(ctx.absPath, "utf-8");
3007
- } catch {
3008
- return c.json({ error: "not found" }, 404);
3009
- }
3010
- const { html: patched, matched } = patchElementInHtml(
3011
- originalContent,
3012
- parsed.target,
3013
- parsed.body.operations
3014
- );
3015
- if (patched === originalContent) {
3016
- const version2 = fileContentVersion(originalContent);
3017
- c.header("ETag", version2);
3090
+ for (let attempt = 1; ; attempt += 1) {
3091
+ let originalContent;
3092
+ try {
3093
+ originalContent = readFileSync6(ctx.absPath, "utf-8");
3094
+ } catch {
3095
+ return c.json({ error: "not found" }, 404);
3096
+ }
3097
+ const { html: patched, matched } = patchElementInHtml(
3098
+ originalContent,
3099
+ parsed.target,
3100
+ parsed.body.operations
3101
+ );
3102
+ if (patched === originalContent) {
3103
+ const version2 = fileContentVersion(originalContent);
3104
+ c.header("ETag", version2);
3105
+ return c.json({
3106
+ ok: true,
3107
+ changed: false,
3108
+ matched,
3109
+ content: originalContent,
3110
+ path: ctx.filePath,
3111
+ version: version2
3112
+ });
3113
+ }
3114
+ const mutationResult = writeMutationResult(
3115
+ c,
3116
+ ctx.project.dir,
3117
+ ctx.filePath,
3118
+ ctx.absPath,
3119
+ patched,
3120
+ originalContent
3121
+ );
3122
+ if (mutationResult instanceof Response) {
3123
+ if (mutationResult.status === 409 && attempt < PATCH_CONFLICT_ATTEMPTS) continue;
3124
+ return mutationResult;
3125
+ }
3126
+ const { backupPath, version } = mutationResult;
3127
+ c.header("ETag", version);
3018
3128
  return c.json({
3019
3129
  ok: true,
3020
- changed: false,
3130
+ changed: true,
3021
3131
  matched,
3022
- content: originalContent,
3132
+ content: patched,
3023
3133
  path: ctx.filePath,
3024
- version: version2
3134
+ version,
3135
+ backupPath
3025
3136
  });
3026
3137
  }
3027
- const mutationResult = writeMutationResult(
3028
- c,
3029
- ctx.project.dir,
3030
- ctx.filePath,
3031
- ctx.absPath,
3032
- patched
3033
- );
3034
- if (mutationResult instanceof Response) return mutationResult;
3035
- const { backupPath, version } = mutationResult;
3036
- c.header("ETag", version);
3037
- return c.json({
3038
- ok: true,
3039
- changed: true,
3040
- matched,
3041
- content: patched,
3042
- path: ctx.filePath,
3043
- version,
3044
- backupPath
3045
- });
3046
3138
  });
3047
3139
  api.post("/projects/:id/file-mutations/patch-element-batches", async (c) => {
3048
3140
  const project = await adapter.resolveProject(c.req.param("id"));
@@ -3132,7 +3224,8 @@ function registerFileRoutes(api, adapter) {
3132
3224
  ctx.project.dir,
3133
3225
  ctx.filePath,
3134
3226
  ctx.absPath,
3135
- result.html
3227
+ result.html,
3228
+ originalContent
3136
3229
  );
3137
3230
  if (mutationResult instanceof Response) return mutationResult;
3138
3231
  const { backupPath } = mutationResult;
@@ -3202,7 +3295,7 @@ function registerFileRoutes(api, adapter) {
3202
3295
  if (!body.newPath || body.newPath.includes("\0")) {
3203
3296
  return c.json({ error: "newPath required" }, 400);
3204
3297
  }
3205
- const newAbs = resolveWithinProject(res.project.dir, body.newPath);
3298
+ const newAbs = resolveWithinProject2(res.project.dir, body.newPath);
3206
3299
  if (!newAbs) {
3207
3300
  return c.json({ error: "forbidden" }, 403);
3208
3301
  }
@@ -3221,17 +3314,24 @@ function registerFileRoutes(api, adapter) {
3221
3314
  if (!body.path || body.path.includes("\0")) {
3222
3315
  return c.json({ error: "path required" }, 400);
3223
3316
  }
3224
- const srcAbs = resolveWithinProject(project.dir, body.path);
3317
+ const srcAbs = resolveWithinProject2(project.dir, body.path);
3225
3318
  if (!srcAbs || !existsSync3(srcAbs)) {
3226
3319
  return c.json({ error: "not found" }, 404);
3227
3320
  }
3228
3321
  const copyPath = generateCopyPath(project.dir, body.path);
3229
- const destAbs = resolveWithinProject(project.dir, copyPath);
3322
+ const destAbs = resolveWithinProject2(project.dir, copyPath);
3230
3323
  if (!destAbs) {
3231
3324
  return c.json({ error: "forbidden" }, 403);
3232
3325
  }
3233
3326
  ensureDir(destAbs);
3234
- writeFileSync5(destAbs, readFileSync6(srcAbs));
3327
+ try {
3328
+ writeFileSync5(destAbs, readFileSync6(srcAbs), { flag: "wx" });
3329
+ } catch (error) {
3330
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
3331
+ throw error;
3332
+ }
3333
+ return c.json({ error: "already exists" }, 409);
3334
+ }
3235
3335
  return c.json({ ok: true, path: copyPath }, 201);
3236
3336
  });
3237
3337
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
@@ -3245,7 +3345,7 @@ function registerFileRoutes(api, adapter) {
3245
3345
  const project = await adapter.resolveProject(c.req.param("id"));
3246
3346
  if (!project) return c.json({ error: "not found" }, 404);
3247
3347
  const subDir = c.req.query("dir") ?? "";
3248
- const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
3348
+ const targetDir = subDir ? resolveWithinProject2(project.dir, subDir) : project.dir;
3249
3349
  if (!targetDir) return c.json({ error: "forbidden" }, 403);
3250
3350
  if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
3251
3351
  const formData = await c.req.formData();
@@ -3281,7 +3381,8 @@ function registerFileRoutes(api, adapter) {
3281
3381
  });
3282
3382
  api.post("/projects/:id/gsap-mutations/*", async (c) => {
3283
3383
  const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
3284
- mustExist: true
3384
+ mustExist: true,
3385
+ pin: true
3285
3386
  });
3286
3387
  if ("error" in res) return res.error;
3287
3388
  const body = await c.req.json().catch(() => null);
@@ -3291,7 +3392,10 @@ function registerFileRoutes(api, adapter) {
3291
3392
  return applyGsapMutations(c, res, [body]);
3292
3393
  });
3293
3394
  api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
3294
- const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", { mustExist: true });
3395
+ const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", {
3396
+ mustExist: true,
3397
+ pin: true
3398
+ });
3295
3399
  if ("error" in res) return res.error;
3296
3400
  const body = await c.req.json().catch(() => null);
3297
3401
  if (!body || !Array.isArray(body.mutations) || body.mutations.length === 0) {
@@ -3304,7 +3408,10 @@ function registerFileRoutes(api, adapter) {
3304
3408
  return applyGsapMutations(c, res, body.mutations);
3305
3409
  });
3306
3410
  api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
3307
- const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", { mustExist: true });
3411
+ const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", {
3412
+ mustExist: true,
3413
+ pin: true
3414
+ });
3308
3415
  if ("error" in res) return res.error;
3309
3416
  const body = await c.req.json().catch(() => null);
3310
3417
  if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
@@ -3325,6 +3432,7 @@ import { Readable } from "stream";
3325
3432
  import { join as join9, resolve as resolve4 } from "path";
3326
3433
  import { createHash as createHash4 } from "crypto";
3327
3434
  import {
3435
+ addScenePartsManifest,
3328
3436
  injectScriptsIntoHtml,
3329
3437
  insertBeforeCloseTag as insertBeforeCloseTag2,
3330
3438
  stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
@@ -3341,9 +3449,7 @@ import {
3341
3449
  rewriteInlineStyleAssetUrls
3342
3450
  } from "@hyperframes/core";
3343
3451
  import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
3344
- function isFullHtmlDocument(html) {
3345
- return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
3346
- }
3452
+ import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
3347
3453
  function rewriteRelativePaths(root, compPath, projectDir) {
3348
3454
  const assetExists = (path) => existsSync4(join8(projectDir, path));
3349
3455
  rewriteAssetPaths(
@@ -3721,7 +3827,8 @@ function resolveProjectMainHtml(projectDir, projectId) {
3721
3827
  var PREVIEW_BUNDLE_OPTIONS = {
3722
3828
  runtime: "placeholder",
3723
3829
  inlineAssets: false,
3724
- staticGuard: false
3830
+ staticGuard: false,
3831
+ sceneParts: true
3725
3832
  };
3726
3833
  function registerPreviewRoutes(api, adapter) {
3727
3834
  const previewCacheHeaders = (etag) => ({
@@ -3771,6 +3878,7 @@ ${runtimeTag}`;
3771
3878
  mainCompositionPath,
3772
3879
  mediaCodecProbeCache
3773
3880
  );
3881
+ bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
3774
3882
  rememberPreview(builtKey, bundled);
3775
3883
  adapter.previewDocuments?.write(builtKey, bundled);
3776
3884
  return bundled;
@@ -3848,7 +3956,7 @@ ${etag}`;
3848
3956
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3849
3957
  const previewVariables = vars.values;
3850
3958
  const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
3851
- const compFile = resolveWithinProject(project.dir, compPath);
3959
+ const compFile = resolveWithinProject2(project.dir, compPath);
3852
3960
  if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
3853
3961
  return c.text("not found", 404);
3854
3962
  }
@@ -4082,7 +4190,7 @@ function registerRenderRoutes(api, adapter) {
4082
4190
  const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
4083
4191
  let composition;
4084
4192
  if (typeof body.composition === "string" && body.composition.length > 0) {
4085
- if (!resolveWithinProject(project.dir, body.composition)) {
4193
+ if (!resolveWithinProject2(project.dir, body.composition)) {
4086
4194
  return c.json({ error: "composition path must be within the project directory" }, 400);
4087
4195
  }
4088
4196
  composition = body.composition;
@@ -4215,7 +4323,7 @@ function registerRenderRoutes(api, adapter) {
4215
4323
  const filename = c.req.path.split("/renders/file/")[1];
4216
4324
  if (!filename) return c.json({ error: "missing filename" }, 400);
4217
4325
  const rendersDir = adapter.rendersDir(project);
4218
- const fp = resolveWithinProject(rendersDir, filename);
4326
+ const fp = resolveWithinProject2(rendersDir, filename);
4219
4327
  if (!fp) return c.json({ error: "forbidden" }, 403);
4220
4328
  if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
4221
4329
  const contentType = renderContentType(fp);
@@ -4299,7 +4407,7 @@ function projectReader(projectDir) {
4299
4407
  const sources = /* @__PURE__ */ new Map();
4300
4408
  return (compPath) => {
4301
4409
  if (!sources.has(compPath)) {
4302
- const file = resolveWithinProject(projectDir, compPath);
4410
+ const file = resolveWithinProject2(projectDir, compPath);
4303
4411
  let source = null;
4304
4412
  try {
4305
4413
  source = file ? readFileSync11(file, "utf-8") : null;
@@ -4524,7 +4632,7 @@ function registerThumbnailRoutes(api, adapter) {
4524
4632
  const { project, signature: projectSignature } = resolved;
4525
4633
  let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
4526
4634
  if (compPath && !compPath.includes(".")) compPath += ".html";
4527
- const htmlFile = resolveWithinProject(project.dir, compPath);
4635
+ const htmlFile = resolveWithinProject2(project.dir, compPath);
4528
4636
  const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
4529
4637
  if (source === "not-a-file") return c.json({ error: "not found" }, 404);
4530
4638
  const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
@@ -4948,7 +5056,7 @@ function registerSelectionRoutes(api, adapter) {
4948
5056
  // src/routes/media.ts
4949
5057
  import { streamSSE as streamSSE2 } from "hono/streaming";
4950
5058
  import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
4951
- import { basename as basename2, dirname as dirname5, extname as extname2, join as join14 } from "path";
5059
+ import { basename as basename3, dirname as dirname6, extname as extname2, join as join14 } from "path";
4952
5060
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
4953
5061
  ".mp4",
4954
5062
  ".mov",
@@ -4978,7 +5086,7 @@ function containsNullByte(path) {
4978
5086
  return path.includes("\0");
4979
5087
  }
4980
5088
  function slugFileBase(path) {
4981
- const name = basename2(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5089
+ const name = basename3(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
4982
5090
  return name || "media";
4983
5091
  }
4984
5092
  function uniqueAssetPath(projectDir, assetPath) {
@@ -5034,7 +5142,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5034
5142
  if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
5035
5143
  return c.json({ error: "media metadata requires a project-local asset" }, 400);
5036
5144
  }
5037
- const filePath = resolveWithinProject(project.dir, assetPath);
5145
+ const filePath = resolveWithinProject2(project.dir, assetPath);
5038
5146
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5039
5147
  if (!existsSync9(filePath)) return c.json({ error: "media not found" }, 404);
5040
5148
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
@@ -5056,7 +5164,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5056
5164
  if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
5057
5165
  return c.json({ error: "background removal requires a project-local media asset" }, 400);
5058
5166
  }
5059
- const inputPath = resolveWithinProject(project.dir, inputAssetPath);
5167
+ const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
5060
5168
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5061
5169
  if (!existsSync9(inputPath)) return c.json({ error: "input media not found" }, 404);
5062
5170
  const inputIsVideo = isVideoPath(inputAssetPath);
@@ -5068,11 +5176,11 @@ function registerMediaRoutes(api, adapter, options = {}) {
5068
5176
  if (requestedOutput && containsNullByte(requestedOutput)) {
5069
5177
  return c.json({ error: "forbidden" }, 403);
5070
5178
  }
5071
- if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
5179
+ if (requestedOutput && !resolveWithinProject2(project.dir, requestedOutput)) {
5072
5180
  return c.json({ error: "forbidden" }, 403);
5073
5181
  }
5074
5182
  const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
5075
- const outputPath = resolveWithinProject(project.dir, outputAssetPath);
5183
+ const outputPath = resolveWithinProject2(project.dir, outputAssetPath);
5076
5184
  if (!outputPath) return c.json({ error: "forbidden" }, 403);
5077
5185
  if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
5078
5186
  return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
@@ -5087,13 +5195,13 @@ function registerMediaRoutes(api, adapter, options = {}) {
5087
5195
  return c.json({ error: "background plates are only supported for video inputs" }, 400);
5088
5196
  }
5089
5197
  backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
5090
- backgroundOutputPath = resolveWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
5198
+ backgroundOutputPath = resolveWithinProject2(project.dir, backgroundOutputAssetPath) ?? void 0;
5091
5199
  if (!backgroundOutputPath) {
5092
5200
  return c.json({ error: "forbidden" }, 403);
5093
5201
  }
5094
5202
  }
5095
- mkdirSync7(dirname5(outputPath), { recursive: true });
5096
- if (backgroundOutputPath) mkdirSync7(dirname5(backgroundOutputPath), { recursive: true });
5203
+ mkdirSync7(dirname6(outputPath), { recursive: true });
5204
+ if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
5097
5205
  const jobId = makeJobId(project.id, mediaJobs);
5098
5206
  const state = adapter.startBackgroundRemoval({
5099
5207
  project,
@@ -5174,175 +5282,40 @@ function registerGlobalAssetRoutes(api) {
5174
5282
  api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
5175
5283
  }
5176
5284
 
5177
- // src/routes/history.ts
5178
- var YOU = { kind: "person", name: "You" };
5179
- var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5180
- async function historyOf(adapter, c) {
5181
- const project = await adapter.resolveProject(c.req.param("id") ?? "");
5182
- return project ? await adapter.history?.(project) ?? null : null;
5183
- }
5184
- async function bodyOf(c) {
5185
- const body = await c.req.json().catch(() => null);
5186
- return body && typeof body === "object" ? body : {};
5187
- }
5188
- var text = (value) => typeof value === "string" && value ? value : null;
5189
- function writing(c) {
5190
- const header = c.req.header("X-Hyperframes-Write-Token");
5191
- return header ? { writeToken: createWriteToken(header) } : {};
5192
- }
5193
- function idleOf(body) {
5194
- const idleMs = body.idleMs;
5195
- return typeof idleMs === "number" && idleMs > 0 ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) : void 0;
5196
- }
5197
- function versionsOf(value) {
5198
- if (!value || typeof value !== "object") return void 0;
5199
- const pairs = Object.entries(value).filter(
5200
- (pair) => typeof pair[1] === "string"
5201
- );
5202
- return pairs.length ? Object.fromEntries(pairs) : void 0;
5203
- }
5204
- function nextStep(history, direction) {
5205
- const target = history.next(direction);
5206
- if (!target) return null;
5207
- const paths = target.files.map((file) => file.path);
5208
- return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
5209
- }
5210
- async function withHistory(adapter, c, task) {
5211
- const history = await historyOf(adapter, c);
5212
- if (!history) return c.json({ error: "This project has no history here." }, 404);
5213
- try {
5214
- return c.json(await task(history, await bodyOf(c)) ?? null);
5215
- } catch (error) {
5216
- return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
5217
- }
5218
- }
5219
- function registerHistoryRoutes(api, adapter) {
5220
- const windows = /* @__PURE__ */ new Map();
5221
- const base = "/projects/:id/history";
5222
- api.get(
5223
- base,
5224
- (c) => withHistory(adapter, c, (history) => {
5225
- const entries = history.list();
5226
- return { entries, back: nextStep(history, "back"), forward: nextStep(history, "forward") };
5227
- })
5228
- );
5229
- api.post(
5230
- `${base}/step`,
5231
- (c) => withHistory(
5232
- adapter,
5233
- c,
5234
- (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU, writing(c))
5235
- )
5236
- );
5237
- api.post(
5238
- `${base}/undo`,
5239
- (c) => withHistory(adapter, c, (history, body) => {
5240
- const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
5241
- return history.undo(text(body.entryId) ?? "", { who: YOU, mode, ...writing(c) });
5242
- })
5243
- );
5244
- api.post(
5245
- `${base}/restore`,
5246
- (c) => withHistory(
5247
- adapter,
5248
- c,
5249
- (history, body) => history.restore(text(body.point) ?? "", YOU, writing(c))
5250
- )
5251
- );
5252
- api.get(
5253
- `${base}/peek/:point`,
5254
- (c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
5255
- );
5256
- api.post(
5257
- `${base}/pin`,
5258
- (c) => withHistory(adapter, c, (history, body) => {
5259
- history.pin(text(body.entryId) ?? "", body.pinned === true);
5260
- return { ok: true };
5261
- })
5262
- );
5263
- api.post(
5264
- `${base}/claim`,
5265
- (c) => withHistory(adapter, c, async (history, body) => {
5266
- const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
5267
- const coalesceKey = text(body.coalesceKey) ?? void 0;
5268
- const idleMs = idleOf(body);
5269
- const overwrote = versionsOf(body.overwrote);
5270
- const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
5271
- ...coalesceKey && { coalesceKey },
5272
- ...idleMs && { idleMs },
5273
- ...overwrote && { overwrote }
5274
- });
5275
- return { claimed };
5276
- })
5277
- );
5278
- api.post(
5279
- `${base}/window`,
5280
- (c) => withHistory(adapter, c, async (history, body) => {
5281
- const idleMs = idleOf(body);
5282
- const window = await history.beginWindow(
5283
- YOU,
5284
- text(body.label) ?? "Edited in Studio",
5285
- idleMs ? { idleMs } : void 0
5286
- );
5287
- windows.set(window.id, { history, window });
5288
- return { windowId: window.id };
5289
- })
5290
- );
5291
- api.post(
5292
- `${base}/window/:windowId/close`,
5293
- (c) => withHistory(adapter, c, async (history) => {
5294
- const id = c.req.param("windowId") ?? "";
5295
- const held = windows.get(id);
5296
- if (held?.history !== history) throw new Error("That window is not open in this project.");
5297
- windows.delete(id);
5298
- return { entry: await held.window.close() };
5299
- })
5300
- );
5301
- }
5302
-
5303
- // src/createStudioApi.ts
5304
- function createStudioApi(adapter) {
5305
- const api = new Hono();
5306
- registerProjectRoutes(api, adapter);
5307
- registerFileRoutes(api, adapter);
5308
- registerPreviewRoutes(api, adapter);
5309
- registerLintRoutes(api, adapter);
5310
- registerRenderRoutes(api, adapter);
5311
- registerThumbnailRoutes(api, adapter);
5312
- registerSelectionRoutes(api, adapter);
5313
- registerMediaRoutes(api, adapter);
5314
- registerWaveformRoutes(api, adapter);
5315
- registerFontRoutes(api);
5316
- registerRegistryRoutes(api, adapter);
5317
- registerGlobalAssetRoutes(api);
5318
- registerHistoryRoutes(api, adapter);
5319
- return api;
5320
- }
5321
-
5322
5285
  // src/history/projectHistory.ts
5323
5286
  import { randomUUID as randomUUID8 } from "crypto";
5324
- import { rm as rm2 } from "fs/promises";
5325
- import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync18 } from "fs";
5287
+ import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
5288
+ import {
5289
+ existsSync as existsSync12,
5290
+ lstatSync as lstatSync4,
5291
+ statSync as statSync9,
5292
+ mkdirSync as mkdirSync11,
5293
+ readdirSync as readdirSync7,
5294
+ readFileSync as readFileSync18,
5295
+ realpathSync as realpathSync3,
5296
+ rmSync as rmSync6
5297
+ } from "fs";
5326
5298
  import { homedir as homedir2 } from "os";
5327
- import { join as join19, relative as relative4, resolve as resolve7, sep as sep3 } from "path";
5299
+ import { dirname as dirname9, isAbsolute as isAbsolute3, join as join19, relative as relative5, resolve as resolve7, sep as sep4 } from "path";
5328
5300
 
5329
5301
  // src/history/blobStore.ts
5330
5302
  import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
5331
- import { constants as constants3, createReadStream as createReadStream2 } from "fs";
5303
+ import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
5332
5304
  import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
5333
- import { dirname as dirname6, join as join16 } from "path";
5305
+ import { dirname as dirname7, join as join16 } from "path";
5334
5306
  var BLOB_HASH = /^[0-9a-f]{64}$/;
5335
5307
  async function hashFile(path) {
5336
5308
  const hash = createHash6("sha256");
5337
5309
  for await (const chunk of createReadStream2(path)) hash.update(chunk);
5338
5310
  return hash.digest("hex");
5339
5311
  }
5340
- async function cloneOrCopy(from, to) {
5341
- await mkdir(dirname6(to), { recursive: true });
5312
+ async function cloneOrCopy(from, to, beforeReplace) {
5313
+ await mkdir(dirname7(to), { recursive: true });
5342
5314
  const temp = `${to}.${randomUUID5()}.tmp`;
5343
5315
  try {
5344
5316
  await copyFile(from, temp, constants3.COPYFILE_FICLONE);
5345
- await rename(temp, to);
5317
+ beforeReplace?.();
5318
+ renameSync4(temp, to);
5346
5319
  } catch (error) {
5347
5320
  await rm(temp, { force: true });
5348
5321
  throw error;
@@ -5369,7 +5342,7 @@ async function openBlobStore(dir) {
5369
5342
  await rm(temp, { force: true });
5370
5343
  return hash;
5371
5344
  }
5372
- await mkdir(dirname6(pathOf(hash)), { recursive: true });
5345
+ await mkdir(dirname7(pathOf(hash)), { recursive: true });
5373
5346
  const size = (await stat(temp)).size;
5374
5347
  await rename(temp, pathOf(hash));
5375
5348
  sizes.set(hash, size);
@@ -5378,7 +5351,7 @@ async function openBlobStore(dir) {
5378
5351
  },
5379
5352
  has: (hash) => sizes.has(hash),
5380
5353
  read: async (hash) => readFile2(pathOf(hash)),
5381
- writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
5354
+ writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
5382
5355
  bytes: () => total,
5383
5356
  size: (hash) => sizes.get(hash) ?? 0,
5384
5357
  async prune(keep) {
@@ -5394,7 +5367,7 @@ async function openBlobStore(dir) {
5394
5367
 
5395
5368
  // src/history/historyId.ts
5396
5369
  import { randomUUID as randomUUID6 } from "crypto";
5397
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, writeFileSync as writeFileSync7 } from "fs";
5370
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5398
5371
  import { join as join17, resolve as resolve6 } from "path";
5399
5372
  var ID_PATH = join17(".hyperframes", "history-id");
5400
5373
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -5406,27 +5379,33 @@ function readId(projectDir) {
5406
5379
  return null;
5407
5380
  }
5408
5381
  }
5409
- function recordedDir(historyDir) {
5382
+ var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
5383
+ function isRecordedFolder(historyDir, folder) {
5410
5384
  try {
5411
- return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8")).dir ?? null;
5385
+ const was = JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
5386
+ return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
5412
5387
  } catch {
5413
- return null;
5388
+ return false;
5414
5389
  }
5415
5390
  }
5416
5391
  function projectHistoryId(projectDir, historyRoot) {
5417
5392
  const dir = resolve6(projectDir);
5393
+ const folder = statSync8(dir);
5418
5394
  let id = readId(dir);
5419
- const was = id && recordedDir(join17(historyRoot, id));
5420
- if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
5395
+ if (!id || existsSync11(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
5421
5396
  id = randomUUID6();
5422
5397
  mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5423
5398
  writeFileSync7(join17(dir, ID_PATH), `${id}
5424
5399
  `);
5425
5400
  }
5426
- mkdirSync8(join17(historyRoot, id), { recursive: true });
5427
- writeFileSync7(join17(historyRoot, id, "project.json"), JSON.stringify({ dir }));
5401
+ recordProject(join17(historyRoot, id), dir, folder);
5428
5402
  return id;
5429
5403
  }
5404
+ function recordProject(historyDir, dir, folder) {
5405
+ const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
5406
+ mkdirSync8(historyDir, { recursive: true });
5407
+ replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
5408
+ }
5430
5409
 
5431
5410
  // src/history/ownerLock.ts
5432
5411
  import { randomUUID as randomUUID7 } from "crypto";
@@ -5511,7 +5490,7 @@ async function takeHistoryOwnership(home, waitMs) {
5511
5490
 
5512
5491
  // src/history/historyLog.ts
5513
5492
  import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
5514
- import { dirname as dirname7 } from "path";
5493
+ import { dirname as dirname8 } from "path";
5515
5494
  var START = "start";
5516
5495
  function parseRecord(line) {
5517
5496
  try {
@@ -5563,21 +5542,25 @@ function writeLog(file, log) {
5563
5542
  ...log.entries.map((entry) => ({ type: "entry", entry })),
5564
5543
  ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5565
5544
  ];
5566
- mkdirSync10(dirname7(file), { recursive: true });
5545
+ mkdirSync10(dirname8(file), { recursive: true });
5567
5546
  replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5568
5547
  `).join(""), 420);
5569
5548
  }
5570
- function applyEntry(manifest, entry) {
5571
- for (const file of entry.files)
5572
- if (file.after === null) manifest.delete(file.path);
5573
- else manifest.set(file.path, file.after);
5549
+ function applyEntry(manifest, entry, side) {
5550
+ for (const file of entry.files) {
5551
+ const hash = file[side];
5552
+ if (hash === null) manifest.delete(file.path);
5553
+ else manifest.set(file.path, hash);
5554
+ }
5574
5555
  }
5575
5556
  function manifestAt(log, point) {
5557
+ return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
5558
+ }
5559
+ function manifestAround(log, id, side) {
5576
5560
  const manifest = new Map(log.baseline);
5577
- if (point === START) return manifest;
5578
5561
  for (const entry of log.entries) {
5579
- applyEntry(manifest, entry);
5580
- if (entry.id === point) return manifest;
5562
+ applyEntry(manifest, entry, entry.id === id ? side : "after");
5563
+ if (entry.id === id) return manifest;
5581
5564
  }
5582
5565
  return null;
5583
5566
  }
@@ -5589,24 +5572,35 @@ function undoneIds(entries) {
5589
5572
  }
5590
5573
  return undone;
5591
5574
  }
5592
- function stepTarget(entries, direction) {
5575
+ function stepTarget(entries, direction, mine) {
5593
5576
  const undone = undoneIds(entries);
5594
5577
  const byId = new Map(entries.map((entry) => [entry.id, entry]));
5595
- for (let i = entries.length - 1; i >= 0; i -= 1) {
5596
- const entry = entries[i];
5597
- const isChange = !entry.undoes;
5598
- if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
5599
- if (direction !== "forward") continue;
5600
- if (isChange) return void 0;
5578
+ const owns = ownerOf2(entries, byId, mine);
5579
+ const newestFirst = [...entries].reverse();
5580
+ if (direction === "back")
5581
+ return newestFirst.find((entry) => !entry.undoes && !undone.has(entry.id) && owns(entry));
5582
+ for (const entry of newestFirst) {
5583
+ if (!entry.undoes) {
5584
+ if (owns(entry)) return void 0;
5585
+ continue;
5586
+ }
5601
5587
  const target = byId.get(entry.undoes);
5602
- if (!undone.has(entry.id) && target && !target.undoes) return entry;
5588
+ if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
5603
5589
  }
5604
5590
  return void 0;
5605
5591
  }
5592
+ function ownerOf2(entries, byId, mine) {
5593
+ const redoneBy = /* @__PURE__ */ new Map();
5594
+ for (const entry of entries) {
5595
+ const target = entry.undoes ? byId.get(entry.undoes) : void 0;
5596
+ if (target?.undoes) redoneBy.set(target.undoes, entry);
5597
+ }
5598
+ return (entry) => mine(entry) || mine(redoneBy.get(entry.id) ?? entry);
5599
+ }
5606
5600
  function foldOldest(log) {
5607
5601
  const oldest = log.entries[0];
5608
5602
  if (!oldest || log.pins.has(oldest.id)) return false;
5609
- applyEntry(log.baseline, oldest);
5603
+ applyEntry(log.baseline, oldest, "after");
5610
5604
  log.entries.shift();
5611
5605
  return true;
5612
5606
  }
@@ -5622,6 +5616,8 @@ function referencedHashes(log, current) {
5622
5616
 
5623
5617
  // src/history/projectHistory.ts
5624
5618
  var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
5619
+ var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5620
+ var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
5625
5621
  var OUTSIDE = { kind: "outside", name: "Outside" };
5626
5622
  var OUTSIDE_LABEL = "Changed outside the app";
5627
5623
  var RACY_MS = 2e3;
@@ -5635,6 +5631,34 @@ function splitAt(change, at, kept) {
5635
5631
  { ...change, before: at }
5636
5632
  ];
5637
5633
  }
5634
+ var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
5635
+ async function removeEmptyFolders(dir) {
5636
+ let entries;
5637
+ try {
5638
+ entries = await readdir2(dir, { withFileTypes: true });
5639
+ } catch (error) {
5640
+ if (isMissingFolder(error)) return;
5641
+ throw error;
5642
+ }
5643
+ for (const entry of entries)
5644
+ if (entry.isDirectory()) await removeEmptyFolders(join19(dir, entry.name));
5645
+ await rmdir(dir);
5646
+ }
5647
+ function fileLeftIn(dir, at, deleted) {
5648
+ for (const entry of readdirSync7(dir, { withFileTypes: true })) {
5649
+ const path = `${at}/${entry.name}`;
5650
+ const left = entry.isDirectory() ? fileLeftIn(join19(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
5651
+ if (left) return left;
5652
+ }
5653
+ return void 0;
5654
+ }
5655
+ function ignoresCase(dir) {
5656
+ const self = statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false });
5657
+ const other = statSync9(join19(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
5658
+ return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
5659
+ }
5660
+ var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
5661
+ var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
5638
5662
  function addChange(group, path, before, after) {
5639
5663
  const earlier = group.changes.get(path);
5640
5664
  const from = earlier ? earlier.before : before;
@@ -5648,17 +5672,24 @@ var Engine = class {
5648
5672
  this.blobs = blobs;
5649
5673
  this.dir = resolve7(options.projectDir);
5650
5674
  this.home = join19(options.historyRoot, projectId);
5675
+ this.foldsCase = ignoresCase(this.dir);
5676
+ const folder = statSync9(this.dir, { throwIfNoEntry: false });
5677
+ if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
5678
+ throw this.replaced();
5679
+ this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
5651
5680
  }
5652
5681
  options;
5653
5682
  projectId;
5654
5683
  blobs;
5655
5684
  dir;
5685
+ foldsCase;
5686
+ folder;
5656
5687
  home;
5657
5688
  log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5658
5689
  tracked = /* @__PURE__ */ new Map();
5659
5690
  windows = [];
5660
5691
  outside = null;
5661
- /** A coalescing claim, open until another key, its idle timer, or an operation commits it. */
5692
+ /** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
5662
5693
  claimed = null;
5663
5694
  writeToken;
5664
5695
  quietTimer;
@@ -5666,6 +5697,12 @@ var Engine = class {
5666
5697
  notedTimer = null;
5667
5698
  listeners = /* @__PURE__ */ new Set();
5668
5699
  tail = Promise.resolve();
5700
+ closed = false;
5701
+ closing;
5702
+ /** A path as the project's disk compares names: folded where `A` and `a` are one name. */
5703
+ nameKey(path) {
5704
+ return this.foldsCase ? path.toLowerCase() : path;
5705
+ }
5669
5706
  get logFile() {
5670
5707
  return join19(this.home, "log.jsonl");
5671
5708
  }
@@ -5674,10 +5711,37 @@ var Engine = class {
5674
5711
  }
5675
5712
  /** One operation at a time, in order: sweeps, windows and undos never interleave. */
5676
5713
  queue(task) {
5714
+ if (this.closed)
5715
+ return Promise.reject(new HistoryClosedError("This project's history is closed."));
5677
5716
  const run = this.tail.then(task);
5678
5717
  this.tail = run.catch(() => void 0);
5679
5718
  return run;
5680
5719
  }
5720
+ /** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
5721
+ whereFolder() {
5722
+ const now = statSync9(this.dir, { throwIfNoEntry: false });
5723
+ if (!now) return "gone";
5724
+ const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
5725
+ return same && readId(this.dir) === this.projectId ? "here" : "replaced";
5726
+ }
5727
+ assertWritable() {
5728
+ const folder = this.whereFolder();
5729
+ if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
5730
+ if (folder === "replaced") throw this.replaced();
5731
+ }
5732
+ persistLog(record) {
5733
+ if (record) saveRecord(this.logFile, this.log, record);
5734
+ else writeLog(this.logFile, this.log);
5735
+ if (!existsSync12(join19(this.home, "project.json")))
5736
+ recordProject(this.home, this.dir, this.folder);
5737
+ }
5738
+ replaced() {
5739
+ return new HistoryClosedError(`${this.dir} is now another project.`);
5740
+ }
5741
+ assertOpen() {
5742
+ if (this.closed) throw new HistoryClosedError("This project's history is closed.");
5743
+ if (this.whereFolder() === "replaced") throw this.replaced();
5744
+ }
5681
5745
  async start() {
5682
5746
  const log = readLog(
5683
5747
  this.logFile,
@@ -5693,18 +5757,24 @@ var Engine = class {
5693
5757
  const cached = cache.get(path);
5694
5758
  this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
5695
5759
  }
5696
- await this.sweep();
5697
- await this.commitOutside();
5760
+ this.reopenClosedWindow();
5761
+ await this.settleAll();
5762
+ }
5763
+ reopenClosedWindow() {
5764
+ const closed = this.options.closedWindow;
5765
+ if (!closed || this.log.entries.some((entry) => entry.id === closed.id)) return;
5766
+ const { id, who, label, startedAt, lastWriteAt, idleMs } = closed;
5767
+ this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: /* @__PURE__ */ new Map() });
5698
5768
  }
5699
5769
  async firstOpen() {
5700
5770
  const sweptAt = Date.now();
5701
- for (const file of listProjectFiles(this.dir))
5702
- this.tracked.set(file.path, {
5703
- hash: await this.blobs.put(join19(this.dir, file.path)),
5704
- stat: statKey(file, sweptAt)
5705
- });
5771
+ for (const file of listProjectFiles(this.dir)) {
5772
+ const hash = await this.storeIfPresent(file.path);
5773
+ if (this.whereFolder() !== "here") throw this.replaced();
5774
+ if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
5775
+ }
5706
5776
  this.log.baseline = this.manifest();
5707
- writeLog(this.logFile, this.log);
5777
+ this.persistLog();
5708
5778
  this.saveStatCache();
5709
5779
  }
5710
5780
  manifest() {
@@ -5728,17 +5798,32 @@ var Engine = class {
5728
5798
  * project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
5729
5799
  */
5730
5800
  async sweep() {
5731
- if (!existsSync12(this.dir)) return;
5801
+ if (this.whereFolder() !== "here") return;
5732
5802
  const sweptAt = Date.now();
5803
+ const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
5733
5804
  const seen = listProjectFiles(this.dir);
5734
- let changed = false;
5735
- for (const file of seen)
5736
- changed = await this.observe(file.path, statKey(file, sweptAt)) || changed;
5737
5805
  const present = new Set(seen.map((file) => file.path));
5738
- for (const [path, known] of this.tracked) {
5739
- if (present.has(path)) continue;
5806
+ const removed = [...this.tracked.keys()].filter((path) => !present.has(path)).map((path) => {
5807
+ const standing = this.standingAt(path);
5808
+ return { at: standing ? changedAt(standing.stat) : sweptAt, path, file: null };
5809
+ });
5810
+ const appearedAt = (file) => Math.max(
5811
+ changedAt(file),
5812
+ ...removed.filter((gone) => blocks(this.nameKey(gone.path), this.nameKey(file.path))).map((gone) => gone.at)
5813
+ );
5814
+ const events = [
5815
+ ...removed,
5816
+ ...seen.map((file) => ({ at: appearedAt(file), path: file.path, file }))
5817
+ ].sort((a, b) => a.at - b.at);
5818
+ let changed = false;
5819
+ for (const { at, path, file } of events) {
5820
+ if (file) {
5821
+ changed = await this.observe(path, statKey(file, sweptAt), at) || changed;
5822
+ continue;
5823
+ }
5824
+ const known = this.tracked.get(path);
5740
5825
  this.tracked.delete(path);
5741
- this.record(path, known.hash, null);
5826
+ await this.record(path, known.hash, null, at);
5742
5827
  changed = true;
5743
5828
  }
5744
5829
  if (changed) this.saveStatCache();
@@ -5752,45 +5837,54 @@ var Engine = class {
5752
5837
  throw error;
5753
5838
  }
5754
5839
  }
5755
- async observe(path, stat2) {
5840
+ async observe(path, stat2, at) {
5756
5841
  const known = this.tracked.get(path) ?? { hash: null, stat: null };
5757
5842
  if (stat2 && known.stat === stat2) return false;
5758
5843
  const hash = await this.storeIfPresent(path);
5759
- if (hash === null) return false;
5844
+ if (hash === null || this.whereFolder() !== "here") return false;
5760
5845
  this.tracked.set(path, { hash, stat: stat2 });
5761
- if (known.hash !== hash) this.record(path, known.hash, hash);
5846
+ if (known.hash !== hash) await this.record(path, known.hash, hash, at);
5762
5847
  return known.hash !== hash || known.stat !== stat2;
5763
5848
  }
5764
- record(path, before, after) {
5849
+ async record(path, before, after, at) {
5850
+ const endedBeforeThisWrite = this.windows.filter((open) => !takesWrite(open, at));
5851
+ if (endedBeforeThisWrite.length) await this.commitOutside();
5852
+ for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
5765
5853
  const window = this.windows.at(-1);
5766
- if (window) this.touch(window);
5854
+ if (window) this.touch(window, at);
5767
5855
  addChange(window ?? this.outsideGroup(), path, before, after);
5768
5856
  }
5769
5857
  /** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
5770
5858
  async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
5771
5859
  await this.sweep();
5772
- const { taken, cut } = this.takeClaimed(who, paths, overwrote);
5773
- if (!taken.length) {
5774
- if (coalesceKey !== this.claimed?.key) await this.commitClaim();
5775
- return null;
5776
- }
5860
+ const taken = await this.takeClaimed(who, paths, overwrote);
5861
+ if (!taken.length) return this.claimNothing(coalesceKey);
5777
5862
  const held = this.heldFor(coalesceKey, taken);
5778
5863
  if (!held) await this.commitClaim();
5779
- await this.commitOlder(cut);
5864
+ await this.commitPending();
5780
5865
  const group = held ?? this.newGroup(who, label);
5781
5866
  for (const change of taken) addChange(group, change.path, change.before, change.after);
5782
5867
  if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
5783
5868
  const entry = await this.commit(group);
5784
5869
  return entry && { id: entry.id };
5785
5870
  }
5786
- async commitOlder(cut) {
5787
- if (this.outside) cut.delete(this.outside);
5871
+ /** A claim that took nothing still ends a held claim under another key. */
5872
+ async claimNothing(coalesceKey) {
5873
+ if (coalesceKey !== this.claimed?.key) await this.commitClaim();
5874
+ return null;
5875
+ }
5876
+ /** Commits every other writer's pending changes, oldest first; open windows stay open. */
5877
+ async commitPending() {
5788
5878
  await this.commitOutside();
5789
- for (const [window, paths] of cut) await this.commitCut(window, paths);
5879
+ for (const window of [...this.windows])
5880
+ if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
5881
+ }
5882
+ pendingElsewhere() {
5883
+ return Boolean(this.outside?.changes.size) || this.windows.some((open) => open.changes.size > 0);
5790
5884
  }
5791
5885
  /** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
5792
5886
  heldFor(key, taken) {
5793
- const group = key && this.claimed?.key === key ? this.claimed.group : null;
5887
+ const group = key && this.claimed?.key === key && !this.pendingElsewhere() ? this.claimed.group : null;
5794
5888
  const after = (path, before) => {
5795
5889
  const own = group?.changes.get(path);
5796
5890
  return own ? own.after : before;
@@ -5800,10 +5894,10 @@ var Engine = class {
5800
5894
  return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
5801
5895
  }
5802
5896
  logPath(path) {
5803
- return relative4(this.dir, resolve7(this.dir, path)).split(sep3).join("/");
5897
+ return relative5(this.dir, resolve7(this.dir, path)).split(sep4).join("/");
5804
5898
  }
5805
- /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote; `cut`: paths each group keeps. */
5806
- takeClaimed(who, paths, overwrote) {
5899
+ /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
5900
+ async takeClaimed(who, paths, overwrote) {
5807
5901
  const wanted = new Set(paths.map((path) => this.logPath(path)));
5808
5902
  const at = new Map(
5809
5903
  Object.entries(overwrote).map(([path, version]) => [
@@ -5813,23 +5907,50 @@ var Engine = class {
5813
5907
  );
5814
5908
  const others = this.windows.filter((open) => !sameWho(open.who, who));
5815
5909
  const groups = this.outside ? [this.outside, ...others] : others;
5816
- const taken = [];
5817
- const cut = /* @__PURE__ */ new Map();
5818
- for (const group of groups) {
5819
- for (const change of [...group.changes.values()]) {
5820
- if (!wanted.has(change.path)) continue;
5821
- const [kept, claimed] = this.cutOut(group, change, at.get(change.path));
5822
- if (claimed) taken.push(claimed);
5823
- if (kept && claimed) cut.set(group, [...cut.get(group) ?? [], change.path]);
5824
- }
5910
+ const hits = groups.flatMap(
5911
+ (group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
5912
+ );
5913
+ const cuts = [];
5914
+ const walked = hits.map(() => /* @__PURE__ */ new Set());
5915
+ for (const [i, { change }] of hits.entries()) {
5916
+ const told = at.get(change.path);
5917
+ cuts.push(await this.overwrittenBy(change, told, walked[i]) ?? told);
5918
+ }
5919
+ hits.forEach(
5920
+ ({ change }, i) => forgetOverwrittenBytes(
5921
+ join19(this.dir, change.path),
5922
+ new Set([...walked[i]].map(hashVersion))
5923
+ )
5924
+ );
5925
+ return hits.flatMap(({ group, change }, i) => this.cutOut(group, change, cuts[i]) ?? []);
5926
+ }
5927
+ /** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
5928
+ async overwrittenBy(change, told, seen) {
5929
+ const absPath = join19(this.dir, change.path);
5930
+ let bytes;
5931
+ for (let hash = change.after; hash; ) {
5932
+ if (seen.has(hash)) return void 0;
5933
+ seen.add(hash);
5934
+ const replaced = bytesOverwrittenBy(absPath, hashVersion(hash));
5935
+ if (replaced === void 0) break;
5936
+ bytes = replaced;
5937
+ hash = hashOfVersion(fileContentVersion(replaced));
5938
+ if (hash === change.before || hash === told) return hash;
5939
+ }
5940
+ if (bytes === void 0) return void 0;
5941
+ const staged = join19(this.home, `overwrote-${randomUUID8()}`);
5942
+ await writeFile(staged, bytes);
5943
+ try {
5944
+ return await this.blobs.put(staged);
5945
+ } finally {
5946
+ await rm2(staged, { force: true });
5825
5947
  }
5826
- return { taken, cut };
5827
5948
  }
5828
5949
  cutOut(group, change, cut) {
5829
5950
  const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
5830
5951
  group.changes.delete(change.path);
5831
5952
  if (kept) group.changes.set(kept.path, kept);
5832
- return [kept, claimed];
5953
+ return claimed;
5833
5954
  }
5834
5955
  /** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
5835
5956
  * so the writer's next write to a cut file would otherwise span the claimer's edit. */
@@ -5843,8 +5964,8 @@ var Engine = class {
5843
5964
  }
5844
5965
  holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
5845
5966
  clearTimeout(this.claimed?.timer);
5846
- const timer = setTimeout(() => this.background(() => this.commitClaim()), idleMs);
5847
- timer.unref?.();
5967
+ const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
5968
+ timer?.unref?.();
5848
5969
  this.claimed = { group, key, timer };
5849
5970
  return group.changes.size ? { id: group.id } : null;
5850
5971
  }
@@ -5874,6 +5995,7 @@ var Engine = class {
5874
5995
  return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5875
5996
  }
5876
5997
  async commitOutside() {
5998
+ if (this.outside?.changes.size) await this.commitClaim();
5877
5999
  clearTimeout(this.quietTimer);
5878
6000
  clearTimeout(this.maxTimer);
5879
6001
  const group = this.outside;
@@ -5882,12 +6004,12 @@ var Engine = class {
5882
6004
  }
5883
6005
  async commit(group, extra = {}) {
5884
6006
  if (!group.changes.size) return null;
5885
- const { id, who, label, startedAt, changes } = group;
5886
- const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
5887
- const entry = { id, who, label, startedAt, endedAt: this.now(), files, ...extra };
6007
+ const pending = this.pendingEntry(group);
6008
+ const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
6009
+ const entry = { ...pending, endedAt, ...extra };
5888
6010
  this.log.entries.push(entry);
5889
6011
  try {
5890
- saveRecord(this.logFile, this.log, { type: "entry", entry });
6012
+ this.persistLog({ type: "entry", entry });
5891
6013
  } catch (error) {
5892
6014
  this.log.entries.pop();
5893
6015
  throw error;
@@ -5908,15 +6030,24 @@ var Engine = class {
5908
6030
  let folded = false;
5909
6031
  while (this.historyBytes() > budget && foldOldest(this.log)) {
5910
6032
  folded = true;
5911
- await this.blobs.prune(referencedHashes(this.log, this.manifest()));
6033
+ await this.blobs.prune(
6034
+ /* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
6035
+ );
5912
6036
  }
5913
- if (folded) writeLog(this.logFile, this.log);
6037
+ if (folded) this.persistLog();
6038
+ }
6039
+ /** Hashes only pending changes point at yet, such as a claim's stored cut. */
6040
+ pendingHashes() {
6041
+ const changes = [...this.windows, this.outside, this.claimed?.group].flatMap((group) => [
6042
+ ...group?.changes.values() ?? []
6043
+ ]);
6044
+ return changes.flatMap((change) => [change.before, change.after]).filter((hash) => hash !== null);
5914
6045
  }
5915
- /** Before an operation: every write so far is filed, and the outside group is closed so it sorts first. */
6046
+ /** Before an operation or a window: every write is filed and every pending change committed, in write order. */
5916
6047
  async settle() {
5917
6048
  await this.sweep();
5918
6049
  await this.commitClaim();
5919
- await this.commitOutside();
6050
+ await this.commitPending();
5920
6051
  }
5921
6052
  /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
5922
6053
  noteChange(path) {
@@ -5928,19 +6059,23 @@ var Engine = class {
5928
6059
  this.notedTimer.unref?.();
5929
6060
  }
5930
6061
  background(task) {
5931
- this.queue(task).catch((error) => this.options.onError?.(error));
6062
+ this.queue(task).catch((error) => {
6063
+ if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
6064
+ });
5932
6065
  }
5933
6066
  beginWindow(who, label, idleMs) {
5934
6067
  return this.queue(async () => {
5935
- await this.sweep();
6068
+ await this.settle();
5936
6069
  const window = { ...this.newGroup(who, label), idleMs };
5937
6070
  this.windows.push(window);
5938
- this.touch(window);
5939
- return { id: window.id, close: () => this.queue(() => this.sweepAndEnd(window)) };
6071
+ this.touch(window, Date.now());
6072
+ const close = () => this.queue(() => this.sweepAndEnd(window));
6073
+ return { id: window.id, startedAt: window.startedAt, close };
5940
6074
  });
5941
6075
  }
5942
6076
  /** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
5943
- touch(window) {
6077
+ touch(window, at) {
6078
+ window.lastWriteAt = Math.max(window.lastWriteAt ?? at, at);
5944
6079
  clearTimeout(window.idleTimer);
5945
6080
  if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
5946
6081
  window.idleTimer = setTimeout(
@@ -5957,6 +6092,7 @@ var Engine = class {
5957
6092
  async endWindow(window) {
5958
6093
  if (!this.windows.includes(window)) return window.entry ?? null;
5959
6094
  clearTimeout(window.idleTimer);
6095
+ await this.commitClaim();
5960
6096
  this.windows = this.windows.filter((open) => open !== window);
5961
6097
  window.entry = await this.commit(window) ?? window.entry ?? null;
5962
6098
  return window.entry;
@@ -5968,15 +6104,39 @@ var Engine = class {
5968
6104
  for (const window of [...this.windows]) await this.endWindow(window);
5969
6105
  await this.commitOutside();
5970
6106
  }
6107
+ /** What took a removed path's place, dating its removal: an entry at the path, or a file at a folder above it. */
6108
+ standingAt(path) {
6109
+ const parts = path.split("/");
6110
+ for (let depth = 1; depth <= parts.length; depth += 1) {
6111
+ const at = parts.slice(0, depth).join("/");
6112
+ const stat2 = lstatSync4(join19(this.dir, at), { throwIfNoEntry: false });
6113
+ if (!stat2) return void 0;
6114
+ if (at === path || !stat2.isDirectory()) return { at, stat: stat2 };
6115
+ }
6116
+ return void 0;
6117
+ }
6118
+ /** What a write of `path` would have to delete: a file or link at or above it, or a file in a folder at it. */
6119
+ inTheWay(path, deleted) {
6120
+ const standing = this.standingAt(path);
6121
+ if (!standing) return void 0;
6122
+ if (standing.stat.isSymbolicLink()) return standing.at;
6123
+ if (standing.at === path && !standing.stat.isDirectory()) return void 0;
6124
+ const real = realpathSync3.native(join19(this.dir, standing.at));
6125
+ const at = relative5(realpathSync3.native(this.dir), real).split(sep4).join("/");
6126
+ if (standing.at !== path) return deleted.has(at) ? void 0 : at;
6127
+ return fileLeftIn(real, at, deleted);
6128
+ }
5971
6129
  /** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
5972
6130
  async writeAs(who, label, target, extra) {
6131
+ this.assertWritable();
6132
+ const changes = [...target].filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash).sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
6133
+ const deleted = new Set(changes.filter(([, hash]) => hash === null).map(([path]) => path));
6134
+ const blocked = changes.filter(([, hash]) => hash !== null).map(([path]) => this.inTheWay(path, deleted)).find(Boolean);
6135
+ if (blocked) throw new Error(`${blocked} is in the way; move or delete it, then try again.`);
5973
6136
  const group = this.newGroup(who, label);
5974
6137
  this.windows.push(group);
5975
6138
  try {
5976
- for (const [path, hash] of target) {
5977
- if ((this.tracked.get(path)?.hash ?? null) !== hash)
5978
- await this.writeProjectFile(path, hash);
5979
- }
6139
+ for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
5980
6140
  await this.sweep();
5981
6141
  } finally {
5982
6142
  this.windows = this.windows.filter((open) => open !== group);
@@ -5985,14 +6145,20 @@ var Engine = class {
5985
6145
  }
5986
6146
  /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
5987
6147
  async writeProjectFile(path, hash) {
6148
+ this.assertWritable();
5988
6149
  const absPath = join19(this.dir, path);
5989
6150
  const { writeToken } = this;
5990
6151
  if (writeToken) {
5991
6152
  const version = hash === null ? DELETED_VERSION : hashVersion(hash);
5992
6153
  recordFileWriteReceipt(absPath, { path, version, writeToken });
5993
6154
  }
5994
- if (hash === null) await rm2(absPath, { force: true });
5995
- else await this.blobs.writeTo(hash, absPath);
6155
+ if (hash === null) {
6156
+ this.assertWritable();
6157
+ rmSync6(absPath, { force: true });
6158
+ } else {
6159
+ await removeEmptyFolders(absPath);
6160
+ await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
6161
+ }
5996
6162
  }
5997
6163
  entry(id) {
5998
6164
  const entry = this.log.entries.find((candidate) => candidate.id === id);
@@ -6016,7 +6182,9 @@ var Engine = class {
6016
6182
  entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
6017
6183
  };
6018
6184
  }
6019
- const target = new Map(entry.files.map((file) => [file.path, file.before]));
6185
+ const target = new Map(
6186
+ entry.files.filter((file) => mode !== "keep-later-edits" || !changed.includes(file)).map((file) => [file.path, file.before])
6187
+ );
6020
6188
  return {
6021
6189
  ok: true,
6022
6190
  entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
@@ -6025,15 +6193,19 @@ var Engine = class {
6025
6193
  movedOn(entry) {
6026
6194
  return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
6027
6195
  }
6028
- /** Back reverts the newest change in effect, unless its files moved on under an earlier-ending edit (a held drag
6029
- * committed after an agent's turn): then that edit, if it still applies. */
6030
- next(direction) {
6031
- const top = stepTarget(this.log.entries, direction);
6032
- const moved = new Set(top && direction === "back" ? this.movedOn(top).map((f) => f.path) : []);
6033
- if (!top || !moved.size) return top;
6034
- const undone = undoneIds(this.log.entries);
6035
- const under = this.log.entries.slice(0, this.log.entries.indexOf(top)).reverse().find((e) => !e.undoes && !undone.has(e.id) && e.files.some((f) => moved.has(f.path)));
6036
- return under && !this.movedOn(under).length ? under : top;
6196
+ next(direction, who) {
6197
+ const mine = (author) => sameWho(author, who) || sameWho(author, OUTSIDE);
6198
+ const pending = [...[...this.windows].reverse(), this.outside, this.claimed?.group].find(
6199
+ (group) => group?.changes.size && mine(group.who)
6200
+ );
6201
+ if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
6202
+ return stepTarget(this.log.entries, direction, (entry) => mine(entry.who));
6203
+ }
6204
+ /** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
6205
+ pendingEntry(group) {
6206
+ const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
6207
+ const { id, who, label, startedAt, lastWriteAt } = group;
6208
+ return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
6037
6209
  }
6038
6210
  conflict(entry, changed) {
6039
6211
  const paths = new Set(changed.map((file) => file.path));
@@ -6070,8 +6242,11 @@ var Engine = class {
6070
6242
  projectId: this.projectId,
6071
6243
  beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
6072
6244
  claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
6073
- noteChange: (path) => this.noteChange(path),
6245
+ noteChange: (path) => {
6246
+ if (!this.closed) this.noteChange(path);
6247
+ },
6074
6248
  list: () => {
6249
+ this.assertOpen();
6075
6250
  const undone = undoneIds(this.log.entries);
6076
6251
  return this.log.entries.map((entry) => ({
6077
6252
  ...entry,
@@ -6080,7 +6255,7 @@ var Engine = class {
6080
6255
  }));
6081
6256
  },
6082
6257
  step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
6083
- const target = this.next(direction);
6258
+ const target = this.next(direction, who);
6084
6259
  return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
6085
6260
  }),
6086
6261
  undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
@@ -6089,30 +6264,76 @@ var Engine = class {
6089
6264
  () => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
6090
6265
  ),
6091
6266
  peek: (point) => {
6267
+ this.assertOpen();
6092
6268
  const files = manifestAt(this.log, point);
6093
6269
  return files && Object.fromEntries(files);
6094
6270
  },
6095
- next: (direction) => this.next(direction),
6096
- readBlob: (hash) => this.blobs.read(hash),
6271
+ checkout: (entryId, side, emptyDir) => this.queue(async () => {
6272
+ this.entry(entryId);
6273
+ if (!existsSync12(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
6274
+ const dest = resolve7(emptyDir);
6275
+ if (isWithin(this.dir, dest))
6276
+ throw new Error(`Checkout writes outside the project only: ${dest}`);
6277
+ if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
6278
+ throw new Error(`Checkout writes into an empty folder only: ${dest}`);
6279
+ const files = manifestAround(this.log, entryId, side);
6280
+ try {
6281
+ for (const [path, hash] of files) await this.blobs.writeTo(hash, join19(dest, path));
6282
+ } catch (error) {
6283
+ for (const name of await readdir2(dest).catch(() => []))
6284
+ await rm2(join19(dest, name), { recursive: true, force: true });
6285
+ throw error;
6286
+ }
6287
+ }),
6288
+ next: (direction, who) => {
6289
+ this.assertOpen();
6290
+ return this.next(direction, who);
6291
+ },
6292
+ readBlob: async (hash) => {
6293
+ this.assertOpen();
6294
+ return this.blobs.read(hash);
6295
+ },
6097
6296
  pin: (id, pinned) => {
6297
+ this.assertOpen();
6098
6298
  this.entry(id);
6099
6299
  if (pinned) this.log.pins.add(id);
6100
6300
  else this.log.pins.delete(id);
6101
- saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
6301
+ this.persistLog({ type: "pin", id, pinned });
6102
6302
  },
6103
6303
  onEntry: (listener) => {
6104
6304
  this.listeners.add(listener);
6105
6305
  return () => this.listeners.delete(listener);
6106
6306
  },
6107
6307
  flush: () => this.queue(() => this.settleAll()),
6108
- close: async () => {
6308
+ replacedAtPath: () => this.whereFolder() === "replaced",
6309
+ close: () => this.closing ??= (async () => {
6109
6310
  if (this.notedTimer) clearTimeout(this.notedTimer);
6110
- await this.queue(() => this.settleAll());
6111
- }
6311
+ const settled = this.queue(() => this.settleAll());
6312
+ this.closed = true;
6313
+ await settled;
6314
+ })()
6112
6315
  };
6113
6316
  }
6114
6317
  };
6318
+ function isWithin(dir, path) {
6319
+ let probe = resolve7(path);
6320
+ while (!existsSync12(probe) && dirname9(probe) !== probe) probe = dirname9(probe);
6321
+ const rel = relative5(realpathSync3.native(dir), realpathSync3.native(probe));
6322
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute3(rel);
6323
+ }
6324
+ function missingIsEmpty(error) {
6325
+ if (error.code === "ENOENT") return [];
6326
+ throw error;
6327
+ }
6328
+ var HistoryClosedError = class extends Error {
6329
+ constructor(message) {
6330
+ super(message);
6331
+ this.name = "HistoryClosedError";
6332
+ }
6333
+ };
6115
6334
  async function openProjectHistory(options) {
6335
+ if (!existsSync12(options.projectDir))
6336
+ throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
6116
6337
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
6117
6338
  const release = await takeHistoryOwnership(
6118
6339
  join19(options.historyRoot, projectId),
@@ -6133,6 +6354,185 @@ async function openProjectHistory(options) {
6133
6354
  }
6134
6355
  }
6135
6356
 
6357
+ // src/routes/history.ts
6358
+ var YOU = { kind: "person", name: "You" };
6359
+ async function historyOf(adapter, c) {
6360
+ const project = await adapter.resolveProject(c.req.param("id") ?? "");
6361
+ return project ? await adapter.history?.(project) ?? null : null;
6362
+ }
6363
+ async function bodyOf(c) {
6364
+ const body = await c.req.json().catch(() => null);
6365
+ return body && typeof body === "object" ? body : {};
6366
+ }
6367
+ var text = (value) => typeof value === "string" && value ? value : null;
6368
+ function writing(c) {
6369
+ const header = c.req.header("X-Hyperframes-Write-Token");
6370
+ return header ? { writeToken: createWriteToken(header) } : {};
6371
+ }
6372
+ function whoOf(body) {
6373
+ const who = body.who;
6374
+ const name = text(who?.name);
6375
+ return who?.kind === "agent" && name ? { kind: "agent", name } : YOU;
6376
+ }
6377
+ function idleOf(body) {
6378
+ const idleMs = body.idleMs;
6379
+ return typeof idleMs === "number" && idleMs > 0 ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) : void 0;
6380
+ }
6381
+ function versionsOf(value) {
6382
+ if (!value || typeof value !== "object") return void 0;
6383
+ const pairs = Object.entries(value).filter(
6384
+ (pair) => typeof pair[1] === "string"
6385
+ );
6386
+ return pairs.length ? Object.fromEntries(pairs) : void 0;
6387
+ }
6388
+ function nextStep(history, direction) {
6389
+ const target = history.next(direction, YOU);
6390
+ if (!target) return null;
6391
+ const paths = target.files.map((file) => file.path);
6392
+ return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
6393
+ }
6394
+ async function withHistory(adapter, c, task) {
6395
+ const history = await historyOf(adapter, c);
6396
+ if (!history) return c.json({ error: "This project has no history here." }, 404);
6397
+ try {
6398
+ return c.json(await task(history, await bodyOf(c)) ?? null);
6399
+ } catch (error) {
6400
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
6401
+ }
6402
+ }
6403
+ function registerHistoryRoutes(api, adapter) {
6404
+ const windows = /* @__PURE__ */ new Map();
6405
+ const base = "/projects/:id/history";
6406
+ api.get(
6407
+ base,
6408
+ (c) => withHistory(adapter, c, (history) => {
6409
+ const entries = history.list();
6410
+ return { entries, back: nextStep(history, "back"), forward: nextStep(history, "forward") };
6411
+ })
6412
+ );
6413
+ api.post(
6414
+ `${base}/step`,
6415
+ (c) => withHistory(
6416
+ adapter,
6417
+ c,
6418
+ (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU, writing(c))
6419
+ )
6420
+ );
6421
+ api.post(
6422
+ `${base}/undo`,
6423
+ (c) => withHistory(adapter, c, (history, body) => {
6424
+ const mode = UNDO_MODES.find((known) => known === body.mode);
6425
+ return history.undo(text(body.entryId) ?? "", { who: whoOf(body), mode, ...writing(c) });
6426
+ })
6427
+ );
6428
+ api.post(
6429
+ `${base}/restore`,
6430
+ (c) => withHistory(
6431
+ adapter,
6432
+ c,
6433
+ (history, body) => history.restore(text(body.point) ?? "", whoOf(body), writing(c))
6434
+ )
6435
+ );
6436
+ api.get(
6437
+ `${base}/peek/:point`,
6438
+ (c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
6439
+ );
6440
+ api.post(
6441
+ `${base}/pin`,
6442
+ (c) => withHistory(adapter, c, (history, body) => {
6443
+ history.pin(text(body.entryId) ?? "", body.pinned === true);
6444
+ return { ok: true };
6445
+ })
6446
+ );
6447
+ api.get(`${base}/blob/:hash`, async (c) => {
6448
+ const history = await historyOf(adapter, c);
6449
+ const hash = c.req.param("hash") ?? "";
6450
+ const missing = () => c.json({ error: "That file is not kept in this project's history." }, 404);
6451
+ if (!history || !/^[0-9a-f]{64}$/.test(hash)) return missing();
6452
+ const bytes = await history.readBlob(hash).catch(() => null);
6453
+ return bytes ? c.body(new Uint8Array(bytes)) : missing();
6454
+ });
6455
+ api.post(
6456
+ `${base}/claim`,
6457
+ (c) => withHistory(adapter, c, async (history, body) => {
6458
+ const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
6459
+ const coalesceKey = text(body.coalesceKey) ?? void 0;
6460
+ const overwrote = versionsOf(body.overwrote);
6461
+ const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
6462
+ ...coalesceKey && { coalesceKey, idleMs: idleOf(body) ?? Infinity },
6463
+ ...overwrote && { overwrote }
6464
+ });
6465
+ return { claimed };
6466
+ })
6467
+ );
6468
+ api.post(
6469
+ `${base}/window`,
6470
+ (c) => withHistory(adapter, c, async (history, body) => {
6471
+ const idleMs = idleOf(body);
6472
+ const window = await history.beginWindow(
6473
+ whoOf(body),
6474
+ text(body.label) ?? "Edited in Studio",
6475
+ idleMs ? { idleMs } : void 0
6476
+ );
6477
+ windows.set(window.id, { history, window });
6478
+ return { windowId: window.id, startedAt: window.startedAt };
6479
+ })
6480
+ );
6481
+ api.post(
6482
+ `${base}/window/:windowId/close`,
6483
+ (c) => withHistory(adapter, c, async (history) => {
6484
+ const id = c.req.param("windowId") ?? "";
6485
+ const held = windows.get(id);
6486
+ if (held?.history !== history) throw new Error("That window is not open in this project.");
6487
+ windows.delete(id);
6488
+ return { entry: await held.window.close() };
6489
+ })
6490
+ );
6491
+ }
6492
+
6493
+ // src/createStudioApi.ts
6494
+ function createStudioApi(adapter) {
6495
+ const api = new Hono();
6496
+ registerProjectRoutes(api, adapter);
6497
+ registerFileRoutes(api, adapter);
6498
+ registerPreviewRoutes(api, adapter);
6499
+ registerLintRoutes(api, adapter);
6500
+ registerRenderRoutes(api, adapter);
6501
+ registerThumbnailRoutes(api, adapter);
6502
+ registerSelectionRoutes(api, adapter);
6503
+ registerMediaRoutes(api, adapter);
6504
+ registerWaveformRoutes(api, adapter);
6505
+ registerFontRoutes(api);
6506
+ registerRegistryRoutes(api, adapter);
6507
+ registerGlobalAssetRoutes(api);
6508
+ registerHistoryRoutes(api, adapter);
6509
+ return api;
6510
+ }
6511
+
6512
+ // src/history/historyCache.ts
6513
+ function historyCache(open) {
6514
+ const histories = /* @__PURE__ */ new Map();
6515
+ const get = async (projectDir) => {
6516
+ let cached = histories.get(projectDir);
6517
+ if (!cached) histories.set(projectDir, cached = open(projectDir));
6518
+ const opened = await cached;
6519
+ if (!opened?.replacedAtPath()) return opened;
6520
+ const reopen = () => open(projectDir);
6521
+ if (histories.get(projectDir) === cached)
6522
+ histories.set(projectDir, opened.close().then(reopen, reopen));
6523
+ return get(projectDir);
6524
+ };
6525
+ return {
6526
+ get,
6527
+ /** The history if one was opened, without opening it. */
6528
+ peek: (projectDir) => histories.get(projectDir),
6529
+ forget: (projectDir) => histories.delete(projectDir),
6530
+ closeAll: () => Promise.all(
6531
+ [...histories.values()].map((opened) => opened.then((history) => history?.close()))
6532
+ )
6533
+ };
6534
+ }
6535
+
6136
6536
  // src/helpers/duplicateElement.ts
6137
6537
  import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
6138
6538
  function duplicateElementInHtml(source, target, newId, at) {
@@ -6186,7 +6586,7 @@ function numericAttribute(element, name) {
6186
6586
 
6187
6587
  // src/helpers/previewDocumentStore.ts
6188
6588
  import { createHash as createHash7 } from "crypto";
6189
- import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync7, rmSync as rmSync6 } from "fs";
6589
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
6190
6590
  import { join as join20 } from "path";
6191
6591
  function createPreviewDocumentStore(dir, salt) {
6192
6592
  const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
@@ -6204,8 +6604,8 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
6204
6604
  mkdirSync12(dir, { recursive: true });
6205
6605
  const file = fileFor(key);
6206
6606
  replaceFileAtomically(file, html, 420);
6207
- for (const name of readdirSync7(dir)) {
6208
- if (join20(dir, name) !== file) rmSync6(join20(dir, name), { force: true });
6607
+ for (const name of readdirSync8(dir)) {
6608
+ if (join20(dir, name) !== file) rmSync7(join20(dir, name), { force: true });
6209
6609
  }
6210
6610
  } catch {
6211
6611
  }
@@ -6285,10 +6685,13 @@ export {
6285
6685
  DELETED_VERSION,
6286
6686
  START as HISTORY_START,
6287
6687
  HistoryBusyError,
6688
+ HistoryClosedError,
6689
+ MAX_WINDOW_IDLE_MS,
6288
6690
  MIME_TYPES,
6289
6691
  PREVIEW_BUNDLE_OPTIONS,
6290
6692
  STUDIO_MANUAL_EDITS_PATH,
6291
6693
  STUDIO_MOTION_PATH,
6694
+ UNDO_MODES,
6292
6695
  affectsProjectSignature,
6293
6696
  applyFileMutations,
6294
6697
  buildSubCompositionHtml,
@@ -6307,6 +6710,7 @@ export {
6307
6710
  findTargetElement,
6308
6711
  getElementScreenshotClip,
6309
6712
  getMimeType,
6713
+ historyCache,
6310
6714
  identifyFileWrite,
6311
6715
  isSafePath,
6312
6716
  openProjectHistory,