@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.
@@ -1,7 +1,7 @@
1
1
  import ts from 'typescript';
2
2
  import { pipe, result, array } from '@valbuild/core/fp';
3
3
  import { PatchError, deepEqual, parseAndValidateArrayIndex, isNotRoot, applyPatch, JSONOps, deepClone, sourceToPatchPath } from '@valbuild/core/patch';
4
- import { derefPatch, RecordSchema, Internal, extractValModules, VAL_EXTENSION, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
4
+ import { derefPatch, RecordSchema, Internal, extractValModules, computeValModuleShas, VAL_EXTENSION, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
5
5
  export { hasRemoteFileSchema } from '@valbuild/core';
6
6
  import * as path from 'path';
7
7
  import path__default from 'path';
@@ -1656,6 +1656,40 @@ class ValOps {
1656
1656
 
1657
1657
  /** The sha256 / hash of schema + config - if this changes users needs to reload */
1658
1658
 
1659
+ /**
1660
+ * What the SHAs above are a fold over, so they can be recomputed.
1661
+ *
1662
+ * See {@link promoteCommittedSources}: the one thing that changes sources
1663
+ * without re-evaluating the modules is a save, and it has to be able to move
1664
+ * the SHAs with them.
1665
+ */
1666
+
1667
+ /**
1668
+ * The extraction's OWN module errors, which are what the fold was given.
1669
+ *
1670
+ * Not the same list as {@link modulesErrors}: that one has the nested
1671
+ * `.jsonValues()` errors concatenated on, and those were never part of the
1672
+ * hash. Re-folding with the wrong list changes the base SHA for no reason.
1673
+ */
1674
+
1675
+ /**
1676
+ * What a save has told us each `.jsonValues()` entry now holds.
1677
+ *
1678
+ * The entry twin of {@link sources}, and it has to be separate because an
1679
+ * entry's content is not IN the source: the source holds a marker, and
1680
+ * {@link getJsonEntries} resolves it by awaiting the marker's own `import()`.
1681
+ * That resolves from the module registry, so after `/save` rewrites a
1682
+ * `*.val.json` the thunk keeps answering with the content from before — and
1683
+ * unlike a module source there is nothing to re-extract, because the memo was
1684
+ * never holding the content in the first place.
1685
+ *
1686
+ * `null` for an entry the commit deleted.
1687
+ *
1688
+ * Never cleared: it describes what is on disk. A host rebuild makes a new
1689
+ * instance, which is the right reset. Bounded by the project's entry count,
1690
+ * holding only the latest content per key.
1691
+ */
1692
+ adoptedJsonEntries = new Map();
1659
1693
  constructor(valModules, options) {
1660
1694
  this.valModules = valModules;
1661
1695
  this.options = options;
@@ -1666,6 +1700,8 @@ class ValOps {
1666
1700
  this.sourcesSha = null;
1667
1701
  this.configSha = null;
1668
1702
  this.modulesErrors = null;
1703
+ this.shaEntries = null;
1704
+ this.shaModuleErrors = null;
1669
1705
  }
1670
1706
 
1671
1707
  // #region stat
@@ -1691,6 +1727,8 @@ class ValOps {
1691
1727
  this.sourcesSha = extracted.sourcesSha;
1692
1728
  this.configSha = extracted.configSha;
1693
1729
  this.modulesErrors = moduleErrors;
1730
+ this.shaEntries = extracted.shaEntries;
1731
+ this.shaModuleErrors = extracted.moduleErrors;
1694
1732
  return {
1695
1733
  baseSha: this.baseSha,
1696
1734
  schemaSha: this.schemaSha,
@@ -1711,6 +1749,141 @@ class ValOps {
1711
1749
  moduleErrors: this.modulesErrors
1712
1750
  };
1713
1751
  }
1752
+
1753
+ /**
1754
+ * These patches are on disk now: adopt what they produced as the committed
1755
+ * sources.
1756
+ *
1757
+ * The entry point for the mechanism {@link promoteCommittedSources} describes,
1758
+ * and the only one — a caller hands over the analysis it just committed and
1759
+ * this works out the rest, so the rule about which sources are adopted lives
1760
+ * in one place rather than at each save site.
1761
+ *
1762
+ * A module whose patches could not be applied cleanly is left alone. `/save`
1763
+ * refuses the whole commit before reaching here if `prepare` found errors, so
1764
+ * this cannot normally fire — but adopting a partially patched source would
1765
+ * put content in the memo that is not what was written, which is worse than
1766
+ * being stale.
1767
+ */
1768
+ async adoptCommittedSources(analysis, preparedCommit) {
1769
+ // Read BEFORE anything is promoted: this applies the chain to the sources as
1770
+ // they stand, and promoting first would apply the same patches twice.
1771
+ const {
1772
+ sources,
1773
+ errors
1774
+ } = await this.getSources(analysis);
1775
+ const adopt = {};
1776
+ for (const [moduleFilePathS, source] of Object.entries(sources)) {
1777
+ const moduleFilePath = moduleFilePathS;
1778
+ if (errors[moduleFilePath] !== undefined) {
1779
+ 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.", {
1780
+ moduleFilePath,
1781
+ errors: errors[moduleFilePath]
1782
+ });
1783
+ continue;
1784
+ }
1785
+ adopt[moduleFilePath] = source;
1786
+ }
1787
+ this.promoteCommittedSources(adopt);
1788
+ /**
1789
+ * And the `.jsonValues()` entry content, which the sources above do not
1790
+ * carry — they hold markers. See {@link adoptedJsonEntries}.
1791
+ *
1792
+ * Only for a module whose source was adopted. The source is what frames an
1793
+ * entry: it decides which keys exist at all, so adopting one without the
1794
+ * other would leave the content and the key set describing different
1795
+ * moments.
1796
+ */
1797
+ for (const [moduleFilePathS, entries] of Object.entries(preparedCommit.patchedJsonEntries)) {
1798
+ const moduleFilePath = moduleFilePathS;
1799
+ if (adopt[moduleFilePath] === undefined) {
1800
+ continue;
1801
+ }
1802
+ const adopted = this.adoptedJsonEntries.get(moduleFilePath) ?? new Map();
1803
+ for (const [entryKey, content] of Object.entries(entries)) {
1804
+ adopted.set(entryKey, content);
1805
+ }
1806
+ this.adoptedJsonEntries.set(moduleFilePath, adopted);
1807
+ }
1808
+ }
1809
+
1810
+ /**
1811
+ * Adopt sources that have just been written to disk, and move the SHAs with
1812
+ * them.
1813
+ *
1814
+ * ## Why this exists rather than an invalidation
1815
+ *
1816
+ * The obvious thing — throw the memo away after a save so the next read
1817
+ * re-extracts — does not work, and quietly. `extractValModules` gets a
1818
+ * module's content by awaiting its `def`, which is the app's own `import()`:
1819
+ * that resolves from the MODULE REGISTRY, not from the file on disk. Right
1820
+ * after `/save` rewrites a `.val.ts`, the registry still holds the module as
1821
+ * it was evaluated before, so a re-extraction returns the pre-save content and
1822
+ * stores it as fresh. What actually replaces it is the host rebuilding its
1823
+ * module graph and constructing a new `ValOps` — which happens on its own
1824
+ * schedule, and until it does, every read is stale.
1825
+ *
1826
+ * Stale reads here are not abstract: `getJsonEntry` resolves a
1827
+ * `.jsonValues()` entry from the committed source and then replays pending
1828
+ * patches over it, so once a publish has removed the patches, a page rendering
1829
+ * draft content gets the committed value — the one this memo is holding from
1830
+ * before the publish.
1831
+ *
1832
+ * So the save tells us instead. It has just computed what the new committed
1833
+ * sources are, and that answer does not depend on anything being
1834
+ * re-evaluated.
1835
+ *
1836
+ * ## And the SHAs move
1837
+ *
1838
+ * Deliberately, and this is the part with consequences. `baseSha` and
1839
+ * `sourcesSha` identify the sources being served; leaving them still while the
1840
+ * sources move would put a value other code compares against into
1841
+ * disagreement with what it describes. Moving them means a `fs`-mode base SHA
1842
+ * changes within a server's lifetime for the first time, which is a signal the
1843
+ * studio already knows how to read: `PatchStore.reconcileVanished` uses a
1844
+ * moved base to tell "these patches were published" from "these patches were
1845
+ * discarded", and takes them out of the chain without reverting the fields —
1846
+ * which is what a second tab watching a publish needs and could not get
1847
+ * before.
1848
+ *
1849
+ * A module the fold does not know is ignored rather than appended: the fold's
1850
+ * order is `val.modules`, and a path that is not in it has no position, so
1851
+ * there is no honest answer for where its hash would go. It also cannot happen
1852
+ * — a save only ever writes modules it read from here.
1853
+ */
1854
+ promoteCommittedSources(patched) {
1855
+ if (this.sources === null || this.shaEntries === null || this.shaModuleErrors === null) {
1856
+ // Nothing has been read yet, so there is no stale answer to correct and
1857
+ // no fold to replay. The first read extracts, as it always would.
1858
+ return;
1859
+ }
1860
+ const known = new Set(this.shaEntries.map(entry => entry.path));
1861
+ const adopt = Object.entries(patched).filter(([moduleFilePath, source]) => source !== undefined && known.has(moduleFilePath));
1862
+ if (adopt.length === 0) {
1863
+ return;
1864
+ }
1865
+ const bySource = new Map(adopt);
1866
+ // A new object rather than a mutation: `getSources` hands this out, and a
1867
+ // caller holding it must not have the ground move under it.
1868
+ this.sources = {
1869
+ ...this.sources
1870
+ };
1871
+ for (const [moduleFilePath, source] of adopt) {
1872
+ this.sources[moduleFilePath] = source;
1873
+ }
1874
+ this.shaEntries = this.shaEntries.map(entry => {
1875
+ const source = bySource.get(entry.path);
1876
+ return source === undefined ? entry : {
1877
+ ...entry,
1878
+ source
1879
+ };
1880
+ });
1881
+ const shas = computeValModuleShas(this.valModules.config, this.shaEntries, this.shaModuleErrors);
1882
+ this.baseSha = shas.baseSha;
1883
+ this.schemaSha = shas.schemaSha;
1884
+ this.sourcesSha = shas.sourcesSha;
1885
+ this.configSha = shas.configSha;
1886
+ }
1714
1887
  async init() {
1715
1888
  const {
1716
1889
  baseSha,
@@ -1860,6 +2033,31 @@ class ValOps {
1860
2033
  baseContent: undefined
1861
2034
  };
1862
2035
  }
2036
+ /**
2037
+ * What a save told us this entry holds, ahead of the thunk.
2038
+ *
2039
+ * The thunk resolves from the module registry, so after `/save` rewrites
2040
+ * a `*.val.json` it keeps answering with the content from before — and
2041
+ * there is nothing to re-extract, because the committed content was
2042
+ * never in the memoised source to begin with. See
2043
+ * {@link adoptedJsonEntries}.
2044
+ *
2045
+ * `null` means the commit deleted the entry, which is reported the same
2046
+ * way an absent key is. (Nearly unreachable — a `remove` also drops the
2047
+ * thunk from the `.val.ts`, so the key is gone from `record` once the
2048
+ * source is adopted — but the map says it, so this says it too.)
2049
+ *
2050
+ * The BASELINE only. Pending patches replay over it below exactly as
2051
+ * they do over the thunk's answer.
2052
+ */
2053
+ const adopted = this.adoptedJsonEntries.get(moduleFilePath);
2054
+ if (adopted !== undefined && adopted.has(entryKey)) {
2055
+ const content = adopted.get(entryKey);
2056
+ return {
2057
+ entryKey,
2058
+ baseContent: content === null ? undefined : content
2059
+ };
2060
+ }
1863
2061
  const thunk = Internal.getJsonImport(marker);
1864
2062
  if (!thunk) {
1865
2063
  return {
@@ -2011,24 +2209,27 @@ class ValOps {
2011
2209
  };
2012
2210
  }
2013
2211
 
2014
- // #region getRenders
2212
+ // #region getPreviews
2015
2213
  /**
2016
- * Reifies each module's render from its schema INSTANCE.
2214
+ * Reifies each module's previews from its schema INSTANCE.
2017
2215
  *
2018
- * Kept even though the Studio also computes renders client-side: `select` is a
2019
- * user function that lives on the instance and is not part of the serialized
2216
+ * Kept even though the Studio also computes previews client-side: a preview is
2217
+ * a user function that lives on the instance and is not part of the serialized
2020
2218
  * schema, so a host app that does not render `<ValModulesClient>` has no
2021
- * instances in the browser and would otherwise get no renders at all. See
2219
+ * instances in the browser and would otherwise get no previews at all. See
2022
2220
  * #470.
2221
+ *
2222
+ * A string's `render` needs none of this — it is static config that travels
2223
+ * with the serialized schema.
2023
2224
  */
2024
- async getRenders(schemas, sources) {
2025
- const renders = {};
2225
+ async getPreviews(schemas, sources) {
2226
+ const previews = {};
2026
2227
  for (const [pathS, schema] of Object.entries(schemas)) {
2027
2228
  const path = pathS;
2028
- renders[path] = schema["executeRender"](path, sources[path]);
2229
+ previews[path] = schema["executePreview"](path, sources[path]);
2029
2230
  }
2030
2231
  return {
2031
- renders
2232
+ previews
2032
2233
  };
2033
2234
  }
2034
2235
 
@@ -2559,6 +2760,22 @@ class ValOps {
2559
2760
 
2560
2761
  // jsonValues entry content, keyed by `*.val.json` path. `null` = delete.
2561
2762
  const jsonEntryContents = new Map();
2763
+ /**
2764
+ * The same content, keyed by ENTRY KEY rather than by file path.
2765
+ *
2766
+ * The path is what gets written; the key is what a reader asks for, and it
2767
+ * is dropped at the flush below. Reconstructing it afterwards is not on:
2768
+ * TWO producers turn a key into a path — `resolveEntryJsonPath` and
2769
+ * `getNewJsonEntryPaths`, the latter a locked convention for `add` and a
2770
+ * move's destination — and a marker does not carry its path at read time
2771
+ * (see `jsonEntryFiles.ts`). So it is recorded where the key is known.
2772
+ *
2773
+ * Read by `ValOps.adoptCommittedSources`, so a save can tell this instance
2774
+ * what an entry now holds. Nothing else can: an entry's committed content
2775
+ * is resolved through the marker's own `import()`, which caches, so the
2776
+ * memo cannot be refreshed by re-reading.
2777
+ */
2778
+ const jsonEntryContentsByKey = new Map();
2562
2779
  // Entries added in this commit → their new `*.val.json` path, so later
2563
2780
  // content ops in the same commit resolve to the freshly-created file.
2564
2781
  const entryKeyToJsonPath = new Map();
@@ -2719,6 +2936,7 @@ class ValOps {
2719
2936
  tsSourceFile = insRes.value;
2720
2937
  tsChanged = true;
2721
2938
  jsonEntryContents.set(jsonPath, op.value);
2939
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
2722
2940
  entryKeyToJsonPath.set(cls.entryKey, jsonPath);
2723
2941
  } else if (op.op === "remove") {
2724
2942
  const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
@@ -2736,6 +2954,7 @@ class ValOps {
2736
2954
  tsSourceFile = remRes.value;
2737
2955
  tsChanged = true;
2738
2956
  jsonEntryContents.set(jsonPathRes.value, null);
2957
+ jsonEntryContentsByKey.set(cls.entryKey, null);
2739
2958
  } else if (op.op === "replace") {
2740
2959
  const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
2741
2960
  if (result.isErr(jsonPathRes)) {
@@ -2744,6 +2963,7 @@ class ValOps {
2744
2963
  break;
2745
2964
  }
2746
2965
  jsonEntryContents.set(jsonPathRes.value, op.value);
2966
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
2747
2967
  } else if (op.op === "move" || op.op === "copy") {
2748
2968
  // Rename (move) or duplicate (copy) a whole entry. The new entry
2749
2969
  // gets its own `*.val.json` written with the source entry's
@@ -2803,9 +3023,11 @@ class ValOps {
2803
3023
  tsSourceFile = insRes.value;
2804
3024
  tsChanged = true;
2805
3025
  jsonEntryContents.set(jsonPath, content);
3026
+ jsonEntryContentsByKey.set(cls.entryKey, content);
2806
3027
  entryKeyToJsonPath.set(cls.entryKey, jsonPath);
2807
3028
  if (op.op === "move" && fromPathRes.value !== jsonPath) {
2808
3029
  jsonEntryContents.set(fromPathRes.value, null);
3030
+ jsonEntryContentsByKey.set(fromKey, null);
2809
3031
  }
2810
3032
  } else {
2811
3033
  errors.push({
@@ -2856,6 +3078,7 @@ class ValOps {
2856
3078
  break;
2857
3079
  }
2858
3080
  jsonEntryContents.set(jsonPath, applied.value);
3081
+ jsonEntryContentsByKey.set(cls.entryKey, applied.value);
2859
3082
  }
2860
3083
  }
2861
3084
  if (patchHadError) {
@@ -2926,7 +3149,8 @@ class ValOps {
2926
3149
  path,
2927
3150
  appliedPatches,
2928
3151
  result: sourceFileText,
2929
- extraFiles
3152
+ extraFiles,
3153
+ jsonEntries: Object.fromEntries(jsonEntryContentsByKey)
2930
3154
  };
2931
3155
  }
2932
3156
  }
@@ -2939,6 +3163,7 @@ class ValOps {
2939
3163
  errors
2940
3164
  };
2941
3165
  };
3166
+ const patchedJsonEntries = {};
2942
3167
  const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
2943
3168
  let hasErrors = false;
2944
3169
  const sourceFilePatchErrors = {};
@@ -2964,6 +3189,12 @@ class ValOps {
2964
3189
  for (const [extraPath, data] of Object.entries(res.extraFiles)) {
2965
3190
  patchedSourceFiles[extraPath] = data;
2966
3191
  }
3192
+ // Kept per module and per entry key, not flattened into
3193
+ // `patchedSourceFiles` beside the files: a reader of an entry has a
3194
+ // module and a key, never a path. See `patchedJsonEntries`.
3195
+ if (Object.keys(res.jsonEntries).length > 0) {
3196
+ patchedJsonEntries[res.path] = res.jsonEntries;
3197
+ }
2967
3198
  appliedPatches[res.path] = res.appliedPatches ?? [];
2968
3199
  }
2969
3200
  for (const patchId of res.appliedPatches ?? []) {
@@ -3004,6 +3235,7 @@ class ValOps {
3004
3235
  binaryFilePatchErrors,
3005
3236
  unappliablePatches,
3006
3237
  patchedSourceFiles,
3238
+ patchedJsonEntries,
3007
3239
  previousSourceFiles,
3008
3240
  partiallyPatchedSourceFiles,
3009
3241
  patchedBinaryFilesDescriptors,
@@ -3596,11 +3828,180 @@ function patchRecordFile(patchesDir, patchId) {
3596
3828
  function patchBaseFile(patchesDir, patchId) {
3597
3829
  return path__default.join(patchDir(patchesDir, patchId), "base.json");
3598
3830
  }
3831
+
3832
+ /**
3833
+ * Where a patch's uploaded bytes wait for the record that will reference them.
3834
+ *
3835
+ * ## Why they cannot simply be written into the patch directory
3836
+ *
3837
+ * A patch that carries a file is written in TWO requests, and the bytes go
3838
+ * first: the record's `file` op holds only a sha, so a record written before its
3839
+ * bytes would point at nothing. Uploading straight into `<patchId>/files/` left
3840
+ * the directory holding files and no `patch.json` for the length of a round
3841
+ * trip — which is neither of the two shapes this store allows, so
3842
+ * {@link readPatchStore} read it as a patch whose contents were lost and repair
3843
+ * removed it, bytes and all.
3844
+ *
3845
+ * And that window is not passive: writing into the patches directory is exactly
3846
+ * what ends `getStat`'s long poll, so the upload summoned the read that
3847
+ * destroyed it. Replacing an image worked only when the two requests happened to
3848
+ * land close enough together.
3849
+ *
3850
+ * So the bytes are not in the store until they belong to something.
3851
+ * {@link appendPatch} moves them in after writing the record, under the lock, so
3852
+ * no reader ever sees a half-built patch directory — and the invariant that a
3853
+ * directory either holds a usable record or is named by the log holds again,
3854
+ * with nothing to tolerate and no ambiguous state to classify.
3855
+ *
3856
+ * A SIBLING of the patches directory, for two reasons: nothing that reads the
3857
+ * store lists it, and it is on the same filesystem, so moving into place is a
3858
+ * rename rather than a copy.
3859
+ */
3860
+ function uploadsDir(patchesDir) {
3861
+ return path__default.join(path__default.dirname(patchesDir), "uploads");
3862
+ }
3863
+
3864
+ /** Where one patch's uploads wait. See {@link uploadsDir}. */
3865
+ function patchUploadDir(patchesDir, patchId) {
3866
+ return path__default.join(uploadsDir(patchesDir), patchId);
3867
+ }
3868
+
3869
+ /**
3870
+ * The binary layout, relative to whichever directory holds it.
3871
+ *
3872
+ * Shared by the patch directory and the staging directory so the two cannot
3873
+ * drift — a move into place has to land the bytes exactly where a read expects
3874
+ * them.
3875
+ */
3876
+ function binaryFilesDir(dir) {
3877
+ return path__default.join(dir, "files");
3878
+ }
3879
+ function binaryFileIn(dir, filePath) {
3880
+ return path__default.join(binaryFilesDir(dir), filePath, path__default.basename(filePath));
3881
+ }
3882
+ function binaryFileMetadataIn(dir, filePath) {
3883
+ return path__default.join(binaryFilesDir(dir), filePath, "metadata.json");
3884
+ }
3599
3885
  function patchBinaryFile(patchesDir, patchId, filePath) {
3600
- return path__default.join(patchDir(patchesDir, patchId), "files", filePath, path__default.basename(filePath));
3886
+ return binaryFileIn(patchDir(patchesDir, patchId), filePath);
3601
3887
  }
3602
3888
  function patchBinaryFileMetadata(patchesDir, patchId, filePath) {
3603
- return path__default.join(patchDir(patchesDir, patchId), "files", filePath, "metadata.json");
3889
+ return binaryFileMetadataIn(patchDir(patchesDir, patchId), filePath);
3890
+ }
3891
+
3892
+ /** The staged twin of {@link patchBinaryFile}. */
3893
+ function stagedPatchBinaryFile(patchesDir, patchId, filePath) {
3894
+ return binaryFileIn(patchUploadDir(patchesDir, patchId), filePath);
3895
+ }
3896
+
3897
+ /** The staged twin of {@link patchBinaryFileMetadata}. */
3898
+ function stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath) {
3899
+ return binaryFileMetadataIn(patchUploadDir(patchesDir, patchId), filePath);
3900
+ }
3901
+
3902
+ /**
3903
+ * Move a patch's staged uploads into the patch directory.
3904
+ *
3905
+ * Called by {@link appendPatch} between the record and the log line, so it runs
3906
+ * under the lock and no reader can observe the halfway state.
3907
+ *
3908
+ * The whole `files` tree in one rename where it can be — the common case, since
3909
+ * a patch's files only ever arrive before its record — and per file otherwise,
3910
+ * for the case where something is already there.
3911
+ */
3912
+ function moveStagedUploadsIn(patchesDir, patchId) {
3913
+ const from = binaryFilesDir(patchUploadDir(patchesDir, patchId));
3914
+ if (!fs.existsSync(from)) {
3915
+ return;
3916
+ }
3917
+ const to = binaryFilesDir(patchDir(patchesDir, patchId));
3918
+ if (!fs.existsSync(to)) {
3919
+ fs.mkdirSync(path__default.dirname(to), {
3920
+ recursive: true
3921
+ });
3922
+ fs.renameSync(from, to);
3923
+ } else {
3924
+ moveTreeInto(from, to);
3925
+ }
3926
+ removeStagedUploads(patchesDir, patchId);
3927
+ }
3928
+
3929
+ /** File-by-file, for when the destination already holds some of the tree. */
3930
+ function moveTreeInto(from, to) {
3931
+ for (const entry of fs.readdirSync(from, {
3932
+ withFileTypes: true
3933
+ })) {
3934
+ const source = path__default.join(from, entry.name);
3935
+ const target = path__default.join(to, entry.name);
3936
+ if (entry.isDirectory()) {
3937
+ fs.mkdirSync(target, {
3938
+ recursive: true
3939
+ });
3940
+ moveTreeInto(source, target);
3941
+ continue;
3942
+ }
3943
+ fs.mkdirSync(path__default.dirname(target), {
3944
+ recursive: true
3945
+ });
3946
+ fs.renameSync(source, target);
3947
+ }
3948
+ }
3949
+
3950
+ /** Drop a patch's staging directory, whatever is left of it. */
3951
+ function removeStagedUploads(patchesDir, patchId) {
3952
+ try {
3953
+ fs.rmSync(patchUploadDir(patchesDir, patchId), {
3954
+ recursive: true,
3955
+ force: true
3956
+ });
3957
+ } catch {
3958
+ // Hygiene, not correctness: nothing reads a staging directory that no patch
3959
+ // claims, and the sweep below gets it eventually.
3960
+ }
3961
+ }
3962
+
3963
+ /**
3964
+ * How long an upload nobody claimed is kept.
3965
+ *
3966
+ * Only garbage collection, which is why it can be a guess at all: these bytes
3967
+ * are outside the store, so no reader can mistake them for a patch and nothing
3968
+ * is lost by keeping them a while. The old marker-based attempt at this problem
3969
+ * had a TTL deciding whether to delete something INSIDE the store, where being
3970
+ * wrong meant destroying a live upload.
3971
+ */
3972
+ const STALE_UPLOAD_MS = 24 * 60 * 60 * 1000;
3973
+
3974
+ /**
3975
+ * Drop staged uploads whose patch never arrived.
3976
+ *
3977
+ * A client that dies between the upload and the `PUT` leaves its bytes here.
3978
+ * Nothing references them — no record points at them and the log never named
3979
+ * them — so they are removed without a word.
3980
+ */
3981
+ function sweepStaleUploads(patchesDir, now = Date.now()) {
3982
+ const dir = uploadsDir(patchesDir);
3983
+ if (!fs.existsSync(dir)) {
3984
+ return;
3985
+ }
3986
+ let names;
3987
+ try {
3988
+ names = fs.readdirSync(dir);
3989
+ } catch {
3990
+ return;
3991
+ }
3992
+ for (const name of names) {
3993
+ const staged = path__default.join(dir, name);
3994
+ try {
3995
+ if (now - fs.statSync(staged).mtimeMs < STALE_UPLOAD_MS) continue;
3996
+ fs.rmSync(staged, {
3997
+ recursive: true,
3998
+ force: true
3999
+ });
4000
+ } catch {
4001
+ // Someone else is writing here, or it is already gone. Either way it is
4002
+ // not this pass's business.
4003
+ }
4004
+ }
3604
4005
  }
3605
4006
 
3606
4007
  /** Names that live in the patches directory but are not patches. */
@@ -3836,6 +4237,18 @@ function writePatchRecord(patchesDir, patchId, record) {
3836
4237
  function appendPatch(patchesDir, record) {
3837
4238
  const patchId = record.patchId;
3838
4239
  writePatchRecord(patchesDir, patchId, record);
4240
+ /*
4241
+ * Then the bytes, then the log line — and the order is the whole point.
4242
+ *
4243
+ * The record goes first, so the directory never exists without one: that is
4244
+ * what makes "files but no patch.json" a state this store cannot produce, and
4245
+ * what lets a reader keep treating it as a patch whose contents are lost.
4246
+ * The log line goes last, so an interrupted append leaves a directory the log
4247
+ * does not name — the benign half of a crash, swept silently.
4248
+ *
4249
+ * See `uploadsDir`. Under the lock, like the rest of this function.
4250
+ */
4251
+ moveStagedUploadsIn(patchesDir, patchId);
3839
4252
  const entry = {
3840
4253
  patchId,
3841
4254
  createdAt: record.createdAt,
@@ -4485,13 +4898,24 @@ class ValOpsFS extends ValOps {
4485
4898
  };
4486
4899
  }
4487
4900
  const patches = announceRes.entries.map(entry => entry.patchId);
4488
- // Drained here, on the channel that always flows. A repair that removed
4489
- // everything leaves the studio nothing to fetch, so a notice riding on
4490
- // `GET /patches` would sit here unread.
4491
- const removed = this.removedPatchNotices.splice(0, this.removedPatchNotices.length);
4492
- const removedNotice = removed.length > 0 ? {
4493
- removed
4494
- } : {};
4901
+ /**
4902
+ * Drained on the channel that always flows.
4903
+ *
4904
+ * A repair that removed everything leaves the studio nothing to fetch, so
4905
+ * a notice riding on `GET /patches` would sit here unread.
4906
+ *
4907
+ * Accumulating rather than assigning, because the long poll below drains
4908
+ * again before it answers: a repair during the wait must not have to sit
4909
+ * out another whole stat.
4910
+ */
4911
+ const removed = [];
4912
+ const drainRemoved = () => {
4913
+ removed.push(...this.removedPatchNotices.splice(0, this.removedPatchNotices.length));
4914
+ return removed.length > 0 ? {
4915
+ removed
4916
+ } : {};
4917
+ };
4918
+ const removedNotice = drainRemoved();
4495
4919
  // something changed: return immediately
4496
4920
  const didChange = !params ||
4497
4921
  // An entry file changed on disk: nothing else here can see that, since a
@@ -4572,6 +4996,14 @@ class ValOpsFS extends ValOps {
4572
4996
  if (Date.now() - start > interval) {
4573
4997
  console.warn("Val: polling interval of files exceeded");
4574
4998
  }
4999
+ // Checked BEFORE rescheduling, as the patches-directory poller
5000
+ // above does. Without it this walk goes on forever: the `finally`
5001
+ // that ends the race clears the handle it can see, and a `go`
5002
+ // already on the queue then schedules one nothing will ever clear —
5003
+ // a leaked timer, re-stat-ing the whole project, per stat poll.
5004
+ if (stopPolling) {
5005
+ return;
5006
+ }
4575
5007
  setHandle(setTimeout(() => go(resolve), interval));
4576
5008
  };
4577
5009
  if (stopPolling) {
@@ -4584,6 +5016,11 @@ class ValOpsFS extends ValOps {
4584
5016
  const disableFilePolling = ((_this$options2 = this.options) === null || _this$options2 === void 0 ? void 0 : _this$options2.disableFilePolling) || false;
4585
5017
  let patchesDirHandle;
4586
5018
  let valFilesIntervalHandle;
5019
+ // Held so the `finally` can clear it. A race the timeout LOSES still
5020
+ // leaves its timer armed, and it is the long one — so every stat that
5021
+ // returned on a file change kept the whole poll interval alive behind it,
5022
+ // doing nothing but holding the event loop open.
5023
+ let noChangeHandle;
4587
5024
  const type = await Promise.race([
4588
5025
  // 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
4589
5026
  disableFilePolling ? new Promise(() => {}) : didDirectoryChangeUsingPolling(this.getPatchesDir(), statFilePollingInterval, handle => {
@@ -4616,7 +5053,7 @@ class ValOpsFS extends ValOps {
4616
5053
  });
4617
5054
  }), new Promise(resolve => {
4618
5055
  var _this$options3;
4619
- return setTimeout(() => resolve("no-change"), ((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : _this$options3.statPollingInterval) || 20000);
5056
+ noChangeHandle = setTimeout(() => resolve("no-change"), ((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : _this$options3.statPollingInterval) || 20000);
4620
5057
  })]).finally(() => {
4621
5058
  if (fsWatcher) {
4622
5059
  fsWatcher.close();
@@ -4624,14 +5061,52 @@ class ValOpsFS extends ValOps {
4624
5061
  stopPolling = true;
4625
5062
  clearInterval(patchesDirHandle);
4626
5063
  clearInterval(valFilesIntervalHandle);
5064
+ clearTimeout(noChangeHandle);
4627
5065
  });
5066
+ /**
5067
+ * Read the store AGAIN, because `patches` above describes the moment this
5068
+ * poll OPENED — up to a whole polling interval ago.
5069
+ *
5070
+ * That staleness is not a detail: what ends the wait is a write, and the
5071
+ * write the studio does most is `/save`, which in `fs` mode commits the
5072
+ * patches and DELETES them. Answering with the list read before it names
5073
+ * patches that no longer exist, and the studio then puts those ids back in
5074
+ * its chain and fetches them from a server that correctly no longer has
5075
+ * them — "unpublished changes could not be loaded", for changes that were
5076
+ * published a moment earlier. With auto-save on, that is every pause in
5077
+ * typing.
5078
+ *
5079
+ * So a stat describes the moment it ANSWERS. `request-again` and
5080
+ * `no-change` differ in why the wait ended, not in how current the answer
5081
+ * has to be, so both come through here.
5082
+ *
5083
+ * The patch list is the only part that CAN have moved: the shas and the
5084
+ * schemas come from `initSources`, which is memoised for the lifetime of
5085
+ * this instance and never invalidated — a module change makes a new
5086
+ * `ValOpsFS` rather than updating this one. Recomputing them would be a
5087
+ * second full schema serialization per poll for a value that cannot have
5088
+ * changed.
5089
+ *
5090
+ * A read error is returned as one rather than papered over with the list
5091
+ * from the open: a patch store that cannot be read is what the error path
5092
+ * is for, and the studio asks again.
5093
+ */
5094
+ const answerRes = await this.readStore();
5095
+ if (answerRes.status === "error") {
5096
+ return {
5097
+ type: "error",
5098
+ error: {
5099
+ message: answerRes.message
5100
+ }
5101
+ };
5102
+ }
4628
5103
  return {
4629
5104
  type,
4630
5105
  baseSha: currentBaseSha,
4631
5106
  schemaSha: currentSchemaSha,
4632
5107
  sourcesSha: currentSourcesSha,
4633
- patches,
4634
- ...removedNotice,
5108
+ patches: answerRes.entries.map(entry => entry.patchId),
5109
+ ...drainRemoved(),
4635
5110
  jsonEntriesSha: currentJsonEntriesSha
4636
5111
  };
4637
5112
  } catch (err) {
@@ -4963,14 +5438,28 @@ class ValOpsFS extends ValOps {
4963
5438
  }
4964
5439
  async saveBase64EncodedBinaryFileFromPatch(filePath, _parentRef, patchId, data, _type, metadata) {
4965
5440
  // Keyed by the patch's own id, so the parent is not needed and is not asked
4966
- // for. Uploads arrive before the patch record does, which is fine: the
4967
- // directory sits there unreferenced until the log line that names it lands,
4968
- // and repair sweeps it up if that never happens.
5441
+ // for.
5442
+ //
5443
+ // Written OUTSIDE the store, and moved in by `appendPatch` once the record
5444
+ // exists. Uploads arrive before the patch record does — the record's `file`
5445
+ // op carries only a sha, so it would otherwise point at nothing — and
5446
+ // writing them straight into `<patchId>/files/` left a directory holding
5447
+ // files and no `patch.json` for a whole round trip. Nothing can read that as
5448
+ // anything but a patch whose contents are lost, so repair removed it, bytes
5449
+ // and all, and the image 404ed.
5450
+ //
5451
+ // Staging is what makes that state unreachable rather than tolerated. See
5452
+ // `uploadsDir`.
4969
5453
  const patchesDir = this.getPatchesDir();
4970
- const patchFilePath = patchBinaryFile(patchesDir, patchId, filePath);
4971
- const metadataFilePath = patchBinaryFileMetadata(patchesDir, patchId, filePath);
5454
+ const patchFilePath = stagedPatchBinaryFile(patchesDir, patchId, filePath);
5455
+ const metadataFilePath = stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath);
4972
5456
  try {
4973
5457
  if (data === null) {
5458
+ // A delete is the other order — the record is written first — so the
5459
+ // bytes are already in the store. Both locations, because a file staged
5460
+ // and then removed before its record never got there.
5461
+ this.host.deleteFile(patchBinaryFile(patchesDir, patchId, filePath));
5462
+ this.host.deleteFile(patchBinaryFileMetadata(patchesDir, patchId, filePath));
4974
5463
  this.host.deleteFile(patchFilePath);
4975
5464
  this.host.deleteFile(metadataFilePath);
4976
5465
  return {
@@ -4978,6 +5467,9 @@ class ValOpsFS extends ValOps {
4978
5467
  filePath
4979
5468
  };
4980
5469
  }
5470
+ // Cheap, and this is the one path that creates staging directories, so it
5471
+ // is where the ones nobody claimed get noticed.
5472
+ sweepStaleUploads(patchesDir);
4981
5473
  const buffer = bufferFromDataUrl(data);
4982
5474
  if (!buffer) {
4983
5475
  return {
@@ -5007,9 +5499,28 @@ class ValOpsFS extends ValOps {
5007
5499
  };
5008
5500
  }
5009
5501
  }
5502
+
5503
+ /**
5504
+ * Which of the two places a patch's file can be, if either.
5505
+ *
5506
+ * The bytes are in the store once the patch's record is, and in the staging
5507
+ * area before that — see `uploadsDir`. Every reader has to accept both, and
5508
+ * they decide it here rather than each on its own, so two readers of the same
5509
+ * file cannot disagree about whether it exists.
5510
+ */
5511
+ wherePatchFileIs(inStore, staged) {
5512
+ if (this.host.fileExists(inStore)) {
5513
+ return inStore;
5514
+ }
5515
+ if (this.host.fileExists(staged)) {
5516
+ return staged;
5517
+ }
5518
+ return null;
5519
+ }
5010
5520
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId) {
5011
- const metadataFilePath = patchBinaryFileMetadata(this.getPatchesDir(), patchId, filePath);
5012
- if (!this.host.fileExists(metadataFilePath)) {
5521
+ const patchesDir = this.getPatchesDir();
5522
+ const metadataFilePath = this.wherePatchFileIs(patchBinaryFileMetadata(patchesDir, patchId, filePath), stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath));
5523
+ if (metadataFilePath === null) {
5013
5524
  return {
5014
5525
  errors: [{
5015
5526
  message: "Metadata file not found",
@@ -5046,8 +5557,9 @@ class ValOpsFS extends ValOps {
5046
5557
  async getBase64EncodedBinaryFileFromPatch(filePath, patchId) {
5047
5558
  // Straight from the id. This used to read and parse every patch on disk to
5048
5559
  // work out which directory the file was under, on every single image request.
5049
- const absPath = patchBinaryFile(this.getPatchesDir(), patchId, filePath);
5050
- if (!this.host.fileExists(absPath)) {
5560
+ const patchesDir = this.getPatchesDir();
5561
+ const absPath = this.wherePatchFileIs(patchBinaryFile(patchesDir, patchId, filePath), stagedPatchBinaryFile(patchesDir, patchId, filePath));
5562
+ if (absPath === null) {
5051
5563
  return null;
5052
5564
  }
5053
5565
  return this.host.readBinaryFile(absPath);
@@ -5092,6 +5604,9 @@ class ValOpsFS extends ValOps {
5092
5604
  recursive: true,
5093
5605
  force: true
5094
5606
  });
5607
+ // And anything this patch had staged but never moved in, which is
5608
+ // the case where a delete arrives before the record does.
5609
+ removeStagedUploads(patchesDir, patchId);
5095
5610
  deleted.push(patchId);
5096
5611
  } catch (err) {
5097
5612
  // Reported. This endpoint used to answer "deleted" unconditionally —
@@ -7942,11 +8457,11 @@ const ValServer = (valModules, options, callbacks) => {
7942
8457
  // The studio client always passes false: it owns patch application
7943
8458
  // and validation, and treats /sources/~ as a pure un-patched read.
7944
8459
  const applyPatches = query.apply_patches !== false;
7945
- // NOTE: renders are computed here even when the client applies patches
7946
- // itself: the render select functions live on the Schema instances and
7947
- // are not part of the serialized schema, so the client cannot derive
7948
- // them. They are computed on the patched sources, so that previews
7949
- // (list titles / subtitles / images) reflect the patches that apply.
8460
+ // NOTE: previews are computed here even when the client applies patches
8461
+ // itself: the preview functions live on the Schema instances and are not
8462
+ // part of the serialized schema, so the client cannot derive them. They
8463
+ // are computed on the patched sources, so that previews (list titles /
8464
+ // subtitles / images) reflect the patches that apply.
7950
8465
  let patchedSources = sourcesRes.sources;
7951
8466
  if ((((_patchOps$patches = patchOps.patches) === null || _patchOps$patches === void 0 ? void 0 : _patchOps$patches.length) ?? 0) > 0) {
7952
8467
  const onlyPatchedTreeModules = await serverOps.getSources({
@@ -7967,7 +8482,7 @@ const ValServer = (valModules, options, callbacks) => {
7967
8482
  };
7968
8483
  }
7969
8484
  }
7970
- const renderRes = await serverOps.getRenders(schemasRes, patchedSources);
8485
+ const previewRes = await serverOps.getPreviews(schemasRes, patchedSources);
7971
8486
  let sourcesValidation = {
7972
8487
  errors: {},
7973
8488
  files: {},
@@ -8018,7 +8533,7 @@ const ValServer = (valModules, options, callbacks) => {
8018
8533
  // baseSource is only meaningful when the server applied patches:
8019
8534
  // with apply_patches=false, `source` is already un-patched.
8020
8535
  baseSource: applyPatches && hasPatches ? unpatchedSources[moduleFilePath] : undefined,
8021
- render: renderRes.renders[moduleFilePath] || null,
8536
+ preview: previewRes.previews[moduleFilePath] || null,
8022
8537
  patches: appliedPatches.length > 0 || skippedPatches.length > 0 || Object.keys(patchErrors).length > 0 ? {
8023
8538
  applied: appliedPatches,
8024
8539
  skipped: skippedPatches.length > 0 ? skippedPatches : undefined,
@@ -8322,6 +8837,26 @@ const ValServer = (valModules, options, callbacks) => {
8322
8837
  }
8323
8838
  };
8324
8839
  }
8840
+ /*
8841
+ * The files on disk are the committed content now, so say so here too.
8842
+ *
8843
+ * Nothing else will: the sources are memoised per `ValOps` instance
8844
+ * and are re-read by awaiting each module's `def`, which is the app's
8845
+ * own `import()` — that resolves from the module registry, not from
8846
+ * the file this save just rewrote. So until the host rebuilds its
8847
+ * module graph, every read of committed content answers with what was
8848
+ * there before the save. A page rendering draft content sees exactly
8849
+ * that once the patches below are gone: `getJsonEntry` resolves the
8850
+ * committed entry and has no patches left to replay over it.
8851
+ *
8852
+ * Before `deletePatches`, because it needs the patches it is adopting
8853
+ * the result of. See `ValOps.promoteCommittedSources` for why the SHAs
8854
+ * move with them.
8855
+ */
8856
+ await serverOps.adoptCommittedSources({
8857
+ ...analysis,
8858
+ ...patches
8859
+ }, preparedCommit);
8325
8860
  /*
8326
8861
  * Only what this request consumed.
8327
8862
  *