Files
2026-02-09 08:36:44 -05:00

38 KiB

Phase 15: Infrastructure Foundation - Research

Researched: 2026-02-09 Domain: Data transformation layers for Unraid GraphQL API integration Confidence: MEDIUM-HIGH

Summary

Phase 15 creates the infrastructure foundation needed to migrate from Docker socket proxy to Unraid's GraphQL API. This phase focuses purely on data transformation layers — no API calls are migrated yet, that happens in Phase 16.

The critical challenge is container ID format translation. Docker uses 64-character hex IDs (e.g., abc123...), Unraid uses 129-character PrefixedID format (e.g., server_hash:container_hash). Phase 14 research revealed actual format from test query, and Phase 15 must build bidirectional translation between container names (workflow contract) and Unraid PrefixedIDs.

Telegram's 64-byte callback_data limit becomes more constrained with longer IDs. Current implementation uses base36-encoded bitmaps for batch operations (handles 50+ containers), but single-container callbacks like action:start:plex may exceed 64 bytes with 129-char IDs. The solution is hash-based token mapping: store full PrefixedID server-side, encode short token in callback_data.

GraphQL response normalization transforms Unraid API shape to match workflow contract. Current Docker API returns { Id, Names, State }, Unraid returns { id, names[], state, isUpdateAvailable }. Normalization layer maps field names, handles array vs string differences, adds missing fields with safe defaults.

GraphQL error handling differs from Docker REST API. Successful HTTP 200 can contain response.errors[] array. HTTP 304 means "already in desired state" (not an error). Current Docker API checking pattern (statusCode === 304 for "already started") extends to GraphQL errors[0].extensions.code === 'ALREADY_IN_STATE'.

Timeout configuration must account for myunraid.net cloud relay latency. Phase 14 research showed 200-500ms overhead vs direct LAN. Current Docker API calls use default n8n timeout (10 seconds). Recommended: 15-second timeout for Unraid API calls, with retry logic for 429 rate limiting.

Primary recommendation: Build Container ID Registry as centralized translation layer, implement hash-based callback encoding with 8-character tokens, create GraphQL Response Normalizer that matches Docker API contract exactly, standardize error checking with checkGraphQLErrors(response) utility function, configure 15-second timeouts on all Unraid API HTTP Request nodes.


Standard Stack

Core

Library Version Purpose Why Standard
n8n Code node Built-in (1.x) Data transformation logic Native JavaScript execution, no external dependencies
n8n HTTP Request node Built-in (1.x) GraphQL API calls Standard HTTP client with configurable timeouts
JavaScript BigInt ES2020 native Bitmap encoding for batch callbacks Handles 50+ containers in 64-byte limit
Base36 encoding JavaScript native Compact number representation Efficient encoding for bitmap values

Supporting

Library Version Purpose When to Use
n8n Set node Built-in Field mapping/normalization Simple field renames, add/remove fields
n8n Merge node Built-in Combine data from multiple sources Join container data with translation tables
SHA-256 hash crypto.subtle Web API Generate callback tokens Short stable references to long IDs

Alternatives Considered

Instead of Could Use Tradeoff
Hash-based token mapping Protobuf+base85 encoding Protobuf adds complexity, no significant byte savings for simple callbacks
Centralized ID registry Per-node ID lookups Registry provides single source of truth, easier debugging
Code node transformations n8n expression editor Code nodes handle complex logic better, more maintainable
15-second timeout Default 10-second Cloud relay adds 200-500ms, 15s provides safety margin

Installation:

No external dependencies required. All transformation logic uses n8n built-in nodes and native JavaScript features.


Architecture Patterns

Phase 15 adds infrastructure nodes to existing workflows:

Main Workflow (n8n-workflow.json)
├── Container ID Registry (Code node)
│   ├── Input: container name
│   └── Output: { name, dockerId, unraidId }
├── Callback Token Encoder (Code node)
│   ├── Input: unraidId
│   └── Output: 8-char token
└── Callback Token Decoder (Code node)
    ├── Input: 8-char token
    └── Output: unraidId

GraphQL Response Normalizer (reusable utility)
├── Input: Unraid API response
└── Output: Docker API-compatible contract

Pattern 1: Container ID Translation Registry

What: Centralized mapping between container names, Docker IDs, and Unraid PrefixedIDs

When to use: Every workflow node that handles container identification

Example:

// Source: Project research, bitmap encoding pattern from n8n-batch-ui.json
// Container ID Registry (Code node)

// Static mapping built from container list query
// In production, this would be populated from "List Containers" nodes
const registry = $getWorkflowStaticData('global');

// Initialize registry if empty
if (!registry._containerIdMap) {
  registry._containerIdMap = JSON.stringify({});
}

const containerMap = JSON.parse(registry._containerIdMap);

// Update registry with new container data
function updateRegistry(containers) {
  const newMap = {};

  for (const container of containers) {
    const name = container.names?.[0] || container.Names?.[0];
    const cleanName = name.replace(/^\//, '').toLowerCase();

    newMap[cleanName] = {
      name: cleanName,
      dockerId: container.Id || container.id,
      unraidId: container.id  // Unraid PrefixedID format
    };
  }

  registry._containerIdMap = JSON.stringify(newMap);
  return newMap;
}

// Lookup by name
function getUnraidId(containerName) {
  const cleanName = containerName.replace(/^\//, '').toLowerCase();
  const entry = containerMap[cleanName];

  if (!entry) {
    throw new Error(`Container not found in registry: ${containerName}`);
  }

  return entry.unraidId;
}

// Example usage: translate name to Unraid ID
const inputName = $input.item.json.containerName;
const unraidId = getUnraidId(inputName);

return {
  json: {
    containerName: inputName,
    unraidId: unraidId
  }
};

Why this pattern: Single source of truth for ID mapping, survives across workflow executions via static data, handles both Docker and Unraid formats.

Pattern 2: Hash-Based Callback Token Encoding

What: Encode long Unraid PrefixedIDs as short tokens for Telegram callback_data

When to use: All inline keyboard callbacks that include container IDs

Example:

// Source: Telegram callback_data 64-byte limit research
// Callback Token Encoder (Code node)

const staticData = $getWorkflowStaticData('global');

// Initialize token store
if (!staticData._callbackTokens) {
  staticData._callbackTokens = JSON.stringify({});
}

const tokenStore = JSON.parse(staticData._callbackTokens);

// Generate 8-character token from container ID
async function encodeToken(unraidId) {
  // Use first 8 chars of SHA-256 hash as stable token
  const encoder = new TextEncoder();
  const data = encoder.encode(unraidId);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
  const token = hashHex.substring(0, 8);

  // Store mapping
  tokenStore[token] = unraidId;
  staticData._callbackTokens = JSON.stringify(tokenStore);

  return token;
}

// Example: action:start:plex (Docker) → action:start:a1b2c3d4 (Unraid)
const action = $input.item.json.action;  // e.g., "start"
const unraidId = $input.item.json.unraidId;  // e.g., "abc123:def456"

const token = await encodeToken(unraidId);
const callbackData = `action:${action}:${token}`;

return {
  json: {
    action: action,
    unraidId: unraidId,
    token: token,
    callbackData: callbackData,
    byteSize: new TextEncoder().encode(callbackData).length
  }
};

Callback Token Decoder (reverse operation):

// Source: Project pattern
// Callback Token Decoder (Code node)

const staticData = $getWorkflowStaticData('global');
const tokenStore = JSON.parse(staticData._callbackTokens || '{}');

function decodeToken(token) {
  const unraidId = tokenStore[token];

  if (!unraidId) {
    throw new Error(`Token not found in registry: ${token}`);
  }

  return unraidId;
}

// Parse callback like "action:start:a1b2c3d4"
const callbackData = $input.item.json.callbackData;
const parts = callbackData.split(':');
const action = parts[1];
const token = parts[2];

const unraidId = decodeToken(token);

return {
  json: {
    action: action,
    token: token,
    unraidId: unraidId
  }
};

Why this pattern: 8-char tokens vs 129-char PrefixedIDs saves ~120 bytes, stable hashing ensures same ID always gets same token, fits within 64-byte limit even with action prefix.

Pattern 3: GraphQL Response Normalization

What: Transform Unraid GraphQL API responses to match Docker API contract

When to use: After every Unraid API query, before passing data to existing workflow nodes

Example:

// Source: Phase 14 research, current Docker API contract
// GraphQL Response Normalizer (Code node)

function normalizeContainers(graphqlResponse) {
  // Check for GraphQL errors first
  if (graphqlResponse.errors) {
    const errorMsg = graphqlResponse.errors.map(e => e.message).join(', ');
    throw new Error(`Unraid API error: ${errorMsg}`);
  }

  if (!graphqlResponse.data?.docker?.containers) {
    throw new Error('Invalid GraphQL response structure');
  }

  const unraidContainers = graphqlResponse.data.docker.containers;

  // Transform to Docker API contract
  const dockerFormat = unraidContainers.map(c => ({
    // Map Unraid fields to Docker fields
    Id: c.id,  // Keep Unraid PrefixedID (translation happens in registry)
    Names: c.names.map(n => '/' + n),  // Docker adds leading slash
    State: c.state.toLowerCase(),  // Unraid: "running", Docker: "running"
    Status: c.state,  // Docker has separate Status field
    Image: c.image || '',  // Add if available

    // Add Unraid-specific fields for update detection
    UpdateAvailable: c.isUpdateAvailable || false,

    // Preserve original Unraid data for debugging
    _unraidOriginal: c
  }));

  return dockerFormat;
}

// Example usage
const graphqlResponse = $input.item.json;
const normalized = normalizeContainers(graphqlResponse);

return normalized.map(c => ({ json: c }));

Why this pattern: Preserves existing workflow logic (no changes to 60+ Code nodes), adds Unraid features (isUpdateAvailable) alongside Docker contract, includes original data for debugging.

Pattern 4: Standardized GraphQL Error Handling

What: Consistent error checking across all Unraid API calls

When to use: Every HTTP Request node that calls Unraid GraphQL API

Example:

// Source: GraphQL error handling research, current Docker API pattern
// Check GraphQL Errors (Code node - place after every Unraid API call)

const response = $input.item.json;

// Utility function for error checking
function checkGraphQLErrors(response) {
  // Check for GraphQL-level errors
  if (response.errors && response.errors.length > 0) {
    const error = response.errors[0];
    const code = error.extensions?.code;
    const message = error.message;

    // HTTP 304 equivalent: already in desired state
    if (code === 'ALREADY_IN_STATE') {
      return {
        alreadyInState: true,
        message: message
      };
    }

    // Permission errors
    if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') {
      throw new Error(`Permission denied: ${message}. Check API key permissions.`);
    }

    // Container not found
    if (code === 'NOT_FOUND') {
      throw new Error(`Container not found: ${message}`);
    }

    // Generic error
    throw new Error(`Unraid API error: ${message}`);
  }

  // Check for HTTP-level errors
  if (response.statusCode && response.statusCode >= 400) {
    throw new Error(`HTTP ${response.statusCode}: ${response.statusMessage || 'Unknown error'}`);
  }

  // Check for missing data
  if (!response.data) {
    throw new Error('GraphQL response missing data field');
  }

  return {
    alreadyInState: false,
    data: response.data
  };
}

// Example usage
const result = checkGraphQLErrors(response);

if (result.alreadyInState) {
  // Handle HTTP 304 equivalent
  return {
    json: {
      success: true,
      message: 'Container already in desired state',
      statusCode: 304
    }
  };
}

// Pass through normalized data
return {
  json: {
    success: true,
    data: result.data
  }
};

Why this pattern: Mirrors existing Docker API error checking (statusCode === 304), handles GraphQL-specific error array, provides clear error messages for debugging.

Pattern 5: Timeout Configuration for Cloud Relay

What: Configure appropriate timeouts for myunraid.net cloud relay latency

When to use: All HTTP Request nodes calling Unraid API

Configuration:

// HTTP Request node settings for Unraid API calls
{
  "url": "={{ $env.UNRAID_HOST }}/graphql",
  "method": "POST",
  "authentication": "headerAuth",
  "sendHeaders": true,
  "headerParameters": {
    "parameters": [
      {
        "name": "Content-Type",
        "value": "application/json"
      }
    ]
  },
  "options": {
    "timeout": 15000,  // 15 seconds (accounts for cloud relay latency)
    "retry": {
      "enabled": true,
      "maxRetries": 2,
      "waitBetweenRetries": 1000  // 1 second between retries
    }
  },
  "onError": "continueRegularOutput"  // Handle errors in Code node
}

Why these values:

  • 15 seconds: Cloud relay adds 200-500ms per request, safety margin for slow responses
  • 2 retries: Handles transient network issues with myunraid.net
  • 1 second wait: Rate limiting consideration (Unraid API has limits, threshold unknown)
  • continueRegularOutput: Allows Code node to check errors (matches Docker API pattern)

Anti-Patterns to Avoid

  • Inline ID translation in every node: Use centralized registry, not scattered lookups
  • Storing full PrefixedIDs in callback_data: Use 8-char tokens, not 129-char IDs
  • Assuming successful HTTP 200 means no errors: Always check response.errors[] array
  • Using Docker API timeout values: Cloud relay is slower, increase timeouts appropriately
  • Hardcoding container ID format: Use registry abstraction, format may change in future Unraid versions

Don't Hand-Roll

Problem Don't Build Use Instead Why
Callback data compression Custom compression algorithm (gzip, LZ4) Hash-based token mapping Compression adds encode/decode overhead, hashes are instant
GraphQL client library Custom schema parser, type validator n8n HTTP Request with JSON body GraphQL-over-HTTP is standard, no client library needed
Container ID cache persistence External database (Redis, SQLite) n8n static data with JSON serialization Built-in persistence, no external dependencies
Error code mapping Large switch statement Utility function with error code constants Centralized logic, easier to maintain

Key insight: n8n provides sufficient primitives (static data, Code nodes, HTTP Request). Building external infrastructure adds operational complexity without benefit for this use case.


Common Pitfalls

Pitfall 1: Static Data Deep Mutation Not Persisting

What goes wrong: Update nested object in static data, changes disappear after workflow execution.

Why it happens: n8n only persists top-level property changes to $getWorkflowStaticData('global'). Deep mutations (e.g., staticData.registry.plex = { ... }) are silently lost.

How to avoid: Always use JSON serialization pattern:

// WRONG - deep mutation not persisted
const registry = $getWorkflowStaticData('global');
registry.containers = registry.containers || {};
registry.containers.plex = { id: 'abc:def' };  // Lost after execution!

// CORRECT - top-level assignment persisted
const registry = $getWorkflowStaticData('global');
const containers = JSON.parse(registry._containers || '{}');
containers.plex = { id: 'abc:def' };
registry._containers = JSON.stringify(containers);  // Persisted!

Warning signs:

  • Container ID registry resets to empty after workflow execution
  • Token mappings disappear between callback queries
  • "Token not found" errors despite recent encoding

Source: Project CLAUDE.md static data persistence pattern

Pitfall 2: Callback Token Collisions with Short Hashes

What goes wrong: Two different Unraid PrefixedIDs hash to same 8-character token, callback decoding returns wrong container.

Why it happens: Birthday paradox — with 50 containers and 8-hex-char tokens (2^32 space), collision probability is ~0.06%.

How to avoid:

  1. Use full SHA-256 hash (64 chars) for token generation, take first 8 chars
  2. Check for collisions when storing tokens, increment suffix if collision detected
  3. Log token generation for debugging
// Collision detection pattern
async function encodeToken(unraidId) {
  const tokenStore = JSON.parse(staticData._callbackTokens || '{}');

  // Generate base token
  const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(unraidId));
  const hashHex = Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  // Try first 8 chars
  let token = hashHex.substring(0, 8);
  let suffix = 0;

  // Check for collision
  while (tokenStore[token] && tokenStore[token] !== unraidId) {
    // Collision detected - try next 8 chars
    const start = 8 + (suffix * 8);
    token = hashHex.substring(start, start + 8);
    suffix++;

    if (start + 8 > hashHex.length) {
      // Ran out of hash space - very unlikely
      throw new Error('Token collision - hash exhausted');
    }
  }

  tokenStore[token] = unraidId;
  staticData._callbackTokens = JSON.stringify(tokenStore);

  return token;
}

Warning signs:

  • Wrong container triggered when clicking action button
  • "Container not found" errors for valid containers
  • Token decode returns different ID than encoded

Impact: LOW (collision probability <0.1% with 50 containers), but consequences are HIGH (wrong container action = data loss risk)

Source: Enhanced Telegram callback_data with protobuf + base85

Pitfall 3: GraphQL Response Shape Mismatch

What goes wrong: Workflow Code node expects container.State (Docker), gets container.state (Unraid), undefined field causes errors.

Why it happens: GraphQL returns different field names and types than Docker REST API. Names vs names[], State vs state, missing fields like Status.

How to avoid: Use normalization layer BEFORE passing data to existing nodes:

// Bad: Pass Unraid response directly
const unraidContainers = response.data.docker.containers;
return unraidContainers;  // Breaks downstream nodes expecting Docker format

// Good: Normalize first
const normalized = normalizeContainers(response);
return normalized;  // Matches Docker contract exactly

Verification pattern:

// Test normalization output
const sample = normalized[0];
console.log({
  hasId: 'Id' in sample,  // Must be true
  hasNames: 'Names' in sample && Array.isArray(sample.Names),  // Must be true
  hasState: 'State' in sample && typeof sample.State === 'string',  // Must be true
  namesHaveSlash: sample.Names[0].startsWith('/')  // Must be true (Docker format)
});

Warning signs:

  • Workflow errors: "Cannot read property 'State' of undefined"
  • Container names missing leading slash (Docker uses /plex, Unraid uses plex)
  • State detection fails (running containers show as stopped)

Source: Phase 14 research Unraid GraphQL schema, current Docker API contract in workflow code

Pitfall 4: Timeout Too Short for Cloud Relay Latency

What goes wrong: HTTP Request to Unraid API times out with "Request timed out after 10000ms", API call succeeded but response didn't arrive in time.

Why it happens: myunraid.net cloud relay adds 200-500ms latency vs direct LAN. Default n8n timeout (10 seconds) doesn't account for relay overhead. Mutation operations (start/stop/update) can take 3-5 seconds plus relay latency.

How to avoid:

  1. Set timeout to 15 seconds in HTTP Request node options
  2. Enable retry logic for transient network failures
  3. Test with worst-case latency (remote access over cellular network)
// HTTP Request node configuration
{
  "options": {
    "timeout": 15000,  // 15 seconds
    "retry": {
      "enabled": true,
      "maxRetries": 2
    }
  }
}

Warning signs:

  • Timeouts during container updates (slow operations)
  • Intermittent failures on same API call (network variance)
  • Success when running workflow manually (lower latency), failure in production

Testing recommendation: Add artificial delay to simulate high latency:

// Test timeout handling with forced delay
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });

Source: n8n HTTP Request timeout configuration, Phase 14 research myunraid.net latency

Pitfall 5: Container Registry Stale Data

What goes wrong: User adds new container in Unraid WebGUI, Telegram bot shows "Container not found" because registry wasn't updated.

Why it happens: Container ID registry is populated once during workflow initialization. New containers added outside the bot aren't automatically detected.

How to avoid:

  1. Refresh registry on every "list" or "status" command (rebuilds from current state)
  2. Add timestamp to registry to detect staleness
  3. Handle "not found" gracefully with helpful message
// Registry refresh pattern
function refreshRegistry(containers) {
  const registry = $getWorkflowStaticData('global');
  const now = Date.now();

  const newMap = {};
  for (const c of containers) {
    const name = c.names[0].replace(/^\//, '').toLowerCase();
    newMap[name] = {
      name: name,
      dockerId: c.Id,
      unraidId: c.id,
      lastSeen: now
    };
  }

  registry._containerIdMap = JSON.stringify(newMap);
  registry._lastRefresh = now;

  return newMap;
}

// Graceful fallback for missing containers
function getUnraidId(containerName) {
  const registry = $getWorkflowStaticData('global');
  const map = JSON.parse(registry._containerIdMap || '{}');
  const lastRefresh = registry._lastRefresh || 0;
  const age = Date.now() - lastRefresh;

  const entry = map[containerName];

  if (!entry) {
    if (age > 60000) {  // Registry older than 1 minute
      throw new Error(`Container "${containerName}" not found. Registry may be stale - try "status" to refresh.`);
    } else {
      throw new Error(`Container "${containerName}" not found. Check spelling or run "status" to see all containers.`);
    }
  }

  return entry.unraidId;
}

Warning signs:

  • "Not found" errors for containers that exist in Unraid
  • Registry size doesn't match container count
  • Errors after adding/removing containers in WebGUI

Mitigation: Auto-refresh on common commands (status, list, batch), add manual "/refresh" command for edge cases.

Source: Project pattern, existing container matching workflow

Pitfall 6: Unraid API Rate Limiting on Batch Operations

What goes wrong: Batch update of 20 containers triggers rate limiting, API returns 429 Too Many Requests after 5th container.

Why it happens: Unraid API implements rate limiting (confirmed in docs, thresholds unknown). Batch operations may trigger limits if implemented as N individual API calls.

How to avoid:

  1. Use batch mutations (updateContainers plural) instead of N individual calls
  2. Implement exponential backoff for 429 responses
  3. Sequence operations instead of parallel (reduces burst load)
// Batch mutation pattern (GraphQL supports multiple IDs)
mutation UpdateMultipleContainers($ids: [PrefixedID!]!) {
  docker {
    updateContainers(ids: $ids) {
      id
      state
      isUpdateAvailable
    }
  }
}

// Exponential backoff for rate limiting
async function callWithRetry(apiCall, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const response = await apiCall();

    if (response.statusCode === 429) {
      const delay = Math.pow(2, i) * 1000;  // 1s, 2s, 4s
      console.log(`Rate limited, retrying in ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
      continue;
    }

    return response;
  }

  throw new Error('Rate limit exceeded after retries');
}

Warning signs:

  • HTTP 429 responses during batch operations
  • GraphQL error: "rate limit exceeded"
  • Partial batch completion (first N containers succeed, rest fail)

Impact assessment: LOW for this project (bot updates are infrequent, user-initiated). Rate limiting unlikely in normal usage but must handle gracefully.

Source: Using Unraid API, Phase 14 research


Code Examples

Verified patterns from official sources and project implementation:

Container ID Translation Flow (End-to-End)

// Source: Project bitmap encoding pattern, Phase 14 container ID format
// Complete flow: User action → Container name → Unraid ID → Token → Callback

// Step 1: User sends "start plex"
// Parse Container Name node
const input = $input.item.json.text;  // "start plex"
const parts = input.trim().split(/\s+/);
const action = parts[0].toLowerCase();  // "start"
const containerName = parts.slice(1).join(' ').toLowerCase();  // "plex"

// Step 2: Translate name to Unraid ID
// Container ID Registry Lookup node
const registry = $getWorkflowStaticData('global');
const containerMap = JSON.parse(registry._containerIdMap || '{}');

const entry = containerMap[containerName];
if (!entry) {
  throw new Error(`Container not found: ${containerName}`);
}

const unraidId = entry.unraidId;  // e.g., "abc123...def456:container_hash"

// Step 3: Generate callback token
// Callback Token Encoder node
const tokenStore = JSON.parse(registry._callbackTokens || '{}');

async function encodeToken(unraidId) {
  const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(unraidId));
  const hex = Array.from(new Uint8Array(hash))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
  return hex.substring(0, 8);
}

const token = await encodeToken(unraidId);
tokenStore[token] = unraidId;
registry._callbackTokens = JSON.stringify(tokenStore);

// Step 4: Build callback data
const callbackData = `action:${action}:${token}`;  // "action:start:a1b2c3d4"
const byteSize = new TextEncoder().encode(callbackData).length;

if (byteSize > 64) {
  throw new Error(`Callback data too large: ${byteSize} bytes`);
}

// Step 5: User clicks button, decode token
// Parse Callback Data node (triggered by Telegram)
const callback = $input.item.json.data;  // "action:start:a1b2c3d4"
const [prefix, cbAction, cbToken] = callback.split(':');

const decodedId = tokenStore[cbToken];
if (!decodedId) {
  throw new Error(`Invalid callback token: ${cbToken}`);
}

// Step 6: Call Unraid API with PrefixedID
// HTTP Request node
const graphqlQuery = {
  query: `
    mutation StartContainer($id: PrefixedID!) {
      docker {
        startContainer(id: $id) {
          id
          state
        }
      }
    }
  `,
  variables: {
    id: decodedId  // Full Unraid PrefixedID format
  }
};

// Return for HTTP Request node
return {
  json: {
    query: graphqlQuery.query,
    variables: graphqlQuery.variables
  }
};

GraphQL Error Handling with HTTP 304 Equivalent

// Source: GraphQL error handling research, current Docker API pattern
// Format Action Result node (after Unraid API call)

const response = $input.item.json;
const routeData = $('Route Action').item.json;
const containerName = routeData.containerName;
const action = routeData.action;

// Check GraphQL errors array
function checkGraphQLErrors(response) {
  if (response.errors && response.errors.length > 0) {
    const error = response.errors[0];
    const code = error.extensions?.code;
    const message = error.message;

    // Map GraphQL error codes to HTTP status codes
    const errorMap = {
      'ALREADY_IN_STATE': 304,  // HTTP 304 Not Modified equivalent
      'NOT_FOUND': 404,
      'FORBIDDEN': 403,
      'UNAUTHORIZED': 401
    };

    const statusCode = errorMap[code] || 500;

    return {
      statusCode: statusCode,
      message: message,
      isError: statusCode >= 400
    };
  }

  // Check HTTP-level errors
  if (response.statusCode && response.statusCode >= 400) {
    return {
      statusCode: response.statusCode,
      message: response.statusMessage || 'Unknown error',
      isError: true
    };
  }

  return {
    statusCode: 200,
    message: 'Success',
    isError: false
  };
}

const result = checkGraphQLErrors(response);

// Handle HTTP 304 equivalent (matches current Docker API pattern)
if (result.statusCode === 304) {
  const messages = {
    start: `is already started`,
    stop: `is already stopped`,
    restart: `was restarted`
  };

  return {
    json: {
      success: true,
      message: `✅ <b>${containerName}</b> ${messages[action] || 'is already in desired state'}`,
      statusCode: 304
    }
  };
}

// Handle errors
if (result.isError) {
  return {
    json: {
      success: false,
      message: `❌ Failed to ${action} <b>${containerName}</b>: ${result.message}`,
      statusCode: result.statusCode
    }
  };
}

// Success
return {
  json: {
    success: true,
    message: `✅ <b>${containerName}</b> ${action} successful`,
    data: response.data
  }
};

Source: GraphQL Error Handling, Apollo Server Error Handling, current Docker API pattern in n8n-actions.json

Bitmap Encoding with Unraid IDs

// Source: Project n8n-batch-ui.json, modified for Unraid ID support
// Build Batch Keyboard node (with Unraid ID registry integration)

// Bitmap helpers (unchanged from current implementation)
function encodeBitmap(selectedIndices) {
  let bitmap = 0n;
  for (const idx of selectedIndices) {
    bitmap |= (1n << BigInt(idx));
  }
  return bitmap.toString(36);
}

function decodeBitmap(b36) {
  if (!b36 || b36 === '0') return new Set();
  let val = 0n;
  for (const ch of b36) {
    val = val * 36n + BigInt(parseInt(ch, 36));
  }
  const indices = new Set();
  let i = 0;
  let v = val;
  while (v > 0n) {
    if (v & 1n) indices.add(i);
    v >>= 1n;
    i++;
  }
  return indices;
}

// NEW: Maintain parallel array of Unraid IDs
const containers = $input.all();
const registry = $getWorkflowStaticData('global');

// Build index → Unraid ID mapping
const indexToId = [];
for (const item of containers) {
  const c = item.json;
  const name = c.Names[0].replace(/^\//, '').toLowerCase();

  // Look up Unraid ID from registry
  const containerMap = JSON.parse(registry._containerIdMap || '{}');
  const entry = containerMap[name];

  if (entry) {
    indexToId.push(entry.unraidId);
  } else {
    // Fallback: container not in registry (shouldn't happen after refresh)
    console.warn(`Container ${name} not in registry`);
    indexToId.push(null);
  }
}

// Store index mapping in static data (needed for exec step)
registry._batchIndexMap = JSON.stringify(indexToId);

// Bitmap encoding unchanged (still uses indices, not IDs)
const page = $input.item.json.batchPage || 0;
const bitmap = $input.item.json.bitmap || '0';
const selected = decodeBitmap(bitmap);

// Build keyboard (existing logic)
// ... keyboard building code unchanged ...

// When user clicks "Execute", decode bitmap to Unraid IDs
// Handle Exec node
const execData = $('Handle Exec').item.json;
const execBitmap = execData.bitmap;
const selectedIndices = decodeBitmap(execBitmap);

const indexMap = JSON.parse(registry._batchIndexMap || '[]');
const selectedIds = Array.from(selectedIndices)
  .map(idx => indexMap[idx])
  .filter(id => id !== null);  // Skip missing entries

return {
  json: {
    action: execData.action,
    containerIds: selectedIds,  // Array of Unraid PrefixedIDs
    count: selectedIds.length
  }
};

Why this works: Bitmap encoding is index-based (unchanged), but index → Unraid ID mapping stored separately. Callback data size stays same (bitmap is compact), ID translation happens server-side during decode.


State of the Art

Old Approach Current Approach When Changed Impact
Store full container data in callback Hash-based token mapping 2025 (Telegram callback_data research) Saves 100+ bytes per callback, enables longer IDs
Direct API response pass-through Normalization layer Common pattern in GraphQL integrations Decouples API contract from workflow logic
Fixed 10-second HTTP timeout Configurable per-node timeout n8n 1.x (2024+) Handles high-latency APIs like cloud relays
String-based error checking Structured error objects with codes GraphQL spec, Apollo Server pattern Enables programmatic error handling

Deprecated/outdated:

  • Inline callback data encoding: Modern bots use server-side mapping (tokens, UUIDs)
  • Hardcoded API contracts: Normalization layers standard for multi-API systems
  • Global timeout settings: Per-node configuration preferred for mixed latency scenarios

Current best practice (2026): Separate concerns — ID translation (registry), callback encoding (token mapping), API contracts (normalization), error handling (utility functions). Each layer independent and testable.


Open Questions

  1. Actual Unraid PrefixedID format in production

    • What we know: Schema defines server_hash:container_hash format, 129 characters typical
    • What's unclear: Exact format varies by Unraid version (6.x vs 7.x), observed in Phase 14 testing
    • Recommendation: Phase 15 uses format documented in Phase 14 verification, builds abstraction layer
  2. Token collision rate with 50+ containers

    • What we know: 8-char hex tokens provide 2^32 space (~4 billion), SHA-256 hash is collision-resistant
    • What's unclear: Real-world collision rate with 50-100 containers in production
    • Recommendation: Implement collision detection with fallback to next 8 chars, monitor in production
  3. Unraid API rate limiting thresholds for batch operations

    • What we know: API implements rate limiting (confirmed in docs), actual thresholds undocumented
    • What's unclear: Requests per minute limit, burst allowance, penalty duration
    • Recommendation: Use batch mutations instead of N individual calls, implement exponential backoff for 429
  4. GraphQL response caching behavior

    • What we know: HTTP 304 Not Modified exists, GraphQL has equivalent error codes
    • What's unclear: Does Unraid API use HTTP caching headers? Is ALREADY_IN_STATE a cacheable response?
    • Recommendation: Treat ALREADY_IN_STATE same as HTTP 304, don't rely on caching for correctness
  5. n8n static data size limits

    • What we know: Static data persists across executions, stores JSON-serialized objects
    • What's unclear: Maximum size limit for static data storage, performance impact with large registries
    • Recommendation: Monitor registry size in production, refresh on every list/status command (keeps fresh)

Sources

Primary (HIGH confidence)

  • Project /home/luc/Projects/unraid-docker-manager/n8n-batch-ui.json - Bitmap encoding implementation
  • Project /home/luc/Projects/unraid-docker-manager/n8n-actions.json - Current Docker API error handling patterns
  • Project /home/luc/Projects/unraid-docker-manager/CLAUDE.md - Static data persistence patterns
  • .planning/phases/14-unraid-api-access/14-RESEARCH.md - Unraid API contract, container ID format

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)


Metadata

Confidence breakdown:

  • Standard stack: HIGH - All components are n8n built-ins or native JavaScript, well-documented
  • Architecture: MEDIUM-HIGH - Patterns verified in existing codebase, new Unraid-specific parts extrapolated from research
  • Pitfalls: MEDIUM - Static data and callback encoding pitfalls from project experience, GraphQL errors from standard patterns, timeout values estimated from Phase 14 research

Research date: 2026-02-09 Valid until: 30 days (stable domain - n8n patterns and GraphQL standards don't change rapidly)

Critical dependencies for planning:

  • Container ID format from Phase 14 verification (MUST be documented before Phase 15 planning)
  • Bitmap encoding pattern from n8n-batch-ui.json (currently working, extend for Unraid IDs)
  • Static data persistence pattern from CLAUDE.md (critical for registry and token storage)
  • Docker API error checking from n8n-actions.json (template for GraphQL error handling)

Ready for planning: YES with caveat - Phase 15 planning depends on Phase 14 verification documenting actual Unraid PrefixedID format observed in test query. If format differs from assumption, registry and token encoding may need adjustment.