DEV Community

LunarDrift
LunarDrift

Posted on

A Pet Photo Is Not Posted Until It Has a Receipt: Build a Durable Community Outbox

A community prompt such as “show us your pet” sounds almost too simple to architect: choose a photo, add a caption, and post it.

Then a mobile connection drops at exactly the wrong moment.

The author presses Post again. The first request may have succeeded, the announcement may have been delivered, and the browser has no receipt. A convincing demo retries everything. A reliable community product must distinguish saved content, message delivery, and unknown outcomes.

In this tutorial, we will build a durable TypeScript submission coordinator for a rich-media community. It guarantees one canonical post per client operation, makes announcement failures visible, and refuses to hide an uncertain delivery behind an automatic retry.

Choose the correct source of truth

There are two reasonable ways to implement a community photo prompt:

Design Good fit Main trade-off
Send the photo directly as a chat message Casual, ephemeral conversation Retrying safely depends on the downstream messaging contract
Save a canonical post, then announce it in chat Moderated, editable, or long-lived community content Requires application storage and two visible states

We will use the second design.

Tencent RTC's Social Messaging solution covers scenarios including group discussions, large communities, and rich-media interaction. Our application will use that messaging layer for the community announcement, while its own database remains authoritative for the submitted post.

This distinction gives us an honest invariant:

A failed announcement must not erase a saved post, and retrying an announcement must never create a second canonical post.

It does not let us claim exactly-once message delivery. If the process crashes after an external send succeeds but before its receipt is persisted, the result is uncertain. We will model that uncertainty explicitly.

The two related state machines

The browser owns media preparation:

draft
  └─> uploading ──> ready ──> submitting
         │                       │
         └─> upload_failed       ├─> accepted
                                 └─> blocked
Enter fullscreen mode Exit fullscreen mode

The server owns the canonical post and announcement:

content:  visible | blocked

delivery:
  queued ──> sending ──> sent
                │
                ├─> failed
                └─> unknown   (detected after an interrupted process)
Enter fullscreen mode Exit fullscreen mode

unknown is intentionally different from failed:

  • failed means the adapter returned an error before a success receipt was recorded.
  • unknown means the application cannot prove whether the external announcement happened.

Automatic retry is reasonable for some known failures. It is unsafe for an unknown outcome unless duplicate announcements are acceptable or the downstream integration supplies an idempotency contract you have verified.

Create the reproducible project

Use Node.js 20 or later:

mkdir durable-community-post
cd durable-community-post
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src test
npm pkg set type=module
npm pkg set scripts.test="tsx --test"
Enter fullscreen mode Exit fullscreen mode

Create src/coordinator.ts:

export type DeliveryState =
  | "not_applicable"
  | "queued"
  | "sending"
  | "sent"
  | "failed"
  | "unknown";

export type ContentState = "visible" | "blocked";

export interface Submission {
  operationId: string;
  authorId: string;
  text: string;
  mediaKey: string;
  altText: string;
  hasPostingRights: boolean;
}

export interface Post {
  postId: string;
  operationId: string;
  fingerprint: string;
  authorId: string;
  text: string;
  mediaKey: string;
  altText: string;
  contentState: ContentState;
  deliveryState: DeliveryState;
  messageId?: string;
  reason?: string;
}

export interface PostRepository {
  // Production implementations must make this atomic.
  create(candidate: Post): Promise<{ post: Post; created: boolean }>;
  getByPostId(postId: string): Promise<Post | undefined>;
  save(post: Post): Promise<void>;
  listSending(): Promise<Post[]>;
}

export interface CommunityMessenger {
  announce(post: Post): Promise<{ messageId: string }>;
}

function fingerprint(input: Submission): string {
  // A fixed tuple avoids depending on object-property order.
  return JSON.stringify([
    input.authorId,
    input.text,
    input.mediaKey,
    input.altText,
    input.hasPostingRights
  ]);
}

function buildPost(input: Submission): Post {
  const blockedReason = !input.hasPostingRights
    ? "posting_rights_not_confirmed"
    : input.altText.trim().length === 0
      ? "alt_text_required"
      : undefined;

  return {
    postId: `post-${input.operationId}`,
    operationId: input.operationId,
    fingerprint: fingerprint(input),
    authorId: input.authorId,
    text: input.text,
    mediaKey: input.mediaKey,
    altText: input.altText,
    contentState: blockedReason ? "blocked" : "visible",
    deliveryState: blockedReason ? "not_applicable" : "queued",
    reason: blockedReason
  };
}

export class SubmissionCoordinator {
  constructor(
    private readonly repository: PostRepository,
    private readonly messenger: CommunityMessenger
  ) {}

  async submit(input: Submission): Promise<Post> {
    const candidate = buildPost(input);
    const result = await this.repository.create(candidate);

    if (result.post.fingerprint !== candidate.fingerprint) {
      throw new Error("operation_id_reused_with_different_content");
    }

    // A repeated HTTP request returns the existing outcome. It does not
    // silently repeat the external announcement.
    if (!result.created) return result.post;

    if (result.post.contentState === "blocked") return result.post;

    return this.deliver(result.post);
  }

  async retryAnnouncement(
    postId: string,
    acceptDuplicateRisk = false
  ): Promise<Post> {
    const post = await this.repository.getByPostId(postId);
    if (!post) throw new Error("post_not_found");

    if (post.deliveryState === "sent") return post;

    if (post.deliveryState === "unknown" && !acceptDuplicateRisk) {
      throw new Error("delivery_unknown_requires_explicit_decision");
    }

    if (!['failed', 'unknown', 'queued'].includes(post.deliveryState)) {
      throw new Error(`cannot_retry_from_${post.deliveryState}`);
    }

    return this.deliver(post);
  }

  async recoverInterruptedDeliveries(): Promise<number> {
    const interrupted = await this.repository.listSending();

    for (const post of interrupted) {
      post.deliveryState = "unknown";
      post.reason = "process_interrupted_during_announcement";
      await this.repository.save(post);
    }

    return interrupted.length;
  }

  private async deliver(post: Post): Promise<Post> {
    post.deliveryState = "sending";
    post.reason = undefined;
    await this.repository.save(post);

    try {
      const receipt = await this.messenger.announce(post);
      post.deliveryState = "sent";
      post.messageId = receipt.messageId;
      await this.repository.save(post);
      return post;
    } catch (error) {
      post.deliveryState = "failed";
      post.reason = String(error);
      await this.repository.save(post);
      return post;
    }
  }
}

export class MemoryPostRepository implements PostRepository {
  private readonly byOperation = new Map<string, Post>();

  async create(candidate: Post): Promise<{ post: Post; created: boolean }> {
    const existing = this.byOperation.get(candidate.operationId);
    if (existing) return { post: structuredClone(existing), created: false };

    this.byOperation.set(candidate.operationId, structuredClone(candidate));
    return { post: structuredClone(candidate), created: true };
  }

  async getByPostId(postId: string): Promise<Post | undefined> {
    const post = [...this.byOperation.values()].find(p => p.postId === postId);
    return post ? structuredClone(post) : undefined;
  }

  async save(post: Post): Promise<void> {
    this.byOperation.set(post.operationId, structuredClone(post));
  }

  async listSending(): Promise<Post[]> {
    return [...this.byOperation.values()]
      .filter(post => post.deliveryState === "sending")
      .map(post => structuredClone(post));
  }
}
Enter fullscreen mode Exit fullscreen mode

The operationId should be generated once when the browser creates the draft and persisted with that draft. Refreshes and request retries must reuse it. Editing the content should create a new revision or operation rather than silently reusing the old key.

Verify the dangerous paths

Create test/coordinator.test.ts:

import assert from "node:assert/strict";
import test from "node:test";
import {
  CommunityMessenger,
  MemoryPostRepository,
  Submission,
  SubmissionCoordinator
} from "../src/coordinator.js";

const validPost: Submission = {
  operationId: "browser-7-draft-41",
  authorId: "member-12",
  text: "Mochi debugging the cardboard box",
  mediaKey: "uploads/member-12/mochi.jpg",
  altText: "A tabby cat sitting inside a cardboard box",
  hasPostingRights: true
};

test("a repeated submission does not announce twice", async () => {
  const repository = new MemoryPostRepository();
  let announcements = 0;

  const messenger: CommunityMessenger = {
    async announce() {
      announcements += 1;
      return { messageId: "message-1" };
    }
  };

  const coordinator = new SubmissionCoordinator(repository, messenger);

  const first = await coordinator.submit(validPost);
  const repeated = await coordinator.submit(validPost);

  assert.equal(first.deliveryState, "sent");
  assert.equal(repeated.postId, first.postId);
  assert.equal(announcements, 1);
});

test("the same operation ID cannot identify changed content", async () => {
  const repository = new MemoryPostRepository();
  const messenger: CommunityMessenger = {
    async announce() {
      return { messageId: "message-1" };
    }
  };

  const coordinator = new SubmissionCoordinator(repository, messenger);
  await coordinator.submit(validPost);

  await assert.rejects(
    coordinator.submit({ ...validPost, text: "Changed caption" }),
    /operation_id_reused_with_different_content/
  );
});

test("a failed announcement leaves the canonical post retryable", async () => {
  const repository = new MemoryPostRepository();
  let attempts = 0;

  const messenger: CommunityMessenger = {
    async announce() {
      attempts += 1;
      if (attempts === 1) throw new Error("temporary_delivery_failure");
      return { messageId: "message-after-retry" };
    }
  };

  const coordinator = new SubmissionCoordinator(repository, messenger);

  const failed = await coordinator.submit(validPost);
  assert.equal(failed.contentState, "visible");
  assert.equal(failed.deliveryState, "failed");

  const retried = await coordinator.retryAnnouncement(failed.postId);
  assert.equal(retried.deliveryState, "sent");
  assert.equal(attempts, 2);
});

test("missing posting confirmation blocks publication", async () => {
  const repository = new MemoryPostRepository();
  let announcements = 0;

  const coordinator = new SubmissionCoordinator(repository, {
    async announce() {
      announcements += 1;
      return { messageId: "should-not-exist" };
    }
  });

  const result = await coordinator.submit({
    ...validPost,
    hasPostingRights: false
  });

  assert.equal(result.contentState, "blocked");
  assert.equal(result.reason, "posting_rights_not_confirmed");
  assert.equal(announcements, 0);
});
Enter fullscreen mode Exit fullscreen mode

Run the suite:

npm test
Enter fullscreen mode Exit fullscreen mode

The tests prove four separate properties:

  1. A repeated request returns the same canonical post.
  2. An operation ID cannot be reassigned to different content.
  3. A delivery failure does not discard the post.
  4. A blocked submission never reaches the messaging adapter.

Replace memory with an atomic database operation

The in-memory repository is only a reproducible test adapter. In production, create() must atomically enforce the operation ID.

A relational table can start with this shape:

CREATE TABLE community_posts (
  post_id         TEXT PRIMARY KEY,
  operation_id    TEXT NOT NULL UNIQUE,
  fingerprint     TEXT NOT NULL,
  author_id       TEXT NOT NULL,
  text_body       TEXT NOT NULL,
  media_key       TEXT NOT NULL,
  alt_text        TEXT NOT NULL,
  content_state   TEXT NOT NULL CHECK (content_state IN ('visible', 'blocked')),
  delivery_state  TEXT NOT NULL CHECK (
    delivery_state IN (
      'not_applicable', 'queued', 'sending', 'sent', 'failed', 'unknown'
    )
  ),
  message_id      TEXT,
  reason          TEXT,
  created_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

On an operation-ID conflict, read the existing row and compare its fingerprint. Do not treat every conflict as success: accepting the same key with different content would make receipts ambiguous.

Media upload also needs its own durable identity. mediaKey should refer to a finalized server-recognized asset, not a browser object URL. If upload fails, keep the browser draft in upload_failed; do not submit a post whose attachment only exists in local memory.

Connect the coordinator to Tencent RTC Social Messaging

Keep product-specific code behind CommunityMessenger:

class TencentCommunityMessenger implements CommunityMessenger {
  async announce(post: Post): Promise<{ messageId: string }> {
    // 1. Construct the approved community announcement presentation.
    // 2. Send it through the Tencent RTC messaging integration selected
    //    for your application.
    // 3. Return only after obtaining the integration's success receipt.
    //
    // Use the exact SDK and message-type documentation for your target
    // platform here rather than guessing an API name or payload shape.
    throw new Error("adapter_not_configured");
  }
}
Enter fullscreen mode Exit fullscreen mode

The announcement can present the image and caption or point members to the canonical post, depending on your product design. The important boundary is that the adapter does not decide whether a post exists, whether the author confirmed posting rights, or whether an uncertain send may be repeated.

Those are application decisions.

If your multilingual community wants on-demand caption translation, check the official TUIChat message translation documentation before including it in acceptance criteria. Supported content types, languages, and edition limits must be verified for the intended deployment. Translation should be an optional reading aid; it should not alter the saved caption, alt text, operation fingerprint, or moderation record.

User-visible behavior for each failure

A state machine only helps if users can understand its outcomes.

Condition UI behavior Safe action
Media upload failed Keep the draft and show the failed attachment Retry upload
Post blocked by policy Explain the actionable reason Edit or cancel
Post saved, announcement failed Show “Post saved; community announcement delayed” Retry announcement
Delivery outcome unknown Show “We could not confirm the announcement” Check the channel or explicitly accept duplicate risk
Operation ID reused with changed content Reject as a client conflict Create a new operation ID
Announcement sent Show the canonical post and receipt-backed status No retry button

Do not show “Post failed” when the canonical post is safely stored. That wording encourages users to resubmit and creates duplicates at the product level.

Crash drill: the gap no unit retry can remove

There is an unavoidable sequence to test:

  1. Persist deliveryState = "sending".
  2. Send the community announcement.
  3. Receive success from the messaging integration.
  4. Crash before persisting deliveryState = "sent".

After restart, call recoverInterruptedDeliveries(). Any row left in sending becomes unknown, not failed.

From there, choose deliberately:

  • Check before retrying if your integration and application can locate the previous announcement reliably.
  • Allow a deliberate retry if duplicate announcements are an acceptable trade-off.
  • Escalate to a moderator for high-visibility community announcements.
  • Do not retry automatically merely because a timer expired.

A transactional outbox improves database-side reliability, but it cannot create exactly-once behavior across an external system unless the complete downstream contract supports compatible idempotency or deduplication.

Pre-release verification checklist

Run these checks with the real database and messaging adapter:

  • [ ] Double-clicking Post produces one canonical database row.
  • [ ] Replaying the same HTTP request returns the same post ID.
  • [ ] Reusing an operation ID with changed content returns a conflict.
  • [ ] A failed media upload never starts post submission.
  • [ ] Missing alt text or posting-rights confirmation prevents announcement.
  • [ ] A known adapter failure leaves the post in failed and permits a controlled retry.
  • [ ] Killing the process during sending produces unknown after recovery.
  • [ ] Unknown delivery is not retried without an explicit decision.
  • [ ] The UI distinguishes “saved but unannounced” from “not saved.”
  • [ ] Reports, deletion, and moderator actions target the canonical post ID.
  • [ ] Logs contain operation and post IDs, but do not expose private media URLs or unnecessary message content.

The larger lesson is not that a playful community feature needs enterprise ceremony. It is that friendliness does not remove distributed-system ambiguity. A durable post ID, an honest receipt, and a visible unknown state let the community remain casual without making its implementation careless.


Disclosure: I created this article as part of a content collaboration connected to Tencent RTC. Official Tencent RTC documentation was used as the implementation reference; the state model and sample application architecture are original tutorial material.

Top comments (0)