Database client, ORM abstractions, and entity tables.
The database layer uses libsql with a type-safe table abstraction that handles column definitions, field transformers (encrypt/decrypt), and generic CRUD operations.
Entity Tables
- Events — event CRUD with cached encrypted slugs/names
- Attendees — hybrid RSA+AES encryption for PII
- Users — password hashing, admin levels, wrapped keys
- Sessions — token hashing with TTL caching
- Groups — event grouping with encrypted names
- Settings — system configuration (currency, email, payment keys)
- Holidays — date exclusions for daily events
- Activity Log — admin audit trail
- Processed Payments — idempotency tracking
- Login Attempts — rate limiting and lockout
Activate a user by wrapping the data key with their KEK
Add a new event link for an existing attendee with atomic capacity check. Does NOT create a new attendee or touch PII — just inserts an event_attendees row.
Record a query (no-op when logging is disabled)
Assign events to a group by updating their group_id.
Build an INSERT statement for the attendees table from encrypted fields.
Build a capacity-checked INSERT into event_attendees.
Build input key mapping from DB columns snake_case DB column → camelCase input key
Build a PII blob JSON from contact fields
Wrapper for test mocking - delegates to attendeesApi at runtime
Check a capacity-guarded write result and invalidate cache on success
Clear login attempts for an IP (on successful login)
Clear stored ticket tokens for a session (after redirect has consumed them)
Compute slug index from slug for blind index lookup
Compute slug index from slug for blind index lookup
Extract ContactInfo fields from an object
Wrapper for test mocking - delegates to attendeesApi at runtime
Create an invited user (no password yet, has invite code)
Create a new session with CSRF token, wrapped data key, and user ID Token is hashed before storage for security
Create a new user with encrypted fields
Convert nullable date to start_at/end_at (null-safe wrapper around dateToRange)
Decrypt a user's admin level
Decrypt attendee fields from the PII blob. Requires migration to be complete (admin is gated behind migration). When paidEvent is false, payment_id and refunded are skipped.
Decrypt a single raw attendee, handling null input. Used when attendee is fetched via batch query.
Decrypt a list of raw attendees (all fields). Used when attendees are fetched via batch query.
Decrypt the ticket_tokens field from a processed payment record. Returns the plaintext token string (e.g. "tok1+tok2") or empty string.
Decrypt a user's username
Helper for tables whose primary key column is id.
Define a table with CRUD operations
Delete all sessions (used when password is changed)
Delete all stale reservations (unfinalized and older than STALE_RESERVATION_MS). Called from admin event views to clean up abandoned checkouts.
Delete an attendee and all its event links, payments, and answers.
Delete rows matching a field value
Delete rows from multiple tables in a single batch transaction
Delete an event and all its attendees in a single database round-trip. Uses write batch to cascade: processed_payments → attendees → event. Reduces 3 sequential HTTP round-trips to 1.
Delete all sessions except the current one Token is hashed before database comparison
Delete a session by token Token is hashed before database lookup
Delete a stale reservation to allow retry
Delete a user and all their sessions and API keys
Enable query logging and clear previous entries
Encrypt attendee fields into a PII blob, returning null if key not configured
Shared encrypted name column for tables that store a display name.
Encrypt a PII blob JSON string with the public key
Execute multiple write statements, discarding results.
Finalize a reserved session with the created attendee ID (second phase)
Get active events in a group with attendee counts.
Get aggregated statistics for active events. Filters active events from the provided list, computes attendees (sum of quantities) from cached EventWithCount data, and queries ticket count and income (sum of price_paid) via a single aggregate.
Get active holidays (end_date >= today) for date computation (from cache). "today" is computed in the configured timezone.
Get all activity log entries (most recent first)
Get all daily events with attendee counts (from cache).
Get all events with attendee counts (from cache)
Get all groups, decrypted, ordered by id (from cache)
Get all holidays, decrypted, ordered by start_date (from cache)
Get all sessions ordered by expiration (newest first)
Get all standard events with attendee counts (from cache). Used by the calendar view to include one-time events on their scheduled date.
Get all users (for admin user management page, from cache)
Get an attendee by ID (decrypted) Requires private key for decryption - only available to authenticated sessions
Get an attendee by ID without decrypting PII Used for payment callbacks and webhooks where decryption is not needed Returns the attendee with encrypted fields (id, event_id, quantity are plaintext)
Get raw attendees for a set of event IDs. Used by the calendar to load attendees for standard events whose decrypted date matches the selected calendar date.
Look up attendees by plaintext tokens, returning full booking data. Two queries: attendees by token index, then all event_attendees for those attendees. Returns results in the same order as input tokens (deduped). Bookings sorted by start_at then event_id for deterministic ordering.
Get attendees for an event without decrypting PII Used for tests and operations that don't need decrypted data
Get distinct attendee dates for daily events. Used for the calendar date picker (lightweight, no attendee data).
Get raw attendees for daily events on a specific date. Bounded query: only returns attendees matching the given date.
Get the total attendee quantity for a specific event + date
Get or create database client
Get a single event by ID (from cache)
Get activity log entries for an event (most recent first)
Get all events in a group with attendee counts (including inactive).
Get multiple events by slugs (from cache). Returns events in the same order as the input slugs. Missing or inactive events are returned as null.
Get event and its activity log in a single database round-trip. Uses batch API to reduce latency for remote databases.
Get event and a single attendee in a single database round-trip. Used for attendee management pages where we need both the event context and the specific attendee data.
Get event and all attendees in a single database round-trip. Uses batch API to execute both queries together, reducing latency for remote databases like Turso from 2 RTTs to 1. Computes attendee_count from the attendees array.
Get event with attendee count (from cache)
Get event with attendee count by slug (from cache)
Get a single group by slug_index (from cache)
Per-event view of group remaining capacity. Daily events are dropped when
date is null — their cap is per-date, so a cumulative count would
misreport spots that other dates still have.
Per-group remaining capacity. Groups with max_attendees <= 0 (no cap)
are omitted from the map. With date = null, daily-event attendees count
cumulatively — correct for booking-time enforcement after upstream date
validation, misleading for display.
Returns undefined when no group cap applies: ungrouped, uncapped
group, or daily event without a date.
Get the newest attendees across all events without decrypting PII. Used for the admin dashboard to show recent registrations.
Get the attendee ID for an already-processed session Used to return success for idempotent webhook retries
Return a snapshot of all logged queries
Return the start time recorded by enableQueryLog()
Get a session by token (with 10s TTL cache) Token is hashed for database lookup
Get ungrouped events (group_id = 0) with attendee counts.
Get a user by ID (from cache)
Find a user by invite code hash Scans all users, decrypts invite_code_hash, and compares
Look up a user by username (using blind index, from cache)
Wrapper for test mocking - delegates to attendeesApi at runtime
Hash an invite code using SHA-256
Check if a user has set their password (password_hash is non-empty encrypted value)
Shared columns for tables with encrypted slug + blind-index slug_index.
Increment the attachment download counter for an attendee. Uses atomic SQL increment to avoid race conditions.
Initialize database tables — idempotent, safe to call on every startup. Uses an advisory lock to prevent concurrent migrations.
Build SQL placeholders for an IN clause, e.g. "?, ?, ?"
Build an INSERT statement from a table name and column→value record.
Invalidate the events cache (for testing or after writes).
Invalidate the groups cache (for testing or after writes).
Invalidate the holidays cache (for testing or after writes).
Invalidate the users cache (for testing or after writes).
Check if a group slug is already in use. Checks both events and groups for cross-table uniqueness.
Check if a user's invite has expired. Callers should skip this for users who have already set a password.
Check if a user's invite is still valid (not expired, has invite code)
Check if IP is rate limited for login
Whether query logging is currently active
Check if a reservation is stale (abandoned by a crashed process)
Check if a payment session has already been processed
Check if a slug is already in use (optionally excluding a specific event ID) Uses slug_index for lookup (blind index)
Check if a username is already taken
Mark an attendee as refunded for a specific event. Keeps payment_id intact so payment details can still be viewed.
Parse a PII blob JSON back into contact fields (defaults v to 1 for pre-versioned blobs)
Query all rows, returning a typed array
Execute a SQL query and map result rows through an async transformer.
Query single row, returning null if not found
Embed a raw SQL expression (e.g. last_insert_rowid())
Record a failed login attempt Returns true if the account is now locked
Register a cache stat provider (called at module load time)
Reserve a payment session for processing (first phase of two-phase lock) Inserts with NULL attendee_id to claim the session. Returns { reserved: true } if we claimed it, or { reserved: false, existing } if already claimed.
Reset the database by dropping all tables (reverse order for FK safety)
Reset group assignment on all events in a group.
Clear session cache (exported for testing)
Cast libsql ResultSet rows to a typed array (single centralized assertion)
Set database client (for testing)
Set the active flag on every event in a group.
Returns the number of events affected.
Set a user's password (for invite flow)
Convert snake_case to camelCase
Convert camelCase to snake_case
Run an async DB operation and log it when tracking is active
Remove a single event link for an attendee. If the attendee has no remaining event links, deletes the attendee entirely. Returns whether the attendee was fully deleted.
Update an attendee's PII (name, email, phone, etc.) — shared across all event links. Caller must be authenticated admin (public key always exists after setup).
Update an attendee's checked_in status for a specific event. Caller must be authenticated admin (public key always exists after setup)
Update a single event link's quantity and date with atomic capacity check. Excludes this attendee's current row from the capacity calculation.
Validate that an event type is compatible with a group's existing events. Returns an error message if mismatched, null if OK. Pass excludeEventId to skip a specific event (for edit-self case).
Verify a user's password (decrypt stored hash, then verify) Returns the decrypted password hash if valid (needed for KEK derivation)
Wrap a table so that insert, update, and deleteById automatically call an invalidation callback (e.g. cache invalidation). Eliminates the repeated spread-and-override pattern in groups/holidays/events.
Encrypt closes_at for DB storage (null/empty → encrypted empty)
Encrypt event date for DB storage
Activity log entry
Table definition with CRUD operations
-
deleteById: (id: InValue) => Promise<void>
Delete a row by primary key
-
findAll: () => Promise<Row[]>
Find all rows
-
findById: (id: InValue) => Promise<Row | null>
Find a row by primary key
-
fromDb: (row: Row) => Promise<Row>
Transform a row from DB (apply read transforms)
- inputKeyMap: Record<string, string>
-
insert: (input: Input) => Promise<Row>
Insert a new row, returns the created row
- name: string
- primaryKey: keyof Row & string
-
rowToInput: () => Partial<Input>row: Row,exclude?: readonly string[]
Build an Input object from an existing Row by copying the input-eligible columns and translating keys through
inputKeyMap. Lets callers spread a row into a new insert without restating every field. Columns named inexcludeare skipped — useful for auto-stamped fields likecreated. - schema: TableSchema<Row>
-
toDbValues: (input: Input | Partial<Input>) => Promise<Record<string, InValue>>
Transform input to DB values (apply write transforms and defaults)
-
update: () => Promise<Row | null>id: InValue,input: Partial<Input>
Update a row by primary key, returns updated row or null if not found
Aggregated statistics for active events
Activity log input for create
& { paymentId?: string; bookings: EventBooking[]; }
Input for creating an attendee atomically (one or more events)
An attendee with all their event bookings (for token resolution)
-
bookings: EventAttendeeRow[]
Per-event bookings, sorted by start_at then event_id
- created: string
-
id: number
Base attendee fields (PII, token, created — shared across events)
- pii_blob: string
- ticket_token: string
- ticket_token_index: string
Item for batch availability check
Column definition for a table
-
default: () => T
Default value generator (for created timestamps etc)
-
generated: boolean
Whether this column is auto-generated (like id)
-
read: (v: T) => Promise<T> | T
Transform value after reading from DB (e.g., decrypt)
-
write: (v: T) => Promise<T> | T
Transform value before writing to DB (e.g., encrypt)
| { success: false; reason: "capacity_exceeded" | "encryption_error"; }
Result of atomic attendee creation
Valid email template formats
Valid email template types
Row from event_attendees — per-event booking data
A single event booking within a multi-event attendee creation
Event input fields for create/update (camelCase)
- active: boolean
- assignBuiltSite: boolean
- attachmentName: string
- attachmentUrl: string
- bookableDays: string[]
- canPayMore: boolean
- closesAt: string
- date: string
- description: string
- eventType: EventType
- fields: EventFields
- groupId: number
- hidden: boolean
- imageUrl: string
- location: string
- maxAttendees: number
- maxPrice: number
- maxQuantity: number
- maximumDaysAfter: number
- minimumDaysBefore: number
- name: string
- nonTransferable: boolean
- purchaseOnly: boolean
- slug: string
- slugIndex: string
- thankYouUrl: string
- unitPrice: number
- webhookUrl: string
Result type for event + activity log batch query
Result type for event + single attendee query
Result type for combined event + attendees query
Group input fields for create/update (camelCase)
Holiday input fields for create/update (camelCase)
& [K in OptionalInputKeys<Row, Schema>]?: Row[K]
Derive Input type from Row type and Schema
A single logged query
| { reserved: false; existing: ProcessedPayment; }
Result of session reservation attempt
Full settings snapshot type.
Union of all string-setting snapshot keys.
Table schema definition Keys are DB column names (snake_case), values are column definitions
Input for updating attendee PII (shared across events)
- address: string
- email: string
- name: string
-
payment_id: string
Decrypted payment_id for PII blob rebuild (from existing attendee)
- phone: string
- special_instructions: string
-
ticket_token: string
Decrypted ticket_token for PII blob rebuild (from existing attendee)
Input for updating a single event link
| { success: false; reason: "capacity_exceeded"; }
Result of updating an event link
Activity log table definition message is encrypted - decrypted only for admin view
SELECT clause for attendee + event_attendees JOINs (INNER JOIN context).
Derives date from start_at for backward compatibility with the Attendee type.
SELECT clause for LEFT JOIN context — COALESCEs nullable join columns so attendees with broken/missing event_attendees linkage still appear in results (with event_id=0 as an obvious corruption indicator).
Stubbable API for testing atomic operations
Shared failure result for capacity-exceeded
Helper to create column definitions
-
boolean: (defaultValue: boolean) => ColumnDef<boolean>
Boolean column stored as INTEGER 0/1 in the database
-
converted: <App>(config: { default?: () => App; write: (v: App) => InValue; read: (raw: InValue) => App; }) => ColumnDef<App>
Column with type conversion between app and DB representations
-
encrypted: <T>() => ColumnDef<T>encrypt: ColumnTransform<T>,decrypt: ColumnTransform<T>
Column with read/write transforms (e.g., for encryption)
-
encryptedNullable: <T>(def: ColumnDef<T>) => ColumnDef<T | null>
Wrap an existing encrypted column def to pass through null values
-
encryptedText: () => ColumnDef<string>encrypt: ColumnTransform<string>,decrypt: ColumnTransform<string>
Encrypted text column with empty-string default
-
generated: <T>() => ColumnDef<T>
Auto-generated column (like id)
-
simple: <T>() => ColumnDef<T>
Simple column with no special handling
-
transform: <T>() => ColumnDef<T>write: (v: T) => Promise<T> | T,read: (v: T) => Promise<T> | T
Column with custom transforms
-
withDefault: <T>(defaultFn: () => T) => ColumnDef<T>
Column with default value
- APPLE_WALLET_PASS_TYPE_ID: string
- APPLE_WALLET_SIGNING_CERT: string
- APPLE_WALLET_SIGNING_KEY: string
- APPLE_WALLET_TEAM_ID: string
- APPLE_WALLET_WWDR_CERT: string
- ATTENDEE_COLUMN_ORDER: string
- BOOKING_FEE: string
- BUNNY_SUBDOMAIN: string
- BUSINESS_EMAIL: string
- CONTACT_PAGE_TEXT: string
- COUNTRY: string
- CURRENT_TASK: string
- CUSTOM_DOMAIN: string
- CUSTOM_DOMAIN_LAST_VALIDATED: string
- EMAIL_API_KEY: string
- EMAIL_FROM_ADDRESS: string
- EMAIL_PROVIDER: string
- EMAIL_TPL_ADMIN_HTML: string
- EMAIL_TPL_ADMIN_SUBJECT: string
- EMAIL_TPL_ADMIN_TEXT: string
- EMAIL_TPL_CONFIRMATION_HTML: string
- EMAIL_TPL_CONFIRMATION_SUBJECT: string
- EMAIL_TPL_CONFIRMATION_TEXT: string
- EMBED_HOSTS: string
- EVENT_COLUMN_ORDER: string
- GOOGLE_WALLET_ISSUER_ID: string
- GOOGLE_WALLET_SERVICE_ACCOUNT_EMAIL: string
- GOOGLE_WALLET_SERVICE_ACCOUNT_KEY: string
- HEADER_IMAGE_URL: string
- HOMEPAGE_TEXT: string
- LAST_PRUNED_LOGINS: string
- LAST_PRUNED_PAYMENTS: string
- LAST_PRUNED_SESSIONS: string
- LAST_PRUNED_TOKENS: string
- LATEST_SCRIPT_VERSION: string
- LATEST_SCRIPT_VERSION_NAME: string
- PAYMENT_PROVIDER: string
- PUBLIC_KEY: string
- SETUP_COMPLETE: string
- SHOW_PUBLIC_API: string
- SHOW_PUBLIC_SITE: string
- SQUARE_ACCESS_TOKEN: string
- SQUARE_LOCATION_ID: string
- SQUARE_SANDBOX: string
- SQUARE_WEBHOOK_SIGNATURE_KEY: string
- STRIPE_SECRET_KEY: string
- STRIPE_WEBHOOK_ENDPOINT_ID: string
- STRIPE_WEBHOOK_SECRET: string
- TERMS_AND_CONDITIONS: string
- THEME: string
- WEBSITE_TITLE: string
- WRAPPED_PRIVATE_KEY: string
Default bookable days (all days of the week)
Execute multiple write statements and return their ResultSets. Statements run in order within a single transaction (Turso batch API). Ideal for cascading deletes and multi-step writes.
Groups table with CRUD operations — writes auto-invalidate the cache
Holidays table with CRUD operations — writes auto-invalidate the cache
Current PII blob schema version
Execute multiple read queries in a single round-trip using Turso batch API.
Ordered table names — matches FK dependency order (parents before children)
- appleWallet
- attendeeColumnOrder(): string
- bookingFee(): string
- bunnySubdomain(): string
- businessEmail(): string
-
clearTestOverride(...keys: (keyof SettingsData)[]): void
Remove specific test override keys (falls back to data).
-
clearTestOverrides(): void
Clear all test overrides.
- contactPageText(): string
- country(): string
- currency(): string
- currentTask(): string
- customDomain(): string
- customDomainLastValidated(): string
-
email: { get apiKey(): string; get fromAddress(): string; get hasApiKey(): boolean; get provider(): string; template(): string; templateSet(type: EmailTemplateType): { subject: string; html: string; text: string; }; }type: EmailTemplateType,format: EmailTemplateFormat
- embedHosts(): string
- eventColumnOrder(): string
-
getCachedRaw
Read a raw (possibly encrypted) value from the cache.
- googleWallet
- headerImageUrl(): string
- homepageText(): string
- invalidateCache
- lastPrunedLogins(): string
- lastPrunedPayments(): string
- lastPrunedSessions(): string
- lastPrunedTokens(): string
- latestScriptVersion(): string
- latestScriptVersionName(): string
- loadAll
- paymentProvider(): PaymentProviderType | null
- phonePrefix(): string
- publicKey(): string
-
setForTest(overrides: Partial<SettingsData>): void
Set test overrides (survive invalidateCache, cleared by clearTestOverrides).
-
setRaw
Write a raw value to the DB (low-level, prefer update.*).
- setup: { clearCache; complete; isComplete; }
- showPublicApi(): boolean
- showPublicSite(): boolean
- square: { get accessToken(): string; get hasToken(): boolean; get locationId(): string; get sandbox(): boolean; get webhookSignatureKey(): string; }
- stripe: { get hasKey(): boolean; get keyMode(): "test" | "live" | null; get secretKey(): string; get webhookEndpointId(): string; get webhookSecret(): string; }
- terms(): string
- theme(): Theme
- timezone(): string
-
update: { appleWallet; attendeeColumnOrder; bookingFee: (v: string) => Promise<void>; bunnySubdomain; businessEmail; clearPaymentProvider: () => Promise<void>; contactPageText; country: (v: string) => Promise<void>; currentTask; customDomain; customDomainLastValidated: () => Promise<void>; email: { apiKey; fromAddress; provider; template: () => Promise<void>; }; embedHosts; eventColumnOrder; googleWallet; headerImageUrl; homepageText; lastPrunedLogins; lastPrunedPayments; lastPrunedSessions; lastPrunedTokens; latestScriptVersion; latestScriptVersionName; paymentProvider: (v: PaymentProviderType) => Promise<void>; showPublicApi; showPublicSite; square: { accessToken; locationId; sandbox; webhookSignatureKey; }; stripe: { secretKey; webhookConfig: (config: { secret: string; endpointId: string; }) => Promise<void>; }; terms; theme: (v: Theme) => Promise<void>; websiteTitle; }type: EmailTemplateType,format: EmailTemplateFormat,content: string
- updateUserPassword
- websiteTitle(): string
- withCurrentTask
- wrappedPrivateKey(): string
Threshold for abandoned payment reservations in ms (default: 300000 = 5 min)
Usage
import * as mod from "docs/database.ts";