Skip to content
Gary Wu
Go back

Self-Healing Parsers

Edit page

Org Status: 🟡 Dormant Cloudflare: N/A Last Audited: 2026-04-28


When an upstream API silently moves a field from user_results.result.legacy.screen_name to user_results.result.core.screen_name, your parser should fix itself — not page you at 3am.

This article describes a five-layer pattern for building parsers that automatically detect when an API schema changes and repair themselves without downtime, manual intervention, or code deploys. The pattern emerged from production experience intercepting X/Twitter’s internal GraphQL API, where schema changes happen without warning, without versioning, and without changelog entries.

What you’ll learn:



Every developer who consumes an API they don’t control will eventually face the same failure mode: the upstream schema changes and their parser silently returns garbage.

The failure is “silent” because most parsers are written as a chain of property accesses. When response.data.user.legacy.screen_name becomes response.data.user.core.screen_name, the old path doesn’t throw — it returns undefined. That undefined flows downstream, gets coerced to an empty string or null, and your system keeps running. It keeps running with broken data.

Here’s what the landscape of existing approaches looks like:

Hardcoded Path Access

// This is what most code looks like
const screenName = tweet.core.user_results.result.legacy.screen_name;

When the path changes, this returns undefined. If you’re lucky and the intermediate object doesn’t exist, you get a TypeError: Cannot read properties of undefined. If you’re unlucky, every intermediate object exists but the leaf moved — and you get silent undefined flowing through your entire pipeline.

Optional Chaining (the false safety net)

// "Safe" version -- still silently broken
const screenName = tweet?.core?.user_results?.result?.legacy?.screen_name ?? "unknown";

This is worse than the hardcoded version. It never throws. It never tells you something is wrong. It just returns "unknown" for every single user, and your downstream systems happily process data with every author labeled “unknown”. You discover this days later when someone notices the analytics look weird.

Schema Validation (Zod, JSON Schema)

const TweetSchema = z.object({
  core: z.object({
    user_results: z.object({
      result: z.object({
        legacy: z.object({
          screen_name: z.string(),
        }),
      }),
    }),
  }),
});

const parsed = TweetSchema.safeParse(tweet);
if (!parsed.success) {
  // Now what? You know it's broken, but you can't fix it.
  // You log an error, drop the data, and page someone.
  console.error("Schema validation failed", parsed.error);
}

Schema validation is a genuine improvement — at least you know the data doesn’t match your expectations. But “knowing it’s broken” isn’t “fixing it.” You still drop every incoming record until a human writes a new schema and deploys it. During a batch import of 500 bookmarks, that means losing all 500.

LLM-Only Parsing

const result = await llm.chat({
  messages: [{
    role: "user",
    content: `Extract the author's screen name from this JSON:\n${JSON.stringify(tweet)}`,
  }],
});

This actually works — LLMs are remarkably good at understanding JSON structure. But it costs money on every single parse ($0.01-0.10 per record depending on payload size and model), it’s slow (500ms-2s per call), and it’s non-deterministic. You might get "elonmusk" or "@elonmusk" or "Elon Musk" depending on the model’s mood. For a batch of 500 tweets, you’re looking at $5-50 and 4-16 minutes of wall time.

Key insight: Each approach solves one problem while creating another. Hardcoded paths are fast but fragile. Validation detects breakage but can’t fix it. LLMs can fix anything but are slow and expensive. The self-healing parser combines all three into a layered system where each layer’s weakness is covered by the next.

What changes if you get this right

When your parser can heal itself:


We built a Chrome extension that intercepts X/Twitter’s internal GraphQL responses — HomeTimeline, Bookmarks, UserTweets, ListLatestTweetsTimeline — and forwards them to a backend for analysis. The extension uses a MAIN world content script to override window.fetch, capture GraphQL responses as they stream back from Twitter’s servers, and forward them to our API.

The GraphQL responses from X/Twitter are deeply nested. Here’s a simplified version of the real structure:

// Simplified X/Twitter HomeTimeline GraphQL response
// Real responses are 50-200KB per request
{
  data: {
    home: {
      home_timeline_urt: {
        instructions: [{
          type: "TimelineAddEntries",
          entries: [{
            content: {
              entryType: "TimelineTimelineItem",
              itemContent: {
                tweet_results: {
                  result: {
                    __typename: "Tweet",
                    rest_id: "1234567890",
                    core: {
                      user_results: {
                        result: {
                          __typename: "User",
                          id: "VXNlcjoxMjM0NTY3ODkw",
                          rest_id: "1234567890",
                          legacy: {
                            screen_name: "elonmusk",   // <-- HERE
                            name: "Elon Musk",
                            followers_count: 180000000,
                            verified: false,
                          },
                          is_blue_verified: true,
                        }
                      }
                    },
                    legacy: {
                      full_text: "The tweet content...",
                      created_at: "Sat Mar 01 12:00:00 +0000 2025",
                      favorite_count: 50000,
                      retweet_count: 10000,
                    }
                  }
                }
              }
            }
          }]
        }]
      }
    }
  }
}

The path to screen_name is:

data.home.home_timeline_urt.instructions[0].entries[0].content.itemContent
  .tweet_results.result.core.user_results.result.legacy.screen_name

That’s thirteen levels deep.

One day, X moved screen_name from inside legacy to inside core for certain response types. No announcement. No deprecation period. No versioning header. The legacy object still existed — it just no longer contained screen_name in some contexts.

Our parser silently returned empty strings for author handles. We had screen_name: undefined coerced to "" for thousands of bookmarks. The data was technically “parsed” — every other field was fine. But every tweet was attributed to nobody.

We discovered the breakage 48 hours later when a downstream report showed 0 unique authors across 2,000 tweets. Two days of data, unrecoverable (the raw responses weren’t stored — only the parsed output).

That’s when we built the self-healing parser.


Concept 1: Schema Map (data, not code)

A schema map is a JSON document that describes the dot-paths to every field you care about in an API response. It’s stored externally (we use Cloudflare R2), versioned immutably, and loaded at runtime.

interface SchemaMap {
  /** Monotonically increasing version number */
  version: number;

  /** ISO 8601 timestamp of creation */
  createdAt: string;

  /** Who created this version */
  createdBy: "hardcoded" | "deterministic-repair" | "ai-repair" | "manual";

  /** Human-readable description of what changed */
  changelog: string;

  /** Paths to user-related fields within a tweet result object */
  paths: {
    /** Path from tweet root to the user result object */
    userResult: string;

    /** Candidate paths for screen_name (tried in order) */
    screenName: string[];

    /** Candidate paths for display name */
    displayName: string[];

    /** Candidate paths for follower count */
    followersCount: string[];

    /** Candidate paths for verified status */
    verified: string[];

    /** Candidate paths for profile image URL */
    profileImageUrl: string[];

    /** Path from tweet root to the tweet text */
    tweetText: string;

    /** Path from tweet root to created_at */
    tweetCreatedAt: string;

    /** Candidate paths for media entities */
    mediaEntities: string[];
  };
}

Key insight: The schema map is the central innovation. By extracting paths into data, you decouple “where the field is” from “how to use the field.” Updating a path requires changing a JSON file, not a code deploy. And because each version is immutable, you have a complete history of every schema change.

Here’s what a real schema map looks like:

const schemaMapV1: SchemaMap = {
  version: 1,
  createdAt: "2025-01-15T00:00:00Z",
  createdBy: "hardcoded",
  changelog: "Initial schema map based on HomeTimeline structure as of 2025-01-15",
  paths: {
    userResult: "core.user_results.result",
    screenName: ["legacy.screen_name"],
    displayName: ["legacy.name"],
    followersCount: ["legacy.followers_count"],
    verified: ["is_blue_verified", "legacy.verified"],
    profileImageUrl: ["legacy.profile_image_url_https"],
    tweetText: "legacy.full_text",
    tweetCreatedAt: "legacy.created_at",
    mediaEntities: [
      "legacy.extended_entities.media",
      "legacy.entities.media",
    ],
  },
};

And here’s what it looks like after a repair:

const schemaMapV2: SchemaMap = {
  version: 2,
  createdAt: "2025-03-22T14:30:00Z",
  createdBy: "deterministic-repair",
  changelog: "screen_name moved from legacy to core; added core.screen_name as primary path",
  paths: {
    userResult: "core.user_results.result",
    screenName: ["core.screen_name", "legacy.screen_name"],  // NEW path first
    displayName: ["legacy.name"],
    followersCount: ["legacy.followers_count"],
    verified: ["is_blue_verified", "legacy.verified"],
    profileImageUrl: ["legacy.profile_image_url_https"],
    tweetText: "legacy.full_text",
    tweetCreatedAt: "legacy.created_at",
    mediaEntities: [
      "legacy.extended_entities.media",
      "legacy.entities.media",
    ],
  },
};

Notice: the old path (legacy.screen_name) is kept as a fallback. Schema maps are additive — new paths go to the front, old paths stay as fallbacks.

Concept 2: Path Resolution

Path resolution is the process of extracting a value from a deeply nested object using a dot-path string. It’s the runtime counterpart to the schema map.

/**
 * Walk a dot-path like "core.user_results.result.legacy.screen_name"
 * against a nested object. Returns undefined if any segment is missing.
 */
function walkPath(obj: unknown, path: string): unknown {
  const segments = path.split(".");
  let current: unknown = obj;

  for (const segment of segments) {
    if (current === null || current === undefined) return undefined;
    if (typeof current !== "object") return undefined;
    current = (current as Record<string, unknown>)[segment];
  }

  return current;
}
// Usage
const user = walkPath(tweetResult, "core.user_results.result");
// user is now the User object (or undefined if the path is wrong)

const screenName = walkPath(user, "legacy.screen_name");
// screenName is now "elonmusk" (or undefined if moved)

Key insight: walkPath is O(d) where d is the depth of the path (typically 3-6 segments). It never searches — it follows a known route. This is your fast path, and it handles 99%+ of requests.

Concept 3: Deep Find (the rescue operation)

Deep find is a BFS traversal that searches an entire object tree for a key, returning the first value found. It’s the rescue operation that keeps data flowing when the schema map’s paths are stale.

/**
 * BFS search for a key in a nested object.
 * Returns the first value found at the shallowest depth.
 *
 * @param obj - The object to search
 * @param targetKey - The key to find (e.g., "screen_name")
 * @param maxDepth - Maximum depth to search (prevents runaway on huge objects)
 */
function deepFind(
  obj: unknown,
  targetKey: string,
  maxDepth: number = 6
): unknown {
  if (obj === null || obj === undefined || typeof obj !== "object") {
    return undefined;
  }

  // BFS queue: [object, currentDepth]
  const queue: Array<[Record<string, unknown>, number]> = [
    [obj as Record<string, unknown>, 0],
  ];

  while (queue.length > 0) {
    const [current, depth] = queue.shift()!;

    if (depth > maxDepth) continue;

    for (const key of Object.keys(current)) {
      if (key === targetKey) {
        return current[key];
      }

      const value = current[key];
      if (value !== null && typeof value === "object" && !Array.isArray(value)) {
        queue.push([value as Record<string, unknown>, depth + 1]);
      }
    }
  }

  return undefined;
}

Key insight: BFS finds the shallowest match, which is almost always the correct one. If screen_name exists at depth 2 in core and at depth 4 in some nested retweet object, BFS returns the shallow one. DFS would find whichever branch it explored first — which might be the wrong one.

Concept 4: Drift Detection

Drift detection is a statistical check that runs after parsing a batch of records. If any critical field is missing from any record in the batch, we know the schema has drifted.

interface DriftReport {
  /** Total records in the batch */
  total: number;

  /** Records where all critical fields were found via schema paths */
  cleanHits: number;

  /** Records where at least one field required deepFind */
  deepFindRescues: number;

  /** Records where a critical field could not be found at all */
  failures: number;

  /** Which fields triggered deepFind, and how many times */
  driftedFields: Record<string, number>;

  /** Overall drift status */
  status: "HEALTHY" | "SCHEMA_DRIFT" | "SCHEMA_BROKEN";
}

Key insight: Drift detection is not about individual records — it’s about batches. A single missing field could be a malformed record. When 50% of records in a batch are missing screen_name, that’s a schema change.

Concept 5: Path Repair

Path repair is the process of discovering where a field actually lives in the current API response, then updating the schema map so future requests use the new path directly. There are two repair strategies: deterministic (BFS-based, free, fast) and AI-assisted (LLM-based, costs money, handles structural changes).

interface RepairResult {
  /** The field that was repaired */
  field: string;

  /** The old path(s) that stopped working */
  oldPaths: string[];

  /** The new path discovered by repair */
  newPath: string;

  /** How the new path was found */
  method: "deterministic-bfs" | "ai-inference";

  /** Confidence level (1.0 for deterministic, varies for AI) */
  confidence: number;

  /** The raw JSON sample used for repair (for audit) */
  samplePayload?: unknown;
}

Layer 1: Versioned Schema Map

The schema map lives in external storage — not in your application code. We use Cloudflare R2 because it’s fast, cheap, and our Workers can access it with zero latency via bindings. You could use S3, GCS, a KV store, or even a database.

Storage Layout

r2://schema-maps/
  twitter/
    HomeTimeline/
      v1.json        # Original schema
      v2.json        # After screen_name moved
      v3.json        # After media entities restructured
      latest.json    # Always points to the current version
    Bookmarks/
      v1.json
      latest.json
    UserTweets/
      v1.json
      latest.json

Schema Map Manager

interface SchemaMapStore {
  /** Load the current schema map for an endpoint */
  getLatest(endpoint: string): Promise<SchemaMap>;

  /** Load a specific version */
  getVersion(endpoint: string, version: number): Promise<SchemaMap>;

  /** Store a new schema map version */
  putVersion(endpoint: string, map: SchemaMap): Promise<void>;

  /** Update latest to point to a specific version */
  setLatest(endpoint: string, version: number): Promise<void>;
}

class R2SchemaMapStore implements SchemaMapStore {
  constructor(private bucket: R2Bucket) {}

  async getLatest(endpoint: string): Promise<SchemaMap> {
    const key = `twitter/${endpoint}/latest.json`;
    const obj = await this.bucket.get(key);
    if (!obj) throw new Error(`No schema map found for ${endpoint}`);
    return obj.json<SchemaMap>();
  }

  async getVersion(endpoint: string, version: number): Promise<SchemaMap> {
    const key = `twitter/${endpoint}/v${version}.json`;
    const obj = await this.bucket.get(key);
    if (!obj) throw new Error(`Schema map v${version} not found for ${endpoint}`);
    return obj.json<SchemaMap>();
  }

  async putVersion(endpoint: string, map: SchemaMap): Promise<void> {
    const key = `twitter/${endpoint}/v${map.version}.json`;
    await this.bucket.put(key, JSON.stringify(map, null, 2), {
      httpMetadata: { contentType: "application/json" },
      customMetadata: {
        createdBy: map.createdBy,
        changelog: map.changelog,
      },
    });
  }

  async setLatest(endpoint: string, version: number): Promise<void> {
    // Load the version first to verify it exists
    const map = await this.getVersion(endpoint, version);
    const key = `twitter/${endpoint}/latest.json`;
    await this.bucket.put(key, JSON.stringify(map, null, 2), {
      httpMetadata: { contentType: "application/json" },
    });
  }
}

In-Memory Cache with TTL

You don’t want to hit R2 on every request. Cache the schema map in memory with a short TTL.

class CachedSchemaMapStore implements SchemaMapStore {
  private cache = new Map<string, { map: SchemaMap; expiresAt: number }>();
  private ttlMs: number;

  constructor(
    private inner: SchemaMapStore,
    ttlMs: number = 60_000, // 1 minute default
  ) {
    this.ttlMs = ttlMs;
  }

  async getLatest(endpoint: string): Promise<SchemaMap> {
    const cacheKey = `latest:${endpoint}`;
    const cached = this.cache.get(cacheKey);

    if (cached && cached.expiresAt > Date.now()) {
      return cached.map;
    }

    const map = await this.inner.getLatest(endpoint);
    this.cache.set(cacheKey, {
      map,
      expiresAt: Date.now() + this.ttlMs,
    });
    return map;
  }

  /** Force-invalidate after a repair writes a new version */
  invalidate(endpoint: string): void {
    this.cache.delete(`latest:${endpoint}`);
  }

  // getVersion, putVersion, setLatest delegate to inner
  async getVersion(endpoint: string, version: number): Promise<SchemaMap> {
    return this.inner.getVersion(endpoint, version);
  }

  async putVersion(endpoint: string, map: SchemaMap): Promise<void> {
    await this.inner.putVersion(endpoint, map);
  }

  async setLatest(endpoint: string, version: number): Promise<void> {
    await this.inner.setLatest(endpoint, version);
    this.invalidate(endpoint);
  }
}

Key insight: The schema map is immutable once written. v1.json never changes. When repair discovers a new path, it creates v2.json and updates latest.json to point to it. This gives you a complete audit trail and instant rollback — just point latest.json back to the previous version.


Layer 2: Path Resolution with Fallback

This is the core parsing logic. For every field, try the schema map’s paths first (fast), then fall back to deep find (expensive but self-healing).

interface ResolvedField<T = unknown> {
  /** The extracted value */
  value: T | undefined;

  /** Which path succeeded (or "deepFind" if rescue was needed) */
  resolvedVia: string | "deepFind";

  /** Whether the fast path worked */
  usedDeepFind: boolean;
}

/**
 * Try each candidate path in order. If all fail, fall back to deepFind.
 *
 * @param obj - The object to search within
 * @param paths - Ordered list of dot-paths to try (from schema map)
 * @param deepFindKey - The key name to search for if all paths fail
 * @param maxDepth - Maximum depth for deepFind traversal
 */
function resolveField<T = unknown>(
  obj: unknown,
  paths: string[],
  deepFindKey: string,
  maxDepth: number = 6,
): ResolvedField<T> {
  // Fast path: try each schema map path in order
  for (const path of paths) {
    const value = walkPath(obj, path);
    if (value !== undefined && value !== null) {
      return {
        value: value as T,
        resolvedVia: path,
        usedDeepFind: false,
      };
    }
  }

  // Rescue path: BFS search for the key
  const value = deepFind(obj, deepFindKey, maxDepth);
  return {
    value: value as T | undefined,
    resolvedVia: "deepFind",
    usedDeepFind: true,
  };
}

The Full Tweet Parser

interface ParsedTweet {
  tweetId: string;
  text: string;
  createdAt: string;
  author: {
    userId: string;
    screenName: string;
    displayName: string;
    followersCount: number;
    verified: boolean;
    profileImageUrl: string;
  };
  metrics: {
    favoriteCount: number;
    retweetCount: number;
    replyCount: number;
    viewCount: number;
  };
}

interface ParseResult {
  tweet: ParsedTweet | null;
  driftSignals: string[];
}

function parseTweetResult(
  tweetResult: Record<string, unknown>,
  schemaMap: SchemaMap,
): ParseResult {
  const driftSignals: string[] = [];

  // Resolve user object first
  const userObj = walkPath(tweetResult, schemaMap.paths.userResult);
  if (!userObj || typeof userObj !== "object") {
    // If we can't find the user object at all, try deepFind for user_results
    const rescued = deepFind(tweetResult, "user_results", 4);
    if (!rescued) {
      return { tweet: null, driftSignals: ["userResult:missing"] };
    }
    driftSignals.push("userResult:deepFind");
  }

  const user = (userObj ?? deepFind(tweetResult, "user_results", 4)) as
    | Record<string, unknown>
    | undefined;

  if (!user) {
    return { tweet: null, driftSignals: ["userResult:missing"] };
  }

  // Resolve each user field
  const screenName = resolveField<string>(
    user,
    schemaMap.paths.screenName,
    "screen_name",
  );
  if (screenName.usedDeepFind) driftSignals.push("screenName:deepFind");

  const displayName = resolveField<string>(
    user,
    schemaMap.paths.displayName,
    "name",
  );
  if (displayName.usedDeepFind) driftSignals.push("displayName:deepFind");

  const followersCount = resolveField<number>(
    user,
    schemaMap.paths.followersCount,
    "followers_count",
  );
  if (followersCount.usedDeepFind) driftSignals.push("followersCount:deepFind");

  const verified = resolveField<boolean>(
    user,
    schemaMap.paths.verified,
    "is_blue_verified",
  );
  if (verified.usedDeepFind) driftSignals.push("verified:deepFind");

  const profileImageUrl = resolveField<string>(
    user,
    schemaMap.paths.profileImageUrl,
    "profile_image_url_https",
  );
  if (profileImageUrl.usedDeepFind) driftSignals.push("profileImageUrl:deepFind");

  // Resolve tweet fields
  const text = walkPath(tweetResult, schemaMap.paths.tweetText) as
    | string
    | undefined;
  const createdAt = walkPath(tweetResult, schemaMap.paths.tweetCreatedAt) as
    | string
    | undefined;

  if (!text) driftSignals.push("tweetText:missing");
  if (!createdAt) driftSignals.push("tweetCreatedAt:missing");

  // Build parsed tweet
  const tweet: ParsedTweet = {
    tweetId: (tweetResult as any).rest_id ?? "",
    text: text ?? "",
    createdAt: createdAt ?? "",
    author: {
      userId: (user as any).rest_id ?? "",
      screenName: screenName.value ?? "",
      displayName: displayName.value ?? "",
      followersCount: followersCount.value ?? 0,
      verified: verified.value ?? false,
      profileImageUrl: profileImageUrl.value ?? "",
    },
    metrics: {
      favoriteCount:
        (walkPath(tweetResult, "legacy.favorite_count") as number) ?? 0,
      retweetCount:
        (walkPath(tweetResult, "legacy.retweet_count") as number) ?? 0,
      replyCount:
        (walkPath(tweetResult, "legacy.reply_count") as number) ?? 0,
      viewCount: Number(
        walkPath(tweetResult, "views.count") ?? 0,
      ),
    },
  };

  return { tweet, driftSignals };
}

Key insight: Every field resolution returns metadata about how it was resolved. The driftSignals array is the input to drift detection. If we parsed 100 tweets and 80 of them have "screenName:deepFind", that tells us the screenName paths in the schema map are stale.


Layer 3: Drift Detection

After parsing a batch, we aggregate the drift signals from every record into a drift report. This report drives the repair decision.

interface DriftReport {
  total: number;
  cleanHits: number;
  deepFindRescues: number;
  failures: number;
  driftedFields: Record<string, number>;
  status: "HEALTHY" | "SCHEMA_DRIFT" | "SCHEMA_BROKEN";
  timestamp: string;
  schemaVersion: number;
}

function buildDriftReport(
  results: ParseResult[],
  schemaVersion: number,
): DriftReport {
  const report: DriftReport = {
    total: results.length,
    cleanHits: 0,
    deepFindRescues: 0,
    failures: 0,
    driftedFields: {},
    status: "HEALTHY",
    timestamp: new Date().toISOString(),
    schemaVersion,
  };

  for (const result of results) {
    if (result.tweet === null) {
      report.failures++;
      continue;
    }

    if (result.driftSignals.length === 0) {
      report.cleanHits++;
    } else {
      report.deepFindRescues++;

      for (const signal of result.driftSignals) {
        const field = signal.split(":")[0];
        report.driftedFields[field] = (report.driftedFields[field] ?? 0) + 1;
      }
    }
  }

  // Determine status
  if (report.failures > 0) {
    report.status = "SCHEMA_BROKEN";
  } else if (report.deepFindRescues > 0) {
    report.status = "SCHEMA_DRIFT";
  } else {
    report.status = "HEALTHY";
  }

  return report;
}

Thresholds and Decision Logic

interface DriftConfig {
  /** Trigger repair if this percentage of records required deepFind */
  driftThreshold: number; // e.g., 0.0 = any drift triggers repair

  /** Trigger alert if this percentage of records completely failed */
  failureThreshold: number; // e.g., 0.5 = 50% failure rate

  /** Minimum batch size before drift detection is meaningful */
  minBatchSize: number; // e.g., 5

  /** Cooldown between repair attempts (prevents storms) */
  repairCooldownMs: number; // e.g., 600_000 = 10 minutes
}

const DEFAULT_DRIFT_CONFIG: DriftConfig = {
  driftThreshold: 0.0,     // Any drift triggers repair
  failureThreshold: 0.5,   // 50% failure rate triggers alert
  minBatchSize: 5,         // Need at least 5 records
  repairCooldownMs: 600_000, // 10 minute cooldown
};

interface DriftDecision {
  shouldRepair: boolean;
  shouldAlert: boolean;
  reason: string;
}

function decideDriftAction(
  report: DriftReport,
  config: DriftConfig,
  lastRepairTimestamp: number | null,
): DriftDecision {
  // Not enough data
  if (report.total < config.minBatchSize) {
    return {
      shouldRepair: false,
      shouldAlert: false,
      reason: `Batch too small (${report.total} < ${config.minBatchSize})`,
    };
  }

  // Check cooldown
  const now = Date.now();
  if (lastRepairTimestamp && now - lastRepairTimestamp < config.repairCooldownMs) {
    const remainingMs = config.repairCooldownMs - (now - lastRepairTimestamp);
    return {
      shouldRepair: false,
      shouldAlert: report.status === "SCHEMA_BROKEN",
      reason: `Repair cooldown active (${Math.ceil(remainingMs / 1000)}s remaining)`,
    };
  }

  // Check failure rate
  const failureRate = report.failures / report.total;
  if (failureRate > config.failureThreshold) {
    return {
      shouldRepair: true,
      shouldAlert: true,
      reason: `High failure rate: ${(failureRate * 100).toFixed(1)}% of records failed to parse`,
    };
  }

  // Check drift rate
  const driftRate = report.deepFindRescues / report.total;
  if (driftRate > config.driftThreshold) {
    return {
      shouldRepair: true,
      shouldAlert: false,
      reason: `Schema drift detected: ${report.deepFindRescues}/${report.total} records required deepFind`,
    };
  }

  return {
    shouldRepair: false,
    shouldAlert: false,
    reason: "All records parsed via schema map paths",
  };
}

Key insight: The threshold of 0.0 for drift means “repair on any deepFind usage.” This is aggressive but correct — if even one record needs deepFind, the schema map is stale. You want to repair proactively, not wait until 50% of records are breaking. The 10-minute cooldown prevents repair storms when the API is actively being deployed.


Layer 4: Deterministic Path Repair

When drift is detected, the first repair strategy is deterministic: use BFS to find where each drifted field actually lives in a sample payload, then update the schema map with the new path.

/**
 * BFS through an object to find the full dot-path to a target key.
 * Returns the shallowest path found.
 *
 * @param obj - The root object to search
 * @param targetKey - The key to find (e.g., "screen_name")
 * @param maxDepth - Maximum depth to search
 * @returns The dot-path (e.g., "core.screen_name") or null if not found
 */
function findPath(
  obj: unknown,
  targetKey: string,
  maxDepth: number = 6,
): string | null {
  if (obj === null || obj === undefined || typeof obj !== "object") {
    return null;
  }

  // BFS queue: [object, currentPath, currentDepth]
  const queue: Array<[Record<string, unknown>, string, number]> = [
    [obj as Record<string, unknown>, "", 0],
  ];

  while (queue.length > 0) {
    const [current, currentPath, depth] = queue.shift()!;

    if (depth > maxDepth) continue;

    for (const key of Object.keys(current)) {
      const fullPath = currentPath ? `${currentPath}.${key}` : key;

      if (key === targetKey) {
        return fullPath;
      }

      const value = current[key];
      if (value !== null && typeof value === "object" && !Array.isArray(value)) {
        queue.push([value as Record<string, unknown>, fullPath, depth + 1]);
      }
    }
  }

  return null;
}

Finding All Occurrences

Sometimes a key appears in multiple places (e.g., the tweet author’s screen_name and a retweeted author’s screen_name). We need to find all paths and pick the right one.

/**
 * Find ALL paths to a target key, sorted by depth (shallowest first).
 */
function findAllPaths(
  obj: unknown,
  targetKey: string,
  maxDepth: number = 8,
): string[] {
  const results: Array<{ path: string; depth: number }> = [];

  if (obj === null || obj === undefined || typeof obj !== "object") {
    return [];
  }

  const queue: Array<[Record<string, unknown>, string, number]> = [
    [obj as Record<string, unknown>, "", 0],
  ];

  while (queue.length > 0) {
    const [current, currentPath, depth] = queue.shift()!;

    if (depth > maxDepth) continue;

    for (const key of Object.keys(current)) {
      const fullPath = currentPath ? `${currentPath}.${key}` : key;

      if (key === targetKey) {
        results.push({ path: fullPath, depth });
      }

      const value = current[key];
      if (value !== null && typeof value === "object" && !Array.isArray(value)) {
        queue.push([value as Record<string, unknown>, fullPath, depth + 1]);
      }
    }
  }

  // Sort by depth (shallowest first)
  results.sort((a, b) => a.depth - b.depth);
  return results.map((r) => r.path);
}

The Repair Engine

// Map from our field names to the key we search for in the raw JSON
const FIELD_KEY_MAP: Record<string, string> = {
  screenName: "screen_name",
  displayName: "name",
  followersCount: "followers_count",
  verified: "is_blue_verified",
  profileImageUrl: "profile_image_url_https",
};

interface RepairPlan {
  /** Fields that were successfully repaired */
  repairs: RepairResult[];

  /** Fields that could not be repaired deterministically */
  unrepairable: string[];

  /** The new schema map (if any repairs were made) */
  newSchemaMap: SchemaMap | null;
}

async function deterministicRepair(
  driftReport: DriftReport,
  currentMap: SchemaMap,
  samplePayloads: Record<string, unknown>[],
): Promise<RepairPlan> {
  const repairs: RepairResult[] = [];
  const unrepairable: string[] = [];

  // For each drifted field, find its new location
  for (const field of Object.keys(driftReport.driftedFields)) {
    const searchKey = FIELD_KEY_MAP[field];
    if (!searchKey) {
      unrepairable.push(field);
      continue;
    }

    // Search across multiple sample payloads for consensus
    const pathCandidates = new Map<string, number>();

    for (const payload of samplePayloads) {
      // Navigate to user object first (since our paths are relative to user)
      const userObj = walkPath(payload, currentMap.paths.userResult) ??
        deepFind(payload, "user_results", 4);

      if (!userObj || typeof userObj !== "object") continue;

      const paths = findAllPaths(userObj, searchKey, 6);
      for (const path of paths) {
        pathCandidates.set(path, (pathCandidates.get(path) ?? 0) + 1);
      }
    }

    if (pathCandidates.size === 0) {
      unrepairable.push(field);
      continue;
    }

    // Pick the path that appears most frequently (consensus across samples)
    const bestPath = [...pathCandidates.entries()]
      .sort((a, b) => b[1] - a[1])[0][0];

    const oldPaths = currentMap.paths[field as keyof typeof currentMap.paths];

    repairs.push({
      field,
      oldPaths: Array.isArray(oldPaths) ? oldPaths : [String(oldPaths)],
      newPath: bestPath,
      method: "deterministic-bfs",
      confidence: 1.0,
    });
  }

  // Build new schema map if we found any repairs
  if (repairs.length === 0) {
    return { repairs, unrepairable, newSchemaMap: null };
  }

  const newPaths = { ...currentMap.paths };

  for (const repair of repairs) {
    const currentPaths = newPaths[repair.field as keyof typeof newPaths];
    if (Array.isArray(currentPaths)) {
      // Prepend new path, keep old paths as fallbacks
      (newPaths as any)[repair.field] = [
        repair.newPath,
        ...currentPaths.filter((p: string) => p !== repair.newPath),
      ];
    } else {
      // Single path field -- replace
      (newPaths as any)[repair.field] = repair.newPath;
    }
  }

  const changelog = repairs
    .map((r) => `${r.field}: ${r.oldPaths[0]} -> ${r.newPath}`)
    .join("; ");

  const newSchemaMap: SchemaMap = {
    version: currentMap.version + 1,
    createdAt: new Date().toISOString(),
    createdBy: "deterministic-repair",
    changelog,
    paths: newPaths,
  };

  return { repairs, unrepairable, newSchemaMap };
}

Key insight: Deterministic repair handles 99% of real-world API changes. Fields don’t disappear — they move. BFS finds where they went. The consensus mechanism (checking multiple sample payloads) prevents false repairs from one-off malformed records. And the cost is zero — it’s just object traversal.


Layer 5: AI Fallback

When deterministic repair fails — the field name itself changed, or the data structure was reorganized beyond a simple move — we fall back to an LLM. This is the last resort, not the first.

interface AIRepairRequest {
  /** The field we're trying to find */
  field: string;

  /** What we expect the field to contain (for context) */
  fieldDescription: string;

  /** Example values from previous working schema */
  exampleValues: string[];

  /** A sample payload where the field could not be found */
  samplePayload: Record<string, unknown>;

  /** The current schema map (for context) */
  currentSchemaMap: SchemaMap;
}

interface AIRepairResponse {
  /** The proposed dot-path to the field */
  proposedPath: string;

  /** The value found at that path (for verification) */
  extractedValue: unknown;

  /** The model's reasoning */
  reasoning: string;

  /** Confidence score (0-1) */
  confidence: number;
}

async function aiRepair(
  request: AIRepairRequest,
  options: {
    model?: string;
    apiKey: string;
    baseUrl?: string;
    maxTokens?: number;
  },
): Promise<AIRepairResponse | null> {
  // Truncate the payload to avoid blowing up token usage
  const truncatedPayload = truncateForLLM(request.samplePayload, 4000);

  const systemPrompt = `You are a JSON schema repair assistant. Your job is to find where a specific field has moved in a JSON structure.

You will be given:
1. A field name and description
2. Example values this field typically contains
3. A JSON payload where the field cannot be found at its expected location
4. The current schema map showing where the field used to be

Your task: Find the new dot-path to this field in the JSON payload.

Respond ONLY with valid JSON matching this schema:
{
  "proposedPath": "the.dot.path.to.the.field",
  "extractedValue": "the value found at that path",
  "reasoning": "brief explanation of what changed",
  "confidence": 0.95
}`;

  const userPrompt = `Find the new location of the "${request.field}" field.

Description: ${request.fieldDescription}
Example values: ${JSON.stringify(request.exampleValues)}

Previous paths (no longer working):
${JSON.stringify(request.currentSchemaMap.paths[request.field as keyof typeof request.currentSchemaMap.paths])}

JSON payload (truncated):
${JSON.stringify(truncatedPayload, null, 2)}`;

  try {
    const response = await fetch(
      options.baseUrl ?? "https://api.openai.com/v1/chat/completions",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${options.apiKey}`,
        },
        body: JSON.stringify({
          model: options.model ?? "gpt-4o-mini",
          messages: [
            { role: "system", content: systemPrompt },
            { role: "user", content: userPrompt },
          ],
          max_tokens: options.maxTokens ?? 500,
          temperature: 0,
          response_format: { type: "json_object" },
        }),
      },
    );

    if (!response.ok) {
      console.error(`AI repair failed: ${response.status} ${response.statusText}`);
      return null;
    }

    const data = await response.json() as any;
    const content = data.choices?.[0]?.message?.content;
    if (!content) return null;

    const parsed = JSON.parse(content) as AIRepairResponse;

    // Verify the proposed path actually works
    const verification = walkPath(request.samplePayload, parsed.proposedPath);
    if (verification === undefined) {
      console.warn(
        `AI proposed path "${parsed.proposedPath}" but it doesn't resolve to a value`,
      );
      return null;
    }

    return {
      ...parsed,
      extractedValue: verification,
    };
  } catch (err) {
    console.error("AI repair error:", err);
    return null;
  }
}

/**
 * Truncate a JSON object to approximately maxChars characters
 * by removing deeply nested content.
 */
function truncateForLLM(
  obj: unknown,
  maxChars: number,
  currentDepth: number = 0,
  maxDepth: number = 5,
): unknown {
  if (currentDepth >= maxDepth) return "[truncated]";

  if (Array.isArray(obj)) {
    // Keep only first 2 items of arrays
    return obj.slice(0, 2).map((item) =>
      truncateForLLM(item, maxChars, currentDepth + 1, maxDepth)
    );
  }

  if (obj !== null && typeof obj === "object") {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
      result[key] = truncateForLLM(value, maxChars, currentDepth + 1, maxDepth);
    }
    return result;
  }

  // Truncate long strings
  if (typeof obj === "string" && obj.length > 200) {
    return obj.slice(0, 200) + "...";
  }

  return obj;
}

The Debounced AI Repair Controller

class AIRepairController {
  private lastAttempt: number = 0;
  private cooldownMs: number;
  private pendingRepairs = new Map<string, Promise<AIRepairResponse | null>>();

  constructor(cooldownMs: number = 600_000) {
    this.cooldownMs = cooldownMs;
  }

  async attemptRepair(
    request: AIRepairRequest,
    options: { model?: string; apiKey: string; baseUrl?: string },
  ): Promise<AIRepairResponse | null> {
    const now = Date.now();

    // Cooldown check
    if (now - this.lastAttempt < this.cooldownMs) {
      return null;
    }

    // Dedup: if we're already repairing this field, return the pending promise
    const existing = this.pendingRepairs.get(request.field);
    if (existing) return existing;

    this.lastAttempt = now;

    const promise = aiRepair(request, options).finally(() => {
      this.pendingRepairs.delete(request.field);
    });

    this.pendingRepairs.set(request.field, promise);
    return promise;
  }
}

Key insight: The AI layer uses the cheapest model that supports structured output (GPT-4o-mini at ~$0.15/1M input tokens). A single repair call costs $0.001-0.005 depending on payload size. Temperature is 0 for determinism. And we verify the proposed path actually resolves to a value before accepting it — the LLM proposes, the code verifies.


Here’s the complete orchestration that ties all five layers together:

class SelfHealingParser {
  private schemaStore: CachedSchemaMapStore;
  private aiRepairController: AIRepairController;
  private driftConfig: DriftConfig;
  private lastRepairTimestamp: number | null = null;
  private endpoint: string;

  constructor(options: {
    bucket: R2Bucket;
    endpoint: string;
    driftConfig?: Partial<DriftConfig>;
    aiRepairCooldownMs?: number;
    schemaCacheTtlMs?: number;
  }) {
    this.endpoint = options.endpoint;
    this.schemaStore = new CachedSchemaMapStore(
      new R2SchemaMapStore(options.bucket),
      options.schemaCacheTtlMs ?? 60_000,
    );
    this.aiRepairController = new AIRepairController(
      options.aiRepairCooldownMs ?? 600_000,
    );
    this.driftConfig = { ...DEFAULT_DRIFT_CONFIG, ...options.driftConfig };
  }

  /**
   * Parse a batch of tweet results from a GraphQL response.
   * Returns parsed tweets + a drift report.
   * Automatically triggers repair if drift is detected.
   */
  async parseBatch(
    tweetResults: Record<string, unknown>[],
    options?: {
      aiApiKey?: string;
      aiBaseUrl?: string;
    },
  ): Promise<{
    tweets: ParsedTweet[];
    driftReport: DriftReport;
    repairPlan: RepairPlan | null;
  }> {
    // 1. Load current schema map
    const schemaMap = await this.schemaStore.getLatest(this.endpoint);

    // 2. Parse all records
    const results: ParseResult[] = tweetResults.map((tr) =>
      parseTweetResult(tr, schemaMap)
    );

    // 3. Build drift report
    const driftReport = buildDriftReport(results, schemaMap.version);

    // 4. Decide if repair is needed
    const decision = decideDriftAction(
      driftReport,
      this.driftConfig,
      this.lastRepairTimestamp,
    );

    let repairPlan: RepairPlan | null = null;

    if (decision.shouldRepair) {
      // 5. Attempt deterministic repair first
      repairPlan = await deterministicRepair(
        driftReport,
        schemaMap,
        tweetResults.slice(0, 10), // Use up to 10 samples
      );

      // 6. If deterministic repair couldn't fix everything, try AI
      if (
        repairPlan.unrepairable.length > 0 &&
        options?.aiApiKey
      ) {
        for (const field of repairPlan.unrepairable) {
          const aiResult = await this.aiRepairController.attemptRepair(
            {
              field,
              fieldDescription: getFieldDescription(field),
              exampleValues: getExampleValues(field),
              samplePayload: tweetResults[0],
              currentSchemaMap: schemaMap,
            },
            {
              apiKey: options.aiApiKey,
              baseUrl: options.aiBaseUrl,
            },
          );

          if (aiResult && aiResult.confidence > 0.8) {
            repairPlan.repairs.push({
              field,
              oldPaths: [],
              newPath: aiResult.proposedPath,
              method: "ai-inference",
              confidence: aiResult.confidence,
              samplePayload: tweetResults[0],
            });
            repairPlan.unrepairable = repairPlan.unrepairable.filter(
              (f) => f !== field,
            );
          }
        }
      }

      // 7. If we have repairs, save the new schema map
      if (repairPlan.newSchemaMap || repairPlan.repairs.length > 0) {
        const newMap = repairPlan.newSchemaMap ?? buildNewSchemaMap(
          schemaMap,
          repairPlan.repairs,
        );

        await this.schemaStore.putVersion(this.endpoint, newMap);
        await this.schemaStore.setLatest(this.endpoint, newMap.version);

        this.lastRepairTimestamp = Date.now();

        // Log the repair for audit
        console.log(
          `[SelfHealingParser] Schema repaired: v${schemaMap.version} -> v${newMap.version}`,
          `Changes: ${newMap.changelog}`,
        );
      }
    }

    if (decision.shouldAlert) {
      console.error(
        `[SelfHealingParser] ALERT: ${decision.reason}`,
        JSON.stringify(driftReport),
      );
    }

    return {
      tweets: results
        .filter((r) => r.tweet !== null)
        .map((r) => r.tweet!),
      driftReport,
      repairPlan,
    };
  }
}

// Helper functions for AI context
function getFieldDescription(field: string): string {
  const descriptions: Record<string, string> = {
    screenName: "The user's @handle on Twitter/X (e.g., 'elonmusk')",
    displayName: "The user's display name (e.g., 'Elon Musk')",
    followersCount: "Number of followers (integer)",
    verified: "Whether the user has a blue verification badge (boolean)",
    profileImageUrl: "HTTPS URL to the user's profile image",
  };
  return descriptions[field] ?? field;
}

function getExampleValues(field: string): string[] {
  const examples: Record<string, string[]> = {
    screenName: ["elonmusk", "jack", "naval"],
    displayName: ["Elon Musk", "jack", "Naval"],
    followersCount: ["180000000", "6500000", "2000000"],
    verified: ["true", "false"],
    profileImageUrl: [
      "https://pbs.twimg.com/profile_images/.../photo.jpg",
    ],
  };
  return examples[field] ?? [];
}

function buildNewSchemaMap(
  current: SchemaMap,
  repairs: RepairResult[],
): SchemaMap {
  const newPaths = { ...current.paths };

  for (const repair of repairs) {
    const currentPaths = newPaths[repair.field as keyof typeof newPaths];
    if (Array.isArray(currentPaths)) {
      (newPaths as any)[repair.field] = [
        repair.newPath,
        ...currentPaths.filter((p: string) => p !== repair.newPath),
      ];
    }
  }

  return {
    version: current.version + 1,
    createdAt: new Date().toISOString(),
    createdBy: repairs.some((r) => r.method === "ai-inference")
      ? "ai-repair"
      : "deterministic-repair",
    changelog: repairs.map((r) =>
      `${r.field}: -> ${r.newPath} (${r.method})`
    ).join("; "),
    paths: newPaths,
  };
}

Usage in a Cloudflare Worker

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const body = await request.json() as {
      endpoint: string;
      tweetResults: Record<string, unknown>[];
    };

    const parser = new SelfHealingParser({
      bucket: env.SCHEMA_MAPS_BUCKET,
      endpoint: body.endpoint,
      driftConfig: {
        driftThreshold: 0.0,
        repairCooldownMs: 600_000,
      },
    });

    const { tweets, driftReport, repairPlan } = await parser.parseBatch(
      body.tweetResults,
      {
        aiApiKey: env.OPENAI_API_KEY,
      },
    );

    return Response.json({
      tweets,
      meta: {
        total: tweets.length,
        schemaVersion: driftReport.schemaVersion,
        driftStatus: driftReport.status,
        repaired: repairPlan?.repairs.length ?? 0,
      },
    });
  },
};

interface Env {
  SCHEMA_MAPS_BUCKET: R2Bucket;
  OPENAI_API_KEY: string;
}

The Chrome Extension Side

Here’s how the Chrome extension intercepts GraphQL responses and forwards them:

// content-script.ts (injected in MAIN world)
// Intercepts X/Twitter's GraphQL responses

const TRACKED_ENDPOINTS = new Set([
  "HomeTimeline",
  "Bookmarks",
  "UserTweets",
  "ListLatestTweetsTimeline",
  "SearchTimeline",
]);

const originalFetch = window.fetch;

window.fetch = async function (...args: Parameters<typeof fetch>) {
  const response = await originalFetch.apply(this, args);

  try {
    const url = typeof args[0] === "string" ? args[0] : args[0]?.url;
    if (!url) return response;

    // Check if this is a GraphQL request we track
    const endpoint = TRACKED_ENDPOINTS.values().find((ep) =>
      url.includes(`/graphql/`) && url.includes(ep)
    );

    if (!endpoint) return response;

    // Clone the response so we don't consume the body
    const clone = response.clone();
    const json = await clone.json();

    // Extract tweet results from the response
    const tweetResults = extractTweetResults(json, endpoint);

    if (tweetResults.length > 0) {
      // Forward to our backend (fire and forget)
      navigator.sendBeacon(
        "https://your-worker.your-domain.workers.dev/parse",
        JSON.stringify({
          endpoint,
          tweetResults,
          capturedAt: new Date().toISOString(),
        }),
      );
    }
  } catch (err) {
    // Never break the original page
    console.debug("[self-healing-parser]", err);
  }

  return response;
};

/**
 * Extract individual tweet result objects from a GraphQL response.
 * This itself uses a simplified version of the self-healing pattern.
 */
function extractTweetResults(
  response: any,
  endpoint: string,
): Record<string, unknown>[] {
  const results: Record<string, unknown>[] = [];

  // Find instructions (the container for timeline entries)
  const instructions = findInstructions(response, endpoint);

  for (const instruction of instructions) {
    if (instruction.type !== "TimelineAddEntries") continue;

    for (const entry of instruction.entries ?? []) {
      // TimelineItem -> single tweet
      const itemContent = entry?.content?.itemContent;
      if (itemContent?.tweet_results?.result) {
        results.push(itemContent.tweet_results.result);
        continue;
      }

      // TimelineModule -> group of tweets (e.g., conversation threads)
      const items = entry?.content?.items;
      if (Array.isArray(items)) {
        for (const item of items) {
          const nested = item?.item?.itemContent?.tweet_results?.result;
          if (nested) results.push(nested);
        }
      }
    }
  }

  return results;
}

function findInstructions(response: any, endpoint: string): any[] {
  // Different endpoints nest instructions differently
  const knownPaths: Record<string, string> = {
    HomeTimeline: "data.home.home_timeline_urt.instructions",
    Bookmarks: "data.bookmark_timeline_v2.timeline.instructions",
    UserTweets: "data.user.result.timeline_v2.timeline.instructions",
    ListLatestTweetsTimeline: "data.list.tweets_timeline.timeline.instructions",
    SearchTimeline: "data.search_by_raw_query.search_timeline.timeline.instructions",
  };

  const path = knownPaths[endpoint];
  if (path) {
    const result = walkPath(response, path);
    if (Array.isArray(result)) return result;
  }

  // Fallback: search for instructions array
  const found = deepFind(response, "instructions", 6);
  return Array.isArray(found) ? found : [];
}

Example 1: Basic walkPath with Arrays

/**
 * Enhanced walkPath that handles array indices in dot-paths.
 * Supports paths like "entries.0.content.itemContent"
 */
function walkPathWithArrays(obj: unknown, path: string): unknown {
  const segments = path.split(".");
  let current: unknown = obj;

  for (const segment of segments) {
    if (current === null || current === undefined) return undefined;

    if (Array.isArray(current)) {
      const index = parseInt(segment, 10);
      if (isNaN(index)) return undefined;
      current = current[index];
    } else if (typeof current === "object") {
      current = (current as Record<string, unknown>)[segment];
    } else {
      return undefined;
    }
  }

  return current;
}

// Usage
const response = {
  data: { entries: [{ content: { text: "hello" } }, { content: { text: "world" } }] },
};

walkPathWithArrays(response, "data.entries.0.content.text"); // "hello"
walkPathWithArrays(response, "data.entries.1.content.text"); // "world"

Example 2: Type-Safe Field Resolver with Zod Validation

import { z } from "zod";

/**
 * Resolve a field and validate its type with Zod.
 * Combines self-healing path resolution with runtime type safety.
 */
function resolveTypedField<T>(
  obj: unknown,
  paths: string[],
  deepFindKey: string,
  schema: z.ZodType<T>,
): { value: T | undefined; usedDeepFind: boolean; validationError?: string } {
  const resolved = resolveField(obj, paths, deepFindKey);

  if (resolved.value === undefined) {
    return { value: undefined, usedDeepFind: resolved.usedDeepFind };
  }

  const parsed = schema.safeParse(resolved.value);
  if (parsed.success) {
    return { value: parsed.data, usedDeepFind: resolved.usedDeepFind };
  }

  return {
    value: undefined,
    usedDeepFind: resolved.usedDeepFind,
    validationError: parsed.error.message,
  };
}

// Usage
const screenName = resolveTypedField(
  userObj,
  ["core.screen_name", "legacy.screen_name"],
  "screen_name",
  z.string().min(1).max(15),
);

const followers = resolveTypedField(
  userObj,
  ["legacy.followers_count"],
  "followers_count",
  z.number().int().nonnegative(),
);

Example 3: Batch Processing with Streaming Drift Detection

/**
 * Process a large batch of items with streaming drift detection.
 * Triggers repair after the first N items if drift is detected,
 * then re-parses remaining items with the repaired schema.
 */
async function processWithEarlyRepair<T>(
  items: Record<string, unknown>[],
  parser: SelfHealingParser,
  options: { earlyCheckSize: number; aiApiKey?: string },
): Promise<ParsedTweet[]> {
  const { earlyCheckSize } = options;
  const allTweets: ParsedTweet[] = [];

  // Phase 1: Parse first N items and check for drift
  const earlyBatch = items.slice(0, earlyCheckSize);
  const earlyResult = await parser.parseBatch(earlyBatch, {
    aiApiKey: options.aiApiKey,
  });

  allTweets.push(...earlyResult.tweets);

  // Phase 2: Parse remaining items (schema may have been repaired)
  if (items.length > earlyCheckSize) {
    const remainingBatch = items.slice(earlyCheckSize);
    const remainingResult = await parser.parseBatch(remainingBatch, {
      aiApiKey: options.aiApiKey,
    });
    allTweets.push(...remainingResult.tweets);
  }

  return allTweets;
}

// Usage: check after 10 items, then parse the rest
const tweets = await processWithEarlyRepair(allTweetResults, parser, {
  earlyCheckSize: 10,
  aiApiKey: env.OPENAI_API_KEY,
});

Example 4: Schema Map Diff Utility

/**
 * Compare two schema maps and generate a human-readable diff.
 * Useful for audit trails and debugging.
 */
function diffSchemaMaps(
  oldMap: SchemaMap,
  newMap: SchemaMap,
): Array<{
  field: string;
  change: "added" | "removed" | "reordered" | "replaced";
  oldValue: string | string[];
  newValue: string | string[];
}> {
  const diffs: Array<{
    field: string;
    change: "added" | "removed" | "reordered" | "replaced";
    oldValue: string | string[];
    newValue: string | string[];
  }> = [];

  const allFields = new Set([
    ...Object.keys(oldMap.paths),
    ...Object.keys(newMap.paths),
  ]);

  for (const field of allFields) {
    const oldVal = oldMap.paths[field as keyof typeof oldMap.paths];
    const newVal = newMap.paths[field as keyof typeof newMap.paths];

    if (oldVal === undefined) {
      diffs.push({ field, change: "added", oldValue: "", newValue: newVal as any });
    } else if (newVal === undefined) {
      diffs.push({ field, change: "removed", oldValue: oldVal as any, newValue: "" });
    } else if (Array.isArray(oldVal) && Array.isArray(newVal)) {
      if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
        const hasNewPaths = newVal.some((p: string) => !oldVal.includes(p));
        diffs.push({
          field,
          change: hasNewPaths ? "added" : "reordered",
          oldValue: oldVal,
          newValue: newVal,
        });
      }
    } else if (oldVal !== newVal) {
      diffs.push({ field, change: "replaced", oldValue: oldVal as any, newValue: newVal as any });
    }
  }

  return diffs;
}

// Usage
const diff = diffSchemaMaps(schemaMapV1, schemaMapV2);
// [{ field: "screenName", change: "added", oldValue: ["legacy.screen_name"],
//    newValue: ["core.screen_name", "legacy.screen_name"] }]

Example 5: Prometheus-Style Metrics for Drift Monitoring

/**
 * Track parser health metrics over time.
 * Export these to your monitoring system.
 */
class ParserMetrics {
  private counters = {
    totalParsed: 0,
    cleanHits: 0,
    deepFindRescues: 0,
    failures: 0,
    repairsAttempted: 0,
    repairsSucceeded: 0,
    aiRepairsTriggered: 0,
  };

  private fieldDriftCounts = new Map<string, number>();

  recordBatch(report: DriftReport): void {
    this.counters.totalParsed += report.total;
    this.counters.cleanHits += report.cleanHits;
    this.counters.deepFindRescues += report.deepFindRescues;
    this.counters.failures += report.failures;

    for (const [field, count] of Object.entries(report.driftedFields)) {
      this.fieldDriftCounts.set(
        field,
        (this.fieldDriftCounts.get(field) ?? 0) + count,
      );
    }
  }

  recordRepair(plan: RepairPlan): void {
    this.counters.repairsAttempted++;
    if (plan.repairs.length > 0) this.counters.repairsSucceeded++;
    if (plan.repairs.some((r) => r.method === "ai-inference")) {
      this.counters.aiRepairsTriggered++;
    }
  }

  toPrometheus(): string {
    const lines: string[] = [];
    for (const [key, value] of Object.entries(this.counters)) {
      lines.push(`parser_${key} ${value}`);
    }
    for (const [field, count] of this.fieldDriftCounts) {
      lines.push(`parser_field_drift{field="${field}"} ${count}`);
    }
    return lines.join("\n");
  }

  /** Health score: 0.0 (completely broken) to 1.0 (perfect) */
  healthScore(): number {
    if (this.counters.totalParsed === 0) return 1.0;
    return this.counters.cleanHits / this.counters.totalParsed;
  }
}

Example 6: Schema Map Migration from Hardcoded Paths

/**
 * One-time utility to generate an initial schema map from
 * a sample payload. Use this to bootstrap the system.
 */
function bootstrapSchemaMap(
  samplePayload: Record<string, unknown>,
  fieldKeys: Record<string, string>,
): SchemaMap {
  const paths: Record<string, string | string[]> = {};

  for (const [fieldName, targetKey] of Object.entries(fieldKeys)) {
    const allPaths = findAllPaths(samplePayload, targetKey, 8);

    if (allPaths.length === 0) {
      console.warn(`Could not find key "${targetKey}" for field "${fieldName}"`);
      paths[fieldName] = [];
    } else if (allPaths.length === 1) {
      paths[fieldName] = allPaths;
    } else {
      // Multiple occurrences -- keep all, shallowest first
      paths[fieldName] = allPaths;
    }
  }

  return {
    version: 1,
    createdAt: new Date().toISOString(),
    createdBy: "hardcoded",
    changelog: "Auto-generated from sample payload",
    paths: paths as SchemaMap["paths"],
  };
}

// Usage
const initialMap = bootstrapSchemaMap(sampleTweet, {
  screenName: "screen_name",
  displayName: "name",
  followersCount: "followers_count",
  verified: "is_blue_verified",
  profileImageUrl: "profile_image_url_https",
});

Example 7: Testing Schema Drift in Unit Tests

import { describe, it, expect } from "vitest";

describe("Self-Healing Parser", () => {
  it("handles screen_name moving from legacy to core", () => {
    const schemaMap: SchemaMap = {
      version: 1,
      createdAt: "2025-01-01T00:00:00Z",
      createdBy: "hardcoded",
      changelog: "initial",
      paths: {
        userResult: "core.user_results.result",
        screenName: ["legacy.screen_name"],
        displayName: ["legacy.name"],
        followersCount: ["legacy.followers_count"],
        verified: ["is_blue_verified"],
        profileImageUrl: ["legacy.profile_image_url_https"],
        tweetText: "legacy.full_text",
        tweetCreatedAt: "legacy.created_at",
        mediaEntities: ["legacy.extended_entities.media"],
      },
    };

    // Simulate X moving screen_name to core
    const tweetWithMovedField = {
      rest_id: "123",
      core: {
        user_results: {
          result: {
            rest_id: "456",
            // screen_name is now HERE (was in legacy)
            core: { screen_name: "testuser" },
            legacy: {
              // screen_name is GONE from here
              name: "Test User",
              followers_count: 100,
              profile_image_url_https: "https://example.com/img.jpg",
            },
            is_blue_verified: false,
          },
        },
      },
      legacy: {
        full_text: "Hello world",
        created_at: "Sat Mar 01 12:00:00 +0000 2025",
      },
      views: { count: "1000" },
    };

    const { tweet, driftSignals } = parseTweetResult(tweetWithMovedField, schemaMap);

    // Data should still be extracted (via deepFind)
    expect(tweet).not.toBeNull();
    expect(tweet!.author.screenName).toBe("testuser");

    // But drift should be signaled
    expect(driftSignals).toContain("screenName:deepFind");
  });

  it("deterministic repair finds the new path", async () => {
    const currentMap: SchemaMap = {
      version: 1,
      createdAt: "2025-01-01T00:00:00Z",
      createdBy: "hardcoded",
      changelog: "initial",
      paths: {
        userResult: "core.user_results.result",
        screenName: ["legacy.screen_name"],
        displayName: ["legacy.name"],
        followersCount: ["legacy.followers_count"],
        verified: ["is_blue_verified"],
        profileImageUrl: ["legacy.profile_image_url_https"],
        tweetText: "legacy.full_text",
        tweetCreatedAt: "legacy.created_at",
        mediaEntities: ["legacy.extended_entities.media"],
      },
    };

    const driftReport: DriftReport = {
      total: 10,
      cleanHits: 0,
      deepFindRescues: 10,
      failures: 0,
      driftedFields: { screenName: 10 },
      status: "SCHEMA_DRIFT",
      timestamp: new Date().toISOString(),
      schemaVersion: 1,
    };

    // User object where screen_name has moved
    const sampleUserObj = {
      rest_id: "456",
      core: { screen_name: "testuser" },
      legacy: { name: "Test", followers_count: 100 },
      is_blue_verified: false,
    };

    // Wrap in full tweet structure
    const samplePayloads = Array(5).fill({
      core: { user_results: { result: sampleUserObj } },
      legacy: { full_text: "test" },
    });

    const plan = await deterministicRepair(driftReport, currentMap, samplePayloads);

    expect(plan.repairs).toHaveLength(1);
    expect(plan.repairs[0].field).toBe("screenName");
    expect(plan.repairs[0].newPath).toBe("core.screen_name");
    expect(plan.repairs[0].method).toBe("deterministic-bfs");
    expect(plan.repairs[0].confidence).toBe(1.0);

    // New schema map should have core.screen_name as first path
    expect(plan.newSchemaMap).not.toBeNull();
    expect(plan.newSchemaMap!.paths.screenName[0]).toBe("core.screen_name");
    // Old path should still be there as fallback
    expect(plan.newSchemaMap!.paths.screenName).toContain("legacy.screen_name");
  });
});

Example 8: Webhook Payload Self-Healing (Generic Version)

/**
 * Generic self-healing resolver for any JSON API.
 * Not tied to Twitter -- works with any webhook or API response.
 */
interface GenericSchemaMap {
  version: number;
  fields: Record<string, {
    paths: string[];
    deepFindKey: string;
    required: boolean;
  }>;
}

function resolveGenericPayload(
  payload: unknown,
  schema: GenericSchemaMap,
): {
  data: Record<string, unknown>;
  driftedFields: string[];
  missingFields: string[];
} {
  const data: Record<string, unknown> = {};
  const driftedFields: string[] = [];
  const missingFields: string[] = [];

  for (const [fieldName, config] of Object.entries(schema.fields)) {
    const resolved = resolveField(
      payload,
      config.paths,
      config.deepFindKey,
    );

    if (resolved.value !== undefined) {
      data[fieldName] = resolved.value;
      if (resolved.usedDeepFind) driftedFields.push(fieldName);
    } else if (config.required) {
      missingFields.push(fieldName);
    }
  }

  return { data, driftedFields, missingFields };
}

// Usage with a Stripe webhook
const stripeSchema: GenericSchemaMap = {
  version: 1,
  fields: {
    customerId: {
      paths: ["data.object.customer", "customer"],
      deepFindKey: "customer",
      required: true,
    },
    amount: {
      paths: ["data.object.amount", "data.object.amount_total"],
      deepFindKey: "amount",
      required: true,
    },
    currency: {
      paths: ["data.object.currency"],
      deepFindKey: "currency",
      required: true,
    },
    subscriptionId: {
      paths: ["data.object.subscription"],
      deepFindKey: "subscription",
      required: false,
    },
  },
};

Example 9: Debug Inspector for Production Issues

/**
 * When a specific record fails to parse, generate a detailed
 * inspection report showing exactly what went wrong.
 */
function inspectParseFailure(
  payload: Record<string, unknown>,
  schemaMap: SchemaMap,
): string {
  const lines: string[] = [];
  lines.push(`=== Parse Failure Inspection ===`);
  lines.push(`Schema version: ${schemaMap.version}`);
  lines.push(`Schema created: ${schemaMap.createdAt} by ${schemaMap.createdBy}`);
  lines.push(``);

  // Check user result path
  const userResult = walkPath(payload, schemaMap.paths.userResult);
  lines.push(`userResult path "${schemaMap.paths.userResult}": ${userResult ? "FOUND" : "MISSING"}`);

  if (!userResult) {
    // Try to find where user_results actually is
    const allUserPaths = findAllPaths(payload, "user_results", 8);
    lines.push(`  deepFind found user_results at: ${allUserPaths.join(", ") || "NOWHERE"}`);
  }

  // Check each field
  const user = userResult ?? {};
  const fieldConfigs = [
    { name: "screenName", paths: schemaMap.paths.screenName, key: "screen_name" },
    { name: "displayName", paths: schemaMap.paths.displayName, key: "name" },
    { name: "followersCount", paths: schemaMap.paths.followersCount, key: "followers_count" },
    { name: "verified", paths: schemaMap.paths.verified, key: "is_blue_verified" },
  ];

  for (const { name, paths, key } of fieldConfigs) {
    lines.push(``);
    lines.push(`--- ${name} ---`);

    for (const path of paths) {
      const value = walkPath(user, path);
      lines.push(`  path "${path}": ${value !== undefined ? JSON.stringify(value) : "MISSING"}`);
    }

    const deepValue = deepFind(user, key, 6);
    lines.push(`  deepFind "${key}": ${deepValue !== undefined ? JSON.stringify(deepValue) : "NOT FOUND"}`);

    const allPaths = findAllPaths(user as Record<string, unknown>, key, 8);
    if (allPaths.length > 0) {
      lines.push(`  all occurrences: ${allPaths.join(", ")}`);
    }
  }

  return lines.join("\n");
}

Example 10: Rate-Limited Repair Queue

/**
 * When multiple endpoints detect drift simultaneously,
 * queue repairs and process them one at a time to avoid
 * racing on R2 writes.
 */
class RepairQueue {
  private queue: Array<{
    endpoint: string;
    driftReport: DriftReport;
    samplePayloads: Record<string, unknown>[];
    resolve: (plan: RepairPlan) => void;
  }> = [];
  private processing = false;
  private schemaStore: CachedSchemaMapStore;

  constructor(schemaStore: CachedSchemaMapStore) {
    this.schemaStore = schemaStore;
  }

  async enqueue(
    endpoint: string,
    driftReport: DriftReport,
    samplePayloads: Record<string, unknown>[],
  ): Promise<RepairPlan> {
    return new Promise((resolve) => {
      this.queue.push({ endpoint, driftReport, samplePayloads, resolve });
      this.processNext();
    });
  }

  private async processNext(): Promise<void> {
    if (this.processing || this.queue.length === 0) return;

    this.processing = true;
    const item = this.queue.shift()!;

    try {
      const currentMap = await this.schemaStore.getLatest(item.endpoint);
      const plan = await deterministicRepair(
        item.driftReport,
        currentMap,
        item.samplePayloads,
      );

      if (plan.newSchemaMap) {
        await this.schemaStore.putVersion(item.endpoint, plan.newSchemaMap);
        await this.schemaStore.setLatest(item.endpoint, plan.newSchemaMap.version);
      }

      item.resolve(plan);
    } catch (err) {
      item.resolve({ repairs: [], unrepairable: [], newSchemaMap: null });
    } finally {
      this.processing = false;
      this.processNext();
    }
  }
}

Parsing Approaches

ApproachDetects breakageFixes itselfCost per recordLatencyHandles field movesHandles restructuresAudit trail
Hardcoded pathsNo (silent undefined)NoFree~0msNoNoNo
Optional chainingNo (silent fallback)NoFree~0msNoNoNo
Zod / JSON SchemaYes (throws/error)NoFree~0.1msNoNoNo
LLM-only parsingYes (implicit)Yes$0.01-0.10500-2000msYesYesNo
Self-healing parserYes (drift detection)Yes (auto-repair)Free (fast path)~0.1msYesYes (AI fallback)Yes (versioned)

Storage for Schema Maps

StorageLatency (from Worker)CostVersioningBest for
Cloudflare R2~1ms (binding)$0.015/GBManual (file naming)Workers-native apps
Cloudflare KV~1ms (binding)$0.50/M readsManualSmall configs (<25MB)
AWS S350-200ms$0.023/GBBuilt-inAWS-native apps
PostgreSQL/D15-50msVariesVia migrationsApps already using SQL
Git repoN/A (deploy time)FreeBuilt-inCI/CD-driven updates
In-memory (hardcoded)0msFreeVia code deploysWhen you control the API

Self-Healing Strategies Compared

StrategyWhen it worksWhen it failsCostSpeedDeterminism
BFS path discoveryField moved to new locationField renamed or removedFree<1ms100% deterministic
Similarity matching (Scrapling-style)HTML elements with changed selectorsComplete page redesignsFree~2msHigh (score-based)
AI inference (LLM)Structural reorganization, field renamesComplete data model change$0.001-0.005/call500-2000msNon-deterministic
Schema registry (Kafka-style)Known schema evolutionUnversioned APIsFree~1ms100% deterministic
Contract testing (Pact)Breaking change detected pre-deployThird-party APIs you don’t controlFreeCI-timeN/A (prevention, not repair)

Compared to Existing Tools and Patterns

SystemWhat it isHow it relatesProsCons
ScraplingAdaptive web scraping framework (Python)Uses fingerprinting + similarity for HTML elementsMature, battle-tested, 99.2% accuracyHTML-only, Python-only, no JSON/API support
ZodTypeScript schema validationValidates structure but doesn’t repairExcellent TypeScript integration, composableDetection only — no self-healing
API Schema DifferentiatorAuto API schema drift detectionDetects changes between API versionsZero-config, automatic learningDetection only — no repair
Refold AIAI-powered integration agentSelf-healing API integrationsFull-service, handles auth + schemaExpensive, vendor lock-in, not open source
Kafka Schema RegistrySchema versioning for event streamsVersion control for schemasIndustry standard, compatibility checksRequires producer cooperation (useless for 3rd party APIs)
Great ExpectationsData quality frameworkValidates data quality after extractionRich assertion library, profilingBatch-oriented, no real-time repair

Don’tDo InsteadWhy
tweet.core.user_results.result.legacy.screen_nameresolveField(user, paths.screenName, "screen_name")Hardcoded paths break silently when the API changes
value ?? "unknown" for critical fieldsTrack when fallback triggers (drift signal)Silent fallbacks mask breakage — you need to know when schema drifts
Parse with LLM on every requestLLM only when deterministic repair failsDeterministic repair is free and instant; LLM costs money and adds 500ms+ latency
Store schema paths in application codeStore in external config (R2, KV, S3)Config changes require deploys; external storage enables zero-deploy repair
Overwrite schema map versions in placeImmutable versions (v1.json, v2.json, …)Immutable versions give you audit trail and instant rollback
Run repair on every single requestBatch drift detection + cooldown (10 min)Repairing per-request wastes compute; cooldown prevents repair storms during API deployments
DFS for finding moved fieldsBFS (breadth-first search)BFS finds the shallowest match, which is almost always the correct one; DFS might find a deeply nested homonym
Trusting AI repair output without verificationVerify proposed paths with walkPath before acceptingLLMs hallucinate paths that look plausible but don’t exist
Ignoring array-typed fields during BFSHandle arrays by iterating the first elementMany APIs wrap data in arrays; skipping them means missing entire field subtrees
Logging raw API responses to production logsLog truncated samples only, store full payloads in R2GraphQL responses can be 200KB+; logging them fills your log storage instantly
Repairing without consensus across samplesCheck multiple sample payloads before committing a repairA single malformed record can produce a false repair; consensus prevents this

High-Value Use Cases

Web scraping pipelines. HTML structures change frequently — class names, nesting depth, element order. The self-healing pattern adapts Scrapling’s approach (element fingerprinting) to JSON APIs. If you’re scraping structured data from rendered pages, consider Scrapling for HTML and this pattern for the underlying API data.

GraphQL consumers of APIs you don’t control. GraphQL is supposed to solve versioning, but internal/undocumented GraphQL APIs (like X/Twitter’s) change their response shape without notice. The type system helps at the schema level but does nothing for response structure changes within the data field.

Chrome extension data interception. Extensions that read page data by intercepting network requests are inherently fragile. The page’s API can change with any deployment. The self-healing parser keeps your extension working across API updates.

Mobile app API reverse engineering. If you’re consuming a mobile app’s API (reverse-engineered from traffic inspection), expect frequent changes. The app update cycle means API changes ship to millions of devices simultaneously, and your backend needs to keep up.

Third-party webhook payloads. Even well-documented APIs like Stripe and GitHub occasionally restructure webhook payloads. Usually it’s additive (new fields), but sometimes fields move or get nested differently. The self-healing pattern handles this gracefully.

RSS/Atom feed variations. Feed parsers deal with enormous variance in real-world XML structure. The same concepts (path resolution with fallback, drift detection) apply to XML as well as JSON.

When NOT to Use This


Runtime Cost Per Request (Fast Path)

OperationTimeCompute Cost
Load schema map from cache<0.01msFree
walkPath per field (5 fields)~0.05msFree
Drift signal collection~0.01msFree
Total (no drift)~0.07msFree

Repair Cost (When Drift Detected)

OperationTimeCompute Cost
findPath BFS per drifted field0.1-1msFree
Consensus check across 10 samples1-10msFree
R2 write (new schema version)5-20ms$0.000005
R2 write (update latest)5-20ms$0.000005
Total (deterministic repair)~50ms~$0.00001

AI Repair Cost (Last Resort)

OperationTimeCompute Cost
Truncate payload<1msFree
LLM inference (GPT-4o-mini)500-2000ms$0.001-0.005
Path verification<0.1msFree
R2 writes10-40ms$0.00001
Total (AI repair)~1-2s~$0.005

The key insight: AI repair is 100,000x more expensive than the fast path. But it triggers at most once per 10-minute cooldown window. In practice, over a month of operation with weekly API changes, you might run AI repair 4-8 times total. That’s $0.02-0.04/month.

Deep Find Cost (Rescue Path)

Payload SizeBFS TimeNotes
1KB (simple object)<0.01msNegligible
10KB (typical tweet)0.1-0.5msStill fast
100KB (full timeline)1-5msNoticeable if hit on every request
1MB (bulk response)10-50msUnacceptable as steady-state; repair should trigger

The deep find cost is why drift detection and repair exist. Deep find is the bridge that keeps data flowing while the system figures out the new schema. It should never be the steady-state path.


Why Schema Map, Not Code?

When your parser paths are in TypeScript, updating them requires:

  1. A developer notices the breakage
  2. Developer finds the new path
  3. Developer updates the code
  4. PR review
  5. Merge
  6. CI/CD build
  7. Deploy

That’s hours to days. When paths are in a JSON file on R2, updating them requires:

  1. Write JSON
  2. Upload to R2

The self-healing parser does this automatically in milliseconds.

Why BFS, Not DFS?

Consider a tweet object where screen_name appears in three places:

BFS returns the shallowest match: core.screen_name. That’s the author’s handle — the one we want.

DFS would return whichever branch it explores first. If the object’s keys are ordered such that quoted_status comes before core, DFS returns the wrong screen_name.

Why Immutable Versions?

Imagine your parser auto-repairs at 2am, and the new schema map is wrong (it picked up a false path from a malformed record). With mutable schema maps, you’ve lost the previous version. With immutable versions:

v3.json <- the broken repair
v2.json <- still exists
latest.json -> v3.json <- points to broken version

Recovery:

await schemaStore.setLatest("HomeTimeline", 2); // instant rollback

Why 10-Minute Cooldown?

API deployments often happen in waves. X/Twitter might update their API servers over a 30-minute window. During that window, you might see mixed responses — some from old servers, some from new. Without a cooldown:

  1. Batch 1: new schema, repair triggers, creates v2
  2. Batch 2: old schema (from unupdated server), repair triggers, creates v3 reverting to old paths
  3. Batch 3: new schema again, repair triggers, creates v4 going back to new paths
  4. Repeat…

The 10-minute cooldown lets the deployment settle before committing to a repair.

Why Zero Drift Threshold?

If the threshold is 50%, you need 50% of records to fail before repair triggers. That means you’re running deep find (the slow path) on 50% of your traffic. Even at 0.5ms per deep find, that adds up.

A threshold of 0% means: the moment any record needs deep find, start the repair. The deep find still rescues that batch’s data, and the repair ensures the next batch uses the fast path.


Initial Setup

wrangler r2 bucket create schema-maps

cat > /tmp/v1.json << 'EOF'
{
  "version": 1,
  "createdAt": "2025-01-15T00:00:00Z",
  "createdBy": "hardcoded",
  "changelog": "Initial schema map",
  "paths": {
    "userResult": "core.user_results.result",
    "screenName": ["legacy.screen_name"],
    "displayName": ["legacy.name"],
    "followersCount": ["legacy.followers_count"],
    "verified": ["is_blue_verified", "legacy.verified"],
    "profileImageUrl": ["legacy.profile_image_url_https"],
    "tweetText": "legacy.full_text",
    "tweetCreatedAt": "legacy.created_at",
    "mediaEntities": ["legacy.extended_entities.media", "legacy.entities.media"]
  }
}
EOF

wrangler r2 object put schema-maps/twitter/HomeTimeline/v1.json --file /tmp/v1.json
wrangler r2 object put schema-maps/twitter/HomeTimeline/latest.json --file /tmp/v1.json

Monitoring

// Add to your Worker's scheduled handler
export default {
  async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
    // Read the current schema versions for all endpoints
    const endpoints = ["HomeTimeline", "Bookmarks", "UserTweets"];

    for (const endpoint of endpoints) {
      const store = new R2SchemaMapStore(env.SCHEMA_MAPS_BUCKET);
      const latest = await store.getLatest(endpoint);

      console.log(JSON.stringify({
        type: "schema_health",
        endpoint,
        version: latest.version,
        createdBy: latest.createdBy,
        createdAt: latest.createdAt,
        changelog: latest.changelog,
      }));
    }
  },
};

Emergency Rollback

wrangler r2 object get schema-maps/twitter/HomeTimeline/v2.json > /tmp/v2.json
wrangler r2 object put schema-maps/twitter/HomeTimeline/latest.json --file /tmp/v2.json

Q: What if deepFind returns the wrong value?

Deep find returns the shallowest match for a given key name. If two unrelated fields share the same key name (e.g., name appears as both a user’s display name and a media entity’s alt text), BFS returns the shallower one. In practice, navigate to the correct sub-object first (e.g., the user object), then deep find within that scope. The schema map’s userResult path does this — it narrows the search space before field resolution begins.

Q: What about arrays in the response?

The current deepFind implementation skips arrays. This is intentional for field discovery (you want the schema-level path, not a path through a specific array index). For traversing arrays during extraction, use the walkPathWithArrays variant shown in Example 1.

Q: How do I handle an API that returns different structures for the same endpoint?

This happens with X/Twitter — the same endpoint returns Tweet objects and TweetWithVisibilityResults objects depending on content restrictions. Handle this with a pre-processing step that normalizes the wrapper:

function unwrapTweetResult(result: Record<string, unknown>): Record<string, unknown> {
  if (result.__typename === "TweetWithVisibilityResults") {
    return (result.tweet as Record<string, unknown>) ?? result;
  }
  if (result.__typename === "TweetTombstone") {
    return result; // Will fail gracefully in parsing
  }
  return result;
}

Q: Can I use this pattern with TypeScript strict mode?

Yes. The walkPath and deepFind functions use unknown types and runtime checks. You lose compile-time type safety on the raw response (which you didn’t have anyway with an unversioned API), but the output types (ParsedTweet, etc.) are fully typed.

Q: What if the entire response structure changes, not just field locations?

If X/Twitter redesigns their HomeTimeline endpoint to use a completely different response envelope, the extraction layer (extractTweetResults) breaks before the field-level parser even runs. This is where you need endpoint-level monitoring: if findInstructions returns an empty array for multiple consecutive requests, alert. The self-healing parser handles field-level changes, not endpoint-level redesigns.


Official Documentation

Analysis and Design

Tools and Libraries

Schema Drift and Data Quality

LLM and AI Integration

Web Scraping Context


MIT


This article describes a pattern that emerged from production experience building systems on top of APIs we don’t control. The X/Twitter GraphQL API is used as the primary example because it is the canonical case of an unstable, unversioned, deeply-nested API that changes without warning. The same pattern applies to any API where the producer doesn’t guarantee schema stability.


Edit page
Share this post on:

Previous Post
Securing Servers with Tailscale and Cloudflare
Next Post
Deploying TanStack Start to Cloudflare Workers