Bootstrap Endpoints

MongoDB

One Express route, a handful of concurrent Mongoose queries, and a single JSON response that flux.bootstrap() reads in one network call per scope — with the ObjectId-to-string conversion every Mongo-backed channel needs.

Prerequisites
  • A running MongoDB instance and Mongoose (or the native driver — examples here use Mongoose) wired into a Node backend
  • A replica set (or Atlas, which is one by default) if you plan to use change streams for realtime — see Step 5
  • Familiarity with api/bootstrap on the client side
1

A basic bootstrap route

The response shape is fixed: { ok, data, assets }. Keys inside data must exactly match the map object in your client-side flux.bootstrap() call — and every document needs its _id normalized before it goes out, covered next.
server/routes/bootstrap.ts
1// server/routes/bootstrap.ts
2import express, { Router, Request, Response } from 'express'
3import Service from '../models/Service'
4import TeamMember from '../models/TeamMember'
5
6const router: Router = express.Router()
7
8router.get('/public', async (req: Request, res: Response) => {
9 try {
10 const services = await Service.find({}).sort({ sortOrder: 1 }).lean()
11 const team = await TeamMember.find({}).sort({ name: 1 }).lean()
12
13 res.json({
14 ok: true,
15 data: {
16 services: services.map(normalizeId),
17 team: team.map(normalizeId),
18 },
19 assets: [],
20 })
21 } catch (err: any) {
22 res.status(500).json({ ok: false, error: err.message })
23 }
24})
25
26function normalizeId(doc: any) {
27 const { _id, __v, ...rest } = doc
28 return { id: _id.toString(), ...rest }
29}
30
31export default router
2

ObjectId → string, every time

This is the one conversion every Mongo-backed Flux integration needs that a Postgres one doesn't. Skip it and dedupe keys, LRU per-item keys, and queue matching will all silently misbehave once real traffic hits the channel.
javascript
1// Every Mongo document's identity lives in _id, an ObjectId — not a
2// plain string. FluxNormalizedEvent.id (Section 4.1) is typed
3// strictly as a string, and per-item LRU keys, dedupeKeys, and
4// queue-entry ids all assume string identifiers throughout the
5// engine. An unconverted ObjectId serializes to JSON as
6// { "$oid": "..." } via some drivers or as an opaque object via
7// others — inconsistent, and never a valid IDB or dedupe key.
8
9// Always normalize before the response leaves this route:
10
11function normalizeId(doc) {
12 const { _id, __v, ...rest } = doc
13 return { id: _id.toString(), ...rest }
14}
15
16// Apply this to every document in every query result, not just the
17// ones your first channel happens to need — a channel added later
18// without this conversion will look correct in testing (small ids
19// render fine as objects) and then silently fail dedupe/eviction
20// once real traffic hits it.
FluxNormalizedEvent.id is strictly a string

Section 4.1's FluxNormalizedEvent interface types id as string — apply the same normalizeId() helper on your realtime adapter's change events (Step 5) as you do here on the bootstrap response, so an item's id is identical whether it arrived via bootstrap or realtime.

3

One network call, not one query

Bundle as many collections as you have registered channels into the same response, run the queries concurrently, and normalize every result set the same way.
javascript
1// Flux's contract is one network call, NOT one query. Bundle as
2// many Mongo queries as you have registered channels, run them
3// concurrently with Promise.all, and normalize every result set:
4
5const [services, team, pricing] = await Promise.all([
6 Service.find({}).sort({ sortOrder: 1 }).lean(),
7 TeamMember.find({}).sort({ name: 1 }).lean(),
8 PricingTier.find({}).sort({ price: 1 }).lean(),
9])
10
11res.json({
12 ok: true,
13 data: {
14 services: services.map(normalizeId),
15 team: team.map(normalizeId),
16 pricing: pricing.map(normalizeId),
17 },
18 assets: [],
19})
20
21// The client still only ever sees ONE request in the Network tab,
22// regardless of how many collections back it.
4

A 'user'-scope endpoint

A 'user'-scope bootstrap call is a separate endpoint from the 'global' one (Section 4.13). Filter every query by a plain string userId field resolved from your own auth middleware — never a value read out of the request.
javascript
1// server/routes/bootstrap.js (excerpt) — a 'user'-scope endpoint
2router.get('/user', requireAuth, async (req, res) => {
3 const userId = req.user.id // set by your own auth middleware, a string
4
5 try {
6 const dashboard = await DashboardWidget.find({ userId }).lean()
7 const billing = await BillingSummary.find({ userId }).lean()
8
9 res.json({
10 ok: true,
11 data: {
12 dashboard: dashboard.map(normalizeId),
13 billing: billing.map(normalizeId),
14 },
15 assets: [],
16 })
17 } catch (err) {
18 res.status(500).json({ ok: false, error: err.message })
19 }
20})
21
22// The client-side call this endpoint answers:
23//
24// flux.bootstrap({
25// endpoint: '/api/bootstrap/user',
26// scope: 'user',
27// userId: currentUser.id,
28// map: { dashboard: dashboardAdapter, billing: billingAdapter },
29// })
30//
31// Filter every query by { userId } sourced from YOUR OWN auth
32// middleware — never trust a userId passed in the request body or
33// query string. Flux's userId field on BootstrapConfig is for
34// client-side IDB namespacing only; it carries no authority
35// server-side. Store userId as a plain string field on each
36// document (not a Mongo ObjectId ref) if it needs to be compared
37// directly against req.user.id without a cast on every query.
Store userId as a string field, not a ref

A Mongoose ref resolves to an ObjectId unless explicitly .populate()'d and stringified. Storing userId as a plain string on each document keeps the query filter and any later equality checks against req.user.id consistent everywhere.

5

Delta catch-up and live updates with change streams

For delta catch-up on reconnect, query the single-state FluxChangelog collection via queryChangesSince() so offline deletions and sparse field updates are captured properly. For live forward events, MongoDB change streams stream sparse field updates directly to connected sockets:
javascript
1// Server-side contract for SyncAnchorMap (Section 4.1) applied to
2// Mongo: for each channel key, query the single-state FluxChangelog
3// collection via queryChangesSince() so offline deletions and sparse
4// field updates are captured accurately.
5
6// 1. One-shot delta catch-up query on reconnect:
7const deltaRows = await queryChangesSince('blog_posts', anchorMs)
8
9// 2. Live forward events after catch-up completes — MongoDB change
10// streams log updates into FluxChangelog and broadcast sparse payloads:
11const changeStream = BlogPost.watch([], { fullDocument: 'updateLookup' })
12
13changeStream.on('change', async (change) => {
14 const { operationType, documentKey, fullDocument, updateDescription } = change
15 const opMap = { insert: 'CREATE', update: 'UPDATE', replace: 'UPDATE', delete: 'DELETE' }
16 const op = opMap[operationType]
17 if (!op) return // ignore drop, rename, invalidate, etc.
18
19 const rowId = documentKey._id.toString() // ObjectId -> string
20
21 let updatedFields = null
22 if (operationType === 'update' && updateDescription?.updatedFields) {
23 updatedFields = { ...updateDescription.updatedFields }
24 delete updatedFields.updatedAt
25 delete updatedFields.__v
26 }
27
28 const rowData = fullDocument
29 ? { ...fullDocument.toObject?.() ?? fullDocument, id: rowId }
30 : { id: rowId }
31
32 // Persist to single-state changelog for future catch-up queries
33 await logChange('blog_posts', rowId, op, rowData, updatedFields)
34
35 const wireData = op === 'UPDATE' && updatedFields && Object.keys(updatedFields).length > 0
36 ? { id: rowId, ...updatedFields }
37 : rowData
38
39 socket.emit('blog_posts:UPDATE', {
40 entity: 'blog_posts',
41 id: rowId,
42 op,
43 data: wireData,
44 timestamp: Date.now(),
45 source: 'mongodb-change-stream',
46 })
47})
48
49// Requires a replica set (or Atlas, which is one by default) — change
50// streams are unavailable on a standalone mongod instance.
6

Backing a handshake replay strategy

If any of your queue configs use replayStrategy: 'handshake', you need a separate revalidation endpoint — this is what revalidateFn calls, once per endpoint group, with the full manifest of queued entries.
javascript
1// server/routes/items-revalidate.js — backing a handshake QueueConfig
2// This is the Pass 1 bulk probe endpoint revalidateFn calls, NOT the
3// bootstrap endpoint. Section 4.6: called ONCE per endpoint group
4// with ALL queued entries' ids + clock-skew-corrected timestamps.
5router.post('/items/revalidate', async (req, res) => {
6 const { manifest } = req.body // [{ id, timestamp }, ...]
7
8 const docs = await Item.find({
9 _id: { $in: manifest.map((m) => m.id) }, // Mongoose casts string -> ObjectId here
10 }).lean()
11
12 const conflictedIds = []
13 const serverStates = {}
14
15 for (const entry of manifest) {
16 const serverDoc = docs.find((d) => d._id.toString() === entry.id)
17 if (serverDoc && serverDoc.updatedAt.getTime() > entry.timestamp) {
18 conflictedIds.push(entry.id)
19 serverStates[entry.id] = normalizeId(serverDoc)
20 }
21 }
22
23 res.json({ conflictedIds, serverStates })
24})
25
26// The matching client-side queueConfig:
27//
28// queueConfig: {
29// storeName: 'items_mutation_queue',
30// replayEndpoint: '/api/items',
31// replayStrategy: 'handshake',
32// revalidateFn: async (entries, manifest) => {
33// const res = await fetch('/api/items/revalidate', {
34// method: 'POST',
35// body: JSON.stringify({ manifest }),
36// })
37// return res.json()
38// },
39// }
7

What not to do

Three mistakes show up repeatedly in early Mongo integrations — all three are specific to how MongoDB differs from Postgres, not general Flux mistakes.
javascript
1// Don't do this — three separate mistakes specific to Mongo-backed
2// bootstrap endpoints:
3
4// ❌ Shipping raw ObjectId _id fields straight from .find() without
5// normalizing to a string 'id'. Works in a quick manual test,
6// breaks dedupe/LRU keys/queue matching once real data flows.
7
8// ❌ Storing a 'user'-scope document's owner as a Mongo ref
9// (ObjectId) instead of a plain string userId, then comparing it
10// against req.user.id with a loose === — Mongoose won't
11// auto-cast a raw ObjectId to string in a JS equality check the
12// way it does inside a query filter.
13
14// ❌ Reaching for change streams on a standalone mongod instance —
15// they require a replica set. If you're not on Atlas and see
16// "The $changeStream stage is only supported on replica sets",
17// that's why — fall back to the polling adapter (createPollingAdapter,
18// ships in @tsworldtech/flux core) until you provision one.

For Postgres, see bootstrap/postgres. For a framework-agnostic Node reference (including assets[] chunk warming for Next.js), see bootstrap/node. For any other backend language, see bootstrap/rest. For the client-side call this endpoint answers, see api/bootstrap.