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.
A keyless invite leaves 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, and protects no key.
Correct projected listing income to the requested amount.
Advance a stored terminal outcome from its conservative shape to its final one, once the follow-up money work has finished. Reads the row fresh, so ANY later process — not just the one that stored it — can finish the move. The write is fenced on the exact bytes just read; a racing advance makes this one converge instead of clobbering. A missing or re-reserved row means the idempotency record was pruned mid-move: the durable money records are already correct, so there is nothing left to advance.
One fenced attempt to move a session's stored outcome to to: the write
lands only while the row still holds the exact fence bytes. When the
fence misses, the row must have converged to to (a racing advance won) or
vanished (pruned) — anything else is a real conflict and throws. Exported
for that race arm: a test can hand it a stale fence deterministically,
where a live race cannot be scripted.
Both repairs for one table's aggregate columns, given the SQL that recounts each one. Every aggregate family declares its table and its per-column recount here rather than writing the same two wrappers again.
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).
Remove or detach every dependent row for one or many attendee ids.
Build an INSERT into listing_attendees, capacity-checked by default. A zero-quantity booking carries no capacity or active condition: it demands no places, so it can land on a full or inactive listing too — but by default it still names a listing that must exist, because listing_attendees has no foreign key. An overbook caller (the payment ghost store) explicitly asks for the row whatever the listing state. An order-level extra condition still applies.
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.
A write to the table, or to a dependsOn table whose triggers feed it,
clears the cache automatically at the db-client layer.
A write to the table, or to a dependsOn table whose triggers feed it,
clears the cache at the db-client layer, so no write path calls it.
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.
Clear the one booking-stage relationship a merge must replace early.
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).
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.
enforceBudget: false exempts the call from the budget allowance only — the
reserve/allowance cap the migration runner sets below the real limit. The hard
round-trip limit below always applies: it mirrors Bunny's platform cap, which
no call, cleanup included, can exceed. So a rollback is let past our own
stricter reserve to run, but is still blocked if it would be a genuine
over-the-platform-limit subrequest — exactly as Bunny would reject it.
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
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
Delete one row and the rows that point at it, children first, in one batch. Each child names the column that holds the parent's id.
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 write that ends in RETURNING and read back the row it wrote. A
write that returns no row means nothing was written — fail there, loudly.
A single statement with no table-scoped invalidation, for a caller keeping its own cache state through a write. Query tracking still happens. Every other write wants execute.
Expand a daily-listing range into individual day strings.
The lower-cased column names an UPDATE's SET clause assigns, or null when none
can be read. Commas inside parentheses are skipped, so a coalesce(x, 0) does
not split one assignment into two. Null means the caller invalidates
unconditionally — safe over stale.
Heals a reservation ONLY while it is unresolved, so it never overwrites the
attendee_id or blanks the ticket_tokens a racing delivery just finalized.
The guard is UNRESOLVED_RESERVATION, so the first outcome wins.
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 whose booking on one listing covers a given day. A booking spanning several days covers each of them, so a stay from Friday to Sunday answers to Saturday as well as to its own first day. Uses the same half-open overlap predicate as the capacity checks, so the people a day's message reaches are the people that day counts.
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.
Pagination counts ATTENDEES, not booking lines, so a grouped attendee row
carries their complete listings list and never splits across a page boundary.
listingIds decides WHICH attendees match, and the returned rows still cover
all of a matched attendee's listings.
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
The first real booking, or a no-quantity placeholder when no real one remains. The returned row itself proves whether the action has a live booking; callers do not need a second existence query.
One stored group, through the shared many-group read. Null when no group has that id.
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.
Every group keyed by id, from the request-cached set — the batched alternative to one read per id when resolving or validating many groups without tripping the N+1 read guard.
Groups by id in one query, read straight from the table: a group about to be shown, edited or acted on must be the stored row, not a cached copy that another edge may already have changed. An id with no row is simply absent from the map.
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 a session and the user it belongs to together.
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.
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.
Transaction-local recheck: whether any sold booking still holds this group's package id. Used inside the group write transaction so a checkout that commits between a request-level sold-hidden check and the write rolls the un-packaging back rather than revealing concealed member names.
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. "?, ?, ?"
The key of the row an INSERT … RETURNING wrote, read from the row itself
rather than from the driver's optional lastInsertRowid. Every generated key
is a positive integer, so anything else means nothing downstream can be keyed
on this row and the write must fail here.
Forget the per-isolate "database is ready" cache.
Clear the listing entity cache.
Invalidate the users cache (for testing or after writes).
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 whether a slug is already used, optionally excluding one listing.
Check if a username is already taken
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.
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.
The full pricing state of SEVERAL package groups, keyed by group id, in two reads however many groups are asked for — an order that books many packages would otherwise spend two round-trips per package and eat the request's subrequest budget. Every requested group is present; one with no membership rows reads as empty maps.
Log several activities as ONE write. A booking with many lines records a line apiece, and one insert per line would eat the edge request's subrequest budget; a batch costs the same single round-trip however many there are, and either they all land or none does. Returns the stored rows in the same order, with their plaintext messages. A caller may pass its open write transaction so the log and the action it records commit together.
Log one activity, through the shared many-activity write. Optionally associate it with a listing and/or attendee so admin views can filter the log by either.
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.
Rewrite a built INSERT as INSERT OR IGNORE, dropping a row whose unique key
is already stored instead of raising a constraint error. This is the once-only
latch resumable flows lean on: a replayed write re-derives the same key and
lands nowhere. It silences every conflict on the statement, so use it only
where the unique key IS the idempotency rule.
Whether a stored booking overlaps one day. String comparison mirrors the SQLite overlap check byte-for-byte.
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. Judged against ONE batched edge load (two
queries for the whole member list, never one per member); the rules and
their messages live in the shared packageMemberError. 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. A non-empty value is an app-written durable outcome, so decryption or schema failure is corruption and must surface here instead of being mistaken for a handled booking result.
Per-day quantity sums from rows fetched for the whole span.
Query all rows as a typed array. A read whose cache refill requires read-your-writes goes to the primary instead.
queryAll for a caller that must read its own writes. The same rows, pinned to the primary, because a replica can lag behind the write and miss them. See queryBatchPrimary. It takes the statement whole, which is the shape a batch runs, so a caller that holds one hands it over as it is.
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 — the singular of
queryAllPrimary, as queryOne is of queryAll. args
is required here: every read-back keys on the row the write just made.
Embed a raw SQL expression (e.g. last_insert_rowid())
Read several groups' members together with one more thing about the SAME
groups — their prices, their full membership, whatever the caller needs — in
one round of reads. A page hydrating many packages would otherwise pay a pair
of reads per package and eat the request's subrequest budget. activeOnly
has the meaning getListingsByGroupIds gives it, and is spelled out at
every call site — whether inactive members count is the caller's decision,
never a default.
Rebuild the full schema after resetDatabase(), 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 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.
Move live attendee assignments while keeping their records.
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). Missing and stale unresolved rows are claimed atomically. Existing fresh, finalized, and failed rows are returned without changing them.
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.
Cast libsql ResultSet rows to a typed array (single centralized assertion)
True when the query returns a row. sql should be an existence probe such as
SELECT 1 ... LIMIT 1; which columns it selects is ignored.
Build an existence check for "one leading id, matched against a list of ids".
The checker binds leadingId to the first ? and expands ids into the
IN (...) that buildSql embeds through the placeholder string it is handed.
Empty ids runs an empty IN (), which matches nothing.
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.
The listings whose lines do not fit right now, in one query. The attendee-edit preflight names its culprit with this.
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).
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.
Verify a user's password (decrypt stored hash, then verify) Returns the decrypted password hash if valid (needed for KEK derivation)
Use this rather than a plain batch only when a multi-step write needs logic between steps, such as 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. On update, an optional readState runs before
the statement and its result reaches persist; creates skip it. Returns the
row id — existingId on update, or the key the INSERT returned on create.
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 table built by defineTable: the transactional statement builders and the primary-pinned read are always present. They stay optional on Table only for hand-written façade tables that never take the transactional path.
A listing's identity, capacity, and current booked quantity.
Input shared by ordered tables whose only required value is a name.
A package group's full pricing state: what it charges, plus the membership rows those charges were read from (which listings are in the bundle).
What a package charges for its members: the flat override + quantity maps (packageMemberMaps) and each customisable member's per-day overrides. The shape the booking page, the webhook payload, and the payment revalidation all price from.
One terminal-failure transition that can join an existing transaction.
statement establishes the conservative outcome; once follow-up money work
finishes, advanceSessionFailure moves it to its final shape.
Table definition with CRUD operations
One thing to record in the log: what happened, and which listing/attendee it happened to.
Per-column comparison of each aggregate F's stored value against its
rebuilt-from-source value — what the "recalculate aggregates" tools return.
The two owner-facing repairs every trigger-maintained aggregate column set needs: write the numbers the operator typed, and rebuild chosen columns from the rows they count.
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)
Runs several statements as one batch and answers each in turn: the shape
queryBatch and executeBatchWithResults have, and the one
TxScope's batch fits. A caller that reads either from the client
or through an open transaction takes one of these instead of naming both.
A browsing-table attendee row — every core column plus refunded, but none
of the expensive money projections.
Result of atomic attendee creation. A failure carries the listings whose lines did not fit when the refusal was checked — empty when the failure was not one listing's capacity shortfall (a duplicate slot, a replayed ledger event, or a race that freed the room again before the check).
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 worked-out 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.
Processed payment record
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.
A session and the user it belongs to. user is null when the session
outlived its user, which the caller reports and clears — never mistake it for
a token nobody ever had.
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.
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
Reads narrow pre-update state through an open write transaction.
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
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.
Most physical database attempts made for one retryable operation on the remote database — the number the edge subrequest budgets are sized from. A file database retries longer, where no subrequest budget binds.
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.
Write statements in order in one transaction, returning every ResultSet — suited to 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.
Execute a query and decrypt the resulting group rows
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.
Schema version label and the migrations bookkeeping table name.
Write the operator's typed listing aggregates, or rebuild chosen ones from the booking rows they count.
Read and decrypt listing names without loading full records.
The shared narrow listing shape used by listing and attribute pickers.
The listing reader. Reading by group starts from the membership rows and folds a listing's several memberships back into one row, naming which groups it matched; every other read starts from the listings themselves.
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.
HTTP primary reads use a separate BEGIN and COMMIT in one pipeline. A complete SELECT-only batch can run on a replica despite its write mode.
Raw listings table. Records adds cache-aware CRUD and price syncing.
Register a cache stat provider (called at module load time)
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)
Every statement inside a withTransaction holds the single primary write
connection open for another edge→primary round-trip. A chatty interactive
transaction is what the primary aborts as "Transaction timed-out".
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.
Run read-only work against one database snapshot.
Usage
import * as mod from "docs/database.ts";