@valbuild/server 0.146.1 → 0.146.2

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`. */
@@ -10871,7 +11044,7 @@ class ValOpsHttp extends ValOps {
10871
11044
  return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
10872
11045
  }
10873
11046
  async proposalsApi(path, init, profileId) {
10874
- var _exec2, _exec3, _this$proposal;
11047
+ var _exec2, _exec3, _exec4, _exec5, _this$proposal;
10875
11048
  const json = "application/json";
10876
11049
  const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
10877
11050
  if (updating !== undefined && init.method === "POST") {
@@ -10881,6 +11054,20 @@ class ValOpsHttp extends ValOps {
10881
11054
  if (withCode !== undefined && init.method === "POST") {
10882
11055
  return this.updateCodeFromSite(withCode, init.body, profileId);
10883
11056
  }
11057
+ /*
11058
+ * Publish, and the checks before it, at the proposal's own address: Val
11059
+ * says which of the site's pending changes the merge moves, from this
11060
+ * build's schemas (step 8.2). Anywhere else they go to content as they
11061
+ * are, and the site's pending changes block as they always did.
11062
+ */
11063
+ const checking = (_exec4 = /^\/([0-9a-f]{20})\/merge-checks$/.exec(path)) === null || _exec4 === void 0 ? void 0 : _exec4[1];
11064
+ if (checking !== undefined && init.method === "GET" && (await this.answersMovesFor(checking, profileId))) {
11065
+ return this.mergeChecksWithMoves(checking, profileId);
11066
+ }
11067
+ const pressing = (_exec5 = /^\/([0-9a-f]{20})\/merge$/.exec(path)) === null || _exec5 === void 0 ? void 0 : _exec5[1];
11068
+ if (pressing !== undefined && init.method === "POST" && (await this.answersMovesFor(pressing, profileId))) {
11069
+ return this.pressMergeWithMoves(pressing, init.body, profileId);
11070
+ }
10884
11071
  if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
10885
11072
  return {
10886
11073
  status: 403,
@@ -11068,6 +11255,143 @@ class ValOpsHttp extends ValOps {
11068
11255
  return updatedAnswer(text);
11069
11256
  }
11070
11257
 
11258
+ /**
11259
+ * Whether this server says what a merge of `name` moves: at the proposal's
11260
+ * own address, whose schemas are the ones it publishes; and on the site, for
11261
+ * Publish selected's proposal, which changes no code -- so the site's
11262
+ * schemas are its own -- and has no address to be published from.
11263
+ */
11264
+ async answersMovesFor(name, profileId) {
11265
+ if (this.proposal !== null) return this.proposal.name === name;
11266
+ const read = await this.proposalContent(name, profileId)("");
11267
+ if (!read.ok) return false;
11268
+ const kind = ProposalKindAnswer.safeParse(await read.json().catch(() => null));
11269
+ return kind.success && kind.data.proposal.kind === "publish";
11270
+ }
11271
+
11272
+ /** Content's proposals API for one proposal, as the person asking. */
11273
+ proposalContent(name, profileId) {
11274
+ return (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
11275
+ ...init,
11276
+ headers: {
11277
+ ...this.authHeaders,
11278
+ ...(profileId !== null ? {
11279
+ "x-val-profile-id": profileId
11280
+ } : {}),
11281
+ ...(init.body === undefined ? {} : {
11282
+ "Content-Type": "application/json"
11283
+ })
11284
+ }
11285
+ });
11286
+ }
11287
+
11288
+ /**
11289
+ * Which of the site's pending changes a merge of this proposal moves
11290
+ * (`movesAtMerge`), or `null` when content could not say what is pending:
11291
+ * then nothing is claimed, and the site's pending changes block the merge.
11292
+ */
11293
+ async mergeMovesOf(name, profileId) {
11294
+ const read = await this.proposalContent(name, profileId)("/move-inputs");
11295
+ if (!read.ok) return null;
11296
+ const inputs = MoveInputs.safeParse(await read.json().catch(() => null));
11297
+ if (!inputs.success) return null;
11298
+ return {
11299
+ ...movesAtMerge(inputs.data, await this.getSchemas()),
11300
+ save: inputs.data.save
11301
+ };
11302
+ }
11303
+
11304
+ /**
11305
+ * The merge checks, with `pending-on-site` answered by what the merge
11306
+ * moves rather than by whether anything is pending: the conflicting
11307
+ * changes move to a proposal of their own when it is published, and the
11308
+ * rest stay, so neither blocks it.
11309
+ */
11310
+ async mergeChecksWithMoves(name, profileId) {
11311
+ const json = "application/json";
11312
+ const read = await this.proposalContent(name, profileId)("/merge-checks");
11313
+ const text = await read.text();
11314
+ const answer = {
11315
+ status: read.status,
11316
+ contentType: json,
11317
+ body: text
11318
+ };
11319
+ if (!read.ok) return answer;
11320
+ const parsed = MergeChecksAnswer.safeParse(parseJsonOrNull(text));
11321
+ if (!parsed.success) return answer;
11322
+ const pending = parsed.data.checks.find(check => check.id === "pending-on-site");
11323
+ if (pending === undefined || pending.ok) return answer;
11324
+ const moves = await this.mergeMovesOf(name, profileId);
11325
+ if (moves === null) return answer;
11326
+ const moving = moves.groups.flat().length;
11327
+ const names = [...new Set(moves.authors.flat())];
11328
+ const checks = parsed.data.checks.map(check => check.id !== "pending-on-site" ? check : {
11329
+ ...check,
11330
+ ok: true,
11331
+ blockers: [],
11332
+ 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`,
11333
+ moves: moves.groups.map((group, at) => ({
11334
+ patchIds: group,
11335
+ authors: moves.authors[at] ?? []
11336
+ }))
11337
+ });
11338
+ const othersOk = parsed.data.checks.every(check => check.ok || check.id === "pending-on-site");
11339
+ return {
11340
+ status: 200,
11341
+ contentType: json,
11342
+ body: JSON.stringify({
11343
+ ...parsed.data,
11344
+ checks,
11345
+ canMerge: parsed.data.canMerge || othersOk
11346
+ })
11347
+ };
11348
+ }
11349
+
11350
+ /** Publish in a proposal, carrying the moves (`MergeBody.moves` in content). */
11351
+ async pressMergeWithMoves(name, body, profileId) {
11352
+ const json = "application/json";
11353
+ const content = this.proposalContent(name, profileId);
11354
+ const forward = async sent => {
11355
+ const res = await content("/merge", {
11356
+ method: "POST",
11357
+ ...(sent === undefined ? {} : {
11358
+ body: sent
11359
+ })
11360
+ });
11361
+ return {
11362
+ status: res.status,
11363
+ contentType: res.headers.get("Content-Type") ?? json,
11364
+ body: await res.text()
11365
+ };
11366
+ };
11367
+ let pressed;
11368
+ try {
11369
+ pressed = body === undefined || body === "" ? {} : JSON.parse(body);
11370
+ } catch {
11371
+ return forward(body);
11372
+ }
11373
+ if (typeof pressed !== "object" || pressed === null) return forward(body);
11374
+ const moves = await this.mergeMovesOf(name, profileId);
11375
+ if (moves === null) return forward(body);
11376
+ return forward(JSON.stringify({
11377
+ /*
11378
+ * The save the moves were worked out against is the one this press
11379
+ * publishes: named, so a change written after the press -- which the
11380
+ * Studio lets an editor make at once -- continues in a new proposal
11381
+ * rather than refusing it as unsaved.
11382
+ */
11383
+ ...(typeof moves.save === "string" ? {
11384
+ save: moves.save
11385
+ } : {}),
11386
+ ...pressed,
11387
+ moves: {
11388
+ siteHead: moves.siteHead,
11389
+ considered: moves.considered,
11390
+ groups: moves.groups
11391
+ }
11392
+ }));
11393
+ }
11394
+
11071
11395
  /**
11072
11396
  * Update from the site, for a proposal with code (valbuild/home
11073
11397
  * `docs/hosted-publishing.md`, step 8.2, slice 1b), at
@@ -11845,6 +12169,9 @@ class ValOpsHttp extends ValOps {
11845
12169
  ...(data.headVersion !== undefined ? {
11846
12170
  headVersion: data.headVersion
11847
12171
  } : {}),
12172
+ ...(data.headCommitSha !== undefined ? {
12173
+ headCommitSha: data.headCommitSha
12174
+ } : {}),
11848
12175
  ...(data.publishingPatches !== undefined ? {
11849
12176
  publishingPatches: data.publishingPatches
11850
12177
  } : {}),
@@ -12289,15 +12616,22 @@ class ValOpsHttp extends ValOps {
12289
12616
  };
12290
12617
  });
12291
12618
  }
12292
- async commit(prepared, message, committer, filesDirectory, newBranch) {
12619
+ async commit(prepared, message, committer, filesDirectory, newBranch,
12620
+ /**
12621
+ * A save into this proposal rather than this server's own branch: the
12622
+ * site's server saving Publish selected's proposal (`/publish-selected`),
12623
+ * which has no address of its own to save from.
12624
+ */
12625
+ intoProposal) {
12293
12626
  try {
12294
- var _res$headers$get3;
12627
+ var _this$proposal5, _res$headers$get3;
12295
12628
  /*
12296
12629
  * A save in a proposal is a commit on the proposal's branch, at its own
12297
12630
  * route: the site's `/commit` refuses a proposal's patches, which is
12298
12631
  * the line that keeps them off the site until a merge.
12299
12632
  */
12300
- const commitUrl = this.proposal !== null ? `${this.contentUrl}/v1/${this.project}/proposals/${this.proposal.name}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
12633
+ const proposalName = intoProposal ?? ((_this$proposal5 = this.proposal) === null || _this$proposal5 === void 0 ? void 0 : _this$proposal5.name);
12634
+ const commitUrl = proposalName !== undefined ? `${this.contentUrl}/v1/${this.project}/proposals/${proposalName}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
12301
12635
  const res = await fetch(commitUrl, {
12302
12636
  method: "POST",
12303
12637
  headers: {
@@ -12705,6 +13039,27 @@ const UpdateCodeBody = z.z.object({
12705
13039
  }).optional()
12706
13040
  });
12707
13041
 
13042
+ /** Content's `GET /proposals/:name/move-inputs`. */
13043
+ /** The one thing `answersMovesFor` reads from content's `GET /proposals/:name`. */
13044
+ const ProposalKindAnswer = z.z.object({
13045
+ proposal: z.z.object({
13046
+ kind: z.z.string()
13047
+ })
13048
+ });
13049
+ /** Content's `GET /proposals/:name/merge-checks`, as far as it is changed here. */
13050
+ const MergeChecksAnswer = z.z.object({
13051
+ checks: z.z.array(z.z.object({
13052
+ id: z.z.string(),
13053
+ ok: z.z.boolean()
13054
+ }).passthrough()),
13055
+ canMerge: z.z.boolean()
13056
+ }).passthrough();
13057
+
13058
+ /** "Kari", "Kari and Bo", "Kari, Bo and 2 more". */
13059
+ function namesOf(names) {
13060
+ return names.length <= 2 ? names.join(" and ") : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
13061
+ }
13062
+
12708
13063
  /** Content's `GET /proposals/:name/merge-trees`. */
12709
13064
  const MergeTrees = z.z.object({
12710
13065
  base: z.z.string().nullable(),
@@ -12748,6 +13103,15 @@ const MergeInputs = z.z.object({
12748
13103
  }))
12749
13104
  });
12750
13105
 
13106
+ /** `JSON.parse`, or `null` for a body that is not JSON: an answer passed on as it came. */
13107
+ function parseJsonOrNull(text) {
13108
+ try {
13109
+ return JSON.parse(text);
13110
+ } catch {
13111
+ return null;
13112
+ }
13113
+ }
13114
+
12751
13115
  /**
12752
13116
  * One stored patch. Everything the ordered chain needs and nothing else.
12753
13117
  */
@@ -16696,6 +17060,142 @@ const ValServer = (valModules, options, callbacks) => {
16696
17060
  });
16697
17061
  }
16698
17062
  },
17063
+ "/publish-selected": {
17064
+ POST: async req => {
17065
+ const auth = getAuth(req.cookies);
17066
+ if (auth.error) {
17067
+ return {
17068
+ status: 401,
17069
+ json: {
17070
+ message: auth.error
17071
+ }
17072
+ };
17073
+ }
17074
+ const profileId = profileOf(auth);
17075
+ if (profileId === null) {
17076
+ return {
17077
+ status: 401,
17078
+ json: {
17079
+ message: "Sign in to publish"
17080
+ }
17081
+ };
17082
+ }
17083
+ if (serverOps instanceof ValOpsHttp) {
17084
+ await serverOps.learnProjectExpectation();
17085
+ }
17086
+ if (!(serverOps instanceof ValOpsHttp) || serverOps.sourceMode() !== "managed" || !serverOps.publishesAsJobs() || serverOps.currentProposal() !== null) {
17087
+ return {
17088
+ status: 409,
17089
+ json: {
17090
+ message: "Publish selected is for a site whose publishes are proposals: this deployment cannot."
17091
+ }
17092
+ };
17093
+ }
17094
+ const refusal = serverOps.publishRefusal();
17095
+ if (refusal) {
17096
+ return {
17097
+ status: 409,
17098
+ json: {
17099
+ message: refusal.message
17100
+ }
17101
+ };
17102
+ }
17103
+ const {
17104
+ patchIds,
17105
+ displayName,
17106
+ requestId
17107
+ } = req.body;
17108
+ /*
17109
+ * The selection's content over the site's head, exactly as a publish
17110
+ * job of it is prepared: this build's source, the commits since it,
17111
+ * and the selection on top. Only what the selection touched is saved,
17112
+ * so the proposal's save is the selection and nothing else.
17113
+ */
17114
+ const built = await buildSourcesOf(serverOps, patchIds, commit, {
17115
+ binaries: false
17116
+ });
17117
+ if ("status" in built) return built;
17118
+ if (built.headCommitSha === undefined) {
17119
+ return {
17120
+ status: 409,
17121
+ json: {
17122
+ message: "The content service does not say where the site's head is, which Publish selected needs: it is older than this Studio."
17123
+ }
17124
+ };
17125
+ }
17126
+ const selected = new Set(patchIds);
17127
+ const prepared = {
17128
+ ...built.withJob,
17129
+ patchedSourceFiles: built.split.archive.patchedSourceFiles,
17130
+ patchedBinaryFilesDescriptors: built.split.archive.patchedBinaryFilesDescriptors,
17131
+ moduleVersions: built.split.archive.modules,
17132
+ appliedPatches: Object.fromEntries(Object.entries(built.withJob.appliedPatches).map(([path, ids]) => [path, ids.filter(id => selected.has(id))]).filter(([, ids]) => ids.length > 0))
17133
+ };
17134
+ /*
17135
+ * Moved and based in one transaction, on the head the selection was
17136
+ * prepared over: content refuses the create when the site is no
17137
+ * longer there, and nothing has moved.
17138
+ */
17139
+ const created = await serverOps.proposalsApi("", {
17140
+ method: "POST",
17141
+ body: JSON.stringify({
17142
+ displayName,
17143
+ kind: "publish",
17144
+ patchIds,
17145
+ requestId,
17146
+ base: built.headCommitSha
17147
+ })
17148
+ }, profileId);
17149
+ const createdBody = PublishProposalCreated.safeParse(safeJson(created.body));
17150
+ const createdError = ContentError.safeParse(safeJson(created.body));
17151
+ if (created.status === 409) {
17152
+ /*
17153
+ * Content refuses a base that is not the site's head -- a publish
17154
+ * landed in between, and pressing again prepares over the new one.
17155
+ * Any other refusal is content's to put in words, and again would
17156
+ * be refused the same way.
17157
+ */
17158
+ const message = createdError.success ? createdError.data.message : "";
17159
+ return /base is not the site's head/.test(message) ? {
17160
+ status: 409,
17161
+ json: {
17162
+ message: "The site was published while this was being prepared. Nothing was published: publish them again.",
17163
+ headMoved: true
17164
+ }
17165
+ } : {
17166
+ status: 409,
17167
+ json: {
17168
+ message: message || "The changes could not be moved into a proposal to publish them."
17169
+ }
17170
+ };
17171
+ }
17172
+ if (created.status !== 200 || !createdBody.success) {
17173
+ return {
17174
+ status: 500,
17175
+ json: {
17176
+ message: `The changes could not be moved into a proposal to publish them (${created.status}): ${created.body.slice(0, 300)}`
17177
+ }
17178
+ };
17179
+ }
17180
+ const proposal = createdBody.data.proposal.name;
17181
+ const saved = await serverOps.commit(prepared, `Publish selected: ${displayName}`, profileId, FILES_DIRECTORY, undefined, proposal);
17182
+ if (saved.error) {
17183
+ return {
17184
+ status: 502,
17185
+ json: {
17186
+ message: `The changes are in a proposal, but could not be saved there: ${saved.error.message}`,
17187
+ proposal
17188
+ }
17189
+ };
17190
+ }
17191
+ return {
17192
+ status: 200,
17193
+ json: {
17194
+ proposal
17195
+ }
17196
+ };
17197
+ }
17198
+ },
16699
17199
  "/admin/status": {
16700
17200
  GET: async req => {
16701
17201
  const credential = await getValBuildCredential(req.cookies);
@@ -18591,7 +19091,14 @@ function getIsRemoteRequired(schemas) {
18591
19091
  * `split.archive` is the managed job's archive; a connected job's archive is
18592
19092
  * the push's, made from its own changes alone, by the caller.
18593
19093
  */
18594
- async function buildSourcesOf(serverOps, patchIds, commit) {
19094
+ async function buildSourcesOf(serverOps, patchIds, commit,
19095
+ /**
19096
+ * Read the job's binary files back for a build. Off for a caller that only
19097
+ * commits what was prepared, which names the files rather than carrying them.
19098
+ */
19099
+ options = {
19100
+ binaries: true
19101
+ }) {
18595
19102
  const chain = await serverOps.fetchPatches({
18596
19103
  patchIds: undefined,
18597
19104
  excludePatchOps: false
@@ -18650,9 +19157,14 @@ async function buildSourcesOf(serverOps, patchIds, commit) {
18650
19157
  jobPatchIds: patchIds
18651
19158
  });
18652
19159
  // Read now, while the job's changes still hold their files.
18653
- const binaries = await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors);
19160
+ const binaries = options.binaries ? await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors) : {
19161
+ files: {},
19162
+ unread: []
19163
+ };
18654
19164
  return {
18655
19165
  split,
19166
+ withJob,
19167
+ headCommitSha: chain.headCommitSha,
18656
19168
  sourceFiles: split.buildSourceFiles,
18657
19169
  binaryFiles: binaries.files,
18658
19170
  binaryFilesUnread: binaries.unread
@@ -18702,6 +19214,24 @@ function jobPrepareAnswer(sent, build) {
18702
19214
  }
18703
19215
  }
18704
19216
 
19217
+ /** What `/publish-selected` reads from content's proposal create. */
19218
+ const PublishProposalCreated = z.z.object({
19219
+ proposal: z.z.object({
19220
+ name: z.z.string()
19221
+ })
19222
+ });
19223
+ /** Content's refusal, in its words. */
19224
+ const ContentError = z.z.object({
19225
+ message: z.z.string()
19226
+ });
19227
+ function safeJson(text) {
19228
+ try {
19229
+ return JSON.parse(text);
19230
+ } catch {
19231
+ return null;
19232
+ }
19233
+ }
19234
+
18705
19235
  async function createValServer(valModules, route, opts, config, callbacks, formatter,
18706
19236
  /**
18707
19237
  * Called after a save has applied its patches. EXPERIMENTAL — see
@@ -23577,7 +24107,8 @@ async function handleExternalUpload() {
23577
24107
  };
23578
24108
  }
23579
24109
 
23580
- // Fix handler registry. `keyof:check-keys`, `router:check-route`,
24110
+ // Fix handler registry. `keyof:check-keys`, the gallery markers
24111
+ // (`image:check-gallery` and the rest), `router:check-route`,
23581
24112
  // `locale:check-locale` and `record:fill-keys` are resolved upfront by the
23582
24113
  // shared resolveSchemaSourceFixes — they never reach this registry, so they're
23583
24114
  // excluded from the key set.