@valbuild/server 0.145.0 → 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 +32 -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
|
@@ -3571,7 +3571,7 @@ const jsonOps = new patch.JSONOps();
|
|
|
3571
3571
|
* host.
|
|
3572
3572
|
*/
|
|
3573
3573
|
const JSON_ENTRY_READ_CONCURRENCY = 8;
|
|
3574
|
-
const tsOps = new TSOps(document => {
|
|
3574
|
+
const tsOps$1 = new TSOps(document => {
|
|
3575
3575
|
return fp.pipe(analyzeValModule(document), fp.result.map(({
|
|
3576
3576
|
source
|
|
3577
3577
|
}) => source));
|
|
@@ -4077,7 +4077,7 @@ class ValOps {
|
|
|
4077
4077
|
message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
|
|
4078
4078
|
};
|
|
4079
4079
|
}
|
|
4080
|
-
modulePatches =
|
|
4080
|
+
modulePatches = draftModulePatches(patchOps.patches, moduleFilePath, patchOps.commits).map(p => ({
|
|
4081
4081
|
patchId: p.patchId,
|
|
4082
4082
|
patch: p.patch
|
|
4083
4083
|
}));
|
|
@@ -5184,7 +5184,7 @@ class ValOps {
|
|
|
5184
5184
|
kind: "normal"
|
|
5185
5185
|
};
|
|
5186
5186
|
if (cls.kind === "normal" && fromCls.kind === "normal") {
|
|
5187
|
-
const patchRes = patch.applyPatch(tsSourceFile, tsOps, [op]);
|
|
5187
|
+
const patchRes = patch.applyPatch(tsSourceFile, tsOps$1, [op]);
|
|
5188
5188
|
if (fp.result.isErr(patchRes)) {
|
|
5189
5189
|
collectPatchError(patchRes.error, patchId, op);
|
|
5190
5190
|
patchHadError = true;
|
|
@@ -5623,21 +5623,8 @@ class ValOps {
|
|
|
5623
5623
|
}
|
|
5624
5624
|
|
|
5625
5625
|
// #region createPatch
|
|
5626
|
-
async createPatch(path, patch, patchId, parentRef, sessionId, authorId
|
|
5627
|
-
|
|
5628
|
-
* Which patch group this patch joins, recorded in the SAME request.
|
|
5629
|
-
*
|
|
5630
|
-
* Atomic on purpose. The content API runs every refusal before its insert,
|
|
5631
|
-
* so an invalid closure is a 400 with nothing written. Recording membership
|
|
5632
|
-
* in a second call would let a patch exist outside its author's group if
|
|
5633
|
-
* that call failed — and a patch outside your own group is one you cannot
|
|
5634
|
-
* publish until a repair puts it back.
|
|
5635
|
-
*
|
|
5636
|
-
* Optional: `fs` mode has no groups, and a client that predates them sends
|
|
5637
|
-
* nothing.
|
|
5638
|
-
*/
|
|
5639
|
-
patchGroup) {
|
|
5640
|
-
const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
|
|
5626
|
+
async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
|
|
5627
|
+
const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
|
|
5641
5628
|
if (fp.result.isErr(saveRes)) {
|
|
5642
5629
|
if (saveRes.error.errorType === "plan-refused") {
|
|
5643
5630
|
// The project's plan, not a failure: the Studio keeps the edit and
|
|
@@ -5658,11 +5645,6 @@ class ValOps {
|
|
|
5658
5645
|
return fp.result.ok({
|
|
5659
5646
|
patchId,
|
|
5660
5647
|
createdAt: new Date().toISOString(),
|
|
5661
|
-
// Spread rather than assigned: absent has to stay distinguishable from a
|
|
5662
|
-
// group whose id is undefined, all the way to the client.
|
|
5663
|
-
...(saveRes.value.patchGroupId !== undefined ? {
|
|
5664
|
-
patchGroupId: saveRes.value.patchGroupId
|
|
5665
|
-
} : {}),
|
|
5666
5648
|
...(saveRes.value.headVersion !== undefined ? {
|
|
5667
5649
|
headVersion: saveRes.value.headVersion
|
|
5668
5650
|
} : {})
|
|
@@ -5820,8 +5802,8 @@ class ValOps {
|
|
|
5820
5802
|
* Almost everything the routes branch on comes from this one fact, which is
|
|
5821
5803
|
* why it is a named property rather than an `instanceof`. If this server owns
|
|
5822
5804
|
* the store then there is no content service to authenticate to (so an absent
|
|
5823
|
-
* or unverifiable session is anonymous rather than a 401), no
|
|
5824
|
-
*
|
|
5805
|
+
* or unverifiable session is anonymous rather than a 401), no deployments to
|
|
5806
|
+
* report, and a
|
|
5825
5807
|
* "publish" writes what the host does with it rather than pushing a commit. If
|
|
5826
5808
|
* it does not, every one of those is the content service's and this server is
|
|
5827
5809
|
* relaying.
|
|
@@ -5956,20 +5938,6 @@ function isFileSource(value) {
|
|
|
5956
5938
|
return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
|
|
5957
5939
|
}
|
|
5958
5940
|
|
|
5959
|
-
/**
|
|
5960
|
-
* The patch group a newly created patch joins.
|
|
5961
|
-
*
|
|
5962
|
-
* `withPatchIds` is the CLOSURE the client computed — the patches that share
|
|
5963
|
-
* a patch set with this one and must move with it. It is not derived here and
|
|
5964
|
-
* must not be: the closure needs the content schema, and the service that
|
|
5965
|
-
* stores groups does not have it. One implementation of that rule, on the side
|
|
5966
|
-
* that can actually compute it.
|
|
5967
|
-
*
|
|
5968
|
-
* Membership rows are stamped with `coreVersion` on the content side, the same
|
|
5969
|
-
* stamp the patch row itself carries, so which client wrote a row stays legible
|
|
5970
|
-
* after the fact.
|
|
5971
|
-
*/
|
|
5972
|
-
|
|
5973
5941
|
/**
|
|
5974
5942
|
* The content API refused a write for the project's plan (a 403 that says why
|
|
5975
5943
|
* as data), as opposed to refusing the patch.
|
|
@@ -6001,34 +5969,16 @@ function formatPatchSourceError(error) {
|
|
|
6001
5969
|
*/
|
|
6002
5970
|
|
|
6003
5971
|
/**
|
|
6004
|
-
* The patches a json entry render should apply, out of the whole chain
|
|
6005
|
-
*
|
|
6006
|
-
* Three rules, and the second is the one that was missing. A draft page renders
|
|
6007
|
-
* `jsonValues` entries beside module content, and only the modules were scoped
|
|
6008
|
-
* — so one screen showed the caller's own view for its modules and base plus
|
|
6009
|
-
* EVERY pending patch on the branch for the entries beside them, including
|
|
6010
|
-
* another author's half-finished edit rendered as though it were live.
|
|
5972
|
+
* The patches a json entry render should apply, out of the whole chain:
|
|
6011
5973
|
*
|
|
6012
5974
|
* 1. this module's, since the chain is branch-wide;
|
|
6013
|
-
* 2. this
|
|
6014
|
-
*
|
|
6015
|
-
*
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
* `commits`.
|
|
6019
|
-
*
|
|
6020
|
-
* Filtered here rather than by asking `fetchPatches` for a list, and that is
|
|
6021
|
-
* load-bearing: both implementations read an empty `patchIds` as "no filter"
|
|
6022
|
-
* and return the whole chain. That is the right default for a caller that
|
|
6023
|
-
* cannot mean "none", and the most dangerous possible reading of a group that
|
|
6024
|
-
* is genuinely empty — it would render every unpublished patch on the branch
|
|
6025
|
-
* instead of base. It costs no round trip either: the whole chain is what the
|
|
6026
|
-
* unscoped path fetches anyway.
|
|
6027
|
-
*/
|
|
6028
|
-
function scopedModulePatches(patches, moduleFilePath, patchIds, /** The commits content says came after this build: see `draftOverlay`. */
|
|
5975
|
+
* 2. a patch published after this build is kept: this build does not have it.
|
|
5976
|
+
* One already in the build is dropped. `draftOverlay` decides which is
|
|
5977
|
+
* which, from `commits`.
|
|
5978
|
+
*/
|
|
5979
|
+
function draftModulePatches(patches, moduleFilePath, /** The commits content says came after this build: see `draftOverlay`. */
|
|
6029
5980
|
commits) {
|
|
6030
|
-
|
|
6031
|
-
return draftOverlay(patches.filter(patch => patch.path === moduleFilePath && (patch.appliedAt !== null || scope === undefined || scope.has(patch.patchId))), commits);
|
|
5981
|
+
return draftOverlay(patches.filter(patch => patch.path === moduleFilePath), commits);
|
|
6032
5982
|
}
|
|
6033
5983
|
|
|
6034
5984
|
/**
|
|
@@ -8120,17 +8070,7 @@ class ValOpsFS extends ValOps {
|
|
|
8120
8070
|
};
|
|
8121
8071
|
}
|
|
8122
8072
|
}
|
|
8123
|
-
async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId
|
|
8124
|
-
/*
|
|
8125
|
-
* Named and ignored, rather than omitted from the signature.
|
|
8126
|
-
*
|
|
8127
|
-
* `fs` mode has no shared store and exactly one author, so there is nothing
|
|
8128
|
-
* for a group to separate — every pending patch is already this person's.
|
|
8129
|
-
* Declaring it makes that a decision a reader can see: TypeScript lets an
|
|
8130
|
-
* implementation take fewer parameters than the abstract, so leaving it off
|
|
8131
|
-
* would drop group membership silently and look identical to handling it.
|
|
8132
|
-
*/
|
|
8133
|
-
_patchGroup) {
|
|
8073
|
+
async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
|
|
8134
8074
|
const patchesDir = this.getPatchesDir();
|
|
8135
8075
|
const record = {
|
|
8136
8076
|
patch,
|
|
@@ -9043,13 +8983,13 @@ function proposalFromEnv(env) {
|
|
|
9043
8983
|
});
|
|
9044
8984
|
}
|
|
9045
8985
|
}
|
|
9046
|
-
if (!isObject$
|
|
8986
|
+
if (!isObject$2(parsed)) {
|
|
9047
8987
|
throw new Error(`Val: proposal ${name}: VAL_OVERLAY is not an object`);
|
|
9048
8988
|
}
|
|
9049
8989
|
const modulesIn = parsed["modules"] ?? {};
|
|
9050
8990
|
const filesIn = parsed["files"] ?? {};
|
|
9051
8991
|
const commit = parsed["commit"] ?? null;
|
|
9052
|
-
if (!isObject$
|
|
8992
|
+
if (!isObject$2(modulesIn) || !isObject$2(filesIn)) {
|
|
9053
8993
|
throw new Error(`Val: proposal ${name}: VAL_OVERLAY's modules and files must be objects`);
|
|
9054
8994
|
}
|
|
9055
8995
|
if (commit !== null && typeof commit !== "string") {
|
|
@@ -9112,7 +9052,7 @@ function contentFromEnv(env) {
|
|
|
9112
9052
|
cause: err
|
|
9113
9053
|
});
|
|
9114
9054
|
}
|
|
9115
|
-
if (!isObject$
|
|
9055
|
+
if (!isObject$2(parsed) || Array.isArray(parsed)) {
|
|
9116
9056
|
throw new Error("Val: VAL_CONTENT is not an object");
|
|
9117
9057
|
}
|
|
9118
9058
|
const id = parsed["id"];
|
|
@@ -9129,7 +9069,7 @@ function contentFromEnv(env) {
|
|
|
9129
9069
|
}
|
|
9130
9070
|
const modulesIn = parsed["modules"] ?? {};
|
|
9131
9071
|
const filesIn = parsed["files"] ?? {};
|
|
9132
|
-
if (!isObject$
|
|
9072
|
+
if (!isObject$2(modulesIn) || Array.isArray(modulesIn) || !isObject$2(filesIn) || Array.isArray(filesIn)) {
|
|
9133
9073
|
throw new Error(`Val: content version ${id}: modules and files must be objects`);
|
|
9134
9074
|
}
|
|
9135
9075
|
const files = {};
|
|
@@ -9142,7 +9082,7 @@ function contentFromEnv(env) {
|
|
|
9142
9082
|
...modulesIn
|
|
9143
9083
|
};
|
|
9144
9084
|
const refs = parsed["refs"] ?? {};
|
|
9145
|
-
if (!isObject$
|
|
9085
|
+
if (!isObject$2(refs) || Array.isArray(refs)) {
|
|
9146
9086
|
throw new Error(`Val: content version ${id}: refs must be an object`);
|
|
9147
9087
|
}
|
|
9148
9088
|
const read = (kind, path, ref) => {
|
|
@@ -9169,7 +9109,7 @@ function contentFromEnv(env) {
|
|
|
9169
9109
|
};
|
|
9170
9110
|
const refModules = refs["modules"] ?? {};
|
|
9171
9111
|
const refFiles = refs["files"] ?? {};
|
|
9172
|
-
if (!isObject$
|
|
9112
|
+
if (!isObject$2(refModules) || Array.isArray(refModules) || !isObject$2(refFiles) || Array.isArray(refFiles)) {
|
|
9173
9113
|
throw new Error(`Val: content version ${id}: refs.modules and refs.files must be objects`);
|
|
9174
9114
|
}
|
|
9175
9115
|
for (const [path, ref] of Object.entries(refModules)) {
|
|
@@ -9314,7 +9254,7 @@ function proposalSnapshotForReaders(proposal, projectSource) {
|
|
|
9314
9254
|
const snapshot = proposalSnapshot(proposal);
|
|
9315
9255
|
for (const [moduleFilePath, entries] of proposalJsonEntries(proposal, projectSource)) {
|
|
9316
9256
|
const source = snapshot[moduleFilePath];
|
|
9317
|
-
if (!isObject$
|
|
9257
|
+
if (!isObject$2(source)) continue;
|
|
9318
9258
|
const filled = {};
|
|
9319
9259
|
for (const [key, value] of Object.entries(source)) {
|
|
9320
9260
|
if (isJsonValue(value)) filled[key] = value;
|
|
@@ -9332,7 +9272,7 @@ function proposalSnapshotForReaders(proposal, projectSource) {
|
|
|
9332
9272
|
|
|
9333
9273
|
/** A record of `.jsonValues()` markers (`{ _type: "json" }`), at least one. */
|
|
9334
9274
|
function holdsJsonMarkers(source) {
|
|
9335
|
-
return isObject$
|
|
9275
|
+
return isObject$2(source) && Object.values(source).some(value => isObject$2(value) && value["_type"] === "json");
|
|
9336
9276
|
}
|
|
9337
9277
|
|
|
9338
9278
|
/** Each entry key's import path, from a `.jsonValues()` module's text. */
|
|
@@ -9368,9 +9308,9 @@ function isMutableJsonValue(value) {
|
|
|
9368
9308
|
if (Array.isArray(value)) {
|
|
9369
9309
|
return value.every(isMutableJsonValue);
|
|
9370
9310
|
}
|
|
9371
|
-
return isObject$
|
|
9311
|
+
return isObject$2(value) && Object.values(value).every(isMutableJsonValue);
|
|
9372
9312
|
}
|
|
9373
|
-
function isObject$
|
|
9313
|
+
function isObject$2(value) {
|
|
9374
9314
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9375
9315
|
}
|
|
9376
9316
|
|
|
@@ -9382,267 +9322,1025 @@ function isJsonValue(value) {
|
|
|
9382
9322
|
if (Array.isArray(value)) {
|
|
9383
9323
|
return value.every(isJsonValue);
|
|
9384
9324
|
}
|
|
9385
|
-
return isObject$
|
|
9325
|
+
return isObject$2(value) && Object.values(value).every(isJsonValue);
|
|
9386
9326
|
}
|
|
9387
9327
|
|
|
9388
|
-
|
|
9328
|
+
/** What a schema reports about this content: see `schemaErrorsOf`. */
|
|
9329
|
+
const validateWith = (schema, moduleFilePath, source) => internal.schemaErrorsOf(schema, moduleFilePath, source);
|
|
9389
9330
|
|
|
9390
|
-
/** The body of the content API's 403 for a write the project's plan refuses. */
|
|
9391
|
-
const PlanRefusedBody = z.z.object({
|
|
9392
|
-
error: internal.PlanRefusalError
|
|
9393
|
-
});
|
|
9394
|
-
const PatchId = z.z.string().refine(s => !!s); // TODO: validate
|
|
9395
|
-
const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
|
|
9396
|
-
z.z.string().refine(s => !!s); // TODO: validate
|
|
9397
|
-
z.z.string().refine(s => !!s); // TODO: validate
|
|
9398
|
-
const ModuleFilePath = z.z.string().refine(s => !!s); // TODO: validate
|
|
9399
|
-
const Metadata = z.z.union([z.z.object({
|
|
9400
|
-
mimeType: z.z.string(),
|
|
9401
|
-
width: z.z.number(),
|
|
9402
|
-
height: z.z.number()
|
|
9403
|
-
}), z.z.object({
|
|
9404
|
-
mimeType: z.z.string()
|
|
9405
|
-
})]);
|
|
9406
|
-
const MetadataRes = z.z.object({
|
|
9407
|
-
filePath: ModuleFilePath,
|
|
9408
|
-
metadata: Metadata,
|
|
9409
|
-
type: z.z.union([z.z.literal("file"), z.z.literal("image")]).nullable()
|
|
9410
|
-
});
|
|
9411
|
-
const GetApplicablePatches = z.z.object({
|
|
9412
|
-
patches: z.z.array(z.z.object({
|
|
9413
|
-
path: z.z.string(),
|
|
9414
|
-
patch: internal.Patch.nullable(),
|
|
9415
|
-
patchId: z.z.string(),
|
|
9416
|
-
authorId: z.z.string().nullable(),
|
|
9417
|
-
baseSha: z.z.string(),
|
|
9418
|
-
createdAt: z.z.string(),
|
|
9419
|
-
applied: z.z.object({
|
|
9420
|
-
commitSha: z.z.string()
|
|
9421
|
-
}).nullable(),
|
|
9422
|
-
/**
|
|
9423
|
-
* The groups this patch is a member of. Read in the SAME transaction as
|
|
9424
|
-
* the list and its `headVersion`, which is the point: see
|
|
9425
|
-
* `OrderedPatches.patchGroups`. Optional for an older content service.
|
|
9426
|
-
*/
|
|
9427
|
-
patchGroupIds: z.z.array(z.z.string()).optional()
|
|
9428
|
-
})),
|
|
9429
|
-
/**
|
|
9430
|
-
* The groups on the branch, without their members — those are on the
|
|
9431
|
-
* patches above. Optional for a content service that predates groups.
|
|
9432
|
-
*/
|
|
9433
|
-
patchGroups: z.z.array(z.z.object({
|
|
9434
|
-
patchGroupId: z.z.string(),
|
|
9435
|
-
authorId: z.z.string().nullable(),
|
|
9436
|
-
createdAt: z.z.string(),
|
|
9437
|
-
publishedAt: z.z.string().nullable()
|
|
9438
|
-
})).optional(),
|
|
9439
|
-
/**
|
|
9440
|
-
* The head of the chain. See `OrderedPatches.headPatchId`. Optional because
|
|
9441
|
-
* a content service that predates it sends nothing.
|
|
9442
|
-
*/
|
|
9443
|
-
headPatchId: internal.PatchId.nullable().optional(),
|
|
9444
|
-
/**
|
|
9445
|
-
* The chain version of that head. See `OrderedPatches.headVersion`.
|
|
9446
|
-
* Optional for the same reason.
|
|
9447
|
-
*/
|
|
9448
|
-
headVersion: z.z.number().optional(),
|
|
9449
|
-
/** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
|
|
9450
|
-
publishingPatches: z.z.array(internal.PatchId).optional(),
|
|
9451
|
-
/** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
|
|
9452
|
-
heldBackPatches: z.z.array(internal.PatchId).optional(),
|
|
9453
|
-
commits: z.z.array(z.z.object({
|
|
9454
|
-
commitSha: z.z.string(),
|
|
9455
|
-
/*
|
|
9456
|
-
* Nullable since the content service mints its own commit shas: a
|
|
9457
|
-
* root commit has no parent, and a publisher that did not say where
|
|
9458
|
-
* it was has no client sha. Parsed rather than trusted, so a service
|
|
9459
|
-
* that sends null gets null here instead of failing the whole poll.
|
|
9460
|
-
*/
|
|
9461
|
-
clientCommitSha: z.z.string().nullable(),
|
|
9462
|
-
parentCommitSha: z.z.string().nullable(),
|
|
9463
|
-
commitMessage: z.z.string().nullable(),
|
|
9464
|
-
branch: z.z.string(),
|
|
9465
|
-
creator: z.z.string(),
|
|
9466
|
-
createdAt: z.z.string()
|
|
9467
|
-
})).optional(),
|
|
9468
|
-
deployments: z.z.array(z.z.object({
|
|
9469
|
-
deploymentId: z.z.string(),
|
|
9470
|
-
commitSha: z.z.string(),
|
|
9471
|
-
deploymentState: z.z.string(),
|
|
9472
|
-
createdAt: z.z.string(),
|
|
9473
|
-
updatedAt: z.z.string(),
|
|
9474
|
-
// The git message, where the content service knows it. See
|
|
9475
|
-
// `ValDeployment`: this is the only source for a deployment that Val
|
|
9476
|
-
// did not publish, and zod would otherwise strip it here.
|
|
9477
|
-
commitMessage: z.z.string().nullable().optional()
|
|
9478
|
-
})).optional(),
|
|
9479
|
-
/**
|
|
9480
|
-
* What the project EXPECTS of whoever publishes it.
|
|
9481
|
-
*
|
|
9482
|
-
* Optional because a content service that predates it sends nothing, and
|
|
9483
|
-
* absent means "not reported" -- never "managed". The difference decides
|
|
9484
|
-
* whether a publish is refused, so guessing either way would be worse than
|
|
9485
|
-
* not checking: guessing `managed` would let a deployment publish a
|
|
9486
|
-
* connected project it cannot mirror, and guessing `connected` would refuse
|
|
9487
|
-
* every publish against an older service.
|
|
9488
|
-
*/
|
|
9489
|
-
project: z.z.object({
|
|
9490
|
-
sourceMode: z.z.union([z.z.literal("managed"), z.z.literal("connected")]),
|
|
9491
|
-
branch: z.z.string(),
|
|
9492
|
-
/**
|
|
9493
|
-
* Whether a press of Publish is a publish JOB. Absent from a content
|
|
9494
|
-
* service that predates connected jobs: then managed projects publish
|
|
9495
|
-
* as jobs, and connected ones by commit, as they did.
|
|
9496
|
-
*/
|
|
9497
|
-
publishJobs: z.z.boolean().optional()
|
|
9498
|
-
}).optional()
|
|
9499
|
-
});
|
|
9500
|
-
const FilesResponse = z.z.object({
|
|
9501
|
-
files: z.z.array(z.z.union([z.z.object({
|
|
9502
|
-
filePath: z.z.string(),
|
|
9503
|
-
location: z.z.literal("patch"),
|
|
9504
|
-
patchId: PatchId,
|
|
9505
|
-
value: z.z.string(),
|
|
9506
|
-
remote: z.z.boolean()
|
|
9507
|
-
}), z.z.object({
|
|
9508
|
-
filePath: z.z.string(),
|
|
9509
|
-
location: z.z.literal("repo"),
|
|
9510
|
-
commitSha: CommitSha,
|
|
9511
|
-
value: z.z.string()
|
|
9512
|
-
})])),
|
|
9513
|
-
errors: z.z.array(z.z.union([z.z.object({
|
|
9514
|
-
filePath: z.z.string(),
|
|
9515
|
-
location: z.z.literal("patch"),
|
|
9516
|
-
patchId: PatchId,
|
|
9517
|
-
message: z.z.string(),
|
|
9518
|
-
remote: z.z.boolean()
|
|
9519
|
-
}), z.z.object({
|
|
9520
|
-
filePath: z.z.string(),
|
|
9521
|
-
location: z.z.literal("repo"),
|
|
9522
|
-
commitSha: CommitSha,
|
|
9523
|
-
message: z.z.string()
|
|
9524
|
-
})])).optional()
|
|
9525
|
-
});
|
|
9526
|
-
const SavePatchResponse = z.z.object({
|
|
9527
|
-
patchId: PatchId,
|
|
9528
|
-
/**
|
|
9529
|
-
* Which group the content API put this patch in.
|
|
9530
|
-
*
|
|
9531
|
-
* Optional: a content API that predates patch groups does not send one, and
|
|
9532
|
-
* absence has to keep meaning "no groups here" rather than failing the save.
|
|
9533
|
-
*/
|
|
9534
|
-
patchGroupId: z.z.string().optional(),
|
|
9535
|
-
/**
|
|
9536
|
-
* The chain version this write made, comparable with `headVersion` on the
|
|
9537
|
-
* listing. Optional: a content service that predates it sends nothing.
|
|
9538
|
-
*/
|
|
9539
|
-
headVersion: z.z.number().optional()
|
|
9540
|
-
});
|
|
9541
|
-
const DeletePatchesResponse = z.z.object({
|
|
9542
|
-
deleted: z.z.array(PatchId),
|
|
9543
|
-
errors: z.z.array(z.z.object({
|
|
9544
|
-
message: z.z.string(),
|
|
9545
|
-
patchId: PatchId,
|
|
9546
|
-
/** The publish job holding it, when that is why it was not deleted. */
|
|
9547
|
-
publishJobId: z.z.string().optional()
|
|
9548
|
-
})).optional(),
|
|
9549
|
-
/**
|
|
9550
|
-
* The chain version each branch the delete changed is now at, keyed by
|
|
9551
|
-
* branch. Optional: a content service that predates it sends none.
|
|
9552
|
-
*/
|
|
9553
|
-
headVersions: z.z.record(z.z.string(), z.z.number()).optional()
|
|
9554
|
-
});
|
|
9555
9331
|
/**
|
|
9556
|
-
*
|
|
9557
|
-
*
|
|
9558
|
-
*
|
|
9559
|
-
*
|
|
9560
|
-
* it), the only entry is ours when there is exactly one; with several, none can
|
|
9561
|
-
* be picked, and a wrong one would floor this client at another chain's count.
|
|
9332
|
+
* A module's errors for this content: its own `validate` where the schema was
|
|
9333
|
+
* evaluated, custom rules and all; otherwise what its serialized schema says,
|
|
9334
|
+
* which is every rule but a custom `.validate(...)` (a closure does not
|
|
9335
|
+
* serialize).
|
|
9562
9336
|
*/
|
|
9563
|
-
function
|
|
9564
|
-
if (
|
|
9565
|
-
|
|
9566
|
-
|
|
9567
|
-
|
|
9337
|
+
function validateModule(moduleFilePath, module, source) {
|
|
9338
|
+
if (module.validate) return module.validate(source);
|
|
9339
|
+
return validateWith(core.deserializeSchema(module.schema), moduleFilePath, source);
|
|
9340
|
+
}
|
|
9341
|
+
|
|
9342
|
+
/** The errors of a module's own Source: as evaluated where they were, else found here. */
|
|
9343
|
+
function errorsOfSource(moduleFilePath, module) {
|
|
9344
|
+
return module.errors ?? validateModule(moduleFilePath, module, module.source);
|
|
9568
9345
|
}
|
|
9569
|
-
const SavePatchFileResponse = z.z.object({
|
|
9570
|
-
patchId: PatchId,
|
|
9571
|
-
filePath: ModuleFilePath
|
|
9572
|
-
});
|
|
9573
|
-
const CommitResponse = z.z.object({
|
|
9574
|
-
updatedFiles: z.z.array(z.z.string()),
|
|
9575
|
-
commit: CommitSha,
|
|
9576
|
-
/*
|
|
9577
|
-
* Optional because a content service that predates them sends neither, and
|
|
9578
|
-
* this client talks to whichever one the project is on. Absent means NOT
|
|
9579
|
-
* REPORTED: `parent: undefined` is not "a root commit", and a caller that
|
|
9580
|
-
* reads it as one would conclude the history starts here.
|
|
9581
|
-
*
|
|
9582
|
-
* Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
|
|
9583
|
-
* from a service that versions separately and are passed through as what it
|
|
9584
|
-
* said, not as something this end vouched for.
|
|
9585
|
-
*/
|
|
9586
|
-
parent: z.z.string().optional(),
|
|
9587
|
-
tree: z.z.string().optional(),
|
|
9588
|
-
branch: z.z.string(),
|
|
9589
|
-
/** The chain version the publish moved the branch to; absent if it applied nothing. */
|
|
9590
|
-
headVersion: z.z.number().optional()
|
|
9591
|
-
});
|
|
9592
|
-
// #region history wire schemas
|
|
9593
|
-
//
|
|
9594
|
-
// Validated on arrival rather than trusted: these come from a service that
|
|
9595
|
-
// versions separately, and a silently mis-shaped commit record reads as "this
|
|
9596
|
-
// commit changed nothing", which is indistinguishable from a real answer.
|
|
9597
|
-
const HistoricalCommitResponse = z.z.object({
|
|
9598
|
-
commitSha: z.z.string(),
|
|
9599
|
-
/** `null` for a root commit; see the note on the applicable-patches schema. */
|
|
9600
|
-
parentCommitSha: z.z.string().nullable(),
|
|
9601
|
-
/** `null` when the publisher did not say where it was. */
|
|
9602
|
-
clientCommitSha: z.z.string().nullable(),
|
|
9603
|
-
branch: z.z.string(),
|
|
9604
|
-
createdBranch: z.z.string().nullable(),
|
|
9605
|
-
creator: z.z.string().nullable(),
|
|
9606
|
-
message: z.z.string().nullable(),
|
|
9607
|
-
createdAt: z.z.string(),
|
|
9608
|
-
seqNum: z.z.string(),
|
|
9609
|
-
patchCount: z.z.number(),
|
|
9610
|
-
hasArchive: z.z.boolean()
|
|
9611
|
-
});
|
|
9612
|
-
const ListCommitsResponse = z.z.object({
|
|
9613
|
-
commits: z.z.array(HistoricalCommitResponse),
|
|
9614
|
-
nextCursor: z.z.string().nullable()
|
|
9615
|
-
});
|
|
9616
|
-
const CommitPatchesResponse = z.z.object({
|
|
9617
|
-
commitSha: z.z.string(),
|
|
9618
|
-
commit: z.z.object({
|
|
9619
|
-
commitSha: z.z.string(),
|
|
9620
|
-
parentCommitSha: z.z.string().nullable(),
|
|
9621
|
-
clientCommitSha: z.z.string().nullable(),
|
|
9622
|
-
branch: z.z.string(),
|
|
9623
|
-
createdBranch: z.z.string().nullable(),
|
|
9624
|
-
creator: z.z.string().nullable(),
|
|
9625
|
-
message: z.z.string().nullable(),
|
|
9626
|
-
createdAt: z.z.string(),
|
|
9627
|
-
seqNum: z.z.string(),
|
|
9628
|
-
hasArchive: z.z.boolean()
|
|
9629
|
-
}),
|
|
9630
|
-
patches: z.z.array(z.z.object({
|
|
9631
|
-
patchId: z.z.string(),
|
|
9632
|
-
path: z.z.string(),
|
|
9633
|
-
patch: z.z.unknown(),
|
|
9634
|
-
authorId: z.z.string().nullable(),
|
|
9635
|
-
createdAt: z.z.string(),
|
|
9636
|
-
baseSha: z.z.string(),
|
|
9637
|
-
coreVersion: z.z.string()
|
|
9638
|
-
}))
|
|
9639
|
-
});
|
|
9640
9346
|
|
|
9641
9347
|
/**
|
|
9642
|
-
* `
|
|
9643
|
-
*
|
|
9644
|
-
|
|
9645
|
-
|
|
9348
|
+
* A `.jsonValues()` record's entry files, by key, read out of its `.val.ts`
|
|
9349
|
+
* (the loaded marker does not carry the path). `null` when it has none.
|
|
9350
|
+
*/
|
|
9351
|
+
function entryFilesOf(moduleFilePath, text) {
|
|
9352
|
+
const analysis = valModuleOf(moduleFilePath, text);
|
|
9353
|
+
if (analysis === null) return null;
|
|
9354
|
+
const entries = analyzeJsonValuesEntries(analysis.source);
|
|
9355
|
+
if (entries.size === 0) return null;
|
|
9356
|
+
const dir = path__namespace["default"].posix.dirname(moduleFilePath.slice(1));
|
|
9357
|
+
return Object.fromEntries([...entries].map(([key, {
|
|
9358
|
+
importPath
|
|
9359
|
+
}]) => [key, path__namespace["default"].posix.join(dir, importPath)]));
|
|
9360
|
+
}
|
|
9361
|
+
|
|
9362
|
+
// --- code and content ------------------------------------------------------------
|
|
9363
|
+
|
|
9364
|
+
/**
|
|
9365
|
+
* A file's code: for a `.val.ts`, everything but `c.define`'s content
|
|
9366
|
+
* argument, which Val reads at request time (step 2) and so is no part of a
|
|
9367
|
+
* build; a `.val.json` entry is no code at all. Every other file is code
|
|
9368
|
+
* whole.
|
|
9369
|
+
*/
|
|
9370
|
+
function codeOf(file, text) {
|
|
9371
|
+
// A `.jsonValues()` entry is content, read at request time like the rest.
|
|
9372
|
+
if (file.endsWith(".val.json")) return "";
|
|
9373
|
+
if (!file.endsWith(".val.ts")) return text;
|
|
9374
|
+
const analysis = valModuleOf(file, text);
|
|
9375
|
+
if (analysis === null) return text;
|
|
9376
|
+
const {
|
|
9377
|
+
source
|
|
9378
|
+
} = analysis;
|
|
9379
|
+
return text.slice(0, source.getStart()) + "/* content */" + text.slice(source.end);
|
|
9380
|
+
}
|
|
9381
|
+
|
|
9382
|
+
/**
|
|
9383
|
+
* A `.val.ts`'s `c.define(...)`, or `null` when it has none: a file of schemas
|
|
9384
|
+
* that pages share (`export const heroSchema = s.object(...)`) is named like a
|
|
9385
|
+
* module and is code. `analyzeValModule` throws for one; this does not.
|
|
9386
|
+
*/
|
|
9387
|
+
function valModuleOf(file, text) {
|
|
9388
|
+
const sourceFile = ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2015, true);
|
|
9389
|
+
if (!sourceFile.statements.some(node => ts__default["default"].isExportAssignment(node))) {
|
|
9390
|
+
return null;
|
|
9391
|
+
}
|
|
9392
|
+
const analysis = analyzeValModule(sourceFile);
|
|
9393
|
+
return fp.result.isErr(analysis) ? null : analysis.value;
|
|
9394
|
+
}
|
|
9395
|
+
|
|
9396
|
+
/**
|
|
9397
|
+
* The three-way merge of a module's content: where both sides started
|
|
9398
|
+
* (`base`), and each side's version. Pure JSON -- the schema decides nothing
|
|
9399
|
+
* here, and is checked afterwards (the schema check in `merge.ts`).
|
|
9400
|
+
*
|
|
9401
|
+
* The rules (valbuild/home `docs/hosted-publishing.md` step 8.2):
|
|
9402
|
+
*
|
|
9403
|
+
* - Changed on one side: that side's.
|
|
9404
|
+
* - Changed on both: a conflict -- even to the same value, which is an easy
|
|
9405
|
+
* conflict to resolve but still one somebody decides.
|
|
9406
|
+
* - An object (a page, a record) merges key by key, so two keys changed on
|
|
9407
|
+
* two sides combine.
|
|
9408
|
+
* - A list merges item by item only when neither side changed its shape: no
|
|
9409
|
+
* item added, removed or moved. Otherwise positions mean different items on
|
|
9410
|
+
* the two sides, and the whole list is the conflict.
|
|
9411
|
+
*/
|
|
9412
|
+
|
|
9413
|
+
function isObject$1(value) {
|
|
9414
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9415
|
+
}
|
|
9416
|
+
function same(a, b) {
|
|
9417
|
+
if (a === undefined || b === undefined) return a === b;
|
|
9418
|
+
return patch.deepEqual(a, b);
|
|
9419
|
+
}
|
|
9420
|
+
|
|
9421
|
+
/**
|
|
9422
|
+
* Did this side add, remove or move an item? Lengths differ, or an item now
|
|
9423
|
+
* sits where it was not, while it is still what used to be somewhere else.
|
|
9424
|
+
*/
|
|
9425
|
+
function reshaped(base, side) {
|
|
9426
|
+
if (base.length !== side.length) return true;
|
|
9427
|
+
return side.some((item, at) => !same(item, base[at]) && base.some((was, from) => from !== at && same(item, was)));
|
|
9428
|
+
}
|
|
9429
|
+
|
|
9430
|
+
/**
|
|
9431
|
+
* Where a value is one value, though it is an object: a media value (its path
|
|
9432
|
+
* and what describes that file) or rich text. The schema says; see `merge.ts`.
|
|
9433
|
+
*/
|
|
9434
|
+
|
|
9435
|
+
function mergeValues(base, site, proposal, atomic = () => false, path = []) {
|
|
9436
|
+
if (same(site, base)) return {
|
|
9437
|
+
value: proposal,
|
|
9438
|
+
conflicts: []
|
|
9439
|
+
};
|
|
9440
|
+
if (same(proposal, base)) return {
|
|
9441
|
+
value: site,
|
|
9442
|
+
conflicts: []
|
|
9443
|
+
};
|
|
9444
|
+
if (atomic(path)) {
|
|
9445
|
+
return {
|
|
9446
|
+
value: site,
|
|
9447
|
+
conflicts: [{
|
|
9448
|
+
path,
|
|
9449
|
+
base,
|
|
9450
|
+
site,
|
|
9451
|
+
proposal
|
|
9452
|
+
}]
|
|
9453
|
+
};
|
|
9454
|
+
}
|
|
9455
|
+
if (isObject$1(base) && isObject$1(site) && isObject$1(proposal)) {
|
|
9456
|
+
const keys = [...new Set([...Object.keys(site), ...Object.keys(proposal), ...Object.keys(base)])];
|
|
9457
|
+
const value = {};
|
|
9458
|
+
const conflicts = [];
|
|
9459
|
+
for (const key of keys) {
|
|
9460
|
+
const merged = mergeValues(base[key], site[key], proposal[key], atomic, [...path, key]);
|
|
9461
|
+
if (merged.value !== undefined) value[key] = merged.value;
|
|
9462
|
+
conflicts.push(...merged.conflicts);
|
|
9463
|
+
}
|
|
9464
|
+
return {
|
|
9465
|
+
value,
|
|
9466
|
+
conflicts
|
|
9467
|
+
};
|
|
9468
|
+
}
|
|
9469
|
+
if (Array.isArray(base) && Array.isArray(site) && Array.isArray(proposal) && !reshaped(base, site) && !reshaped(base, proposal)) {
|
|
9470
|
+
const value = [];
|
|
9471
|
+
const conflicts = [];
|
|
9472
|
+
base.forEach((item, at) => {
|
|
9473
|
+
const merged = mergeValues(item, site[at], proposal[at], atomic, [...path, String(at)]);
|
|
9474
|
+
// Neither side removed an item, so every position holds one.
|
|
9475
|
+
value.push(merged.value ?? null);
|
|
9476
|
+
conflicts.push(...merged.conflicts);
|
|
9477
|
+
});
|
|
9478
|
+
return {
|
|
9479
|
+
value,
|
|
9480
|
+
conflicts
|
|
9481
|
+
};
|
|
9482
|
+
}
|
|
9483
|
+
return {
|
|
9484
|
+
value: site,
|
|
9485
|
+
conflicts: [{
|
|
9486
|
+
path,
|
|
9487
|
+
base,
|
|
9488
|
+
site,
|
|
9489
|
+
proposal
|
|
9490
|
+
}]
|
|
9491
|
+
};
|
|
9492
|
+
}
|
|
9493
|
+
|
|
9494
|
+
/** `doc` with `value` at `path`; `undefined` removes it. `doc` is not changed. */
|
|
9495
|
+
function setAt(doc, path, value) {
|
|
9496
|
+
if (path.length === 0) return value;
|
|
9497
|
+
const [head, ...rest] = path;
|
|
9498
|
+
if (Array.isArray(doc)) {
|
|
9499
|
+
const copy = [...doc];
|
|
9500
|
+
const at = Number(head);
|
|
9501
|
+
const next = setAt(copy[at], rest, value);
|
|
9502
|
+
if (next === undefined) copy.splice(at, 1);else copy[at] = next;
|
|
9503
|
+
return copy;
|
|
9504
|
+
}
|
|
9505
|
+
const copy = isObject$1(doc) ? {
|
|
9506
|
+
...doc
|
|
9507
|
+
} : {};
|
|
9508
|
+
const next = setAt(copy[head], rest, value);
|
|
9509
|
+
if (next === undefined) delete copy[head];else copy[head] = next;
|
|
9510
|
+
return copy;
|
|
9511
|
+
}
|
|
9512
|
+
function valueAt(doc, path) {
|
|
9513
|
+
let current = doc;
|
|
9514
|
+
for (const key of path) {
|
|
9515
|
+
if (Array.isArray(current)) current = current[Number(key)];else if (isObject$1(current)) current = current[key];else return undefined;
|
|
9516
|
+
}
|
|
9517
|
+
return current;
|
|
9518
|
+
}
|
|
9519
|
+
|
|
9520
|
+
/**
|
|
9521
|
+
* The Val patch that turns `from` into `to`, as small as it can be: what
|
|
9522
|
+
* writes a merge into a `.val.ts` without rewriting what did not change.
|
|
9523
|
+
*/
|
|
9524
|
+
function diffOps(from, to, path = []) {
|
|
9525
|
+
if (same(from, to)) return [];
|
|
9526
|
+
if (isObject$1(from) && isObject$1(to)) {
|
|
9527
|
+
const ops = [];
|
|
9528
|
+
for (const key of Object.keys(from)) {
|
|
9529
|
+
if (!(key in to)) ops.push({
|
|
9530
|
+
op: "remove",
|
|
9531
|
+
path: below(path, key)
|
|
9532
|
+
});
|
|
9533
|
+
}
|
|
9534
|
+
for (const [key, value] of Object.entries(to)) {
|
|
9535
|
+
ops.push(...(key in from ? diffOps(from[key], value, [...path, key]) : [add([...path, key], value)]));
|
|
9536
|
+
}
|
|
9537
|
+
return ops;
|
|
9538
|
+
}
|
|
9539
|
+
if (Array.isArray(from) && Array.isArray(to) && from.length === to.length) {
|
|
9540
|
+
return from.flatMap((item, at) => diffOps(item, to[at], [...path, String(at)]));
|
|
9541
|
+
}
|
|
9542
|
+
return [{
|
|
9543
|
+
op: "replace",
|
|
9544
|
+
path,
|
|
9545
|
+
value: to ?? null
|
|
9546
|
+
}];
|
|
9547
|
+
}
|
|
9548
|
+
function add(path, value) {
|
|
9549
|
+
return {
|
|
9550
|
+
op: "add",
|
|
9551
|
+
path,
|
|
9552
|
+
value
|
|
9553
|
+
};
|
|
9554
|
+
}
|
|
9555
|
+
function below(path, key) {
|
|
9556
|
+
return path.length === 0 ? [key] : [path[0], ...path.slice(1), key];
|
|
9557
|
+
}
|
|
9558
|
+
|
|
9559
|
+
/**
|
|
9560
|
+
* Publishing a proposal as a pull request is merged: valbuild/home
|
|
9561
|
+
* `docs/hosted-publishing.md`, step 8.2.
|
|
9562
|
+
*
|
|
9563
|
+
* Val's half: the two answers that need a schema. Content gets them as files
|
|
9564
|
+
* and change ids and carries them out, never looking at a field, a list or a
|
|
9565
|
+
* patch set.
|
|
9566
|
+
*
|
|
9567
|
+
* - {@link mergeFromSite} -- Update from the site: the merge commit `M`.
|
|
9568
|
+
* - {@link changesToMove} -- which of the site's pending changes leave the
|
|
9569
|
+
* site when a proposal is merged.
|
|
9570
|
+
*
|
|
9571
|
+
* `mergeScenarios.test.ts` is their specification.
|
|
9572
|
+
*/
|
|
9573
|
+
|
|
9574
|
+
/** A field both sides changed since the proposal started. */
|
|
9575
|
+
|
|
9576
|
+
/**
|
|
9577
|
+
* Code both sides changed: a component, `val.modules.ts`, or the schema of a
|
|
9578
|
+
* `.val.ts` (its code -- everything but `c.define`'s content argument, whose
|
|
9579
|
+
* fields merge on their own).
|
|
9580
|
+
*/
|
|
9581
|
+
|
|
9582
|
+
/**
|
|
9583
|
+
* The schema check: content that merged cleanly and is not valid under the
|
|
9584
|
+
* schema the merge ends with -- an entry the site added, say, under a field
|
|
9585
|
+
* whose type the proposal changed.
|
|
9586
|
+
*/
|
|
9587
|
+
|
|
9588
|
+
/** How a conflict is resolved: keep one side, or write it -- as AI or a developer does. */
|
|
9589
|
+
|
|
9590
|
+
/**
|
|
9591
|
+
* Update from the site: a three-way merge of where the proposal started
|
|
9592
|
+
* (`base`), the site's head and the proposal's last save, written whole.
|
|
9593
|
+
* Every conflict needs a resolution, or nothing is merged.
|
|
9594
|
+
*
|
|
9595
|
+
* File by file. A file that is not a `.val.ts` is code, and merges as a
|
|
9596
|
+
* file: changed on one side, that side's; on both, a conflict. A `.val.ts`
|
|
9597
|
+
* is code (everything but `c.define`'s content argument) and content, and the
|
|
9598
|
+
* two merge apart: the code as a file, the content field by field
|
|
9599
|
+
* (`mergeValues`). So a schema the proposal changed and a field the site
|
|
9600
|
+
* published in the same file do not conflict. A code conflict in a `.val.ts`
|
|
9601
|
+
* is resolved by keeping a side's code -- the content still merges -- or by
|
|
9602
|
+
* writing the whole file.
|
|
9603
|
+
*
|
|
9604
|
+
* Not here: the schema check ({@link schemaCheck}). It needs the merged
|
|
9605
|
+
* files' schemas, so whoever can evaluate them -- Val's loader, the builder
|
|
9606
|
+
* tab -- runs it on `files` before the merge is committed.
|
|
9607
|
+
*/
|
|
9608
|
+
function mergeFromSite(input) {
|
|
9609
|
+
const {
|
|
9610
|
+
base,
|
|
9611
|
+
site,
|
|
9612
|
+
proposal,
|
|
9613
|
+
resolutions
|
|
9614
|
+
} = input;
|
|
9615
|
+
const files = {};
|
|
9616
|
+
const modules = {};
|
|
9617
|
+
const conflicts = [];
|
|
9618
|
+
const paths = [...new Set([...Object.keys(base.files), ...Object.keys(site.files), ...Object.keys(proposal.files)])].sort();
|
|
9619
|
+
// A `.jsonValues()` entry is written with its module, never on its own.
|
|
9620
|
+
const entryFiles = new Set([base, site, proposal].flatMap(side => Object.values(side.modules).flatMap(module => Object.values(module.entries ?? {}))));
|
|
9621
|
+
for (const file of paths) {
|
|
9622
|
+
var _moduleOf3, _moduleOf4, _moduleOf5, _moduleOf6;
|
|
9623
|
+
if (entryFiles.has(file)) continue;
|
|
9624
|
+
const b = base.files[file] ?? null;
|
|
9625
|
+
const s = site.files[file] ?? null;
|
|
9626
|
+
const p = proposal.files[file] ?? null;
|
|
9627
|
+
const resolution = fileResolution(resolutions, file);
|
|
9628
|
+
const codeConflict = () => ({
|
|
9629
|
+
kind: "code",
|
|
9630
|
+
file,
|
|
9631
|
+
base: b,
|
|
9632
|
+
site: s,
|
|
9633
|
+
proposal: p
|
|
9634
|
+
});
|
|
9635
|
+
|
|
9636
|
+
// A file named like a module that no side defines as one -- schemas the
|
|
9637
|
+
// pages share -- is code.
|
|
9638
|
+
const moduleFilePath = `/${file}`;
|
|
9639
|
+
const isModule = [base, site, proposal].some(side => side.modules[moduleFilePath] !== undefined);
|
|
9640
|
+
if (!file.endsWith(".val.ts") || !isModule) {
|
|
9641
|
+
if (s === b) files[file] = p;else if (p === b) files[file] = s;else if (resolution) files[file] = resolvedText(resolution, s, p);else {
|
|
9642
|
+
conflicts.push(codeConflict());
|
|
9643
|
+
files[file] = s;
|
|
9644
|
+
}
|
|
9645
|
+
continue;
|
|
9646
|
+
}
|
|
9647
|
+
|
|
9648
|
+
// A .val.ts: whose code, then the content under it.
|
|
9649
|
+
const code = text => text === null ? null : codeOf(file, text);
|
|
9650
|
+
let side;
|
|
9651
|
+
if (code(s) === code(b)) side = "proposal";else if (code(p) === code(b)) side = "site";else if (resolution && "text" in resolution) {
|
|
9652
|
+
files[file] = resolution.text;
|
|
9653
|
+
continue;
|
|
9654
|
+
} else if (resolution) side = resolution.keep;else {
|
|
9655
|
+
conflicts.push(codeConflict());
|
|
9656
|
+
files[file] = s;
|
|
9657
|
+
continue;
|
|
9658
|
+
}
|
|
9659
|
+
const moduleOf = project => project.modules[moduleFilePath];
|
|
9660
|
+
const sourceOf = project => {
|
|
9661
|
+
var _moduleOf;
|
|
9662
|
+
return (_moduleOf = moduleOf(project)) === null || _moduleOf === void 0 ? void 0 : _moduleOf.source;
|
|
9663
|
+
};
|
|
9664
|
+
const chosen = side === "site" ? site : proposal;
|
|
9665
|
+
const text = side === "site" ? s : p;
|
|
9666
|
+
if (text === null) {
|
|
9667
|
+
// That side deleted the file. Content the other side changed would go
|
|
9668
|
+
// with it: that is for a person to decide -- unless they have.
|
|
9669
|
+
const other = side === "site" ? p : s;
|
|
9670
|
+
if (!resolution && other !== b) {
|
|
9671
|
+
conflicts.push(codeConflict());
|
|
9672
|
+
}
|
|
9673
|
+
files[file] = null;
|
|
9674
|
+
for (const project of [base, site, proposal]) {
|
|
9675
|
+
for (const entry of Object.values(((_moduleOf2 = moduleOf(project)) === null || _moduleOf2 === void 0 ? void 0 : _moduleOf2.entries) ?? {})) {
|
|
9676
|
+
var _moduleOf2;
|
|
9677
|
+
files[entry] = null;
|
|
9678
|
+
}
|
|
9679
|
+
}
|
|
9680
|
+
continue;
|
|
9681
|
+
}
|
|
9682
|
+
// What the chosen text holds, which the merge is written over.
|
|
9683
|
+
const from = ((_moduleOf3 = moduleOf(chosen)) === null || _moduleOf3 === void 0 ? void 0 : _moduleOf3.written) ?? ((_moduleOf4 = moduleOf(chosen)) === null || _moduleOf4 === void 0 ? void 0 : _moduleOf4.source);
|
|
9684
|
+
const merged = mergeValues(sourceOf(base), sourceOf(site), sourceOf(proposal), atomicFor((_moduleOf5 = moduleOf(chosen)) === null || _moduleOf5 === void 0 ? void 0 : _moduleOf5.schema));
|
|
9685
|
+
const resolved = resolveContent(moduleFilePath, merged, resolutions);
|
|
9686
|
+
conflicts.push(...resolved.unresolved);
|
|
9687
|
+
const content = resolved.content;
|
|
9688
|
+
modules[moduleFilePath] = content;
|
|
9689
|
+
const entries = (_moduleOf6 = moduleOf(chosen)) === null || _moduleOf6 === void 0 ? void 0 : _moduleOf6.entries;
|
|
9690
|
+
if (entries) {
|
|
9691
|
+
var _moduleOf7, _moduleOf8;
|
|
9692
|
+
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]));
|
|
9693
|
+
} else {
|
|
9694
|
+
files[file] = writeContent(file, text, from, content);
|
|
9695
|
+
}
|
|
9696
|
+
}
|
|
9697
|
+
if (conflicts.length > 0) return {
|
|
9698
|
+
status: "conflicts",
|
|
9699
|
+
conflicts
|
|
9700
|
+
};
|
|
9701
|
+
return {
|
|
9702
|
+
status: "merged",
|
|
9703
|
+
files,
|
|
9704
|
+
modules,
|
|
9705
|
+
code: Object.keys(files).filter(file => {
|
|
9706
|
+
const text = files[file];
|
|
9707
|
+
const before = site.files[file] ?? null;
|
|
9708
|
+
if (text === null || before === null) return text !== before;
|
|
9709
|
+
return codeOf(file, text) !== codeOf(file, before);
|
|
9710
|
+
})
|
|
9711
|
+
};
|
|
9712
|
+
}
|
|
9713
|
+
|
|
9714
|
+
// --- the pieces --------------------------------------------------------------
|
|
9715
|
+
|
|
9716
|
+
/**
|
|
9717
|
+
* A module's merged content with the resolutions written in: each field
|
|
9718
|
+
* conflict that has one, and any value written for the schema check (or
|
|
9719
|
+
* anywhere else) as given. What is left is what still needs a person.
|
|
9720
|
+
*/
|
|
9721
|
+
function resolveContent(moduleFilePath, merged, resolutions) {
|
|
9722
|
+
let content = merged.value;
|
|
9723
|
+
const unresolved = [];
|
|
9724
|
+
for (const conflict of merged.conflicts) {
|
|
9725
|
+
const resolved = fieldResolution(resolutions, moduleFilePath, conflict);
|
|
9726
|
+
if (resolved === null) {
|
|
9727
|
+
unresolved.push({
|
|
9728
|
+
kind: "field",
|
|
9729
|
+
moduleFilePath,
|
|
9730
|
+
...conflict
|
|
9731
|
+
});
|
|
9732
|
+
} else {
|
|
9733
|
+
content = setAt(content, conflict.path, resolved.value);
|
|
9734
|
+
}
|
|
9735
|
+
}
|
|
9736
|
+
for (const resolution of resolutions) {
|
|
9737
|
+
if ("moduleFilePath" in resolution && resolution.moduleFilePath === moduleFilePath && "value" in resolution && !merged.conflicts.some(c => samePath(c.path, resolution.path))) {
|
|
9738
|
+
content = setAt(content, resolution.path, resolution.value);
|
|
9739
|
+
}
|
|
9740
|
+
}
|
|
9741
|
+
return {
|
|
9742
|
+
content,
|
|
9743
|
+
unresolved
|
|
9744
|
+
};
|
|
9745
|
+
}
|
|
9746
|
+
|
|
9747
|
+
/**
|
|
9748
|
+
* The schema check of a merge: content in `merged` -- the project
|
|
9749
|
+
* {@link mergeFromSite} wrote, with its schemas evaluated -- that is invalid
|
|
9750
|
+
* under the schema it ends with, leaving out what was already invalid on a
|
|
9751
|
+
* side. A conflict, each; a resolution with a value for one is written into
|
|
9752
|
+
* the content by the merge, and so is checked here.
|
|
9753
|
+
*/
|
|
9754
|
+
function schemaCheck(merged, sides) {
|
|
9755
|
+
const conflicts = [];
|
|
9756
|
+
for (const [path, module] of Object.entries(merged.modules)) {
|
|
9757
|
+
const moduleFilePath = path;
|
|
9758
|
+
const known = new Set(sides.flatMap(side => {
|
|
9759
|
+
const there = side.modules[moduleFilePath];
|
|
9760
|
+
return there ? errorsOfSource(moduleFilePath, there).map(e => e.path.join("/")) : [];
|
|
9761
|
+
}));
|
|
9762
|
+
for (const error of errorsOfSource(moduleFilePath, module)) {
|
|
9763
|
+
if (known.has(error.path.join("/"))) continue;
|
|
9764
|
+
conflicts.push({
|
|
9765
|
+
kind: "invalid",
|
|
9766
|
+
moduleFilePath,
|
|
9767
|
+
path: error.path,
|
|
9768
|
+
value: valueAt(module.source, error.path),
|
|
9769
|
+
message: error.message
|
|
9770
|
+
});
|
|
9771
|
+
}
|
|
9772
|
+
}
|
|
9773
|
+
return conflicts;
|
|
9774
|
+
}
|
|
9775
|
+
|
|
9776
|
+
/**
|
|
9777
|
+
* The values that merge whole, though they are objects: media (an image's
|
|
9778
|
+
* alt text describes that image, not another) and rich text.
|
|
9779
|
+
*/
|
|
9780
|
+
const ATOMIC = new Set(["image", "file", "video", "font", "richtext"]);
|
|
9781
|
+
|
|
9782
|
+
/** See {@link ATOMIC}: where this schema holds a value that merges whole. */
|
|
9783
|
+
function atomicFor(schema) {
|
|
9784
|
+
if (!schema) return () => false;
|
|
9785
|
+
return path => {
|
|
9786
|
+
const types = internal.schemaTypesOfPath(schema, path);
|
|
9787
|
+
return [...types].some(type => ATOMIC.has(type));
|
|
9788
|
+
};
|
|
9789
|
+
}
|
|
9790
|
+
const tsOps = new TSOps(document => fp.pipe(analyzeValModule(document), fp.result.map(({
|
|
9791
|
+
source
|
|
9792
|
+
}) => source)));
|
|
9793
|
+
|
|
9794
|
+
/**
|
|
9795
|
+
* `content` written into the `.val.ts` `text`, whose content is `from`: as
|
|
9796
|
+
* the smallest patch, through the same AST operations a save uses, so what
|
|
9797
|
+
* did not change is left exactly as it was.
|
|
9798
|
+
*/
|
|
9799
|
+
function writeContent(file, text, from, content) {
|
|
9800
|
+
const ops = diffOps(from, content);
|
|
9801
|
+
if (ops.length === 0) return text;
|
|
9802
|
+
const sourceFile = ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2015, true);
|
|
9803
|
+
return printed(unwrap(file, patch.applyPatch(sourceFile, tsOps, ops)));
|
|
9804
|
+
}
|
|
9805
|
+
|
|
9806
|
+
/**
|
|
9807
|
+
* A `.jsonValues()` module, written as its files: the `.val.ts` names the
|
|
9808
|
+
* entries -- one added gets a `c.json(...)` line, as a save writes it, one
|
|
9809
|
+
* removed loses its line -- and each entry is its own `.val.json`. An entry
|
|
9810
|
+
* whose content did not change keeps its file's text exactly.
|
|
9811
|
+
*/
|
|
9812
|
+
function writeEntries(file, text, chosenFiles, entries, from, content, others) {
|
|
9813
|
+
const written = {};
|
|
9814
|
+
const before = isRecord$4(from) ? from : {};
|
|
9815
|
+
const after = isRecord$4(content) ? content : {};
|
|
9816
|
+
const moduleDir = path__namespace["default"].posix.dirname(file);
|
|
9817
|
+
let sourceFile = ts__default["default"].createSourceFile(file, text, ts__default["default"].ScriptTarget.ES2015, true);
|
|
9818
|
+
for (const key of Object.keys(before)) {
|
|
9819
|
+
if (key in after) continue;
|
|
9820
|
+
sourceFile = unwrap(file, removeValJsonEntry(sourceFile, [], key));
|
|
9821
|
+
written[entries[key]] = null;
|
|
9822
|
+
}
|
|
9823
|
+
for (const [key, value] of Object.entries(after)) {
|
|
9824
|
+
const entryFile = entries[key] ?? others.map(other => other === null || other === void 0 ? void 0 : other[key]).find(f => f !== undefined) ?? path__namespace["default"].posix.join(moduleDir, path__namespace["default"].posix.basename(file, ".val.ts"), `${key}.val.json`);
|
|
9825
|
+
if (!(key in before)) {
|
|
9826
|
+
const importPath = `./${path__namespace["default"].posix.relative(moduleDir, entryFile)}`;
|
|
9827
|
+
sourceFile = unwrap(file, insertValJsonEntry(sourceFile, [], key, importPath));
|
|
9828
|
+
}
|
|
9829
|
+
const kept = chosenFiles[entryFile];
|
|
9830
|
+
written[entryFile] = key in before && diffOps(before[key], value).length === 0 && kept ? kept : JSON.stringify(value, null, 2);
|
|
9831
|
+
}
|
|
9832
|
+
written[file] = printed(sourceFile);
|
|
9833
|
+
return written;
|
|
9834
|
+
}
|
|
9835
|
+
function isRecord$4(value) {
|
|
9836
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9837
|
+
}
|
|
9838
|
+
function unwrap(file, written) {
|
|
9839
|
+
if (fp.result.isErr(written)) {
|
|
9840
|
+
throw new Error(`could not write the merge into ${file}: ${String(written.error)}`);
|
|
9841
|
+
}
|
|
9842
|
+
return written.value;
|
|
9843
|
+
}
|
|
9844
|
+
|
|
9845
|
+
/** As `prepare` prints: TypeScript escapes non-ASCII as \\u sequences. */
|
|
9846
|
+
function printed(sourceFile) {
|
|
9847
|
+
return unescape(sourceFile.getText(sourceFile).replace(/\\u/g, "%u"));
|
|
9848
|
+
}
|
|
9849
|
+
function fileResolution(resolutions, file) {
|
|
9850
|
+
for (const resolution of resolutions) {
|
|
9851
|
+
if ("file" in resolution && resolution.file === file) return resolution;
|
|
9852
|
+
}
|
|
9853
|
+
return null;
|
|
9854
|
+
}
|
|
9855
|
+
function resolvedText(resolution, site, proposal) {
|
|
9856
|
+
if ("text" in resolution) return resolution.text;
|
|
9857
|
+
return resolution.keep === "site" ? site : proposal;
|
|
9858
|
+
}
|
|
9859
|
+
|
|
9860
|
+
/** A field conflict's resolution, as the value it leaves; `null` when it has none. */
|
|
9861
|
+
function fieldResolution(resolutions, moduleFilePath, conflict) {
|
|
9862
|
+
for (const resolution of resolutions) {
|
|
9863
|
+
if (!("moduleFilePath" in resolution) || resolution.moduleFilePath !== moduleFilePath || !samePath(resolution.path, conflict.path)) {
|
|
9864
|
+
continue;
|
|
9865
|
+
}
|
|
9866
|
+
if ("value" in resolution) return {
|
|
9867
|
+
value: resolution.value
|
|
9868
|
+
};
|
|
9869
|
+
return {
|
|
9870
|
+
value: resolution.keep === "site" ? conflict.site : conflict.proposal
|
|
9871
|
+
};
|
|
9872
|
+
}
|
|
9873
|
+
return null;
|
|
9874
|
+
}
|
|
9875
|
+
function samePath(a, b) {
|
|
9876
|
+
return a.length === b.length && a.every((key, at) => key === b[at]);
|
|
9877
|
+
}
|
|
9878
|
+
|
|
9879
|
+
/**
|
|
9880
|
+
* Update from the site when only content changed (valbuild/home
|
|
9881
|
+
* `docs/hosted-publishing.md` step 8.2, slice 1a): the proposal staged no code
|
|
9882
|
+
* and the site still serves the build the proposal stands on. Then all three
|
|
9883
|
+
* sides share one schema -- the running build's -- so the merge needs no
|
|
9884
|
+
* project loaded, which is what lets Val's server at a proposal's address run
|
|
9885
|
+
* it (it cannot evaluate a `.val.ts`).
|
|
9886
|
+
*
|
|
9887
|
+
* The same rules as {@link mergeFromSite}, module by module: field by field,
|
|
9888
|
+
* the same value on both sides a conflict, media and rich text whole. Content
|
|
9889
|
+
* hands over each module the proposal saved, as it was where the proposal
|
|
9890
|
+
* started, as the site has it, and as the proposal saved it; the merge is
|
|
9891
|
+
* written into the proposal's own `.val.ts` text with the same AST operations
|
|
9892
|
+
* a save uses.
|
|
9893
|
+
*
|
|
9894
|
+
* Not here: a `.jsonValues()` record the site changed. Its Source in content's
|
|
9895
|
+
* index holds only the entries a commit touched, so it cannot be merged from
|
|
9896
|
+
* the index, and its entries are `.val.json` files the `.val.ts` only names,
|
|
9897
|
+
* which writing into the `.val.ts` cannot change. That waits for the full
|
|
9898
|
+
* merge (`writeEntries`), and is answered as such -- with or without a
|
|
9899
|
+
* conflict, since a clean merge still has the site's entries to write.
|
|
9900
|
+
*/
|
|
9901
|
+
|
|
9902
|
+
function mergeContent(input) {
|
|
9903
|
+
const conflicts = [];
|
|
9904
|
+
const files = {};
|
|
9905
|
+
const modules = {};
|
|
9906
|
+
const needsFullMerge = [];
|
|
9907
|
+
for (const path of Object.keys(input.modules).sort()) {
|
|
9908
|
+
const moduleFilePath = path;
|
|
9909
|
+
const {
|
|
9910
|
+
base,
|
|
9911
|
+
site,
|
|
9912
|
+
proposal,
|
|
9913
|
+
text
|
|
9914
|
+
} = input.modules[moduleFilePath];
|
|
9915
|
+
const schema = input.schemas[moduleFilePath];
|
|
9916
|
+
if (!schema) {
|
|
9917
|
+
// The build has no such module: its code changed, so this is not a
|
|
9918
|
+
// content-only update.
|
|
9919
|
+
needsFullMerge.push(moduleFilePath);
|
|
9920
|
+
continue;
|
|
9921
|
+
}
|
|
9922
|
+
const serialized = schema["executeSerialize"]();
|
|
9923
|
+
const orUndefined = value => value ?? undefined;
|
|
9924
|
+
const merged = mergeValues(orUndefined(base), orUndefined(site), orUndefined(proposal), atomicFor(serialized));
|
|
9925
|
+
if (serialized.type === "record" && serialized.jsonValues && (merged.conflicts.length > 0 || diffOps(orUndefined(proposal), merged.value).length > 0)) {
|
|
9926
|
+
needsFullMerge.push(moduleFilePath);
|
|
9927
|
+
continue;
|
|
9928
|
+
}
|
|
9929
|
+
const resolved = resolveContent(moduleFilePath, merged, input.resolutions);
|
|
9930
|
+
conflicts.push(...resolved.unresolved);
|
|
9931
|
+
if (resolved.unresolved.length > 0) continue;
|
|
9932
|
+
|
|
9933
|
+
// The schema check, under the one schema there is: what was already
|
|
9934
|
+
// invalid on a side is not the merge's doing. A value written as a
|
|
9935
|
+
// resolution is already in `content`, and checked like the rest -- as
|
|
9936
|
+
// `schemaCheck` does for the full merge.
|
|
9937
|
+
const known = new Set([site, proposal].flatMap(side => validateWith(schema, moduleFilePath, orUndefined(side)).map(e => e.path.join("/"))));
|
|
9938
|
+
const content = resolved.content;
|
|
9939
|
+
for (const error of validateWith(schema, moduleFilePath, content)) {
|
|
9940
|
+
if (known.has(error.path.join("/"))) continue;
|
|
9941
|
+
conflicts.push({
|
|
9942
|
+
kind: "invalid",
|
|
9943
|
+
moduleFilePath,
|
|
9944
|
+
path: error.path,
|
|
9945
|
+
value: valueAt(content, error.path),
|
|
9946
|
+
message: error.message
|
|
9947
|
+
});
|
|
9948
|
+
}
|
|
9949
|
+
const file = moduleFilePath.slice(1);
|
|
9950
|
+
files[file] = writeContent(file, text, orUndefined(proposal), content);
|
|
9951
|
+
modules[moduleFilePath] = {
|
|
9952
|
+
source: content ?? null,
|
|
9953
|
+
schema: serialized
|
|
9954
|
+
};
|
|
9955
|
+
}
|
|
9956
|
+
if (needsFullMerge.length > 0) {
|
|
9957
|
+
return {
|
|
9958
|
+
status: "needs-full-merge",
|
|
9959
|
+
modules: needsFullMerge
|
|
9960
|
+
};
|
|
9961
|
+
}
|
|
9962
|
+
if (conflicts.length > 0) return {
|
|
9963
|
+
status: "conflicts",
|
|
9964
|
+
conflicts
|
|
9965
|
+
};
|
|
9966
|
+
return {
|
|
9967
|
+
status: "merged",
|
|
9968
|
+
files,
|
|
9969
|
+
modules
|
|
9970
|
+
};
|
|
9971
|
+
}
|
|
9972
|
+
|
|
9973
|
+
/**
|
|
9974
|
+
* Update from the site, for a proposal with code (valbuild/home
|
|
9975
|
+
* `docs/hosted-publishing.md` step 8.2, slice 1b): Val's server's half.
|
|
9976
|
+
*
|
|
9977
|
+
* Nothing here runs project code. Content hands over the three trees and each
|
|
9978
|
+
* module's Source in each (`merge-trees`); the builder tab evaluates each
|
|
9979
|
+
* tree's `val.modules.ts` -- the site's own dependencies, from its vendor
|
|
9980
|
+
* layer -- and sends what that gave: each module's schema, the content its
|
|
9981
|
+
* text holds, and the errors of its Source under that schema. This does the
|
|
9982
|
+
* text, with the TypeScript Val's server already has.
|
|
9983
|
+
*
|
|
9984
|
+
* In two calls, because the schema check needs the merged tree evaluated too:
|
|
9985
|
+
*
|
|
9986
|
+
* 1. Without `merged`: the merge `M`, as the files it changes over the site
|
|
9987
|
+
* and each module's content -- for the tab to evaluate -- under an `id`.
|
|
9988
|
+
* Or the conflicts, for a person.
|
|
9989
|
+
* 2. With `merged`, the tab's evaluation of that `id`: the schema check, then
|
|
9990
|
+
* what content commits. The merge is made again rather than taken from the
|
|
9991
|
+
* tab, and must come out as the same `id`.
|
|
9992
|
+
*/
|
|
9993
|
+
|
|
9994
|
+
function updateWithCode(input) {
|
|
9995
|
+
const {
|
|
9996
|
+
trees,
|
|
9997
|
+
evaluated,
|
|
9998
|
+
resolutions
|
|
9999
|
+
} = input;
|
|
10000
|
+
const projectOf = side => {
|
|
10001
|
+
const files = internal.sideFiles(trees, side);
|
|
10002
|
+
return {
|
|
10003
|
+
files,
|
|
10004
|
+
modules: modulesOf(files, evaluated[side], path => internal.sideSource(trees, side, path))
|
|
10005
|
+
};
|
|
10006
|
+
};
|
|
10007
|
+
const base = projectOf("base");
|
|
10008
|
+
const site = projectOf("site");
|
|
10009
|
+
const proposal = projectOf("proposal");
|
|
10010
|
+
const result = mergeFromSite({
|
|
10011
|
+
base,
|
|
10012
|
+
site,
|
|
10013
|
+
proposal,
|
|
10014
|
+
resolutions
|
|
10015
|
+
});
|
|
10016
|
+
if (result.status === "conflicts") return result;
|
|
10017
|
+
const changes = {};
|
|
10018
|
+
for (const [path, text] of Object.entries(result.files)) {
|
|
10019
|
+
if (text !== (site.files[path] ?? null)) changes[path] = text;
|
|
10020
|
+
}
|
|
10021
|
+
const id = mergeId(changes, result.modules);
|
|
10022
|
+
if (input.merged === undefined) {
|
|
10023
|
+
const sources = {};
|
|
10024
|
+
for (const [path, content] of Object.entries(result.modules)) {
|
|
10025
|
+
sources[path] = content ?? null;
|
|
10026
|
+
}
|
|
10027
|
+
return {
|
|
10028
|
+
status: "evaluate",
|
|
10029
|
+
id,
|
|
10030
|
+
files: changes,
|
|
10031
|
+
sources
|
|
10032
|
+
};
|
|
10033
|
+
}
|
|
10034
|
+
if (input.merged.id !== id) return {
|
|
10035
|
+
status: "stale"
|
|
10036
|
+
};
|
|
10037
|
+
const mergedFiles = {
|
|
10038
|
+
...site.files,
|
|
10039
|
+
...changes
|
|
10040
|
+
};
|
|
10041
|
+
const mergedProject = {
|
|
10042
|
+
files: mergedFiles,
|
|
10043
|
+
modules: modulesOf(Object.fromEntries(Object.entries(mergedFiles).filter(entry => entry[1] !== null)), input.merged.evaluated, path => result.modules[path] ?? null)
|
|
10044
|
+
};
|
|
10045
|
+
const invalid = schemaCheck(mergedProject, [site, proposal]);
|
|
10046
|
+
if (invalid.length > 0) return {
|
|
10047
|
+
status: "conflicts",
|
|
10048
|
+
conflicts: invalid
|
|
10049
|
+
};
|
|
10050
|
+
|
|
10051
|
+
// The paths the proposal held go back to what the merge left -- the site's,
|
|
10052
|
+
// often -- so nothing it held before is left standing over the new base.
|
|
10053
|
+
const files = {
|
|
10054
|
+
...changes
|
|
10055
|
+
};
|
|
10056
|
+
for (const path of Object.keys(trees.trees.proposal)) {
|
|
10057
|
+
if (!(path in files)) files[path] = mergedFiles[path] ?? null;
|
|
10058
|
+
}
|
|
10059
|
+
const modules = {};
|
|
10060
|
+
for (const [path, module] of Object.entries(mergedProject.modules)) {
|
|
10061
|
+
var _trees$modules$module, _trees$modules$module2;
|
|
10062
|
+
const moduleFilePath = path;
|
|
10063
|
+
const onSite = site.modules[moduleFilePath];
|
|
10064
|
+
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);
|
|
10065
|
+
if (touched || onSite === undefined || !sameJson(onSite.source, module.source) || !sameJson(onSite.schema, module.schema)) {
|
|
10066
|
+
modules[moduleFilePath] = {
|
|
10067
|
+
source: module.source,
|
|
10068
|
+
schema: module.schema
|
|
10069
|
+
};
|
|
10070
|
+
}
|
|
10071
|
+
}
|
|
10072
|
+
return {
|
|
10073
|
+
status: "merged",
|
|
10074
|
+
files,
|
|
10075
|
+
staged: result.code.filter(path => path in files),
|
|
10076
|
+
modules
|
|
10077
|
+
};
|
|
10078
|
+
}
|
|
10079
|
+
function modulesOf(files, evaluated, sourceOf) {
|
|
10080
|
+
const modules = {};
|
|
10081
|
+
for (const [path, module] of Object.entries(evaluated)) {
|
|
10082
|
+
const moduleFilePath = path;
|
|
10083
|
+
const text = files[moduleFilePath.slice(1)];
|
|
10084
|
+
modules[moduleFilePath] = {
|
|
10085
|
+
// A module the index has no Source for is one this tree's code adds:
|
|
10086
|
+
// its text is all there is.
|
|
10087
|
+
source: sourceOf(moduleFilePath) ?? module.written,
|
|
10088
|
+
written: module.written,
|
|
10089
|
+
schema: module.schema,
|
|
10090
|
+
entries: text === undefined ? null : entryFilesOf(moduleFilePath, text),
|
|
10091
|
+
errors: module.errors
|
|
10092
|
+
};
|
|
10093
|
+
}
|
|
10094
|
+
return modules;
|
|
10095
|
+
}
|
|
10096
|
+
|
|
10097
|
+
/** The merge, named by what it is: the files it changes and the content it writes. */
|
|
10098
|
+
function mergeId(files, modules) {
|
|
10099
|
+
const sorted = record => Object.keys(record).sort().map(key => [key, record[key] ?? null]);
|
|
10100
|
+
return crypto$1.createHash("sha256").update("val.update-with-code.v1\n").update(JSON.stringify({
|
|
10101
|
+
files: sorted(files),
|
|
10102
|
+
modules: sorted(modules)
|
|
10103
|
+
})).digest("hex");
|
|
10104
|
+
}
|
|
10105
|
+
function sameJson(a, b) {
|
|
10106
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
10107
|
+
}
|
|
10108
|
+
|
|
10109
|
+
const textEncoder$2 = new TextEncoder();
|
|
10110
|
+
|
|
10111
|
+
/** The body of the content API's 403 for a write the project's plan refuses. */
|
|
10112
|
+
const PlanRefusedBody = z.z.object({
|
|
10113
|
+
error: internal.PlanRefusalError
|
|
10114
|
+
});
|
|
10115
|
+
const PatchId = z.z.string().refine(s => !!s); // TODO: validate
|
|
10116
|
+
const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
|
|
10117
|
+
z.z.string().refine(s => !!s); // TODO: validate
|
|
10118
|
+
z.z.string().refine(s => !!s); // TODO: validate
|
|
10119
|
+
const ModuleFilePath = z.z.string().refine(s => !!s); // TODO: validate
|
|
10120
|
+
const Metadata = z.z.union([z.z.object({
|
|
10121
|
+
mimeType: z.z.string(),
|
|
10122
|
+
width: z.z.number(),
|
|
10123
|
+
height: z.z.number()
|
|
10124
|
+
}), z.z.object({
|
|
10125
|
+
mimeType: z.z.string()
|
|
10126
|
+
})]);
|
|
10127
|
+
const MetadataRes = z.z.object({
|
|
10128
|
+
filePath: ModuleFilePath,
|
|
10129
|
+
metadata: Metadata,
|
|
10130
|
+
type: z.z.union([z.z.literal("file"), z.z.literal("image")]).nullable()
|
|
10131
|
+
});
|
|
10132
|
+
const GetApplicablePatches = z.z.object({
|
|
10133
|
+
patches: z.z.array(z.z.object({
|
|
10134
|
+
path: z.z.string(),
|
|
10135
|
+
patch: internal.Patch.nullable(),
|
|
10136
|
+
patchId: z.z.string(),
|
|
10137
|
+
authorId: z.z.string().nullable(),
|
|
10138
|
+
baseSha: z.z.string(),
|
|
10139
|
+
createdAt: z.z.string(),
|
|
10140
|
+
applied: z.z.object({
|
|
10141
|
+
commitSha: z.z.string()
|
|
10142
|
+
}).nullable()
|
|
10143
|
+
})),
|
|
10144
|
+
/**
|
|
10145
|
+
* The head of the chain. See `OrderedPatches.headPatchId`. Optional because
|
|
10146
|
+
* a content service that predates it sends nothing.
|
|
10147
|
+
*/
|
|
10148
|
+
headPatchId: internal.PatchId.nullable().optional(),
|
|
10149
|
+
/**
|
|
10150
|
+
* The chain version of that head. See `OrderedPatches.headVersion`.
|
|
10151
|
+
* Optional for the same reason.
|
|
10152
|
+
*/
|
|
10153
|
+
headVersion: z.z.number().optional(),
|
|
10154
|
+
/** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
|
|
10155
|
+
publishingPatches: z.z.array(internal.PatchId).optional(),
|
|
10156
|
+
/** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
|
|
10157
|
+
heldBackPatches: z.z.array(internal.PatchId).optional(),
|
|
10158
|
+
commits: z.z.array(z.z.object({
|
|
10159
|
+
commitSha: z.z.string(),
|
|
10160
|
+
/*
|
|
10161
|
+
* Nullable since the content service mints its own commit shas: a
|
|
10162
|
+
* root commit has no parent, and a publisher that did not say where
|
|
10163
|
+
* it was has no client sha. Parsed rather than trusted, so a service
|
|
10164
|
+
* that sends null gets null here instead of failing the whole poll.
|
|
10165
|
+
*/
|
|
10166
|
+
clientCommitSha: z.z.string().nullable(),
|
|
10167
|
+
parentCommitSha: z.z.string().nullable(),
|
|
10168
|
+
commitMessage: z.z.string().nullable(),
|
|
10169
|
+
branch: z.z.string(),
|
|
10170
|
+
creator: z.z.string(),
|
|
10171
|
+
createdAt: z.z.string()
|
|
10172
|
+
})).optional(),
|
|
10173
|
+
deployments: z.z.array(z.z.object({
|
|
10174
|
+
deploymentId: z.z.string(),
|
|
10175
|
+
commitSha: z.z.string(),
|
|
10176
|
+
deploymentState: z.z.string(),
|
|
10177
|
+
createdAt: z.z.string(),
|
|
10178
|
+
updatedAt: z.z.string(),
|
|
10179
|
+
// The git message, where the content service knows it. See
|
|
10180
|
+
// `ValDeployment`: this is the only source for a deployment that Val
|
|
10181
|
+
// did not publish, and zod would otherwise strip it here.
|
|
10182
|
+
commitMessage: z.z.string().nullable().optional()
|
|
10183
|
+
})).optional(),
|
|
10184
|
+
/**
|
|
10185
|
+
* What the project EXPECTS of whoever publishes it.
|
|
10186
|
+
*
|
|
10187
|
+
* Optional because a content service that predates it sends nothing, and
|
|
10188
|
+
* absent means "not reported" -- never "managed". The difference decides
|
|
10189
|
+
* whether a publish is refused, so guessing either way would be worse than
|
|
10190
|
+
* not checking: guessing `managed` would let a deployment publish a
|
|
10191
|
+
* connected project it cannot mirror, and guessing `connected` would refuse
|
|
10192
|
+
* every publish against an older service.
|
|
10193
|
+
*/
|
|
10194
|
+
project: z.z.object({
|
|
10195
|
+
sourceMode: z.z.union([z.z.literal("managed"), z.z.literal("connected")]),
|
|
10196
|
+
branch: z.z.string(),
|
|
10197
|
+
/**
|
|
10198
|
+
* Whether a press of Publish is a publish JOB. Absent from a content
|
|
10199
|
+
* service that predates connected jobs: then managed projects publish
|
|
10200
|
+
* as jobs, and connected ones by commit, as they did.
|
|
10201
|
+
*/
|
|
10202
|
+
publishJobs: z.z.boolean().optional()
|
|
10203
|
+
}).optional()
|
|
10204
|
+
});
|
|
10205
|
+
const FilesResponse = z.z.object({
|
|
10206
|
+
files: z.z.array(z.z.union([z.z.object({
|
|
10207
|
+
filePath: z.z.string(),
|
|
10208
|
+
location: z.z.literal("patch"),
|
|
10209
|
+
patchId: PatchId,
|
|
10210
|
+
value: z.z.string(),
|
|
10211
|
+
remote: z.z.boolean()
|
|
10212
|
+
}), z.z.object({
|
|
10213
|
+
filePath: z.z.string(),
|
|
10214
|
+
location: z.z.literal("repo"),
|
|
10215
|
+
commitSha: CommitSha,
|
|
10216
|
+
value: z.z.string()
|
|
10217
|
+
})])),
|
|
10218
|
+
errors: z.z.array(z.z.union([z.z.object({
|
|
10219
|
+
filePath: z.z.string(),
|
|
10220
|
+
location: z.z.literal("patch"),
|
|
10221
|
+
patchId: PatchId,
|
|
10222
|
+
message: z.z.string(),
|
|
10223
|
+
remote: z.z.boolean()
|
|
10224
|
+
}), z.z.object({
|
|
10225
|
+
filePath: z.z.string(),
|
|
10226
|
+
location: z.z.literal("repo"),
|
|
10227
|
+
commitSha: CommitSha,
|
|
10228
|
+
message: z.z.string()
|
|
10229
|
+
})])).optional()
|
|
10230
|
+
});
|
|
10231
|
+
const SavePatchResponse = z.z.object({
|
|
10232
|
+
patchId: PatchId,
|
|
10233
|
+
/**
|
|
10234
|
+
* The chain version this write made, comparable with `headVersion` on the
|
|
10235
|
+
* listing. Optional: a content service that predates it sends nothing.
|
|
10236
|
+
*/
|
|
10237
|
+
headVersion: z.z.number().optional()
|
|
10238
|
+
});
|
|
10239
|
+
const DeletePatchesResponse = z.z.object({
|
|
10240
|
+
deleted: z.z.array(PatchId),
|
|
10241
|
+
errors: z.z.array(z.z.object({
|
|
10242
|
+
message: z.z.string(),
|
|
10243
|
+
patchId: PatchId,
|
|
10244
|
+
/** The publish job holding it, when that is why it was not deleted. */
|
|
10245
|
+
publishJobId: z.z.string().optional()
|
|
10246
|
+
})).optional(),
|
|
10247
|
+
/**
|
|
10248
|
+
* The chain version each branch the delete changed is now at, keyed by
|
|
10249
|
+
* branch. Optional: a content service that predates it sends none.
|
|
10250
|
+
*/
|
|
10251
|
+
headVersions: z.z.record(z.z.string(), z.z.number()).optional()
|
|
10252
|
+
});
|
|
10253
|
+
/**
|
|
10254
|
+
* This client's own branch's entry in a per-branch version map.
|
|
10255
|
+
*
|
|
10256
|
+
* A delete can span branches, so the content service answers per branch. With
|
|
10257
|
+
* no branch of its own (a build with no repository, where the service resolves
|
|
10258
|
+
* it), the only entry is ours when there is exactly one; with several, none can
|
|
10259
|
+
* be picked, and a wrong one would floor this client at another chain's count.
|
|
10260
|
+
*/
|
|
10261
|
+
function ownBranchVersion(headVersions, branch) {
|
|
10262
|
+
if (headVersions === undefined) return undefined;
|
|
10263
|
+
if (branch !== undefined) return headVersions[branch];
|
|
10264
|
+
const versions = Object.values(headVersions);
|
|
10265
|
+
return versions.length === 1 ? versions[0] : undefined;
|
|
10266
|
+
}
|
|
10267
|
+
const SavePatchFileResponse = z.z.object({
|
|
10268
|
+
patchId: PatchId,
|
|
10269
|
+
filePath: ModuleFilePath
|
|
10270
|
+
});
|
|
10271
|
+
const CommitResponse = z.z.object({
|
|
10272
|
+
updatedFiles: z.z.array(z.z.string()),
|
|
10273
|
+
commit: CommitSha,
|
|
10274
|
+
/*
|
|
10275
|
+
* Optional because a content service that predates them sends neither, and
|
|
10276
|
+
* this client talks to whichever one the project is on. Absent means NOT
|
|
10277
|
+
* REPORTED: `parent: undefined` is not "a root commit", and a caller that
|
|
10278
|
+
* reads it as one would conclude the history starts here.
|
|
10279
|
+
*
|
|
10280
|
+
* Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
|
|
10281
|
+
* from a service that versions separately and are passed through as what it
|
|
10282
|
+
* said, not as something this end vouched for.
|
|
10283
|
+
*/
|
|
10284
|
+
parent: z.z.string().optional(),
|
|
10285
|
+
tree: z.z.string().optional(),
|
|
10286
|
+
branch: z.z.string(),
|
|
10287
|
+
/** The chain version the publish moved the branch to; absent if it applied nothing. */
|
|
10288
|
+
headVersion: z.z.number().optional()
|
|
10289
|
+
});
|
|
10290
|
+
// #region history wire schemas
|
|
10291
|
+
//
|
|
10292
|
+
// Validated on arrival rather than trusted: these come from a service that
|
|
10293
|
+
// versions separately, and a silently mis-shaped commit record reads as "this
|
|
10294
|
+
// commit changed nothing", which is indistinguishable from a real answer.
|
|
10295
|
+
const HistoricalCommitResponse = z.z.object({
|
|
10296
|
+
commitSha: z.z.string(),
|
|
10297
|
+
/** `null` for a root commit; see the note on the applicable-patches schema. */
|
|
10298
|
+
parentCommitSha: z.z.string().nullable(),
|
|
10299
|
+
/** `null` when the publisher did not say where it was. */
|
|
10300
|
+
clientCommitSha: z.z.string().nullable(),
|
|
10301
|
+
branch: z.z.string(),
|
|
10302
|
+
createdBranch: z.z.string().nullable(),
|
|
10303
|
+
creator: z.z.string().nullable(),
|
|
10304
|
+
message: z.z.string().nullable(),
|
|
10305
|
+
createdAt: z.z.string(),
|
|
10306
|
+
seqNum: z.z.string(),
|
|
10307
|
+
patchCount: z.z.number(),
|
|
10308
|
+
hasArchive: z.z.boolean()
|
|
10309
|
+
});
|
|
10310
|
+
const ListCommitsResponse = z.z.object({
|
|
10311
|
+
commits: z.z.array(HistoricalCommitResponse),
|
|
10312
|
+
nextCursor: z.z.string().nullable()
|
|
10313
|
+
});
|
|
10314
|
+
const CommitPatchesResponse = z.z.object({
|
|
10315
|
+
commitSha: z.z.string(),
|
|
10316
|
+
commit: z.z.object({
|
|
10317
|
+
commitSha: z.z.string(),
|
|
10318
|
+
parentCommitSha: z.z.string().nullable(),
|
|
10319
|
+
clientCommitSha: z.z.string().nullable(),
|
|
10320
|
+
branch: z.z.string(),
|
|
10321
|
+
createdBranch: z.z.string().nullable(),
|
|
10322
|
+
creator: z.z.string().nullable(),
|
|
10323
|
+
message: z.z.string().nullable(),
|
|
10324
|
+
createdAt: z.z.string(),
|
|
10325
|
+
seqNum: z.z.string(),
|
|
10326
|
+
hasArchive: z.z.boolean()
|
|
10327
|
+
}),
|
|
10328
|
+
patches: z.z.array(z.z.object({
|
|
10329
|
+
patchId: z.z.string(),
|
|
10330
|
+
path: z.z.string(),
|
|
10331
|
+
patch: z.z.unknown(),
|
|
10332
|
+
authorId: z.z.string().nullable(),
|
|
10333
|
+
createdAt: z.z.string(),
|
|
10334
|
+
baseSha: z.z.string(),
|
|
10335
|
+
coreVersion: z.z.string()
|
|
10336
|
+
}))
|
|
10337
|
+
});
|
|
10338
|
+
|
|
10339
|
+
/**
|
|
10340
|
+
* `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
|
|
10341
|
+
*
|
|
10342
|
+
* `schema` stays `unknown` here on purpose. The content service stores it
|
|
10343
|
+
* opaquely and cannot vouch for it, so validating it at the transport boundary
|
|
9646
10344
|
* would turn "a schema written by a different version of Val" into a failed
|
|
9647
10345
|
* REQUEST rather than one module that cannot be shown. It is checked in
|
|
9648
10346
|
* `getHistoricalPatchSet`, per module, where a failure degrades that module and
|
|
@@ -9685,39 +10383,10 @@ const CommitAffectedFilesResponse = z.z.object({
|
|
|
9685
10383
|
});
|
|
9686
10384
|
// #endregion history wire schemas
|
|
9687
10385
|
|
|
9688
|
-
/*
|
|
9689
|
-
* The shared schema, not a copy of it.
|
|
9690
|
-
*
|
|
9691
|
-
* This re-declared `PatchGroup` field for field while the file already imported
|
|
9692
|
-
* `PatchGroupT` from the same module — so a field added on one side and not the
|
|
9693
|
-
* other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
|
|
9694
|
-
* it, with no type error anywhere.
|
|
9695
|
-
*/
|
|
9696
|
-
const PatchGroupsResponse = z.z.object({
|
|
9697
|
-
patchGroups: z.z.array(internal.PatchGroup)
|
|
9698
|
-
});
|
|
9699
|
-
const PatchGroupMutationResponse = z.z.object({
|
|
9700
|
-
/** `null` for an unstage of `~` when the caller has no open group. */
|
|
9701
|
-
patchGroupId: z.z.string().nullable(),
|
|
9702
|
-
patchIds: z.z.array(PatchId),
|
|
9703
|
-
/** The chain version the change committed at. Absent from an older `home`. */
|
|
9704
|
-
headVersion: z.z.number().optional()
|
|
9705
|
-
});
|
|
9706
10386
|
const NonceResponse = z.z.object({
|
|
9707
10387
|
nonce: z.z.string(),
|
|
9708
10388
|
url: z.z.string()
|
|
9709
10389
|
});
|
|
9710
|
-
|
|
9711
|
-
/**
|
|
9712
|
-
* How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
|
|
9713
|
-
*
|
|
9714
|
-
* Sized to cover one server render, not to be a cache: several `fetchVal` calls
|
|
9715
|
-
* in one request share an answer, and the next request asks again.
|
|
9716
|
-
*/
|
|
9717
|
-
const PATCH_GROUPS_CACHE_MS = 1000;
|
|
9718
|
-
|
|
9719
|
-
/** The content API's id for "the caller's open group on the branch". */
|
|
9720
|
-
const OWN_OPEN_GROUP = "~";
|
|
9721
10390
|
class ValOpsHttp extends ValOps {
|
|
9722
10391
|
authHeaders;
|
|
9723
10392
|
root;
|
|
@@ -10165,7 +10834,7 @@ class ValOpsHttp extends ValOps {
|
|
|
10165
10834
|
if (path === "") return method === "GET" || method === "POST";
|
|
10166
10835
|
const one = /^\/[0-9a-f]{20}$/.test(path);
|
|
10167
10836
|
if (one) return method === "GET" || method === "PATCH";
|
|
10168
|
-
if (method === "GET") return /^\/[0-9a-f]{20}\/(merge-checks|files)$/.test(path);
|
|
10837
|
+
if (method === "GET") return /^\/[0-9a-f]{20}\/(merge-checks|files|merge-trees)$/.test(path);
|
|
10169
10838
|
return method === "POST" && /^\/[0-9a-f]{20}\/(close|reopen|setup\/retry|merge|files|build|update-from-site|restore-code)$/.test(path);
|
|
10170
10839
|
}
|
|
10171
10840
|
|
|
@@ -10202,8 +10871,16 @@ class ValOpsHttp extends ValOps {
|
|
|
10202
10871
|
return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
|
|
10203
10872
|
}
|
|
10204
10873
|
async proposalsApi(path, init, profileId) {
|
|
10205
|
-
var _this$proposal;
|
|
10874
|
+
var _exec2, _exec3, _this$proposal;
|
|
10206
10875
|
const json = "application/json";
|
|
10876
|
+
const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
|
|
10877
|
+
if (updating !== undefined && init.method === "POST") {
|
|
10878
|
+
return this.updateContentFromSite(updating, init.body, profileId);
|
|
10879
|
+
}
|
|
10880
|
+
const withCode = (_exec3 = /^\/([0-9a-f]{20})\/update-code$/.exec(path)) === null || _exec3 === void 0 ? void 0 : _exec3[1];
|
|
10881
|
+
if (withCode !== undefined && init.method === "POST") {
|
|
10882
|
+
return this.updateCodeFromSite(withCode, init.body, profileId);
|
|
10883
|
+
}
|
|
10207
10884
|
if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
|
|
10208
10885
|
return {
|
|
10209
10886
|
status: 403,
|
|
@@ -10213,41 +10890,305 @@ class ValOpsHttp extends ValOps {
|
|
|
10213
10890
|
})
|
|
10214
10891
|
};
|
|
10215
10892
|
}
|
|
10216
|
-
const writes = ValOpsHttp.proposalFilesWrite(init.method, path);
|
|
10217
|
-
if (writes !== null && writes !== ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.name)) {
|
|
10893
|
+
const writes = ValOpsHttp.proposalFilesWrite(init.method, path);
|
|
10894
|
+
if (writes !== null && writes !== ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.name)) {
|
|
10895
|
+
return {
|
|
10896
|
+
status: 403,
|
|
10897
|
+
contentType: json,
|
|
10898
|
+
body: JSON.stringify({
|
|
10899
|
+
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."
|
|
10900
|
+
})
|
|
10901
|
+
};
|
|
10902
|
+
}
|
|
10903
|
+
const res = await fetch(`${this.contentUrl}/v1/${this.project}/proposals${path}`, {
|
|
10904
|
+
method: init.method,
|
|
10905
|
+
headers: {
|
|
10906
|
+
...this.authHeaders,
|
|
10907
|
+
/*
|
|
10908
|
+
* WHO is asking, as for a commit: the app's key names the project,
|
|
10909
|
+
* and a proposal is opened, renamed and closed by a person -- content
|
|
10910
|
+
* refuses those writes from a credential that names nobody.
|
|
10911
|
+
*/
|
|
10912
|
+
...(profileId !== null ? {
|
|
10913
|
+
"x-val-profile-id": profileId
|
|
10914
|
+
} : {}),
|
|
10915
|
+
...(init.body === undefined ? {} : {
|
|
10916
|
+
"Content-Type": json
|
|
10917
|
+
})
|
|
10918
|
+
},
|
|
10919
|
+
...(init.body === undefined ? {} : {
|
|
10920
|
+
body: init.body
|
|
10921
|
+
})
|
|
10922
|
+
});
|
|
10923
|
+
return {
|
|
10924
|
+
status: res.status,
|
|
10925
|
+
body: await res.text(),
|
|
10926
|
+
contentType: res.headers.get("Content-Type") ?? json
|
|
10927
|
+
};
|
|
10928
|
+
}
|
|
10929
|
+
|
|
10930
|
+
/**
|
|
10931
|
+
* Update from the site, for a proposal that changes only content
|
|
10932
|
+
* (valbuild/home `docs/hosted-publishing.md`, step 8.2, slice 1a): content
|
|
10933
|
+
* hands over the three versions of every module the proposal saved, this
|
|
10934
|
+
* server merges them under its own build's schemas -- the same on all
|
|
10935
|
+
* three sides, which is what "content only" means -- and content commits
|
|
10936
|
+
* the merge on the proposal's branch.
|
|
10937
|
+
*
|
|
10938
|
+
* Answered here rather than forwarded, at `POST /<name>/update-content`, and
|
|
10939
|
+
* only by the proposal's own server: the schemas it merges under are this
|
|
10940
|
+
* build's. A conflict comes back for a person to resolve, and the same call
|
|
10941
|
+
* with their `resolutions` merges.
|
|
10942
|
+
*/
|
|
10943
|
+
async updateContentFromSite(name, body, profileId) {
|
|
10944
|
+
var _this$proposal2;
|
|
10945
|
+
const json = "application/json";
|
|
10946
|
+
const answer = (status, value) => ({
|
|
10947
|
+
status,
|
|
10948
|
+
contentType: json,
|
|
10949
|
+
body: JSON.stringify(value)
|
|
10950
|
+
});
|
|
10951
|
+
if (((_this$proposal2 = this.proposal) === null || _this$proposal2 === void 0 ? void 0 : _this$proposal2.name) !== name) {
|
|
10952
|
+
return answer(403, {
|
|
10953
|
+
message: "A proposal is updated from the site at its own address, where its build is."
|
|
10954
|
+
});
|
|
10955
|
+
}
|
|
10956
|
+
let parsedBody;
|
|
10957
|
+
try {
|
|
10958
|
+
parsedBody = body === undefined || body === "" ? {} : JSON.parse(body);
|
|
10959
|
+
} catch {
|
|
10960
|
+
return answer(400, {
|
|
10961
|
+
message: "The update's body is not JSON."
|
|
10962
|
+
});
|
|
10963
|
+
}
|
|
10964
|
+
const request = UpdateContentBody.safeParse(parsedBody);
|
|
10965
|
+
if (!request.success) {
|
|
10966
|
+
return answer(400, {
|
|
10967
|
+
message: `Invalid update: ${zodValidationError.fromError(request.error).toString()}`
|
|
10968
|
+
});
|
|
10969
|
+
}
|
|
10970
|
+
const content = (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
|
|
10971
|
+
...init,
|
|
10972
|
+
headers: {
|
|
10973
|
+
...this.authHeaders,
|
|
10974
|
+
...(profileId !== null ? {
|
|
10975
|
+
"x-val-profile-id": profileId
|
|
10976
|
+
} : {}),
|
|
10977
|
+
...(init.body === undefined ? {} : {
|
|
10978
|
+
"Content-Type": json
|
|
10979
|
+
})
|
|
10980
|
+
}
|
|
10981
|
+
});
|
|
10982
|
+
const read = await content("/merge-inputs");
|
|
10983
|
+
if (!read.ok) {
|
|
10984
|
+
return {
|
|
10985
|
+
status: read.status,
|
|
10986
|
+
contentType: json,
|
|
10987
|
+
body: await read.text()
|
|
10988
|
+
};
|
|
10989
|
+
}
|
|
10990
|
+
const inputs = MergeInputs.safeParse(await read.json());
|
|
10991
|
+
if (!inputs.success) {
|
|
10992
|
+
return answer(502, {
|
|
10993
|
+
message: `Content answered the merge's inputs in a shape this Val does not read: ${zodValidationError.fromError(inputs.error).toString()}`
|
|
10994
|
+
});
|
|
10995
|
+
}
|
|
10996
|
+
if (inputs.data.upToDate) return answer(200, {
|
|
10997
|
+
status: "up-to-date"
|
|
10998
|
+
});
|
|
10999
|
+
if (!inputs.data.contentOnly.ok || inputs.data.siteHead === null) {
|
|
11000
|
+
return answer(409, {
|
|
11001
|
+
status: "needs-build",
|
|
11002
|
+
message: inputs.data.contentOnly.ok ? "The site has no commit to update from." : inputs.data.contentOnly.reason
|
|
11003
|
+
});
|
|
11004
|
+
}
|
|
11005
|
+
const modules = {};
|
|
11006
|
+
const withoutText = [];
|
|
11007
|
+
for (const [path, module] of Object.entries(inputs.data.modules)) {
|
|
11008
|
+
const moduleFilePath = path;
|
|
11009
|
+
if (module.text === null) {
|
|
11010
|
+
withoutText.push(moduleFilePath);
|
|
11011
|
+
continue;
|
|
11012
|
+
}
|
|
11013
|
+
modules[moduleFilePath] = {
|
|
11014
|
+
base: module.base,
|
|
11015
|
+
site: module.site,
|
|
11016
|
+
proposal: module.proposal,
|
|
11017
|
+
text: module.text
|
|
11018
|
+
};
|
|
11019
|
+
}
|
|
11020
|
+
// Decisions are about the values a person was shown. When the site or
|
|
11021
|
+
// the proposal moved since, they would settle values nobody saw, so the
|
|
11022
|
+
// merge is done again without them: what still conflicts is shown again.
|
|
11023
|
+
const {
|
|
11024
|
+
siteHead,
|
|
11025
|
+
proposalHead
|
|
11026
|
+
} = request.data;
|
|
11027
|
+
const decidedHere = siteHead === undefined || siteHead === inputs.data.siteHead && proposalHead === inputs.data.proposalHead;
|
|
11028
|
+
const merged = withoutText.length > 0 ? {
|
|
11029
|
+
status: "needs-full-merge",
|
|
11030
|
+
modules: withoutText
|
|
11031
|
+
} : mergeContent({
|
|
11032
|
+
modules,
|
|
11033
|
+
schemas: await this.getSchemas(),
|
|
11034
|
+
resolutions: decidedHere ? request.data.resolutions : []
|
|
11035
|
+
});
|
|
11036
|
+
if (merged.status === "conflicts") {
|
|
11037
|
+
return answer(200, {
|
|
11038
|
+
status: "conflicts",
|
|
11039
|
+
conflicts: merged.conflicts,
|
|
11040
|
+
siteHead: inputs.data.siteHead,
|
|
11041
|
+
proposalHead: inputs.data.proposalHead
|
|
11042
|
+
});
|
|
11043
|
+
}
|
|
11044
|
+
if (merged.status === "needs-full-merge") {
|
|
11045
|
+
return answer(409, {
|
|
11046
|
+
status: "needs-full-merge",
|
|
11047
|
+
modules: merged.modules,
|
|
11048
|
+
message: `${merged.modules.join(", ")} changed both here and on the site in a way this version cannot merge yet.`
|
|
11049
|
+
});
|
|
11050
|
+
}
|
|
11051
|
+
const written = await content("/merge-from-site", {
|
|
11052
|
+
method: "POST",
|
|
11053
|
+
body: JSON.stringify({
|
|
11054
|
+
siteHead: inputs.data.siteHead,
|
|
11055
|
+
proposalHead: inputs.data.proposalHead,
|
|
11056
|
+
files: merged.files,
|
|
11057
|
+
modules: merged.modules
|
|
11058
|
+
})
|
|
11059
|
+
});
|
|
11060
|
+
const text = await written.text();
|
|
11061
|
+
if (!written.ok) {
|
|
10218
11062
|
return {
|
|
10219
|
-
status:
|
|
11063
|
+
status: written.status,
|
|
10220
11064
|
contentType: json,
|
|
10221
|
-
body:
|
|
10222
|
-
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."
|
|
10223
|
-
})
|
|
11065
|
+
body: text
|
|
10224
11066
|
};
|
|
10225
11067
|
}
|
|
10226
|
-
|
|
10227
|
-
|
|
11068
|
+
return updatedAnswer(text);
|
|
11069
|
+
}
|
|
11070
|
+
|
|
11071
|
+
/**
|
|
11072
|
+
* Update from the site, for a proposal with code (valbuild/home
|
|
11073
|
+
* `docs/hosted-publishing.md`, step 8.2, slice 1b), at
|
|
11074
|
+
* `POST /<name>/update-code`. The builder tab has read content's trees
|
|
11075
|
+
* (`GET /<name>/merge-trees`, forwarded) and evaluated them; this reads the
|
|
11076
|
+
* trees again -- the tab's evaluation is of the trees content has, or it is
|
|
11077
|
+
* of nothing -- and does the text (`updateWithCode`). The first call
|
|
11078
|
+
* answers the merge for the tab to evaluate; the second, with that
|
|
11079
|
+
* evaluation, commits it on the proposal's branch, staging its code.
|
|
11080
|
+
*
|
|
11081
|
+
* Only at the proposal's own address, as every write to its files is.
|
|
11082
|
+
*/
|
|
11083
|
+
async updateCodeFromSite(name, body, profileId) {
|
|
11084
|
+
var _this$proposal3;
|
|
11085
|
+
const json = "application/json";
|
|
11086
|
+
const answer = (status, value) => ({
|
|
11087
|
+
status,
|
|
11088
|
+
contentType: json,
|
|
11089
|
+
body: JSON.stringify(value)
|
|
11090
|
+
});
|
|
11091
|
+
if (((_this$proposal3 = this.proposal) === null || _this$proposal3 === void 0 ? void 0 : _this$proposal3.name) !== name) {
|
|
11092
|
+
return answer(403, {
|
|
11093
|
+
message: "A proposal is updated from the site at its own address, where its files are written."
|
|
11094
|
+
});
|
|
11095
|
+
}
|
|
11096
|
+
let parsedBody;
|
|
11097
|
+
try {
|
|
11098
|
+
parsedBody = body === undefined || body === "" ? {} : JSON.parse(body);
|
|
11099
|
+
} catch {
|
|
11100
|
+
return answer(400, {
|
|
11101
|
+
message: "The update's body is not JSON."
|
|
11102
|
+
});
|
|
11103
|
+
}
|
|
11104
|
+
const request = UpdateCodeBody.safeParse(parsedBody);
|
|
11105
|
+
if (!request.success) {
|
|
11106
|
+
return answer(400, {
|
|
11107
|
+
message: `Invalid update: ${zodValidationError.fromError(request.error).toString()}`
|
|
11108
|
+
});
|
|
11109
|
+
}
|
|
11110
|
+
const content = (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
|
|
11111
|
+
...init,
|
|
10228
11112
|
headers: {
|
|
10229
11113
|
...this.authHeaders,
|
|
10230
|
-
/*
|
|
10231
|
-
* WHO is asking, as for a commit: the app's key names the project,
|
|
10232
|
-
* and a proposal is opened, renamed and closed by a person -- content
|
|
10233
|
-
* refuses those writes from a credential that names nobody.
|
|
10234
|
-
*/
|
|
10235
11114
|
...(profileId !== null ? {
|
|
10236
11115
|
"x-val-profile-id": profileId
|
|
10237
11116
|
} : {}),
|
|
10238
11117
|
...(init.body === undefined ? {} : {
|
|
10239
11118
|
"Content-Type": json
|
|
10240
11119
|
})
|
|
10241
|
-
}
|
|
10242
|
-
|
|
10243
|
-
|
|
11120
|
+
}
|
|
11121
|
+
});
|
|
11122
|
+
const read = await content("/merge-trees");
|
|
11123
|
+
if (!read.ok) {
|
|
11124
|
+
return {
|
|
11125
|
+
status: read.status,
|
|
11126
|
+
contentType: json,
|
|
11127
|
+
body: await read.text()
|
|
11128
|
+
};
|
|
11129
|
+
}
|
|
11130
|
+
const trees = MergeTrees.safeParse(await read.json());
|
|
11131
|
+
if (!trees.success) {
|
|
11132
|
+
return answer(502, {
|
|
11133
|
+
message: `Content answered the merge's trees in a shape this Val does not read: ${zodValidationError.fromError(trees.error).toString()}`
|
|
11134
|
+
});
|
|
11135
|
+
}
|
|
11136
|
+
const {
|
|
11137
|
+
siteHead,
|
|
11138
|
+
proposalHead,
|
|
11139
|
+
builds
|
|
11140
|
+
} = request.data;
|
|
11141
|
+
if (trees.data.siteHead !== siteHead || trees.data.proposalHead !== proposalHead || trees.data.builds.base !== builds.base || trees.data.builds.site !== builds.site) {
|
|
11142
|
+
return answer(409, {
|
|
11143
|
+
status: "moved",
|
|
11144
|
+
message: "The site or this proposal changed while this update was being made. Update from the site again."
|
|
11145
|
+
});
|
|
11146
|
+
}
|
|
11147
|
+
if (trees.data.upToDate) return answer(200, {
|
|
11148
|
+
status: "up-to-date"
|
|
11149
|
+
});
|
|
11150
|
+
if (siteHead === null) {
|
|
11151
|
+
return answer(409, {
|
|
11152
|
+
message: "The site has no commit to update from."
|
|
11153
|
+
});
|
|
11154
|
+
}
|
|
11155
|
+
const merged = updateWithCode({
|
|
11156
|
+
trees: trees.data,
|
|
11157
|
+
evaluated: request.data.evaluated,
|
|
11158
|
+
resolutions: request.data.resolutions,
|
|
11159
|
+
...(request.data.merged ? {
|
|
11160
|
+
merged: request.data.merged
|
|
11161
|
+
} : {})
|
|
11162
|
+
});
|
|
11163
|
+
if (merged.status === "stale") {
|
|
11164
|
+
return answer(409, {
|
|
11165
|
+
status: "stale",
|
|
11166
|
+
message: "What was checked is not this update any more. Update from the site again."
|
|
11167
|
+
});
|
|
11168
|
+
}
|
|
11169
|
+
if (merged.status !== "merged") return answer(200, merged);
|
|
11170
|
+
const written = await content("/merge-from-site", {
|
|
11171
|
+
method: "POST",
|
|
11172
|
+
body: JSON.stringify({
|
|
11173
|
+
siteHead,
|
|
11174
|
+
proposalHead,
|
|
11175
|
+
files: merged.files,
|
|
11176
|
+
modules: merged.modules,
|
|
11177
|
+
code: {
|
|
11178
|
+
build: builds.site,
|
|
11179
|
+
staged: merged.staged
|
|
11180
|
+
}
|
|
10244
11181
|
})
|
|
10245
11182
|
});
|
|
10246
|
-
|
|
10247
|
-
|
|
10248
|
-
|
|
10249
|
-
|
|
10250
|
-
|
|
11183
|
+
const text = await written.text();
|
|
11184
|
+
if (!written.ok) {
|
|
11185
|
+
return {
|
|
11186
|
+
status: written.status,
|
|
11187
|
+
contentType: json,
|
|
11188
|
+
body: text
|
|
11189
|
+
};
|
|
11190
|
+
}
|
|
11191
|
+
return updatedAnswer(text);
|
|
10251
11192
|
}
|
|
10252
11193
|
|
|
10253
11194
|
/**
|
|
@@ -10525,15 +11466,6 @@ class ValOpsHttp extends ValOps {
|
|
|
10525
11466
|
...(allPatchData.headVersion !== undefined ? {
|
|
10526
11467
|
headVersion: allPatchData.headVersion
|
|
10527
11468
|
} : {}),
|
|
10528
|
-
/*
|
|
10529
|
-
* Who holds what, read with the list and its version. On every stat, so
|
|
10530
|
-
* a Studio's view of the groups is never older than its view of the
|
|
10531
|
-
* chain — which is what lets a stage made in another browser show here
|
|
10532
|
-
* without a reload. See `OrderedPatches.patchGroups`.
|
|
10533
|
-
*/
|
|
10534
|
-
...(allPatchData.patchGroups !== undefined ? {
|
|
10535
|
-
patchGroups: allPatchData.patchGroups
|
|
10536
|
-
} : {}),
|
|
10537
11469
|
/*
|
|
10538
11470
|
* The PUBLISH head, which is not `commitSha`.
|
|
10539
11471
|
*
|
|
@@ -10602,8 +11534,8 @@ class ValOpsHttp extends ValOps {
|
|
|
10602
11534
|
|
|
10603
11535
|
/** The branch this server's patches are on, where it knows it. */
|
|
10604
11536
|
ownBranch() {
|
|
10605
|
-
var _this$
|
|
10606
|
-
return ((_this$
|
|
11537
|
+
var _this$proposal4, _this$git;
|
|
11538
|
+
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);
|
|
10607
11539
|
}
|
|
10608
11540
|
async getWebSocketNonce(profileId) {
|
|
10609
11541
|
return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
|
|
@@ -10900,437 +11832,62 @@ class ValOpsHttp extends ValOps {
|
|
|
10900
11832
|
updatedAt: deployment.updatedAt,
|
|
10901
11833
|
commitMessage: deployment.commitMessage
|
|
10902
11834
|
});
|
|
10903
|
-
}
|
|
10904
|
-
}
|
|
10905
|
-
/*
|
|
10906
|
-
* Membership, folded from the per-patch annotation onto the groups.
|
|
10907
|
-
*
|
|
10908
|
-
* From THIS response and not from `GET /patch-groups`, because only
|
|
10909
|
-
* this one is consistent with the list and the version beside it:
|
|
10910
|
-
* the content service reads all three in one transaction. A group
|
|
10911
|
-
* list fetched separately can be older or newer than the chain it is
|
|
10912
|
-
* shown with — and the Studio decides what an editor sees, and what
|
|
10913
|
-
* a publish ships, from the pair.
|
|
10914
|
-
*
|
|
10915
|
-
* A group holding none of the listed patches is still listed, with
|
|
10916
|
-
* no members: "groups exist and you hold nothing here" is a real
|
|
10917
|
-
* answer, and different from "this content service has no groups".
|
|
10918
|
-
*/
|
|
10919
|
-
let patchGroups;
|
|
10920
|
-
if (data.patchGroups !== undefined) {
|
|
10921
|
-
const members = new Map();
|
|
10922
|
-
for (const patch of data.patches) {
|
|
10923
|
-
for (const groupId of patch.patchGroupIds ?? []) {
|
|
10924
|
-
const list = members.get(groupId) ?? [];
|
|
10925
|
-
list.push(patch.patchId);
|
|
10926
|
-
members.set(groupId, list);
|
|
10927
|
-
}
|
|
10928
|
-
}
|
|
10929
|
-
patchGroups = data.patchGroups.map(group => ({
|
|
10930
|
-
...group,
|
|
10931
|
-
patchIds: members.get(group.patchGroupId) ?? []
|
|
10932
|
-
}));
|
|
10933
|
-
}
|
|
10934
|
-
return {
|
|
10935
|
-
commits,
|
|
10936
|
-
deployments,
|
|
10937
|
-
patches,
|
|
10938
|
-
errors,
|
|
10939
|
-
...(patchGroups !== undefined ? {
|
|
10940
|
-
patchGroups
|
|
10941
|
-
} : {}),
|
|
10942
|
-
...(data.headPatchId !== undefined ? {
|
|
10943
|
-
headPatchId: data.headPatchId
|
|
10944
|
-
} : {}),
|
|
10945
|
-
...(data.headVersion !== undefined ? {
|
|
10946
|
-
headVersion: data.headVersion
|
|
10947
|
-
} : {}),
|
|
10948
|
-
...(data.publishingPatches !== undefined ? {
|
|
10949
|
-
publishingPatches: data.publishingPatches
|
|
10950
|
-
} : {}),
|
|
10951
|
-
...(data.heldBackPatches !== undefined ? {
|
|
10952
|
-
heldBackPatches: data.heldBackPatches
|
|
10953
|
-
} : {})
|
|
10954
|
-
};
|
|
10955
|
-
}
|
|
10956
|
-
console.error("Could not parse patches response. Error: " + zodValidationError.fromError(parsed.error));
|
|
10957
|
-
return {
|
|
10958
|
-
patches,
|
|
10959
|
-
error: {
|
|
10960
|
-
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.`
|
|
10961
|
-
}
|
|
10962
|
-
};
|
|
10963
|
-
}
|
|
10964
|
-
console.error("Could not get patches. HTTP error: " + patchesRes.status + " " + patchesRes.statusText);
|
|
10965
|
-
if (patchesRes.status === 401) {
|
|
10966
|
-
return {
|
|
10967
|
-
patches,
|
|
10968
|
-
error: {
|
|
10969
|
-
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."
|
|
10970
|
-
}
|
|
10971
|
-
};
|
|
10972
|
-
}
|
|
10973
|
-
return {
|
|
10974
|
-
patches,
|
|
10975
|
-
error: {
|
|
10976
|
-
message: "Could not get your changes. It is most likely due to a network issue. Check your network connection and please try again."
|
|
10977
|
-
}
|
|
10978
|
-
};
|
|
10979
|
-
} catch (err) {
|
|
10980
|
-
console.error("Could not get patches (connection error):", err instanceof Error ? err.message : JSON.stringify(err));
|
|
10981
|
-
return {
|
|
10982
|
-
patches: [],
|
|
10983
|
-
networkError: true,
|
|
10984
|
-
error: {
|
|
10985
|
-
message: `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}`
|
|
10986
|
-
}
|
|
10987
|
-
};
|
|
10988
|
-
}
|
|
10989
|
-
}
|
|
10990
|
-
|
|
10991
|
-
// #region patch groups
|
|
10992
|
-
/**
|
|
10993
|
-
* Add patches to a patch group.
|
|
10994
|
-
*
|
|
10995
|
-
* The set arrives closed by the client — `withPatchIds` is the prefix closure
|
|
10996
|
-
* over the patch sets the staged patches belong to. We forward it and do not
|
|
10997
|
-
* second-guess it: deriving the closure needs the content schema, which this
|
|
10998
|
-
* process does have but content.val.build does not, and having two
|
|
10999
|
-
* implementations of the rule would be worse than having one.
|
|
11000
|
-
*
|
|
11001
|
-
* Membership rows are stamped with `coreVersion` on the content side — the
|
|
11002
|
-
* same stamp the patch row carries — so which client wrote a row stays
|
|
11003
|
-
* legible after the fact.
|
|
11004
|
-
*/
|
|
11005
|
-
async stagePatches(
|
|
11006
|
-
/**
|
|
11007
|
-
* The group to stage into, or `undefined` for the caller's open group on
|
|
11008
|
-
* this branch — `~` on the content API, which creates it if there is none.
|
|
11009
|
-
*/
|
|
11010
|
-
patchGroupId, /** What the user asked to stage. */
|
|
11011
|
-
patchIds,
|
|
11012
|
-
/**
|
|
11013
|
-
* What has to come with it, because the staged patches are written on top
|
|
11014
|
-
* of it.
|
|
11015
|
-
*
|
|
11016
|
-
* The content API stores each membership row as `explicit` or `dependency`
|
|
11017
|
-
* and treats what it is not told about as a dependency. Folding the two
|
|
11018
|
-
* halves into `patchIds` therefore files the patch somebody clicked as one
|
|
11019
|
-
* the closure dragged in — the exact opposite of what happened, and the
|
|
11020
|
-
* only record anywhere of what the author chose.
|
|
11021
|
-
*/
|
|
11022
|
-
withPatchIds,
|
|
11023
|
-
/**
|
|
11024
|
-
* WHO is asking, so the content API can refuse a group that is not theirs.
|
|
11025
|
-
*
|
|
11026
|
-
* Every call from this class carries the app's API key, which says which
|
|
11027
|
-
* PROJECT is calling and nothing about which editor. Without this the
|
|
11028
|
-
* content API cannot tell one of a project's editors from another, so the
|
|
11029
|
-
* only check on stage and unstage is the one in `ValServer` — and anything
|
|
11030
|
-
* reaching the content API by another route (an API key, a PAT) has none at
|
|
11031
|
-
* all.
|
|
11032
|
-
*
|
|
11033
|
-
* `null` where there is no session. The content API refuses rather than
|
|
11034
|
-
* treating that as a match: a group written by an api key has a null author
|
|
11035
|
-
* too, and `null === null` must not read as ownership.
|
|
11036
|
-
*/
|
|
11037
|
-
authorId) {
|
|
11038
|
-
return this.mutatePatchGroup("POST",
|
|
11039
|
-
// Encoded: patchGroupId arrives in a request body, so an unencoded value
|
|
11040
|
-
// like "../../commit" would reach a different endpoint carrying this
|
|
11041
|
-
// project's auth headers.
|
|
11042
|
-
`patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
|
|
11043
|
-
patchIds,
|
|
11044
|
-
withPatchIds,
|
|
11045
|
-
coreVersion: core.Internal.VERSION.core,
|
|
11046
|
-
...this.ownGroupBranch()
|
|
11047
|
-
}, authorId);
|
|
11048
|
-
}
|
|
11049
|
-
|
|
11050
|
-
/**
|
|
11051
|
-
* Remove patches from a patch group.
|
|
11052
|
-
*
|
|
11053
|
-
* The set arrives closed FORWARDS by the client: unstaging a patch also
|
|
11054
|
-
* unstages everything built on top of it within its patch sets, and that is
|
|
11055
|
-
* what `withPatchIds` carries.
|
|
11056
|
-
*/
|
|
11057
|
-
async unstagePatches(/** See {@link stagePatches}. With no open group, there is nothing to remove. */
|
|
11058
|
-
patchGroupId, /** What the user asked to unstage. */
|
|
11059
|
-
patchIds, /** What has to go with it: everything built on top of it. */
|
|
11060
|
-
withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
|
|
11061
|
-
authorId) {
|
|
11062
|
-
return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
|
|
11063
|
-
patchIds,
|
|
11064
|
-
withPatchIds,
|
|
11065
|
-
...this.ownGroupBranch()
|
|
11066
|
-
}, authorId);
|
|
11067
|
-
}
|
|
11068
|
-
|
|
11069
|
-
/**
|
|
11070
|
-
* Every patch group on this branch, with what each holds.
|
|
11071
|
-
*
|
|
11072
|
-
* Read rather than mutated, and used to answer "which pending patches is THIS
|
|
11073
|
-
* person allowed to see". A draft render that skips this shows base + every
|
|
11074
|
-
* pending patch on the branch, including work other people have not
|
|
11075
|
-
* published — which is what independent publish exists to prevent.
|
|
11076
|
-
*
|
|
11077
|
-
* A failure is an empty list rather than a throw, and the caller decides what
|
|
11078
|
-
* that means. For a draft render the honest fallback is "show nothing
|
|
11079
|
-
* pending" rather than "show everything": being shown your own committed
|
|
11080
|
-
* content when the group lookup is down is a worse experience than being
|
|
11081
|
-
* shown somebody else's unpublished draft is a bug.
|
|
11082
|
-
*/
|
|
11083
|
-
/**
|
|
11084
|
-
* The last group lookup, and when it was made.
|
|
11085
|
-
*
|
|
11086
|
-
* A draft render calls `getPatchGroups` once per `fetchVal`, in series with
|
|
11087
|
-
* the whole-chain fetch, and a page that calls `fetchVal` several times pays
|
|
11088
|
-
* the round trip several times. Groups are per branch and change rarely, so a
|
|
11089
|
-
* short window removes the multiplier without letting a stage go unseen for
|
|
11090
|
-
* meaningfully longer than one render.
|
|
11091
|
-
*
|
|
11092
|
-
* Deliberately short. This is a read whose staleness decides whose draft
|
|
11093
|
-
* content someone sees, so it is a per-request de-duplication rather than a
|
|
11094
|
-
* cache: a second render a second later asks again.
|
|
11095
|
-
*/
|
|
11096
|
-
patchGroupsCache = null;
|
|
11097
|
-
|
|
11098
|
-
/**
|
|
11099
|
-
* Forget the remembered group list, after a write through this server.
|
|
11100
|
-
*
|
|
11101
|
-
* A write changes membership -- a save joins its author's group, a delete or
|
|
11102
|
-
* an unstage leaves it -- and the window above is long enough for the next
|
|
11103
|
-
* render to be served the list from before it: an editor who saved and
|
|
11104
|
-
* reloaded inside a second was shown their draft without the change they had
|
|
11105
|
-
* just made. Called once the write has answered, so a read that started
|
|
11106
|
-
* while it was in flight cannot leave the old list behind it.
|
|
11107
|
-
*
|
|
11108
|
-
* Only this instance's writes. Another instance's write is still seen at
|
|
11109
|
-
* most one window late, which is what the window is for.
|
|
11110
|
-
*/
|
|
11111
|
-
forgetPatchGroups() {
|
|
11112
|
-
this.patchGroupsCache = null;
|
|
11113
|
-
}
|
|
11114
|
-
async getPatchGroups(options) {
|
|
11115
|
-
const now = Date.now();
|
|
11116
|
-
if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
|
|
11117
|
-
return this.patchGroupsCache.res;
|
|
11118
|
-
}
|
|
11119
|
-
const res = await this.fetchPatchGroups();
|
|
11120
|
-
/*
|
|
11121
|
-
* ANSWERS are cached; failures are not.
|
|
11122
|
-
*
|
|
11123
|
-
* A transient failure held for a second is replayed to every caller in it,
|
|
11124
|
-
* and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
|
|
11125
|
-
* that refuses the stage, a scoped draft render falls back to base and
|
|
11126
|
-
* drops every pending patch on the page, and `GET /patches` omits the
|
|
11127
|
-
* annotation. One flaky request became a second of all three. The point of
|
|
11128
|
-
* this cache is to collapse the several `fetchVal` calls in one render into
|
|
11129
|
-
* one round trip, and an error is exactly the case worth retrying inside
|
|
11130
|
-
* that window rather than the case worth remembering.
|
|
11131
|
-
*
|
|
11132
|
-
* `unsupported` is cached with `ok` deliberately: it is a real answer about
|
|
11133
|
-
* the deployment — this content API predates patch groups — and it will not
|
|
11134
|
-
* change between two renders.
|
|
11135
|
-
*/
|
|
11136
|
-
if (res.status === "ok" || res.status === "unsupported") {
|
|
11137
|
-
this.patchGroupsCache = {
|
|
11138
|
-
at: now,
|
|
11139
|
-
res
|
|
11140
|
-
};
|
|
11141
|
-
}
|
|
11142
|
-
return res;
|
|
11143
|
-
}
|
|
11144
|
-
async fetchPatchGroups() {
|
|
11145
|
-
/*
|
|
11146
|
-
* A proposal has no patch groups: its changes are one unit, saved
|
|
11147
|
-
* together, and the content service answers 404 for groups on its branch.
|
|
11148
|
-
* "Unsupported" is exactly that answer, and it is what makes a draft in a
|
|
11149
|
-
* proposal show all of the proposal's changes rather than one person's.
|
|
11150
|
-
*/
|
|
11151
|
-
if (this.proposal !== null) {
|
|
11152
|
-
return {
|
|
11153
|
-
status: "unsupported"
|
|
11154
|
-
};
|
|
11155
|
-
}
|
|
11156
|
-
try {
|
|
11157
|
-
/*
|
|
11158
|
-
* `branch` is REQUIRED by the endpoint, which answers 400 without it.
|
|
11159
|
-
*
|
|
11160
|
-
* Same two params every other read here sends (`fetchPatchesInternal`,
|
|
11161
|
-
* `saveSourceFilePatch`): groups are per branch, so a request without one
|
|
11162
|
-
* is not merely under-specified, it is rejected.
|
|
11163
|
-
*/
|
|
11164
|
-
const branch = this.ownBranch();
|
|
11165
|
-
const params = new URLSearchParams(branch !== undefined ? [["branch", branch]] : []);
|
|
11166
|
-
const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
|
|
11167
|
-
headers: this.authHeaders
|
|
11168
|
-
});
|
|
11169
|
-
if (res.status === 404) {
|
|
11170
|
-
/*
|
|
11171
|
-
* The endpoint is not there, which is a content API that PREDATES patch
|
|
11172
|
-
* groups — not a failure.
|
|
11173
|
-
*
|
|
11174
|
-
* The distinction decides what a draft render shows, and collapsing it
|
|
11175
|
-
* into "error" is not a small mistake: a caller that reads an error as
|
|
11176
|
-
* "could not ask" renders BASE, so every existing http deployment would
|
|
11177
|
-
* silently drop all pending content from every draft preview. "There
|
|
11178
|
-
* are no groups here" has to mean unscoped, which is exactly the
|
|
11179
|
-
* behaviour those projects have today.
|
|
11180
|
-
*/
|
|
11181
|
-
return {
|
|
11182
|
-
status: "unsupported"
|
|
11183
|
-
};
|
|
11184
|
-
}
|
|
11185
|
-
if (!res.ok) {
|
|
11186
|
-
return {
|
|
11187
|
-
status: "error",
|
|
11188
|
-
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}`
|
|
11189
|
-
};
|
|
11190
|
-
}
|
|
11191
|
-
const parsed = PatchGroupsResponse.safeParse(await res.json());
|
|
11192
|
-
if (!parsed.success) {
|
|
11193
|
-
return {
|
|
11194
|
-
status: "error",
|
|
11195
|
-
message: `Could not parse patch groups response. Error: ${zodValidationError.fromError(parsed.error)}`
|
|
11196
|
-
};
|
|
11197
|
-
}
|
|
11198
|
-
return {
|
|
11199
|
-
status: "ok",
|
|
11200
|
-
patchGroups: parsed.data.patchGroups
|
|
11201
|
-
};
|
|
11202
|
-
} catch (err) {
|
|
11203
|
-
return {
|
|
11204
|
-
status: "error",
|
|
11205
|
-
message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
|
|
11206
|
-
};
|
|
11207
|
-
}
|
|
11208
|
-
}
|
|
11209
|
-
|
|
11210
|
-
/**
|
|
11211
|
-
* Which branch `~` means, where this build knows: the branch it reads
|
|
11212
|
-
* `applicable/patches` on, so the group staged into is the group listed.
|
|
11213
|
-
* Without one the content API uses the project's own, as it does for reads.
|
|
11214
|
-
*/
|
|
11215
|
-
ownGroupBranch() {
|
|
11216
|
-
const branch = this.ownBranch();
|
|
11217
|
-
return branch !== undefined ? {
|
|
11218
|
-
branch
|
|
11219
|
-
} : {};
|
|
11220
|
-
}
|
|
11221
|
-
async mutatePatchGroup(method, path, body,
|
|
11222
|
-
/**
|
|
11223
|
-
* WHO is asking. Sent as `x-val-profile-id`, which is what the content API
|
|
11224
|
-
* reads to decide whether this group is the caller's.
|
|
11225
|
-
*
|
|
11226
|
-
* `this.authHeaders` is the app's API key, and that names the PROJECT, not
|
|
11227
|
-
* the person — so without this the content API cannot resolve a profile
|
|
11228
|
-
* and refuses every stage and unstage with
|
|
11229
|
-
* "Cannot resolve the caller's profile". The group endpoints are the only
|
|
11230
|
-
* ones here that need it, because they are the only ones whose answer
|
|
11231
|
-
* depends on which of a project's editors is calling.
|
|
11232
|
-
*
|
|
11233
|
-
* Omitted when there is no session rather than sent empty: the content API
|
|
11234
|
-
* treats an unidentified caller as a refusal, which is what we want, and an
|
|
11235
|
-
* empty header would be a different and less obvious way to say it.
|
|
11236
|
-
*
|
|
11237
|
-
* A PAT already identifies a person, so `authHeaders` carries the identity
|
|
11238
|
-
* on its own there and this adds nothing.
|
|
11239
|
-
*/
|
|
11240
|
-
authorId) {
|
|
11241
|
-
if (this.proposal !== null) {
|
|
11242
|
-
return {
|
|
11243
|
-
status: 409,
|
|
11244
|
-
patchIds: [],
|
|
11245
|
-
error: {
|
|
11246
|
-
message: "A proposal's changes are saved together, so there is nothing to stage or unstage in one."
|
|
11247
|
-
}
|
|
11248
|
-
};
|
|
11249
|
-
}
|
|
11250
|
-
try {
|
|
11251
|
-
return await this.mutatePatchGroupRequest(method, path, body, authorId);
|
|
11252
|
-
} finally {
|
|
11253
|
-
this.forgetPatchGroups();
|
|
11254
|
-
}
|
|
11255
|
-
}
|
|
11256
|
-
async mutatePatchGroupRequest(method, path, body, authorId) {
|
|
11257
|
-
try {
|
|
11258
|
-
const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
|
|
11259
|
-
method,
|
|
11260
|
-
headers: {
|
|
11261
|
-
...this.authHeaders,
|
|
11262
|
-
...(authorId !== null ? {
|
|
11263
|
-
"x-val-profile-id": authorId
|
|
11264
|
-
} : {}),
|
|
11265
|
-
"Content-Type": "application/json"
|
|
11266
|
-
},
|
|
11267
|
-
body: JSON.stringify(body)
|
|
11268
|
-
});
|
|
11269
|
-
if (res.ok) {
|
|
11270
|
-
const parsed = PatchGroupMutationResponse.safeParse(await res.json());
|
|
11271
|
-
if (parsed.success) {
|
|
11835
|
+
}
|
|
11836
|
+
}
|
|
11272
11837
|
return {
|
|
11273
|
-
|
|
11274
|
-
|
|
11275
|
-
|
|
11276
|
-
|
|
11838
|
+
commits,
|
|
11839
|
+
deployments,
|
|
11840
|
+
patches,
|
|
11841
|
+
errors,
|
|
11842
|
+
...(data.headPatchId !== undefined ? {
|
|
11843
|
+
headPatchId: data.headPatchId
|
|
11844
|
+
} : {}),
|
|
11845
|
+
...(data.headVersion !== undefined ? {
|
|
11846
|
+
headVersion: data.headVersion
|
|
11847
|
+
} : {}),
|
|
11848
|
+
...(data.publishingPatches !== undefined ? {
|
|
11849
|
+
publishingPatches: data.publishingPatches
|
|
11850
|
+
} : {}),
|
|
11851
|
+
...(data.heldBackPatches !== undefined ? {
|
|
11852
|
+
heldBackPatches: data.heldBackPatches
|
|
11277
11853
|
} : {})
|
|
11278
11854
|
};
|
|
11279
11855
|
}
|
|
11856
|
+
console.error("Could not parse patches response. Error: " + zodValidationError.fromError(parsed.error));
|
|
11280
11857
|
return {
|
|
11281
|
-
|
|
11282
|
-
patchIds: [],
|
|
11283
|
-
error: {
|
|
11284
|
-
message: `Could not parse patch group response. Error: ${zodValidationError.fromError(parsed.error)}`
|
|
11285
|
-
}
|
|
11286
|
-
};
|
|
11287
|
-
}
|
|
11288
|
-
// 403 (not your group) and 409 (already published) are meaningful to the
|
|
11289
|
-
// client, so they are passed through rather than flattened to a 500.
|
|
11290
|
-
if (res.status === 403 || res.status === 409) {
|
|
11291
|
-
// `home` answers these with a JSON body, so `res.text()` put the literal
|
|
11292
|
-
// `{"message":"..."}` in front of the user. Every other branch in this
|
|
11293
|
-
// class unwraps it; this one now does too.
|
|
11294
|
-
return {
|
|
11295
|
-
status: res.status,
|
|
11296
|
-
patchIds: [],
|
|
11858
|
+
patches,
|
|
11297
11859
|
error: {
|
|
11298
|
-
message:
|
|
11860
|
+
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.`
|
|
11299
11861
|
}
|
|
11300
11862
|
};
|
|
11301
11863
|
}
|
|
11302
|
-
|
|
11303
|
-
|
|
11304
|
-
// 500 that sends the user looking at their own session.
|
|
11305
|
-
if (res.status === 401) {
|
|
11864
|
+
console.error("Could not get patches. HTTP error: " + patchesRes.status + " " + patchesRes.statusText);
|
|
11865
|
+
if (patchesRes.status === 401) {
|
|
11306
11866
|
return {
|
|
11307
|
-
|
|
11308
|
-
patchIds: [],
|
|
11867
|
+
patches,
|
|
11309
11868
|
error: {
|
|
11310
11869
|
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."
|
|
11311
11870
|
}
|
|
11312
11871
|
};
|
|
11313
11872
|
}
|
|
11314
11873
|
return {
|
|
11315
|
-
|
|
11316
|
-
patchIds: [],
|
|
11874
|
+
patches,
|
|
11317
11875
|
error: {
|
|
11318
|
-
message:
|
|
11876
|
+
message: "Could not get your changes. It is most likely due to a network issue. Check your network connection and please try again."
|
|
11319
11877
|
}
|
|
11320
11878
|
};
|
|
11321
11879
|
} catch (err) {
|
|
11880
|
+
console.error("Could not get patches (connection error):", err instanceof Error ? err.message : JSON.stringify(err));
|
|
11322
11881
|
return {
|
|
11323
|
-
|
|
11324
|
-
|
|
11882
|
+
patches: [],
|
|
11883
|
+
networkError: true,
|
|
11325
11884
|
error: {
|
|
11326
|
-
message: `
|
|
11885
|
+
message: `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}`
|
|
11327
11886
|
}
|
|
11328
11887
|
};
|
|
11329
11888
|
}
|
|
11330
11889
|
}
|
|
11331
|
-
|
|
11332
|
-
|
|
11333
|
-
async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
|
|
11890
|
+
async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
|
|
11334
11891
|
const baseSha = await this.getBaseSha();
|
|
11335
11892
|
return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
|
|
11336
11893
|
method: "POST",
|
|
@@ -11356,24 +11913,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11356
11913
|
commit: this.git.commit,
|
|
11357
11914
|
branch: this.git.branch
|
|
11358
11915
|
} : {}),
|
|
11359
|
-
coreVersion: core.Internal.VERSION.core
|
|
11360
|
-
/*
|
|
11361
|
-
* Group membership in the SAME request as the patch.
|
|
11362
|
-
*
|
|
11363
|
-
* The content API runs every refusal before its insert, so an invalid
|
|
11364
|
-
* closure is a 400 with nothing written. Spread rather than sent as
|
|
11365
|
-
* nulls: a client with no group omits the fields entirely, which is
|
|
11366
|
-
* what an older content API expects to see.
|
|
11367
|
-
*/
|
|
11368
|
-
...(patchGroup ? {
|
|
11369
|
-
// Only when the caller named one. Omitted, the content API
|
|
11370
|
-
// resolves this author's open group and creates it if absent,
|
|
11371
|
-
// which is what every write wants.
|
|
11372
|
-
...(patchGroup.patchGroupId !== undefined ? {
|
|
11373
|
-
patchGroupId: patchGroup.patchGroupId
|
|
11374
|
-
} : {}),
|
|
11375
|
-
withPatchIds: patchGroup.withPatchIds
|
|
11376
|
-
} : {})
|
|
11916
|
+
coreVersion: core.Internal.VERSION.core
|
|
11377
11917
|
})
|
|
11378
11918
|
}).then(async res => {
|
|
11379
11919
|
var _res$headers$get2;
|
|
@@ -11382,20 +11922,6 @@ class ValOpsHttp extends ValOps {
|
|
|
11382
11922
|
if (parsed.success) {
|
|
11383
11923
|
return fp.result.ok({
|
|
11384
11924
|
patchId: parsed.data.patchId,
|
|
11385
|
-
/*
|
|
11386
|
-
* Passed back to the client, which cannot learn it any other way.
|
|
11387
|
-
*
|
|
11388
|
-
* A write names no group — the content API resolves this author's
|
|
11389
|
-
* open group and CREATES it if absent — so on a fresh branch the
|
|
11390
|
-
* group comes into existence here and nowhere else. The chain
|
|
11391
|
-
* annotation is only re-read when a fetch has missing ids to ask
|
|
11392
|
-
* for, and a patch this client made is never missing, so without
|
|
11393
|
-
* this the tab that bootstrapped the group would never learn its
|
|
11394
|
-
* id and every stage would be a no-op.
|
|
11395
|
-
*/
|
|
11396
|
-
...(parsed.data.patchGroupId !== undefined ? {
|
|
11397
|
-
patchGroupId: parsed.data.patchGroupId
|
|
11398
|
-
} : {}),
|
|
11399
11925
|
...(parsed.data.headVersion !== undefined ? {
|
|
11400
11926
|
headVersion: parsed.data.headVersion
|
|
11401
11927
|
} : {})
|
|
@@ -11445,7 +11971,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11445
11971
|
errorType: "other",
|
|
11446
11972
|
message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
|
|
11447
11973
|
});
|
|
11448
|
-
})
|
|
11974
|
+
});
|
|
11449
11975
|
}
|
|
11450
11976
|
|
|
11451
11977
|
/**
|
|
@@ -11710,17 +12236,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11710
12236
|
}]
|
|
11711
12237
|
};
|
|
11712
12238
|
}
|
|
11713
|
-
async deletePatches(patchIds
|
|
11714
|
-
/**
|
|
11715
|
-
* Patches that are NOT deleted but must lose their group membership.
|
|
11716
|
-
*
|
|
11717
|
-
* Deleting a patch out of the middle of a patch set leaves every group
|
|
11718
|
-
* still holding the rest with a non-prefix intersection — the patches after
|
|
11719
|
-
* the hole were written against a view that had it. The content API cannot
|
|
11720
|
-
* work out which those are (it has no schema), so the client sends the
|
|
11721
|
-
* forward closure and it drops those memberships without deleting anything.
|
|
11722
|
-
*/
|
|
11723
|
-
unstagePatchIds) {
|
|
12239
|
+
async deletePatches(patchIds) {
|
|
11724
12240
|
return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
|
|
11725
12241
|
method: "DELETE",
|
|
11726
12242
|
headers: {
|
|
@@ -11728,10 +12244,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11728
12244
|
"Content-Type": "application/json"
|
|
11729
12245
|
},
|
|
11730
12246
|
body: JSON.stringify({
|
|
11731
|
-
patchIds
|
|
11732
|
-
...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
|
|
11733
|
-
unstagePatchIds
|
|
11734
|
-
} : {})
|
|
12247
|
+
patchIds
|
|
11735
12248
|
})
|
|
11736
12249
|
}).then(async res => {
|
|
11737
12250
|
if (res.ok) {
|
|
@@ -11774,24 +12287,9 @@ class ValOpsHttp extends ValOps {
|
|
|
11774
12287
|
message: `Could not delete patches (connection error?): ${e instanceof Error ? e.message : e.toString()}`
|
|
11775
12288
|
}
|
|
11776
12289
|
};
|
|
11777
|
-
})
|
|
12290
|
+
});
|
|
11778
12291
|
}
|
|
11779
|
-
async commit(prepared, message, committer, filesDirectory, newBranch
|
|
11780
|
-
/**
|
|
11781
|
-
* The patch group this commit EMPTIES, if it empties one.
|
|
11782
|
-
*
|
|
11783
|
-
* The content API closes the group it is given — and closes it WITHOUT
|
|
11784
|
-
* checking that the commit shipped all of it, so a caller that names a
|
|
11785
|
-
* group still holding work takes those patches out of every group and
|
|
11786
|
-
* leaves their author unable to publish them. The client therefore sends it
|
|
11787
|
-
* only when the publish accounts for everything the group still holds.
|
|
11788
|
-
*
|
|
11789
|
-
* Omitting it is not neutral: the commit still empties the group (the
|
|
11790
|
-
* content API drops applied ids from every group), but `published_at` is
|
|
11791
|
-
* never set, so the id is reused across publishes instead of a new group
|
|
11792
|
-
* per publish and the "already published" refusal can never fire.
|
|
11793
|
-
*/
|
|
11794
|
-
patchGroupId) {
|
|
12292
|
+
async commit(prepared, message, committer, filesDirectory, newBranch) {
|
|
11795
12293
|
try {
|
|
11796
12294
|
var _res$headers$get3;
|
|
11797
12295
|
/*
|
|
@@ -11805,23 +12303,10 @@ class ValOpsHttp extends ValOps {
|
|
|
11805
12303
|
headers: {
|
|
11806
12304
|
...this.authHeaders,
|
|
11807
12305
|
/*
|
|
11808
|
-
* WHO is publishing
|
|
11809
|
-
*
|
|
11810
|
-
*
|
|
11811
|
-
*
|
|
11812
|
-
* Closing a group is an ownership decision, so the content API
|
|
11813
|
-
* refuses a commit that names a `patchGroupId` it cannot attribute:
|
|
11814
|
-
* without this header `profileId` is `undefined` there and every
|
|
11815
|
-
* group-closing publish is 403 "Cannot resolve the caller's profile".
|
|
11816
|
-
* That is the NORMAL full publish, not an edge — `publish` names the
|
|
11817
|
-
* group whenever the commit empties it.
|
|
11818
|
-
*
|
|
11819
|
-
* Sent on every commit rather than only when a group is named. The
|
|
11820
|
-
* committer is a person either way, `committer` in the body already
|
|
11821
|
-
* says so, and a header that appears only on some commits is one more
|
|
11822
|
-
* conditional for a reader of either repo to reconstruct. It changes
|
|
11823
|
-
* nothing else: the content API reads it as an identity claim beside
|
|
11824
|
-
* the app's key and derives no scope from it.
|
|
12306
|
+
* WHO is publishing: `this.authHeaders` is the app's API key, which
|
|
12307
|
+
* names the PROJECT and not the person. The content API reads it as
|
|
12308
|
+
* an identity claim beside the app's key and derives no scope from
|
|
12309
|
+
* it; `committer` in the body says the same.
|
|
11825
12310
|
*/
|
|
11826
12311
|
"x-val-profile-id": committer,
|
|
11827
12312
|
"Content-Type": "application/json"
|
|
@@ -11866,10 +12351,7 @@ class ValOpsHttp extends ValOps {
|
|
|
11866
12351
|
baseSha: await this.getBaseSha(),
|
|
11867
12352
|
committer,
|
|
11868
12353
|
message,
|
|
11869
|
-
newBranch
|
|
11870
|
-
...(patchGroupId !== undefined ? {
|
|
11871
|
-
patchGroupId
|
|
11872
|
-
} : {})
|
|
12354
|
+
newBranch
|
|
11873
12355
|
})
|
|
11874
12356
|
});
|
|
11875
12357
|
if (res.ok) {
|
|
@@ -12126,6 +12608,146 @@ class ValOpsHttp extends ValOps {
|
|
|
12126
12608
|
// #endregion history
|
|
12127
12609
|
}
|
|
12128
12610
|
|
|
12611
|
+
/**
|
|
12612
|
+
* Content's answer to a merge it committed, as the Studio is answered: what
|
|
12613
|
+
* content says, marked updated. Content has written the merge by then, so an
|
|
12614
|
+
* answer that cannot be read says so rather than failing as though nothing
|
|
12615
|
+
* happened.
|
|
12616
|
+
*/
|
|
12617
|
+
function updatedAnswer(text) {
|
|
12618
|
+
let parsed;
|
|
12619
|
+
try {
|
|
12620
|
+
parsed = JSON.parse(text);
|
|
12621
|
+
} catch {
|
|
12622
|
+
parsed = undefined;
|
|
12623
|
+
}
|
|
12624
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
12625
|
+
return {
|
|
12626
|
+
status: 502,
|
|
12627
|
+
contentType: "application/json",
|
|
12628
|
+
body: JSON.stringify({
|
|
12629
|
+
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."
|
|
12630
|
+
})
|
|
12631
|
+
};
|
|
12632
|
+
}
|
|
12633
|
+
return {
|
|
12634
|
+
status: 200,
|
|
12635
|
+
contentType: "application/json",
|
|
12636
|
+
body: JSON.stringify({
|
|
12637
|
+
status: "updated",
|
|
12638
|
+
...parsed
|
|
12639
|
+
})
|
|
12640
|
+
};
|
|
12641
|
+
}
|
|
12642
|
+
|
|
12643
|
+
/** What the Studio sends to update a proposal from the site: its decisions. */
|
|
12644
|
+
const UpdateContentBody = z.z.object({
|
|
12645
|
+
/**
|
|
12646
|
+
* Where the site and the proposal were when the conflicts the
|
|
12647
|
+
* `resolutions` decide were found: the conflicts answer says. Absent with
|
|
12648
|
+
* no decisions.
|
|
12649
|
+
*/
|
|
12650
|
+
siteHead: z.z.string().nullable().optional(),
|
|
12651
|
+
proposalHead: z.z.string().nullable().optional(),
|
|
12652
|
+
resolutions: z.z.array(z.z.union([z.z.object({
|
|
12653
|
+
moduleFilePath: z.z.string().transform(p => p),
|
|
12654
|
+
path: z.z.array(z.z.string()),
|
|
12655
|
+
keep: z.z.enum(["site", "proposal"])
|
|
12656
|
+
}), z.z.object({
|
|
12657
|
+
moduleFilePath: z.z.string().transform(p => p),
|
|
12658
|
+
path: z.z.array(z.z.string()),
|
|
12659
|
+
value: internal.JSONValue
|
|
12660
|
+
})])).default([])
|
|
12661
|
+
});
|
|
12662
|
+
|
|
12663
|
+
/** A module, as the builder tab's evaluation of its tree gave it. */
|
|
12664
|
+
const EvaluatedModules = z.z.record(z.z.string().transform(p => p), z.z.object({
|
|
12665
|
+
schema: internal.SerializedSchema,
|
|
12666
|
+
written: internal.JSONValue,
|
|
12667
|
+
errors: z.z.array(z.z.object({
|
|
12668
|
+
path: z.z.array(z.z.string()),
|
|
12669
|
+
message: z.z.string()
|
|
12670
|
+
}))
|
|
12671
|
+
}));
|
|
12672
|
+
|
|
12673
|
+
/** What the builder tab sends to update a proposal with code from the site. */
|
|
12674
|
+
const UpdateCodeBody = z.z.object({
|
|
12675
|
+
siteHead: z.z.string().nullable(),
|
|
12676
|
+
proposalHead: z.z.string().nullable(),
|
|
12677
|
+
builds: z.z.object({
|
|
12678
|
+
base: z.z.string(),
|
|
12679
|
+
site: z.z.string()
|
|
12680
|
+
}),
|
|
12681
|
+
evaluated: z.z.object({
|
|
12682
|
+
base: EvaluatedModules,
|
|
12683
|
+
site: EvaluatedModules,
|
|
12684
|
+
proposal: EvaluatedModules
|
|
12685
|
+
}),
|
|
12686
|
+
resolutions: z.z.array(z.z.union([z.z.object({
|
|
12687
|
+
moduleFilePath: z.z.string().transform(p => p),
|
|
12688
|
+
path: z.z.array(z.z.string()),
|
|
12689
|
+
keep: z.z.enum(["site", "proposal"])
|
|
12690
|
+
}), z.z.object({
|
|
12691
|
+
moduleFilePath: z.z.string().transform(p => p),
|
|
12692
|
+
path: z.z.array(z.z.string()),
|
|
12693
|
+
value: internal.JSONValue
|
|
12694
|
+
}), z.z.object({
|
|
12695
|
+
file: z.z.string(),
|
|
12696
|
+
keep: z.z.enum(["site", "proposal"])
|
|
12697
|
+
}), z.z.object({
|
|
12698
|
+
file: z.z.string(),
|
|
12699
|
+
text: z.z.string()
|
|
12700
|
+
})])).default([]),
|
|
12701
|
+
/** The tab's evaluation of the merge the first call answered. */
|
|
12702
|
+
merged: z.z.object({
|
|
12703
|
+
id: z.z.string(),
|
|
12704
|
+
evaluated: EvaluatedModules
|
|
12705
|
+
}).optional()
|
|
12706
|
+
});
|
|
12707
|
+
|
|
12708
|
+
/** Content's `GET /proposals/:name/merge-trees`. */
|
|
12709
|
+
const MergeTrees = z.z.object({
|
|
12710
|
+
base: z.z.string().nullable(),
|
|
12711
|
+
siteHead: z.z.string().nullable(),
|
|
12712
|
+
proposalHead: z.z.string().nullable(),
|
|
12713
|
+
upToDate: z.z.boolean(),
|
|
12714
|
+
builds: z.z.object({
|
|
12715
|
+
base: z.z.string(),
|
|
12716
|
+
site: z.z.string()
|
|
12717
|
+
}),
|
|
12718
|
+
trees: z.z.object({
|
|
12719
|
+
site: z.z.record(z.z.string(), z.z.string()),
|
|
12720
|
+
base: z.z.record(z.z.string(), z.z.string().nullable()),
|
|
12721
|
+
proposal: z.z.record(z.z.string(), z.z.string().nullable())
|
|
12722
|
+
}),
|
|
12723
|
+
modules: z.z.record(z.z.string(), z.z.object({
|
|
12724
|
+
base: z.z.string().nullable(),
|
|
12725
|
+
site: z.z.string().nullable(),
|
|
12726
|
+
proposal: z.z.string().nullable()
|
|
12727
|
+
})),
|
|
12728
|
+
sources: z.z.record(z.z.string(), internal.JSONValue)
|
|
12729
|
+
});
|
|
12730
|
+
|
|
12731
|
+
/** Content's `GET /proposals/:name/merge-inputs`. */
|
|
12732
|
+
const MergeInputs = z.z.object({
|
|
12733
|
+
base: z.z.string().nullable(),
|
|
12734
|
+
siteHead: z.z.string().nullable(),
|
|
12735
|
+
proposalHead: z.z.string().nullable(),
|
|
12736
|
+
upToDate: z.z.boolean(),
|
|
12737
|
+
contentOnly: z.z.union([z.z.object({
|
|
12738
|
+
ok: z.z.literal(true)
|
|
12739
|
+
}), z.z.object({
|
|
12740
|
+
ok: z.z.literal(false),
|
|
12741
|
+
reason: z.z.string()
|
|
12742
|
+
})]),
|
|
12743
|
+
modules: z.z.record(z.z.string(), z.z.object({
|
|
12744
|
+
base: internal.JSONValue.nullable(),
|
|
12745
|
+
site: internal.JSONValue.nullable(),
|
|
12746
|
+
proposal: internal.JSONValue.nullable(),
|
|
12747
|
+
text: z.z.string().nullable()
|
|
12748
|
+
}))
|
|
12749
|
+
});
|
|
12750
|
+
|
|
12129
12751
|
/**
|
|
12130
12752
|
* One stored patch. Everything the ordered chain needs and nothing else.
|
|
12131
12753
|
*/
|
|
@@ -12415,8 +13037,7 @@ class ValOpsMemory extends ValOps {
|
|
|
12415
13037
|
}
|
|
12416
13038
|
async fetchPatches(filters) {
|
|
12417
13039
|
// An empty `patchIds` means "no filter", not "none": both shipped
|
|
12418
|
-
// implementations read it that way and callers rely on it.
|
|
12419
|
-
// `scopedModulePatches` in ValOps.ts, which says why at length.
|
|
13040
|
+
// implementations read it that way and callers rely on it.
|
|
12420
13041
|
const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
|
|
12421
13042
|
const order = await this.store.list();
|
|
12422
13043
|
const patches = [];
|
|
@@ -12462,12 +13083,7 @@ class ValOpsMemory extends ValOps {
|
|
|
12462
13083
|
* point at nothing. A patch computed against a different state is caught
|
|
12463
13084
|
* where it shows -- applying it -- rather than by refusing the write.
|
|
12464
13085
|
*/
|
|
12465
|
-
_parentRef, authorId, _sessionId
|
|
12466
|
-
/* No shared store and no second author yet, so nothing for a group to
|
|
12467
|
-
* separate. Named rather than omitted: TypeScript allows an implementation
|
|
12468
|
-
* to take fewer parameters, so dropping it would look identical to
|
|
12469
|
-
* handling it. */
|
|
12470
|
-
_patchGroup) {
|
|
13086
|
+
_parentRef, authorId, _sessionId) {
|
|
12471
13087
|
await this.store.append({
|
|
12472
13088
|
patchId,
|
|
12473
13089
|
path,
|
|
@@ -15232,195 +15848,25 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15232
15848
|
/*
|
|
15233
15849
|
* Every URL in the batch at once, and nothing beyond it.
|
|
15234
15850
|
*
|
|
15235
|
-
* The concurrency here is the batch size the route caps at, which is
|
|
15236
|
-
* what keeps "check a thousand links" from becoming a thousand
|
|
15237
|
-
* simultaneous outbound sockets: the client sends ten, waits, sends
|
|
15238
|
-
* the next ten. Doing them in parallel WITHIN a batch is what makes a
|
|
15239
|
-
* batch worth having — ten five-second timeouts in series is a minute.
|
|
15240
|
-
*/
|
|
15241
|
-
const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
|
|
15242
|
-
return {
|
|
15243
|
-
status: 200,
|
|
15244
|
-
json: {
|
|
15245
|
-
results: Object.fromEntries(results)
|
|
15246
|
-
}
|
|
15247
|
-
};
|
|
15248
|
-
}
|
|
15249
|
-
},
|
|
15250
|
-
"/direct-file-upload-settings": {
|
|
15251
|
-
POST: async req => {
|
|
15252
|
-
const cookies = req.cookies;
|
|
15253
|
-
const auth = getAuth(cookies);
|
|
15254
|
-
if (auth.error) {
|
|
15255
|
-
return {
|
|
15256
|
-
status: 401,
|
|
15257
|
-
json: {
|
|
15258
|
-
message: auth.error
|
|
15259
|
-
}
|
|
15260
|
-
};
|
|
15261
|
-
}
|
|
15262
|
-
if (serverOps.patchesAreLocal) {
|
|
15263
|
-
// In FS mode patch-file uploads are buffered through this server (no remote round-trip),
|
|
15264
|
-
// so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
|
|
15265
|
-
// content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
|
|
15266
|
-
// can POST directly without exposing the PAT.
|
|
15267
|
-
const host = `/api/val`;
|
|
15268
|
-
let contentBaseUrl = null;
|
|
15269
|
-
let contentAuthNonce = null;
|
|
15270
|
-
if (!options.project) {
|
|
15271
|
-
console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
|
|
15272
|
-
} else if (!(serverOps instanceof ValOpsFS)) {
|
|
15273
|
-
/*
|
|
15274
|
-
* Presigning needs a content host to presign AGAINST, and a local
|
|
15275
|
-
* store does not necessarily have one configured: `fs` mode has the
|
|
15276
|
-
* developer's `valContentUrl`, and a host holding its own source has
|
|
15277
|
-
* no such setting.
|
|
15278
|
-
*
|
|
15279
|
-
* Warned and disabled, like the missing-project case above, rather
|
|
15280
|
-
* than failing the request: everything else this route answers --
|
|
15281
|
-
* the patch upload base url -- still works, and the only thing lost
|
|
15282
|
-
* is the browser posting AI images straight to the content host.
|
|
15283
|
-
*/
|
|
15284
|
-
console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
|
|
15285
|
-
} else {
|
|
15286
|
-
const authDataRes = await getRemoteFileAuth();
|
|
15287
|
-
if (authDataRes.status !== 200) {
|
|
15288
|
-
console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
|
|
15289
|
-
} else {
|
|
15290
|
-
const corsOrigin = "*"; // TODO: add cors origin
|
|
15291
|
-
const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
|
|
15292
|
-
if (presignedAuthNonce.status === "success") {
|
|
15293
|
-
contentBaseUrl = presignedAuthNonce.data.baseUrl;
|
|
15294
|
-
contentAuthNonce = presignedAuthNonce.data.nonce;
|
|
15295
|
-
} else {
|
|
15296
|
-
console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
|
|
15297
|
-
}
|
|
15298
|
-
}
|
|
15299
|
-
}
|
|
15300
|
-
return {
|
|
15301
|
-
status: 200,
|
|
15302
|
-
json: {
|
|
15303
|
-
nonce: null,
|
|
15304
|
-
baseUrl: `${host}/upload`,
|
|
15305
|
-
// NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
|
|
15306
|
-
contentBaseUrl,
|
|
15307
|
-
contentAuthNonce
|
|
15308
|
-
}
|
|
15309
|
-
};
|
|
15310
|
-
}
|
|
15311
|
-
const httpOps = serverOps;
|
|
15312
|
-
const profileId = "id" in auth && auth.id ? auth.id : undefined;
|
|
15313
|
-
if (!profileId) {
|
|
15314
|
-
return {
|
|
15315
|
-
status: 401,
|
|
15316
|
-
json: {
|
|
15317
|
-
message: "Unauthorized"
|
|
15318
|
-
}
|
|
15319
|
-
};
|
|
15320
|
-
}
|
|
15321
|
-
const corsOrigin = "*"; // TODO: add cors origin
|
|
15322
|
-
const presignedAuthNonce = await httpOps.getPresignedAuthNonce(profileId, corsOrigin);
|
|
15323
|
-
if (presignedAuthNonce.status === "error") {
|
|
15324
|
-
return {
|
|
15325
|
-
status: presignedAuthNonce.statusCode,
|
|
15326
|
-
json: presignedAuthNonce.error
|
|
15327
|
-
};
|
|
15328
|
-
}
|
|
15329
|
-
return {
|
|
15330
|
-
status: 200,
|
|
15331
|
-
json: {
|
|
15332
|
-
nonce: presignedAuthNonce.data.nonce,
|
|
15333
|
-
baseUrl: presignedAuthNonce.data.baseUrl,
|
|
15334
|
-
contentBaseUrl: presignedAuthNonce.data.baseUrl,
|
|
15335
|
-
contentAuthNonce: presignedAuthNonce.data.nonce
|
|
15336
|
-
}
|
|
15337
|
-
};
|
|
15338
|
-
}
|
|
15339
|
-
},
|
|
15340
|
-
//#region patch groups
|
|
15341
|
-
// Staging and unstaging patches. Both take an already-closed set of patch ids:
|
|
15342
|
-
// the prefix closure (staging) or the forward closure (unstaging) is computed
|
|
15343
|
-
// on the client, which is the only side that has the schema needed to derive
|
|
15344
|
-
// patch sets. See `docs/independent-publish/PLAN.md`.
|
|
15345
|
-
//
|
|
15346
|
-
// In FS mode there is no shared store and exactly one author, so group
|
|
15347
|
-
// membership lives in the client and these handlers simply acknowledge. The
|
|
15348
|
-
// client already sends an explicit patch id list to `/save`, so a locally-held
|
|
15349
|
-
// group is enough to publish a subset correctly.
|
|
15350
|
-
"/patch-groups/~/patches": {
|
|
15351
|
-
PUT: async req => {
|
|
15352
|
-
const auth = getAuth(req.cookies);
|
|
15353
|
-
if (auth.error) {
|
|
15354
|
-
return {
|
|
15355
|
-
status: 401,
|
|
15356
|
-
json: {
|
|
15357
|
-
message: auth.error
|
|
15358
|
-
}
|
|
15359
|
-
};
|
|
15360
|
-
}
|
|
15361
|
-
const {
|
|
15362
|
-
patchGroupId,
|
|
15363
|
-
patchIds
|
|
15364
|
-
} = req.body;
|
|
15365
|
-
const withPatchIds = req.body.withPatchIds ?? [];
|
|
15366
|
-
if (serverOps.patchesAreLocal) {
|
|
15367
|
-
return {
|
|
15368
|
-
status: 200,
|
|
15369
|
-
json: {
|
|
15370
|
-
patchGroupId: patchGroupId ?? null,
|
|
15371
|
-
patchIds: [...patchIds, ...withPatchIds]
|
|
15372
|
-
}
|
|
15373
|
-
};
|
|
15374
|
-
}
|
|
15375
|
-
if (!("id" in auth) || !auth.id) {
|
|
15376
|
-
return {
|
|
15377
|
-
status: 401,
|
|
15378
|
-
json: {
|
|
15379
|
-
message: "Unauthorized"
|
|
15380
|
-
}
|
|
15381
|
-
};
|
|
15382
|
-
}
|
|
15383
|
-
// A named group is checked here as well as by the content API. No name
|
|
15384
|
-
// is the caller's own open group, which the content API resolves from
|
|
15385
|
-
// the profile it is sent — there is nothing here to check it against.
|
|
15386
|
-
if (patchGroupId !== undefined) {
|
|
15387
|
-
const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
|
|
15388
|
-
if (refusal !== null) {
|
|
15389
|
-
return {
|
|
15390
|
-
status: refusal.status,
|
|
15391
|
-
json: {
|
|
15392
|
-
message: refusal.message
|
|
15393
|
-
}
|
|
15394
|
-
};
|
|
15395
|
-
}
|
|
15396
|
-
}
|
|
15397
|
-
const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
|
|
15398
|
-
// Forwarded so the content API can refuse independently. This server
|
|
15399
|
-
// has already refused a group that is not the caller's; sending the
|
|
15400
|
-
// author means the check also holds for anything reaching the content
|
|
15401
|
-
// API without coming through here.
|
|
15402
|
-
auth.id);
|
|
15403
|
-
if (res.error) {
|
|
15404
|
-
return {
|
|
15405
|
-
status: res.status,
|
|
15406
|
-
json: {
|
|
15407
|
-
message: res.error.message
|
|
15408
|
-
}
|
|
15409
|
-
};
|
|
15410
|
-
}
|
|
15851
|
+
* The concurrency here is the batch size the route caps at, which is
|
|
15852
|
+
* what keeps "check a thousand links" from becoming a thousand
|
|
15853
|
+
* simultaneous outbound sockets: the client sends ten, waits, sends
|
|
15854
|
+
* the next ten. Doing them in parallel WITHIN a batch is what makes a
|
|
15855
|
+
* batch worth having — ten five-second timeouts in series is a minute.
|
|
15856
|
+
*/
|
|
15857
|
+
const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
|
|
15411
15858
|
return {
|
|
15412
15859
|
status: 200,
|
|
15413
15860
|
json: {
|
|
15414
|
-
|
|
15415
|
-
patchIds: res.patchIds,
|
|
15416
|
-
...(res.headVersion !== undefined ? {
|
|
15417
|
-
headVersion: res.headVersion
|
|
15418
|
-
} : {})
|
|
15861
|
+
results: Object.fromEntries(results)
|
|
15419
15862
|
}
|
|
15420
15863
|
};
|
|
15421
|
-
}
|
|
15422
|
-
|
|
15423
|
-
|
|
15864
|
+
}
|
|
15865
|
+
},
|
|
15866
|
+
"/direct-file-upload-settings": {
|
|
15867
|
+
POST: async req => {
|
|
15868
|
+
const cookies = req.cookies;
|
|
15869
|
+
const auth = getAuth(cookies);
|
|
15424
15870
|
if (auth.error) {
|
|
15425
15871
|
return {
|
|
15426
15872
|
status: 401,
|
|
@@ -15429,21 +15875,58 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15429
15875
|
}
|
|
15430
15876
|
};
|
|
15431
15877
|
}
|
|
15432
|
-
const {
|
|
15433
|
-
patchGroupId,
|
|
15434
|
-
patchIds
|
|
15435
|
-
} = req.body;
|
|
15436
|
-
const withPatchIds = req.body.withPatchIds ?? [];
|
|
15437
15878
|
if (serverOps.patchesAreLocal) {
|
|
15879
|
+
// In FS mode patch-file uploads are buffered through this server (no remote round-trip),
|
|
15880
|
+
// so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
|
|
15881
|
+
// content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
|
|
15882
|
+
// can POST directly without exposing the PAT.
|
|
15883
|
+
const host = `/api/val`;
|
|
15884
|
+
let contentBaseUrl = null;
|
|
15885
|
+
let contentAuthNonce = null;
|
|
15886
|
+
if (!options.project) {
|
|
15887
|
+
console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
|
|
15888
|
+
} else if (!(serverOps instanceof ValOpsFS)) {
|
|
15889
|
+
/*
|
|
15890
|
+
* Presigning needs a content host to presign AGAINST, and a local
|
|
15891
|
+
* store does not necessarily have one configured: `fs` mode has the
|
|
15892
|
+
* developer's `valContentUrl`, and a host holding its own source has
|
|
15893
|
+
* no such setting.
|
|
15894
|
+
*
|
|
15895
|
+
* Warned and disabled, like the missing-project case above, rather
|
|
15896
|
+
* than failing the request: everything else this route answers --
|
|
15897
|
+
* the patch upload base url -- still works, and the only thing lost
|
|
15898
|
+
* is the browser posting AI images straight to the content host.
|
|
15899
|
+
*/
|
|
15900
|
+
console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
|
|
15901
|
+
} else {
|
|
15902
|
+
const authDataRes = await getRemoteFileAuth();
|
|
15903
|
+
if (authDataRes.status !== 200) {
|
|
15904
|
+
console.warn("Direct content-host uploads (AI images) disabled: " + authDataRes.json.message);
|
|
15905
|
+
} else {
|
|
15906
|
+
const corsOrigin = "*"; // TODO: add cors origin
|
|
15907
|
+
const presignedAuthNonce = await serverOps.getPresignedAuthNonce(options.project, corsOrigin, authDataRes.json.remoteFileAuth);
|
|
15908
|
+
if (presignedAuthNonce.status === "success") {
|
|
15909
|
+
contentBaseUrl = presignedAuthNonce.data.baseUrl;
|
|
15910
|
+
contentAuthNonce = presignedAuthNonce.data.nonce;
|
|
15911
|
+
} else {
|
|
15912
|
+
console.warn("Direct content-host uploads (AI images) disabled: " + presignedAuthNonce.error.message);
|
|
15913
|
+
}
|
|
15914
|
+
}
|
|
15915
|
+
}
|
|
15438
15916
|
return {
|
|
15439
15917
|
status: 200,
|
|
15440
15918
|
json: {
|
|
15441
|
-
|
|
15442
|
-
|
|
15919
|
+
nonce: null,
|
|
15920
|
+
baseUrl: `${host}/upload`,
|
|
15921
|
+
// NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it
|
|
15922
|
+
contentBaseUrl,
|
|
15923
|
+
contentAuthNonce
|
|
15443
15924
|
}
|
|
15444
15925
|
};
|
|
15445
15926
|
}
|
|
15446
|
-
|
|
15927
|
+
const httpOps = serverOps;
|
|
15928
|
+
const profileId = "id" in auth && auth.id ? auth.id : undefined;
|
|
15929
|
+
if (!profileId) {
|
|
15447
15930
|
return {
|
|
15448
15931
|
status: 401,
|
|
15449
15932
|
json: {
|
|
@@ -15451,37 +15934,21 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15451
15934
|
}
|
|
15452
15935
|
};
|
|
15453
15936
|
}
|
|
15454
|
-
|
|
15455
|
-
|
|
15456
|
-
|
|
15457
|
-
if (patchGroupId !== undefined) {
|
|
15458
|
-
const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
|
|
15459
|
-
if (refusal !== null) {
|
|
15460
|
-
return {
|
|
15461
|
-
status: refusal.status,
|
|
15462
|
-
json: {
|
|
15463
|
-
message: refusal.message
|
|
15464
|
-
}
|
|
15465
|
-
};
|
|
15466
|
-
}
|
|
15467
|
-
}
|
|
15468
|
-
const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
|
|
15469
|
-
if (res.error) {
|
|
15937
|
+
const corsOrigin = "*"; // TODO: add cors origin
|
|
15938
|
+
const presignedAuthNonce = await httpOps.getPresignedAuthNonce(profileId, corsOrigin);
|
|
15939
|
+
if (presignedAuthNonce.status === "error") {
|
|
15470
15940
|
return {
|
|
15471
|
-
status:
|
|
15472
|
-
json:
|
|
15473
|
-
message: res.error.message
|
|
15474
|
-
}
|
|
15941
|
+
status: presignedAuthNonce.statusCode,
|
|
15942
|
+
json: presignedAuthNonce.error
|
|
15475
15943
|
};
|
|
15476
15944
|
}
|
|
15477
15945
|
return {
|
|
15478
15946
|
status: 200,
|
|
15479
15947
|
json: {
|
|
15480
|
-
|
|
15481
|
-
|
|
15482
|
-
|
|
15483
|
-
|
|
15484
|
-
} : {})
|
|
15948
|
+
nonce: presignedAuthNonce.data.nonce,
|
|
15949
|
+
baseUrl: presignedAuthNonce.data.baseUrl,
|
|
15950
|
+
contentBaseUrl: presignedAuthNonce.data.baseUrl,
|
|
15951
|
+
contentAuthNonce: presignedAuthNonce.data.nonce
|
|
15485
15952
|
}
|
|
15486
15953
|
};
|
|
15487
15954
|
}
|
|
@@ -15490,53 +15957,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15490
15957
|
"/patches": {
|
|
15491
15958
|
PUT: async req => {
|
|
15492
15959
|
const cookies = req.cookies;
|
|
15493
|
-
|
|
15494
|
-
/**
|
|
15495
|
-
* Group membership travels WITH the patch, in one request.
|
|
15496
|
-
*
|
|
15497
|
-
* Atomic on purpose: the content API runs every refusal before its
|
|
15498
|
-
* insert, so an invalid closure is a 400 with nothing written. Recording
|
|
15499
|
-
* membership in a second call would let a patch exist outside its
|
|
15500
|
-
* author's group whenever that call failed — and a patch outside your
|
|
15501
|
-
* own group is one you cannot publish until a repair puts it back.
|
|
15502
|
-
*
|
|
15503
|
-
*/
|
|
15504
|
-
/*
|
|
15505
|
-
* A membership is present if EITHER field is.
|
|
15506
|
-
*
|
|
15507
|
-
* The common case names no group: the content API resolves the author's
|
|
15508
|
-
* open group, creating it if absent, so the client never has to hold an
|
|
15509
|
-
* id across publishes. It still sends the closure, which is the part it
|
|
15510
|
-
* alone can compute.
|
|
15511
|
-
*
|
|
15512
|
-
* `patchGroupId` is nullable, so an explicit `null` means "no group
|
|
15513
|
-
* named" exactly as omitting it does — it is passed through as
|
|
15514
|
-
* `undefined` rather than becoming a membership keyed by null.
|
|
15515
|
-
*/
|
|
15516
|
-
const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
|
|
15517
|
-
const requestedWith = req.body.withPatchIds;
|
|
15518
|
-
const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
|
|
15519
|
-
...(requestedPatchGroupId !== undefined ? {
|
|
15520
|
-
patchGroupId: requestedPatchGroupId
|
|
15521
|
-
} : {}),
|
|
15522
|
-
withPatchIds: requestedWith ?? []
|
|
15523
|
-
} : undefined;
|
|
15524
|
-
if (patchGroup !== undefined && serverOps.patchesAreLocal) {
|
|
15525
|
-
/*
|
|
15526
|
-
* `fs` has no shared store and one author, so there is no group to
|
|
15527
|
-
* join. Refused rather than acknowledged: answering 200 would tell the
|
|
15528
|
-
* client its membership was recorded when it was dropped, and the
|
|
15529
|
-
* client would then believe a publish is scoped when it is not.
|
|
15530
|
-
*/
|
|
15531
|
-
return {
|
|
15532
|
-
status: 400,
|
|
15533
|
-
json: {
|
|
15534
|
-
type: "patch-error",
|
|
15535
|
-
message: "Patch groups are not available in fs mode. Omit the patch group fields.",
|
|
15536
|
-
errors: {}
|
|
15537
|
-
}
|
|
15538
|
-
};
|
|
15539
|
-
}
|
|
15540
15960
|
const auth = getAuth(cookies);
|
|
15541
15961
|
if (auth.error) {
|
|
15542
15962
|
return {
|
|
@@ -15559,17 +15979,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15559
15979
|
const sessionId = req.body.sessionId ?? null;
|
|
15560
15980
|
const authorId = "id" in auth ? auth.id : null;
|
|
15561
15981
|
const newPatchIds = [];
|
|
15562
|
-
/*
|
|
15563
|
-
* The group the content API put these patches in.
|
|
15564
|
-
*
|
|
15565
|
-
* Every patch in one request has the same author and the same
|
|
15566
|
-
* membership, so the last answer is the answer — they all land in the
|
|
15567
|
-
* same group. Reported back because the client cannot learn it any
|
|
15568
|
-
* other way: it names no group (the content API resolves the author's
|
|
15569
|
-
* open one, creating it if absent), and the chain annotation is only
|
|
15570
|
-
* re-read when a fetch has missing ids to ask for.
|
|
15571
|
-
*/
|
|
15572
|
-
let patchGroupIdFromStore;
|
|
15573
15982
|
/*
|
|
15574
15983
|
* The chain version the LAST patch made — the head this batch leaves.
|
|
15575
15984
|
* The client keeps the highest version it has seen, so a `/stat` read
|
|
@@ -15585,18 +15994,12 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15585
15994
|
const savedSoFar = () => ({
|
|
15586
15995
|
newPatchIds,
|
|
15587
15996
|
parentRef,
|
|
15588
|
-
// Absent rather than null where there are no groups: `fs` mode and
|
|
15589
|
-
// a content API that predates them both answer without one, and the
|
|
15590
|
-
// client reads absence as "staging is not available here".
|
|
15591
|
-
...(patchGroupIdFromStore !== undefined ? {
|
|
15592
|
-
patchGroupId: patchGroupIdFromStore
|
|
15593
|
-
} : {}),
|
|
15594
15997
|
...(headVersion !== undefined ? {
|
|
15595
15998
|
headVersion
|
|
15596
15999
|
} : {})
|
|
15597
16000
|
});
|
|
15598
16001
|
for (const patch of patches) {
|
|
15599
|
-
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId
|
|
16002
|
+
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
|
|
15600
16003
|
if (fp.result.isErr(createPatchRes)) {
|
|
15601
16004
|
if (createPatchRes.error.errorType === "patch-head-conflict") {
|
|
15602
16005
|
return {
|
|
@@ -15645,9 +16048,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15645
16048
|
patchId: createPatchRes.value.patchId
|
|
15646
16049
|
};
|
|
15647
16050
|
newPatchIds.push(createPatchRes.value.patchId);
|
|
15648
|
-
if (createPatchRes.value.patchGroupId !== undefined) {
|
|
15649
|
-
patchGroupIdFromStore = createPatchRes.value.patchGroupId;
|
|
15650
|
-
}
|
|
15651
16051
|
headVersion = createPatchRes.value.headVersion;
|
|
15652
16052
|
}
|
|
15653
16053
|
}
|
|
@@ -15714,84 +16114,16 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15714
16114
|
}
|
|
15715
16115
|
// TODO: we should sort by parentRef instead:
|
|
15716
16116
|
patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
15717
|
-
/**
|
|
15718
|
-
* Patch groups, ANNOTATED onto the chain rather than filtering it.
|
|
15719
|
-
*
|
|
15720
|
-
* The client computes a new patch's parent as the last id in this
|
|
15721
|
-
* response, so a filtered chain would make every client name a parent
|
|
15722
|
-
* that is not the real head and `POST /patches` would answer 409
|
|
15723
|
-
* forever. Annotate, never filter.
|
|
15724
|
-
*
|
|
15725
|
-
* Absent — not empty — where there are no groups: `fs` mode, a content
|
|
15726
|
-
* API that predates them, or a failed lookup. The client reads absence
|
|
15727
|
-
* as "this deployment has no groups" and leaves staging off, which is
|
|
15728
|
-
* the behaviour every project has today. An empty array would say
|
|
15729
|
-
* "groups exist and hold nothing", which would turn the staging UI on
|
|
15730
|
-
* with everything held.
|
|
15731
|
-
*/
|
|
15732
|
-
let patchGroups;
|
|
15733
|
-
if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
|
|
15734
|
-
/*
|
|
15735
|
-
* FRESH, because this answer makes a CLOSING decision.
|
|
15736
|
-
*
|
|
15737
|
-
* The client adopts this annotation as its group membership and
|
|
15738
|
-
* `emptiesOwnPatchGroup` then decides from it whether a publish may
|
|
15739
|
-
* name the group — and the content API closes what it is named
|
|
15740
|
-
* without checking. A one-second-old list is enough to get that
|
|
15741
|
-
* wrong: the same author writing in a second tab joins the open
|
|
15742
|
-
* group, the websocket pushes the chain immediately, so this tab's
|
|
15743
|
-
* fetch for the missing patch lands well inside the cache window and
|
|
15744
|
-
* reads a membership that is one patch short. It then publishes,
|
|
15745
|
-
* names the group, and closes it with the other tab's work still in
|
|
15746
|
-
* it — which leaves that tab wedged on 409 until a reload.
|
|
15747
|
-
*
|
|
15748
|
-
* `resolveOwnPatchScope` keeps the cache deliberately: that read
|
|
15749
|
-
* decides what a draft render SHOWS, it is repeated once per
|
|
15750
|
-
* `fetchVal` in a single render, and being a second stale there costs
|
|
15751
|
-
* a patch appearing late rather than a group closing early.
|
|
15752
|
-
*/
|
|
15753
|
-
const groupsRes = await serverOps.getPatchGroups({
|
|
15754
|
-
fresh: true
|
|
15755
|
-
});
|
|
15756
|
-
if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
|
|
15757
|
-
patchGroups = groupsRes.patchGroups;
|
|
15758
|
-
} else if (groupsRes.status === "error") {
|
|
15759
|
-
// Not fatal: the chain is what this endpoint is for, and staging
|
|
15760
|
-
// simply stays off for this read rather than the whole review
|
|
15761
|
-
// screen failing to load.
|
|
15762
|
-
console.error("Val: could not read patch groups", groupsRes.message);
|
|
15763
|
-
}
|
|
15764
|
-
}
|
|
15765
|
-
const groupIdsByPatchId = new Map();
|
|
15766
|
-
for (const group of patchGroups ?? []) {
|
|
15767
|
-
for (const patchId of group.patchIds) {
|
|
15768
|
-
const existing = groupIdsByPatchId.get(patchId);
|
|
15769
|
-
if (existing) {
|
|
15770
|
-
existing.push(group.patchGroupId);
|
|
15771
|
-
} else {
|
|
15772
|
-
groupIdsByPatchId.set(patchId, [group.patchGroupId]);
|
|
15773
|
-
}
|
|
15774
|
-
}
|
|
15775
|
-
}
|
|
15776
16117
|
return {
|
|
15777
16118
|
status: 200,
|
|
15778
16119
|
json: {
|
|
15779
|
-
patches
|
|
15780
|
-
const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
|
|
15781
|
-
return patchGroupIds ? {
|
|
15782
|
-
...patch,
|
|
15783
|
-
patchGroupIds
|
|
15784
|
-
} : patch;
|
|
15785
|
-
}),
|
|
15786
|
-
...(patchGroups ? {
|
|
15787
|
-
patchGroups
|
|
15788
|
-
} : {}),
|
|
16120
|
+
patches,
|
|
15789
16121
|
baseSha: await serverOps.getBaseSha()
|
|
15790
16122
|
}
|
|
15791
16123
|
};
|
|
15792
16124
|
},
|
|
15793
16125
|
DELETE: async req => {
|
|
15794
|
-
var _req$body
|
|
16126
|
+
var _req$body;
|
|
15795
16127
|
const query = req.query;
|
|
15796
16128
|
const cookies = req.cookies;
|
|
15797
16129
|
const auth = getAuth(cookies);
|
|
@@ -15812,25 +16144,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15812
16144
|
};
|
|
15813
16145
|
}
|
|
15814
16146
|
const ids = query.id;
|
|
15815
|
-
|
|
15816
|
-
* Which OTHER patches lose their group membership because these are
|
|
15817
|
-
* going. Only the client can COMPUTE it — that needs the patch sets,
|
|
15818
|
-
* which need the schema — but it is bounded here rather than trusted,
|
|
15819
|
-
* because the content API strips those memberships from every group
|
|
15820
|
-
* without an ownership check. See `boundUnstageClosure`.
|
|
15821
|
-
*
|
|
15822
|
-
* Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
|
|
15823
|
-
* client does not send it there.
|
|
15824
|
-
*/
|
|
15825
|
-
let unstagePatchIds;
|
|
15826
|
-
const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
|
|
15827
|
-
if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
|
|
15828
|
-
const chain = await serverOps.fetchPatches({
|
|
15829
|
-
excludePatchOps: true
|
|
15830
|
-
});
|
|
15831
|
-
unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
|
|
15832
|
-
}
|
|
15833
|
-
const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
|
|
16147
|
+
const deleteRes = await serverOps.deletePatches(ids);
|
|
15834
16148
|
if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
|
|
15835
16149
|
console.error("Val: Failed to delete patches", deleteRes.errors);
|
|
15836
16150
|
return {
|
|
@@ -15851,7 +16165,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15851
16165
|
}
|
|
15852
16166
|
};
|
|
15853
16167
|
}
|
|
15854
|
-
if (((_req$
|
|
16168
|
+
if (((_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.reportHeadVersion) === true) {
|
|
15855
16169
|
return {
|
|
15856
16170
|
status: 200,
|
|
15857
16171
|
json: {
|
|
@@ -15962,22 +16276,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15962
16276
|
} = req.query;
|
|
15963
16277
|
// Defaults to true, mirroring /sources/~. The Studio opts out.
|
|
15964
16278
|
const applyPatches = req.query.apply_patches !== false;
|
|
15965
|
-
/*
|
|
15966
|
-
* Whose pending work this render may see.
|
|
15967
|
-
*
|
|
15968
|
-
* The same resolution `/sources/~` runs, through the same function: a
|
|
15969
|
-
* draft page renders module content and `jsonValues` entries together,
|
|
15970
|
-
* and this route used to apply every pending patch on the branch while
|
|
15971
|
-
* the modules beside it were scoped — so one screen showed the caller's
|
|
15972
|
-
* view and everybody's unpublished work at once.
|
|
15973
|
-
*/
|
|
15974
|
-
const {
|
|
15975
|
-
ownPatchIds
|
|
15976
|
-
} = await resolveOwnPatchScope(serverOps, {
|
|
15977
|
-
explicitPatchIds: undefined,
|
|
15978
|
-
ownGroupsOnly: req.query.own_patch_groups_only === true,
|
|
15979
|
-
authorId: "id" in auth && auth.id || undefined
|
|
15980
|
-
});
|
|
15981
16279
|
const isWindow = offset !== undefined || limit !== undefined;
|
|
15982
16280
|
const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
|
|
15983
16281
|
if (shapes !== 1) {
|
|
@@ -15998,8 +16296,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
15998
16296
|
}
|
|
15999
16297
|
if (key !== undefined) {
|
|
16000
16298
|
const res = await serverOps.getJsonEntry(moduleFilePath, key, {
|
|
16001
|
-
applyPatches
|
|
16002
|
-
patchIds: ownPatchIds
|
|
16299
|
+
applyPatches
|
|
16003
16300
|
});
|
|
16004
16301
|
if (res.status === "unauthorized") {
|
|
16005
16302
|
return {
|
|
@@ -16040,8 +16337,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16040
16337
|
offset: offset,
|
|
16041
16338
|
limit: limit
|
|
16042
16339
|
}, {
|
|
16043
|
-
applyPatches
|
|
16044
|
-
patchIds: ownPatchIds
|
|
16340
|
+
applyPatches
|
|
16045
16341
|
});
|
|
16046
16342
|
if (res.status === "unauthorized") {
|
|
16047
16343
|
return {
|
|
@@ -16126,101 +16422,10 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16126
16422
|
patches: []
|
|
16127
16423
|
};
|
|
16128
16424
|
if (query.exclude_patches !== true) {
|
|
16129
|
-
|
|
16130
|
-
|
|
16131
|
-
|
|
16132
|
-
* A draft render cannot name its own group ids — it has no client
|
|
16133
|
-
* state — so it asks for "mine" and the server works out which. Only
|
|
16134
|
-
* when `patch_id` is absent: an explicit list is a caller that already
|
|
16135
|
-
* knows what it wants.
|
|
16136
|
-
*
|
|
16137
|
-
* A group lookup that FAILS renders base rather than everything. Being
|
|
16138
|
-
* shown only committed content while the content API is unreachable is
|
|
16139
|
-
* a degraded preview; being shown another author's unpublished draft
|
|
16140
|
-
* because a lookup failed is the bug this feature exists to prevent,
|
|
16141
|
-
* and it would be silent.
|
|
16142
|
-
*/
|
|
16143
|
-
const {
|
|
16144
|
-
ownPatchIds,
|
|
16145
|
-
scopeAlsoIncludesApplied
|
|
16146
|
-
} = await resolveOwnPatchScope(serverOps, {
|
|
16147
|
-
explicitPatchIds: query.patch_id,
|
|
16148
|
-
ownGroupsOnly: query.own_patch_groups_only === true,
|
|
16149
|
-
authorId: "id" in auth && auth.id || undefined
|
|
16425
|
+
patchOps = await serverOps.fetchPatches({
|
|
16426
|
+
patchIds: undefined,
|
|
16427
|
+
excludePatchOps: false
|
|
16150
16428
|
});
|
|
16151
|
-
const requestedPatchIds = query.patch_id ?? ownPatchIds;
|
|
16152
|
-
if (scopeAlsoIncludesApplied) {
|
|
16153
|
-
/*
|
|
16154
|
-
* Scoped to this caller's groups, PLUS everything already
|
|
16155
|
-
* committed.
|
|
16156
|
-
*
|
|
16157
|
-
* Filtered here rather than through `patchIds`, because the set is
|
|
16158
|
-
* not knowable before the fetch: `appliedAt` lives on the patch,
|
|
16159
|
-
* not on the group. One request either way — the whole chain is
|
|
16160
|
-
* what the unscoped path fetches too — so this costs a filter, not
|
|
16161
|
-
* a round trip.
|
|
16162
|
-
*
|
|
16163
|
-
* This also subsumes the empty-group case below: a caller holding
|
|
16164
|
-
* nothing, on a branch with nothing applied, filters down to no
|
|
16165
|
-
* patches, which is base.
|
|
16166
|
-
*/
|
|
16167
|
-
const all = await serverOps.fetchPatches({
|
|
16168
|
-
patchIds: undefined,
|
|
16169
|
-
excludePatchOps: false
|
|
16170
|
-
});
|
|
16171
|
-
/*
|
|
16172
|
-
* Membership from THIS response when it carries one, not from the
|
|
16173
|
-
* group list `resolveOwnPatchScope` read.
|
|
16174
|
-
*
|
|
16175
|
-
* That list is a separate request, remembered for a second
|
|
16176
|
-
* (`ValOpsHttp.getPatchGroups`), so it can be older than the chain
|
|
16177
|
-
* fetched here: an editor who saved and reloaded at once was
|
|
16178
|
-
* rendered without the patch they had just written, because the
|
|
16179
|
-
* list still had their group without it. The content service reads
|
|
16180
|
-
* the chain and each patch's groups in one transaction, and
|
|
16181
|
-
* `fetchPatches` folds that onto `patchGroups` -- so the patches
|
|
16182
|
-
* and who owns them come from the same moment.
|
|
16183
|
-
*/
|
|
16184
|
-
const own = ownPatchScopeFor(all, "id" in auth && auth.id || undefined, ownPatchIds);
|
|
16185
|
-
patchOps = {
|
|
16186
|
-
...all,
|
|
16187
|
-
patches: scopedPatches(all.patches, own)
|
|
16188
|
-
};
|
|
16189
|
-
} else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
|
|
16190
|
-
/*
|
|
16191
|
-
* A group that holds nothing renders base, and is handled HERE.
|
|
16192
|
-
*
|
|
16193
|
-
* `fetchPatches` cannot express it: both implementations read an
|
|
16194
|
-
* empty `patchIds` as "no filter" and return the whole chain
|
|
16195
|
-
* (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
|
|
16196
|
-
* `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
|
|
16197
|
-
* That is the right default for every caller that has ever passed a
|
|
16198
|
-
* list, since none of them can mean "none" — but it is the most
|
|
16199
|
-
* dangerous possible reading of an EXPLICITLY empty group, which
|
|
16200
|
-
* would render every unpublished patch on the branch instead of
|
|
16201
|
-
* base.
|
|
16202
|
-
*
|
|
16203
|
-
* Answered before the call rather than by changing that shared
|
|
16204
|
-
* default, which seven other call sites rely on.
|
|
16205
|
-
*/
|
|
16206
|
-
patchOps = {
|
|
16207
|
-
patches: []
|
|
16208
|
-
};
|
|
16209
|
-
} else {
|
|
16210
|
-
patchOps = await serverOps.fetchPatches({
|
|
16211
|
-
/*
|
|
16212
|
-
* The caller's patch group, when it named one.
|
|
16213
|
-
*
|
|
16214
|
-
* `undefined` means every pending patch, which is what every
|
|
16215
|
-
* existing caller gets and has to keep getting. A draft-mode
|
|
16216
|
-
* render that names its group gets base + that group instead, so
|
|
16217
|
-
* a server-rendered preview shows the same thing the person
|
|
16218
|
-
* editing is looking at rather than everybody's unpublished work.
|
|
16219
|
-
*/
|
|
16220
|
-
patchIds: requestedPatchIds,
|
|
16221
|
-
excludePatchOps: false
|
|
16222
|
-
});
|
|
16223
|
-
}
|
|
16224
16429
|
}
|
|
16225
16430
|
// We check authorization here, because it is the first call to the backend
|
|
16226
16431
|
if (patchOps.error && patchOps.unauthorized) {
|
|
@@ -16930,44 +17135,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16930
17135
|
};
|
|
16931
17136
|
} else if (serverOps instanceof ValOpsHttp) {
|
|
16932
17137
|
if (auth.error === undefined && auth.id) {
|
|
16933
|
-
/*
|
|
16934
|
-
* The group this commit CLOSES has to be the caller's.
|
|
16935
|
-
*
|
|
16936
|
-
* Stage and unstage go through `refuseUnlessOwn`; this route did
|
|
16937
|
-
* not, and the content API's `postCommit` marks the group published
|
|
16938
|
-
* on id alone with no author clause — so the id was trusted twice
|
|
16939
|
-
* and checked nowhere. Any logged-in editor could name a colleague's
|
|
16940
|
-
* open group and close it: their pending patches land in a closed
|
|
16941
|
-
* group and in no open one, so a scoped draft render shows base for
|
|
16942
|
-
* them, and their own tab still believes the group is open, so their
|
|
16943
|
-
* next stage is refused with 409.
|
|
16944
|
-
*
|
|
16945
|
-
* Same hole as the stage/unstage one this branch already closed, one
|
|
16946
|
-
* route over. The content API needs the same guard — this one is the
|
|
16947
|
-
* convenience, that one is what actually holds.
|
|
16948
|
-
*/
|
|
16949
|
-
if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
|
|
16950
|
-
const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
|
|
16951
|
-
if (refusal !== null) {
|
|
16952
|
-
return {
|
|
16953
|
-
status: refusal.status,
|
|
16954
|
-
json: {
|
|
16955
|
-
message: refusal.message,
|
|
16956
|
-
/*
|
|
16957
|
-
* Flagged, because a bare 409 here reads as git refusing
|
|
16958
|
-
* the commit — which is retryable, and this is the
|
|
16959
|
-
* opposite. `refuseUnlessOwn` answers 409 for one reason
|
|
16960
|
-
* only: the group has already been published, so its id
|
|
16961
|
-
* will never be writable again and retrying reproduces
|
|
16962
|
-
* this forever. The client forgets the id instead.
|
|
16963
|
-
*/
|
|
16964
|
-
...(refusal.status === 409 ? {
|
|
16965
|
-
patchGroupPublished: true
|
|
16966
|
-
} : {})
|
|
16967
|
-
}
|
|
16968
|
-
};
|
|
16969
|
-
}
|
|
16970
|
-
}
|
|
16971
17138
|
const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
|
|
16972
17139
|
/*
|
|
16973
17140
|
* Before the commit, because after it the files are no longer the
|
|
@@ -16975,14 +17142,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16975
17142
|
*/
|
|
16976
17143
|
const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
|
|
16977
17144
|
const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
|
|
16978
|
-
const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined
|
|
16979
|
-
/*
|
|
16980
|
-
* Forwarded verbatim, and only the client can decide it: the
|
|
16981
|
-
* content API closes the group it is named without checking
|
|
16982
|
-
* that the commit shipped all of it, and whether it did needs
|
|
16983
|
-
* the patch sets, which live in the browser.
|
|
16984
|
-
*/
|
|
16985
|
-
body.patchGroupId);
|
|
17145
|
+
const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined);
|
|
16986
17146
|
/*
|
|
16987
17147
|
* A host may publish somewhere other than the repository.
|
|
16988
17148
|
*
|
|
@@ -16995,7 +17155,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16995
17155
|
preparedCommit,
|
|
16996
17156
|
message,
|
|
16997
17157
|
authorId: auth.id,
|
|
16998
|
-
patchGroupId: body.patchGroupId,
|
|
16999
17158
|
commitToGit
|
|
17000
17159
|
}) : await commitToGit();
|
|
17001
17160
|
if (commitRes.error) {
|
|
@@ -18048,268 +18207,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
18048
18207
|
}
|
|
18049
18208
|
};
|
|
18050
18209
|
};
|
|
18051
|
-
/**
|
|
18052
|
-
* Refuse to touch a group that is not the caller's.
|
|
18053
|
-
*
|
|
18054
|
-
* Exported for `patchGroupOwnership.test.ts`: this is the whole of the
|
|
18055
|
-
* authorization for stage and unstage, and nothing else in the process checks
|
|
18056
|
-
* it, so it is worth testing as a policy rather than only through a route.
|
|
18057
|
-
*
|
|
18058
|
-
* `getAuth` only proves a session EXISTS; it says nothing about whose
|
|
18059
|
-
* group this is. And the content API cannot decide either — every call
|
|
18060
|
-
* from here carries the app's API key, not the editor's identity — so if
|
|
18061
|
-
* this does not check, nothing does.
|
|
18062
|
-
*
|
|
18063
|
-
* `GET /patches?include_patch_groups=true` hands every editor the id and
|
|
18064
|
-
* author of every open group on the branch, so without this any logged-in
|
|
18065
|
-
* editor can unstage another author's patches (their next publish
|
|
18066
|
-
* silently ships less) or stage into their group (it silently ships
|
|
18067
|
-
* more). The 403 declared for this route in `ApiRoutes.ts` was
|
|
18068
|
-
* unreachable.
|
|
18069
|
-
*
|
|
18070
|
-
* Fails CLOSED: if the groups cannot be read, the mutation is refused
|
|
18071
|
-
* rather than allowed unverified.
|
|
18072
|
-
*/
|
|
18073
|
-
|
|
18074
|
-
/**
|
|
18075
|
-
* The patches a scoped draft render should apply: the caller's own group, plus
|
|
18076
|
-
* everything already committed.
|
|
18077
|
-
*
|
|
18078
|
-
* Scoping is about PENDING work. A published patch stays in the chain with
|
|
18079
|
-
* `appliedAt` set until the next deployment moves the base, and it is part of
|
|
18080
|
-
* everyone's view in that window — the unscoped path applies it. Dropping it
|
|
18081
|
-
* meant the moment somebody published, their own draft preview reverted the
|
|
18082
|
-
* field they had just shipped, and nobody else saw it either until the deploy
|
|
18083
|
-
* landed; anything written on top in that window is authored against content
|
|
18084
|
-
* already stale on `main`.
|
|
18085
|
-
*
|
|
18086
|
-
* Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
|
|
18087
|
-
* PARTIAL publish leaves the group open with only some of its patches applied.
|
|
18088
|
-
* Those are committed too, and no flag on the group names them.
|
|
18089
|
-
*
|
|
18090
|
-
* `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
|
|
18091
|
-
* caller holding nothing, and on a branch with nothing applied it correctly
|
|
18092
|
-
* filters down to no patches, which renders base.
|
|
18093
|
-
*/
|
|
18094
|
-
function scopedPatches(patches, ownPatchIds) {
|
|
18095
|
-
const own = new Set(ownPatchIds ?? []);
|
|
18096
|
-
return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
|
|
18097
|
-
}
|
|
18098
|
-
|
|
18099
|
-
/**
|
|
18100
|
-
* Which of the client's `unstagePatchIds` this server is willing to forward.
|
|
18101
|
-
*
|
|
18102
|
-
* The forward closure of a discard is the client's to compute — it needs the
|
|
18103
|
-
* patch sets, which need the schema — and it was being forwarded verbatim. But
|
|
18104
|
-
* the content API removes those memberships from EVERY group with no ownership
|
|
18105
|
-
* check, so any logged-in editor could strip arbitrary patches out of any other
|
|
18106
|
-
* author's group by attaching them to a delete of one of their own throwaway
|
|
18107
|
-
* patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
|
|
18108
|
-
* reached by a different door: their next publish silently ships less.
|
|
18109
|
-
*
|
|
18110
|
-
* Neither server can compute the true closure, but this one can BOUND it. A
|
|
18111
|
-
* patch can only be invalidated by a delete if it was written after that delete
|
|
18112
|
-
* — its paths were chosen against a view that had it — and if it is in the same
|
|
18113
|
-
* module, since a patch set never spans two. Anything outside those bounds was
|
|
18114
|
-
* not in the closure whatever the client says, so it is dropped rather than
|
|
18115
|
-
* refused: the delete is still correct, and refusing the whole request over an
|
|
18116
|
-
* over-broad extra would turn a discard into an error the user cannot act on.
|
|
18117
|
-
*
|
|
18118
|
-
* Exported for the test. Pure, and given the chain rather than fetching it, so
|
|
18119
|
-
* the ordering it depends on is visible in the test rather than mocked.
|
|
18120
|
-
*/
|
|
18121
|
-
function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
|
|
18122
|
-
chain, deleted, requested) {
|
|
18123
|
-
if (requested.length === 0) {
|
|
18124
|
-
return [];
|
|
18125
|
-
}
|
|
18126
|
-
const positionOf = new Map();
|
|
18127
|
-
const moduleOf = new Map();
|
|
18128
|
-
chain.forEach((entry, index) => {
|
|
18129
|
-
positionOf.set(entry.patchId, index);
|
|
18130
|
-
moduleOf.set(entry.patchId, entry.path);
|
|
18131
|
-
});
|
|
18132
|
-
const doomed = new Set(deleted);
|
|
18133
|
-
return requested.filter(patchId => {
|
|
18134
|
-
// A patch being deleted anyway does not need its membership stripped
|
|
18135
|
-
// separately, and naming one is how an over-broad list hides.
|
|
18136
|
-
if (doomed.has(patchId)) return false;
|
|
18137
|
-
const position = positionOf.get(patchId);
|
|
18138
|
-
const moduleFilePath = moduleOf.get(patchId);
|
|
18139
|
-
if (position === undefined || moduleFilePath === undefined) return false;
|
|
18140
|
-
return deleted.some(deletedId => {
|
|
18141
|
-
const deletedPosition = positionOf.get(deletedId);
|
|
18142
|
-
if (deletedPosition === undefined) return false;
|
|
18143
|
-
return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
|
|
18144
|
-
});
|
|
18145
|
-
});
|
|
18146
|
-
}
|
|
18147
|
-
|
|
18148
|
-
/**
|
|
18149
|
-
* Which pending patches this caller may see, when they asked for "only mine".
|
|
18150
|
-
*
|
|
18151
|
-
* Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
|
|
18152
|
-
* both, so two answers to "whose work is this" put one person's half-finished
|
|
18153
|
-
* edit on another person's preview through whichever route was not scoped. That
|
|
18154
|
-
* is exactly what happened — `/json` applied every pending patch on the branch
|
|
18155
|
-
* while the module content beside it was scoped.
|
|
18156
|
-
*
|
|
18157
|
-
* `undefined` means "apply everything", which is what every caller that does
|
|
18158
|
-
* not ask for scoping gets and must keep getting.
|
|
18159
|
-
*/
|
|
18160
|
-
/** The patches in this author's OPEN groups: what a scoped draft shows. */
|
|
18161
|
-
function ownPatchIdsIn(patchGroups, authorId) {
|
|
18162
|
-
return patchGroups.filter(group => group.publishedAt === null && group.authorId === authorId).flatMap(group => group.patchIds);
|
|
18163
|
-
}
|
|
18164
|
-
|
|
18165
|
-
/**
|
|
18166
|
-
* Whose pending patches a scoped draft shows, taken from the SAME response as
|
|
18167
|
-
* the patches when it says, and from the list read beforehand only when not.
|
|
18168
|
-
*
|
|
18169
|
-
* See the call in `/sources/~`: the list read beforehand can be older than the
|
|
18170
|
-
* chain, and the response's membership cannot be.
|
|
18171
|
-
*/
|
|
18172
|
-
function ownPatchScopeFor(fetched, authorId, remembered) {
|
|
18173
|
-
if (fetched.patchGroups === undefined || authorId === undefined) {
|
|
18174
|
-
return remembered;
|
|
18175
|
-
}
|
|
18176
|
-
return ownPatchIdsIn(fetched.patchGroups, authorId);
|
|
18177
|
-
}
|
|
18178
|
-
async function resolveOwnPatchScope(
|
|
18179
|
-
/*
|
|
18180
|
-
* `ValOps`, not the two concrete stores: the one thing this needs is whether
|
|
18181
|
-
* a content service holds the groups, and it asks that below with an
|
|
18182
|
-
* `instanceof ValOpsHttp`. Naming the implementations here meant every new
|
|
18183
|
-
* store had to be added to a list to be allowed to call a function that does
|
|
18184
|
-
* not use it.
|
|
18185
|
-
*/
|
|
18186
|
-
serverOps, opts) {
|
|
18187
|
-
let ownPatchIds;
|
|
18188
|
-
/** See where this is set: committed work is nobody's to hold back. */
|
|
18189
|
-
let scopeAlsoIncludesApplied = false;
|
|
18190
|
-
if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
|
|
18191
|
-
if (serverOps instanceof ValOpsHttp && opts.authorId) {
|
|
18192
|
-
const groupsRes = await serverOps.getPatchGroups();
|
|
18193
|
-
if (groupsRes.status === "unsupported") {
|
|
18194
|
-
/*
|
|
18195
|
-
* A content API that PREDATES patch groups — the endpoint 404s.
|
|
18196
|
-
*
|
|
18197
|
-
* Unscoped, which is what those deployments do today and must
|
|
18198
|
-
* keep doing. Reading this as a failure and rendering base
|
|
18199
|
-
* would silently drop every pending patch from every draft
|
|
18200
|
-
* preview on every existing http project — the exact opposite
|
|
18201
|
-
* of "keeps working unchanged", and invisible to the reader.
|
|
18202
|
-
*/
|
|
18203
|
-
ownPatchIds = undefined;
|
|
18204
|
-
} else if (groupsRes.status === "error") {
|
|
18205
|
-
/*
|
|
18206
|
-
* We could not ask, and this deployment DOES have the endpoint.
|
|
18207
|
-
* Render base rather than everything: a degraded preview is
|
|
18208
|
-
* recoverable, showing another author's unpublished draft is
|
|
18209
|
-
* not, and it would be silent.
|
|
18210
|
-
*/
|
|
18211
|
-
ownPatchIds = [];
|
|
18212
|
-
} else if (groupsRes.patchGroups.length === 0) {
|
|
18213
|
-
/*
|
|
18214
|
-
* The branch has no groups AT ALL, so this deployment is not
|
|
18215
|
-
* using them — a content API that predates patch groups, or a
|
|
18216
|
-
* project where nobody has staged anything since they existed.
|
|
18217
|
-
*
|
|
18218
|
-
* Unscoped, which is the behaviour every such project has
|
|
18219
|
-
* today. Collapsing this into "your group is empty" would make
|
|
18220
|
-
* every draft render base and silently drop all pending
|
|
18221
|
-
* content, which is what happened before this branch: nothing
|
|
18222
|
-
* writes a group yet, so EVERY project is in this state right
|
|
18223
|
-
* now.
|
|
18224
|
-
*/
|
|
18225
|
-
ownPatchIds = undefined;
|
|
18226
|
-
} else {
|
|
18227
|
-
/*
|
|
18228
|
-
* Groups exist and none are this person's: they have staged
|
|
18229
|
-
* nothing, and base is the honest answer. Distinct from the
|
|
18230
|
-
* case above, which is why the two are not one expression.
|
|
18231
|
-
*/
|
|
18232
|
-
ownPatchIds = ownPatchIdsIn(groupsRes.patchGroups, opts.authorId);
|
|
18233
|
-
/*
|
|
18234
|
-
* Scoping applies to PENDING work only. Anything already
|
|
18235
|
-
* committed is part of everyone's view.
|
|
18236
|
-
*
|
|
18237
|
-
* A published patch stays in the chain with `appliedAt` set
|
|
18238
|
-
* until the next deployment moves the base, and the unscoped
|
|
18239
|
-
* path applies it. Filtering to open groups dropped it — so the
|
|
18240
|
-
* moment someone published, their own draft preview reverted
|
|
18241
|
-
* the field they had just shipped, and nobody else saw it
|
|
18242
|
-
* either until the deploy landed. Anything written on top in
|
|
18243
|
-
* that window is authored against content that is already
|
|
18244
|
-
* stale on `main`.
|
|
18245
|
-
*
|
|
18246
|
-
* Unioned by `appliedAt` rather than by pulling in groups with
|
|
18247
|
-
* a `publishedAt`, because a partial publish leaves the group
|
|
18248
|
-
* OPEN with some of its patches applied — those are committed
|
|
18249
|
-
* too, and no group flag names them.
|
|
18250
|
-
*/
|
|
18251
|
-
scopeAlsoIncludesApplied = true;
|
|
18252
|
-
}
|
|
18253
|
-
} else {
|
|
18254
|
-
/*
|
|
18255
|
-
* fs mode, or a server with no groups: there is nothing to scope
|
|
18256
|
-
* BY, and every pending patch is this one person's anyway. Left
|
|
18257
|
-
* `undefined` so the existing "apply everything" path runs.
|
|
18258
|
-
*/
|
|
18259
|
-
ownPatchIds = undefined;
|
|
18260
|
-
}
|
|
18261
|
-
}
|
|
18262
|
-
return {
|
|
18263
|
-
ownPatchIds,
|
|
18264
|
-
scopeAlsoIncludesApplied
|
|
18265
|
-
};
|
|
18266
|
-
}
|
|
18267
|
-
async function refuseUnlessOwn(ops, patchGroupId, authorId) {
|
|
18268
|
-
/*
|
|
18269
|
-
* Never from the cache. This answers "is this group yours", and a group is at
|
|
18270
|
-
* its youngest exactly when the question is asked — the first write creates
|
|
18271
|
-
* it and the shell flushes its queued stages the moment the save response
|
|
18272
|
-
* names it. A cached list fetched a few hundred milliseconds earlier does not
|
|
18273
|
-
* contain it, and every one of those stages was refused and dropped.
|
|
18274
|
-
*/
|
|
18275
|
-
const groupsRes = await ops.getPatchGroups({
|
|
18276
|
-
fresh: true
|
|
18277
|
-
});
|
|
18278
|
-
if (groupsRes.status !== "ok") {
|
|
18279
|
-
return {
|
|
18280
|
-
status: 500,
|
|
18281
|
-
message: "Could not verify that this patch group is yours, so it was not changed."
|
|
18282
|
-
};
|
|
18283
|
-
}
|
|
18284
|
-
const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
|
|
18285
|
-
if (group === undefined || group.authorId === null || group.authorId !== authorId) {
|
|
18286
|
-
/*
|
|
18287
|
-
* "Not found" and "not yours" are the SAME refusal on purpose: the route
|
|
18288
|
-
* schema has no 404, and `GET /patches` already lists every group on the
|
|
18289
|
-
* branch, so distinguishing them hides nothing and only adds a second
|
|
18290
|
-
* message to keep consistent.
|
|
18291
|
-
*
|
|
18292
|
-
* A null author is a group written by an api key or a PAT. Nobody owns it,
|
|
18293
|
-
* so nobody may stage into it — `null === null` must not read as a match.
|
|
18294
|
-
*/
|
|
18295
|
-
return {
|
|
18296
|
-
status: 403,
|
|
18297
|
-
message: "You can only change your own patch group"
|
|
18298
|
-
};
|
|
18299
|
-
}
|
|
18300
|
-
if (group.publishedAt !== null) {
|
|
18301
|
-
/*
|
|
18302
|
-
* Already shipped, so it can never be written again. The content API
|
|
18303
|
-
* answers this too; refusing here saves the round trip and keeps the
|
|
18304
|
-
* wording the same as every other refusal on this route.
|
|
18305
|
-
*/
|
|
18306
|
-
return {
|
|
18307
|
-
status: 409,
|
|
18308
|
-
message: "Patch group is already published"
|
|
18309
|
-
};
|
|
18310
|
-
}
|
|
18311
|
-
return null;
|
|
18312
|
-
}
|
|
18313
18210
|
function verifyCallbackReq(stateCookie, queryParams) {
|
|
18314
18211
|
if (typeof stateCookie !== "string") {
|
|
18315
18212
|
return {
|