Threaded Tweet Replies
Summary
Add internal threaded replies to www tweet pages by continuing to model tweets and replies as rows in posts with type = 'micro'.
The recommended model is a self-referential thread shape on posts: direct parent, root post, and depth. Top-level tweet creation stays restricted to creator/editor/admin roles. Replies use community mode: any authenticated user can reply through a dedicated reply endpoint. The server owns slug generation and all thread fields.
Context / Current State
apps/www/src/routes/tweet/index.tsx renders the tweet feed from useMicroPosts(5).
apps/www/src/routes/tweet/$slug.tsx loads one micro post from GET /content/posts/micro/{slug}.
apps/www/src/routes/new/-TweetCapturePage.tsx currently creates tweet slugs on the client because POST /content/post requires slug.
apps/vps/src/db/post.schema.ts has postsTable with content fields, type, musicEntityType, and musicEntityId, but no thread fields.
apps/vps/src/services/post.service.ts owns getMicroPosts, getMicroPostBySlug, create, update, micro/editorial refinement, and MDX compilation.
apps/vps/src/routes/content/content.routes.ts and content.handlers.ts expose the current Hono OpenAPI content routes.
- Existing post validation already allows micro posts with either
title or content.
- Existing route tests include blackbox HTTP tests in
apps/vps/src/http/routes.blackbox.test.ts; route files should be tested through integration/blackbox behavior, not route unit tests.
Goals
- Let any authenticated user reply to an existing tweet/micro post.
- Keep top-level tweet creation restricted to creator/editor/admin users.
- Keep replies out of the main
/tweet feed by default.
- Support direct replies and deeper nested thread data at the persistence/API level.
- Let v1 UI render replies simply while storing enough structure for nested UI later.
- Move slug ownership for new tweet/reply creates to the server.
- Preserve existing creator/admin edit behavior for created replies.
Non-Goals
- No X/Twitter API integration.
- No real-time replies.
- No notification system.
- No moderation queue.
- No delete/tombstone workflow in v1 unless the project decides to add reply deletion in the same pass.
- No full nested reply composer UI required in v1; the data model supports it, but the page can render flat initially.
Invariants
type TopLevelTweetThreadFields = {
readonly parentPostId: null
readonly rootPostId: null
readonly depth: 0
}
type ReplyThreadFields = {
readonly parentPostId: string
readonly rootPostId: string
readonly depth: number
}
- A top-level tweet has
type = 'micro', parentPostId = null, rootPostId = null, and depth = 0.
- A reply has
type = 'micro', parentPostId = parent.id, rootPostId = parent.rootPostId ?? parent.id, and depth = parent.depth + 1.
- Replies can only target
type = 'micro' posts.
- Reply endpoint callers cannot set
slug, parentPostId, rootPostId, depth, or creatorIds.
parentPostId, rootPostId, and depth are immutable after creation.
- The main tweet feed returns only top-level tweets unless an explicit query asks for replies.
- Full thread reads return the root and descendants with stable oldest-first ordering for descendants.
- Editing uses existing post creator/admin ownership rules.
- Community reply permission means any authenticated user can create a reply, but that permission must not allow top-level post creation.
Design Constraints
- Production database change must be additive and nullable for existing rows.
- Database column names should be snake_case while TypeScript fields stay camelCase.
- Existing
www create flow currently sends slugs, so POST /content/post should tolerate supplied slugs while allowing server-generated slugs for new clients.
- New reply create should use a dedicated endpoint, not generic
POST /content/post, so community permissions cannot accidentally create top-level tweets.
- Keep using the existing Hono OpenAPI route style for content routes unless this is intentionally folded into the Effect HTTP migration later.
- Keep using
PostService as the Service Module for post orchestration.
- Expected failures stay in Effect's typed error channel inside
PostService.
- HTTP bodies are parsed with the established Zod OpenAPI route schemas before service logic receives them.
Alternatives Considered
Option 1: Separate post_replies Table
type PostReplyRow = {
readonly id: string
readonly parentPostId: string
readonly rootPostId: string
readonly creatorId: string
readonly content: string
readonly createdAt: Date
readonly updatedAt: Date
}
Pros:
- Replies can have a narrower schema than posts.
- It avoids making all
posts rows thread-aware.
- Deletion/tombstone behavior can be designed independently.
Cons:
- Duplicates creator, MDX, slug, and content behavior already owned by posts.
- Forces separate rendering and API projections for tweet-like content.
- Makes it harder to link to a reply as a normal micro post.
- Adds a second content lifecycle too early.
Option 2: parentPostId Only
type PostThreadFields = {
readonly parentPostId: string | null
}
Pros:
- Smallest schema change.
- Direct replies are easy to query.
- Simple mental model.
Cons:
- Full thread reads need recursive SQL or multiple round trips.
- Sorting a whole thread is more expensive and more complex.
- Every descendant lookup starts from adjacency traversal.
- Nested UI is harder to build later.
Option 3: parentPostId, rootPostId, and depth on posts
type PostThreadFields =
| { readonly _tag: 'TopLevel'; readonly parentPostId: null; readonly rootPostId: null; readonly depth: 0 }
| { readonly _tag: 'Reply'; readonly parentPostId: string; readonly rootPostId: string; readonly depth: number }
Pros:
- Reuses existing post/micro-post system.
- Direct replies and full threads are both efficient.
- Thread invariants are local to
PostService.createReply.
- Existing tweet pages and post creator model remain useful.
- Supports nested UI later without another migration.
Cons:
rootPostId and depth are denormalized and must be derived by the server.
- Requires immutable field rules on update.
- Adds thread fields to all posts, even editorial posts where they should stay null.
Recommendation
Use Option 3.
Replies should be micro posts in posts with parentPostId, rootPostId, and depth. The dedicated reply endpoint derives those fields from the parent. The generic create endpoint can be improved to generate slugs when omitted, but community reply writes should not go through the generic create route.
Proposed Design
Persistence Shape
export const postsTable = pgTable(
'posts',
{
...postContentFields,
title: varchar({ length: 255 }),
content: text(),
type: postTypeEnum(),
musicEntityType: text('music_entity_type'),
musicEntityId: uuid('music_entity_id'),
parentPostId: uuid('parent_post_id').references((): AnyPgColumn => postsTable.id, {
onDelete: 'set null'
}),
rootPostId: uuid('root_post_id').references((): AnyPgColumn => postsTable.id, {
onDelete: 'set null'
}),
depth: integer('depth').notNull().default(0)
},
(table) => [
index('posts_slug_idx').on(table.slug),
index('posts_music_entity_idx').on(table.musicEntityType, table.musicEntityId),
index('posts_type_created_idx').on(table.type, table.createdAt),
index('posts_tags_gin_idx').using('gin', table.tags),
index('posts_parent_created_idx').on(table.parentPostId, table.createdAt),
index('posts_root_created_idx').on(table.rootPostId, table.createdAt)
]
)
If Drizzle self-reference typing needs it, import AnyPgColumn from drizzle-orm/pg-core and use the self-reference callback form shown above.
Suggested database checks:
CHECK (depth >= 0)
CHECK (
(parent_post_id IS NULL AND root_post_id IS NULL AND depth = 0)
OR
(parent_post_id IS NOT NULL AND root_post_id IS NOT NULL AND depth > 0)
)
The service still owns the stronger invariant that a reply's rootPostId equals parent.rootPostId ?? parent.id.
Domain Model and Types
type MicroPostId = string
type MicroPostSlug = string
type ThreadPosition =
| {
readonly _tag: 'TopLevel'
readonly parentPostId: null
readonly rootPostId: null
readonly depth: 0
}
| {
readonly _tag: 'Reply'
readonly parentPostId: MicroPostId
readonly rootPostId: MicroPostId
readonly depth: number
}
type ReplyableMicroPost = SelectPost & {
readonly id: MicroPostId
readonly slug: MicroPostSlug
readonly type: 'micro'
readonly draft: false
readonly depth: number
readonly rootPostId: MicroPostId | null
}
type CreateMicroPostReplyInput = {
readonly parentSlug: MicroPostSlug
readonly actor: {
readonly id: string
readonly role: string | null | undefined
}
readonly title: string | null | undefined
readonly content: string | null | undefined
}
type CreateMicroPostReplyData = {
readonly title?: string | null
readonly content?: string | null
}
type MicroPostThread = {
readonly root: SelectMdxCompiledMicroPost
readonly focus: SelectMdxCompiledMicroPost
readonly posts: ReadonlyArray<SelectMdxCompiledMicroPost>
readonly pagination: PaginationMetadata
}
Expected Failures
Use existing errors where they fit. Add narrow errors only if current errors are not semantically precise enough.
type CreateReplyError =
| DatabaseError
| NotFoundError
| ValidationError
| ConflictError
| UnauthorizedError
Expected cases:
- Parent slug does not exist:
NotFoundError.
- Parent exists but is not
type = 'micro': ValidationError or a new ParentPostNotReplyableError.
- Parent is a draft and actor cannot view it:
UnauthorizedError or NotFoundError, matching existing content visibility policy.
- Reply body has no title or content: existing
ValidationError from validatePostData.
- Slug collision: existing
ConflictError, with retry around slug generation if desired.
- Database insert/query fails: existing
DatabaseError.
Types, Interfaces, and APIs
PostService
export interface PostService {
readonly getMicroPosts: (options: {
readonly limit: number
readonly offset: number
readonly includeReplies?: boolean
}) => Effect.Effect<
{ readonly data: SelectMdxCompiledMicroPost[]; readonly pagination: PaginationMetadata },
DatabaseError,
SentryService
>
readonly getMicroPostReplies: (
parentSlug: string,
options: { readonly limit: number; readonly offset: number }
) => Effect.Effect<
{ readonly data: SelectMdxCompiledMicroPost[]; readonly pagination: PaginationMetadata },
DatabaseError | NotFoundError,
SentryService
>
readonly getMicroPostThread: (
slug: string,
options: { readonly limit: number; readonly offset: number }
) => Effect.Effect<MicroPostThread, DatabaseError | NotFoundError, SentryService>
readonly createMicroPostReply: (
input: CreateMicroPostReplyInput
) => Effect.Effect<SelectMdxCompiledMicroPost, CreateReplyError, SentryService>
}
HTTP Routes
Top-level feed, unchanged URL with refined semantics:
GET /content/posts/micro?limit=5&offset=0
Default response excludes replies:
type GetMicroPostsQuery = {
readonly limit: number
readonly offset: number
readonly includeReplies?: boolean
}
Direct replies:
GET /content/posts/micro/{slug}/replies?limit=20&offset=0
type MicroPostRepliesResponse = PaginatedResponse<SelectMdxCompiledMicroPost>
Full thread:
GET /content/posts/micro/{slug}/thread?limit=100&offset=0
type MicroPostThreadResponse = {
readonly root: SelectMdxCompiledMicroPost
readonly focus: SelectMdxCompiledMicroPost
readonly posts: ReadonlyArray<SelectMdxCompiledMicroPost>
readonly pagination: PaginationMetadata
}
Create reply:
POST /content/posts/micro/{parentSlug}/replies
{
"content": "yeah, exactly",
"title": null
}
const createMicroPostReplySchema = z
.object({
title: z.string().nullable().optional(),
content: z.string().nullable().optional()
})
.strict()
Server-derived fields:
type ServerDerivedReplyFields = {
readonly slug: string
readonly type: 'micro'
readonly parentPostId: string
readonly rootPostId: string
readonly depth: number
readonly creatorIds: readonly [string]
}
Generic post create should allow server-generated slugs:
type CreatePostRequest = {
readonly slug?: string
readonly title?: string | null
readonly content?: string | null
readonly description?: string
readonly thumbnailUrl?: string
readonly draft?: boolean
readonly tags?: readonly string[]
readonly type?: 'post' | 'micro' | null
readonly musicEntityType?: 'album' | 'track' | 'playlist' | null
readonly musicEntityId?: string | null
readonly creatorIds?: readonly string[]
}
www should eventually stop sending slugs for top-level tweets, but the API can continue accepting supplied slugs because the current client already sends them.
Seams, Boundaries, Adapters, and Implementations
content.routes.ts is the inbound HTTP schema/parser and OpenAPI boundary.
content.handlers.ts translates authenticated Hono context into service input.
PostService owns thread write policy, community reply permission, parent lookup, slug generation, transaction sequencing, and typed failures.
post.schema.ts owns persistence shape and protocol schemas exported through @gbfm/vps/schemas.
post-thread.ts can be added as a small pure Domain Module only if the derivation or invariants start spreading. Otherwise, keep the pure derivation local to post.service.ts.
www/src/lib/http.ts owns React Query hooks and protocol calls.
www/src/routes/tweet/$slug.tsx owns page composition.
TweetReplyComposer owns reply input state and mutation UX.
TweetReplyList owns reply rendering and loading states.
Call Stacks and Data Flow
Current / Old Flow
Top-level tweet creation today:
TweetCapturePage form state
-> client normalizeSlugBase + Date.now suffix
-> fetcher POST /content/post
-> Hono route schema parses CreatePostRequest with required slug
-> content.handlers.createPost
-> PostService.create(postData, creatorIds)
-> normalizePostData
-> validatePostData
-> db transaction insert posts + post_creators
-> SelectPost response
-> www navigates to /tweet/$slug
Tweet feed today:
useMicroPosts
-> GET /content/posts/micro
-> content.handlers.getMicroPosts
-> PostService.getMicroPosts
-> getAllEffect({ type: 'micro' })
-> db select all micro posts sorted desc
-> buildPostWithPreloadedCreators + MDX compile
-> TweetListCard renders every micro post
Proposed / New Flow
Community reply create:
TweetReplyComposer form state
-> fetcher POST /content/posts/micro/{parentSlug}/replies
-> createMicroPostReplySchema parses title/content only
-> betterAuthMiddleware provides authenticated user
-> content.handlers.createMicroPostReply maps context to CreateMicroPostReplyInput
-> PostService.createMicroPostReply
-> load parent by slug
-> refine parent as replyable micro post
-> derive rootPostId and depth
-> generate server slug
-> normalizePostData({ type: 'micro', title, content })
-> validatePostData
-> db transaction insert posts + post_creators
-> buildPostWithCreators
-> SelectMdxCompiledMicroPost response
-> www invalidates replies/thread queries
Direct replies read:
useMicroPostReplies(parentSlug)
-> GET /content/posts/micro/{slug}/replies
-> route query parser limit/offset
-> PostService.getMicroPostReplies
-> load parent by slug or fail NotFoundError
-> db count where parentPostId = parent.id
-> db select where parentPostId = parent.id order by createdAt asc
-> preload creators
-> MDX compile bounded concurrency
-> PaginatedResponse<SelectMdxCompiledMicroPost>
Full thread read:
useMicroPostThread(slug)
-> GET /content/posts/micro/{slug}/thread
-> PostService.getMicroPostThread
-> load focus by slug or fail NotFoundError
-> rootId = focus.rootPostId ?? focus.id
-> load root by id
-> db count/select descendants where rootPostId = rootId order by createdAt asc
-> include root separately in response
-> compile posts
-> MicroPostThreadResponse
Top-level feed read:
useMicroPosts
-> GET /content/posts/micro
-> PostService.getMicroPosts({ includeReplies: false })
-> db where type = 'micro' and parentPostId is null
-> TweetListCard renders only top-level tweets
Failure Flow
POST reply
-> parent slug missing
-> PostService returns NotFoundError
-> runEffect maps to 404
-> www toast: reply target not found
POST reply
-> parent type != micro
-> PostService returns ValidationError or ParentPostNotReplyableError
-> runEffect maps to 422
-> www toast: cannot reply to this content
POST reply
-> unauthenticated
-> betterAuthMiddleware rejects
-> HTTP 401
-> www shows sign-in CTA or auth prompt
POST reply
-> blank title and content
-> validatePostData fails with ValidationError
-> HTTP 422
-> composer keeps draft text and renders toast
PATCH existing reply
-> caller is not reply creator and not admin
-> requireCreatorOrAdmin returns UnauthorizedError
-> HTTP 401/403 using existing route mapping
Retry / Cancellation / Idempotency Flow
- Existing
fetcher calls do not expose caller-owned cancellation into PostService; this spec does not add a new cancellation seam.
- UI must disable the reply submit button while the mutation is pending.
- The strict idempotency design would add an
Idempotency-Key header and a persisted replay table scoped by actor plus parent plus key.
- V1 can intentionally defer that table because existing content creates are not idempotent and reply duplication is low consequence, but that is a known duplicate-risk tradeoff.
- If strict retry safety is required before implementation, add
post_create_requests before shipping reply creation.
Strict idempotency option:
type PostCreateRequestRow = {
readonly key: string
readonly actorUserId: string
readonly parentPostId: string | null
readonly postId: string
readonly createdAt: Date
}
POST reply with Idempotency-Key
-> transaction checks actor + parent + key
-> existing replay returns original post
-> missing replay inserts post and replay row together
Observability Flow
Use existing Effect spans/logs around PostService methods. Add safe fields only:
type ReplyTelemetryFields = {
readonly operation: 'post.createMicroPostReply'
readonly parentSlug: string
readonly parentPostId?: string
readonly rootPostId?: string
readonly actorUserId: string
readonly depth?: number
readonly errorTag?: string
}
Do not log raw reply content, title, request body, session object, or unknown caught values.
Files to Add / Change / Delete
Change: apps/vps/src/db/post.schema.ts
Owns:
- Add
parentPostId, rootPostId, and depth columns.
- Add indexes for direct replies and full thread reads.
- Extend select/openapi schemas with thread fields.
- Make create request slug optional at the protocol schema layer.
- Add reply create schema if colocated here.
Add: apps/vps/drizzle/<next>_threaded_tweet_replies.sql
Owns:
- Add nullable thread columns.
- Add
depth default.
- Add indexes.
- Add check constraints if practical with current Drizzle generation.
Change: apps/vps/src/services/post.service.ts
Owns:
- Filter
getMicroPosts to top-level tweets by default.
- Add direct reply read.
- Add full thread read.
- Add reply create orchestration.
- Derive thread fields server-side.
- Reject thread field mutation in update.
- Generate slug when missing for generic create.
Optional Add: apps/vps/src/services/post-thread.ts
Owns pure derivation if it would otherwise spread:
export const deriveReplyThreadFields = (parent: ReplyableMicroPost): ReplyThreadFields => ({
_tag: 'Reply',
parentPostId: parent.id,
rootPostId: parent.rootPostId ?? parent.id,
depth: parent.depth + 1
})
Skip this file if the invariant remains local to one function.
Change: apps/vps/src/routes/content/content.routes.ts
Owns:
getMicroPostReplies route.
getMicroPostThread route.
createMicroPostReply route.
includeReplies query option if needed.
- OpenAPI response schemas.
Change: apps/vps/src/routes/content/content.handlers.ts
Owns:
- Parse route params/query/body through OpenAPI route validation.
- Pull
user from auth middleware for reply creation.
- Call new
PostService methods.
Change: apps/vps/src/routes/content/content.index.ts
Owns:
- Register the new content routes.
Change: apps/www/src/lib/http.ts
Owns:
useMicroPostReplies(parentSlug, limit).
useMicroPostThread(slug, limit) if full thread is used by the page.
useCreateMicroPostReply(parentSlug) mutation.
- Query invalidation key conventions.
Change: apps/www/src/routes/tweet/$slug.tsx
Owns:
- Render reply composer for authenticated users.
- Render sign-in CTA for unauthenticated users.
- Render replies below the root tweet.
- Keep existing edit/actions behavior for the root tweet.
Add: apps/www/src/components/TweetReplyComposer.tsx
Owns:
- Reply text state.
- Submit button pending state.
- Mutation trigger.
- Error/success toast behavior.
Add: apps/www/src/components/TweetReplyList.tsx
Owns:
- Reply loading/error/empty states.
- Direct reply rendering.
- Load-more trigger if paginated in v1.
Add or Change Tests
apps/vps/src/services/post.service.test.ts for pure validation and thread field mutation rejection if kept as pure helpers.
apps/vps/src/http/routes.blackbox.test.ts or a new blackbox route test file for HTTP reply behavior.
apps/www component tests only if the project already has a practical seam for them; otherwise rely on typecheck plus manual/E2E for the page flow.
RGR TDD Test Plan
Slice 1: Persistence shape compiles
- Red: Add a focused schema/type expectation that a
SelectMdxCompiledMicroPost can carry thread fields.
- Green: Add nullable columns and exported schemas.
- Refactor: Keep field names camelCase in TS and snake_case in SQL.
Slice 2: Feed excludes replies
- Red: Service or HTTP blackbox test creates one top-level micro post and one reply-shaped micro post, then asserts
GET /content/posts/micro returns only the top-level post.
- Green: Add
parentPostId IS NULL to the default micro query.
- Refactor: Add
includeReplies only if there is a real caller.
Slice 3: Server derives reply thread fields
- Red: Service test or integration test creates a reply to a root and asserts
parentPostId, rootPostId, and depth are derived, ignoring any client-supplied thread fields.
- Green: Add
createMicroPostReply derivation and insert.
- Refactor: Extract
deriveReplyThreadFields only if useful.
Slice 4: Community reply permission does not broaden top-level create
- Red: HTTP blackbox test shows an authenticated normal user can
POST /content/posts/micro/{slug}/replies but cannot create a top-level tweet through POST /content/post if that route is supposed to stay creator/editor/admin.
- Green: Put community permission only on the reply route.
- Refactor: Keep auth checks explicit and local.
Slice 5: Reply creation rejects invalid parents
- Red: HTTP blackbox tests for missing parent and non-micro parent.
- Green: Add parent lookup and
type = 'micro' refinement.
- Refactor: Use existing
NotFoundError and ValidationError unless a precise new error is warranted.
Slice 6: Direct replies read oldest-first
- Red: Create two replies to one parent and one reply to another parent. Assert
GET /content/posts/micro/{slug}/replies returns only direct replies ordered by createdAt ASC.
- Green: Add direct reply query with count and pagination.
- Refactor: Share creator preload/compile path with existing post list logic if it remains readable.
Slice 7: Full thread read returns root, focus, and descendants
- Red: Create root, reply, nested reply. Assert
GET /content/posts/micro/{replySlug}/thread returns root, focus reply, and descendants for the same root.
- Green: Add root derivation and descendants query by
rootPostId.
- Refactor: Keep response projection explicit.
Slice 8: Update cannot mutate thread fields
- Red: Attempt
PATCH /content/posts/{slug} with parentPostId, rootPostId, or depth and assert the persisted thread position remains unchanged or the request rejects.
- Green: Omit/reject immutable thread fields in update schema/service.
- Refactor: Prefer rejecting unknown/immutable fields in mutating schemas for the changed route.
Slice 9: Server generates slugs
- Red:
POST /content/post without slug creates a post with a stable non-empty slug; reply create also returns a generated slug and rejects body-level slug.
- Green: Use existing
toSlug for top-level creates and reply or content-derived text for replies.
- Refactor: Move slug generation into a small helper only if multiple call sites duplicate it.
Slice 10: www reply UX
- Red: E2E or component-level seam, if available, verifies signed-in user can submit a reply and sees it in the reply list.
- Green: Add
TweetReplyComposer, hooks, and invalidation.
- Refactor: Keep route page small by composing reply components.
Risks and Open Questions
- Should full thread reads paginate all descendants, or should v1 cap thread size without pagination?
- Should replies to drafts be blocked as
404 or 403? The answer should follow the existing draft visibility model.
- Should reply content require
content, or keep the existing micro-post rule of title or content?
- Should nested replies be creatable from v1 UI, or only through the data/API model for now?
- Should strict POST idempotency be added now? The standards-preferred answer is yes, but it adds a replay table. The 80/20 answer is to rely on pending-state UI and accept low-consequence duplicate risk for v1.
- Should server slug generation replace client slug generation for all content creates in the same pass, or only for tweet/reply creation?
- Existing comments in
routes.blackbox.test.ts use comments to describe migration behavior. New tests should avoid unnecessary comments unless a route migration nuance is not obvious.
Threaded Tweet Replies
Summary
Add internal threaded replies to
wwwtweet pages by continuing to model tweets and replies as rows inpostswithtype = 'micro'.The recommended model is a self-referential thread shape on
posts: direct parent, root post, and depth. Top-level tweet creation stays restricted to creator/editor/admin roles. Replies use community mode: any authenticated user can reply through a dedicated reply endpoint. The server owns slug generation and all thread fields.Context / Current State
apps/www/src/routes/tweet/index.tsxrenders the tweet feed fromuseMicroPosts(5).apps/www/src/routes/tweet/$slug.tsxloads one micro post fromGET /content/posts/micro/{slug}.apps/www/src/routes/new/-TweetCapturePage.tsxcurrently creates tweet slugs on the client becausePOST /content/postrequiresslug.apps/vps/src/db/post.schema.tshaspostsTablewith content fields,type,musicEntityType, andmusicEntityId, but no thread fields.apps/vps/src/services/post.service.tsownsgetMicroPosts,getMicroPostBySlug,create,update, micro/editorial refinement, and MDX compilation.apps/vps/src/routes/content/content.routes.tsandcontent.handlers.tsexpose the current Hono OpenAPI content routes.titleorcontent.apps/vps/src/http/routes.blackbox.test.ts; route files should be tested through integration/blackbox behavior, not route unit tests.Goals
/tweetfeed by default.Non-Goals
Invariants
type = 'micro',parentPostId = null,rootPostId = null, anddepth = 0.type = 'micro',parentPostId = parent.id,rootPostId = parent.rootPostId ?? parent.id, anddepth = parent.depth + 1.type = 'micro'posts.slug,parentPostId,rootPostId,depth, orcreatorIds.parentPostId,rootPostId, anddepthare immutable after creation.Design Constraints
wwwcreate flow currently sends slugs, soPOST /content/postshould tolerate supplied slugs while allowing server-generated slugs for new clients.POST /content/post, so community permissions cannot accidentally create top-level tweets.PostServiceas the Service Module for post orchestration.PostService.Alternatives Considered
Option 1: Separate
post_repliesTablePros:
postsrows thread-aware.Cons:
Option 2:
parentPostIdOnlyPros:
Cons:
Option 3:
parentPostId,rootPostId, anddepthonpostsPros:
PostService.createReply.Cons:
rootPostIdanddepthare denormalized and must be derived by the server.Recommendation
Use Option 3.
Replies should be micro posts in
postswithparentPostId,rootPostId, anddepth. The dedicated reply endpoint derives those fields from the parent. The generic create endpoint can be improved to generate slugs when omitted, but community reply writes should not go through the generic create route.Proposed Design
Persistence Shape
If Drizzle self-reference typing needs it, import
AnyPgColumnfromdrizzle-orm/pg-coreand use the self-reference callback form shown above.Suggested database checks:
The service still owns the stronger invariant that a reply's
rootPostIdequalsparent.rootPostId ?? parent.id.Domain Model and Types
Expected Failures
Use existing errors where they fit. Add narrow errors only if current errors are not semantically precise enough.
Expected cases:
NotFoundError.type = 'micro':ValidationErroror a newParentPostNotReplyableError.UnauthorizedErrororNotFoundError, matching existing content visibility policy.ValidationErrorfromvalidatePostData.ConflictError, with retry around slug generation if desired.DatabaseError.Types, Interfaces, and APIs
PostServiceHTTP Routes
Top-level feed, unchanged URL with refined semantics:
Default response excludes replies:
Direct replies:
Full thread:
Create reply:
{ "content": "yeah, exactly", "title": null }Server-derived fields:
Generic post create should allow server-generated slugs:
wwwshould eventually stop sending slugs for top-level tweets, but the API can continue accepting supplied slugs because the current client already sends them.Seams, Boundaries, Adapters, and Implementations
content.routes.tsis the inbound HTTP schema/parser and OpenAPI boundary.content.handlers.tstranslates authenticated Hono context into service input.PostServiceowns thread write policy, community reply permission, parent lookup, slug generation, transaction sequencing, and typed failures.post.schema.tsowns persistence shape and protocol schemas exported through@gbfm/vps/schemas.post-thread.tscan be added as a small pure Domain Module only if the derivation or invariants start spreading. Otherwise, keep the pure derivation local topost.service.ts.www/src/lib/http.tsowns React Query hooks and protocol calls.www/src/routes/tweet/$slug.tsxowns page composition.TweetReplyComposerowns reply input state and mutation UX.TweetReplyListowns reply rendering and loading states.Call Stacks and Data Flow
Current / Old Flow
Top-level tweet creation today:
TweetCapturePage form state -> client normalizeSlugBase + Date.now suffix -> fetcher POST /content/post -> Hono route schema parses CreatePostRequest with required slug -> content.handlers.createPost -> PostService.create(postData, creatorIds) -> normalizePostData -> validatePostData -> db transaction insert posts + post_creators -> SelectPost response -> www navigates to /tweet/$slugTweet feed today:
useMicroPosts -> GET /content/posts/micro -> content.handlers.getMicroPosts -> PostService.getMicroPosts -> getAllEffect({ type: 'micro' }) -> db select all micro posts sorted desc -> buildPostWithPreloadedCreators + MDX compile -> TweetListCard renders every micro postProposed / New Flow
Community reply create:
TweetReplyComposer form state -> fetcher POST /content/posts/micro/{parentSlug}/replies -> createMicroPostReplySchema parses title/content only -> betterAuthMiddleware provides authenticated user -> content.handlers.createMicroPostReply maps context to CreateMicroPostReplyInput -> PostService.createMicroPostReply -> load parent by slug -> refine parent as replyable micro post -> derive rootPostId and depth -> generate server slug -> normalizePostData({ type: 'micro', title, content }) -> validatePostData -> db transaction insert posts + post_creators -> buildPostWithCreators -> SelectMdxCompiledMicroPost response -> www invalidates replies/thread queriesDirect replies read:
useMicroPostReplies(parentSlug) -> GET /content/posts/micro/{slug}/replies -> route query parser limit/offset -> PostService.getMicroPostReplies -> load parent by slug or fail NotFoundError -> db count where parentPostId = parent.id -> db select where parentPostId = parent.id order by createdAt asc -> preload creators -> MDX compile bounded concurrency -> PaginatedResponse<SelectMdxCompiledMicroPost>Full thread read:
useMicroPostThread(slug) -> GET /content/posts/micro/{slug}/thread -> PostService.getMicroPostThread -> load focus by slug or fail NotFoundError -> rootId = focus.rootPostId ?? focus.id -> load root by id -> db count/select descendants where rootPostId = rootId order by createdAt asc -> include root separately in response -> compile posts -> MicroPostThreadResponseTop-level feed read:
useMicroPosts -> GET /content/posts/micro -> PostService.getMicroPosts({ includeReplies: false }) -> db where type = 'micro' and parentPostId is null -> TweetListCard renders only top-level tweetsFailure Flow
Retry / Cancellation / Idempotency Flow
fetchercalls do not expose caller-owned cancellation intoPostService; this spec does not add a new cancellation seam.Idempotency-Keyheader and a persisted replay table scoped by actor plus parent plus key.post_create_requestsbefore shipping reply creation.Strict idempotency option:
Observability Flow
Use existing Effect spans/logs around
PostServicemethods. Add safe fields only:Do not log raw reply content, title, request body, session object, or unknown caught values.
Files to Add / Change / Delete
Change:
apps/vps/src/db/post.schema.tsOwns:
parentPostId,rootPostId, anddepthcolumns.Add:
apps/vps/drizzle/<next>_threaded_tweet_replies.sqlOwns:
depthdefault.Change:
apps/vps/src/services/post.service.tsOwns:
getMicroPoststo top-level tweets by default.Optional Add:
apps/vps/src/services/post-thread.tsOwns pure derivation if it would otherwise spread:
Skip this file if the invariant remains local to one function.
Change:
apps/vps/src/routes/content/content.routes.tsOwns:
getMicroPostRepliesroute.getMicroPostThreadroute.createMicroPostReplyroute.includeRepliesquery option if needed.Change:
apps/vps/src/routes/content/content.handlers.tsOwns:
userfrom auth middleware for reply creation.PostServicemethods.Change:
apps/vps/src/routes/content/content.index.tsOwns:
Change:
apps/www/src/lib/http.tsOwns:
useMicroPostReplies(parentSlug, limit).useMicroPostThread(slug, limit)if full thread is used by the page.useCreateMicroPostReply(parentSlug)mutation.Change:
apps/www/src/routes/tweet/$slug.tsxOwns:
Add:
apps/www/src/components/TweetReplyComposer.tsxOwns:
Add:
apps/www/src/components/TweetReplyList.tsxOwns:
Add or Change Tests
apps/vps/src/services/post.service.test.tsfor pure validation and thread field mutation rejection if kept as pure helpers.apps/vps/src/http/routes.blackbox.test.tsor a new blackbox route test file for HTTP reply behavior.apps/wwwcomponent tests only if the project already has a practical seam for them; otherwise rely on typecheck plus manual/E2E for the page flow.RGR TDD Test Plan
Slice 1: Persistence shape compiles
SelectMdxCompiledMicroPostcan carry thread fields.Slice 2: Feed excludes replies
GET /content/posts/microreturns only the top-level post.parentPostId IS NULLto the default micro query.includeRepliesonly if there is a real caller.Slice 3: Server derives reply thread fields
parentPostId,rootPostId, anddepthare derived, ignoring any client-supplied thread fields.createMicroPostReplyderivation and insert.deriveReplyThreadFieldsonly if useful.Slice 4: Community reply permission does not broaden top-level create
POST /content/posts/micro/{slug}/repliesbut cannot create a top-level tweet throughPOST /content/postif that route is supposed to stay creator/editor/admin.Slice 5: Reply creation rejects invalid parents
type = 'micro'refinement.NotFoundErrorandValidationErrorunless a precise new error is warranted.Slice 6: Direct replies read oldest-first
GET /content/posts/micro/{slug}/repliesreturns only direct replies ordered bycreatedAt ASC.Slice 7: Full thread read returns root, focus, and descendants
GET /content/posts/micro/{replySlug}/threadreturns root, focus reply, and descendants for the same root.rootPostId.Slice 8: Update cannot mutate thread fields
PATCH /content/posts/{slug}withparentPostId,rootPostId, ordepthand assert the persisted thread position remains unchanged or the request rejects.Slice 9: Server generates slugs
POST /content/postwithout slug creates a post with a stable non-empty slug; reply create also returns a generated slug and rejects body-levelslug.toSlugfor top-level creates andreplyor content-derived text for replies.Slice 10:
wwwreply UXTweetReplyComposer, hooks, and invalidation.Risks and Open Questions
404or403? The answer should follow the existing draft visibility model.content, or keep the existing micro-post rule oftitleorcontent?routes.blackbox.test.tsuse comments to describe migration behavior. New tests should avoid unnecessary comments unless a route migration nuance is not obvious.