@volter/browser-runtime 0.1.166 → 0.1.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +1 -0
  2. package/dist/browser-file-coherence.d.ts +1 -1
  3. package/dist/browser-file-coherence.d.ts.map +1 -1
  4. package/dist/browser-file-coherence.js +3 -2
  5. package/dist/browser-file-coherence.js.map +1 -1
  6. package/dist/browser-file-times.d.ts +8 -0
  7. package/dist/browser-file-times.d.ts.map +1 -0
  8. package/dist/browser-file-times.js +8 -0
  9. package/dist/browser-file-times.js.map +1 -0
  10. package/dist/browser-filesystem-channel.d.ts +2 -0
  11. package/dist/browser-filesystem-channel.d.ts.map +1 -1
  12. package/dist/browser-filesystem-channel.js +11 -2
  13. package/dist/browser-filesystem-channel.js.map +1 -1
  14. package/dist/browser-filesystem.d.ts +10 -0
  15. package/dist/browser-filesystem.d.ts.map +1 -1
  16. package/dist/browser-filesystem.js.map +1 -1
  17. package/dist/browser-image-ownership.d.ts +15 -0
  18. package/dist/browser-image-ownership.d.ts.map +1 -0
  19. package/dist/browser-image-ownership.js +119 -0
  20. package/dist/browser-image-ownership.js.map +1 -0
  21. package/dist/browser-image-ranges.d.ts +2 -1
  22. package/dist/browser-image-ranges.d.ts.map +1 -1
  23. package/dist/browser-image-ranges.js +8 -1
  24. package/dist/browser-image-ranges.js.map +1 -1
  25. package/dist/browser-memory-filesystem.d.ts +2 -0
  26. package/dist/browser-memory-filesystem.d.ts.map +1 -1
  27. package/dist/browser-memory-filesystem.js +11 -1
  28. package/dist/browser-memory-filesystem.js.map +1 -1
  29. package/dist/browser-opfs-access.d.ts +20 -0
  30. package/dist/browser-opfs-access.d.ts.map +1 -0
  31. package/dist/browser-opfs-access.js +48 -0
  32. package/dist/browser-opfs-access.js.map +1 -0
  33. package/dist/browser-opfs-filesystem.d.ts.map +1 -1
  34. package/dist/browser-opfs-filesystem.js +6 -0
  35. package/dist/browser-opfs-filesystem.js.map +1 -1
  36. package/dist/browser-opfs-storage-worker.d.ts.map +1 -1
  37. package/dist/browser-opfs-storage-worker.js +3 -2
  38. package/dist/browser-opfs-storage-worker.js.map +1 -1
  39. package/dist/browser-pack-store.d.ts +24 -1
  40. package/dist/browser-pack-store.d.ts.map +1 -1
  41. package/dist/browser-pack-store.js +295 -37
  42. package/dist/browser-pack-store.js.map +1 -1
  43. package/dist/browser-posix-metadata.d.ts +2 -0
  44. package/dist/browser-posix-metadata.d.ts.map +1 -1
  45. package/dist/browser-posix-metadata.js +16 -0
  46. package/dist/browser-posix-metadata.js.map +1 -1
  47. package/dist/browser-process-filesystem-policy.d.ts.map +1 -1
  48. package/dist/browser-process-filesystem-policy.js +1 -0
  49. package/dist/browser-process-filesystem-policy.js.map +1 -1
  50. package/dist/browser-process-filesystem.d.ts +2 -1
  51. package/dist/browser-process-filesystem.d.ts.map +1 -1
  52. package/dist/browser-process-filesystem.js +29 -48
  53. package/dist/browser-process-filesystem.js.map +1 -1
  54. package/dist/browser-process-host.d.ts.map +1 -1
  55. package/dist/browser-process-host.js +9 -3
  56. package/dist/browser-process-host.js.map +1 -1
  57. package/dist/browser-process.d.ts +2 -0
  58. package/dist/browser-process.d.ts.map +1 -1
  59. package/dist/browser-project-archive.d.ts.map +1 -1
  60. package/dist/browser-project-archive.js +2 -1
  61. package/dist/browser-project-archive.js.map +1 -1
  62. package/dist/browser-project-store.d.ts.map +1 -1
  63. package/dist/browser-project-store.js +2 -1
  64. package/dist/browser-project-store.js.map +1 -1
  65. package/dist/browser-store-filesystem.d.ts +1 -0
  66. package/dist/browser-store-filesystem.d.ts.map +1 -1
  67. package/dist/browser-store-filesystem.js +3 -0
  68. package/dist/browser-store-filesystem.js.map +1 -1
  69. package/dist/kernel-processes.d.ts +11 -1
  70. package/dist/kernel-processes.d.ts.map +1 -1
  71. package/dist/kernel-processes.js +79 -2
  72. package/dist/kernel-processes.js.map +1 -1
  73. package/package.json +1 -1
@@ -1,6 +1,8 @@
1
+ import { adaptBrowserOpfsSyncAccess, browserOpfsAccessError, isBrowserOpfsAccessContention } from "./browser-opfs-access.js";
1
2
  import { encodeSharedIndex, OverlaidChildren, OverlaidIndex, SharedIndex, sameIndexEntry } from "./browser-pack-index.js";
2
3
  import { PACKAGES_STORE, STORE_BASE_DIRECTORY, STORE_STATE_FILE } from "./browser-store-layout.js";
3
4
  import { ImageRangeReader, IMAGE_RANGES_FILE, validateImageRanges } from "./browser-image-ranges.js";
5
+ import { ImageExtentOwnership } from "./browser-image-ownership.js";
4
6
  /** The one shared store of an origin: every package version's files, once, under `/packages/<name>@<version>/`. */
5
7
  export { PACKAGES_STORE };
6
8
  export const PACKAGES_ROOT = "/packages";
@@ -64,6 +66,10 @@ const DIRECTORY_MODE = 0o755;
64
66
  const FILE_MODE = 0o644;
65
67
  /** The store, bound to the worker that holds its handles. */
66
68
  export function createPackStore(environment, options = {}) {
69
+ return createPackStoreInternal(environment, options);
70
+ }
71
+ /** Maintenance callers already hold PACKAGES_OPEN_LOCK exclusively. */
72
+ function createPackStoreInternal(environment, options = {}, maintenance = false) {
67
73
  const explicitBytes = options.limits?.maxBytes;
68
74
  const maximumEntries = options.limits?.maxEntries ?? MAX_ENTRIES;
69
75
  if ((explicitBytes !== undefined && (!Number.isSafeInteger(explicitBytes) || explicitBytes < 1))
@@ -82,6 +88,8 @@ export function createPackStore(environment, options = {}) {
82
88
  let linkedBytes = 0;
83
89
  /** Linked bytes under the retention root, which the project budget excludes. */
84
90
  let releaseLock;
91
+ let releaseUsersLock;
92
+ let committedPredecessor;
85
93
  let projectId;
86
94
  let generation;
87
95
  let entries = new Map();
@@ -90,6 +98,7 @@ export function createPackStore(environment, options = {}) {
90
98
  let packAccess;
91
99
  let imageRangesDigest;
92
100
  let imageReader;
101
+ let imageOwnership;
93
102
  let deferredImage;
94
103
  let receivedImageCompletion;
95
104
  let receivedImageLine;
@@ -366,7 +375,13 @@ export function createPackStore(environment, options = {}) {
366
375
  if (!access)
367
376
  throw new Error("OPFS filesystem worker is not initialized.");
368
377
  const value = new Uint8Array(length);
369
- if (length > 0 && length <= READ_AHEAD_BYTES && offset >= 0 && offset + length <= immutableReadEnd) {
378
+ const aheadStart = Math.floor(offset / READ_AHEAD_BYTES) * READ_AHEAD_BYTES;
379
+ const aheadEnd = Math.min(Math.ceil((offset + length) / READ_AHEAD_BYTES) * READ_AHEAD_BYTES, immutableReadEnd);
380
+ // Recovered files must not fetch an unrelated old range merely to
381
+ // fill an aligned cache block containing still-unacquired neighbours.
382
+ const boundedOwnership = imageReader && ownsImageExtent(offset, length)
383
+ && !ownsImageExtent(aheadStart, Math.max(0, aheadEnd - aheadStart));
384
+ if (!boundedOwnership && length > 0 && length <= READ_AHEAD_BYTES && offset >= 0 && offset + length <= immutableReadEnd) {
370
385
  let copied = 0;
371
386
  while (copied < length) {
372
387
  const position = offset + copied;
@@ -410,6 +425,17 @@ export function createPackStore(environment, options = {}) {
410
425
  const opening = new AbortController();
411
426
  openingController = opening;
412
427
  try {
428
+ if (!maintenance) {
429
+ const locks = environment.navigator.locks;
430
+ if (!locks)
431
+ throw new Error("Opening a store requires storage ownership locks.");
432
+ await new Promise((ready, fail) => {
433
+ void locks.request(PACKAGES_OPEN_LOCK, { mode: "shared", signal: opening.signal }, () => new Promise(release => {
434
+ releaseUsersLock = release;
435
+ ready();
436
+ })).catch(fail);
437
+ });
438
+ }
413
439
  // A closed holder can be reopened, but no index or failed-write latch from
414
440
  // its previous workspace may become the opening state of the next one.
415
441
  entries = new Map();
@@ -577,10 +603,10 @@ export function createPackStore(environment, options = {}) {
577
603
  }
578
604
  if (!shared) {
579
605
  packAccess.truncate(packBytes);
580
- // Cleanup follows successful validation and replay. On unreadable metadata,
581
- // even staging records may be useful recovery evidence and must survive.
582
- await removeEntry(project, STAGING_DIRECTORY, true);
583
- await discardGenerations(generation);
606
+ // A committed index does not identify interrupted staging files as ours
607
+ // to delete. Each atomic writer cleans only its own temporary file.
608
+ // An open cannot identify a validated predecessor; unknown generations
609
+ // remain available for interrupted-operation diagnosis.
584
610
  opening.signal.throwIfAborted();
585
611
  if (deadBytes > COMPACTION_FLOOR_BYTES && deadBytes > liveBytes)
586
612
  await compact();
@@ -614,7 +640,14 @@ export function createPackStore(environment, options = {}) {
614
640
  catch (error) {
615
641
  failures.push(error);
616
642
  }
643
+ try {
644
+ releaseUsersLock?.();
645
+ }
646
+ catch (error) {
647
+ failures.push(error);
648
+ }
617
649
  releaseLock = undefined;
650
+ releaseUsersLock = undefined;
618
651
  project = undefined;
619
652
  if (failures.length > 1)
620
653
  throw new AggregateError(failures, "OPFS workspace open and cleanup failed.");
@@ -731,14 +764,21 @@ export function createPackStore(environment, options = {}) {
731
764
  abort();
732
765
  });
733
766
  const opened = await Promise.race([
734
- pending.then((access) => ({ access })).catch((cause) => { refusal = cause; return undefined; }),
767
+ pending.then((access) => ({ access })).catch((cause) => {
768
+ refusal = cause;
769
+ // Keep the unsupported sharing-mode explanation below; retry only
770
+ // actual access contention, not quota/security/device failures.
771
+ if (!(cause instanceof TypeError) && !isBrowserOpfsAccessContention(cause))
772
+ throw browserOpfsAccessError("open", cause);
773
+ return undefined;
774
+ }),
735
775
  new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), OPEN_ATTEMPT_MS); }),
736
776
  cancelled,
737
777
  ]);
738
778
  signal?.throwIfAborted();
739
779
  if (opened) {
740
780
  claimed = true;
741
- return opened.access;
781
+ return adaptBrowserOpfsSyncAccess(opened.access);
742
782
  }
743
783
  }
744
784
  finally {
@@ -756,8 +796,10 @@ export function createPackStore(environment, options = {}) {
756
796
  }
757
797
  await new Promise((resolve) => setTimeout(resolve, 500));
758
798
  }
759
- const reason = refusal instanceof Error ? ` (${refusal.message})` : "";
760
- throw new Error(`This browser workspace's ${name} is held by another page${reason}.`);
799
+ if (isBrowserOpfsAccessContention(refusal))
800
+ throw browserOpfsAccessError("open", refusal);
801
+ // No observed permission error means no evidence that another page owns it.
802
+ throw new Error(`This browser workspace's ${name} access handle did not become available.`);
761
803
  }
762
804
  async function openGenerationHandles(directory) {
763
805
  closeGenerationHandles();
@@ -800,7 +842,9 @@ export function createPackStore(environment, options = {}) {
800
842
  if (!line)
801
843
  receivedImageLine?.port.close();
802
844
  receivedImageLine = undefined;
803
- imageReader = new ImageRangeReader(ranges, directory, `${projectId}.${generation}`, completion?.completed, receiptKey, line);
845
+ imageOwnership = new ImageExtentOwnership();
846
+ await imageOwnership.open(directory, ranges.bytes, !readOnly);
847
+ imageReader = new ImageRangeReader(ranges, directory, `${projectId}.${generation}`, completion?.completed, receiptKey, line, (offset, length) => imageOwnership?.covers(offset, length) ?? false);
804
848
  // Whether this reader shares its owner's completion: one that does not
805
849
  // asks the materializer for every range the owner already placed.
806
850
  console.log(`[boot-trace] ${JSON.stringify({ event: "image-reader-open", at: Date.now(), reader: `${projectId}.${generation}`, offered: supplied !== undefined,
@@ -817,6 +861,8 @@ export function createPackStore(environment, options = {}) {
817
861
  function closeGenerationHandles() {
818
862
  imageReader?.close();
819
863
  imageReader = undefined;
864
+ imageOwnership?.close();
865
+ imageOwnership = undefined;
820
866
  deferredImage = undefined;
821
867
  const pack = packAccess, log = logAccess;
822
868
  packAccess = undefined;
@@ -969,12 +1015,6 @@ export function createPackStore(environment, options = {}) {
969
1015
  clearTimeout(timer);
970
1016
  }
971
1017
  }
972
- function validProjectId(value) {
973
- if (typeof value !== "string" || !/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(value)) {
974
- throw new Error("OPFS filesystem projectId is invalid.");
975
- }
976
- return value;
977
- }
978
1018
  async function projectDirectory() {
979
1019
  if (!project)
980
1020
  throw new Error("OPFS filesystem worker is not initialized.");
@@ -1000,8 +1040,13 @@ export function createPackStore(environment, options = {}) {
1000
1040
  }
1001
1041
  }
1002
1042
  /** Only validated committed state establishes which generations are unreachable. */
1003
- async function discardGenerations(retain) {
1043
+ async function discardPredecessor(previous, retain) {
1044
+ if (!previous || previous === retain)
1045
+ return;
1004
1046
  const directory = await projectDirectory();
1047
+ const state = readStorageState(await readJson(directory, STATE_FILE));
1048
+ if (!state || state.generation !== retain)
1049
+ throw new Error("Committed state changed before predecessor release; generations were retained.");
1005
1050
  let root;
1006
1051
  try {
1007
1052
  root = await directory.getDirectoryHandle(GENERATIONS_DIRECTORY);
@@ -1011,9 +1056,12 @@ export function createPackStore(environment, options = {}) {
1011
1056
  return;
1012
1057
  throw cause;
1013
1058
  }
1014
- for await (const [name] of root.entries()) {
1015
- if (name !== retain)
1016
- await removeGenerationQuietly(root, name);
1059
+ try {
1060
+ await root.removeEntry(previous, { recursive: true });
1061
+ }
1062
+ catch (cause) {
1063
+ if (!notFound(cause))
1064
+ throw cause;
1017
1065
  }
1018
1066
  }
1019
1067
  // ---------------------------------------------------------------- writes
@@ -1771,7 +1819,7 @@ export function createPackStore(environment, options = {}) {
1771
1819
  pack.flush();
1772
1820
  log.flush();
1773
1821
  flushStorage();
1774
- const previous = { pack: packAccess, log: logAccess };
1822
+ const previous = { pack: packAccess, log: logAccess, generation };
1775
1823
  packAccess = pack;
1776
1824
  logAccess = log;
1777
1825
  logBytes = 0;
@@ -1799,7 +1847,7 @@ export function createPackStore(environment, options = {}) {
1799
1847
  throw mutationFailure;
1800
1848
  }
1801
1849
  named();
1802
- await discardGenerations(next);
1850
+ await discardPredecessor(previous.generation, next);
1803
1851
  }
1804
1852
  finally {
1805
1853
  if (!switched) {
@@ -1841,10 +1889,10 @@ export function createPackStore(environment, options = {}) {
1841
1889
  if (changed)
1842
1890
  await checkpoint();
1843
1891
  }
1844
- function retain(keep) {
1845
- return rewriteGeneration(() => retainGeneration(keep));
1892
+ function retain(keep, options = {}) {
1893
+ return rewriteGeneration(() => retainGeneration(keep, options.preserveOffsets));
1846
1894
  }
1847
- async function retainGeneration(keep) {
1895
+ async function retainGeneration(keep, preserveOffsets = false) {
1848
1896
  const source = packAccess;
1849
1897
  if (!source)
1850
1898
  throw new Error("OPFS filesystem worker is not initialized.");
@@ -1868,7 +1916,7 @@ export function createPackStore(environment, options = {}) {
1868
1916
  index.set(path, { ...entry, offset: 0, length: 0 });
1869
1917
  continue;
1870
1918
  }
1871
- const offset = cursor;
1919
+ const offset = preserveOffsets ? entry.offset : cursor;
1872
1920
  moved.set(entry.offset, offset);
1873
1921
  let copied = 0;
1874
1922
  while (copied < entry.length) {
@@ -1887,7 +1935,7 @@ export function createPackStore(environment, options = {}) {
1887
1935
  }
1888
1936
  copied += span;
1889
1937
  }
1890
- cursor += entry.length;
1938
+ cursor = Math.max(cursor, offset + entry.length);
1891
1939
  const { capacity: _room, ...extent } = entry;
1892
1940
  index.set(path, { ...extent, offset, length: entry.length });
1893
1941
  }
@@ -1911,6 +1959,7 @@ export function createPackStore(environment, options = {}) {
1911
1959
  }
1912
1960
  }
1913
1961
  async function commitGenerationIn(name, directory, index, bytes, prepared, rangesDigest) {
1962
+ const previous = generation;
1914
1963
  // Publish only a generation whose pack, index and initial journal exist.
1915
1964
  // Opening a committed generation must never fabricate missing data files.
1916
1965
  const journal = await directory.getFileHandle(LOG_FILE, { create: true });
@@ -1952,8 +2001,9 @@ export function createPackStore(environment, options = {}) {
1952
2001
  measureBytes();
1953
2002
  // Another tab may hold a shared store's older generation open; only a
1954
2003
  // project's own are discarded.
2004
+ committedPredecessor = previous;
1955
2005
  if (!shared)
1956
- await discardGenerations(name);
2006
+ await discardPredecessor(previous, name);
1957
2007
  }
1958
2008
  async function installPreparedImage(prepared, json, binary, suppliedRanges, verified = false) {
1959
2009
  assertWritable();
@@ -2522,9 +2572,6 @@ export function createPackStore(environment, options = {}) {
2522
2572
  segments += 1;
2523
2573
  return segments;
2524
2574
  }
2525
- function notFound(cause) {
2526
- return cause instanceof DOMException && cause.name === "NotFoundError";
2527
- }
2528
2575
  /** FNV-1a: enough to tell a whole record from a torn one. */
2529
2576
  function fingerprint(value) {
2530
2577
  let hash = 0x811c9dc5;
@@ -2542,6 +2589,7 @@ export function createPackStore(environment, options = {}) {
2542
2589
  logAccess?.flush();
2543
2590
  closeGenerationHandles();
2544
2591
  releaseLock?.();
2592
+ releaseUsersLock?.();
2545
2593
  });
2546
2594
  function applyNow(mutations, options = {}) {
2547
2595
  assertWritable();
@@ -2598,6 +2646,50 @@ export function createPackStore(environment, options = {}) {
2598
2646
  }
2599
2647
  }, options);
2600
2648
  }
2649
+ function ownsImageExtent(offset, length) {
2650
+ return imageOwnership?.covers(offset, length) ?? true;
2651
+ }
2652
+ function materializeImageExtentsNow(bytes, files, sourceDigest) {
2653
+ assertWritable();
2654
+ if (!shared || !imageReader || !imageOwnership || !packAccess)
2655
+ throw new Error("Only a reserved shared image can acquire existing immutable extents.");
2656
+ if (!/^[a-f0-9]{64}$/u.test(sourceDigest))
2657
+ throw new Error("Immutable acquisition requires its verified source digest.");
2658
+ const acquired = [];
2659
+ // Validate the entire unit before its first write. Matching metadata
2660
+ // comes from an integrity-addressed unit, never a filename substitution.
2661
+ for (const file of files) {
2662
+ const entry = entries.get(requiredPath(file.path));
2663
+ if (!entry || entry.pack || entry.type !== file.type || entry.mode !== file.mode
2664
+ || (file.type !== "directory" && (entry.length !== file.length || file.offset === undefined
2665
+ || !Number.isSafeInteger(file.offset) || file.offset < 0 || file.offset + entry.length > bytes.byteLength))) {
2666
+ throw new Error(`The current image's immutable unit disagrees with retained metadata at ${file.path}. Stored offsets were retained.`);
2667
+ }
2668
+ }
2669
+ for (const file of files) {
2670
+ const entry = entries.get(file.path);
2671
+ if (entry.type === "directory" || entry.length === 0 || ownsImageExtent(entry.offset, entry.length))
2672
+ continue;
2673
+ const content = bytes.subarray(file.offset, file.offset + entry.length);
2674
+ writePackAt(entry.offset, content);
2675
+ acquired.push([entry.offset, entry.length]);
2676
+ }
2677
+ if (acquired.length === 0)
2678
+ return;
2679
+ // Adjacent verified files are one ownership interval; no gap is inferred.
2680
+ acquired.sort((left, right) => left[0] - right[0]);
2681
+ const joined = [];
2682
+ for (const [offset, length] of acquired) {
2683
+ const last = joined.at(-1);
2684
+ if (last && last[0] + last[1] === offset)
2685
+ last[1] += length;
2686
+ else
2687
+ joined.push([offset, length]);
2688
+ }
2689
+ packAccess.flush();
2690
+ imageOwnership.record(joined, sourceDigest);
2691
+ readAhead.clear();
2692
+ }
2601
2693
  /** One batch of index changes: applied, bounded, recorded as one record, or rolled back whole. */
2602
2694
  function runBatch(apply, options) {
2603
2695
  if (!packAccess || !logAccess)
@@ -2696,10 +2788,13 @@ export function createPackStore(environment, options = {}) {
2696
2788
  }
2697
2789
  finally {
2698
2790
  const release = releaseLock;
2791
+ const releaseUsers = releaseUsersLock;
2699
2792
  releaseLock = undefined;
2793
+ releaseUsersLock = undefined;
2700
2794
  project = undefined;
2701
2795
  try {
2702
2796
  release?.();
2797
+ releaseUsers?.();
2703
2798
  }
2704
2799
  finally {
2705
2800
  if (options.ownsPackages)
@@ -2718,6 +2813,13 @@ export function createPackStore(environment, options = {}) {
2718
2813
  readExtent,
2719
2814
  descendants,
2720
2815
  retain,
2816
+ discardUnreferencedGenerations: async () => {
2817
+ assertWritable();
2818
+ if (!generation)
2819
+ throw new Error("No committed generation establishes what can be released.");
2820
+ await discardPredecessor(committedPredecessor, generation);
2821
+ committedPredecessor = undefined;
2822
+ },
2721
2823
  remapLinks,
2722
2824
  childrenOf: (path) => [...(children.get(path) ?? [])].sort(),
2723
2825
  refresh,
@@ -2733,6 +2835,8 @@ export function createPackStore(environment, options = {}) {
2733
2835
  checkpoint,
2734
2836
  deferMaintenance,
2735
2837
  installPreparedImage,
2838
+ ownsImageExtent,
2839
+ materializeImageExtentsNow,
2736
2840
  installPreparedProject,
2737
2841
  get deferredImage() { return deferredImage; },
2738
2842
  get imagePlacedAt() { return project && imageRangesDigest ? generationInitializedAt : undefined; },
@@ -2774,6 +2878,7 @@ export async function openPackReader(environment, projectId, seed, imageLine) {
2774
2878
  throw cause;
2775
2879
  }
2776
2880
  if (!opened && !project.opened()) {
2881
+ project.close();
2777
2882
  packages.close();
2778
2883
  return undefined;
2779
2884
  }
@@ -2785,6 +2890,158 @@ const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
2785
2890
  const SWEEP_MARK_FILE = "sweep.json";
2786
2891
  /** A package or module linked this recently is kept, linked now or not: two projects taking turns on one origin keep both trees. */
2787
2892
  const SWEEP_GRACE_MS = 14 * 24 * 60 * 60 * 1000;
2893
+ /** Stop/dispose the selected project first. Reclamation never relocates another project's offsets. */
2894
+ export async function releaseProjectStore(environment, id, progress) {
2895
+ validProjectId(id);
2896
+ if (id === PACKAGES_STORE || id === STAGING_DIRECTORY || id.startsWith(TREE_STORE_PREFIX))
2897
+ throw new Error("Select an application project to release.");
2898
+ const locks = environment.navigator.locks;
2899
+ if (!locks)
2900
+ throw new Error("Project release requires storage ownership locks.");
2901
+ return locks.request(`supercode-browser-project:${id}`, { mode: "exclusive", ifAvailable: true }, async (projectLock) => {
2902
+ if (!projectLock)
2903
+ throw new Error("This project is still open. Stop it before releasing its saved files.");
2904
+ return locks.request(PACKAGES_OPEN_LOCK, { mode: "exclusive", ifAvailable: true }, async (packagesLock) => {
2905
+ if (!packagesLock)
2906
+ throw new Error("Another project still uses shared packages. Close its page before releasing saved files.");
2907
+ const root = await environment.navigator.storage.getDirectory();
2908
+ let base;
2909
+ try {
2910
+ base = await root.getDirectoryHandle(BASE_DIRECTORY);
2911
+ }
2912
+ catch (cause) {
2913
+ if (!notFound(cause))
2914
+ throw cause;
2915
+ return { project: id, removed: false, projectsRetained: 0, unitsReleased: 0, bytesReleased: 0, before: [], after: [] };
2916
+ }
2917
+ const ids = [];
2918
+ let exists = false, hasPackages = false;
2919
+ for await (const [name, handle] of base.entries()) {
2920
+ if (handle.kind !== "directory")
2921
+ continue;
2922
+ if (name === id) {
2923
+ exists = true;
2924
+ continue;
2925
+ }
2926
+ if (name === PACKAGES_STORE) {
2927
+ hasPackages = true;
2928
+ continue;
2929
+ }
2930
+ if (name === STAGING_DIRECTORY || name.startsWith(TREE_STORE_PREFIX))
2931
+ continue;
2932
+ ids.push(name);
2933
+ }
2934
+ if (!exists)
2935
+ throw new Error(`Saved project ${id} was not found; no files were released.`);
2936
+ ids.sort();
2937
+ const shared = hasPackages ? createPackStoreInternal(environment, {}, true) : undefined;
2938
+ const referenced = new Set();
2939
+ const before = [];
2940
+ try {
2941
+ if (shared)
2942
+ await shared.initialize(PACKAGES_STORE, { shared: true });
2943
+ const known = new Map();
2944
+ for (const entry of shared?.entries.values() ?? [])
2945
+ if (entry.type !== "directory" && entry.length > 0 && !entry.pack) {
2946
+ const same = known.get(entry.offset) ?? [];
2947
+ same.push(entry);
2948
+ known.set(entry.offset, same);
2949
+ }
2950
+ // Read every committed index without maintenance on another project's
2951
+ // generations. Any unreadable or partial state stops before deletion.
2952
+ for (const [position, name] of [...ids, id].entries()) {
2953
+ progress?.(`Checking saved project ${position + 1} of ${ids.length + 1}`);
2954
+ await (await base.getDirectoryHandle(name)).getFileHandle(STATE_FILE);
2955
+ const store = createPackStoreInternal(environment, shared ? { packages: shared } : {}, true);
2956
+ try {
2957
+ await store.initialize(name, { readOnly: true });
2958
+ before.push({ project: name, entries: [...store.entries].sort(([left], [right]) => left.localeCompare(right)) });
2959
+ for (const entry of store.entries.values()) {
2960
+ if (!entry.pack || entry.length === 0)
2961
+ continue;
2962
+ if (!shared)
2963
+ throw new Error(`Retained project ${name} has unknown shared package references.`);
2964
+ if (entry.offset + entry.length > shared.packBytes || !known.get(entry.offset)?.some(source => source.type === entry.type && source.length === entry.length)) {
2965
+ throw new Error(`Project ${name} has an unknown shared extent ${entry.offset}+${entry.length}; all stores were retained.`);
2966
+ }
2967
+ if (name !== id)
2968
+ referenced.add(entry.offset);
2969
+ }
2970
+ }
2971
+ finally {
2972
+ store.close();
2973
+ }
2974
+ }
2975
+ const kept = new Set(), units = new Set(), unitBytes = new Map();
2976
+ if (shared) {
2977
+ for (const [path, entry] of shared.entries) {
2978
+ const unit = unitOf(path);
2979
+ if (unit === undefined)
2980
+ continue;
2981
+ units.add(unit);
2982
+ unitBytes.set(unit, (unitBytes.get(unit) ?? 0) + entry.length);
2983
+ if (entry.type !== "directory" && entry.length > 0 && referenced.has(entry.offset))
2984
+ kept.add(unit);
2985
+ }
2986
+ // An empty unit costs no file bytes; its zero-length links cannot
2987
+ // identify a unique shared offset, so keep its metadata conservatively.
2988
+ for (const [unit, bytes] of unitBytes)
2989
+ if (bytes === 0)
2990
+ kept.add(unit);
2991
+ }
2992
+ const released = [...units].filter(unit => !kept.has(unit));
2993
+ if (released.length) {
2994
+ kept.delete(".layers");
2995
+ kept.delete(".layers-deferred");
2996
+ kept.delete(".layers-local");
2997
+ }
2998
+ // Removing the selected store first is fail-safe: an interrupted
2999
+ // reclamation leaves unused packages, never a project pointing at holes.
3000
+ if (exists) {
3001
+ progress?.("Removing this project's saved files");
3002
+ await base.removeEntry(id, { recursive: true });
3003
+ }
3004
+ let bytesReleased = 0;
3005
+ if (shared && released.length) {
3006
+ bytesReleased = released.reduce((sum, unit) => sum + (unitBytes.get(unit) ?? 0), 0);
3007
+ progress?.("Reclaiming packages no retained project uses");
3008
+ await shared.retain(path => { const unit = unitOf(path); return unit === undefined || kept.has(unit); }, { preserveOffsets: true });
3009
+ // All package users are excluded; every retained link still names
3010
+ // the same offset in the newly committed sparse generation.
3011
+ await shared.discardUnreferencedGenerations();
3012
+ }
3013
+ const after = [];
3014
+ for (const name of ids) {
3015
+ const store = createPackStoreInternal(environment, shared ? { packages: shared } : {}, true);
3016
+ try {
3017
+ await store.initialize(name, { readOnly: true });
3018
+ after.push({ project: name, entries: [...store.entries].sort(([left], [right]) => left.localeCompare(right)) });
3019
+ }
3020
+ finally {
3021
+ store.close();
3022
+ }
3023
+ }
3024
+ return { project: id, removed: exists, projectsRetained: ids.length, unitsReleased: released.length, bytesReleased, before, after };
3025
+ }
3026
+ catch (cause) {
3027
+ throw new Error(`Project release did not finish: ${cause instanceof Error ? cause.message : String(cause)} Other projects were retained; shared reclamation is not complete.`, { cause });
3028
+ }
3029
+ finally {
3030
+ shared?.close();
3031
+ }
3032
+ });
3033
+ });
3034
+ }
3035
+ /** Shared by store initialization and the origin's project-release door. */
3036
+ function validProjectId(value) {
3037
+ if (typeof value !== "string" || !/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(value)) {
3038
+ throw new Error("OPFS filesystem projectId is invalid.");
3039
+ }
3040
+ return value;
3041
+ }
3042
+ function notFound(cause) {
3043
+ return cause instanceof DOMException && cause.name === "NotFoundError";
3044
+ }
2788
3045
  /**
2789
3046
  * Packages and modules no project links leave the shared store. Runs only
2790
3047
  * when no tab holds the store open, so no reader is under it: every
@@ -2824,7 +3081,7 @@ export async function sweepSharedPackages(environment, progress) {
2824
3081
  if (handle.kind === "directory" && name !== PACKAGES_STORE && name !== STAGING_DIRECTORY && !name.startsWith(TREE_STORE_PREFIX))
2825
3082
  projectIds.push(name);
2826
3083
  }
2827
- const shared = createPackStore(environment);
3084
+ const shared = createPackStoreInternal(environment, {}, true);
2828
3085
  if (!await shared.initialize(PACKAGES_STORE, { shared: true })) {
2829
3086
  shared.close();
2830
3087
  return undefined;
@@ -2836,7 +3093,7 @@ export async function sweepSharedPackages(environment, progress) {
2836
3093
  // A sweep has no fixed length; each project opened is a sign of life
2837
3094
  // for the page waiting on the worker's start.
2838
3095
  progress?.(`sweeping the origin's packages, project ${index + 1} of ${projectIds.length}`);
2839
- const project = createPackStore(environment, { packages: shared });
3096
+ const project = createPackStoreInternal(environment, { packages: shared }, true);
2840
3097
  try {
2841
3098
  const opened = await project.initialize(id);
2842
3099
  if (!opened && !project.opened()) {
@@ -2844,9 +3101,9 @@ export async function sweepSharedPackages(environment, progress) {
2844
3101
  continue;
2845
3102
  }
2846
3103
  }
2847
- catch {
3104
+ catch (cause) {
2848
3105
  project.close();
2849
- continue;
3106
+ throw new Error(`Shared package references for project ${id} are unknown; stored packages were retained.`, { cause });
2850
3107
  }
2851
3108
  for (const entry of project.entries.values())
2852
3109
  if (entry.pack && entry.length > 0)
@@ -2879,9 +3136,10 @@ export async function sweepSharedPackages(environment, progress) {
2879
3136
  }
2880
3137
  // A layers document's marker says every unit it names is here: once any
2881
3138
  // unit goes, none of those markers may stand (ADR-0045).
2882
- if ([...units].some((unit) => !kept.has(unit) && unit !== ".layers" && unit !== ".layers-deferred")) {
3139
+ if ([...units].some((unit) => !kept.has(unit) && unit !== ".layers" && unit !== ".layers-deferred" && unit !== ".layers-local")) {
2883
3140
  kept.delete(".layers");
2884
3141
  kept.delete(".layers-deferred");
3142
+ kept.delete(".layers-local");
2885
3143
  }
2886
3144
  for (const unit of units)
2887
3145
  if (!kept.has(unit))
@@ -2945,7 +3203,7 @@ function unitOf(path) {
2945
3203
  return parts.length >= 4 ? parts.slice(0, 4).join("/") : undefined;
2946
3204
  // Each program's tree is its own unit: kept while a project links it or it
2947
3205
  // was placed lately, swept with its receipt when neither holds.
2948
- if (parts[0] === ".programs")
3206
+ if (parts[0] === ".programs" || parts[0] === ".prepared" || parts[0] === ".trees")
2949
3207
  return parts.length >= 2 ? parts.slice(0, 2).join("/") : undefined;
2950
3208
  if (parts[0] === ".browser-packs") {
2951
3209
  if (parts[1] === "objects" && parts.length >= 3)