Threads

The BubbleThread shape, subthreads, and the store operations that create and inspect threads.

Conceptsdocs/concepts/20-threads.mdx
On this page

A thread is the unit of conversation in Bubble. Concretely, a thread is two things glued together by a shared threadId:

  1. An ordered, append-only stream of events.
  2. A small piece of metadata — when the thread was created, what its head sequence number is, and (optionally) its parent.

This page covers the metadata side and the store operations that manage it. Event append and subscribe are described in Events; participants and grants on a thread are described in Principals and Authorization.

The BubbleThread Shape

A thread snapshot is a plain object. Every field except parentThreadId is always present:

ts
interface BubbleThread {
  threadId: string;
  parentThreadId?: string;
  title?: string;
  headSeq: number;
  createdAt: string;
  participants: Participant[];
}
FieldMeaning
threadIdStable, opaque identifier. Chosen by the caller when the thread is created.
parentThreadIdSet only on subthreads (see below). Omitted for top-level threads.
titleOptional human-readable label. Omitted when no title is set. Mutable via Renaming a thread.
headSeqLatest committed seq in the thread; 0 when no events have been appended yet.
createdAtRFC 3339 timestamp recorded when the thread was created.
participantsCurrent participant rows for the thread, derived from grants. Covered in Authorization.

headSeq and Lifecycle

A freshly created thread has headSeq: 0. Each successful append bumps headSeq by exactly one — sequence numbers are assigned by the store and start at 1. There is no upper bound and no retention policy: the stream is designed to grow indefinitely.

The headSeq field is the canonical "where is this thread up to?" indicator. A subscriber can record the latest seq it has processed and compare against headSeq later to know how much backlog it would need to replay; an HTTP poller can pass the value to getEventPage to advance through history.

Top-Level Threads vs. Subthreads

A thread can optionally be scoped under a parent thread. The child still has its own event stream, its own sequence numbers, its own creation timestamp, its own participants, and its own grants — parentThreadId is metadata only:

ts
const root = await store.createThread('thread-root');
// root.parentThreadId === undefined

const child = await store.createThread('thread-child', { parentThreadId: 'thread-root' });
// child.parentThreadId === 'thread-root'
// child.headSeq === 0  (events on root are not visible on child)

Two consequences worth internalising up front:

  • Events appended to the parent are not copied or mirrored to the child, and vice versa. Every thread has exactly one stream.
  • Grants on the parent are not copied to the child. They may inherit selected read/manage capabilities to the child via the policy, but no child grant rows are materialized. The full inheritance rules live in Authorization.

Subthreads are most useful when you want a logically nested conversation (for example, a side discussion spawned off the main thread) but still need to reason about it as an independent stream with its own membership.

Creating and Inspecting Threads

The store contract exposes four operations that deal with thread metadata. They all live on BubbleStore and are async:

ts
interface BubbleStore {
  createThread(threadId: string, options?: { parentThreadId?: string }): Promise<BubbleThread>;
  getThread(threadId: string): Promise<BubbleThread>;
  getHeadSeq(threadId: string): Promise<number>;
  getCreatedAt(threadId: string): Promise<string>;
  // ... event, principal, grant, and participant operations covered in later docs
}

createThread

Explicitly creates an empty thread stream and returns its snapshot.

ts
const thread = await store.createThread('thread-42');
// thread.headSeq === 0
// thread.participants === []

The operation is idempotent when the existing parent relationship matches the request. Calling createThread('thread-42') twice returns the same snapshot the second time. Calling it with a different parentThreadId than the existing row throws — implementations reject:

  • turning an existing top-level thread into a subthread,
  • moving a subthread to a different parent,
  • creating a subthread whose parentThreadId does not exist,
  • creating a subthread whose parentThreadId equals its own threadId.

getThread

Returns the current snapshot, including parentThreadId when the thread is a subthread and the current participant set:

ts
const thread = await store.getThread('thread-42');
// thread.headSeq            => latest committed seq, or 0
// thread.participants       => current participant rows
// thread.parentThreadId     => set when this is a subthread

This is the same shape that the HTTP GET /v1/threads/:threadId route returns.

getHeadSeq and getCreatedAt

Narrow accessors for the two scalar fields. They exist as separate operations so that callers (for example, a subscription opener that only needs the head seq to pin a catch-up boundary) do not have to materialise the full snapshot:

ts
const head = await store.getHeadSeq('thread-42'); // number
const ts = await store.getCreatedAt('thread-42'); // RFC 3339 string

Renaming a Thread

Threads carry an optional human-readable title. Update it via PATCH /v1/threads/:threadId with body { title: string | null }: pass a string to set or replace the title, null to clear it, or omit the key to leave it untouched.

ts
await store.updateThread('thread-42', { title: 'Q3 planning' });
await store.updateThread('thread-42', { title: null }); // clear

The route requires the thread.manage capability on the thread target, which is bundled into the owner and thread_manager roles. Plain members and readers cannot rename. When the title actually changes, the server appends a thread_title_updated event to the thread's stream so live subscribers and history readers observe the rename:

ts
{ type: 'thread_title_updated', byPrincipalId: 'bob', title: 'Q3 planning' }

The same event is emitted with title: null when the title is cleared.

Storage Notes

The store contract assigns sequence numbers, persists events, and fans them out to live subscribers. The default InMemoryBubbleStore keeps one growing array per thread and notifies subscribers synchronously after each append; production deployments swap in a durable backend that preserves the same ordering and idempotency guarantees.

The stream has no TTL and no retention policy — once an event is appended it stays at its assigned seq forever, and a thread's headSeq only grows. Backends that need pruning have to implement it as an explicit operation outside this contract.