@valbuild/server 0.146.1 → 0.146.4

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.
@@ -9711,6 +9711,45 @@ function mergeFromSite(input) {
9711
9711
  };
9712
9712
  }
9713
9713
 
9714
+ /** A change pending on the site, in chain order. */
9715
+
9716
+ /**
9717
+ * The site's pending changes that conflict with going from `site` to
9718
+ * `merged`, as whole patch sets: each inner list moves together, into a
9719
+ * proposal of its own. Everything else stays pending.
9720
+ *
9721
+ * Patch sets are the Studio's own (`PatchSets`), from the schema the changes
9722
+ * were written against; sets that share a change are one, because a change
9723
+ * cannot be split. A set moves when, taken as the site's side of a three-way
9724
+ * merge with the merged commit as the other, it conflicts (`mergeValues`, so
9725
+ * the same value on both sides moves too); or when it no longer applies to
9726
+ * the merged commit, or leaves it invalid under the merged schema.
9727
+ */
9728
+ function changesToMove(input) {
9729
+ const {
9730
+ site,
9731
+ merged,
9732
+ pending
9733
+ } = input;
9734
+ const sets = new internal.PatchSets();
9735
+ for (const change of pending) {
9736
+ var _site$modules$change$;
9737
+ sets.insert(change.moduleFilePath, (_site$modules$change$ = site.modules[change.moduleFilePath]) === null || _site$modules$change$ === void 0 ? void 0 : _site$modules$change$.schema, change.patch, change.patchId, "", null);
9738
+ }
9739
+ const groups = internal.joinSharedChanges(sets.serialize().map(set => set.patches.map(p => p.patchId)));
9740
+ const moves = [];
9741
+ for (const group of groups) {
9742
+ const changes = pending.filter(change => group.has(change.patchId));
9743
+ if (conflictsWithMerge(changes, site, merged)) {
9744
+ moves.push(changes.map(change => change.patchId));
9745
+ }
9746
+ }
9747
+ // In chain order, so the moved proposals are opened in the order the
9748
+ // changes were written.
9749
+ const order = ids => pending.findIndex(change => change.patchId === ids[0]);
9750
+ return moves.sort((a, b) => order(a) - order(b));
9751
+ }
9752
+
9714
9753
  // --- the pieces --------------------------------------------------------------
9715
9754
 
9716
9755
  /**
@@ -9743,6 +9782,39 @@ function resolveContent(moduleFilePath, merged, resolutions) {
9743
9782
  unresolved
9744
9783
  };
9745
9784
  }
9785
+ function conflictsWithMerge(changes, site, merged) {
9786
+ const modules = [...new Set(changes.map(change => change.moduleFilePath))];
9787
+ for (const moduleFilePath of modules) {
9788
+ var _site$modules$moduleF;
9789
+ const ops = changes.filter(change => change.moduleFilePath === moduleFilePath).flatMap(change => change.patch).filter(op => op.op !== "file");
9790
+ const before = (_site$modules$moduleF = site.modules[moduleFilePath]) === null || _site$modules$moduleF === void 0 ? void 0 : _site$modules$moduleF.source;
9791
+ const after = merged.modules[moduleFilePath];
9792
+ if (before === undefined || after === undefined) return true;
9793
+ const written = apply(before, ops);
9794
+ if (written === undefined) return true;
9795
+ const mergedSource = after.source;
9796
+ const atomic = atomicFor(after.schema);
9797
+ if (mergeValues(before, written, mergedSource, atomic).conflicts.length) {
9798
+ return true;
9799
+ }
9800
+ // Over the merged commit: it applies, and is as valid as it was.
9801
+ const over = apply(mergedSource, ops);
9802
+ if (over === undefined) return true;
9803
+ const known = new Set(errorPaths(moduleFilePath, after, mergedSource));
9804
+ if (errorPaths(moduleFilePath, after, over).some(path => !known.has(path))) {
9805
+ return true;
9806
+ }
9807
+ }
9808
+ return false;
9809
+ }
9810
+ function apply(source, ops) {
9811
+ if (source === undefined) return undefined;
9812
+ const applied = patch.applyPatch(patch.deepClone(source), new patch.JSONOps(), ops);
9813
+ return fp.result.isOk(applied) ? applied.value : undefined;
9814
+ }
9815
+ function errorPaths(moduleFilePath, module, source) {
9816
+ return validateModule(moduleFilePath, module, source).map(error => error.path.join("/"));
9817
+ }
9746
9818
 
9747
9819
  /**
9748
9820
  * The schema check of a merge: content in `merged` -- the project
@@ -10106,6 +10178,105 @@ function sameJson(a, b) {
10106
10178
  return JSON.stringify(a) === JSON.stringify(b);
10107
10179
  }
10108
10180
 
10181
+ /**
10182
+ * Which of the site's pending changes a merge of a proposal moves into
10183
+ * proposals of their own (valbuild/home `docs/hosted-publishing.md`, step 8.2,
10184
+ * decision 4), from what content hands over (`GET /proposals/:name/move-inputs`)
10185
+ * and this server's schemas.
10186
+ *
10187
+ * The schemas are the ones this server runs: the proposal's own build at its
10188
+ * address, which is the code the merge publishes. They stand in for the site's
10189
+ * too, to group the site's changes into patch sets -- the same unless the
10190
+ * proposal changed that module's schema, in which case a change written
10191
+ * against the old one is checked against the new one, which is what the
10192
+ * merge leaves it under anyway.
10193
+ *
10194
+ * A page this server has no schema for is moved whole: what cannot be checked
10195
+ * is not left on the site to break.
10196
+ */
10197
+
10198
+ const PatchIdOf = z.z.string().refine(s => !!s);
10199
+ const ModuleFilePathOf = z.z.string().refine(s => s.startsWith("/"));
10200
+
10201
+ /**
10202
+ * Content's `move-inputs` answer, as it is parsed: the one definition, so the
10203
+ * parser and what `movesAtMerge` reads cannot drift apart.
10204
+ */
10205
+ const MoveInputs = z.z.object({
10206
+ /** The proposal's last save; absent from a content service before it. */
10207
+ save: z.z.string().nullable().optional(),
10208
+ siteHead: z.z.string().nullable(),
10209
+ pending: z.z.array(z.z.object({
10210
+ patchId: PatchIdOf,
10211
+ moduleFilePath: ModuleFilePathOf,
10212
+ patch: internal.Patch,
10213
+ author: z.z.string().nullable(),
10214
+ authorName: z.z.string().nullable()
10215
+ })),
10216
+ modules: z.z.record(z.z.string(), z.z.object({
10217
+ site: internal.JSONValue.nullable(),
10218
+ merged: internal.JSONValue.nullable()
10219
+ }))
10220
+ });
10221
+
10222
+ /** What the Publish press carries (`MergeBody.moves` in content), and who moves. */
10223
+
10224
+ function movesAtMerge(inputs, schemas) {
10225
+ const site = {
10226
+ files: {},
10227
+ modules: {}
10228
+ };
10229
+ const merged = {
10230
+ files: {},
10231
+ modules: {}
10232
+ };
10233
+ // The pages of the pending changes, which are the pages content answered.
10234
+ for (const moduleFilePath of new Set(inputs.pending.map(change => change.moduleFilePath))) {
10235
+ const page = inputs.modules[moduleFilePath];
10236
+ if (page === undefined) continue;
10237
+ const {
10238
+ site: before,
10239
+ merged: after
10240
+ } = page;
10241
+ const schema = schemas[moduleFilePath];
10242
+ if (schema === undefined) continue;
10243
+ const serialized = schema["executeSerialize"]();
10244
+ const validate = source => validateWith(schema, moduleFilePath, source);
10245
+ if (before !== null) {
10246
+ site.modules[moduleFilePath] = {
10247
+ source: before,
10248
+ schema: serialized,
10249
+ entries: null,
10250
+ validate
10251
+ };
10252
+ }
10253
+ if (after !== null) {
10254
+ merged.modules[moduleFilePath] = {
10255
+ source: after,
10256
+ schema: serialized,
10257
+ entries: null,
10258
+ validate
10259
+ };
10260
+ }
10261
+ }
10262
+ const groups = changesToMove({
10263
+ site,
10264
+ merged,
10265
+ pending: inputs.pending.map(change => ({
10266
+ patchId: change.patchId,
10267
+ moduleFilePath: change.moduleFilePath,
10268
+ patch: change.patch
10269
+ }))
10270
+ });
10271
+ const nameOf = new Map(inputs.pending.map(change => [change.patchId, change.authorName ?? "Someone"]));
10272
+ return {
10273
+ siteHead: inputs.siteHead,
10274
+ considered: inputs.pending.map(change => change.patchId),
10275
+ groups,
10276
+ authors: groups.map(group => [...new Set(group.map(id => nameOf.get(id) ?? "Someone"))])
10277
+ };
10278
+ }
10279
+
10109
10280
  const textEncoder$2 = new TextEncoder();
10110
10281
 
10111
10282
  /** The body of the content API's 403 for a write the project's plan refuses. */
@@ -10151,6 +10322,8 @@ const GetApplicablePatches = z.z.object({
10151
10322
  * Optional for the same reason.
10152
10323
  */
10153
10324
  headVersion: z.z.number().optional(),
10325
+ /** The branch's last commit. See `OrderedPatches.headCommitSha`. */
10326
+ headCommitSha: z.z.string().nullable().optional(),
10154
10327
  /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
10155
10328
  publishingPatches: z.z.array(internal.PatchId).optional(),
10156
10329
  /** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
@@ -10743,6 +10916,19 @@ class ValOpsHttp extends ValOps {
10743
10916
  };
10744
10917
  }
10745
10918
 
10919
+ /**
10920
+ * adept, through content: lockfiles, upgrades and type bundles, for the
10921
+ * Studio's unlinked `/val/adept` page. A job is content's to scope to
10922
+ * this project; a name and a version are matched, not passed, so nothing
10923
+ * with a `..` reaches another of content's routes.
10924
+ *
10925
+ * Allowed at a proposal's address too: adept computes and publishes
10926
+ * nothing, so there is nothing a proposal's Studio could move on the site.
10927
+ */
10928
+ static adeptPathAllowed(path) {
10929
+ return /^\/adept\/(lockfile|upgrade|jobs\/\d{1,18}(\/result)?|compiler\/[0-9A-Za-z][0-9A-Za-z.+-]{0,63})$/.test(path) || /^\/adept\/types\/public\/(@[a-z0-9~-][a-z0-9._~-]{0,213}\/)?[a-z0-9~-][a-z0-9._~-]{0,213}@[0-9a-z][0-9a-z.+-]{0,63}$/i.test(path);
10930
+ }
10931
+
10746
10932
  /**
10747
10933
  * The content paths this may reach, and nothing else.
10748
10934
  *
@@ -10785,6 +10971,7 @@ class ValOpsHttp extends ValOps {
10785
10971
  if (/^\/publish-requests(\/[A-Za-z0-9_-]{1,100})?$/.test(path)) {
10786
10972
  return true;
10787
10973
  }
10974
+ if (ValOpsHttp.adeptPathAllowed(path)) return true;
10788
10975
  if (/^\/publish-jobs\/(next|[A-Za-z0-9_-]{1,100}\/(steps|renew|cancel|discard))$/.test(path)) {
10789
10976
  return true;
10790
10977
  }
@@ -10866,12 +11053,13 @@ class ValOpsHttp extends ValOps {
10866
11053
  return true;
10867
11054
  }
10868
11055
  if (/^\/publish\/[A-Za-z0-9_-]+\/artifacts$/.test(path)) return true;
11056
+ if (ValOpsHttp.adeptPathAllowed(path)) return true;
10869
11057
  // Where its press is -- read only: a POST there is the site's Try again.
10870
11058
  if (method === "GET" && path !== "/publish-requests/try-again" && /^\/publish-requests\/[A-Za-z0-9_-]{1,100}$/.test(path)) return true;
10871
11059
  return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
10872
11060
  }
10873
11061
  async proposalsApi(path, init, profileId) {
10874
- var _exec2, _exec3, _this$proposal;
11062
+ var _exec2, _exec3, _exec4, _exec5, _this$proposal;
10875
11063
  const json = "application/json";
10876
11064
  const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
10877
11065
  if (updating !== undefined && init.method === "POST") {
@@ -10881,6 +11069,20 @@ class ValOpsHttp extends ValOps {
10881
11069
  if (withCode !== undefined && init.method === "POST") {
10882
11070
  return this.updateCodeFromSite(withCode, init.body, profileId);
10883
11071
  }
11072
+ /*
11073
+ * Publish, and the checks before it, at the proposal's own address: Val
11074
+ * says which of the site's pending changes the merge moves, from this
11075
+ * build's schemas (step 8.2). Anywhere else they go to content as they
11076
+ * are, and the site's pending changes block as they always did.
11077
+ */
11078
+ const checking = (_exec4 = /^\/([0-9a-f]{20})\/merge-checks$/.exec(path)) === null || _exec4 === void 0 ? void 0 : _exec4[1];
11079
+ if (checking !== undefined && init.method === "GET" && (await this.answersMovesFor(checking, profileId))) {
11080
+ return this.mergeChecksWithMoves(checking, profileId);
11081
+ }
11082
+ const pressing = (_exec5 = /^\/([0-9a-f]{20})\/merge$/.exec(path)) === null || _exec5 === void 0 ? void 0 : _exec5[1];
11083
+ if (pressing !== undefined && init.method === "POST" && (await this.answersMovesFor(pressing, profileId))) {
11084
+ return this.pressMergeWithMoves(pressing, init.body, profileId);
11085
+ }
10884
11086
  if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
10885
11087
  return {
10886
11088
  status: 403,
@@ -11068,6 +11270,143 @@ class ValOpsHttp extends ValOps {
11068
11270
  return updatedAnswer(text);
11069
11271
  }
11070
11272
 
11273
+ /**
11274
+ * Whether this server says what a merge of `name` moves: at the proposal's
11275
+ * own address, whose schemas are the ones it publishes; and on the site, for
11276
+ * Publish selected's proposal, which changes no code -- so the site's
11277
+ * schemas are its own -- and has no address to be published from.
11278
+ */
11279
+ async answersMovesFor(name, profileId) {
11280
+ if (this.proposal !== null) return this.proposal.name === name;
11281
+ const read = await this.proposalContent(name, profileId)("");
11282
+ if (!read.ok) return false;
11283
+ const kind = ProposalKindAnswer.safeParse(await read.json().catch(() => null));
11284
+ return kind.success && kind.data.proposal.kind === "publish";
11285
+ }
11286
+
11287
+ /** Content's proposals API for one proposal, as the person asking. */
11288
+ proposalContent(name, profileId) {
11289
+ return (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
11290
+ ...init,
11291
+ headers: {
11292
+ ...this.authHeaders,
11293
+ ...(profileId !== null ? {
11294
+ "x-val-profile-id": profileId
11295
+ } : {}),
11296
+ ...(init.body === undefined ? {} : {
11297
+ "Content-Type": "application/json"
11298
+ })
11299
+ }
11300
+ });
11301
+ }
11302
+
11303
+ /**
11304
+ * Which of the site's pending changes a merge of this proposal moves
11305
+ * (`movesAtMerge`), or `null` when content could not say what is pending:
11306
+ * then nothing is claimed, and the site's pending changes block the merge.
11307
+ */
11308
+ async mergeMovesOf(name, profileId) {
11309
+ const read = await this.proposalContent(name, profileId)("/move-inputs");
11310
+ if (!read.ok) return null;
11311
+ const inputs = MoveInputs.safeParse(await read.json().catch(() => null));
11312
+ if (!inputs.success) return null;
11313
+ return {
11314
+ ...movesAtMerge(inputs.data, await this.getSchemas()),
11315
+ save: inputs.data.save
11316
+ };
11317
+ }
11318
+
11319
+ /**
11320
+ * The merge checks, with `pending-on-site` answered by what the merge
11321
+ * moves rather than by whether anything is pending: the conflicting
11322
+ * changes move to a proposal of their own when it is published, and the
11323
+ * rest stay, so neither blocks it.
11324
+ */
11325
+ async mergeChecksWithMoves(name, profileId) {
11326
+ const json = "application/json";
11327
+ const read = await this.proposalContent(name, profileId)("/merge-checks");
11328
+ const text = await read.text();
11329
+ const answer = {
11330
+ status: read.status,
11331
+ contentType: json,
11332
+ body: text
11333
+ };
11334
+ if (!read.ok) return answer;
11335
+ const parsed = MergeChecksAnswer.safeParse(parseJsonOrNull(text));
11336
+ if (!parsed.success) return answer;
11337
+ const pending = parsed.data.checks.find(check => check.id === "pending-on-site");
11338
+ if (pending === undefined || pending.ok) return answer;
11339
+ const moves = await this.mergeMovesOf(name, profileId);
11340
+ if (moves === null) return answer;
11341
+ const moving = moves.groups.flat().length;
11342
+ const names = [...new Set(moves.authors.flat())];
11343
+ const checks = parsed.data.checks.map(check => check.id !== "pending-on-site" ? check : {
11344
+ ...check,
11345
+ ok: true,
11346
+ blockers: [],
11347
+ message: moving === 0 ? "Unpublished changes on these pages do not conflict with this proposal, and stay unpublished" : `${moving === 1 ? "1 unpublished change" : `${moving} unpublished changes`} by ${namesOf(names)} ${moving === 1 ? "conflicts" : "conflict"} with this proposal, and ${moving === 1 ? "moves" : "move"} to ${moves.groups.length === 1 ? "a new proposal" : "new proposals"} when it is published`,
11348
+ moves: moves.groups.map((group, at) => ({
11349
+ patchIds: group,
11350
+ authors: moves.authors[at] ?? []
11351
+ }))
11352
+ });
11353
+ const othersOk = parsed.data.checks.every(check => check.ok || check.id === "pending-on-site");
11354
+ return {
11355
+ status: 200,
11356
+ contentType: json,
11357
+ body: JSON.stringify({
11358
+ ...parsed.data,
11359
+ checks,
11360
+ canMerge: parsed.data.canMerge || othersOk
11361
+ })
11362
+ };
11363
+ }
11364
+
11365
+ /** Publish in a proposal, carrying the moves (`MergeBody.moves` in content). */
11366
+ async pressMergeWithMoves(name, body, profileId) {
11367
+ const json = "application/json";
11368
+ const content = this.proposalContent(name, profileId);
11369
+ const forward = async sent => {
11370
+ const res = await content("/merge", {
11371
+ method: "POST",
11372
+ ...(sent === undefined ? {} : {
11373
+ body: sent
11374
+ })
11375
+ });
11376
+ return {
11377
+ status: res.status,
11378
+ contentType: res.headers.get("Content-Type") ?? json,
11379
+ body: await res.text()
11380
+ };
11381
+ };
11382
+ let pressed;
11383
+ try {
11384
+ pressed = body === undefined || body === "" ? {} : JSON.parse(body);
11385
+ } catch {
11386
+ return forward(body);
11387
+ }
11388
+ if (typeof pressed !== "object" || pressed === null) return forward(body);
11389
+ const moves = await this.mergeMovesOf(name, profileId);
11390
+ if (moves === null) return forward(body);
11391
+ return forward(JSON.stringify({
11392
+ /*
11393
+ * The save the moves were worked out against is the one this press
11394
+ * publishes: named, so a change written after the press -- which the
11395
+ * Studio lets an editor make at once -- continues in a new proposal
11396
+ * rather than refusing it as unsaved.
11397
+ */
11398
+ ...(typeof moves.save === "string" ? {
11399
+ save: moves.save
11400
+ } : {}),
11401
+ ...pressed,
11402
+ moves: {
11403
+ siteHead: moves.siteHead,
11404
+ considered: moves.considered,
11405
+ groups: moves.groups
11406
+ }
11407
+ }));
11408
+ }
11409
+
11071
11410
  /**
11072
11411
  * Update from the site, for a proposal with code (valbuild/home
11073
11412
  * `docs/hosted-publishing.md`, step 8.2, slice 1b), at
@@ -11845,6 +12184,9 @@ class ValOpsHttp extends ValOps {
11845
12184
  ...(data.headVersion !== undefined ? {
11846
12185
  headVersion: data.headVersion
11847
12186
  } : {}),
12187
+ ...(data.headCommitSha !== undefined ? {
12188
+ headCommitSha: data.headCommitSha
12189
+ } : {}),
11848
12190
  ...(data.publishingPatches !== undefined ? {
11849
12191
  publishingPatches: data.publishingPatches
11850
12192
  } : {}),
@@ -12289,15 +12631,22 @@ class ValOpsHttp extends ValOps {
12289
12631
  };
12290
12632
  });
12291
12633
  }
12292
- async commit(prepared, message, committer, filesDirectory, newBranch) {
12634
+ async commit(prepared, message, committer, filesDirectory, newBranch,
12635
+ /**
12636
+ * A save into this proposal rather than this server's own branch: the
12637
+ * site's server saving Publish selected's proposal (`/publish-selected`),
12638
+ * which has no address of its own to save from.
12639
+ */
12640
+ intoProposal) {
12293
12641
  try {
12294
- var _res$headers$get3;
12642
+ var _this$proposal5, _res$headers$get3;
12295
12643
  /*
12296
12644
  * A save in a proposal is a commit on the proposal's branch, at its own
12297
12645
  * route: the site's `/commit` refuses a proposal's patches, which is
12298
12646
  * the line that keeps them off the site until a merge.
12299
12647
  */
12300
- const commitUrl = this.proposal !== null ? `${this.contentUrl}/v1/${this.project}/proposals/${this.proposal.name}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
12648
+ const proposalName = intoProposal ?? ((_this$proposal5 = this.proposal) === null || _this$proposal5 === void 0 ? void 0 : _this$proposal5.name);
12649
+ const commitUrl = proposalName !== undefined ? `${this.contentUrl}/v1/${this.project}/proposals/${proposalName}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
12301
12650
  const res = await fetch(commitUrl, {
12302
12651
  method: "POST",
12303
12652
  headers: {
@@ -12705,6 +13054,27 @@ const UpdateCodeBody = z.z.object({
12705
13054
  }).optional()
12706
13055
  });
12707
13056
 
13057
+ /** Content's `GET /proposals/:name/move-inputs`. */
13058
+ /** The one thing `answersMovesFor` reads from content's `GET /proposals/:name`. */
13059
+ const ProposalKindAnswer = z.z.object({
13060
+ proposal: z.z.object({
13061
+ kind: z.z.string()
13062
+ })
13063
+ });
13064
+ /** Content's `GET /proposals/:name/merge-checks`, as far as it is changed here. */
13065
+ const MergeChecksAnswer = z.z.object({
13066
+ checks: z.z.array(z.z.object({
13067
+ id: z.z.string(),
13068
+ ok: z.z.boolean()
13069
+ }).passthrough()),
13070
+ canMerge: z.z.boolean()
13071
+ }).passthrough();
13072
+
13073
+ /** "Kari", "Kari and Bo", "Kari, Bo and 2 more". */
13074
+ function namesOf(names) {
13075
+ return names.length <= 2 ? names.join(" and ") : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
13076
+ }
13077
+
12708
13078
  /** Content's `GET /proposals/:name/merge-trees`. */
12709
13079
  const MergeTrees = z.z.object({
12710
13080
  base: z.z.string().nullable(),
@@ -12748,6 +13118,15 @@ const MergeInputs = z.z.object({
12748
13118
  }))
12749
13119
  });
12750
13120
 
13121
+ /** `JSON.parse`, or `null` for a body that is not JSON: an answer passed on as it came. */
13122
+ function parseJsonOrNull(text) {
13123
+ try {
13124
+ return JSON.parse(text);
13125
+ } catch {
13126
+ return null;
13127
+ }
13128
+ }
13129
+
12751
13130
  /**
12752
13131
  * One stored patch. Everything the ordered chain needs and nothing else.
12753
13132
  */
@@ -16696,6 +17075,142 @@ const ValServer = (valModules, options, callbacks) => {
16696
17075
  });
16697
17076
  }
16698
17077
  },
17078
+ "/publish-selected": {
17079
+ POST: async req => {
17080
+ const auth = getAuth(req.cookies);
17081
+ if (auth.error) {
17082
+ return {
17083
+ status: 401,
17084
+ json: {
17085
+ message: auth.error
17086
+ }
17087
+ };
17088
+ }
17089
+ const profileId = profileOf(auth);
17090
+ if (profileId === null) {
17091
+ return {
17092
+ status: 401,
17093
+ json: {
17094
+ message: "Sign in to publish"
17095
+ }
17096
+ };
17097
+ }
17098
+ if (serverOps instanceof ValOpsHttp) {
17099
+ await serverOps.learnProjectExpectation();
17100
+ }
17101
+ if (!(serverOps instanceof ValOpsHttp) || serverOps.sourceMode() !== "managed" || !serverOps.publishesAsJobs() || serverOps.currentProposal() !== null) {
17102
+ return {
17103
+ status: 409,
17104
+ json: {
17105
+ message: "Publish selected is for a site whose publishes are proposals: this deployment cannot."
17106
+ }
17107
+ };
17108
+ }
17109
+ const refusal = serverOps.publishRefusal();
17110
+ if (refusal) {
17111
+ return {
17112
+ status: 409,
17113
+ json: {
17114
+ message: refusal.message
17115
+ }
17116
+ };
17117
+ }
17118
+ const {
17119
+ patchIds,
17120
+ displayName,
17121
+ requestId
17122
+ } = req.body;
17123
+ /*
17124
+ * The selection's content over the site's head, exactly as a publish
17125
+ * job of it is prepared: this build's source, the commits since it,
17126
+ * and the selection on top. Only what the selection touched is saved,
17127
+ * so the proposal's save is the selection and nothing else.
17128
+ */
17129
+ const built = await buildSourcesOf(serverOps, patchIds, commit, {
17130
+ binaries: false
17131
+ });
17132
+ if ("status" in built) return built;
17133
+ if (built.headCommitSha === undefined) {
17134
+ return {
17135
+ status: 409,
17136
+ json: {
17137
+ message: "The content service does not say where the site's head is, which Publish selected needs: it is older than this Studio."
17138
+ }
17139
+ };
17140
+ }
17141
+ const selected = new Set(patchIds);
17142
+ const prepared = {
17143
+ ...built.withJob,
17144
+ patchedSourceFiles: built.split.archive.patchedSourceFiles,
17145
+ patchedBinaryFilesDescriptors: built.split.archive.patchedBinaryFilesDescriptors,
17146
+ moduleVersions: built.split.archive.modules,
17147
+ appliedPatches: Object.fromEntries(Object.entries(built.withJob.appliedPatches).map(([path, ids]) => [path, ids.filter(id => selected.has(id))]).filter(([, ids]) => ids.length > 0))
17148
+ };
17149
+ /*
17150
+ * Moved and based in one transaction, on the head the selection was
17151
+ * prepared over: content refuses the create when the site is no
17152
+ * longer there, and nothing has moved.
17153
+ */
17154
+ const created = await serverOps.proposalsApi("", {
17155
+ method: "POST",
17156
+ body: JSON.stringify({
17157
+ displayName,
17158
+ kind: "publish",
17159
+ patchIds,
17160
+ requestId,
17161
+ base: built.headCommitSha
17162
+ })
17163
+ }, profileId);
17164
+ const createdBody = PublishProposalCreated.safeParse(safeJson(created.body));
17165
+ const createdError = ContentError.safeParse(safeJson(created.body));
17166
+ if (created.status === 409) {
17167
+ /*
17168
+ * Content refuses a base that is not the site's head -- a publish
17169
+ * landed in between, and pressing again prepares over the new one.
17170
+ * Any other refusal is content's to put in words, and again would
17171
+ * be refused the same way.
17172
+ */
17173
+ const message = createdError.success ? createdError.data.message : "";
17174
+ return /base is not the site's head/.test(message) ? {
17175
+ status: 409,
17176
+ json: {
17177
+ message: "The site was published while this was being prepared. Nothing was published: publish them again.",
17178
+ headMoved: true
17179
+ }
17180
+ } : {
17181
+ status: 409,
17182
+ json: {
17183
+ message: message || "The changes could not be moved into a proposal to publish them."
17184
+ }
17185
+ };
17186
+ }
17187
+ if (created.status !== 200 || !createdBody.success) {
17188
+ return {
17189
+ status: 500,
17190
+ json: {
17191
+ message: `The changes could not be moved into a proposal to publish them (${created.status}): ${created.body.slice(0, 300)}`
17192
+ }
17193
+ };
17194
+ }
17195
+ const proposal = createdBody.data.proposal.name;
17196
+ const saved = await serverOps.commit(prepared, `Publish selected: ${displayName}`, profileId, FILES_DIRECTORY, undefined, proposal);
17197
+ if (saved.error) {
17198
+ return {
17199
+ status: 502,
17200
+ json: {
17201
+ message: `The changes are in a proposal, but could not be saved there: ${saved.error.message}`,
17202
+ proposal
17203
+ }
17204
+ };
17205
+ }
17206
+ return {
17207
+ status: 200,
17208
+ json: {
17209
+ proposal
17210
+ }
17211
+ };
17212
+ }
17213
+ },
16699
17214
  "/admin/status": {
16700
17215
  GET: async req => {
16701
17216
  const credential = await getValBuildCredential(req.cookies);
@@ -18591,7 +19106,14 @@ function getIsRemoteRequired(schemas) {
18591
19106
  * `split.archive` is the managed job's archive; a connected job's archive is
18592
19107
  * the push's, made from its own changes alone, by the caller.
18593
19108
  */
18594
- async function buildSourcesOf(serverOps, patchIds, commit) {
19109
+ async function buildSourcesOf(serverOps, patchIds, commit,
19110
+ /**
19111
+ * Read the job's binary files back for a build. Off for a caller that only
19112
+ * commits what was prepared, which names the files rather than carrying them.
19113
+ */
19114
+ options = {
19115
+ binaries: true
19116
+ }) {
18595
19117
  const chain = await serverOps.fetchPatches({
18596
19118
  patchIds: undefined,
18597
19119
  excludePatchOps: false
@@ -18650,9 +19172,14 @@ async function buildSourcesOf(serverOps, patchIds, commit) {
18650
19172
  jobPatchIds: patchIds
18651
19173
  });
18652
19174
  // Read now, while the job's changes still hold their files.
18653
- const binaries = await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors);
19175
+ const binaries = options.binaries ? await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors) : {
19176
+ files: {},
19177
+ unread: []
19178
+ };
18654
19179
  return {
18655
19180
  split,
19181
+ withJob,
19182
+ headCommitSha: chain.headCommitSha,
18656
19183
  sourceFiles: split.buildSourceFiles,
18657
19184
  binaryFiles: binaries.files,
18658
19185
  binaryFilesUnread: binaries.unread
@@ -18702,6 +19229,24 @@ function jobPrepareAnswer(sent, build) {
18702
19229
  }
18703
19230
  }
18704
19231
 
19232
+ /** What `/publish-selected` reads from content's proposal create. */
19233
+ const PublishProposalCreated = z.z.object({
19234
+ proposal: z.z.object({
19235
+ name: z.z.string()
19236
+ })
19237
+ });
19238
+ /** Content's refusal, in its words. */
19239
+ const ContentError = z.z.object({
19240
+ message: z.z.string()
19241
+ });
19242
+ function safeJson(text) {
19243
+ try {
19244
+ return JSON.parse(text);
19245
+ } catch {
19246
+ return null;
19247
+ }
19248
+ }
19249
+
18705
19250
  async function createValServer(valModules, route, opts, config, callbacks, formatter,
18706
19251
  /**
18707
19252
  * Called after a save has applied its patches. EXPERIMENTAL — see
@@ -23577,7 +24122,8 @@ async function handleExternalUpload() {
23577
24122
  };
23578
24123
  }
23579
24124
 
23580
- // Fix handler registry. `keyof:check-keys`, `router:check-route`,
24125
+ // Fix handler registry. `keyof:check-keys`, the gallery markers
24126
+ // (`image:check-gallery` and the rest), `router:check-route`,
23581
24127
  // `locale:check-locale` and `record:fill-keys` are resolved upfront by the
23582
24128
  // shared resolveSchemaSourceFixes — they never reach this registry, so they're
23583
24129
  // excluded from the key set.