rowbound 1.0.2

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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/dist/adapters/adapter.d.ts +1 -0
  4. package/dist/adapters/adapter.js +1 -0
  5. package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
  6. package/dist/adapters/sheets/sheets-adapter.js +531 -0
  7. package/dist/cli/config.d.ts +2 -0
  8. package/dist/cli/config.js +397 -0
  9. package/dist/cli/env.d.ts +3 -0
  10. package/dist/cli/env.js +103 -0
  11. package/dist/cli/format.d.ts +5 -0
  12. package/dist/cli/format.js +6 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +39 -0
  15. package/dist/cli/init.d.ts +10 -0
  16. package/dist/cli/init.js +72 -0
  17. package/dist/cli/run.d.ts +2 -0
  18. package/dist/cli/run.js +212 -0
  19. package/dist/cli/runs.d.ts +2 -0
  20. package/dist/cli/runs.js +108 -0
  21. package/dist/cli/status.d.ts +2 -0
  22. package/dist/cli/status.js +108 -0
  23. package/dist/cli/sync.d.ts +2 -0
  24. package/dist/cli/sync.js +84 -0
  25. package/dist/cli/watch.d.ts +2 -0
  26. package/dist/cli/watch.js +348 -0
  27. package/dist/core/condition.d.ts +25 -0
  28. package/dist/core/condition.js +66 -0
  29. package/dist/core/defaults.d.ts +3 -0
  30. package/dist/core/defaults.js +7 -0
  31. package/dist/core/engine.d.ts +50 -0
  32. package/dist/core/engine.js +234 -0
  33. package/dist/core/env.d.ts +13 -0
  34. package/dist/core/env.js +72 -0
  35. package/dist/core/exec.d.ts +24 -0
  36. package/dist/core/exec.js +134 -0
  37. package/dist/core/extractor.d.ts +10 -0
  38. package/dist/core/extractor.js +33 -0
  39. package/dist/core/http-client.d.ts +32 -0
  40. package/dist/core/http-client.js +161 -0
  41. package/dist/core/rate-limiter.d.ts +25 -0
  42. package/dist/core/rate-limiter.js +64 -0
  43. package/dist/core/reconcile.d.ts +24 -0
  44. package/dist/core/reconcile.js +192 -0
  45. package/dist/core/run-format.d.ts +39 -0
  46. package/dist/core/run-format.js +201 -0
  47. package/dist/core/run-state.d.ts +64 -0
  48. package/dist/core/run-state.js +141 -0
  49. package/dist/core/run-tracker.d.ts +15 -0
  50. package/dist/core/run-tracker.js +57 -0
  51. package/dist/core/safe-compare.d.ts +8 -0
  52. package/dist/core/safe-compare.js +19 -0
  53. package/dist/core/shell-escape.d.ts +7 -0
  54. package/dist/core/shell-escape.js +9 -0
  55. package/dist/core/tab-resolver.d.ts +17 -0
  56. package/dist/core/tab-resolver.js +44 -0
  57. package/dist/core/template.d.ts +32 -0
  58. package/dist/core/template.js +82 -0
  59. package/dist/core/types.d.ts +105 -0
  60. package/dist/core/types.js +2 -0
  61. package/dist/core/url-guard.d.ts +21 -0
  62. package/dist/core/url-guard.js +184 -0
  63. package/dist/core/validator.d.ts +11 -0
  64. package/dist/core/validator.js +261 -0
  65. package/dist/core/waterfall.d.ts +26 -0
  66. package/dist/core/waterfall.js +55 -0
  67. package/dist/index.d.ts +15 -0
  68. package/dist/index.js +16 -0
  69. package/dist/mcp/server.d.ts +1 -0
  70. package/dist/mcp/server.js +943 -0
  71. package/package.json +67 -0
@@ -0,0 +1,161 @@
1
+ import { validateUrl } from "./url-guard.js";
2
+ /** Thrown when onError config specifies "stop_provider" */
3
+ export class StopProviderError extends Error {
4
+ constructor(message = "Provider stopped due to error") {
5
+ super(message);
6
+ this.name = "StopProviderError";
7
+ }
8
+ }
9
+ /**
10
+ * Check if a status code is retryable (429 or 5xx).
11
+ */
12
+ function isRetryable(status) {
13
+ return status === 429 || (status >= 500 && status <= 599);
14
+ }
15
+ /**
16
+ * Resolve the onError action for a given status code.
17
+ * Checks the specific status first, then falls back to "default".
18
+ */
19
+ function resolveErrorAction(onError, status) {
20
+ if (!onError)
21
+ return undefined;
22
+ const statusKey = String(status);
23
+ if (statusKey in onError) {
24
+ return onError[statusKey];
25
+ }
26
+ if ("default" in onError) {
27
+ return onError.default;
28
+ }
29
+ return undefined;
30
+ }
31
+ /**
32
+ * Apply the resolved error action, returning an HttpResponse or throwing.
33
+ */
34
+ function applyErrorAction(action, status) {
35
+ if (action === undefined) {
36
+ // No error handler — throw a generic error
37
+ throw new Error(`HTTP request failed with status ${status}`);
38
+ }
39
+ if (action === "skip") {
40
+ return null;
41
+ }
42
+ if (action === "stop_provider") {
43
+ throw new StopProviderError(`Provider stopped: HTTP ${status}`);
44
+ }
45
+ if (typeof action === "object" && "write" in action) {
46
+ return { status, data: action.write };
47
+ }
48
+ // Unknown action — treat as skip
49
+ return null;
50
+ }
51
+ /**
52
+ * Make an HTTP request with retry, rate limiting, and structured error handling.
53
+ *
54
+ * - Acquires a rate limiter token before each request attempt
55
+ * - Retries on 429/5xx with exponential backoff
56
+ * - Applies onError config for non-retryable errors or exhausted retries
57
+ * - Respects AbortSignal for cancellation
58
+ */
59
+ export async function httpRequest(options) {
60
+ const { method, url, headers, body, retryAttempts = 0, retryBackoff, onError, rateLimiter, signal, } = options;
61
+ // Validate URL to prevent SSRF
62
+ validateUrl(url);
63
+ const maxAttempts = retryAttempts + 1;
64
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
65
+ // Check abort before each attempt
66
+ if (signal?.aborted) {
67
+ throw new Error("Request aborted");
68
+ }
69
+ // Acquire rate limiter token
70
+ if (rateLimiter) {
71
+ await rateLimiter.acquire(signal);
72
+ }
73
+ let finalHeaders = headers;
74
+ if (body !== undefined) {
75
+ const hasContentType = headers &&
76
+ Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
77
+ if (!hasContentType) {
78
+ finalHeaders = { ...headers, "Content-Type": "application/json" };
79
+ }
80
+ }
81
+ let response;
82
+ try {
83
+ response = await fetch(url, {
84
+ method,
85
+ headers: finalHeaders,
86
+ body: body !== undefined ? JSON.stringify(body) : undefined,
87
+ signal,
88
+ });
89
+ }
90
+ catch (_err) {
91
+ // Network error or abort
92
+ if (signal?.aborted) {
93
+ throw new Error("Request aborted");
94
+ }
95
+ if (attempt < maxAttempts - 1) {
96
+ await backoff(attempt, retryBackoff, signal);
97
+ continue;
98
+ }
99
+ const action = resolveErrorAction(onError, 0);
100
+ return applyErrorAction(action, 0);
101
+ }
102
+ // Success
103
+ if (response.ok) {
104
+ const data = await parseResponseBody(response);
105
+ return { status: response.status, data };
106
+ }
107
+ // Retryable error
108
+ if (isRetryable(response.status) && attempt < maxAttempts - 1) {
109
+ await backoff(attempt, retryBackoff, signal);
110
+ continue;
111
+ }
112
+ // Non-retryable error, or retries exhausted
113
+ const action = resolveErrorAction(onError, response.status);
114
+ return applyErrorAction(action, response.status);
115
+ }
116
+ // Should not be reached, but handle gracefully
117
+ throw new Error("HTTP request failed: no attempts made");
118
+ }
119
+ /**
120
+ * Backoff delay between retries. Respects AbortSignal to allow early exit.
121
+ *
122
+ * Strategies:
123
+ * - "exponential" (default): 2^attempt * 100ms (100, 200, 400, 800, ...)
124
+ * - "linear": (attempt + 1) * 200ms (200, 400, 600, ...)
125
+ * - "fixed": constant 1000ms
126
+ */
127
+ async function backoff(attempt, strategy, signal) {
128
+ let delayMs;
129
+ switch (strategy) {
130
+ case "linear":
131
+ delayMs = (attempt + 1) * 200;
132
+ break;
133
+ case "fixed":
134
+ delayMs = 1000;
135
+ break;
136
+ default:
137
+ delayMs = 2 ** attempt * 100;
138
+ break;
139
+ }
140
+ return new Promise((resolve) => {
141
+ if (signal?.aborted) {
142
+ resolve();
143
+ return;
144
+ }
145
+ const timer = setTimeout(resolve, delayMs);
146
+ signal?.addEventListener("abort", () => {
147
+ clearTimeout(timer);
148
+ resolve();
149
+ }, { once: true });
150
+ });
151
+ }
152
+ /**
153
+ * Parse response body as JSON, falling back to text.
154
+ */
155
+ async function parseResponseBody(response) {
156
+ const contentType = response.headers.get("content-type") ?? "";
157
+ if (contentType.includes("json")) {
158
+ return response.json();
159
+ }
160
+ return response.text();
161
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Token bucket rate limiter.
3
+ *
4
+ * Refills tokens based on elapsed time, with max capacity equal to
5
+ * tokensPerSecond. Used globally across all HTTP requests.
6
+ */
7
+ export declare class RateLimiter {
8
+ private readonly tokensPerSecond;
9
+ private tokens;
10
+ private readonly maxTokens;
11
+ private lastRefill;
12
+ private queue;
13
+ constructor(tokensPerSecond: number);
14
+ /**
15
+ * Acquire a single token. Resolves immediately if a token is available,
16
+ * otherwise waits until one is refilled. Respects AbortSignal for early exit.
17
+ *
18
+ * Serialized via a promise chain to prevent race conditions when
19
+ * multiple callers invoke acquire() concurrently.
20
+ */
21
+ acquire(signal?: AbortSignal): Promise<void>;
22
+ private _acquire;
23
+ private refill;
24
+ private sleep;
25
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Token bucket rate limiter.
3
+ *
4
+ * Refills tokens based on elapsed time, with max capacity equal to
5
+ * tokensPerSecond. Used globally across all HTTP requests.
6
+ */
7
+ export class RateLimiter {
8
+ tokensPerSecond;
9
+ tokens;
10
+ maxTokens;
11
+ lastRefill;
12
+ queue = Promise.resolve();
13
+ constructor(tokensPerSecond) {
14
+ this.tokensPerSecond = tokensPerSecond;
15
+ this.maxTokens = tokensPerSecond;
16
+ this.tokens = tokensPerSecond;
17
+ this.lastRefill = Date.now();
18
+ }
19
+ /**
20
+ * Acquire a single token. Resolves immediately if a token is available,
21
+ * otherwise waits until one is refilled. Respects AbortSignal for early exit.
22
+ *
23
+ * Serialized via a promise chain to prevent race conditions when
24
+ * multiple callers invoke acquire() concurrently.
25
+ */
26
+ async acquire(signal) {
27
+ this.queue = this.queue.then(() => this._acquire(signal));
28
+ return this.queue;
29
+ }
30
+ async _acquire(signal) {
31
+ if (signal?.aborted)
32
+ return;
33
+ this.refill();
34
+ if (this.tokens >= 1) {
35
+ this.tokens -= 1;
36
+ return;
37
+ }
38
+ // Calculate wait time until at least one token is available
39
+ const deficit = 1 - this.tokens;
40
+ const waitMs = (deficit / this.tokensPerSecond) * 1000;
41
+ await this.sleep(waitMs, signal);
42
+ this.refill();
43
+ this.tokens -= 1;
44
+ }
45
+ refill() {
46
+ const now = Date.now();
47
+ const elapsed = (now - this.lastRefill) / 1000;
48
+ this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.tokensPerSecond);
49
+ this.lastRefill = now;
50
+ }
51
+ sleep(ms, signal) {
52
+ return new Promise((resolve) => {
53
+ if (signal?.aborted) {
54
+ resolve();
55
+ return;
56
+ }
57
+ const timer = setTimeout(resolve, ms);
58
+ signal?.addEventListener("abort", () => {
59
+ clearTimeout(timer);
60
+ resolve();
61
+ }, { once: true });
62
+ });
63
+ }
64
+ }
@@ -0,0 +1,24 @@
1
+ import type { SheetsAdapter } from "../adapters/sheets/sheets-adapter.js";
2
+ import type { PipelineConfig, SheetRef, TabConfig } from "./types.js";
3
+ export interface ReconcileResult {
4
+ /** Updated config — always v2 format */
5
+ config: PipelineConfig;
6
+ /** The GID of the tab being operated on */
7
+ tabGid: string;
8
+ /** The specific tab's config (convenience) */
9
+ tabConfig: TabConfig;
10
+ /** User-facing messages about detected changes */
11
+ messages: string[];
12
+ /** Whether the config was modified and needs re-saving */
13
+ configChanged: boolean;
14
+ }
15
+ /**
16
+ * Reconcile a pipeline config with the current sheet state.
17
+ *
18
+ * This function handles:
19
+ * 1. v1 → v2 migration (wraps top-level actions/columns under the resolved GID)
20
+ * 2. Tab name reconciliation (detects renamed tabs by GID)
21
+ * 3. Column reconciliation for the target tab (named ranges, renames, new columns)
22
+ * 4. Action target migration from column names to IDs
23
+ */
24
+ export declare function reconcile(adapter: SheetsAdapter, ref: SheetRef, config: PipelineConfig): Promise<ReconcileResult>;
@@ -0,0 +1,192 @@
1
+ import { randomBytes } from "node:crypto";
2
+ /**
3
+ * Reconcile a pipeline config with the current sheet state.
4
+ *
5
+ * This function handles:
6
+ * 1. v1 → v2 migration (wraps top-level actions/columns under the resolved GID)
7
+ * 2. Tab name reconciliation (detects renamed tabs by GID)
8
+ * 3. Column reconciliation for the target tab (named ranges, renames, new columns)
9
+ * 4. Action target migration from column names to IDs
10
+ */
11
+ export async function reconcile(adapter, ref, config) {
12
+ const messages = [];
13
+ let configChanged = false;
14
+ // --- Action 1: Get all tabs from the spreadsheet ---
15
+ const sheets = await adapter.listSheets(ref.spreadsheetId);
16
+ const targetName = ref.sheetName || "Sheet1";
17
+ const targetSheet = sheets.find((s) => s.name === targetName);
18
+ if (!targetSheet) {
19
+ throw new Error(`Tab "${targetName}" not found in spreadsheet ${ref.spreadsheetId}`);
20
+ }
21
+ const tabGid = String(targetSheet.gid);
22
+ // --- Action 2: Migrate v1 → v2 if needed ---
23
+ let tabs;
24
+ if (!config.tabs) {
25
+ // v1 config — migrate to v2
26
+ const v1Columns = config.columns ?? {};
27
+ const v1Actions = config.actions ?? [];
28
+ tabs = {
29
+ [tabGid]: {
30
+ name: targetName,
31
+ columns: { ...v1Columns },
32
+ actions: [...v1Actions],
33
+ },
34
+ };
35
+ configChanged = true;
36
+ messages.push(`Migrated v1 config to v2 multi-tab format (tab GID: ${tabGid})`);
37
+ }
38
+ else {
39
+ // Already v2 — deep clone tabs
40
+ tabs = {};
41
+ for (const [gid, tab] of Object.entries(config.tabs)) {
42
+ tabs[gid] = {
43
+ name: tab.name,
44
+ columns: { ...tab.columns },
45
+ actions: [...tab.actions],
46
+ };
47
+ }
48
+ }
49
+ // Ensure the target tab exists in config
50
+ if (!tabs[tabGid]) {
51
+ tabs[tabGid] = {
52
+ name: targetName,
53
+ columns: {},
54
+ actions: [],
55
+ };
56
+ configChanged = true;
57
+ }
58
+ // --- Action 3: Reconcile tab names ---
59
+ for (const [gid, tab] of Object.entries(tabs)) {
60
+ const sheet = sheets.find((s) => String(s.gid) === gid);
61
+ if (sheet && sheet.name !== tab.name) {
62
+ messages.push(`Tab ${gid}: renamed "${tab.name}" → "${sheet.name}"`);
63
+ tab.name = sheet.name;
64
+ configChanged = true;
65
+ }
66
+ }
67
+ // --- Action 4: Reconcile columns for the target tab ---
68
+ const tabConfig = tabs[tabGid];
69
+ const headers = await adapter.getHeaders(ref);
70
+ const sheetRanges = await adapter.readColumnRanges(ref, targetSheet.gid);
71
+ let oldColumns = tabConfig.columns;
72
+ const newColumns = {};
73
+ if (Object.keys(oldColumns).length === 0 && !config.tabs) {
74
+ // First time — configChanged already set from migration
75
+ }
76
+ // Detect old {name: id} format and flip to new {id: name} format.
77
+ const isOldFormat = Object.keys(oldColumns).length > 0 &&
78
+ Object.values(oldColumns).some((v) => sheetRanges.has(v));
79
+ if (isOldFormat) {
80
+ const flipped = {};
81
+ for (const [name, id] of Object.entries(oldColumns)) {
82
+ flipped[id] = name;
83
+ }
84
+ oldColumns = flipped;
85
+ tabConfig.columns = flipped;
86
+ configChanged = true;
87
+ }
88
+ // Track which header indices are covered by existing ranges
89
+ const coveredIndices = new Set();
90
+ // --- Pass 1: Process all named ranges found in the sheet ---
91
+ for (const [rangeId, colIndex] of sheetRanges) {
92
+ const currentHeader = headers[colIndex];
93
+ if (!currentHeader) {
94
+ // Range points beyond current headers → column was deleted
95
+ const oldName = oldColumns[rangeId];
96
+ if (oldName) {
97
+ messages.push(`✗ "${oldName}" (${rangeId}) → deleted, removing`);
98
+ configChanged = true;
99
+ }
100
+ continue;
101
+ }
102
+ coveredIndices.add(colIndex);
103
+ const oldName = oldColumns[rangeId];
104
+ if (oldName && oldName !== currentHeader) {
105
+ // Header name changed — just update the label
106
+ newColumns[rangeId] = currentHeader;
107
+ messages.push(`✓ "${oldName}" → renamed to "${currentHeader}" (${rangeId})`);
108
+ configChanged = true;
109
+ }
110
+ else if (oldName) {
111
+ // No change
112
+ newColumns[rangeId] = currentHeader;
113
+ }
114
+ else {
115
+ // Range exists in sheet but not in our config (migration from action-based ranges)
116
+ newColumns[rangeId] = currentHeader;
117
+ configChanged = true;
118
+ }
119
+ }
120
+ // --- Pass 2: Create ranges for untracked headers ---
121
+ const trackedNames = new Set(Object.values(newColumns));
122
+ for (let i = 0; i < headers.length; i++) {
123
+ if (coveredIndices.has(i))
124
+ continue;
125
+ const header = headers[i];
126
+ if (!header || trackedNames.has(header))
127
+ continue;
128
+ const rangeId = randomBytes(4).toString("hex");
129
+ try {
130
+ await adapter.createColumnRange(ref, rangeId, i);
131
+ newColumns[rangeId] = header;
132
+ messages.push(`✓ "${header}" → new column (${rangeId})`);
133
+ configChanged = true;
134
+ }
135
+ catch {
136
+ // Non-fatal — column just won't have tracking
137
+ }
138
+ }
139
+ // --- Pass 3: Migrate action targets from column names to IDs ---
140
+ const nameToId = new Map();
141
+ for (const [id, name] of Object.entries(newColumns)) {
142
+ nameToId.set(name, id);
143
+ }
144
+ const actions = tabConfig.actions.map((action) => {
145
+ // Target is already an ID (exists as key in columns)
146
+ if (newColumns[action.target] !== undefined) {
147
+ return action;
148
+ }
149
+ // Target is a column name — migrate to ID
150
+ const id = nameToId.get(action.target);
151
+ if (id) {
152
+ messages.push(`✓ action "${action.id}": target "${action.target}" → ${id} (migrated to ID)`);
153
+ return { ...action, target: id };
154
+ }
155
+ // Legacy migration: action.id was used as the range name in the old system
156
+ const legacyColIndex = sheetRanges.get(action.id);
157
+ if (legacyColIndex !== undefined) {
158
+ const currentHeader = headers[legacyColIndex];
159
+ if (currentHeader) {
160
+ const resolvedId = nameToId.get(currentHeader);
161
+ if (resolvedId) {
162
+ messages.push(`✓ action "${action.id}": target "${action.target}" → ${resolvedId} (legacy migration)`);
163
+ return { ...action, target: resolvedId };
164
+ }
165
+ }
166
+ }
167
+ return action;
168
+ });
169
+ if (actions.some((s, i) => s !== tabConfig.actions[i])) {
170
+ configChanged = true;
171
+ }
172
+ // Update tab config
173
+ tabConfig.columns = newColumns;
174
+ tabConfig.actions = actions;
175
+ // Build the final v2 config
176
+ const updatedConfig = {
177
+ ...config,
178
+ version: "2",
179
+ tabs,
180
+ // Clear v1 top-level fields after migration
181
+ columns: undefined,
182
+ actions: [],
183
+ settings: config.settings,
184
+ };
185
+ return {
186
+ config: updatedConfig,
187
+ tabGid,
188
+ tabConfig,
189
+ messages,
190
+ configChanged,
191
+ };
192
+ }
@@ -0,0 +1,39 @@
1
+ import type { RunState } from "./run-state.js";
2
+ /**
3
+ * Format milliseconds as a human-readable duration.
4
+ * Examples: "12s", "1m30s", "2h5m"
5
+ */
6
+ export declare function formatDuration(ms: number): string;
7
+ /**
8
+ * Format an ISO date as a relative "age" string.
9
+ * Examples: "just now", "5m ago", "2h ago", "3d ago", or "running" if status is running.
10
+ */
11
+ export declare function formatAge(isoDate: string, status?: string): string;
12
+ /**
13
+ * Format a list of runs as a compact table.
14
+ *
15
+ * ```
16
+ * STATUS RUN SHEET ROWS UPDATES ERRORS DURATION AGE
17
+ * ✓ a1b2c3 EliteCart 30/30 28 0 12s 5m ago
18
+ * ```
19
+ */
20
+ export declare function formatRunList(runs: RunState[]): string;
21
+ /**
22
+ * Format a detailed view of a single run.
23
+ *
24
+ * ```
25
+ * ✗ Run d4e5f6 · LeadList
26
+ * Sheet: 1xABC...def · Started: 2h ago · Duration: 45s
27
+ *
28
+ * ACTIONS
29
+ * extract_domain ✓ 150/150
30
+ * enrich_company ✗ 147/150 (3 errors)
31
+ * find_email ⚠ 120/147 (27 skipped)
32
+ *
33
+ * ERRORS (3)
34
+ * Row 45 enrich_company 429 Too Many Requests (retries exhausted)
35
+ * Row 89 enrich_company timeout after 30s
36
+ * Row 102 enrich_company 404 → wrote "not_found"
37
+ * ```
38
+ */
39
+ export declare function formatRunDetail(run: RunState, errorsOnly?: boolean): string;