Skip to main content

Overview

Your Bible uses Convex as its real-time database backend for storing user-generated content: collections, verses, notes, and AI-generated stories. The schema is defined in convex/schema.ts and provides automatic type generation and real-time synchronization.
Convex automatically generates TypeScript types from the schema, ensuring type safety across your application.

Schema Definition

The complete schema as defined in convex/schema.ts:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  collections: defineTable({
    name: v.string(),
    userId: v.string(),
  }),
  collectionVerses: defineTable({
    bibleId: v.string(),
    verseId: v.string(),
    chapterId: v.string(),
    verseText: v.string(),
    collectionId: v.id("collections"),
  }).index("by_collection_id", ["collectionId"]),
  notes: defineTable({
    chapterId: v.string(),
    content: v.string(),
    userId: v.string(),
  }).index("by_chapter_id", ["chapterId"]),
  stories: defineTable({
    title: v.string(),
    bibleId: v.string(),
    chapterId: v.string(),
    chapterReference: v.string(),
    userId: v.string(),
    perspective: v.string(),
    setting: v.string(),
    tone: v.string(),
    storyLength: v.string(),
    story: v.string(),
  }).index("by_user_id", ["userId"]),
});

Tables

collections

User-created collections for organizing favorite Bible verses.
Purpose: Store collection metadata (name, owner) Fields:
_id
Id<'collections'>
Auto-generated unique identifier for the collection.Type: Convex IDGenerated: Automatically by ConvexExample: k17abc123def456
name
string
required
Display name of the collection.Example: "Favorite Psalms", "Comfort Verses", "Daily Reading"Constraints:
  • Minimum 1 character
  • Maximum 100 characters (enforced by app, not schema)
Validation: See src/schemas/collection-schema.ts
userId
string
required
ID of the user who owns this collection.Source: Better Auth user IDPurpose: Ensures users only see their own collectionsExample: "user_abc123"
_creationTime
number
Timestamp when the collection was created.Type: Unix timestamp (milliseconds)Generated: Automatically by ConvexExample: 1709481234567
Indexes: None (small table, queries by userId are fast) Relationships:
  • One-to-many with collectionVerses (one collection has many verses)
Usage Example:
// Create a collection
const collectionId = await ctx.runMutation(api.collections.create, {
  name: "My Favorites",
  userId: currentUser.id,
});

// Query user's collections
const myCollections = await ctx.runQuery(api.collections.list, {
  userId: currentUser.id,
});

collectionVerses

Individual verses stored within collections. Links Bible verses to collections.
Purpose: Store verses added to collections with full metadata Fields:
_id
Id<'collectionVerses'>
Auto-generated unique identifier for the verse entry.Generated: Automatically by Convex
bibleId
string
required
ID of the Bible translation this verse belongs to.Source: API.Bible translation IDExample: "de4e12af7f28f599-02" (KJV), "592420522e16049f-01" (ASV)Purpose: Allows verses from different translations
verseId
string
required
Unique identifier for the specific verse.Source: API.Bible verse IDFormat: {bookId}.{chapterId}.{verseNumber}Example: "GEN.1.1", "PSA.23.1", "JHN.3.16"
chapterId
string
required
ID of the chapter containing this verse.Source: API.Bible chapter IDFormat: {bookId}.{chapterNumber}Example: "GEN.1", "PSA.23", "JHN.3"Purpose: Enable navigation to full chapter context
verseText
string
required
The actual text content of the verse.Source: API.Bible verse contentExample: "In the beginning God created the heaven and the earth."Purpose: Display verse without additional API calls
collectionId
Id<'collections'>
required
Reference to the parent collection.Type: Foreign key to collections tablePurpose: Links verse to its collection
Indexes:
by_collection_id
index
Index on collectionId field for efficient verse queries.Purpose: Quickly retrieve all verses in a collectionQuery pattern: ctx.db.query("collectionVerses").withIndex("by_collection_id", q => q.eq("collectionId", id))
Relationships:
  • Many-to-one with collections (many verses belong to one collection)
Usage Example:
// Add verse to collection
await ctx.runMutation(api.collectionVerses.add, {
  collectionId: collection._id,
  bibleId: "de4e12af7f28f599-02",
  verseId: "JHN.3.16",
  chapterId: "JHN.3",
  verseText: "For God so loved the world...",
});

// Get all verses in a collection
const verses = await ctx.runQuery(api.collectionVerses.list, {
  collectionId: collection._id,
});

notes

User’s personal notes for Bible chapters with rich text formatting.
Purpose: Store chapter-specific notes with rich text content Fields:
_id
Id<'notes'>
Auto-generated unique identifier for the note.
chapterId
string
required
ID of the Bible chapter this note is about.Source: API.Bible chapter IDFormat: {bookId}.{chapterNumber}Example: "GEN.1", "MAT.5", "REV.22"Uniqueness: One note per chapter per user (enforced by app logic)
content
string
required
Rich text content of the note.Format: JSON string from Plate.js editorExample:
[
  {"type": "h3", "children": [{"text": "My Thoughts"}]},
  {"type": "p", "children": [{"text": "This chapter shows..."}]}
]
Editor: Plate.js rich text editorFeatures: Bold, italic, headings, lists, quotes, etc.
userId
string
required
ID of the user who owns this note.Purpose: Ensures notes are private to each user
Indexes:
by_chapter_id
index
Index on chapterId field for efficient note retrieval.Purpose: Quickly find note for current chapterQuery pattern: ctx.db.query("notes").withIndex("by_chapter_id", q => q.eq("chapterId", id))
Relationships:
  • Independent table (references Bible chapters via ID string)
Usage Example:
// Create or update note
await ctx.runMutation(api.notes.upsert, {
  chapterId: "GEN.1",
  content: JSON.stringify(editorContent),
  userId: currentUser.id,
});

// Get note for chapter
const note = await ctx.runQuery(api.notes.get, {
  chapterId: "GEN.1",
  userId: currentUser.id,
});

stories

AI-generated stories based on Bible passages with customizable parameters.
Purpose: Store AI-generated stories with generation parameters and metadata Fields:
_id
Id<'stories'>
Auto-generated unique identifier for the story.
title
string
required
User-provided title for the story.Example: "Creation from Adam's Perspective", "A Journey Through the Red Sea"Constraints: Minimum 1 character, maximum 200 characters
bibleId
string
required
ID of the Bible translation used as source.Source: API.Bible translation IDPurpose: Link story to original biblical text
chapterId
string
required
ID of the chapter the story is based on.Format: {bookId}.{chapterNumber}Example: "GEN.1", "EXO.14"
chapterReference
string
required
Human-readable chapter reference.Example: "Genesis 1", "Exodus 14", "Psalm 23"Purpose: Display-friendly reference
userId
string
required
ID of the user who generated this story.Purpose: Show user’s own stories, enforce rate limits
perspective
string
required
Narrative perspective for the story.Examples: "first-person", "third-person observer", "from Mary's perspective"Usage: Passed to AI for story generation
setting
string
required
Setting or context for the story.Examples: "ancient Israel", "modern day", "fantasy world"Usage: Influences AI story generation
tone
string
required
Emotional tone of the story.Examples: "contemplative", "adventurous", "solemn", "joyful"Usage: Guides AI’s writing style
storyLength
string
required
Desired length of the generated story.Options: "short", "medium", "long"Approximate lengths:
  • Short: 200-400 words
  • Medium: 400-800 words
  • Long: 800-1500 words
story
string
required
The AI-generated story content.Format: Plain text or markdownSource: Google Gemini AI responseLength: Varies based on storyLength parameter
Indexes:
by_user_id
index
Index on userId field for efficient user story queries.Purpose: List all stories created by a userQuery pattern: ctx.db.query("stories").withIndex("by_user_id", q => q.eq("userId", id))
Relationships:
  • Independent table (references Bible chapters and users via ID strings)
Usage Example:
// Create story
const storyId = await ctx.runMutation(api.stories.create, {
  title: "Creation Story",
  bibleId: "de4e12af7f28f599-02",
  chapterId: "GEN.1",
  chapterReference: "Genesis 1",
  userId: currentUser.id,
  perspective: "first-person from God's perspective",
  setting: "the beginning of time",
  tone: "majestic and powerful",
  storyLength: "medium",
  story: generatedStoryText,
});

// Get user's stories
const myStories = await ctx.runQuery(api.stories.list, {
  userId: currentUser.id,
});

Relationships Diagram

Indexes and Performance

Why Indexes Matter

Indexes dramatically improve query performance for common access patterns:

by_collection_id

Table: collectionVersesPurpose: Efficiently query all verses in a collectionWithout index: O(n) - scans all versesWith index: O(log n) - fast lookup

by_chapter_id

Table: notesPurpose: Quickly find note for current chapterBenefit: Instant note loading when viewing chapters

by_user_id

Table: storiesPurpose: List all stories for a userBenefit: Fast story dashboard loading

Query Patterns

// Efficient: Uses by_collection_id index
const verses = await ctx.db
  .query("collectionVerses")
  .withIndex("by_collection_id", (q) =>
    q.eq("collectionId", collectionId)
  )
  .collect();

Schema Migrations

Convex handles schema migrations automatically. Changes are applied when you save the schema file.

Adding a New Field

1

Update Schema

Edit convex/schema.ts to add the new field:
collections: defineTable({
  name: v.string(),
  userId: v.string(),
  description: v.optional(v.string()), // New field
}),
2

Save and Deploy

Convex automatically detects the change and updates the schema.
# If running npx convex dev, it auto-deploys
# Otherwise:
npx convex deploy
3

Update Types

Convex regenerates TypeScript types automatically. Import updated types:
import { Doc } from "convex/_generated/dataModel";

type Collection = Doc<"collections">;
// Now includes optional 'description' field
4

Handle Existing Data

Existing documents won’t have the new field. Use optional fields or provide defaults:
const description = collection.description ?? "No description";

Adding an Index

// Add index to schema
collections: defineTable({
  name: v.string(),
  userId: v.string(),
})
.index("by_user_id", ["userId"]), // New index

// Use in queries
const userCollections = await ctx.db
  .query("collections")
  .withIndex("by_user_id", (q) => q.eq("userId", userId))
  .collect();

Removing a Field

Be careful when removing fields. Ensure no code references the field before removal.
1

Update All Code

Remove all references to the field from your codebase.
2

Update Schema

Remove the field from the schema definition.
3

Deploy Changes

Convex will ignore the field in existing documents, but won’t delete the data.
4

Clean Up Data (Optional)

Write a migration script to remove the field from existing documents if needed.

Best Practices

Add indexes for fields you frequently query:
// Good: Index on commonly queried field
.index("by_user_id", ["userId"])

// Consider: Compound indexes for complex queries
.index("by_user_and_date", ["userId", "_creationTime"])
  • Store large text in separate documents or external storage
  • Current design is good: story content is reasonable size
  • Avoid storing binary data directly in Convex
// Good: Optional field for future features
tags: v.optional(v.array(v.string())),

// Can add later without breaking existing documents
// Validate before inserting
export const create = mutation({
  args: {
    name: v.string(),
    userId: v.string(),
  },
  handler: async (ctx, args) => {
    if (args.name.length < 1 || args.name.length > 100) {
      throw new Error("Invalid name length");
    }
    return await ctx.db.insert("collections", args);
  },
});
  • API.Bible IDs: Store as-is (e.g., "GEN.1.1")
  • User IDs: Store from Better Auth (e.g., "user_abc123")
  • Convex IDs: Use generated IDs (e.g., Id<"collections">)

Data Access Patterns

Reading Data

// Queries are reactive - updates automatically
const collections = useQuery(api.collections.list, {
  userId: user.id,
});

// Re-renders when data changes

Pagination

// Paginate large datasets
export const listPaginated = query({
  args: {
    userId: v.string(),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("stories")
      .withIndex("by_user_id", (q) => q.eq("userId", args.userId))
      .order("desc")
      .paginate(args.paginationOpts);
  },
});

Security Considerations

Always validate user access in Convex functions. The schema doesn’t enforce permissions.

Access Control Example

// Validate user can access collection
export const get = query({
  args: { id: v.id("collections"), userId: v.string() },
  handler: async (ctx, args) => {
    const collection = await ctx.db.get(args.id);
    
    if (!collection) {
      throw new Error("Collection not found");
    }
    
    if (collection.userId !== args.userId) {
      throw new Error("Unauthorized");
    }
    
    return collection;
  },
});

Convex Documentation

Official Convex documentation and guides

Project Structure

How Convex integrates with the project

Integrations

Convex integration details

API Reference

API documentation for Convex functions