@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.
@@ -8,7 +8,7 @@ import path__default from 'path';
8
8
  import fs from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module } from 'node:module';
11
- import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, schemaErrorsOf, schemaTypesOfPath, sideFiles, sideSource, PlanRefusalError, PatchId as PatchId$1, JSONValue, SerializedSchema, newestCommitSha, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, parseJob, VAL_STUDIO_HEADER, Api, traverseSchemaSource, richTextImageSchema, forEachRichTextImage } from '@valbuild/shared/internal';
11
+ import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, schemaErrorsOf, PatchSets, joinSharedChanges, schemaTypesOfPath, sideFiles, sideSource, JSONValue, PlanRefusalError, PatchId as PatchId$1, SerializedSchema, newestCommitSha, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, parseJob, VAL_STUDIO_HEADER, Api, traverseSchemaSource, richTextImageSchema, forEachRichTextImage } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
13
  import crypto$1, { createHash } from 'crypto';
14
14
  import z$1, { z } from 'zod';
@@ -9674,6 +9674,45 @@ function mergeFromSite(input) {
9674
9674
  };
9675
9675
  }
9676
9676
 
9677
+ /** A change pending on the site, in chain order. */
9678
+
9679
+ /**
9680
+ * The site's pending changes that conflict with going from `site` to
9681
+ * `merged`, as whole patch sets: each inner list moves together, into a
9682
+ * proposal of its own. Everything else stays pending.
9683
+ *
9684
+ * Patch sets are the Studio's own (`PatchSets`), from the schema the changes
9685
+ * were written against; sets that share a change are one, because a change
9686
+ * cannot be split. A set moves when, taken as the site's side of a three-way
9687
+ * merge with the merged commit as the other, it conflicts (`mergeValues`, so
9688
+ * the same value on both sides moves too); or when it no longer applies to
9689
+ * the merged commit, or leaves it invalid under the merged schema.
9690
+ */
9691
+ function changesToMove(input) {
9692
+ const {
9693
+ site,
9694
+ merged,
9695
+ pending
9696
+ } = input;
9697
+ const sets = new PatchSets();
9698
+ for (const change of pending) {
9699
+ var _site$modules$change$;
9700
+ 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);
9701
+ }
9702
+ const groups = joinSharedChanges(sets.serialize().map(set => set.patches.map(p => p.patchId)));
9703
+ const moves = [];
9704
+ for (const group of groups) {
9705
+ const changes = pending.filter(change => group.has(change.patchId));
9706
+ if (conflictsWithMerge(changes, site, merged)) {
9707
+ moves.push(changes.map(change => change.patchId));
9708
+ }
9709
+ }
9710
+ // In chain order, so the moved proposals are opened in the order the
9711
+ // changes were written.
9712
+ const order = ids => pending.findIndex(change => change.patchId === ids[0]);
9713
+ return moves.sort((a, b) => order(a) - order(b));
9714
+ }
9715
+
9677
9716
  // --- the pieces --------------------------------------------------------------
9678
9717
 
9679
9718
  /**
@@ -9706,6 +9745,39 @@ function resolveContent(moduleFilePath, merged, resolutions) {
9706
9745
  unresolved
9707
9746
  };
9708
9747
  }
9748
+ function conflictsWithMerge(changes, site, merged) {
9749
+ const modules = [...new Set(changes.map(change => change.moduleFilePath))];
9750
+ for (const moduleFilePath of modules) {
9751
+ var _site$modules$moduleF;
9752
+ const ops = changes.filter(change => change.moduleFilePath === moduleFilePath).flatMap(change => change.patch).filter(op => op.op !== "file");
9753
+ const before = (_site$modules$moduleF = site.modules[moduleFilePath]) === null || _site$modules$moduleF === void 0 ? void 0 : _site$modules$moduleF.source;
9754
+ const after = merged.modules[moduleFilePath];
9755
+ if (before === undefined || after === undefined) return true;
9756
+ const written = apply(before, ops);
9757
+ if (written === undefined) return true;
9758
+ const mergedSource = after.source;
9759
+ const atomic = atomicFor(after.schema);
9760
+ if (mergeValues(before, written, mergedSource, atomic).conflicts.length) {
9761
+ return true;
9762
+ }
9763
+ // Over the merged commit: it applies, and is as valid as it was.
9764
+ const over = apply(mergedSource, ops);
9765
+ if (over === undefined) return true;
9766
+ const known = new Set(errorPaths(moduleFilePath, after, mergedSource));
9767
+ if (errorPaths(moduleFilePath, after, over).some(path => !known.has(path))) {
9768
+ return true;
9769
+ }
9770
+ }
9771
+ return false;
9772
+ }
9773
+ function apply(source, ops) {
9774
+ if (source === undefined) return undefined;
9775
+ const applied = applyPatch(deepClone(source), new JSONOps(), ops);
9776
+ return result.isOk(applied) ? applied.value : undefined;
9777
+ }
9778
+ function errorPaths(moduleFilePath, module, source) {
9779
+ return validateModule(moduleFilePath, module, source).map(error => error.path.join("/"));
9780
+ }
9709
9781
 
9710
9782
  /**
9711
9783
  * The schema check of a merge: content in `merged` -- the project
@@ -10069,6 +10141,105 @@ function sameJson(a, b) {
10069
10141
  return JSON.stringify(a) === JSON.stringify(b);
10070
10142
  }
10071
10143
 
10144
+ /**
10145
+ * Which of the site's pending changes a merge of a proposal moves into
10146
+ * proposals of their own (valbuild/home `docs/hosted-publishing.md`, step 8.2,
10147
+ * decision 4), from what content hands over (`GET /proposals/:name/move-inputs`)
10148
+ * and this server's schemas.
10149
+ *
10150
+ * The schemas are the ones this server runs: the proposal's own build at its
10151
+ * address, which is the code the merge publishes. They stand in for the site's
10152
+ * too, to group the site's changes into patch sets -- the same unless the
10153
+ * proposal changed that module's schema, in which case a change written
10154
+ * against the old one is checked against the new one, which is what the
10155
+ * merge leaves it under anyway.
10156
+ *
10157
+ * A page this server has no schema for is moved whole: what cannot be checked
10158
+ * is not left on the site to break.
10159
+ */
10160
+
10161
+ const PatchIdOf = z.string().refine(s => !!s);
10162
+ const ModuleFilePathOf = z.string().refine(s => s.startsWith("/"));
10163
+
10164
+ /**
10165
+ * Content's `move-inputs` answer, as it is parsed: the one definition, so the
10166
+ * parser and what `movesAtMerge` reads cannot drift apart.
10167
+ */
10168
+ const MoveInputs = z.object({
10169
+ /** The proposal's last save; absent from a content service before it. */
10170
+ save: z.string().nullable().optional(),
10171
+ siteHead: z.string().nullable(),
10172
+ pending: z.array(z.object({
10173
+ patchId: PatchIdOf,
10174
+ moduleFilePath: ModuleFilePathOf,
10175
+ patch: Patch,
10176
+ author: z.string().nullable(),
10177
+ authorName: z.string().nullable()
10178
+ })),
10179
+ modules: z.record(z.string(), z.object({
10180
+ site: JSONValue.nullable(),
10181
+ merged: JSONValue.nullable()
10182
+ }))
10183
+ });
10184
+
10185
+ /** What the Publish press carries (`MergeBody.moves` in content), and who moves. */
10186
+
10187
+ function movesAtMerge(inputs, schemas) {
10188
+ const site = {
10189
+ files: {},
10190
+ modules: {}
10191
+ };
10192
+ const merged = {
10193
+ files: {},
10194
+ modules: {}
10195
+ };
10196
+ // The pages of the pending changes, which are the pages content answered.
10197
+ for (const moduleFilePath of new Set(inputs.pending.map(change => change.moduleFilePath))) {
10198
+ const page = inputs.modules[moduleFilePath];
10199
+ if (page === undefined) continue;
10200
+ const {
10201
+ site: before,
10202
+ merged: after
10203
+ } = page;
10204
+ const schema = schemas[moduleFilePath];
10205
+ if (schema === undefined) continue;
10206
+ const serialized = schema["executeSerialize"]();
10207
+ const validate = source => validateWith(schema, moduleFilePath, source);
10208
+ if (before !== null) {
10209
+ site.modules[moduleFilePath] = {
10210
+ source: before,
10211
+ schema: serialized,
10212
+ entries: null,
10213
+ validate
10214
+ };
10215
+ }
10216
+ if (after !== null) {
10217
+ merged.modules[moduleFilePath] = {
10218
+ source: after,
10219
+ schema: serialized,
10220
+ entries: null,
10221
+ validate
10222
+ };
10223
+ }
10224
+ }
10225
+ const groups = changesToMove({
10226
+ site,
10227
+ merged,
10228
+ pending: inputs.pending.map(change => ({
10229
+ patchId: change.patchId,
10230
+ moduleFilePath: change.moduleFilePath,
10231
+ patch: change.patch
10232
+ }))
10233
+ });
10234
+ const nameOf = new Map(inputs.pending.map(change => [change.patchId, change.authorName ?? "Someone"]));
10235
+ return {
10236
+ siteHead: inputs.siteHead,
10237
+ considered: inputs.pending.map(change => change.patchId),
10238
+ groups,
10239
+ authors: groups.map(group => [...new Set(group.map(id => nameOf.get(id) ?? "Someone"))])
10240
+ };
10241
+ }
10242
+
10072
10243
  const textEncoder$2 = new TextEncoder();
10073
10244
 
10074
10245
  /** The body of the content API's 403 for a write the project's plan refuses. */
@@ -10114,6 +10285,8 @@ const GetApplicablePatches = z.object({
10114
10285
  * Optional for the same reason.
10115
10286
  */
10116
10287
  headVersion: z.number().optional(),
10288
+ /** The branch's last commit. See `OrderedPatches.headCommitSha`. */
10289
+ headCommitSha: z.string().nullable().optional(),
10117
10290
  /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
10118
10291
  publishingPatches: z.array(PatchId$1).optional(),
10119
10292
  /** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
@@ -10834,7 +11007,7 @@ class ValOpsHttp extends ValOps {
10834
11007
  return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
10835
11008
  }
10836
11009
  async proposalsApi(path, init, profileId) {
10837
- var _exec2, _exec3, _this$proposal;
11010
+ var _exec2, _exec3, _exec4, _exec5, _this$proposal;
10838
11011
  const json = "application/json";
10839
11012
  const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
10840
11013
  if (updating !== undefined && init.method === "POST") {
@@ -10844,6 +11017,20 @@ class ValOpsHttp extends ValOps {
10844
11017
  if (withCode !== undefined && init.method === "POST") {
10845
11018
  return this.updateCodeFromSite(withCode, init.body, profileId);
10846
11019
  }
11020
+ /*
11021
+ * Publish, and the checks before it, at the proposal's own address: Val
11022
+ * says which of the site's pending changes the merge moves, from this
11023
+ * build's schemas (step 8.2). Anywhere else they go to content as they
11024
+ * are, and the site's pending changes block as they always did.
11025
+ */
11026
+ const checking = (_exec4 = /^\/([0-9a-f]{20})\/merge-checks$/.exec(path)) === null || _exec4 === void 0 ? void 0 : _exec4[1];
11027
+ if (checking !== undefined && init.method === "GET" && (await this.answersMovesFor(checking, profileId))) {
11028
+ return this.mergeChecksWithMoves(checking, profileId);
11029
+ }
11030
+ const pressing = (_exec5 = /^\/([0-9a-f]{20})\/merge$/.exec(path)) === null || _exec5 === void 0 ? void 0 : _exec5[1];
11031
+ if (pressing !== undefined && init.method === "POST" && (await this.answersMovesFor(pressing, profileId))) {
11032
+ return this.pressMergeWithMoves(pressing, init.body, profileId);
11033
+ }
10847
11034
  if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
10848
11035
  return {
10849
11036
  status: 403,
@@ -11031,6 +11218,143 @@ class ValOpsHttp extends ValOps {
11031
11218
  return updatedAnswer(text);
11032
11219
  }
11033
11220
 
11221
+ /**
11222
+ * Whether this server says what a merge of `name` moves: at the proposal's
11223
+ * own address, whose schemas are the ones it publishes; and on the site, for
11224
+ * Publish selected's proposal, which changes no code -- so the site's
11225
+ * schemas are its own -- and has no address to be published from.
11226
+ */
11227
+ async answersMovesFor(name, profileId) {
11228
+ if (this.proposal !== null) return this.proposal.name === name;
11229
+ const read = await this.proposalContent(name, profileId)("");
11230
+ if (!read.ok) return false;
11231
+ const kind = ProposalKindAnswer.safeParse(await read.json().catch(() => null));
11232
+ return kind.success && kind.data.proposal.kind === "publish";
11233
+ }
11234
+
11235
+ /** Content's proposals API for one proposal, as the person asking. */
11236
+ proposalContent(name, profileId) {
11237
+ return (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
11238
+ ...init,
11239
+ headers: {
11240
+ ...this.authHeaders,
11241
+ ...(profileId !== null ? {
11242
+ "x-val-profile-id": profileId
11243
+ } : {}),
11244
+ ...(init.body === undefined ? {} : {
11245
+ "Content-Type": "application/json"
11246
+ })
11247
+ }
11248
+ });
11249
+ }
11250
+
11251
+ /**
11252
+ * Which of the site's pending changes a merge of this proposal moves
11253
+ * (`movesAtMerge`), or `null` when content could not say what is pending:
11254
+ * then nothing is claimed, and the site's pending changes block the merge.
11255
+ */
11256
+ async mergeMovesOf(name, profileId) {
11257
+ const read = await this.proposalContent(name, profileId)("/move-inputs");
11258
+ if (!read.ok) return null;
11259
+ const inputs = MoveInputs.safeParse(await read.json().catch(() => null));
11260
+ if (!inputs.success) return null;
11261
+ return {
11262
+ ...movesAtMerge(inputs.data, await this.getSchemas()),
11263
+ save: inputs.data.save
11264
+ };
11265
+ }
11266
+
11267
+ /**
11268
+ * The merge checks, with `pending-on-site` answered by what the merge
11269
+ * moves rather than by whether anything is pending: the conflicting
11270
+ * changes move to a proposal of their own when it is published, and the
11271
+ * rest stay, so neither blocks it.
11272
+ */
11273
+ async mergeChecksWithMoves(name, profileId) {
11274
+ const json = "application/json";
11275
+ const read = await this.proposalContent(name, profileId)("/merge-checks");
11276
+ const text = await read.text();
11277
+ const answer = {
11278
+ status: read.status,
11279
+ contentType: json,
11280
+ body: text
11281
+ };
11282
+ if (!read.ok) return answer;
11283
+ const parsed = MergeChecksAnswer.safeParse(parseJsonOrNull(text));
11284
+ if (!parsed.success) return answer;
11285
+ const pending = parsed.data.checks.find(check => check.id === "pending-on-site");
11286
+ if (pending === undefined || pending.ok) return answer;
11287
+ const moves = await this.mergeMovesOf(name, profileId);
11288
+ if (moves === null) return answer;
11289
+ const moving = moves.groups.flat().length;
11290
+ const names = [...new Set(moves.authors.flat())];
11291
+ const checks = parsed.data.checks.map(check => check.id !== "pending-on-site" ? check : {
11292
+ ...check,
11293
+ ok: true,
11294
+ blockers: [],
11295
+ 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`,
11296
+ moves: moves.groups.map((group, at) => ({
11297
+ patchIds: group,
11298
+ authors: moves.authors[at] ?? []
11299
+ }))
11300
+ });
11301
+ const othersOk = parsed.data.checks.every(check => check.ok || check.id === "pending-on-site");
11302
+ return {
11303
+ status: 200,
11304
+ contentType: json,
11305
+ body: JSON.stringify({
11306
+ ...parsed.data,
11307
+ checks,
11308
+ canMerge: parsed.data.canMerge || othersOk
11309
+ })
11310
+ };
11311
+ }
11312
+
11313
+ /** Publish in a proposal, carrying the moves (`MergeBody.moves` in content). */
11314
+ async pressMergeWithMoves(name, body, profileId) {
11315
+ const json = "application/json";
11316
+ const content = this.proposalContent(name, profileId);
11317
+ const forward = async sent => {
11318
+ const res = await content("/merge", {
11319
+ method: "POST",
11320
+ ...(sent === undefined ? {} : {
11321
+ body: sent
11322
+ })
11323
+ });
11324
+ return {
11325
+ status: res.status,
11326
+ contentType: res.headers.get("Content-Type") ?? json,
11327
+ body: await res.text()
11328
+ };
11329
+ };
11330
+ let pressed;
11331
+ try {
11332
+ pressed = body === undefined || body === "" ? {} : JSON.parse(body);
11333
+ } catch {
11334
+ return forward(body);
11335
+ }
11336
+ if (typeof pressed !== "object" || pressed === null) return forward(body);
11337
+ const moves = await this.mergeMovesOf(name, profileId);
11338
+ if (moves === null) return forward(body);
11339
+ return forward(JSON.stringify({
11340
+ /*
11341
+ * The save the moves were worked out against is the one this press
11342
+ * publishes: named, so a change written after the press -- which the
11343
+ * Studio lets an editor make at once -- continues in a new proposal
11344
+ * rather than refusing it as unsaved.
11345
+ */
11346
+ ...(typeof moves.save === "string" ? {
11347
+ save: moves.save
11348
+ } : {}),
11349
+ ...pressed,
11350
+ moves: {
11351
+ siteHead: moves.siteHead,
11352
+ considered: moves.considered,
11353
+ groups: moves.groups
11354
+ }
11355
+ }));
11356
+ }
11357
+
11034
11358
  /**
11035
11359
  * Update from the site, for a proposal with code (valbuild/home
11036
11360
  * `docs/hosted-publishing.md`, step 8.2, slice 1b), at
@@ -11808,6 +12132,9 @@ class ValOpsHttp extends ValOps {
11808
12132
  ...(data.headVersion !== undefined ? {
11809
12133
  headVersion: data.headVersion
11810
12134
  } : {}),
12135
+ ...(data.headCommitSha !== undefined ? {
12136
+ headCommitSha: data.headCommitSha
12137
+ } : {}),
11811
12138
  ...(data.publishingPatches !== undefined ? {
11812
12139
  publishingPatches: data.publishingPatches
11813
12140
  } : {}),
@@ -12252,15 +12579,22 @@ class ValOpsHttp extends ValOps {
12252
12579
  };
12253
12580
  });
12254
12581
  }
12255
- async commit(prepared, message, committer, filesDirectory, newBranch) {
12582
+ async commit(prepared, message, committer, filesDirectory, newBranch,
12583
+ /**
12584
+ * A save into this proposal rather than this server's own branch: the
12585
+ * site's server saving Publish selected's proposal (`/publish-selected`),
12586
+ * which has no address of its own to save from.
12587
+ */
12588
+ intoProposal) {
12256
12589
  try {
12257
- var _res$headers$get3;
12590
+ var _this$proposal5, _res$headers$get3;
12258
12591
  /*
12259
12592
  * A save in a proposal is a commit on the proposal's branch, at its own
12260
12593
  * route: the site's `/commit` refuses a proposal's patches, which is
12261
12594
  * the line that keeps them off the site until a merge.
12262
12595
  */
12263
- const commitUrl = this.proposal !== null ? `${this.contentUrl}/v1/${this.project}/proposals/${this.proposal.name}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
12596
+ const proposalName = intoProposal ?? ((_this$proposal5 = this.proposal) === null || _this$proposal5 === void 0 ? void 0 : _this$proposal5.name);
12597
+ const commitUrl = proposalName !== undefined ? `${this.contentUrl}/v1/${this.project}/proposals/${proposalName}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
12264
12598
  const res = await fetch(commitUrl, {
12265
12599
  method: "POST",
12266
12600
  headers: {
@@ -12668,6 +13002,27 @@ const UpdateCodeBody = z.object({
12668
13002
  }).optional()
12669
13003
  });
12670
13004
 
13005
+ /** Content's `GET /proposals/:name/move-inputs`. */
13006
+ /** The one thing `answersMovesFor` reads from content's `GET /proposals/:name`. */
13007
+ const ProposalKindAnswer = z.object({
13008
+ proposal: z.object({
13009
+ kind: z.string()
13010
+ })
13011
+ });
13012
+ /** Content's `GET /proposals/:name/merge-checks`, as far as it is changed here. */
13013
+ const MergeChecksAnswer = z.object({
13014
+ checks: z.array(z.object({
13015
+ id: z.string(),
13016
+ ok: z.boolean()
13017
+ }).passthrough()),
13018
+ canMerge: z.boolean()
13019
+ }).passthrough();
13020
+
13021
+ /** "Kari", "Kari and Bo", "Kari, Bo and 2 more". */
13022
+ function namesOf(names) {
13023
+ return names.length <= 2 ? names.join(" and ") : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
13024
+ }
13025
+
12671
13026
  /** Content's `GET /proposals/:name/merge-trees`. */
12672
13027
  const MergeTrees = z.object({
12673
13028
  base: z.string().nullable(),
@@ -12711,6 +13066,15 @@ const MergeInputs = z.object({
12711
13066
  }))
12712
13067
  });
12713
13068
 
13069
+ /** `JSON.parse`, or `null` for a body that is not JSON: an answer passed on as it came. */
13070
+ function parseJsonOrNull(text) {
13071
+ try {
13072
+ return JSON.parse(text);
13073
+ } catch {
13074
+ return null;
13075
+ }
13076
+ }
13077
+
12714
13078
  /**
12715
13079
  * One stored patch. Everything the ordered chain needs and nothing else.
12716
13080
  */
@@ -16659,6 +17023,142 @@ const ValServer = (valModules, options, callbacks) => {
16659
17023
  });
16660
17024
  }
16661
17025
  },
17026
+ "/publish-selected": {
17027
+ POST: async req => {
17028
+ const auth = getAuth(req.cookies);
17029
+ if (auth.error) {
17030
+ return {
17031
+ status: 401,
17032
+ json: {
17033
+ message: auth.error
17034
+ }
17035
+ };
17036
+ }
17037
+ const profileId = profileOf(auth);
17038
+ if (profileId === null) {
17039
+ return {
17040
+ status: 401,
17041
+ json: {
17042
+ message: "Sign in to publish"
17043
+ }
17044
+ };
17045
+ }
17046
+ if (serverOps instanceof ValOpsHttp) {
17047
+ await serverOps.learnProjectExpectation();
17048
+ }
17049
+ if (!(serverOps instanceof ValOpsHttp) || serverOps.sourceMode() !== "managed" || !serverOps.publishesAsJobs() || serverOps.currentProposal() !== null) {
17050
+ return {
17051
+ status: 409,
17052
+ json: {
17053
+ message: "Publish selected is for a site whose publishes are proposals: this deployment cannot."
17054
+ }
17055
+ };
17056
+ }
17057
+ const refusal = serverOps.publishRefusal();
17058
+ if (refusal) {
17059
+ return {
17060
+ status: 409,
17061
+ json: {
17062
+ message: refusal.message
17063
+ }
17064
+ };
17065
+ }
17066
+ const {
17067
+ patchIds,
17068
+ displayName,
17069
+ requestId
17070
+ } = req.body;
17071
+ /*
17072
+ * The selection's content over the site's head, exactly as a publish
17073
+ * job of it is prepared: this build's source, the commits since it,
17074
+ * and the selection on top. Only what the selection touched is saved,
17075
+ * so the proposal's save is the selection and nothing else.
17076
+ */
17077
+ const built = await buildSourcesOf(serverOps, patchIds, commit, {
17078
+ binaries: false
17079
+ });
17080
+ if ("status" in built) return built;
17081
+ if (built.headCommitSha === undefined) {
17082
+ return {
17083
+ status: 409,
17084
+ json: {
17085
+ message: "The content service does not say where the site's head is, which Publish selected needs: it is older than this Studio."
17086
+ }
17087
+ };
17088
+ }
17089
+ const selected = new Set(patchIds);
17090
+ const prepared = {
17091
+ ...built.withJob,
17092
+ patchedSourceFiles: built.split.archive.patchedSourceFiles,
17093
+ patchedBinaryFilesDescriptors: built.split.archive.patchedBinaryFilesDescriptors,
17094
+ moduleVersions: built.split.archive.modules,
17095
+ appliedPatches: Object.fromEntries(Object.entries(built.withJob.appliedPatches).map(([path, ids]) => [path, ids.filter(id => selected.has(id))]).filter(([, ids]) => ids.length > 0))
17096
+ };
17097
+ /*
17098
+ * Moved and based in one transaction, on the head the selection was
17099
+ * prepared over: content refuses the create when the site is no
17100
+ * longer there, and nothing has moved.
17101
+ */
17102
+ const created = await serverOps.proposalsApi("", {
17103
+ method: "POST",
17104
+ body: JSON.stringify({
17105
+ displayName,
17106
+ kind: "publish",
17107
+ patchIds,
17108
+ requestId,
17109
+ base: built.headCommitSha
17110
+ })
17111
+ }, profileId);
17112
+ const createdBody = PublishProposalCreated.safeParse(safeJson(created.body));
17113
+ const createdError = ContentError.safeParse(safeJson(created.body));
17114
+ if (created.status === 409) {
17115
+ /*
17116
+ * Content refuses a base that is not the site's head -- a publish
17117
+ * landed in between, and pressing again prepares over the new one.
17118
+ * Any other refusal is content's to put in words, and again would
17119
+ * be refused the same way.
17120
+ */
17121
+ const message = createdError.success ? createdError.data.message : "";
17122
+ return /base is not the site's head/.test(message) ? {
17123
+ status: 409,
17124
+ json: {
17125
+ message: "The site was published while this was being prepared. Nothing was published: publish them again.",
17126
+ headMoved: true
17127
+ }
17128
+ } : {
17129
+ status: 409,
17130
+ json: {
17131
+ message: message || "The changes could not be moved into a proposal to publish them."
17132
+ }
17133
+ };
17134
+ }
17135
+ if (created.status !== 200 || !createdBody.success) {
17136
+ return {
17137
+ status: 500,
17138
+ json: {
17139
+ message: `The changes could not be moved into a proposal to publish them (${created.status}): ${created.body.slice(0, 300)}`
17140
+ }
17141
+ };
17142
+ }
17143
+ const proposal = createdBody.data.proposal.name;
17144
+ const saved = await serverOps.commit(prepared, `Publish selected: ${displayName}`, profileId, FILES_DIRECTORY, undefined, proposal);
17145
+ if (saved.error) {
17146
+ return {
17147
+ status: 502,
17148
+ json: {
17149
+ message: `The changes are in a proposal, but could not be saved there: ${saved.error.message}`,
17150
+ proposal
17151
+ }
17152
+ };
17153
+ }
17154
+ return {
17155
+ status: 200,
17156
+ json: {
17157
+ proposal
17158
+ }
17159
+ };
17160
+ }
17161
+ },
16662
17162
  "/admin/status": {
16663
17163
  GET: async req => {
16664
17164
  const credential = await getValBuildCredential(req.cookies);
@@ -18554,7 +19054,14 @@ function getIsRemoteRequired(schemas) {
18554
19054
  * `split.archive` is the managed job's archive; a connected job's archive is
18555
19055
  * the push's, made from its own changes alone, by the caller.
18556
19056
  */
18557
- async function buildSourcesOf(serverOps, patchIds, commit) {
19057
+ async function buildSourcesOf(serverOps, patchIds, commit,
19058
+ /**
19059
+ * Read the job's binary files back for a build. Off for a caller that only
19060
+ * commits what was prepared, which names the files rather than carrying them.
19061
+ */
19062
+ options = {
19063
+ binaries: true
19064
+ }) {
18558
19065
  const chain = await serverOps.fetchPatches({
18559
19066
  patchIds: undefined,
18560
19067
  excludePatchOps: false
@@ -18613,9 +19120,14 @@ async function buildSourcesOf(serverOps, patchIds, commit) {
18613
19120
  jobPatchIds: patchIds
18614
19121
  });
18615
19122
  // Read now, while the job's changes still hold their files.
18616
- const binaries = await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors);
19123
+ const binaries = options.binaries ? await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors) : {
19124
+ files: {},
19125
+ unread: []
19126
+ };
18617
19127
  return {
18618
19128
  split,
19129
+ withJob,
19130
+ headCommitSha: chain.headCommitSha,
18619
19131
  sourceFiles: split.buildSourceFiles,
18620
19132
  binaryFiles: binaries.files,
18621
19133
  binaryFilesUnread: binaries.unread
@@ -18665,6 +19177,24 @@ function jobPrepareAnswer(sent, build) {
18665
19177
  }
18666
19178
  }
18667
19179
 
19180
+ /** What `/publish-selected` reads from content's proposal create. */
19181
+ const PublishProposalCreated = z.object({
19182
+ proposal: z.object({
19183
+ name: z.string()
19184
+ })
19185
+ });
19186
+ /** Content's refusal, in its words. */
19187
+ const ContentError = z.object({
19188
+ message: z.string()
19189
+ });
19190
+ function safeJson(text) {
19191
+ try {
19192
+ return JSON.parse(text);
19193
+ } catch {
19194
+ return null;
19195
+ }
19196
+ }
19197
+
18668
19198
  async function createValServer(valModules, route, opts, config, callbacks, formatter,
18669
19199
  /**
18670
19200
  * Called after a save has applied its patches. EXPERIMENTAL — see
@@ -23540,7 +24070,8 @@ async function handleExternalUpload() {
23540
24070
  };
23541
24071
  }
23542
24072
 
23543
- // Fix handler registry. `keyof:check-keys`, `router:check-route`,
24073
+ // Fix handler registry. `keyof:check-keys`, the gallery markers
24074
+ // (`image:check-gallery` and the rest), `router:check-route`,
23544
24075
  // `locale:check-locale` and `record:fill-keys` are resolved upfront by the
23545
24076
  // shared resolveSchemaSourceFixes — they never reach this registry, so they're
23546
24077
  // excluded from the key set.
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.146.1",
19
+ "version": "0.146.2",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0",
@@ -30,9 +30,9 @@
30
30
  "typescript": "^6.0.3",
31
31
  "zod": "^4.4.3",
32
32
  "zod-validation-error": "^5.0.0",
33
- "@valbuild/core": "0.145.0",
34
- "@valbuild/shared": "0.146.0",
35
- "@valbuild/ui": "0.146.1"
33
+ "@valbuild/shared": "0.146.2",
34
+ "@valbuild/ui": "0.146.2",
35
+ "@valbuild/core": "0.146.2"
36
36
  },
37
37
  "engines": {
38
38
  "node": "^20.19.0 || >=22"