From 811890443bcab7c0101f456c2999fb17ef4e3486 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 22 Aug 2026 18:17:36 +0700 Subject: [PATCH] refactor(server): improve URL change detection and context handling - Renamed `ensureDataObject` to `passContext` and added `mergeData` for better context management. - Updated `detectUrlChanges` to utilize the new context handling functions, improving data merging and response handling. - Enhanced evaluation of JSONata expressions with a new `evalCtx` function to streamline context passing. - Adjusted test assertions to reflect changes in output structure from `data` to `output`. Co-authored-by: Nasyarobby Putra --- packages/server/scripts/detect-url-changes.js | 80 ++++++++++++++----- .../server/test/detect-url-changes-smoke.js | 28 +++---- .../components/NewWorkflowPresetDialog.jsx | 6 +- 3 files changed, 79 insertions(+), 35 deletions(-) diff --git a/packages/server/scripts/detect-url-changes.js b/packages/server/scripts/detect-url-changes.js index a6e2059..47ad1e6 100644 --- a/packages/server/scripts/detect-url-changes.js +++ b/packages/server/scripts/detect-url-changes.js @@ -1,9 +1,17 @@ import jsonata from "jsonata"; -function ensureDataObject(ctx) { - if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { - ctx.data = {}; +function passContext(ctx) { + if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) { + return { ...ctx.context }; } + return {}; +} + +function mergeData(data) { + if (data != null && typeof data === "object" && !Array.isArray(data)) { + return { ...data }; + } + return {}; } const ALLOWED_METHODS = new Set([ @@ -35,6 +43,19 @@ function previewValue(value) { return { preview: `${json.slice(0, 500)}...`, truncated: true }; } +/** + * Eval context for fingerprint/transform JSONata (reads `data.*`). + * @param {unknown} ctx + * @param {Record} data + */ +function evalCtx(ctx, data) { + return { + data, + context: passContext(ctx), + config: ctx?.config ?? {}, + }; +} + async function detectUrlChanges(ctx) { const url = ctx.config?.url; if (typeof url !== "string" || url.length === 0) { @@ -63,7 +84,7 @@ async function detectUrlChanges(ctx) { ); } - ensureDataObject(ctx); + const data = mergeData(ctx.data); log.info({ url, method, key }, "detect-url-changes: fetching url"); const response = await $axios.request({ @@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) { "detect-url-changes: fetch complete", ); - ctx.data.httpResponse = response.data; + data.httpResponse = response.data; - let fingerprintSource = ctx.data.httpResponse; + let fingerprintSource = data.httpResponse; if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) { log.info( { jsonata: fingerprintExpr }, "detect-url-changes: evaluating fingerprint jsonata", ); - fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx); + fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data)); } const result = await $fingerprint.claim(key, fingerprintSource, { maxAge: ctx.config?.maxAge, }); - ctx.data.hasChanges = result.changed; - ctx.data.fingerprint = result.hash; - ctx.data.fingerprintChanged = result.changed; - ctx.data.fingerprintPrevious = result.previous; - ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt; - ctx.data.fingerprintAge = result.ageMs; - ctx.data.fingerprintExpired = result.expired; + const extra = { + hasChanges: result.changed, + fingerprint: result.hash, + fingerprintChanged: result.changed, + fingerprintPrevious: result.previous, + fingerprintAt: result.changed ? result.at : result.previousAt, + fingerprintAge: result.ageMs, + fingerprintExpired: result.expired, + }; + Object.assign(data, extra); log.info( { @@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) { { outputVar, jsonata: transformExpr }, "detect-url-changes: evaluating transform jsonata", ); - const transformed = await jsonata(transformExpr).evaluate(ctx); - ctx.data[outputVar] = transformed; + const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data)); + data[outputVar] = transformed; log.info( { outputVar, value: previewValue(transformed) }, "detect-url-changes: saved transform result", ); } else { - ctx.data[outputVar] = ctx.data.httpResponse; + data[outputVar] = data.httpResponse; log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar"); } } + /** @type {{ output: Record, context: Record, skipRemaining?: true }} */ + const envelope = { + output: data, + context: { ...passContext(ctx), ...extra }, + }; if (skipRemainingWhenUnchanged && !result.changed) { - ctx.skipRemaining = true; + envelope.skipRemaining = true; } - - return ctx; + return envelope; } detectUrlChanges.meta = { description: "Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run", + previewConfigKey: "url", + tags: ["HTTP"], + reads: "ctx", config: { url: { type: "string", required: true, description: "URL to fetch" }, method: { @@ -203,6 +234,15 @@ detectUrlChanges.meta = { fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" }, fingerprintExpired: { type: "boolean" }, }, + context: { + hasChanges: { type: "boolean" }, + fingerprint: { type: "string" }, + fingerprintChanged: { type: "boolean" }, + fingerprintPrevious: { type: "string", required: false }, + fingerprintAt: { type: "string", required: false }, + fingerprintAge: { type: "number", required: false }, + fingerprintExpired: { type: "boolean" }, + }, example: { data: {}, config: { diff --git a/packages/server/test/detect-url-changes-smoke.js b/packages/server/test/detect-url-changes-smoke.js index 1493458..654d6cc 100644 --- a/packages/server/test/detect-url-changes-smoke.js +++ b/packages/server/test/detect-url-changes-smoke.js @@ -51,21 +51,21 @@ async function freshUrl(pathname) { state.body = "v1"; const first = await run({ url }); - assert(first.data.hasChanges === true, "first run should report hasChanges=true"); + assert(first.output.hasChanges === true, "first run should report hasChanges=true"); assert( - first.data.httpResponse === "v1", + first.output.httpResponse === "v1", "httpResponse should hold the raw body", ); - assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set"); + assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set"); const second = await run({ url }); - assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false"); + assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false"); state.body = "v2 CHANGED"; const third = await run({ url }); - assert(third.data.hasChanges === true, "changed body should report hasChanges=true"); + assert(third.output.hasChanges === true, "changed body should report hasChanges=true"); assert( - third.data.fingerprintPrevious === second.data.fingerprint, + third.output.fingerprintPrevious === second.output.fingerprint, "fingerprintPrevious should equal the prior hash", ); } @@ -77,20 +77,20 @@ async function freshUrl(pathname) { state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" }; const first = await run({ url, fingerprint: "data.httpResponse.version" }); - assert(first.data.hasChanges === true, "json first run should report a change"); + assert(first.output.hasChanges === true, "json first run should report a change"); // Change only an unwatched field -> no change. state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" }; const second = await run({ url, fingerprint: "data.httpResponse.version" }); assert( - second.data.hasChanges === false, + second.output.hasChanges === false, "changing an unwatched field should not report a change", ); // Change the watched field -> change. state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" }; const third = await run({ url, fingerprint: "data.httpResponse.version" }); - assert(third.data.hasChanges === true, "changing the watched field should report a change"); + assert(third.output.hasChanges === true, "changing the watched field should report a change"); state.contentType = "text/html; charset=utf-8"; } @@ -106,13 +106,13 @@ async function freshUrl(pathname) { transform: '"changed=" & $string(data.hasChanges)', }); assert( - withTransform.data.message === "changed=true", - `transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`, + withTransform.output.message === "changed=true", + `transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`, ); const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" }); assert( - rawOutput.data.payload === rawOutput.data.httpResponse, + rawOutput.output.payload === rawOutput.output.httpResponse, "outputVar without transform should store the raw response", ); @@ -131,11 +131,11 @@ async function freshUrl(pathname) { state.body = "stable"; const first = await run({ url, skipRemaining: true }); - assert(first.data.hasChanges === true, "skip test first run should change"); + assert(first.output.hasChanges === true, "skip test first run should change"); assert(first.skipRemaining !== true, "changed run must not set skipRemaining"); const second = await run({ url, skipRemaining: true }); - assert(second.data.hasChanges === false, "skip test second run should be unchanged"); + assert(second.output.hasChanges === false, "skip test second run should be unchanged"); assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt"); // Default (skipRemaining off) never halts, so downstream can still notify. diff --git a/packages/web/src/components/NewWorkflowPresetDialog.jsx b/packages/web/src/components/NewWorkflowPresetDialog.jsx index c2fcf3a..4e20b8f 100644 --- a/packages/web/src/components/NewWorkflowPresetDialog.jsx +++ b/packages/web/src/components/NewWorkflowPresetDialog.jsx @@ -136,7 +136,11 @@ export function NewWorkflowPresetDialog({ onChoose, onCancel }) { type="checkbox" className="checkbox checkbox-sm" checked={dontAsk} - onChange={(e) => setDontAsk(e.target.checked)} + onChange={(e) => { + const checked = e.target.checked; + setDontAsk(checked); + if (checked) setMode("empty"); + }} /> Don't ask this. Always create empty workflow.