Skip to content
Social SDK megaphone markSocial SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

SurrealDB

Keep connected accounts, deliveries, and publish state in SurrealDB, and stream delivery updates to your app with live queries.

This guide stores everything the SDK needs to survive a restart in SurrealDB: connected accounts, publication records, the idempotency store, the Threads workflow store, and the webhook inbox. The SDK runs in your Node.js or Bun server and talks to SurrealDB Cloud or a self-hosted SurrealDB 3 instance over a WebSocket. SurrealDB has no job scheduler, so reconciliation runs from whatever scheduler you already use.

Install

npm install @opencoredev/social-sdk surrealdb
pnpm add @opencoredev/social-sdk surrealdb
yarn add @opencoredev/social-sdk surrealdb
bun add @opencoredev/social-sdk surrealdb
nub add @opencoredev/social-sdk surrealdb
aube add @opencoredev/social-sdk surrealdb

The examples use version 2 of the surrealdb JavaScript SDK.

Connect

Create the namespace, the database, and a user for the server once, as the root user. EDITOR can read and write records and define tables in this database, but can’t manage users:

DEFINE NAMESPACE app;
USE NS app;
DEFINE DATABASE social;
USE DB social;
DEFINE USER social ON DATABASE PASSWORD "change-me" ROLES EDITOR;
import { Surreal } from "surrealdb";

export const db = new Surreal();

await db.connect(process.env.SURREAL_URL!, {
  namespace: "app",
  database: "social",
  // Passed here rather than through signin() so the SDK signs in again after a reconnect.
  authentication: {
    namespace: "app",
    database: "social",
    username: process.env.SURREAL_USER!,
    password: process.env.SURREAL_PASS!,
  },
});

SURREAL_URL is a wss:// address for SurrealDB Cloud, or ws://127.0.0.1:8000 for a local server. Table PERMISSIONS only apply to record users, not to a database user like social, so every query must filter by the tenant you verified.

Schema

Define the tables in schema.surql:

DEFINE TABLE connected_account SCHEMAFULL;
DEFINE FIELD tenant ON connected_account TYPE string;
DEFINE FIELD platform ON connected_account TYPE string;
DEFINE FIELD account_id ON connected_account TYPE string;
DEFINE FIELD backend ON connected_account TYPE string DEFAULT "default";
DEFINE FIELD created_at ON connected_account TYPE datetime DEFAULT time::now();
DEFINE INDEX account_unique ON connected_account FIELDS tenant, backend, platform, account_id UNIQUE;

DEFINE TABLE delivery SCHEMAFULL;
DEFINE FIELD tenant ON delivery TYPE string;
DEFINE FIELD idempotency_key ON delivery TYPE string;
DEFINE FIELD account ON delivery TYPE record<connected_account>;
DEFINE FIELD state ON delivery TYPE string;
DEFINE FIELD delivery_ref ON delivery TYPE option<object> FLEXIBLE;
DEFINE FIELD updated_at ON delivery TYPE datetime VALUE time::now();
DEFINE INDEX delivery_state ON delivery FIELDS state;

DEFINE TABLE idempotency_claim SCHEMAFULL;
DEFINE FIELD scope ON idempotency_claim TYPE string;
DEFINE FIELD key ON idempotency_claim TYPE string;
DEFINE FIELD fingerprint ON idempotency_claim TYPE string;
DEFINE FIELD target_keys ON idempotency_claim TYPE array<string>;
DEFINE FIELD outcomes ON idempotency_claim TYPE object FLEXIBLE DEFAULT {};
DEFINE INDEX claim_unique ON idempotency_claim FIELDS scope, key UNIQUE;

DEFINE TABLE threads_workflow SCHEMAFULL;
DEFINE FIELD workflow ON threads_workflow TYPE object FLEXIBLE;
DEFINE FIELD claimed_until ON threads_workflow TYPE option<datetime>;
DEFINE FIELD claimed_by ON threads_workflow TYPE option<string>;

DEFINE TABLE social_event SCHEMAFULL;
DEFINE FIELD platform ON social_event TYPE string;
DEFINE FIELD payload ON social_event TYPE object FLEXIBLE;
DEFINE FIELD processed_at ON social_event TYPE option<datetime>;

Apply it with the surreal CLI:

surreal sql --endpoint "$SURREAL_URL" --user root --pass "$SURREAL_ROOT_PASS" \
  --namespace app --database social < schema.surql

A SCHEMAFULL table rejects any field it doesn’t define, including keys nested inside an object field. The SDK’s delivery references, outcomes, and workflows are JSON whose keys can change between SDK versions, so those fields are FLEXIBLE. Tokens do not belong in these tables.

Each delivery record will get an array ID, delivery:[tenant, draftId]. The ID is unique per tenant and draft, so the delivery table needs no separate unique index.

Durable stores

Nothing the SDK needs later can live in process memory, because a restart or a second server instance would lose it. Two stores need SurrealDB:

  • The client’s idempotencyStore stops a retried request from posting twice. MemoryIdempotencyStore only covers one process.
  • The Threads adapter’s workflowStore holds each post between the publish call and the Threads API writes. The default MemoryThreadsWorkflowStore loses the post when the process exits.
import { RecordId, Surreal, surql } from "surrealdb";
import type { DeliveryOutcome, IdempotencyStore } from "@opencoredev/social-sdk";
import type { ThreadsWorkflow, ThreadsWorkflowStore } from "@opencoredev/social-sdk/threads";

interface ClaimRow {
  id: RecordId<"idempotency_claim", string>;
  fingerprint: string;
  target_keys: string[];
  outcomes: Record<string, DeliveryOutcome>;
}

export function surrealIdempotencyStore(db: Surreal): IdempotencyStore {
  return {
    async claim({ scope, key, fingerprint, targetKeys }) {
      // The unique index on (scope, key) makes INSERT IGNORE return nothing for a duplicate.
      const [inserted] = await db
        .query<[ClaimRow[]]>(
          surql`INSERT IGNORE INTO idempotency_claim
            { scope: ${scope}, key: ${key}, fingerprint: ${fingerprint}, target_keys: ${targetKeys} }`,
        )
        .collect();
      if (inserted[0]) return { kind: "new", claimId: inserted[0].id.id, outcomes: {} };

      const [rows] = await db
        .query<
          [ClaimRow[]]
        >(surql`SELECT * FROM idempotency_claim WHERE scope = ${scope} AND key = ${key}`)
        .collect();
      const row = rows[0]!;
      if (
        row.fingerprint !== fingerprint ||
        JSON.stringify(row.target_keys) !== JSON.stringify(targetKeys)
      )
        return { kind: "conflict" };

      return { kind: "existing", claimId: row.id.id, outcomes: row.outcomes };
    },
    async saveOutcome({ claimId, targetKey, outcome }) {
      // MERGE is a deep merge, so outcomes saved for other targets stay put.
      await db
        .update(new RecordId("idempotency_claim", claimId))
        .merge({ outcomes: { [targetKey]: outcome } });
    },
  };
}

export function surrealThreadsWorkflowStore(db: Surreal): ThreadsWorkflowStore {
  const record = (id: string) => new RecordId("threads_workflow", id);
  const leases = new Map<string, string>();

  return {
    async create(input) {
      const workflow: ThreadsWorkflow = { ...input, id: crypto.randomUUID() };
      await db.create(record(workflow.id)).content({ workflow });
      return workflow;
    },
    async get(id) {
      const row = await db.select<{ workflow: ThreadsWorkflow }>(record(id));
      return row?.workflow;
    },
    async update(id, update) {
      const [rows] = await db
        .query<
          [{ workflow: ThreadsWorkflow }[]]
        >(surql`UPDATE ${record(id)} MERGE { workflow: ${update} }`)
        .collect();
      if (!rows[0]) throw new Error("Threads workflow not found");
      return rows[0].workflow;
    },
    // A lease, not a lock, so a claim held by a crashed process expires on its own.
    // The token lets release clear only this process's lease, never a newer one.
    async claim(id) {
      if (leases.has(id)) return false;
      const token = crypto.randomUUID();
      const [rows] = await db
        .query<[unknown[]]>(
          surql`UPDATE ${record(id)} SET claimed_until = time::now() + 5m, claimed_by = ${token}
            WHERE claimed_until = NONE OR claimed_until < time::now()`,
        )
        .collect();
      if (rows.length !== 1) return false;
      leases.set(id, token);
      return true;
    },
    async release(id) {
      const token = leases.get(id);
      if (!token) return;
      leases.delete(id);
      await db
        .query(
          surql`UPDATE ${record(id)} SET claimed_until = NONE, claimed_by = NONE
            WHERE claimed_by = ${token}`,
        )
        .collect();
    },
  };
}

In SurrealDB 3, UPDATE on a missing record returns an empty result instead of creating it, which is how update detects a missing workflow and how claim detects a lease someone else holds. Keep the lease longer than the retry budget you pass to resumePublication below, so a claim can’t expire while its process is still writing to Threads.

Publish

Threads publishing takes two steps. posts.publish records the post in the workflow store and returns processing; the adapter’s native resumePublication makes the Threads API calls. publishDraft runs both, so a post normally goes out before it returns. Call it from your route handler after you have verified the tenant.

import { RecordId, surql } from "surrealdb";
import { createSocial, connectedAccountRef, type DeliveryRef } from "@opencoredev/social-sdk";
import { threads } from "@opencoredev/social-sdk/threads";
import { db } from "./db";
import { surrealIdempotencyStore, surrealThreadsWorkflowStore } from "./stores";

export const social = createSocial({
  backend: threads({
    auth: {
      userId: process.env.THREADS_USER_ID!,
      accessToken: process.env.THREADS_ACCESS_TOKEN!,
    },
    workflowStore: surrealThreadsWorkflowStore(db),
  }),
  idempotencyStore: surrealIdempotencyStore(db),
});

// Native calls skip the client's tenant checks, so callers authorize the account first.
const native = social.native("default", { acknowledgeUnsafe: true })!;

interface DeliveryRow {
  id: RecordId<"delivery">;
  state: string;
  delivery_ref?: DeliveryRef;
}

export async function advance(delivery: RecordId<"delivery">, ref: DeliveryRef) {
  const account = connectedAccountRef({
    backend: ref.backend,
    platform: "threads",
    accountId: ref.accountId,
  });
  const outcome = await native.resumePublication(account, ref.deliveryId, {
    backendInstance: ref.backend,
    correlationId: String(delivery),
    retryBudget: { maxAttempts: 3, maxElapsedMs: 60_000 },
  });
  await db
    .query(
      surql`UPDATE ${delivery} SET state = ${outcome.state}
        WHERE state NOTINSIDE ["published", "failed"]`,
    )
    .collect();
  return outcome;
}

export async function publishDraft(tenantId: string, draftId: string, text: string) {
  const [accounts] = await db
    .query<[{ id: RecordId<"connected_account">; account_id: string; backend: string }[]]>(
      surql`SELECT id, account_id, backend FROM connected_account
        WHERE tenant = ${tenantId} AND platform = "threads" LIMIT 1`,
    )
    .collect();
  const acct = accounts[0];
  if (!acct) throw new Error("No authorized Threads account");

  const result = await social.posts.publish(
    {
      targets: [
        {
          account: connectedAccountRef({
            backend: acct.backend,
            platform: "threads",
            accountId: acct.account_id,
          }),
        },
      ],
      content: { text },
      idempotencyKey: draftId,
    },
    // Scopes the idempotency key to this tenant, so tenants can't collide on a draft ID.
    { authorization: { tenantId } },
  );

  const outcome = result.outcomes[0];
  // One record per tenant and draft. A concurrent duplicate can report unknown with no
  // delivery reference; it must not replace a resumable row or undo a final state.
  const [saved] = await db
    .query<[DeliveryRow[]]>(
      surql`UPSERT ${new RecordId("delivery", [tenantId, draftId])} SET
        tenant = ${tenantId},
        idempotency_key = ${draftId},
        account = ${acct.id},
        state = IF state INSIDE ["published", "failed"] { state }
          ELSE IF ${outcome?.delivery} = NONE AND delivery_ref != NONE { state }
          ELSE { ${outcome?.state ?? "unknown"} },
        delivery_ref = ${outcome?.delivery} ?? delivery_ref`,
    )
    .collect();
  const row = saved[0]!;

  const state =
    row.state === "processing" && row.delivery_ref
      ? (await advance(row.id, row.delivery_ref)).state
      : row.state;

  return { status: result.status, state };
}

Validate draftId and text before calling publishDraft. Threads caps a post at 500 characters. A second call with the same draft ID and text returns the saved result without posting again; the same draft ID with different text is rejected as an idempotency conflict.

Reconcile

A delivery can still be processing when publishDraft returns: Threads may not have finished processing the media, or the server may have restarted partway through. reconcile resumes those workflows.

export async function reconcile() {
  // account.id is NONE once the tenant deletes the connected account, so those rows are skipped.
  const [rows] = await db
    .query<[DeliveryRow[]]>(
      surql`SELECT id, state, delivery_ref FROM delivery
        WHERE state = "processing" AND account.id != NONE LIMIT 20`,
    )
    .collect();

  for (const row of rows) if (row.delivery_ref) await advance(row.id, row.delivery_ref);

  return rows.length;
}

Run it every few minutes from a scheduler such as Trigger.dev, Inngest, Restate, or a cron job. The workflow store’s claim stops two processes from resuming the same post at once, so an overlapping run does no harm. A workflow whose publish outcome is unknown is never replayed: resumePublication reports unknown again. Leave those rows for review, as the idempotency guide describes.

Webhooks

Verify the raw request body before parsing it, then store the event in social_event with a dedupe key as its record ID. INSERT IGNORE returns an empty result when that ID already exists, so a redelivered event is stored once:

const [inserted] = await db
  .query<[unknown[]]>(
    surql`INSERT IGNORE INTO social_event
      { id: ${dedupeKey}, platform: "threads", payload: ${payload} }`,
  )
  .collect();
const isNew = inserted.length === 1;

Build dedupeKey from the platform and the event’s own ID, following the events guide, and process rows where processed_at = NONE from the same scheduler that runs reconcile.

Live delivery updates

A live query pushes each change to a delivery record to your server as it happens, so a dashboard can show processing turning into published without polling. Filter by tenant so one tenant never receives another’s records:

import { Table, eq } from "surrealdb";
import { db } from "./db";

export async function watchDeliveries(
  tenantId: string,
  onChange: (delivery: { idempotency_key: string; state: string }) => void,
) {
  const live = await db
    .live(new Table("delivery"))
    .fields("idempotency_key", "state")
    .where(eq("tenant", tenantId));

  live.subscribe((message) => {
    if (message.action === "CREATE" || message.action === "UPDATE")
      onChange(message.value as { idempotency_key: string; state: string });
  });

  return () => live.kill();
}

Forward onChange to the browser over your own server-sent events or WebSocket route. Call the returned function when the client disconnects.

Notes

  • Some SurrealDB examples pass the subscribe callback three arguments. In surrealdb 2.0.8 it receives one message with action, value, and recordId, as shown above.
  • The connection option retry doesn’t retry ordinary queries. It only sets the defaults for queries you mark with .retry(). Without it, a write conflict under heavy load surfaces as an error; publishDraft is safe to call again with the same draft ID.
  • The SurrealDB details on this page come from SurrealDB’s documentation as of September 22, 2026: connecting, authentication, executing queries, live queries, DEFINE USER, DEFINE FIELD, INSERT, UPSERT, UPDATE, and record IDs.
  • The samples ran against SurrealDB 3.2.4 with surrealdb 2.0.8, connected as an EDITOR database user, with a stubbed Threads API. They have not been run against a live Threads account.

Last updated on September 23, 2026