@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.
- package/CHANGELOG.md +54 -0
- package/dist/declarations/src/ValOps.d.ts +11 -0
- package/dist/declarations/src/ValOpsHttp.d.ts +41 -1
- package/dist/declarations/src/fixHandlers.d.ts +1 -1
- package/dist/valbuild-server.cjs.dev.js +553 -7
- package/dist/valbuild-server.cjs.prod.js +553 -7
- package/dist/valbuild-server.esm.js +554 -8
- package/package.json +4 -4
|
@@ -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
|
|
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`,
|
|
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.
|