@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.
@@ -896,6 +896,150 @@ function resolveRelative(dirName, spec, host) {
896
896
  return null;
897
897
  }
898
898
 
899
+ /**
900
+ * What a `*.val.ts` file that is NOT registered in `val.modules` turns out to
901
+ * be.
902
+ *
903
+ * A file matching `*.val.ts` is not necessarily a Val module: the same
904
+ * convention is used for shared schemas and other content-adjacent helpers, and
905
+ * those are not meant to be registered. Only a file that actually default
906
+ * exports a module is worth warning about; one that default exports something
907
+ * else is a mistake, because nothing will ever load it.
908
+ *
909
+ * See {@link createValModuleFileInspector}.
910
+ */
911
+
912
+ /**
913
+ * Inspects individual `*.val.{ts,js}` files, sharing one module cache and one
914
+ * parsed tsconfig across every call.
915
+ *
916
+ * The default export is checked SYNTACTICALLY first and only evaluated if it is
917
+ * there. That ordering is the point: a `.val.ts` with no default export is a
918
+ * helper file, and evaluating it to learn that would be both wasted work and a
919
+ * way to turn an unrelated top-level throw into a reported error.
920
+ *
921
+ * SECURITY: evaluation goes through the same `vm` loader as
922
+ * {@link loadValModules} — see the warning there. Only ever point this at the
923
+ * project's own first-party files.
924
+ */
925
+ function createValModuleFileInspector(projectRoot, host = ts__default["default"].sys) {
926
+ const compilerOptions = getCompilerOptions(projectRoot, host);
927
+ const cache = {};
928
+ return absPath => {
929
+ const code = host.readFile(absPath);
930
+ if (code === undefined) {
931
+ return {
932
+ status: "invalid",
933
+ message: `Could not read file: '${absPath}'`
934
+ };
935
+ }
936
+ const sourceFile = ts__default["default"].createSourceFile(absPath, code, ts__default["default"].ScriptTarget.ES2020, true);
937
+ if (!hasDefaultExport(sourceFile)) {
938
+ return {
939
+ status: "no-default-export"
940
+ };
941
+ }
942
+ let exports;
943
+ // `loadModule` inserts a module into the cache BEFORE evaluating it, so
944
+ // that a cycle resolves. One that throws therefore leaves a half-built
945
+ // entry behind - and unlike `loadValModules`, which builds a cache per
946
+ // call and lets the throw escape, this cache outlives the failure. A later
947
+ // inspection of the same file (or of the helper that actually threw) would
948
+ // hit that entry, see empty exports, and report "default export is
949
+ // undefined" instead of the real error - or, worse, quietly downgrade it to
950
+ // a warning. So roll the cache back to what it was before this attempt.
951
+ const before = new Set(Object.keys(cache));
952
+ try {
953
+ exports = loadModule(absPath, cache, compilerOptions, host).exports;
954
+ } catch (e) {
955
+ for (const key of Object.keys(cache)) {
956
+ if (!before.has(key)) {
957
+ delete cache[key];
958
+ }
959
+ }
960
+ return {
961
+ status: "invalid",
962
+ message: `Could not be loaded. Error: ${errorMessage(e)}`
963
+ };
964
+ }
965
+ if (core.Internal.isValModule(exports.default)) {
966
+ return {
967
+ status: "val-module"
968
+ };
969
+ }
970
+ // NOTE: do NOT suggest wrapping this in `c.define`. A shared schema turned
971
+ // into a module is an UNREGISTERED module, i.e. straight back to a warning.
972
+ // The fix is to move it out of the default export slot, which is the one
973
+ // thing about a `.val.ts` that Val reserves for itself.
974
+ return {
975
+ status: "invalid",
976
+ 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`
977
+ };
978
+ };
979
+ }
980
+
981
+ /**
982
+ * Whether the file exports a RUNTIME value as `default`, without evaluating it.
983
+ *
984
+ * Two things deliberately do not count, because neither exists once the file is
985
+ * transpiled — and treating either as a default export would send a pure helper
986
+ * off to be evaluated and reported:
987
+ *
988
+ * - `export * from "./x"`, since a star re-export never carries the default;
989
+ * - a type-only export, in either of its spellings
990
+ * (`export type { T as default }` and `export { type T as default }`).
991
+ */
992
+ function hasDefaultExport(sourceFile) {
993
+ return sourceFile.statements.some(statement => {
994
+ // `export default <expr>` — but not `export = x`, which shares this node.
995
+ if (ts__default["default"].isExportAssignment(statement)) {
996
+ return !statement.isExportEquals;
997
+ }
998
+ // `export { x as default }` / `export { default } from "./x"`
999
+ if (ts__default["default"].isExportDeclaration(statement) && !statement.isTypeOnly && statement.exportClause && ts__default["default"].isNamedExports(statement.exportClause)) {
1000
+ return statement.exportClause.elements.some(element => !element.isTypeOnly && element.name.text === "default");
1001
+ }
1002
+ // `export default function f() {}` / `export default class C {}`, which are
1003
+ // declarations carrying a `default` modifier rather than export assignments.
1004
+ return ts__default["default"].canHaveModifiers(statement) && (ts__default["default"].getModifiers(statement) ?? []).some(modifier => modifier.kind === ts__default["default"].SyntaxKind.DefaultKeyword);
1005
+ });
1006
+ }
1007
+
1008
+ /** A short, human-readable "what you exported instead" for the error message. */
1009
+ function describeDefaultExport(value) {
1010
+ if (value === null) {
1011
+ return "null";
1012
+ }
1013
+ if (Array.isArray(value)) {
1014
+ return "an array";
1015
+ }
1016
+ if (typeof value === "object") {
1017
+ // Duck-typed rather than `instanceof Schema`, for the same cross-realm
1018
+ // reason `isValModule` avoids a constructor check.
1019
+ if ("executeSerialize" in value && typeof value["executeSerialize"] === "function") {
1020
+ return "a schema";
1021
+ }
1022
+ return "an object";
1023
+ }
1024
+ if (typeof value === "undefined") {
1025
+ return "undefined";
1026
+ }
1027
+ return `a ${typeof value}`;
1028
+ }
1029
+ function errorMessage(e) {
1030
+ // NOT `e instanceof Error`: an error thrown from inside the `vm` context is
1031
+ // built from that realm's Error constructor. Duck-type the message instead.
1032
+ if (typeof e === "object" && e !== null && "message" in e) {
1033
+ const {
1034
+ message
1035
+ } = e;
1036
+ if (typeof message === "string") {
1037
+ return message;
1038
+ }
1039
+ }
1040
+ return String(e);
1041
+ }
1042
+
899
1043
  const jsonOps$1 = new patch.JSONOps();
900
1044
 
901
1045
  /**
@@ -1690,6 +1834,40 @@ class ValOps {
1690
1834
 
1691
1835
  /** The sha256 / hash of schema + config - if this changes users needs to reload */
1692
1836
 
1837
+ /**
1838
+ * What the SHAs above are a fold over, so they can be recomputed.
1839
+ *
1840
+ * See {@link promoteCommittedSources}: the one thing that changes sources
1841
+ * without re-evaluating the modules is a save, and it has to be able to move
1842
+ * the SHAs with them.
1843
+ */
1844
+
1845
+ /**
1846
+ * The extraction's OWN module errors, which are what the fold was given.
1847
+ *
1848
+ * Not the same list as {@link modulesErrors}: that one has the nested
1849
+ * `.jsonValues()` errors concatenated on, and those were never part of the
1850
+ * hash. Re-folding with the wrong list changes the base SHA for no reason.
1851
+ */
1852
+
1853
+ /**
1854
+ * What a save has told us each `.jsonValues()` entry now holds.
1855
+ *
1856
+ * The entry twin of {@link sources}, and it has to be separate because an
1857
+ * entry's content is not IN the source: the source holds a marker, and
1858
+ * {@link getJsonEntries} resolves it by awaiting the marker's own `import()`.
1859
+ * That resolves from the module registry, so after `/save` rewrites a
1860
+ * `*.val.json` the thunk keeps answering with the content from before — and
1861
+ * unlike a module source there is nothing to re-extract, because the memo was
1862
+ * never holding the content in the first place.
1863
+ *
1864
+ * `null` for an entry the commit deleted.
1865
+ *
1866
+ * Never cleared: it describes what is on disk. A host rebuild makes a new
1867
+ * instance, which is the right reset. Bounded by the project's entry count,
1868
+ * holding only the latest content per key.
1869
+ */
1870
+ adoptedJsonEntries = new Map();
1693
1871
  constructor(valModules, options) {
1694
1872
  this.valModules = valModules;
1695
1873
  this.options = options;
@@ -1700,6 +1878,8 @@ class ValOps {
1700
1878
  this.sourcesSha = null;
1701
1879
  this.configSha = null;
1702
1880
  this.modulesErrors = null;
1881
+ this.shaEntries = null;
1882
+ this.shaModuleErrors = null;
1703
1883
  }
1704
1884
 
1705
1885
  // #region stat
@@ -1725,6 +1905,8 @@ class ValOps {
1725
1905
  this.sourcesSha = extracted.sourcesSha;
1726
1906
  this.configSha = extracted.configSha;
1727
1907
  this.modulesErrors = moduleErrors;
1908
+ this.shaEntries = extracted.shaEntries;
1909
+ this.shaModuleErrors = extracted.moduleErrors;
1728
1910
  return {
1729
1911
  baseSha: this.baseSha,
1730
1912
  schemaSha: this.schemaSha,
@@ -1745,6 +1927,141 @@ class ValOps {
1745
1927
  moduleErrors: this.modulesErrors
1746
1928
  };
1747
1929
  }
1930
+
1931
+ /**
1932
+ * These patches are on disk now: adopt what they produced as the committed
1933
+ * sources.
1934
+ *
1935
+ * The entry point for the mechanism {@link promoteCommittedSources} describes,
1936
+ * and the only one — a caller hands over the analysis it just committed and
1937
+ * this works out the rest, so the rule about which sources are adopted lives
1938
+ * in one place rather than at each save site.
1939
+ *
1940
+ * A module whose patches could not be applied cleanly is left alone. `/save`
1941
+ * refuses the whole commit before reaching here if `prepare` found errors, so
1942
+ * this cannot normally fire — but adopting a partially patched source would
1943
+ * put content in the memo that is not what was written, which is worse than
1944
+ * being stale.
1945
+ */
1946
+ async adoptCommittedSources(analysis, preparedCommit) {
1947
+ // Read BEFORE anything is promoted: this applies the chain to the sources as
1948
+ // they stand, and promoting first would apply the same patches twice.
1949
+ const {
1950
+ sources,
1951
+ errors
1952
+ } = await this.getSources(analysis);
1953
+ const adopt = {};
1954
+ for (const [moduleFilePathS, source] of Object.entries(sources)) {
1955
+ const moduleFilePath = moduleFilePathS;
1956
+ if (errors[moduleFilePath] !== undefined) {
1957
+ 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.", {
1958
+ moduleFilePath,
1959
+ errors: errors[moduleFilePath]
1960
+ });
1961
+ continue;
1962
+ }
1963
+ adopt[moduleFilePath] = source;
1964
+ }
1965
+ this.promoteCommittedSources(adopt);
1966
+ /**
1967
+ * And the `.jsonValues()` entry content, which the sources above do not
1968
+ * carry — they hold markers. See {@link adoptedJsonEntries}.
1969
+ *
1970
+ * Only for a module whose source was adopted. The source is what frames an
1971
+ * entry: it decides which keys exist at all, so adopting one without the
1972
+ * other would leave the content and the key set describing different
1973
+ * moments.
1974
+ */
1975
+ for (const [moduleFilePathS, entries] of Object.entries(preparedCommit.patchedJsonEntries)) {
1976
+ const moduleFilePath = moduleFilePathS;
1977
+ if (adopt[moduleFilePath] === undefined) {
1978
+ continue;
1979
+ }
1980
+ const adopted = this.adoptedJsonEntries.get(moduleFilePath) ?? new Map();
1981
+ for (const [entryKey, content] of Object.entries(entries)) {
1982
+ adopted.set(entryKey, content);
1983
+ }
1984
+ this.adoptedJsonEntries.set(moduleFilePath, adopted);
1985
+ }
1986
+ }
1987
+
1988
+ /**
1989
+ * Adopt sources that have just been written to disk, and move the SHAs with
1990
+ * them.
1991
+ *
1992
+ * ## Why this exists rather than an invalidation
1993
+ *
1994
+ * The obvious thing — throw the memo away after a save so the next read
1995
+ * re-extracts — does not work, and quietly. `extractValModules` gets a
1996
+ * module's content by awaiting its `def`, which is the app's own `import()`:
1997
+ * that resolves from the MODULE REGISTRY, not from the file on disk. Right
1998
+ * after `/save` rewrites a `.val.ts`, the registry still holds the module as
1999
+ * it was evaluated before, so a re-extraction returns the pre-save content and
2000
+ * stores it as fresh. What actually replaces it is the host rebuilding its
2001
+ * module graph and constructing a new `ValOps` — which happens on its own
2002
+ * schedule, and until it does, every read is stale.
2003
+ *
2004
+ * Stale reads here are not abstract: `getJsonEntry` resolves a
2005
+ * `.jsonValues()` entry from the committed source and then replays pending
2006
+ * patches over it, so once a publish has removed the patches, a page rendering
2007
+ * draft content gets the committed value — the one this memo is holding from
2008
+ * before the publish.
2009
+ *
2010
+ * So the save tells us instead. It has just computed what the new committed
2011
+ * sources are, and that answer does not depend on anything being
2012
+ * re-evaluated.
2013
+ *
2014
+ * ## And the SHAs move
2015
+ *
2016
+ * Deliberately, and this is the part with consequences. `baseSha` and
2017
+ * `sourcesSha` identify the sources being served; leaving them still while the
2018
+ * sources move would put a value other code compares against into
2019
+ * disagreement with what it describes. Moving them means a `fs`-mode base SHA
2020
+ * changes within a server's lifetime for the first time, which is a signal the
2021
+ * studio already knows how to read: `PatchStore.reconcileVanished` uses a
2022
+ * moved base to tell "these patches were published" from "these patches were
2023
+ * discarded", and takes them out of the chain without reverting the fields —
2024
+ * which is what a second tab watching a publish needs and could not get
2025
+ * before.
2026
+ *
2027
+ * A module the fold does not know is ignored rather than appended: the fold's
2028
+ * order is `val.modules`, and a path that is not in it has no position, so
2029
+ * there is no honest answer for where its hash would go. It also cannot happen
2030
+ * — a save only ever writes modules it read from here.
2031
+ */
2032
+ promoteCommittedSources(patched) {
2033
+ if (this.sources === null || this.shaEntries === null || this.shaModuleErrors === null) {
2034
+ // Nothing has been read yet, so there is no stale answer to correct and
2035
+ // no fold to replay. The first read extracts, as it always would.
2036
+ return;
2037
+ }
2038
+ const known = new Set(this.shaEntries.map(entry => entry.path));
2039
+ const adopt = Object.entries(patched).filter(([moduleFilePath, source]) => source !== undefined && known.has(moduleFilePath));
2040
+ if (adopt.length === 0) {
2041
+ return;
2042
+ }
2043
+ const bySource = new Map(adopt);
2044
+ // A new object rather than a mutation: `getSources` hands this out, and a
2045
+ // caller holding it must not have the ground move under it.
2046
+ this.sources = {
2047
+ ...this.sources
2048
+ };
2049
+ for (const [moduleFilePath, source] of adopt) {
2050
+ this.sources[moduleFilePath] = source;
2051
+ }
2052
+ this.shaEntries = this.shaEntries.map(entry => {
2053
+ const source = bySource.get(entry.path);
2054
+ return source === undefined ? entry : {
2055
+ ...entry,
2056
+ source
2057
+ };
2058
+ });
2059
+ const shas = core.computeValModuleShas(this.valModules.config, this.shaEntries, this.shaModuleErrors);
2060
+ this.baseSha = shas.baseSha;
2061
+ this.schemaSha = shas.schemaSha;
2062
+ this.sourcesSha = shas.sourcesSha;
2063
+ this.configSha = shas.configSha;
2064
+ }
1748
2065
  async init() {
1749
2066
  const {
1750
2067
  baseSha,
@@ -1894,6 +2211,31 @@ class ValOps {
1894
2211
  baseContent: undefined
1895
2212
  };
1896
2213
  }
2214
+ /**
2215
+ * What a save told us this entry holds, ahead of the thunk.
2216
+ *
2217
+ * The thunk resolves from the module registry, so after `/save` rewrites
2218
+ * a `*.val.json` it keeps answering with the content from before — and
2219
+ * there is nothing to re-extract, because the committed content was
2220
+ * never in the memoised source to begin with. See
2221
+ * {@link adoptedJsonEntries}.
2222
+ *
2223
+ * `null` means the commit deleted the entry, which is reported the same
2224
+ * way an absent key is. (Nearly unreachable — a `remove` also drops the
2225
+ * thunk from the `.val.ts`, so the key is gone from `record` once the
2226
+ * source is adopted — but the map says it, so this says it too.)
2227
+ *
2228
+ * The BASELINE only. Pending patches replay over it below exactly as
2229
+ * they do over the thunk's answer.
2230
+ */
2231
+ const adopted = this.adoptedJsonEntries.get(moduleFilePath);
2232
+ if (adopted !== undefined && adopted.has(entryKey)) {
2233
+ const content = adopted.get(entryKey);
2234
+ return {
2235
+ entryKey,
2236
+ baseContent: content === null ? undefined : content
2237
+ };
2238
+ }
1897
2239
  const thunk = core.Internal.getJsonImport(marker);
1898
2240
  if (!thunk) {
1899
2241
  return {
@@ -2596,6 +2938,22 @@ class ValOps {
2596
2938
 
2597
2939
  // jsonValues entry content, keyed by `*.val.json` path. `null` = delete.
2598
2940
  const jsonEntryContents = new Map();
2941
+ /**
2942
+ * The same content, keyed by ENTRY KEY rather than by file path.
2943
+ *
2944
+ * The path is what gets written; the key is what a reader asks for, and it
2945
+ * is dropped at the flush below. Reconstructing it afterwards is not on:
2946
+ * TWO producers turn a key into a path — `resolveEntryJsonPath` and
2947
+ * `getNewJsonEntryPaths`, the latter a locked convention for `add` and a
2948
+ * move's destination — and a marker does not carry its path at read time
2949
+ * (see `jsonEntryFiles.ts`). So it is recorded where the key is known.
2950
+ *
2951
+ * Read by `ValOps.adoptCommittedSources`, so a save can tell this instance
2952
+ * what an entry now holds. Nothing else can: an entry's committed content
2953
+ * is resolved through the marker's own `import()`, which caches, so the
2954
+ * memo cannot be refreshed by re-reading.
2955
+ */
2956
+ const jsonEntryContentsByKey = new Map();
2599
2957
  // Entries added in this commit → their new `*.val.json` path, so later
2600
2958
  // content ops in the same commit resolve to the freshly-created file.
2601
2959
  const entryKeyToJsonPath = new Map();
@@ -2756,6 +3114,7 @@ class ValOps {
2756
3114
  tsSourceFile = insRes.value;
2757
3115
  tsChanged = true;
2758
3116
  jsonEntryContents.set(jsonPath, op.value);
3117
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
2759
3118
  entryKeyToJsonPath.set(cls.entryKey, jsonPath);
2760
3119
  } else if (op.op === "remove") {
2761
3120
  const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
@@ -2773,6 +3132,7 @@ class ValOps {
2773
3132
  tsSourceFile = remRes.value;
2774
3133
  tsChanged = true;
2775
3134
  jsonEntryContents.set(jsonPathRes.value, null);
3135
+ jsonEntryContentsByKey.set(cls.entryKey, null);
2776
3136
  } else if (op.op === "replace") {
2777
3137
  const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
2778
3138
  if (fp.result.isErr(jsonPathRes)) {
@@ -2781,6 +3141,7 @@ class ValOps {
2781
3141
  break;
2782
3142
  }
2783
3143
  jsonEntryContents.set(jsonPathRes.value, op.value);
3144
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
2784
3145
  } else if (op.op === "move" || op.op === "copy") {
2785
3146
  // Rename (move) or duplicate (copy) a whole entry. The new entry
2786
3147
  // gets its own `*.val.json` written with the source entry's
@@ -2840,9 +3201,11 @@ class ValOps {
2840
3201
  tsSourceFile = insRes.value;
2841
3202
  tsChanged = true;
2842
3203
  jsonEntryContents.set(jsonPath, content);
3204
+ jsonEntryContentsByKey.set(cls.entryKey, content);
2843
3205
  entryKeyToJsonPath.set(cls.entryKey, jsonPath);
2844
3206
  if (op.op === "move" && fromPathRes.value !== jsonPath) {
2845
3207
  jsonEntryContents.set(fromPathRes.value, null);
3208
+ jsonEntryContentsByKey.set(fromKey, null);
2846
3209
  }
2847
3210
  } else {
2848
3211
  errors.push({
@@ -2893,6 +3256,7 @@ class ValOps {
2893
3256
  break;
2894
3257
  }
2895
3258
  jsonEntryContents.set(jsonPath, applied.value);
3259
+ jsonEntryContentsByKey.set(cls.entryKey, applied.value);
2896
3260
  }
2897
3261
  }
2898
3262
  if (patchHadError) {
@@ -2963,7 +3327,8 @@ class ValOps {
2963
3327
  path,
2964
3328
  appliedPatches,
2965
3329
  result: sourceFileText,
2966
- extraFiles
3330
+ extraFiles,
3331
+ jsonEntries: Object.fromEntries(jsonEntryContentsByKey)
2967
3332
  };
2968
3333
  }
2969
3334
  }
@@ -2976,6 +3341,7 @@ class ValOps {
2976
3341
  errors
2977
3342
  };
2978
3343
  };
3344
+ const patchedJsonEntries = {};
2979
3345
  const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
2980
3346
  let hasErrors = false;
2981
3347
  const sourceFilePatchErrors = {};
@@ -3001,6 +3367,12 @@ class ValOps {
3001
3367
  for (const [extraPath, data] of Object.entries(res.extraFiles)) {
3002
3368
  patchedSourceFiles[extraPath] = data;
3003
3369
  }
3370
+ // Kept per module and per entry key, not flattened into
3371
+ // `patchedSourceFiles` beside the files: a reader of an entry has a
3372
+ // module and a key, never a path. See `patchedJsonEntries`.
3373
+ if (Object.keys(res.jsonEntries).length > 0) {
3374
+ patchedJsonEntries[res.path] = res.jsonEntries;
3375
+ }
3004
3376
  appliedPatches[res.path] = res.appliedPatches ?? [];
3005
3377
  }
3006
3378
  for (const patchId of res.appliedPatches ?? []) {
@@ -3041,6 +3413,7 @@ class ValOps {
3041
3413
  binaryFilePatchErrors,
3042
3414
  unappliablePatches,
3043
3415
  patchedSourceFiles,
3416
+ patchedJsonEntries,
3044
3417
  previousSourceFiles,
3045
3418
  partiallyPatchedSourceFiles,
3046
3419
  patchedBinaryFilesDescriptors,
@@ -3633,11 +4006,180 @@ function patchRecordFile(patchesDir, patchId) {
3633
4006
  function patchBaseFile(patchesDir, patchId) {
3634
4007
  return path__namespace["default"].join(patchDir(patchesDir, patchId), "base.json");
3635
4008
  }
4009
+
4010
+ /**
4011
+ * Where a patch's uploaded bytes wait for the record that will reference them.
4012
+ *
4013
+ * ## Why they cannot simply be written into the patch directory
4014
+ *
4015
+ * A patch that carries a file is written in TWO requests, and the bytes go
4016
+ * first: the record's `file` op holds only a sha, so a record written before its
4017
+ * bytes would point at nothing. Uploading straight into `<patchId>/files/` left
4018
+ * the directory holding files and no `patch.json` for the length of a round
4019
+ * trip — which is neither of the two shapes this store allows, so
4020
+ * {@link readPatchStore} read it as a patch whose contents were lost and repair
4021
+ * removed it, bytes and all.
4022
+ *
4023
+ * And that window is not passive: writing into the patches directory is exactly
4024
+ * what ends `getStat`'s long poll, so the upload summoned the read that
4025
+ * destroyed it. Replacing an image worked only when the two requests happened to
4026
+ * land close enough together.
4027
+ *
4028
+ * So the bytes are not in the store until they belong to something.
4029
+ * {@link appendPatch} moves them in after writing the record, under the lock, so
4030
+ * no reader ever sees a half-built patch directory — and the invariant that a
4031
+ * directory either holds a usable record or is named by the log holds again,
4032
+ * with nothing to tolerate and no ambiguous state to classify.
4033
+ *
4034
+ * A SIBLING of the patches directory, for two reasons: nothing that reads the
4035
+ * store lists it, and it is on the same filesystem, so moving into place is a
4036
+ * rename rather than a copy.
4037
+ */
4038
+ function uploadsDir(patchesDir) {
4039
+ return path__namespace["default"].join(path__namespace["default"].dirname(patchesDir), "uploads");
4040
+ }
4041
+
4042
+ /** Where one patch's uploads wait. See {@link uploadsDir}. */
4043
+ function patchUploadDir(patchesDir, patchId) {
4044
+ return path__namespace["default"].join(uploadsDir(patchesDir), patchId);
4045
+ }
4046
+
4047
+ /**
4048
+ * The binary layout, relative to whichever directory holds it.
4049
+ *
4050
+ * Shared by the patch directory and the staging directory so the two cannot
4051
+ * drift — a move into place has to land the bytes exactly where a read expects
4052
+ * them.
4053
+ */
4054
+ function binaryFilesDir(dir) {
4055
+ return path__namespace["default"].join(dir, "files");
4056
+ }
4057
+ function binaryFileIn(dir, filePath) {
4058
+ return path__namespace["default"].join(binaryFilesDir(dir), filePath, path__namespace["default"].basename(filePath));
4059
+ }
4060
+ function binaryFileMetadataIn(dir, filePath) {
4061
+ return path__namespace["default"].join(binaryFilesDir(dir), filePath, "metadata.json");
4062
+ }
3636
4063
  function patchBinaryFile(patchesDir, patchId, filePath) {
3637
- return path__namespace["default"].join(patchDir(patchesDir, patchId), "files", filePath, path__namespace["default"].basename(filePath));
4064
+ return binaryFileIn(patchDir(patchesDir, patchId), filePath);
3638
4065
  }
3639
4066
  function patchBinaryFileMetadata(patchesDir, patchId, filePath) {
3640
- return path__namespace["default"].join(patchDir(patchesDir, patchId), "files", filePath, "metadata.json");
4067
+ return binaryFileMetadataIn(patchDir(patchesDir, patchId), filePath);
4068
+ }
4069
+
4070
+ /** The staged twin of {@link patchBinaryFile}. */
4071
+ function stagedPatchBinaryFile(patchesDir, patchId, filePath) {
4072
+ return binaryFileIn(patchUploadDir(patchesDir, patchId), filePath);
4073
+ }
4074
+
4075
+ /** The staged twin of {@link patchBinaryFileMetadata}. */
4076
+ function stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath) {
4077
+ return binaryFileMetadataIn(patchUploadDir(patchesDir, patchId), filePath);
4078
+ }
4079
+
4080
+ /**
4081
+ * Move a patch's staged uploads into the patch directory.
4082
+ *
4083
+ * Called by {@link appendPatch} between the record and the log line, so it runs
4084
+ * under the lock and no reader can observe the halfway state.
4085
+ *
4086
+ * The whole `files` tree in one rename where it can be — the common case, since
4087
+ * a patch's files only ever arrive before its record — and per file otherwise,
4088
+ * for the case where something is already there.
4089
+ */
4090
+ function moveStagedUploadsIn(patchesDir, patchId) {
4091
+ const from = binaryFilesDir(patchUploadDir(patchesDir, patchId));
4092
+ if (!fs__default["default"].existsSync(from)) {
4093
+ return;
4094
+ }
4095
+ const to = binaryFilesDir(patchDir(patchesDir, patchId));
4096
+ if (!fs__default["default"].existsSync(to)) {
4097
+ fs__default["default"].mkdirSync(path__namespace["default"].dirname(to), {
4098
+ recursive: true
4099
+ });
4100
+ fs__default["default"].renameSync(from, to);
4101
+ } else {
4102
+ moveTreeInto(from, to);
4103
+ }
4104
+ removeStagedUploads(patchesDir, patchId);
4105
+ }
4106
+
4107
+ /** File-by-file, for when the destination already holds some of the tree. */
4108
+ function moveTreeInto(from, to) {
4109
+ for (const entry of fs__default["default"].readdirSync(from, {
4110
+ withFileTypes: true
4111
+ })) {
4112
+ const source = path__namespace["default"].join(from, entry.name);
4113
+ const target = path__namespace["default"].join(to, entry.name);
4114
+ if (entry.isDirectory()) {
4115
+ fs__default["default"].mkdirSync(target, {
4116
+ recursive: true
4117
+ });
4118
+ moveTreeInto(source, target);
4119
+ continue;
4120
+ }
4121
+ fs__default["default"].mkdirSync(path__namespace["default"].dirname(target), {
4122
+ recursive: true
4123
+ });
4124
+ fs__default["default"].renameSync(source, target);
4125
+ }
4126
+ }
4127
+
4128
+ /** Drop a patch's staging directory, whatever is left of it. */
4129
+ function removeStagedUploads(patchesDir, patchId) {
4130
+ try {
4131
+ fs__default["default"].rmSync(patchUploadDir(patchesDir, patchId), {
4132
+ recursive: true,
4133
+ force: true
4134
+ });
4135
+ } catch {
4136
+ // Hygiene, not correctness: nothing reads a staging directory that no patch
4137
+ // claims, and the sweep below gets it eventually.
4138
+ }
4139
+ }
4140
+
4141
+ /**
4142
+ * How long an upload nobody claimed is kept.
4143
+ *
4144
+ * Only garbage collection, which is why it can be a guess at all: these bytes
4145
+ * are outside the store, so no reader can mistake them for a patch and nothing
4146
+ * is lost by keeping them a while. The old marker-based attempt at this problem
4147
+ * had a TTL deciding whether to delete something INSIDE the store, where being
4148
+ * wrong meant destroying a live upload.
4149
+ */
4150
+ const STALE_UPLOAD_MS = 24 * 60 * 60 * 1000;
4151
+
4152
+ /**
4153
+ * Drop staged uploads whose patch never arrived.
4154
+ *
4155
+ * A client that dies between the upload and the `PUT` leaves its bytes here.
4156
+ * Nothing references them — no record points at them and the log never named
4157
+ * them — so they are removed without a word.
4158
+ */
4159
+ function sweepStaleUploads(patchesDir, now = Date.now()) {
4160
+ const dir = uploadsDir(patchesDir);
4161
+ if (!fs__default["default"].existsSync(dir)) {
4162
+ return;
4163
+ }
4164
+ let names;
4165
+ try {
4166
+ names = fs__default["default"].readdirSync(dir);
4167
+ } catch {
4168
+ return;
4169
+ }
4170
+ for (const name of names) {
4171
+ const staged = path__namespace["default"].join(dir, name);
4172
+ try {
4173
+ if (now - fs__default["default"].statSync(staged).mtimeMs < STALE_UPLOAD_MS) continue;
4174
+ fs__default["default"].rmSync(staged, {
4175
+ recursive: true,
4176
+ force: true
4177
+ });
4178
+ } catch {
4179
+ // Someone else is writing here, or it is already gone. Either way it is
4180
+ // not this pass's business.
4181
+ }
4182
+ }
3641
4183
  }
3642
4184
 
3643
4185
  /** Names that live in the patches directory but are not patches. */
@@ -3873,6 +4415,18 @@ function writePatchRecord(patchesDir, patchId, record) {
3873
4415
  function appendPatch(patchesDir, record) {
3874
4416
  const patchId = record.patchId;
3875
4417
  writePatchRecord(patchesDir, patchId, record);
4418
+ /*
4419
+ * Then the bytes, then the log line — and the order is the whole point.
4420
+ *
4421
+ * The record goes first, so the directory never exists without one: that is
4422
+ * what makes "files but no patch.json" a state this store cannot produce, and
4423
+ * what lets a reader keep treating it as a patch whose contents are lost.
4424
+ * The log line goes last, so an interrupted append leaves a directory the log
4425
+ * does not name — the benign half of a crash, swept silently.
4426
+ *
4427
+ * See `uploadsDir`. Under the lock, like the rest of this function.
4428
+ */
4429
+ moveStagedUploadsIn(patchesDir, patchId);
3876
4430
  const entry = {
3877
4431
  patchId,
3878
4432
  createdAt: record.createdAt,
@@ -4522,13 +5076,24 @@ class ValOpsFS extends ValOps {
4522
5076
  };
4523
5077
  }
4524
5078
  const patches = announceRes.entries.map(entry => entry.patchId);
4525
- // Drained here, on the channel that always flows. A repair that removed
4526
- // everything leaves the studio nothing to fetch, so a notice riding on
4527
- // `GET /patches` would sit here unread.
4528
- const removed = this.removedPatchNotices.splice(0, this.removedPatchNotices.length);
4529
- const removedNotice = removed.length > 0 ? {
4530
- removed
4531
- } : {};
5079
+ /**
5080
+ * Drained on the channel that always flows.
5081
+ *
5082
+ * A repair that removed everything leaves the studio nothing to fetch, so
5083
+ * a notice riding on `GET /patches` would sit here unread.
5084
+ *
5085
+ * Accumulating rather than assigning, because the long poll below drains
5086
+ * again before it answers: a repair during the wait must not have to sit
5087
+ * out another whole stat.
5088
+ */
5089
+ const removed = [];
5090
+ const drainRemoved = () => {
5091
+ removed.push(...this.removedPatchNotices.splice(0, this.removedPatchNotices.length));
5092
+ return removed.length > 0 ? {
5093
+ removed
5094
+ } : {};
5095
+ };
5096
+ const removedNotice = drainRemoved();
4532
5097
  // something changed: return immediately
4533
5098
  const didChange = !params ||
4534
5099
  // An entry file changed on disk: nothing else here can see that, since a
@@ -4609,6 +5174,14 @@ class ValOpsFS extends ValOps {
4609
5174
  if (Date.now() - start > interval) {
4610
5175
  console.warn("Val: polling interval of files exceeded");
4611
5176
  }
5177
+ // Checked BEFORE rescheduling, as the patches-directory poller
5178
+ // above does. Without it this walk goes on forever: the `finally`
5179
+ // that ends the race clears the handle it can see, and a `go`
5180
+ // already on the queue then schedules one nothing will ever clear —
5181
+ // a leaked timer, re-stat-ing the whole project, per stat poll.
5182
+ if (stopPolling) {
5183
+ return;
5184
+ }
4612
5185
  setHandle(setTimeout(() => go(resolve), interval));
4613
5186
  };
4614
5187
  if (stopPolling) {
@@ -4621,6 +5194,11 @@ class ValOpsFS extends ValOps {
4621
5194
  const disableFilePolling = ((_this$options2 = this.options) === null || _this$options2 === void 0 ? void 0 : _this$options2.disableFilePolling) || false;
4622
5195
  let patchesDirHandle;
4623
5196
  let valFilesIntervalHandle;
5197
+ // Held so the `finally` can clear it. A race the timeout LOSES still
5198
+ // leaves its timer armed, and it is the long one — so every stat that
5199
+ // returned on a file change kept the whole poll interval alive behind it,
5200
+ // doing nothing but holding the event loop open.
5201
+ let noChangeHandle;
4624
5202
  const type = await Promise.race([
4625
5203
  // 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
4626
5204
  disableFilePolling ? new Promise(() => {}) : didDirectoryChangeUsingPolling(this.getPatchesDir(), statFilePollingInterval, handle => {
@@ -4653,7 +5231,7 @@ class ValOpsFS extends ValOps {
4653
5231
  });
4654
5232
  }), new Promise(resolve => {
4655
5233
  var _this$options3;
4656
- return setTimeout(() => resolve("no-change"), ((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : _this$options3.statPollingInterval) || 20000);
5234
+ noChangeHandle = setTimeout(() => resolve("no-change"), ((_this$options3 = this.options) === null || _this$options3 === void 0 ? void 0 : _this$options3.statPollingInterval) || 20000);
4657
5235
  })]).finally(() => {
4658
5236
  if (fsWatcher) {
4659
5237
  fsWatcher.close();
@@ -4661,14 +5239,52 @@ class ValOpsFS extends ValOps {
4661
5239
  stopPolling = true;
4662
5240
  clearInterval(patchesDirHandle);
4663
5241
  clearInterval(valFilesIntervalHandle);
5242
+ clearTimeout(noChangeHandle);
4664
5243
  });
5244
+ /**
5245
+ * Read the store AGAIN, because `patches` above describes the moment this
5246
+ * poll OPENED — up to a whole polling interval ago.
5247
+ *
5248
+ * That staleness is not a detail: what ends the wait is a write, and the
5249
+ * write the studio does most is `/save`, which in `fs` mode commits the
5250
+ * patches and DELETES them. Answering with the list read before it names
5251
+ * patches that no longer exist, and the studio then puts those ids back in
5252
+ * its chain and fetches them from a server that correctly no longer has
5253
+ * them — "unpublished changes could not be loaded", for changes that were
5254
+ * published a moment earlier. With auto-save on, that is every pause in
5255
+ * typing.
5256
+ *
5257
+ * So a stat describes the moment it ANSWERS. `request-again` and
5258
+ * `no-change` differ in why the wait ended, not in how current the answer
5259
+ * has to be, so both come through here.
5260
+ *
5261
+ * The patch list is the only part that CAN have moved: the shas and the
5262
+ * schemas come from `initSources`, which is memoised for the lifetime of
5263
+ * this instance and never invalidated — a module change makes a new
5264
+ * `ValOpsFS` rather than updating this one. Recomputing them would be a
5265
+ * second full schema serialization per poll for a value that cannot have
5266
+ * changed.
5267
+ *
5268
+ * A read error is returned as one rather than papered over with the list
5269
+ * from the open: a patch store that cannot be read is what the error path
5270
+ * is for, and the studio asks again.
5271
+ */
5272
+ const answerRes = await this.readStore();
5273
+ if (answerRes.status === "error") {
5274
+ return {
5275
+ type: "error",
5276
+ error: {
5277
+ message: answerRes.message
5278
+ }
5279
+ };
5280
+ }
4665
5281
  return {
4666
5282
  type,
4667
5283
  baseSha: currentBaseSha,
4668
5284
  schemaSha: currentSchemaSha,
4669
5285
  sourcesSha: currentSourcesSha,
4670
- patches,
4671
- ...removedNotice,
5286
+ patches: answerRes.entries.map(entry => entry.patchId),
5287
+ ...drainRemoved(),
4672
5288
  jsonEntriesSha: currentJsonEntriesSha
4673
5289
  };
4674
5290
  } catch (err) {
@@ -5000,14 +5616,28 @@ class ValOpsFS extends ValOps {
5000
5616
  }
5001
5617
  async saveBase64EncodedBinaryFileFromPatch(filePath, _parentRef, patchId, data, _type, metadata) {
5002
5618
  // Keyed by the patch's own id, so the parent is not needed and is not asked
5003
- // for. Uploads arrive before the patch record does, which is fine: the
5004
- // directory sits there unreferenced until the log line that names it lands,
5005
- // and repair sweeps it up if that never happens.
5619
+ // for.
5620
+ //
5621
+ // Written OUTSIDE the store, and moved in by `appendPatch` once the record
5622
+ // exists. Uploads arrive before the patch record does — the record's `file`
5623
+ // op carries only a sha, so it would otherwise point at nothing — and
5624
+ // writing them straight into `<patchId>/files/` left a directory holding
5625
+ // files and no `patch.json` for a whole round trip. Nothing can read that as
5626
+ // anything but a patch whose contents are lost, so repair removed it, bytes
5627
+ // and all, and the image 404ed.
5628
+ //
5629
+ // Staging is what makes that state unreachable rather than tolerated. See
5630
+ // `uploadsDir`.
5006
5631
  const patchesDir = this.getPatchesDir();
5007
- const patchFilePath = patchBinaryFile(patchesDir, patchId, filePath);
5008
- const metadataFilePath = patchBinaryFileMetadata(patchesDir, patchId, filePath);
5632
+ const patchFilePath = stagedPatchBinaryFile(patchesDir, patchId, filePath);
5633
+ const metadataFilePath = stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath);
5009
5634
  try {
5010
5635
  if (data === null) {
5636
+ // A delete is the other order — the record is written first — so the
5637
+ // bytes are already in the store. Both locations, because a file staged
5638
+ // and then removed before its record never got there.
5639
+ this.host.deleteFile(patchBinaryFile(patchesDir, patchId, filePath));
5640
+ this.host.deleteFile(patchBinaryFileMetadata(patchesDir, patchId, filePath));
5011
5641
  this.host.deleteFile(patchFilePath);
5012
5642
  this.host.deleteFile(metadataFilePath);
5013
5643
  return {
@@ -5015,6 +5645,9 @@ class ValOpsFS extends ValOps {
5015
5645
  filePath
5016
5646
  };
5017
5647
  }
5648
+ // Cheap, and this is the one path that creates staging directories, so it
5649
+ // is where the ones nobody claimed get noticed.
5650
+ sweepStaleUploads(patchesDir);
5018
5651
  const buffer = bufferFromDataUrl(data);
5019
5652
  if (!buffer) {
5020
5653
  return {
@@ -5044,9 +5677,28 @@ class ValOpsFS extends ValOps {
5044
5677
  };
5045
5678
  }
5046
5679
  }
5680
+
5681
+ /**
5682
+ * Which of the two places a patch's file can be, if either.
5683
+ *
5684
+ * The bytes are in the store once the patch's record is, and in the staging
5685
+ * area before that — see `uploadsDir`. Every reader has to accept both, and
5686
+ * they decide it here rather than each on its own, so two readers of the same
5687
+ * file cannot disagree about whether it exists.
5688
+ */
5689
+ wherePatchFileIs(inStore, staged) {
5690
+ if (this.host.fileExists(inStore)) {
5691
+ return inStore;
5692
+ }
5693
+ if (this.host.fileExists(staged)) {
5694
+ return staged;
5695
+ }
5696
+ return null;
5697
+ }
5047
5698
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId) {
5048
- const metadataFilePath = patchBinaryFileMetadata(this.getPatchesDir(), patchId, filePath);
5049
- if (!this.host.fileExists(metadataFilePath)) {
5699
+ const patchesDir = this.getPatchesDir();
5700
+ const metadataFilePath = this.wherePatchFileIs(patchBinaryFileMetadata(patchesDir, patchId, filePath), stagedPatchBinaryFileMetadata(patchesDir, patchId, filePath));
5701
+ if (metadataFilePath === null) {
5050
5702
  return {
5051
5703
  errors: [{
5052
5704
  message: "Metadata file not found",
@@ -5083,8 +5735,9 @@ class ValOpsFS extends ValOps {
5083
5735
  async getBase64EncodedBinaryFileFromPatch(filePath, patchId) {
5084
5736
  // Straight from the id. This used to read and parse every patch on disk to
5085
5737
  // work out which directory the file was under, on every single image request.
5086
- const absPath = patchBinaryFile(this.getPatchesDir(), patchId, filePath);
5087
- if (!this.host.fileExists(absPath)) {
5738
+ const patchesDir = this.getPatchesDir();
5739
+ const absPath = this.wherePatchFileIs(patchBinaryFile(patchesDir, patchId, filePath), stagedPatchBinaryFile(patchesDir, patchId, filePath));
5740
+ if (absPath === null) {
5088
5741
  return null;
5089
5742
  }
5090
5743
  return this.host.readBinaryFile(absPath);
@@ -5129,6 +5782,9 @@ class ValOpsFS extends ValOps {
5129
5782
  recursive: true,
5130
5783
  force: true
5131
5784
  });
5785
+ // And anything this patch had staged but never moved in, which is
5786
+ // the case where a delete arrives before the record does.
5787
+ removeStagedUploads(patchesDir, patchId);
5132
5788
  deleted.push(patchId);
5133
5789
  } catch (err) {
5134
5790
  // Reported. This endpoint used to answer "deleted" unconditionally —
@@ -8359,6 +9015,26 @@ const ValServer = (valModules, options, callbacks) => {
8359
9015
  }
8360
9016
  };
8361
9017
  }
9018
+ /*
9019
+ * The files on disk are the committed content now, so say so here too.
9020
+ *
9021
+ * Nothing else will: the sources are memoised per `ValOps` instance
9022
+ * and are re-read by awaiting each module's `def`, which is the app's
9023
+ * own `import()` — that resolves from the module registry, not from
9024
+ * the file this save just rewrote. So until the host rebuilds its
9025
+ * module graph, every read of committed content answers with what was
9026
+ * there before the save. A page rendering draft content sees exactly
9027
+ * that once the patches below are gone: `getJsonEntry` resolves the
9028
+ * committed entry and has no patches left to replay over it.
9029
+ *
9030
+ * Before `deletePatches`, because it needs the patches it is adopting
9031
+ * the result of. See `ValOps.promoteCommittedSources` for why the SHAs
9032
+ * move with them.
9033
+ */
9034
+ await serverOps.adoptCommittedSources({
9035
+ ...analysis,
9036
+ ...patches
9037
+ }, preparedCommit);
8362
9038
  /*
8363
9039
  * Only what this request consumed.
8364
9040
  *
@@ -11999,6 +12675,7 @@ exports.createJsonEntryPathMap = createJsonEntryPathMap;
11999
12675
  exports.createModulePathMap = createModulePathMap;
12000
12676
  exports.createService = createService;
12001
12677
  exports.createValApiRouter = createValApiRouter;
12678
+ exports.createValModuleFileInspector = createValModuleFileInspector;
12002
12679
  exports.createValServer = createValServer;
12003
12680
  exports.currentFixHandlers = currentFixHandlers;
12004
12681
  exports.decodeJwt = decodeJwt;