@valbuild/server 0.103.2 → 0.105.1

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.
@@ -1690,6 +1690,40 @@ class ValOps {
1690
1690
 
1691
1691
  /** The sha256 / hash of schema + config - if this changes users needs to reload */
1692
1692
 
1693
+ /**
1694
+ * What the SHAs above are a fold over, so they can be recomputed.
1695
+ *
1696
+ * See {@link promoteCommittedSources}: the one thing that changes sources
1697
+ * without re-evaluating the modules is a save, and it has to be able to move
1698
+ * the SHAs with them.
1699
+ */
1700
+
1701
+ /**
1702
+ * The extraction's OWN module errors, which are what the fold was given.
1703
+ *
1704
+ * Not the same list as {@link modulesErrors}: that one has the nested
1705
+ * `.jsonValues()` errors concatenated on, and those were never part of the
1706
+ * hash. Re-folding with the wrong list changes the base SHA for no reason.
1707
+ */
1708
+
1709
+ /**
1710
+ * What a save has told us each `.jsonValues()` entry now holds.
1711
+ *
1712
+ * The entry twin of {@link sources}, and it has to be separate because an
1713
+ * entry's content is not IN the source: the source holds a marker, and
1714
+ * {@link getJsonEntries} resolves it by awaiting the marker's own `import()`.
1715
+ * That resolves from the module registry, so after `/save` rewrites a
1716
+ * `*.val.json` the thunk keeps answering with the content from before — and
1717
+ * unlike a module source there is nothing to re-extract, because the memo was
1718
+ * never holding the content in the first place.
1719
+ *
1720
+ * `null` for an entry the commit deleted.
1721
+ *
1722
+ * Never cleared: it describes what is on disk. A host rebuild makes a new
1723
+ * instance, which is the right reset. Bounded by the project's entry count,
1724
+ * holding only the latest content per key.
1725
+ */
1726
+ adoptedJsonEntries = new Map();
1693
1727
  constructor(valModules, options) {
1694
1728
  this.valModules = valModules;
1695
1729
  this.options = options;
@@ -1700,6 +1734,8 @@ class ValOps {
1700
1734
  this.sourcesSha = null;
1701
1735
  this.configSha = null;
1702
1736
  this.modulesErrors = null;
1737
+ this.shaEntries = null;
1738
+ this.shaModuleErrors = null;
1703
1739
  }
1704
1740
 
1705
1741
  // #region stat
@@ -1725,6 +1761,8 @@ class ValOps {
1725
1761
  this.sourcesSha = extracted.sourcesSha;
1726
1762
  this.configSha = extracted.configSha;
1727
1763
  this.modulesErrors = moduleErrors;
1764
+ this.shaEntries = extracted.shaEntries;
1765
+ this.shaModuleErrors = extracted.moduleErrors;
1728
1766
  return {
1729
1767
  baseSha: this.baseSha,
1730
1768
  schemaSha: this.schemaSha,
@@ -1745,6 +1783,141 @@ class ValOps {
1745
1783
  moduleErrors: this.modulesErrors
1746
1784
  };
1747
1785
  }
1786
+
1787
+ /**
1788
+ * These patches are on disk now: adopt what they produced as the committed
1789
+ * sources.
1790
+ *
1791
+ * The entry point for the mechanism {@link promoteCommittedSources} describes,
1792
+ * and the only one — a caller hands over the analysis it just committed and
1793
+ * this works out the rest, so the rule about which sources are adopted lives
1794
+ * in one place rather than at each save site.
1795
+ *
1796
+ * A module whose patches could not be applied cleanly is left alone. `/save`
1797
+ * refuses the whole commit before reaching here if `prepare` found errors, so
1798
+ * this cannot normally fire — but adopting a partially patched source would
1799
+ * put content in the memo that is not what was written, which is worse than
1800
+ * being stale.
1801
+ */
1802
+ async adoptCommittedSources(analysis, preparedCommit) {
1803
+ // Read BEFORE anything is promoted: this applies the chain to the sources as
1804
+ // they stand, and promoting first would apply the same patches twice.
1805
+ const {
1806
+ sources,
1807
+ errors
1808
+ } = await this.getSources(analysis);
1809
+ const adopt = {};
1810
+ for (const [moduleFilePathS, source] of Object.entries(sources)) {
1811
+ const moduleFilePath = moduleFilePathS;
1812
+ if (errors[moduleFilePath] !== undefined) {
1813
+ console.error("Val: not adopting the committed source of a module whose patches " + "did not apply cleanly. Its content here stays as it was until the " + "modules are re-evaluated.", {
1814
+ moduleFilePath,
1815
+ errors: errors[moduleFilePath]
1816
+ });
1817
+ continue;
1818
+ }
1819
+ adopt[moduleFilePath] = source;
1820
+ }
1821
+ this.promoteCommittedSources(adopt);
1822
+ /**
1823
+ * And the `.jsonValues()` entry content, which the sources above do not
1824
+ * carry — they hold markers. See {@link adoptedJsonEntries}.
1825
+ *
1826
+ * Only for a module whose source was adopted. The source is what frames an
1827
+ * entry: it decides which keys exist at all, so adopting one without the
1828
+ * other would leave the content and the key set describing different
1829
+ * moments.
1830
+ */
1831
+ for (const [moduleFilePathS, entries] of Object.entries(preparedCommit.patchedJsonEntries)) {
1832
+ const moduleFilePath = moduleFilePathS;
1833
+ if (adopt[moduleFilePath] === undefined) {
1834
+ continue;
1835
+ }
1836
+ const adopted = this.adoptedJsonEntries.get(moduleFilePath) ?? new Map();
1837
+ for (const [entryKey, content] of Object.entries(entries)) {
1838
+ adopted.set(entryKey, content);
1839
+ }
1840
+ this.adoptedJsonEntries.set(moduleFilePath, adopted);
1841
+ }
1842
+ }
1843
+
1844
+ /**
1845
+ * Adopt sources that have just been written to disk, and move the SHAs with
1846
+ * them.
1847
+ *
1848
+ * ## Why this exists rather than an invalidation
1849
+ *
1850
+ * The obvious thing — throw the memo away after a save so the next read
1851
+ * re-extracts — does not work, and quietly. `extractValModules` gets a
1852
+ * module's content by awaiting its `def`, which is the app's own `import()`:
1853
+ * that resolves from the MODULE REGISTRY, not from the file on disk. Right
1854
+ * after `/save` rewrites a `.val.ts`, the registry still holds the module as
1855
+ * it was evaluated before, so a re-extraction returns the pre-save content and
1856
+ * stores it as fresh. What actually replaces it is the host rebuilding its
1857
+ * module graph and constructing a new `ValOps` — which happens on its own
1858
+ * schedule, and until it does, every read is stale.
1859
+ *
1860
+ * Stale reads here are not abstract: `getJsonEntry` resolves a
1861
+ * `.jsonValues()` entry from the committed source and then replays pending
1862
+ * patches over it, so once a publish has removed the patches, a page rendering
1863
+ * draft content gets the committed value — the one this memo is holding from
1864
+ * before the publish.
1865
+ *
1866
+ * So the save tells us instead. It has just computed what the new committed
1867
+ * sources are, and that answer does not depend on anything being
1868
+ * re-evaluated.
1869
+ *
1870
+ * ## And the SHAs move
1871
+ *
1872
+ * Deliberately, and this is the part with consequences. `baseSha` and
1873
+ * `sourcesSha` identify the sources being served; leaving them still while the
1874
+ * sources move would put a value other code compares against into
1875
+ * disagreement with what it describes. Moving them means a `fs`-mode base SHA
1876
+ * changes within a server's lifetime for the first time, which is a signal the
1877
+ * studio already knows how to read: `PatchStore.reconcileVanished` uses a
1878
+ * moved base to tell "these patches were published" from "these patches were
1879
+ * discarded", and takes them out of the chain without reverting the fields —
1880
+ * which is what a second tab watching a publish needs and could not get
1881
+ * before.
1882
+ *
1883
+ * A module the fold does not know is ignored rather than appended: the fold's
1884
+ * order is `val.modules`, and a path that is not in it has no position, so
1885
+ * there is no honest answer for where its hash would go. It also cannot happen
1886
+ * — a save only ever writes modules it read from here.
1887
+ */
1888
+ promoteCommittedSources(patched) {
1889
+ if (this.sources === null || this.shaEntries === null || this.shaModuleErrors === null) {
1890
+ // Nothing has been read yet, so there is no stale answer to correct and
1891
+ // no fold to replay. The first read extracts, as it always would.
1892
+ return;
1893
+ }
1894
+ const known = new Set(this.shaEntries.map(entry => entry.path));
1895
+ const adopt = Object.entries(patched).filter(([moduleFilePath, source]) => source !== undefined && known.has(moduleFilePath));
1896
+ if (adopt.length === 0) {
1897
+ return;
1898
+ }
1899
+ const bySource = new Map(adopt);
1900
+ // A new object rather than a mutation: `getSources` hands this out, and a
1901
+ // caller holding it must not have the ground move under it.
1902
+ this.sources = {
1903
+ ...this.sources
1904
+ };
1905
+ for (const [moduleFilePath, source] of adopt) {
1906
+ this.sources[moduleFilePath] = source;
1907
+ }
1908
+ this.shaEntries = this.shaEntries.map(entry => {
1909
+ const source = bySource.get(entry.path);
1910
+ return source === undefined ? entry : {
1911
+ ...entry,
1912
+ source
1913
+ };
1914
+ });
1915
+ const shas = core.computeValModuleShas(this.valModules.config, this.shaEntries, this.shaModuleErrors);
1916
+ this.baseSha = shas.baseSha;
1917
+ this.schemaSha = shas.schemaSha;
1918
+ this.sourcesSha = shas.sourcesSha;
1919
+ this.configSha = shas.configSha;
1920
+ }
1748
1921
  async init() {
1749
1922
  const {
1750
1923
  baseSha,
@@ -1894,6 +2067,31 @@ class ValOps {
1894
2067
  baseContent: undefined
1895
2068
  };
1896
2069
  }
2070
+ /**
2071
+ * What a save told us this entry holds, ahead of the thunk.
2072
+ *
2073
+ * The thunk resolves from the module registry, so after `/save` rewrites
2074
+ * a `*.val.json` it keeps answering with the content from before — and
2075
+ * there is nothing to re-extract, because the committed content was
2076
+ * never in the memoised source to begin with. See
2077
+ * {@link adoptedJsonEntries}.
2078
+ *
2079
+ * `null` means the commit deleted the entry, which is reported the same
2080
+ * way an absent key is. (Nearly unreachable — a `remove` also drops the
2081
+ * thunk from the `.val.ts`, so the key is gone from `record` once the
2082
+ * source is adopted — but the map says it, so this says it too.)
2083
+ *
2084
+ * The BASELINE only. Pending patches replay over it below exactly as
2085
+ * they do over the thunk's answer.
2086
+ */
2087
+ const adopted = this.adoptedJsonEntries.get(moduleFilePath);
2088
+ if (adopted !== undefined && adopted.has(entryKey)) {
2089
+ const content = adopted.get(entryKey);
2090
+ return {
2091
+ entryKey,
2092
+ baseContent: content === null ? undefined : content
2093
+ };
2094
+ }
1897
2095
  const thunk = core.Internal.getJsonImport(marker);
1898
2096
  if (!thunk) {
1899
2097
  return {
@@ -2045,24 +2243,27 @@ class ValOps {
2045
2243
  };
2046
2244
  }
2047
2245
 
2048
- // #region getRenders
2246
+ // #region getPreviews
2049
2247
  /**
2050
- * Reifies each module's render from its schema INSTANCE.
2248
+ * Reifies each module's previews from its schema INSTANCE.
2051
2249
  *
2052
- * Kept even though the Studio also computes renders client-side: `select` is a
2053
- * user function that lives on the instance and is not part of the serialized
2250
+ * Kept even though the Studio also computes previews client-side: a preview is
2251
+ * a user function that lives on the instance and is not part of the serialized
2054
2252
  * schema, so a host app that does not render `<ValModulesClient>` has no
2055
- * instances in the browser and would otherwise get no renders at all. See
2253
+ * instances in the browser and would otherwise get no previews at all. See
2056
2254
  * #470.
2255
+ *
2256
+ * A string's `render` needs none of this — it is static config that travels
2257
+ * with the serialized schema.
2057
2258
  */
2058
- async getRenders(schemas, sources) {
2059
- const renders = {};
2259
+ async getPreviews(schemas, sources) {
2260
+ const previews = {};
2060
2261
  for (const [pathS, schema] of Object.entries(schemas)) {
2061
2262
  const path = pathS;
2062
- renders[path] = schema["executeRender"](path, sources[path]);
2263
+ previews[path] = schema["executePreview"](path, sources[path]);
2063
2264
  }
2064
2265
  return {
2065
- renders
2266
+ previews
2066
2267
  };
2067
2268
  }
2068
2269
 
@@ -2593,6 +2794,22 @@ class ValOps {
2593
2794
 
2594
2795
  // jsonValues entry content, keyed by `*.val.json` path. `null` = delete.
2595
2796
  const jsonEntryContents = new Map();
2797
+ /**
2798
+ * The same content, keyed by ENTRY KEY rather than by file path.
2799
+ *
2800
+ * The path is what gets written; the key is what a reader asks for, and it
2801
+ * is dropped at the flush below. Reconstructing it afterwards is not on:
2802
+ * TWO producers turn a key into a path — `resolveEntryJsonPath` and
2803
+ * `getNewJsonEntryPaths`, the latter a locked convention for `add` and a
2804
+ * move's destination — and a marker does not carry its path at read time
2805
+ * (see `jsonEntryFiles.ts`). So it is recorded where the key is known.
2806
+ *
2807
+ * Read by `ValOps.adoptCommittedSources`, so a save can tell this instance
2808
+ * what an entry now holds. Nothing else can: an entry's committed content
2809
+ * is resolved through the marker's own `import()`, which caches, so the
2810
+ * memo cannot be refreshed by re-reading.
2811
+ */
2812
+ const jsonEntryContentsByKey = new Map();
2596
2813
  // Entries added in this commit → their new `*.val.json` path, so later
2597
2814
  // content ops in the same commit resolve to the freshly-created file.
2598
2815
  const entryKeyToJsonPath = new Map();
@@ -2753,6 +2970,7 @@ class ValOps {
2753
2970
  tsSourceFile = insRes.value;
2754
2971
  tsChanged = true;
2755
2972
  jsonEntryContents.set(jsonPath, op.value);
2973
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
2756
2974
  entryKeyToJsonPath.set(cls.entryKey, jsonPath);
2757
2975
  } else if (op.op === "remove") {
2758
2976
  const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
@@ -2770,6 +2988,7 @@ class ValOps {
2770
2988
  tsSourceFile = remRes.value;
2771
2989
  tsChanged = true;
2772
2990
  jsonEntryContents.set(jsonPathRes.value, null);
2991
+ jsonEntryContentsByKey.set(cls.entryKey, null);
2773
2992
  } else if (op.op === "replace") {
2774
2993
  const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
2775
2994
  if (fp.result.isErr(jsonPathRes)) {
@@ -2778,6 +2997,7 @@ class ValOps {
2778
2997
  break;
2779
2998
  }
2780
2999
  jsonEntryContents.set(jsonPathRes.value, op.value);
3000
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
2781
3001
  } else if (op.op === "move" || op.op === "copy") {
2782
3002
  // Rename (move) or duplicate (copy) a whole entry. The new entry
2783
3003
  // gets its own `*.val.json` written with the source entry's
@@ -2837,9 +3057,11 @@ class ValOps {
2837
3057
  tsSourceFile = insRes.value;
2838
3058
  tsChanged = true;
2839
3059
  jsonEntryContents.set(jsonPath, content);
3060
+ jsonEntryContentsByKey.set(cls.entryKey, content);
2840
3061
  entryKeyToJsonPath.set(cls.entryKey, jsonPath);
2841
3062
  if (op.op === "move" && fromPathRes.value !== jsonPath) {
2842
3063
  jsonEntryContents.set(fromPathRes.value, null);
3064
+ jsonEntryContentsByKey.set(fromKey, null);
2843
3065
  }
2844
3066
  } else {
2845
3067
  errors.push({
@@ -2890,6 +3112,7 @@ class ValOps {
2890
3112
  break;
2891
3113
  }
2892
3114
  jsonEntryContents.set(jsonPath, applied.value);
3115
+ jsonEntryContentsByKey.set(cls.entryKey, applied.value);
2893
3116
  }
2894
3117
  }
2895
3118
  if (patchHadError) {
@@ -2960,7 +3183,8 @@ class ValOps {
2960
3183
  path,
2961
3184
  appliedPatches,
2962
3185
  result: sourceFileText,
2963
- extraFiles
3186
+ extraFiles,
3187
+ jsonEntries: Object.fromEntries(jsonEntryContentsByKey)
2964
3188
  };
2965
3189
  }
2966
3190
  }
@@ -2973,6 +3197,7 @@ class ValOps {
2973
3197
  errors
2974
3198
  };
2975
3199
  };
3200
+ const patchedJsonEntries = {};
2976
3201
  const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
2977
3202
  let hasErrors = false;
2978
3203
  const sourceFilePatchErrors = {};
@@ -2998,6 +3223,12 @@ class ValOps {
2998
3223
  for (const [extraPath, data] of Object.entries(res.extraFiles)) {
2999
3224
  patchedSourceFiles[extraPath] = data;
3000
3225
  }
3226
+ // Kept per module and per entry key, not flattened into
3227
+ // `patchedSourceFiles` beside the files: a reader of an entry has a
3228
+ // module and a key, never a path. See `patchedJsonEntries`.
3229
+ if (Object.keys(res.jsonEntries).length > 0) {
3230
+ patchedJsonEntries[res.path] = res.jsonEntries;
3231
+ }
3001
3232
  appliedPatches[res.path] = res.appliedPatches ?? [];
3002
3233
  }
3003
3234
  for (const patchId of res.appliedPatches ?? []) {
@@ -3038,6 +3269,7 @@ class ValOps {
3038
3269
  binaryFilePatchErrors,
3039
3270
  unappliablePatches,
3040
3271
  patchedSourceFiles,
3272
+ patchedJsonEntries,
3041
3273
  previousSourceFiles,
3042
3274
  partiallyPatchedSourceFiles,
3043
3275
  patchedBinaryFilesDescriptors,
@@ -3630,11 +3862,180 @@ function patchRecordFile(patchesDir, patchId) {
3630
3862
  function patchBaseFile(patchesDir, patchId) {
3631
3863
  return path__namespace["default"].join(patchDir(patchesDir, patchId), "base.json");
3632
3864
  }
3865
+
3866
+ /**
3867
+ * Where a patch's uploaded bytes wait for the record that will reference them.
3868
+ *
3869
+ * ## Why they cannot simply be written into the patch directory
3870
+ *
3871
+ * A patch that carries a file is written in TWO requests, and the bytes go
3872
+ * first: the record's `file` op holds only a sha, so a record written before its
3873
+ * bytes would point at nothing. Uploading straight into `<patchId>/files/` left
3874
+ * the directory holding files and no `patch.json` for the length of a round
3875
+ * trip — which is neither of the two shapes this store allows, so
3876
+ * {@link readPatchStore} read it as a patch whose contents were lost and repair
3877
+ * removed it, bytes and all.
3878
+ *
3879
+ * And that window is not passive: writing into the patches directory is exactly
3880
+ * what ends `getStat`'s long poll, so the upload summoned the read that
3881
+ * destroyed it. Replacing an image worked only when the two requests happened to
3882
+ * land close enough together.
3883
+ *
3884
+ * So the bytes are not in the store until they belong to something.
3885
+ * {@link appendPatch} moves them in after writing the record, under the lock, so
3886
+ * no reader ever sees a half-built patch directory — and the invariant that a
3887
+ * directory either holds a usable record or is named by the log holds again,
3888
+ * with nothing to tolerate and no ambiguous state to classify.
3889
+ *
3890
+ * A SIBLING of the patches directory, for two reasons: nothing that reads the
3891
+ * store lists it, and it is on the same filesystem, so moving into place is a
3892
+ * rename rather than a copy.
3893
+ */
3894
+ function uploadsDir(patchesDir) {
3895
+ return path__namespace["default"].join(path__namespace["default"].dirname(patchesDir), "uploads");
3896
+ }
3897
+
3898
+ /** Where one patch's uploads wait. See {@link uploadsDir}. */
3899
+ function patchUploadDir(patchesDir, patchId) {
3900
+ return path__namespace["default"].join(uploadsDir(patchesDir), patchId);
3901
+ }
3902
+
3903
+ /**
3904
+ * The binary layout, relative to whichever directory holds it.
3905
+ *
3906
+ * Shared by the patch directory and the staging directory so the two cannot
3907
+ * drift — a move into place has to land the bytes exactly where a read expects
3908
+ * them.
3909
+ */
3910
+ function binaryFilesDir(dir) {
3911
+ return path__namespace["default"].join(dir, "files");
3912
+ }
3913
+ function binaryFileIn(dir, filePath) {
3914
+ return path__namespace["default"].join(binaryFilesDir(dir), filePath, path__namespace["default"].basename(filePath));
3915
+ }
3916
+ function binaryFileMetadataIn(dir, filePath) {
3917
+ return path__namespace["default"].join(binaryFilesDir(dir), filePath, "metadata.json");
3918
+ }
3633
3919
  function patchBinaryFile(patchesDir, patchId, filePath) {
3634
- return path__namespace["default"].join(patchDir(patchesDir, patchId), "files", filePath, path__namespace["default"].basename(filePath));
3920
+ return binaryFileIn(patchDir(patchesDir, patchId), filePath);
3635
3921
  }
3636
3922
  function patchBinaryFileMetadata(patchesDir, patchId, filePath) {
3637
- return path__namespace["default"].join(patchDir(patchesDir, patchId), "files", filePath, "metadata.json");
3923
+ return binaryFileMetadataIn(patchDir(patchesDir, patchId), filePath);
3924
+ }
3925
+
3926
+ /** The staged twin of {@link patchBinaryFile}. */
3927
+ function stagedPatchBinaryFile(patchesDir, patchId, filePath) {
3928
+ return binaryFileIn(patchUploadDir(patchesDir, patchId), filePath);
3929
+ }
3930
+
3931
+ /** The staged twin of {@link patchBinaryFileMetadata}. */
3932
+ function stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath) {
3933
+ return binaryFileMetadataIn(patchUploadDir(patchesDir, patchId), filePath);
3934
+ }
3935
+
3936
+ /**
3937
+ * Move a patch's staged uploads into the patch directory.
3938
+ *
3939
+ * Called by {@link appendPatch} between the record and the log line, so it runs
3940
+ * under the lock and no reader can observe the halfway state.
3941
+ *
3942
+ * The whole `files` tree in one rename where it can be — the common case, since
3943
+ * a patch's files only ever arrive before its record — and per file otherwise,
3944
+ * for the case where something is already there.
3945
+ */
3946
+ function moveStagedUploadsIn(patchesDir, patchId) {
3947
+ const from = binaryFilesDir(patchUploadDir(patchesDir, patchId));
3948
+ if (!fs__default["default"].existsSync(from)) {
3949
+ return;
3950
+ }
3951
+ const to = binaryFilesDir(patchDir(patchesDir, patchId));
3952
+ if (!fs__default["default"].existsSync(to)) {
3953
+ fs__default["default"].mkdirSync(path__namespace["default"].dirname(to), {
3954
+ recursive: true
3955
+ });
3956
+ fs__default["default"].renameSync(from, to);
3957
+ } else {
3958
+ moveTreeInto(from, to);
3959
+ }
3960
+ removeStagedUploads(patchesDir, patchId);
3961
+ }
3962
+
3963
+ /** File-by-file, for when the destination already holds some of the tree. */
3964
+ function moveTreeInto(from, to) {
3965
+ for (const entry of fs__default["default"].readdirSync(from, {
3966
+ withFileTypes: true
3967
+ })) {
3968
+ const source = path__namespace["default"].join(from, entry.name);
3969
+ const target = path__namespace["default"].join(to, entry.name);
3970
+ if (entry.isDirectory()) {
3971
+ fs__default["default"].mkdirSync(target, {
3972
+ recursive: true
3973
+ });
3974
+ moveTreeInto(source, target);
3975
+ continue;
3976
+ }
3977
+ fs__default["default"].mkdirSync(path__namespace["default"].dirname(target), {
3978
+ recursive: true
3979
+ });
3980
+ fs__default["default"].renameSync(source, target);
3981
+ }
3982
+ }
3983
+
3984
+ /** Drop a patch's staging directory, whatever is left of it. */
3985
+ function removeStagedUploads(patchesDir, patchId) {
3986
+ try {
3987
+ fs__default["default"].rmSync(patchUploadDir(patchesDir, patchId), {
3988
+ recursive: true,
3989
+ force: true
3990
+ });
3991
+ } catch {
3992
+ // Hygiene, not correctness: nothing reads a staging directory that no patch
3993
+ // claims, and the sweep below gets it eventually.
3994
+ }
3995
+ }
3996
+
3997
+ /**
3998
+ * How long an upload nobody claimed is kept.
3999
+ *
4000
+ * Only garbage collection, which is why it can be a guess at all: these bytes
4001
+ * are outside the store, so no reader can mistake them for a patch and nothing
4002
+ * is lost by keeping them a while. The old marker-based attempt at this problem
4003
+ * had a TTL deciding whether to delete something INSIDE the store, where being
4004
+ * wrong meant destroying a live upload.
4005
+ */
4006
+ const STALE_UPLOAD_MS = 24 * 60 * 60 * 1000;
4007
+
4008
+ /**
4009
+ * Drop staged uploads whose patch never arrived.
4010
+ *
4011
+ * A client that dies between the upload and the `PUT` leaves its bytes here.
4012
+ * Nothing references them — no record points at them and the log never named
4013
+ * them — so they are removed without a word.
4014
+ */
4015
+ function sweepStaleUploads(patchesDir, now = Date.now()) {
4016
+ const dir = uploadsDir(patchesDir);
4017
+ if (!fs__default["default"].existsSync(dir)) {
4018
+ return;
4019
+ }
4020
+ let names;
4021
+ try {
4022
+ names = fs__default["default"].readdirSync(dir);
4023
+ } catch {
4024
+ return;
4025
+ }
4026
+ for (const name of names) {
4027
+ const staged = path__namespace["default"].join(dir, name);
4028
+ try {
4029
+ if (now - fs__default["default"].statSync(staged).mtimeMs < STALE_UPLOAD_MS) continue;
4030
+ fs__default["default"].rmSync(staged, {
4031
+ recursive: true,
4032
+ force: true
4033
+ });
4034
+ } catch {
4035
+ // Someone else is writing here, or it is already gone. Either way it is
4036
+ // not this pass's business.
4037
+ }
4038
+ }
3638
4039
  }
3639
4040
 
3640
4041
  /** Names that live in the patches directory but are not patches. */
@@ -3870,6 +4271,18 @@ function writePatchRecord(patchesDir, patchId, record) {
3870
4271
  function appendPatch(patchesDir, record) {
3871
4272
  const patchId = record.patchId;
3872
4273
  writePatchRecord(patchesDir, patchId, record);
4274
+ /*
4275
+ * Then the bytes, then the log line — and the order is the whole point.
4276
+ *
4277
+ * The record goes first, so the directory never exists without one: that is
4278
+ * what makes "files but no patch.json" a state this store cannot produce, and
4279
+ * what lets a reader keep treating it as a patch whose contents are lost.
4280
+ * The log line goes last, so an interrupted append leaves a directory the log
4281
+ * does not name — the benign half of a crash, swept silently.
4282
+ *
4283
+ * See `uploadsDir`. Under the lock, like the rest of this function.
4284
+ */
4285
+ moveStagedUploadsIn(patchesDir, patchId);
3873
4286
  const entry = {
3874
4287
  patchId,
3875
4288
  createdAt: record.createdAt,
@@ -4519,13 +4932,24 @@ class ValOpsFS extends ValOps {
4519
4932
  };
4520
4933
  }
4521
4934
  const patches = announceRes.entries.map(entry => entry.patchId);
4522
- // Drained here, on the channel that always flows. A repair that removed
4523
- // everything leaves the studio nothing to fetch, so a notice riding on
4524
- // `GET /patches` would sit here unread.
4525
- const removed = this.removedPatchNotices.splice(0, this.removedPatchNotices.length);
4526
- const removedNotice = removed.length > 0 ? {
4527
- removed
4528
- } : {};
4935
+ /**
4936
+ * Drained on the channel that always flows.
4937
+ *
4938
+ * A repair that removed everything leaves the studio nothing to fetch, so
4939
+ * a notice riding on `GET /patches` would sit here unread.
4940
+ *
4941
+ * Accumulating rather than assigning, because the long poll below drains
4942
+ * again before it answers: a repair during the wait must not have to sit
4943
+ * out another whole stat.
4944
+ */
4945
+ const removed = [];
4946
+ const drainRemoved = () => {
4947
+ removed.push(...this.removedPatchNotices.splice(0, this.removedPatchNotices.length));
4948
+ return removed.length > 0 ? {
4949
+ removed
4950
+ } : {};
4951
+ };
4952
+ const removedNotice = drainRemoved();
4529
4953
  // something changed: return immediately
4530
4954
  const didChange = !params ||
4531
4955
  // An entry file changed on disk: nothing else here can see that, since a
@@ -4606,6 +5030,14 @@ class ValOpsFS extends ValOps {
4606
5030
  if (Date.now() - start > interval) {
4607
5031
  console.warn("Val: polling interval of files exceeded");
4608
5032
  }
5033
+ // Checked BEFORE rescheduling, as the patches-directory poller
5034
+ // above does. Without it this walk goes on forever: the `finally`
5035
+ // that ends the race clears the handle it can see, and a `go`
5036
+ // already on the queue then schedules one nothing will ever clear —
5037
+ // a leaked timer, re-stat-ing the whole project, per stat poll.
5038
+ if (stopPolling) {
5039
+ return;
5040
+ }
4609
5041
  setHandle(setTimeout(() => go(resolve), interval));
4610
5042
  };
4611
5043
  if (stopPolling) {
@@ -4618,6 +5050,11 @@ class ValOpsFS extends ValOps {
4618
5050
  const disableFilePolling = ((_this$options2 = this.options) === null || _this$options2 === void 0 ? void 0 : _this$options2.disableFilePolling) || false;
4619
5051
  let patchesDirHandle;
4620
5052
  let valFilesIntervalHandle;
5053
+ // Held so the `finally` can clear it. A race the timeout LOSES still
5054
+ // leaves its timer armed, and it is the long one — so every stat that
5055
+ // returned on a file change kept the whole poll interval alive behind it,
5056
+ // doing nothing but holding the event loop open.
5057
+ let noChangeHandle;
4621
5058
  const type = await Promise.race([
4622
5059
  // we poll the patches directory for changes since fs.watch does not work reliably on all system (in particular on WSL) and just checking the patches dir is relatively cheap
4623
5060
  disableFilePolling ? new Promise(() => {}) : didDirectoryChangeUsingPolling(this.getPatchesDir(), statFilePollingInterval, handle => {
@@ -4650,7 +5087,7 @@ class ValOpsFS extends ValOps {
4650
5087
  });
4651
5088
  }), new Promise(resolve => {
4652
5089
  var _this$options3;
4653
- return setTimeout(() => resolve("no-change"), ((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : _this$options3.statPollingInterval) || 20000);
5090
+ noChangeHandle = setTimeout(() => resolve("no-change"), ((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : _this$options3.statPollingInterval) || 20000);
4654
5091
  })]).finally(() => {
4655
5092
  if (fsWatcher) {
4656
5093
  fsWatcher.close();
@@ -4658,14 +5095,52 @@ class ValOpsFS extends ValOps {
4658
5095
  stopPolling = true;
4659
5096
  clearInterval(patchesDirHandle);
4660
5097
  clearInterval(valFilesIntervalHandle);
5098
+ clearTimeout(noChangeHandle);
4661
5099
  });
5100
+ /**
5101
+ * Read the store AGAIN, because `patches` above describes the moment this
5102
+ * poll OPENED — up to a whole polling interval ago.
5103
+ *
5104
+ * That staleness is not a detail: what ends the wait is a write, and the
5105
+ * write the studio does most is `/save`, which in `fs` mode commits the
5106
+ * patches and DELETES them. Answering with the list read before it names
5107
+ * patches that no longer exist, and the studio then puts those ids back in
5108
+ * its chain and fetches them from a server that correctly no longer has
5109
+ * them — "unpublished changes could not be loaded", for changes that were
5110
+ * published a moment earlier. With auto-save on, that is every pause in
5111
+ * typing.
5112
+ *
5113
+ * So a stat describes the moment it ANSWERS. `request-again` and
5114
+ * `no-change` differ in why the wait ended, not in how current the answer
5115
+ * has to be, so both come through here.
5116
+ *
5117
+ * The patch list is the only part that CAN have moved: the shas and the
5118
+ * schemas come from `initSources`, which is memoised for the lifetime of
5119
+ * this instance and never invalidated — a module change makes a new
5120
+ * `ValOpsFS` rather than updating this one. Recomputing them would be a
5121
+ * second full schema serialization per poll for a value that cannot have
5122
+ * changed.
5123
+ *
5124
+ * A read error is returned as one rather than papered over with the list
5125
+ * from the open: a patch store that cannot be read is what the error path
5126
+ * is for, and the studio asks again.
5127
+ */
5128
+ const answerRes = await this.readStore();
5129
+ if (answerRes.status === "error") {
5130
+ return {
5131
+ type: "error",
5132
+ error: {
5133
+ message: answerRes.message
5134
+ }
5135
+ };
5136
+ }
4662
5137
  return {
4663
5138
  type,
4664
5139
  baseSha: currentBaseSha,
4665
5140
  schemaSha: currentSchemaSha,
4666
5141
  sourcesSha: currentSourcesSha,
4667
- patches,
4668
- ...removedNotice,
5142
+ patches: answerRes.entries.map(entry => entry.patchId),
5143
+ ...drainRemoved(),
4669
5144
  jsonEntriesSha: currentJsonEntriesSha
4670
5145
  };
4671
5146
  } catch (err) {
@@ -4997,14 +5472,28 @@ class ValOpsFS extends ValOps {
4997
5472
  }
4998
5473
  async saveBase64EncodedBinaryFileFromPatch(filePath, _parentRef, patchId, data, _type, metadata) {
4999
5474
  // Keyed by the patch's own id, so the parent is not needed and is not asked
5000
- // for. Uploads arrive before the patch record does, which is fine: the
5001
- // directory sits there unreferenced until the log line that names it lands,
5002
- // and repair sweeps it up if that never happens.
5475
+ // for.
5476
+ //
5477
+ // Written OUTSIDE the store, and moved in by `appendPatch` once the record
5478
+ // exists. Uploads arrive before the patch record does — the record's `file`
5479
+ // op carries only a sha, so it would otherwise point at nothing — and
5480
+ // writing them straight into `<patchId>/files/` left a directory holding
5481
+ // files and no `patch.json` for a whole round trip. Nothing can read that as
5482
+ // anything but a patch whose contents are lost, so repair removed it, bytes
5483
+ // and all, and the image 404ed.
5484
+ //
5485
+ // Staging is what makes that state unreachable rather than tolerated. See
5486
+ // `uploadsDir`.
5003
5487
  const patchesDir = this.getPatchesDir();
5004
- const patchFilePath = patchBinaryFile(patchesDir, patchId, filePath);
5005
- const metadataFilePath = patchBinaryFileMetadata(patchesDir, patchId, filePath);
5488
+ const patchFilePath = stagedPatchBinaryFile(patchesDir, patchId, filePath);
5489
+ const metadataFilePath = stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath);
5006
5490
  try {
5007
5491
  if (data === null) {
5492
+ // A delete is the other order — the record is written first — so the
5493
+ // bytes are already in the store. Both locations, because a file staged
5494
+ // and then removed before its record never got there.
5495
+ this.host.deleteFile(patchBinaryFile(patchesDir, patchId, filePath));
5496
+ this.host.deleteFile(patchBinaryFileMetadata(patchesDir, patchId, filePath));
5008
5497
  this.host.deleteFile(patchFilePath);
5009
5498
  this.host.deleteFile(metadataFilePath);
5010
5499
  return {
@@ -5012,6 +5501,9 @@ class ValOpsFS extends ValOps {
5012
5501
  filePath
5013
5502
  };
5014
5503
  }
5504
+ // Cheap, and this is the one path that creates staging directories, so it
5505
+ // is where the ones nobody claimed get noticed.
5506
+ sweepStaleUploads(patchesDir);
5015
5507
  const buffer = bufferFromDataUrl(data);
5016
5508
  if (!buffer) {
5017
5509
  return {
@@ -5041,9 +5533,28 @@ class ValOpsFS extends ValOps {
5041
5533
  };
5042
5534
  }
5043
5535
  }
5536
+
5537
+ /**
5538
+ * Which of the two places a patch's file can be, if either.
5539
+ *
5540
+ * The bytes are in the store once the patch's record is, and in the staging
5541
+ * area before that — see `uploadsDir`. Every reader has to accept both, and
5542
+ * they decide it here rather than each on its own, so two readers of the same
5543
+ * file cannot disagree about whether it exists.
5544
+ */
5545
+ wherePatchFileIs(inStore, staged) {
5546
+ if (this.host.fileExists(inStore)) {
5547
+ return inStore;
5548
+ }
5549
+ if (this.host.fileExists(staged)) {
5550
+ return staged;
5551
+ }
5552
+ return null;
5553
+ }
5044
5554
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId) {
5045
- const metadataFilePath = patchBinaryFileMetadata(this.getPatchesDir(), patchId, filePath);
5046
- if (!this.host.fileExists(metadataFilePath)) {
5555
+ const patchesDir = this.getPatchesDir();
5556
+ const metadataFilePath = this.wherePatchFileIs(patchBinaryFileMetadata(patchesDir, patchId, filePath), stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath));
5557
+ if (metadataFilePath === null) {
5047
5558
  return {
5048
5559
  errors: [{
5049
5560
  message: "Metadata file not found",
@@ -5080,8 +5591,9 @@ class ValOpsFS extends ValOps {
5080
5591
  async getBase64EncodedBinaryFileFromPatch(filePath, patchId) {
5081
5592
  // Straight from the id. This used to read and parse every patch on disk to
5082
5593
  // work out which directory the file was under, on every single image request.
5083
- const absPath = patchBinaryFile(this.getPatchesDir(), patchId, filePath);
5084
- if (!this.host.fileExists(absPath)) {
5594
+ const patchesDir = this.getPatchesDir();
5595
+ const absPath = this.wherePatchFileIs(patchBinaryFile(patchesDir, patchId, filePath), stagedPatchBinaryFile(patchesDir, patchId, filePath));
5596
+ if (absPath === null) {
5085
5597
  return null;
5086
5598
  }
5087
5599
  return this.host.readBinaryFile(absPath);
@@ -5126,6 +5638,9 @@ class ValOpsFS extends ValOps {
5126
5638
  recursive: true,
5127
5639
  force: true
5128
5640
  });
5641
+ // And anything this patch had staged but never moved in, which is
5642
+ // the case where a delete arrives before the record does.
5643
+ removeStagedUploads(patchesDir, patchId);
5129
5644
  deleted.push(patchId);
5130
5645
  } catch (err) {
5131
5646
  // Reported. This endpoint used to answer "deleted" unconditionally —
@@ -7976,11 +8491,11 @@ const ValServer = (valModules, options, callbacks) => {
7976
8491
  // The studio client always passes false: it owns patch application
7977
8492
  // and validation, and treats /sources/~ as a pure un-patched read.
7978
8493
  const applyPatches = query.apply_patches !== false;
7979
- // NOTE: renders are computed here even when the client applies patches
7980
- // itself: the render select functions live on the Schema instances and
7981
- // are not part of the serialized schema, so the client cannot derive
7982
- // them. They are computed on the patched sources, so that previews
7983
- // (list titles / subtitles / images) reflect the patches that apply.
8494
+ // NOTE: previews are computed here even when the client applies patches
8495
+ // itself: the preview functions live on the Schema instances and are not
8496
+ // part of the serialized schema, so the client cannot derive them. They
8497
+ // are computed on the patched sources, so that previews (list titles /
8498
+ // subtitles / images) reflect the patches that apply.
7984
8499
  let patchedSources = sourcesRes.sources;
7985
8500
  if ((((_patchOps$patches = patchOps.patches) === null || _patchOps$patches === void 0 ? void 0 : _patchOps$patches.length) ?? 0) > 0) {
7986
8501
  const onlyPatchedTreeModules = await serverOps.getSources({
@@ -8001,7 +8516,7 @@ const ValServer = (valModules, options, callbacks) => {
8001
8516
  };
8002
8517
  }
8003
8518
  }
8004
- const renderRes = await serverOps.getRenders(schemasRes, patchedSources);
8519
+ const previewRes = await serverOps.getPreviews(schemasRes, patchedSources);
8005
8520
  let sourcesValidation = {
8006
8521
  errors: {},
8007
8522
  files: {},
@@ -8052,7 +8567,7 @@ const ValServer = (valModules, options, callbacks) => {
8052
8567
  // baseSource is only meaningful when the server applied patches:
8053
8568
  // with apply_patches=false, `source` is already un-patched.
8054
8569
  baseSource: applyPatches && hasPatches ? unpatchedSources[moduleFilePath] : undefined,
8055
- render: renderRes.renders[moduleFilePath] || null,
8570
+ preview: previewRes.previews[moduleFilePath] || null,
8056
8571
  patches: appliedPatches.length > 0 || skippedPatches.length > 0 || Object.keys(patchErrors).length > 0 ? {
8057
8572
  applied: appliedPatches,
8058
8573
  skipped: skippedPatches.length > 0 ? skippedPatches : undefined,
@@ -8356,6 +8871,26 @@ const ValServer = (valModules, options, callbacks) => {
8356
8871
  }
8357
8872
  };
8358
8873
  }
8874
+ /*
8875
+ * The files on disk are the committed content now, so say so here too.
8876
+ *
8877
+ * Nothing else will: the sources are memoised per `ValOps` instance
8878
+ * and are re-read by awaiting each module's `def`, which is the app's
8879
+ * own `import()` — that resolves from the module registry, not from
8880
+ * the file this save just rewrote. So until the host rebuilds its
8881
+ * module graph, every read of committed content answers with what was
8882
+ * there before the save. A page rendering draft content sees exactly
8883
+ * that once the patches below are gone: `getJsonEntry` resolves the
8884
+ * committed entry and has no patches left to replay over it.
8885
+ *
8886
+ * Before `deletePatches`, because it needs the patches it is adopting
8887
+ * the result of. See `ValOps.promoteCommittedSources` for why the SHAs
8888
+ * move with them.
8889
+ */
8890
+ await serverOps.adoptCommittedSources({
8891
+ ...analysis,
8892
+ ...patches
8893
+ }, preparedCommit);
8359
8894
  /*
8360
8895
  * Only what this request consumed.
8361
8896
  *