← migration

mongodb → briven

port a mongodb project onto briven. follow the ten-step playbook on /migration — this page covers only the mongodb-specific parts.

mongodb → postgres is the second-hardest migration shape (only firebase is harder). the work isn't the dump/restore — it's deciding which embedded documents stay embedded as jsonb and which get flattened into separate tables. plan for the schema port to take 60–80% of the project time, and run a 2+ week parallel-run window because shape mismatches surface late.

when to keep documents embedded vs flatten

briven is postgres-first. jsonb<T>()is a first-class column type, so you don't have to flatten every embedded doc. the decision matrix:

  • embedded: the sub-doc is always read with the parent, never queried independently, never updated in isolation. example: a profile object on a user doc with name/avatar/bio fields.
  • flatten to a row: the sub-doc is independently queryable (find by an inner field), independently updated (atomic write to one nested item), or unbounded in count (notifications, audit entries). these become real tables with a foreign key.
  • flatten to a row, eagerly fetched: when you almost always want the parent + child together, the rows-with-fk shape still wins. briven query functions return whatever shape you build — fetch both, return them as one object.

if you can't decide, flatten. it's easier to inline two relational reads than to undo a too-large jsonb column later.

schema port — collection → table

// before — mongoose-ish shape (representative)
const PostSchema = new Schema({
  _id: ObjectId,
  authorId: ObjectId,
  title: { type: String, required: true },
  body: { type: String, required: true },
  published: { type: Boolean, default: false },
  views: { type: Number, default: 0 },
  tags: [String],                                // unbounded → think hard
  author: {                                      // embedded, always read together → keep
    name: String,
    avatarUrl: String,
  },
  createdAt: { type: Date, default: Date.now },
});

// after — briven/schema.ts
import { boolean, bigint, jsonb, schema, table, text, timestamp } from '@briven/cli/schema';

export default schema({
  posts: table({
    columns: {
      id:        text().primaryKey(),
      authorId:  text().notNull().references('users', 'id'),
      title:     text().notNull(),
      body:      text().notNull(),
      published: boolean().notNull().default('false'),
      views:     bigint().notNull().default('0'),
      // tags: unbounded array — flatten to a separate post_tags table.
      // embedded author profile stays as jsonb (typed, read-with-parent).
      author:    jsonb<{ name: string; avatarUrl: string }>().notNull(),
      createdAt: timestamp().notNull().default('now()'),
    },
  }),
  post_tags: table({
    columns: {
      id:     text().primaryKey(),
      postId: text().notNull().references('posts', 'id'),
      tag:    text().notNull(),
    },
    indexes: [{ columns: ['postId'], unique: false }, { columns: ['tag'], unique: false }],
  }),
});
  • ObjectIdtext(). mint new ids via ulid('prefix') in function code (sorts lexicographically by creation, which most callers want). if you need to preserve existing object ids for backwards compatibility with clients, keep them as text — the dump step below stringifies ObjectIds.
  • Numberbigint(). mongo's default 64-bit double works for counters; briven's bigint avoids overflow on long-running tallies.
  • Datetimestamp(). unix-ms Date values round-trip via --date_oid_dates in mongoexport.
  • [String] / [ObjectId] arrays — flatten to a join table. postgres arrays exist but cost you index-able query support; the join table is cheaper and more obvious.

data export from mongodb

mongo doesn't natively output rows — you go through json. the canonical sequence:

# 1. export each collection to JSON (one doc per line)
mongoexport \
  --uri="$MONGO_URI" \
  --collection=posts \
  --out=posts.json \
  --jsonArray

# 2. transform the JSON into postgres COPY-compatible CSV.
# this step is custom per collection — a small node script that:
#   - flattens embedded docs into jsonb columns
#   - emits join-table rows for unbounded arrays
#   - stringifies ObjectIds
#   - converts mongo's $date / $oid extended-json into postgres values
# see: docs/migration/scripts/mongo-to-csv.ts (template)

# 3. load with COPY against the briven project dsn
psql "$BRIVEN_PROJECT_DSN" -c "\copy posts FROM 'posts.csv' CSV HEADER"
psql "$BRIVEN_PROJECT_DSN" -c "\copy post_tags FROM 'post_tags.csv' CSV HEADER"

the transform script is the migration. budget a day per non-trivial collection — the rules for which fields fold into jsonb vs flatten are decisions you make once and codify in the script.

functions port — find/aggregate → ctx.db chains

// before — mongoose
const recent = await Post.find({ authorId, published: true })
  .select({ title: 1, body: 1, createdAt: 1 })
  .sort({ createdAt: -1 })
  .limit(50);

// after — briven/functions/recentPostsByAuthor.ts
import { brivenError, query, type Ctx } from '@briven/cli/server';
interface Args { authorId: string; limit?: number }
export default query(async (ctx: Ctx, args: Args) => {
  if (!args.authorId)
    throw new brivenError('validation_failed', 'authorId required', { status: 400 });
  return ctx
    .db('posts')
    .select(['id', 'title', 'body', 'createdAt'])
    .where({ authorId: args.authorId, published: true })
    .orderBy('createdAt', 'desc')
    .limit(Math.min(args.limit ?? 50, 200));
});
  • aggregate pipelines → either chain in the query-builder (group-by / having lives there) or drop into raw sql via ctx.db.raw(...). complex pipelines often read better as a sql CTE; the migration is the right time to rewrite.
  • $lookup joins → either run two queries inside the same function (single transaction; identical consistency) or use a raw join. mongo apps that relied on $lookup heavily tend to over-flatten in mongo — the briven port is a chance to fix the model.
  • upserts → no first-class helper today; raw sql INSERT ... ON CONFLICT. an upsert helper on ctx.db is queued.
  • transactions → every mutation()body is a single transaction by default. mongo's session.withTransaction blocks map 1:1 — drop the session arg.

auth port

mongo apps usually run auth on top of the same database (nextauth's mongo adapter, lucia's mongo adapter, or a hand-rolled session collection). the schema maps to better-auth's shape one collection at a time — same drill as the drizzle/prisma ports.

reactivity (new capability)

mongo apps use change streams for reactive reads. on briven, wrap a read as query() and the same call from @briven/react's useQuery auto-refetches on table-level NOTIFYs — no change-stream subscription, no oplog reader, no per-document filter handling.

parallel-run window — non-negotiable

mongo → relational migrations almost always surface a shape mismatch in week 2 (the field you thought was optional is required for some old document set, or vice versa). plan a minimum 14-day parallel run before traffic flips. budget time to fix the transform script and re-import; the briven schema rarely needs to change.