@valbuild/server 0.145.1 → 0.146.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,16 +1,16 @@
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, Internal, VAL_EXTENSION, extractValModules, computeValModuleShas, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
4
+ import { derefPatch, Internal, VAL_EXTENSION, extractValModules, computeValModuleShas, ImageSchema, deserializeSchema, 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';
8
8
  import fs from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module } from 'node:module';
11
- import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, PlanRefusalError, PatchId as PatchId$1, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, parseJob, VAL_STUDIO_HEADER, Api, traverseSchemaSource, richTextImageSchema, forEachRichTextImage } from '@valbuild/shared/internal';
11
+ import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, schemaErrorsOf, schemaTypesOfPath, sideFiles, sideSource, PlanRefusalError, PatchId as PatchId$1, JSONValue, SerializedSchema, newestCommitSha, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, parseJob, VAL_STUDIO_HEADER, Api, traverseSchemaSource, richTextImageSchema, forEachRichTextImage } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
- import crypto$1 from 'crypto';
13
+ import crypto$1, { createHash } from 'crypto';
14
14
  import z$1, { z } from 'zod';
15
15
  import http from 'node:http';
16
16
  import https from 'node:https';
@@ -3534,7 +3534,7 @@ const jsonOps = new JSONOps();
3534
3534
  * host.
3535
3535
  */
3536
3536
  const JSON_ENTRY_READ_CONCURRENCY = 8;
3537
- const tsOps = new TSOps(document => {
3537
+ const tsOps$1 = new TSOps(document => {
3538
3538
  return pipe(analyzeValModule(document), result.map(({
3539
3539
  source
3540
3540
  }) => source));
@@ -4040,7 +4040,7 @@ class ValOps {
4040
4040
  message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
4041
4041
  };
4042
4042
  }
4043
- modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds, patchOps.commits).map(p => ({
4043
+ modulePatches = draftModulePatches(patchOps.patches, moduleFilePath, patchOps.commits).map(p => ({
4044
4044
  patchId: p.patchId,
4045
4045
  patch: p.patch
4046
4046
  }));
@@ -5147,7 +5147,7 @@ class ValOps {
5147
5147
  kind: "normal"
5148
5148
  };
5149
5149
  if (cls.kind === "normal" && fromCls.kind === "normal") {
5150
- const patchRes = applyPatch(tsSourceFile, tsOps, [op]);
5150
+ const patchRes = applyPatch(tsSourceFile, tsOps$1, [op]);
5151
5151
  if (result.isErr(patchRes)) {
5152
5152
  collectPatchError(patchRes.error, patchId, op);
5153
5153
  patchHadError = true;
@@ -5586,21 +5586,8 @@ class ValOps {
5586
5586
  }
5587
5587
 
5588
5588
  // #region createPatch
5589
- async createPatch(path, patch, patchId, parentRef, sessionId, authorId,
5590
- /**
5591
- * Which patch group this patch joins, recorded in the SAME request.
5592
- *
5593
- * Atomic on purpose. The content API runs every refusal before its insert,
5594
- * so an invalid closure is a 400 with nothing written. Recording membership
5595
- * in a second call would let a patch exist outside its author's group if
5596
- * that call failed — and a patch outside your own group is one you cannot
5597
- * publish until a repair puts it back.
5598
- *
5599
- * Optional: `fs` mode has no groups, and a client that predates them sends
5600
- * nothing.
5601
- */
5602
- patchGroup) {
5603
- const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
5589
+ async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
5590
+ const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
5604
5591
  if (result.isErr(saveRes)) {
5605
5592
  if (saveRes.error.errorType === "plan-refused") {
5606
5593
  // The project's plan, not a failure: the Studio keeps the edit and
@@ -5621,11 +5608,6 @@ class ValOps {
5621
5608
  return result.ok({
5622
5609
  patchId,
5623
5610
  createdAt: new Date().toISOString(),
5624
- // Spread rather than assigned: absent has to stay distinguishable from a
5625
- // group whose id is undefined, all the way to the client.
5626
- ...(saveRes.value.patchGroupId !== undefined ? {
5627
- patchGroupId: saveRes.value.patchGroupId
5628
- } : {}),
5629
5611
  ...(saveRes.value.headVersion !== undefined ? {
5630
5612
  headVersion: saveRes.value.headVersion
5631
5613
  } : {})
@@ -5783,8 +5765,8 @@ class ValOps {
5783
5765
  * Almost everything the routes branch on comes from this one fact, which is
5784
5766
  * why it is a named property rather than an `instanceof`. If this server owns
5785
5767
  * the store then there is no content service to authenticate to (so an absent
5786
- * or unverifiable session is anonymous rather than a 401), no shared store for
5787
- * a patch group to separate authors in, no deployments to report, and a
5768
+ * or unverifiable session is anonymous rather than a 401), no deployments to
5769
+ * report, and a
5788
5770
  * "publish" writes what the host does with it rather than pushing a commit. If
5789
5771
  * it does not, every one of those is the content service's and this server is
5790
5772
  * relaying.
@@ -5919,20 +5901,6 @@ function isFileSource(value) {
5919
5901
  return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
5920
5902
  }
5921
5903
 
5922
- /**
5923
- * The patch group a newly created patch joins.
5924
- *
5925
- * `withPatchIds` is the CLOSURE the client computed — the patches that share
5926
- * a patch set with this one and must move with it. It is not derived here and
5927
- * must not be: the closure needs the content schema, and the service that
5928
- * stores groups does not have it. One implementation of that rule, on the side
5929
- * that can actually compute it.
5930
- *
5931
- * Membership rows are stamped with `coreVersion` on the content side, the same
5932
- * stamp the patch row itself carries, so which client wrote a row stays legible
5933
- * after the fact.
5934
- */
5935
-
5936
5904
  /**
5937
5905
  * The content API refused a write for the project's plan (a 403 that says why
5938
5906
  * as data), as opposed to refusing the patch.
@@ -5964,34 +5932,16 @@ function formatPatchSourceError(error) {
5964
5932
  */
5965
5933
 
5966
5934
  /**
5967
- * The patches a json entry render should apply, out of the whole chain.
5968
- *
5969
- * Three rules, and the second is the one that was missing. A draft page renders
5970
- * `jsonValues` entries beside module content, and only the modules were scoped
5971
- * — so one screen showed the caller's own view for its modules and base plus
5972
- * EVERY pending patch on the branch for the entries beside them, including
5973
- * another author's half-finished edit rendered as though it were live.
5935
+ * The patches a json entry render should apply, out of the whole chain:
5974
5936
  *
5975
5937
  * 1. this module's, since the chain is branch-wide;
5976
- * 2. this caller's, when they asked to be scoped. `undefined` is "everything",
5977
- * which is what every unscoped caller gets and must keep getting;
5978
- * 3. a patch published after this build is kept whatever the scope: it is
5979
- * nobody's to hold back, and this build does not have it. One already in
5980
- * the build is dropped. `draftOverlay` decides which is which, from
5981
- * `commits`.
5982
- *
5983
- * Filtered here rather than by asking `fetchPatches` for a list, and that is
5984
- * load-bearing: both implementations read an empty `patchIds` as "no filter"
5985
- * and return the whole chain. That is the right default for a caller that
5986
- * cannot mean "none", and the most dangerous possible reading of a group that
5987
- * is genuinely empty — it would render every unpublished patch on the branch
5988
- * instead of base. It costs no round trip either: the whole chain is what the
5989
- * unscoped path fetches anyway.
5990
- */
5991
- function scopedModulePatches(patches, moduleFilePath, patchIds, /** The commits content says came after this build: see `draftOverlay`. */
5938
+ * 2. a patch published after this build is kept: this build does not have it.
5939
+ * One already in the build is dropped. `draftOverlay` decides which is
5940
+ * which, from `commits`.
5941
+ */
5942
+ function draftModulePatches(patches, moduleFilePath, /** The commits content says came after this build: see `draftOverlay`. */
5992
5943
  commits) {
5993
- const scope = patchIds && new Set(patchIds);
5994
- return draftOverlay(patches.filter(patch => patch.path === moduleFilePath && (patch.appliedAt !== null || scope === undefined || scope.has(patch.patchId))), commits);
5944
+ return draftOverlay(patches.filter(patch => patch.path === moduleFilePath), commits);
5995
5945
  }
5996
5946
 
5997
5947
  /**
@@ -8083,17 +8033,7 @@ class ValOpsFS extends ValOps {
8083
8033
  };
8084
8034
  }
8085
8035
  }
8086
- async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId,
8087
- /*
8088
- * Named and ignored, rather than omitted from the signature.
8089
- *
8090
- * `fs` mode has no shared store and exactly one author, so there is nothing
8091
- * for a group to separate — every pending patch is already this person's.
8092
- * Declaring it makes that a decision a reader can see: TypeScript lets an
8093
- * implementation take fewer parameters than the abstract, so leaving it off
8094
- * would drop group membership silently and look identical to handling it.
8095
- */
8096
- _patchGroup) {
8036
+ async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
8097
8037
  const patchesDir = this.getPatchesDir();
8098
8038
  const record = {
8099
8039
  patch,
@@ -9006,13 +8946,13 @@ function proposalFromEnv(env) {
9006
8946
  });
9007
8947
  }
9008
8948
  }
9009
- if (!isObject$1(parsed)) {
8949
+ if (!isObject$2(parsed)) {
9010
8950
  throw new Error(`Val: proposal ${name}: VAL_OVERLAY is not an object`);
9011
8951
  }
9012
8952
  const modulesIn = parsed["modules"] ?? {};
9013
8953
  const filesIn = parsed["files"] ?? {};
9014
8954
  const commit = parsed["commit"] ?? null;
9015
- if (!isObject$1(modulesIn) || !isObject$1(filesIn)) {
8955
+ if (!isObject$2(modulesIn) || !isObject$2(filesIn)) {
9016
8956
  throw new Error(`Val: proposal ${name}: VAL_OVERLAY's modules and files must be objects`);
9017
8957
  }
9018
8958
  if (commit !== null && typeof commit !== "string") {
@@ -9075,7 +9015,7 @@ function contentFromEnv(env) {
9075
9015
  cause: err
9076
9016
  });
9077
9017
  }
9078
- if (!isObject$1(parsed) || Array.isArray(parsed)) {
9018
+ if (!isObject$2(parsed) || Array.isArray(parsed)) {
9079
9019
  throw new Error("Val: VAL_CONTENT is not an object");
9080
9020
  }
9081
9021
  const id = parsed["id"];
@@ -9092,7 +9032,7 @@ function contentFromEnv(env) {
9092
9032
  }
9093
9033
  const modulesIn = parsed["modules"] ?? {};
9094
9034
  const filesIn = parsed["files"] ?? {};
9095
- if (!isObject$1(modulesIn) || Array.isArray(modulesIn) || !isObject$1(filesIn) || Array.isArray(filesIn)) {
9035
+ if (!isObject$2(modulesIn) || Array.isArray(modulesIn) || !isObject$2(filesIn) || Array.isArray(filesIn)) {
9096
9036
  throw new Error(`Val: content version ${id}: modules and files must be objects`);
9097
9037
  }
9098
9038
  const files = {};
@@ -9105,7 +9045,7 @@ function contentFromEnv(env) {
9105
9045
  ...modulesIn
9106
9046
  };
9107
9047
  const refs = parsed["refs"] ?? {};
9108
- if (!isObject$1(refs) || Array.isArray(refs)) {
9048
+ if (!isObject$2(refs) || Array.isArray(refs)) {
9109
9049
  throw new Error(`Val: content version ${id}: refs must be an object`);
9110
9050
  }
9111
9051
  const read = (kind, path, ref) => {
@@ -9132,7 +9072,7 @@ function contentFromEnv(env) {
9132
9072
  };
9133
9073
  const refModules = refs["modules"] ?? {};
9134
9074
  const refFiles = refs["files"] ?? {};
9135
- if (!isObject$1(refModules) || Array.isArray(refModules) || !isObject$1(refFiles) || Array.isArray(refFiles)) {
9075
+ if (!isObject$2(refModules) || Array.isArray(refModules) || !isObject$2(refFiles) || Array.isArray(refFiles)) {
9136
9076
  throw new Error(`Val: content version ${id}: refs.modules and refs.files must be objects`);
9137
9077
  }
9138
9078
  for (const [path, ref] of Object.entries(refModules)) {
@@ -9277,7 +9217,7 @@ function proposalSnapshotForReaders(proposal, projectSource) {
9277
9217
  const snapshot = proposalSnapshot(proposal);
9278
9218
  for (const [moduleFilePath, entries] of proposalJsonEntries(proposal, projectSource)) {
9279
9219
  const source = snapshot[moduleFilePath];
9280
- if (!isObject$1(source)) continue;
9220
+ if (!isObject$2(source)) continue;
9281
9221
  const filled = {};
9282
9222
  for (const [key, value] of Object.entries(source)) {
9283
9223
  if (isJsonValue(value)) filled[key] = value;
@@ -9295,7 +9235,7 @@ function proposalSnapshotForReaders(proposal, projectSource) {
9295
9235
 
9296
9236
  /** A record of `.jsonValues()` markers (`{ _type: "json" }`), at least one. */
9297
9237
  function holdsJsonMarkers(source) {
9298
- return isObject$1(source) && Object.values(source).some(value => isObject$1(value) && value["_type"] === "json");
9238
+ return isObject$2(source) && Object.values(source).some(value => isObject$2(value) && value["_type"] === "json");
9299
9239
  }
9300
9240
 
9301
9241
  /** Each entry key's import path, from a `.jsonValues()` module's text. */
@@ -9331,9 +9271,9 @@ function isMutableJsonValue(value) {
9331
9271
  if (Array.isArray(value)) {
9332
9272
  return value.every(isMutableJsonValue);
9333
9273
  }
9334
- return isObject$1(value) && Object.values(value).every(isMutableJsonValue);
9274
+ return isObject$2(value) && Object.values(value).every(isMutableJsonValue);
9335
9275
  }
9336
- function isObject$1(value) {
9276
+ function isObject$2(value) {
9337
9277
  return typeof value === "object" && value !== null && !Array.isArray(value);
9338
9278
  }
9339
9279
 
@@ -9345,267 +9285,1025 @@ function isJsonValue(value) {
9345
9285
  if (Array.isArray(value)) {
9346
9286
  return value.every(isJsonValue);
9347
9287
  }
9348
- return isObject$1(value) && Object.values(value).every(isJsonValue);
9288
+ return isObject$2(value) && Object.values(value).every(isJsonValue);
9349
9289
  }
9350
9290
 
9351
- const textEncoder$2 = new TextEncoder();
9291
+ /** What a schema reports about this content: see `schemaErrorsOf`. */
9292
+ const validateWith = (schema, moduleFilePath, source) => schemaErrorsOf(schema, moduleFilePath, source);
9352
9293
 
9353
- /** The body of the content API's 403 for a write the project's plan refuses. */
9354
- const PlanRefusedBody = z.object({
9355
- error: PlanRefusalError
9356
- });
9357
- const PatchId = z.string().refine(s => !!s); // TODO: validate
9358
- const CommitSha = z.string().refine(s => !!s); // TODO: validate
9359
- z.string().refine(s => !!s); // TODO: validate
9360
- z.string().refine(s => !!s); // TODO: validate
9361
- const ModuleFilePath = z.string().refine(s => !!s); // TODO: validate
9362
- const Metadata = z.union([z.object({
9363
- mimeType: z.string(),
9364
- width: z.number(),
9365
- height: z.number()
9366
- }), z.object({
9367
- mimeType: z.string()
9368
- })]);
9369
- const MetadataRes = z.object({
9370
- filePath: ModuleFilePath,
9371
- metadata: Metadata,
9372
- type: z.union([z.literal("file"), z.literal("image")]).nullable()
9373
- });
9374
- const GetApplicablePatches = z.object({
9375
- patches: z.array(z.object({
9376
- path: z.string(),
9377
- patch: Patch.nullable(),
9378
- patchId: z.string(),
9379
- authorId: z.string().nullable(),
9380
- baseSha: z.string(),
9381
- createdAt: z.string(),
9382
- applied: z.object({
9383
- commitSha: z.string()
9384
- }).nullable(),
9385
- /**
9386
- * The groups this patch is a member of. Read in the SAME transaction as
9387
- * the list and its `headVersion`, which is the point: see
9388
- * `OrderedPatches.patchGroups`. Optional for an older content service.
9389
- */
9390
- patchGroupIds: z.array(z.string()).optional()
9391
- })),
9392
- /**
9393
- * The groups on the branch, without their members — those are on the
9394
- * patches above. Optional for a content service that predates groups.
9395
- */
9396
- patchGroups: z.array(z.object({
9397
- patchGroupId: z.string(),
9398
- authorId: z.string().nullable(),
9399
- createdAt: z.string(),
9400
- publishedAt: z.string().nullable()
9401
- })).optional(),
9402
- /**
9403
- * The head of the chain. See `OrderedPatches.headPatchId`. Optional because
9404
- * a content service that predates it sends nothing.
9405
- */
9406
- headPatchId: PatchId$1.nullable().optional(),
9407
- /**
9408
- * The chain version of that head. See `OrderedPatches.headVersion`.
9409
- * Optional for the same reason.
9410
- */
9411
- headVersion: z.number().optional(),
9412
- /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
9413
- publishingPatches: z.array(PatchId$1).optional(),
9414
- /** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
9415
- heldBackPatches: z.array(PatchId$1).optional(),
9416
- commits: z.array(z.object({
9417
- commitSha: z.string(),
9418
- /*
9419
- * Nullable since the content service mints its own commit shas: a
9420
- * root commit has no parent, and a publisher that did not say where
9421
- * it was has no client sha. Parsed rather than trusted, so a service
9422
- * that sends null gets null here instead of failing the whole poll.
9423
- */
9424
- clientCommitSha: z.string().nullable(),
9425
- parentCommitSha: z.string().nullable(),
9426
- commitMessage: z.string().nullable(),
9427
- branch: z.string(),
9428
- creator: z.string(),
9429
- createdAt: z.string()
9430
- })).optional(),
9431
- deployments: z.array(z.object({
9432
- deploymentId: z.string(),
9433
- commitSha: z.string(),
9434
- deploymentState: z.string(),
9435
- createdAt: z.string(),
9436
- updatedAt: z.string(),
9437
- // The git message, where the content service knows it. See
9438
- // `ValDeployment`: this is the only source for a deployment that Val
9439
- // did not publish, and zod would otherwise strip it here.
9440
- commitMessage: z.string().nullable().optional()
9441
- })).optional(),
9442
- /**
9443
- * What the project EXPECTS of whoever publishes it.
9444
- *
9445
- * Optional because a content service that predates it sends nothing, and
9446
- * absent means "not reported" -- never "managed". The difference decides
9447
- * whether a publish is refused, so guessing either way would be worse than
9448
- * not checking: guessing `managed` would let a deployment publish a
9449
- * connected project it cannot mirror, and guessing `connected` would refuse
9450
- * every publish against an older service.
9451
- */
9452
- project: z.object({
9453
- sourceMode: z.union([z.literal("managed"), z.literal("connected")]),
9454
- branch: z.string(),
9455
- /**
9456
- * Whether a press of Publish is a publish JOB. Absent from a content
9457
- * service that predates connected jobs: then managed projects publish
9458
- * as jobs, and connected ones by commit, as they did.
9459
- */
9460
- publishJobs: z.boolean().optional()
9461
- }).optional()
9462
- });
9463
- const FilesResponse = z.object({
9464
- files: z.array(z.union([z.object({
9465
- filePath: z.string(),
9466
- location: z.literal("patch"),
9467
- patchId: PatchId,
9468
- value: z.string(),
9469
- remote: z.boolean()
9470
- }), z.object({
9471
- filePath: z.string(),
9472
- location: z.literal("repo"),
9473
- commitSha: CommitSha,
9474
- value: z.string()
9475
- })])),
9476
- errors: z.array(z.union([z.object({
9477
- filePath: z.string(),
9478
- location: z.literal("patch"),
9479
- patchId: PatchId,
9480
- message: z.string(),
9481
- remote: z.boolean()
9482
- }), z.object({
9483
- filePath: z.string(),
9484
- location: z.literal("repo"),
9485
- commitSha: CommitSha,
9486
- message: z.string()
9487
- })])).optional()
9488
- });
9489
- const SavePatchResponse = z.object({
9490
- patchId: PatchId,
9491
- /**
9492
- * Which group the content API put this patch in.
9493
- *
9494
- * Optional: a content API that predates patch groups does not send one, and
9495
- * absence has to keep meaning "no groups here" rather than failing the save.
9496
- */
9497
- patchGroupId: z.string().optional(),
9498
- /**
9499
- * The chain version this write made, comparable with `headVersion` on the
9500
- * listing. Optional: a content service that predates it sends nothing.
9501
- */
9502
- headVersion: z.number().optional()
9503
- });
9504
- const DeletePatchesResponse = z.object({
9505
- deleted: z.array(PatchId),
9506
- errors: z.array(z.object({
9507
- message: z.string(),
9508
- patchId: PatchId,
9509
- /** The publish job holding it, when that is why it was not deleted. */
9510
- publishJobId: z.string().optional()
9511
- })).optional(),
9512
- /**
9513
- * The chain version each branch the delete changed is now at, keyed by
9514
- * branch. Optional: a content service that predates it sends none.
9515
- */
9516
- headVersions: z.record(z.string(), z.number()).optional()
9517
- });
9518
9294
  /**
9519
- * This client's own branch's entry in a per-branch version map.
9520
- *
9521
- * A delete can span branches, so the content service answers per branch. With
9522
- * no branch of its own (a build with no repository, where the service resolves
9523
- * it), the only entry is ours when there is exactly one; with several, none can
9524
- * be picked, and a wrong one would floor this client at another chain's count.
9295
+ * A module's errors for this content: its own `validate` where the schema was
9296
+ * evaluated, custom rules and all; otherwise what its serialized schema says,
9297
+ * which is every rule but a custom `.validate(...)` (a closure does not
9298
+ * serialize).
9525
9299
  */
9526
- function ownBranchVersion(headVersions, branch) {
9527
- if (headVersions === undefined) return undefined;
9528
- if (branch !== undefined) return headVersions[branch];
9529
- const versions = Object.values(headVersions);
9530
- return versions.length === 1 ? versions[0] : undefined;
9300
+ function validateModule(moduleFilePath, module, source) {
9301
+ if (module.validate) return module.validate(source);
9302
+ return validateWith(deserializeSchema(module.schema), moduleFilePath, source);
9303
+ }
9304
+
9305
+ /** The errors of a module's own Source: as evaluated where they were, else found here. */
9306
+ function errorsOfSource(moduleFilePath, module) {
9307
+ return module.errors ?? validateModule(moduleFilePath, module, module.source);
9531
9308
  }
9532
- const SavePatchFileResponse = z.object({
9533
- patchId: PatchId,
9534
- filePath: ModuleFilePath
9535
- });
9536
- const CommitResponse = z.object({
9537
- updatedFiles: z.array(z.string()),
9538
- commit: CommitSha,
9539
- /*
9540
- * Optional because a content service that predates them sends neither, and
9541
- * this client talks to whichever one the project is on. Absent means NOT
9542
- * REPORTED: `parent: undefined` is not "a root commit", and a caller that
9543
- * reads it as one would conclude the history starts here.
9544
- *
9545
- * Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
9546
- * from a service that versions separately and are passed through as what it
9547
- * said, not as something this end vouched for.
9548
- */
9549
- parent: z.string().optional(),
9550
- tree: z.string().optional(),
9551
- branch: z.string(),
9552
- /** The chain version the publish moved the branch to; absent if it applied nothing. */
9553
- headVersion: z.number().optional()
9554
- });
9555
- // #region history wire schemas
9556
- //
9557
- // Validated on arrival rather than trusted: these come from a service that
9558
- // versions separately, and a silently mis-shaped commit record reads as "this
9559
- // commit changed nothing", which is indistinguishable from a real answer.
9560
- const HistoricalCommitResponse = z.object({
9561
- commitSha: z.string(),
9562
- /** `null` for a root commit; see the note on the applicable-patches schema. */
9563
- parentCommitSha: z.string().nullable(),
9564
- /** `null` when the publisher did not say where it was. */
9565
- clientCommitSha: z.string().nullable(),
9566
- branch: z.string(),
9567
- createdBranch: z.string().nullable(),
9568
- creator: z.string().nullable(),
9569
- message: z.string().nullable(),
9570
- createdAt: z.string(),
9571
- seqNum: z.string(),
9572
- patchCount: z.number(),
9573
- hasArchive: z.boolean()
9574
- });
9575
- const ListCommitsResponse = z.object({
9576
- commits: z.array(HistoricalCommitResponse),
9577
- nextCursor: z.string().nullable()
9578
- });
9579
- const CommitPatchesResponse = z.object({
9580
- commitSha: z.string(),
9581
- commit: z.object({
9582
- commitSha: z.string(),
9583
- parentCommitSha: z.string().nullable(),
9584
- clientCommitSha: z.string().nullable(),
9585
- branch: z.string(),
9586
- createdBranch: z.string().nullable(),
9587
- creator: z.string().nullable(),
9588
- message: z.string().nullable(),
9589
- createdAt: z.string(),
9590
- seqNum: z.string(),
9591
- hasArchive: z.boolean()
9592
- }),
9593
- patches: z.array(z.object({
9594
- patchId: z.string(),
9595
- path: z.string(),
9596
- patch: z.unknown(),
9597
- authorId: z.string().nullable(),
9598
- createdAt: z.string(),
9599
- baseSha: z.string(),
9600
- coreVersion: z.string()
9601
- }))
9602
- });
9603
9309
 
9604
9310
  /**
9605
- * `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
9606
- *
9607
- * `schema` stays `unknown` here on purpose. The content service stores it
9608
- * opaquely and cannot vouch for it, so validating it at the transport boundary
9311
+ * A `.jsonValues()` record's entry files, by key, read out of its `.val.ts`
9312
+ * (the loaded marker does not carry the path). `null` when it has none.
9313
+ */
9314
+ function entryFilesOf(moduleFilePath, text) {
9315
+ const analysis = valModuleOf(moduleFilePath, text);
9316
+ if (analysis === null) return null;
9317
+ const entries = analyzeJsonValuesEntries(analysis.source);
9318
+ if (entries.size === 0) return null;
9319
+ const dir = path__default.posix.dirname(moduleFilePath.slice(1));
9320
+ return Object.fromEntries([...entries].map(([key, {
9321
+ importPath
9322
+ }]) => [key, path__default.posix.join(dir, importPath)]));
9323
+ }
9324
+
9325
+ // --- code and content ------------------------------------------------------------
9326
+
9327
+ /**
9328
+ * A file's code: for a `.val.ts`, everything but `c.define`'s content
9329
+ * argument, which Val reads at request time (step 2) and so is no part of a
9330
+ * build; a `.val.json` entry is no code at all. Every other file is code
9331
+ * whole.
9332
+ */
9333
+ function codeOf(file, text) {
9334
+ // A `.jsonValues()` entry is content, read at request time like the rest.
9335
+ if (file.endsWith(".val.json")) return "";
9336
+ if (!file.endsWith(".val.ts")) return text;
9337
+ const analysis = valModuleOf(file, text);
9338
+ if (analysis === null) return text;
9339
+ const {
9340
+ source
9341
+ } = analysis;
9342
+ return text.slice(0, source.getStart()) + "/* content */" + text.slice(source.end);
9343
+ }
9344
+
9345
+ /**
9346
+ * A `.val.ts`'s `c.define(...)`, or `null` when it has none: a file of schemas
9347
+ * that pages share (`export const heroSchema = s.object(...)`) is named like a
9348
+ * module and is code. `analyzeValModule` throws for one; this does not.
9349
+ */
9350
+ function valModuleOf(file, text) {
9351
+ const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2015, true);
9352
+ if (!sourceFile.statements.some(node => ts.isExportAssignment(node))) {
9353
+ return null;
9354
+ }
9355
+ const analysis = analyzeValModule(sourceFile);
9356
+ return result.isErr(analysis) ? null : analysis.value;
9357
+ }
9358
+
9359
+ /**
9360
+ * The three-way merge of a module's content: where both sides started
9361
+ * (`base`), and each side's version. Pure JSON -- the schema decides nothing
9362
+ * here, and is checked afterwards (the schema check in `merge.ts`).
9363
+ *
9364
+ * The rules (valbuild/home `docs/hosted-publishing.md` step 8.2):
9365
+ *
9366
+ * - Changed on one side: that side's.
9367
+ * - Changed on both: a conflict -- even to the same value, which is an easy
9368
+ * conflict to resolve but still one somebody decides.
9369
+ * - An object (a page, a record) merges key by key, so two keys changed on
9370
+ * two sides combine.
9371
+ * - A list merges item by item only when neither side changed its shape: no
9372
+ * item added, removed or moved. Otherwise positions mean different items on
9373
+ * the two sides, and the whole list is the conflict.
9374
+ */
9375
+
9376
+ function isObject$1(value) {
9377
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9378
+ }
9379
+ function same(a, b) {
9380
+ if (a === undefined || b === undefined) return a === b;
9381
+ return deepEqual(a, b);
9382
+ }
9383
+
9384
+ /**
9385
+ * Did this side add, remove or move an item? Lengths differ, or an item now
9386
+ * sits where it was not, while it is still what used to be somewhere else.
9387
+ */
9388
+ function reshaped(base, side) {
9389
+ if (base.length !== side.length) return true;
9390
+ return side.some((item, at) => !same(item, base[at]) && base.some((was, from) => from !== at && same(item, was)));
9391
+ }
9392
+
9393
+ /**
9394
+ * Where a value is one value, though it is an object: a media value (its path
9395
+ * and what describes that file) or rich text. The schema says; see `merge.ts`.
9396
+ */
9397
+
9398
+ function mergeValues(base, site, proposal, atomic = () => false, path = []) {
9399
+ if (same(site, base)) return {
9400
+ value: proposal,
9401
+ conflicts: []
9402
+ };
9403
+ if (same(proposal, base)) return {
9404
+ value: site,
9405
+ conflicts: []
9406
+ };
9407
+ if (atomic(path)) {
9408
+ return {
9409
+ value: site,
9410
+ conflicts: [{
9411
+ path,
9412
+ base,
9413
+ site,
9414
+ proposal
9415
+ }]
9416
+ };
9417
+ }
9418
+ if (isObject$1(base) && isObject$1(site) && isObject$1(proposal)) {
9419
+ const keys = [...new Set([...Object.keys(site), ...Object.keys(proposal), ...Object.keys(base)])];
9420
+ const value = {};
9421
+ const conflicts = [];
9422
+ for (const key of keys) {
9423
+ const merged = mergeValues(base[key], site[key], proposal[key], atomic, [...path, key]);
9424
+ if (merged.value !== undefined) value[key] = merged.value;
9425
+ conflicts.push(...merged.conflicts);
9426
+ }
9427
+ return {
9428
+ value,
9429
+ conflicts
9430
+ };
9431
+ }
9432
+ if (Array.isArray(base) && Array.isArray(site) && Array.isArray(proposal) && !reshaped(base, site) && !reshaped(base, proposal)) {
9433
+ const value = [];
9434
+ const conflicts = [];
9435
+ base.forEach((item, at) => {
9436
+ const merged = mergeValues(item, site[at], proposal[at], atomic, [...path, String(at)]);
9437
+ // Neither side removed an item, so every position holds one.
9438
+ value.push(merged.value ?? null);
9439
+ conflicts.push(...merged.conflicts);
9440
+ });
9441
+ return {
9442
+ value,
9443
+ conflicts
9444
+ };
9445
+ }
9446
+ return {
9447
+ value: site,
9448
+ conflicts: [{
9449
+ path,
9450
+ base,
9451
+ site,
9452
+ proposal
9453
+ }]
9454
+ };
9455
+ }
9456
+
9457
+ /** `doc` with `value` at `path`; `undefined` removes it. `doc` is not changed. */
9458
+ function setAt(doc, path, value) {
9459
+ if (path.length === 0) return value;
9460
+ const [head, ...rest] = path;
9461
+ if (Array.isArray(doc)) {
9462
+ const copy = [...doc];
9463
+ const at = Number(head);
9464
+ const next = setAt(copy[at], rest, value);
9465
+ if (next === undefined) copy.splice(at, 1);else copy[at] = next;
9466
+ return copy;
9467
+ }
9468
+ const copy = isObject$1(doc) ? {
9469
+ ...doc
9470
+ } : {};
9471
+ const next = setAt(copy[head], rest, value);
9472
+ if (next === undefined) delete copy[head];else copy[head] = next;
9473
+ return copy;
9474
+ }
9475
+ function valueAt(doc, path) {
9476
+ let current = doc;
9477
+ for (const key of path) {
9478
+ if (Array.isArray(current)) current = current[Number(key)];else if (isObject$1(current)) current = current[key];else return undefined;
9479
+ }
9480
+ return current;
9481
+ }
9482
+
9483
+ /**
9484
+ * The Val patch that turns `from` into `to`, as small as it can be: what
9485
+ * writes a merge into a `.val.ts` without rewriting what did not change.
9486
+ */
9487
+ function diffOps(from, to, path = []) {
9488
+ if (same(from, to)) return [];
9489
+ if (isObject$1(from) && isObject$1(to)) {
9490
+ const ops = [];
9491
+ for (const key of Object.keys(from)) {
9492
+ if (!(key in to)) ops.push({
9493
+ op: "remove",
9494
+ path: below(path, key)
9495
+ });
9496
+ }
9497
+ for (const [key, value] of Object.entries(to)) {
9498
+ ops.push(...(key in from ? diffOps(from[key], value, [...path, key]) : [add([...path, key], value)]));
9499
+ }
9500
+ return ops;
9501
+ }
9502
+ if (Array.isArray(from) && Array.isArray(to) && from.length === to.length) {
9503
+ return from.flatMap((item, at) => diffOps(item, to[at], [...path, String(at)]));
9504
+ }
9505
+ return [{
9506
+ op: "replace",
9507
+ path,
9508
+ value: to ?? null
9509
+ }];
9510
+ }
9511
+ function add(path, value) {
9512
+ return {
9513
+ op: "add",
9514
+ path,
9515
+ value
9516
+ };
9517
+ }
9518
+ function below(path, key) {
9519
+ return path.length === 0 ? [key] : [path[0], ...path.slice(1), key];
9520
+ }
9521
+
9522
+ /**
9523
+ * Publishing a proposal as a pull request is merged: valbuild/home
9524
+ * `docs/hosted-publishing.md`, step 8.2.
9525
+ *
9526
+ * Val's half: the two answers that need a schema. Content gets them as files
9527
+ * and change ids and carries them out, never looking at a field, a list or a
9528
+ * patch set.
9529
+ *
9530
+ * - {@link mergeFromSite} -- Update from the site: the merge commit `M`.
9531
+ * - {@link changesToMove} -- which of the site's pending changes leave the
9532
+ * site when a proposal is merged.
9533
+ *
9534
+ * `mergeScenarios.test.ts` is their specification.
9535
+ */
9536
+
9537
+ /** A field both sides changed since the proposal started. */
9538
+
9539
+ /**
9540
+ * Code both sides changed: a component, `val.modules.ts`, or the schema of a
9541
+ * `.val.ts` (its code -- everything but `c.define`'s content argument, whose
9542
+ * fields merge on their own).
9543
+ */
9544
+
9545
+ /**
9546
+ * The schema check: content that merged cleanly and is not valid under the
9547
+ * schema the merge ends with -- an entry the site added, say, under a field
9548
+ * whose type the proposal changed.
9549
+ */
9550
+
9551
+ /** How a conflict is resolved: keep one side, or write it -- as AI or a developer does. */
9552
+
9553
+ /**
9554
+ * Update from the site: a three-way merge of where the proposal started
9555
+ * (`base`), the site's head and the proposal's last save, written whole.
9556
+ * Every conflict needs a resolution, or nothing is merged.
9557
+ *
9558
+ * File by file. A file that is not a `.val.ts` is code, and merges as a
9559
+ * file: changed on one side, that side's; on both, a conflict. A `.val.ts`
9560
+ * is code (everything but `c.define`'s content argument) and content, and the
9561
+ * two merge apart: the code as a file, the content field by field
9562
+ * (`mergeValues`). So a schema the proposal changed and a field the site
9563
+ * published in the same file do not conflict. A code conflict in a `.val.ts`
9564
+ * is resolved by keeping a side's code -- the content still merges -- or by
9565
+ * writing the whole file.
9566
+ *
9567
+ * Not here: the schema check ({@link schemaCheck}). It needs the merged
9568
+ * files' schemas, so whoever can evaluate them -- Val's loader, the builder
9569
+ * tab -- runs it on `files` before the merge is committed.
9570
+ */
9571
+ function mergeFromSite(input) {
9572
+ const {
9573
+ base,
9574
+ site,
9575
+ proposal,
9576
+ resolutions
9577
+ } = input;
9578
+ const files = {};
9579
+ const modules = {};
9580
+ const conflicts = [];
9581
+ const paths = [...new Set([...Object.keys(base.files), ...Object.keys(site.files), ...Object.keys(proposal.files)])].sort();
9582
+ // A `.jsonValues()` entry is written with its module, never on its own.
9583
+ const entryFiles = new Set([base, site, proposal].flatMap(side => Object.values(side.modules).flatMap(module => Object.values(module.entries ?? {}))));
9584
+ for (const file of paths) {
9585
+ var _moduleOf3, _moduleOf4, _moduleOf5, _moduleOf6;
9586
+ if (entryFiles.has(file)) continue;
9587
+ const b = base.files[file] ?? null;
9588
+ const s = site.files[file] ?? null;
9589
+ const p = proposal.files[file] ?? null;
9590
+ const resolution = fileResolution(resolutions, file);
9591
+ const codeConflict = () => ({
9592
+ kind: "code",
9593
+ file,
9594
+ base: b,
9595
+ site: s,
9596
+ proposal: p
9597
+ });
9598
+
9599
+ // A file named like a module that no side defines as one -- schemas the
9600
+ // pages share -- is code.
9601
+ const moduleFilePath = `/${file}`;
9602
+ const isModule = [base, site, proposal].some(side => side.modules[moduleFilePath] !== undefined);
9603
+ if (!file.endsWith(".val.ts") || !isModule) {
9604
+ if (s === b) files[file] = p;else if (p === b) files[file] = s;else if (resolution) files[file] = resolvedText(resolution, s, p);else {
9605
+ conflicts.push(codeConflict());
9606
+ files[file] = s;
9607
+ }
9608
+ continue;
9609
+ }
9610
+
9611
+ // A .val.ts: whose code, then the content under it.
9612
+ const code = text => text === null ? null : codeOf(file, text);
9613
+ let side;
9614
+ if (code(s) === code(b)) side = "proposal";else if (code(p) === code(b)) side = "site";else if (resolution && "text" in resolution) {
9615
+ files[file] = resolution.text;
9616
+ continue;
9617
+ } else if (resolution) side = resolution.keep;else {
9618
+ conflicts.push(codeConflict());
9619
+ files[file] = s;
9620
+ continue;
9621
+ }
9622
+ const moduleOf = project => project.modules[moduleFilePath];
9623
+ const sourceOf = project => {
9624
+ var _moduleOf;
9625
+ return (_moduleOf = moduleOf(project)) === null || _moduleOf === void 0 ? void 0 : _moduleOf.source;
9626
+ };
9627
+ const chosen = side === "site" ? site : proposal;
9628
+ const text = side === "site" ? s : p;
9629
+ if (text === null) {
9630
+ // That side deleted the file. Content the other side changed would go
9631
+ // with it: that is for a person to decide -- unless they have.
9632
+ const other = side === "site" ? p : s;
9633
+ if (!resolution && other !== b) {
9634
+ conflicts.push(codeConflict());
9635
+ }
9636
+ files[file] = null;
9637
+ for (const project of [base, site, proposal]) {
9638
+ for (const entry of Object.values(((_moduleOf2 = moduleOf(project)) === null || _moduleOf2 === void 0 ? void 0 : _moduleOf2.entries) ?? {})) {
9639
+ var _moduleOf2;
9640
+ files[entry] = null;
9641
+ }
9642
+ }
9643
+ continue;
9644
+ }
9645
+ // What the chosen text holds, which the merge is written over.
9646
+ const from = ((_moduleOf3 = moduleOf(chosen)) === null || _moduleOf3 === void 0 ? void 0 : _moduleOf3.written) ?? ((_moduleOf4 = moduleOf(chosen)) === null || _moduleOf4 === void 0 ? void 0 : _moduleOf4.source);
9647
+ const merged = mergeValues(sourceOf(base), sourceOf(site), sourceOf(proposal), atomicFor((_moduleOf5 = moduleOf(chosen)) === null || _moduleOf5 === void 0 ? void 0 : _moduleOf5.schema));
9648
+ const resolved = resolveContent(moduleFilePath, merged, resolutions);
9649
+ conflicts.push(...resolved.unresolved);
9650
+ const content = resolved.content;
9651
+ modules[moduleFilePath] = content;
9652
+ const entries = (_moduleOf6 = moduleOf(chosen)) === null || _moduleOf6 === void 0 ? void 0 : _moduleOf6.entries;
9653
+ if (entries) {
9654
+ var _moduleOf7, _moduleOf8;
9655
+ Object.assign(files, writeEntries(file, text, chosen.files, entries, from, content, [(_moduleOf7 = moduleOf(site)) === null || _moduleOf7 === void 0 ? void 0 : _moduleOf7.entries, (_moduleOf8 = moduleOf(proposal)) === null || _moduleOf8 === void 0 ? void 0 : _moduleOf8.entries]));
9656
+ } else {
9657
+ files[file] = writeContent(file, text, from, content);
9658
+ }
9659
+ }
9660
+ if (conflicts.length > 0) return {
9661
+ status: "conflicts",
9662
+ conflicts
9663
+ };
9664
+ return {
9665
+ status: "merged",
9666
+ files,
9667
+ modules,
9668
+ code: Object.keys(files).filter(file => {
9669
+ const text = files[file];
9670
+ const before = site.files[file] ?? null;
9671
+ if (text === null || before === null) return text !== before;
9672
+ return codeOf(file, text) !== codeOf(file, before);
9673
+ })
9674
+ };
9675
+ }
9676
+
9677
+ // --- the pieces --------------------------------------------------------------
9678
+
9679
+ /**
9680
+ * A module's merged content with the resolutions written in: each field
9681
+ * conflict that has one, and any value written for the schema check (or
9682
+ * anywhere else) as given. What is left is what still needs a person.
9683
+ */
9684
+ function resolveContent(moduleFilePath, merged, resolutions) {
9685
+ let content = merged.value;
9686
+ const unresolved = [];
9687
+ for (const conflict of merged.conflicts) {
9688
+ const resolved = fieldResolution(resolutions, moduleFilePath, conflict);
9689
+ if (resolved === null) {
9690
+ unresolved.push({
9691
+ kind: "field",
9692
+ moduleFilePath,
9693
+ ...conflict
9694
+ });
9695
+ } else {
9696
+ content = setAt(content, conflict.path, resolved.value);
9697
+ }
9698
+ }
9699
+ for (const resolution of resolutions) {
9700
+ if ("moduleFilePath" in resolution && resolution.moduleFilePath === moduleFilePath && "value" in resolution && !merged.conflicts.some(c => samePath(c.path, resolution.path))) {
9701
+ content = setAt(content, resolution.path, resolution.value);
9702
+ }
9703
+ }
9704
+ return {
9705
+ content,
9706
+ unresolved
9707
+ };
9708
+ }
9709
+
9710
+ /**
9711
+ * The schema check of a merge: content in `merged` -- the project
9712
+ * {@link mergeFromSite} wrote, with its schemas evaluated -- that is invalid
9713
+ * under the schema it ends with, leaving out what was already invalid on a
9714
+ * side. A conflict, each; a resolution with a value for one is written into
9715
+ * the content by the merge, and so is checked here.
9716
+ */
9717
+ function schemaCheck(merged, sides) {
9718
+ const conflicts = [];
9719
+ for (const [path, module] of Object.entries(merged.modules)) {
9720
+ const moduleFilePath = path;
9721
+ const known = new Set(sides.flatMap(side => {
9722
+ const there = side.modules[moduleFilePath];
9723
+ return there ? errorsOfSource(moduleFilePath, there).map(e => e.path.join("/")) : [];
9724
+ }));
9725
+ for (const error of errorsOfSource(moduleFilePath, module)) {
9726
+ if (known.has(error.path.join("/"))) continue;
9727
+ conflicts.push({
9728
+ kind: "invalid",
9729
+ moduleFilePath,
9730
+ path: error.path,
9731
+ value: valueAt(module.source, error.path),
9732
+ message: error.message
9733
+ });
9734
+ }
9735
+ }
9736
+ return conflicts;
9737
+ }
9738
+
9739
+ /**
9740
+ * The values that merge whole, though they are objects: media (an image's
9741
+ * alt text describes that image, not another) and rich text.
9742
+ */
9743
+ const ATOMIC = new Set(["image", "file", "video", "font", "richtext"]);
9744
+
9745
+ /** See {@link ATOMIC}: where this schema holds a value that merges whole. */
9746
+ function atomicFor(schema) {
9747
+ if (!schema) return () => false;
9748
+ return path => {
9749
+ const types = schemaTypesOfPath(schema, path);
9750
+ return [...types].some(type => ATOMIC.has(type));
9751
+ };
9752
+ }
9753
+ const tsOps = new TSOps(document => pipe(analyzeValModule(document), result.map(({
9754
+ source
9755
+ }) => source)));
9756
+
9757
+ /**
9758
+ * `content` written into the `.val.ts` `text`, whose content is `from`: as
9759
+ * the smallest patch, through the same AST operations a save uses, so what
9760
+ * did not change is left exactly as it was.
9761
+ */
9762
+ function writeContent(file, text, from, content) {
9763
+ const ops = diffOps(from, content);
9764
+ if (ops.length === 0) return text;
9765
+ const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2015, true);
9766
+ return printed(unwrap(file, applyPatch(sourceFile, tsOps, ops)));
9767
+ }
9768
+
9769
+ /**
9770
+ * A `.jsonValues()` module, written as its files: the `.val.ts` names the
9771
+ * entries -- one added gets a `c.json(...)` line, as a save writes it, one
9772
+ * removed loses its line -- and each entry is its own `.val.json`. An entry
9773
+ * whose content did not change keeps its file's text exactly.
9774
+ */
9775
+ function writeEntries(file, text, chosenFiles, entries, from, content, others) {
9776
+ const written = {};
9777
+ const before = isRecord$4(from) ? from : {};
9778
+ const after = isRecord$4(content) ? content : {};
9779
+ const moduleDir = path__default.posix.dirname(file);
9780
+ let sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2015, true);
9781
+ for (const key of Object.keys(before)) {
9782
+ if (key in after) continue;
9783
+ sourceFile = unwrap(file, removeValJsonEntry(sourceFile, [], key));
9784
+ written[entries[key]] = null;
9785
+ }
9786
+ for (const [key, value] of Object.entries(after)) {
9787
+ const entryFile = entries[key] ?? others.map(other => other === null || other === void 0 ? void 0 : other[key]).find(f => f !== undefined) ?? path__default.posix.join(moduleDir, path__default.posix.basename(file, ".val.ts"), `${key}.val.json`);
9788
+ if (!(key in before)) {
9789
+ const importPath = `./${path__default.posix.relative(moduleDir, entryFile)}`;
9790
+ sourceFile = unwrap(file, insertValJsonEntry(sourceFile, [], key, importPath));
9791
+ }
9792
+ const kept = chosenFiles[entryFile];
9793
+ written[entryFile] = key in before && diffOps(before[key], value).length === 0 && kept ? kept : JSON.stringify(value, null, 2);
9794
+ }
9795
+ written[file] = printed(sourceFile);
9796
+ return written;
9797
+ }
9798
+ function isRecord$4(value) {
9799
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9800
+ }
9801
+ function unwrap(file, written) {
9802
+ if (result.isErr(written)) {
9803
+ throw new Error(`could not write the merge into ${file}: ${String(written.error)}`);
9804
+ }
9805
+ return written.value;
9806
+ }
9807
+
9808
+ /** As `prepare` prints: TypeScript escapes non-ASCII as \\u sequences. */
9809
+ function printed(sourceFile) {
9810
+ return unescape(sourceFile.getText(sourceFile).replace(/\\u/g, "%u"));
9811
+ }
9812
+ function fileResolution(resolutions, file) {
9813
+ for (const resolution of resolutions) {
9814
+ if ("file" in resolution && resolution.file === file) return resolution;
9815
+ }
9816
+ return null;
9817
+ }
9818
+ function resolvedText(resolution, site, proposal) {
9819
+ if ("text" in resolution) return resolution.text;
9820
+ return resolution.keep === "site" ? site : proposal;
9821
+ }
9822
+
9823
+ /** A field conflict's resolution, as the value it leaves; `null` when it has none. */
9824
+ function fieldResolution(resolutions, moduleFilePath, conflict) {
9825
+ for (const resolution of resolutions) {
9826
+ if (!("moduleFilePath" in resolution) || resolution.moduleFilePath !== moduleFilePath || !samePath(resolution.path, conflict.path)) {
9827
+ continue;
9828
+ }
9829
+ if ("value" in resolution) return {
9830
+ value: resolution.value
9831
+ };
9832
+ return {
9833
+ value: resolution.keep === "site" ? conflict.site : conflict.proposal
9834
+ };
9835
+ }
9836
+ return null;
9837
+ }
9838
+ function samePath(a, b) {
9839
+ return a.length === b.length && a.every((key, at) => key === b[at]);
9840
+ }
9841
+
9842
+ /**
9843
+ * Update from the site when only content changed (valbuild/home
9844
+ * `docs/hosted-publishing.md` step 8.2, slice 1a): the proposal staged no code
9845
+ * and the site still serves the build the proposal stands on. Then all three
9846
+ * sides share one schema -- the running build's -- so the merge needs no
9847
+ * project loaded, which is what lets Val's server at a proposal's address run
9848
+ * it (it cannot evaluate a `.val.ts`).
9849
+ *
9850
+ * The same rules as {@link mergeFromSite}, module by module: field by field,
9851
+ * the same value on both sides a conflict, media and rich text whole. Content
9852
+ * hands over each module the proposal saved, as it was where the proposal
9853
+ * started, as the site has it, and as the proposal saved it; the merge is
9854
+ * written into the proposal's own `.val.ts` text with the same AST operations
9855
+ * a save uses.
9856
+ *
9857
+ * Not here: a `.jsonValues()` record the site changed. Its Source in content's
9858
+ * index holds only the entries a commit touched, so it cannot be merged from
9859
+ * the index, and its entries are `.val.json` files the `.val.ts` only names,
9860
+ * which writing into the `.val.ts` cannot change. That waits for the full
9861
+ * merge (`writeEntries`), and is answered as such -- with or without a
9862
+ * conflict, since a clean merge still has the site's entries to write.
9863
+ */
9864
+
9865
+ function mergeContent(input) {
9866
+ const conflicts = [];
9867
+ const files = {};
9868
+ const modules = {};
9869
+ const needsFullMerge = [];
9870
+ for (const path of Object.keys(input.modules).sort()) {
9871
+ const moduleFilePath = path;
9872
+ const {
9873
+ base,
9874
+ site,
9875
+ proposal,
9876
+ text
9877
+ } = input.modules[moduleFilePath];
9878
+ const schema = input.schemas[moduleFilePath];
9879
+ if (!schema) {
9880
+ // The build has no such module: its code changed, so this is not a
9881
+ // content-only update.
9882
+ needsFullMerge.push(moduleFilePath);
9883
+ continue;
9884
+ }
9885
+ const serialized = schema["executeSerialize"]();
9886
+ const orUndefined = value => value ?? undefined;
9887
+ const merged = mergeValues(orUndefined(base), orUndefined(site), orUndefined(proposal), atomicFor(serialized));
9888
+ if (serialized.type === "record" && serialized.jsonValues && (merged.conflicts.length > 0 || diffOps(orUndefined(proposal), merged.value).length > 0)) {
9889
+ needsFullMerge.push(moduleFilePath);
9890
+ continue;
9891
+ }
9892
+ const resolved = resolveContent(moduleFilePath, merged, input.resolutions);
9893
+ conflicts.push(...resolved.unresolved);
9894
+ if (resolved.unresolved.length > 0) continue;
9895
+
9896
+ // The schema check, under the one schema there is: what was already
9897
+ // invalid on a side is not the merge's doing. A value written as a
9898
+ // resolution is already in `content`, and checked like the rest -- as
9899
+ // `schemaCheck` does for the full merge.
9900
+ const known = new Set([site, proposal].flatMap(side => validateWith(schema, moduleFilePath, orUndefined(side)).map(e => e.path.join("/"))));
9901
+ const content = resolved.content;
9902
+ for (const error of validateWith(schema, moduleFilePath, content)) {
9903
+ if (known.has(error.path.join("/"))) continue;
9904
+ conflicts.push({
9905
+ kind: "invalid",
9906
+ moduleFilePath,
9907
+ path: error.path,
9908
+ value: valueAt(content, error.path),
9909
+ message: error.message
9910
+ });
9911
+ }
9912
+ const file = moduleFilePath.slice(1);
9913
+ files[file] = writeContent(file, text, orUndefined(proposal), content);
9914
+ modules[moduleFilePath] = {
9915
+ source: content ?? null,
9916
+ schema: serialized
9917
+ };
9918
+ }
9919
+ if (needsFullMerge.length > 0) {
9920
+ return {
9921
+ status: "needs-full-merge",
9922
+ modules: needsFullMerge
9923
+ };
9924
+ }
9925
+ if (conflicts.length > 0) return {
9926
+ status: "conflicts",
9927
+ conflicts
9928
+ };
9929
+ return {
9930
+ status: "merged",
9931
+ files,
9932
+ modules
9933
+ };
9934
+ }
9935
+
9936
+ /**
9937
+ * Update from the site, for a proposal with code (valbuild/home
9938
+ * `docs/hosted-publishing.md` step 8.2, slice 1b): Val's server's half.
9939
+ *
9940
+ * Nothing here runs project code. Content hands over the three trees and each
9941
+ * module's Source in each (`merge-trees`); the builder tab evaluates each
9942
+ * tree's `val.modules.ts` -- the site's own dependencies, from its vendor
9943
+ * layer -- and sends what that gave: each module's schema, the content its
9944
+ * text holds, and the errors of its Source under that schema. This does the
9945
+ * text, with the TypeScript Val's server already has.
9946
+ *
9947
+ * In two calls, because the schema check needs the merged tree evaluated too:
9948
+ *
9949
+ * 1. Without `merged`: the merge `M`, as the files it changes over the site
9950
+ * and each module's content -- for the tab to evaluate -- under an `id`.
9951
+ * Or the conflicts, for a person.
9952
+ * 2. With `merged`, the tab's evaluation of that `id`: the schema check, then
9953
+ * what content commits. The merge is made again rather than taken from the
9954
+ * tab, and must come out as the same `id`.
9955
+ */
9956
+
9957
+ function updateWithCode(input) {
9958
+ const {
9959
+ trees,
9960
+ evaluated,
9961
+ resolutions
9962
+ } = input;
9963
+ const projectOf = side => {
9964
+ const files = sideFiles(trees, side);
9965
+ return {
9966
+ files,
9967
+ modules: modulesOf(files, evaluated[side], path => sideSource(trees, side, path))
9968
+ };
9969
+ };
9970
+ const base = projectOf("base");
9971
+ const site = projectOf("site");
9972
+ const proposal = projectOf("proposal");
9973
+ const result = mergeFromSite({
9974
+ base,
9975
+ site,
9976
+ proposal,
9977
+ resolutions
9978
+ });
9979
+ if (result.status === "conflicts") return result;
9980
+ const changes = {};
9981
+ for (const [path, text] of Object.entries(result.files)) {
9982
+ if (text !== (site.files[path] ?? null)) changes[path] = text;
9983
+ }
9984
+ const id = mergeId(changes, result.modules);
9985
+ if (input.merged === undefined) {
9986
+ const sources = {};
9987
+ for (const [path, content] of Object.entries(result.modules)) {
9988
+ sources[path] = content ?? null;
9989
+ }
9990
+ return {
9991
+ status: "evaluate",
9992
+ id,
9993
+ files: changes,
9994
+ sources
9995
+ };
9996
+ }
9997
+ if (input.merged.id !== id) return {
9998
+ status: "stale"
9999
+ };
10000
+ const mergedFiles = {
10001
+ ...site.files,
10002
+ ...changes
10003
+ };
10004
+ const mergedProject = {
10005
+ files: mergedFiles,
10006
+ modules: modulesOf(Object.fromEntries(Object.entries(mergedFiles).filter(entry => entry[1] !== null)), input.merged.evaluated, path => result.modules[path] ?? null)
10007
+ };
10008
+ const invalid = schemaCheck(mergedProject, [site, proposal]);
10009
+ if (invalid.length > 0) return {
10010
+ status: "conflicts",
10011
+ conflicts: invalid
10012
+ };
10013
+
10014
+ // The paths the proposal held go back to what the merge left -- the site's,
10015
+ // often -- so nothing it held before is left standing over the new base.
10016
+ const files = {
10017
+ ...changes
10018
+ };
10019
+ for (const path of Object.keys(trees.trees.proposal)) {
10020
+ if (!(path in files)) files[path] = mergedFiles[path] ?? null;
10021
+ }
10022
+ const modules = {};
10023
+ for (const [path, module] of Object.entries(mergedProject.modules)) {
10024
+ var _trees$modules$module, _trees$modules$module2;
10025
+ const moduleFilePath = path;
10026
+ const onSite = site.modules[moduleFilePath];
10027
+ const touched = ((_trees$modules$module = trees.modules[moduleFilePath]) === null || _trees$modules$module === void 0 ? void 0 : _trees$modules$module.proposal) !== ((_trees$modules$module2 = trees.modules[moduleFilePath]) === null || _trees$modules$module2 === void 0 ? void 0 : _trees$modules$module2.base);
10028
+ if (touched || onSite === undefined || !sameJson(onSite.source, module.source) || !sameJson(onSite.schema, module.schema)) {
10029
+ modules[moduleFilePath] = {
10030
+ source: module.source,
10031
+ schema: module.schema
10032
+ };
10033
+ }
10034
+ }
10035
+ return {
10036
+ status: "merged",
10037
+ files,
10038
+ staged: result.code.filter(path => path in files),
10039
+ modules
10040
+ };
10041
+ }
10042
+ function modulesOf(files, evaluated, sourceOf) {
10043
+ const modules = {};
10044
+ for (const [path, module] of Object.entries(evaluated)) {
10045
+ const moduleFilePath = path;
10046
+ const text = files[moduleFilePath.slice(1)];
10047
+ modules[moduleFilePath] = {
10048
+ // A module the index has no Source for is one this tree's code adds:
10049
+ // its text is all there is.
10050
+ source: sourceOf(moduleFilePath) ?? module.written,
10051
+ written: module.written,
10052
+ schema: module.schema,
10053
+ entries: text === undefined ? null : entryFilesOf(moduleFilePath, text),
10054
+ errors: module.errors
10055
+ };
10056
+ }
10057
+ return modules;
10058
+ }
10059
+
10060
+ /** The merge, named by what it is: the files it changes and the content it writes. */
10061
+ function mergeId(files, modules) {
10062
+ const sorted = record => Object.keys(record).sort().map(key => [key, record[key] ?? null]);
10063
+ return createHash("sha256").update("val.update-with-code.v1\n").update(JSON.stringify({
10064
+ files: sorted(files),
10065
+ modules: sorted(modules)
10066
+ })).digest("hex");
10067
+ }
10068
+ function sameJson(a, b) {
10069
+ return JSON.stringify(a) === JSON.stringify(b);
10070
+ }
10071
+
10072
+ const textEncoder$2 = new TextEncoder();
10073
+
10074
+ /** The body of the content API's 403 for a write the project's plan refuses. */
10075
+ const PlanRefusedBody = z.object({
10076
+ error: PlanRefusalError
10077
+ });
10078
+ const PatchId = z.string().refine(s => !!s); // TODO: validate
10079
+ const CommitSha = z.string().refine(s => !!s); // TODO: validate
10080
+ z.string().refine(s => !!s); // TODO: validate
10081
+ z.string().refine(s => !!s); // TODO: validate
10082
+ const ModuleFilePath = z.string().refine(s => !!s); // TODO: validate
10083
+ const Metadata = z.union([z.object({
10084
+ mimeType: z.string(),
10085
+ width: z.number(),
10086
+ height: z.number()
10087
+ }), z.object({
10088
+ mimeType: z.string()
10089
+ })]);
10090
+ const MetadataRes = z.object({
10091
+ filePath: ModuleFilePath,
10092
+ metadata: Metadata,
10093
+ type: z.union([z.literal("file"), z.literal("image")]).nullable()
10094
+ });
10095
+ const GetApplicablePatches = z.object({
10096
+ patches: z.array(z.object({
10097
+ path: z.string(),
10098
+ patch: Patch.nullable(),
10099
+ patchId: z.string(),
10100
+ authorId: z.string().nullable(),
10101
+ baseSha: z.string(),
10102
+ createdAt: z.string(),
10103
+ applied: z.object({
10104
+ commitSha: z.string()
10105
+ }).nullable()
10106
+ })),
10107
+ /**
10108
+ * The head of the chain. See `OrderedPatches.headPatchId`. Optional because
10109
+ * a content service that predates it sends nothing.
10110
+ */
10111
+ headPatchId: PatchId$1.nullable().optional(),
10112
+ /**
10113
+ * The chain version of that head. See `OrderedPatches.headVersion`.
10114
+ * Optional for the same reason.
10115
+ */
10116
+ headVersion: z.number().optional(),
10117
+ /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
10118
+ publishingPatches: z.array(PatchId$1).optional(),
10119
+ /** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
10120
+ heldBackPatches: z.array(PatchId$1).optional(),
10121
+ commits: z.array(z.object({
10122
+ commitSha: z.string(),
10123
+ /*
10124
+ * Nullable since the content service mints its own commit shas: a
10125
+ * root commit has no parent, and a publisher that did not say where
10126
+ * it was has no client sha. Parsed rather than trusted, so a service
10127
+ * that sends null gets null here instead of failing the whole poll.
10128
+ */
10129
+ clientCommitSha: z.string().nullable(),
10130
+ parentCommitSha: z.string().nullable(),
10131
+ commitMessage: z.string().nullable(),
10132
+ branch: z.string(),
10133
+ creator: z.string(),
10134
+ createdAt: z.string()
10135
+ })).optional(),
10136
+ deployments: z.array(z.object({
10137
+ deploymentId: z.string(),
10138
+ commitSha: z.string(),
10139
+ deploymentState: z.string(),
10140
+ createdAt: z.string(),
10141
+ updatedAt: z.string(),
10142
+ // The git message, where the content service knows it. See
10143
+ // `ValDeployment`: this is the only source for a deployment that Val
10144
+ // did not publish, and zod would otherwise strip it here.
10145
+ commitMessage: z.string().nullable().optional()
10146
+ })).optional(),
10147
+ /**
10148
+ * What the project EXPECTS of whoever publishes it.
10149
+ *
10150
+ * Optional because a content service that predates it sends nothing, and
10151
+ * absent means "not reported" -- never "managed". The difference decides
10152
+ * whether a publish is refused, so guessing either way would be worse than
10153
+ * not checking: guessing `managed` would let a deployment publish a
10154
+ * connected project it cannot mirror, and guessing `connected` would refuse
10155
+ * every publish against an older service.
10156
+ */
10157
+ project: z.object({
10158
+ sourceMode: z.union([z.literal("managed"), z.literal("connected")]),
10159
+ branch: z.string(),
10160
+ /**
10161
+ * Whether a press of Publish is a publish JOB. Absent from a content
10162
+ * service that predates connected jobs: then managed projects publish
10163
+ * as jobs, and connected ones by commit, as they did.
10164
+ */
10165
+ publishJobs: z.boolean().optional()
10166
+ }).optional()
10167
+ });
10168
+ const FilesResponse = z.object({
10169
+ files: z.array(z.union([z.object({
10170
+ filePath: z.string(),
10171
+ location: z.literal("patch"),
10172
+ patchId: PatchId,
10173
+ value: z.string(),
10174
+ remote: z.boolean()
10175
+ }), z.object({
10176
+ filePath: z.string(),
10177
+ location: z.literal("repo"),
10178
+ commitSha: CommitSha,
10179
+ value: z.string()
10180
+ })])),
10181
+ errors: z.array(z.union([z.object({
10182
+ filePath: z.string(),
10183
+ location: z.literal("patch"),
10184
+ patchId: PatchId,
10185
+ message: z.string(),
10186
+ remote: z.boolean()
10187
+ }), z.object({
10188
+ filePath: z.string(),
10189
+ location: z.literal("repo"),
10190
+ commitSha: CommitSha,
10191
+ message: z.string()
10192
+ })])).optional()
10193
+ });
10194
+ const SavePatchResponse = z.object({
10195
+ patchId: PatchId,
10196
+ /**
10197
+ * The chain version this write made, comparable with `headVersion` on the
10198
+ * listing. Optional: a content service that predates it sends nothing.
10199
+ */
10200
+ headVersion: z.number().optional()
10201
+ });
10202
+ const DeletePatchesResponse = z.object({
10203
+ deleted: z.array(PatchId),
10204
+ errors: z.array(z.object({
10205
+ message: z.string(),
10206
+ patchId: PatchId,
10207
+ /** The publish job holding it, when that is why it was not deleted. */
10208
+ publishJobId: z.string().optional()
10209
+ })).optional(),
10210
+ /**
10211
+ * The chain version each branch the delete changed is now at, keyed by
10212
+ * branch. Optional: a content service that predates it sends none.
10213
+ */
10214
+ headVersions: z.record(z.string(), z.number()).optional()
10215
+ });
10216
+ /**
10217
+ * This client's own branch's entry in a per-branch version map.
10218
+ *
10219
+ * A delete can span branches, so the content service answers per branch. With
10220
+ * no branch of its own (a build with no repository, where the service resolves
10221
+ * it), the only entry is ours when there is exactly one; with several, none can
10222
+ * be picked, and a wrong one would floor this client at another chain's count.
10223
+ */
10224
+ function ownBranchVersion(headVersions, branch) {
10225
+ if (headVersions === undefined) return undefined;
10226
+ if (branch !== undefined) return headVersions[branch];
10227
+ const versions = Object.values(headVersions);
10228
+ return versions.length === 1 ? versions[0] : undefined;
10229
+ }
10230
+ const SavePatchFileResponse = z.object({
10231
+ patchId: PatchId,
10232
+ filePath: ModuleFilePath
10233
+ });
10234
+ const CommitResponse = z.object({
10235
+ updatedFiles: z.array(z.string()),
10236
+ commit: CommitSha,
10237
+ /*
10238
+ * Optional because a content service that predates them sends neither, and
10239
+ * this client talks to whichever one the project is on. Absent means NOT
10240
+ * REPORTED: `parent: undefined` is not "a root commit", and a caller that
10241
+ * reads it as one would conclude the history starts here.
10242
+ *
10243
+ * Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
10244
+ * from a service that versions separately and are passed through as what it
10245
+ * said, not as something this end vouched for.
10246
+ */
10247
+ parent: z.string().optional(),
10248
+ tree: z.string().optional(),
10249
+ branch: z.string(),
10250
+ /** The chain version the publish moved the branch to; absent if it applied nothing. */
10251
+ headVersion: z.number().optional()
10252
+ });
10253
+ // #region history wire schemas
10254
+ //
10255
+ // Validated on arrival rather than trusted: these come from a service that
10256
+ // versions separately, and a silently mis-shaped commit record reads as "this
10257
+ // commit changed nothing", which is indistinguishable from a real answer.
10258
+ const HistoricalCommitResponse = z.object({
10259
+ commitSha: z.string(),
10260
+ /** `null` for a root commit; see the note on the applicable-patches schema. */
10261
+ parentCommitSha: z.string().nullable(),
10262
+ /** `null` when the publisher did not say where it was. */
10263
+ clientCommitSha: z.string().nullable(),
10264
+ branch: z.string(),
10265
+ createdBranch: z.string().nullable(),
10266
+ creator: z.string().nullable(),
10267
+ message: z.string().nullable(),
10268
+ createdAt: z.string(),
10269
+ seqNum: z.string(),
10270
+ patchCount: z.number(),
10271
+ hasArchive: z.boolean()
10272
+ });
10273
+ const ListCommitsResponse = z.object({
10274
+ commits: z.array(HistoricalCommitResponse),
10275
+ nextCursor: z.string().nullable()
10276
+ });
10277
+ const CommitPatchesResponse = z.object({
10278
+ commitSha: z.string(),
10279
+ commit: z.object({
10280
+ commitSha: z.string(),
10281
+ parentCommitSha: z.string().nullable(),
10282
+ clientCommitSha: z.string().nullable(),
10283
+ branch: z.string(),
10284
+ createdBranch: z.string().nullable(),
10285
+ creator: z.string().nullable(),
10286
+ message: z.string().nullable(),
10287
+ createdAt: z.string(),
10288
+ seqNum: z.string(),
10289
+ hasArchive: z.boolean()
10290
+ }),
10291
+ patches: z.array(z.object({
10292
+ patchId: z.string(),
10293
+ path: z.string(),
10294
+ patch: z.unknown(),
10295
+ authorId: z.string().nullable(),
10296
+ createdAt: z.string(),
10297
+ baseSha: z.string(),
10298
+ coreVersion: z.string()
10299
+ }))
10300
+ });
10301
+
10302
+ /**
10303
+ * `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
10304
+ *
10305
+ * `schema` stays `unknown` here on purpose. The content service stores it
10306
+ * opaquely and cannot vouch for it, so validating it at the transport boundary
9609
10307
  * would turn "a schema written by a different version of Val" into a failed
9610
10308
  * REQUEST rather than one module that cannot be shown. It is checked in
9611
10309
  * `getHistoricalPatchSet`, per module, where a failure degrades that module and
@@ -9648,39 +10346,10 @@ const CommitAffectedFilesResponse = z.object({
9648
10346
  });
9649
10347
  // #endregion history wire schemas
9650
10348
 
9651
- /*
9652
- * The shared schema, not a copy of it.
9653
- *
9654
- * This re-declared `PatchGroup` field for field while the file already imported
9655
- * `PatchGroupT` from the same module — so a field added on one side and not the
9656
- * other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
9657
- * it, with no type error anywhere.
9658
- */
9659
- const PatchGroupsResponse = z.object({
9660
- patchGroups: z.array(PatchGroup)
9661
- });
9662
- const PatchGroupMutationResponse = z.object({
9663
- /** `null` for an unstage of `~` when the caller has no open group. */
9664
- patchGroupId: z.string().nullable(),
9665
- patchIds: z.array(PatchId),
9666
- /** The chain version the change committed at. Absent from an older `home`. */
9667
- headVersion: z.number().optional()
9668
- });
9669
10349
  const NonceResponse = z.object({
9670
10350
  nonce: z.string(),
9671
10351
  url: z.string()
9672
10352
  });
9673
-
9674
- /**
9675
- * How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
9676
- *
9677
- * Sized to cover one server render, not to be a cache: several `fetchVal` calls
9678
- * in one request share an answer, and the next request asks again.
9679
- */
9680
- const PATCH_GROUPS_CACHE_MS = 1000;
9681
-
9682
- /** The content API's id for "the caller's open group on the branch". */
9683
- const OWN_OPEN_GROUP = "~";
9684
10353
  class ValOpsHttp extends ValOps {
9685
10354
  authHeaders;
9686
10355
  root;
@@ -10128,7 +10797,7 @@ class ValOpsHttp extends ValOps {
10128
10797
  if (path === "") return method === "GET" || method === "POST";
10129
10798
  const one = /^\/[0-9a-f]{20}$/.test(path);
10130
10799
  if (one) return method === "GET" || method === "PATCH";
10131
- if (method === "GET") return /^\/[0-9a-f]{20}\/(merge-checks|files)$/.test(path);
10800
+ if (method === "GET") return /^\/[0-9a-f]{20}\/(merge-checks|files|merge-trees)$/.test(path);
10132
10801
  return method === "POST" && /^\/[0-9a-f]{20}\/(close|reopen|setup\/retry|merge|files|build|update-from-site|restore-code)$/.test(path);
10133
10802
  }
10134
10803
 
@@ -10165,8 +10834,16 @@ class ValOpsHttp extends ValOps {
10165
10834
  return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
10166
10835
  }
10167
10836
  async proposalsApi(path, init, profileId) {
10168
- var _this$proposal;
10837
+ var _exec2, _exec3, _this$proposal;
10169
10838
  const json = "application/json";
10839
+ const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
10840
+ if (updating !== undefined && init.method === "POST") {
10841
+ return this.updateContentFromSite(updating, init.body, profileId);
10842
+ }
10843
+ const withCode = (_exec3 = /^\/([0-9a-f]{20})\/update-code$/.exec(path)) === null || _exec3 === void 0 ? void 0 : _exec3[1];
10844
+ if (withCode !== undefined && init.method === "POST") {
10845
+ return this.updateCodeFromSite(withCode, init.body, profileId);
10846
+ }
10170
10847
  if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
10171
10848
  return {
10172
10849
  status: 403,
@@ -10176,41 +10853,305 @@ class ValOpsHttp extends ValOps {
10176
10853
  })
10177
10854
  };
10178
10855
  }
10179
- const writes = ValOpsHttp.proposalFilesWrite(init.method, path);
10180
- if (writes !== null && writes !== ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.name)) {
10856
+ const writes = ValOpsHttp.proposalFilesWrite(init.method, path);
10857
+ if (writes !== null && writes !== ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.name)) {
10858
+ return {
10859
+ status: 403,
10860
+ contentType: json,
10861
+ body: JSON.stringify({
10862
+ message: this.proposal === null ? "The site's files are changed in a proposal: open one, and change them there." : "Files are changed in the proposal this Studio is open in, not in another."
10863
+ })
10864
+ };
10865
+ }
10866
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/proposals${path}`, {
10867
+ method: init.method,
10868
+ headers: {
10869
+ ...this.authHeaders,
10870
+ /*
10871
+ * WHO is asking, as for a commit: the app's key names the project,
10872
+ * and a proposal is opened, renamed and closed by a person -- content
10873
+ * refuses those writes from a credential that names nobody.
10874
+ */
10875
+ ...(profileId !== null ? {
10876
+ "x-val-profile-id": profileId
10877
+ } : {}),
10878
+ ...(init.body === undefined ? {} : {
10879
+ "Content-Type": json
10880
+ })
10881
+ },
10882
+ ...(init.body === undefined ? {} : {
10883
+ body: init.body
10884
+ })
10885
+ });
10886
+ return {
10887
+ status: res.status,
10888
+ body: await res.text(),
10889
+ contentType: res.headers.get("Content-Type") ?? json
10890
+ };
10891
+ }
10892
+
10893
+ /**
10894
+ * Update from the site, for a proposal that changes only content
10895
+ * (valbuild/home `docs/hosted-publishing.md`, step 8.2, slice 1a): content
10896
+ * hands over the three versions of every module the proposal saved, this
10897
+ * server merges them under its own build's schemas -- the same on all
10898
+ * three sides, which is what "content only" means -- and content commits
10899
+ * the merge on the proposal's branch.
10900
+ *
10901
+ * Answered here rather than forwarded, at `POST /<name>/update-content`, and
10902
+ * only by the proposal's own server: the schemas it merges under are this
10903
+ * build's. A conflict comes back for a person to resolve, and the same call
10904
+ * with their `resolutions` merges.
10905
+ */
10906
+ async updateContentFromSite(name, body, profileId) {
10907
+ var _this$proposal2;
10908
+ const json = "application/json";
10909
+ const answer = (status, value) => ({
10910
+ status,
10911
+ contentType: json,
10912
+ body: JSON.stringify(value)
10913
+ });
10914
+ if (((_this$proposal2 = this.proposal) === null || _this$proposal2 === void 0 ? void 0 : _this$proposal2.name) !== name) {
10915
+ return answer(403, {
10916
+ message: "A proposal is updated from the site at its own address, where its build is."
10917
+ });
10918
+ }
10919
+ let parsedBody;
10920
+ try {
10921
+ parsedBody = body === undefined || body === "" ? {} : JSON.parse(body);
10922
+ } catch {
10923
+ return answer(400, {
10924
+ message: "The update's body is not JSON."
10925
+ });
10926
+ }
10927
+ const request = UpdateContentBody.safeParse(parsedBody);
10928
+ if (!request.success) {
10929
+ return answer(400, {
10930
+ message: `Invalid update: ${fromError(request.error).toString()}`
10931
+ });
10932
+ }
10933
+ const content = (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
10934
+ ...init,
10935
+ headers: {
10936
+ ...this.authHeaders,
10937
+ ...(profileId !== null ? {
10938
+ "x-val-profile-id": profileId
10939
+ } : {}),
10940
+ ...(init.body === undefined ? {} : {
10941
+ "Content-Type": json
10942
+ })
10943
+ }
10944
+ });
10945
+ const read = await content("/merge-inputs");
10946
+ if (!read.ok) {
10947
+ return {
10948
+ status: read.status,
10949
+ contentType: json,
10950
+ body: await read.text()
10951
+ };
10952
+ }
10953
+ const inputs = MergeInputs.safeParse(await read.json());
10954
+ if (!inputs.success) {
10955
+ return answer(502, {
10956
+ message: `Content answered the merge's inputs in a shape this Val does not read: ${fromError(inputs.error).toString()}`
10957
+ });
10958
+ }
10959
+ if (inputs.data.upToDate) return answer(200, {
10960
+ status: "up-to-date"
10961
+ });
10962
+ if (!inputs.data.contentOnly.ok || inputs.data.siteHead === null) {
10963
+ return answer(409, {
10964
+ status: "needs-build",
10965
+ message: inputs.data.contentOnly.ok ? "The site has no commit to update from." : inputs.data.contentOnly.reason
10966
+ });
10967
+ }
10968
+ const modules = {};
10969
+ const withoutText = [];
10970
+ for (const [path, module] of Object.entries(inputs.data.modules)) {
10971
+ const moduleFilePath = path;
10972
+ if (module.text === null) {
10973
+ withoutText.push(moduleFilePath);
10974
+ continue;
10975
+ }
10976
+ modules[moduleFilePath] = {
10977
+ base: module.base,
10978
+ site: module.site,
10979
+ proposal: module.proposal,
10980
+ text: module.text
10981
+ };
10982
+ }
10983
+ // Decisions are about the values a person was shown. When the site or
10984
+ // the proposal moved since, they would settle values nobody saw, so the
10985
+ // merge is done again without them: what still conflicts is shown again.
10986
+ const {
10987
+ siteHead,
10988
+ proposalHead
10989
+ } = request.data;
10990
+ const decidedHere = siteHead === undefined || siteHead === inputs.data.siteHead && proposalHead === inputs.data.proposalHead;
10991
+ const merged = withoutText.length > 0 ? {
10992
+ status: "needs-full-merge",
10993
+ modules: withoutText
10994
+ } : mergeContent({
10995
+ modules,
10996
+ schemas: await this.getSchemas(),
10997
+ resolutions: decidedHere ? request.data.resolutions : []
10998
+ });
10999
+ if (merged.status === "conflicts") {
11000
+ return answer(200, {
11001
+ status: "conflicts",
11002
+ conflicts: merged.conflicts,
11003
+ siteHead: inputs.data.siteHead,
11004
+ proposalHead: inputs.data.proposalHead
11005
+ });
11006
+ }
11007
+ if (merged.status === "needs-full-merge") {
11008
+ return answer(409, {
11009
+ status: "needs-full-merge",
11010
+ modules: merged.modules,
11011
+ message: `${merged.modules.join(", ")} changed both here and on the site in a way this version cannot merge yet.`
11012
+ });
11013
+ }
11014
+ const written = await content("/merge-from-site", {
11015
+ method: "POST",
11016
+ body: JSON.stringify({
11017
+ siteHead: inputs.data.siteHead,
11018
+ proposalHead: inputs.data.proposalHead,
11019
+ files: merged.files,
11020
+ modules: merged.modules
11021
+ })
11022
+ });
11023
+ const text = await written.text();
11024
+ if (!written.ok) {
10181
11025
  return {
10182
- status: 403,
11026
+ status: written.status,
10183
11027
  contentType: json,
10184
- body: JSON.stringify({
10185
- message: this.proposal === null ? "The site's files are changed in a proposal: open one, and change them there." : "Files are changed in the proposal this Studio is open in, not in another."
10186
- })
11028
+ body: text
10187
11029
  };
10188
11030
  }
10189
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/proposals${path}`, {
10190
- method: init.method,
11031
+ return updatedAnswer(text);
11032
+ }
11033
+
11034
+ /**
11035
+ * Update from the site, for a proposal with code (valbuild/home
11036
+ * `docs/hosted-publishing.md`, step 8.2, slice 1b), at
11037
+ * `POST /<name>/update-code`. The builder tab has read content's trees
11038
+ * (`GET /<name>/merge-trees`, forwarded) and evaluated them; this reads the
11039
+ * trees again -- the tab's evaluation is of the trees content has, or it is
11040
+ * of nothing -- and does the text (`updateWithCode`). The first call
11041
+ * answers the merge for the tab to evaluate; the second, with that
11042
+ * evaluation, commits it on the proposal's branch, staging its code.
11043
+ *
11044
+ * Only at the proposal's own address, as every write to its files is.
11045
+ */
11046
+ async updateCodeFromSite(name, body, profileId) {
11047
+ var _this$proposal3;
11048
+ const json = "application/json";
11049
+ const answer = (status, value) => ({
11050
+ status,
11051
+ contentType: json,
11052
+ body: JSON.stringify(value)
11053
+ });
11054
+ if (((_this$proposal3 = this.proposal) === null || _this$proposal3 === void 0 ? void 0 : _this$proposal3.name) !== name) {
11055
+ return answer(403, {
11056
+ message: "A proposal is updated from the site at its own address, where its files are written."
11057
+ });
11058
+ }
11059
+ let parsedBody;
11060
+ try {
11061
+ parsedBody = body === undefined || body === "" ? {} : JSON.parse(body);
11062
+ } catch {
11063
+ return answer(400, {
11064
+ message: "The update's body is not JSON."
11065
+ });
11066
+ }
11067
+ const request = UpdateCodeBody.safeParse(parsedBody);
11068
+ if (!request.success) {
11069
+ return answer(400, {
11070
+ message: `Invalid update: ${fromError(request.error).toString()}`
11071
+ });
11072
+ }
11073
+ const content = (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
11074
+ ...init,
10191
11075
  headers: {
10192
11076
  ...this.authHeaders,
10193
- /*
10194
- * WHO is asking, as for a commit: the app's key names the project,
10195
- * and a proposal is opened, renamed and closed by a person -- content
10196
- * refuses those writes from a credential that names nobody.
10197
- */
10198
11077
  ...(profileId !== null ? {
10199
11078
  "x-val-profile-id": profileId
10200
11079
  } : {}),
10201
11080
  ...(init.body === undefined ? {} : {
10202
11081
  "Content-Type": json
10203
11082
  })
10204
- },
10205
- ...(init.body === undefined ? {} : {
10206
- body: init.body
11083
+ }
11084
+ });
11085
+ const read = await content("/merge-trees");
11086
+ if (!read.ok) {
11087
+ return {
11088
+ status: read.status,
11089
+ contentType: json,
11090
+ body: await read.text()
11091
+ };
11092
+ }
11093
+ const trees = MergeTrees.safeParse(await read.json());
11094
+ if (!trees.success) {
11095
+ return answer(502, {
11096
+ message: `Content answered the merge's trees in a shape this Val does not read: ${fromError(trees.error).toString()}`
11097
+ });
11098
+ }
11099
+ const {
11100
+ siteHead,
11101
+ proposalHead,
11102
+ builds
11103
+ } = request.data;
11104
+ if (trees.data.siteHead !== siteHead || trees.data.proposalHead !== proposalHead || trees.data.builds.base !== builds.base || trees.data.builds.site !== builds.site) {
11105
+ return answer(409, {
11106
+ status: "moved",
11107
+ message: "The site or this proposal changed while this update was being made. Update from the site again."
11108
+ });
11109
+ }
11110
+ if (trees.data.upToDate) return answer(200, {
11111
+ status: "up-to-date"
11112
+ });
11113
+ if (siteHead === null) {
11114
+ return answer(409, {
11115
+ message: "The site has no commit to update from."
11116
+ });
11117
+ }
11118
+ const merged = updateWithCode({
11119
+ trees: trees.data,
11120
+ evaluated: request.data.evaluated,
11121
+ resolutions: request.data.resolutions,
11122
+ ...(request.data.merged ? {
11123
+ merged: request.data.merged
11124
+ } : {})
11125
+ });
11126
+ if (merged.status === "stale") {
11127
+ return answer(409, {
11128
+ status: "stale",
11129
+ message: "What was checked is not this update any more. Update from the site again."
11130
+ });
11131
+ }
11132
+ if (merged.status !== "merged") return answer(200, merged);
11133
+ const written = await content("/merge-from-site", {
11134
+ method: "POST",
11135
+ body: JSON.stringify({
11136
+ siteHead,
11137
+ proposalHead,
11138
+ files: merged.files,
11139
+ modules: merged.modules,
11140
+ code: {
11141
+ build: builds.site,
11142
+ staged: merged.staged
11143
+ }
10207
11144
  })
10208
11145
  });
10209
- return {
10210
- status: res.status,
10211
- body: await res.text(),
10212
- contentType: res.headers.get("Content-Type") ?? json
10213
- };
11146
+ const text = await written.text();
11147
+ if (!written.ok) {
11148
+ return {
11149
+ status: written.status,
11150
+ contentType: json,
11151
+ body: text
11152
+ };
11153
+ }
11154
+ return updatedAnswer(text);
10214
11155
  }
10215
11156
 
10216
11157
  /**
@@ -10488,15 +11429,6 @@ class ValOpsHttp extends ValOps {
10488
11429
  ...(allPatchData.headVersion !== undefined ? {
10489
11430
  headVersion: allPatchData.headVersion
10490
11431
  } : {}),
10491
- /*
10492
- * Who holds what, read with the list and its version. On every stat, so
10493
- * a Studio's view of the groups is never older than its view of the
10494
- * chain — which is what lets a stage made in another browser show here
10495
- * without a reload. See `OrderedPatches.patchGroups`.
10496
- */
10497
- ...(allPatchData.patchGroups !== undefined ? {
10498
- patchGroups: allPatchData.patchGroups
10499
- } : {}),
10500
11432
  /*
10501
11433
  * The PUBLISH head, which is not `commitSha`.
10502
11434
  *
@@ -10565,8 +11497,8 @@ class ValOpsHttp extends ValOps {
10565
11497
 
10566
11498
  /** The branch this server's patches are on, where it knows it. */
10567
11499
  ownBranch() {
10568
- var _this$proposal2, _this$git;
10569
- return ((_this$proposal2 = this.proposal) === null || _this$proposal2 === void 0 ? void 0 : _this$proposal2.branch) ?? ((_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
11500
+ var _this$proposal4, _this$git;
11501
+ return ((_this$proposal4 = this.proposal) === null || _this$proposal4 === void 0 ? void 0 : _this$proposal4.branch) ?? ((_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
10570
11502
  }
10571
11503
  async getWebSocketNonce(profileId) {
10572
11504
  return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
@@ -10863,437 +11795,62 @@ class ValOpsHttp extends ValOps {
10863
11795
  updatedAt: deployment.updatedAt,
10864
11796
  commitMessage: deployment.commitMessage
10865
11797
  });
10866
- }
10867
- }
10868
- /*
10869
- * Membership, folded from the per-patch annotation onto the groups.
10870
- *
10871
- * From THIS response and not from `GET /patch-groups`, because only
10872
- * this one is consistent with the list and the version beside it:
10873
- * the content service reads all three in one transaction. A group
10874
- * list fetched separately can be older or newer than the chain it is
10875
- * shown with — and the Studio decides what an editor sees, and what
10876
- * a publish ships, from the pair.
10877
- *
10878
- * A group holding none of the listed patches is still listed, with
10879
- * no members: "groups exist and you hold nothing here" is a real
10880
- * answer, and different from "this content service has no groups".
10881
- */
10882
- let patchGroups;
10883
- if (data.patchGroups !== undefined) {
10884
- const members = new Map();
10885
- for (const patch of data.patches) {
10886
- for (const groupId of patch.patchGroupIds ?? []) {
10887
- const list = members.get(groupId) ?? [];
10888
- list.push(patch.patchId);
10889
- members.set(groupId, list);
10890
- }
10891
- }
10892
- patchGroups = data.patchGroups.map(group => ({
10893
- ...group,
10894
- patchIds: members.get(group.patchGroupId) ?? []
10895
- }));
10896
- }
10897
- return {
10898
- commits,
10899
- deployments,
10900
- patches,
10901
- errors,
10902
- ...(patchGroups !== undefined ? {
10903
- patchGroups
10904
- } : {}),
10905
- ...(data.headPatchId !== undefined ? {
10906
- headPatchId: data.headPatchId
10907
- } : {}),
10908
- ...(data.headVersion !== undefined ? {
10909
- headVersion: data.headVersion
10910
- } : {}),
10911
- ...(data.publishingPatches !== undefined ? {
10912
- publishingPatches: data.publishingPatches
10913
- } : {}),
10914
- ...(data.heldBackPatches !== undefined ? {
10915
- heldBackPatches: data.heldBackPatches
10916
- } : {})
10917
- };
10918
- }
10919
- console.error("Could not parse patches response. Error: " + fromError(parsed.error));
10920
- return {
10921
- patches,
10922
- error: {
10923
- message: `The response that Val got from the server was not in the expected format. This might be a transient error or a configuration issue. Please try again later.`
10924
- }
10925
- };
10926
- }
10927
- console.error("Could not get patches. HTTP error: " + patchesRes.status + " " + patchesRes.statusText);
10928
- if (patchesRes.status === 401) {
10929
- return {
10930
- patches,
10931
- error: {
10932
- message: "Although your user is authorized, the application has authorization issues. Contact the developers on your team and ask them to verify the api keys."
10933
- }
10934
- };
10935
- }
10936
- return {
10937
- patches,
10938
- error: {
10939
- message: "Could not get your changes. It is most likely due to a network issue. Check your network connection and please try again."
10940
- }
10941
- };
10942
- } catch (err) {
10943
- console.error("Could not get patches (connection error):", err instanceof Error ? err.message : JSON.stringify(err));
10944
- return {
10945
- patches: [],
10946
- networkError: true,
10947
- error: {
10948
- message: `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}`
10949
- }
10950
- };
10951
- }
10952
- }
10953
-
10954
- // #region patch groups
10955
- /**
10956
- * Add patches to a patch group.
10957
- *
10958
- * The set arrives closed by the client — `withPatchIds` is the prefix closure
10959
- * over the patch sets the staged patches belong to. We forward it and do not
10960
- * second-guess it: deriving the closure needs the content schema, which this
10961
- * process does have but content.val.build does not, and having two
10962
- * implementations of the rule would be worse than having one.
10963
- *
10964
- * Membership rows are stamped with `coreVersion` on the content side — the
10965
- * same stamp the patch row carries — so which client wrote a row stays
10966
- * legible after the fact.
10967
- */
10968
- async stagePatches(
10969
- /**
10970
- * The group to stage into, or `undefined` for the caller's open group on
10971
- * this branch — `~` on the content API, which creates it if there is none.
10972
- */
10973
- patchGroupId, /** What the user asked to stage. */
10974
- patchIds,
10975
- /**
10976
- * What has to come with it, because the staged patches are written on top
10977
- * of it.
10978
- *
10979
- * The content API stores each membership row as `explicit` or `dependency`
10980
- * and treats what it is not told about as a dependency. Folding the two
10981
- * halves into `patchIds` therefore files the patch somebody clicked as one
10982
- * the closure dragged in — the exact opposite of what happened, and the
10983
- * only record anywhere of what the author chose.
10984
- */
10985
- withPatchIds,
10986
- /**
10987
- * WHO is asking, so the content API can refuse a group that is not theirs.
10988
- *
10989
- * Every call from this class carries the app's API key, which says which
10990
- * PROJECT is calling and nothing about which editor. Without this the
10991
- * content API cannot tell one of a project's editors from another, so the
10992
- * only check on stage and unstage is the one in `ValServer` — and anything
10993
- * reaching the content API by another route (an API key, a PAT) has none at
10994
- * all.
10995
- *
10996
- * `null` where there is no session. The content API refuses rather than
10997
- * treating that as a match: a group written by an api key has a null author
10998
- * too, and `null === null` must not read as ownership.
10999
- */
11000
- authorId) {
11001
- return this.mutatePatchGroup("POST",
11002
- // Encoded: patchGroupId arrives in a request body, so an unencoded value
11003
- // like "../../commit" would reach a different endpoint carrying this
11004
- // project's auth headers.
11005
- `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
11006
- patchIds,
11007
- withPatchIds,
11008
- coreVersion: Internal.VERSION.core,
11009
- ...this.ownGroupBranch()
11010
- }, authorId);
11011
- }
11012
-
11013
- /**
11014
- * Remove patches from a patch group.
11015
- *
11016
- * The set arrives closed FORWARDS by the client: unstaging a patch also
11017
- * unstages everything built on top of it within its patch sets, and that is
11018
- * what `withPatchIds` carries.
11019
- */
11020
- async unstagePatches(/** See {@link stagePatches}. With no open group, there is nothing to remove. */
11021
- patchGroupId, /** What the user asked to unstage. */
11022
- patchIds, /** What has to go with it: everything built on top of it. */
11023
- withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
11024
- authorId) {
11025
- return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
11026
- patchIds,
11027
- withPatchIds,
11028
- ...this.ownGroupBranch()
11029
- }, authorId);
11030
- }
11031
-
11032
- /**
11033
- * Every patch group on this branch, with what each holds.
11034
- *
11035
- * Read rather than mutated, and used to answer "which pending patches is THIS
11036
- * person allowed to see". A draft render that skips this shows base + every
11037
- * pending patch on the branch, including work other people have not
11038
- * published — which is what independent publish exists to prevent.
11039
- *
11040
- * A failure is an empty list rather than a throw, and the caller decides what
11041
- * that means. For a draft render the honest fallback is "show nothing
11042
- * pending" rather than "show everything": being shown your own committed
11043
- * content when the group lookup is down is a worse experience than being
11044
- * shown somebody else's unpublished draft is a bug.
11045
- */
11046
- /**
11047
- * The last group lookup, and when it was made.
11048
- *
11049
- * A draft render calls `getPatchGroups` once per `fetchVal`, in series with
11050
- * the whole-chain fetch, and a page that calls `fetchVal` several times pays
11051
- * the round trip several times. Groups are per branch and change rarely, so a
11052
- * short window removes the multiplier without letting a stage go unseen for
11053
- * meaningfully longer than one render.
11054
- *
11055
- * Deliberately short. This is a read whose staleness decides whose draft
11056
- * content someone sees, so it is a per-request de-duplication rather than a
11057
- * cache: a second render a second later asks again.
11058
- */
11059
- patchGroupsCache = null;
11060
-
11061
- /**
11062
- * Forget the remembered group list, after a write through this server.
11063
- *
11064
- * A write changes membership -- a save joins its author's group, a delete or
11065
- * an unstage leaves it -- and the window above is long enough for the next
11066
- * render to be served the list from before it: an editor who saved and
11067
- * reloaded inside a second was shown their draft without the change they had
11068
- * just made. Called once the write has answered, so a read that started
11069
- * while it was in flight cannot leave the old list behind it.
11070
- *
11071
- * Only this instance's writes. Another instance's write is still seen at
11072
- * most one window late, which is what the window is for.
11073
- */
11074
- forgetPatchGroups() {
11075
- this.patchGroupsCache = null;
11076
- }
11077
- async getPatchGroups(options) {
11078
- const now = Date.now();
11079
- if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
11080
- return this.patchGroupsCache.res;
11081
- }
11082
- const res = await this.fetchPatchGroups();
11083
- /*
11084
- * ANSWERS are cached; failures are not.
11085
- *
11086
- * A transient failure held for a second is replayed to every caller in it,
11087
- * and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
11088
- * that refuses the stage, a scoped draft render falls back to base and
11089
- * drops every pending patch on the page, and `GET /patches` omits the
11090
- * annotation. One flaky request became a second of all three. The point of
11091
- * this cache is to collapse the several `fetchVal` calls in one render into
11092
- * one round trip, and an error is exactly the case worth retrying inside
11093
- * that window rather than the case worth remembering.
11094
- *
11095
- * `unsupported` is cached with `ok` deliberately: it is a real answer about
11096
- * the deployment — this content API predates patch groups — and it will not
11097
- * change between two renders.
11098
- */
11099
- if (res.status === "ok" || res.status === "unsupported") {
11100
- this.patchGroupsCache = {
11101
- at: now,
11102
- res
11103
- };
11104
- }
11105
- return res;
11106
- }
11107
- async fetchPatchGroups() {
11108
- /*
11109
- * A proposal has no patch groups: its changes are one unit, saved
11110
- * together, and the content service answers 404 for groups on its branch.
11111
- * "Unsupported" is exactly that answer, and it is what makes a draft in a
11112
- * proposal show all of the proposal's changes rather than one person's.
11113
- */
11114
- if (this.proposal !== null) {
11115
- return {
11116
- status: "unsupported"
11117
- };
11118
- }
11119
- try {
11120
- /*
11121
- * `branch` is REQUIRED by the endpoint, which answers 400 without it.
11122
- *
11123
- * Same two params every other read here sends (`fetchPatchesInternal`,
11124
- * `saveSourceFilePatch`): groups are per branch, so a request without one
11125
- * is not merely under-specified, it is rejected.
11126
- */
11127
- const branch = this.ownBranch();
11128
- const params = new URLSearchParams(branch !== undefined ? [["branch", branch]] : []);
11129
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
11130
- headers: this.authHeaders
11131
- });
11132
- if (res.status === 404) {
11133
- /*
11134
- * The endpoint is not there, which is a content API that PREDATES patch
11135
- * groups — not a failure.
11136
- *
11137
- * The distinction decides what a draft render shows, and collapsing it
11138
- * into "error" is not a small mistake: a caller that reads an error as
11139
- * "could not ask" renders BASE, so every existing http deployment would
11140
- * silently drop all pending content from every draft preview. "There
11141
- * are no groups here" has to mean unscoped, which is exactly the
11142
- * behaviour those projects have today.
11143
- */
11144
- return {
11145
- status: "unsupported"
11146
- };
11147
- }
11148
- if (!res.ok) {
11149
- return {
11150
- status: "error",
11151
- message: res.status === 401 ? "Could not read patch groups: unauthorized. Verify that the val api keys are correct." : `Could not read patch groups. HTTP error: ${res.status} ${res.statusText}`
11152
- };
11153
- }
11154
- const parsed = PatchGroupsResponse.safeParse(await res.json());
11155
- if (!parsed.success) {
11156
- return {
11157
- status: "error",
11158
- message: `Could not parse patch groups response. Error: ${fromError(parsed.error)}`
11159
- };
11160
- }
11161
- return {
11162
- status: "ok",
11163
- patchGroups: parsed.data.patchGroups
11164
- };
11165
- } catch (err) {
11166
- return {
11167
- status: "error",
11168
- message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
11169
- };
11170
- }
11171
- }
11172
-
11173
- /**
11174
- * Which branch `~` means, where this build knows: the branch it reads
11175
- * `applicable/patches` on, so the group staged into is the group listed.
11176
- * Without one the content API uses the project's own, as it does for reads.
11177
- */
11178
- ownGroupBranch() {
11179
- const branch = this.ownBranch();
11180
- return branch !== undefined ? {
11181
- branch
11182
- } : {};
11183
- }
11184
- async mutatePatchGroup(method, path, body,
11185
- /**
11186
- * WHO is asking. Sent as `x-val-profile-id`, which is what the content API
11187
- * reads to decide whether this group is the caller's.
11188
- *
11189
- * `this.authHeaders` is the app's API key, and that names the PROJECT, not
11190
- * the person — so without this the content API cannot resolve a profile
11191
- * and refuses every stage and unstage with
11192
- * "Cannot resolve the caller's profile". The group endpoints are the only
11193
- * ones here that need it, because they are the only ones whose answer
11194
- * depends on which of a project's editors is calling.
11195
- *
11196
- * Omitted when there is no session rather than sent empty: the content API
11197
- * treats an unidentified caller as a refusal, which is what we want, and an
11198
- * empty header would be a different and less obvious way to say it.
11199
- *
11200
- * A PAT already identifies a person, so `authHeaders` carries the identity
11201
- * on its own there and this adds nothing.
11202
- */
11203
- authorId) {
11204
- if (this.proposal !== null) {
11205
- return {
11206
- status: 409,
11207
- patchIds: [],
11208
- error: {
11209
- message: "A proposal's changes are saved together, so there is nothing to stage or unstage in one."
11210
- }
11211
- };
11212
- }
11213
- try {
11214
- return await this.mutatePatchGroupRequest(method, path, body, authorId);
11215
- } finally {
11216
- this.forgetPatchGroups();
11217
- }
11218
- }
11219
- async mutatePatchGroupRequest(method, path, body, authorId) {
11220
- try {
11221
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
11222
- method,
11223
- headers: {
11224
- ...this.authHeaders,
11225
- ...(authorId !== null ? {
11226
- "x-val-profile-id": authorId
11227
- } : {}),
11228
- "Content-Type": "application/json"
11229
- },
11230
- body: JSON.stringify(body)
11231
- });
11232
- if (res.ok) {
11233
- const parsed = PatchGroupMutationResponse.safeParse(await res.json());
11234
- if (parsed.success) {
11798
+ }
11799
+ }
11235
11800
  return {
11236
- patchIds: parsed.data.patchIds,
11237
- patchGroupId: parsed.data.patchGroupId,
11238
- ...(parsed.data.headVersion !== undefined ? {
11239
- headVersion: parsed.data.headVersion
11801
+ commits,
11802
+ deployments,
11803
+ patches,
11804
+ errors,
11805
+ ...(data.headPatchId !== undefined ? {
11806
+ headPatchId: data.headPatchId
11807
+ } : {}),
11808
+ ...(data.headVersion !== undefined ? {
11809
+ headVersion: data.headVersion
11810
+ } : {}),
11811
+ ...(data.publishingPatches !== undefined ? {
11812
+ publishingPatches: data.publishingPatches
11813
+ } : {}),
11814
+ ...(data.heldBackPatches !== undefined ? {
11815
+ heldBackPatches: data.heldBackPatches
11240
11816
  } : {})
11241
11817
  };
11242
11818
  }
11819
+ console.error("Could not parse patches response. Error: " + fromError(parsed.error));
11243
11820
  return {
11244
- status: 500,
11245
- patchIds: [],
11246
- error: {
11247
- message: `Could not parse patch group response. Error: ${fromError(parsed.error)}`
11248
- }
11249
- };
11250
- }
11251
- // 403 (not your group) and 409 (already published) are meaningful to the
11252
- // client, so they are passed through rather than flattened to a 500.
11253
- if (res.status === 403 || res.status === 409) {
11254
- // `home` answers these with a JSON body, so `res.text()` put the literal
11255
- // `{"message":"..."}` in front of the user. Every other branch in this
11256
- // class unwraps it; this one now does too.
11257
- return {
11258
- status: res.status,
11259
- patchIds: [],
11821
+ patches,
11260
11822
  error: {
11261
- message: getErrorMessageFromUnknownJson(await res.json().catch(() => undefined), `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`)
11823
+ message: `The response that Val got from the server was not in the expected format. This might be a transient error or a configuration issue. Please try again later.`
11262
11824
  }
11263
11825
  };
11264
11826
  }
11265
- // A 401 here is the app's own credentials failing, not the user's, so it gets
11266
- // the same wording as every other call in this class rather than an opaque
11267
- // 500 that sends the user looking at their own session.
11268
- if (res.status === 401) {
11827
+ console.error("Could not get patches. HTTP error: " + patchesRes.status + " " + patchesRes.statusText);
11828
+ if (patchesRes.status === 401) {
11269
11829
  return {
11270
- status: 500,
11271
- patchIds: [],
11830
+ patches,
11272
11831
  error: {
11273
11832
  message: "Although your user is authorized, the application has authorization issues. Contact the developers on your team and ask them to verify the api keys."
11274
11833
  }
11275
11834
  };
11276
11835
  }
11277
11836
  return {
11278
- status: 500,
11279
- patchIds: [],
11837
+ patches,
11280
11838
  error: {
11281
- message: `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`
11839
+ message: "Could not get your changes. It is most likely due to a network issue. Check your network connection and please try again."
11282
11840
  }
11283
11841
  };
11284
11842
  } catch (err) {
11843
+ console.error("Could not get patches (connection error):", err instanceof Error ? err.message : JSON.stringify(err));
11285
11844
  return {
11286
- status: 500,
11287
- patchIds: [],
11845
+ patches: [],
11846
+ networkError: true,
11288
11847
  error: {
11289
- message: `Could not update patch group (connection error?): ${err instanceof Error ? err.message : JSON.stringify(err)}`
11848
+ message: `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}`
11290
11849
  }
11291
11850
  };
11292
11851
  }
11293
11852
  }
11294
- // #endregion
11295
-
11296
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
11853
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
11297
11854
  const baseSha = await this.getBaseSha();
11298
11855
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
11299
11856
  method: "POST",
@@ -11319,24 +11876,7 @@ class ValOpsHttp extends ValOps {
11319
11876
  commit: this.git.commit,
11320
11877
  branch: this.git.branch
11321
11878
  } : {}),
11322
- coreVersion: Internal.VERSION.core,
11323
- /*
11324
- * Group membership in the SAME request as the patch.
11325
- *
11326
- * The content API runs every refusal before its insert, so an invalid
11327
- * closure is a 400 with nothing written. Spread rather than sent as
11328
- * nulls: a client with no group omits the fields entirely, which is
11329
- * what an older content API expects to see.
11330
- */
11331
- ...(patchGroup ? {
11332
- // Only when the caller named one. Omitted, the content API
11333
- // resolves this author's open group and creates it if absent,
11334
- // which is what every write wants.
11335
- ...(patchGroup.patchGroupId !== undefined ? {
11336
- patchGroupId: patchGroup.patchGroupId
11337
- } : {}),
11338
- withPatchIds: patchGroup.withPatchIds
11339
- } : {})
11879
+ coreVersion: Internal.VERSION.core
11340
11880
  })
11341
11881
  }).then(async res => {
11342
11882
  var _res$headers$get2;
@@ -11345,20 +11885,6 @@ class ValOpsHttp extends ValOps {
11345
11885
  if (parsed.success) {
11346
11886
  return result.ok({
11347
11887
  patchId: parsed.data.patchId,
11348
- /*
11349
- * Passed back to the client, which cannot learn it any other way.
11350
- *
11351
- * A write names no group — the content API resolves this author's
11352
- * open group and CREATES it if absent — so on a fresh branch the
11353
- * group comes into existence here and nowhere else. The chain
11354
- * annotation is only re-read when a fetch has missing ids to ask
11355
- * for, and a patch this client made is never missing, so without
11356
- * this the tab that bootstrapped the group would never learn its
11357
- * id and every stage would be a no-op.
11358
- */
11359
- ...(parsed.data.patchGroupId !== undefined ? {
11360
- patchGroupId: parsed.data.patchGroupId
11361
- } : {}),
11362
11888
  ...(parsed.data.headVersion !== undefined ? {
11363
11889
  headVersion: parsed.data.headVersion
11364
11890
  } : {})
@@ -11408,7 +11934,7 @@ class ValOpsHttp extends ValOps {
11408
11934
  errorType: "other",
11409
11935
  message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
11410
11936
  });
11411
- }).finally(() => this.forgetPatchGroups());
11937
+ });
11412
11938
  }
11413
11939
 
11414
11940
  /**
@@ -11673,17 +12199,7 @@ class ValOpsHttp extends ValOps {
11673
12199
  }]
11674
12200
  };
11675
12201
  }
11676
- async deletePatches(patchIds,
11677
- /**
11678
- * Patches that are NOT deleted but must lose their group membership.
11679
- *
11680
- * Deleting a patch out of the middle of a patch set leaves every group
11681
- * still holding the rest with a non-prefix intersection — the patches after
11682
- * the hole were written against a view that had it. The content API cannot
11683
- * work out which those are (it has no schema), so the client sends the
11684
- * forward closure and it drops those memberships without deleting anything.
11685
- */
11686
- unstagePatchIds) {
12202
+ async deletePatches(patchIds) {
11687
12203
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
11688
12204
  method: "DELETE",
11689
12205
  headers: {
@@ -11691,10 +12207,7 @@ class ValOpsHttp extends ValOps {
11691
12207
  "Content-Type": "application/json"
11692
12208
  },
11693
12209
  body: JSON.stringify({
11694
- patchIds,
11695
- ...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
11696
- unstagePatchIds
11697
- } : {})
12210
+ patchIds
11698
12211
  })
11699
12212
  }).then(async res => {
11700
12213
  if (res.ok) {
@@ -11737,24 +12250,9 @@ class ValOpsHttp extends ValOps {
11737
12250
  message: `Could not delete patches (connection error?): ${e instanceof Error ? e.message : e.toString()}`
11738
12251
  }
11739
12252
  };
11740
- }).finally(() => this.forgetPatchGroups());
12253
+ });
11741
12254
  }
11742
- async commit(prepared, message, committer, filesDirectory, newBranch,
11743
- /**
11744
- * The patch group this commit EMPTIES, if it empties one.
11745
- *
11746
- * The content API closes the group it is given — and closes it WITHOUT
11747
- * checking that the commit shipped all of it, so a caller that names a
11748
- * group still holding work takes those patches out of every group and
11749
- * leaves their author unable to publish them. The client therefore sends it
11750
- * only when the publish accounts for everything the group still holds.
11751
- *
11752
- * Omitting it is not neutral: the commit still empties the group (the
11753
- * content API drops applied ids from every group), but `published_at` is
11754
- * never set, so the id is reused across publishes instead of a new group
11755
- * per publish and the "already published" refusal can never fire.
11756
- */
11757
- patchGroupId) {
12255
+ async commit(prepared, message, committer, filesDirectory, newBranch) {
11758
12256
  try {
11759
12257
  var _res$headers$get3;
11760
12258
  /*
@@ -11768,23 +12266,10 @@ class ValOpsHttp extends ValOps {
11768
12266
  headers: {
11769
12267
  ...this.authHeaders,
11770
12268
  /*
11771
- * WHO is publishing — the same `x-val-profile-id` stage and unstage
11772
- * send, and for the same reason: `this.authHeaders` is the app's API
11773
- * key, which names the PROJECT and not the person.
11774
- *
11775
- * Closing a group is an ownership decision, so the content API
11776
- * refuses a commit that names a `patchGroupId` it cannot attribute:
11777
- * without this header `profileId` is `undefined` there and every
11778
- * group-closing publish is 403 "Cannot resolve the caller's profile".
11779
- * That is the NORMAL full publish, not an edge — `publish` names the
11780
- * group whenever the commit empties it.
11781
- *
11782
- * Sent on every commit rather than only when a group is named. The
11783
- * committer is a person either way, `committer` in the body already
11784
- * says so, and a header that appears only on some commits is one more
11785
- * conditional for a reader of either repo to reconstruct. It changes
11786
- * nothing else: the content API reads it as an identity claim beside
11787
- * the app's key and derives no scope from it.
12269
+ * WHO is publishing: `this.authHeaders` is the app's API key, which
12270
+ * names the PROJECT and not the person. The content API reads it as
12271
+ * an identity claim beside the app's key and derives no scope from
12272
+ * it; `committer` in the body says the same.
11788
12273
  */
11789
12274
  "x-val-profile-id": committer,
11790
12275
  "Content-Type": "application/json"
@@ -11829,10 +12314,7 @@ class ValOpsHttp extends ValOps {
11829
12314
  baseSha: await this.getBaseSha(),
11830
12315
  committer,
11831
12316
  message,
11832
- newBranch,
11833
- ...(patchGroupId !== undefined ? {
11834
- patchGroupId
11835
- } : {})
12317
+ newBranch
11836
12318
  })
11837
12319
  });
11838
12320
  if (res.ok) {
@@ -12089,6 +12571,146 @@ class ValOpsHttp extends ValOps {
12089
12571
  // #endregion history
12090
12572
  }
12091
12573
 
12574
+ /**
12575
+ * Content's answer to a merge it committed, as the Studio is answered: what
12576
+ * content says, marked updated. Content has written the merge by then, so an
12577
+ * answer that cannot be read says so rather than failing as though nothing
12578
+ * happened.
12579
+ */
12580
+ function updatedAnswer(text) {
12581
+ let parsed;
12582
+ try {
12583
+ parsed = JSON.parse(text);
12584
+ } catch {
12585
+ parsed = undefined;
12586
+ }
12587
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
12588
+ return {
12589
+ status: 502,
12590
+ contentType: "application/json",
12591
+ body: JSON.stringify({
12592
+ message: "The proposal was updated from the site, but content's answer could not be read. Reload to see the proposal as it is now."
12593
+ })
12594
+ };
12595
+ }
12596
+ return {
12597
+ status: 200,
12598
+ contentType: "application/json",
12599
+ body: JSON.stringify({
12600
+ status: "updated",
12601
+ ...parsed
12602
+ })
12603
+ };
12604
+ }
12605
+
12606
+ /** What the Studio sends to update a proposal from the site: its decisions. */
12607
+ const UpdateContentBody = z.object({
12608
+ /**
12609
+ * Where the site and the proposal were when the conflicts the
12610
+ * `resolutions` decide were found: the conflicts answer says. Absent with
12611
+ * no decisions.
12612
+ */
12613
+ siteHead: z.string().nullable().optional(),
12614
+ proposalHead: z.string().nullable().optional(),
12615
+ resolutions: z.array(z.union([z.object({
12616
+ moduleFilePath: z.string().transform(p => p),
12617
+ path: z.array(z.string()),
12618
+ keep: z.enum(["site", "proposal"])
12619
+ }), z.object({
12620
+ moduleFilePath: z.string().transform(p => p),
12621
+ path: z.array(z.string()),
12622
+ value: JSONValue
12623
+ })])).default([])
12624
+ });
12625
+
12626
+ /** A module, as the builder tab's evaluation of its tree gave it. */
12627
+ const EvaluatedModules = z.record(z.string().transform(p => p), z.object({
12628
+ schema: SerializedSchema,
12629
+ written: JSONValue,
12630
+ errors: z.array(z.object({
12631
+ path: z.array(z.string()),
12632
+ message: z.string()
12633
+ }))
12634
+ }));
12635
+
12636
+ /** What the builder tab sends to update a proposal with code from the site. */
12637
+ const UpdateCodeBody = z.object({
12638
+ siteHead: z.string().nullable(),
12639
+ proposalHead: z.string().nullable(),
12640
+ builds: z.object({
12641
+ base: z.string(),
12642
+ site: z.string()
12643
+ }),
12644
+ evaluated: z.object({
12645
+ base: EvaluatedModules,
12646
+ site: EvaluatedModules,
12647
+ proposal: EvaluatedModules
12648
+ }),
12649
+ resolutions: z.array(z.union([z.object({
12650
+ moduleFilePath: z.string().transform(p => p),
12651
+ path: z.array(z.string()),
12652
+ keep: z.enum(["site", "proposal"])
12653
+ }), z.object({
12654
+ moduleFilePath: z.string().transform(p => p),
12655
+ path: z.array(z.string()),
12656
+ value: JSONValue
12657
+ }), z.object({
12658
+ file: z.string(),
12659
+ keep: z.enum(["site", "proposal"])
12660
+ }), z.object({
12661
+ file: z.string(),
12662
+ text: z.string()
12663
+ })])).default([]),
12664
+ /** The tab's evaluation of the merge the first call answered. */
12665
+ merged: z.object({
12666
+ id: z.string(),
12667
+ evaluated: EvaluatedModules
12668
+ }).optional()
12669
+ });
12670
+
12671
+ /** Content's `GET /proposals/:name/merge-trees`. */
12672
+ const MergeTrees = z.object({
12673
+ base: z.string().nullable(),
12674
+ siteHead: z.string().nullable(),
12675
+ proposalHead: z.string().nullable(),
12676
+ upToDate: z.boolean(),
12677
+ builds: z.object({
12678
+ base: z.string(),
12679
+ site: z.string()
12680
+ }),
12681
+ trees: z.object({
12682
+ site: z.record(z.string(), z.string()),
12683
+ base: z.record(z.string(), z.string().nullable()),
12684
+ proposal: z.record(z.string(), z.string().nullable())
12685
+ }),
12686
+ modules: z.record(z.string(), z.object({
12687
+ base: z.string().nullable(),
12688
+ site: z.string().nullable(),
12689
+ proposal: z.string().nullable()
12690
+ })),
12691
+ sources: z.record(z.string(), JSONValue)
12692
+ });
12693
+
12694
+ /** Content's `GET /proposals/:name/merge-inputs`. */
12695
+ const MergeInputs = z.object({
12696
+ base: z.string().nullable(),
12697
+ siteHead: z.string().nullable(),
12698
+ proposalHead: z.string().nullable(),
12699
+ upToDate: z.boolean(),
12700
+ contentOnly: z.union([z.object({
12701
+ ok: z.literal(true)
12702
+ }), z.object({
12703
+ ok: z.literal(false),
12704
+ reason: z.string()
12705
+ })]),
12706
+ modules: z.record(z.string(), z.object({
12707
+ base: JSONValue.nullable(),
12708
+ site: JSONValue.nullable(),
12709
+ proposal: JSONValue.nullable(),
12710
+ text: z.string().nullable()
12711
+ }))
12712
+ });
12713
+
12092
12714
  /**
12093
12715
  * One stored patch. Everything the ordered chain needs and nothing else.
12094
12716
  */
@@ -12378,8 +13000,7 @@ class ValOpsMemory extends ValOps {
12378
13000
  }
12379
13001
  async fetchPatches(filters) {
12380
13002
  // An empty `patchIds` means "no filter", not "none": both shipped
12381
- // implementations read it that way and callers rely on it. See
12382
- // `scopedModulePatches` in ValOps.ts, which says why at length.
13003
+ // implementations read it that way and callers rely on it.
12383
13004
  const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
12384
13005
  const order = await this.store.list();
12385
13006
  const patches = [];
@@ -12425,12 +13046,7 @@ class ValOpsMemory extends ValOps {
12425
13046
  * point at nothing. A patch computed against a different state is caught
12426
13047
  * where it shows -- applying it -- rather than by refusing the write.
12427
13048
  */
12428
- _parentRef, authorId, _sessionId,
12429
- /* No shared store and no second author yet, so nothing for a group to
12430
- * separate. Named rather than omitted: TypeScript allows an implementation
12431
- * to take fewer parameters, so dropping it would look identical to
12432
- * handling it. */
12433
- _patchGroup) {
13049
+ _parentRef, authorId, _sessionId) {
12434
13050
  await this.store.append({
12435
13051
  patchId,
12436
13052
  path,
@@ -15195,195 +15811,25 @@ const ValServer = (valModules, options, callbacks) => {
15195
15811
  /*
15196
15812
  * Every URL in the batch at once, and nothing beyond it.
15197
15813
  *
15198
- * The concurrency here is the batch size the route caps at, which is
15199
- * what keeps "check a thousand links" from becoming a thousand
15200
- * simultaneous outbound sockets: the client sends ten, waits, sends
15201
- * the next ten. Doing them in parallel WITHIN a batch is what makes a
15202
- * batch worth having — ten five-second timeouts in series is a minute.
15203
- */
15204
- const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
15205
- return {
15206
- status: 200,
15207
- json: {
15208
- results: Object.fromEntries(results)
15209
- }
15210
- };
15211
- }
15212
- },
15213
- "/direct-file-upload-settings": {
15214
- POST: async req => {
15215
- const cookies = req.cookies;
15216
- const auth = getAuth(cookies);
15217
- if (auth.error) {
15218
- return {
15219
- status: 401,
15220
- json: {
15221
- message: auth.error
15222
- }
15223
- };
15224
- }
15225
- if (serverOps.patchesAreLocal) {
15226
- // In FS mode patch-file uploads are buffered through this server (no remote round-trip),
15227
- // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
15228
- // content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
15229
- // can POST directly without exposing the PAT.
15230
- const host = `/api/val`;
15231
- let contentBaseUrl = null;
15232
- let contentAuthNonce = null;
15233
- if (!options.project) {
15234
- console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
15235
- } else if (!(serverOps instanceof ValOpsFS)) {
15236
- /*
15237
- * Presigning needs a content host to presign AGAINST, and a local
15238
- * store does not necessarily have one configured: `fs` mode has the
15239
- * developer's `valContentUrl`, and a host holding its own source has
15240
- * no such setting.
15241
- *
15242
- * Warned and disabled, like the missing-project case above, rather
15243
- * than failing the request: everything else this route answers --
15244
- * the patch upload base url -- still works, and the only thing lost
15245
- * is the browser posting AI images straight to the content host.
15246
- */
15247
- console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
15248
- } else {
15249
- const authDataRes = await getRemoteFileAuth();
15250
- if (authDataRes.status !== 200) {
15251
- console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
15252
- } else {
15253
- const corsOrigin = "*"; // TODO: add cors origin
15254
- const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
15255
- if (presignedAuthNonce.status === "success") {
15256
- contentBaseUrl = presignedAuthNonce.data.baseUrl;
15257
- contentAuthNonce = presignedAuthNonce.data.nonce;
15258
- } else {
15259
- console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
15260
- }
15261
- }
15262
- }
15263
- return {
15264
- status: 200,
15265
- json: {
15266
- nonce: null,
15267
- baseUrl: `${host}/upload`,
15268
- // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
15269
- contentBaseUrl,
15270
- contentAuthNonce
15271
- }
15272
- };
15273
- }
15274
- const httpOps = serverOps;
15275
- const profileId = "id" in auth && auth.id ? auth.id : undefined;
15276
- if (!profileId) {
15277
- return {
15278
- status: 401,
15279
- json: {
15280
- message: "Unauthorized"
15281
- }
15282
- };
15283
- }
15284
- const corsOrigin = "*"; // TODO: add cors origin
15285
- const presignedAuthNonce = await httpOps.getPresignedAuthNonce(profileId, corsOrigin);
15286
- if (presignedAuthNonce.status === "error") {
15287
- return {
15288
- status: presignedAuthNonce.statusCode,
15289
- json: presignedAuthNonce.error
15290
- };
15291
- }
15292
- return {
15293
- status: 200,
15294
- json: {
15295
- nonce: presignedAuthNonce.data.nonce,
15296
- baseUrl: presignedAuthNonce.data.baseUrl,
15297
- contentBaseUrl: presignedAuthNonce.data.baseUrl,
15298
- contentAuthNonce: presignedAuthNonce.data.nonce
15299
- }
15300
- };
15301
- }
15302
- },
15303
- //#region patch groups
15304
- // Staging and unstaging patches. Both take an already-closed set of patch ids:
15305
- // the prefix closure (staging) or the forward closure (unstaging) is computed
15306
- // on the client, which is the only side that has the schema needed to derive
15307
- // patch sets. See `docs/independent-publish/PLAN.md`.
15308
- //
15309
- // In FS mode there is no shared store and exactly one author, so group
15310
- // membership lives in the client and these handlers simply acknowledge. The
15311
- // client already sends an explicit patch id list to `/save`, so a locally-held
15312
- // group is enough to publish a subset correctly.
15313
- "/patch-groups/~/patches": {
15314
- PUT: async req => {
15315
- const auth = getAuth(req.cookies);
15316
- if (auth.error) {
15317
- return {
15318
- status: 401,
15319
- json: {
15320
- message: auth.error
15321
- }
15322
- };
15323
- }
15324
- const {
15325
- patchGroupId,
15326
- patchIds
15327
- } = req.body;
15328
- const withPatchIds = req.body.withPatchIds ?? [];
15329
- if (serverOps.patchesAreLocal) {
15330
- return {
15331
- status: 200,
15332
- json: {
15333
- patchGroupId: patchGroupId ?? null,
15334
- patchIds: [...patchIds, ...withPatchIds]
15335
- }
15336
- };
15337
- }
15338
- if (!("id" in auth) || !auth.id) {
15339
- return {
15340
- status: 401,
15341
- json: {
15342
- message: "Unauthorized"
15343
- }
15344
- };
15345
- }
15346
- // A named group is checked here as well as by the content API. No name
15347
- // is the caller's own open group, which the content API resolves from
15348
- // the profile it is sent — there is nothing here to check it against.
15349
- if (patchGroupId !== undefined) {
15350
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
15351
- if (refusal !== null) {
15352
- return {
15353
- status: refusal.status,
15354
- json: {
15355
- message: refusal.message
15356
- }
15357
- };
15358
- }
15359
- }
15360
- const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
15361
- // Forwarded so the content API can refuse independently. This server
15362
- // has already refused a group that is not the caller's; sending the
15363
- // author means the check also holds for anything reaching the content
15364
- // API without coming through here.
15365
- auth.id);
15366
- if (res.error) {
15367
- return {
15368
- status: res.status,
15369
- json: {
15370
- message: res.error.message
15371
- }
15372
- };
15373
- }
15814
+ * The concurrency here is the batch size the route caps at, which is
15815
+ * what keeps "check a thousand links" from becoming a thousand
15816
+ * simultaneous outbound sockets: the client sends ten, waits, sends
15817
+ * the next ten. Doing them in parallel WITHIN a batch is what makes a
15818
+ * batch worth having — ten five-second timeouts in series is a minute.
15819
+ */
15820
+ const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
15374
15821
  return {
15375
15822
  status: 200,
15376
15823
  json: {
15377
- patchGroupId: res.patchGroupId,
15378
- patchIds: res.patchIds,
15379
- ...(res.headVersion !== undefined ? {
15380
- headVersion: res.headVersion
15381
- } : {})
15824
+ results: Object.fromEntries(results)
15382
15825
  }
15383
15826
  };
15384
- },
15385
- DELETE: async req => {
15386
- const auth = getAuth(req.cookies);
15827
+ }
15828
+ },
15829
+ "/direct-file-upload-settings": {
15830
+ POST: async req => {
15831
+ const cookies = req.cookies;
15832
+ const auth = getAuth(cookies);
15387
15833
  if (auth.error) {
15388
15834
  return {
15389
15835
  status: 401,
@@ -15392,21 +15838,58 @@ const ValServer = (valModules, options, callbacks) => {
15392
15838
  }
15393
15839
  };
15394
15840
  }
15395
- const {
15396
- patchGroupId,
15397
- patchIds
15398
- } = req.body;
15399
- const withPatchIds = req.body.withPatchIds ?? [];
15400
15841
  if (serverOps.patchesAreLocal) {
15842
+ // In FS mode patch-file uploads are buffered through this server (no remote round-trip),
15843
+ // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
15844
+ // content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
15845
+ // can POST directly without exposing the PAT.
15846
+ const host = `/api/val`;
15847
+ let contentBaseUrl = null;
15848
+ let contentAuthNonce = null;
15849
+ if (!options.project) {
15850
+ console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
15851
+ } else if (!(serverOps instanceof ValOpsFS)) {
15852
+ /*
15853
+ * Presigning needs a content host to presign AGAINST, and a local
15854
+ * store does not necessarily have one configured: `fs` mode has the
15855
+ * developer's `valContentUrl`, and a host holding its own source has
15856
+ * no such setting.
15857
+ *
15858
+ * Warned and disabled, like the missing-project case above, rather
15859
+ * than failing the request: everything else this route answers --
15860
+ * the patch upload base url -- still works, and the only thing lost
15861
+ * is the browser posting AI images straight to the content host.
15862
+ */
15863
+ console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
15864
+ } else {
15865
+ const authDataRes = await getRemoteFileAuth();
15866
+ if (authDataRes.status !== 200) {
15867
+ console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
15868
+ } else {
15869
+ const corsOrigin = "*"; // TODO: add cors origin
15870
+ const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
15871
+ if (presignedAuthNonce.status === "success") {
15872
+ contentBaseUrl = presignedAuthNonce.data.baseUrl;
15873
+ contentAuthNonce = presignedAuthNonce.data.nonce;
15874
+ } else {
15875
+ console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
15876
+ }
15877
+ }
15878
+ }
15401
15879
  return {
15402
15880
  status: 200,
15403
15881
  json: {
15404
- patchGroupId: patchGroupId ?? null,
15405
- patchIds: [...patchIds, ...withPatchIds]
15882
+ nonce: null,
15883
+ baseUrl: `${host}/upload`,
15884
+ // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
15885
+ contentBaseUrl,
15886
+ contentAuthNonce
15406
15887
  }
15407
15888
  };
15408
15889
  }
15409
- if (!("id" in auth) || !auth.id) {
15890
+ const httpOps = serverOps;
15891
+ const profileId = "id" in auth && auth.id ? auth.id : undefined;
15892
+ if (!profileId) {
15410
15893
  return {
15411
15894
  status: 401,
15412
15895
  json: {
@@ -15414,37 +15897,21 @@ const ValServer = (valModules, options, callbacks) => {
15414
15897
  }
15415
15898
  };
15416
15899
  }
15417
- // A named group is checked here as well as by the content API. No name
15418
- // is the caller's own open group, which the content API resolves from
15419
- // the profile it is sent — there is nothing here to check it against.
15420
- if (patchGroupId !== undefined) {
15421
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
15422
- if (refusal !== null) {
15423
- return {
15424
- status: refusal.status,
15425
- json: {
15426
- message: refusal.message
15427
- }
15428
- };
15429
- }
15430
- }
15431
- const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
15432
- if (res.error) {
15900
+ const corsOrigin = "*"; // TODO: add cors origin
15901
+ const presignedAuthNonce = await httpOps.getPresignedAuthNonce(profileId, corsOrigin);
15902
+ if (presignedAuthNonce.status === "error") {
15433
15903
  return {
15434
- status: res.status,
15435
- json: {
15436
- message: res.error.message
15437
- }
15904
+ status: presignedAuthNonce.statusCode,
15905
+ json: presignedAuthNonce.error
15438
15906
  };
15439
15907
  }
15440
15908
  return {
15441
15909
  status: 200,
15442
15910
  json: {
15443
- patchGroupId: res.patchGroupId,
15444
- patchIds: res.patchIds,
15445
- ...(res.headVersion !== undefined ? {
15446
- headVersion: res.headVersion
15447
- } : {})
15911
+ nonce: presignedAuthNonce.data.nonce,
15912
+ baseUrl: presignedAuthNonce.data.baseUrl,
15913
+ contentBaseUrl: presignedAuthNonce.data.baseUrl,
15914
+ contentAuthNonce: presignedAuthNonce.data.nonce
15448
15915
  }
15449
15916
  };
15450
15917
  }
@@ -15453,53 +15920,6 @@ const ValServer = (valModules, options, callbacks) => {
15453
15920
  "/patches": {
15454
15921
  PUT: async req => {
15455
15922
  const cookies = req.cookies;
15456
-
15457
- /**
15458
- * Group membership travels WITH the patch, in one request.
15459
- *
15460
- * Atomic on purpose: the content API runs every refusal before its
15461
- * insert, so an invalid closure is a 400 with nothing written. Recording
15462
- * membership in a second call would let a patch exist outside its
15463
- * author's group whenever that call failed — and a patch outside your
15464
- * own group is one you cannot publish until a repair puts it back.
15465
- *
15466
- */
15467
- /*
15468
- * A membership is present if EITHER field is.
15469
- *
15470
- * The common case names no group: the content API resolves the author's
15471
- * open group, creating it if absent, so the client never has to hold an
15472
- * id across publishes. It still sends the closure, which is the part it
15473
- * alone can compute.
15474
- *
15475
- * `patchGroupId` is nullable, so an explicit `null` means "no group
15476
- * named" exactly as omitting it does — it is passed through as
15477
- * `undefined` rather than becoming a membership keyed by null.
15478
- */
15479
- const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
15480
- const requestedWith = req.body.withPatchIds;
15481
- const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
15482
- ...(requestedPatchGroupId !== undefined ? {
15483
- patchGroupId: requestedPatchGroupId
15484
- } : {}),
15485
- withPatchIds: requestedWith ?? []
15486
- } : undefined;
15487
- if (patchGroup !== undefined && serverOps.patchesAreLocal) {
15488
- /*
15489
- * `fs` has no shared store and one author, so there is no group to
15490
- * join. Refused rather than acknowledged: answering 200 would tell the
15491
- * client its membership was recorded when it was dropped, and the
15492
- * client would then believe a publish is scoped when it is not.
15493
- */
15494
- return {
15495
- status: 400,
15496
- json: {
15497
- type: "patch-error",
15498
- message: "Patch groups are not available in fs mode. Omit the patch group fields.",
15499
- errors: {}
15500
- }
15501
- };
15502
- }
15503
15923
  const auth = getAuth(cookies);
15504
15924
  if (auth.error) {
15505
15925
  return {
@@ -15522,17 +15942,6 @@ const ValServer = (valModules, options, callbacks) => {
15522
15942
  const sessionId = req.body.sessionId ?? null;
15523
15943
  const authorId = "id" in auth ? auth.id : null;
15524
15944
  const newPatchIds = [];
15525
- /*
15526
- * The group the content API put these patches in.
15527
- *
15528
- * Every patch in one request has the same author and the same
15529
- * membership, so the last answer is the answer — they all land in the
15530
- * same group. Reported back because the client cannot learn it any
15531
- * other way: it names no group (the content API resolves the author's
15532
- * open one, creating it if absent), and the chain annotation is only
15533
- * re-read when a fetch has missing ids to ask for.
15534
- */
15535
- let patchGroupIdFromStore;
15536
15945
  /*
15537
15946
  * The chain version the LAST patch made — the head this batch leaves.
15538
15947
  * The client keeps the highest version it has seen, so a `/stat` read
@@ -15548,18 +15957,12 @@ const ValServer = (valModules, options, callbacks) => {
15548
15957
  const savedSoFar = () => ({
15549
15958
  newPatchIds,
15550
15959
  parentRef,
15551
- // Absent rather than null where there are no groups: `fs` mode and
15552
- // a content API that predates them both answer without one, and the
15553
- // client reads absence as "staging is not available here".
15554
- ...(patchGroupIdFromStore !== undefined ? {
15555
- patchGroupId: patchGroupIdFromStore
15556
- } : {}),
15557
15960
  ...(headVersion !== undefined ? {
15558
15961
  headVersion
15559
15962
  } : {})
15560
15963
  });
15561
15964
  for (const patch of patches) {
15562
- const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
15965
+ const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
15563
15966
  if (result.isErr(createPatchRes)) {
15564
15967
  if (createPatchRes.error.errorType === "patch-head-conflict") {
15565
15968
  return {
@@ -15608,9 +16011,6 @@ const ValServer = (valModules, options, callbacks) => {
15608
16011
  patchId: createPatchRes.value.patchId
15609
16012
  };
15610
16013
  newPatchIds.push(createPatchRes.value.patchId);
15611
- if (createPatchRes.value.patchGroupId !== undefined) {
15612
- patchGroupIdFromStore = createPatchRes.value.patchGroupId;
15613
- }
15614
16014
  headVersion = createPatchRes.value.headVersion;
15615
16015
  }
15616
16016
  }
@@ -15677,84 +16077,16 @@ const ValServer = (valModules, options, callbacks) => {
15677
16077
  }
15678
16078
  // TODO: we should sort by parentRef instead:
15679
16079
  patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
15680
- /**
15681
- * Patch groups, ANNOTATED onto the chain rather than filtering it.
15682
- *
15683
- * The client computes a new patch's parent as the last id in this
15684
- * response, so a filtered chain would make every client name a parent
15685
- * that is not the real head and `POST /patches` would answer 409
15686
- * forever. Annotate, never filter.
15687
- *
15688
- * Absent — not empty — where there are no groups: `fs` mode, a content
15689
- * API that predates them, or a failed lookup. The client reads absence
15690
- * as "this deployment has no groups" and leaves staging off, which is
15691
- * the behaviour every project has today. An empty array would say
15692
- * "groups exist and hold nothing", which would turn the staging UI on
15693
- * with everything held.
15694
- */
15695
- let patchGroups;
15696
- if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
15697
- /*
15698
- * FRESH, because this answer makes a CLOSING decision.
15699
- *
15700
- * The client adopts this annotation as its group membership and
15701
- * `emptiesOwnPatchGroup` then decides from it whether a publish may
15702
- * name the group — and the content API closes what it is named
15703
- * without checking. A one-second-old list is enough to get that
15704
- * wrong: the same author writing in a second tab joins the open
15705
- * group, the websocket pushes the chain immediately, so this tab's
15706
- * fetch for the missing patch lands well inside the cache window and
15707
- * reads a membership that is one patch short. It then publishes,
15708
- * names the group, and closes it with the other tab's work still in
15709
- * it — which leaves that tab wedged on 409 until a reload.
15710
- *
15711
- * `resolveOwnPatchScope` keeps the cache deliberately: that read
15712
- * decides what a draft render SHOWS, it is repeated once per
15713
- * `fetchVal` in a single render, and being a second stale there costs
15714
- * a patch appearing late rather than a group closing early.
15715
- */
15716
- const groupsRes = await serverOps.getPatchGroups({
15717
- fresh: true
15718
- });
15719
- if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
15720
- patchGroups = groupsRes.patchGroups;
15721
- } else if (groupsRes.status === "error") {
15722
- // Not fatal: the chain is what this endpoint is for, and staging
15723
- // simply stays off for this read rather than the whole review
15724
- // screen failing to load.
15725
- console.error("Val: could not read patch groups", groupsRes.message);
15726
- }
15727
- }
15728
- const groupIdsByPatchId = new Map();
15729
- for (const group of patchGroups ?? []) {
15730
- for (const patchId of group.patchIds) {
15731
- const existing = groupIdsByPatchId.get(patchId);
15732
- if (existing) {
15733
- existing.push(group.patchGroupId);
15734
- } else {
15735
- groupIdsByPatchId.set(patchId, [group.patchGroupId]);
15736
- }
15737
- }
15738
- }
15739
16080
  return {
15740
16081
  status: 200,
15741
16082
  json: {
15742
- patches: patches.map(patch => {
15743
- const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
15744
- return patchGroupIds ? {
15745
- ...patch,
15746
- patchGroupIds
15747
- } : patch;
15748
- }),
15749
- ...(patchGroups ? {
15750
- patchGroups
15751
- } : {}),
16083
+ patches,
15752
16084
  baseSha: await serverOps.getBaseSha()
15753
16085
  }
15754
16086
  };
15755
16087
  },
15756
16088
  DELETE: async req => {
15757
- var _req$body, _req$body2;
16089
+ var _req$body;
15758
16090
  const query = req.query;
15759
16091
  const cookies = req.cookies;
15760
16092
  const auth = getAuth(cookies);
@@ -15775,25 +16107,7 @@ const ValServer = (valModules, options, callbacks) => {
15775
16107
  };
15776
16108
  }
15777
16109
  const ids = query.id;
15778
- /*
15779
- * Which OTHER patches lose their group membership because these are
15780
- * going. Only the client can COMPUTE it — that needs the patch sets,
15781
- * which need the schema — but it is bounded here rather than trusted,
15782
- * because the content API strips those memberships from every group
15783
- * without an ownership check. See `boundUnstageClosure`.
15784
- *
15785
- * Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
15786
- * client does not send it there.
15787
- */
15788
- let unstagePatchIds;
15789
- const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
15790
- if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
15791
- const chain = await serverOps.fetchPatches({
15792
- excludePatchOps: true
15793
- });
15794
- unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
15795
- }
15796
- const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
16110
+ const deleteRes = await serverOps.deletePatches(ids);
15797
16111
  if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
15798
16112
  console.error("Val: Failed to delete patches", deleteRes.errors);
15799
16113
  return {
@@ -15814,7 +16128,7 @@ const ValServer = (valModules, options, callbacks) => {
15814
16128
  }
15815
16129
  };
15816
16130
  }
15817
- if (((_req$body2 = req.body) === null || _req$body2 === void 0 ? void 0 : _req$body2.reportHeadVersion) === true) {
16131
+ if (((_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.reportHeadVersion) === true) {
15818
16132
  return {
15819
16133
  status: 200,
15820
16134
  json: {
@@ -15925,22 +16239,6 @@ const ValServer = (valModules, options, callbacks) => {
15925
16239
  } = req.query;
15926
16240
  // Defaults to true, mirroring /sources/~. The Studio opts out.
15927
16241
  const applyPatches = req.query.apply_patches !== false;
15928
- /*
15929
- * Whose pending work this render may see.
15930
- *
15931
- * The same resolution `/sources/~` runs, through the same function: a
15932
- * draft page renders module content and `jsonValues` entries together,
15933
- * and this route used to apply every pending patch on the branch while
15934
- * the modules beside it were scoped — so one screen showed the caller's
15935
- * view and everybody's unpublished work at once.
15936
- */
15937
- const {
15938
- ownPatchIds
15939
- } = await resolveOwnPatchScope(serverOps, {
15940
- explicitPatchIds: undefined,
15941
- ownGroupsOnly: req.query.own_patch_groups_only === true,
15942
- authorId: "id" in auth && auth.id || undefined
15943
- });
15944
16242
  const isWindow = offset !== undefined || limit !== undefined;
15945
16243
  const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
15946
16244
  if (shapes !== 1) {
@@ -15961,8 +16259,7 @@ const ValServer = (valModules, options, callbacks) => {
15961
16259
  }
15962
16260
  if (key !== undefined) {
15963
16261
  const res = await serverOps.getJsonEntry(moduleFilePath, key, {
15964
- applyPatches,
15965
- patchIds: ownPatchIds
16262
+ applyPatches
15966
16263
  });
15967
16264
  if (res.status === "unauthorized") {
15968
16265
  return {
@@ -16003,8 +16300,7 @@ const ValServer = (valModules, options, callbacks) => {
16003
16300
  offset: offset,
16004
16301
  limit: limit
16005
16302
  }, {
16006
- applyPatches,
16007
- patchIds: ownPatchIds
16303
+ applyPatches
16008
16304
  });
16009
16305
  if (res.status === "unauthorized") {
16010
16306
  return {
@@ -16089,101 +16385,10 @@ const ValServer = (valModules, options, callbacks) => {
16089
16385
  patches: []
16090
16386
  };
16091
16387
  if (query.exclude_patches !== true) {
16092
- /**
16093
- * The caller's own groups, resolved from their session.
16094
- *
16095
- * A draft render cannot name its own group ids — it has no client
16096
- * state — so it asks for "mine" and the server works out which. Only
16097
- * when `patch_id` is absent: an explicit list is a caller that already
16098
- * knows what it wants.
16099
- *
16100
- * A group lookup that FAILS renders base rather than everything. Being
16101
- * shown only committed content while the content API is unreachable is
16102
- * a degraded preview; being shown another author's unpublished draft
16103
- * because a lookup failed is the bug this feature exists to prevent,
16104
- * and it would be silent.
16105
- */
16106
- const {
16107
- ownPatchIds,
16108
- scopeAlsoIncludesApplied
16109
- } = await resolveOwnPatchScope(serverOps, {
16110
- explicitPatchIds: query.patch_id,
16111
- ownGroupsOnly: query.own_patch_groups_only === true,
16112
- authorId: "id" in auth && auth.id || undefined
16388
+ patchOps = await serverOps.fetchPatches({
16389
+ patchIds: undefined,
16390
+ excludePatchOps: false
16113
16391
  });
16114
- const requestedPatchIds = query.patch_id ?? ownPatchIds;
16115
- if (scopeAlsoIncludesApplied) {
16116
- /*
16117
- * Scoped to this caller's groups, PLUS everything already
16118
- * committed.
16119
- *
16120
- * Filtered here rather than through `patchIds`, because the set is
16121
- * not knowable before the fetch: `appliedAt` lives on the patch,
16122
- * not on the group. One request either way — the whole chain is
16123
- * what the unscoped path fetches too — so this costs a filter, not
16124
- * a round trip.
16125
- *
16126
- * This also subsumes the empty-group case below: a caller holding
16127
- * nothing, on a branch with nothing applied, filters down to no
16128
- * patches, which is base.
16129
- */
16130
- const all = await serverOps.fetchPatches({
16131
- patchIds: undefined,
16132
- excludePatchOps: false
16133
- });
16134
- /*
16135
- * Membership from THIS response when it carries one, not from the
16136
- * group list `resolveOwnPatchScope` read.
16137
- *
16138
- * That list is a separate request, remembered for a second
16139
- * (`ValOpsHttp.getPatchGroups`), so it can be older than the chain
16140
- * fetched here: an editor who saved and reloaded at once was
16141
- * rendered without the patch they had just written, because the
16142
- * list still had their group without it. The content service reads
16143
- * the chain and each patch's groups in one transaction, and
16144
- * `fetchPatches` folds that onto `patchGroups` -- so the patches
16145
- * and who owns them come from the same moment.
16146
- */
16147
- const own = ownPatchScopeFor(all, "id" in auth && auth.id || undefined, ownPatchIds);
16148
- patchOps = {
16149
- ...all,
16150
- patches: scopedPatches(all.patches, own)
16151
- };
16152
- } else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
16153
- /*
16154
- * A group that holds nothing renders base, and is handled HERE.
16155
- *
16156
- * `fetchPatches` cannot express it: both implementations read an
16157
- * empty `patchIds` as "no filter" and return the whole chain
16158
- * (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
16159
- * `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
16160
- * That is the right default for every caller that has ever passed a
16161
- * list, since none of them can mean "none" — but it is the most
16162
- * dangerous possible reading of an EXPLICITLY empty group, which
16163
- * would render every unpublished patch on the branch instead of
16164
- * base.
16165
- *
16166
- * Answered before the call rather than by changing that shared
16167
- * default, which seven other call sites rely on.
16168
- */
16169
- patchOps = {
16170
- patches: []
16171
- };
16172
- } else {
16173
- patchOps = await serverOps.fetchPatches({
16174
- /*
16175
- * The caller's patch group, when it named one.
16176
- *
16177
- * `undefined` means every pending patch, which is what every
16178
- * existing caller gets and has to keep getting. A draft-mode
16179
- * render that names its group gets base + that group instead, so
16180
- * a server-rendered preview shows the same thing the person
16181
- * editing is looking at rather than everybody's unpublished work.
16182
- */
16183
- patchIds: requestedPatchIds,
16184
- excludePatchOps: false
16185
- });
16186
- }
16187
16392
  }
16188
16393
  // We check authorization here, because it is the first call to the backend
16189
16394
  if (patchOps.error && patchOps.unauthorized) {
@@ -16893,44 +17098,6 @@ const ValServer = (valModules, options, callbacks) => {
16893
17098
  };
16894
17099
  } else if (serverOps instanceof ValOpsHttp) {
16895
17100
  if (auth.error === undefined && auth.id) {
16896
- /*
16897
- * The group this commit CLOSES has to be the caller's.
16898
- *
16899
- * Stage and unstage go through `refuseUnlessOwn`; this route did
16900
- * not, and the content API's `postCommit` marks the group published
16901
- * on id alone with no author clause — so the id was trusted twice
16902
- * and checked nowhere. Any logged-in editor could name a colleague's
16903
- * open group and close it: their pending patches land in a closed
16904
- * group and in no open one, so a scoped draft render shows base for
16905
- * them, and their own tab still believes the group is open, so their
16906
- * next stage is refused with 409.
16907
- *
16908
- * Same hole as the stage/unstage one this branch already closed, one
16909
- * route over. The content API needs the same guard — this one is the
16910
- * convenience, that one is what actually holds.
16911
- */
16912
- if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
16913
- const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
16914
- if (refusal !== null) {
16915
- return {
16916
- status: refusal.status,
16917
- json: {
16918
- message: refusal.message,
16919
- /*
16920
- * Flagged, because a bare 409 here reads as git refusing
16921
- * the commit — which is retryable, and this is the
16922
- * opposite. `refuseUnlessOwn` answers 409 for one reason
16923
- * only: the group has already been published, so its id
16924
- * will never be writable again and retrying reproduces
16925
- * this forever. The client forgets the id instead.
16926
- */
16927
- ...(refusal.status === 409 ? {
16928
- patchGroupPublished: true
16929
- } : {})
16930
- }
16931
- };
16932
- }
16933
- }
16934
17101
  const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
16935
17102
  /*
16936
17103
  * Before the commit, because after it the files are no longer the
@@ -16938,14 +17105,7 @@ const ValServer = (valModules, options, callbacks) => {
16938
17105
  */
16939
17106
  const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
16940
17107
  const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
16941
- const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined,
16942
- /*
16943
- * Forwarded verbatim, and only the client can decide it: the
16944
- * content API closes the group it is named without checking
16945
- * that the commit shipped all of it, and whether it did needs
16946
- * the patch sets, which live in the browser.
16947
- */
16948
- body.patchGroupId);
17108
+ const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined);
16949
17109
  /*
16950
17110
  * A host may publish somewhere other than the repository.
16951
17111
  *
@@ -16958,7 +17118,6 @@ const ValServer = (valModules, options, callbacks) => {
16958
17118
  preparedCommit,
16959
17119
  message,
16960
17120
  authorId: auth.id,
16961
- patchGroupId: body.patchGroupId,
16962
17121
  commitToGit
16963
17122
  }) : await commitToGit();
16964
17123
  if (commitRes.error) {
@@ -18011,268 +18170,6 @@ const ValServer = (valModules, options, callbacks) => {
18011
18170
  }
18012
18171
  };
18013
18172
  };
18014
- /**
18015
- * Refuse to touch a group that is not the caller's.
18016
- *
18017
- * Exported for `patchGroupOwnership.test.ts`: this is the whole of the
18018
- * authorization for stage and unstage, and nothing else in the process checks
18019
- * it, so it is worth testing as a policy rather than only through a route.
18020
- *
18021
- * `getAuth` only proves a session EXISTS; it says nothing about whose
18022
- * group this is. And the content API cannot decide either — every call
18023
- * from here carries the app's API key, not the editor's identity — so if
18024
- * this does not check, nothing does.
18025
- *
18026
- * `GET /patches?include_patch_groups=true` hands every editor the id and
18027
- * author of every open group on the branch, so without this any logged-in
18028
- * editor can unstage another author's patches (their next publish
18029
- * silently ships less) or stage into their group (it silently ships
18030
- * more). The 403 declared for this route in `ApiRoutes.ts` was
18031
- * unreachable.
18032
- *
18033
- * Fails CLOSED: if the groups cannot be read, the mutation is refused
18034
- * rather than allowed unverified.
18035
- */
18036
-
18037
- /**
18038
- * The patches a scoped draft render should apply: the caller's own group, plus
18039
- * everything already committed.
18040
- *
18041
- * Scoping is about PENDING work. A published patch stays in the chain with
18042
- * `appliedAt` set until the next deployment moves the base, and it is part of
18043
- * everyone's view in that window — the unscoped path applies it. Dropping it
18044
- * meant the moment somebody published, their own draft preview reverted the
18045
- * field they had just shipped, and nobody else saw it either until the deploy
18046
- * landed; anything written on top in that window is authored against content
18047
- * already stale on `main`.
18048
- *
18049
- * Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
18050
- * PARTIAL publish leaves the group open with only some of its patches applied.
18051
- * Those are committed too, and no flag on the group names them.
18052
- *
18053
- * `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
18054
- * caller holding nothing, and on a branch with nothing applied it correctly
18055
- * filters down to no patches, which renders base.
18056
- */
18057
- function scopedPatches(patches, ownPatchIds) {
18058
- const own = new Set(ownPatchIds ?? []);
18059
- return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
18060
- }
18061
-
18062
- /**
18063
- * Which of the client's `unstagePatchIds` this server is willing to forward.
18064
- *
18065
- * The forward closure of a discard is the client's to compute — it needs the
18066
- * patch sets, which need the schema — and it was being forwarded verbatim. But
18067
- * the content API removes those memberships from EVERY group with no ownership
18068
- * check, so any logged-in editor could strip arbitrary patches out of any other
18069
- * author's group by attaching them to a delete of one of their own throwaway
18070
- * patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
18071
- * reached by a different door: their next publish silently ships less.
18072
- *
18073
- * Neither server can compute the true closure, but this one can BOUND it. A
18074
- * patch can only be invalidated by a delete if it was written after that delete
18075
- * — its paths were chosen against a view that had it — and if it is in the same
18076
- * module, since a patch set never spans two. Anything outside those bounds was
18077
- * not in the closure whatever the client says, so it is dropped rather than
18078
- * refused: the delete is still correct, and refusing the whole request over an
18079
- * over-broad extra would turn a discard into an error the user cannot act on.
18080
- *
18081
- * Exported for the test. Pure, and given the chain rather than fetching it, so
18082
- * the ordering it depends on is visible in the test rather than mocked.
18083
- */
18084
- function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
18085
- chain, deleted, requested) {
18086
- if (requested.length === 0) {
18087
- return [];
18088
- }
18089
- const positionOf = new Map();
18090
- const moduleOf = new Map();
18091
- chain.forEach((entry, index) => {
18092
- positionOf.set(entry.patchId, index);
18093
- moduleOf.set(entry.patchId, entry.path);
18094
- });
18095
- const doomed = new Set(deleted);
18096
- return requested.filter(patchId => {
18097
- // A patch being deleted anyway does not need its membership stripped
18098
- // separately, and naming one is how an over-broad list hides.
18099
- if (doomed.has(patchId)) return false;
18100
- const position = positionOf.get(patchId);
18101
- const moduleFilePath = moduleOf.get(patchId);
18102
- if (position === undefined || moduleFilePath === undefined) return false;
18103
- return deleted.some(deletedId => {
18104
- const deletedPosition = positionOf.get(deletedId);
18105
- if (deletedPosition === undefined) return false;
18106
- return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
18107
- });
18108
- });
18109
- }
18110
-
18111
- /**
18112
- * Which pending patches this caller may see, when they asked for "only mine".
18113
- *
18114
- * Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
18115
- * both, so two answers to "whose work is this" put one person's half-finished
18116
- * edit on another person's preview through whichever route was not scoped. That
18117
- * is exactly what happened — `/json` applied every pending patch on the branch
18118
- * while the module content beside it was scoped.
18119
- *
18120
- * `undefined` means "apply everything", which is what every caller that does
18121
- * not ask for scoping gets and must keep getting.
18122
- */
18123
- /** The patches in this author's OPEN groups: what a scoped draft shows. */
18124
- function ownPatchIdsIn(patchGroups, authorId) {
18125
- return patchGroups.filter(group => group.publishedAt === null && group.authorId === authorId).flatMap(group => group.patchIds);
18126
- }
18127
-
18128
- /**
18129
- * Whose pending patches a scoped draft shows, taken from the SAME response as
18130
- * the patches when it says, and from the list read beforehand only when not.
18131
- *
18132
- * See the call in `/sources/~`: the list read beforehand can be older than the
18133
- * chain, and the response's membership cannot be.
18134
- */
18135
- function ownPatchScopeFor(fetched, authorId, remembered) {
18136
- if (fetched.patchGroups === undefined || authorId === undefined) {
18137
- return remembered;
18138
- }
18139
- return ownPatchIdsIn(fetched.patchGroups, authorId);
18140
- }
18141
- async function resolveOwnPatchScope(
18142
- /*
18143
- * `ValOps`, not the two concrete stores: the one thing this needs is whether
18144
- * a content service holds the groups, and it asks that below with an
18145
- * `instanceof ValOpsHttp`. Naming the implementations here meant every new
18146
- * store had to be added to a list to be allowed to call a function that does
18147
- * not use it.
18148
- */
18149
- serverOps, opts) {
18150
- let ownPatchIds;
18151
- /** See where this is set: committed work is nobody's to hold back. */
18152
- let scopeAlsoIncludesApplied = false;
18153
- if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
18154
- if (serverOps instanceof ValOpsHttp && opts.authorId) {
18155
- const groupsRes = await serverOps.getPatchGroups();
18156
- if (groupsRes.status === "unsupported") {
18157
- /*
18158
- * A content API that PREDATES patch groups — the endpoint 404s.
18159
- *
18160
- * Unscoped, which is what those deployments do today and must
18161
- * keep doing. Reading this as a failure and rendering base
18162
- * would silently drop every pending patch from every draft
18163
- * preview on every existing http project — the exact opposite
18164
- * of "keeps working unchanged", and invisible to the reader.
18165
- */
18166
- ownPatchIds = undefined;
18167
- } else if (groupsRes.status === "error") {
18168
- /*
18169
- * We could not ask, and this deployment DOES have the endpoint.
18170
- * Render base rather than everything: a degraded preview is
18171
- * recoverable, showing another author's unpublished draft is
18172
- * not, and it would be silent.
18173
- */
18174
- ownPatchIds = [];
18175
- } else if (groupsRes.patchGroups.length === 0) {
18176
- /*
18177
- * The branch has no groups AT ALL, so this deployment is not
18178
- * using them — a content API that predates patch groups, or a
18179
- * project where nobody has staged anything since they existed.
18180
- *
18181
- * Unscoped, which is the behaviour every such project has
18182
- * today. Collapsing this into "your group is empty" would make
18183
- * every draft render base and silently drop all pending
18184
- * content, which is what happened before this branch: nothing
18185
- * writes a group yet, so EVERY project is in this state right
18186
- * now.
18187
- */
18188
- ownPatchIds = undefined;
18189
- } else {
18190
- /*
18191
- * Groups exist and none are this person's: they have staged
18192
- * nothing, and base is the honest answer. Distinct from the
18193
- * case above, which is why the two are not one expression.
18194
- */
18195
- ownPatchIds = ownPatchIdsIn(groupsRes.patchGroups, opts.authorId);
18196
- /*
18197
- * Scoping applies to PENDING work only. Anything already
18198
- * committed is part of everyone's view.
18199
- *
18200
- * A published patch stays in the chain with `appliedAt` set
18201
- * until the next deployment moves the base, and the unscoped
18202
- * path applies it. Filtering to open groups dropped it — so the
18203
- * moment someone published, their own draft preview reverted
18204
- * the field they had just shipped, and nobody else saw it
18205
- * either until the deploy landed. Anything written on top in
18206
- * that window is authored against content that is already
18207
- * stale on `main`.
18208
- *
18209
- * Unioned by `appliedAt` rather than by pulling in groups with
18210
- * a `publishedAt`, because a partial publish leaves the group
18211
- * OPEN with some of its patches applied — those are committed
18212
- * too, and no group flag names them.
18213
- */
18214
- scopeAlsoIncludesApplied = true;
18215
- }
18216
- } else {
18217
- /*
18218
- * fs mode, or a server with no groups: there is nothing to scope
18219
- * BY, and every pending patch is this one person's anyway. Left
18220
- * `undefined` so the existing "apply everything" path runs.
18221
- */
18222
- ownPatchIds = undefined;
18223
- }
18224
- }
18225
- return {
18226
- ownPatchIds,
18227
- scopeAlsoIncludesApplied
18228
- };
18229
- }
18230
- async function refuseUnlessOwn(ops, patchGroupId, authorId) {
18231
- /*
18232
- * Never from the cache. This answers "is this group yours", and a group is at
18233
- * its youngest exactly when the question is asked — the first write creates
18234
- * it and the shell flushes its queued stages the moment the save response
18235
- * names it. A cached list fetched a few hundred milliseconds earlier does not
18236
- * contain it, and every one of those stages was refused and dropped.
18237
- */
18238
- const groupsRes = await ops.getPatchGroups({
18239
- fresh: true
18240
- });
18241
- if (groupsRes.status !== "ok") {
18242
- return {
18243
- status: 500,
18244
- message: "Could not verify that this patch group is yours, so it was not changed."
18245
- };
18246
- }
18247
- const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
18248
- if (group === undefined || group.authorId === null || group.authorId !== authorId) {
18249
- /*
18250
- * "Not found" and "not yours" are the SAME refusal on purpose: the route
18251
- * schema has no 404, and `GET /patches` already lists every group on the
18252
- * branch, so distinguishing them hides nothing and only adds a second
18253
- * message to keep consistent.
18254
- *
18255
- * A null author is a group written by an api key or a PAT. Nobody owns it,
18256
- * so nobody may stage into it — `null === null` must not read as a match.
18257
- */
18258
- return {
18259
- status: 403,
18260
- message: "You can only change your own patch group"
18261
- };
18262
- }
18263
- if (group.publishedAt !== null) {
18264
- /*
18265
- * Already shipped, so it can never be written again. The content API
18266
- * answers this too; refusing here saves the round trip and keeps the
18267
- * wording the same as every other refusal on this route.
18268
- */
18269
- return {
18270
- status: 409,
18271
- message: "Patch group is already published"
18272
- };
18273
- }
18274
- return null;
18275
- }
18276
18173
  function verifyCallbackReq(stateCookie, queryParams) {
18277
18174
  if (typeof stateCookie !== "string") {
18278
18175
  return {