@oada/client
Version:
A lightweight client tool to interact with an OADA-compliant server
211 lines • 7.25 kB
JavaScript
/**
* @license
* Copyright 2023 Open Ag Data Alliance
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from "debug";
import { EventEmitter } from "eventemitter3";
import { JSONPath } from "jsonpath-plus";
import { deserializeError } from "serialize-error";
import { buildChangeObject, changeSym } from "./utils.js";
const log = {
trace: debug("@oada/client/jobs:trace"),
error: debug("@oada/client/jobs:error"),
info: debug("@oada/client/jobs:info"),
fatal: debug("@oada/client/jobs:fatal"),
};
export class JobsRequest {
job;
oadaId;
oadaListKey;
oada;
#emitter;
// @ts-expect-error expect for now
#watch;
constructor({ oada, job }) {
this.job = job;
this.oada = oada;
this.#emitter = new EventEmitter();
}
async on(event, listener) {
this.#emitter.on(event, this.#wrapListener(event, listener));
}
async start() {
const pending = `/bookmarks/services/${this.job.service}/jobs/pending`;
const { headers } = await this.oada.post({
path: `/resources`,
data: this.job,
contentType: "application/vnd.oada.service.jobs.1+json",
});
const _id = headers["content-location"]?.replace(/^\//, "") ?? "";
const key = _id.replace(/^resources\//, "");
// Const { _id, key } = await postJob(this.oada, pending, this.job as Json);
this.oadaId = _id;
this.oadaListKey = key;
this.#watch = await this.#watchJob();
await this.oada.put({
path: `${pending}/${key}`,
data: {
_id,
},
});
return { _id, key };
}
/*
async postUpdate(update: string | Json, status: string): Promise<void> {
return postUpdate(this.oada, this.oadaId!, update, status || 'in-progress');
}
*/
#wrapListener(type, listener) {
return async (jobChange) => {
try {
await listener(jobChange);
}
catch (error) {
log.error({
type,
listener: listener.name,
error,
}, "Error in job listener");
}
};
}
async #emit(event) {
const getJob = this.#getJob.bind(this);
let jobP;
const out = {
get job() {
if (jobP === undefined) {
jobP = getJob();
}
return jobP;
},
};
switch (event) {
case JobEventType.Success: {
this.#emitter.emit(JobEventType.Success, out);
break;
}
case JobEventType.Status: {
this.#emitter.emit(JobEventType.Status, out);
break;
}
case JobEventType.Failure: {
this.#emitter.emit(JobEventType.Failure, out);
break;
}
case JobEventType.Result: {
this.#emitter.emit(JobEventType.Result, out);
break;
}
case JobEventType.Update: {
this.#emitter.emit(JobEventType.Update, out);
break;
}
default: {
this.#emitter.emit(event, out);
break;
}
}
}
async #watchJob() {
const result = await this.oada.watch({
path: `/${this.oadaId}`,
rev: 0,
type: "tree",
});
const { changes } = result;
// eslint-disable-next-line github/no-then
void this.#handleChangeFeed(changes).catch((error) => this.#emitter.emit("error", error));
log.info({ this: this }, "Job watch initialized");
return changes;
}
async #handleChangeFeed(watch) {
for await (const [rootChange, ...children] of watch) {
const changeBody = buildChangeObject(rootChange, ...children);
await this.#handleJobChanges(changeBody);
}
log.fatal("Change feed ended unexpectedly");
throw new Error("Change feed ended");
}
async #handleJobChanges(changeBody) {
// eslint-disable-next-line new-cap
const items = JSONPath({
resultType: "all",
path: `$`,
json: changeBody,
});
for await (const { value } of items) {
const { [changeSym]: changes } = value;
for await (const change of changes ?? []) {
log.trace({ change }, "Received change");
// TODO: determine whether we can get a resource at a particular rev
// Emit generic item change event
if (change?.body?.status === "success")
await this.#emit(JobEventType.Success);
if (change?.body?.status)
await this.#emit(JobEventType.Status);
if (change?.body?.status === "failure")
await this.#emit(JobEventType.Failure);
if (change.type === "merge" &&
change?.body?._rev >= 2 &&
change?.body?.result)
await this.#emit(JobEventType.Result);
if (change.type === "merge" && change?.body?.updates)
await this.#emit(JobEventType.Update);
}
}
}
async #getJob() {
// Needed because TS is weird about asserts...
// const assertJob: TypeAssert<Job> = this.#assertJob;
const { data } = await this.oada.get({
path: `/${this.oadaId}`,
});
// AssertJob(item);
return data;
}
}
export var JobEventType;
(function (JobEventType) {
// The job is finished; a status arrives
JobEventType["Status"] = "Status";
// Success status
JobEventType["Success"] = "Success";
// Fail status
JobEventType["Failure"] = "Failure";
// Result arrived
JobEventType["Result"] = "Result";
// Update comes into the job
JobEventType["Update"] = "Update";
/*
// Job completed, any status result
Done = 'Done',
*/
})(JobEventType || (JobEventType = {}));
// TODO: should this be a JobConfig?
export const doJob = async (oada, job) => new Promise((resolve, reject) => {
const jr = new JobsRequest({ oada, job });
jr.on(JobEventType.Status, async ({ job: jo }) => {
const index = await jo;
if (index.status === "success") {
resolve(index);
}
else if (index.status === "failure") {
reject(deserializeError(index.result));
}
}).catch(reject);
jr.start();
});
//# sourceMappingURL=jobs.js.map