Restate
Run each social post as a Restate Virtual Object that journals Social SDK calls, schedules its own reconciliation, and serializes duplicate requests.
Restate runs your handlers durably: it journals every step, retries failures, and resumes an interrupted handler from its last completed step. With Social SDK, each draft becomes a Restate Virtual Object. Restate runs one handler at a time per draft, so a double-clicked publish button can’t race itself. The object keeps the delivery outcome in its own state, and it schedules its own reconciliation with delayed messages, so you don’t need a cron job.
The SDK still makes every platform call. Restate decides when those calls run and remembers their results.
Install
npm install @opencoredev/social-sdk @restatedev/restate-sdk @restatedev/restate-sdk-clientspnpm add @opencoredev/social-sdk @restatedev/restate-sdk @restatedev/restate-sdk-clientsyarn add @opencoredev/social-sdk @restatedev/restate-sdk @restatedev/restate-sdk-clientsbun add @opencoredev/social-sdk @restatedev/restate-sdk @restatedev/restate-sdk-clientsnub add @opencoredev/social-sdk @restatedev/restate-sdk @restatedev/restate-sdk-clientsaube add @opencoredev/social-sdk @restatedev/restate-sdk @restatedev/restate-sdk-clientsThe examples also use pg, and they need a running Restate server. Follow the Restate quickstart to start one locally.
Durable stores still matter
The Restate journal records a step’s result only after the step returns. If the service crashes during ctx.run, Restate runs that step again, and the SDK sees a fresh call. Two SDK stores make that repeat harmless, so both must be durable and shared by every service instance:
- The client’s
idempotencyStoreturns a repeatedposts.publishinto a read of the first result. - The Threads adapter’s
workflowStoreholds each post betweenposts.publishand the Threads API writes, and its claim lease stops two instances from resuming the same post.
The Neon guide has Postgres implementations of both stores, with their schema. They work with any Postgres database. Keep them in src/stores.ts.
Keep the SDK calls in their own ctx.run steps and away from Restate state. The stores do their own I/O, so they can’t use ctx.get or ctx.set, which aren’t allowed inside ctx.run.
Define the draft object
The object key is tenantId:draftId. Every handler for that key runs in order, one at a time, and sees the state the previous one left.
import * as restate from "@restatedev/restate-sdk";
import { Pool } from "pg";
import {
createSocial,
connectedAccountRef,
type DeliveryOutcome,
type DeliveryRef,
} from "@opencoredev/social-sdk";
import { threads } from "@opencoredev/social-sdk/threads";
import { postgresIdempotencyStore, postgresThreadsWorkflowStore } from "./stores";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
const social = createSocial({
backend: threads({
auth: {
userId: process.env.THREADS_USER_ID!,
accessToken: process.env.THREADS_ACCESS_TOKEN!,
},
workflowStore: postgresThreadsWorkflowStore(pool),
}),
idempotencyStore: postgresIdempotencyStore(pool),
});
// Native calls skip the client's tenant checks, so the API authorizes the account first.
const native = social.native("default", { acknowledgeUnsafe: true })!;
export interface PublishRequest {
readonly tenantId: string;
readonly draftId: string;
readonly accountId: string;
readonly text: string;
}
type DraftState = {
request: PublishRequest;
outcome: DeliveryOutcome;
cancelled: boolean;
reconcileRuns: number;
};
type DraftContext = restate.ObjectContext<DraftState>;
export type DraftObject = typeof draft;
// Keep each resume well under Restate's abort timeout and the workflow store's 5-minute lease.
const resumeBudget = { maxAttempts: 3, maxElapsedMs: 30_000 };
const stepRetry = { maxRetryAttempts: 5, initialRetryInterval: { seconds: 2 } };
const maxReconcileRuns = 30;
async function resume(ctx: DraftContext, ref: DeliveryRef): Promise<DeliveryOutcome["state"]> {
const account = connectedAccountRef({
backend: ref.backend,
platform: "threads",
accountId: ref.accountId,
});
let state: DeliveryOutcome["state"] = "processing";
try {
const outcome = await ctx.run(
"resume",
() =>
native.resumePublication(account, ref.deliveryId, {
backendInstance: ref.backend,
correlationId: ctx.key,
retryBudget: resumeBudget,
}),
stepRetry,
);
ctx.set("outcome", outcome);
state = outcome.state;
} catch (error) {
// Threads or Postgres failed every attempt. The draft stays processing for the next run.
if (!(error instanceof restate.TerminalError)) throw error;
}
// Media still processing, another instance holds the workflow claim, or the step failed.
if (state === "processing") {
const runs = (await ctx.get("reconcileRuns")) ?? 0;
if (runs < maxReconcileRuns) {
ctx.set("reconcileRuns", runs + 1);
ctx
.objectSendClient<DraftObject>({ name: "Draft" }, ctx.key)
.reconcile(restate.rpc.sendOpts({ delay: { minutes: 2 } }));
}
}
return state;
}
export const draft = restate.object({
name: "Draft",
handlers: {
publish: async (ctx: DraftContext, request: PublishRequest) => {
if (ctx.key !== `${request.tenantId}:${request.draftId}`)
throw new restate.TerminalError("Object key does not match the request");
const previous = await ctx.get("request");
if (previous && (previous.accountId !== request.accountId || previous.text !== request.text))
throw new restate.TerminalError("Draft was already submitted with different content");
if (await ctx.get("cancelled")) return { state: "cancelled" };
// Any stored outcome is final: a second publish for the same draft reports it.
const existing = await ctx.get("outcome");
if (existing) return { state: existing.state };
ctx.set("request", request);
const result = await ctx.run(
"publish",
() =>
social.posts.publish(
{
targets: [
{
account: connectedAccountRef({
backend: "default",
platform: "threads",
accountId: request.accountId,
}),
},
],
content: { text: request.text },
idempotencyKey: request.draftId,
},
// Scopes the idempotency key to this tenant.
{ authorization: { tenantId: request.tenantId } },
),
stepRetry,
);
const outcome = result.outcomes[0];
if (!outcome) throw new restate.TerminalError("Publish returned no outcome");
ctx.set("outcome", outcome);
if (outcome.state !== "processing" || !outcome.delivery) return { state: outcome.state };
return { state: await resume(ctx, outcome.delivery) };
},
reconcile: async (ctx: DraftContext) => {
const outcome = await ctx.get("outcome");
if (outcome?.state !== "processing" || !outcome.delivery) return;
await resume(ctx, outcome.delivery);
},
cancel: async (ctx: DraftContext) => {
if (await ctx.get("outcome")) return { cancelled: false };
ctx.set("cancelled", true);
return { cancelled: true };
},
status: restate.handlers.object.shared(async (ctx: restate.ObjectSharedContext<DraftState>) => {
if (await ctx.get("cancelled")) return { state: "cancelled" };
return { state: (await ctx.get("outcome"))?.state ?? "not-started" };
}),
},
});
Serve the object from a Node or Bun process:
import * as restate from "@restatedev/restate-sdk";
import { draft } from "./draft";
restate.serve({ services: [draft], port: 9080 });
Then register the service with the Restate server, so it knows where to send invocations:
restate deployments register http://localhost:9080
How the handlers behave
publish records the post and resumes it right away, so most posts go out during the first invocation. Threads publishing takes two steps: posts.publish stores a workflow and returns processing, and the adapter’s native resumePublication makes the Threads API calls.
When media is still processing, or the resume step fails all five attempts, resume sends a delayed reconcile message to the same draft instead of sleeping. The draft doesn’t block other calls while it waits, and a running invocation never keeps an old deployment alive. After 30 runs, about an hour, the draft stops rescheduling and stays processing for you to review.
unknown is final here. The Threads workflow reports unknown when a write may or may not have reached Threads, and resumePublication won’t replay it. Leave those drafts for review, as the idempotency guide describes.
A draft accepts one request. Publishing the same draft ID again with a different account or text fails with a TerminalError instead of returning the first outcome, so an edited draft is never reported as published when it wasn’t.
The draft treats every stored outcome as final, including failed and not-submitted. not-submitted means nothing reached Threads, for example because the client’s rate limiter refused the call. To try again, publish under a new draft ID, since the old ID is already the idempotency key for the first attempt.
status is a shared handler, so it answers even while publish or reconcile holds the draft.
Publish from your API
Your web app authenticates the user, checks that the tenant owns the account, and then sends the request to Restate. A send returns as soon as Restate has durably accepted it.
import { createHash } from "node:crypto";
import * as clients from "@restatedev/restate-sdk-clients";
import type { DraftObject, PublishRequest } from "../draft";
const restate = clients.connect({ url: process.env.RESTATE_INGRESS_URL! });
export async function queuePublish(request: PublishRequest, publishAt?: Date) {
const key = `${request.tenantId}:${request.draftId}`;
// Same request, same key. An edited draft gets a new key and reaches the handler's check.
const digest = createHash("sha256")
.update(JSON.stringify([request.accountId, request.text]))
.digest("hex");
const publishTime = publishAt?.getTime();
if (Number.isNaN(publishTime)) throw new TypeError("publishAt must be a valid date");
const delayMs = publishTime === undefined ? 0 : Math.max(0, publishTime - Date.now());
await restate.objectSendClient<DraftObject>({ name: "Draft" }, key).publish(
request,
clients.rpc.sendOpts({
idempotencyKey: `${key}:${digest}`,
...(delayMs > 0 && { delay: { milliseconds: delayMs } }),
}),
);
}
Pass a publishAt date to schedule the post. Restate holds the delayed message, and nothing needs to poll. To cancel a scheduled post, call cancel on the same key before it fires. The publish handler checks that flag first. Once publish has stored an outcome, cancel returns { cancelled: false }.
The idempotencyKey stops a retried send from queuing the publish twice. It includes a digest of the account and text rather than the text itself, so a send with edited content isn’t dropped as a duplicate. It reaches publish, which rejects it. Restate keeps idempotency results for 24 hours by default, and the draft’s own state rejects duplicates after that.
A send returns before the handler runs, so that rejection shows up as a failed invocation in Restate, not as an error from queuePublish. Lock the draft in your own database once it’s queued, and have your API refuse edits after that.
To read a draft’s state from your API:
const { state } = await restate
.objectClient<DraftObject>({ name: "Draft" }, `${tenantId}:${draftId}`)
.status();
Restate state is durable, but it isn’t a query layer for your product. If you list deliveries across drafts, write the outcome to your own table from a ctx.run step as well.
Retries
Restate retries a failed ctx.run step with the policy you pass. stepRetry allows five attempts, then throws a TerminalError that ends the invocation. Without a policy, Restate retries the whole invocation with backoff and pauses it after 70 attempts.
A retry can’t double-post. A retried publish step hits the idempotency store. If the first attempt saved its outcome, the retry gets that outcome back. If the crash came after the claim but before the outcome was saved, the retry gets unknown with no delivery reference. For Threads, that outcome means no Threads request was made, because posts.publish only writes the workflow row. The crash may leave a workflow row behind, but no delivery reference points at it and nothing resumes it, so publishing again under a new draft ID can’t post twice.
A retried resume step hits the workflow store, which knows how far the post got and reports unknown rather than replay a write it can’t confirm.
Webhooks
Verify provider webhooks in your web app, following the events guide, and store them in an inbox table. To process an event durably, send it to a Restate handler with the inbox row’s dedupe key as the idempotencyKey.
Notes
- The Restate TypeScript SDK supports Node.js 22 and later and Bun, which covers the SDK’s tested runtimes. Keep the Social SDK client at module scope so every invocation reuses it.
- Keep platform tokens in the service’s environment. They never pass through Restate, and neither the journal nor object state should hold them.
- The journal stores each step’s result. For
publishthat is aPublishResult, and forresumeaDeliveryOutcome. Both hold references and states but no post text. The request itself, including the post text, is stored as therequeststate and in the invocation journal. - Handler changes follow Restate’s versioning rules. Register each new deployment, and keep the old one running until its invocations finish.
- The Restate details on this page come from Restate’s documentation as of September 22, 2026: durable steps, state, service communication (delayed messages, idempotency keys), clients, HTTP invocation (24-hour idempotency retention), and service configuration (timeouts, default retry policy).
- The samples typecheck against
@restatedev/restate-sdk1.17. They ran against Restate server 1.7 and Postgres with a stubbed Threads API, including a crash afterposts.publishthat Restate retried without a second post. They have not run against a live Threads account.