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
The server owns the canonical post and announcement:
content: visible | blocked
delivery:
queued ──> sending ──> sent
│
├─> failed
└─> unknown (detected after an interrupted process)
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"
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));
}
}
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);
});
Run the suite:
npm test
The tests prove four separate properties:
- A repeated request returns the same canonical post.
- An operation ID cannot be reassigned to different content.
- A delivery failure does not discard the post.
- 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
);
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");
}
}
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:
- Persist
deliveryState = "sending". - Send the community announcement.
- Receive success from the messaging integration.
- 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
failedand permits a controlled retry. - [ ] Killing the process during
sendingproducesunknownafter 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)