Skip to content

Database

tripplan.ing uses a single SQLite database for all data, accessed through Drizzle ORM. In production this is Cloudflare D1; locally it's better-sqlite3. The schema contains 30+ tables organized into two families: platform tables (global) and event tables (scoped by event_id).

Schema families

Platform tables

These store global platform state — operators, events, organizations, and audit logs. They have no event_id column.

TablePurposeKey fields
platform_usersOperator accountsemail, status
platform_rolesOperator role assignmentsuserId, role (super_admin/admin)
platform_eventsEvent metadata and lifecycleslug, status, adminEmail, primaryDomain
platform_event_domainsHostname-to-event mappingeventId, hostname, isPrimary
platform_organizationsOrganization groupingname, slug
platform_org_membersOrg membershiporgId, userId
platform_audit_logsOperator action trackingactorEmail, action, targetType

Event tables

All event-scoped tables include event_id as a required column. Every query must filter by it.

Registration & payments:

TablePurpose
rsvpsParty/group registrations (contact email, status, dietary, custom fields)
attendeesIndividual people within an RSVP (name, tier, optional email, add-on selections)
paymentsPayment records (Stripe/PayPal/manual, amount, status, refund tracking)
payment_itemsLine items within a payment (attendee + expense mapping)

Schedule:

TablePurpose
schedule_daysDay containers (date, label, sort order)
schedule_itemsAgenda items (time, title, location, category, rich content)
schedule_item_permissionsPer-item access control (email or group)

Content & media:

TablePurpose
content_sectionsHomepage blocks (type, body, images, links, visibility)
content_section_permissionsPer-section access control
photosGallery entries (R2 key, filename, caption, uploader)
documentsFile entries (R2 key, title, auth requirement, sort order)
document_permissionsPer-document access control (email or group)
announcementsEmail blasts and site banners (delivery type, recipients, status)

Engagement:

TablePurpose
pollsPoll definitions (type: availability/single/multiple, status, anonymous)
poll_optionsOptions within a poll
poll_votesIndividual votes (voter email, option, optional "other" text)

Access & people:

TablePurpose
access_requestsSign-up requests from non-allowed users (status: pending/approved/denied)
groupsNamed groups for organizing people (color, description)
group_membersEmail-to-group associations

Configuration:

TablePurpose
settingsEvent config overrides (single row per event, all fields nullable)
pricing_tiersAttendee types with multipliers (Adult 1.0, Child 0.5, Infant 0)
add_onsBillable items (per-attendee or flat, optional, with tier-specific pricing)
custom_fieldsDynamic RSVP form fields (text, select, checkbox, number)
custom_field_optionsOptions for select-type custom fields

Drizzle ORM patterns

Table definitions

Tables are defined using Drizzle's SQLite helpers:

typescript
import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core';

export const rsvps = sqliteTable(
  'rsvps',
  {
    id: text('id').primaryKey(),
    eventId: text('event_id').notNull(),
    contactEmail: text('contact_email').notNull(),
    contactName: text('contact_name').notNull(),
    status: text('status', { enum: ['confirmed', 'tentative', 'declined'] })
      .notNull()
      .default('confirmed'),
    notes: text('notes'),
    customFields: text('custom_fields'),  // JSON string
    createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
    deletedAt: text('deleted_at')  // Soft-delete support
  },
  (table) => [
    index('rsvps_event_id_idx').on(table.eventId),
    index('rsvps_contact_email_idx').on(table.contactEmail),
    index('rsvps_event_email_idx').on(table.eventId, table.contactEmail)
  ]
);

Key conventions:

  • IDs: Always text('id').primaryKey() — generated with crypto.randomUUID()
  • Timestamps: Stored as ISO text strings, not integers
  • Enums: Defined inline with text('col', { enum: [...] })
  • JSON fields: Stored as text, parsed/serialized in application code
  • Soft deletes: deletedAt column (used by RSVPs)
  • Indexes: Defined in the third argument as an array

Queries

Always scope by event_id for event tables:

typescript
import { eq, and } from 'drizzle-orm';
import { getRuntimeEnv } from '$lib/server/runtime/index.js';
import { rsvps } from '$lib/server/db/schema';

const env = await getRuntimeEnv(platform);
const confirmed = await env.db
  .select()
  .from(rsvps)
  .where(and(
    eq(rsvps.eventId, eventId),
    eq(rsvps.status, 'confirmed')
  ));

Inserts

typescript
await env.db.insert(rsvps).values({
  id: crypto.randomUUID(),
  eventId,
  contactEmail: email,
  contactName: name,
  status: 'confirmed',
  createdAt: new Date().toISOString()
});

Updates

typescript
await env.db
  .update(rsvps)
  .set({ status: 'declined', updatedAt: new Date().toISOString() })
  .where(and(eq(rsvps.eventId, eventId), eq(rsvps.id, rsvpId)));

Joins

typescript
const rsvpsWithAttendees = await env.db
  .select()
  .from(rsvps)
  .leftJoin(attendees, eq(rsvps.id, attendees.rsvpId))
  .where(eq(rsvps.eventId, eventId));

Migration workflow

Drizzle Kit generates migrations from schema changes:

bash
# 1. Edit schema.ts
# 2. Generate a migration file
npm run db:generate

# 3. Apply to local SQLite
npm run db:migrate:local

# 4. Apply to remote D1 (production)
npm run db:migrate:remote

Migrations live in apps/event-site/drizzle/ as numbered SQL files. The CI pipeline applies migrations before deploying.

Migration tips

  • Adding a column: Make it nullable or provide a default. D1 doesn't support ALTER TABLE ... ADD COLUMN ... NOT NULL without a default.
  • Renaming a column: Create a new column, migrate data, drop old column (three separate migrations).
  • Adding an index: Safe to do in a single migration.
  • Dropping a table: Ensure no foreign keys reference it.

JSON field patterns

Several tables store structured data as JSON text:

Table.ColumnContains
rsvps.customFieldsRecord<string, string | boolean | number>
attendees.customFieldsSame as above
attendees.selectedAddOnIdsstring[] (add-on IDs)
settings.allowedEmailsstring[]
settings.adminEmailsstring[]
settings.paymentMethods('stripe' | 'paypal')[]
add_ons.tierPricesRecord<tierId, amountCents>
content_sections.galleryImageKeysstring[]
announcements.recipientEmailsstring[]

Parse with JSON.parse() and validate the shape in application code. Always handle null (column not set) gracefully.

Permission model

Three tables implement fine-grained access control using a consistent pattern:

typescript
// All permission tables share this shape
{
  id: text('id').primaryKey(),
  eventId: text('event_id').notNull(),
  [parentId]: text('parent_id').notNull().references(...),
  permissionType: text('permission_type', { enum: ['email', 'group'] }).notNull(),
  permissionValue: text('permission_value').notNull(),  // email address or group ID
  createdBy: text('created_by').notNull(),
  createdAt: text('created_at').notNull()
}

Used by: document_permissions, schedule_item_permissions, content_section_permissions.

To check access: query permissions for the item, then check if the user's email matches directly or via group membership.

  • Architecture — how the database fits into the runtime abstraction
  • Data Models — TypeScript interfaces that map to these tables
  • Code Patterns — Drizzle import conventions and query patterns

Released under the MIT License.