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
- Listings — listing 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 — listing grouping with encrypted names
- Settings — system configuration (currency, email, payment keys)
- Holidays — date exclusions for daily listings
- Activity Log — admin audit trail
- Processed Payments — idempotency tracking
- Login Attempts — rate limiting and lockout
Raised when a write can't get through because the database stays locked after the retries below — too busy. The request layer turns this into a friendly auto-reloading page rather than a generic error.
Complete a keyless invite (the editor role): set the password and clear
the invite, leaving wrapped_data_key NULL. An editor holds no DATA_KEY, so
unlike acceptInvite there is no handoff to unwrap or re-wrap — the
password only authenticates; it protects no key. The user's role is fixed at
invite time and is not changed here.
Correct projected listing income to the requested amount.
Join SQL conditions with AND while preserving their argument order.
Whether any of the given listings is a member of a package group. Empty input → false (no query). Used to keep a package member from being turned into another listing's required child (a package page can't render child edges).
Add listings to a group (membership rows), ignoring any already present.
Build the common dependent-row deletes for one or many attendee ids.
The id of the attendee whose booking owns this ledger event group, or null
when none does. The single-batch booking write stamps every one of an
attendee's listing_attendees rows with the booking's ledger_event_group
(in the same batch that posts the legs), so a paid session's event group
resolves back to exactly the attendee it created. This lets an idempotent
replay recover the existing booking from the durable ledger after the
(prunable) processed_payments idempotency row has gone — without it, a replay
whose legs already exist would be mistaken for a capacity failure and refund a
live ticket.
Build an INSERT into listing_attendees, capacity-checked by default.
Build input key mapping from DB columns snake_case DB column → camelCase input key
Build a PII blob JSON from contact fields. An unpinned latitude/longitude ("") is left out of the JSON so blobs without a pin stay as small as before.
Wire a keyed cache to an id-table in one step: build the cache, register it
for the debug-footer stats, and register it with the table→cache invalidation
registry so any write to the table (or to a dependsOn table whose triggers
feed it — e.g. listings depend on listing_attendees) clears the cache
automatically at the db-client layer. Centralises the create-cache + register
trio that listings and groups would otherwise each repeat. Cached lets the
cache hold a richer row than the table writes (e.g. listings cached with
attendee counts).
Bundle a request-scoped cache around a table.
Check a whole booking batch in one preflight query.
After a duration change on a grouped listing, check whether any day in any existing booking's new range now exceeds the group cap. Returns the earliest over-capacity day, or null if everything fits. Call AFTER recomputeListingBookingRanges so end_at is already updated.
Check several capacity conditions in one query.
Check one listing's availability, including its group limits.
Delete checkout stages for one or many attendee ids.
Clear every module-level in-process cache.
Clear login attempts for an IP on successful login. Clearing is login-only: successful API-key, booking, and address requests must retain their counters.
Clear stored ticket tokens for a session (after redirect has consumed them)
One group_listings row for a DUPLICATED group, resolving both the new group
and the cloned listing by the slug_index each was just inserted with, so the
whole clone (group + listings + memberships) runs as one batch — one
round-trip, atomic, and clear of the interactive-transaction round-trip guard.
Carries the source member's per-package quantity; the flat price override
lives in listing_prices and is copied separately (keyed to the new group).
Compute slug index from slug for blind index lookup
Compute the blind index used for listing slug lookups.
Extract ContactInfo fields from an object
Copy the source's package overrides onto the duplicate's membership rows in
the SAME transaction that inserted them (the create write's afterWrite), so a
failure rolls the whole duplicate back rather than leaving a live member at the
default price. The flat group and per-day group_day price rows are copied
only for package groups the NEW listing actually joined (the duplicate form may
untick some of the source's groups) — scoping each source row's encoded group
to the clone's group_listings, exactly as the quantity copy does. Otherwise a
copied override for a non-joined group would lurk invisibly and resurrect the
source's price if the clone were later added to that package.
Count one actual libsql client call and stop before call 51 reaches the network. Unlike advisory N+1 reporting, this stays hard in production because Bunny would reject the same request immediately afterwards anyway.
Count all rows in a table. table must be a trusted constant, not input.
Create an invited user (no password yet, has invite code). When the inviter passes a wrapped DATA_KEY handoff, the invitee self-activates at /join under the v2 scheme; otherwise an admin activates them later (legacy v1 path). kek_version is a placeholder here — there is no wrapped_data_key until activation, which sets the real version.
Create a new session with CSRF token, wrapped data key, and user ID Token is hashed before storage for security
Convert a nullable date to the stored half-open range.
Half-open span covering a non-empty set of YYYY-MM-DD days.
Decrypt a user's admin level
Decrypt attendee fields from the PII blob. Requires migration to be complete (admin is gated behind migration). When paidListing 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.
Convert a projected DB row and overlay the effective listing defaults.
Decrypt a PII blob and extract all contact fields
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
Define a cached "list" table in one call: build the table with
defineTable, then wrap it in cachedTable whose fetchAll
selects and decrypts every row in orderBy sequence. Returns the cached
table plus its getAll/invalidate.
Helper for tables whose primary key column is id.
Define a table with CRUD operations
Define an explicit physical-column projection and reuse the table's read transforms without loading or decrypting the rest of the row.
Delete all sessions (used when password is changed)
Delete all stale reservations (unfinalized, outcome-less, and older than STALE_RESERVATION_MS). Called from admin listing views to clean up abandoned checkouts. Rows carrying a recorded terminal failure are kept so a late redirect/webhook replays the handled outcome rather than re-refunding.
Delete an attendee and all its listing links, payments, and answers.
Delete rows matching a field value
Delete rows from multiple tables in a single batch transaction
Build the DELETE statement for one DeleteByFieldTarget — for batches that mix these deletes with other statements.
Delete one listing and its listing-owned relationships in one batch.
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 user and all their sessions and API keys
Enable query logging and clear previous entries
Encrypt attendee fields into a PII blob.
Shared encrypted name column for tables that store a display name.
Shared encrypted SEO/content columns for operator-authored pages (site pages, news posts): the markdown body plus the meta pair.
Encrypted slug + its plaintext blind-index slug_index (the permalink
pair shared by pages and news posts).
Encrypt a PII blob JSON string with the public key
Encrypt ticket tokens for the atomic payment finalize.
Count a statement within one interactive transaction and fire once, exactly
when the running count crosses the threshold. Only enforced inside a request
scope — startup migrations rebuild tables in one big transaction outside any
request, so they are never counted. count is the running per-transaction
statement count.
A table's env-key-encrypted name column as an id → name source. The env
decrypt and the name column are the common case, so per-table wrappers bind
just the table and its singular-word alias, then take .byIds (narrow id
lookups) or .all() (every name, for pickers/labels).
Execute multiple write statements, discarding results.
Write without firing cache invalidation. Reserved for plaintext bookkeeping rows (script-version markers) no cache ever holds — written concurrently with requests, the normal path would wipe the settings snapshot the request just loaded.
Run a single statement without table-scoped cache invalidation.
Expand a daily-listing range into individual day strings.
Parse the column names assigned by an UPDATE SET clause.
Returns a lower-cased Set, or null if the SET clause cannot be found.
Each col = expr left-hand side is extracted; commas inside parentheses
are skipped so subexpressions don't split assignments. If extraction yields
no columns the caller falls back to unconditional invalidation.
Exported for unit testing; not part of the public db-client API.
Heal a still-unresolved reservation by stamping attendee_id, leaving
ticket_tokens untouched. The ledger-replay path uses this: when a late
delivery finds the booking already recorded in the ledger, it points its fresh
reservation row at the existing attendee so the next delivery takes the fast
already-processed path — but ONLY while the row is unresolved, so it never
overwrites the attendee_id or blanks the ticket_tokens a racing delivery
may have just finalized and stored. Guarded on UNRESOLVED_RESERVATION
(the first outcome wins), and a no-op if the row was pruned away.
Generate a unique group slug, retrying on collision.
Get active holidays (end_date >= today) for date computation (from cache). "today" is computed in the configured timezone.
Get aggregated statistics for active listings. All three values are summed from the precomputed aggregate columns on ListingWithCount (trigger-maintained), which are already in memory from the caller's getAllListings() fetch — no additional DB query needed.
Get all activity log entries (most recent first)
Get every attendee's encrypted PII blob (one row per attendee). Used to resolve bulk-email recipient lists, where only the email inside each blob is needed. De-duplication of addresses happens after decryption.
Narrow id → name map for every group (selects + decrypts only the name), for pickers/labels that must not load the whole groups cache.
Read the narrow listing option projection used by item pickers.
Read every listing with effective defaults and aggregate projections.
Get all sessions ordered by expiration (newest first)
Get activity log entries for a specific attendee (most recent first), decrypting messages.
Look up attendees by plaintext tokens for the Previous bookings table.
Bounded id → kind lookup for attendee-linked admin surfaces. Empty ids ⇒ empty map. Unknown/deleted ids are omitted.
Bounded id → name lookup for the given attendees, decrypting only the name from each PII blob with the owner private key (no booking join, one row per attendee). Empty ids ⇒ empty map. Used for link labels in the activity log; a deleted attendee's id simply has no entry.
Get an attendee by ID (decrypted) Requires private key for decryption - only available to authenticated sessions
One attendee's raw booking rows within one package group (real lines only — quantity > 0). Lets a listing-scoped action rehydrate the WHOLE package the selected line belongs to, so a per-member notification resend doesn't treat a single member row as the complete package.
Get the encrypted PII blob for the attendee identified by a plaintext ticket token. Used to resolve a single-attendee bulk-email recipient. Ticket tokens are unique, so this matches at most one attendee; returns null when the token matches none, so a stale or unknown token resolves to no recipient rather than erroring.
Get the encrypted PII blobs for attendees booked onto any of the given listings (one row per attendee, even if booked onto several of them). Returns an empty array when no listing IDs are supplied.
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, listing_id, quantity are plaintext)
Get attendees by ID without decrypting PII, one row per (attendee, booking). Used by the agent run sheet, which already knows the attendee ids it needs and only reads each attendee's contact fields. Returns an empty array for no ids. Decrypt with decryptAttendees before display.
Read raw attendees attached to any requested listing.
Look up attendees by plaintext tokens, returning full booking data. Two queries: attendees by token index, then all listing_attendees for those attendees. Returns results in the same order as input tokens. Bookings sorted by start_at then listing_id for deterministic ordering.
Get one page of attendees — with every one of their booking rows — for the admin attendees browser.
Read only active, effectively visible listings for the public catalog.
Read the current settings_version counter straight from the DB (bypassing
the snapshot and the read audit — it is cache machinery, not an app setting).
The row is an integer once any write has created it; before the first write
(a fresh database) it is absent, which reads as version 0.
Read every occupied date across daily listing bookings.
Read daily-listing attendees whose booking overlaps one date.
Date-less remaining for capped groups reached from cumulative listings.
Get or create database client
Get a single group by slug_index (from cache)
Every membership row for a group, carrying its package_price override and
per-package quantity. A null package_price means "no override — use the
listing's own price", 0 means explicitly free in this package, and a
positive value overrides the price; quantity defaults to 1. The override is
read from the group dimension of listing_prices; quantity from the
membership row.
The membership rows for several groups in one query, keyed by group id, so a list endpoint can hydrate every group's package members without a per-group round-trip. Groups with no membership rows are absent from the map.
Remaining group capacity for one listing, or undefined when no cap applies or the listing does not exist.
Tightest remaining group capacity over a whole daily span.
Every group keyed by id, from the request-cached set — the batched alternative to one findById per id when resolving or validating many groups without tripping the N+1 read guard.
Static maximum capacity for each capped group.
Get activity log entries for an listing (most recent first)
Compare stored listing aggregates with the values rebuilt from bookings.
Read the flags that decide whether one listing may be offered.
Read names and offer flags for the admin site-page picker.
Remaining bookable units for each listing over a date range.
The one reader every listing-record surface uses: declare the filter and the
order, and it returns raw rows. Encrypted columns are still encrypted —
decrypt with the readers in records.ts before display.
Members of SEVERAL groups at once, keyed by group id — the batched form of the
single-group loaders for a multi-group surface. A page with many group leaves
would otherwise run one member query per group; this loads the join once and
the member listings once, then assembles each group's list in memory. Every
requested group id maps to an entry (empty when it has no matching member).
activeOnly keeps just active members (the site-page nav's liveness gate); the
default includes inactive members (the validators' group-compatibility read
for a listing that joins many groups, kept batched to stay under the N+1
guard).
Read every listing keyed by id.
Read listings by slug in input order, retaining nulls for missing rows.
Read listings in input order, retaining nulls for expected missing rows.
Get listing and its activity log in a single database round-trip. Uses batch API to reduce latency for remote databases.
Read one listing and one attendee in one round-trip.
Read one listing and all its attendee rows in one round-trip.
Read one listing when absence is expected.
Read one listing by its plaintext slug when absence is expected.
Read a just-written listing from the primary, or null if it was deleted.
Get the newest attendees across all listings without decrypting PII. Used for the admin dashboard to show recent registrations.
The package displays for a set of (possibly repeated or zero)
package_group_ids — only ids naming a live package appear in the map. Lets
the ticket view collapse each token's package rows into one card per package,
so an attendee holding both a package booking and a standalone one (e.g. after
an attendee merge) doesn't fall back to per-row cards that leak a hidden member.
Groups are resolved together from their shared cache.
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
Read requested listings' stored values without overlaying inherited defaults.
Read one listing's stored values without overlaying inherited defaults.
Get the minimal encrypted user fields needed to authenticate a session.
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)
Get the minimal encrypted user fields needed to show assignable users.
Does a group row exist? The add-item revalidation's single-row check — no name decryption, never the whole table.
The in-memory core of validateGroupListingType: given a group's already-loaded members, return the homogeneity error (or null). Callers that validate many groups at once batch the member reads (see getListingsByGroupIds) and drive this directly, so they never issue one sibling query per group and trip the N+1 read guard.
True when the attendee has a real (quantity > 0) booking on the exact listing. Authorizes per-(attendee, listing) actions — e.g. the signed attachment download — against the EXACT row, not getAttendeeRaw's arbitrary left-joined sibling row (which for a mixed attendee could pass on a ghost/other-listing row, or wrongly reject a valid real-line download). A no-quantity sentinel line is excluded, so a line later marked no-quantity stops authorizing.
Hash an invite code using SHA-256
Whether any booking row is stamped with this package's group id — sold tickets whose display (and hidden-member concealment) resolves through the live package row. Refund placeholders (quantity 0) don't count.
Shared generated id + plaintext created stamp columns. created stays
unencrypted so SQL can order and prune by time without decrypting.
Initialize database tables for an existing database. Fresh database creation requires allowMissingSettings. 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.
Forget the per-isolate "database is ready" cache.
Clear the listing entity cache.
Invalidate the users cache (for testing or after writes).
Whether database calls are currently subject to Bunny's per-request cap.
Check if a group slug is already in use. Checks both listings 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 a reservation is stale (abandoned by a crashed process)
Check if a payment session has already been processed
Check whether a slug is already used, optionally excluding one listing.
True when a row is an in-progress reservation with no recorded outcome — the in-memory mirror of the UNRESOLVED_RESERVATION SQL predicate.
Check if a username is already taken
Combine several { sql, args } pieces into one statement: the SQL fragments
joined with joiner, the args concatenated in the same order. For SQL built
from repeated sub-clauses (e.g. one capacity clause per day, joined with
" AND ").
Build the canonical line key from a stored booking row (matches the
${listingId}|${startAt}|${parentListingId}|${packageGroupId} identity
carried by the form's hidden key field). parent_listing_id distinguishes the
two rows produced when the same child is booked under two different parents;
package_group_id the rows produced when the same listing is booked through
two packages (or a package plus its own standalone row) in one order.
Columns for a ListingAttendeeRow read straight from one listing_attendees
source. The source name feeds correlated ledger subqueries, so a caller can
pass either the table name or a query alias without the sibling subquery
shadowing bare column names.
A queryBatch statement (SQL + bound args) for a listing read: the single
place a declared query becomes runnable SQL. getListingRows runs it;
the activity-log reader embeds it in a batch, and the read-your-own-write
reader runs it against the primary.
Load attendee rows carrying the standard ATTENDEE_FIELDS set (PII still encrypted — decrypt before display). Callers vary only in join, order, and where, so the field set is declared in exactly one place.
Read all current listing_attendees rows for an attendee, with line keys.
A package group's full pricing state in one load: its membership rows, the flat override + quantity maps (packageMemberMaps), and each customisable member's per-day overrides — the shape the booking flow, the webhook payload, and the payment revalidation all consume.
Log an activity. Optionally associate it with a listing and/or attendee so admin views can filter the log by either. A caller may pass its open write transaction so the activity and the action it records commit together.
Mirror the debug footer to the system logs: emit each SQL statement as it
completes, with its bound values omitted. The statement is parameterised, so
the string carries only ? placeholders — never PII or secrets — exactly the
value-free view the admin footer renders. Whitespace is collapsed so a
multi-line statement logs on one line. Routed through logDebug (category
"SQL") so it honours the same debug-log suppression as other debug output;
the dynamic import avoids the static cycle (query-log is imported by the db
client, which the logger transitively depends on), mirroring
notifyN1Violation.
Build a namespaced per-IP limiter: isLimited checks the lockout,
record counts one attempt (locking out at maxAttempts for lockoutMs
and returning true once locked). Each caller picks its own prefix so
counters never collide across features.
Run an integer-keyed lookup query and turn each row into a [key, value]
pair via toEntry, returning the id-keyed map (empty when ids is empty).
Record a handled terminal failure on a still-unresolved session. A later redirect/webhook for the same session reads this back via parseSessionFailure and returns the same outcome, so refunds and validation never run twice. Guarded on UNRESOLVED_RESERVATION, so it never clobbers a finalized success and never overwrites an already-recorded failure (the first outcome wins); a no-op if the row was pruned away.
Re-wrap a user's DATA_KEY under the password-bound (v2) KEK. Called at login — the one place both the raw password and the freshly-unwrapped DATA_KEY are in hand — for users still on the legacy v1 wrap, replacing the DB-recoverable wrap in place without touching any encrypted data.
A table's id → name projection, bound to its columns once. byIds returns
the map for the requested ids (empty ids ⇒ empty map); all returns it for
every row, ordered by id. Only the name column is decrypted, via the
decryption-agnostic decryptName; table/alias/nameColumn (alias
qualifies the selected columns, repo SQL convention) are internal constants.
Register a callback to run whenever the users cache is invalidated.
Whether a stored booking overlaps one day. String comparison mirrors the SQLite overlap check byte-for-byte.
Which package invariant adding child edges would violate, or null when the
edges are fine: gate_in_hidden when the parent is joining/in a HIDDEN
package group (a visible package renders the member's child selector, so a
member gating children is fine there), or child_is_member when any chosen
child is itself a package member (a package member is only ever sold as part
of its bundle, never folded under another parent). An empty childIds
(clearing children) is never a conflict.
The package displays behind a set of booked rows — each row's attendee names
its persisted package_group_id (0 on a plain row, matching no package).
Shared by the ticket view, the wallet lookup, and the email renderer, which
all carry { attendee, listing } row shapes.
A package group's member rows projected into the two maps every consumer
needs (the booking flow, the webhook revalidation, the bookability gate, and
the test harness): prices keeps only members with a real override — a
positive price OR an explicit free 0, dropping a null "no override" — while
quantities covers every member (default 1). Owning both here keeps the "what
counts as an override" rule in one place; callers destructure what they use.
The member-naming package error for the first listing in listings that
can't be a package member (pay-what-you-want, an add-on of another listing,
or — on a hidden package — a member gating its own children), or null when
every listing is a valid member. The one place every package save (group
form, add-listings, listing form/API, catalog import) turns an unpackageable
member into its user-facing message.
Parse a PII blob JSON back into contact fields (defaults v to 1 for pre-versioned blobs)
Parse a stored terminal failure, or null when the row carries none. We only ever write valid encrypted JSON (via markSessionFailed), but a value that won't decrypt or parse (restore, manual edit, rotated key) must not crash the replay path — it degrades to a generic terminal failure so the session still resolves instead of looping.
Per-day quantity sums from rows fetched for the whole span.
Query all rows, returning a typed array.
Execute a SQL query and map result rows through an async transformer.
Run a single-column SELECT and collect that column's values into a Set of strings — the shared shape of the "which hashes/names already exist" reads (e.g. the live table names, the unsubscribed contact hashes).
Run a query whose single selected column is aliased id and return the ids.
Query one row, or null when the query returns none.
Query an optional row on the primary (read-your-writes). Use this to read a
row back immediately after committing its own write:
a plain queryOne runs in "read" mode, which Turso can route to a
replica lagging the just-committed write and so miss the row (returning null);
routing through queryBatchPrimary ("write" mode) always hits the
primary. Mirrors the same guard on syncListingPrices. args is
required — every read-back keys on the written row's id.
Embed a raw SQL expression (e.g. last_insert_rowid())
Rebuild the full schema on a database that resetDatabase() just wiped, without reading the database to decide what to create.
Recompute end_at on all existing listing_attendees rows for an listing
based on a new duration_days value. Leaves NULL-start rows alone.
The .000Z suffix matches the format fresh inserts produce via
toISOString() so raw-row dumps stay consistent.
Register a cache stat provider (called at module load time)
Register invalidate to run whenever any of tables is written.
Release an in-progress reservation so the very next delivery can re-claim it. Deletes only a still-unresolved row, so it never clobbers a finalized success or a recorded terminal failure that a racing delivery may have written.
Tightest capped-group value for each listing.
Read required listings in input order through the shared cache path.
Read one required listing through the shared many-listing path.
Query one required row and name the failed query when none exists.
Query one required row from the primary.
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 selected aggregate columns from trusted SQL expressions. Each expression must use the entity id as its only placeholder.
Reset the database by dropping all tables (reverse order for FK safety)
Remove every listing from a group (used when the group is deleted), along with
the group's package price overrides — its flat group and per-day group_day
price rows key on the group id, so they'd otherwise outlive the deletion.
Reset selected listing aggregate columns from booking rows.
Cast libsql ResultSet rows to a typed array (single centralized assertion)
True when the query returns at least one row. sql should be an existence
probe (e.g. SELECT 1 ... LIMIT 1); the selected columns are ignored. Shared
by the per-(attendee, listing) and built-site assignment checks so the
row-presence boilerplate lives in one place.
Build an existence check for "one leading id, matched against a list of ids".
The returned checker binds leadingId to the first ? and expands ids into
the IN (...) your buildSql embeds via the placeholder string it receives.
Shared by the per-attendee "across these listings" probes so their signature
and args boilerplate live in one place. Empty ids still runs the query with
an empty IN (), which matches nothing — callers pass a non-empty list.
Run an id-keyed SELECT, short-circuiting to [] (no query) when ids is
empty. buildSql receives the bound ?-placeholder list for ids, so ids
are the only query args. The base skeleton for the id-map helpers below and
for any read that loads rows for a caller-supplied id list.
Set database client (for testing)
Set the active flag on every listing in a group.
Returns the number of listings affected.
Set a group's package member overrides — the flat group price rows in
listing_prices plus the per-package quantity on the membership rows. Pass
tx to run inside an existing write transaction (the admin API update path, so the
overrides commit atomically with the group row write); omit it to run as the
function's own statements. See applyPackageMembers for the
partial-update rules.
Replace a listing's group memberships inside an existing write transaction,
so the change commits atomically with the listing row write (the admin API
create/update path). Mirrors setListingGroups but reads the current
set and runs each statement on the caller's tx.
Switch the N+1 guard between throw (default) and notify-only (production).
Wall-clock milliseconds during which at least one query was in flight: the combined length of the query intervals with overlaps merged.
Collapse a result's rows to the set of one column's values, as strings — the shared tail of the "which names/ids already exist" reads (applied migrations, live table columns, index and trigger names).
Convert snake_case to camelCase (e.g. max_attendees → maxAttendees).
Convert camelCase to snake_case (the inverse of toCamelCase).
Run an async DB operation, enforcing the N+1 read guard and logging it when footer tracking is active.
Build an UPDATE statement from a table name, a column→value record for the
SET clause, and a column→value record for the WHERE clause (equality checks,
ANDed together). The counterpart of insert — use it instead of
hand-writing the UPDATE … SET … WHERE … string when every condition is a
plain column = value match; a write that needs a richer guard (IS NULL,
an inequality, a subquery) keeps its own SQL. SET values may be
rawSql expressions (e.g. a counter increment).
Set an attendee's status from the admin edit form (a plain column write, outside the encrypted pii_blob). The outstanding balance is NOT set from the form — it projects from the transfers ledger, and an operator adjusts it through the ledger's manual write-off entries.
Set a line's check-in flag, refusing a no-quantity (quantity 0) line — it
isn't a real ticket, mirroring the refunded-ticket guard in checkin.ts. The
quantity > 0 predicate scopes the write so a ghost row is a no-op (it can
never have been checked in, so scoping the check-OUT case too is harmless).
Manually set every editable listing aggregate.
Run work on the caller's open transaction, or open one when there is no caller transaction. Transaction-aware table methods use this so direct calls and larger atomic operations share the same write path.
Validate that a listing is compatible with a group's existing listings.
Every listing in a group must share both the same ListingType and
the same customisable_days setting, so the shared booking form can show a
single day-count selector (or none) for the whole group.
Returns an error message if mismatched, null if OK.
Pass excludeListingId to skip a specific listing (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)
Run work inside one interactive write transaction, committing on success and
rolling back (then rethrowing) on any error. Use this — rather than a plain
batch — when a multi-step write needs conditional logic between steps, e.g.
create → check capacity → finalize, where a zero-row guard must abort and undo
everything.
Write one row statement in a fresh write transaction and run persist (the
coupled join-table writes) on the same tx, so the row and its side writes
commit or roll back together. Returns the row id — existingId on update, or
the INSERT's lastInsertRowid on create (existingId null). Shared by the
REST resource (HTML forms) and CRUD API write paths.
Execute one table-built INSERT/UPDATE on an open transaction and return the affected row. A conditional write returns null when its condition is false.
Activity log entry as callers see it: the message decrypted to plaintext.
A listing's identity, capacity, and current booked quantity.
Input shared by ordered tables whose only required value is a name.
Per-column comparison of each aggregate F's stored value against its
rebuilt-from-source value — what the "recalculate aggregates" tools return.
Stored values of the trigger-maintained aggregate columns F, keyed by column.
A desired final-state line for the atomic update path. Re-exported from the shared types module so callers can keep importing it from here.
Input for creating an attendee atomically (one or more listings)
One page of attendee booking rows, plus whether a further page exists.
Carries the full field set because the same page query feeds both the
browsing table (which shows no money) and the CSV export (which sums
price_paid); the table simply ignores the columns it doesn't render.
An attendee with all their listing bookings (for token resolution)
A browsing-table attendee row — every core column plus refunded, but none
of the expensive money projections.
Result of atomic attendee creation
A decrypted attendee row: the raw row with its PII overlaid and its
booleans/price coerced, keeping exactly whichever optional money fields the
read selected. DecryptedAttendeeRow<Attendee> is the full Attendee.
One delete-rows-matching-a-field target: which table, matched on which field, for which value.
Everything a caller declares to read listing records: which rows to keep and in what order.
Row from listing_attendees — per-listing booking data
A single listing booking within a multi-listing attendee creation
How the rows come back. A named order so callers can't hand-roll a stray
ORDER BY. Exported because a narrow listing read — one that selects its own
columns rather than the whole record — still wants to come back in the same
order as the full reads.
The raw shape a listing read returns: the stored columns plus the projected values, before decryption and before any inherited defaults are overlaid.
A declarative filter for a listing read. Each present field adds one WHERE clause (absent fields don't constrain), so a caller says WHICH listings it wants rather than hand-writing SQL. An empty filter reads every listing.
A batch loader: takes a list of ids and returns, for each id, the list of related numbers found for it. Ids with no matches are absent from the map.
Package-group display info for grouping a booking's lines under the package name on tickets/emails.
The raw attendee columns the decrypt step reads and coerces. price_paid
and refunded are optional because a field-selected read may leave them out
(see file://./select.ts); the decrypt then leaves them out too rather
than coercing an absent column into "undefined" / false.
Run one column's declared read transform (e.g. decrypt) on a stored value —
identity when the column declares none or the value is null. For reading a
single column back without building a whole row. rowId (when known) lets
the transform name the record in error reports.
Result of session reservation attempt
The schema objects a single migration is responsible for. Drives that migration's verify() so failures name exactly what the migration was meant to add or remove.
Full settings snapshot type.
A single SQL statement plus its bound arguments — the object form libsql's
batch API accepts. This is the one shared shape for a { sql, args } pair;
callers that build statements to hand to executeBatch and friends
import this rather than re-declaring the same object type locally.
A stored log message: owner-key ciphertext for rows written since the keypair existed, env-key ciphertext for legacy rows the backfill hasn't re-encrypted yet. The format prefix routes decryption at runtime.
A selected row before the table's declared read transforms run. Database values are unknown here because booleans and encrypted strings have a different stored representation from the application's Row type.
The shape that defines a table: its name, primary key, and column schema.
Table schema definition Keys are DB column names (snake_case), values are column definitions
The slice of an open write transaction handed to a withTransaction callback: run statements singly or as one batch; commit/rollback are managed for you.
Result of an atomic attendee update. Every failure carries listingIds —
the SPECIFIC listings that failed the capacity preflight — so a caller can
tell the operator what was actually sold out instead of a bare reason
string. Empty when no particular listing is to blame: a duplicate booking
slot (see applyAttendeeAtomicEdit's duplicate-slot guard) or a
no_lines rejection.
Input for updating attendee PII (shared across listings)
Activity log table definition.
All keys that populate the snapshot plus the setup-complete flag. Equivalent
to the former loadAll SELECT * in terms of what affects request behaviour.
Use in tests and in pre-load bundles that need every setting.
Per-listing aggregate contributions of an attendee's lines, summed so the hold-delete restore can add them back after deleting. tickets_count counts only quantity > 0 rows (mirroring the delete trigger, which now subtracts 0 for a no-quantity line — see ticketCountSumExpr); booked_quantity sums over all rows. Exported for the shared-predicate guard test.
Attendees per page in the admin attendees browser. Fixed here so the page size is never derived from the request — callers choose only the page.
Stubbable API for testing atomic operations
Sort order for the admin attendees browser
Build the INSERT that createUser would run, without executing it, so a caller can include the user creation in a batch/transaction with other writes (e.g. initial setup creates the owner atomically alongside its config keys).
Helper to create column definitions
Create a new (already-activated) user with encrypted fields. Activated users are created at the password-bound KEK scheme (v2); the caller computes the matching wrapped_data_key via wrapDataKeyForPassword.
Run a single statement: track it for the query log / N+1 guard, then fire any table-scoped cache invalidation. Every single-statement read and write goes through here (queryOne/queryAll wrap it), so cache invalidation is driven by the write itself rather than by each call site remembering to invalidate.
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.
Per-day remaining for several capped groups, loaded in two queries.
Remaining capacity for each capped group.
Tightest remaining capped-group capacity for each listing.
Get all listings in a group with attendee counts (including inactive).
The listing ids in a group, and the reverse listing-to-groups side.
True when any of the listings has a paid line for this attendee — a gross
sale leg in the row's ledger_event_group (a sale leg's amount is always > 0,
so its existence is exactly a non-zero projected price_paid; a refund keeps the
gross leg, so a refunded line still reads as paid). One query over all the IDs,
read from the live ledger rather than the edit form's submitted key (a
stale/missing key can leave it null), so a recorded payment is never dropped
onto a fresh quantity-0 row. Callers pass a non-empty list.
Cached holidays table — name is encrypted, dates are plaintext; writes auto-invalidate the cache.
Shared columns for tables with a generated id plus an encrypted name.
Shared columns for tables with a generated id plus the encrypted slug pair.
Type guard: narrows an arbitrary string to an AttendeeSort.
Schema version label and the migrations bookkeeping table name.
Read and decrypt listing names without loading full records.
The shared narrow listing shape used by listing and attribute pickers.
Listing CRUD with cache invalidation and listing-price synchronization.
Max times one parameterized read may run as a separate round-trip within a single request before the N+1 guard fires. Set above the worst legitimate repeat in the suite; lower it to catch smaller N+1s.
Current PII blob schema version
Execute multiple read queries in a single round-trip using Turso batch API.
Run read queries pinned to the primary in a single round-trip.
Raw listings table. Records adds cache-aware CRUD and price syncing.
Ordered table names — matches FK dependency order (parents before children)
Every config key that maps to a snapshot field, in load order.
Threshold for abandoned payment reservations in ms (default: 300000 = 5 min)
Max statements one interactive write transaction may issue before the
round-trip guard fires. Every statement inside a withTransaction holds the
single primary write connection open for another edge→primary round-trip, so a
chatty interactive transaction is what the primary aborts as "Transaction
timed-out". A plain batch (executeBatch) is one round-trip regardless of how
many statements it carries and is never counted — the whole point is to push
chatty writes onto it. Set above the largest legitimate interactive
transaction; anything that grows with input size (a big attendee merge, a
per-leg ledger post) must prepare its reads outside the lock and apply its
writes as one batch instead. The current high-water mark is recreateTable
on attendee_answers: 8 DROP TRIGGER + 5 rebuild + 6 CREATE INDEX +
8 CREATE TRIGGER = 27 statements; the threshold sits above that.
A processed_payments row is in exactly one of three lifecycle states, encoded across two columns: reserved (in-progress: attendee_id NULL, no failure_data), finalized (success: attendee_id set), failed (terminal handled failure: attendee_id NULL, failure_data set). This predicate is the single source of truth for the unresolved shape, so the encoding can't drift between call sites.
Usage
import * as mod from "docs/database.ts";