@valbuild/server 0.105.0 → 0.106.0

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, Internal, RecordSchema, 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';
@@ -862,6 +862,150 @@ function resolveRelative(dirName, spec, host) {
862
862
  return null;
863
863
  }
864
864
 
865
+ /**
866
+ * What a `*.val.ts` file that is NOT registered in `val.modules` turns out to
867
+ * be.
868
+ *
869
+ * A file matching `*.val.ts` is not necessarily a Val module: the same
870
+ * convention is used for shared schemas and other content-adjacent helpers, and
871
+ * those are not meant to be registered. Only a file that actually default
872
+ * exports a module is worth warning about; one that default exports something
873
+ * else is a mistake, because nothing will ever load it.
874
+ *
875
+ * See {@link createValModuleFileInspector}.
876
+ */
877
+
878
+ /**
879
+ * Inspects individual `*.val.{ts,js}` files, sharing one module cache and one
880
+ * parsed tsconfig across every call.
881
+ *
882
+ * The default export is checked SYNTACTICALLY first and only evaluated if it is
883
+ * there. That ordering is the point: a `.val.ts` with no default export is a
884
+ * helper file, and evaluating it to learn that would be both wasted work and a
885
+ * way to turn an unrelated top-level throw into a reported error.
886
+ *
887
+ * SECURITY: evaluation goes through the same `vm` loader as
888
+ * {@link loadValModules} — see the warning there. Only ever point this at the
889
+ * project's own first-party files.
890
+ */
891
+ function createValModuleFileInspector(projectRoot, host = ts.sys) {
892
+ const compilerOptions = getCompilerOptions(projectRoot, host);
893
+ const cache = {};
894
+ return absPath => {
895
+ const code = host.readFile(absPath);
896
+ if (code === undefined) {
897
+ return {
898
+ status: "invalid",
899
+ message: `Could not read file: '${absPath}'`
900
+ };
901
+ }
902
+ const sourceFile = ts.createSourceFile(absPath, code, ts.ScriptTarget.ES2020, true);
903
+ if (!hasDefaultExport(sourceFile)) {
904
+ return {
905
+ status: "no-default-export"
906
+ };
907
+ }
908
+ let exports;
909
+ // `loadModule` inserts a module into the cache BEFORE evaluating it, so
910
+ // that a cycle resolves. One that throws therefore leaves a half-built
911
+ // entry behind - and unlike `loadValModules`, which builds a cache per
912
+ // call and lets the throw escape, this cache outlives the failure. A later
913
+ // inspection of the same file (or of the helper that actually threw) would
914
+ // hit that entry, see empty exports, and report "default export is
915
+ // undefined" instead of the real error - or, worse, quietly downgrade it to
916
+ // a warning. So roll the cache back to what it was before this attempt.
917
+ const before = new Set(Object.keys(cache));
918
+ try {
919
+ exports = loadModule(absPath, cache, compilerOptions, host).exports;
920
+ } catch (e) {
921
+ for (const key of Object.keys(cache)) {
922
+ if (!before.has(key)) {
923
+ delete cache[key];
924
+ }
925
+ }
926
+ return {
927
+ status: "invalid",
928
+ message: `Could not be loaded. Error: ${errorMessage(e)}`
929
+ };
930
+ }
931
+ if (Internal.isValModule(exports.default)) {
932
+ return {
933
+ status: "val-module"
934
+ };
935
+ }
936
+ // NOTE: do NOT suggest wrapping this in `c.define`. A shared schema turned
937
+ // into a module is an UNREGISTERED module, i.e. straight back to a warning.
938
+ // The fix is to move it out of the default export slot, which is the one
939
+ // thing about a `.val.ts` that Val reserves for itself.
940
+ return {
941
+ status: "invalid",
942
+ message: `Default export is ${describeDefaultExport(exports.default)}, not a Val module. Only 'c.define(...)' may be the default export of a '*.val.ts' file: use a named export for a shared schema or helper`
943
+ };
944
+ };
945
+ }
946
+
947
+ /**
948
+ * Whether the file exports a RUNTIME value as `default`, without evaluating it.
949
+ *
950
+ * Two things deliberately do not count, because neither exists once the file is
951
+ * transpiled — and treating either as a default export would send a pure helper
952
+ * off to be evaluated and reported:
953
+ *
954
+ * - `export * from "./x"`, since a star re-export never carries the default;
955
+ * - a type-only export, in either of its spellings
956
+ * (`export type { T as default }` and `export { type T as default }`).
957
+ */
958
+ function hasDefaultExport(sourceFile) {
959
+ return sourceFile.statements.some(statement => {
960
+ // `export default <expr>` — but not `export = x`, which shares this node.
961
+ if (ts.isExportAssignment(statement)) {
962
+ return !statement.isExportEquals;
963
+ }
964
+ // `export { x as default }` / `export { default } from "./x"`
965
+ if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
966
+ return statement.exportClause.elements.some(element => !element.isTypeOnly && element.name.text === "default");
967
+ }
968
+ // `export default function f() {}` / `export default class C {}`, which are
969
+ // declarations carrying a `default` modifier rather than export assignments.
970
+ return ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword);
971
+ });
972
+ }
973
+
974
+ /** A short, human-readable "what you exported instead" for the error message. */
975
+ function describeDefaultExport(value) {
976
+ if (value === null) {
977
+ return "null";
978
+ }
979
+ if (Array.isArray(value)) {
980
+ return "an array";
981
+ }
982
+ if (typeof value === "object") {
983
+ // Duck-typed rather than `instanceof Schema`, for the same cross-realm
984
+ // reason `isValModule` avoids a constructor check.
985
+ if ("executeSerialize" in value && typeof value["executeSerialize"] === "function") {
986
+ return "a schema";
987
+ }
988
+ return "an object";
989
+ }
990
+ if (typeof value === "undefined") {
991
+ return "undefined";
992
+ }
993
+ return `a ${typeof value}`;
994
+ }
995
+ function errorMessage(e) {
996
+ // NOT `e instanceof Error`: an error thrown from inside the `vm` context is
997
+ // built from that realm's Error constructor. Duck-type the message instead.
998
+ if (typeof e === "object" && e !== null && "message" in e) {
999
+ const {
1000
+ message
1001
+ } = e;
1002
+ if (typeof message === "string") {
1003
+ return message;
1004
+ }
1005
+ }
1006
+ return String(e);
1007
+ }
1008
+
865
1009
  const jsonOps$1 = new JSONOps();
866
1010
 
867
1011
  /**
@@ -1656,6 +1800,40 @@ class ValOps {
1656
1800
 
1657
1801
  /** The sha256 / hash of schema + config - if this changes users needs to reload */
1658
1802
 
1803
+ /**
1804
+ * What the SHAs above are a fold over, so they can be recomputed.
1805
+ *
1806
+ * See {@link promoteCommittedSources}: the one thing that changes sources
1807
+ * without re-evaluating the modules is a save, and it has to be able to move
1808
+ * the SHAs with them.
1809
+ */
1810
+
1811
+ /**
1812
+ * The extraction's OWN module errors, which are what the fold was given.
1813
+ *
1814
+ * Not the same list as {@link modulesErrors}: that one has the nested
1815
+ * `.jsonValues()` errors concatenated on, and those were never part of the
1816
+ * hash. Re-folding with the wrong list changes the base SHA for no reason.
1817
+ */
1818
+
1819
+ /**
1820
+ * What a save has told us each `.jsonValues()` entry now holds.
1821
+ *
1822
+ * The entry twin of {@link sources}, and it has to be separate because an
1823
+ * entry's content is not IN the source: the source holds a marker, and
1824
+ * {@link getJsonEntries} resolves it by awaiting the marker's own `import()`.
1825
+ * That resolves from the module registry, so after `/save` rewrites a
1826
+ * `*.val.json` the thunk keeps answering with the content from before — and
1827
+ * unlike a module source there is nothing to re-extract, because the memo was
1828
+ * never holding the content in the first place.
1829
+ *
1830
+ * `null` for an entry the commit deleted.
1831
+ *
1832
+ * Never cleared: it describes what is on disk. A host rebuild makes a new
1833
+ * instance, which is the right reset. Bounded by the project's entry count,
1834
+ * holding only the latest content per key.
1835
+ */
1836
+ adoptedJsonEntries = new Map();
1659
1837
  constructor(valModules, options) {
1660
1838
  this.valModules = valModules;
1661
1839
  this.options = options;
@@ -1666,6 +1844,8 @@ class ValOps {
1666
1844
  this.sourcesSha = null;
1667
1845
  this.configSha = null;
1668
1846
  this.modulesErrors = null;
1847
+ this.shaEntries = null;
1848
+ this.shaModuleErrors = null;
1669
1849
  }
1670
1850
 
1671
1851
  // #region stat
@@ -1691,6 +1871,8 @@ class ValOps {
1691
1871
  this.sourcesSha = extracted.sourcesSha;
1692
1872
  this.configSha = extracted.configSha;
1693
1873
  this.modulesErrors = moduleErrors;
1874
+ this.shaEntries = extracted.shaEntries;
1875
+ this.shaModuleErrors = extracted.moduleErrors;
1694
1876
  return {
1695
1877
  baseSha: this.baseSha,
1696
1878
  schemaSha: this.schemaSha,
@@ -1711,6 +1893,141 @@ class ValOps {
1711
1893
  moduleErrors: this.modulesErrors
1712
1894
  };
1713
1895
  }
1896
+
1897
+ /**
1898
+ * These patches are on disk now: adopt what they produced as the committed
1899
+ * sources.
1900
+ *
1901
+ * The entry point for the mechanism {@link promoteCommittedSources} describes,
1902
+ * and the only one — a caller hands over the analysis it just committed and
1903
+ * this works out the rest, so the rule about which sources are adopted lives
1904
+ * in one place rather than at each save site.
1905
+ *
1906
+ * A module whose patches could not be applied cleanly is left alone. `/save`
1907
+ * refuses the whole commit before reaching here if `prepare` found errors, so
1908
+ * this cannot normally fire — but adopting a partially patched source would
1909
+ * put content in the memo that is not what was written, which is worse than
1910
+ * being stale.
1911
+ */
1912
+ async adoptCommittedSources(analysis, preparedCommit) {
1913
+ // Read BEFORE anything is promoted: this applies the chain to the sources as
1914
+ // they stand, and promoting first would apply the same patches twice.
1915
+ const {
1916
+ sources,
1917
+ errors
1918
+ } = await this.getSources(analysis);
1919
+ const adopt = {};
1920
+ for (const [moduleFilePathS, source] of Object.entries(sources)) {
1921
+ const moduleFilePath = moduleFilePathS;
1922
+ if (errors[moduleFilePath] !== undefined) {
1923
+ 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.", {
1924
+ moduleFilePath,
1925
+ errors: errors[moduleFilePath]
1926
+ });
1927
+ continue;
1928
+ }
1929
+ adopt[moduleFilePath] = source;
1930
+ }
1931
+ this.promoteCommittedSources(adopt);
1932
+ /**
1933
+ * And the `.jsonValues()` entry content, which the sources above do not
1934
+ * carry — they hold markers. See {@link adoptedJsonEntries}.
1935
+ *
1936
+ * Only for a module whose source was adopted. The source is what frames an
1937
+ * entry: it decides which keys exist at all, so adopting one without the
1938
+ * other would leave the content and the key set describing different
1939
+ * moments.
1940
+ */
1941
+ for (const [moduleFilePathS, entries] of Object.entries(preparedCommit.patchedJsonEntries)) {
1942
+ const moduleFilePath = moduleFilePathS;
1943
+ if (adopt[moduleFilePath] === undefined) {
1944
+ continue;
1945
+ }
1946
+ const adopted = this.adoptedJsonEntries.get(moduleFilePath) ?? new Map();
1947
+ for (const [entryKey, content] of Object.entries(entries)) {
1948
+ adopted.set(entryKey, content);
1949
+ }
1950
+ this.adoptedJsonEntries.set(moduleFilePath, adopted);
1951
+ }
1952
+ }
1953
+
1954
+ /**
1955
+ * Adopt sources that have just been written to disk, and move the SHAs with
1956
+ * them.
1957
+ *
1958
+ * ## Why this exists rather than an invalidation
1959
+ *
1960
+ * The obvious thing — throw the memo away after a save so the next read
1961
+ * re-extracts — does not work, and quietly. `extractValModules` gets a
1962
+ * module's content by awaiting its `def`, which is the app's own `import()`:
1963
+ * that resolves from the MODULE REGISTRY, not from the file on disk. Right
1964
+ * after `/save` rewrites a `.val.ts`, the registry still holds the module as
1965
+ * it was evaluated before, so a re-extraction returns the pre-save content and
1966
+ * stores it as fresh. What actually replaces it is the host rebuilding its
1967
+ * module graph and constructing a new `ValOps` — which happens on its own
1968
+ * schedule, and until it does, every read is stale.
1969
+ *
1970
+ * Stale reads here are not abstract: `getJsonEntry` resolves a
1971
+ * `.jsonValues()` entry from the committed source and then replays pending
1972
+ * patches over it, so once a publish has removed the patches, a page rendering
1973
+ * draft content gets the committed value — the one this memo is holding from
1974
+ * before the publish.
1975
+ *
1976
+ * So the save tells us instead. It has just computed what the new committed
1977
+ * sources are, and that answer does not depend on anything being
1978
+ * re-evaluated.
1979
+ *
1980
+ * ## And the SHAs move
1981
+ *
1982
+ * Deliberately, and this is the part with consequences. `baseSha` and
1983
+ * `sourcesSha` identify the sources being served; leaving them still while the
1984
+ * sources move would put a value other code compares against into
1985
+ * disagreement with what it describes. Moving them means a `fs`-mode base SHA
1986
+ * changes within a server's lifetime for the first time, which is a signal the
1987
+ * studio already knows how to read: `PatchStore.reconcileVanished` uses a
1988
+ * moved base to tell "these patches were published" from "these patches were
1989
+ * discarded", and takes them out of the chain without reverting the fields —
1990
+ * which is what a second tab watching a publish needs and could not get
1991
+ * before.
1992
+ *
1993
+ * A module the fold does not know is ignored rather than appended: the fold's
1994
+ * order is `val.modules`, and a path that is not in it has no position, so
1995
+ * there is no honest answer for where its hash would go. It also cannot happen
1996
+ * — a save only ever writes modules it read from here.
1997
+ */
1998
+ promoteCommittedSources(patched) {
1999
+ if (this.sources === null || this.shaEntries === null || this.shaModuleErrors === null) {
2000
+ // Nothing has been read yet, so there is no stale answer to correct and
2001
+ // no fold to replay. The first read extracts, as it always would.
2002
+ return;
2003
+ }
2004
+ const known = new Set(this.shaEntries.map(entry => entry.path));
2005
+ const adopt = Object.entries(patched).filter(([moduleFilePath, source]) => source !== undefined && known.has(moduleFilePath));
2006
+ if (adopt.length === 0) {
2007
+ return;
2008
+ }
2009
+ const bySource = new Map(adopt);
2010
+ // A new object rather than a mutation: `getSources` hands this out, and a
2011
+ // caller holding it must not have the ground move under it.
2012
+ this.sources = {
2013
+ ...this.sources
2014
+ };
2015
+ for (const [moduleFilePath, source] of adopt) {
2016
+ this.sources[moduleFilePath] = source;
2017
+ }
2018
+ this.shaEntries = this.shaEntries.map(entry => {
2019
+ const source = bySource.get(entry.path);
2020
+ return source === undefined ? entry : {
2021
+ ...entry,
2022
+ source
2023
+ };
2024
+ });
2025
+ const shas = computeValModuleShas(this.valModules.config, this.shaEntries, this.shaModuleErrors);
2026
+ this.baseSha = shas.baseSha;
2027
+ this.schemaSha = shas.schemaSha;
2028
+ this.sourcesSha = shas.sourcesSha;
2029
+ this.configSha = shas.configSha;
2030
+ }
1714
2031
  async init() {
1715
2032
  const {
1716
2033
  baseSha,
@@ -1860,6 +2177,31 @@ class ValOps {
1860
2177
  baseContent: undefined
1861
2178
  };
1862
2179
  }
2180
+ /**
2181
+ * What a save told us this entry holds, ahead of the thunk.
2182
+ *
2183
+ * The thunk resolves from the module registry, so after `/save` rewrites
2184
+ * a `*.val.json` it keeps answering with the content from before — and
2185
+ * there is nothing to re-extract, because the committed content was
2186
+ * never in the memoised source to begin with. See
2187
+ * {@link adoptedJsonEntries}.
2188
+ *
2189
+ * `null` means the commit deleted the entry, which is reported the same
2190
+ * way an absent key is. (Nearly unreachable — a `remove` also drops the
2191
+ * thunk from the `.val.ts`, so the key is gone from `record` once the
2192
+ * source is adopted — but the map says it, so this says it too.)
2193
+ *
2194
+ * The BASELINE only. Pending patches replay over it below exactly as
2195
+ * they do over the thunk's answer.
2196
+ */
2197
+ const adopted = this.adoptedJsonEntries.get(moduleFilePath);
2198
+ if (adopted !== undefined && adopted.has(entryKey)) {
2199
+ const content = adopted.get(entryKey);
2200
+ return {
2201
+ entryKey,
2202
+ baseContent: content === null ? undefined : content
2203
+ };
2204
+ }
1863
2205
  const thunk = Internal.getJsonImport(marker);
1864
2206
  if (!thunk) {
1865
2207
  return {
@@ -2562,6 +2904,22 @@ class ValOps {
2562
2904
 
2563
2905
  // jsonValues entry content, keyed by `*.val.json` path. `null` = delete.
2564
2906
  const jsonEntryContents = new Map();
2907
+ /**
2908
+ * The same content, keyed by ENTRY KEY rather than by file path.
2909
+ *
2910
+ * The path is what gets written; the key is what a reader asks for, and it
2911
+ * is dropped at the flush below. Reconstructing it afterwards is not on:
2912
+ * TWO producers turn a key into a path — `resolveEntryJsonPath` and
2913
+ * `getNewJsonEntryPaths`, the latter a locked convention for `add` and a
2914
+ * move's destination — and a marker does not carry its path at read time
2915
+ * (see `jsonEntryFiles.ts`). So it is recorded where the key is known.
2916
+ *
2917
+ * Read by `ValOps.adoptCommittedSources`, so a save can tell this instance
2918
+ * what an entry now holds. Nothing else can: an entry's committed content
2919
+ * is resolved through the marker's own `import()`, which caches, so the
2920
+ * memo cannot be refreshed by re-reading.
2921
+ */
2922
+ const jsonEntryContentsByKey = new Map();
2565
2923
  // Entries added in this commit → their new `*.val.json` path, so later
2566
2924
  // content ops in the same commit resolve to the freshly-created file.
2567
2925
  const entryKeyToJsonPath = new Map();
@@ -2722,6 +3080,7 @@ class ValOps {
2722
3080
  tsSourceFile = insRes.value;
2723
3081
  tsChanged = true;
2724
3082
  jsonEntryContents.set(jsonPath, op.value);
3083
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
2725
3084
  entryKeyToJsonPath.set(cls.entryKey, jsonPath);
2726
3085
  } else if (op.op === "remove") {
2727
3086
  const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
@@ -2739,6 +3098,7 @@ class ValOps {
2739
3098
  tsSourceFile = remRes.value;
2740
3099
  tsChanged = true;
2741
3100
  jsonEntryContents.set(jsonPathRes.value, null);
3101
+ jsonEntryContentsByKey.set(cls.entryKey, null);
2742
3102
  } else if (op.op === "replace") {
2743
3103
  const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
2744
3104
  if (result.isErr(jsonPathRes)) {
@@ -2747,6 +3107,7 @@ class ValOps {
2747
3107
  break;
2748
3108
  }
2749
3109
  jsonEntryContents.set(jsonPathRes.value, op.value);
3110
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
2750
3111
  } else if (op.op === "move" || op.op === "copy") {
2751
3112
  // Rename (move) or duplicate (copy) a whole entry. The new entry
2752
3113
  // gets its own `*.val.json` written with the source entry's
@@ -2806,9 +3167,11 @@ class ValOps {
2806
3167
  tsSourceFile = insRes.value;
2807
3168
  tsChanged = true;
2808
3169
  jsonEntryContents.set(jsonPath, content);
3170
+ jsonEntryContentsByKey.set(cls.entryKey, content);
2809
3171
  entryKeyToJsonPath.set(cls.entryKey, jsonPath);
2810
3172
  if (op.op === "move" && fromPathRes.value !== jsonPath) {
2811
3173
  jsonEntryContents.set(fromPathRes.value, null);
3174
+ jsonEntryContentsByKey.set(fromKey, null);
2812
3175
  }
2813
3176
  } else {
2814
3177
  errors.push({
@@ -2859,6 +3222,7 @@ class ValOps {
2859
3222
  break;
2860
3223
  }
2861
3224
  jsonEntryContents.set(jsonPath, applied.value);
3225
+ jsonEntryContentsByKey.set(cls.entryKey, applied.value);
2862
3226
  }
2863
3227
  }
2864
3228
  if (patchHadError) {
@@ -2929,7 +3293,8 @@ class ValOps {
2929
3293
  path,
2930
3294
  appliedPatches,
2931
3295
  result: sourceFileText,
2932
- extraFiles
3296
+ extraFiles,
3297
+ jsonEntries: Object.fromEntries(jsonEntryContentsByKey)
2933
3298
  };
2934
3299
  }
2935
3300
  }
@@ -2942,6 +3307,7 @@ class ValOps {
2942
3307
  errors
2943
3308
  };
2944
3309
  };
3310
+ const patchedJsonEntries = {};
2945
3311
  const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
2946
3312
  let hasErrors = false;
2947
3313
  const sourceFilePatchErrors = {};
@@ -2967,6 +3333,12 @@ class ValOps {
2967
3333
  for (const [extraPath, data] of Object.entries(res.extraFiles)) {
2968
3334
  patchedSourceFiles[extraPath] = data;
2969
3335
  }
3336
+ // Kept per module and per entry key, not flattened into
3337
+ // `patchedSourceFiles` beside the files: a reader of an entry has a
3338
+ // module and a key, never a path. See `patchedJsonEntries`.
3339
+ if (Object.keys(res.jsonEntries).length > 0) {
3340
+ patchedJsonEntries[res.path] = res.jsonEntries;
3341
+ }
2970
3342
  appliedPatches[res.path] = res.appliedPatches ?? [];
2971
3343
  }
2972
3344
  for (const patchId of res.appliedPatches ?? []) {
@@ -3007,6 +3379,7 @@ class ValOps {
3007
3379
  binaryFilePatchErrors,
3008
3380
  unappliablePatches,
3009
3381
  patchedSourceFiles,
3382
+ patchedJsonEntries,
3010
3383
  previousSourceFiles,
3011
3384
  partiallyPatchedSourceFiles,
3012
3385
  patchedBinaryFilesDescriptors,
@@ -3599,11 +3972,180 @@ function patchRecordFile(patchesDir, patchId) {
3599
3972
  function patchBaseFile(patchesDir, patchId) {
3600
3973
  return path__default.join(patchDir(patchesDir, patchId), "base.json");
3601
3974
  }
3975
+
3976
+ /**
3977
+ * Where a patch's uploaded bytes wait for the record that will reference them.
3978
+ *
3979
+ * ## Why they cannot simply be written into the patch directory
3980
+ *
3981
+ * A patch that carries a file is written in TWO requests, and the bytes go
3982
+ * first: the record's `file` op holds only a sha, so a record written before its
3983
+ * bytes would point at nothing. Uploading straight into `<patchId>/files/` left
3984
+ * the directory holding files and no `patch.json` for the length of a round
3985
+ * trip — which is neither of the two shapes this store allows, so
3986
+ * {@link readPatchStore} read it as a patch whose contents were lost and repair
3987
+ * removed it, bytes and all.
3988
+ *
3989
+ * And that window is not passive: writing into the patches directory is exactly
3990
+ * what ends `getStat`'s long poll, so the upload summoned the read that
3991
+ * destroyed it. Replacing an image worked only when the two requests happened to
3992
+ * land close enough together.
3993
+ *
3994
+ * So the bytes are not in the store until they belong to something.
3995
+ * {@link appendPatch} moves them in after writing the record, under the lock, so
3996
+ * no reader ever sees a half-built patch directory — and the invariant that a
3997
+ * directory either holds a usable record or is named by the log holds again,
3998
+ * with nothing to tolerate and no ambiguous state to classify.
3999
+ *
4000
+ * A SIBLING of the patches directory, for two reasons: nothing that reads the
4001
+ * store lists it, and it is on the same filesystem, so moving into place is a
4002
+ * rename rather than a copy.
4003
+ */
4004
+ function uploadsDir(patchesDir) {
4005
+ return path__default.join(path__default.dirname(patchesDir), "uploads");
4006
+ }
4007
+
4008
+ /** Where one patch's uploads wait. See {@link uploadsDir}. */
4009
+ function patchUploadDir(patchesDir, patchId) {
4010
+ return path__default.join(uploadsDir(patchesDir), patchId);
4011
+ }
4012
+
4013
+ /**
4014
+ * The binary layout, relative to whichever directory holds it.
4015
+ *
4016
+ * Shared by the patch directory and the staging directory so the two cannot
4017
+ * drift — a move into place has to land the bytes exactly where a read expects
4018
+ * them.
4019
+ */
4020
+ function binaryFilesDir(dir) {
4021
+ return path__default.join(dir, "files");
4022
+ }
4023
+ function binaryFileIn(dir, filePath) {
4024
+ return path__default.join(binaryFilesDir(dir), filePath, path__default.basename(filePath));
4025
+ }
4026
+ function binaryFileMetadataIn(dir, filePath) {
4027
+ return path__default.join(binaryFilesDir(dir), filePath, "metadata.json");
4028
+ }
3602
4029
  function patchBinaryFile(patchesDir, patchId, filePath) {
3603
- return path__default.join(patchDir(patchesDir, patchId), "files", filePath, path__default.basename(filePath));
4030
+ return binaryFileIn(patchDir(patchesDir, patchId), filePath);
3604
4031
  }
3605
4032
  function patchBinaryFileMetadata(patchesDir, patchId, filePath) {
3606
- return path__default.join(patchDir(patchesDir, patchId), "files", filePath, "metadata.json");
4033
+ return binaryFileMetadataIn(patchDir(patchesDir, patchId), filePath);
4034
+ }
4035
+
4036
+ /** The staged twin of {@link patchBinaryFile}. */
4037
+ function stagedPatchBinaryFile(patchesDir, patchId, filePath) {
4038
+ return binaryFileIn(patchUploadDir(patchesDir, patchId), filePath);
4039
+ }
4040
+
4041
+ /** The staged twin of {@link patchBinaryFileMetadata}. */
4042
+ function stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath) {
4043
+ return binaryFileMetadataIn(patchUploadDir(patchesDir, patchId), filePath);
4044
+ }
4045
+
4046
+ /**
4047
+ * Move a patch's staged uploads into the patch directory.
4048
+ *
4049
+ * Called by {@link appendPatch} between the record and the log line, so it runs
4050
+ * under the lock and no reader can observe the halfway state.
4051
+ *
4052
+ * The whole `files` tree in one rename where it can be — the common case, since
4053
+ * a patch's files only ever arrive before its record — and per file otherwise,
4054
+ * for the case where something is already there.
4055
+ */
4056
+ function moveStagedUploadsIn(patchesDir, patchId) {
4057
+ const from = binaryFilesDir(patchUploadDir(patchesDir, patchId));
4058
+ if (!fs.existsSync(from)) {
4059
+ return;
4060
+ }
4061
+ const to = binaryFilesDir(patchDir(patchesDir, patchId));
4062
+ if (!fs.existsSync(to)) {
4063
+ fs.mkdirSync(path__default.dirname(to), {
4064
+ recursive: true
4065
+ });
4066
+ fs.renameSync(from, to);
4067
+ } else {
4068
+ moveTreeInto(from, to);
4069
+ }
4070
+ removeStagedUploads(patchesDir, patchId);
4071
+ }
4072
+
4073
+ /** File-by-file, for when the destination already holds some of the tree. */
4074
+ function moveTreeInto(from, to) {
4075
+ for (const entry of fs.readdirSync(from, {
4076
+ withFileTypes: true
4077
+ })) {
4078
+ const source = path__default.join(from, entry.name);
4079
+ const target = path__default.join(to, entry.name);
4080
+ if (entry.isDirectory()) {
4081
+ fs.mkdirSync(target, {
4082
+ recursive: true
4083
+ });
4084
+ moveTreeInto(source, target);
4085
+ continue;
4086
+ }
4087
+ fs.mkdirSync(path__default.dirname(target), {
4088
+ recursive: true
4089
+ });
4090
+ fs.renameSync(source, target);
4091
+ }
4092
+ }
4093
+
4094
+ /** Drop a patch's staging directory, whatever is left of it. */
4095
+ function removeStagedUploads(patchesDir, patchId) {
4096
+ try {
4097
+ fs.rmSync(patchUploadDir(patchesDir, patchId), {
4098
+ recursive: true,
4099
+ force: true
4100
+ });
4101
+ } catch {
4102
+ // Hygiene, not correctness: nothing reads a staging directory that no patch
4103
+ // claims, and the sweep below gets it eventually.
4104
+ }
4105
+ }
4106
+
4107
+ /**
4108
+ * How long an upload nobody claimed is kept.
4109
+ *
4110
+ * Only garbage collection, which is why it can be a guess at all: these bytes
4111
+ * are outside the store, so no reader can mistake them for a patch and nothing
4112
+ * is lost by keeping them a while. The old marker-based attempt at this problem
4113
+ * had a TTL deciding whether to delete something INSIDE the store, where being
4114
+ * wrong meant destroying a live upload.
4115
+ */
4116
+ const STALE_UPLOAD_MS = 24 * 60 * 60 * 1000;
4117
+
4118
+ /**
4119
+ * Drop staged uploads whose patch never arrived.
4120
+ *
4121
+ * A client that dies between the upload and the `PUT` leaves its bytes here.
4122
+ * Nothing references them — no record points at them and the log never named
4123
+ * them — so they are removed without a word.
4124
+ */
4125
+ function sweepStaleUploads(patchesDir, now = Date.now()) {
4126
+ const dir = uploadsDir(patchesDir);
4127
+ if (!fs.existsSync(dir)) {
4128
+ return;
4129
+ }
4130
+ let names;
4131
+ try {
4132
+ names = fs.readdirSync(dir);
4133
+ } catch {
4134
+ return;
4135
+ }
4136
+ for (const name of names) {
4137
+ const staged = path__default.join(dir, name);
4138
+ try {
4139
+ if (now - fs.statSync(staged).mtimeMs < STALE_UPLOAD_MS) continue;
4140
+ fs.rmSync(staged, {
4141
+ recursive: true,
4142
+ force: true
4143
+ });
4144
+ } catch {
4145
+ // Someone else is writing here, or it is already gone. Either way it is
4146
+ // not this pass's business.
4147
+ }
4148
+ }
3607
4149
  }
3608
4150
 
3609
4151
  /** Names that live in the patches directory but are not patches. */
@@ -3839,6 +4381,18 @@ function writePatchRecord(patchesDir, patchId, record) {
3839
4381
  function appendPatch(patchesDir, record) {
3840
4382
  const patchId = record.patchId;
3841
4383
  writePatchRecord(patchesDir, patchId, record);
4384
+ /*
4385
+ * Then the bytes, then the log line — and the order is the whole point.
4386
+ *
4387
+ * The record goes first, so the directory never exists without one: that is
4388
+ * what makes "files but no patch.json" a state this store cannot produce, and
4389
+ * what lets a reader keep treating it as a patch whose contents are lost.
4390
+ * The log line goes last, so an interrupted append leaves a directory the log
4391
+ * does not name — the benign half of a crash, swept silently.
4392
+ *
4393
+ * See `uploadsDir`. Under the lock, like the rest of this function.
4394
+ */
4395
+ moveStagedUploadsIn(patchesDir, patchId);
3842
4396
  const entry = {
3843
4397
  patchId,
3844
4398
  createdAt: record.createdAt,
@@ -4488,13 +5042,24 @@ class ValOpsFS extends ValOps {
4488
5042
  };
4489
5043
  }
4490
5044
  const patches = announceRes.entries.map(entry => entry.patchId);
4491
- // Drained here, on the channel that always flows. A repair that removed
4492
- // everything leaves the studio nothing to fetch, so a notice riding on
4493
- // `GET /patches` would sit here unread.
4494
- const removed = this.removedPatchNotices.splice(0, this.removedPatchNotices.length);
4495
- const removedNotice = removed.length > 0 ? {
4496
- removed
4497
- } : {};
5045
+ /**
5046
+ * Drained on the channel that always flows.
5047
+ *
5048
+ * A repair that removed everything leaves the studio nothing to fetch, so
5049
+ * a notice riding on `GET /patches` would sit here unread.
5050
+ *
5051
+ * Accumulating rather than assigning, because the long poll below drains
5052
+ * again before it answers: a repair during the wait must not have to sit
5053
+ * out another whole stat.
5054
+ */
5055
+ const removed = [];
5056
+ const drainRemoved = () => {
5057
+ removed.push(...this.removedPatchNotices.splice(0, this.removedPatchNotices.length));
5058
+ return removed.length > 0 ? {
5059
+ removed
5060
+ } : {};
5061
+ };
5062
+ const removedNotice = drainRemoved();
4498
5063
  // something changed: return immediately
4499
5064
  const didChange = !params ||
4500
5065
  // An entry file changed on disk: nothing else here can see that, since a
@@ -4575,6 +5140,14 @@ class ValOpsFS extends ValOps {
4575
5140
  if (Date.now() - start > interval) {
4576
5141
  console.warn("Val: polling interval of files exceeded");
4577
5142
  }
5143
+ // Checked BEFORE rescheduling, as the patches-directory poller
5144
+ // above does. Without it this walk goes on forever: the `finally`
5145
+ // that ends the race clears the handle it can see, and a `go`
5146
+ // already on the queue then schedules one nothing will ever clear —
5147
+ // a leaked timer, re-stat-ing the whole project, per stat poll.
5148
+ if (stopPolling) {
5149
+ return;
5150
+ }
4578
5151
  setHandle(setTimeout(() => go(resolve), interval));
4579
5152
  };
4580
5153
  if (stopPolling) {
@@ -4587,6 +5160,11 @@ class ValOpsFS extends ValOps {
4587
5160
  const disableFilePolling = ((_this$options2 = this.options) === null || _this$options2 === void 0 ? void 0 : _this$options2.disableFilePolling) || false;
4588
5161
  let patchesDirHandle;
4589
5162
  let valFilesIntervalHandle;
5163
+ // Held so the `finally` can clear it. A race the timeout LOSES still
5164
+ // leaves its timer armed, and it is the long one — so every stat that
5165
+ // returned on a file change kept the whole poll interval alive behind it,
5166
+ // doing nothing but holding the event loop open.
5167
+ let noChangeHandle;
4590
5168
  const type = await Promise.race([
4591
5169
  // 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
4592
5170
  disableFilePolling ? new Promise(() => {}) : didDirectoryChangeUsingPolling(this.getPatchesDir(), statFilePollingInterval, handle => {
@@ -4619,7 +5197,7 @@ class ValOpsFS extends ValOps {
4619
5197
  });
4620
5198
  }), new Promise(resolve => {
4621
5199
  var _this$options3;
4622
- return setTimeout(() => resolve("no-change"), ((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : _this$options3.statPollingInterval) || 20000);
5200
+ noChangeHandle = setTimeout(() => resolve("no-change"), ((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : _this$options3.statPollingInterval) || 20000);
4623
5201
  })]).finally(() => {
4624
5202
  if (fsWatcher) {
4625
5203
  fsWatcher.close();
@@ -4627,14 +5205,52 @@ class ValOpsFS extends ValOps {
4627
5205
  stopPolling = true;
4628
5206
  clearInterval(patchesDirHandle);
4629
5207
  clearInterval(valFilesIntervalHandle);
5208
+ clearTimeout(noChangeHandle);
4630
5209
  });
5210
+ /**
5211
+ * Read the store AGAIN, because `patches` above describes the moment this
5212
+ * poll OPENED — up to a whole polling interval ago.
5213
+ *
5214
+ * That staleness is not a detail: what ends the wait is a write, and the
5215
+ * write the studio does most is `/save`, which in `fs` mode commits the
5216
+ * patches and DELETES them. Answering with the list read before it names
5217
+ * patches that no longer exist, and the studio then puts those ids back in
5218
+ * its chain and fetches them from a server that correctly no longer has
5219
+ * them — "unpublished changes could not be loaded", for changes that were
5220
+ * published a moment earlier. With auto-save on, that is every pause in
5221
+ * typing.
5222
+ *
5223
+ * So a stat describes the moment it ANSWERS. `request-again` and
5224
+ * `no-change` differ in why the wait ended, not in how current the answer
5225
+ * has to be, so both come through here.
5226
+ *
5227
+ * The patch list is the only part that CAN have moved: the shas and the
5228
+ * schemas come from `initSources`, which is memoised for the lifetime of
5229
+ * this instance and never invalidated — a module change makes a new
5230
+ * `ValOpsFS` rather than updating this one. Recomputing them would be a
5231
+ * second full schema serialization per poll for a value that cannot have
5232
+ * changed.
5233
+ *
5234
+ * A read error is returned as one rather than papered over with the list
5235
+ * from the open: a patch store that cannot be read is what the error path
5236
+ * is for, and the studio asks again.
5237
+ */
5238
+ const answerRes = await this.readStore();
5239
+ if (answerRes.status === "error") {
5240
+ return {
5241
+ type: "error",
5242
+ error: {
5243
+ message: answerRes.message
5244
+ }
5245
+ };
5246
+ }
4631
5247
  return {
4632
5248
  type,
4633
5249
  baseSha: currentBaseSha,
4634
5250
  schemaSha: currentSchemaSha,
4635
5251
  sourcesSha: currentSourcesSha,
4636
- patches,
4637
- ...removedNotice,
5252
+ patches: answerRes.entries.map(entry => entry.patchId),
5253
+ ...drainRemoved(),
4638
5254
  jsonEntriesSha: currentJsonEntriesSha
4639
5255
  };
4640
5256
  } catch (err) {
@@ -4966,14 +5582,28 @@ class ValOpsFS extends ValOps {
4966
5582
  }
4967
5583
  async saveBase64EncodedBinaryFileFromPatch(filePath, _parentRef, patchId, data, _type, metadata) {
4968
5584
  // Keyed by the patch's own id, so the parent is not needed and is not asked
4969
- // for. Uploads arrive before the patch record does, which is fine: the
4970
- // directory sits there unreferenced until the log line that names it lands,
4971
- // and repair sweeps it up if that never happens.
5585
+ // for.
5586
+ //
5587
+ // Written OUTSIDE the store, and moved in by `appendPatch` once the record
5588
+ // exists. Uploads arrive before the patch record does — the record's `file`
5589
+ // op carries only a sha, so it would otherwise point at nothing — and
5590
+ // writing them straight into `<patchId>/files/` left a directory holding
5591
+ // files and no `patch.json` for a whole round trip. Nothing can read that as
5592
+ // anything but a patch whose contents are lost, so repair removed it, bytes
5593
+ // and all, and the image 404ed.
5594
+ //
5595
+ // Staging is what makes that state unreachable rather than tolerated. See
5596
+ // `uploadsDir`.
4972
5597
  const patchesDir = this.getPatchesDir();
4973
- const patchFilePath = patchBinaryFile(patchesDir, patchId, filePath);
4974
- const metadataFilePath = patchBinaryFileMetadata(patchesDir, patchId, filePath);
5598
+ const patchFilePath = stagedPatchBinaryFile(patchesDir, patchId, filePath);
5599
+ const metadataFilePath = stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath);
4975
5600
  try {
4976
5601
  if (data === null) {
5602
+ // A delete is the other order — the record is written first — so the
5603
+ // bytes are already in the store. Both locations, because a file staged
5604
+ // and then removed before its record never got there.
5605
+ this.host.deleteFile(patchBinaryFile(patchesDir, patchId, filePath));
5606
+ this.host.deleteFile(patchBinaryFileMetadata(patchesDir, patchId, filePath));
4977
5607
  this.host.deleteFile(patchFilePath);
4978
5608
  this.host.deleteFile(metadataFilePath);
4979
5609
  return {
@@ -4981,6 +5611,9 @@ class ValOpsFS extends ValOps {
4981
5611
  filePath
4982
5612
  };
4983
5613
  }
5614
+ // Cheap, and this is the one path that creates staging directories, so it
5615
+ // is where the ones nobody claimed get noticed.
5616
+ sweepStaleUploads(patchesDir);
4984
5617
  const buffer = bufferFromDataUrl(data);
4985
5618
  if (!buffer) {
4986
5619
  return {
@@ -5010,9 +5643,28 @@ class ValOpsFS extends ValOps {
5010
5643
  };
5011
5644
  }
5012
5645
  }
5646
+
5647
+ /**
5648
+ * Which of the two places a patch's file can be, if either.
5649
+ *
5650
+ * The bytes are in the store once the patch's record is, and in the staging
5651
+ * area before that — see `uploadsDir`. Every reader has to accept both, and
5652
+ * they decide it here rather than each on its own, so two readers of the same
5653
+ * file cannot disagree about whether it exists.
5654
+ */
5655
+ wherePatchFileIs(inStore, staged) {
5656
+ if (this.host.fileExists(inStore)) {
5657
+ return inStore;
5658
+ }
5659
+ if (this.host.fileExists(staged)) {
5660
+ return staged;
5661
+ }
5662
+ return null;
5663
+ }
5013
5664
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId) {
5014
- const metadataFilePath = patchBinaryFileMetadata(this.getPatchesDir(), patchId, filePath);
5015
- if (!this.host.fileExists(metadataFilePath)) {
5665
+ const patchesDir = this.getPatchesDir();
5666
+ const metadataFilePath = this.wherePatchFileIs(patchBinaryFileMetadata(patchesDir, patchId, filePath), stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath));
5667
+ if (metadataFilePath === null) {
5016
5668
  return {
5017
5669
  errors: [{
5018
5670
  message: "Metadata file not found",
@@ -5049,8 +5701,9 @@ class ValOpsFS extends ValOps {
5049
5701
  async getBase64EncodedBinaryFileFromPatch(filePath, patchId) {
5050
5702
  // Straight from the id. This used to read and parse every patch on disk to
5051
5703
  // work out which directory the file was under, on every single image request.
5052
- const absPath = patchBinaryFile(this.getPatchesDir(), patchId, filePath);
5053
- if (!this.host.fileExists(absPath)) {
5704
+ const patchesDir = this.getPatchesDir();
5705
+ const absPath = this.wherePatchFileIs(patchBinaryFile(patchesDir, patchId, filePath), stagedPatchBinaryFile(patchesDir, patchId, filePath));
5706
+ if (absPath === null) {
5054
5707
  return null;
5055
5708
  }
5056
5709
  return this.host.readBinaryFile(absPath);
@@ -5095,6 +5748,9 @@ class ValOpsFS extends ValOps {
5095
5748
  recursive: true,
5096
5749
  force: true
5097
5750
  });
5751
+ // And anything this patch had staged but never moved in, which is
5752
+ // the case where a delete arrives before the record does.
5753
+ removeStagedUploads(patchesDir, patchId);
5098
5754
  deleted.push(patchId);
5099
5755
  } catch (err) {
5100
5756
  // Reported. This endpoint used to answer "deleted" unconditionally —
@@ -8325,6 +8981,26 @@ const ValServer = (valModules, options, callbacks) => {
8325
8981
  }
8326
8982
  };
8327
8983
  }
8984
+ /*
8985
+ * The files on disk are the committed content now, so say so here too.
8986
+ *
8987
+ * Nothing else will: the sources are memoised per `ValOps` instance
8988
+ * and are re-read by awaiting each module's `def`, which is the app's
8989
+ * own `import()` — that resolves from the module registry, not from
8990
+ * the file this save just rewrote. So until the host rebuilds its
8991
+ * module graph, every read of committed content answers with what was
8992
+ * there before the save. A page rendering draft content sees exactly
8993
+ * that once the patches below are gone: `getJsonEntry` resolves the
8994
+ * committed entry and has no patches left to replay over it.
8995
+ *
8996
+ * Before `deletePatches`, because it needs the patches it is adopting
8997
+ * the result of. See `ValOps.promoteCommittedSources` for why the SHAs
8998
+ * move with them.
8999
+ */
9000
+ await serverOps.adoptCommittedSources({
9001
+ ...analysis,
9002
+ ...patches
9003
+ }, preparedCommit);
8328
9004
  /*
8329
9005
  * Only what this request consumed.
8330
9006
  *
@@ -11941,4 +12617,4 @@ function readCapturedReport(snapshotDir) {
11941
12617
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
11942
12618
  }
11943
12619
 
11944
- export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValServer, currentFixHandlers, decodeJwt, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };
12620
+ export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValServer, currentFixHandlers, decodeJwt, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };