@valbuild/server 0.145.1 → 0.146.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/declarations/src/ValOps.d.ts +11 -92
- package/dist/declarations/src/ValOpsFS.d.ts +2 -2
- package/dist/declarations/src/ValOpsHttp.d.ts +32 -184
- package/dist/declarations/src/ValOpsMemory.d.ts +2 -2
- package/dist/declarations/src/ValServer.d.ts +3 -120
- package/dist/valbuild-server.cjs.dev.js +1595 -1698
- package/dist/valbuild-server.cjs.prod.js +1595 -1698
- package/dist/valbuild-server.esm.js +1598 -1701
- package/package.json +3 -3
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { pipe, result, array } from '@valbuild/core/fp';
|
|
3
3
|
import { PatchError, deepEqual, parseAndValidateArrayIndex, isNotRoot, applyPatch, JSONOps, deepClone, sourceToPatchPath } from '@valbuild/core/patch';
|
|
4
|
-
import { derefPatch, Internal, VAL_EXTENSION, extractValModules, computeValModuleShas, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
|
|
4
|
+
import { derefPatch, Internal, VAL_EXTENSION, extractValModules, computeValModuleShas, ImageSchema, deserializeSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
|
|
5
5
|
export { hasRemoteFileSchema } from '@valbuild/core';
|
|
6
6
|
import * as path from 'path';
|
|
7
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, PlanRefusalError, PatchId as PatchId$1, JSONValue,
|
|
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';
|
|
12
12
|
import { createUIRequestHandler } from '@valbuild/ui/server';
|
|
13
|
-
import crypto$1 from 'crypto';
|
|
13
|
+
import crypto$1, { createHash } from 'crypto';
|
|
14
14
|
import z$1, { z } from 'zod';
|
|
15
15
|
import http from 'node:http';
|
|
16
16
|
import https from 'node:https';
|
|
@@ -3534,7 +3534,7 @@ const jsonOps = new JSONOps();
|
|
|
3534
3534
|
* host.
|
|
3535
3535
|
*/
|
|
3536
3536
|
const JSON_ENTRY_READ_CONCURRENCY = 8;
|
|
3537
|
-
const tsOps = new TSOps(document => {
|
|
3537
|
+
const tsOps$1 = new TSOps(document => {
|
|
3538
3538
|
return pipe(analyzeValModule(document), result.map(({
|
|
3539
3539
|
source
|
|
3540
3540
|
}) => source));
|
|
@@ -4040,7 +4040,7 @@ class ValOps {
|
|
|
4040
4040
|
message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
|
|
4041
4041
|
};
|
|
4042
4042
|
}
|
|
4043
|
-
modulePatches =
|
|
4043
|
+
modulePatches = draftModulePatches(patchOps.patches, moduleFilePath, patchOps.commits).map(p => ({
|
|
4044
4044
|
patchId: p.patchId,
|
|
4045
4045
|
patch: p.patch
|
|
4046
4046
|
}));
|
|
@@ -5147,7 +5147,7 @@ class ValOps {
|
|
|
5147
5147
|
kind: "normal"
|
|
5148
5148
|
};
|
|
5149
5149
|
if (cls.kind === "normal" && fromCls.kind === "normal") {
|
|
5150
|
-
const patchRes = applyPatch(tsSourceFile, tsOps, [op]);
|
|
5150
|
+
const patchRes = applyPatch(tsSourceFile, tsOps$1, [op]);
|
|
5151
5151
|
if (result.isErr(patchRes)) {
|
|
5152
5152
|
collectPatchError(patchRes.error, patchId, op);
|
|
5153
5153
|
patchHadError = true;
|
|
@@ -5586,21 +5586,8 @@ class ValOps {
|
|
|
5586
5586
|
}
|
|
5587
5587
|
|
|
5588
5588
|
// #region createPatch
|
|
5589
|
-
async createPatch(path, patch, patchId, parentRef, sessionId, authorId
|
|
5590
|
-
|
|
5591
|
-
* Which patch group this patch joins, recorded in the SAME request.
|
|
5592
|
-
*
|
|
5593
|
-
* Atomic on purpose. The content API runs every refusal before its insert,
|
|
5594
|
-
* so an invalid closure is a 400 with nothing written. Recording membership
|
|
5595
|
-
* in a second call would let a patch exist outside its author's group if
|
|
5596
|
-
* that call failed — and a patch outside your own group is one you cannot
|
|
5597
|
-
* publish until a repair puts it back.
|
|
5598
|
-
*
|
|
5599
|
-
* Optional: `fs` mode has no groups, and a client that predates them sends
|
|
5600
|
-
* nothing.
|
|
5601
|
-
*/
|
|
5602
|
-
patchGroup) {
|
|
5603
|
-
const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
|
|
5589
|
+
async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
|
|
5590
|
+
const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
|
|
5604
5591
|
if (result.isErr(saveRes)) {
|
|
5605
5592
|
if (saveRes.error.errorType === "plan-refused") {
|
|
5606
5593
|
// The project's plan, not a failure: the Studio keeps the edit and
|
|
@@ -5621,11 +5608,6 @@ class ValOps {
|
|
|
5621
5608
|
return result.ok({
|
|
5622
5609
|
patchId,
|
|
5623
5610
|
createdAt: new Date().toISOString(),
|
|
5624
|
-
// Spread rather than assigned: absent has to stay distinguishable from a
|
|
5625
|
-
// group whose id is undefined, all the way to the client.
|
|
5626
|
-
...(saveRes.value.patchGroupId !== undefined ? {
|
|
5627
|
-
patchGroupId: saveRes.value.patchGroupId
|
|
5628
|
-
} : {}),
|
|
5629
5611
|
...(saveRes.value.headVersion !== undefined ? {
|
|
5630
5612
|
headVersion: saveRes.value.headVersion
|
|
5631
5613
|
} : {})
|
|
@@ -5783,8 +5765,8 @@ class ValOps {
|
|
|
5783
5765
|
* Almost everything the routes branch on comes from this one fact, which is
|
|
5784
5766
|
* why it is a named property rather than an `instanceof`. If this server owns
|
|
5785
5767
|
* the store then there is no content service to authenticate to (so an absent
|
|
5786
|
-
* or unverifiable session is anonymous rather than a 401), no
|
|
5787
|
-
*
|
|
5768
|
+
* or unverifiable session is anonymous rather than a 401), no deployments to
|
|
5769
|
+
* report, and a
|
|
5788
5770
|
* "publish" writes what the host does with it rather than pushing a commit. If
|
|
5789
5771
|
* it does not, every one of those is the content service's and this server is
|
|
5790
5772
|
* relaying.
|
|
@@ -5919,20 +5901,6 @@ function isFileSource(value) {
|
|
|
5919
5901
|
return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
|
|
5920
5902
|
}
|
|
5921
5903
|
|
|
5922
|
-
/**
|
|
5923
|
-
* The patch group a newly created patch joins.
|
|
5924
|
-
*
|
|
5925
|
-
* `withPatchIds` is the CLOSURE the client computed — the patches that share
|
|
5926
|
-
* a patch set with this one and must move with it. It is not derived here and
|
|
5927
|
-
* must not be: the closure needs the content schema, and the service that
|
|
5928
|
-
* stores groups does not have it. One implementation of that rule, on the side
|
|
5929
|
-
* that can actually compute it.
|
|
5930
|
-
*
|
|
5931
|
-
* Membership rows are stamped with `coreVersion` on the content side, the same
|
|
5932
|
-
* stamp the patch row itself carries, so which client wrote a row stays legible
|
|
5933
|
-
* after the fact.
|
|
5934
|
-
*/
|
|
5935
|
-
|
|
5936
5904
|
/**
|
|
5937
5905
|
* The content API refused a write for the project's plan (a 403 that says why
|
|
5938
5906
|
* as data), as opposed to refusing the patch.
|
|
@@ -5964,34 +5932,16 @@ function formatPatchSourceError(error) {
|
|
|
5964
5932
|
*/
|
|
5965
5933
|
|
|
5966
5934
|
/**
|
|
5967
|
-
* The patches a json entry render should apply, out of the whole chain
|
|
5968
|
-
*
|
|
5969
|
-
* Three rules, and the second is the one that was missing. A draft page renders
|
|
5970
|
-
* `jsonValues` entries beside module content, and only the modules were scoped
|
|
5971
|
-
* — so one screen showed the caller's own view for its modules and base plus
|
|
5972
|
-
* EVERY pending patch on the branch for the entries beside them, including
|
|
5973
|
-
* another author's half-finished edit rendered as though it were live.
|
|
5935
|
+
* The patches a json entry render should apply, out of the whole chain:
|
|
5974
5936
|
*
|
|
5975
5937
|
* 1. this module's, since the chain is branch-wide;
|
|
5976
|
-
* 2. this
|
|
5977
|
-
*
|
|
5978
|
-
*
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
* `commits`.
|
|
5982
|
-
*
|
|
5983
|
-
* Filtered here rather than by asking `fetchPatches` for a list, and that is
|
|
5984
|
-
* load-bearing: both implementations read an empty `patchIds` as "no filter"
|
|
5985
|
-
* and return the whole chain. That is the right default for a caller that
|
|
5986
|
-
* cannot mean "none", and the most dangerous possible reading of a group that
|
|
5987
|
-
* is genuinely empty — it would render every unpublished patch on the branch
|
|
5988
|
-
* instead of base. It costs no round trip either: the whole chain is what the
|
|
5989
|
-
* unscoped path fetches anyway.
|
|
5990
|
-
*/
|
|
5991
|
-
function scopedModulePatches(patches, moduleFilePath, patchIds, /** The commits content says came after this build: see `draftOverlay`. */
|
|
5938
|
+
* 2. a patch published after this build is kept: this build does not have it.
|
|
5939
|
+
* One already in the build is dropped. `draftOverlay` decides which is
|
|
5940
|
+
* which, from `commits`.
|
|
5941
|
+
*/
|
|
5942
|
+
function draftModulePatches(patches, moduleFilePath, /** The commits content says came after this build: see `draftOverlay`. */
|
|
5992
5943
|
commits) {
|
|
5993
|
-
|
|
5994
|
-
return draftOverlay(patches.filter(patch => patch.path === moduleFilePath && (patch.appliedAt !== null || scope === undefined || scope.has(patch.patchId))), commits);
|
|
5944
|
+
return draftOverlay(patches.filter(patch => patch.path === moduleFilePath), commits);
|
|
5995
5945
|
}
|
|
5996
5946
|
|
|
5997
5947
|
/**
|
|
@@ -8083,17 +8033,7 @@ class ValOpsFS extends ValOps {
|
|
|
8083
8033
|
};
|
|
8084
8034
|
}
|
|
8085
8035
|
}
|
|
8086
|
-
async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId
|
|
8087
|
-
/*
|
|
8088
|
-
* Named and ignored, rather than omitted from the signature.
|
|
8089
|
-
*
|
|
8090
|
-
* `fs` mode has no shared store and exactly one author, so there is nothing
|
|
8091
|
-
* for a group to separate — every pending patch is already this person's.
|
|
8092
|
-
* Declaring it makes that a decision a reader can see: TypeScript lets an
|
|
8093
|
-
* implementation take fewer parameters than the abstract, so leaving it off
|
|
8094
|
-
* would drop group membership silently and look identical to handling it.
|
|
8095
|
-
*/
|
|
8096
|
-
_patchGroup) {
|
|
8036
|
+
async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
|
|
8097
8037
|
const patchesDir = this.getPatchesDir();
|
|
8098
8038
|
const record = {
|
|
8099
8039
|
patch,
|
|
@@ -9006,13 +8946,13 @@ function proposalFromEnv(env) {
|
|
|
9006
8946
|
});
|
|
9007
8947
|
}
|
|
9008
8948
|
}
|
|
9009
|
-
if (!isObject$
|
|
8949
|
+
if (!isObject$2(parsed)) {
|
|
9010
8950
|
throw new Error(`Val: proposal ${name}: VAL_OVERLAY is not an object`);
|
|
9011
8951
|
}
|
|
9012
8952
|
const modulesIn = parsed["modules"] ?? {};
|
|
9013
8953
|
const filesIn = parsed["files"] ?? {};
|
|
9014
8954
|
const commit = parsed["commit"] ?? null;
|
|
9015
|
-
if (!isObject$
|
|
8955
|
+
if (!isObject$2(modulesIn) || !isObject$2(filesIn)) {
|
|
9016
8956
|
throw new Error(`Val: proposal ${name}: VAL_OVERLAY's modules and files must be objects`);
|
|
9017
8957
|
}
|
|
9018
8958
|
if (commit !== null && typeof commit !== "string") {
|
|
@@ -9075,7 +9015,7 @@ function contentFromEnv(env) {
|
|
|
9075
9015
|
cause: err
|
|
9076
9016
|
});
|
|
9077
9017
|
}
|
|
9078
|
-
if (!isObject$
|
|
9018
|
+
if (!isObject$2(parsed) || Array.isArray(parsed)) {
|
|
9079
9019
|
throw new Error("Val: VAL_CONTENT is not an object");
|
|
9080
9020
|
}
|
|
9081
9021
|
const id = parsed["id"];
|
|
@@ -9092,7 +9032,7 @@ function contentFromEnv(env) {
|
|
|
9092
9032
|
}
|
|
9093
9033
|
const modulesIn = parsed["modules"] ?? {};
|
|
9094
9034
|
const filesIn = parsed["files"] ?? {};
|
|
9095
|
-
if (!isObject$
|
|
9035
|
+
if (!isObject$2(modulesIn) || Array.isArray(modulesIn) || !isObject$2(filesIn) || Array.isArray(filesIn)) {
|
|
9096
9036
|
throw new Error(`Val: content version ${id}: modules and files must be objects`);
|
|
9097
9037
|
}
|
|
9098
9038
|
const files = {};
|
|
@@ -9105,7 +9045,7 @@ function contentFromEnv(env) {
|
|
|
9105
9045
|
...modulesIn
|
|
9106
9046
|
};
|
|
9107
9047
|
const refs = parsed["refs"] ?? {};
|
|
9108
|
-
if (!isObject$
|
|
9048
|
+
if (!isObject$2(refs) || Array.isArray(refs)) {
|
|
9109
9049
|
throw new Error(`Val: content version ${id}: refs must be an object`);
|
|
9110
9050
|
}
|
|
9111
9051
|
const read = (kind, path, ref) => {
|
|
@@ -9132,7 +9072,7 @@ function contentFromEnv(env) {
|
|
|
9132
9072
|
};
|
|
9133
9073
|
const refModules = refs["modules"] ?? {};
|
|
9134
9074
|
const refFiles = refs["files"] ?? {};
|
|
9135
|
-
if (!isObject$
|
|
9075
|
+
if (!isObject$2(refModules) || Array.isArray(refModules) || !isObject$2(refFiles) || Array.isArray(refFiles)) {
|
|
9136
9076
|
throw new Error(`Val: content version ${id}: refs.modules and refs.files must be objects`);
|
|
9137
9077
|
}
|
|
9138
9078
|
for (const [path, ref] of Object.entries(refModules)) {
|
|
@@ -9277,7 +9217,7 @@ function proposalSnapshotForReaders(proposal, projectSource) {
|
|
|
9277
9217
|
const snapshot = proposalSnapshot(proposal);
|
|
9278
9218
|
for (const [moduleFilePath, entries] of proposalJsonEntries(proposal, projectSource)) {
|
|
9279
9219
|
const source = snapshot[moduleFilePath];
|
|
9280
|
-
if (!isObject$
|
|
9220
|
+
if (!isObject$2(source)) continue;
|
|
9281
9221
|
const filled = {};
|
|
9282
9222
|
for (const [key, value] of Object.entries(source)) {
|
|
9283
9223
|
if (isJsonValue(value)) filled[key] = value;
|
|
@@ -9295,7 +9235,7 @@ function proposalSnapshotForReaders(proposal, projectSource) {
|
|
|
9295
9235
|
|
|
9296
9236
|
/** A record of `.jsonValues()` markers (`{ _type: "json" }`), at least one. */
|
|
9297
9237
|
function holdsJsonMarkers(source) {
|
|
9298
|
-
return isObject$
|
|
9238
|
+
return isObject$2(source) && Object.values(source).some(value => isObject$2(value) && value["_type"] === "json");
|
|
9299
9239
|
}
|
|
9300
9240
|
|
|
9301
9241
|
/** Each entry key's import path, from a `.jsonValues()` module's text. */
|
|
@@ -9331,9 +9271,9 @@ function isMutableJsonValue(value) {
|
|
|
9331
9271
|
if (Array.isArray(value)) {
|
|
9332
9272
|
return value.every(isMutableJsonValue);
|
|
9333
9273
|
}
|
|
9334
|
-
return isObject$
|
|
9274
|
+
return isObject$2(value) && Object.values(value).every(isMutableJsonValue);
|
|
9335
9275
|
}
|
|
9336
|
-
function isObject$
|
|
9276
|
+
function isObject$2(value) {
|
|
9337
9277
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9338
9278
|
}
|
|
9339
9279
|
|
|
@@ -9345,267 +9285,1025 @@ function isJsonValue(value) {
|
|
|
9345
9285
|
if (Array.isArray(value)) {
|
|
9346
9286
|
return value.every(isJsonValue);
|
|
9347
9287
|
}
|
|
9348
|
-
return isObject$
|
|
9288
|
+
return isObject$2(value) && Object.values(value).every(isJsonValue);
|
|
9349
9289
|
}
|
|
9350
9290
|
|
|
9351
|
-
|
|
9291
|
+
/** What a schema reports about this content: see `schemaErrorsOf`. */
|
|
9292
|
+
const validateWith = (schema, moduleFilePath, source) => schemaErrorsOf(schema, moduleFilePath, source);
|
|
9352
9293
|
|
|
9353
|
-
/** The body of the content API's 403 for a write the project's plan refuses. */
|
|
9354
|
-
const PlanRefusedBody = z.object({
|
|
9355
|
-
error: PlanRefusalError
|
|
9356
|
-
});
|
|
9357
|
-
const PatchId = z.string().refine(s => !!s); // TODO: validate
|
|
9358
|
-
const CommitSha = z.string().refine(s => !!s); // TODO: validate
|
|
9359
|
-
z.string().refine(s => !!s); // TODO: validate
|
|
9360
|
-
z.string().refine(s => !!s); // TODO: validate
|
|
9361
|
-
const ModuleFilePath = z.string().refine(s => !!s); // TODO: validate
|
|
9362
|
-
const Metadata = z.union([z.object({
|
|
9363
|
-
mimeType: z.string(),
|
|
9364
|
-
width: z.number(),
|
|
9365
|
-
height: z.number()
|
|
9366
|
-
}), z.object({
|
|
9367
|
-
mimeType: z.string()
|
|
9368
|
-
})]);
|
|
9369
|
-
const MetadataRes = z.object({
|
|
9370
|
-
filePath: ModuleFilePath,
|
|
9371
|
-
metadata: Metadata,
|
|
9372
|
-
type: z.union([z.literal("file"), z.literal("image")]).nullable()
|
|
9373
|
-
});
|
|
9374
|
-
const GetApplicablePatches = z.object({
|
|
9375
|
-
patches: z.array(z.object({
|
|
9376
|
-
path: z.string(),
|
|
9377
|
-
patch: Patch.nullable(),
|
|
9378
|
-
patchId: z.string(),
|
|
9379
|
-
authorId: z.string().nullable(),
|
|
9380
|
-
baseSha: z.string(),
|
|
9381
|
-
createdAt: z.string(),
|
|
9382
|
-
applied: z.object({
|
|
9383
|
-
commitSha: z.string()
|
|
9384
|
-
}).nullable(),
|
|
9385
|
-
/**
|
|
9386
|
-
* The groups this patch is a member of. Read in the SAME transaction as
|
|
9387
|
-
* the list and its `headVersion`, which is the point: see
|
|
9388
|
-
* `OrderedPatches.patchGroups`. Optional for an older content service.
|
|
9389
|
-
*/
|
|
9390
|
-
patchGroupIds: z.array(z.string()).optional()
|
|
9391
|
-
})),
|
|
9392
|
-
/**
|
|
9393
|
-
* The groups on the branch, without their members — those are on the
|
|
9394
|
-
* patches above. Optional for a content service that predates groups.
|
|
9395
|
-
*/
|
|
9396
|
-
patchGroups: z.array(z.object({
|
|
9397
|
-
patchGroupId: z.string(),
|
|
9398
|
-
authorId: z.string().nullable(),
|
|
9399
|
-
createdAt: z.string(),
|
|
9400
|
-
publishedAt: z.string().nullable()
|
|
9401
|
-
})).optional(),
|
|
9402
|
-
/**
|
|
9403
|
-
* The head of the chain. See `OrderedPatches.headPatchId`. Optional because
|
|
9404
|
-
* a content service that predates it sends nothing.
|
|
9405
|
-
*/
|
|
9406
|
-
headPatchId: PatchId$1.nullable().optional(),
|
|
9407
|
-
/**
|
|
9408
|
-
* The chain version of that head. See `OrderedPatches.headVersion`.
|
|
9409
|
-
* Optional for the same reason.
|
|
9410
|
-
*/
|
|
9411
|
-
headVersion: z.number().optional(),
|
|
9412
|
-
/** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
|
|
9413
|
-
publishingPatches: z.array(PatchId$1).optional(),
|
|
9414
|
-
/** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
|
|
9415
|
-
heldBackPatches: z.array(PatchId$1).optional(),
|
|
9416
|
-
commits: z.array(z.object({
|
|
9417
|
-
commitSha: z.string(),
|
|
9418
|
-
/*
|
|
9419
|
-
* Nullable since the content service mints its own commit shas: a
|
|
9420
|
-
* root commit has no parent, and a publisher that did not say where
|
|
9421
|
-
* it was has no client sha. Parsed rather than trusted, so a service
|
|
9422
|
-
* that sends null gets null here instead of failing the whole poll.
|
|
9423
|
-
*/
|
|
9424
|
-
clientCommitSha: z.string().nullable(),
|
|
9425
|
-
parentCommitSha: z.string().nullable(),
|
|
9426
|
-
commitMessage: z.string().nullable(),
|
|
9427
|
-
branch: z.string(),
|
|
9428
|
-
creator: z.string(),
|
|
9429
|
-
createdAt: z.string()
|
|
9430
|
-
})).optional(),
|
|
9431
|
-
deployments: z.array(z.object({
|
|
9432
|
-
deploymentId: z.string(),
|
|
9433
|
-
commitSha: z.string(),
|
|
9434
|
-
deploymentState: z.string(),
|
|
9435
|
-
createdAt: z.string(),
|
|
9436
|
-
updatedAt: z.string(),
|
|
9437
|
-
// The git message, where the content service knows it. See
|
|
9438
|
-
// `ValDeployment`: this is the only source for a deployment that Val
|
|
9439
|
-
// did not publish, and zod would otherwise strip it here.
|
|
9440
|
-
commitMessage: z.string().nullable().optional()
|
|
9441
|
-
})).optional(),
|
|
9442
|
-
/**
|
|
9443
|
-
* What the project EXPECTS of whoever publishes it.
|
|
9444
|
-
*
|
|
9445
|
-
* Optional because a content service that predates it sends nothing, and
|
|
9446
|
-
* absent means "not reported" -- never "managed". The difference decides
|
|
9447
|
-
* whether a publish is refused, so guessing either way would be worse than
|
|
9448
|
-
* not checking: guessing `managed` would let a deployment publish a
|
|
9449
|
-
* connected project it cannot mirror, and guessing `connected` would refuse
|
|
9450
|
-
* every publish against an older service.
|
|
9451
|
-
*/
|
|
9452
|
-
project: z.object({
|
|
9453
|
-
sourceMode: z.union([z.literal("managed"), z.literal("connected")]),
|
|
9454
|
-
branch: z.string(),
|
|
9455
|
-
/**
|
|
9456
|
-
* Whether a press of Publish is a publish JOB. Absent from a content
|
|
9457
|
-
* service that predates connected jobs: then managed projects publish
|
|
9458
|
-
* as jobs, and connected ones by commit, as they did.
|
|
9459
|
-
*/
|
|
9460
|
-
publishJobs: z.boolean().optional()
|
|
9461
|
-
}).optional()
|
|
9462
|
-
});
|
|
9463
|
-
const FilesResponse = z.object({
|
|
9464
|
-
files: z.array(z.union([z.object({
|
|
9465
|
-
filePath: z.string(),
|
|
9466
|
-
location: z.literal("patch"),
|
|
9467
|
-
patchId: PatchId,
|
|
9468
|
-
value: z.string(),
|
|
9469
|
-
remote: z.boolean()
|
|
9470
|
-
}), z.object({
|
|
9471
|
-
filePath: z.string(),
|
|
9472
|
-
location: z.literal("repo"),
|
|
9473
|
-
commitSha: CommitSha,
|
|
9474
|
-
value: z.string()
|
|
9475
|
-
})])),
|
|
9476
|
-
errors: z.array(z.union([z.object({
|
|
9477
|
-
filePath: z.string(),
|
|
9478
|
-
location: z.literal("patch"),
|
|
9479
|
-
patchId: PatchId,
|
|
9480
|
-
message: z.string(),
|
|
9481
|
-
remote: z.boolean()
|
|
9482
|
-
}), z.object({
|
|
9483
|
-
filePath: z.string(),
|
|
9484
|
-
location: z.literal("repo"),
|
|
9485
|
-
commitSha: CommitSha,
|
|
9486
|
-
message: z.string()
|
|
9487
|
-
})])).optional()
|
|
9488
|
-
});
|
|
9489
|
-
const SavePatchResponse = z.object({
|
|
9490
|
-
patchId: PatchId,
|
|
9491
|
-
/**
|
|
9492
|
-
* Which group the content API put this patch in.
|
|
9493
|
-
*
|
|
9494
|
-
* Optional: a content API that predates patch groups does not send one, and
|
|
9495
|
-
* absence has to keep meaning "no groups here" rather than failing the save.
|
|
9496
|
-
*/
|
|
9497
|
-
patchGroupId: z.string().optional(),
|
|
9498
|
-
/**
|
|
9499
|
-
* The chain version this write made, comparable with `headVersion` on the
|
|
9500
|
-
* listing. Optional: a content service that predates it sends nothing.
|
|
9501
|
-
*/
|
|
9502
|
-
headVersion: z.number().optional()
|
|
9503
|
-
});
|
|
9504
|
-
const DeletePatchesResponse = z.object({
|
|
9505
|
-
deleted: z.array(PatchId),
|
|
9506
|
-
errors: z.array(z.object({
|
|
9507
|
-
message: z.string(),
|
|
9508
|
-
patchId: PatchId,
|
|
9509
|
-
/** The publish job holding it, when that is why it was not deleted. */
|
|
9510
|
-
publishJobId: z.string().optional()
|
|
9511
|
-
})).optional(),
|
|
9512
|
-
/**
|
|
9513
|
-
* The chain version each branch the delete changed is now at, keyed by
|
|
9514
|
-
* branch. Optional: a content service that predates it sends none.
|
|
9515
|
-
*/
|
|
9516
|
-
headVersions: z.record(z.string(), z.number()).optional()
|
|
9517
|
-
});
|
|
9518
9294
|
/**
|
|
9519
|
-
*
|
|
9520
|
-
*
|
|
9521
|
-
*
|
|
9522
|
-
*
|
|
9523
|
-
* it), the only entry is ours when there is exactly one; with several, none can
|
|
9524
|
-
* be picked, and a wrong one would floor this client at another chain's count.
|
|
9295
|
+
* A module's errors for this content: its own `validate` where the schema was
|
|
9296
|
+
* evaluated, custom rules and all; otherwise what its serialized schema says,
|
|
9297
|
+
* which is every rule but a custom `.validate(...)` (a closure does not
|
|
9298
|
+
* serialize).
|
|
9525
9299
|
*/
|
|
9526
|
-
function
|
|
9527
|
-
if (
|
|
9528
|
-
|
|
9529
|
-
|
|
9530
|
-
|
|
9300
|
+
function validateModule(moduleFilePath, module, source) {
|
|
9301
|
+
if (module.validate) return module.validate(source);
|
|
9302
|
+
return validateWith(deserializeSchema(module.schema), moduleFilePath, source);
|
|
9303
|
+
}
|
|
9304
|
+
|
|
9305
|
+
/** The errors of a module's own Source: as evaluated where they were, else found here. */
|
|
9306
|
+
function errorsOfSource(moduleFilePath, module) {
|
|
9307
|
+
return module.errors ?? validateModule(moduleFilePath, module, module.source);
|
|
9531
9308
|
}
|
|
9532
|
-
const SavePatchFileResponse = z.object({
|
|
9533
|
-
patchId: PatchId,
|
|
9534
|
-
filePath: ModuleFilePath
|
|
9535
|
-
});
|
|
9536
|
-
const CommitResponse = z.object({
|
|
9537
|
-
updatedFiles: z.array(z.string()),
|
|
9538
|
-
commit: CommitSha,
|
|
9539
|
-
/*
|
|
9540
|
-
* Optional because a content service that predates them sends neither, and
|
|
9541
|
-
* this client talks to whichever one the project is on. Absent means NOT
|
|
9542
|
-
* REPORTED: `parent: undefined` is not "a root commit", and a caller that
|
|
9543
|
-
* reads it as one would conclude the history starts here.
|
|
9544
|
-
*
|
|
9545
|
-
* Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
|
|
9546
|
-
* from a service that versions separately and are passed through as what it
|
|
9547
|
-
* said, not as something this end vouched for.
|
|
9548
|
-
*/
|
|
9549
|
-
parent: z.string().optional(),
|
|
9550
|
-
tree: z.string().optional(),
|
|
9551
|
-
branch: z.string(),
|
|
9552
|
-
/** The chain version the publish moved the branch to; absent if it applied nothing. */
|
|
9553
|
-
headVersion: z.number().optional()
|
|
9554
|
-
});
|
|
9555
|
-
// #region history wire schemas
|
|
9556
|
-
//
|
|
9557
|
-
// Validated on arrival rather than trusted: these come from a service that
|
|
9558
|
-
// versions separately, and a silently mis-shaped commit record reads as "this
|
|
9559
|
-
// commit changed nothing", which is indistinguishable from a real answer.
|
|
9560
|
-
const HistoricalCommitResponse = z.object({
|
|
9561
|
-
commitSha: z.string(),
|
|
9562
|
-
/** `null` for a root commit; see the note on the applicable-patches schema. */
|
|
9563
|
-
parentCommitSha: z.string().nullable(),
|
|
9564
|
-
/** `null` when the publisher did not say where it was. */
|
|
9565
|
-
clientCommitSha: z.string().nullable(),
|
|
9566
|
-
branch: z.string(),
|
|
9567
|
-
createdBranch: z.string().nullable(),
|
|
9568
|
-
creator: z.string().nullable(),
|
|
9569
|
-
message: z.string().nullable(),
|
|
9570
|
-
createdAt: z.string(),
|
|
9571
|
-
seqNum: z.string(),
|
|
9572
|
-
patchCount: z.number(),
|
|
9573
|
-
hasArchive: z.boolean()
|
|
9574
|
-
});
|
|
9575
|
-
const ListCommitsResponse = z.object({
|
|
9576
|
-
commits: z.array(HistoricalCommitResponse),
|
|
9577
|
-
nextCursor: z.string().nullable()
|
|
9578
|
-
});
|
|
9579
|
-
const CommitPatchesResponse = z.object({
|
|
9580
|
-
commitSha: z.string(),
|
|
9581
|
-
commit: z.object({
|
|
9582
|
-
commitSha: z.string(),
|
|
9583
|
-
parentCommitSha: z.string().nullable(),
|
|
9584
|
-
clientCommitSha: z.string().nullable(),
|
|
9585
|
-
branch: z.string(),
|
|
9586
|
-
createdBranch: z.string().nullable(),
|
|
9587
|
-
creator: z.string().nullable(),
|
|
9588
|
-
message: z.string().nullable(),
|
|
9589
|
-
createdAt: z.string(),
|
|
9590
|
-
seqNum: z.string(),
|
|
9591
|
-
hasArchive: z.boolean()
|
|
9592
|
-
}),
|
|
9593
|
-
patches: z.array(z.object({
|
|
9594
|
-
patchId: z.string(),
|
|
9595
|
-
path: z.string(),
|
|
9596
|
-
patch: z.unknown(),
|
|
9597
|
-
authorId: z.string().nullable(),
|
|
9598
|
-
createdAt: z.string(),
|
|
9599
|
-
baseSha: z.string(),
|
|
9600
|
-
coreVersion: z.string()
|
|
9601
|
-
}))
|
|
9602
|
-
});
|
|
9603
9309
|
|
|
9604
9310
|
/**
|
|
9605
|
-
* `
|
|
9606
|
-
*
|
|
9607
|
-
|
|
9608
|
-
|
|
9311
|
+
* A `.jsonValues()` record's entry files, by key, read out of its `.val.ts`
|
|
9312
|
+
* (the loaded marker does not carry the path). `null` when it has none.
|
|
9313
|
+
*/
|
|
9314
|
+
function entryFilesOf(moduleFilePath, text) {
|
|
9315
|
+
const analysis = valModuleOf(moduleFilePath, text);
|
|
9316
|
+
if (analysis === null) return null;
|
|
9317
|
+
const entries = analyzeJsonValuesEntries(analysis.source);
|
|
9318
|
+
if (entries.size === 0) return null;
|
|
9319
|
+
const dir = path__default.posix.dirname(moduleFilePath.slice(1));
|
|
9320
|
+
return Object.fromEntries([...entries].map(([key, {
|
|
9321
|
+
importPath
|
|
9322
|
+
}]) => [key, path__default.posix.join(dir, importPath)]));
|
|
9323
|
+
}
|
|
9324
|
+
|
|
9325
|
+
// --- code and content ------------------------------------------------------------
|
|
9326
|
+
|
|
9327
|
+
/**
|
|
9328
|
+
* A file's code: for a `.val.ts`, everything but `c.define`'s content
|
|
9329
|
+
* argument, which Val reads at request time (step 2) and so is no part of a
|
|
9330
|
+
* build; a `.val.json` entry is no code at all. Every other file is code
|
|
9331
|
+
* whole.
|
|
9332
|
+
*/
|
|
9333
|
+
function codeOf(file, text) {
|
|
9334
|
+
// A `.jsonValues()` entry is content, read at request time like the rest.
|
|
9335
|
+
if (file.endsWith(".val.json")) return "";
|
|
9336
|
+
if (!file.endsWith(".val.ts")) return text;
|
|
9337
|
+
const analysis = valModuleOf(file, text);
|
|
9338
|
+
if (analysis === null) return text;
|
|
9339
|
+
const {
|
|
9340
|
+
source
|
|
9341
|
+
} = analysis;
|
|
9342
|
+
return text.slice(0, source.getStart()) + "/* content */" + text.slice(source.end);
|
|
9343
|
+
}
|
|
9344
|
+
|
|
9345
|
+
/**
|
|
9346
|
+
* A `.val.ts`'s `c.define(...)`, or `null` when it has none: a file of schemas
|
|
9347
|
+
* that pages share (`export const heroSchema = s.object(...)`) is named like a
|
|
9348
|
+
* module and is code. `analyzeValModule` throws for one; this does not.
|
|
9349
|
+
*/
|
|
9350
|
+
function valModuleOf(file, text) {
|
|
9351
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2015, true);
|
|
9352
|
+
if (!sourceFile.statements.some(node => ts.isExportAssignment(node))) {
|
|
9353
|
+
return null;
|
|
9354
|
+
}
|
|
9355
|
+
const analysis = analyzeValModule(sourceFile);
|
|
9356
|
+
return result.isErr(analysis) ? null : analysis.value;
|
|
9357
|
+
}
|
|
9358
|
+
|
|
9359
|
+
/**
|
|
9360
|
+
* The three-way merge of a module's content: where both sides started
|
|
9361
|
+
* (`base`), and each side's version. Pure JSON -- the schema decides nothing
|
|
9362
|
+
* here, and is checked afterwards (the schema check in `merge.ts`).
|
|
9363
|
+
*
|
|
9364
|
+
* The rules (valbuild/home `docs/hosted-publishing.md` step 8.2):
|
|
9365
|
+
*
|
|
9366
|
+
* - Changed on one side: that side's.
|
|
9367
|
+
* - Changed on both: a conflict -- even to the same value, which is an easy
|
|
9368
|
+
* conflict to resolve but still one somebody decides.
|
|
9369
|
+
* - An object (a page, a record) merges key by key, so two keys changed on
|
|
9370
|
+
* two sides combine.
|
|
9371
|
+
* - A list merges item by item only when neither side changed its shape: no
|
|
9372
|
+
* item added, removed or moved. Otherwise positions mean different items on
|
|
9373
|
+
* the two sides, and the whole list is the conflict.
|
|
9374
|
+
*/
|
|
9375
|
+
|
|
9376
|
+
function isObject$1(value) {
|
|
9377
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9378
|
+
}
|
|
9379
|
+
function same(a, b) {
|
|
9380
|
+
if (a === undefined || b === undefined) return a === b;
|
|
9381
|
+
return deepEqual(a, b);
|
|
9382
|
+
}
|
|
9383
|
+
|
|
9384
|
+
/**
|
|
9385
|
+
* Did this side add, remove or move an item? Lengths differ, or an item now
|
|
9386
|
+
* sits where it was not, while it is still what used to be somewhere else.
|
|
9387
|
+
*/
|
|
9388
|
+
function reshaped(base, side) {
|
|
9389
|
+
if (base.length !== side.length) return true;
|
|
9390
|
+
return side.some((item, at) => !same(item, base[at]) && base.some((was, from) => from !== at && same(item, was)));
|
|
9391
|
+
}
|
|
9392
|
+
|
|
9393
|
+
/**
|
|
9394
|
+
* Where a value is one value, though it is an object: a media value (its path
|
|
9395
|
+
* and what describes that file) or rich text. The schema says; see `merge.ts`.
|
|
9396
|
+
*/
|
|
9397
|
+
|
|
9398
|
+
function mergeValues(base, site, proposal, atomic = () => false, path = []) {
|
|
9399
|
+
if (same(site, base)) return {
|
|
9400
|
+
value: proposal,
|
|
9401
|
+
conflicts: []
|
|
9402
|
+
};
|
|
9403
|
+
if (same(proposal, base)) return {
|
|
9404
|
+
value: site,
|
|
9405
|
+
conflicts: []
|
|
9406
|
+
};
|
|
9407
|
+
if (atomic(path)) {
|
|
9408
|
+
return {
|
|
9409
|
+
value: site,
|
|
9410
|
+
conflicts: [{
|
|
9411
|
+
path,
|
|
9412
|
+
base,
|
|
9413
|
+
site,
|
|
9414
|
+
proposal
|
|
9415
|
+
}]
|
|
9416
|
+
};
|
|
9417
|
+
}
|
|
9418
|
+
if (isObject$1(base) && isObject$1(site) && isObject$1(proposal)) {
|
|
9419
|
+
const keys = [...new Set([...Object.keys(site), ...Object.keys(proposal), ...Object.keys(base)])];
|
|
9420
|
+
const value = {};
|
|
9421
|
+
const conflicts = [];
|
|
9422
|
+
for (const key of keys) {
|
|
9423
|
+
const merged = mergeValues(base[key], site[key], proposal[key], atomic, [...path, key]);
|
|
9424
|
+
if (merged.value !== undefined) value[key] = merged.value;
|
|
9425
|
+
conflicts.push(...merged.conflicts);
|
|
9426
|
+
}
|
|
9427
|
+
return {
|
|
9428
|
+
value,
|
|
9429
|
+
conflicts
|
|
9430
|
+
};
|
|
9431
|
+
}
|
|
9432
|
+
if (Array.isArray(base) && Array.isArray(site) && Array.isArray(proposal) && !reshaped(base, site) && !reshaped(base, proposal)) {
|
|
9433
|
+
const value = [];
|
|
9434
|
+
const conflicts = [];
|
|
9435
|
+
base.forEach((item, at) => {
|
|
9436
|
+
const merged = mergeValues(item, site[at], proposal[at], atomic, [...path, String(at)]);
|
|
9437
|
+
// Neither side removed an item, so every position holds one.
|
|
9438
|
+
value.push(merged.value ?? null);
|
|
9439
|
+
conflicts.push(...merged.conflicts);
|
|
9440
|
+
});
|
|
9441
|
+
return {
|
|
9442
|
+
value,
|
|
9443
|
+
conflicts
|
|
9444
|
+
};
|
|
9445
|
+
}
|
|
9446
|
+
return {
|
|
9447
|
+
value: site,
|
|
9448
|
+
conflicts: [{
|
|
9449
|
+
path,
|
|
9450
|
+
base,
|
|
9451
|
+
site,
|
|
9452
|
+
proposal
|
|
9453
|
+
}]
|
|
9454
|
+
};
|
|
9455
|
+
}
|
|
9456
|
+
|
|
9457
|
+
/** `doc` with `value` at `path`; `undefined` removes it. `doc` is not changed. */
|
|
9458
|
+
function setAt(doc, path, value) {
|
|
9459
|
+
if (path.length === 0) return value;
|
|
9460
|
+
const [head, ...rest] = path;
|
|
9461
|
+
if (Array.isArray(doc)) {
|
|
9462
|
+
const copy = [...doc];
|
|
9463
|
+
const at = Number(head);
|
|
9464
|
+
const next = setAt(copy[at], rest, value);
|
|
9465
|
+
if (next === undefined) copy.splice(at, 1);else copy[at] = next;
|
|
9466
|
+
return copy;
|
|
9467
|
+
}
|
|
9468
|
+
const copy = isObject$1(doc) ? {
|
|
9469
|
+
...doc
|
|
9470
|
+
} : {};
|
|
9471
|
+
const next = setAt(copy[head], rest, value);
|
|
9472
|
+
if (next === undefined) delete copy[head];else copy[head] = next;
|
|
9473
|
+
return copy;
|
|
9474
|
+
}
|
|
9475
|
+
function valueAt(doc, path) {
|
|
9476
|
+
let current = doc;
|
|
9477
|
+
for (const key of path) {
|
|
9478
|
+
if (Array.isArray(current)) current = current[Number(key)];else if (isObject$1(current)) current = current[key];else return undefined;
|
|
9479
|
+
}
|
|
9480
|
+
return current;
|
|
9481
|
+
}
|
|
9482
|
+
|
|
9483
|
+
/**
|
|
9484
|
+
* The Val patch that turns `from` into `to`, as small as it can be: what
|
|
9485
|
+
* writes a merge into a `.val.ts` without rewriting what did not change.
|
|
9486
|
+
*/
|
|
9487
|
+
function diffOps(from, to, path = []) {
|
|
9488
|
+
if (same(from, to)) return [];
|
|
9489
|
+
if (isObject$1(from) && isObject$1(to)) {
|
|
9490
|
+
const ops = [];
|
|
9491
|
+
for (const key of Object.keys(from)) {
|
|
9492
|
+
if (!(key in to)) ops.push({
|
|
9493
|
+
op: "remove",
|
|
9494
|
+
path: below(path, key)
|
|
9495
|
+
});
|
|
9496
|
+
}
|
|
9497
|
+
for (const [key, value] of Object.entries(to)) {
|
|
9498
|
+
ops.push(...(key in from ? diffOps(from[key], value, [...path, key]) : [add([...path, key], value)]));
|
|
9499
|
+
}
|
|
9500
|
+
return ops;
|
|
9501
|
+
}
|
|
9502
|
+
if (Array.isArray(from) && Array.isArray(to) && from.length === to.length) {
|
|
9503
|
+
return from.flatMap((item, at) => diffOps(item, to[at], [...path, String(at)]));
|
|
9504
|
+
}
|
|
9505
|
+
return [{
|
|
9506
|
+
op: "replace",
|
|
9507
|
+
path,
|
|
9508
|
+
value: to ?? null
|
|
9509
|
+
}];
|
|
9510
|
+
}
|
|
9511
|
+
function add(path, value) {
|
|
9512
|
+
return {
|
|
9513
|
+
op: "add",
|
|
9514
|
+
path,
|
|
9515
|
+
value
|
|
9516
|
+
};
|
|
9517
|
+
}
|
|
9518
|
+
function below(path, key) {
|
|
9519
|
+
return path.length === 0 ? [key] : [path[0], ...path.slice(1), key];
|
|
9520
|
+
}
|
|
9521
|
+
|
|
9522
|
+
/**
|
|
9523
|
+
* Publishing a proposal as a pull request is merged: valbuild/home
|
|
9524
|
+
* `docs/hosted-publishing.md`, step 8.2.
|
|
9525
|
+
*
|
|
9526
|
+
* Val's half: the two answers that need a schema. Content gets them as files
|
|
9527
|
+
* and change ids and carries them out, never looking at a field, a list or a
|
|
9528
|
+
* patch set.
|
|
9529
|
+
*
|
|
9530
|
+
* - {@link mergeFromSite} -- Update from the site: the merge commit `M`.
|
|
9531
|
+
* - {@link changesToMove} -- which of the site's pending changes leave the
|
|
9532
|
+
* site when a proposal is merged.
|
|
9533
|
+
*
|
|
9534
|
+
* `mergeScenarios.test.ts` is their specification.
|
|
9535
|
+
*/
|
|
9536
|
+
|
|
9537
|
+
/** A field both sides changed since the proposal started. */
|
|
9538
|
+
|
|
9539
|
+
/**
|
|
9540
|
+
* Code both sides changed: a component, `val.modules.ts`, or the schema of a
|
|
9541
|
+
* `.val.ts` (its code -- everything but `c.define`'s content argument, whose
|
|
9542
|
+
* fields merge on their own).
|
|
9543
|
+
*/
|
|
9544
|
+
|
|
9545
|
+
/**
|
|
9546
|
+
* The schema check: content that merged cleanly and is not valid under the
|
|
9547
|
+
* schema the merge ends with -- an entry the site added, say, under a field
|
|
9548
|
+
* whose type the proposal changed.
|
|
9549
|
+
*/
|
|
9550
|
+
|
|
9551
|
+
/** How a conflict is resolved: keep one side, or write it -- as AI or a developer does. */
|
|
9552
|
+
|
|
9553
|
+
/**
|
|
9554
|
+
* Update from the site: a three-way merge of where the proposal started
|
|
9555
|
+
* (`base`), the site's head and the proposal's last save, written whole.
|
|
9556
|
+
* Every conflict needs a resolution, or nothing is merged.
|
|
9557
|
+
*
|
|
9558
|
+
* File by file. A file that is not a `.val.ts` is code, and merges as a
|
|
9559
|
+
* file: changed on one side, that side's; on both, a conflict. A `.val.ts`
|
|
9560
|
+
* is code (everything but `c.define`'s content argument) and content, and the
|
|
9561
|
+
* two merge apart: the code as a file, the content field by field
|
|
9562
|
+
* (`mergeValues`). So a schema the proposal changed and a field the site
|
|
9563
|
+
* published in the same file do not conflict. A code conflict in a `.val.ts`
|
|
9564
|
+
* is resolved by keeping a side's code -- the content still merges -- or by
|
|
9565
|
+
* writing the whole file.
|
|
9566
|
+
*
|
|
9567
|
+
* Not here: the schema check ({@link schemaCheck}). It needs the merged
|
|
9568
|
+
* files' schemas, so whoever can evaluate them -- Val's loader, the builder
|
|
9569
|
+
* tab -- runs it on `files` before the merge is committed.
|
|
9570
|
+
*/
|
|
9571
|
+
function mergeFromSite(input) {
|
|
9572
|
+
const {
|
|
9573
|
+
base,
|
|
9574
|
+
site,
|
|
9575
|
+
proposal,
|
|
9576
|
+
resolutions
|
|
9577
|
+
} = input;
|
|
9578
|
+
const files = {};
|
|
9579
|
+
const modules = {};
|
|
9580
|
+
const conflicts = [];
|
|
9581
|
+
const paths = [...new Set([...Object.keys(base.files), ...Object.keys(site.files), ...Object.keys(proposal.files)])].sort();
|
|
9582
|
+
// A `.jsonValues()` entry is written with its module, never on its own.
|
|
9583
|
+
const entryFiles = new Set([base, site, proposal].flatMap(side => Object.values(side.modules).flatMap(module => Object.values(module.entries ?? {}))));
|
|
9584
|
+
for (const file of paths) {
|
|
9585
|
+
var _moduleOf3, _moduleOf4, _moduleOf5, _moduleOf6;
|
|
9586
|
+
if (entryFiles.has(file)) continue;
|
|
9587
|
+
const b = base.files[file] ?? null;
|
|
9588
|
+
const s = site.files[file] ?? null;
|
|
9589
|
+
const p = proposal.files[file] ?? null;
|
|
9590
|
+
const resolution = fileResolution(resolutions, file);
|
|
9591
|
+
const codeConflict = () => ({
|
|
9592
|
+
kind: "code",
|
|
9593
|
+
file,
|
|
9594
|
+
base: b,
|
|
9595
|
+
site: s,
|
|
9596
|
+
proposal: p
|
|
9597
|
+
});
|
|
9598
|
+
|
|
9599
|
+
// A file named like a module that no side defines as one -- schemas the
|
|
9600
|
+
// pages share -- is code.
|
|
9601
|
+
const moduleFilePath = `/${file}`;
|
|
9602
|
+
const isModule = [base, site, proposal].some(side => side.modules[moduleFilePath] !== undefined);
|
|
9603
|
+
if (!file.endsWith(".val.ts") || !isModule) {
|
|
9604
|
+
if (s === b) files[file] = p;else if (p === b) files[file] = s;else if (resolution) files[file] = resolvedText(resolution, s, p);else {
|
|
9605
|
+
conflicts.push(codeConflict());
|
|
9606
|
+
files[file] = s;
|
|
9607
|
+
}
|
|
9608
|
+
continue;
|
|
9609
|
+
}
|
|
9610
|
+
|
|
9611
|
+
// A .val.ts: whose code, then the content under it.
|
|
9612
|
+
const code = text => text === null ? null : codeOf(file, text);
|
|
9613
|
+
let side;
|
|
9614
|
+
if (code(s) === code(b)) side = "proposal";else if (code(p) === code(b)) side = "site";else if (resolution && "text" in resolution) {
|
|
9615
|
+
files[file] = resolution.text;
|
|
9616
|
+
continue;
|
|
9617
|
+
} else if (resolution) side = resolution.keep;else {
|
|
9618
|
+
conflicts.push(codeConflict());
|
|
9619
|
+
files[file] = s;
|
|
9620
|
+
continue;
|
|
9621
|
+
}
|
|
9622
|
+
const moduleOf = project => project.modules[moduleFilePath];
|
|
9623
|
+
const sourceOf = project => {
|
|
9624
|
+
var _moduleOf;
|
|
9625
|
+
return (_moduleOf = moduleOf(project)) === null || _moduleOf === void 0 ? void 0 : _moduleOf.source;
|
|
9626
|
+
};
|
|
9627
|
+
const chosen = side === "site" ? site : proposal;
|
|
9628
|
+
const text = side === "site" ? s : p;
|
|
9629
|
+
if (text === null) {
|
|
9630
|
+
// That side deleted the file. Content the other side changed would go
|
|
9631
|
+
// with it: that is for a person to decide -- unless they have.
|
|
9632
|
+
const other = side === "site" ? p : s;
|
|
9633
|
+
if (!resolution && other !== b) {
|
|
9634
|
+
conflicts.push(codeConflict());
|
|
9635
|
+
}
|
|
9636
|
+
files[file] = null;
|
|
9637
|
+
for (const project of [base, site, proposal]) {
|
|
9638
|
+
for (const entry of Object.values(((_moduleOf2 = moduleOf(project)) === null || _moduleOf2 === void 0 ? void 0 : _moduleOf2.entries) ?? {})) {
|
|
9639
|
+
var _moduleOf2;
|
|
9640
|
+
files[entry] = null;
|
|
9641
|
+
}
|
|
9642
|
+
}
|
|
9643
|
+
continue;
|
|
9644
|
+
}
|
|
9645
|
+
// What the chosen text holds, which the merge is written over.
|
|
9646
|
+
const from = ((_moduleOf3 = moduleOf(chosen)) === null || _moduleOf3 === void 0 ? void 0 : _moduleOf3.written) ?? ((_moduleOf4 = moduleOf(chosen)) === null || _moduleOf4 === void 0 ? void 0 : _moduleOf4.source);
|
|
9647
|
+
const merged = mergeValues(sourceOf(base), sourceOf(site), sourceOf(proposal), atomicFor((_moduleOf5 = moduleOf(chosen)) === null || _moduleOf5 === void 0 ? void 0 : _moduleOf5.schema));
|
|
9648
|
+
const resolved = resolveContent(moduleFilePath, merged, resolutions);
|
|
9649
|
+
conflicts.push(...resolved.unresolved);
|
|
9650
|
+
const content = resolved.content;
|
|
9651
|
+
modules[moduleFilePath] = content;
|
|
9652
|
+
const entries = (_moduleOf6 = moduleOf(chosen)) === null || _moduleOf6 === void 0 ? void 0 : _moduleOf6.entries;
|
|
9653
|
+
if (entries) {
|
|
9654
|
+
var _moduleOf7, _moduleOf8;
|
|
9655
|
+
Object.assign(files, writeEntries(file, text, chosen.files, entries, from, content, [(_moduleOf7 = moduleOf(site)) === null || _moduleOf7 === void 0 ? void 0 : _moduleOf7.entries, (_moduleOf8 = moduleOf(proposal)) === null || _moduleOf8 === void 0 ? void 0 : _moduleOf8.entries]));
|
|
9656
|
+
} else {
|
|
9657
|
+
files[file] = writeContent(file, text, from, content);
|
|
9658
|
+
}
|
|
9659
|
+
}
|
|
9660
|
+
if (conflicts.length > 0) return {
|
|
9661
|
+
status: "conflicts",
|
|
9662
|
+
conflicts
|
|
9663
|
+
};
|
|
9664
|
+
return {
|
|
9665
|
+
status: "merged",
|
|
9666
|
+
files,
|
|
9667
|
+
modules,
|
|
9668
|
+
code: Object.keys(files).filter(file => {
|
|
9669
|
+
const text = files[file];
|
|
9670
|
+
const before = site.files[file] ?? null;
|
|
9671
|
+
if (text === null || before === null) return text !== before;
|
|
9672
|
+
return codeOf(file, text) !== codeOf(file, before);
|
|
9673
|
+
})
|
|
9674
|
+
};
|
|
9675
|
+
}
|
|
9676
|
+
|
|
9677
|
+
// --- the pieces --------------------------------------------------------------
|
|
9678
|
+
|
|
9679
|
+
/**
|
|
9680
|
+
* A module's merged content with the resolutions written in: each field
|
|
9681
|
+
* conflict that has one, and any value written for the schema check (or
|
|
9682
|
+
* anywhere else) as given. What is left is what still needs a person.
|
|
9683
|
+
*/
|
|
9684
|
+
function resolveContent(moduleFilePath, merged, resolutions) {
|
|
9685
|
+
let content = merged.value;
|
|
9686
|
+
const unresolved = [];
|
|
9687
|
+
for (const conflict of merged.conflicts) {
|
|
9688
|
+
const resolved = fieldResolution(resolutions, moduleFilePath, conflict);
|
|
9689
|
+
if (resolved === null) {
|
|
9690
|
+
unresolved.push({
|
|
9691
|
+
kind: "field",
|
|
9692
|
+
moduleFilePath,
|
|
9693
|
+
...conflict
|
|
9694
|
+
});
|
|
9695
|
+
} else {
|
|
9696
|
+
content = setAt(content, conflict.path, resolved.value);
|
|
9697
|
+
}
|
|
9698
|
+
}
|
|
9699
|
+
for (const resolution of resolutions) {
|
|
9700
|
+
if ("moduleFilePath" in resolution && resolution.moduleFilePath === moduleFilePath && "value" in resolution && !merged.conflicts.some(c => samePath(c.path, resolution.path))) {
|
|
9701
|
+
content = setAt(content, resolution.path, resolution.value);
|
|
9702
|
+
}
|
|
9703
|
+
}
|
|
9704
|
+
return {
|
|
9705
|
+
content,
|
|
9706
|
+
unresolved
|
|
9707
|
+
};
|
|
9708
|
+
}
|
|
9709
|
+
|
|
9710
|
+
/**
|
|
9711
|
+
* The schema check of a merge: content in `merged` -- the project
|
|
9712
|
+
* {@link mergeFromSite} wrote, with its schemas evaluated -- that is invalid
|
|
9713
|
+
* under the schema it ends with, leaving out what was already invalid on a
|
|
9714
|
+
* side. A conflict, each; a resolution with a value for one is written into
|
|
9715
|
+
* the content by the merge, and so is checked here.
|
|
9716
|
+
*/
|
|
9717
|
+
function schemaCheck(merged, sides) {
|
|
9718
|
+
const conflicts = [];
|
|
9719
|
+
for (const [path, module] of Object.entries(merged.modules)) {
|
|
9720
|
+
const moduleFilePath = path;
|
|
9721
|
+
const known = new Set(sides.flatMap(side => {
|
|
9722
|
+
const there = side.modules[moduleFilePath];
|
|
9723
|
+
return there ? errorsOfSource(moduleFilePath, there).map(e => e.path.join("/")) : [];
|
|
9724
|
+
}));
|
|
9725
|
+
for (const error of errorsOfSource(moduleFilePath, module)) {
|
|
9726
|
+
if (known.has(error.path.join("/"))) continue;
|
|
9727
|
+
conflicts.push({
|
|
9728
|
+
kind: "invalid",
|
|
9729
|
+
moduleFilePath,
|
|
9730
|
+
path: error.path,
|
|
9731
|
+
value: valueAt(module.source, error.path),
|
|
9732
|
+
message: error.message
|
|
9733
|
+
});
|
|
9734
|
+
}
|
|
9735
|
+
}
|
|
9736
|
+
return conflicts;
|
|
9737
|
+
}
|
|
9738
|
+
|
|
9739
|
+
/**
|
|
9740
|
+
* The values that merge whole, though they are objects: media (an image's
|
|
9741
|
+
* alt text describes that image, not another) and rich text.
|
|
9742
|
+
*/
|
|
9743
|
+
const ATOMIC = new Set(["image", "file", "video", "font", "richtext"]);
|
|
9744
|
+
|
|
9745
|
+
/** See {@link ATOMIC}: where this schema holds a value that merges whole. */
|
|
9746
|
+
function atomicFor(schema) {
|
|
9747
|
+
if (!schema) return () => false;
|
|
9748
|
+
return path => {
|
|
9749
|
+
const types = schemaTypesOfPath(schema, path);
|
|
9750
|
+
return [...types].some(type => ATOMIC.has(type));
|
|
9751
|
+
};
|
|
9752
|
+
}
|
|
9753
|
+
const tsOps = new TSOps(document => pipe(analyzeValModule(document), result.map(({
|
|
9754
|
+
source
|
|
9755
|
+
}) => source)));
|
|
9756
|
+
|
|
9757
|
+
/**
|
|
9758
|
+
* `content` written into the `.val.ts` `text`, whose content is `from`: as
|
|
9759
|
+
* the smallest patch, through the same AST operations a save uses, so what
|
|
9760
|
+
* did not change is left exactly as it was.
|
|
9761
|
+
*/
|
|
9762
|
+
function writeContent(file, text, from, content) {
|
|
9763
|
+
const ops = diffOps(from, content);
|
|
9764
|
+
if (ops.length === 0) return text;
|
|
9765
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2015, true);
|
|
9766
|
+
return printed(unwrap(file, applyPatch(sourceFile, tsOps, ops)));
|
|
9767
|
+
}
|
|
9768
|
+
|
|
9769
|
+
/**
|
|
9770
|
+
* A `.jsonValues()` module, written as its files: the `.val.ts` names the
|
|
9771
|
+
* entries -- one added gets a `c.json(...)` line, as a save writes it, one
|
|
9772
|
+
* removed loses its line -- and each entry is its own `.val.json`. An entry
|
|
9773
|
+
* whose content did not change keeps its file's text exactly.
|
|
9774
|
+
*/
|
|
9775
|
+
function writeEntries(file, text, chosenFiles, entries, from, content, others) {
|
|
9776
|
+
const written = {};
|
|
9777
|
+
const before = isRecord$4(from) ? from : {};
|
|
9778
|
+
const after = isRecord$4(content) ? content : {};
|
|
9779
|
+
const moduleDir = path__default.posix.dirname(file);
|
|
9780
|
+
let sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2015, true);
|
|
9781
|
+
for (const key of Object.keys(before)) {
|
|
9782
|
+
if (key in after) continue;
|
|
9783
|
+
sourceFile = unwrap(file, removeValJsonEntry(sourceFile, [], key));
|
|
9784
|
+
written[entries[key]] = null;
|
|
9785
|
+
}
|
|
9786
|
+
for (const [key, value] of Object.entries(after)) {
|
|
9787
|
+
const entryFile = entries[key] ?? others.map(other => other === null || other === void 0 ? void 0 : other[key]).find(f => f !== undefined) ?? path__default.posix.join(moduleDir, path__default.posix.basename(file, ".val.ts"), `${key}.val.json`);
|
|
9788
|
+
if (!(key in before)) {
|
|
9789
|
+
const importPath = `./${path__default.posix.relative(moduleDir, entryFile)}`;
|
|
9790
|
+
sourceFile = unwrap(file, insertValJsonEntry(sourceFile, [], key, importPath));
|
|
9791
|
+
}
|
|
9792
|
+
const kept = chosenFiles[entryFile];
|
|
9793
|
+
written[entryFile] = key in before && diffOps(before[key], value).length === 0 && kept ? kept : JSON.stringify(value, null, 2);
|
|
9794
|
+
}
|
|
9795
|
+
written[file] = printed(sourceFile);
|
|
9796
|
+
return written;
|
|
9797
|
+
}
|
|
9798
|
+
function isRecord$4(value) {
|
|
9799
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9800
|
+
}
|
|
9801
|
+
function unwrap(file, written) {
|
|
9802
|
+
if (result.isErr(written)) {
|
|
9803
|
+
throw new Error(`could not write the merge into ${file}: ${String(written.error)}`);
|
|
9804
|
+
}
|
|
9805
|
+
return written.value;
|
|
9806
|
+
}
|
|
9807
|
+
|
|
9808
|
+
/** As `prepare` prints: TypeScript escapes non-ASCII as \\u sequences. */
|
|
9809
|
+
function printed(sourceFile) {
|
|
9810
|
+
return unescape(sourceFile.getText(sourceFile).replace(/\\u/g, "%u"));
|
|
9811
|
+
}
|
|
9812
|
+
function fileResolution(resolutions, file) {
|
|
9813
|
+
for (const resolution of resolutions) {
|
|
9814
|
+
if ("file" in resolution && resolution.file === file) return resolution;
|
|
9815
|
+
}
|
|
9816
|
+
return null;
|
|
9817
|
+
}
|
|
9818
|
+
function resolvedText(resolution, site, proposal) {
|
|
9819
|
+
if ("text" in resolution) return resolution.text;
|
|
9820
|
+
return resolution.keep === "site" ? site : proposal;
|
|
9821
|
+
}
|
|
9822
|
+
|
|
9823
|
+
/** A field conflict's resolution, as the value it leaves; `null` when it has none. */
|
|
9824
|
+
function fieldResolution(resolutions, moduleFilePath, conflict) {
|
|
9825
|
+
for (const resolution of resolutions) {
|
|
9826
|
+
if (!("moduleFilePath" in resolution) || resolution.moduleFilePath !== moduleFilePath || !samePath(resolution.path, conflict.path)) {
|
|
9827
|
+
continue;
|
|
9828
|
+
}
|
|
9829
|
+
if ("value" in resolution) return {
|
|
9830
|
+
value: resolution.value
|
|
9831
|
+
};
|
|
9832
|
+
return {
|
|
9833
|
+
value: resolution.keep === "site" ? conflict.site : conflict.proposal
|
|
9834
|
+
};
|
|
9835
|
+
}
|
|
9836
|
+
return null;
|
|
9837
|
+
}
|
|
9838
|
+
function samePath(a, b) {
|
|
9839
|
+
return a.length === b.length && a.every((key, at) => key === b[at]);
|
|
9840
|
+
}
|
|
9841
|
+
|
|
9842
|
+
/**
|
|
9843
|
+
* Update from the site when only content changed (valbuild/home
|
|
9844
|
+
* `docs/hosted-publishing.md` step 8.2, slice 1a): the proposal staged no code
|
|
9845
|
+
* and the site still serves the build the proposal stands on. Then all three
|
|
9846
|
+
* sides share one schema -- the running build's -- so the merge needs no
|
|
9847
|
+
* project loaded, which is what lets Val's server at a proposal's address run
|
|
9848
|
+
* it (it cannot evaluate a `.val.ts`).
|
|
9849
|
+
*
|
|
9850
|
+
* The same rules as {@link mergeFromSite}, module by module: field by field,
|
|
9851
|
+
* the same value on both sides a conflict, media and rich text whole. Content
|
|
9852
|
+
* hands over each module the proposal saved, as it was where the proposal
|
|
9853
|
+
* started, as the site has it, and as the proposal saved it; the merge is
|
|
9854
|
+
* written into the proposal's own `.val.ts` text with the same AST operations
|
|
9855
|
+
* a save uses.
|
|
9856
|
+
*
|
|
9857
|
+
* Not here: a `.jsonValues()` record the site changed. Its Source in content's
|
|
9858
|
+
* index holds only the entries a commit touched, so it cannot be merged from
|
|
9859
|
+
* the index, and its entries are `.val.json` files the `.val.ts` only names,
|
|
9860
|
+
* which writing into the `.val.ts` cannot change. That waits for the full
|
|
9861
|
+
* merge (`writeEntries`), and is answered as such -- with or without a
|
|
9862
|
+
* conflict, since a clean merge still has the site's entries to write.
|
|
9863
|
+
*/
|
|
9864
|
+
|
|
9865
|
+
function mergeContent(input) {
|
|
9866
|
+
const conflicts = [];
|
|
9867
|
+
const files = {};
|
|
9868
|
+
const modules = {};
|
|
9869
|
+
const needsFullMerge = [];
|
|
9870
|
+
for (const path of Object.keys(input.modules).sort()) {
|
|
9871
|
+
const moduleFilePath = path;
|
|
9872
|
+
const {
|
|
9873
|
+
base,
|
|
9874
|
+
site,
|
|
9875
|
+
proposal,
|
|
9876
|
+
text
|
|
9877
|
+
} = input.modules[moduleFilePath];
|
|
9878
|
+
const schema = input.schemas[moduleFilePath];
|
|
9879
|
+
if (!schema) {
|
|
9880
|
+
// The build has no such module: its code changed, so this is not a
|
|
9881
|
+
// content-only update.
|
|
9882
|
+
needsFullMerge.push(moduleFilePath);
|
|
9883
|
+
continue;
|
|
9884
|
+
}
|
|
9885
|
+
const serialized = schema["executeSerialize"]();
|
|
9886
|
+
const orUndefined = value => value ?? undefined;
|
|
9887
|
+
const merged = mergeValues(orUndefined(base), orUndefined(site), orUndefined(proposal), atomicFor(serialized));
|
|
9888
|
+
if (serialized.type === "record" && serialized.jsonValues && (merged.conflicts.length > 0 || diffOps(orUndefined(proposal), merged.value).length > 0)) {
|
|
9889
|
+
needsFullMerge.push(moduleFilePath);
|
|
9890
|
+
continue;
|
|
9891
|
+
}
|
|
9892
|
+
const resolved = resolveContent(moduleFilePath, merged, input.resolutions);
|
|
9893
|
+
conflicts.push(...resolved.unresolved);
|
|
9894
|
+
if (resolved.unresolved.length > 0) continue;
|
|
9895
|
+
|
|
9896
|
+
// The schema check, under the one schema there is: what was already
|
|
9897
|
+
// invalid on a side is not the merge's doing. A value written as a
|
|
9898
|
+
// resolution is already in `content`, and checked like the rest -- as
|
|
9899
|
+
// `schemaCheck` does for the full merge.
|
|
9900
|
+
const known = new Set([site, proposal].flatMap(side => validateWith(schema, moduleFilePath, orUndefined(side)).map(e => e.path.join("/"))));
|
|
9901
|
+
const content = resolved.content;
|
|
9902
|
+
for (const error of validateWith(schema, moduleFilePath, content)) {
|
|
9903
|
+
if (known.has(error.path.join("/"))) continue;
|
|
9904
|
+
conflicts.push({
|
|
9905
|
+
kind: "invalid",
|
|
9906
|
+
moduleFilePath,
|
|
9907
|
+
path: error.path,
|
|
9908
|
+
value: valueAt(content, error.path),
|
|
9909
|
+
message: error.message
|
|
9910
|
+
});
|
|
9911
|
+
}
|
|
9912
|
+
const file = moduleFilePath.slice(1);
|
|
9913
|
+
files[file] = writeContent(file, text, orUndefined(proposal), content);
|
|
9914
|
+
modules[moduleFilePath] = {
|
|
9915
|
+
source: content ?? null,
|
|
9916
|
+
schema: serialized
|
|
9917
|
+
};
|
|
9918
|
+
}
|
|
9919
|
+
if (needsFullMerge.length > 0) {
|
|
9920
|
+
return {
|
|
9921
|
+
status: "needs-full-merge",
|
|
9922
|
+
modules: needsFullMerge
|
|
9923
|
+
};
|
|
9924
|
+
}
|
|
9925
|
+
if (conflicts.length > 0) return {
|
|
9926
|
+
status: "conflicts",
|
|
9927
|
+
conflicts
|
|
9928
|
+
};
|
|
9929
|
+
return {
|
|
9930
|
+
status: "merged",
|
|
9931
|
+
files,
|
|
9932
|
+
modules
|
|
9933
|
+
};
|
|
9934
|
+
}
|
|
9935
|
+
|
|
9936
|
+
/**
|
|
9937
|
+
* Update from the site, for a proposal with code (valbuild/home
|
|
9938
|
+
* `docs/hosted-publishing.md` step 8.2, slice 1b): Val's server's half.
|
|
9939
|
+
*
|
|
9940
|
+
* Nothing here runs project code. Content hands over the three trees and each
|
|
9941
|
+
* module's Source in each (`merge-trees`); the builder tab evaluates each
|
|
9942
|
+
* tree's `val.modules.ts` -- the site's own dependencies, from its vendor
|
|
9943
|
+
* layer -- and sends what that gave: each module's schema, the content its
|
|
9944
|
+
* text holds, and the errors of its Source under that schema. This does the
|
|
9945
|
+
* text, with the TypeScript Val's server already has.
|
|
9946
|
+
*
|
|
9947
|
+
* In two calls, because the schema check needs the merged tree evaluated too:
|
|
9948
|
+
*
|
|
9949
|
+
* 1. Without `merged`: the merge `M`, as the files it changes over the site
|
|
9950
|
+
* and each module's content -- for the tab to evaluate -- under an `id`.
|
|
9951
|
+
* Or the conflicts, for a person.
|
|
9952
|
+
* 2. With `merged`, the tab's evaluation of that `id`: the schema check, then
|
|
9953
|
+
* what content commits. The merge is made again rather than taken from the
|
|
9954
|
+
* tab, and must come out as the same `id`.
|
|
9955
|
+
*/
|
|
9956
|
+
|
|
9957
|
+
function updateWithCode(input) {
|
|
9958
|
+
const {
|
|
9959
|
+
trees,
|
|
9960
|
+
evaluated,
|
|
9961
|
+
resolutions
|
|
9962
|
+
} = input;
|
|
9963
|
+
const projectOf = side => {
|
|
9964
|
+
const files = sideFiles(trees, side);
|
|
9965
|
+
return {
|
|
9966
|
+
files,
|
|
9967
|
+
modules: modulesOf(files, evaluated[side], path => sideSource(trees, side, path))
|
|
9968
|
+
};
|
|
9969
|
+
};
|
|
9970
|
+
const base = projectOf("base");
|
|
9971
|
+
const site = projectOf("site");
|
|
9972
|
+
const proposal = projectOf("proposal");
|
|
9973
|
+
const result = mergeFromSite({
|
|
9974
|
+
base,
|
|
9975
|
+
site,
|
|
9976
|
+
proposal,
|
|
9977
|
+
resolutions
|
|
9978
|
+
});
|
|
9979
|
+
if (result.status === "conflicts") return result;
|
|
9980
|
+
const changes = {};
|
|
9981
|
+
for (const [path, text] of Object.entries(result.files)) {
|
|
9982
|
+
if (text !== (site.files[path] ?? null)) changes[path] = text;
|
|
9983
|
+
}
|
|
9984
|
+
const id = mergeId(changes, result.modules);
|
|
9985
|
+
if (input.merged === undefined) {
|
|
9986
|
+
const sources = {};
|
|
9987
|
+
for (const [path, content] of Object.entries(result.modules)) {
|
|
9988
|
+
sources[path] = content ?? null;
|
|
9989
|
+
}
|
|
9990
|
+
return {
|
|
9991
|
+
status: "evaluate",
|
|
9992
|
+
id,
|
|
9993
|
+
files: changes,
|
|
9994
|
+
sources
|
|
9995
|
+
};
|
|
9996
|
+
}
|
|
9997
|
+
if (input.merged.id !== id) return {
|
|
9998
|
+
status: "stale"
|
|
9999
|
+
};
|
|
10000
|
+
const mergedFiles = {
|
|
10001
|
+
...site.files,
|
|
10002
|
+
...changes
|
|
10003
|
+
};
|
|
10004
|
+
const mergedProject = {
|
|
10005
|
+
files: mergedFiles,
|
|
10006
|
+
modules: modulesOf(Object.fromEntries(Object.entries(mergedFiles).filter(entry => entry[1] !== null)), input.merged.evaluated, path => result.modules[path] ?? null)
|
|
10007
|
+
};
|
|
10008
|
+
const invalid = schemaCheck(mergedProject, [site, proposal]);
|
|
10009
|
+
if (invalid.length > 0) return {
|
|
10010
|
+
status: "conflicts",
|
|
10011
|
+
conflicts: invalid
|
|
10012
|
+
};
|
|
10013
|
+
|
|
10014
|
+
// The paths the proposal held go back to what the merge left -- the site's,
|
|
10015
|
+
// often -- so nothing it held before is left standing over the new base.
|
|
10016
|
+
const files = {
|
|
10017
|
+
...changes
|
|
10018
|
+
};
|
|
10019
|
+
for (const path of Object.keys(trees.trees.proposal)) {
|
|
10020
|
+
if (!(path in files)) files[path] = mergedFiles[path] ?? null;
|
|
10021
|
+
}
|
|
10022
|
+
const modules = {};
|
|
10023
|
+
for (const [path, module] of Object.entries(mergedProject.modules)) {
|
|
10024
|
+
var _trees$modules$module, _trees$modules$module2;
|
|
10025
|
+
const moduleFilePath = path;
|
|
10026
|
+
const onSite = site.modules[moduleFilePath];
|
|
10027
|
+
const touched = ((_trees$modules$module = trees.modules[moduleFilePath]) === null || _trees$modules$module === void 0 ? void 0 : _trees$modules$module.proposal) !== ((_trees$modules$module2 = trees.modules[moduleFilePath]) === null || _trees$modules$module2 === void 0 ? void 0 : _trees$modules$module2.base);
|
|
10028
|
+
if (touched || onSite === undefined || !sameJson(onSite.source, module.source) || !sameJson(onSite.schema, module.schema)) {
|
|
10029
|
+
modules[moduleFilePath] = {
|
|
10030
|
+
source: module.source,
|
|
10031
|
+
schema: module.schema
|
|
10032
|
+
};
|
|
10033
|
+
}
|
|
10034
|
+
}
|
|
10035
|
+
return {
|
|
10036
|
+
status: "merged",
|
|
10037
|
+
files,
|
|
10038
|
+
staged: result.code.filter(path => path in files),
|
|
10039
|
+
modules
|
|
10040
|
+
};
|
|
10041
|
+
}
|
|
10042
|
+
function modulesOf(files, evaluated, sourceOf) {
|
|
10043
|
+
const modules = {};
|
|
10044
|
+
for (const [path, module] of Object.entries(evaluated)) {
|
|
10045
|
+
const moduleFilePath = path;
|
|
10046
|
+
const text = files[moduleFilePath.slice(1)];
|
|
10047
|
+
modules[moduleFilePath] = {
|
|
10048
|
+
// A module the index has no Source for is one this tree's code adds:
|
|
10049
|
+
// its text is all there is.
|
|
10050
|
+
source: sourceOf(moduleFilePath) ?? module.written,
|
|
10051
|
+
written: module.written,
|
|
10052
|
+
schema: module.schema,
|
|
10053
|
+
entries: text === undefined ? null : entryFilesOf(moduleFilePath, text),
|
|
10054
|
+
errors: module.errors
|
|
10055
|
+
};
|
|
10056
|
+
}
|
|
10057
|
+
return modules;
|
|
10058
|
+
}
|
|
10059
|
+
|
|
10060
|
+
/** The merge, named by what it is: the files it changes and the content it writes. */
|
|
10061
|
+
function mergeId(files, modules) {
|
|
10062
|
+
const sorted = record => Object.keys(record).sort().map(key => [key, record[key] ?? null]);
|
|
10063
|
+
return createHash("sha256").update("val.update-with-code.v1\n").update(JSON.stringify({
|
|
10064
|
+
files: sorted(files),
|
|
10065
|
+
modules: sorted(modules)
|
|
10066
|
+
})).digest("hex");
|
|
10067
|
+
}
|
|
10068
|
+
function sameJson(a, b) {
|
|
10069
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
10070
|
+
}
|
|
10071
|
+
|
|
10072
|
+
const textEncoder$2 = new TextEncoder();
|
|
10073
|
+
|
|
10074
|
+
/** The body of the content API's 403 for a write the project's plan refuses. */
|
|
10075
|
+
const PlanRefusedBody = z.object({
|
|
10076
|
+
error: PlanRefusalError
|
|
10077
|
+
});
|
|
10078
|
+
const PatchId = z.string().refine(s => !!s); // TODO: validate
|
|
10079
|
+
const CommitSha = z.string().refine(s => !!s); // TODO: validate
|
|
10080
|
+
z.string().refine(s => !!s); // TODO: validate
|
|
10081
|
+
z.string().refine(s => !!s); // TODO: validate
|
|
10082
|
+
const ModuleFilePath = z.string().refine(s => !!s); // TODO: validate
|
|
10083
|
+
const Metadata = z.union([z.object({
|
|
10084
|
+
mimeType: z.string(),
|
|
10085
|
+
width: z.number(),
|
|
10086
|
+
height: z.number()
|
|
10087
|
+
}), z.object({
|
|
10088
|
+
mimeType: z.string()
|
|
10089
|
+
})]);
|
|
10090
|
+
const MetadataRes = z.object({
|
|
10091
|
+
filePath: ModuleFilePath,
|
|
10092
|
+
metadata: Metadata,
|
|
10093
|
+
type: z.union([z.literal("file"), z.literal("image")]).nullable()
|
|
10094
|
+
});
|
|
10095
|
+
const GetApplicablePatches = z.object({
|
|
10096
|
+
patches: z.array(z.object({
|
|
10097
|
+
path: z.string(),
|
|
10098
|
+
patch: Patch.nullable(),
|
|
10099
|
+
patchId: z.string(),
|
|
10100
|
+
authorId: z.string().nullable(),
|
|
10101
|
+
baseSha: z.string(),
|
|
10102
|
+
createdAt: z.string(),
|
|
10103
|
+
applied: z.object({
|
|
10104
|
+
commitSha: z.string()
|
|
10105
|
+
}).nullable()
|
|
10106
|
+
})),
|
|
10107
|
+
/**
|
|
10108
|
+
* The head of the chain. See `OrderedPatches.headPatchId`. Optional because
|
|
10109
|
+
* a content service that predates it sends nothing.
|
|
10110
|
+
*/
|
|
10111
|
+
headPatchId: PatchId$1.nullable().optional(),
|
|
10112
|
+
/**
|
|
10113
|
+
* The chain version of that head. See `OrderedPatches.headVersion`.
|
|
10114
|
+
* Optional for the same reason.
|
|
10115
|
+
*/
|
|
10116
|
+
headVersion: z.number().optional(),
|
|
10117
|
+
/** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
|
|
10118
|
+
publishingPatches: z.array(PatchId$1).optional(),
|
|
10119
|
+
/** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
|
|
10120
|
+
heldBackPatches: z.array(PatchId$1).optional(),
|
|
10121
|
+
commits: z.array(z.object({
|
|
10122
|
+
commitSha: z.string(),
|
|
10123
|
+
/*
|
|
10124
|
+
* Nullable since the content service mints its own commit shas: a
|
|
10125
|
+
* root commit has no parent, and a publisher that did not say where
|
|
10126
|
+
* it was has no client sha. Parsed rather than trusted, so a service
|
|
10127
|
+
* that sends null gets null here instead of failing the whole poll.
|
|
10128
|
+
*/
|
|
10129
|
+
clientCommitSha: z.string().nullable(),
|
|
10130
|
+
parentCommitSha: z.string().nullable(),
|
|
10131
|
+
commitMessage: z.string().nullable(),
|
|
10132
|
+
branch: z.string(),
|
|
10133
|
+
creator: z.string(),
|
|
10134
|
+
createdAt: z.string()
|
|
10135
|
+
})).optional(),
|
|
10136
|
+
deployments: z.array(z.object({
|
|
10137
|
+
deploymentId: z.string(),
|
|
10138
|
+
commitSha: z.string(),
|
|
10139
|
+
deploymentState: z.string(),
|
|
10140
|
+
createdAt: z.string(),
|
|
10141
|
+
updatedAt: z.string(),
|
|
10142
|
+
// The git message, where the content service knows it. See
|
|
10143
|
+
// `ValDeployment`: this is the only source for a deployment that Val
|
|
10144
|
+
// did not publish, and zod would otherwise strip it here.
|
|
10145
|
+
commitMessage: z.string().nullable().optional()
|
|
10146
|
+
})).optional(),
|
|
10147
|
+
/**
|
|
10148
|
+
* What the project EXPECTS of whoever publishes it.
|
|
10149
|
+
*
|
|
10150
|
+
* Optional because a content service that predates it sends nothing, and
|
|
10151
|
+
* absent means "not reported" -- never "managed". The difference decides
|
|
10152
|
+
* whether a publish is refused, so guessing either way would be worse than
|
|
10153
|
+
* not checking: guessing `managed` would let a deployment publish a
|
|
10154
|
+
* connected project it cannot mirror, and guessing `connected` would refuse
|
|
10155
|
+
* every publish against an older service.
|
|
10156
|
+
*/
|
|
10157
|
+
project: z.object({
|
|
10158
|
+
sourceMode: z.union([z.literal("managed"), z.literal("connected")]),
|
|
10159
|
+
branch: z.string(),
|
|
10160
|
+
/**
|
|
10161
|
+
* Whether a press of Publish is a publish JOB. Absent from a content
|
|
10162
|
+
* service that predates connected jobs: then managed projects publish
|
|
10163
|
+
* as jobs, and connected ones by commit, as they did.
|
|
10164
|
+
*/
|
|
10165
|
+
publishJobs: z.boolean().optional()
|
|
10166
|
+
}).optional()
|
|
10167
|
+
});
|
|
10168
|
+
const FilesResponse = z.object({
|
|
10169
|
+
files: z.array(z.union([z.object({
|
|
10170
|
+
filePath: z.string(),
|
|
10171
|
+
location: z.literal("patch"),
|
|
10172
|
+
patchId: PatchId,
|
|
10173
|
+
value: z.string(),
|
|
10174
|
+
remote: z.boolean()
|
|
10175
|
+
}), z.object({
|
|
10176
|
+
filePath: z.string(),
|
|
10177
|
+
location: z.literal("repo"),
|
|
10178
|
+
commitSha: CommitSha,
|
|
10179
|
+
value: z.string()
|
|
10180
|
+
})])),
|
|
10181
|
+
errors: z.array(z.union([z.object({
|
|
10182
|
+
filePath: z.string(),
|
|
10183
|
+
location: z.literal("patch"),
|
|
10184
|
+
patchId: PatchId,
|
|
10185
|
+
message: z.string(),
|
|
10186
|
+
remote: z.boolean()
|
|
10187
|
+
}), z.object({
|
|
10188
|
+
filePath: z.string(),
|
|
10189
|
+
location: z.literal("repo"),
|
|
10190
|
+
commitSha: CommitSha,
|
|
10191
|
+
message: z.string()
|
|
10192
|
+
})])).optional()
|
|
10193
|
+
});
|
|
10194
|
+
const SavePatchResponse = z.object({
|
|
10195
|
+
patchId: PatchId,
|
|
10196
|
+
/**
|
|
10197
|
+
* The chain version this write made, comparable with `headVersion` on the
|
|
10198
|
+
* listing. Optional: a content service that predates it sends nothing.
|
|
10199
|
+
*/
|
|
10200
|
+
headVersion: z.number().optional()
|
|
10201
|
+
});
|
|
10202
|
+
const DeletePatchesResponse = z.object({
|
|
10203
|
+
deleted: z.array(PatchId),
|
|
10204
|
+
errors: z.array(z.object({
|
|
10205
|
+
message: z.string(),
|
|
10206
|
+
patchId: PatchId,
|
|
10207
|
+
/** The publish job holding it, when that is why it was not deleted. */
|
|
10208
|
+
publishJobId: z.string().optional()
|
|
10209
|
+
})).optional(),
|
|
10210
|
+
/**
|
|
10211
|
+
* The chain version each branch the delete changed is now at, keyed by
|
|
10212
|
+
* branch. Optional: a content service that predates it sends none.
|
|
10213
|
+
*/
|
|
10214
|
+
headVersions: z.record(z.string(), z.number()).optional()
|
|
10215
|
+
});
|
|
10216
|
+
/**
|
|
10217
|
+
* This client's own branch's entry in a per-branch version map.
|
|
10218
|
+
*
|
|
10219
|
+
* A delete can span branches, so the content service answers per branch. With
|
|
10220
|
+
* no branch of its own (a build with no repository, where the service resolves
|
|
10221
|
+
* it), the only entry is ours when there is exactly one; with several, none can
|
|
10222
|
+
* be picked, and a wrong one would floor this client at another chain's count.
|
|
10223
|
+
*/
|
|
10224
|
+
function ownBranchVersion(headVersions, branch) {
|
|
10225
|
+
if (headVersions === undefined) return undefined;
|
|
10226
|
+
if (branch !== undefined) return headVersions[branch];
|
|
10227
|
+
const versions = Object.values(headVersions);
|
|
10228
|
+
return versions.length === 1 ? versions[0] : undefined;
|
|
10229
|
+
}
|
|
10230
|
+
const SavePatchFileResponse = z.object({
|
|
10231
|
+
patchId: PatchId,
|
|
10232
|
+
filePath: ModuleFilePath
|
|
10233
|
+
});
|
|
10234
|
+
const CommitResponse = z.object({
|
|
10235
|
+
updatedFiles: z.array(z.string()),
|
|
10236
|
+
commit: CommitSha,
|
|
10237
|
+
/*
|
|
10238
|
+
* Optional because a content service that predates them sends neither, and
|
|
10239
|
+
* this client talks to whichever one the project is on. Absent means NOT
|
|
10240
|
+
* REPORTED: `parent: undefined` is not "a root commit", and a caller that
|
|
10241
|
+
* reads it as one would conclude the history starts here.
|
|
10242
|
+
*
|
|
10243
|
+
* Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
|
|
10244
|
+
* from a service that versions separately and are passed through as what it
|
|
10245
|
+
* said, not as something this end vouched for.
|
|
10246
|
+
*/
|
|
10247
|
+
parent: z.string().optional(),
|
|
10248
|
+
tree: z.string().optional(),
|
|
10249
|
+
branch: z.string(),
|
|
10250
|
+
/** The chain version the publish moved the branch to; absent if it applied nothing. */
|
|
10251
|
+
headVersion: z.number().optional()
|
|
10252
|
+
});
|
|
10253
|
+
// #region history wire schemas
|
|
10254
|
+
//
|
|
10255
|
+
// Validated on arrival rather than trusted: these come from a service that
|
|
10256
|
+
// versions separately, and a silently mis-shaped commit record reads as "this
|
|
10257
|
+
// commit changed nothing", which is indistinguishable from a real answer.
|
|
10258
|
+
const HistoricalCommitResponse = z.object({
|
|
10259
|
+
commitSha: z.string(),
|
|
10260
|
+
/** `null` for a root commit; see the note on the applicable-patches schema. */
|
|
10261
|
+
parentCommitSha: z.string().nullable(),
|
|
10262
|
+
/** `null` when the publisher did not say where it was. */
|
|
10263
|
+
clientCommitSha: z.string().nullable(),
|
|
10264
|
+
branch: z.string(),
|
|
10265
|
+
createdBranch: z.string().nullable(),
|
|
10266
|
+
creator: z.string().nullable(),
|
|
10267
|
+
message: z.string().nullable(),
|
|
10268
|
+
createdAt: z.string(),
|
|
10269
|
+
seqNum: z.string(),
|
|
10270
|
+
patchCount: z.number(),
|
|
10271
|
+
hasArchive: z.boolean()
|
|
10272
|
+
});
|
|
10273
|
+
const ListCommitsResponse = z.object({
|
|
10274
|
+
commits: z.array(HistoricalCommitResponse),
|
|
10275
|
+
nextCursor: z.string().nullable()
|
|
10276
|
+
});
|
|
10277
|
+
const CommitPatchesResponse = z.object({
|
|
10278
|
+
commitSha: z.string(),
|
|
10279
|
+
commit: z.object({
|
|
10280
|
+
commitSha: z.string(),
|
|
10281
|
+
parentCommitSha: z.string().nullable(),
|
|
10282
|
+
clientCommitSha: z.string().nullable(),
|
|
10283
|
+
branch: z.string(),
|
|
10284
|
+
createdBranch: z.string().nullable(),
|
|
10285
|
+
creator: z.string().nullable(),
|
|
10286
|
+
message: z.string().nullable(),
|
|
10287
|
+
createdAt: z.string(),
|
|
10288
|
+
seqNum: z.string(),
|
|
10289
|
+
hasArchive: z.boolean()
|
|
10290
|
+
}),
|
|
10291
|
+
patches: z.array(z.object({
|
|
10292
|
+
patchId: z.string(),
|
|
10293
|
+
path: z.string(),
|
|
10294
|
+
patch: z.unknown(),
|
|
10295
|
+
authorId: z.string().nullable(),
|
|
10296
|
+
createdAt: z.string(),
|
|
10297
|
+
baseSha: z.string(),
|
|
10298
|
+
coreVersion: z.string()
|
|
10299
|
+
}))
|
|
10300
|
+
});
|
|
10301
|
+
|
|
10302
|
+
/**
|
|
10303
|
+
* `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
|
|
10304
|
+
*
|
|
10305
|
+
* `schema` stays `unknown` here on purpose. The content service stores it
|
|
10306
|
+
* opaquely and cannot vouch for it, so validating it at the transport boundary
|
|
9609
10307
|
* would turn "a schema written by a different version of Val" into a failed
|
|
9610
10308
|
* REQUEST rather than one module that cannot be shown. It is checked in
|
|
9611
10309
|
* `getHistoricalPatchSet`, per module, where a failure degrades that module and
|
|
@@ -9648,39 +10346,10 @@ const CommitAffectedFilesResponse = z.object({
|
|
|
9648
10346
|
});
|
|
9649
10347
|
// #endregion history wire schemas
|
|
9650
10348
|
|
|
9651
|
-
/*
|
|
9652
|
-
* The shared schema, not a copy of it.
|
|
9653
|
-
*
|
|
9654
|
-
* This re-declared `PatchGroup` field for field while the file already imported
|
|
9655
|
-
* `PatchGroupT` from the same module — so a field added on one side and not the
|
|
9656
|
-
* other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
|
|
9657
|
-
* it, with no type error anywhere.
|
|
9658
|
-
*/
|
|
9659
|
-
const PatchGroupsResponse = z.object({
|
|
9660
|
-
patchGroups: z.array(PatchGroup)
|
|
9661
|
-
});
|
|
9662
|
-
const PatchGroupMutationResponse = z.object({
|
|
9663
|
-
/** `null` for an unstage of `~` when the caller has no open group. */
|
|
9664
|
-
patchGroupId: z.string().nullable(),
|
|
9665
|
-
patchIds: z.array(PatchId),
|
|
9666
|
-
/** The chain version the change committed at. Absent from an older `home`. */
|
|
9667
|
-
headVersion: z.number().optional()
|
|
9668
|
-
});
|
|
9669
10349
|
const NonceResponse = z.object({
|
|
9670
10350
|
nonce: z.string(),
|
|
9671
10351
|
url: z.string()
|
|
9672
10352
|
});
|
|
9673
|
-
|
|
9674
|
-
/**
|
|
9675
|
-
* How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
|
|
9676
|
-
*
|
|
9677
|
-
* Sized to cover one server render, not to be a cache: several `fetchVal` calls
|
|
9678
|
-
* in one request share an answer, and the next request asks again.
|
|
9679
|
-
*/
|
|
9680
|
-
const PATCH_GROUPS_CACHE_MS = 1000;
|
|
9681
|
-
|
|
9682
|
-
/** The content API's id for "the caller's open group on the branch". */
|
|
9683
|
-
const OWN_OPEN_GROUP = "~";
|
|
9684
10353
|
class ValOpsHttp extends ValOps {
|
|
9685
10354
|
authHeaders;
|
|
9686
10355
|
root;
|
|
@@ -10128,7 +10797,7 @@ class ValOpsHttp extends ValOps {
|
|
|
10128
10797
|
if (path === "") return method === "GET" || method === "POST";
|
|
10129
10798
|
const one = /^\/[0-9a-f]{20}$/.test(path);
|
|
10130
10799
|
if (one) return method === "GET" || method === "PATCH";
|
|
10131
|
-
if (method === "GET") return /^\/[0-9a-f]{20}\/(merge-checks|files)$/.test(path);
|
|
10800
|
+
if (method === "GET") return /^\/[0-9a-f]{20}\/(merge-checks|files|merge-trees)$/.test(path);
|
|
10132
10801
|
return method === "POST" && /^\/[0-9a-f]{20}\/(close|reopen|setup\/retry|merge|files|build|update-from-site|restore-code)$/.test(path);
|
|
10133
10802
|
}
|
|
10134
10803
|
|
|
@@ -10165,8 +10834,16 @@ class ValOpsHttp extends ValOps {
|
|
|
10165
10834
|
return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
|
|
10166
10835
|
}
|
|
10167
10836
|
async proposalsApi(path, init, profileId) {
|
|
10168
|
-
var _this$proposal;
|
|
10837
|
+
var _exec2, _exec3, _this$proposal;
|
|
10169
10838
|
const json = "application/json";
|
|
10839
|
+
const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
|
|
10840
|
+
if (updating !== undefined && init.method === "POST") {
|
|
10841
|
+
return this.updateContentFromSite(updating, init.body, profileId);
|
|
10842
|
+
}
|
|
10843
|
+
const withCode = (_exec3 = /^\/([0-9a-f]{20})\/update-code$/.exec(path)) === null || _exec3 === void 0 ? void 0 : _exec3[1];
|
|
10844
|
+
if (withCode !== undefined && init.method === "POST") {
|
|
10845
|
+
return this.updateCodeFromSite(withCode, init.body, profileId);
|
|
10846
|
+
}
|
|
10170
10847
|
if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
|
|
10171
10848
|
return {
|
|
10172
10849
|
status: 403,
|
|
@@ -10176,41 +10853,305 @@ class ValOpsHttp extends ValOps {
|
|
|
10176
10853
|
})
|
|
10177
10854
|
};
|
|
10178
10855
|
}
|
|
10179
|
-
const writes = ValOpsHttp.proposalFilesWrite(init.method, path);
|
|
10180
|
-
if (writes !== null && writes !== ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.name)) {
|
|
10856
|
+
const writes = ValOpsHttp.proposalFilesWrite(init.method, path);
|
|
10857
|
+
if (writes !== null && writes !== ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.name)) {
|
|
10858
|
+
return {
|
|
10859
|
+
status: 403,
|
|
10860
|
+
contentType: json,
|
|
10861
|
+
body: JSON.stringify({
|
|
10862
|
+
message: this.proposal === null ? "The site's files are changed in a proposal: open one, and change them there." : "Files are changed in the proposal this Studio is open in, not in another."
|
|
10863
|
+
})
|
|
10864
|
+
};
|
|
10865
|
+
}
|
|
10866
|
+
const res = await fetch(`${this.contentUrl}/v1/${this.project}/proposals${path}`, {
|
|
10867
|
+
method: init.method,
|
|
10868
|
+
headers: {
|
|
10869
|
+
...this.authHeaders,
|
|
10870
|
+
/*
|
|
10871
|
+
* WHO is asking, as for a commit: the app's key names the project,
|
|
10872
|
+
* and a proposal is opened, renamed and closed by a person -- content
|
|
10873
|
+
* refuses those writes from a credential that names nobody.
|
|
10874
|
+
*/
|
|
10875
|
+
...(profileId !== null ? {
|
|
10876
|
+
"x-val-profile-id": profileId
|
|
10877
|
+
} : {}),
|
|
10878
|
+
...(init.body === undefined ? {} : {
|
|
10879
|
+
"Content-Type": json
|
|
10880
|
+
})
|
|
10881
|
+
},
|
|
10882
|
+
...(init.body === undefined ? {} : {
|
|
10883
|
+
body: init.body
|
|
10884
|
+
})
|
|
10885
|
+
});
|
|
10886
|
+
return {
|
|
10887
|
+
status: res.status,
|
|
10888
|
+
body: await res.text(),
|
|
10889
|
+
contentType: res.headers.get("Content-Type") ?? json
|
|
10890
|
+
};
|
|
10891
|
+
}
|
|
10892
|
+
|
|
10893
|
+
/**
|
|
10894
|
+
* Update from the site, for a proposal that changes only content
|
|
10895
|
+
* (valbuild/home `docs/hosted-publishing.md`, step 8.2, slice 1a): content
|
|
10896
|
+
* hands over the three versions of every module the proposal saved, this
|
|
10897
|
+
* server merges them under its own build's schemas -- the same on all
|
|
10898
|
+
* three sides, which is what "content only" means -- and content commits
|
|
10899
|
+
* the merge on the proposal's branch.
|
|
10900
|
+
*
|
|
10901
|
+
* Answered here rather than forwarded, at `POST /<name>/update-content`, and
|
|
10902
|
+
* only by the proposal's own server: the schemas it merges under are this
|
|
10903
|
+
* build's. A conflict comes back for a person to resolve, and the same call
|
|
10904
|
+
* with their `resolutions` merges.
|
|
10905
|
+
*/
|
|
10906
|
+
async updateContentFromSite(name, body, profileId) {
|
|
10907
|
+
var _this$proposal2;
|
|
10908
|
+
const json = "application/json";
|
|
10909
|
+
const answer = (status, value) => ({
|
|
10910
|
+
status,
|
|
10911
|
+
contentType: json,
|
|
10912
|
+
body: JSON.stringify(value)
|
|
10913
|
+
});
|
|
10914
|
+
if (((_this$proposal2 = this.proposal) === null || _this$proposal2 === void 0 ? void 0 : _this$proposal2.name) !== name) {
|
|
10915
|
+
return answer(403, {
|
|
10916
|
+
message: "A proposal is updated from the site at its own address, where its build is."
|
|
10917
|
+
});
|
|
10918
|
+
}
|
|
10919
|
+
let parsedBody;
|
|
10920
|
+
try {
|
|
10921
|
+
parsedBody = body === undefined || body === "" ? {} : JSON.parse(body);
|
|
10922
|
+
} catch {
|
|
10923
|
+
return answer(400, {
|
|
10924
|
+
message: "The update's body is not JSON."
|
|
10925
|
+
});
|
|
10926
|
+
}
|
|
10927
|
+
const request = UpdateContentBody.safeParse(parsedBody);
|
|
10928
|
+
if (!request.success) {
|
|
10929
|
+
return answer(400, {
|
|
10930
|
+
message: `Invalid update: ${fromError(request.error).toString()}`
|
|
10931
|
+
});
|
|
10932
|
+
}
|
|
10933
|
+
const content = (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
|
|
10934
|
+
...init,
|
|
10935
|
+
headers: {
|
|
10936
|
+
...this.authHeaders,
|
|
10937
|
+
...(profileId !== null ? {
|
|
10938
|
+
"x-val-profile-id": profileId
|
|
10939
|
+
} : {}),
|
|
10940
|
+
...(init.body === undefined ? {} : {
|
|
10941
|
+
"Content-Type": json
|
|
10942
|
+
})
|
|
10943
|
+
}
|
|
10944
|
+
});
|
|
10945
|
+
const read = await content("/merge-inputs");
|
|
10946
|
+
if (!read.ok) {
|
|
10947
|
+
return {
|
|
10948
|
+
status: read.status,
|
|
10949
|
+
contentType: json,
|
|
10950
|
+
body: await read.text()
|
|
10951
|
+
};
|
|
10952
|
+
}
|
|
10953
|
+
const inputs = MergeInputs.safeParse(await read.json());
|
|
10954
|
+
if (!inputs.success) {
|
|
10955
|
+
return answer(502, {
|
|
10956
|
+
message: `Content answered the merge's inputs in a shape this Val does not read: ${fromError(inputs.error).toString()}`
|
|
10957
|
+
});
|
|
10958
|
+
}
|
|
10959
|
+
if (inputs.data.upToDate) return answer(200, {
|
|
10960
|
+
status: "up-to-date"
|
|
10961
|
+
});
|
|
10962
|
+
if (!inputs.data.contentOnly.ok || inputs.data.siteHead === null) {
|
|
10963
|
+
return answer(409, {
|
|
10964
|
+
status: "needs-build",
|
|
10965
|
+
message: inputs.data.contentOnly.ok ? "The site has no commit to update from." : inputs.data.contentOnly.reason
|
|
10966
|
+
});
|
|
10967
|
+
}
|
|
10968
|
+
const modules = {};
|
|
10969
|
+
const withoutText = [];
|
|
10970
|
+
for (const [path, module] of Object.entries(inputs.data.modules)) {
|
|
10971
|
+
const moduleFilePath = path;
|
|
10972
|
+
if (module.text === null) {
|
|
10973
|
+
withoutText.push(moduleFilePath);
|
|
10974
|
+
continue;
|
|
10975
|
+
}
|
|
10976
|
+
modules[moduleFilePath] = {
|
|
10977
|
+
base: module.base,
|
|
10978
|
+
site: module.site,
|
|
10979
|
+
proposal: module.proposal,
|
|
10980
|
+
text: module.text
|
|
10981
|
+
};
|
|
10982
|
+
}
|
|
10983
|
+
// Decisions are about the values a person was shown. When the site or
|
|
10984
|
+
// the proposal moved since, they would settle values nobody saw, so the
|
|
10985
|
+
// merge is done again without them: what still conflicts is shown again.
|
|
10986
|
+
const {
|
|
10987
|
+
siteHead,
|
|
10988
|
+
proposalHead
|
|
10989
|
+
} = request.data;
|
|
10990
|
+
const decidedHere = siteHead === undefined || siteHead === inputs.data.siteHead && proposalHead === inputs.data.proposalHead;
|
|
10991
|
+
const merged = withoutText.length > 0 ? {
|
|
10992
|
+
status: "needs-full-merge",
|
|
10993
|
+
modules: withoutText
|
|
10994
|
+
} : mergeContent({
|
|
10995
|
+
modules,
|
|
10996
|
+
schemas: await this.getSchemas(),
|
|
10997
|
+
resolutions: decidedHere ? request.data.resolutions : []
|
|
10998
|
+
});
|
|
10999
|
+
if (merged.status === "conflicts") {
|
|
11000
|
+
return answer(200, {
|
|
11001
|
+
status: "conflicts",
|
|
11002
|
+
conflicts: merged.conflicts,
|
|
11003
|
+
siteHead: inputs.data.siteHead,
|
|
11004
|
+
proposalHead: inputs.data.proposalHead
|
|
11005
|
+
});
|
|
11006
|
+
}
|
|
11007
|
+
if (merged.status === "needs-full-merge") {
|
|
11008
|
+
return answer(409, {
|
|
11009
|
+
status: "needs-full-merge",
|
|
11010
|
+
modules: merged.modules,
|
|
11011
|
+
message: `${merged.modules.join(", ")} changed both here and on the site in a way this version cannot merge yet.`
|
|
11012
|
+
});
|
|
11013
|
+
}
|
|
11014
|
+
const written = await content("/merge-from-site", {
|
|
11015
|
+
method: "POST",
|
|
11016
|
+
body: JSON.stringify({
|
|
11017
|
+
siteHead: inputs.data.siteHead,
|
|
11018
|
+
proposalHead: inputs.data.proposalHead,
|
|
11019
|
+
files: merged.files,
|
|
11020
|
+
modules: merged.modules
|
|
11021
|
+
})
|
|
11022
|
+
});
|
|
11023
|
+
const text = await written.text();
|
|
11024
|
+
if (!written.ok) {
|
|
10181
11025
|
return {
|
|
10182
|
-
status:
|
|
11026
|
+
status: written.status,
|
|
10183
11027
|
contentType: json,
|
|
10184
|
-
body:
|
|
10185
|
-
message: this.proposal === null ? "The site's files are changed in a proposal: open one, and change them there." : "Files are changed in the proposal this Studio is open in, not in another."
|
|
10186
|
-
})
|
|
11028
|
+
body: text
|
|
10187
11029
|
};
|
|
10188
11030
|
}
|
|
10189
|
-
|
|
10190
|
-
|
|
11031
|
+
return updatedAnswer(text);
|
|
11032
|
+
}
|
|
11033
|
+
|
|
11034
|
+
/**
|
|
11035
|
+
* Update from the site, for a proposal with code (valbuild/home
|
|
11036
|
+
* `docs/hosted-publishing.md`, step 8.2, slice 1b), at
|
|
11037
|
+
* `POST /<name>/update-code`. The builder tab has read content's trees
|
|
11038
|
+
* (`GET /<name>/merge-trees`, forwarded) and evaluated them; this reads the
|
|
11039
|
+
* trees again -- the tab's evaluation is of the trees content has, or it is
|
|
11040
|
+
* of nothing -- and does the text (`updateWithCode`). The first call
|
|
11041
|
+
* answers the merge for the tab to evaluate; the second, with that
|
|
11042
|
+
* evaluation, commits it on the proposal's branch, staging its code.
|
|
11043
|
+
*
|
|
11044
|
+
* Only at the proposal's own address, as every write to its files is.
|
|
11045
|
+
*/
|
|
11046
|
+
async updateCodeFromSite(name, body, profileId) {
|
|
11047
|
+
var _this$proposal3;
|
|
11048
|
+
const json = "application/json";
|
|
11049
|
+
const answer = (status, value) => ({
|
|
11050
|
+
status,
|
|
11051
|
+
contentType: json,
|
|
11052
|
+
body: JSON.stringify(value)
|
|
11053
|
+
});
|
|
11054
|
+
if (((_this$proposal3 = this.proposal) === null || _this$proposal3 === void 0 ? void 0 : _this$proposal3.name) !== name) {
|
|
11055
|
+
return answer(403, {
|
|
11056
|
+
message: "A proposal is updated from the site at its own address, where its files are written."
|
|
11057
|
+
});
|
|
11058
|
+
}
|
|
11059
|
+
let parsedBody;
|
|
11060
|
+
try {
|
|
11061
|
+
parsedBody = body === undefined || body === "" ? {} : JSON.parse(body);
|
|
11062
|
+
} catch {
|
|
11063
|
+
return answer(400, {
|
|
11064
|
+
message: "The update's body is not JSON."
|
|
11065
|
+
});
|
|
11066
|
+
}
|
|
11067
|
+
const request = UpdateCodeBody.safeParse(parsedBody);
|
|
11068
|
+
if (!request.success) {
|
|
11069
|
+
return answer(400, {
|
|
11070
|
+
message: `Invalid update: ${fromError(request.error).toString()}`
|
|
11071
|
+
});
|
|
11072
|
+
}
|
|
11073
|
+
const content = (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
|
|
11074
|
+
...init,
|
|
10191
11075
|
headers: {
|
|
10192
11076
|
...this.authHeaders,
|
|
10193
|
-
/*
|
|
10194
|
-
* WHO is asking, as for a commit: the app's key names the project,
|
|
10195
|
-
* and a proposal is opened, renamed and closed by a person -- content
|
|
10196
|
-
* refuses those writes from a credential that names nobody.
|
|
10197
|
-
*/
|
|
10198
11077
|
...(profileId !== null ? {
|
|
10199
11078
|
"x-val-profile-id": profileId
|
|
10200
11079
|
} : {}),
|
|
10201
11080
|
...(init.body === undefined ? {} : {
|
|
10202
11081
|
"Content-Type": json
|
|
10203
11082
|
})
|
|
10204
|
-
}
|
|
10205
|
-
|
|
10206
|
-
|
|
11083
|
+
}
|
|
11084
|
+
});
|
|
11085
|
+
const read = await content("/merge-trees");
|
|
11086
|
+
if (!read.ok) {
|
|
11087
|
+
return {
|
|
11088
|
+
status: read.status,
|
|
11089
|
+
contentType: json,
|
|
11090
|
+
body: await read.text()
|
|
11091
|
+
};
|
|
11092
|
+
}
|
|
11093
|
+
const trees = MergeTrees.safeParse(await read.json());
|
|
11094
|
+
if (!trees.success) {
|
|
11095
|
+
return answer(502, {
|
|
11096
|
+
message: `Content answered the merge's trees in a shape this Val does not read: ${fromError(trees.error).toString()}`
|
|
11097
|
+
});
|
|
11098
|
+
}
|
|
11099
|
+
const {
|
|
11100
|
+
siteHead,
|
|
11101
|
+
proposalHead,
|
|
11102
|
+
builds
|
|
11103
|
+
} = request.data;
|
|
11104
|
+
if (trees.data.siteHead !== siteHead || trees.data.proposalHead !== proposalHead || trees.data.builds.base !== builds.base || trees.data.builds.site !== builds.site) {
|
|
11105
|
+
return answer(409, {
|
|
11106
|
+
status: "moved",
|
|
11107
|
+
message: "The site or this proposal changed while this update was being made. Update from the site again."
|
|
11108
|
+
});
|
|
11109
|
+
}
|
|
11110
|
+
if (trees.data.upToDate) return answer(200, {
|
|
11111
|
+
status: "up-to-date"
|
|
11112
|
+
});
|
|
11113
|
+
if (siteHead === null) {
|
|
11114
|
+
return answer(409, {
|
|
11115
|
+
message: "The site has no commit to update from."
|
|
11116
|
+
});
|
|
11117
|
+
}
|
|
11118
|
+
const merged = updateWithCode({
|
|
11119
|
+
trees: trees.data,
|
|
11120
|
+
evaluated: request.data.evaluated,
|
|
11121
|
+
resolutions: request.data.resolutions,
|
|
11122
|
+
...(request.data.merged ? {
|
|
11123
|
+
merged: request.data.merged
|
|
11124
|
+
} : {})
|
|
11125
|
+
});
|
|
11126
|
+
if (merged.status === "stale") {
|
|
11127
|
+
return answer(409, {
|
|
11128
|
+
status: "stale",
|
|
11129
|
+
message: "What was checked is not this update any more. Update from the site again."
|
|
11130
|
+
});
|
|
11131
|
+
}
|
|
11132
|
+
if (merged.status !== "merged") return answer(200, merged);
|
|
11133
|
+
const written = await content("/merge-from-site", {
|
|
11134
|
+
method: "POST",
|
|
11135
|
+
body: JSON.stringify({
|
|
11136
|
+
siteHead,
|
|
11137
|
+
proposalHead,
|
|
11138
|
+
files: merged.files,
|
|
11139
|
+
modules: merged.modules,
|
|
11140
|
+
code: {
|
|
11141
|
+
build: builds.site,
|
|
11142
|
+
staged: merged.staged
|
|
11143
|
+
}
|
|
10207
11144
|
})
|
|
10208
11145
|
});
|
|
10209
|
-
|
|
10210
|
-
|
|
10211
|
-
|
|
10212
|
-
|
|
10213
|
-
|
|
11146
|
+
const text = await written.text();
|
|
11147
|
+
if (!written.ok) {
|
|
11148
|
+
return {
|
|
11149
|
+
status: written.status,
|
|
11150
|
+
contentType: json,
|
|
11151
|
+
body: text
|
|
11152
|
+
};
|
|
11153
|
+
}
|
|
11154
|
+
return updatedAnswer(text);
|
|
10214
11155
|
}
|
|
10215
11156
|
|
|
10216
11157
|
/**
|
|
@@ -10488,15 +11429,6 @@ class ValOpsHttp extends ValOps {
|
|
|
10488
11429
|
...(allPatchData.headVersion !== undefined ? {
|
|
10489
11430
|
headVersion: allPatchData.headVersion
|
|
10490
11431
|
} : {}),
|
|
10491
|
-
/*
|
|
10492
|
-
* Who holds what, read with the list and its version. On every stat, so
|
|
10493
|
-
* a Studio's view of the groups is never older than its view of the
|
|
10494
|
-
* chain — which is what lets a stage made in another browser show here
|
|
10495
|
-
* without a reload. See `OrderedPatches.patchGroups`.
|
|
10496
|
-
*/
|
|
10497
|
-
...(allPatchData.patchGroups !== undefined ? {
|
|
10498
|
-
patchGroups: allPatchData.patchGroups
|
|
10499
|
-
} : {}),
|
|
10500
11432
|
/*
|
|
10501
11433
|
* The PUBLISH head, which is not `commitSha`.
|
|
10502
11434
|
*
|
|
@@ -10565,8 +11497,8 @@ class ValOpsHttp extends ValOps {
|
|
|
10565
11497
|
|
|
10566
11498
|
/** The branch this server's patches are on, where it knows it. */
|
|
10567
11499
|
ownBranch() {
|
|
10568
|
-
var _this$
|
|
10569
|
-
return ((_this$
|
|
11500
|
+
var _this$proposal4, _this$git;
|
|
11501
|
+
return ((_this$proposal4 = this.proposal) === null || _this$proposal4 === void 0 ? void 0 : _this$proposal4.branch) ?? ((_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
|
|
10570
11502
|
}
|
|
10571
11503
|
async getWebSocketNonce(profileId) {
|
|
10572
11504
|
return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
|
|
@@ -10863,437 +11795,62 @@ class ValOpsHttp extends ValOps {
|
|
|
10863
11795
|
updatedAt: deployment.updatedAt,
|
|
10864
11796
|
commitMessage: deployment.commitMessage
|
|
10865
11797
|
});
|
|
10866
|
-
}
|
|
10867
|
-
}
|
|
10868
|
-
/*
|
|
10869
|
-
* Membership, folded from the per-patch annotation onto the groups.
|
|
10870
|
-
*
|
|
10871
|
-
* From THIS response and not from `GET /patch-groups`, because only
|
|
10872
|
-
* this one is consistent with the list and the version beside it:
|
|
10873
|
-
* the content service reads all three in one transaction. A group
|
|
10874
|
-
* list fetched separately can be older or newer than the chain it is
|
|
10875
|
-
* shown with — and the Studio decides what an editor sees, and what
|
|
10876
|
-
* a publish ships, from the pair.
|
|
10877
|
-
*
|
|
10878
|
-
* A group holding none of the listed patches is still listed, with
|
|
10879
|
-
* no members: "groups exist and you hold nothing here" is a real
|
|
10880
|
-
* answer, and different from "this content service has no groups".
|
|
10881
|
-
*/
|
|
10882
|
-
let patchGroups;
|
|
10883
|
-
if (data.patchGroups !== undefined) {
|
|
10884
|
-
const members = new Map();
|
|
10885
|
-
for (const patch of data.patches) {
|
|
10886
|
-
for (const groupId of patch.patchGroupIds ?? []) {
|
|
10887
|
-
const list = members.get(groupId) ?? [];
|
|
10888
|
-
list.push(patch.patchId);
|
|
10889
|
-
members.set(groupId, list);
|
|
10890
|
-
}
|
|
10891
|
-
}
|
|
10892
|
-
patchGroups = data.patchGroups.map(group => ({
|
|
10893
|
-
...group,
|
|
10894
|
-
patchIds: members.get(group.patchGroupId) ?? []
|
|
10895
|
-
}));
|
|
10896
|
-
}
|
|
10897
|
-
return {
|
|
10898
|
-
commits,
|
|
10899
|
-
deployments,
|
|
10900
|
-
patches,
|
|
10901
|
-
errors,
|
|
10902
|
-
...(patchGroups !== undefined ? {
|
|
10903
|
-
patchGroups
|
|
10904
|
-
} : {}),
|
|
10905
|
-
...(data.headPatchId !== undefined ? {
|
|
10906
|
-
headPatchId: data.headPatchId
|
|
10907
|
-
} : {}),
|
|
10908
|
-
...(data.headVersion !== undefined ? {
|
|
10909
|
-
headVersion: data.headVersion
|
|
10910
|
-
} : {}),
|
|
10911
|
-
...(data.publishingPatches !== undefined ? {
|
|
10912
|
-
publishingPatches: data.publishingPatches
|
|
10913
|
-
} : {}),
|
|
10914
|
-
...(data.heldBackPatches !== undefined ? {
|
|
10915
|
-
heldBackPatches: data.heldBackPatches
|
|
10916
|
-
} : {})
|
|
10917
|
-
};
|
|
10918
|
-
}
|
|
10919
|
-
console.error("Could not parse patches response. Error: " + fromError(parsed.error));
|
|
10920
|
-
return {
|
|
10921
|
-
patches,
|
|
10922
|
-
error: {
|
|
10923
|
-
message: `The response that Val got from the server was not in the expected format. This might be a transient error or a configuration issue. Please try again later.`
|
|
10924
|
-
}
|
|
10925
|
-
};
|
|
10926
|
-
}
|
|
10927
|
-
console.error("Could not get patches. HTTP error: " + patchesRes.status + " " + patchesRes.statusText);
|
|
10928
|
-
if (patchesRes.status === 401) {
|
|
10929
|
-
return {
|
|
10930
|
-
patches,
|
|
10931
|
-
error: {
|
|
10932
|
-
message: "Although your user is authorized, the application has authorization issues. Contact the developers on your team and ask them to verify the api keys."
|
|
10933
|
-
}
|
|
10934
|
-
};
|
|
10935
|
-
}
|
|
10936
|
-
return {
|
|
10937
|
-
patches,
|
|
10938
|
-
error: {
|
|
10939
|
-
message: "Could not get your changes. It is most likely due to a network issue. Check your network connection and please try again."
|
|
10940
|
-
}
|
|
10941
|
-
};
|
|
10942
|
-
} catch (err) {
|
|
10943
|
-
console.error("Could not get patches (connection error):", err instanceof Error ? err.message : JSON.stringify(err));
|
|
10944
|
-
return {
|
|
10945
|
-
patches: [],
|
|
10946
|
-
networkError: true,
|
|
10947
|
-
error: {
|
|
10948
|
-
message: `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}`
|
|
10949
|
-
}
|
|
10950
|
-
};
|
|
10951
|
-
}
|
|
10952
|
-
}
|
|
10953
|
-
|
|
10954
|
-
// #region patch groups
|
|
10955
|
-
/**
|
|
10956
|
-
* Add patches to a patch group.
|
|
10957
|
-
*
|
|
10958
|
-
* The set arrives closed by the client — `withPatchIds` is the prefix closure
|
|
10959
|
-
* over the patch sets the staged patches belong to. We forward it and do not
|
|
10960
|
-
* second-guess it: deriving the closure needs the content schema, which this
|
|
10961
|
-
* process does have but content.val.build does not, and having two
|
|
10962
|
-
* implementations of the rule would be worse than having one.
|
|
10963
|
-
*
|
|
10964
|
-
* Membership rows are stamped with `coreVersion` on the content side — the
|
|
10965
|
-
* same stamp the patch row carries — so which client wrote a row stays
|
|
10966
|
-
* legible after the fact.
|
|
10967
|
-
*/
|
|
10968
|
-
async stagePatches(
|
|
10969
|
-
/**
|
|
10970
|
-
* The group to stage into, or `undefined` for the caller's open group on
|
|
10971
|
-
* this branch — `~` on the content API, which creates it if there is none.
|
|
10972
|
-
*/
|
|
10973
|
-
patchGroupId, /** What the user asked to stage. */
|
|
10974
|
-
patchIds,
|
|
10975
|
-
/**
|
|
10976
|
-
* What has to come with it, because the staged patches are written on top
|
|
10977
|
-
* of it.
|
|
10978
|
-
*
|
|
10979
|
-
* The content API stores each membership row as `explicit` or `dependency`
|
|
10980
|
-
* and treats what it is not told about as a dependency. Folding the two
|
|
10981
|
-
* halves into `patchIds` therefore files the patch somebody clicked as one
|
|
10982
|
-
* the closure dragged in — the exact opposite of what happened, and the
|
|
10983
|
-
* only record anywhere of what the author chose.
|
|
10984
|
-
*/
|
|
10985
|
-
withPatchIds,
|
|
10986
|
-
/**
|
|
10987
|
-
* WHO is asking, so the content API can refuse a group that is not theirs.
|
|
10988
|
-
*
|
|
10989
|
-
* Every call from this class carries the app's API key, which says which
|
|
10990
|
-
* PROJECT is calling and nothing about which editor. Without this the
|
|
10991
|
-
* content API cannot tell one of a project's editors from another, so the
|
|
10992
|
-
* only check on stage and unstage is the one in `ValServer` — and anything
|
|
10993
|
-
* reaching the content API by another route (an API key, a PAT) has none at
|
|
10994
|
-
* all.
|
|
10995
|
-
*
|
|
10996
|
-
* `null` where there is no session. The content API refuses rather than
|
|
10997
|
-
* treating that as a match: a group written by an api key has a null author
|
|
10998
|
-
* too, and `null === null` must not read as ownership.
|
|
10999
|
-
*/
|
|
11000
|
-
authorId) {
|
|
11001
|
-
return this.mutatePatchGroup("POST",
|
|
11002
|
-
// Encoded: patchGroupId arrives in a request body, so an unencoded value
|
|
11003
|
-
// like "../../commit" would reach a different endpoint carrying this
|
|
11004
|
-
// project's auth headers.
|
|
11005
|
-
`patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
|
|
11006
|
-
patchIds,
|
|
11007
|
-
withPatchIds,
|
|
11008
|
-
coreVersion: Internal.VERSION.core,
|
|
11009
|
-
...this.ownGroupBranch()
|
|
11010
|
-
}, authorId);
|
|
11011
|
-
}
|
|
11012
|
-
|
|
11013
|
-
/**
|
|
11014
|
-
* Remove patches from a patch group.
|
|
11015
|
-
*
|
|
11016
|
-
* The set arrives closed FORWARDS by the client: unstaging a patch also
|
|
11017
|
-
* unstages everything built on top of it within its patch sets, and that is
|
|
11018
|
-
* what `withPatchIds` carries.
|
|
11019
|
-
*/
|
|
11020
|
-
async unstagePatches(/** See {@link stagePatches}. With no open group, there is nothing to remove. */
|
|
11021
|
-
patchGroupId, /** What the user asked to unstage. */
|
|
11022
|
-
patchIds, /** What has to go with it: everything built on top of it. */
|
|
11023
|
-
withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
|
|
11024
|
-
authorId) {
|
|
11025
|
-
return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
|
|
11026
|
-
patchIds,
|
|
11027
|
-
withPatchIds,
|
|
11028
|
-
...this.ownGroupBranch()
|
|
11029
|
-
}, authorId);
|
|
11030
|
-
}
|
|
11031
|
-
|
|
11032
|
-
/**
|
|
11033
|
-
* Every patch group on this branch, with what each holds.
|
|
11034
|
-
*
|
|
11035
|
-
* Read rather than mutated, and used to answer "which pending patches is THIS
|
|
11036
|
-
* person allowed to see". A draft render that skips this shows base + every
|
|
11037
|
-
* pending patch on the branch, including work other people have not
|
|
11038
|
-
* published — which is what independent publish exists to prevent.
|
|
11039
|
-
*
|
|
11040
|
-
* A failure is an empty list rather than a throw, and the caller decides what
|
|
11041
|
-
* that means. For a draft render the honest fallback is "show nothing
|
|
11042
|
-
* pending" rather than "show everything": being shown your own committed
|
|
11043
|
-
* content when the group lookup is down is a worse experience than being
|
|
11044
|
-
* shown somebody else's unpublished draft is a bug.
|
|
11045
|
-
*/
|
|
11046
|
-
/**
|
|
11047
|
-
* The last group lookup, and when it was made.
|
|
11048
|
-
*
|
|
11049
|
-
* A draft render calls `getPatchGroups` once per `fetchVal`, in series with
|
|
11050
|
-
* the whole-chain fetch, and a page that calls `fetchVal` several times pays
|
|
11051
|
-
* the round trip several times. Groups are per branch and change rarely, so a
|
|
11052
|
-
* short window removes the multiplier without letting a stage go unseen for
|
|
11053
|
-
* meaningfully longer than one render.
|
|
11054
|
-
*
|
|
11055
|
-
* Deliberately short. This is a read whose staleness decides whose draft
|
|
11056
|
-
* content someone sees, so it is a per-request de-duplication rather than a
|
|
11057
|
-
* cache: a second render a second later asks again.
|
|
11058
|
-
*/
|
|
11059
|
-
patchGroupsCache = null;
|
|
11060
|
-
|
|
11061
|
-
/**
|
|
11062
|
-
* Forget the remembered group list, after a write through this server.
|
|
11063
|
-
*
|
|
11064
|
-
* A write changes membership -- a save joins its author's group, a delete or
|
|
11065
|
-
* an unstage leaves it -- and the window above is long enough for the next
|
|
11066
|
-
* render to be served the list from before it: an editor who saved and
|
|
11067
|
-
* reloaded inside a second was shown their draft without the change they had
|
|
11068
|
-
* just made. Called once the write has answered, so a read that started
|
|
11069
|
-
* while it was in flight cannot leave the old list behind it.
|
|
11070
|
-
*
|
|
11071
|
-
* Only this instance's writes. Another instance's write is still seen at
|
|
11072
|
-
* most one window late, which is what the window is for.
|
|
11073
|
-
*/
|
|
11074
|
-
forgetPatchGroups() {
|
|
11075
|
-
this.patchGroupsCache = null;
|
|
11076
|
-
}
|
|
11077
|
-
async getPatchGroups(options) {
|
|
11078
|
-
const now = Date.now();
|
|
11079
|
-
if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
|
|
11080
|
-
return this.patchGroupsCache.res;
|
|
11081
|
-
}
|
|
11082
|
-
const res = await this.fetchPatchGroups();
|
|
11083
|
-
/*
|
|
11084
|
-
* ANSWERS are cached; failures are not.
|
|
11085
|
-
*
|
|
11086
|
-
* A transient failure held for a second is replayed to every caller in it,
|
|
11087
|
-
* and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
|
|
11088
|
-
* that refuses the stage, a scoped draft render falls back to base and
|
|
11089
|
-
* drops every pending patch on the page, and `GET /patches` omits the
|
|
11090
|
-
* annotation. One flaky request became a second of all three. The point of
|
|
11091
|
-
* this cache is to collapse the several `fetchVal` calls in one render into
|
|
11092
|
-
* one round trip, and an error is exactly the case worth retrying inside
|
|
11093
|
-
* that window rather than the case worth remembering.
|
|
11094
|
-
*
|
|
11095
|
-
* `unsupported` is cached with `ok` deliberately: it is a real answer about
|
|
11096
|
-
* the deployment — this content API predates patch groups — and it will not
|
|
11097
|
-
* change between two renders.
|
|
11098
|
-
*/
|
|
11099
|
-
if (res.status === "ok" || res.status === "unsupported") {
|
|
11100
|
-
this.patchGroupsCache = {
|
|
11101
|
-
at: now,
|
|
11102
|
-
res
|
|
11103
|
-
};
|
|
11104
|
-
}
|
|
11105
|
-
return res;
|
|
11106
|
-
}
|
|
11107
|
-
async fetchPatchGroups() {
|
|
11108
|
-
/*
|
|
11109
|
-
* A proposal has no patch groups: its changes are one unit, saved
|
|
11110
|
-
* together, and the content service answers 404 for groups on its branch.
|
|
11111
|
-
* "Unsupported" is exactly that answer, and it is what makes a draft in a
|
|
11112
|
-
* proposal show all of the proposal's changes rather than one person's.
|
|
11113
|
-
*/
|
|
11114
|
-
if (this.proposal !== null) {
|
|
11115
|
-
return {
|
|
11116
|
-
status: "unsupported"
|
|
11117
|
-
};
|
|
11118
|
-
}
|
|
11119
|
-
try {
|
|
11120
|
-
/*
|
|
11121
|
-
* `branch` is REQUIRED by the endpoint, which answers 400 without it.
|
|
11122
|
-
*
|
|
11123
|
-
* Same two params every other read here sends (`fetchPatchesInternal`,
|
|
11124
|
-
* `saveSourceFilePatch`): groups are per branch, so a request without one
|
|
11125
|
-
* is not merely under-specified, it is rejected.
|
|
11126
|
-
*/
|
|
11127
|
-
const branch = this.ownBranch();
|
|
11128
|
-
const params = new URLSearchParams(branch !== undefined ? [["branch", branch]] : []);
|
|
11129
|
-
const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
|
|
11130
|
-
headers: this.authHeaders
|
|
11131
|
-
});
|
|
11132
|
-
if (res.status === 404) {
|
|
11133
|
-
/*
|
|
11134
|
-
* The endpoint is not there, which is a content API that PREDATES patch
|
|
11135
|
-
* groups — not a failure.
|
|
11136
|
-
*
|
|
11137
|
-
* The distinction decides what a draft render shows, and collapsing it
|
|
11138
|
-
* into "error" is not a small mistake: a caller that reads an error as
|
|
11139
|
-
* "could not ask" renders BASE, so every existing http deployment would
|
|
11140
|
-
* silently drop all pending content from every draft preview. "There
|
|
11141
|
-
* are no groups here" has to mean unscoped, which is exactly the
|
|
11142
|
-
* behaviour those projects have today.
|
|
11143
|
-
*/
|
|
11144
|
-
return {
|
|
11145
|
-
status: "unsupported"
|
|
11146
|
-
};
|
|
11147
|
-
}
|
|
11148
|
-
if (!res.ok) {
|
|
11149
|
-
return {
|
|
11150
|
-
status: "error",
|
|
11151
|
-
message: res.status === 401 ? "Could not read patch groups: unauthorized. Verify that the val api keys are correct." : `Could not read patch groups. HTTP error: ${res.status} ${res.statusText}`
|
|
11152
|
-
};
|
|
11153
|
-
}
|
|
11154
|
-
const parsed = PatchGroupsResponse.safeParse(await res.json());
|
|
11155
|
-
if (!parsed.success) {
|
|
11156
|
-
return {
|
|
11157
|
-
status: "error",
|
|
11158
|
-
message: `Could not parse patch groups response. Error: ${fromError(parsed.error)}`
|
|
11159
|
-
};
|
|
11160
|
-
}
|
|
11161
|
-
return {
|
|
11162
|
-
status: "ok",
|
|
11163
|
-
patchGroups: parsed.data.patchGroups
|
|
11164
|
-
};
|
|
11165
|
-
} catch (err) {
|
|
11166
|
-
return {
|
|
11167
|
-
status: "error",
|
|
11168
|
-
message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
|
|
11169
|
-
};
|
|
11170
|
-
}
|
|
11171
|
-
}
|
|
11172
|
-
|
|
11173
|
-
/**
|
|
11174
|
-
* Which branch `~` means, where this build knows: the branch it reads
|
|
11175
|
-
* `applicable/patches` on, so the group staged into is the group listed.
|
|
11176
|
-
* Without one the content API uses the project's own, as it does for reads.
|
|
11177
|
-
*/
|
|
11178
|
-
ownGroupBranch() {
|
|
11179
|
-
const branch = this.ownBranch();
|
|
11180
|
-
return branch !== undefined ? {
|
|
11181
|
-
branch
|
|
11182
|
-
} : {};
|
|
11183
|
-
}
|
|
11184
|
-
async mutatePatchGroup(method, path, body,
|
|
11185
|
-
/**
|
|
11186
|
-
* WHO is asking. Sent as `x-val-profile-id`, which is what the content API
|
|
11187
|
-
* reads to decide whether this group is the caller's.
|
|
11188
|
-
*
|
|
11189
|
-
* `this.authHeaders` is the app's API key, and that names the PROJECT, not
|
|
11190
|
-
* the person — so without this the content API cannot resolve a profile
|
|
11191
|
-
* and refuses every stage and unstage with
|
|
11192
|
-
* "Cannot resolve the caller's profile". The group endpoints are the only
|
|
11193
|
-
* ones here that need it, because they are the only ones whose answer
|
|
11194
|
-
* depends on which of a project's editors is calling.
|
|
11195
|
-
*
|
|
11196
|
-
* Omitted when there is no session rather than sent empty: the content API
|
|
11197
|
-
* treats an unidentified caller as a refusal, which is what we want, and an
|
|
11198
|
-
* empty header would be a different and less obvious way to say it.
|
|
11199
|
-
*
|
|
11200
|
-
* A PAT already identifies a person, so `authHeaders` carries the identity
|
|
11201
|
-
* on its own there and this adds nothing.
|
|
11202
|
-
*/
|
|
11203
|
-
authorId) {
|
|
11204
|
-
if (this.proposal !== null) {
|
|
11205
|
-
return {
|
|
11206
|
-
status: 409,
|
|
11207
|
-
patchIds: [],
|
|
11208
|
-
error: {
|
|
11209
|
-
message: "A proposal's changes are saved together, so there is nothing to stage or unstage in one."
|
|
11210
|
-
}
|
|
11211
|
-
};
|
|
11212
|
-
}
|
|
11213
|
-
try {
|
|
11214
|
-
return await this.mutatePatchGroupRequest(method, path, body, authorId);
|
|
11215
|
-
} finally {
|
|
11216
|
-
this.forgetPatchGroups();
|
|
11217
|
-
}
|
|
11218
|
-
}
|
|
11219
|
-
async mutatePatchGroupRequest(method, path, body, authorId) {
|
|
11220
|
-
try {
|
|
11221
|
-
const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
|
|
11222
|
-
method,
|
|
11223
|
-
headers: {
|
|
11224
|
-
...this.authHeaders,
|
|
11225
|
-
...(authorId !== null ? {
|
|
11226
|
-
"x-val-profile-id": authorId
|
|
11227
|
-
} : {}),
|
|
11228
|
-
"Content-Type": "application/json"
|
|
11229
|
-
},
|
|
11230
|
-
body: JSON.stringify(body)
|
|
11231
|
-
});
|
|
11232
|
-
if (res.ok) {
|
|
11233
|
-
const parsed = PatchGroupMutationResponse.safeParse(await res.json());
|
|
11234
|
-
if (parsed.success) {
|
|
11798
|
+
}
|
|
11799
|
+
}
|
|
11235
11800
|
return {
|
|
11236
|
-
|
|
11237
|
-
|
|
11238
|
-
|
|
11239
|
-
|
|
11801
|
+
commits,
|
|
11802
|
+
deployments,
|
|
11803
|
+
patches,
|
|
11804
|
+
errors,
|
|
11805
|
+
...(data.headPatchId !== undefined ? {
|
|
11806
|
+
headPatchId: data.headPatchId
|
|
11807
|
+
} : {}),
|
|
11808
|
+
...(data.headVersion !== undefined ? {
|
|
11809
|
+
headVersion: data.headVersion
|
|
11810
|
+
} : {}),
|
|
11811
|
+
...(data.publishingPatches !== undefined ? {
|
|
11812
|
+
publishingPatches: data.publishingPatches
|
|
11813
|
+
} : {}),
|
|
11814
|
+
...(data.heldBackPatches !== undefined ? {
|
|
11815
|
+
heldBackPatches: data.heldBackPatches
|
|
11240
11816
|
} : {})
|
|
11241
11817
|
};
|
|
11242
11818
|
}
|
|
11819
|
+
console.error("Could not parse patches response. Error: " + fromError(parsed.error));
|
|
11243
11820
|
return {
|
|
11244
|
-
|
|
11245
|
-
patchIds: [],
|
|
11246
|
-
error: {
|
|
11247
|
-
message: `Could not parse patch group response. Error: ${fromError(parsed.error)}`
|
|
11248
|
-
}
|
|
11249
|
-
};
|
|
11250
|
-
}
|
|
11251
|
-
// 403 (not your group) and 409 (already published) are meaningful to the
|
|
11252
|
-
// client, so they are passed through rather than flattened to a 500.
|
|
11253
|
-
if (res.status === 403 || res.status === 409) {
|
|
11254
|
-
// `home` answers these with a JSON body, so `res.text()` put the literal
|
|
11255
|
-
// `{"message":"..."}` in front of the user. Every other branch in this
|
|
11256
|
-
// class unwraps it; this one now does too.
|
|
11257
|
-
return {
|
|
11258
|
-
status: res.status,
|
|
11259
|
-
patchIds: [],
|
|
11821
|
+
patches,
|
|
11260
11822
|
error: {
|
|
11261
|
-
message:
|
|
11823
|
+
message: `The response that Val got from the server was not in the expected format. This might be a transient error or a configuration issue. Please try again later.`
|
|
11262
11824
|
}
|
|
11263
11825
|
};
|
|
11264
11826
|
}
|
|
11265
|
-
|
|
11266
|
-
|
|
11267
|
-
// 500 that sends the user looking at their own session.
|
|
11268
|
-
if (res.status === 401) {
|
|
11827
|
+
console.error("Could not get patches. HTTP error: " + patchesRes.status + " " + patchesRes.statusText);
|
|
11828
|
+
if (patchesRes.status === 401) {
|
|
11269
11829
|
return {
|
|
11270
|
-
|
|
11271
|
-
patchIds: [],
|
|
11830
|
+
patches,
|
|
11272
11831
|
error: {
|
|
11273
11832
|
message: "Although your user is authorized, the application has authorization issues. Contact the developers on your team and ask them to verify the api keys."
|
|
11274
11833
|
}
|
|
11275
11834
|
};
|
|
11276
11835
|
}
|
|
11277
11836
|
return {
|
|
11278
|
-
|
|
11279
|
-
patchIds: [],
|
|
11837
|
+
patches,
|
|
11280
11838
|
error: {
|
|
11281
|
-
message:
|
|
11839
|
+
message: "Could not get your changes. It is most likely due to a network issue. Check your network connection and please try again."
|
|
11282
11840
|
}
|
|
11283
11841
|
};
|
|
11284
11842
|
} catch (err) {
|
|
11843
|
+
console.error("Could not get patches (connection error):", err instanceof Error ? err.message : JSON.stringify(err));
|
|
11285
11844
|
return {
|
|
11286
|
-
|
|
11287
|
-
|
|
11845
|
+
patches: [],
|
|
11846
|
+
networkError: true,
|
|
11288
11847
|
error: {
|
|
11289
|
-
message: `
|
|
11848
|
+
message: `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}`
|
|
11290
11849
|
}
|
|
11291
11850
|
};
|
|
11292
11851
|
}
|
|
11293
11852
|
}
|
|
11294
|
-
|
|
11295
|
-
|
|
11296
|
-
async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
|
|
11853
|
+
async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
|
|
11297
11854
|
const baseSha = await this.getBaseSha();
|
|
11298
11855
|
return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
|
|
11299
11856
|
method: "POST",
|
|
@@ -11319,24 +11876,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11319
11876
|
commit: this.git.commit,
|
|
11320
11877
|
branch: this.git.branch
|
|
11321
11878
|
} : {}),
|
|
11322
|
-
coreVersion: Internal.VERSION.core
|
|
11323
|
-
/*
|
|
11324
|
-
* Group membership in the SAME request as the patch.
|
|
11325
|
-
*
|
|
11326
|
-
* The content API runs every refusal before its insert, so an invalid
|
|
11327
|
-
* closure is a 400 with nothing written. Spread rather than sent as
|
|
11328
|
-
* nulls: a client with no group omits the fields entirely, which is
|
|
11329
|
-
* what an older content API expects to see.
|
|
11330
|
-
*/
|
|
11331
|
-
...(patchGroup ? {
|
|
11332
|
-
// Only when the caller named one. Omitted, the content API
|
|
11333
|
-
// resolves this author's open group and creates it if absent,
|
|
11334
|
-
// which is what every write wants.
|
|
11335
|
-
...(patchGroup.patchGroupId !== undefined ? {
|
|
11336
|
-
patchGroupId: patchGroup.patchGroupId
|
|
11337
|
-
} : {}),
|
|
11338
|
-
withPatchIds: patchGroup.withPatchIds
|
|
11339
|
-
} : {})
|
|
11879
|
+
coreVersion: Internal.VERSION.core
|
|
11340
11880
|
})
|
|
11341
11881
|
}).then(async res => {
|
|
11342
11882
|
var _res$headers$get2;
|
|
@@ -11345,20 +11885,6 @@ class ValOpsHttp extends ValOps {
|
|
|
11345
11885
|
if (parsed.success) {
|
|
11346
11886
|
return result.ok({
|
|
11347
11887
|
patchId: parsed.data.patchId,
|
|
11348
|
-
/*
|
|
11349
|
-
* Passed back to the client, which cannot learn it any other way.
|
|
11350
|
-
*
|
|
11351
|
-
* A write names no group — the content API resolves this author's
|
|
11352
|
-
* open group and CREATES it if absent — so on a fresh branch the
|
|
11353
|
-
* group comes into existence here and nowhere else. The chain
|
|
11354
|
-
* annotation is only re-read when a fetch has missing ids to ask
|
|
11355
|
-
* for, and a patch this client made is never missing, so without
|
|
11356
|
-
* this the tab that bootstrapped the group would never learn its
|
|
11357
|
-
* id and every stage would be a no-op.
|
|
11358
|
-
*/
|
|
11359
|
-
...(parsed.data.patchGroupId !== undefined ? {
|
|
11360
|
-
patchGroupId: parsed.data.patchGroupId
|
|
11361
|
-
} : {}),
|
|
11362
11888
|
...(parsed.data.headVersion !== undefined ? {
|
|
11363
11889
|
headVersion: parsed.data.headVersion
|
|
11364
11890
|
} : {})
|
|
@@ -11408,7 +11934,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11408
11934
|
errorType: "other",
|
|
11409
11935
|
message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
|
|
11410
11936
|
});
|
|
11411
|
-
})
|
|
11937
|
+
});
|
|
11412
11938
|
}
|
|
11413
11939
|
|
|
11414
11940
|
/**
|
|
@@ -11673,17 +12199,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11673
12199
|
}]
|
|
11674
12200
|
};
|
|
11675
12201
|
}
|
|
11676
|
-
async deletePatches(patchIds
|
|
11677
|
-
/**
|
|
11678
|
-
* Patches that are NOT deleted but must lose their group membership.
|
|
11679
|
-
*
|
|
11680
|
-
* Deleting a patch out of the middle of a patch set leaves every group
|
|
11681
|
-
* still holding the rest with a non-prefix intersection — the patches after
|
|
11682
|
-
* the hole were written against a view that had it. The content API cannot
|
|
11683
|
-
* work out which those are (it has no schema), so the client sends the
|
|
11684
|
-
* forward closure and it drops those memberships without deleting anything.
|
|
11685
|
-
*/
|
|
11686
|
-
unstagePatchIds) {
|
|
12202
|
+
async deletePatches(patchIds) {
|
|
11687
12203
|
return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
|
|
11688
12204
|
method: "DELETE",
|
|
11689
12205
|
headers: {
|
|
@@ -11691,10 +12207,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11691
12207
|
"Content-Type": "application/json"
|
|
11692
12208
|
},
|
|
11693
12209
|
body: JSON.stringify({
|
|
11694
|
-
patchIds
|
|
11695
|
-
...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
|
|
11696
|
-
unstagePatchIds
|
|
11697
|
-
} : {})
|
|
12210
|
+
patchIds
|
|
11698
12211
|
})
|
|
11699
12212
|
}).then(async res => {
|
|
11700
12213
|
if (res.ok) {
|
|
@@ -11737,24 +12250,9 @@ class ValOpsHttp extends ValOps {
|
|
|
11737
12250
|
message: `Could not delete patches (connection error?): ${e instanceof Error ? e.message : e.toString()}`
|
|
11738
12251
|
}
|
|
11739
12252
|
};
|
|
11740
|
-
})
|
|
12253
|
+
});
|
|
11741
12254
|
}
|
|
11742
|
-
async commit(prepared, message, committer, filesDirectory, newBranch
|
|
11743
|
-
/**
|
|
11744
|
-
* The patch group this commit EMPTIES, if it empties one.
|
|
11745
|
-
*
|
|
11746
|
-
* The content API closes the group it is given — and closes it WITHOUT
|
|
11747
|
-
* checking that the commit shipped all of it, so a caller that names a
|
|
11748
|
-
* group still holding work takes those patches out of every group and
|
|
11749
|
-
* leaves their author unable to publish them. The client therefore sends it
|
|
11750
|
-
* only when the publish accounts for everything the group still holds.
|
|
11751
|
-
*
|
|
11752
|
-
* Omitting it is not neutral: the commit still empties the group (the
|
|
11753
|
-
* content API drops applied ids from every group), but `published_at` is
|
|
11754
|
-
* never set, so the id is reused across publishes instead of a new group
|
|
11755
|
-
* per publish and the "already published" refusal can never fire.
|
|
11756
|
-
*/
|
|
11757
|
-
patchGroupId) {
|
|
12255
|
+
async commit(prepared, message, committer, filesDirectory, newBranch) {
|
|
11758
12256
|
try {
|
|
11759
12257
|
var _res$headers$get3;
|
|
11760
12258
|
/*
|
|
@@ -11768,23 +12266,10 @@ class ValOpsHttp extends ValOps {
|
|
|
11768
12266
|
headers: {
|
|
11769
12267
|
...this.authHeaders,
|
|
11770
12268
|
/*
|
|
11771
|
-
* WHO is publishing
|
|
11772
|
-
*
|
|
11773
|
-
*
|
|
11774
|
-
*
|
|
11775
|
-
* Closing a group is an ownership decision, so the content API
|
|
11776
|
-
* refuses a commit that names a `patchGroupId` it cannot attribute:
|
|
11777
|
-
* without this header `profileId` is `undefined` there and every
|
|
11778
|
-
* group-closing publish is 403 "Cannot resolve the caller's profile".
|
|
11779
|
-
* That is the NORMAL full publish, not an edge — `publish` names the
|
|
11780
|
-
* group whenever the commit empties it.
|
|
11781
|
-
*
|
|
11782
|
-
* Sent on every commit rather than only when a group is named. The
|
|
11783
|
-
* committer is a person either way, `committer` in the body already
|
|
11784
|
-
* says so, and a header that appears only on some commits is one more
|
|
11785
|
-
* conditional for a reader of either repo to reconstruct. It changes
|
|
11786
|
-
* nothing else: the content API reads it as an identity claim beside
|
|
11787
|
-
* the app's key and derives no scope from it.
|
|
12269
|
+
* WHO is publishing: `this.authHeaders` is the app's API key, which
|
|
12270
|
+
* names the PROJECT and not the person. The content API reads it as
|
|
12271
|
+
* an identity claim beside the app's key and derives no scope from
|
|
12272
|
+
* it; `committer` in the body says the same.
|
|
11788
12273
|
*/
|
|
11789
12274
|
"x-val-profile-id": committer,
|
|
11790
12275
|
"Content-Type": "application/json"
|
|
@@ -11829,10 +12314,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11829
12314
|
baseSha: await this.getBaseSha(),
|
|
11830
12315
|
committer,
|
|
11831
12316
|
message,
|
|
11832
|
-
newBranch
|
|
11833
|
-
...(patchGroupId !== undefined ? {
|
|
11834
|
-
patchGroupId
|
|
11835
|
-
} : {})
|
|
12317
|
+
newBranch
|
|
11836
12318
|
})
|
|
11837
12319
|
});
|
|
11838
12320
|
if (res.ok) {
|
|
@@ -12089,6 +12571,146 @@ class ValOpsHttp extends ValOps {
|
|
|
12089
12571
|
// #endregion history
|
|
12090
12572
|
}
|
|
12091
12573
|
|
|
12574
|
+
/**
|
|
12575
|
+
* Content's answer to a merge it committed, as the Studio is answered: what
|
|
12576
|
+
* content says, marked updated. Content has written the merge by then, so an
|
|
12577
|
+
* answer that cannot be read says so rather than failing as though nothing
|
|
12578
|
+
* happened.
|
|
12579
|
+
*/
|
|
12580
|
+
function updatedAnswer(text) {
|
|
12581
|
+
let parsed;
|
|
12582
|
+
try {
|
|
12583
|
+
parsed = JSON.parse(text);
|
|
12584
|
+
} catch {
|
|
12585
|
+
parsed = undefined;
|
|
12586
|
+
}
|
|
12587
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
12588
|
+
return {
|
|
12589
|
+
status: 502,
|
|
12590
|
+
contentType: "application/json",
|
|
12591
|
+
body: JSON.stringify({
|
|
12592
|
+
message: "The proposal was updated from the site, but content's answer could not be read. Reload to see the proposal as it is now."
|
|
12593
|
+
})
|
|
12594
|
+
};
|
|
12595
|
+
}
|
|
12596
|
+
return {
|
|
12597
|
+
status: 200,
|
|
12598
|
+
contentType: "application/json",
|
|
12599
|
+
body: JSON.stringify({
|
|
12600
|
+
status: "updated",
|
|
12601
|
+
...parsed
|
|
12602
|
+
})
|
|
12603
|
+
};
|
|
12604
|
+
}
|
|
12605
|
+
|
|
12606
|
+
/** What the Studio sends to update a proposal from the site: its decisions. */
|
|
12607
|
+
const UpdateContentBody = z.object({
|
|
12608
|
+
/**
|
|
12609
|
+
* Where the site and the proposal were when the conflicts the
|
|
12610
|
+
* `resolutions` decide were found: the conflicts answer says. Absent with
|
|
12611
|
+
* no decisions.
|
|
12612
|
+
*/
|
|
12613
|
+
siteHead: z.string().nullable().optional(),
|
|
12614
|
+
proposalHead: z.string().nullable().optional(),
|
|
12615
|
+
resolutions: z.array(z.union([z.object({
|
|
12616
|
+
moduleFilePath: z.string().transform(p => p),
|
|
12617
|
+
path: z.array(z.string()),
|
|
12618
|
+
keep: z.enum(["site", "proposal"])
|
|
12619
|
+
}), z.object({
|
|
12620
|
+
moduleFilePath: z.string().transform(p => p),
|
|
12621
|
+
path: z.array(z.string()),
|
|
12622
|
+
value: JSONValue
|
|
12623
|
+
})])).default([])
|
|
12624
|
+
});
|
|
12625
|
+
|
|
12626
|
+
/** A module, as the builder tab's evaluation of its tree gave it. */
|
|
12627
|
+
const EvaluatedModules = z.record(z.string().transform(p => p), z.object({
|
|
12628
|
+
schema: SerializedSchema,
|
|
12629
|
+
written: JSONValue,
|
|
12630
|
+
errors: z.array(z.object({
|
|
12631
|
+
path: z.array(z.string()),
|
|
12632
|
+
message: z.string()
|
|
12633
|
+
}))
|
|
12634
|
+
}));
|
|
12635
|
+
|
|
12636
|
+
/** What the builder tab sends to update a proposal with code from the site. */
|
|
12637
|
+
const UpdateCodeBody = z.object({
|
|
12638
|
+
siteHead: z.string().nullable(),
|
|
12639
|
+
proposalHead: z.string().nullable(),
|
|
12640
|
+
builds: z.object({
|
|
12641
|
+
base: z.string(),
|
|
12642
|
+
site: z.string()
|
|
12643
|
+
}),
|
|
12644
|
+
evaluated: z.object({
|
|
12645
|
+
base: EvaluatedModules,
|
|
12646
|
+
site: EvaluatedModules,
|
|
12647
|
+
proposal: EvaluatedModules
|
|
12648
|
+
}),
|
|
12649
|
+
resolutions: z.array(z.union([z.object({
|
|
12650
|
+
moduleFilePath: z.string().transform(p => p),
|
|
12651
|
+
path: z.array(z.string()),
|
|
12652
|
+
keep: z.enum(["site", "proposal"])
|
|
12653
|
+
}), z.object({
|
|
12654
|
+
moduleFilePath: z.string().transform(p => p),
|
|
12655
|
+
path: z.array(z.string()),
|
|
12656
|
+
value: JSONValue
|
|
12657
|
+
}), z.object({
|
|
12658
|
+
file: z.string(),
|
|
12659
|
+
keep: z.enum(["site", "proposal"])
|
|
12660
|
+
}), z.object({
|
|
12661
|
+
file: z.string(),
|
|
12662
|
+
text: z.string()
|
|
12663
|
+
})])).default([]),
|
|
12664
|
+
/** The tab's evaluation of the merge the first call answered. */
|
|
12665
|
+
merged: z.object({
|
|
12666
|
+
id: z.string(),
|
|
12667
|
+
evaluated: EvaluatedModules
|
|
12668
|
+
}).optional()
|
|
12669
|
+
});
|
|
12670
|
+
|
|
12671
|
+
/** Content's `GET /proposals/:name/merge-trees`. */
|
|
12672
|
+
const MergeTrees = z.object({
|
|
12673
|
+
base: z.string().nullable(),
|
|
12674
|
+
siteHead: z.string().nullable(),
|
|
12675
|
+
proposalHead: z.string().nullable(),
|
|
12676
|
+
upToDate: z.boolean(),
|
|
12677
|
+
builds: z.object({
|
|
12678
|
+
base: z.string(),
|
|
12679
|
+
site: z.string()
|
|
12680
|
+
}),
|
|
12681
|
+
trees: z.object({
|
|
12682
|
+
site: z.record(z.string(), z.string()),
|
|
12683
|
+
base: z.record(z.string(), z.string().nullable()),
|
|
12684
|
+
proposal: z.record(z.string(), z.string().nullable())
|
|
12685
|
+
}),
|
|
12686
|
+
modules: z.record(z.string(), z.object({
|
|
12687
|
+
base: z.string().nullable(),
|
|
12688
|
+
site: z.string().nullable(),
|
|
12689
|
+
proposal: z.string().nullable()
|
|
12690
|
+
})),
|
|
12691
|
+
sources: z.record(z.string(), JSONValue)
|
|
12692
|
+
});
|
|
12693
|
+
|
|
12694
|
+
/** Content's `GET /proposals/:name/merge-inputs`. */
|
|
12695
|
+
const MergeInputs = z.object({
|
|
12696
|
+
base: z.string().nullable(),
|
|
12697
|
+
siteHead: z.string().nullable(),
|
|
12698
|
+
proposalHead: z.string().nullable(),
|
|
12699
|
+
upToDate: z.boolean(),
|
|
12700
|
+
contentOnly: z.union([z.object({
|
|
12701
|
+
ok: z.literal(true)
|
|
12702
|
+
}), z.object({
|
|
12703
|
+
ok: z.literal(false),
|
|
12704
|
+
reason: z.string()
|
|
12705
|
+
})]),
|
|
12706
|
+
modules: z.record(z.string(), z.object({
|
|
12707
|
+
base: JSONValue.nullable(),
|
|
12708
|
+
site: JSONValue.nullable(),
|
|
12709
|
+
proposal: JSONValue.nullable(),
|
|
12710
|
+
text: z.string().nullable()
|
|
12711
|
+
}))
|
|
12712
|
+
});
|
|
12713
|
+
|
|
12092
12714
|
/**
|
|
12093
12715
|
* One stored patch. Everything the ordered chain needs and nothing else.
|
|
12094
12716
|
*/
|
|
@@ -12378,8 +13000,7 @@ class ValOpsMemory extends ValOps {
|
|
|
12378
13000
|
}
|
|
12379
13001
|
async fetchPatches(filters) {
|
|
12380
13002
|
// An empty `patchIds` means "no filter", not "none": both shipped
|
|
12381
|
-
// implementations read it that way and callers rely on it.
|
|
12382
|
-
// `scopedModulePatches` in ValOps.ts, which says why at length.
|
|
13003
|
+
// implementations read it that way and callers rely on it.
|
|
12383
13004
|
const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
|
|
12384
13005
|
const order = await this.store.list();
|
|
12385
13006
|
const patches = [];
|
|
@@ -12425,12 +13046,7 @@ class ValOpsMemory extends ValOps {
|
|
|
12425
13046
|
* point at nothing. A patch computed against a different state is caught
|
|
12426
13047
|
* where it shows -- applying it -- rather than by refusing the write.
|
|
12427
13048
|
*/
|
|
12428
|
-
_parentRef, authorId, _sessionId
|
|
12429
|
-
/* No shared store and no second author yet, so nothing for a group to
|
|
12430
|
-
* separate. Named rather than omitted: TypeScript allows an implementation
|
|
12431
|
-
* to take fewer parameters, so dropping it would look identical to
|
|
12432
|
-
* handling it. */
|
|
12433
|
-
_patchGroup) {
|
|
13049
|
+
_parentRef, authorId, _sessionId) {
|
|
12434
13050
|
await this.store.append({
|
|
12435
13051
|
patchId,
|
|
12436
13052
|
path,
|
|
@@ -15195,195 +15811,25 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15195
15811
|
/*
|
|
15196
15812
|
* Every URL in the batch at once, and nothing beyond it.
|
|
15197
15813
|
*
|
|
15198
|
-
* The concurrency here is the batch size the route caps at, which is
|
|
15199
|
-
* what keeps "check a thousand links" from becoming a thousand
|
|
15200
|
-
* simultaneous outbound sockets: the client sends ten, waits, sends
|
|
15201
|
-
* the next ten. Doing them in parallel WITHIN a batch is what makes a
|
|
15202
|
-
* batch worth having — ten five-second timeouts in series is a minute.
|
|
15203
|
-
*/
|
|
15204
|
-
const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
|
|
15205
|
-
return {
|
|
15206
|
-
status: 200,
|
|
15207
|
-
json: {
|
|
15208
|
-
results: Object.fromEntries(results)
|
|
15209
|
-
}
|
|
15210
|
-
};
|
|
15211
|
-
}
|
|
15212
|
-
},
|
|
15213
|
-
"/direct-file-upload-settings": {
|
|
15214
|
-
POST: async req => {
|
|
15215
|
-
const cookies = req.cookies;
|
|
15216
|
-
const auth = getAuth(cookies);
|
|
15217
|
-
if (auth.error) {
|
|
15218
|
-
return {
|
|
15219
|
-
status: 401,
|
|
15220
|
-
json: {
|
|
15221
|
-
message: auth.error
|
|
15222
|
-
}
|
|
15223
|
-
};
|
|
15224
|
-
}
|
|
15225
|
-
if (serverOps.patchesAreLocal) {
|
|
15226
|
-
// In FS mode patch-file uploads are buffered through this server (no remote round-trip),
|
|
15227
|
-
// so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
|
|
15228
|
-
// content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
|
|
15229
|
-
// can POST directly without exposing the PAT.
|
|
15230
|
-
const host = `/api/val`;
|
|
15231
|
-
let contentBaseUrl = null;
|
|
15232
|
-
let contentAuthNonce = null;
|
|
15233
|
-
if (!options.project) {
|
|
15234
|
-
console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
|
|
15235
|
-
} else if (!(serverOps instanceof ValOpsFS)) {
|
|
15236
|
-
/*
|
|
15237
|
-
* Presigning needs a content host to presign AGAINST, and a local
|
|
15238
|
-
* store does not necessarily have one configured: `fs` mode has the
|
|
15239
|
-
* developer's `valContentUrl`, and a host holding its own source has
|
|
15240
|
-
* no such setting.
|
|
15241
|
-
*
|
|
15242
|
-
* Warned and disabled, like the missing-project case above, rather
|
|
15243
|
-
* than failing the request: everything else this route answers --
|
|
15244
|
-
* the patch upload base url -- still works, and the only thing lost
|
|
15245
|
-
* is the browser posting AI images straight to the content host.
|
|
15246
|
-
*/
|
|
15247
|
-
console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
|
|
15248
|
-
} else {
|
|
15249
|
-
const authDataRes = await getRemoteFileAuth();
|
|
15250
|
-
if (authDataRes.status !== 200) {
|
|
15251
|
-
console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
|
|
15252
|
-
} else {
|
|
15253
|
-
const corsOrigin = "*"; // TODO: add cors origin
|
|
15254
|
-
const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
|
|
15255
|
-
if (presignedAuthNonce.status === "success") {
|
|
15256
|
-
contentBaseUrl = presignedAuthNonce.data.baseUrl;
|
|
15257
|
-
contentAuthNonce = presignedAuthNonce.data.nonce;
|
|
15258
|
-
} else {
|
|
15259
|
-
console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
|
|
15260
|
-
}
|
|
15261
|
-
}
|
|
15262
|
-
}
|
|
15263
|
-
return {
|
|
15264
|
-
status: 200,
|
|
15265
|
-
json: {
|
|
15266
|
-
nonce: null,
|
|
15267
|
-
baseUrl: `${host}/upload`,
|
|
15268
|
-
// NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
|
|
15269
|
-
contentBaseUrl,
|
|
15270
|
-
contentAuthNonce
|
|
15271
|
-
}
|
|
15272
|
-
};
|
|
15273
|
-
}
|
|
15274
|
-
const httpOps = serverOps;
|
|
15275
|
-
const profileId = "id" in auth && auth.id ? auth.id : undefined;
|
|
15276
|
-
if (!profileId) {
|
|
15277
|
-
return {
|
|
15278
|
-
status: 401,
|
|
15279
|
-
json: {
|
|
15280
|
-
message: "Unauthorized"
|
|
15281
|
-
}
|
|
15282
|
-
};
|
|
15283
|
-
}
|
|
15284
|
-
const corsOrigin = "*"; // TODO: add cors origin
|
|
15285
|
-
const presignedAuthNonce = await httpOps.getPresignedAuthNonce(profileId, corsOrigin);
|
|
15286
|
-
if (presignedAuthNonce.status === "error") {
|
|
15287
|
-
return {
|
|
15288
|
-
status: presignedAuthNonce.statusCode,
|
|
15289
|
-
json: presignedAuthNonce.error
|
|
15290
|
-
};
|
|
15291
|
-
}
|
|
15292
|
-
return {
|
|
15293
|
-
status: 200,
|
|
15294
|
-
json: {
|
|
15295
|
-
nonce: presignedAuthNonce.data.nonce,
|
|
15296
|
-
baseUrl: presignedAuthNonce.data.baseUrl,
|
|
15297
|
-
contentBaseUrl: presignedAuthNonce.data.baseUrl,
|
|
15298
|
-
contentAuthNonce: presignedAuthNonce.data.nonce
|
|
15299
|
-
}
|
|
15300
|
-
};
|
|
15301
|
-
}
|
|
15302
|
-
},
|
|
15303
|
-
//#region patch groups
|
|
15304
|
-
// Staging and unstaging patches. Both take an already-closed set of patch ids:
|
|
15305
|
-
// the prefix closure (staging) or the forward closure (unstaging) is computed
|
|
15306
|
-
// on the client, which is the only side that has the schema needed to derive
|
|
15307
|
-
// patch sets. See `docs/independent-publish/PLAN.md`.
|
|
15308
|
-
//
|
|
15309
|
-
// In FS mode there is no shared store and exactly one author, so group
|
|
15310
|
-
// membership lives in the client and these handlers simply acknowledge. The
|
|
15311
|
-
// client already sends an explicit patch id list to `/save`, so a locally-held
|
|
15312
|
-
// group is enough to publish a subset correctly.
|
|
15313
|
-
"/patch-groups/~/patches": {
|
|
15314
|
-
PUT: async req => {
|
|
15315
|
-
const auth = getAuth(req.cookies);
|
|
15316
|
-
if (auth.error) {
|
|
15317
|
-
return {
|
|
15318
|
-
status: 401,
|
|
15319
|
-
json: {
|
|
15320
|
-
message: auth.error
|
|
15321
|
-
}
|
|
15322
|
-
};
|
|
15323
|
-
}
|
|
15324
|
-
const {
|
|
15325
|
-
patchGroupId,
|
|
15326
|
-
patchIds
|
|
15327
|
-
} = req.body;
|
|
15328
|
-
const withPatchIds = req.body.withPatchIds ?? [];
|
|
15329
|
-
if (serverOps.patchesAreLocal) {
|
|
15330
|
-
return {
|
|
15331
|
-
status: 200,
|
|
15332
|
-
json: {
|
|
15333
|
-
patchGroupId: patchGroupId ?? null,
|
|
15334
|
-
patchIds: [...patchIds, ...withPatchIds]
|
|
15335
|
-
}
|
|
15336
|
-
};
|
|
15337
|
-
}
|
|
15338
|
-
if (!("id" in auth) || !auth.id) {
|
|
15339
|
-
return {
|
|
15340
|
-
status: 401,
|
|
15341
|
-
json: {
|
|
15342
|
-
message: "Unauthorized"
|
|
15343
|
-
}
|
|
15344
|
-
};
|
|
15345
|
-
}
|
|
15346
|
-
// A named group is checked here as well as by the content API. No name
|
|
15347
|
-
// is the caller's own open group, which the content API resolves from
|
|
15348
|
-
// the profile it is sent — there is nothing here to check it against.
|
|
15349
|
-
if (patchGroupId !== undefined) {
|
|
15350
|
-
const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
|
|
15351
|
-
if (refusal !== null) {
|
|
15352
|
-
return {
|
|
15353
|
-
status: refusal.status,
|
|
15354
|
-
json: {
|
|
15355
|
-
message: refusal.message
|
|
15356
|
-
}
|
|
15357
|
-
};
|
|
15358
|
-
}
|
|
15359
|
-
}
|
|
15360
|
-
const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
|
|
15361
|
-
// Forwarded so the content API can refuse independently. This server
|
|
15362
|
-
// has already refused a group that is not the caller's; sending the
|
|
15363
|
-
// author means the check also holds for anything reaching the content
|
|
15364
|
-
// API without coming through here.
|
|
15365
|
-
auth.id);
|
|
15366
|
-
if (res.error) {
|
|
15367
|
-
return {
|
|
15368
|
-
status: res.status,
|
|
15369
|
-
json: {
|
|
15370
|
-
message: res.error.message
|
|
15371
|
-
}
|
|
15372
|
-
};
|
|
15373
|
-
}
|
|
15814
|
+
* The concurrency here is the batch size the route caps at, which is
|
|
15815
|
+
* what keeps "check a thousand links" from becoming a thousand
|
|
15816
|
+
* simultaneous outbound sockets: the client sends ten, waits, sends
|
|
15817
|
+
* the next ten. Doing them in parallel WITHIN a batch is what makes a
|
|
15818
|
+
* batch worth having — ten five-second timeouts in series is a minute.
|
|
15819
|
+
*/
|
|
15820
|
+
const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
|
|
15374
15821
|
return {
|
|
15375
15822
|
status: 200,
|
|
15376
15823
|
json: {
|
|
15377
|
-
|
|
15378
|
-
patchIds: res.patchIds,
|
|
15379
|
-
...(res.headVersion !== undefined ? {
|
|
15380
|
-
headVersion: res.headVersion
|
|
15381
|
-
} : {})
|
|
15824
|
+
results: Object.fromEntries(results)
|
|
15382
15825
|
}
|
|
15383
15826
|
};
|
|
15384
|
-
}
|
|
15385
|
-
|
|
15386
|
-
|
|
15827
|
+
}
|
|
15828
|
+
},
|
|
15829
|
+
"/direct-file-upload-settings": {
|
|
15830
|
+
POST: async req => {
|
|
15831
|
+
const cookies = req.cookies;
|
|
15832
|
+
const auth = getAuth(cookies);
|
|
15387
15833
|
if (auth.error) {
|
|
15388
15834
|
return {
|
|
15389
15835
|
status: 401,
|
|
@@ -15392,21 +15838,58 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15392
15838
|
}
|
|
15393
15839
|
};
|
|
15394
15840
|
}
|
|
15395
|
-
const {
|
|
15396
|
-
patchGroupId,
|
|
15397
|
-
patchIds
|
|
15398
|
-
} = req.body;
|
|
15399
|
-
const withPatchIds = req.body.withPatchIds ?? [];
|
|
15400
15841
|
if (serverOps.patchesAreLocal) {
|
|
15842
|
+
// In FS mode patch-file uploads are buffered through this server (no remote round-trip),
|
|
15843
|
+
// so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
|
|
15844
|
+
// content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
|
|
15845
|
+
// can POST directly without exposing the PAT.
|
|
15846
|
+
const host = `/api/val`;
|
|
15847
|
+
let contentBaseUrl = null;
|
|
15848
|
+
let contentAuthNonce = null;
|
|
15849
|
+
if (!options.project) {
|
|
15850
|
+
console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
|
|
15851
|
+
} else if (!(serverOps instanceof ValOpsFS)) {
|
|
15852
|
+
/*
|
|
15853
|
+
* Presigning needs a content host to presign AGAINST, and a local
|
|
15854
|
+
* store does not necessarily have one configured: `fs` mode has the
|
|
15855
|
+
* developer's `valContentUrl`, and a host holding its own source has
|
|
15856
|
+
* no such setting.
|
|
15857
|
+
*
|
|
15858
|
+
* Warned and disabled, like the missing-project case above, rather
|
|
15859
|
+
* than failing the request: everything else this route answers --
|
|
15860
|
+
* the patch upload base url -- still works, and the only thing lost
|
|
15861
|
+
* is the browser posting AI images straight to the content host.
|
|
15862
|
+
*/
|
|
15863
|
+
console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
|
|
15864
|
+
} else {
|
|
15865
|
+
const authDataRes = await getRemoteFileAuth();
|
|
15866
|
+
if (authDataRes.status !== 200) {
|
|
15867
|
+
console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
|
|
15868
|
+
} else {
|
|
15869
|
+
const corsOrigin = "*"; // TODO: add cors origin
|
|
15870
|
+
const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
|
|
15871
|
+
if (presignedAuthNonce.status === "success") {
|
|
15872
|
+
contentBaseUrl = presignedAuthNonce.data.baseUrl;
|
|
15873
|
+
contentAuthNonce = presignedAuthNonce.data.nonce;
|
|
15874
|
+
} else {
|
|
15875
|
+
console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
|
|
15876
|
+
}
|
|
15877
|
+
}
|
|
15878
|
+
}
|
|
15401
15879
|
return {
|
|
15402
15880
|
status: 200,
|
|
15403
15881
|
json: {
|
|
15404
|
-
|
|
15405
|
-
|
|
15882
|
+
nonce: null,
|
|
15883
|
+
baseUrl: `${host}/upload`,
|
|
15884
|
+
// NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
|
|
15885
|
+
contentBaseUrl,
|
|
15886
|
+
contentAuthNonce
|
|
15406
15887
|
}
|
|
15407
15888
|
};
|
|
15408
15889
|
}
|
|
15409
|
-
|
|
15890
|
+
const httpOps = serverOps;
|
|
15891
|
+
const profileId = "id" in auth && auth.id ? auth.id : undefined;
|
|
15892
|
+
if (!profileId) {
|
|
15410
15893
|
return {
|
|
15411
15894
|
status: 401,
|
|
15412
15895
|
json: {
|
|
@@ -15414,37 +15897,21 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15414
15897
|
}
|
|
15415
15898
|
};
|
|
15416
15899
|
}
|
|
15417
|
-
|
|
15418
|
-
|
|
15419
|
-
|
|
15420
|
-
if (patchGroupId !== undefined) {
|
|
15421
|
-
const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
|
|
15422
|
-
if (refusal !== null) {
|
|
15423
|
-
return {
|
|
15424
|
-
status: refusal.status,
|
|
15425
|
-
json: {
|
|
15426
|
-
message: refusal.message
|
|
15427
|
-
}
|
|
15428
|
-
};
|
|
15429
|
-
}
|
|
15430
|
-
}
|
|
15431
|
-
const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
|
|
15432
|
-
if (res.error) {
|
|
15900
|
+
const corsOrigin = "*"; // TODO: add cors origin
|
|
15901
|
+
const presignedAuthNonce = await httpOps.getPresignedAuthNonce(profileId, corsOrigin);
|
|
15902
|
+
if (presignedAuthNonce.status === "error") {
|
|
15433
15903
|
return {
|
|
15434
|
-
status:
|
|
15435
|
-
json:
|
|
15436
|
-
message: res.error.message
|
|
15437
|
-
}
|
|
15904
|
+
status: presignedAuthNonce.statusCode,
|
|
15905
|
+
json: presignedAuthNonce.error
|
|
15438
15906
|
};
|
|
15439
15907
|
}
|
|
15440
15908
|
return {
|
|
15441
15909
|
status: 200,
|
|
15442
15910
|
json: {
|
|
15443
|
-
|
|
15444
|
-
|
|
15445
|
-
|
|
15446
|
-
|
|
15447
|
-
} : {})
|
|
15911
|
+
nonce: presignedAuthNonce.data.nonce,
|
|
15912
|
+
baseUrl: presignedAuthNonce.data.baseUrl,
|
|
15913
|
+
contentBaseUrl: presignedAuthNonce.data.baseUrl,
|
|
15914
|
+
contentAuthNonce: presignedAuthNonce.data.nonce
|
|
15448
15915
|
}
|
|
15449
15916
|
};
|
|
15450
15917
|
}
|
|
@@ -15453,53 +15920,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15453
15920
|
"/patches": {
|
|
15454
15921
|
PUT: async req => {
|
|
15455
15922
|
const cookies = req.cookies;
|
|
15456
|
-
|
|
15457
|
-
/**
|
|
15458
|
-
* Group membership travels WITH the patch, in one request.
|
|
15459
|
-
*
|
|
15460
|
-
* Atomic on purpose: the content API runs every refusal before its
|
|
15461
|
-
* insert, so an invalid closure is a 400 with nothing written. Recording
|
|
15462
|
-
* membership in a second call would let a patch exist outside its
|
|
15463
|
-
* author's group whenever that call failed — and a patch outside your
|
|
15464
|
-
* own group is one you cannot publish until a repair puts it back.
|
|
15465
|
-
*
|
|
15466
|
-
*/
|
|
15467
|
-
/*
|
|
15468
|
-
* A membership is present if EITHER field is.
|
|
15469
|
-
*
|
|
15470
|
-
* The common case names no group: the content API resolves the author's
|
|
15471
|
-
* open group, creating it if absent, so the client never has to hold an
|
|
15472
|
-
* id across publishes. It still sends the closure, which is the part it
|
|
15473
|
-
* alone can compute.
|
|
15474
|
-
*
|
|
15475
|
-
* `patchGroupId` is nullable, so an explicit `null` means "no group
|
|
15476
|
-
* named" exactly as omitting it does — it is passed through as
|
|
15477
|
-
* `undefined` rather than becoming a membership keyed by null.
|
|
15478
|
-
*/
|
|
15479
|
-
const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
|
|
15480
|
-
const requestedWith = req.body.withPatchIds;
|
|
15481
|
-
const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
|
|
15482
|
-
...(requestedPatchGroupId !== undefined ? {
|
|
15483
|
-
patchGroupId: requestedPatchGroupId
|
|
15484
|
-
} : {}),
|
|
15485
|
-
withPatchIds: requestedWith ?? []
|
|
15486
|
-
} : undefined;
|
|
15487
|
-
if (patchGroup !== undefined && serverOps.patchesAreLocal) {
|
|
15488
|
-
/*
|
|
15489
|
-
* `fs` has no shared store and one author, so there is no group to
|
|
15490
|
-
* join. Refused rather than acknowledged: answering 200 would tell the
|
|
15491
|
-
* client its membership was recorded when it was dropped, and the
|
|
15492
|
-
* client would then believe a publish is scoped when it is not.
|
|
15493
|
-
*/
|
|
15494
|
-
return {
|
|
15495
|
-
status: 400,
|
|
15496
|
-
json: {
|
|
15497
|
-
type: "patch-error",
|
|
15498
|
-
message: "Patch groups are not available in fs mode. Omit the patch group fields.",
|
|
15499
|
-
errors: {}
|
|
15500
|
-
}
|
|
15501
|
-
};
|
|
15502
|
-
}
|
|
15503
15923
|
const auth = getAuth(cookies);
|
|
15504
15924
|
if (auth.error) {
|
|
15505
15925
|
return {
|
|
@@ -15522,17 +15942,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15522
15942
|
const sessionId = req.body.sessionId ?? null;
|
|
15523
15943
|
const authorId = "id" in auth ? auth.id : null;
|
|
15524
15944
|
const newPatchIds = [];
|
|
15525
|
-
/*
|
|
15526
|
-
* The group the content API put these patches in.
|
|
15527
|
-
*
|
|
15528
|
-
* Every patch in one request has the same author and the same
|
|
15529
|
-
* membership, so the last answer is the answer — they all land in the
|
|
15530
|
-
* same group. Reported back because the client cannot learn it any
|
|
15531
|
-
* other way: it names no group (the content API resolves the author's
|
|
15532
|
-
* open one, creating it if absent), and the chain annotation is only
|
|
15533
|
-
* re-read when a fetch has missing ids to ask for.
|
|
15534
|
-
*/
|
|
15535
|
-
let patchGroupIdFromStore;
|
|
15536
15945
|
/*
|
|
15537
15946
|
* The chain version the LAST patch made — the head this batch leaves.
|
|
15538
15947
|
* The client keeps the highest version it has seen, so a `/stat` read
|
|
@@ -15548,18 +15957,12 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15548
15957
|
const savedSoFar = () => ({
|
|
15549
15958
|
newPatchIds,
|
|
15550
15959
|
parentRef,
|
|
15551
|
-
// Absent rather than null where there are no groups: `fs` mode and
|
|
15552
|
-
// a content API that predates them both answer without one, and the
|
|
15553
|
-
// client reads absence as "staging is not available here".
|
|
15554
|
-
...(patchGroupIdFromStore !== undefined ? {
|
|
15555
|
-
patchGroupId: patchGroupIdFromStore
|
|
15556
|
-
} : {}),
|
|
15557
15960
|
...(headVersion !== undefined ? {
|
|
15558
15961
|
headVersion
|
|
15559
15962
|
} : {})
|
|
15560
15963
|
});
|
|
15561
15964
|
for (const patch of patches) {
|
|
15562
|
-
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId
|
|
15965
|
+
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
|
|
15563
15966
|
if (result.isErr(createPatchRes)) {
|
|
15564
15967
|
if (createPatchRes.error.errorType === "patch-head-conflict") {
|
|
15565
15968
|
return {
|
|
@@ -15608,9 +16011,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15608
16011
|
patchId: createPatchRes.value.patchId
|
|
15609
16012
|
};
|
|
15610
16013
|
newPatchIds.push(createPatchRes.value.patchId);
|
|
15611
|
-
if (createPatchRes.value.patchGroupId !== undefined) {
|
|
15612
|
-
patchGroupIdFromStore = createPatchRes.value.patchGroupId;
|
|
15613
|
-
}
|
|
15614
16014
|
headVersion = createPatchRes.value.headVersion;
|
|
15615
16015
|
}
|
|
15616
16016
|
}
|
|
@@ -15677,84 +16077,16 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15677
16077
|
}
|
|
15678
16078
|
// TODO: we should sort by parentRef instead:
|
|
15679
16079
|
patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
15680
|
-
/**
|
|
15681
|
-
* Patch groups, ANNOTATED onto the chain rather than filtering it.
|
|
15682
|
-
*
|
|
15683
|
-
* The client computes a new patch's parent as the last id in this
|
|
15684
|
-
* response, so a filtered chain would make every client name a parent
|
|
15685
|
-
* that is not the real head and `POST /patches` would answer 409
|
|
15686
|
-
* forever. Annotate, never filter.
|
|
15687
|
-
*
|
|
15688
|
-
* Absent — not empty — where there are no groups: `fs` mode, a content
|
|
15689
|
-
* API that predates them, or a failed lookup. The client reads absence
|
|
15690
|
-
* as "this deployment has no groups" and leaves staging off, which is
|
|
15691
|
-
* the behaviour every project has today. An empty array would say
|
|
15692
|
-
* "groups exist and hold nothing", which would turn the staging UI on
|
|
15693
|
-
* with everything held.
|
|
15694
|
-
*/
|
|
15695
|
-
let patchGroups;
|
|
15696
|
-
if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
|
|
15697
|
-
/*
|
|
15698
|
-
* FRESH, because this answer makes a CLOSING decision.
|
|
15699
|
-
*
|
|
15700
|
-
* The client adopts this annotation as its group membership and
|
|
15701
|
-
* `emptiesOwnPatchGroup` then decides from it whether a publish may
|
|
15702
|
-
* name the group — and the content API closes what it is named
|
|
15703
|
-
* without checking. A one-second-old list is enough to get that
|
|
15704
|
-
* wrong: the same author writing in a second tab joins the open
|
|
15705
|
-
* group, the websocket pushes the chain immediately, so this tab's
|
|
15706
|
-
* fetch for the missing patch lands well inside the cache window and
|
|
15707
|
-
* reads a membership that is one patch short. It then publishes,
|
|
15708
|
-
* names the group, and closes it with the other tab's work still in
|
|
15709
|
-
* it — which leaves that tab wedged on 409 until a reload.
|
|
15710
|
-
*
|
|
15711
|
-
* `resolveOwnPatchScope` keeps the cache deliberately: that read
|
|
15712
|
-
* decides what a draft render SHOWS, it is repeated once per
|
|
15713
|
-
* `fetchVal` in a single render, and being a second stale there costs
|
|
15714
|
-
* a patch appearing late rather than a group closing early.
|
|
15715
|
-
*/
|
|
15716
|
-
const groupsRes = await serverOps.getPatchGroups({
|
|
15717
|
-
fresh: true
|
|
15718
|
-
});
|
|
15719
|
-
if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
|
|
15720
|
-
patchGroups = groupsRes.patchGroups;
|
|
15721
|
-
} else if (groupsRes.status === "error") {
|
|
15722
|
-
// Not fatal: the chain is what this endpoint is for, and staging
|
|
15723
|
-
// simply stays off for this read rather than the whole review
|
|
15724
|
-
// screen failing to load.
|
|
15725
|
-
console.error("Val: could not read patch groups", groupsRes.message);
|
|
15726
|
-
}
|
|
15727
|
-
}
|
|
15728
|
-
const groupIdsByPatchId = new Map();
|
|
15729
|
-
for (const group of patchGroups ?? []) {
|
|
15730
|
-
for (const patchId of group.patchIds) {
|
|
15731
|
-
const existing = groupIdsByPatchId.get(patchId);
|
|
15732
|
-
if (existing) {
|
|
15733
|
-
existing.push(group.patchGroupId);
|
|
15734
|
-
} else {
|
|
15735
|
-
groupIdsByPatchId.set(patchId, [group.patchGroupId]);
|
|
15736
|
-
}
|
|
15737
|
-
}
|
|
15738
|
-
}
|
|
15739
16080
|
return {
|
|
15740
16081
|
status: 200,
|
|
15741
16082
|
json: {
|
|
15742
|
-
patches
|
|
15743
|
-
const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
|
|
15744
|
-
return patchGroupIds ? {
|
|
15745
|
-
...patch,
|
|
15746
|
-
patchGroupIds
|
|
15747
|
-
} : patch;
|
|
15748
|
-
}),
|
|
15749
|
-
...(patchGroups ? {
|
|
15750
|
-
patchGroups
|
|
15751
|
-
} : {}),
|
|
16083
|
+
patches,
|
|
15752
16084
|
baseSha: await serverOps.getBaseSha()
|
|
15753
16085
|
}
|
|
15754
16086
|
};
|
|
15755
16087
|
},
|
|
15756
16088
|
DELETE: async req => {
|
|
15757
|
-
var _req$body
|
|
16089
|
+
var _req$body;
|
|
15758
16090
|
const query = req.query;
|
|
15759
16091
|
const cookies = req.cookies;
|
|
15760
16092
|
const auth = getAuth(cookies);
|
|
@@ -15775,25 +16107,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15775
16107
|
};
|
|
15776
16108
|
}
|
|
15777
16109
|
const ids = query.id;
|
|
15778
|
-
|
|
15779
|
-
* Which OTHER patches lose their group membership because these are
|
|
15780
|
-
* going. Only the client can COMPUTE it — that needs the patch sets,
|
|
15781
|
-
* which need the schema — but it is bounded here rather than trusted,
|
|
15782
|
-
* because the content API strips those memberships from every group
|
|
15783
|
-
* without an ownership check. See `boundUnstageClosure`.
|
|
15784
|
-
*
|
|
15785
|
-
* Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
|
|
15786
|
-
* client does not send it there.
|
|
15787
|
-
*/
|
|
15788
|
-
let unstagePatchIds;
|
|
15789
|
-
const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
|
|
15790
|
-
if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
|
|
15791
|
-
const chain = await serverOps.fetchPatches({
|
|
15792
|
-
excludePatchOps: true
|
|
15793
|
-
});
|
|
15794
|
-
unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
|
|
15795
|
-
}
|
|
15796
|
-
const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
|
|
16110
|
+
const deleteRes = await serverOps.deletePatches(ids);
|
|
15797
16111
|
if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
|
|
15798
16112
|
console.error("Val: Failed to delete patches", deleteRes.errors);
|
|
15799
16113
|
return {
|
|
@@ -15814,7 +16128,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15814
16128
|
}
|
|
15815
16129
|
};
|
|
15816
16130
|
}
|
|
15817
|
-
if (((_req$
|
|
16131
|
+
if (((_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.reportHeadVersion) === true) {
|
|
15818
16132
|
return {
|
|
15819
16133
|
status: 200,
|
|
15820
16134
|
json: {
|
|
@@ -15925,22 +16239,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15925
16239
|
} = req.query;
|
|
15926
16240
|
// Defaults to true, mirroring /sources/~. The Studio opts out.
|
|
15927
16241
|
const applyPatches = req.query.apply_patches !== false;
|
|
15928
|
-
/*
|
|
15929
|
-
* Whose pending work this render may see.
|
|
15930
|
-
*
|
|
15931
|
-
* The same resolution `/sources/~` runs, through the same function: a
|
|
15932
|
-
* draft page renders module content and `jsonValues` entries together,
|
|
15933
|
-
* and this route used to apply every pending patch on the branch while
|
|
15934
|
-
* the modules beside it were scoped — so one screen showed the caller's
|
|
15935
|
-
* view and everybody's unpublished work at once.
|
|
15936
|
-
*/
|
|
15937
|
-
const {
|
|
15938
|
-
ownPatchIds
|
|
15939
|
-
} = await resolveOwnPatchScope(serverOps, {
|
|
15940
|
-
explicitPatchIds: undefined,
|
|
15941
|
-
ownGroupsOnly: req.query.own_patch_groups_only === true,
|
|
15942
|
-
authorId: "id" in auth && auth.id || undefined
|
|
15943
|
-
});
|
|
15944
16242
|
const isWindow = offset !== undefined || limit !== undefined;
|
|
15945
16243
|
const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
|
|
15946
16244
|
if (shapes !== 1) {
|
|
@@ -15961,8 +16259,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15961
16259
|
}
|
|
15962
16260
|
if (key !== undefined) {
|
|
15963
16261
|
const res = await serverOps.getJsonEntry(moduleFilePath, key, {
|
|
15964
|
-
applyPatches
|
|
15965
|
-
patchIds: ownPatchIds
|
|
16262
|
+
applyPatches
|
|
15966
16263
|
});
|
|
15967
16264
|
if (res.status === "unauthorized") {
|
|
15968
16265
|
return {
|
|
@@ -16003,8 +16300,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16003
16300
|
offset: offset,
|
|
16004
16301
|
limit: limit
|
|
16005
16302
|
}, {
|
|
16006
|
-
applyPatches
|
|
16007
|
-
patchIds: ownPatchIds
|
|
16303
|
+
applyPatches
|
|
16008
16304
|
});
|
|
16009
16305
|
if (res.status === "unauthorized") {
|
|
16010
16306
|
return {
|
|
@@ -16089,101 +16385,10 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16089
16385
|
patches: []
|
|
16090
16386
|
};
|
|
16091
16387
|
if (query.exclude_patches !== true) {
|
|
16092
|
-
|
|
16093
|
-
|
|
16094
|
-
|
|
16095
|
-
* A draft render cannot name its own group ids — it has no client
|
|
16096
|
-
* state — so it asks for "mine" and the server works out which. Only
|
|
16097
|
-
* when `patch_id` is absent: an explicit list is a caller that already
|
|
16098
|
-
* knows what it wants.
|
|
16099
|
-
*
|
|
16100
|
-
* A group lookup that FAILS renders base rather than everything. Being
|
|
16101
|
-
* shown only committed content while the content API is unreachable is
|
|
16102
|
-
* a degraded preview; being shown another author's unpublished draft
|
|
16103
|
-
* because a lookup failed is the bug this feature exists to prevent,
|
|
16104
|
-
* and it would be silent.
|
|
16105
|
-
*/
|
|
16106
|
-
const {
|
|
16107
|
-
ownPatchIds,
|
|
16108
|
-
scopeAlsoIncludesApplied
|
|
16109
|
-
} = await resolveOwnPatchScope(serverOps, {
|
|
16110
|
-
explicitPatchIds: query.patch_id,
|
|
16111
|
-
ownGroupsOnly: query.own_patch_groups_only === true,
|
|
16112
|
-
authorId: "id" in auth && auth.id || undefined
|
|
16388
|
+
patchOps = await serverOps.fetchPatches({
|
|
16389
|
+
patchIds: undefined,
|
|
16390
|
+
excludePatchOps: false
|
|
16113
16391
|
});
|
|
16114
|
-
const requestedPatchIds = query.patch_id ?? ownPatchIds;
|
|
16115
|
-
if (scopeAlsoIncludesApplied) {
|
|
16116
|
-
/*
|
|
16117
|
-
* Scoped to this caller's groups, PLUS everything already
|
|
16118
|
-
* committed.
|
|
16119
|
-
*
|
|
16120
|
-
* Filtered here rather than through `patchIds`, because the set is
|
|
16121
|
-
* not knowable before the fetch: `appliedAt` lives on the patch,
|
|
16122
|
-
* not on the group. One request either way — the whole chain is
|
|
16123
|
-
* what the unscoped path fetches too — so this costs a filter, not
|
|
16124
|
-
* a round trip.
|
|
16125
|
-
*
|
|
16126
|
-
* This also subsumes the empty-group case below: a caller holding
|
|
16127
|
-
* nothing, on a branch with nothing applied, filters down to no
|
|
16128
|
-
* patches, which is base.
|
|
16129
|
-
*/
|
|
16130
|
-
const all = await serverOps.fetchPatches({
|
|
16131
|
-
patchIds: undefined,
|
|
16132
|
-
excludePatchOps: false
|
|
16133
|
-
});
|
|
16134
|
-
/*
|
|
16135
|
-
* Membership from THIS response when it carries one, not from the
|
|
16136
|
-
* group list `resolveOwnPatchScope` read.
|
|
16137
|
-
*
|
|
16138
|
-
* That list is a separate request, remembered for a second
|
|
16139
|
-
* (`ValOpsHttp.getPatchGroups`), so it can be older than the chain
|
|
16140
|
-
* fetched here: an editor who saved and reloaded at once was
|
|
16141
|
-
* rendered without the patch they had just written, because the
|
|
16142
|
-
* list still had their group without it. The content service reads
|
|
16143
|
-
* the chain and each patch's groups in one transaction, and
|
|
16144
|
-
* `fetchPatches` folds that onto `patchGroups` -- so the patches
|
|
16145
|
-
* and who owns them come from the same moment.
|
|
16146
|
-
*/
|
|
16147
|
-
const own = ownPatchScopeFor(all, "id" in auth && auth.id || undefined, ownPatchIds);
|
|
16148
|
-
patchOps = {
|
|
16149
|
-
...all,
|
|
16150
|
-
patches: scopedPatches(all.patches, own)
|
|
16151
|
-
};
|
|
16152
|
-
} else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
|
|
16153
|
-
/*
|
|
16154
|
-
* A group that holds nothing renders base, and is handled HERE.
|
|
16155
|
-
*
|
|
16156
|
-
* `fetchPatches` cannot express it: both implementations read an
|
|
16157
|
-
* empty `patchIds` as "no filter" and return the whole chain
|
|
16158
|
-
* (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
|
|
16159
|
-
* `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
|
|
16160
|
-
* That is the right default for every caller that has ever passed a
|
|
16161
|
-
* list, since none of them can mean "none" — but it is the most
|
|
16162
|
-
* dangerous possible reading of an EXPLICITLY empty group, which
|
|
16163
|
-
* would render every unpublished patch on the branch instead of
|
|
16164
|
-
* base.
|
|
16165
|
-
*
|
|
16166
|
-
* Answered before the call rather than by changing that shared
|
|
16167
|
-
* default, which seven other call sites rely on.
|
|
16168
|
-
*/
|
|
16169
|
-
patchOps = {
|
|
16170
|
-
patches: []
|
|
16171
|
-
};
|
|
16172
|
-
} else {
|
|
16173
|
-
patchOps = await serverOps.fetchPatches({
|
|
16174
|
-
/*
|
|
16175
|
-
* The caller's patch group, when it named one.
|
|
16176
|
-
*
|
|
16177
|
-
* `undefined` means every pending patch, which is what every
|
|
16178
|
-
* existing caller gets and has to keep getting. A draft-mode
|
|
16179
|
-
* render that names its group gets base + that group instead, so
|
|
16180
|
-
* a server-rendered preview shows the same thing the person
|
|
16181
|
-
* editing is looking at rather than everybody's unpublished work.
|
|
16182
|
-
*/
|
|
16183
|
-
patchIds: requestedPatchIds,
|
|
16184
|
-
excludePatchOps: false
|
|
16185
|
-
});
|
|
16186
|
-
}
|
|
16187
16392
|
}
|
|
16188
16393
|
// We check authorization here, because it is the first call to the backend
|
|
16189
16394
|
if (patchOps.error && patchOps.unauthorized) {
|
|
@@ -16893,44 +17098,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16893
17098
|
};
|
|
16894
17099
|
} else if (serverOps instanceof ValOpsHttp) {
|
|
16895
17100
|
if (auth.error === undefined && auth.id) {
|
|
16896
|
-
/*
|
|
16897
|
-
* The group this commit CLOSES has to be the caller's.
|
|
16898
|
-
*
|
|
16899
|
-
* Stage and unstage go through `refuseUnlessOwn`; this route did
|
|
16900
|
-
* not, and the content API's `postCommit` marks the group published
|
|
16901
|
-
* on id alone with no author clause — so the id was trusted twice
|
|
16902
|
-
* and checked nowhere. Any logged-in editor could name a colleague's
|
|
16903
|
-
* open group and close it: their pending patches land in a closed
|
|
16904
|
-
* group and in no open one, so a scoped draft render shows base for
|
|
16905
|
-
* them, and their own tab still believes the group is open, so their
|
|
16906
|
-
* next stage is refused with 409.
|
|
16907
|
-
*
|
|
16908
|
-
* Same hole as the stage/unstage one this branch already closed, one
|
|
16909
|
-
* route over. The content API needs the same guard — this one is the
|
|
16910
|
-
* convenience, that one is what actually holds.
|
|
16911
|
-
*/
|
|
16912
|
-
if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
|
|
16913
|
-
const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
|
|
16914
|
-
if (refusal !== null) {
|
|
16915
|
-
return {
|
|
16916
|
-
status: refusal.status,
|
|
16917
|
-
json: {
|
|
16918
|
-
message: refusal.message,
|
|
16919
|
-
/*
|
|
16920
|
-
* Flagged, because a bare 409 here reads as git refusing
|
|
16921
|
-
* the commit — which is retryable, and this is the
|
|
16922
|
-
* opposite. `refuseUnlessOwn` answers 409 for one reason
|
|
16923
|
-
* only: the group has already been published, so its id
|
|
16924
|
-
* will never be writable again and retrying reproduces
|
|
16925
|
-
* this forever. The client forgets the id instead.
|
|
16926
|
-
*/
|
|
16927
|
-
...(refusal.status === 409 ? {
|
|
16928
|
-
patchGroupPublished: true
|
|
16929
|
-
} : {})
|
|
16930
|
-
}
|
|
16931
|
-
};
|
|
16932
|
-
}
|
|
16933
|
-
}
|
|
16934
17101
|
const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
|
|
16935
17102
|
/*
|
|
16936
17103
|
* Before the commit, because after it the files are no longer the
|
|
@@ -16938,14 +17105,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16938
17105
|
*/
|
|
16939
17106
|
const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
|
|
16940
17107
|
const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
|
|
16941
|
-
const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined
|
|
16942
|
-
/*
|
|
16943
|
-
* Forwarded verbatim, and only the client can decide it: the
|
|
16944
|
-
* content API closes the group it is named without checking
|
|
16945
|
-
* that the commit shipped all of it, and whether it did needs
|
|
16946
|
-
* the patch sets, which live in the browser.
|
|
16947
|
-
*/
|
|
16948
|
-
body.patchGroupId);
|
|
17108
|
+
const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined);
|
|
16949
17109
|
/*
|
|
16950
17110
|
* A host may publish somewhere other than the repository.
|
|
16951
17111
|
*
|
|
@@ -16958,7 +17118,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16958
17118
|
preparedCommit,
|
|
16959
17119
|
message,
|
|
16960
17120
|
authorId: auth.id,
|
|
16961
|
-
patchGroupId: body.patchGroupId,
|
|
16962
17121
|
commitToGit
|
|
16963
17122
|
}) : await commitToGit();
|
|
16964
17123
|
if (commitRes.error) {
|
|
@@ -18011,268 +18170,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
18011
18170
|
}
|
|
18012
18171
|
};
|
|
18013
18172
|
};
|
|
18014
|
-
/**
|
|
18015
|
-
* Refuse to touch a group that is not the caller's.
|
|
18016
|
-
*
|
|
18017
|
-
* Exported for `patchGroupOwnership.test.ts`: this is the whole of the
|
|
18018
|
-
* authorization for stage and unstage, and nothing else in the process checks
|
|
18019
|
-
* it, so it is worth testing as a policy rather than only through a route.
|
|
18020
|
-
*
|
|
18021
|
-
* `getAuth` only proves a session EXISTS; it says nothing about whose
|
|
18022
|
-
* group this is. And the content API cannot decide either — every call
|
|
18023
|
-
* from here carries the app's API key, not the editor's identity — so if
|
|
18024
|
-
* this does not check, nothing does.
|
|
18025
|
-
*
|
|
18026
|
-
* `GET /patches?include_patch_groups=true` hands every editor the id and
|
|
18027
|
-
* author of every open group on the branch, so without this any logged-in
|
|
18028
|
-
* editor can unstage another author's patches (their next publish
|
|
18029
|
-
* silently ships less) or stage into their group (it silently ships
|
|
18030
|
-
* more). The 403 declared for this route in `ApiRoutes.ts` was
|
|
18031
|
-
* unreachable.
|
|
18032
|
-
*
|
|
18033
|
-
* Fails CLOSED: if the groups cannot be read, the mutation is refused
|
|
18034
|
-
* rather than allowed unverified.
|
|
18035
|
-
*/
|
|
18036
|
-
|
|
18037
|
-
/**
|
|
18038
|
-
* The patches a scoped draft render should apply: the caller's own group, plus
|
|
18039
|
-
* everything already committed.
|
|
18040
|
-
*
|
|
18041
|
-
* Scoping is about PENDING work. A published patch stays in the chain with
|
|
18042
|
-
* `appliedAt` set until the next deployment moves the base, and it is part of
|
|
18043
|
-
* everyone's view in that window — the unscoped path applies it. Dropping it
|
|
18044
|
-
* meant the moment somebody published, their own draft preview reverted the
|
|
18045
|
-
* field they had just shipped, and nobody else saw it either until the deploy
|
|
18046
|
-
* landed; anything written on top in that window is authored against content
|
|
18047
|
-
* already stale on `main`.
|
|
18048
|
-
*
|
|
18049
|
-
* Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
|
|
18050
|
-
* PARTIAL publish leaves the group open with only some of its patches applied.
|
|
18051
|
-
* Those are committed too, and no flag on the group names them.
|
|
18052
|
-
*
|
|
18053
|
-
* `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
|
|
18054
|
-
* caller holding nothing, and on a branch with nothing applied it correctly
|
|
18055
|
-
* filters down to no patches, which renders base.
|
|
18056
|
-
*/
|
|
18057
|
-
function scopedPatches(patches, ownPatchIds) {
|
|
18058
|
-
const own = new Set(ownPatchIds ?? []);
|
|
18059
|
-
return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
|
|
18060
|
-
}
|
|
18061
|
-
|
|
18062
|
-
/**
|
|
18063
|
-
* Which of the client's `unstagePatchIds` this server is willing to forward.
|
|
18064
|
-
*
|
|
18065
|
-
* The forward closure of a discard is the client's to compute — it needs the
|
|
18066
|
-
* patch sets, which need the schema — and it was being forwarded verbatim. But
|
|
18067
|
-
* the content API removes those memberships from EVERY group with no ownership
|
|
18068
|
-
* check, so any logged-in editor could strip arbitrary patches out of any other
|
|
18069
|
-
* author's group by attaching them to a delete of one of their own throwaway
|
|
18070
|
-
* patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
|
|
18071
|
-
* reached by a different door: their next publish silently ships less.
|
|
18072
|
-
*
|
|
18073
|
-
* Neither server can compute the true closure, but this one can BOUND it. A
|
|
18074
|
-
* patch can only be invalidated by a delete if it was written after that delete
|
|
18075
|
-
* — its paths were chosen against a view that had it — and if it is in the same
|
|
18076
|
-
* module, since a patch set never spans two. Anything outside those bounds was
|
|
18077
|
-
* not in the closure whatever the client says, so it is dropped rather than
|
|
18078
|
-
* refused: the delete is still correct, and refusing the whole request over an
|
|
18079
|
-
* over-broad extra would turn a discard into an error the user cannot act on.
|
|
18080
|
-
*
|
|
18081
|
-
* Exported for the test. Pure, and given the chain rather than fetching it, so
|
|
18082
|
-
* the ordering it depends on is visible in the test rather than mocked.
|
|
18083
|
-
*/
|
|
18084
|
-
function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
|
|
18085
|
-
chain, deleted, requested) {
|
|
18086
|
-
if (requested.length === 0) {
|
|
18087
|
-
return [];
|
|
18088
|
-
}
|
|
18089
|
-
const positionOf = new Map();
|
|
18090
|
-
const moduleOf = new Map();
|
|
18091
|
-
chain.forEach((entry, index) => {
|
|
18092
|
-
positionOf.set(entry.patchId, index);
|
|
18093
|
-
moduleOf.set(entry.patchId, entry.path);
|
|
18094
|
-
});
|
|
18095
|
-
const doomed = new Set(deleted);
|
|
18096
|
-
return requested.filter(patchId => {
|
|
18097
|
-
// A patch being deleted anyway does not need its membership stripped
|
|
18098
|
-
// separately, and naming one is how an over-broad list hides.
|
|
18099
|
-
if (doomed.has(patchId)) return false;
|
|
18100
|
-
const position = positionOf.get(patchId);
|
|
18101
|
-
const moduleFilePath = moduleOf.get(patchId);
|
|
18102
|
-
if (position === undefined || moduleFilePath === undefined) return false;
|
|
18103
|
-
return deleted.some(deletedId => {
|
|
18104
|
-
const deletedPosition = positionOf.get(deletedId);
|
|
18105
|
-
if (deletedPosition === undefined) return false;
|
|
18106
|
-
return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
|
|
18107
|
-
});
|
|
18108
|
-
});
|
|
18109
|
-
}
|
|
18110
|
-
|
|
18111
|
-
/**
|
|
18112
|
-
* Which pending patches this caller may see, when they asked for "only mine".
|
|
18113
|
-
*
|
|
18114
|
-
* Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
|
|
18115
|
-
* both, so two answers to "whose work is this" put one person's half-finished
|
|
18116
|
-
* edit on another person's preview through whichever route was not scoped. That
|
|
18117
|
-
* is exactly what happened — `/json` applied every pending patch on the branch
|
|
18118
|
-
* while the module content beside it was scoped.
|
|
18119
|
-
*
|
|
18120
|
-
* `undefined` means "apply everything", which is what every caller that does
|
|
18121
|
-
* not ask for scoping gets and must keep getting.
|
|
18122
|
-
*/
|
|
18123
|
-
/** The patches in this author's OPEN groups: what a scoped draft shows. */
|
|
18124
|
-
function ownPatchIdsIn(patchGroups, authorId) {
|
|
18125
|
-
return patchGroups.filter(group => group.publishedAt === null && group.authorId === authorId).flatMap(group => group.patchIds);
|
|
18126
|
-
}
|
|
18127
|
-
|
|
18128
|
-
/**
|
|
18129
|
-
* Whose pending patches a scoped draft shows, taken from the SAME response as
|
|
18130
|
-
* the patches when it says, and from the list read beforehand only when not.
|
|
18131
|
-
*
|
|
18132
|
-
* See the call in `/sources/~`: the list read beforehand can be older than the
|
|
18133
|
-
* chain, and the response's membership cannot be.
|
|
18134
|
-
*/
|
|
18135
|
-
function ownPatchScopeFor(fetched, authorId, remembered) {
|
|
18136
|
-
if (fetched.patchGroups === undefined || authorId === undefined) {
|
|
18137
|
-
return remembered;
|
|
18138
|
-
}
|
|
18139
|
-
return ownPatchIdsIn(fetched.patchGroups, authorId);
|
|
18140
|
-
}
|
|
18141
|
-
async function resolveOwnPatchScope(
|
|
18142
|
-
/*
|
|
18143
|
-
* `ValOps`, not the two concrete stores: the one thing this needs is whether
|
|
18144
|
-
* a content service holds the groups, and it asks that below with an
|
|
18145
|
-
* `instanceof ValOpsHttp`. Naming the implementations here meant every new
|
|
18146
|
-
* store had to be added to a list to be allowed to call a function that does
|
|
18147
|
-
* not use it.
|
|
18148
|
-
*/
|
|
18149
|
-
serverOps, opts) {
|
|
18150
|
-
let ownPatchIds;
|
|
18151
|
-
/** See where this is set: committed work is nobody's to hold back. */
|
|
18152
|
-
let scopeAlsoIncludesApplied = false;
|
|
18153
|
-
if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
|
|
18154
|
-
if (serverOps instanceof ValOpsHttp && opts.authorId) {
|
|
18155
|
-
const groupsRes = await serverOps.getPatchGroups();
|
|
18156
|
-
if (groupsRes.status === "unsupported") {
|
|
18157
|
-
/*
|
|
18158
|
-
* A content API that PREDATES patch groups — the endpoint 404s.
|
|
18159
|
-
*
|
|
18160
|
-
* Unscoped, which is what those deployments do today and must
|
|
18161
|
-
* keep doing. Reading this as a failure and rendering base
|
|
18162
|
-
* would silently drop every pending patch from every draft
|
|
18163
|
-
* preview on every existing http project — the exact opposite
|
|
18164
|
-
* of "keeps working unchanged", and invisible to the reader.
|
|
18165
|
-
*/
|
|
18166
|
-
ownPatchIds = undefined;
|
|
18167
|
-
} else if (groupsRes.status === "error") {
|
|
18168
|
-
/*
|
|
18169
|
-
* We could not ask, and this deployment DOES have the endpoint.
|
|
18170
|
-
* Render base rather than everything: a degraded preview is
|
|
18171
|
-
* recoverable, showing another author's unpublished draft is
|
|
18172
|
-
* not, and it would be silent.
|
|
18173
|
-
*/
|
|
18174
|
-
ownPatchIds = [];
|
|
18175
|
-
} else if (groupsRes.patchGroups.length === 0) {
|
|
18176
|
-
/*
|
|
18177
|
-
* The branch has no groups AT ALL, so this deployment is not
|
|
18178
|
-
* using them — a content API that predates patch groups, or a
|
|
18179
|
-
* project where nobody has staged anything since they existed.
|
|
18180
|
-
*
|
|
18181
|
-
* Unscoped, which is the behaviour every such project has
|
|
18182
|
-
* today. Collapsing this into "your group is empty" would make
|
|
18183
|
-
* every draft render base and silently drop all pending
|
|
18184
|
-
* content, which is what happened before this branch: nothing
|
|
18185
|
-
* writes a group yet, so EVERY project is in this state right
|
|
18186
|
-
* now.
|
|
18187
|
-
*/
|
|
18188
|
-
ownPatchIds = undefined;
|
|
18189
|
-
} else {
|
|
18190
|
-
/*
|
|
18191
|
-
* Groups exist and none are this person's: they have staged
|
|
18192
|
-
* nothing, and base is the honest answer. Distinct from the
|
|
18193
|
-
* case above, which is why the two are not one expression.
|
|
18194
|
-
*/
|
|
18195
|
-
ownPatchIds = ownPatchIdsIn(groupsRes.patchGroups, opts.authorId);
|
|
18196
|
-
/*
|
|
18197
|
-
* Scoping applies to PENDING work only. Anything already
|
|
18198
|
-
* committed is part of everyone's view.
|
|
18199
|
-
*
|
|
18200
|
-
* A published patch stays in the chain with `appliedAt` set
|
|
18201
|
-
* until the next deployment moves the base, and the unscoped
|
|
18202
|
-
* path applies it. Filtering to open groups dropped it — so the
|
|
18203
|
-
* moment someone published, their own draft preview reverted
|
|
18204
|
-
* the field they had just shipped, and nobody else saw it
|
|
18205
|
-
* either until the deploy landed. Anything written on top in
|
|
18206
|
-
* that window is authored against content that is already
|
|
18207
|
-
* stale on `main`.
|
|
18208
|
-
*
|
|
18209
|
-
* Unioned by `appliedAt` rather than by pulling in groups with
|
|
18210
|
-
* a `publishedAt`, because a partial publish leaves the group
|
|
18211
|
-
* OPEN with some of its patches applied — those are committed
|
|
18212
|
-
* too, and no group flag names them.
|
|
18213
|
-
*/
|
|
18214
|
-
scopeAlsoIncludesApplied = true;
|
|
18215
|
-
}
|
|
18216
|
-
} else {
|
|
18217
|
-
/*
|
|
18218
|
-
* fs mode, or a server with no groups: there is nothing to scope
|
|
18219
|
-
* BY, and every pending patch is this one person's anyway. Left
|
|
18220
|
-
* `undefined` so the existing "apply everything" path runs.
|
|
18221
|
-
*/
|
|
18222
|
-
ownPatchIds = undefined;
|
|
18223
|
-
}
|
|
18224
|
-
}
|
|
18225
|
-
return {
|
|
18226
|
-
ownPatchIds,
|
|
18227
|
-
scopeAlsoIncludesApplied
|
|
18228
|
-
};
|
|
18229
|
-
}
|
|
18230
|
-
async function refuseUnlessOwn(ops, patchGroupId, authorId) {
|
|
18231
|
-
/*
|
|
18232
|
-
* Never from the cache. This answers "is this group yours", and a group is at
|
|
18233
|
-
* its youngest exactly when the question is asked — the first write creates
|
|
18234
|
-
* it and the shell flushes its queued stages the moment the save response
|
|
18235
|
-
* names it. A cached list fetched a few hundred milliseconds earlier does not
|
|
18236
|
-
* contain it, and every one of those stages was refused and dropped.
|
|
18237
|
-
*/
|
|
18238
|
-
const groupsRes = await ops.getPatchGroups({
|
|
18239
|
-
fresh: true
|
|
18240
|
-
});
|
|
18241
|
-
if (groupsRes.status !== "ok") {
|
|
18242
|
-
return {
|
|
18243
|
-
status: 500,
|
|
18244
|
-
message: "Could not verify that this patch group is yours, so it was not changed."
|
|
18245
|
-
};
|
|
18246
|
-
}
|
|
18247
|
-
const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
|
|
18248
|
-
if (group === undefined || group.authorId === null || group.authorId !== authorId) {
|
|
18249
|
-
/*
|
|
18250
|
-
* "Not found" and "not yours" are the SAME refusal on purpose: the route
|
|
18251
|
-
* schema has no 404, and `GET /patches` already lists every group on the
|
|
18252
|
-
* branch, so distinguishing them hides nothing and only adds a second
|
|
18253
|
-
* message to keep consistent.
|
|
18254
|
-
*
|
|
18255
|
-
* A null author is a group written by an api key or a PAT. Nobody owns it,
|
|
18256
|
-
* so nobody may stage into it — `null === null` must not read as a match.
|
|
18257
|
-
*/
|
|
18258
|
-
return {
|
|
18259
|
-
status: 403,
|
|
18260
|
-
message: "You can only change your own patch group"
|
|
18261
|
-
};
|
|
18262
|
-
}
|
|
18263
|
-
if (group.publishedAt !== null) {
|
|
18264
|
-
/*
|
|
18265
|
-
* Already shipped, so it can never be written again. The content API
|
|
18266
|
-
* answers this too; refusing here saves the round trip and keeps the
|
|
18267
|
-
* wording the same as every other refusal on this route.
|
|
18268
|
-
*/
|
|
18269
|
-
return {
|
|
18270
|
-
status: 409,
|
|
18271
|
-
message: "Patch group is already published"
|
|
18272
|
-
};
|
|
18273
|
-
}
|
|
18274
|
-
return null;
|
|
18275
|
-
}
|
|
18276
18173
|
function verifyCallbackReq(stateCookie, queryParams) {
|
|
18277
18174
|
if (typeof stateCookie !== "string") {
|
|
18278
18175
|
return {
|