Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ coverage/
*.temp
.direnv
.env

# Embedded repo (nested clone — not a submodule)
jam-nodes/
19 changes: 16 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/nodes/src/integrations/dataforseo/seo-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,12 @@ export const seoAuditNode = defineNode({
let isPassed: boolean;

if (POSITIVE_CHECKS.has(checkId)) {
// Positive checks: passed=true means the good thing IS present (e.g. is_https)
isPassed = checkData.passed;
} else {
isPassed = checkData.passed;
// Normal checks: passed=true means the problem is ABSENT (i.e. the page is clean)
// DataForSEO sets passed=true to mean "no issue found", so invert for our tracking
isPassed = !checkData.passed;
}

if (isPassed) {
Expand Down
23 changes: 1 addition & 22 deletions packages/nodes/src/transform/filter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { defineNode } from '@jam-nodes/core';
import { resolvePath } from '../utils/resolve-path.js';

/**
* Filter operator schema
Expand Down Expand Up @@ -44,28 +45,6 @@ export const FilterOutputSchema = z.object({

export type FilterOutput = z.infer<typeof FilterOutputSchema>;

/**
* Resolve a nested path on an object
*/
function resolvePath(obj: unknown, path: string): unknown {
// Empty path means use the item itself
if (!path) {
return obj;
}

const parts = path.split('.');
let current: unknown = obj;

for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}

return current;
}

/**
* Evaluate filter condition
*/
Expand Down
29 changes: 1 addition & 28 deletions packages/nodes/src/transform/map.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { defineNode } from '@jam-nodes/core';
import { resolvePath } from '../utils/resolve-path.js';

/**
* Input schema for map node
Expand All @@ -23,34 +24,6 @@ export const MapOutputSchema = z.object({

export type MapOutput = z.infer<typeof MapOutputSchema>;

/**
* Resolve a nested path on an object
*/
function resolvePath(obj: unknown, path: string): unknown {
const parts = path.split('.');
let current: unknown = obj;

for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}

// Handle array access like "[0]"
const arrayMatch = part.match(/^\[(\d+)\]$/);
if (arrayMatch) {
if (Array.isArray(current)) {
current = current[parseInt(arrayMatch[1]!, 10)];
} else {
return undefined;
}
} else {
current = (current as Record<string, unknown>)[part];
}
}

return current;
}

/**
* Map node - extract a property from each item in an array.
*
Expand Down
8 changes: 8 additions & 0 deletions packages/nodes/src/utils/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export async function fetchWithRetry(
await sleep(delayMs);
continue;
}

// All retries exhausted — throw instead of returning the 429 response
const errorText = await response.text();
throw new FetchRetryError(
`Rate limit exceeded after ${maxRetries} attempts`,
429,
errorText
);
}

// Retry on server errors
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export {
FetchRetryError,
type FetchWithRetryOptions,
} from './http.js';

export { resolvePath } from './resolve-path.js';
61 changes: 61 additions & 0 deletions packages/nodes/src/utils/resolve-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Shared path resolution utility for transform nodes.
*
* Resolves a dot-notation path string against an arbitrary object.
* This mirrors the same logic as `ExecutionContext.resolveNestedPath`,
* but operates on any object rather than the workflow variable store.
*
* Supported syntax:
* - Dot notation: "contact.email"
* - Keyed array access: "contacts[0].name"
* - Standalone array index: "[0].name" (when current value is an array)
* - Empty path returns the object itself
*
* @example
* resolvePath({ a: { b: [1, 2] } }, 'a.b[1]') // → 2
* resolvePath([{ id: 1 }, { id: 2 }], '[0].id') // → 1
* resolvePath({ score: 42 }, '') // → { score: 42 }
*/
export function resolvePath(obj: unknown, path: string): unknown {
// Empty path means use the value itself
if (!path) {
return obj;
}

const parts = path.split('.');
let current: unknown = obj;

for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}

// Handle keyed array access like "contacts[0]"
const keyedArrayMatch = part.match(/^(\w+)\[(\d+)\]$/);
if (keyedArrayMatch) {
const [, key, index] = keyedArrayMatch;
current = (current as Record<string, unknown>)[key!];
if (Array.isArray(current)) {
current = current[parseInt(index!, 10)];
} else {
return undefined;
}
continue;
}

// Handle standalone array index like "[0]"
const standaloneIndexMatch = part.match(/^\[(\d+)\]$/);
if (standaloneIndexMatch) {
if (Array.isArray(current)) {
current = current[parseInt(standaloneIndexMatch[1]!, 10)];
} else {
return undefined;
}
continue;
}

current = (current as Record<string, unknown>)[part];
}

return current;
}