@valbuild/server 0.145.0 → 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.
@@ -3571,7 +3571,7 @@ const jsonOps = new patch.JSONOps();
3571
3571
  * host.
3572
3572
  */
3573
3573
  const JSON_ENTRY_READ_CONCURRENCY = 8;
3574
- const tsOps = new TSOps(document => {
3574
+ const tsOps$1 = new TSOps(document => {
3575
3575
  return fp.pipe(analyzeValModule(document), fp.result.map(({
3576
3576
  source
3577
3577
  }) => source));
@@ -4077,7 +4077,7 @@ class ValOps {
4077
4077
  message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
4078
4078
  };
4079
4079
  }
4080
- modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds, patchOps.commits).map(p => ({
4080
+ modulePatches = draftModulePatches(patchOps.patches, moduleFilePath, patchOps.commits).map(p => ({
4081
4081
  patchId: p.patchId,
4082
4082
  patch: p.patch
4083
4083
  }));
@@ -5184,7 +5184,7 @@ class ValOps {
5184
5184
  kind: "normal"
5185
5185
  };
5186
5186
  if (cls.kind === "normal" && fromCls.kind === "normal") {
5187
- const patchRes = patch.applyPatch(tsSourceFile, tsOps, [op]);
5187
+ const patchRes = patch.applyPatch(tsSourceFile, tsOps$1, [op]);
5188
5188
  if (fp.result.isErr(patchRes)) {
5189
5189
  collectPatchError(patchRes.error, patchId, op);
5190
5190
  patchHadError = true;
@@ -5623,21 +5623,8 @@ class ValOps {
5623
5623
  }
5624
5624
 
5625
5625
  // #region createPatch
5626
- async createPatch(path, patch, patchId, parentRef, sessionId, authorId,
5627
- /**
5628
- * Which patch group this patch joins, recorded in the SAME request.
5629
- *
5630
- * Atomic on purpose. The content API runs every refusal before its insert,
5631
- * so an invalid closure is a 400 with nothing written. Recording membership
5632
- * in a second call would let a patch exist outside its author's group if
5633
- * that call failed — and a patch outside your own group is one you cannot
5634
- * publish until a repair puts it back.
5635
- *
5636
- * Optional: `fs` mode has no groups, and a client that predates them sends
5637
- * nothing.
5638
- */
5639
- patchGroup) {
5640
- const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
5626
+ async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
5627
+ const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
5641
5628
  if (fp.result.isErr(saveRes)) {
5642
5629
  if (saveRes.error.errorType === "plan-refused") {
5643
5630
  // The project's plan, not a failure: the Studio keeps the edit and
@@ -5658,11 +5645,6 @@ class ValOps {
5658
5645
  return fp.result.ok({
5659
5646
  patchId,
5660
5647
  createdAt: new Date().toISOString(),
5661
- // Spread rather than assigned: absent has to stay distinguishable from a
5662
- // group whose id is undefined, all the way to the client.
5663
- ...(saveRes.value.patchGroupId !== undefined ? {
5664
- patchGroupId: saveRes.value.patchGroupId
5665
- } : {}),
5666
5648
  ...(saveRes.value.headVersion !== undefined ? {
5667
5649
  headVersion: saveRes.value.headVersion
5668
5650
  } : {})
@@ -5820,8 +5802,8 @@ class ValOps {
5820
5802
  * Almost everything the routes branch on comes from this one fact, which is
5821
5803
  * why it is a named property rather than an `instanceof`. If this server owns
5822
5804
  * the store then there is no content service to authenticate to (so an absent
5823
- * or unverifiable session is anonymous rather than a 401), no shared store for
5824
- * a patch group to separate authors in, no deployments to report, and a
5805
+ * or unverifiable session is anonymous rather than a 401), no deployments to
5806
+ * report, and a
5825
5807
  * "publish" writes what the host does with it rather than pushing a commit. If
5826
5808
  * it does not, every one of those is the content service's and this server is
5827
5809
  * relaying.
@@ -5956,20 +5938,6 @@ function isFileSource(value) {
5956
5938
  return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
5957
5939
  }
5958
5940
 
5959
- /**
5960
- * The patch group a newly created patch joins.
5961
- *
5962
- * `withPatchIds` is the CLOSURE the client computed — the patches that share
5963
- * a patch set with this one and must move with it. It is not derived here and
5964
- * must not be: the closure needs the content schema, and the service that
5965
- * stores groups does not have it. One implementation of that rule, on the side
5966
- * that can actually compute it.
5967
- *
5968
- * Membership rows are stamped with `coreVersion` on the content side, the same
5969
- * stamp the patch row itself carries, so which client wrote a row stays legible
5970
- * after the fact.
5971
- */
5972
-
5973
5941
  /**
5974
5942
  * The content API refused a write for the project's plan (a 403 that says why
5975
5943
  * as data), as opposed to refusing the patch.
@@ -6001,34 +5969,16 @@ function formatPatchSourceError(error) {
6001
5969
  */
6002
5970
 
6003
5971
  /**
6004
- * The patches a json entry render should apply, out of the whole chain.
6005
- *
6006
- * Three rules, and the second is the one that was missing. A draft page renders
6007
- * `jsonValues` entries beside module content, and only the modules were scoped
6008
- * — so one screen showed the caller's own view for its modules and base plus
6009
- * EVERY pending patch on the branch for the entries beside them, including
6010
- * another author's half-finished edit rendered as though it were live.
5972
+ * The patches a json entry render should apply, out of the whole chain:
6011
5973
  *
6012
5974
  * 1. this module's, since the chain is branch-wide;
6013
- * 2. this caller's, when they asked to be scoped. `undefined` is "everything",
6014
- * which is what every unscoped caller gets and must keep getting;
6015
- * 3. a patch published after this build is kept whatever the scope: it is
6016
- * nobody's to hold back, and this build does not have it. One already in
6017
- * the build is dropped. `draftOverlay` decides which is which, from
6018
- * `commits`.
6019
- *
6020
- * Filtered here rather than by asking `fetchPatches` for a list, and that is
6021
- * load-bearing: both implementations read an empty `patchIds` as "no filter"
6022
- * and return the whole chain. That is the right default for a caller that
6023
- * cannot mean "none", and the most dangerous possible reading of a group that
6024
- * is genuinely empty — it would render every unpublished patch on the branch
6025
- * instead of base. It costs no round trip either: the whole chain is what the
6026
- * unscoped path fetches anyway.
6027
- */
6028
- function scopedModulePatches(patches, moduleFilePath, patchIds, /** The commits content says came after this build: see `draftOverlay`. */
5975
+ * 2. a patch published after this build is kept: this build does not have it.
5976
+ * One already in the build is dropped. `draftOverlay` decides which is
5977
+ * which, from `commits`.
5978
+ */
5979
+ function draftModulePatches(patches, moduleFilePath, /** The commits content says came after this build: see `draftOverlay`. */
6029
5980
  commits) {
6030
- const scope = patchIds && new Set(patchIds);
6031
- return draftOverlay(patches.filter(patch => patch.path === moduleFilePath && (patch.appliedAt !== null || scope === undefined || scope.has(patch.patchId))), commits);
5981
+ return draftOverlay(patches.filter(patch => patch.path === moduleFilePath), commits);
6032
5982
  }
6033
5983
 
6034
5984
  /**
@@ -8120,17 +8070,7 @@ class ValOpsFS extends ValOps {
8120
8070
  };
8121
8071
  }
8122
8072
  }
8123
- async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId,
8124
- /*
8125
- * Named and ignored, rather than omitted from the signature.
8126
- *
8127
- * `fs` mode has no shared store and exactly one author, so there is nothing
8128
- * for a group to separate — every pending patch is already this person's.
8129
- * Declaring it makes that a decision a reader can see: TypeScript lets an
8130
- * implementation take fewer parameters than the abstract, so leaving it off
8131
- * would drop group membership silently and look identical to handling it.
8132
- */
8133
- _patchGroup) {
8073
+ async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
8134
8074
  const patchesDir = this.getPatchesDir();
8135
8075
  const record = {
8136
8076
  patch,
@@ -9043,13 +8983,13 @@ function proposalFromEnv(env) {
9043
8983
  });
9044
8984
  }
9045
8985
  }
9046
- if (!isObject$1(parsed)) {
8986
+ if (!isObject$2(parsed)) {
9047
8987
  throw new Error(`Val: proposal ${name}: VAL_OVERLAY is not an object`);
9048
8988
  }
9049
8989
  const modulesIn = parsed["modules"] ?? {};
9050
8990
  const filesIn = parsed["files"] ?? {};
9051
8991
  const commit = parsed["commit"] ?? null;
9052
- if (!isObject$1(modulesIn) || !isObject$1(filesIn)) {
8992
+ if (!isObject$2(modulesIn) || !isObject$2(filesIn)) {
9053
8993
  throw new Error(`Val: proposal ${name}: VAL_OVERLAY's modules and files must be objects`);
9054
8994
  }
9055
8995
  if (commit !== null && typeof commit !== "string") {
@@ -9112,7 +9052,7 @@ function contentFromEnv(env) {
9112
9052
  cause: err
9113
9053
  });
9114
9054
  }
9115
- if (!isObject$1(parsed) || Array.isArray(parsed)) {
9055
+ if (!isObject$2(parsed) || Array.isArray(parsed)) {
9116
9056
  throw new Error("Val: VAL_CONTENT is not an object");
9117
9057
  }
9118
9058
  const id = parsed["id"];
@@ -9129,7 +9069,7 @@ function contentFromEnv(env) {
9129
9069
  }
9130
9070
  const modulesIn = parsed["modules"] ?? {};
9131
9071
  const filesIn = parsed["files"] ?? {};
9132
- if (!isObject$1(modulesIn) || Array.isArray(modulesIn) || !isObject$1(filesIn) || Array.isArray(filesIn)) {
9072
+ if (!isObject$2(modulesIn) || Array.isArray(modulesIn) || !isObject$2(filesIn) || Array.isArray(filesIn)) {
9133
9073
  throw new Error(`Val: content version ${id}: modules and files must be objects`);
9134
9074
  }
9135
9075
  const files = {};
@@ -9142,7 +9082,7 @@ function contentFromEnv(env) {
9142
9082
  ...modulesIn
9143
9083
  };
9144
9084
  const refs = parsed["refs"] ?? {};
9145
- if (!isObject$1(refs) || Array.isArray(refs)) {
9085
+ if (!isObject$2(refs) || Array.isArray(refs)) {
9146
9086
  throw new Error(`Val: content version ${id}: refs must be an object`);
9147
9087
  }
9148
9088
  const read = (kind, path, ref) => {
@@ -9169,7 +9109,7 @@ function contentFromEnv(env) {
9169
9109
  };
9170
9110
  const refModules = refs["modules"] ?? {};
9171
9111
  const refFiles = refs["files"] ?? {};
9172
- if (!isObject$1(refModules) || Array.isArray(refModules) || !isObject$1(refFiles) || Array.isArray(refFiles)) {
9112
+ if (!isObject$2(refModules) || Array.isArray(refModules) || !isObject$2(refFiles) || Array.isArray(refFiles)) {
9173
9113
  throw new Error(`Val: content version ${id}: refs.modules and refs.files must be objects`);
9174
9114
  }
9175
9115
  for (const [path, ref] of Object.entries(refModules)) {
@@ -9314,7 +9254,7 @@ function proposalSnapshotForReaders(proposal, projectSource) {
9314
9254
  const snapshot = proposalSnapshot(proposal);
9315
9255
  for (const [moduleFilePath, entries] of proposalJsonEntries(proposal, projectSource)) {
9316
9256
  const source = snapshot[moduleFilePath];
9317
- if (!isObject$1(source)) continue;
9257
+ if (!isObject$2(source)) continue;
9318
9258
  const filled = {};
9319
9259
  for (const [key, value] of Object.entries(source)) {
9320
9260
  if (isJsonValue(value)) filled[key] = value;
@@ -9332,7 +9272,7 @@ function proposalSnapshotForReaders(proposal, projectSource) {
9332
9272
 
9333
9273
  /** A record of `.jsonValues()` markers (`{ _type: "json" }`), at least one. */
9334
9274
  function holdsJsonMarkers(source) {
9335
- return isObject$1(source) && Object.values(source).some(value => isObject$1(value) && value["_type"] === "json");
9275
+ return isObject$2(source) && Object.values(source).some(value => isObject$2(value) && value["_type"] === "json");
9336
9276
  }
9337
9277
 
9338
9278
  /** Each entry key's import path, from a `.jsonValues()` module's text. */
@@ -9368,9 +9308,9 @@ function isMutableJsonValue(value) {
9368
9308
  if (Array.isArray(value)) {
9369
9309
  return value.every(isMutableJsonValue);
9370
9310
  }
9371
- return isObject$1(value) && Object.values(value).every(isMutableJsonValue);
9311
+ return isObject$2(value) && Object.values(value).every(isMutableJsonValue);
9372
9312
  }
9373
- function isObject$1(value) {
9313
+ function isObject$2(value) {
9374
9314
  return typeof value === "object" && value !== null && !Array.isArray(value);
9375
9315
  }
9376
9316
 
@@ -9382,267 +9322,1025 @@ function isJsonValue(value) {
9382
9322
  if (Array.isArray(value)) {
9383
9323
  return value.every(isJsonValue);
9384
9324
  }
9385
- return isObject$1(value) && Object.values(value).every(isJsonValue);
9325
+ return isObject$2(value) && Object.values(value).every(isJsonValue);
9386
9326
  }
9387
9327
 
9388
- const textEncoder$2 = new TextEncoder();
9328
+ /** What a schema reports about this content: see `schemaErrorsOf`. */
9329
+ const validateWith = (schema, moduleFilePath, source) => internal.schemaErrorsOf(schema, moduleFilePath, source);
9389
9330
 
9390
- /** The body of the content API's 403 for a write the project's plan refuses. */
9391
- const PlanRefusedBody = z.z.object({
9392
- error: internal.PlanRefusalError
9393
- });
9394
- const PatchId = z.z.string().refine(s => !!s); // TODO: validate
9395
- const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
9396
- z.z.string().refine(s => !!s); // TODO: validate
9397
- z.z.string().refine(s => !!s); // TODO: validate
9398
- const ModuleFilePath = z.z.string().refine(s => !!s); // TODO: validate
9399
- const Metadata = z.z.union([z.z.object({
9400
- mimeType: z.z.string(),
9401
- width: z.z.number(),
9402
- height: z.z.number()
9403
- }), z.z.object({
9404
- mimeType: z.z.string()
9405
- })]);
9406
- const MetadataRes = z.z.object({
9407
- filePath: ModuleFilePath,
9408
- metadata: Metadata,
9409
- type: z.z.union([z.z.literal("file"), z.z.literal("image")]).nullable()
9410
- });
9411
- const GetApplicablePatches = z.z.object({
9412
- patches: z.z.array(z.z.object({
9413
- path: z.z.string(),
9414
- patch: internal.Patch.nullable(),
9415
- patchId: z.z.string(),
9416
- authorId: z.z.string().nullable(),
9417
- baseSha: z.z.string(),
9418
- createdAt: z.z.string(),
9419
- applied: z.z.object({
9420
- commitSha: z.z.string()
9421
- }).nullable(),
9422
- /**
9423
- * The groups this patch is a member of. Read in the SAME transaction as
9424
- * the list and its `headVersion`, which is the point: see
9425
- * `OrderedPatches.patchGroups`. Optional for an older content service.
9426
- */
9427
- patchGroupIds: z.z.array(z.z.string()).optional()
9428
- })),
9429
- /**
9430
- * The groups on the branch, without their members — those are on the
9431
- * patches above. Optional for a content service that predates groups.
9432
- */
9433
- patchGroups: z.z.array(z.z.object({
9434
- patchGroupId: z.z.string(),
9435
- authorId: z.z.string().nullable(),
9436
- createdAt: z.z.string(),
9437
- publishedAt: z.z.string().nullable()
9438
- })).optional(),
9439
- /**
9440
- * The head of the chain. See `OrderedPatches.headPatchId`. Optional because
9441
- * a content service that predates it sends nothing.
9442
- */
9443
- headPatchId: internal.PatchId.nullable().optional(),
9444
- /**
9445
- * The chain version of that head. See `OrderedPatches.headVersion`.
9446
- * Optional for the same reason.
9447
- */
9448
- headVersion: z.z.number().optional(),
9449
- /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
9450
- publishingPatches: z.z.array(internal.PatchId).optional(),
9451
- /** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
9452
- heldBackPatches: z.z.array(internal.PatchId).optional(),
9453
- commits: z.z.array(z.z.object({
9454
- commitSha: z.z.string(),
9455
- /*
9456
- * Nullable since the content service mints its own commit shas: a
9457
- * root commit has no parent, and a publisher that did not say where
9458
- * it was has no client sha. Parsed rather than trusted, so a service
9459
- * that sends null gets null here instead of failing the whole poll.
9460
- */
9461
- clientCommitSha: z.z.string().nullable(),
9462
- parentCommitSha: z.z.string().nullable(),
9463
- commitMessage: z.z.string().nullable(),
9464
- branch: z.z.string(),
9465
- creator: z.z.string(),
9466
- createdAt: z.z.string()
9467
- })).optional(),
9468
- deployments: z.z.array(z.z.object({
9469
- deploymentId: z.z.string(),
9470
- commitSha: z.z.string(),
9471
- deploymentState: z.z.string(),
9472
- createdAt: z.z.string(),
9473
- updatedAt: z.z.string(),
9474
- // The git message, where the content service knows it. See
9475
- // `ValDeployment`: this is the only source for a deployment that Val
9476
- // did not publish, and zod would otherwise strip it here.
9477
- commitMessage: z.z.string().nullable().optional()
9478
- })).optional(),
9479
- /**
9480
- * What the project EXPECTS of whoever publishes it.
9481
- *
9482
- * Optional because a content service that predates it sends nothing, and
9483
- * absent means "not reported" -- never "managed". The difference decides
9484
- * whether a publish is refused, so guessing either way would be worse than
9485
- * not checking: guessing `managed` would let a deployment publish a
9486
- * connected project it cannot mirror, and guessing `connected` would refuse
9487
- * every publish against an older service.
9488
- */
9489
- project: z.z.object({
9490
- sourceMode: z.z.union([z.z.literal("managed"), z.z.literal("connected")]),
9491
- branch: z.z.string(),
9492
- /**
9493
- * Whether a press of Publish is a publish JOB. Absent from a content
9494
- * service that predates connected jobs: then managed projects publish
9495
- * as jobs, and connected ones by commit, as they did.
9496
- */
9497
- publishJobs: z.z.boolean().optional()
9498
- }).optional()
9499
- });
9500
- const FilesResponse = z.z.object({
9501
- files: z.z.array(z.z.union([z.z.object({
9502
- filePath: z.z.string(),
9503
- location: z.z.literal("patch"),
9504
- patchId: PatchId,
9505
- value: z.z.string(),
9506
- remote: z.z.boolean()
9507
- }), z.z.object({
9508
- filePath: z.z.string(),
9509
- location: z.z.literal("repo"),
9510
- commitSha: CommitSha,
9511
- value: z.z.string()
9512
- })])),
9513
- errors: z.z.array(z.z.union([z.z.object({
9514
- filePath: z.z.string(),
9515
- location: z.z.literal("patch"),
9516
- patchId: PatchId,
9517
- message: z.z.string(),
9518
- remote: z.z.boolean()
9519
- }), z.z.object({
9520
- filePath: z.z.string(),
9521
- location: z.z.literal("repo"),
9522
- commitSha: CommitSha,
9523
- message: z.z.string()
9524
- })])).optional()
9525
- });
9526
- const SavePatchResponse = z.z.object({
9527
- patchId: PatchId,
9528
- /**
9529
- * Which group the content API put this patch in.
9530
- *
9531
- * Optional: a content API that predates patch groups does not send one, and
9532
- * absence has to keep meaning "no groups here" rather than failing the save.
9533
- */
9534
- patchGroupId: z.z.string().optional(),
9535
- /**
9536
- * The chain version this write made, comparable with `headVersion` on the
9537
- * listing. Optional: a content service that predates it sends nothing.
9538
- */
9539
- headVersion: z.z.number().optional()
9540
- });
9541
- const DeletePatchesResponse = z.z.object({
9542
- deleted: z.z.array(PatchId),
9543
- errors: z.z.array(z.z.object({
9544
- message: z.z.string(),
9545
- patchId: PatchId,
9546
- /** The publish job holding it, when that is why it was not deleted. */
9547
- publishJobId: z.z.string().optional()
9548
- })).optional(),
9549
- /**
9550
- * The chain version each branch the delete changed is now at, keyed by
9551
- * branch. Optional: a content service that predates it sends none.
9552
- */
9553
- headVersions: z.z.record(z.z.string(), z.z.number()).optional()
9554
- });
9555
9331
  /**
9556
- * This client's own branch's entry in a per-branch version map.
9557
- *
9558
- * A delete can span branches, so the content service answers per branch. With
9559
- * no branch of its own (a build with no repository, where the service resolves
9560
- * it), the only entry is ours when there is exactly one; with several, none can
9561
- * be picked, and a wrong one would floor this client at another chain's count.
9332
+ * A module's errors for this content: its own `validate` where the schema was
9333
+ * evaluated, custom rules and all; otherwise what its serialized schema says,
9334
+ * which is every rule but a custom `.validate(...)` (a closure does not
9335
+ * serialize).
9562
9336
  */
9563
- function ownBranchVersion(headVersions, branch) {
9564
- if (headVersions === undefined) return undefined;
9565
- if (branch !== undefined) return headVersions[branch];
9566
- const versions = Object.values(headVersions);
9567
- return versions.length === 1 ? versions[0] : undefined;
9337
+ function validateModule(moduleFilePath, module, source) {
9338
+ if (module.validate) return module.validate(source);
9339
+ return validateWith(core.deserializeSchema(module.schema), moduleFilePath, source);
9340
+ }
9341
+
9342
+ /** The errors of a module's own Source: as evaluated where they were, else found here. */
9343
+ function errorsOfSource(moduleFilePath, module) {
9344
+ return module.errors ?? validateModule(moduleFilePath, module, module.source);
9568
9345
  }
9569
- const SavePatchFileResponse = z.z.object({
9570
- patchId: PatchId,
9571
- filePath: ModuleFilePath
9572
- });
9573
- const CommitResponse = z.z.object({
9574
- updatedFiles: z.z.array(z.z.string()),
9575
- commit: CommitSha,
9576
- /*
9577
- * Optional because a content service that predates them sends neither, and
9578
- * this client talks to whichever one the project is on. Absent means NOT
9579
- * REPORTED: `parent: undefined` is not "a root commit", and a caller that
9580
- * reads it as one would conclude the history starts here.
9581
- *
9582
- * Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
9583
- * from a service that versions separately and are passed through as what it
9584
- * said, not as something this end vouched for.
9585
- */
9586
- parent: z.z.string().optional(),
9587
- tree: z.z.string().optional(),
9588
- branch: z.z.string(),
9589
- /** The chain version the publish moved the branch to; absent if it applied nothing. */
9590
- headVersion: z.z.number().optional()
9591
- });
9592
- // #region history wire schemas
9593
- //
9594
- // Validated on arrival rather than trusted: these come from a service that
9595
- // versions separately, and a silently mis-shaped commit record reads as "this
9596
- // commit changed nothing", which is indistinguishable from a real answer.
9597
- const HistoricalCommitResponse = z.z.object({
9598
- commitSha: z.z.string(),
9599
- /** `null` for a root commit; see the note on the applicable-patches schema. */
9600
- parentCommitSha: z.z.string().nullable(),
9601
- /** `null` when the publisher did not say where it was. */
9602
- clientCommitSha: z.z.string().nullable(),
9603
- branch: z.z.string(),
9604
- createdBranch: z.z.string().nullable(),
9605
- creator: z.z.string().nullable(),
9606
- message: z.z.string().nullable(),
9607
- createdAt: z.z.string(),
9608
- seqNum: z.z.string(),
9609
- patchCount: z.z.number(),
9610
- hasArchive: z.z.boolean()
9611
- });
9612
- const ListCommitsResponse = z.z.object({
9613
- commits: z.z.array(HistoricalCommitResponse),
9614
- nextCursor: z.z.string().nullable()
9615
- });
9616
- const CommitPatchesResponse = z.z.object({
9617
- commitSha: z.z.string(),
9618
- commit: z.z.object({
9619
- commitSha: z.z.string(),
9620
- parentCommitSha: z.z.string().nullable(),
9621
- clientCommitSha: z.z.string().nullable(),
9622
- branch: z.z.string(),
9623
- createdBranch: z.z.string().nullable(),
9624
- creator: z.z.string().nullable(),
9625
- message: z.z.string().nullable(),
9626
- createdAt: z.z.string(),
9627
- seqNum: z.z.string(),
9628
- hasArchive: z.z.boolean()
9629
- }),
9630
- patches: z.z.array(z.z.object({
9631
- patchId: z.z.string(),
9632
- path: z.z.string(),
9633
- patch: z.z.unknown(),
9634
- authorId: z.z.string().nullable(),
9635
- createdAt: z.z.string(),
9636
- baseSha: z.z.string(),
9637
- coreVersion: z.z.string()
9638
- }))
9639
- });
9640
9346
 
9641
9347
  /**
9642
- * `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
9643
- *
9644
- * `schema` stays `unknown` here on purpose. The content service stores it
9645
- * opaquely and cannot vouch for it, so validating it at the transport boundary
9348
+ * A `.jsonValues()` record's entry files, by key, read out of its `.val.ts`
9349
+ * (the loaded marker does not carry the path). `null` when it has none.
9350
+ */
9351
+ function entryFilesOf(moduleFilePath, text) {
9352
+ const analysis = valModuleOf(moduleFilePath, text);
9353
+ if (analysis === null) return null;
9354
+ const entries = analyzeJsonValuesEntries(analysis.source);
9355
+ if (entries.size === 0) return null;
9356
+ const dir = path__namespace["default"].posix.dirname(moduleFilePath.slice(1));
9357
+ return Object.fromEntries([...entries].map(([key, {
9358
+ importPath
9359
+ }]) => [key, path__namespace["default"].posix.join(dir, importPath)]));
9360
+ }
9361
+
9362
+ // --- code and content ------------------------------------------------------------
9363
+
9364
+ /**
9365
+ * A file's code: for a `.val.ts`, everything but `c.define`'s content
9366
+ * argument, which Val reads at request time (step 2) and so is no part of a
9367
+ * build; a `.val.json` entry is no code at all. Every other file is code
9368
+ * whole.
9369
+ */
9370
+ function codeOf(file, text) {
9371
+ // A `.jsonValues()` entry is content, read at request time like the rest.
9372
+ if (file.endsWith(".val.json")) return "";
9373
+ if (!file.endsWith(".val.ts")) return text;
9374
+ const analysis = valModuleOf(file, text);
9375
+ if (analysis === null) return text;
9376
+ const {
9377
+ source
9378
+ } = analysis;
9379
+ return text.slice(0, source.getStart()) + "/* content */" + text.slice(source.end);
9380
+ }
9381
+
9382
+ /**
9383
+ * A `.val.ts`'s `c.define(...)`, or `null` when it has none: a file of schemas
9384
+ * that pages share (`export const heroSchema = s.object(...)`) is named like a
9385
+ * module and is code. `analyzeValModule` throws for one; this does not.
9386
+ */
9387
+ function valModuleOf(file, text) {
9388
+ const sourceFile = ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2015, true);
9389
+ if (!sourceFile.statements.some(node => ts__default["default"].isExportAssignment(node))) {
9390
+ return null;
9391
+ }
9392
+ const analysis = analyzeValModule(sourceFile);
9393
+ return fp.result.isErr(analysis) ? null : analysis.value;
9394
+ }
9395
+
9396
+ /**
9397
+ * The three-way merge of a module's content: where both sides started
9398
+ * (`base`), and each side's version. Pure JSON -- the schema decides nothing
9399
+ * here, and is checked afterwards (the schema check in `merge.ts`).
9400
+ *
9401
+ * The rules (valbuild/home `docs/hosted-publishing.md` step 8.2):
9402
+ *
9403
+ * - Changed on one side: that side's.
9404
+ * - Changed on both: a conflict -- even to the same value, which is an easy
9405
+ * conflict to resolve but still one somebody decides.
9406
+ * - An object (a page, a record) merges key by key, so two keys changed on
9407
+ * two sides combine.
9408
+ * - A list merges item by item only when neither side changed its shape: no
9409
+ * item added, removed or moved. Otherwise positions mean different items on
9410
+ * the two sides, and the whole list is the conflict.
9411
+ */
9412
+
9413
+ function isObject$1(value) {
9414
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9415
+ }
9416
+ function same(a, b) {
9417
+ if (a === undefined || b === undefined) return a === b;
9418
+ return patch.deepEqual(a, b);
9419
+ }
9420
+
9421
+ /**
9422
+ * Did this side add, remove or move an item? Lengths differ, or an item now
9423
+ * sits where it was not, while it is still what used to be somewhere else.
9424
+ */
9425
+ function reshaped(base, side) {
9426
+ if (base.length !== side.length) return true;
9427
+ return side.some((item, at) => !same(item, base[at]) && base.some((was, from) => from !== at && same(item, was)));
9428
+ }
9429
+
9430
+ /**
9431
+ * Where a value is one value, though it is an object: a media value (its path
9432
+ * and what describes that file) or rich text. The schema says; see `merge.ts`.
9433
+ */
9434
+
9435
+ function mergeValues(base, site, proposal, atomic = () => false, path = []) {
9436
+ if (same(site, base)) return {
9437
+ value: proposal,
9438
+ conflicts: []
9439
+ };
9440
+ if (same(proposal, base)) return {
9441
+ value: site,
9442
+ conflicts: []
9443
+ };
9444
+ if (atomic(path)) {
9445
+ return {
9446
+ value: site,
9447
+ conflicts: [{
9448
+ path,
9449
+ base,
9450
+ site,
9451
+ proposal
9452
+ }]
9453
+ };
9454
+ }
9455
+ if (isObject$1(base) && isObject$1(site) && isObject$1(proposal)) {
9456
+ const keys = [...new Set([...Object.keys(site), ...Object.keys(proposal), ...Object.keys(base)])];
9457
+ const value = {};
9458
+ const conflicts = [];
9459
+ for (const key of keys) {
9460
+ const merged = mergeValues(base[key], site[key], proposal[key], atomic, [...path, key]);
9461
+ if (merged.value !== undefined) value[key] = merged.value;
9462
+ conflicts.push(...merged.conflicts);
9463
+ }
9464
+ return {
9465
+ value,
9466
+ conflicts
9467
+ };
9468
+ }
9469
+ if (Array.isArray(base) && Array.isArray(site) && Array.isArray(proposal) && !reshaped(base, site) && !reshaped(base, proposal)) {
9470
+ const value = [];
9471
+ const conflicts = [];
9472
+ base.forEach((item, at) => {
9473
+ const merged = mergeValues(item, site[at], proposal[at], atomic, [...path, String(at)]);
9474
+ // Neither side removed an item, so every position holds one.
9475
+ value.push(merged.value ?? null);
9476
+ conflicts.push(...merged.conflicts);
9477
+ });
9478
+ return {
9479
+ value,
9480
+ conflicts
9481
+ };
9482
+ }
9483
+ return {
9484
+ value: site,
9485
+ conflicts: [{
9486
+ path,
9487
+ base,
9488
+ site,
9489
+ proposal
9490
+ }]
9491
+ };
9492
+ }
9493
+
9494
+ /** `doc` with `value` at `path`; `undefined` removes it. `doc` is not changed. */
9495
+ function setAt(doc, path, value) {
9496
+ if (path.length === 0) return value;
9497
+ const [head, ...rest] = path;
9498
+ if (Array.isArray(doc)) {
9499
+ const copy = [...doc];
9500
+ const at = Number(head);
9501
+ const next = setAt(copy[at], rest, value);
9502
+ if (next === undefined) copy.splice(at, 1);else copy[at] = next;
9503
+ return copy;
9504
+ }
9505
+ const copy = isObject$1(doc) ? {
9506
+ ...doc
9507
+ } : {};
9508
+ const next = setAt(copy[head], rest, value);
9509
+ if (next === undefined) delete copy[head];else copy[head] = next;
9510
+ return copy;
9511
+ }
9512
+ function valueAt(doc, path) {
9513
+ let current = doc;
9514
+ for (const key of path) {
9515
+ if (Array.isArray(current)) current = current[Number(key)];else if (isObject$1(current)) current = current[key];else return undefined;
9516
+ }
9517
+ return current;
9518
+ }
9519
+
9520
+ /**
9521
+ * The Val patch that turns `from` into `to`, as small as it can be: what
9522
+ * writes a merge into a `.val.ts` without rewriting what did not change.
9523
+ */
9524
+ function diffOps(from, to, path = []) {
9525
+ if (same(from, to)) return [];
9526
+ if (isObject$1(from) && isObject$1(to)) {
9527
+ const ops = [];
9528
+ for (const key of Object.keys(from)) {
9529
+ if (!(key in to)) ops.push({
9530
+ op: "remove",
9531
+ path: below(path, key)
9532
+ });
9533
+ }
9534
+ for (const [key, value] of Object.entries(to)) {
9535
+ ops.push(...(key in from ? diffOps(from[key], value, [...path, key]) : [add([...path, key], value)]));
9536
+ }
9537
+ return ops;
9538
+ }
9539
+ if (Array.isArray(from) && Array.isArray(to) && from.length === to.length) {
9540
+ return from.flatMap((item, at) => diffOps(item, to[at], [...path, String(at)]));
9541
+ }
9542
+ return [{
9543
+ op: "replace",
9544
+ path,
9545
+ value: to ?? null
9546
+ }];
9547
+ }
9548
+ function add(path, value) {
9549
+ return {
9550
+ op: "add",
9551
+ path,
9552
+ value
9553
+ };
9554
+ }
9555
+ function below(path, key) {
9556
+ return path.length === 0 ? [key] : [path[0], ...path.slice(1), key];
9557
+ }
9558
+
9559
+ /**
9560
+ * Publishing a proposal as a pull request is merged: valbuild/home
9561
+ * `docs/hosted-publishing.md`, step 8.2.
9562
+ *
9563
+ * Val's half: the two answers that need a schema. Content gets them as files
9564
+ * and change ids and carries them out, never looking at a field, a list or a
9565
+ * patch set.
9566
+ *
9567
+ * - {@link mergeFromSite} -- Update from the site: the merge commit `M`.
9568
+ * - {@link changesToMove} -- which of the site's pending changes leave the
9569
+ * site when a proposal is merged.
9570
+ *
9571
+ * `mergeScenarios.test.ts` is their specification.
9572
+ */
9573
+
9574
+ /** A field both sides changed since the proposal started. */
9575
+
9576
+ /**
9577
+ * Code both sides changed: a component, `val.modules.ts`, or the schema of a
9578
+ * `.val.ts` (its code -- everything but `c.define`'s content argument, whose
9579
+ * fields merge on their own).
9580
+ */
9581
+
9582
+ /**
9583
+ * The schema check: content that merged cleanly and is not valid under the
9584
+ * schema the merge ends with -- an entry the site added, say, under a field
9585
+ * whose type the proposal changed.
9586
+ */
9587
+
9588
+ /** How a conflict is resolved: keep one side, or write it -- as AI or a developer does. */
9589
+
9590
+ /**
9591
+ * Update from the site: a three-way merge of where the proposal started
9592
+ * (`base`), the site's head and the proposal's last save, written whole.
9593
+ * Every conflict needs a resolution, or nothing is merged.
9594
+ *
9595
+ * File by file. A file that is not a `.val.ts` is code, and merges as a
9596
+ * file: changed on one side, that side's; on both, a conflict. A `.val.ts`
9597
+ * is code (everything but `c.define`'s content argument) and content, and the
9598
+ * two merge apart: the code as a file, the content field by field
9599
+ * (`mergeValues`). So a schema the proposal changed and a field the site
9600
+ * published in the same file do not conflict. A code conflict in a `.val.ts`
9601
+ * is resolved by keeping a side's code -- the content still merges -- or by
9602
+ * writing the whole file.
9603
+ *
9604
+ * Not here: the schema check ({@link schemaCheck}). It needs the merged
9605
+ * files' schemas, so whoever can evaluate them -- Val's loader, the builder
9606
+ * tab -- runs it on `files` before the merge is committed.
9607
+ */
9608
+ function mergeFromSite(input) {
9609
+ const {
9610
+ base,
9611
+ site,
9612
+ proposal,
9613
+ resolutions
9614
+ } = input;
9615
+ const files = {};
9616
+ const modules = {};
9617
+ const conflicts = [];
9618
+ const paths = [...new Set([...Object.keys(base.files), ...Object.keys(site.files), ...Object.keys(proposal.files)])].sort();
9619
+ // A `.jsonValues()` entry is written with its module, never on its own.
9620
+ const entryFiles = new Set([base, site, proposal].flatMap(side => Object.values(side.modules).flatMap(module => Object.values(module.entries ?? {}))));
9621
+ for (const file of paths) {
9622
+ var _moduleOf3, _moduleOf4, _moduleOf5, _moduleOf6;
9623
+ if (entryFiles.has(file)) continue;
9624
+ const b = base.files[file] ?? null;
9625
+ const s = site.files[file] ?? null;
9626
+ const p = proposal.files[file] ?? null;
9627
+ const resolution = fileResolution(resolutions, file);
9628
+ const codeConflict = () => ({
9629
+ kind: "code",
9630
+ file,
9631
+ base: b,
9632
+ site: s,
9633
+ proposal: p
9634
+ });
9635
+
9636
+ // A file named like a module that no side defines as one -- schemas the
9637
+ // pages share -- is code.
9638
+ const moduleFilePath = `/${file}`;
9639
+ const isModule = [base, site, proposal].some(side => side.modules[moduleFilePath] !== undefined);
9640
+ if (!file.endsWith(".val.ts") || !isModule) {
9641
+ if (s === b) files[file] = p;else if (p === b) files[file] = s;else if (resolution) files[file] = resolvedText(resolution, s, p);else {
9642
+ conflicts.push(codeConflict());
9643
+ files[file] = s;
9644
+ }
9645
+ continue;
9646
+ }
9647
+
9648
+ // A .val.ts: whose code, then the content under it.
9649
+ const code = text => text === null ? null : codeOf(file, text);
9650
+ let side;
9651
+ if (code(s) === code(b)) side = "proposal";else if (code(p) === code(b)) side = "site";else if (resolution && "text" in resolution) {
9652
+ files[file] = resolution.text;
9653
+ continue;
9654
+ } else if (resolution) side = resolution.keep;else {
9655
+ conflicts.push(codeConflict());
9656
+ files[file] = s;
9657
+ continue;
9658
+ }
9659
+ const moduleOf = project => project.modules[moduleFilePath];
9660
+ const sourceOf = project => {
9661
+ var _moduleOf;
9662
+ return (_moduleOf = moduleOf(project)) === null || _moduleOf === void 0 ? void 0 : _moduleOf.source;
9663
+ };
9664
+ const chosen = side === "site" ? site : proposal;
9665
+ const text = side === "site" ? s : p;
9666
+ if (text === null) {
9667
+ // That side deleted the file. Content the other side changed would go
9668
+ // with it: that is for a person to decide -- unless they have.
9669
+ const other = side === "site" ? p : s;
9670
+ if (!resolution && other !== b) {
9671
+ conflicts.push(codeConflict());
9672
+ }
9673
+ files[file] = null;
9674
+ for (const project of [base, site, proposal]) {
9675
+ for (const entry of Object.values(((_moduleOf2 = moduleOf(project)) === null || _moduleOf2 === void 0 ? void 0 : _moduleOf2.entries) ?? {})) {
9676
+ var _moduleOf2;
9677
+ files[entry] = null;
9678
+ }
9679
+ }
9680
+ continue;
9681
+ }
9682
+ // What the chosen text holds, which the merge is written over.
9683
+ const from = ((_moduleOf3 = moduleOf(chosen)) === null || _moduleOf3 === void 0 ? void 0 : _moduleOf3.written) ?? ((_moduleOf4 = moduleOf(chosen)) === null || _moduleOf4 === void 0 ? void 0 : _moduleOf4.source);
9684
+ const merged = mergeValues(sourceOf(base), sourceOf(site), sourceOf(proposal), atomicFor((_moduleOf5 = moduleOf(chosen)) === null || _moduleOf5 === void 0 ? void 0 : _moduleOf5.schema));
9685
+ const resolved = resolveContent(moduleFilePath, merged, resolutions);
9686
+ conflicts.push(...resolved.unresolved);
9687
+ const content = resolved.content;
9688
+ modules[moduleFilePath] = content;
9689
+ const entries = (_moduleOf6 = moduleOf(chosen)) === null || _moduleOf6 === void 0 ? void 0 : _moduleOf6.entries;
9690
+ if (entries) {
9691
+ var _moduleOf7, _moduleOf8;
9692
+ 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]));
9693
+ } else {
9694
+ files[file] = writeContent(file, text, from, content);
9695
+ }
9696
+ }
9697
+ if (conflicts.length > 0) return {
9698
+ status: "conflicts",
9699
+ conflicts
9700
+ };
9701
+ return {
9702
+ status: "merged",
9703
+ files,
9704
+ modules,
9705
+ code: Object.keys(files).filter(file => {
9706
+ const text = files[file];
9707
+ const before = site.files[file] ?? null;
9708
+ if (text === null || before === null) return text !== before;
9709
+ return codeOf(file, text) !== codeOf(file, before);
9710
+ })
9711
+ };
9712
+ }
9713
+
9714
+ // --- the pieces --------------------------------------------------------------
9715
+
9716
+ /**
9717
+ * A module's merged content with the resolutions written in: each field
9718
+ * conflict that has one, and any value written for the schema check (or
9719
+ * anywhere else) as given. What is left is what still needs a person.
9720
+ */
9721
+ function resolveContent(moduleFilePath, merged, resolutions) {
9722
+ let content = merged.value;
9723
+ const unresolved = [];
9724
+ for (const conflict of merged.conflicts) {
9725
+ const resolved = fieldResolution(resolutions, moduleFilePath, conflict);
9726
+ if (resolved === null) {
9727
+ unresolved.push({
9728
+ kind: "field",
9729
+ moduleFilePath,
9730
+ ...conflict
9731
+ });
9732
+ } else {
9733
+ content = setAt(content, conflict.path, resolved.value);
9734
+ }
9735
+ }
9736
+ for (const resolution of resolutions) {
9737
+ if ("moduleFilePath" in resolution && resolution.moduleFilePath === moduleFilePath && "value" in resolution && !merged.conflicts.some(c => samePath(c.path, resolution.path))) {
9738
+ content = setAt(content, resolution.path, resolution.value);
9739
+ }
9740
+ }
9741
+ return {
9742
+ content,
9743
+ unresolved
9744
+ };
9745
+ }
9746
+
9747
+ /**
9748
+ * The schema check of a merge: content in `merged` -- the project
9749
+ * {@link mergeFromSite} wrote, with its schemas evaluated -- that is invalid
9750
+ * under the schema it ends with, leaving out what was already invalid on a
9751
+ * side. A conflict, each; a resolution with a value for one is written into
9752
+ * the content by the merge, and so is checked here.
9753
+ */
9754
+ function schemaCheck(merged, sides) {
9755
+ const conflicts = [];
9756
+ for (const [path, module] of Object.entries(merged.modules)) {
9757
+ const moduleFilePath = path;
9758
+ const known = new Set(sides.flatMap(side => {
9759
+ const there = side.modules[moduleFilePath];
9760
+ return there ? errorsOfSource(moduleFilePath, there).map(e => e.path.join("/")) : [];
9761
+ }));
9762
+ for (const error of errorsOfSource(moduleFilePath, module)) {
9763
+ if (known.has(error.path.join("/"))) continue;
9764
+ conflicts.push({
9765
+ kind: "invalid",
9766
+ moduleFilePath,
9767
+ path: error.path,
9768
+ value: valueAt(module.source, error.path),
9769
+ message: error.message
9770
+ });
9771
+ }
9772
+ }
9773
+ return conflicts;
9774
+ }
9775
+
9776
+ /**
9777
+ * The values that merge whole, though they are objects: media (an image's
9778
+ * alt text describes that image, not another) and rich text.
9779
+ */
9780
+ const ATOMIC = new Set(["image", "file", "video", "font", "richtext"]);
9781
+
9782
+ /** See {@link ATOMIC}: where this schema holds a value that merges whole. */
9783
+ function atomicFor(schema) {
9784
+ if (!schema) return () => false;
9785
+ return path => {
9786
+ const types = internal.schemaTypesOfPath(schema, path);
9787
+ return [...types].some(type => ATOMIC.has(type));
9788
+ };
9789
+ }
9790
+ const tsOps = new TSOps(document => fp.pipe(analyzeValModule(document), fp.result.map(({
9791
+ source
9792
+ }) => source)));
9793
+
9794
+ /**
9795
+ * `content` written into the `.val.ts` `text`, whose content is `from`: as
9796
+ * the smallest patch, through the same AST operations a save uses, so what
9797
+ * did not change is left exactly as it was.
9798
+ */
9799
+ function writeContent(file, text, from, content) {
9800
+ const ops = diffOps(from, content);
9801
+ if (ops.length === 0) return text;
9802
+ const sourceFile = ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2015, true);
9803
+ return printed(unwrap(file, patch.applyPatch(sourceFile, tsOps, ops)));
9804
+ }
9805
+
9806
+ /**
9807
+ * A `.jsonValues()` module, written as its files: the `.val.ts` names the
9808
+ * entries -- one added gets a `c.json(...)` line, as a save writes it, one
9809
+ * removed loses its line -- and each entry is its own `.val.json`. An entry
9810
+ * whose content did not change keeps its file's text exactly.
9811
+ */
9812
+ function writeEntries(file, text, chosenFiles, entries, from, content, others) {
9813
+ const written = {};
9814
+ const before = isRecord$4(from) ? from : {};
9815
+ const after = isRecord$4(content) ? content : {};
9816
+ const moduleDir = path__namespace["default"].posix.dirname(file);
9817
+ let sourceFile = ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2015, true);
9818
+ for (const key of Object.keys(before)) {
9819
+ if (key in after) continue;
9820
+ sourceFile = unwrap(file, removeValJsonEntry(sourceFile, [], key));
9821
+ written[entries[key]] = null;
9822
+ }
9823
+ for (const [key, value] of Object.entries(after)) {
9824
+ const entryFile = entries[key] ?? others.map(other => other === null || other === void 0 ? void 0 : other[key]).find(f => f !== undefined) ?? path__namespace["default"].posix.join(moduleDir, path__namespace["default"].posix.basename(file, ".val.ts"), `${key}.val.json`);
9825
+ if (!(key in before)) {
9826
+ const importPath = `./${path__namespace["default"].posix.relative(moduleDir, entryFile)}`;
9827
+ sourceFile = unwrap(file, insertValJsonEntry(sourceFile, [], key, importPath));
9828
+ }
9829
+ const kept = chosenFiles[entryFile];
9830
+ written[entryFile] = key in before && diffOps(before[key], value).length === 0 && kept ? kept : JSON.stringify(value, null, 2);
9831
+ }
9832
+ written[file] = printed(sourceFile);
9833
+ return written;
9834
+ }
9835
+ function isRecord$4(value) {
9836
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9837
+ }
9838
+ function unwrap(file, written) {
9839
+ if (fp.result.isErr(written)) {
9840
+ throw new Error(`could not write the merge into ${file}: ${String(written.error)}`);
9841
+ }
9842
+ return written.value;
9843
+ }
9844
+
9845
+ /** As `prepare` prints: TypeScript escapes non-ASCII as \\u sequences. */
9846
+ function printed(sourceFile) {
9847
+ return unescape(sourceFile.getText(sourceFile).replace(/\\u/g, "%u"));
9848
+ }
9849
+ function fileResolution(resolutions, file) {
9850
+ for (const resolution of resolutions) {
9851
+ if ("file" in resolution && resolution.file === file) return resolution;
9852
+ }
9853
+ return null;
9854
+ }
9855
+ function resolvedText(resolution, site, proposal) {
9856
+ if ("text" in resolution) return resolution.text;
9857
+ return resolution.keep === "site" ? site : proposal;
9858
+ }
9859
+
9860
+ /** A field conflict's resolution, as the value it leaves; `null` when it has none. */
9861
+ function fieldResolution(resolutions, moduleFilePath, conflict) {
9862
+ for (const resolution of resolutions) {
9863
+ if (!("moduleFilePath" in resolution) || resolution.moduleFilePath !== moduleFilePath || !samePath(resolution.path, conflict.path)) {
9864
+ continue;
9865
+ }
9866
+ if ("value" in resolution) return {
9867
+ value: resolution.value
9868
+ };
9869
+ return {
9870
+ value: resolution.keep === "site" ? conflict.site : conflict.proposal
9871
+ };
9872
+ }
9873
+ return null;
9874
+ }
9875
+ function samePath(a, b) {
9876
+ return a.length === b.length && a.every((key, at) => key === b[at]);
9877
+ }
9878
+
9879
+ /**
9880
+ * Update from the site when only content changed (valbuild/home
9881
+ * `docs/hosted-publishing.md` step 8.2, slice 1a): the proposal staged no code
9882
+ * and the site still serves the build the proposal stands on. Then all three
9883
+ * sides share one schema -- the running build's -- so the merge needs no
9884
+ * project loaded, which is what lets Val's server at a proposal's address run
9885
+ * it (it cannot evaluate a `.val.ts`).
9886
+ *
9887
+ * The same rules as {@link mergeFromSite}, module by module: field by field,
9888
+ * the same value on both sides a conflict, media and rich text whole. Content
9889
+ * hands over each module the proposal saved, as it was where the proposal
9890
+ * started, as the site has it, and as the proposal saved it; the merge is
9891
+ * written into the proposal's own `.val.ts` text with the same AST operations
9892
+ * a save uses.
9893
+ *
9894
+ * Not here: a `.jsonValues()` record the site changed. Its Source in content's
9895
+ * index holds only the entries a commit touched, so it cannot be merged from
9896
+ * the index, and its entries are `.val.json` files the `.val.ts` only names,
9897
+ * which writing into the `.val.ts` cannot change. That waits for the full
9898
+ * merge (`writeEntries`), and is answered as such -- with or without a
9899
+ * conflict, since a clean merge still has the site's entries to write.
9900
+ */
9901
+
9902
+ function mergeContent(input) {
9903
+ const conflicts = [];
9904
+ const files = {};
9905
+ const modules = {};
9906
+ const needsFullMerge = [];
9907
+ for (const path of Object.keys(input.modules).sort()) {
9908
+ const moduleFilePath = path;
9909
+ const {
9910
+ base,
9911
+ site,
9912
+ proposal,
9913
+ text
9914
+ } = input.modules[moduleFilePath];
9915
+ const schema = input.schemas[moduleFilePath];
9916
+ if (!schema) {
9917
+ // The build has no such module: its code changed, so this is not a
9918
+ // content-only update.
9919
+ needsFullMerge.push(moduleFilePath);
9920
+ continue;
9921
+ }
9922
+ const serialized = schema["executeSerialize"]();
9923
+ const orUndefined = value => value ?? undefined;
9924
+ const merged = mergeValues(orUndefined(base), orUndefined(site), orUndefined(proposal), atomicFor(serialized));
9925
+ if (serialized.type === "record" && serialized.jsonValues && (merged.conflicts.length > 0 || diffOps(orUndefined(proposal), merged.value).length > 0)) {
9926
+ needsFullMerge.push(moduleFilePath);
9927
+ continue;
9928
+ }
9929
+ const resolved = resolveContent(moduleFilePath, merged, input.resolutions);
9930
+ conflicts.push(...resolved.unresolved);
9931
+ if (resolved.unresolved.length > 0) continue;
9932
+
9933
+ // The schema check, under the one schema there is: what was already
9934
+ // invalid on a side is not the merge's doing. A value written as a
9935
+ // resolution is already in `content`, and checked like the rest -- as
9936
+ // `schemaCheck` does for the full merge.
9937
+ const known = new Set([site, proposal].flatMap(side => validateWith(schema, moduleFilePath, orUndefined(side)).map(e => e.path.join("/"))));
9938
+ const content = resolved.content;
9939
+ for (const error of validateWith(schema, moduleFilePath, content)) {
9940
+ if (known.has(error.path.join("/"))) continue;
9941
+ conflicts.push({
9942
+ kind: "invalid",
9943
+ moduleFilePath,
9944
+ path: error.path,
9945
+ value: valueAt(content, error.path),
9946
+ message: error.message
9947
+ });
9948
+ }
9949
+ const file = moduleFilePath.slice(1);
9950
+ files[file] = writeContent(file, text, orUndefined(proposal), content);
9951
+ modules[moduleFilePath] = {
9952
+ source: content ?? null,
9953
+ schema: serialized
9954
+ };
9955
+ }
9956
+ if (needsFullMerge.length > 0) {
9957
+ return {
9958
+ status: "needs-full-merge",
9959
+ modules: needsFullMerge
9960
+ };
9961
+ }
9962
+ if (conflicts.length > 0) return {
9963
+ status: "conflicts",
9964
+ conflicts
9965
+ };
9966
+ return {
9967
+ status: "merged",
9968
+ files,
9969
+ modules
9970
+ };
9971
+ }
9972
+
9973
+ /**
9974
+ * Update from the site, for a proposal with code (valbuild/home
9975
+ * `docs/hosted-publishing.md` step 8.2, slice 1b): Val's server's half.
9976
+ *
9977
+ * Nothing here runs project code. Content hands over the three trees and each
9978
+ * module's Source in each (`merge-trees`); the builder tab evaluates each
9979
+ * tree's `val.modules.ts` -- the site's own dependencies, from its vendor
9980
+ * layer -- and sends what that gave: each module's schema, the content its
9981
+ * text holds, and the errors of its Source under that schema. This does the
9982
+ * text, with the TypeScript Val's server already has.
9983
+ *
9984
+ * In two calls, because the schema check needs the merged tree evaluated too:
9985
+ *
9986
+ * 1. Without `merged`: the merge `M`, as the files it changes over the site
9987
+ * and each module's content -- for the tab to evaluate -- under an `id`.
9988
+ * Or the conflicts, for a person.
9989
+ * 2. With `merged`, the tab's evaluation of that `id`: the schema check, then
9990
+ * what content commits. The merge is made again rather than taken from the
9991
+ * tab, and must come out as the same `id`.
9992
+ */
9993
+
9994
+ function updateWithCode(input) {
9995
+ const {
9996
+ trees,
9997
+ evaluated,
9998
+ resolutions
9999
+ } = input;
10000
+ const projectOf = side => {
10001
+ const files = internal.sideFiles(trees, side);
10002
+ return {
10003
+ files,
10004
+ modules: modulesOf(files, evaluated[side], path => internal.sideSource(trees, side, path))
10005
+ };
10006
+ };
10007
+ const base = projectOf("base");
10008
+ const site = projectOf("site");
10009
+ const proposal = projectOf("proposal");
10010
+ const result = mergeFromSite({
10011
+ base,
10012
+ site,
10013
+ proposal,
10014
+ resolutions
10015
+ });
10016
+ if (result.status === "conflicts") return result;
10017
+ const changes = {};
10018
+ for (const [path, text] of Object.entries(result.files)) {
10019
+ if (text !== (site.files[path] ?? null)) changes[path] = text;
10020
+ }
10021
+ const id = mergeId(changes, result.modules);
10022
+ if (input.merged === undefined) {
10023
+ const sources = {};
10024
+ for (const [path, content] of Object.entries(result.modules)) {
10025
+ sources[path] = content ?? null;
10026
+ }
10027
+ return {
10028
+ status: "evaluate",
10029
+ id,
10030
+ files: changes,
10031
+ sources
10032
+ };
10033
+ }
10034
+ if (input.merged.id !== id) return {
10035
+ status: "stale"
10036
+ };
10037
+ const mergedFiles = {
10038
+ ...site.files,
10039
+ ...changes
10040
+ };
10041
+ const mergedProject = {
10042
+ files: mergedFiles,
10043
+ modules: modulesOf(Object.fromEntries(Object.entries(mergedFiles).filter(entry => entry[1] !== null)), input.merged.evaluated, path => result.modules[path] ?? null)
10044
+ };
10045
+ const invalid = schemaCheck(mergedProject, [site, proposal]);
10046
+ if (invalid.length > 0) return {
10047
+ status: "conflicts",
10048
+ conflicts: invalid
10049
+ };
10050
+
10051
+ // The paths the proposal held go back to what the merge left -- the site's,
10052
+ // often -- so nothing it held before is left standing over the new base.
10053
+ const files = {
10054
+ ...changes
10055
+ };
10056
+ for (const path of Object.keys(trees.trees.proposal)) {
10057
+ if (!(path in files)) files[path] = mergedFiles[path] ?? null;
10058
+ }
10059
+ const modules = {};
10060
+ for (const [path, module] of Object.entries(mergedProject.modules)) {
10061
+ var _trees$modules$module, _trees$modules$module2;
10062
+ const moduleFilePath = path;
10063
+ const onSite = site.modules[moduleFilePath];
10064
+ 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);
10065
+ if (touched || onSite === undefined || !sameJson(onSite.source, module.source) || !sameJson(onSite.schema, module.schema)) {
10066
+ modules[moduleFilePath] = {
10067
+ source: module.source,
10068
+ schema: module.schema
10069
+ };
10070
+ }
10071
+ }
10072
+ return {
10073
+ status: "merged",
10074
+ files,
10075
+ staged: result.code.filter(path => path in files),
10076
+ modules
10077
+ };
10078
+ }
10079
+ function modulesOf(files, evaluated, sourceOf) {
10080
+ const modules = {};
10081
+ for (const [path, module] of Object.entries(evaluated)) {
10082
+ const moduleFilePath = path;
10083
+ const text = files[moduleFilePath.slice(1)];
10084
+ modules[moduleFilePath] = {
10085
+ // A module the index has no Source for is one this tree's code adds:
10086
+ // its text is all there is.
10087
+ source: sourceOf(moduleFilePath) ?? module.written,
10088
+ written: module.written,
10089
+ schema: module.schema,
10090
+ entries: text === undefined ? null : entryFilesOf(moduleFilePath, text),
10091
+ errors: module.errors
10092
+ };
10093
+ }
10094
+ return modules;
10095
+ }
10096
+
10097
+ /** The merge, named by what it is: the files it changes and the content it writes. */
10098
+ function mergeId(files, modules) {
10099
+ const sorted = record => Object.keys(record).sort().map(key => [key, record[key] ?? null]);
10100
+ return crypto$1.createHash("sha256").update("val.update-with-code.v1\n").update(JSON.stringify({
10101
+ files: sorted(files),
10102
+ modules: sorted(modules)
10103
+ })).digest("hex");
10104
+ }
10105
+ function sameJson(a, b) {
10106
+ return JSON.stringify(a) === JSON.stringify(b);
10107
+ }
10108
+
10109
+ const textEncoder$2 = new TextEncoder();
10110
+
10111
+ /** The body of the content API's 403 for a write the project's plan refuses. */
10112
+ const PlanRefusedBody = z.z.object({
10113
+ error: internal.PlanRefusalError
10114
+ });
10115
+ const PatchId = z.z.string().refine(s => !!s); // TODO: validate
10116
+ const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
10117
+ z.z.string().refine(s => !!s); // TODO: validate
10118
+ z.z.string().refine(s => !!s); // TODO: validate
10119
+ const ModuleFilePath = z.z.string().refine(s => !!s); // TODO: validate
10120
+ const Metadata = z.z.union([z.z.object({
10121
+ mimeType: z.z.string(),
10122
+ width: z.z.number(),
10123
+ height: z.z.number()
10124
+ }), z.z.object({
10125
+ mimeType: z.z.string()
10126
+ })]);
10127
+ const MetadataRes = z.z.object({
10128
+ filePath: ModuleFilePath,
10129
+ metadata: Metadata,
10130
+ type: z.z.union([z.z.literal("file"), z.z.literal("image")]).nullable()
10131
+ });
10132
+ const GetApplicablePatches = z.z.object({
10133
+ patches: z.z.array(z.z.object({
10134
+ path: z.z.string(),
10135
+ patch: internal.Patch.nullable(),
10136
+ patchId: z.z.string(),
10137
+ authorId: z.z.string().nullable(),
10138
+ baseSha: z.z.string(),
10139
+ createdAt: z.z.string(),
10140
+ applied: z.z.object({
10141
+ commitSha: z.z.string()
10142
+ }).nullable()
10143
+ })),
10144
+ /**
10145
+ * The head of the chain. See `OrderedPatches.headPatchId`. Optional because
10146
+ * a content service that predates it sends nothing.
10147
+ */
10148
+ headPatchId: internal.PatchId.nullable().optional(),
10149
+ /**
10150
+ * The chain version of that head. See `OrderedPatches.headVersion`.
10151
+ * Optional for the same reason.
10152
+ */
10153
+ headVersion: z.z.number().optional(),
10154
+ /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
10155
+ publishingPatches: z.z.array(internal.PatchId).optional(),
10156
+ /** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
10157
+ heldBackPatches: z.z.array(internal.PatchId).optional(),
10158
+ commits: z.z.array(z.z.object({
10159
+ commitSha: z.z.string(),
10160
+ /*
10161
+ * Nullable since the content service mints its own commit shas: a
10162
+ * root commit has no parent, and a publisher that did not say where
10163
+ * it was has no client sha. Parsed rather than trusted, so a service
10164
+ * that sends null gets null here instead of failing the whole poll.
10165
+ */
10166
+ clientCommitSha: z.z.string().nullable(),
10167
+ parentCommitSha: z.z.string().nullable(),
10168
+ commitMessage: z.z.string().nullable(),
10169
+ branch: z.z.string(),
10170
+ creator: z.z.string(),
10171
+ createdAt: z.z.string()
10172
+ })).optional(),
10173
+ deployments: z.z.array(z.z.object({
10174
+ deploymentId: z.z.string(),
10175
+ commitSha: z.z.string(),
10176
+ deploymentState: z.z.string(),
10177
+ createdAt: z.z.string(),
10178
+ updatedAt: z.z.string(),
10179
+ // The git message, where the content service knows it. See
10180
+ // `ValDeployment`: this is the only source for a deployment that Val
10181
+ // did not publish, and zod would otherwise strip it here.
10182
+ commitMessage: z.z.string().nullable().optional()
10183
+ })).optional(),
10184
+ /**
10185
+ * What the project EXPECTS of whoever publishes it.
10186
+ *
10187
+ * Optional because a content service that predates it sends nothing, and
10188
+ * absent means "not reported" -- never "managed". The difference decides
10189
+ * whether a publish is refused, so guessing either way would be worse than
10190
+ * not checking: guessing `managed` would let a deployment publish a
10191
+ * connected project it cannot mirror, and guessing `connected` would refuse
10192
+ * every publish against an older service.
10193
+ */
10194
+ project: z.z.object({
10195
+ sourceMode: z.z.union([z.z.literal("managed"), z.z.literal("connected")]),
10196
+ branch: z.z.string(),
10197
+ /**
10198
+ * Whether a press of Publish is a publish JOB. Absent from a content
10199
+ * service that predates connected jobs: then managed projects publish
10200
+ * as jobs, and connected ones by commit, as they did.
10201
+ */
10202
+ publishJobs: z.z.boolean().optional()
10203
+ }).optional()
10204
+ });
10205
+ const FilesResponse = z.z.object({
10206
+ files: z.z.array(z.z.union([z.z.object({
10207
+ filePath: z.z.string(),
10208
+ location: z.z.literal("patch"),
10209
+ patchId: PatchId,
10210
+ value: z.z.string(),
10211
+ remote: z.z.boolean()
10212
+ }), z.z.object({
10213
+ filePath: z.z.string(),
10214
+ location: z.z.literal("repo"),
10215
+ commitSha: CommitSha,
10216
+ value: z.z.string()
10217
+ })])),
10218
+ errors: z.z.array(z.z.union([z.z.object({
10219
+ filePath: z.z.string(),
10220
+ location: z.z.literal("patch"),
10221
+ patchId: PatchId,
10222
+ message: z.z.string(),
10223
+ remote: z.z.boolean()
10224
+ }), z.z.object({
10225
+ filePath: z.z.string(),
10226
+ location: z.z.literal("repo"),
10227
+ commitSha: CommitSha,
10228
+ message: z.z.string()
10229
+ })])).optional()
10230
+ });
10231
+ const SavePatchResponse = z.z.object({
10232
+ patchId: PatchId,
10233
+ /**
10234
+ * The chain version this write made, comparable with `headVersion` on the
10235
+ * listing. Optional: a content service that predates it sends nothing.
10236
+ */
10237
+ headVersion: z.z.number().optional()
10238
+ });
10239
+ const DeletePatchesResponse = z.z.object({
10240
+ deleted: z.z.array(PatchId),
10241
+ errors: z.z.array(z.z.object({
10242
+ message: z.z.string(),
10243
+ patchId: PatchId,
10244
+ /** The publish job holding it, when that is why it was not deleted. */
10245
+ publishJobId: z.z.string().optional()
10246
+ })).optional(),
10247
+ /**
10248
+ * The chain version each branch the delete changed is now at, keyed by
10249
+ * branch. Optional: a content service that predates it sends none.
10250
+ */
10251
+ headVersions: z.z.record(z.z.string(), z.z.number()).optional()
10252
+ });
10253
+ /**
10254
+ * This client's own branch's entry in a per-branch version map.
10255
+ *
10256
+ * A delete can span branches, so the content service answers per branch. With
10257
+ * no branch of its own (a build with no repository, where the service resolves
10258
+ * it), the only entry is ours when there is exactly one; with several, none can
10259
+ * be picked, and a wrong one would floor this client at another chain's count.
10260
+ */
10261
+ function ownBranchVersion(headVersions, branch) {
10262
+ if (headVersions === undefined) return undefined;
10263
+ if (branch !== undefined) return headVersions[branch];
10264
+ const versions = Object.values(headVersions);
10265
+ return versions.length === 1 ? versions[0] : undefined;
10266
+ }
10267
+ const SavePatchFileResponse = z.z.object({
10268
+ patchId: PatchId,
10269
+ filePath: ModuleFilePath
10270
+ });
10271
+ const CommitResponse = z.z.object({
10272
+ updatedFiles: z.z.array(z.z.string()),
10273
+ commit: CommitSha,
10274
+ /*
10275
+ * Optional because a content service that predates them sends neither, and
10276
+ * this client talks to whichever one the project is on. Absent means NOT
10277
+ * REPORTED: `parent: undefined` is not "a root commit", and a caller that
10278
+ * reads it as one would conclude the history starts here.
10279
+ *
10280
+ * Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
10281
+ * from a service that versions separately and are passed through as what it
10282
+ * said, not as something this end vouched for.
10283
+ */
10284
+ parent: z.z.string().optional(),
10285
+ tree: z.z.string().optional(),
10286
+ branch: z.z.string(),
10287
+ /** The chain version the publish moved the branch to; absent if it applied nothing. */
10288
+ headVersion: z.z.number().optional()
10289
+ });
10290
+ // #region history wire schemas
10291
+ //
10292
+ // Validated on arrival rather than trusted: these come from a service that
10293
+ // versions separately, and a silently mis-shaped commit record reads as "this
10294
+ // commit changed nothing", which is indistinguishable from a real answer.
10295
+ const HistoricalCommitResponse = z.z.object({
10296
+ commitSha: z.z.string(),
10297
+ /** `null` for a root commit; see the note on the applicable-patches schema. */
10298
+ parentCommitSha: z.z.string().nullable(),
10299
+ /** `null` when the publisher did not say where it was. */
10300
+ clientCommitSha: z.z.string().nullable(),
10301
+ branch: z.z.string(),
10302
+ createdBranch: z.z.string().nullable(),
10303
+ creator: z.z.string().nullable(),
10304
+ message: z.z.string().nullable(),
10305
+ createdAt: z.z.string(),
10306
+ seqNum: z.z.string(),
10307
+ patchCount: z.z.number(),
10308
+ hasArchive: z.z.boolean()
10309
+ });
10310
+ const ListCommitsResponse = z.z.object({
10311
+ commits: z.z.array(HistoricalCommitResponse),
10312
+ nextCursor: z.z.string().nullable()
10313
+ });
10314
+ const CommitPatchesResponse = z.z.object({
10315
+ commitSha: z.z.string(),
10316
+ commit: z.z.object({
10317
+ commitSha: z.z.string(),
10318
+ parentCommitSha: z.z.string().nullable(),
10319
+ clientCommitSha: z.z.string().nullable(),
10320
+ branch: z.z.string(),
10321
+ createdBranch: z.z.string().nullable(),
10322
+ creator: z.z.string().nullable(),
10323
+ message: z.z.string().nullable(),
10324
+ createdAt: z.z.string(),
10325
+ seqNum: z.z.string(),
10326
+ hasArchive: z.z.boolean()
10327
+ }),
10328
+ patches: z.z.array(z.z.object({
10329
+ patchId: z.z.string(),
10330
+ path: z.z.string(),
10331
+ patch: z.z.unknown(),
10332
+ authorId: z.z.string().nullable(),
10333
+ createdAt: z.z.string(),
10334
+ baseSha: z.z.string(),
10335
+ coreVersion: z.z.string()
10336
+ }))
10337
+ });
10338
+
10339
+ /**
10340
+ * `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
10341
+ *
10342
+ * `schema` stays `unknown` here on purpose. The content service stores it
10343
+ * opaquely and cannot vouch for it, so validating it at the transport boundary
9646
10344
  * would turn "a schema written by a different version of Val" into a failed
9647
10345
  * REQUEST rather than one module that cannot be shown. It is checked in
9648
10346
  * `getHistoricalPatchSet`, per module, where a failure degrades that module and
@@ -9685,39 +10383,10 @@ const CommitAffectedFilesResponse = z.z.object({
9685
10383
  });
9686
10384
  // #endregion history wire schemas
9687
10385
 
9688
- /*
9689
- * The shared schema, not a copy of it.
9690
- *
9691
- * This re-declared `PatchGroup` field for field while the file already imported
9692
- * `PatchGroupT` from the same module — so a field added on one side and not the
9693
- * other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
9694
- * it, with no type error anywhere.
9695
- */
9696
- const PatchGroupsResponse = z.z.object({
9697
- patchGroups: z.z.array(internal.PatchGroup)
9698
- });
9699
- const PatchGroupMutationResponse = z.z.object({
9700
- /** `null` for an unstage of `~` when the caller has no open group. */
9701
- patchGroupId: z.z.string().nullable(),
9702
- patchIds: z.z.array(PatchId),
9703
- /** The chain version the change committed at. Absent from an older `home`. */
9704
- headVersion: z.z.number().optional()
9705
- });
9706
10386
  const NonceResponse = z.z.object({
9707
10387
  nonce: z.z.string(),
9708
10388
  url: z.z.string()
9709
10389
  });
9710
-
9711
- /**
9712
- * How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
9713
- *
9714
- * Sized to cover one server render, not to be a cache: several `fetchVal` calls
9715
- * in one request share an answer, and the next request asks again.
9716
- */
9717
- const PATCH_GROUPS_CACHE_MS = 1000;
9718
-
9719
- /** The content API's id for "the caller's open group on the branch". */
9720
- const OWN_OPEN_GROUP = "~";
9721
10390
  class ValOpsHttp extends ValOps {
9722
10391
  authHeaders;
9723
10392
  root;
@@ -10165,7 +10834,7 @@ class ValOpsHttp extends ValOps {
10165
10834
  if (path === "") return method === "GET" || method === "POST";
10166
10835
  const one = /^\/[0-9a-f]{20}$/.test(path);
10167
10836
  if (one) return method === "GET" || method === "PATCH";
10168
- if (method === "GET") return /^\/[0-9a-f]{20}\/(merge-checks|files)$/.test(path);
10837
+ if (method === "GET") return /^\/[0-9a-f]{20}\/(merge-checks|files|merge-trees)$/.test(path);
10169
10838
  return method === "POST" && /^\/[0-9a-f]{20}\/(close|reopen|setup\/retry|merge|files|build|update-from-site|restore-code)$/.test(path);
10170
10839
  }
10171
10840
 
@@ -10202,8 +10871,16 @@ class ValOpsHttp extends ValOps {
10202
10871
  return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
10203
10872
  }
10204
10873
  async proposalsApi(path, init, profileId) {
10205
- var _this$proposal;
10874
+ var _exec2, _exec3, _this$proposal;
10206
10875
  const json = "application/json";
10876
+ const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
10877
+ if (updating !== undefined && init.method === "POST") {
10878
+ return this.updateContentFromSite(updating, init.body, profileId);
10879
+ }
10880
+ const withCode = (_exec3 = /^\/([0-9a-f]{20})\/update-code$/.exec(path)) === null || _exec3 === void 0 ? void 0 : _exec3[1];
10881
+ if (withCode !== undefined && init.method === "POST") {
10882
+ return this.updateCodeFromSite(withCode, init.body, profileId);
10883
+ }
10207
10884
  if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
10208
10885
  return {
10209
10886
  status: 403,
@@ -10213,41 +10890,305 @@ class ValOpsHttp extends ValOps {
10213
10890
  })
10214
10891
  };
10215
10892
  }
10216
- const writes = ValOpsHttp.proposalFilesWrite(init.method, path);
10217
- if (writes !== null && writes !== ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.name)) {
10893
+ const writes = ValOpsHttp.proposalFilesWrite(init.method, path);
10894
+ if (writes !== null && writes !== ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.name)) {
10895
+ return {
10896
+ status: 403,
10897
+ contentType: json,
10898
+ body: JSON.stringify({
10899
+ 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."
10900
+ })
10901
+ };
10902
+ }
10903
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/proposals${path}`, {
10904
+ method: init.method,
10905
+ headers: {
10906
+ ...this.authHeaders,
10907
+ /*
10908
+ * WHO is asking, as for a commit: the app's key names the project,
10909
+ * and a proposal is opened, renamed and closed by a person -- content
10910
+ * refuses those writes from a credential that names nobody.
10911
+ */
10912
+ ...(profileId !== null ? {
10913
+ "x-val-profile-id": profileId
10914
+ } : {}),
10915
+ ...(init.body === undefined ? {} : {
10916
+ "Content-Type": json
10917
+ })
10918
+ },
10919
+ ...(init.body === undefined ? {} : {
10920
+ body: init.body
10921
+ })
10922
+ });
10923
+ return {
10924
+ status: res.status,
10925
+ body: await res.text(),
10926
+ contentType: res.headers.get("Content-Type") ?? json
10927
+ };
10928
+ }
10929
+
10930
+ /**
10931
+ * Update from the site, for a proposal that changes only content
10932
+ * (valbuild/home `docs/hosted-publishing.md`, step 8.2, slice 1a): content
10933
+ * hands over the three versions of every module the proposal saved, this
10934
+ * server merges them under its own build's schemas -- the same on all
10935
+ * three sides, which is what "content only" means -- and content commits
10936
+ * the merge on the proposal's branch.
10937
+ *
10938
+ * Answered here rather than forwarded, at `POST /<name>/update-content`, and
10939
+ * only by the proposal's own server: the schemas it merges under are this
10940
+ * build's. A conflict comes back for a person to resolve, and the same call
10941
+ * with their `resolutions` merges.
10942
+ */
10943
+ async updateContentFromSite(name, body, profileId) {
10944
+ var _this$proposal2;
10945
+ const json = "application/json";
10946
+ const answer = (status, value) => ({
10947
+ status,
10948
+ contentType: json,
10949
+ body: JSON.stringify(value)
10950
+ });
10951
+ if (((_this$proposal2 = this.proposal) === null || _this$proposal2 === void 0 ? void 0 : _this$proposal2.name) !== name) {
10952
+ return answer(403, {
10953
+ message: "A proposal is updated from the site at its own address, where its build is."
10954
+ });
10955
+ }
10956
+ let parsedBody;
10957
+ try {
10958
+ parsedBody = body === undefined || body === "" ? {} : JSON.parse(body);
10959
+ } catch {
10960
+ return answer(400, {
10961
+ message: "The update's body is not JSON."
10962
+ });
10963
+ }
10964
+ const request = UpdateContentBody.safeParse(parsedBody);
10965
+ if (!request.success) {
10966
+ return answer(400, {
10967
+ message: `Invalid update: ${zodValidationError.fromError(request.error).toString()}`
10968
+ });
10969
+ }
10970
+ const content = (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
10971
+ ...init,
10972
+ headers: {
10973
+ ...this.authHeaders,
10974
+ ...(profileId !== null ? {
10975
+ "x-val-profile-id": profileId
10976
+ } : {}),
10977
+ ...(init.body === undefined ? {} : {
10978
+ "Content-Type": json
10979
+ })
10980
+ }
10981
+ });
10982
+ const read = await content("/merge-inputs");
10983
+ if (!read.ok) {
10984
+ return {
10985
+ status: read.status,
10986
+ contentType: json,
10987
+ body: await read.text()
10988
+ };
10989
+ }
10990
+ const inputs = MergeInputs.safeParse(await read.json());
10991
+ if (!inputs.success) {
10992
+ return answer(502, {
10993
+ message: `Content answered the merge's inputs in a shape this Val does not read: ${zodValidationError.fromError(inputs.error).toString()}`
10994
+ });
10995
+ }
10996
+ if (inputs.data.upToDate) return answer(200, {
10997
+ status: "up-to-date"
10998
+ });
10999
+ if (!inputs.data.contentOnly.ok || inputs.data.siteHead === null) {
11000
+ return answer(409, {
11001
+ status: "needs-build",
11002
+ message: inputs.data.contentOnly.ok ? "The site has no commit to update from." : inputs.data.contentOnly.reason
11003
+ });
11004
+ }
11005
+ const modules = {};
11006
+ const withoutText = [];
11007
+ for (const [path, module] of Object.entries(inputs.data.modules)) {
11008
+ const moduleFilePath = path;
11009
+ if (module.text === null) {
11010
+ withoutText.push(moduleFilePath);
11011
+ continue;
11012
+ }
11013
+ modules[moduleFilePath] = {
11014
+ base: module.base,
11015
+ site: module.site,
11016
+ proposal: module.proposal,
11017
+ text: module.text
11018
+ };
11019
+ }
11020
+ // Decisions are about the values a person was shown. When the site or
11021
+ // the proposal moved since, they would settle values nobody saw, so the
11022
+ // merge is done again without them: what still conflicts is shown again.
11023
+ const {
11024
+ siteHead,
11025
+ proposalHead
11026
+ } = request.data;
11027
+ const decidedHere = siteHead === undefined || siteHead === inputs.data.siteHead && proposalHead === inputs.data.proposalHead;
11028
+ const merged = withoutText.length > 0 ? {
11029
+ status: "needs-full-merge",
11030
+ modules: withoutText
11031
+ } : mergeContent({
11032
+ modules,
11033
+ schemas: await this.getSchemas(),
11034
+ resolutions: decidedHere ? request.data.resolutions : []
11035
+ });
11036
+ if (merged.status === "conflicts") {
11037
+ return answer(200, {
11038
+ status: "conflicts",
11039
+ conflicts: merged.conflicts,
11040
+ siteHead: inputs.data.siteHead,
11041
+ proposalHead: inputs.data.proposalHead
11042
+ });
11043
+ }
11044
+ if (merged.status === "needs-full-merge") {
11045
+ return answer(409, {
11046
+ status: "needs-full-merge",
11047
+ modules: merged.modules,
11048
+ message: `${merged.modules.join(", ")} changed both here and on the site in a way this version cannot merge yet.`
11049
+ });
11050
+ }
11051
+ const written = await content("/merge-from-site", {
11052
+ method: "POST",
11053
+ body: JSON.stringify({
11054
+ siteHead: inputs.data.siteHead,
11055
+ proposalHead: inputs.data.proposalHead,
11056
+ files: merged.files,
11057
+ modules: merged.modules
11058
+ })
11059
+ });
11060
+ const text = await written.text();
11061
+ if (!written.ok) {
10218
11062
  return {
10219
- status: 403,
11063
+ status: written.status,
10220
11064
  contentType: json,
10221
- body: JSON.stringify({
10222
- 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."
10223
- })
11065
+ body: text
10224
11066
  };
10225
11067
  }
10226
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/proposals${path}`, {
10227
- method: init.method,
11068
+ return updatedAnswer(text);
11069
+ }
11070
+
11071
+ /**
11072
+ * Update from the site, for a proposal with code (valbuild/home
11073
+ * `docs/hosted-publishing.md`, step 8.2, slice 1b), at
11074
+ * `POST /<name>/update-code`. The builder tab has read content's trees
11075
+ * (`GET /<name>/merge-trees`, forwarded) and evaluated them; this reads the
11076
+ * trees again -- the tab's evaluation is of the trees content has, or it is
11077
+ * of nothing -- and does the text (`updateWithCode`). The first call
11078
+ * answers the merge for the tab to evaluate; the second, with that
11079
+ * evaluation, commits it on the proposal's branch, staging its code.
11080
+ *
11081
+ * Only at the proposal's own address, as every write to its files is.
11082
+ */
11083
+ async updateCodeFromSite(name, body, profileId) {
11084
+ var _this$proposal3;
11085
+ const json = "application/json";
11086
+ const answer = (status, value) => ({
11087
+ status,
11088
+ contentType: json,
11089
+ body: JSON.stringify(value)
11090
+ });
11091
+ if (((_this$proposal3 = this.proposal) === null || _this$proposal3 === void 0 ? void 0 : _this$proposal3.name) !== name) {
11092
+ return answer(403, {
11093
+ message: "A proposal is updated from the site at its own address, where its files are written."
11094
+ });
11095
+ }
11096
+ let parsedBody;
11097
+ try {
11098
+ parsedBody = body === undefined || body === "" ? {} : JSON.parse(body);
11099
+ } catch {
11100
+ return answer(400, {
11101
+ message: "The update's body is not JSON."
11102
+ });
11103
+ }
11104
+ const request = UpdateCodeBody.safeParse(parsedBody);
11105
+ if (!request.success) {
11106
+ return answer(400, {
11107
+ message: `Invalid update: ${zodValidationError.fromError(request.error).toString()}`
11108
+ });
11109
+ }
11110
+ const content = (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
11111
+ ...init,
10228
11112
  headers: {
10229
11113
  ...this.authHeaders,
10230
- /*
10231
- * WHO is asking, as for a commit: the app's key names the project,
10232
- * and a proposal is opened, renamed and closed by a person -- content
10233
- * refuses those writes from a credential that names nobody.
10234
- */
10235
11114
  ...(profileId !== null ? {
10236
11115
  "x-val-profile-id": profileId
10237
11116
  } : {}),
10238
11117
  ...(init.body === undefined ? {} : {
10239
11118
  "Content-Type": json
10240
11119
  })
10241
- },
10242
- ...(init.body === undefined ? {} : {
10243
- body: init.body
11120
+ }
11121
+ });
11122
+ const read = await content("/merge-trees");
11123
+ if (!read.ok) {
11124
+ return {
11125
+ status: read.status,
11126
+ contentType: json,
11127
+ body: await read.text()
11128
+ };
11129
+ }
11130
+ const trees = MergeTrees.safeParse(await read.json());
11131
+ if (!trees.success) {
11132
+ return answer(502, {
11133
+ message: `Content answered the merge's trees in a shape this Val does not read: ${zodValidationError.fromError(trees.error).toString()}`
11134
+ });
11135
+ }
11136
+ const {
11137
+ siteHead,
11138
+ proposalHead,
11139
+ builds
11140
+ } = request.data;
11141
+ if (trees.data.siteHead !== siteHead || trees.data.proposalHead !== proposalHead || trees.data.builds.base !== builds.base || trees.data.builds.site !== builds.site) {
11142
+ return answer(409, {
11143
+ status: "moved",
11144
+ message: "The site or this proposal changed while this update was being made. Update from the site again."
11145
+ });
11146
+ }
11147
+ if (trees.data.upToDate) return answer(200, {
11148
+ status: "up-to-date"
11149
+ });
11150
+ if (siteHead === null) {
11151
+ return answer(409, {
11152
+ message: "The site has no commit to update from."
11153
+ });
11154
+ }
11155
+ const merged = updateWithCode({
11156
+ trees: trees.data,
11157
+ evaluated: request.data.evaluated,
11158
+ resolutions: request.data.resolutions,
11159
+ ...(request.data.merged ? {
11160
+ merged: request.data.merged
11161
+ } : {})
11162
+ });
11163
+ if (merged.status === "stale") {
11164
+ return answer(409, {
11165
+ status: "stale",
11166
+ message: "What was checked is not this update any more. Update from the site again."
11167
+ });
11168
+ }
11169
+ if (merged.status !== "merged") return answer(200, merged);
11170
+ const written = await content("/merge-from-site", {
11171
+ method: "POST",
11172
+ body: JSON.stringify({
11173
+ siteHead,
11174
+ proposalHead,
11175
+ files: merged.files,
11176
+ modules: merged.modules,
11177
+ code: {
11178
+ build: builds.site,
11179
+ staged: merged.staged
11180
+ }
10244
11181
  })
10245
11182
  });
10246
- return {
10247
- status: res.status,
10248
- body: await res.text(),
10249
- contentType: res.headers.get("Content-Type") ?? json
10250
- };
11183
+ const text = await written.text();
11184
+ if (!written.ok) {
11185
+ return {
11186
+ status: written.status,
11187
+ contentType: json,
11188
+ body: text
11189
+ };
11190
+ }
11191
+ return updatedAnswer(text);
10251
11192
  }
10252
11193
 
10253
11194
  /**
@@ -10525,15 +11466,6 @@ class ValOpsHttp extends ValOps {
10525
11466
  ...(allPatchData.headVersion !== undefined ? {
10526
11467
  headVersion: allPatchData.headVersion
10527
11468
  } : {}),
10528
- /*
10529
- * Who holds what, read with the list and its version. On every stat, so
10530
- * a Studio's view of the groups is never older than its view of the
10531
- * chain — which is what lets a stage made in another browser show here
10532
- * without a reload. See `OrderedPatches.patchGroups`.
10533
- */
10534
- ...(allPatchData.patchGroups !== undefined ? {
10535
- patchGroups: allPatchData.patchGroups
10536
- } : {}),
10537
11469
  /*
10538
11470
  * The PUBLISH head, which is not `commitSha`.
10539
11471
  *
@@ -10602,8 +11534,8 @@ class ValOpsHttp extends ValOps {
10602
11534
 
10603
11535
  /** The branch this server's patches are on, where it knows it. */
10604
11536
  ownBranch() {
10605
- var _this$proposal2, _this$git;
10606
- 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);
11537
+ var _this$proposal4, _this$git;
11538
+ 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);
10607
11539
  }
10608
11540
  async getWebSocketNonce(profileId) {
10609
11541
  return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
@@ -10900,437 +11832,62 @@ class ValOpsHttp extends ValOps {
10900
11832
  updatedAt: deployment.updatedAt,
10901
11833
  commitMessage: deployment.commitMessage
10902
11834
  });
10903
- }
10904
- }
10905
- /*
10906
- * Membership, folded from the per-patch annotation onto the groups.
10907
- *
10908
- * From THIS response and not from `GET /patch-groups`, because only
10909
- * this one is consistent with the list and the version beside it:
10910
- * the content service reads all three in one transaction. A group
10911
- * list fetched separately can be older or newer than the chain it is
10912
- * shown with — and the Studio decides what an editor sees, and what
10913
- * a publish ships, from the pair.
10914
- *
10915
- * A group holding none of the listed patches is still listed, with
10916
- * no members: "groups exist and you hold nothing here" is a real
10917
- * answer, and different from "this content service has no groups".
10918
- */
10919
- let patchGroups;
10920
- if (data.patchGroups !== undefined) {
10921
- const members = new Map();
10922
- for (const patch of data.patches) {
10923
- for (const groupId of patch.patchGroupIds ?? []) {
10924
- const list = members.get(groupId) ?? [];
10925
- list.push(patch.patchId);
10926
- members.set(groupId, list);
10927
- }
10928
- }
10929
- patchGroups = data.patchGroups.map(group => ({
10930
- ...group,
10931
- patchIds: members.get(group.patchGroupId) ?? []
10932
- }));
10933
- }
10934
- return {
10935
- commits,
10936
- deployments,
10937
- patches,
10938
- errors,
10939
- ...(patchGroups !== undefined ? {
10940
- patchGroups
10941
- } : {}),
10942
- ...(data.headPatchId !== undefined ? {
10943
- headPatchId: data.headPatchId
10944
- } : {}),
10945
- ...(data.headVersion !== undefined ? {
10946
- headVersion: data.headVersion
10947
- } : {}),
10948
- ...(data.publishingPatches !== undefined ? {
10949
- publishingPatches: data.publishingPatches
10950
- } : {}),
10951
- ...(data.heldBackPatches !== undefined ? {
10952
- heldBackPatches: data.heldBackPatches
10953
- } : {})
10954
- };
10955
- }
10956
- console.error("Could not parse patches response. Error: " + zodValidationError.fromError(parsed.error));
10957
- return {
10958
- patches,
10959
- error: {
10960
- 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.`
10961
- }
10962
- };
10963
- }
10964
- console.error("Could not get patches. HTTP error: " + patchesRes.status + " " + patchesRes.statusText);
10965
- if (patchesRes.status === 401) {
10966
- return {
10967
- patches,
10968
- error: {
10969
- 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."
10970
- }
10971
- };
10972
- }
10973
- return {
10974
- patches,
10975
- error: {
10976
- message: "Could not get your changes. It is most likely due to a network issue. Check your network connection and please try again."
10977
- }
10978
- };
10979
- } catch (err) {
10980
- console.error("Could not get patches (connection error):", err instanceof Error ? err.message : JSON.stringify(err));
10981
- return {
10982
- patches: [],
10983
- networkError: true,
10984
- error: {
10985
- message: `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}`
10986
- }
10987
- };
10988
- }
10989
- }
10990
-
10991
- // #region patch groups
10992
- /**
10993
- * Add patches to a patch group.
10994
- *
10995
- * The set arrives closed by the client — `withPatchIds` is the prefix closure
10996
- * over the patch sets the staged patches belong to. We forward it and do not
10997
- * second-guess it: deriving the closure needs the content schema, which this
10998
- * process does have but content.val.build does not, and having two
10999
- * implementations of the rule would be worse than having one.
11000
- *
11001
- * Membership rows are stamped with `coreVersion` on the content side — the
11002
- * same stamp the patch row carries — so which client wrote a row stays
11003
- * legible after the fact.
11004
- */
11005
- async stagePatches(
11006
- /**
11007
- * The group to stage into, or `undefined` for the caller's open group on
11008
- * this branch — `~` on the content API, which creates it if there is none.
11009
- */
11010
- patchGroupId, /** What the user asked to stage. */
11011
- patchIds,
11012
- /**
11013
- * What has to come with it, because the staged patches are written on top
11014
- * of it.
11015
- *
11016
- * The content API stores each membership row as `explicit` or `dependency`
11017
- * and treats what it is not told about as a dependency. Folding the two
11018
- * halves into `patchIds` therefore files the patch somebody clicked as one
11019
- * the closure dragged in — the exact opposite of what happened, and the
11020
- * only record anywhere of what the author chose.
11021
- */
11022
- withPatchIds,
11023
- /**
11024
- * WHO is asking, so the content API can refuse a group that is not theirs.
11025
- *
11026
- * Every call from this class carries the app's API key, which says which
11027
- * PROJECT is calling and nothing about which editor. Without this the
11028
- * content API cannot tell one of a project's editors from another, so the
11029
- * only check on stage and unstage is the one in `ValServer` — and anything
11030
- * reaching the content API by another route (an API key, a PAT) has none at
11031
- * all.
11032
- *
11033
- * `null` where there is no session. The content API refuses rather than
11034
- * treating that as a match: a group written by an api key has a null author
11035
- * too, and `null === null` must not read as ownership.
11036
- */
11037
- authorId) {
11038
- return this.mutatePatchGroup("POST",
11039
- // Encoded: patchGroupId arrives in a request body, so an unencoded value
11040
- // like "../../commit" would reach a different endpoint carrying this
11041
- // project's auth headers.
11042
- `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
11043
- patchIds,
11044
- withPatchIds,
11045
- coreVersion: core.Internal.VERSION.core,
11046
- ...this.ownGroupBranch()
11047
- }, authorId);
11048
- }
11049
-
11050
- /**
11051
- * Remove patches from a patch group.
11052
- *
11053
- * The set arrives closed FORWARDS by the client: unstaging a patch also
11054
- * unstages everything built on top of it within its patch sets, and that is
11055
- * what `withPatchIds` carries.
11056
- */
11057
- async unstagePatches(/** See {@link stagePatches}. With no open group, there is nothing to remove. */
11058
- patchGroupId, /** What the user asked to unstage. */
11059
- patchIds, /** What has to go with it: everything built on top of it. */
11060
- withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
11061
- authorId) {
11062
- return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
11063
- patchIds,
11064
- withPatchIds,
11065
- ...this.ownGroupBranch()
11066
- }, authorId);
11067
- }
11068
-
11069
- /**
11070
- * Every patch group on this branch, with what each holds.
11071
- *
11072
- * Read rather than mutated, and used to answer "which pending patches is THIS
11073
- * person allowed to see". A draft render that skips this shows base + every
11074
- * pending patch on the branch, including work other people have not
11075
- * published — which is what independent publish exists to prevent.
11076
- *
11077
- * A failure is an empty list rather than a throw, and the caller decides what
11078
- * that means. For a draft render the honest fallback is "show nothing
11079
- * pending" rather than "show everything": being shown your own committed
11080
- * content when the group lookup is down is a worse experience than being
11081
- * shown somebody else's unpublished draft is a bug.
11082
- */
11083
- /**
11084
- * The last group lookup, and when it was made.
11085
- *
11086
- * A draft render calls `getPatchGroups` once per `fetchVal`, in series with
11087
- * the whole-chain fetch, and a page that calls `fetchVal` several times pays
11088
- * the round trip several times. Groups are per branch and change rarely, so a
11089
- * short window removes the multiplier without letting a stage go unseen for
11090
- * meaningfully longer than one render.
11091
- *
11092
- * Deliberately short. This is a read whose staleness decides whose draft
11093
- * content someone sees, so it is a per-request de-duplication rather than a
11094
- * cache: a second render a second later asks again.
11095
- */
11096
- patchGroupsCache = null;
11097
-
11098
- /**
11099
- * Forget the remembered group list, after a write through this server.
11100
- *
11101
- * A write changes membership -- a save joins its author's group, a delete or
11102
- * an unstage leaves it -- and the window above is long enough for the next
11103
- * render to be served the list from before it: an editor who saved and
11104
- * reloaded inside a second was shown their draft without the change they had
11105
- * just made. Called once the write has answered, so a read that started
11106
- * while it was in flight cannot leave the old list behind it.
11107
- *
11108
- * Only this instance's writes. Another instance's write is still seen at
11109
- * most one window late, which is what the window is for.
11110
- */
11111
- forgetPatchGroups() {
11112
- this.patchGroupsCache = null;
11113
- }
11114
- async getPatchGroups(options) {
11115
- const now = Date.now();
11116
- if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
11117
- return this.patchGroupsCache.res;
11118
- }
11119
- const res = await this.fetchPatchGroups();
11120
- /*
11121
- * ANSWERS are cached; failures are not.
11122
- *
11123
- * A transient failure held for a second is replayed to every caller in it,
11124
- * and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
11125
- * that refuses the stage, a scoped draft render falls back to base and
11126
- * drops every pending patch on the page, and `GET /patches` omits the
11127
- * annotation. One flaky request became a second of all three. The point of
11128
- * this cache is to collapse the several `fetchVal` calls in one render into
11129
- * one round trip, and an error is exactly the case worth retrying inside
11130
- * that window rather than the case worth remembering.
11131
- *
11132
- * `unsupported` is cached with `ok` deliberately: it is a real answer about
11133
- * the deployment — this content API predates patch groups — and it will not
11134
- * change between two renders.
11135
- */
11136
- if (res.status === "ok" || res.status === "unsupported") {
11137
- this.patchGroupsCache = {
11138
- at: now,
11139
- res
11140
- };
11141
- }
11142
- return res;
11143
- }
11144
- async fetchPatchGroups() {
11145
- /*
11146
- * A proposal has no patch groups: its changes are one unit, saved
11147
- * together, and the content service answers 404 for groups on its branch.
11148
- * "Unsupported" is exactly that answer, and it is what makes a draft in a
11149
- * proposal show all of the proposal's changes rather than one person's.
11150
- */
11151
- if (this.proposal !== null) {
11152
- return {
11153
- status: "unsupported"
11154
- };
11155
- }
11156
- try {
11157
- /*
11158
- * `branch` is REQUIRED by the endpoint, which answers 400 without it.
11159
- *
11160
- * Same two params every other read here sends (`fetchPatchesInternal`,
11161
- * `saveSourceFilePatch`): groups are per branch, so a request without one
11162
- * is not merely under-specified, it is rejected.
11163
- */
11164
- const branch = this.ownBranch();
11165
- const params = new URLSearchParams(branch !== undefined ? [["branch", branch]] : []);
11166
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
11167
- headers: this.authHeaders
11168
- });
11169
- if (res.status === 404) {
11170
- /*
11171
- * The endpoint is not there, which is a content API that PREDATES patch
11172
- * groups — not a failure.
11173
- *
11174
- * The distinction decides what a draft render shows, and collapsing it
11175
- * into "error" is not a small mistake: a caller that reads an error as
11176
- * "could not ask" renders BASE, so every existing http deployment would
11177
- * silently drop all pending content from every draft preview. "There
11178
- * are no groups here" has to mean unscoped, which is exactly the
11179
- * behaviour those projects have today.
11180
- */
11181
- return {
11182
- status: "unsupported"
11183
- };
11184
- }
11185
- if (!res.ok) {
11186
- return {
11187
- status: "error",
11188
- 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}`
11189
- };
11190
- }
11191
- const parsed = PatchGroupsResponse.safeParse(await res.json());
11192
- if (!parsed.success) {
11193
- return {
11194
- status: "error",
11195
- message: `Could not parse patch groups response. Error: ${zodValidationError.fromError(parsed.error)}`
11196
- };
11197
- }
11198
- return {
11199
- status: "ok",
11200
- patchGroups: parsed.data.patchGroups
11201
- };
11202
- } catch (err) {
11203
- return {
11204
- status: "error",
11205
- message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
11206
- };
11207
- }
11208
- }
11209
-
11210
- /**
11211
- * Which branch `~` means, where this build knows: the branch it reads
11212
- * `applicable/patches` on, so the group staged into is the group listed.
11213
- * Without one the content API uses the project's own, as it does for reads.
11214
- */
11215
- ownGroupBranch() {
11216
- const branch = this.ownBranch();
11217
- return branch !== undefined ? {
11218
- branch
11219
- } : {};
11220
- }
11221
- async mutatePatchGroup(method, path, body,
11222
- /**
11223
- * WHO is asking. Sent as `x-val-profile-id`, which is what the content API
11224
- * reads to decide whether this group is the caller's.
11225
- *
11226
- * `this.authHeaders` is the app's API key, and that names the PROJECT, not
11227
- * the person — so without this the content API cannot resolve a profile
11228
- * and refuses every stage and unstage with
11229
- * "Cannot resolve the caller's profile". The group endpoints are the only
11230
- * ones here that need it, because they are the only ones whose answer
11231
- * depends on which of a project's editors is calling.
11232
- *
11233
- * Omitted when there is no session rather than sent empty: the content API
11234
- * treats an unidentified caller as a refusal, which is what we want, and an
11235
- * empty header would be a different and less obvious way to say it.
11236
- *
11237
- * A PAT already identifies a person, so `authHeaders` carries the identity
11238
- * on its own there and this adds nothing.
11239
- */
11240
- authorId) {
11241
- if (this.proposal !== null) {
11242
- return {
11243
- status: 409,
11244
- patchIds: [],
11245
- error: {
11246
- message: "A proposal's changes are saved together, so there is nothing to stage or unstage in one."
11247
- }
11248
- };
11249
- }
11250
- try {
11251
- return await this.mutatePatchGroupRequest(method, path, body, authorId);
11252
- } finally {
11253
- this.forgetPatchGroups();
11254
- }
11255
- }
11256
- async mutatePatchGroupRequest(method, path, body, authorId) {
11257
- try {
11258
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
11259
- method,
11260
- headers: {
11261
- ...this.authHeaders,
11262
- ...(authorId !== null ? {
11263
- "x-val-profile-id": authorId
11264
- } : {}),
11265
- "Content-Type": "application/json"
11266
- },
11267
- body: JSON.stringify(body)
11268
- });
11269
- if (res.ok) {
11270
- const parsed = PatchGroupMutationResponse.safeParse(await res.json());
11271
- if (parsed.success) {
11835
+ }
11836
+ }
11272
11837
  return {
11273
- patchIds: parsed.data.patchIds,
11274
- patchGroupId: parsed.data.patchGroupId,
11275
- ...(parsed.data.headVersion !== undefined ? {
11276
- headVersion: parsed.data.headVersion
11838
+ commits,
11839
+ deployments,
11840
+ patches,
11841
+ errors,
11842
+ ...(data.headPatchId !== undefined ? {
11843
+ headPatchId: data.headPatchId
11844
+ } : {}),
11845
+ ...(data.headVersion !== undefined ? {
11846
+ headVersion: data.headVersion
11847
+ } : {}),
11848
+ ...(data.publishingPatches !== undefined ? {
11849
+ publishingPatches: data.publishingPatches
11850
+ } : {}),
11851
+ ...(data.heldBackPatches !== undefined ? {
11852
+ heldBackPatches: data.heldBackPatches
11277
11853
  } : {})
11278
11854
  };
11279
11855
  }
11856
+ console.error("Could not parse patches response. Error: " + zodValidationError.fromError(parsed.error));
11280
11857
  return {
11281
- status: 500,
11282
- patchIds: [],
11283
- error: {
11284
- message: `Could not parse patch group response. Error: ${zodValidationError.fromError(parsed.error)}`
11285
- }
11286
- };
11287
- }
11288
- // 403 (not your group) and 409 (already published) are meaningful to the
11289
- // client, so they are passed through rather than flattened to a 500.
11290
- if (res.status === 403 || res.status === 409) {
11291
- // `home` answers these with a JSON body, so `res.text()` put the literal
11292
- // `{"message":"..."}` in front of the user. Every other branch in this
11293
- // class unwraps it; this one now does too.
11294
- return {
11295
- status: res.status,
11296
- patchIds: [],
11858
+ patches,
11297
11859
  error: {
11298
- message: internal.getErrorMessageFromUnknownJson(await res.json().catch(() => undefined), `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`)
11860
+ 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.`
11299
11861
  }
11300
11862
  };
11301
11863
  }
11302
- // A 401 here is the app's own credentials failing, not the user's, so it gets
11303
- // the same wording as every other call in this class rather than an opaque
11304
- // 500 that sends the user looking at their own session.
11305
- if (res.status === 401) {
11864
+ console.error("Could not get patches. HTTP error: " + patchesRes.status + " " + patchesRes.statusText);
11865
+ if (patchesRes.status === 401) {
11306
11866
  return {
11307
- status: 500,
11308
- patchIds: [],
11867
+ patches,
11309
11868
  error: {
11310
11869
  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."
11311
11870
  }
11312
11871
  };
11313
11872
  }
11314
11873
  return {
11315
- status: 500,
11316
- patchIds: [],
11874
+ patches,
11317
11875
  error: {
11318
- message: `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`
11876
+ message: "Could not get your changes. It is most likely due to a network issue. Check your network connection and please try again."
11319
11877
  }
11320
11878
  };
11321
11879
  } catch (err) {
11880
+ console.error("Could not get patches (connection error):", err instanceof Error ? err.message : JSON.stringify(err));
11322
11881
  return {
11323
- status: 500,
11324
- patchIds: [],
11882
+ patches: [],
11883
+ networkError: true,
11325
11884
  error: {
11326
- message: `Could not update patch group (connection error?): ${err instanceof Error ? err.message : JSON.stringify(err)}`
11885
+ message: `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}`
11327
11886
  }
11328
11887
  };
11329
11888
  }
11330
11889
  }
11331
- // #endregion
11332
-
11333
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
11890
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
11334
11891
  const baseSha = await this.getBaseSha();
11335
11892
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
11336
11893
  method: "POST",
@@ -11356,24 +11913,7 @@ class ValOpsHttp extends ValOps {
11356
11913
  commit: this.git.commit,
11357
11914
  branch: this.git.branch
11358
11915
  } : {}),
11359
- coreVersion: core.Internal.VERSION.core,
11360
- /*
11361
- * Group membership in the SAME request as the patch.
11362
- *
11363
- * The content API runs every refusal before its insert, so an invalid
11364
- * closure is a 400 with nothing written. Spread rather than sent as
11365
- * nulls: a client with no group omits the fields entirely, which is
11366
- * what an older content API expects to see.
11367
- */
11368
- ...(patchGroup ? {
11369
- // Only when the caller named one. Omitted, the content API
11370
- // resolves this author's open group and creates it if absent,
11371
- // which is what every write wants.
11372
- ...(patchGroup.patchGroupId !== undefined ? {
11373
- patchGroupId: patchGroup.patchGroupId
11374
- } : {}),
11375
- withPatchIds: patchGroup.withPatchIds
11376
- } : {})
11916
+ coreVersion: core.Internal.VERSION.core
11377
11917
  })
11378
11918
  }).then(async res => {
11379
11919
  var _res$headers$get2;
@@ -11382,20 +11922,6 @@ class ValOpsHttp extends ValOps {
11382
11922
  if (parsed.success) {
11383
11923
  return fp.result.ok({
11384
11924
  patchId: parsed.data.patchId,
11385
- /*
11386
- * Passed back to the client, which cannot learn it any other way.
11387
- *
11388
- * A write names no group — the content API resolves this author's
11389
- * open group and CREATES it if absent — so on a fresh branch the
11390
- * group comes into existence here and nowhere else. The chain
11391
- * annotation is only re-read when a fetch has missing ids to ask
11392
- * for, and a patch this client made is never missing, so without
11393
- * this the tab that bootstrapped the group would never learn its
11394
- * id and every stage would be a no-op.
11395
- */
11396
- ...(parsed.data.patchGroupId !== undefined ? {
11397
- patchGroupId: parsed.data.patchGroupId
11398
- } : {}),
11399
11925
  ...(parsed.data.headVersion !== undefined ? {
11400
11926
  headVersion: parsed.data.headVersion
11401
11927
  } : {})
@@ -11445,7 +11971,7 @@ class ValOpsHttp extends ValOps {
11445
11971
  errorType: "other",
11446
11972
  message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
11447
11973
  });
11448
- }).finally(() => this.forgetPatchGroups());
11974
+ });
11449
11975
  }
11450
11976
 
11451
11977
  /**
@@ -11710,17 +12236,7 @@ class ValOpsHttp extends ValOps {
11710
12236
  }]
11711
12237
  };
11712
12238
  }
11713
- async deletePatches(patchIds,
11714
- /**
11715
- * Patches that are NOT deleted but must lose their group membership.
11716
- *
11717
- * Deleting a patch out of the middle of a patch set leaves every group
11718
- * still holding the rest with a non-prefix intersection — the patches after
11719
- * the hole were written against a view that had it. The content API cannot
11720
- * work out which those are (it has no schema), so the client sends the
11721
- * forward closure and it drops those memberships without deleting anything.
11722
- */
11723
- unstagePatchIds) {
12239
+ async deletePatches(patchIds) {
11724
12240
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
11725
12241
  method: "DELETE",
11726
12242
  headers: {
@@ -11728,10 +12244,7 @@ class ValOpsHttp extends ValOps {
11728
12244
  "Content-Type": "application/json"
11729
12245
  },
11730
12246
  body: JSON.stringify({
11731
- patchIds,
11732
- ...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
11733
- unstagePatchIds
11734
- } : {})
12247
+ patchIds
11735
12248
  })
11736
12249
  }).then(async res => {
11737
12250
  if (res.ok) {
@@ -11774,24 +12287,9 @@ class ValOpsHttp extends ValOps {
11774
12287
  message: `Could not delete patches (connection error?): ${e instanceof Error ? e.message : e.toString()}`
11775
12288
  }
11776
12289
  };
11777
- }).finally(() => this.forgetPatchGroups());
12290
+ });
11778
12291
  }
11779
- async commit(prepared, message, committer, filesDirectory, newBranch,
11780
- /**
11781
- * The patch group this commit EMPTIES, if it empties one.
11782
- *
11783
- * The content API closes the group it is given — and closes it WITHOUT
11784
- * checking that the commit shipped all of it, so a caller that names a
11785
- * group still holding work takes those patches out of every group and
11786
- * leaves their author unable to publish them. The client therefore sends it
11787
- * only when the publish accounts for everything the group still holds.
11788
- *
11789
- * Omitting it is not neutral: the commit still empties the group (the
11790
- * content API drops applied ids from every group), but `published_at` is
11791
- * never set, so the id is reused across publishes instead of a new group
11792
- * per publish and the "already published" refusal can never fire.
11793
- */
11794
- patchGroupId) {
12292
+ async commit(prepared, message, committer, filesDirectory, newBranch) {
11795
12293
  try {
11796
12294
  var _res$headers$get3;
11797
12295
  /*
@@ -11805,23 +12303,10 @@ class ValOpsHttp extends ValOps {
11805
12303
  headers: {
11806
12304
  ...this.authHeaders,
11807
12305
  /*
11808
- * WHO is publishing — the same `x-val-profile-id` stage and unstage
11809
- * send, and for the same reason: `this.authHeaders` is the app's API
11810
- * key, which names the PROJECT and not the person.
11811
- *
11812
- * Closing a group is an ownership decision, so the content API
11813
- * refuses a commit that names a `patchGroupId` it cannot attribute:
11814
- * without this header `profileId` is `undefined` there and every
11815
- * group-closing publish is 403 "Cannot resolve the caller's profile".
11816
- * That is the NORMAL full publish, not an edge — `publish` names the
11817
- * group whenever the commit empties it.
11818
- *
11819
- * Sent on every commit rather than only when a group is named. The
11820
- * committer is a person either way, `committer` in the body already
11821
- * says so, and a header that appears only on some commits is one more
11822
- * conditional for a reader of either repo to reconstruct. It changes
11823
- * nothing else: the content API reads it as an identity claim beside
11824
- * the app's key and derives no scope from it.
12306
+ * WHO is publishing: `this.authHeaders` is the app's API key, which
12307
+ * names the PROJECT and not the person. The content API reads it as
12308
+ * an identity claim beside the app's key and derives no scope from
12309
+ * it; `committer` in the body says the same.
11825
12310
  */
11826
12311
  "x-val-profile-id": committer,
11827
12312
  "Content-Type": "application/json"
@@ -11866,10 +12351,7 @@ class ValOpsHttp extends ValOps {
11866
12351
  baseSha: await this.getBaseSha(),
11867
12352
  committer,
11868
12353
  message,
11869
- newBranch,
11870
- ...(patchGroupId !== undefined ? {
11871
- patchGroupId
11872
- } : {})
12354
+ newBranch
11873
12355
  })
11874
12356
  });
11875
12357
  if (res.ok) {
@@ -12126,6 +12608,146 @@ class ValOpsHttp extends ValOps {
12126
12608
  // #endregion history
12127
12609
  }
12128
12610
 
12611
+ /**
12612
+ * Content's answer to a merge it committed, as the Studio is answered: what
12613
+ * content says, marked updated. Content has written the merge by then, so an
12614
+ * answer that cannot be read says so rather than failing as though nothing
12615
+ * happened.
12616
+ */
12617
+ function updatedAnswer(text) {
12618
+ let parsed;
12619
+ try {
12620
+ parsed = JSON.parse(text);
12621
+ } catch {
12622
+ parsed = undefined;
12623
+ }
12624
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
12625
+ return {
12626
+ status: 502,
12627
+ contentType: "application/json",
12628
+ body: JSON.stringify({
12629
+ 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."
12630
+ })
12631
+ };
12632
+ }
12633
+ return {
12634
+ status: 200,
12635
+ contentType: "application/json",
12636
+ body: JSON.stringify({
12637
+ status: "updated",
12638
+ ...parsed
12639
+ })
12640
+ };
12641
+ }
12642
+
12643
+ /** What the Studio sends to update a proposal from the site: its decisions. */
12644
+ const UpdateContentBody = z.z.object({
12645
+ /**
12646
+ * Where the site and the proposal were when the conflicts the
12647
+ * `resolutions` decide were found: the conflicts answer says. Absent with
12648
+ * no decisions.
12649
+ */
12650
+ siteHead: z.z.string().nullable().optional(),
12651
+ proposalHead: z.z.string().nullable().optional(),
12652
+ resolutions: z.z.array(z.z.union([z.z.object({
12653
+ moduleFilePath: z.z.string().transform(p => p),
12654
+ path: z.z.array(z.z.string()),
12655
+ keep: z.z.enum(["site", "proposal"])
12656
+ }), z.z.object({
12657
+ moduleFilePath: z.z.string().transform(p => p),
12658
+ path: z.z.array(z.z.string()),
12659
+ value: internal.JSONValue
12660
+ })])).default([])
12661
+ });
12662
+
12663
+ /** A module, as the builder tab's evaluation of its tree gave it. */
12664
+ const EvaluatedModules = z.z.record(z.z.string().transform(p => p), z.z.object({
12665
+ schema: internal.SerializedSchema,
12666
+ written: internal.JSONValue,
12667
+ errors: z.z.array(z.z.object({
12668
+ path: z.z.array(z.z.string()),
12669
+ message: z.z.string()
12670
+ }))
12671
+ }));
12672
+
12673
+ /** What the builder tab sends to update a proposal with code from the site. */
12674
+ const UpdateCodeBody = z.z.object({
12675
+ siteHead: z.z.string().nullable(),
12676
+ proposalHead: z.z.string().nullable(),
12677
+ builds: z.z.object({
12678
+ base: z.z.string(),
12679
+ site: z.z.string()
12680
+ }),
12681
+ evaluated: z.z.object({
12682
+ base: EvaluatedModules,
12683
+ site: EvaluatedModules,
12684
+ proposal: EvaluatedModules
12685
+ }),
12686
+ resolutions: z.z.array(z.z.union([z.z.object({
12687
+ moduleFilePath: z.z.string().transform(p => p),
12688
+ path: z.z.array(z.z.string()),
12689
+ keep: z.z.enum(["site", "proposal"])
12690
+ }), z.z.object({
12691
+ moduleFilePath: z.z.string().transform(p => p),
12692
+ path: z.z.array(z.z.string()),
12693
+ value: internal.JSONValue
12694
+ }), z.z.object({
12695
+ file: z.z.string(),
12696
+ keep: z.z.enum(["site", "proposal"])
12697
+ }), z.z.object({
12698
+ file: z.z.string(),
12699
+ text: z.z.string()
12700
+ })])).default([]),
12701
+ /** The tab's evaluation of the merge the first call answered. */
12702
+ merged: z.z.object({
12703
+ id: z.z.string(),
12704
+ evaluated: EvaluatedModules
12705
+ }).optional()
12706
+ });
12707
+
12708
+ /** Content's `GET /proposals/:name/merge-trees`. */
12709
+ const MergeTrees = z.z.object({
12710
+ base: z.z.string().nullable(),
12711
+ siteHead: z.z.string().nullable(),
12712
+ proposalHead: z.z.string().nullable(),
12713
+ upToDate: z.z.boolean(),
12714
+ builds: z.z.object({
12715
+ base: z.z.string(),
12716
+ site: z.z.string()
12717
+ }),
12718
+ trees: z.z.object({
12719
+ site: z.z.record(z.z.string(), z.z.string()),
12720
+ base: z.z.record(z.z.string(), z.z.string().nullable()),
12721
+ proposal: z.z.record(z.z.string(), z.z.string().nullable())
12722
+ }),
12723
+ modules: z.z.record(z.z.string(), z.z.object({
12724
+ base: z.z.string().nullable(),
12725
+ site: z.z.string().nullable(),
12726
+ proposal: z.z.string().nullable()
12727
+ })),
12728
+ sources: z.z.record(z.z.string(), internal.JSONValue)
12729
+ });
12730
+
12731
+ /** Content's `GET /proposals/:name/merge-inputs`. */
12732
+ const MergeInputs = z.z.object({
12733
+ base: z.z.string().nullable(),
12734
+ siteHead: z.z.string().nullable(),
12735
+ proposalHead: z.z.string().nullable(),
12736
+ upToDate: z.z.boolean(),
12737
+ contentOnly: z.z.union([z.z.object({
12738
+ ok: z.z.literal(true)
12739
+ }), z.z.object({
12740
+ ok: z.z.literal(false),
12741
+ reason: z.z.string()
12742
+ })]),
12743
+ modules: z.z.record(z.z.string(), z.z.object({
12744
+ base: internal.JSONValue.nullable(),
12745
+ site: internal.JSONValue.nullable(),
12746
+ proposal: internal.JSONValue.nullable(),
12747
+ text: z.z.string().nullable()
12748
+ }))
12749
+ });
12750
+
12129
12751
  /**
12130
12752
  * One stored patch. Everything the ordered chain needs and nothing else.
12131
12753
  */
@@ -12415,8 +13037,7 @@ class ValOpsMemory extends ValOps {
12415
13037
  }
12416
13038
  async fetchPatches(filters) {
12417
13039
  // An empty `patchIds` means "no filter", not "none": both shipped
12418
- // implementations read it that way and callers rely on it. See
12419
- // `scopedModulePatches` in ValOps.ts, which says why at length.
13040
+ // implementations read it that way and callers rely on it.
12420
13041
  const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
12421
13042
  const order = await this.store.list();
12422
13043
  const patches = [];
@@ -12462,12 +13083,7 @@ class ValOpsMemory extends ValOps {
12462
13083
  * point at nothing. A patch computed against a different state is caught
12463
13084
  * where it shows -- applying it -- rather than by refusing the write.
12464
13085
  */
12465
- _parentRef, authorId, _sessionId,
12466
- /* No shared store and no second author yet, so nothing for a group to
12467
- * separate. Named rather than omitted: TypeScript allows an implementation
12468
- * to take fewer parameters, so dropping it would look identical to
12469
- * handling it. */
12470
- _patchGroup) {
13086
+ _parentRef, authorId, _sessionId) {
12471
13087
  await this.store.append({
12472
13088
  patchId,
12473
13089
  path,
@@ -15232,195 +15848,25 @@ const ValServer = (valModules, options, callbacks) => {
15232
15848
  /*
15233
15849
  * Every URL in the batch at once, and nothing beyond it.
15234
15850
  *
15235
- * The concurrency here is the batch size the route caps at, which is
15236
- * what keeps "check a thousand links" from becoming a thousand
15237
- * simultaneous outbound sockets: the client sends ten, waits, sends
15238
- * the next ten. Doing them in parallel WITHIN a batch is what makes a
15239
- * batch worth having — ten five-second timeouts in series is a minute.
15240
- */
15241
- const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
15242
- return {
15243
- status: 200,
15244
- json: {
15245
- results: Object.fromEntries(results)
15246
- }
15247
- };
15248
- }
15249
- },
15250
- "/direct-file-upload-settings": {
15251
- POST: async req => {
15252
- const cookies = req.cookies;
15253
- const auth = getAuth(cookies);
15254
- if (auth.error) {
15255
- return {
15256
- status: 401,
15257
- json: {
15258
- message: auth.error
15259
- }
15260
- };
15261
- }
15262
- if (serverOps.patchesAreLocal) {
15263
- // In FS mode patch-file uploads are buffered through this server (no remote round-trip),
15264
- // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
15265
- // content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
15266
- // can POST directly without exposing the PAT.
15267
- const host = `/api/val`;
15268
- let contentBaseUrl = null;
15269
- let contentAuthNonce = null;
15270
- if (!options.project) {
15271
- console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
15272
- } else if (!(serverOps instanceof ValOpsFS)) {
15273
- /*
15274
- * Presigning needs a content host to presign AGAINST, and a local
15275
- * store does not necessarily have one configured: `fs` mode has the
15276
- * developer's `valContentUrl`, and a host holding its own source has
15277
- * no such setting.
15278
- *
15279
- * Warned and disabled, like the missing-project case above, rather
15280
- * than failing the request: everything else this route answers --
15281
- * the patch upload base url -- still works, and the only thing lost
15282
- * is the browser posting AI images straight to the content host.
15283
- */
15284
- console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
15285
- } else {
15286
- const authDataRes = await getRemoteFileAuth();
15287
- if (authDataRes.status !== 200) {
15288
- console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
15289
- } else {
15290
- const corsOrigin = "*"; // TODO: add cors origin
15291
- const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
15292
- if (presignedAuthNonce.status === "success") {
15293
- contentBaseUrl = presignedAuthNonce.data.baseUrl;
15294
- contentAuthNonce = presignedAuthNonce.data.nonce;
15295
- } else {
15296
- console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
15297
- }
15298
- }
15299
- }
15300
- return {
15301
- status: 200,
15302
- json: {
15303
- nonce: null,
15304
- baseUrl: `${host}/upload`,
15305
- // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
15306
- contentBaseUrl,
15307
- contentAuthNonce
15308
- }
15309
- };
15310
- }
15311
- const httpOps = serverOps;
15312
- const profileId = "id" in auth && auth.id ? auth.id : undefined;
15313
- if (!profileId) {
15314
- return {
15315
- status: 401,
15316
- json: {
15317
- message: "Unauthorized"
15318
- }
15319
- };
15320
- }
15321
- const corsOrigin = "*"; // TODO: add cors origin
15322
- const presignedAuthNonce = await httpOps.getPresignedAuthNonce(profileId, corsOrigin);
15323
- if (presignedAuthNonce.status === "error") {
15324
- return {
15325
- status: presignedAuthNonce.statusCode,
15326
- json: presignedAuthNonce.error
15327
- };
15328
- }
15329
- return {
15330
- status: 200,
15331
- json: {
15332
- nonce: presignedAuthNonce.data.nonce,
15333
- baseUrl: presignedAuthNonce.data.baseUrl,
15334
- contentBaseUrl: presignedAuthNonce.data.baseUrl,
15335
- contentAuthNonce: presignedAuthNonce.data.nonce
15336
- }
15337
- };
15338
- }
15339
- },
15340
- //#region patch groups
15341
- // Staging and unstaging patches. Both take an already-closed set of patch ids:
15342
- // the prefix closure (staging) or the forward closure (unstaging) is computed
15343
- // on the client, which is the only side that has the schema needed to derive
15344
- // patch sets. See `docs/independent-publish/PLAN.md`.
15345
- //
15346
- // In FS mode there is no shared store and exactly one author, so group
15347
- // membership lives in the client and these handlers simply acknowledge. The
15348
- // client already sends an explicit patch id list to `/save`, so a locally-held
15349
- // group is enough to publish a subset correctly.
15350
- "/patch-groups/~/patches": {
15351
- PUT: async req => {
15352
- const auth = getAuth(req.cookies);
15353
- if (auth.error) {
15354
- return {
15355
- status: 401,
15356
- json: {
15357
- message: auth.error
15358
- }
15359
- };
15360
- }
15361
- const {
15362
- patchGroupId,
15363
- patchIds
15364
- } = req.body;
15365
- const withPatchIds = req.body.withPatchIds ?? [];
15366
- if (serverOps.patchesAreLocal) {
15367
- return {
15368
- status: 200,
15369
- json: {
15370
- patchGroupId: patchGroupId ?? null,
15371
- patchIds: [...patchIds, ...withPatchIds]
15372
- }
15373
- };
15374
- }
15375
- if (!("id" in auth) || !auth.id) {
15376
- return {
15377
- status: 401,
15378
- json: {
15379
- message: "Unauthorized"
15380
- }
15381
- };
15382
- }
15383
- // A named group is checked here as well as by the content API. No name
15384
- // is the caller's own open group, which the content API resolves from
15385
- // the profile it is sent — there is nothing here to check it against.
15386
- if (patchGroupId !== undefined) {
15387
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
15388
- if (refusal !== null) {
15389
- return {
15390
- status: refusal.status,
15391
- json: {
15392
- message: refusal.message
15393
- }
15394
- };
15395
- }
15396
- }
15397
- const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
15398
- // Forwarded so the content API can refuse independently. This server
15399
- // has already refused a group that is not the caller's; sending the
15400
- // author means the check also holds for anything reaching the content
15401
- // API without coming through here.
15402
- auth.id);
15403
- if (res.error) {
15404
- return {
15405
- status: res.status,
15406
- json: {
15407
- message: res.error.message
15408
- }
15409
- };
15410
- }
15851
+ * The concurrency here is the batch size the route caps at, which is
15852
+ * what keeps "check a thousand links" from becoming a thousand
15853
+ * simultaneous outbound sockets: the client sends ten, waits, sends
15854
+ * the next ten. Doing them in parallel WITHIN a batch is what makes a
15855
+ * batch worth having — ten five-second timeouts in series is a minute.
15856
+ */
15857
+ const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
15411
15858
  return {
15412
15859
  status: 200,
15413
15860
  json: {
15414
- patchGroupId: res.patchGroupId,
15415
- patchIds: res.patchIds,
15416
- ...(res.headVersion !== undefined ? {
15417
- headVersion: res.headVersion
15418
- } : {})
15861
+ results: Object.fromEntries(results)
15419
15862
  }
15420
15863
  };
15421
- },
15422
- DELETE: async req => {
15423
- const auth = getAuth(req.cookies);
15864
+ }
15865
+ },
15866
+ "/direct-file-upload-settings": {
15867
+ POST: async req => {
15868
+ const cookies = req.cookies;
15869
+ const auth = getAuth(cookies);
15424
15870
  if (auth.error) {
15425
15871
  return {
15426
15872
  status: 401,
@@ -15429,21 +15875,58 @@ const ValServer = (valModules, options, callbacks) => {
15429
15875
  }
15430
15876
  };
15431
15877
  }
15432
- const {
15433
- patchGroupId,
15434
- patchIds
15435
- } = req.body;
15436
- const withPatchIds = req.body.withPatchIds ?? [];
15437
15878
  if (serverOps.patchesAreLocal) {
15879
+ // In FS mode patch-file uploads are buffered through this server (no remote round-trip),
15880
+ // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
15881
+ // content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
15882
+ // can POST directly without exposing the PAT.
15883
+ const host = `/api/val`;
15884
+ let contentBaseUrl = null;
15885
+ let contentAuthNonce = null;
15886
+ if (!options.project) {
15887
+ console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
15888
+ } else if (!(serverOps instanceof ValOpsFS)) {
15889
+ /*
15890
+ * Presigning needs a content host to presign AGAINST, and a local
15891
+ * store does not necessarily have one configured: `fs` mode has the
15892
+ * developer's `valContentUrl`, and a host holding its own source has
15893
+ * no such setting.
15894
+ *
15895
+ * Warned and disabled, like the missing-project case above, rather
15896
+ * than failing the request: everything else this route answers --
15897
+ * the patch upload base url -- still works, and the only thing lost
15898
+ * is the browser posting AI images straight to the content host.
15899
+ */
15900
+ console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
15901
+ } else {
15902
+ const authDataRes = await getRemoteFileAuth();
15903
+ if (authDataRes.status !== 200) {
15904
+ console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
15905
+ } else {
15906
+ const corsOrigin = "*"; // TODO: add cors origin
15907
+ const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
15908
+ if (presignedAuthNonce.status === "success") {
15909
+ contentBaseUrl = presignedAuthNonce.data.baseUrl;
15910
+ contentAuthNonce = presignedAuthNonce.data.nonce;
15911
+ } else {
15912
+ console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
15913
+ }
15914
+ }
15915
+ }
15438
15916
  return {
15439
15917
  status: 200,
15440
15918
  json: {
15441
- patchGroupId: patchGroupId ?? null,
15442
- patchIds: [...patchIds, ...withPatchIds]
15919
+ nonce: null,
15920
+ baseUrl: `${host}/upload`,
15921
+ // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
15922
+ contentBaseUrl,
15923
+ contentAuthNonce
15443
15924
  }
15444
15925
  };
15445
15926
  }
15446
- if (!("id" in auth) || !auth.id) {
15927
+ const httpOps = serverOps;
15928
+ const profileId = "id" in auth && auth.id ? auth.id : undefined;
15929
+ if (!profileId) {
15447
15930
  return {
15448
15931
  status: 401,
15449
15932
  json: {
@@ -15451,37 +15934,21 @@ const ValServer = (valModules, options, callbacks) => {
15451
15934
  }
15452
15935
  };
15453
15936
  }
15454
- // A named group is checked here as well as by the content API. No name
15455
- // is the caller's own open group, which the content API resolves from
15456
- // the profile it is sent — there is nothing here to check it against.
15457
- if (patchGroupId !== undefined) {
15458
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
15459
- if (refusal !== null) {
15460
- return {
15461
- status: refusal.status,
15462
- json: {
15463
- message: refusal.message
15464
- }
15465
- };
15466
- }
15467
- }
15468
- const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
15469
- if (res.error) {
15937
+ const corsOrigin = "*"; // TODO: add cors origin
15938
+ const presignedAuthNonce = await httpOps.getPresignedAuthNonce(profileId, corsOrigin);
15939
+ if (presignedAuthNonce.status === "error") {
15470
15940
  return {
15471
- status: res.status,
15472
- json: {
15473
- message: res.error.message
15474
- }
15941
+ status: presignedAuthNonce.statusCode,
15942
+ json: presignedAuthNonce.error
15475
15943
  };
15476
15944
  }
15477
15945
  return {
15478
15946
  status: 200,
15479
15947
  json: {
15480
- patchGroupId: res.patchGroupId,
15481
- patchIds: res.patchIds,
15482
- ...(res.headVersion !== undefined ? {
15483
- headVersion: res.headVersion
15484
- } : {})
15948
+ nonce: presignedAuthNonce.data.nonce,
15949
+ baseUrl: presignedAuthNonce.data.baseUrl,
15950
+ contentBaseUrl: presignedAuthNonce.data.baseUrl,
15951
+ contentAuthNonce: presignedAuthNonce.data.nonce
15485
15952
  }
15486
15953
  };
15487
15954
  }
@@ -15490,53 +15957,6 @@ const ValServer = (valModules, options, callbacks) => {
15490
15957
  "/patches": {
15491
15958
  PUT: async req => {
15492
15959
  const cookies = req.cookies;
15493
-
15494
- /**
15495
- * Group membership travels WITH the patch, in one request.
15496
- *
15497
- * Atomic on purpose: the content API runs every refusal before its
15498
- * insert, so an invalid closure is a 400 with nothing written. Recording
15499
- * membership in a second call would let a patch exist outside its
15500
- * author's group whenever that call failed — and a patch outside your
15501
- * own group is one you cannot publish until a repair puts it back.
15502
- *
15503
- */
15504
- /*
15505
- * A membership is present if EITHER field is.
15506
- *
15507
- * The common case names no group: the content API resolves the author's
15508
- * open group, creating it if absent, so the client never has to hold an
15509
- * id across publishes. It still sends the closure, which is the part it
15510
- * alone can compute.
15511
- *
15512
- * `patchGroupId` is nullable, so an explicit `null` means "no group
15513
- * named" exactly as omitting it does — it is passed through as
15514
- * `undefined` rather than becoming a membership keyed by null.
15515
- */
15516
- const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
15517
- const requestedWith = req.body.withPatchIds;
15518
- const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
15519
- ...(requestedPatchGroupId !== undefined ? {
15520
- patchGroupId: requestedPatchGroupId
15521
- } : {}),
15522
- withPatchIds: requestedWith ?? []
15523
- } : undefined;
15524
- if (patchGroup !== undefined && serverOps.patchesAreLocal) {
15525
- /*
15526
- * `fs` has no shared store and one author, so there is no group to
15527
- * join. Refused rather than acknowledged: answering 200 would tell the
15528
- * client its membership was recorded when it was dropped, and the
15529
- * client would then believe a publish is scoped when it is not.
15530
- */
15531
- return {
15532
- status: 400,
15533
- json: {
15534
- type: "patch-error",
15535
- message: "Patch groups are not available in fs mode. Omit the patch group fields.",
15536
- errors: {}
15537
- }
15538
- };
15539
- }
15540
15960
  const auth = getAuth(cookies);
15541
15961
  if (auth.error) {
15542
15962
  return {
@@ -15559,17 +15979,6 @@ const ValServer = (valModules, options, callbacks) => {
15559
15979
  const sessionId = req.body.sessionId ?? null;
15560
15980
  const authorId = "id" in auth ? auth.id : null;
15561
15981
  const newPatchIds = [];
15562
- /*
15563
- * The group the content API put these patches in.
15564
- *
15565
- * Every patch in one request has the same author and the same
15566
- * membership, so the last answer is the answer — they all land in the
15567
- * same group. Reported back because the client cannot learn it any
15568
- * other way: it names no group (the content API resolves the author's
15569
- * open one, creating it if absent), and the chain annotation is only
15570
- * re-read when a fetch has missing ids to ask for.
15571
- */
15572
- let patchGroupIdFromStore;
15573
15982
  /*
15574
15983
  * The chain version the LAST patch made — the head this batch leaves.
15575
15984
  * The client keeps the highest version it has seen, so a `/stat` read
@@ -15585,18 +15994,12 @@ const ValServer = (valModules, options, callbacks) => {
15585
15994
  const savedSoFar = () => ({
15586
15995
  newPatchIds,
15587
15996
  parentRef,
15588
- // Absent rather than null where there are no groups: `fs` mode and
15589
- // a content API that predates them both answer without one, and the
15590
- // client reads absence as "staging is not available here".
15591
- ...(patchGroupIdFromStore !== undefined ? {
15592
- patchGroupId: patchGroupIdFromStore
15593
- } : {}),
15594
15997
  ...(headVersion !== undefined ? {
15595
15998
  headVersion
15596
15999
  } : {})
15597
16000
  });
15598
16001
  for (const patch of patches) {
15599
- const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
16002
+ const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
15600
16003
  if (fp.result.isErr(createPatchRes)) {
15601
16004
  if (createPatchRes.error.errorType === "patch-head-conflict") {
15602
16005
  return {
@@ -15645,9 +16048,6 @@ const ValServer = (valModules, options, callbacks) => {
15645
16048
  patchId: createPatchRes.value.patchId
15646
16049
  };
15647
16050
  newPatchIds.push(createPatchRes.value.patchId);
15648
- if (createPatchRes.value.patchGroupId !== undefined) {
15649
- patchGroupIdFromStore = createPatchRes.value.patchGroupId;
15650
- }
15651
16051
  headVersion = createPatchRes.value.headVersion;
15652
16052
  }
15653
16053
  }
@@ -15714,84 +16114,16 @@ const ValServer = (valModules, options, callbacks) => {
15714
16114
  }
15715
16115
  // TODO: we should sort by parentRef instead:
15716
16116
  patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
15717
- /**
15718
- * Patch groups, ANNOTATED onto the chain rather than filtering it.
15719
- *
15720
- * The client computes a new patch's parent as the last id in this
15721
- * response, so a filtered chain would make every client name a parent
15722
- * that is not the real head and `POST /patches` would answer 409
15723
- * forever. Annotate, never filter.
15724
- *
15725
- * Absent — not empty — where there are no groups: `fs` mode, a content
15726
- * API that predates them, or a failed lookup. The client reads absence
15727
- * as "this deployment has no groups" and leaves staging off, which is
15728
- * the behaviour every project has today. An empty array would say
15729
- * "groups exist and hold nothing", which would turn the staging UI on
15730
- * with everything held.
15731
- */
15732
- let patchGroups;
15733
- if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
15734
- /*
15735
- * FRESH, because this answer makes a CLOSING decision.
15736
- *
15737
- * The client adopts this annotation as its group membership and
15738
- * `emptiesOwnPatchGroup` then decides from it whether a publish may
15739
- * name the group — and the content API closes what it is named
15740
- * without checking. A one-second-old list is enough to get that
15741
- * wrong: the same author writing in a second tab joins the open
15742
- * group, the websocket pushes the chain immediately, so this tab's
15743
- * fetch for the missing patch lands well inside the cache window and
15744
- * reads a membership that is one patch short. It then publishes,
15745
- * names the group, and closes it with the other tab's work still in
15746
- * it — which leaves that tab wedged on 409 until a reload.
15747
- *
15748
- * `resolveOwnPatchScope` keeps the cache deliberately: that read
15749
- * decides what a draft render SHOWS, it is repeated once per
15750
- * `fetchVal` in a single render, and being a second stale there costs
15751
- * a patch appearing late rather than a group closing early.
15752
- */
15753
- const groupsRes = await serverOps.getPatchGroups({
15754
- fresh: true
15755
- });
15756
- if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
15757
- patchGroups = groupsRes.patchGroups;
15758
- } else if (groupsRes.status === "error") {
15759
- // Not fatal: the chain is what this endpoint is for, and staging
15760
- // simply stays off for this read rather than the whole review
15761
- // screen failing to load.
15762
- console.error("Val: could not read patch groups", groupsRes.message);
15763
- }
15764
- }
15765
- const groupIdsByPatchId = new Map();
15766
- for (const group of patchGroups ?? []) {
15767
- for (const patchId of group.patchIds) {
15768
- const existing = groupIdsByPatchId.get(patchId);
15769
- if (existing) {
15770
- existing.push(group.patchGroupId);
15771
- } else {
15772
- groupIdsByPatchId.set(patchId, [group.patchGroupId]);
15773
- }
15774
- }
15775
- }
15776
16117
  return {
15777
16118
  status: 200,
15778
16119
  json: {
15779
- patches: patches.map(patch => {
15780
- const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
15781
- return patchGroupIds ? {
15782
- ...patch,
15783
- patchGroupIds
15784
- } : patch;
15785
- }),
15786
- ...(patchGroups ? {
15787
- patchGroups
15788
- } : {}),
16120
+ patches,
15789
16121
  baseSha: await serverOps.getBaseSha()
15790
16122
  }
15791
16123
  };
15792
16124
  },
15793
16125
  DELETE: async req => {
15794
- var _req$body, _req$body2;
16126
+ var _req$body;
15795
16127
  const query = req.query;
15796
16128
  const cookies = req.cookies;
15797
16129
  const auth = getAuth(cookies);
@@ -15812,25 +16144,7 @@ const ValServer = (valModules, options, callbacks) => {
15812
16144
  };
15813
16145
  }
15814
16146
  const ids = query.id;
15815
- /*
15816
- * Which OTHER patches lose their group membership because these are
15817
- * going. Only the client can COMPUTE it — that needs the patch sets,
15818
- * which need the schema — but it is bounded here rather than trusted,
15819
- * because the content API strips those memberships from every group
15820
- * without an ownership check. See `boundUnstageClosure`.
15821
- *
15822
- * Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
15823
- * client does not send it there.
15824
- */
15825
- let unstagePatchIds;
15826
- const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
15827
- if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
15828
- const chain = await serverOps.fetchPatches({
15829
- excludePatchOps: true
15830
- });
15831
- unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
15832
- }
15833
- const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
16147
+ const deleteRes = await serverOps.deletePatches(ids);
15834
16148
  if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
15835
16149
  console.error("Val: Failed to delete patches", deleteRes.errors);
15836
16150
  return {
@@ -15851,7 +16165,7 @@ const ValServer = (valModules, options, callbacks) => {
15851
16165
  }
15852
16166
  };
15853
16167
  }
15854
- if (((_req$body2 = req.body) === null || _req$body2 === void 0 ? void 0 : _req$body2.reportHeadVersion) === true) {
16168
+ if (((_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.reportHeadVersion) === true) {
15855
16169
  return {
15856
16170
  status: 200,
15857
16171
  json: {
@@ -15962,22 +16276,6 @@ const ValServer = (valModules, options, callbacks) => {
15962
16276
  } = req.query;
15963
16277
  // Defaults to true, mirroring /sources/~. The Studio opts out.
15964
16278
  const applyPatches = req.query.apply_patches !== false;
15965
- /*
15966
- * Whose pending work this render may see.
15967
- *
15968
- * The same resolution `/sources/~` runs, through the same function: a
15969
- * draft page renders module content and `jsonValues` entries together,
15970
- * and this route used to apply every pending patch on the branch while
15971
- * the modules beside it were scoped — so one screen showed the caller's
15972
- * view and everybody's unpublished work at once.
15973
- */
15974
- const {
15975
- ownPatchIds
15976
- } = await resolveOwnPatchScope(serverOps, {
15977
- explicitPatchIds: undefined,
15978
- ownGroupsOnly: req.query.own_patch_groups_only === true,
15979
- authorId: "id" in auth && auth.id || undefined
15980
- });
15981
16279
  const isWindow = offset !== undefined || limit !== undefined;
15982
16280
  const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
15983
16281
  if (shapes !== 1) {
@@ -15998,8 +16296,7 @@ const ValServer = (valModules, options, callbacks) => {
15998
16296
  }
15999
16297
  if (key !== undefined) {
16000
16298
  const res = await serverOps.getJsonEntry(moduleFilePath, key, {
16001
- applyPatches,
16002
- patchIds: ownPatchIds
16299
+ applyPatches
16003
16300
  });
16004
16301
  if (res.status === "unauthorized") {
16005
16302
  return {
@@ -16040,8 +16337,7 @@ const ValServer = (valModules, options, callbacks) => {
16040
16337
  offset: offset,
16041
16338
  limit: limit
16042
16339
  }, {
16043
- applyPatches,
16044
- patchIds: ownPatchIds
16340
+ applyPatches
16045
16341
  });
16046
16342
  if (res.status === "unauthorized") {
16047
16343
  return {
@@ -16126,101 +16422,10 @@ const ValServer = (valModules, options, callbacks) => {
16126
16422
  patches: []
16127
16423
  };
16128
16424
  if (query.exclude_patches !== true) {
16129
- /**
16130
- * The caller's own groups, resolved from their session.
16131
- *
16132
- * A draft render cannot name its own group ids — it has no client
16133
- * state — so it asks for "mine" and the server works out which. Only
16134
- * when `patch_id` is absent: an explicit list is a caller that already
16135
- * knows what it wants.
16136
- *
16137
- * A group lookup that FAILS renders base rather than everything. Being
16138
- * shown only committed content while the content API is unreachable is
16139
- * a degraded preview; being shown another author's unpublished draft
16140
- * because a lookup failed is the bug this feature exists to prevent,
16141
- * and it would be silent.
16142
- */
16143
- const {
16144
- ownPatchIds,
16145
- scopeAlsoIncludesApplied
16146
- } = await resolveOwnPatchScope(serverOps, {
16147
- explicitPatchIds: query.patch_id,
16148
- ownGroupsOnly: query.own_patch_groups_only === true,
16149
- authorId: "id" in auth && auth.id || undefined
16425
+ patchOps = await serverOps.fetchPatches({
16426
+ patchIds: undefined,
16427
+ excludePatchOps: false
16150
16428
  });
16151
- const requestedPatchIds = query.patch_id ?? ownPatchIds;
16152
- if (scopeAlsoIncludesApplied) {
16153
- /*
16154
- * Scoped to this caller's groups, PLUS everything already
16155
- * committed.
16156
- *
16157
- * Filtered here rather than through `patchIds`, because the set is
16158
- * not knowable before the fetch: `appliedAt` lives on the patch,
16159
- * not on the group. One request either way — the whole chain is
16160
- * what the unscoped path fetches too — so this costs a filter, not
16161
- * a round trip.
16162
- *
16163
- * This also subsumes the empty-group case below: a caller holding
16164
- * nothing, on a branch with nothing applied, filters down to no
16165
- * patches, which is base.
16166
- */
16167
- const all = await serverOps.fetchPatches({
16168
- patchIds: undefined,
16169
- excludePatchOps: false
16170
- });
16171
- /*
16172
- * Membership from THIS response when it carries one, not from the
16173
- * group list `resolveOwnPatchScope` read.
16174
- *
16175
- * That list is a separate request, remembered for a second
16176
- * (`ValOpsHttp.getPatchGroups`), so it can be older than the chain
16177
- * fetched here: an editor who saved and reloaded at once was
16178
- * rendered without the patch they had just written, because the
16179
- * list still had their group without it. The content service reads
16180
- * the chain and each patch's groups in one transaction, and
16181
- * `fetchPatches` folds that onto `patchGroups` -- so the patches
16182
- * and who owns them come from the same moment.
16183
- */
16184
- const own = ownPatchScopeFor(all, "id" in auth && auth.id || undefined, ownPatchIds);
16185
- patchOps = {
16186
- ...all,
16187
- patches: scopedPatches(all.patches, own)
16188
- };
16189
- } else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
16190
- /*
16191
- * A group that holds nothing renders base, and is handled HERE.
16192
- *
16193
- * `fetchPatches` cannot express it: both implementations read an
16194
- * empty `patchIds` as "no filter" and return the whole chain
16195
- * (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
16196
- * `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
16197
- * That is the right default for every caller that has ever passed a
16198
- * list, since none of them can mean "none" — but it is the most
16199
- * dangerous possible reading of an EXPLICITLY empty group, which
16200
- * would render every unpublished patch on the branch instead of
16201
- * base.
16202
- *
16203
- * Answered before the call rather than by changing that shared
16204
- * default, which seven other call sites rely on.
16205
- */
16206
- patchOps = {
16207
- patches: []
16208
- };
16209
- } else {
16210
- patchOps = await serverOps.fetchPatches({
16211
- /*
16212
- * The caller's patch group, when it named one.
16213
- *
16214
- * `undefined` means every pending patch, which is what every
16215
- * existing caller gets and has to keep getting. A draft-mode
16216
- * render that names its group gets base + that group instead, so
16217
- * a server-rendered preview shows the same thing the person
16218
- * editing is looking at rather than everybody's unpublished work.
16219
- */
16220
- patchIds: requestedPatchIds,
16221
- excludePatchOps: false
16222
- });
16223
- }
16224
16429
  }
16225
16430
  // We check authorization here, because it is the first call to the backend
16226
16431
  if (patchOps.error && patchOps.unauthorized) {
@@ -16930,44 +17135,6 @@ const ValServer = (valModules, options, callbacks) => {
16930
17135
  };
16931
17136
  } else if (serverOps instanceof ValOpsHttp) {
16932
17137
  if (auth.error === undefined && auth.id) {
16933
- /*
16934
- * The group this commit CLOSES has to be the caller's.
16935
- *
16936
- * Stage and unstage go through `refuseUnlessOwn`; this route did
16937
- * not, and the content API's `postCommit` marks the group published
16938
- * on id alone with no author clause — so the id was trusted twice
16939
- * and checked nowhere. Any logged-in editor could name a colleague's
16940
- * open group and close it: their pending patches land in a closed
16941
- * group and in no open one, so a scoped draft render shows base for
16942
- * them, and their own tab still believes the group is open, so their
16943
- * next stage is refused with 409.
16944
- *
16945
- * Same hole as the stage/unstage one this branch already closed, one
16946
- * route over. The content API needs the same guard — this one is the
16947
- * convenience, that one is what actually holds.
16948
- */
16949
- if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
16950
- const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
16951
- if (refusal !== null) {
16952
- return {
16953
- status: refusal.status,
16954
- json: {
16955
- message: refusal.message,
16956
- /*
16957
- * Flagged, because a bare 409 here reads as git refusing
16958
- * the commit — which is retryable, and this is the
16959
- * opposite. `refuseUnlessOwn` answers 409 for one reason
16960
- * only: the group has already been published, so its id
16961
- * will never be writable again and retrying reproduces
16962
- * this forever. The client forgets the id instead.
16963
- */
16964
- ...(refusal.status === 409 ? {
16965
- patchGroupPublished: true
16966
- } : {})
16967
- }
16968
- };
16969
- }
16970
- }
16971
17138
  const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
16972
17139
  /*
16973
17140
  * Before the commit, because after it the files are no longer the
@@ -16975,14 +17142,7 @@ const ValServer = (valModules, options, callbacks) => {
16975
17142
  */
16976
17143
  const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
16977
17144
  const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
16978
- const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined,
16979
- /*
16980
- * Forwarded verbatim, and only the client can decide it: the
16981
- * content API closes the group it is named without checking
16982
- * that the commit shipped all of it, and whether it did needs
16983
- * the patch sets, which live in the browser.
16984
- */
16985
- body.patchGroupId);
17145
+ const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined);
16986
17146
  /*
16987
17147
  * A host may publish somewhere other than the repository.
16988
17148
  *
@@ -16995,7 +17155,6 @@ const ValServer = (valModules, options, callbacks) => {
16995
17155
  preparedCommit,
16996
17156
  message,
16997
17157
  authorId: auth.id,
16998
- patchGroupId: body.patchGroupId,
16999
17158
  commitToGit
17000
17159
  }) : await commitToGit();
17001
17160
  if (commitRes.error) {
@@ -18048,268 +18207,6 @@ const ValServer = (valModules, options, callbacks) => {
18048
18207
  }
18049
18208
  };
18050
18209
  };
18051
- /**
18052
- * Refuse to touch a group that is not the caller's.
18053
- *
18054
- * Exported for `patchGroupOwnership.test.ts`: this is the whole of the
18055
- * authorization for stage and unstage, and nothing else in the process checks
18056
- * it, so it is worth testing as a policy rather than only through a route.
18057
- *
18058
- * `getAuth` only proves a session EXISTS; it says nothing about whose
18059
- * group this is. And the content API cannot decide either — every call
18060
- * from here carries the app's API key, not the editor's identity — so if
18061
- * this does not check, nothing does.
18062
- *
18063
- * `GET /patches?include_patch_groups=true` hands every editor the id and
18064
- * author of every open group on the branch, so without this any logged-in
18065
- * editor can unstage another author's patches (their next publish
18066
- * silently ships less) or stage into their group (it silently ships
18067
- * more). The 403 declared for this route in `ApiRoutes.ts` was
18068
- * unreachable.
18069
- *
18070
- * Fails CLOSED: if the groups cannot be read, the mutation is refused
18071
- * rather than allowed unverified.
18072
- */
18073
-
18074
- /**
18075
- * The patches a scoped draft render should apply: the caller's own group, plus
18076
- * everything already committed.
18077
- *
18078
- * Scoping is about PENDING work. A published patch stays in the chain with
18079
- * `appliedAt` set until the next deployment moves the base, and it is part of
18080
- * everyone's view in that window — the unscoped path applies it. Dropping it
18081
- * meant the moment somebody published, their own draft preview reverted the
18082
- * field they had just shipped, and nobody else saw it either until the deploy
18083
- * landed; anything written on top in that window is authored against content
18084
- * already stale on `main`.
18085
- *
18086
- * Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
18087
- * PARTIAL publish leaves the group open with only some of its patches applied.
18088
- * Those are committed too, and no flag on the group names them.
18089
- *
18090
- * `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
18091
- * caller holding nothing, and on a branch with nothing applied it correctly
18092
- * filters down to no patches, which renders base.
18093
- */
18094
- function scopedPatches(patches, ownPatchIds) {
18095
- const own = new Set(ownPatchIds ?? []);
18096
- return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
18097
- }
18098
-
18099
- /**
18100
- * Which of the client's `unstagePatchIds` this server is willing to forward.
18101
- *
18102
- * The forward closure of a discard is the client's to compute — it needs the
18103
- * patch sets, which need the schema — and it was being forwarded verbatim. But
18104
- * the content API removes those memberships from EVERY group with no ownership
18105
- * check, so any logged-in editor could strip arbitrary patches out of any other
18106
- * author's group by attaching them to a delete of one of their own throwaway
18107
- * patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
18108
- * reached by a different door: their next publish silently ships less.
18109
- *
18110
- * Neither server can compute the true closure, but this one can BOUND it. A
18111
- * patch can only be invalidated by a delete if it was written after that delete
18112
- * — its paths were chosen against a view that had it — and if it is in the same
18113
- * module, since a patch set never spans two. Anything outside those bounds was
18114
- * not in the closure whatever the client says, so it is dropped rather than
18115
- * refused: the delete is still correct, and refusing the whole request over an
18116
- * over-broad extra would turn a discard into an error the user cannot act on.
18117
- *
18118
- * Exported for the test. Pure, and given the chain rather than fetching it, so
18119
- * the ordering it depends on is visible in the test rather than mocked.
18120
- */
18121
- function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
18122
- chain, deleted, requested) {
18123
- if (requested.length === 0) {
18124
- return [];
18125
- }
18126
- const positionOf = new Map();
18127
- const moduleOf = new Map();
18128
- chain.forEach((entry, index) => {
18129
- positionOf.set(entry.patchId, index);
18130
- moduleOf.set(entry.patchId, entry.path);
18131
- });
18132
- const doomed = new Set(deleted);
18133
- return requested.filter(patchId => {
18134
- // A patch being deleted anyway does not need its membership stripped
18135
- // separately, and naming one is how an over-broad list hides.
18136
- if (doomed.has(patchId)) return false;
18137
- const position = positionOf.get(patchId);
18138
- const moduleFilePath = moduleOf.get(patchId);
18139
- if (position === undefined || moduleFilePath === undefined) return false;
18140
- return deleted.some(deletedId => {
18141
- const deletedPosition = positionOf.get(deletedId);
18142
- if (deletedPosition === undefined) return false;
18143
- return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
18144
- });
18145
- });
18146
- }
18147
-
18148
- /**
18149
- * Which pending patches this caller may see, when they asked for "only mine".
18150
- *
18151
- * Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
18152
- * both, so two answers to "whose work is this" put one person's half-finished
18153
- * edit on another person's preview through whichever route was not scoped. That
18154
- * is exactly what happened — `/json` applied every pending patch on the branch
18155
- * while the module content beside it was scoped.
18156
- *
18157
- * `undefined` means "apply everything", which is what every caller that does
18158
- * not ask for scoping gets and must keep getting.
18159
- */
18160
- /** The patches in this author's OPEN groups: what a scoped draft shows. */
18161
- function ownPatchIdsIn(patchGroups, authorId) {
18162
- return patchGroups.filter(group => group.publishedAt === null && group.authorId === authorId).flatMap(group => group.patchIds);
18163
- }
18164
-
18165
- /**
18166
- * Whose pending patches a scoped draft shows, taken from the SAME response as
18167
- * the patches when it says, and from the list read beforehand only when not.
18168
- *
18169
- * See the call in `/sources/~`: the list read beforehand can be older than the
18170
- * chain, and the response's membership cannot be.
18171
- */
18172
- function ownPatchScopeFor(fetched, authorId, remembered) {
18173
- if (fetched.patchGroups === undefined || authorId === undefined) {
18174
- return remembered;
18175
- }
18176
- return ownPatchIdsIn(fetched.patchGroups, authorId);
18177
- }
18178
- async function resolveOwnPatchScope(
18179
- /*
18180
- * `ValOps`, not the two concrete stores: the one thing this needs is whether
18181
- * a content service holds the groups, and it asks that below with an
18182
- * `instanceof ValOpsHttp`. Naming the implementations here meant every new
18183
- * store had to be added to a list to be allowed to call a function that does
18184
- * not use it.
18185
- */
18186
- serverOps, opts) {
18187
- let ownPatchIds;
18188
- /** See where this is set: committed work is nobody's to hold back. */
18189
- let scopeAlsoIncludesApplied = false;
18190
- if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
18191
- if (serverOps instanceof ValOpsHttp && opts.authorId) {
18192
- const groupsRes = await serverOps.getPatchGroups();
18193
- if (groupsRes.status === "unsupported") {
18194
- /*
18195
- * A content API that PREDATES patch groups — the endpoint 404s.
18196
- *
18197
- * Unscoped, which is what those deployments do today and must
18198
- * keep doing. Reading this as a failure and rendering base
18199
- * would silently drop every pending patch from every draft
18200
- * preview on every existing http project — the exact opposite
18201
- * of "keeps working unchanged", and invisible to the reader.
18202
- */
18203
- ownPatchIds = undefined;
18204
- } else if (groupsRes.status === "error") {
18205
- /*
18206
- * We could not ask, and this deployment DOES have the endpoint.
18207
- * Render base rather than everything: a degraded preview is
18208
- * recoverable, showing another author's unpublished draft is
18209
- * not, and it would be silent.
18210
- */
18211
- ownPatchIds = [];
18212
- } else if (groupsRes.patchGroups.length === 0) {
18213
- /*
18214
- * The branch has no groups AT ALL, so this deployment is not
18215
- * using them — a content API that predates patch groups, or a
18216
- * project where nobody has staged anything since they existed.
18217
- *
18218
- * Unscoped, which is the behaviour every such project has
18219
- * today. Collapsing this into "your group is empty" would make
18220
- * every draft render base and silently drop all pending
18221
- * content, which is what happened before this branch: nothing
18222
- * writes a group yet, so EVERY project is in this state right
18223
- * now.
18224
- */
18225
- ownPatchIds = undefined;
18226
- } else {
18227
- /*
18228
- * Groups exist and none are this person's: they have staged
18229
- * nothing, and base is the honest answer. Distinct from the
18230
- * case above, which is why the two are not one expression.
18231
- */
18232
- ownPatchIds = ownPatchIdsIn(groupsRes.patchGroups, opts.authorId);
18233
- /*
18234
- * Scoping applies to PENDING work only. Anything already
18235
- * committed is part of everyone's view.
18236
- *
18237
- * A published patch stays in the chain with `appliedAt` set
18238
- * until the next deployment moves the base, and the unscoped
18239
- * path applies it. Filtering to open groups dropped it — so the
18240
- * moment someone published, their own draft preview reverted
18241
- * the field they had just shipped, and nobody else saw it
18242
- * either until the deploy landed. Anything written on top in
18243
- * that window is authored against content that is already
18244
- * stale on `main`.
18245
- *
18246
- * Unioned by `appliedAt` rather than by pulling in groups with
18247
- * a `publishedAt`, because a partial publish leaves the group
18248
- * OPEN with some of its patches applied — those are committed
18249
- * too, and no group flag names them.
18250
- */
18251
- scopeAlsoIncludesApplied = true;
18252
- }
18253
- } else {
18254
- /*
18255
- * fs mode, or a server with no groups: there is nothing to scope
18256
- * BY, and every pending patch is this one person's anyway. Left
18257
- * `undefined` so the existing "apply everything" path runs.
18258
- */
18259
- ownPatchIds = undefined;
18260
- }
18261
- }
18262
- return {
18263
- ownPatchIds,
18264
- scopeAlsoIncludesApplied
18265
- };
18266
- }
18267
- async function refuseUnlessOwn(ops, patchGroupId, authorId) {
18268
- /*
18269
- * Never from the cache. This answers "is this group yours", and a group is at
18270
- * its youngest exactly when the question is asked — the first write creates
18271
- * it and the shell flushes its queued stages the moment the save response
18272
- * names it. A cached list fetched a few hundred milliseconds earlier does not
18273
- * contain it, and every one of those stages was refused and dropped.
18274
- */
18275
- const groupsRes = await ops.getPatchGroups({
18276
- fresh: true
18277
- });
18278
- if (groupsRes.status !== "ok") {
18279
- return {
18280
- status: 500,
18281
- message: "Could not verify that this patch group is yours, so it was not changed."
18282
- };
18283
- }
18284
- const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
18285
- if (group === undefined || group.authorId === null || group.authorId !== authorId) {
18286
- /*
18287
- * "Not found" and "not yours" are the SAME refusal on purpose: the route
18288
- * schema has no 404, and `GET /patches` already lists every group on the
18289
- * branch, so distinguishing them hides nothing and only adds a second
18290
- * message to keep consistent.
18291
- *
18292
- * A null author is a group written by an api key or a PAT. Nobody owns it,
18293
- * so nobody may stage into it — `null === null` must not read as a match.
18294
- */
18295
- return {
18296
- status: 403,
18297
- message: "You can only change your own patch group"
18298
- };
18299
- }
18300
- if (group.publishedAt !== null) {
18301
- /*
18302
- * Already shipped, so it can never be written again. The content API
18303
- * answers this too; refusing here saves the round trip and keeps the
18304
- * wording the same as every other refusal on this route.
18305
- */
18306
- return {
18307
- status: 409,
18308
- message: "Patch group is already published"
18309
- };
18310
- }
18311
- return null;
18312
- }
18313
18210
  function verifyCallbackReq(stateCookie, queryParams) {
18314
18211
  if (typeof stateCookie !== "string") {
18315
18212
  return {