doc.ts
Chobble Tickets
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.
Activity log entry as callers see it: the message decrypted to plaintext.
Activity log table definition.
Add days to a YYYY-MM-DD date string
Add N months to an ISO timestamp, clamping to the last day of the target month. e.g. 2026-01-31 + 1mo → 2026-02-28 Preserves the time component (hour/minute/second/ms). Zero months returns the input with canonical ISO string formatting.
Queue a promise that must complete before the response is sent
Correct projected listing income to the requested amount.
Admin role levels
Schema for admin role levels.
Admin API listing shape — all listing fields except internal indices. Used by both admin JSON API and admin templates to ensure consistent field exposure. Snake_case keys match the DB schema.
Key length AES-256 takes, in bytes.
AES-GCM decrypt with raw key bytes. The mirror of aesGcmEncryptBytes; note it measures the ciphertext, which carries the tag the plaintext does not.
AES-GCM decrypt raw data with an imported key
AES-GCM encrypt with raw key bytes, using whichever implementation is faster for this payload.
AES-GCM encrypt raw data with an imported key, returning IV and ciphertext
AES-GCM encrypt a text string with an imported key
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.
Every admin role level — used to gate actions every authenticated user must reach (e.g. logout). Derived from AdminLevelSchema so adding a new role propagates automatically instead of being hand-listed here.
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.
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).
Example availability response JSON
Example free booking response JSON
Example paid booking response JSON
Example booking request body
Example listing matching the webhook example data
The bundle both package examples describe: browsing lists it, and the package endpoint documents the same one in full.
The example PublicListing, produced by toPublicListing
Example list response JSON
Example single-listing response JSON
Append iframe=true query param to a URL when in iframe mode
Replace form field values with demo data when demo mode is active. Only replaces fields that are present and non-empty in the form. Mutates and returns the same URLSearchParams for chaining.
The day counts a customisable listing offers, ascending: the priced counts
that fall within [1, duration_days] (duration_days is the maximum when
customisable_days is on). Empty for non-customisable listings.
Build a checkout's signed metadata the way the given provider needs it, with the caps read from the provider registry: build the logical shape within the per-value and entry caps, pack the small fields into one entry when the provider needs that to fit, and enforce the caps on the shape that reaches the wire. SumUp's caps are unbounded (its metadata is stored locally, never sent to the provider), which makes the enforcement a no-op for it.
Add listings to a group (membership rows), ignoring any already present.
Narrow an unknown value to string, defaulting to "" if not a string.
Replaces typeof x === "string" ? x : "" at type boundaries.
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.
User-facing messages for attachment validation errors
Attachment validation error
Attachment validation result
Attendee PII fields
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.
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.
Input for creating an attendee atomically (one or more listings)
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
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.
A single row in the attendee table: an attendee plus the listings the row covers, in display order. Roster/check-in tables render one row per booking line (a one-listing array); the browsing tables (attendees list, dashboard) group an attendee's lines into one row carrying every listing.
An attendee with all their listing bookings (for token resolution)
Convert standard base64 to base64url (no padding). Works on both strings and Uint8Array (bytes are first encoded to base64).
The human-readable label for one booking's actual span: the stored
[date, endDate) range when a multi-day range is stored, the listing's
fixed duration when only a start date is (legacy rows written before end
dates were stored), else the single booked day. "" when there is no date.
The ONE booked-range renderer the confirmation email, the /t ticket cards,
and the collapsed package displays share, so they can never disagree about
a booking's stay.
The whole day count of a stored [start_at, end_at) booking range — the
customisable day count the buyer chose. A missing or degenerate range is 1.
Processed booking intent extracted from payment session metadata
Booking result — callers map this to their response format
Resource management pattern (like Haskell's bracket or try-with-resources). Ensures cleanup happens even if the operation throws.
A browsing-table attendee row — every core column plus refunded, but none
of the expensive money projections.
Build an INSERT into listing_attendees, capacity-checked by default.
Build the check-in URL for a single ticket token
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).
Build a DayPrices map from raw entries. checkEntry reads one raw
key/value pair and returns the day count and price to keep, null to skip
that entry, or a problem message — which stops the walk and is returned in
place of the map. Shared by the lenient stored-value reader
(parseDayPrices) and the fail-closed admin API body parser.
Build embed snippets (script and iframe variants) for a ticket URL
Build a frame-ancestors CSP value from allowed embed hosts. Returns null if the list is empty (allow embedding from anywhere).
Build info lines from ticket data (non-PII listing and booking details)
Build input key mapping from DB columns snake_case DB column → camelCase input key
Build checkout session metadata from booking data (items already compact).
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.
Build a complete .pkpass file as a Uint8Array (ZIP archive)
Render a priced order into a provider's line-item array: each ticket line via
line, each extra (booking fee, …) via extra. Providers supply the two
shape callbacks; the ordering (tickets, then extras) matches what Stripe and
Square built by hand before.
Build the full subdomain record name (user choice + suffix). e.g. "mylisting" + ".tickets" → "mylisting.tickets"
Build SVG ticket data from an email entry (non-PII only)
Build the data object exposed to Liquid templates. Rows booked through a
package head the email by the package's name (listing_names); an order may
carry several bundles beside plain rows. hidePackageMembers (set for the
buyer's confirmation, not the admin notification) collapses each HIDDEN
package's member rows into one package row so members aren't revealed —
whatever else the order carries beside them.
Generate SVG ticket attachments for the buyer's row groups: each HIDDEN package collapses to a single package-level SVG so the attachments don't reveal the member listings the email body hides — whatever else the order carries beside the bundle. Callers without package rows pass one group per entry (buyerEntryGroups builds the real thing).
Build a consolidated webhook payload from registration entries
Placeholder in a bulk template marking where each recipient's unsubscribe URL goes.
What the provider returned for one batch: HTTP status, ok flag, and the raw response body. Providers reply with queued message IDs (or rejection reasons), so the body is kept to surface back to the sender and the log.
A bulk send: shared template (html/text may contain the placeholder) + recipients.
One bulk recipient: address plus its unsubscribe URL (marketing sends only).
Outcome of a bulk send: recipients attempted, batches sent, recipients in failed batches, and the raw per-batch provider responses.
One buyer-facing row group: a HIDDEN package's rows gather behind its name; every other row stands alone.
Group an order's entries for buyer-facing rendering (the confirmation body and its SVG tickets): each hidden package's rows collapse into one group sitting where its first row was, so a mixed order conceals every hidden bundle while its other rows render normally.
Cache a provider API client keyed on its config. Reuses the cached client while the config is unchanged and recreates it when the config changes; returns null when the provider is unconfigured.
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.
Why cached data was cleared. Only a committed write needs primary refills.
Build the calendar grid for a YYYY-MM month as a flat list of YYYY-MM-DD strings. The grid is whole Monday→Sunday weeks spanning the month plus one extra full week on each side, so adjacent-month context is always visible.
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.
Registration intent for checkout (one or more listings)
Single item within a checkout — one bookable PATH. A listing booked through two overlapping packages (or a package plus its own standalone row) in one order is one item per path, each with its own quantity and price.
Build a standalone-line CheckoutItem for one listing — the shared
shape every single-listing checkout (direct-to-provider QR booking, the
plain public booking form) builds its one-item items array from.
Result of creating a checkout session.
Delete checkout stages for one or many attendee ids.
Split an array into chunks of a given size.
Curried adapter over @std/collections.chunk (which throws for size < 1).
Clamp a valid whole-day count to the supported booking range.
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)
Run an operation with the lazily-resolved client. Returns null when the
client is unconfigured or the operation fails (unless the error should
propagate). The named type keeps the contract visible to callers of the
widely-used stripeClientRuntime.run so a signature drift fails at the
definition instead of leaking to callers.
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).
Helper to create column definitions
The single-row summary a hidden package collapses to for buyers: the bundle's summed price and quantity plus the widest member's dated stay (hiding members must not lose the date the buyer booked). Shared by the email body row and the SVG ticket, so the two can never disagree.
Create an in-memory collection cache with TTL. Loads all items via fetchAll on first access or after invalidation/expiry, then serves from memory until the TTL expires or invalidate() is called. Accepts an optional clock function for testing.
Compute slug index from slug for blind index lookup
Compute HMAC-SHA256 using Web Crypto API, returning raw ArrayBuffer
Compute the blind index used for listing slug lookups.
Concatenate byte arrays into one array.
Constant-time compare of two code sequences, given their lengths and a per-index code reader for each. Walks the longer sequence and folds every difference into one flag with XOR, so no early return leaks a length or the position of the first mismatch. Callers supply the code source (UTF-8 bytes, UTF-16 char codes, …), keeping this the single constant-time comparison loop.
Constant-time string comparison to prevent timing attacks Always iterates over the longer string and XORs the lengths so that different-length inputs don't leak via an early return.
Constant-time comparison for Uint8Arrays of equal length Caller must ensure arrays have the same length (validated by verifyPassword)
All valid contact field names (runtime array matching the ContactField union)
Individual contact field name
Required name+email with optional phone/address/special_instructions from ContactInfo
Extract ContactInfo fields from an object
Schema for an individual contact field name
Attendee contact details — the core PII fields collected at registration
Admin role levels that may create/edit listings & groups: the back-office
staff plus the content-only editor. Used to gate the listing/group content
routes editors are explicitly opted into; deliberately excludes agent.
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.
Result of atomic attendee creation
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 manifest.json mapping filenames to SHA-1 hashes
Create a request timer for measuring duration
Create seed listings and attendees using efficient batch writes. Encrypts all data before inserting, matching production behavior. Assigns random ticket quantities (1-4) per attendee without overselling.
Create a new session with CSRF token, wrapped data key, and user ID Token is hashed before storage for security
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.
Create a withClient helper that runs an operation with a lazily-resolved client. Returns null if the client is not available or the operation fails.
Shared shape for a provider credential check in connection-test results.
Default message for invalid/expired CSRF form submissions (request-scoped).
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.
Generate a range of YYYY-MM-DD date strings from start to end (inclusive)
Convert a nullable date to the stored half-open range.
The subset of listing fields needed to reason about day-count pricing.
The per-ticket price (minor units) for booking days on a customisable
listing, or null when the listing isn't customisable or that count has no
configured price (and therefore isn't offered).
Per-day-count ticket prices for "customisable days" listings, in minor
units, keyed by the number of days booked. e.g. { 1: 1000, 2: 1800 }
means a 1-day booking costs 1000 and a 2-day booking 1800. Only counts
present here are offered to the visitor.
Strict stored shape for per-day-count prices.
Compute how many days ago an listing started, relative to today in the configured timezone. Returns null if the listing date is today or in the future, or if the date is empty/invalid. For past listings, returns a positive integer (1 = yesterday).
Half-open span covering a non-empty set of YYYY-MM-DD days.
The epoch-ms instant of the START of a calendar day (00:00 local time) in the
given timezone. Used to turn a YYYY-MM-DD filter bound into the integer
occurred_at bound the ledger queries compare against, so a day range is
interpreted in the operator's own timezone rather than UTC.
Decrypt a string value encrypted with encrypt() Expects format: enc:1:$base64iv:$base64ciphertext
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.
Decrypt binary data encrypted with encryptBytes(). Expects ENCB binary format: magic + version + IV + ciphertext.
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.
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
Decrypt data with a symmetric key
Decrypt a value encrypted with encryptWithOwnerKey, using the owner's private key (obtained from the session in admin views).
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.
Resolve after ms milliseconds — for retry backoff and similar waits.
Delete all first-class image files.
Delete all attachment files for a list of listings
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.
One delete-rows-matching-a-field target: which table, matched on which field, for which value.
Delete a file, routing to local or Bunny based on config.
Delete the full-size image and thumbnail files for a first-class image.
Delete an image's storage files, throwing if any file could not be removed (a file that is already gone counts as success, so retries are safe). Unlike deleteImageStorageFiles, this surfaces failures so the caller can keep the image's DB record for a later retry instead of orphaning the stored files under a deleted record.
Delete one listing and its listing-owned relationships in one batch.
Delete the attachment file for a single listing
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
Admin role levels that may reach the delivery run sheet
(/admin/deliveries): staff plus delivery agents. This is the audience the
run sheet has always had; the content-only editor is excluded.
Demo addresses
Demo email addresses
Demo group descriptions
Demo group names
Demo holiday names
Demo listing descriptions — rock-themed gig blurbs assembled from word pools. Like the names above, the list is procedurally generated but seeded so it stays deterministic across runs.
Demo listing locations — pretend rock-venue / festival listings. Procedurally generated from the venue word pools using a seeded PRNG.
Demo listing names — pretend rock/heavy-metal band listings. Generated procedurally from a seeded PRNG so the list stays deterministic across runs (tests rely on this) but offers far more variety than a hand-curated list while staying on-theme.
Demo attendee names (full names for demo mode overrides)
Demo page text (homepage / contact)
Demo phone numbers (UK format)
Demo servicing-event names (a reason/job for a capacity hold, not a person)
Demo special instructions
Demo terms and conditions
Demo website titles
Maps form field names to arrays of possible demo values
A dependsOn entry accepted by cachedTable / cachedEntityTable.
Upload and publish new script code to a Bunny edge script (defaults to this
host's own script when scriptId is omitted).
Legacy (v1) KEK derived from the stored password hash. Retained only to unwrap and migrate existing wrapped_data_keys — new wraps use deriveKEKFromPassword. Salt prefix is empty so this stays byte-compatible with keys wrapped before the v2 split.
Password-bound (v2) KEK derived from the raw password. Because the password is never stored, a database dump plus DB_ENCRYPTION_KEY cannot unwrap the DATA_KEY — this is what binds attendee PII at rest to the account password.
Detect iframe mode from a request URL and store it for the current request
Detect the actual image type from magic bytes. Returns the MIME type if matched, null otherwise.
The attendee fields the shared attendee table and its column registry
actually read. A full decrypted Attendee satisfies it, and so does a
field-selected read that skipped the money subqueries — which is exactly why
the browsing tables can render rows that never computed price_paid or
remaining_balance (see src/shared/db/attendees/select.ts).
Matches a valid hostname like "example.com" or "sub.example.com"
Download and decrypt a file. Returns the decrypted bytes, or null if the file does not exist.
Download raw bytes from storage. Returns null if the file does not exist.
A secret as reported by the Bunny API (name + metadata only — never the value).
Display labels for email providers — keys must match EmailProvider
Format-only email schema: validates an address exactly as typed, without trimming or lowercasing. Used by field validators that check raw user input (see validateEmail in #templates/fields.ts).
Host (everything after the last @) of a validated address. The ValidEmail
type guarantees a host is present, so there is no empty-host case to handle
and the compiler forbids passing a raw, unvalidated string.
Listing data needed for registration pipeline (extends webhook listing with display + assignment fields)
Local part (everything before the last @) of a validated address.
Union of all supported email provider keys, derived from the PROVIDERS map
Picklist schema for the supported email providers. Its options are derived
from the PROVIDERS map so the two can never drift, and it mirrors the
string-union picklists in types.ts (ContactFieldSchema, PaymentProviderSchema
…) — EmailProviderSchema.options + v.is replace the previous hand-rolled
Set + .has() guard.
Canonical email schema used across the app: local@host.tld. valibot's
email action guarantees a non-empty local part and a host containing at
least one dot. The input is trimmed and lowercased before validation, and the
output is branded as ValidEmail so a value can only be produced by passing
validation. All email validation that needs a normalized, carry-onward value
goes through this (see isValidEmail / parseEmail).
A single part of an email template
Schema for the parts of an email template: the subject line, the html body, and the plain-text body.
Persisted email template type
Schema for the persisted email template types: the attendee confirmation and the admin notification. The single source of truth for the template discriminator used by the renderer, settings store, and admin forms.
Enable query logging and clear previous entries
Encrypt a string value using AES-256-GCM via node:crypto (faster than Web Crypto for the small payloads this handles; output stays interoperable). Returns format: enc:1:$base64iv:$base64ciphertext Note: ciphertext includes the GCM auth tag appended.
Encrypt attendee fields into a PII blob.
Encrypt binary data with AES-256-GCM using compact binary format. Output: ENCB + version byte + 12-byte IV + ciphertext (with GCM auth tag). Overhead is only 33 bytes (vs ~76% bloat in the legacy text format).
The fields shared by every named, slugged content record whose free-text
columns are stored encrypted: the body/meta blobs, the display name, and the
/slug permalink paired with its plaintext HMAC blind index (slug_index).
Both site pages and news posts build on this.
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).
Encryption format version prefix Format: enc:1:$base64iv:$base64ciphertext
Encrypt a PII blob JSON string with the public key
Encrypt ticket tokens for the atomic payment finalize.
Encrypt data with a symmetric key (for wrapping private key with DATA_KEY)
Encrypt a value with the site owner's public key (hybrid RSA+AES). Only the owner's password-derived private key can decrypt it. Used for attendee PII, email-preference blobs, and bulk-email drafts/templates. Can be called without authentication (e.g. from public ticket forms).
Enforce a payment provider's metadata limits.
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).
The YYYY-MM-DD calendar day an epoch-ms instant falls on in tz. The
inverse direction of dayStartEpochMs, for labelling a stored
occurred_at as the local day it belongs to.
Error code strings for use in logError calls
Human-readable labels for error codes (shown in admin activity log)
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, 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.
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.
Run a single statement without table-scoped cache invalidation.
Expand a daily-listing range into individual day strings.
Epoch seconds maxAgeSeconds from now — the expiry (e) that signed tokens
carry, kept in one place so every builder computes it the same way.
A copy of base with extra entries merged on top (an extra key wins over
the same key in base). Curried so a constant overlay (a fixed content type,
a default set) can extend whatever record it is given.
Normalize validated session metadata into the canonical SessionMetadata shape.
Extract the inner content of an SVG element (strip the outer <svg> wrapper)
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.
Extract the viewBox from an SVG element to compute its coordinate space
Index one field from each item by its id.
Curried filter
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.
Run a valibot schema with abortPipeEarly and return the first error message or null.
Alternative combinator: try a sequence of producers in order and return the first that yields a defined value, or undefined if every one declines.
Check items in order and stop at the first reported problem.
Visual level of a flash message: a positive result, a failure, or a neutral acknowledgement.
Curried flatMap
Await all queued work. Call before returning the response. Loops until the queue stays empty: work already running can queue more (a background job that fails queues its error's activity-log write), and a single pass would discard those late arrivals unawaited.
Format an amount in minor units (pence/cents) as a currency string. e.g. formatCurrency(1050) → "£10.50" (when currency is GBP)
Format a YYYY-MM-DD date for display. Returns "Monday 15 March 2026"
Format a UTC ISO datetime as a date-only label in the configured timezone, e.g. "Monday 15 June 2026" — no time. Returns "" for an empty/invalid input. Used where a stored timestamp should read as a plain published date (the public news post page).
Format a booking's stored [start_at, end_at) ISO range as a human label.
1-day bookings collapse to formatDateLabel; multi-day bookings use the
compact English range formatter (inclusive — subtracts 1 day from end_at,
which is the first midnight after the booked window).
Compact English date-range formatter. Uses an en dash (–) for ranges.
Format a UTC ISO datetime string for display in the given timezone. Returns e.g. "Monday 15 June 2026 at 14:00 BST"
Format an ISO datetime string for display in the given timezone. Returns e.g. "Monday 15 June 2026 at 14:00 BST"
Compact ISO datetime formatter for table cells. Returns e.g. "07/04/2026 14:00" in the configured timezone.
Compact format for table cells: "yyyy-MM-dd HH:mm" in the given timezone.
Delegates to the browser-compatible formatIsoForPreview helper so the
same formatting runs on the server and in the admin JS bundle.
Format an error context into a human-readable activity log message
Format a YYYY-MM month string for display, e.g. "July 2026".
Format IV + ciphertext as a prefixed base64 string
Format an error detail string with request context and error message
Format a signed change in minor units. Positive value is added, negative value is removed, and zero has no misleading sign.
Human "time ago" label for a past ISO timestamp, relative to nowMsValue
(epoch ms), via Intl.RelativeTimeFormat in the largest whole unit that
applies — e.g. "now", "5 minutes ago", "yesterday", "2 days ago". Returns
null for an unparseable or future timestamp.
Convert base64 string to Uint8Array
Convert a base64url string (no padding) back to a Uint8Array — the inverse of toBase64Url.
Generate a random CDN filename preserving the original name for readability
Generate a random 256-bit symmetric key for data encryption
Generate an RSA key pair for asymmetric encryption Returns { publicKey, privateKey } as exportable JWK strings
Build the pass.json content from pass data and signing credentials
Generate an SVG string for a QR code encoding the given text. Returns a complete <svg> element suitable for inline embedding.
Generate a cryptographically secure random token Uses Web Crypto API getRandomValues
Generate a random slug with at least 2 digits and 2 letters. Uses Fisher-Yates shuffle on the fixed positions to avoid bias.
Generate a standalone SVG ticket with QR code and listing/booking details. Returns a complete SVG document string.
Generate a 5-byte uppercase hex ticket token for public ticket URLs
Generate a unique group slug, retrying on collision.
Generate a unique slug by retrying random slugs until one is not taken.
Generate a random .webp filename. Every uploaded image is transcoded to
WebP, so stored image variants always carry the .webp extension.
The email config sending should use: the site's own settings when complete, otherwise the host-level config. Null when neither is configured — callers treat that as "email is off".
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.
Collect stats from all registered caches
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.
Compute available booking dates for a daily listing.
Filters by bookable days of the week and excludes holidays.
For listings with duration_days > 1, excludes start dates whose full range
would hit a non-bookable day or extend past the booking window.
Extract the basename from a path (handles both forward and backslash separators)
Available start dates for a daily listing's booking/date pickers. Customisable-days listings use single-day availability — the span is chosen separately and validated at submit time — so every individually-bookable start is offered; other listings use their fixed duration.
Get booking fee percentage from database. Returns 0 if not set.
Get the Botpoison public key from environment (safe to expose to browsers). Returns empty string when unset.
Get the Botpoison secret key from environment (server-side verification only). Returns empty string when unset.
Get the Bunny CDN API key from environment
Get the Bunny DNS subdomain suffix (e.g. ".tickets") from environment
Get the Bunny DNS zone ID from environment
Get the Bunny Edge Script ID from environment
Return the cached session if already resolved, or undefined if not yet resolved
Read only active, effectively visible listings for the public catalog.
Get CDN hostname (delegates to bunnyCdnApi for testability).
Get the most recently generated CSRF token (for synchronous JSX rendering)
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
Diagnostic key gating the verbose /health response. Empty when unset, in
which case /health only ever returns the plain liveness reply. Holding the
key reveals non-private build/runtime diagnostics (commit, build time) that
are useful for operators but needlessly helpful to an attacker.
Get the number of decimal places for a currency code
Get the default database provider from DEFAULT_DB_HOST env var. Returns "turso" when set to "turso", "bunny" otherwise.
Get the Deno Deploy organization ID from environment.
Get the Deno Deploy organization slug used in managed production domains.
Get the Deno Deploy API token from environment.
Get the effective domain synchronously; DEFAULT_DOMAIN until a request resolves a real one.
Read email config from DB settings. Falls back to business email for fromAddress. Returns null if not configured or the from address is invalid.
Get allowed embed hosts from database (encrypted, parsed to array) Returns empty array if not configured (embedding allowed from anywhere)
Raw 256-bit encryption key bytes, decoded once from DB_ENCRYPTION_KEY
Get the encryption key bytes from environment variable (sync validation only) Expects DB_ENCRYPTION_KEY to be a base64-encoded 256-bit (32 byte) key
Get an environment variable value Checks process.env first (Bunny Edge), falls back to Deno.env (local dev)
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.
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.
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 host-level email config. Uses test override if set, otherwise reads env vars.
Get the current request's iframe mode
Images are encrypted at rest, so browsers load them through this route.
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.
Get all listings in a group with attendee counts (including inactive).
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.
Everything a caller declares to read listing records: which rows to keep and in what order.
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.
The shared secret authorizing the inter-instance site-credentials endpoint.
Get the MIME type for an image filename from its extension.
Get the newest attendees across all listings without decrypting PII. Used for the admin dashboard to show recent registrations.
Get the next available booking date for a daily listing. More efficient than getAvailableDates()[0] — stops at first match. Returns null if no bookable dates are available.
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.
Derive the private key from session credentials Used to decrypt attendee PII in admin views Results are cached per session token for 10 seconds
Return a snapshot of all logged queries
Return the start time recorded by enableQueryLog()
Generate random bytes using Web Crypto API
Get the READ_ONLY_FROM cutoff ISO string, or null if not set
Get the RENEWAL_URL, or null if not set
Get the current request ID, or empty string if outside request context
Get a session by token (with 10s TTL cache) Token is hashed for database lookup
Returns which storage backend is active: "bunny", "local", or "none".
Read requested listings' stored values without overlaying inherited defaults.
Read one listing's stored values without overlaying inherited defaults.
Get the Turso API token from environment.
Get the Turso database group from environment.
Get the Turso organization name from environment.
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.
Group name and description fields
Does a group row exist? The add-item revalidation's single-row check — no name decryption, never the whole table.
The group ids each listing belongs to (listing id → group ids). A listing absent from the map belongs to no group.
A membership of listing_id in group_id, hydrated for the package editor
and booking flow. A listing may belong to several groups. package_price
(minor units) is the per-listing override when the group is a package, read
from the group dimension of listing_prices (not a column on the join
table): null means no override (use the listing's own price), 0 means
explicitly free in the package, and a positive value overrides the price.
quantity (≥1, stored on the join row) is how many of this listing one unit of
the package includes. Both are ignored for non-package groups.
The listing ids in a group, and the reverse listing-to-groups side.
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.
Group rows by a key, keeping only the chosen value from each row. Keys appear in first-occurrence order and each value list preserves input order.
A group paired with the active member listings used to decide and render it.
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
Hash a password using PBKDF2 Returns format: pbkdf2:iterations:$base64salt:$base64hash
Hash a session token using SHA-256 Used to store session lookups without exposing the actual token
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.
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.
True when running inside a runWithPendingWork scope (i.e. a request).
True when an attendee/booking row is a real ticket (quantity ≥ 1) rather than
the no-quantity sentinel (quantity 0). The shared "is this a real ticket, not a
ghost" test for the readers, rosters, and exports that must skip sentinel rows —
one home for the rule instead of a bare quantity > 0 plus an explanatory
comment at each call site.
HMAC-SHA256 hash using DB_ENCRYPTION_KEY Used for blind indexes and hashing limited keyspace values Returns deterministic output for same input (unlike encrypt)
Hex-encoded HMAC-SHA256 of a UTF-8 message under the given secret.
Convert ArrayBuffer to base64 string
Convert ArrayBuffer to hex string
Holiday name field
Cached holidays table — name is encrypted, dates are plaintext; writes auto-invalidate the cache.
Prefix tagging a hybrid (RSA+AES) ciphertext, e.g. owner-key activity-log messages and attendee PII. Distinguishes them from env-key ENCRYPTION_PREFIX values so a decrypt path can route by format.
Decrypt data using hybrid encryption Expects format: hyb:1:$base64WrappedKey:$base64iv:$base64ciphertext Results are cached in a bounded LRU (ciphertext -> plaintext)
Encrypt data using hybrid encryption (RSA + AES)
Shared generated id + plaintext created stamp columns. created stays
unencrypted so SQL can order and prune by time without decrypting.
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.
Return a value unchanged.
User-facing messages for image validation errors
Schema for the kind of item an image can be attached to.
Image validation error
Image validation result
Import a CryptoKey from DB_ENCRYPTION_KEY.
Import a private key from JWK string
Import a public key from JWK string
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.
Fire every cache invalidator registered against table (no-op if none).
Treats the write as unconditional (INSERT semantics): always fires column-gated entries too.
Fire registered cache invalidators for table, respecting column gates.
Forget the per-isolate "database is ready" cache.
Clear the listing entity cache.
Invalidate the users cache (for testing or after writes).
Type guard: check if a string is a valid AdminLevel
Type guard: narrows an arbitrary string to an AttendeeSort.
Whether booking days consecutive days starting on date is valid for a
daily listing: every day must be a bookable weekday, fall outside all
holidays, and stay within the listing's booking window. Used to enforce the
visitor's chosen span on "customisable days" listings at submit time, where
the day count isn't known when the date list is rendered.
Check if Botpoison spam protection is configured. Requires both BOTPOISON_PUBLIC_KEY and BOTPOISON_SECRET_KEY to be set. This gates the public contact form feature.
Check if this instance can build other sites.
Check if Bunny CDN pull zone management is enabled Requires both BUNNY_API_KEY and BUNNY_SCRIPT_ID to be set
Check if the Bunny hosted database provider is enabled (requires BUNNY_API_KEY).
Check if Bunny DNS subdomain feature is enabled. Requires BUNNY_API_KEY and BUNNY_DNS_ZONE_ID to be set.
Type guard: check if an arbitrary string is a valid ContactField
True for roles that may create/edit listings & groups (owner/manager/editor).
Whether database calls are currently subject to Bunny's per-request cap.
True for roles that may reach the delivery run sheet (owner/manager/agent).
Check if demo mode is enabled
Check if Deno Deploy hosting has its token, organization ID, and domain slug.
Type guard: checks if a string is a valid EmailProvider
Type guard: check if a string is a valid EmailTemplateFormat
Type guard: check if a string is a valid EmailTemplateType
Check if a group slug is already in use. Checks both listings and groups for cross-table uniqueness.
Whether the inter-instance site-credentials endpoint is enabled. Off unless MAIN_INSTANCE_KEY is set, so a non-builder instance never exposes it. The key is a high-entropy shared secret the operator passes to the upgrade workflow at trigger time (it is never stored in GitHub).
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)
Pure helper: is the current time within the warning window before cutoff?
Type guard: check if an arbitrary string is a valid ListingType
Remove null and undefined values from array
ISO timestamp a fixed duration before the current time.
True only for the owner role.
Whether an listing can accept payments: a flat price, pay-what-you-want, or a customisable-days listing with at least one non-zero day-count price.
Type guard: check if a string is a valid PaymentProviderType
Type guard: check if a string is a valid PaymentProviderSetting
Check if payments are enabled (any provider configured with valid keys)
Check if the system is in read-only mode based on the READ_ONLY_FROM cutoff
Pure helper: is the site read-only based on a cutoff timestamp?
Check if the site should show a pre-expiry warning banner
Type guard: a non-null, non-array object (a Record shape).
Check if a reservation is stale (abandoned by a crashed process)
True when a link/image URL is safe to render. Relative URLs (no scheme) are
allowed; absolute URLs must use a scheme from SAFE_URL_SCHEMES.
Leading ASCII control characters and spaces — which browsers strip before
resolving a scheme — are removed first so java\tscript: can't sneak through.
Whether we are serving a real, resolved host rather than the default. Gates
HTTPS-only behaviour — Secure/__Host- cookies and the HSTS header — which
must stay off for local development on DEFAULT_DOMAIN.
Check if a payment session has already been processed
Check whether a token uses the signed format
True when text is markdown so simple it renders as nothing more than a
single <p> of plain text — no bold, italic, links, lists, headings, code,
blockquotes, tables, or multiple paragraphs. When this returns true the
question can safely be used as the clickable label of its control; when
false the question should be rendered as a prose block above the control.
Type guard: is this string a valid SitePageItemType?
True for roles that may edit public-site content (owner/editor).
Check whether a slug is already used, optionally excluding one listing.
True for back-office staff (owner/manager).
Check if image storage is enabled (Bunny CDN or local filesystem).
Check if Turso hosted database provider is enabled (requires TURSO_API_TOKEN, TURSO_ORGANIZATION, TURSO_GROUP).
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
Check if a naive datetime-local string is a parseable datetime. Does not interpret timezone — purely a format check.
Whether a string is a valid email (trimmed) per EmailSchema.
Validate that a string is a valid IANA timezone identifier.
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 ").
Join an array of strings into a single string (curried reduce shorthand). Replaces the common pattern: reduce((acc: string, s: string) => acc + s, "")
Schema version label and the migrations bookkeeping table name.
Resettable lazy reference - like once() but can be reset for testing. Returns [get, set] tuple where set(null) resets to uncomputed state.
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.
List files in storage matching a prefix (names only), sorted by name.
List files (with size metadata) matching a path prefix, sorted by name. The
prefix may name a subfolder (see splitListingPrefix); returned names always
include that folder so callers can download/delete them directly. For Bunny
CDN the size comes from the Length field of the listing API.
- active
- assign_built_site
- attachment_name
- attachment_url
- bookable_alone
- bookable_days
- can_pay_more
- closes_at
- created
- customisable_days
- date
- day_prices
- description
- duration_days
- fields
- hidden
- id
- initial_site_months
- listing_type
- location
- max_attendees
- max_price
- max_quantity
- maximum_days_after
- minimum_days_before
- months_per_unit
- name
- non_transferable
- purchase_only
- slug
- slug_index
- thank_you_url
- unit_price
- use_defaults
- uses_logistics
- webhook_url
Listing metadata fields
Per-listing answer references carried through a checkout, shared by the booking and checkout intents.
Row from listing_attendees — per-listing booking data
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 single listing booking within a multi-listing attendee creation
A listing's identity, capacity, and current booked quantity.
Convert a UTC ISO datetime to a YYYY-MM-DD calendar date in the given timezone. Returns null if the input is empty or invalid. Used by the calendar view to map standard listing dates to calendar days.
Contact fields setting for an listing (comma-separated ContactField names, or empty for name-only). Alias kept for documentation; runtime enforcement happens in parseListingFields.
True when the listing still has spots for this quantity on this date (no date for a date-less listing, whose capacity is one running total).
Read and decrypt listing names without loading full records.
The shared narrow listing shape used by listing and attribute pickers.
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.
Listing CRUD with cache invalidation and listing-price synchronization.
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.
Whether this listing can send QR code scanners directly to checkout. True when no extra contact fields or questions are required.
Listing type: standard (one-time) or daily (date-based booking)
Schema for a listing type: standard (one-time) or daily (date-based booking)
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.
Apply demo-mode overrides to the form, then run the loader — the shared prelude of the attendee submit handlers, which 404 when the loader finds nothing (a stale id racing a delete).
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.
Load the effective domain from DB, falling back to the request URL hostname.
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.
Load all listings with holidays and return them sorted. With a filter, only the listings it keeps are returned; without one, every listing is returned.
Convert a naive datetime-local value (YYYY-MM-DDTHH:MM) to a UTC ISO string, interpreting the value as local time in the given timezone.
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.
Log attendee registration and send consolidated webhook Used for single-listing registrations
Log categories for debug logging
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.
Log a failed database operation under the standard DB_QUERY code.
Log a debug message with category prefix For detailed debugging during development
Log a classified error to console.error and persist to the activity log. Console output uses error codes and safe metadata (never PII). Activity log entry is encrypted and visible to admins on the log pages.
Log a classified error to console.error only (no ntfy, no activity log). Use this where calling logError would cause infinite recursion (e.g. ntfy.ts).
Attendee Logistics tab fields: the address is masked like the attendee form's, and the pinned latitude/longitude are cleared outright — an exact real-world location is PII even beside a masked address.
Log a completed request to console.debug Path is automatically redacted for privacy
Build a provider's createCheckoutSession: call the provider's own create
function, read the session id and URL off whatever shape it returns, and map
that to a shared CheckoutSessionResult — all inside the standard checkout
error guard. Each provider only supplies its create call, how to read the
id/url, and its display label.
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.
Curried map
Index items by one field and chosen value. Keys keep first-occurrence order, while later matching items replace the stored value.
Index items by id and a chosen value.
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).
Curried map that drops null/undefined results in one pass.
Curried adapter over @std/collections.mapNotNullish.
Replaces the two-step pattern: compact(map(fn)(array))
Map over a promise-returning function in parallel (Promise.all)
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.
Maximum attachment file size in bytes (default: 25MB)
Upper bound on multi-day booking duration. Each day in a booking range adds a per-day clause to the atomic capacity SQL, so the cap keeps that statement bounded regardless of which write path set the value.
Determine which contact fields to collect for multiple listings. Returns the union of all field settings, sorted by canonical CONTACT_FIELDS order.
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.
An owner-defined price modifier (surcharge / discount / add-on). calc_value
is the positive magnitude the owner entered (a fixed amount in major currency
units, a percentage, or a multiplier); direction chooses charge vs discount.
Compact modifier reference stored in session metadata: the modifier id and the quantity taken. The webhook re-fetches the modifier by id and re-derives its amount from the current database — provider metadata amounts are never trusted.
A modifier resolved for a specific checkout — the input the pricing pipeline
applies. Eligibility (scope, stock, codes) is decided upstream; by the time a
spec reaches pricing it is known to apply. value is the signed calc value
(see modifierDelta); listingIds scopes which items it is charged on
(null = the whole order); quantity is how many the buyer took (1 for an
automatic or code modifier, more for an opt-in add-on).
List every YYYY-MM month within yearsEitherSide years of the given month's
year, in ascending order. e.g. monthsAround("2026-03", 5) runs from
"2021-01" to "2031-12". Used to populate the calendar's month picker.
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.
Unique identifiers for settings nags that prompt the admin to complete required or recommended configuration.
Input shared by ordered tables whose only required value is a name.
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.
A news post shown on the public /news page. All free-text columns are
stored encrypted; created stays plaintext (like listings) so the
newest-first ordering and the RSS pubDate never need a scan-and-decrypt.
slug is the /news/:slug permalink (auto-generated from the created date
and the name at creation, then immutable); slug_index is its blind index.
The public /news list projection: a summary plus the post's first image (the shared ItemImageProjection columns).
The narrow list projection — id, created, slug, name, snippet — for readers
that render no images (the RSS feed, the admin list). Never the large
content/meta_* blobs (cold-start efficiency, like SitePageNavRow).
Normalize datetime-local "YYYY-MM-DDTHH:MM" to full UTC ISO string. The input is interpreted as local time in the given timezone and converted to UTC.
Strip non-numeric characters from a phone number and normalize to +{prefix}{local}
Normalize a user-provided slug: trim, lowercase, replace spaces with hyphens
Current time as a Date
Full ISO-8601 timestamp for created/logged_at fields
Epoch milliseconds for numeric comparisons
Current time in whole epoch seconds — the unit signed-token expiry uses.
Lazy evaluation - compute once on first call, cache forever.
Use instead of let x = null; const getX = () => x ??= compute();
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.
Package-group display info for grouping a booking's lines under the package name on tickets/emails.
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.
Collapse the packable small fields into one JSON b entry, dropping them
from the top level. Falsy values are omitted (the "" = absent convention), so
the b entry only appears when at least one packed field is actually present.
Pad a serial number to meet Apple's minimum authenticationToken length. Uses "-" (not in uppercase hex charset) so padding is cleanly reversible.
Units of a shared capped group consumed by one parent+child order: the parent line plus its single required child line each take one spot in the group they share (invariants I1, I7). Used to convert a shared group's remaining spots into how many whole parent+child orders still fit.
Parse a Bunny API error response into a BunnyApiResult.
Parse a date/timestamp string to epoch milliseconds, or null when it
doesn't parse — the safe wrapper around Date.parse, whose own failure
mode (NaN) is easy to let leak into arithmetic by accident.
Coerce an arbitrary stored/parsed value into a clean DayPrices map. Keeps only whole-number day counts in [1, MAX_DURATION_DAYS] mapped to finite, non-negative whole-number minor-unit prices; everything else is dropped. Used on both the DB read path and form parsing so the rest of the code can treat the map as already-valid.
Parse and normalize a candidate email, returning the branded ValidEmail when it is valid or null otherwise. Use this in preference to isValidEmail when the validated address needs to be carried onward in a type-safe way.
Parse a comma-separated list of hosts into trimmed, lowercased entries. Filters out empty strings from trailing commas etc.
Parse a prefixed encrypted payload into IV and ciphertext bytes. Validates the prefix and separator; throws on invalid format.
Parse a flash cookie value into type, message, optional result, and the optional form re-fill stash token.
Parse a user-supplied YYYY-MM-DD value (a ?date= query param): the string
when well-formed and a real calendar date, else null. The UTC round-trip
rejects rolled-over impossibilities like 2025-02-30.
Parse a comma-separated fields string into individual ContactField names
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.
Parse a string into a positive integer for warning days. Returns defaultVal on bad input.
Split an array into [matching, rest] by a predicate, keeping order.
Curried adapter over @std/collections.partition.
Data needed to generate a pass — maps to existing ticket/listing data
Schema for supported payment provider identifiers
Persisted payment-provider setting: an explicit provider, "none" (admin saved payments-disabled), or absent (never saved — drives the settings nag).
Supported payment provider identifiers
Stubbable API for internal calls (testable via spyOn, like stripeApi/squareApi)
Valid payment status value
Schema for valid payment status values. "failed" is a terminal non-payment (declined or expired checkout) — distinct from "unpaid", which may still complete.
Error subclass for user-facing payment validation errors (e.g. invalid phone number). These propagate through safeAsync so the message can be shown to the user.
Per-day quantity sums from rows fetched for the whole span.
Build tel: and wa.me hrefs for a phone number, or null when the number
has no digits. The prefix is the country dialling code (e.g. "44"); a
leading "+" is tolerated so a settings value of either "44" or "+44" works.
WhatsApp's wa.me wants the international number with no leading "+".
Current PII blob schema version
Compose functions left-to-right (pipe).
Process a single-listing booking.
Pick the value for the active payment provider from a per-provider set, or
null when no known provider is active. The one place the stripe/square/sumup
dispatch lives, so every per-provider flag is read the same way.
Query all rows, returning a typed array.
Execute a SQL query and map result rows through an async transformer.
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.
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.
Pick a random element from an array
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.
Raw listings table. Records adds cache-aware CRUD and price syncing.
Embed a raw SQL expression (e.g. last_insert_rowid())
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.
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.
Redact dynamic segments from paths for privacy-safe logging Replaces:
Curried reduce
Register a bunny subdomain (DNS + CDN).
Register an extra full-clear to run with a write cause when every cache is
reset. Only needed by caches without a table registration;
resetAllCaches already fires every table-registered invalidator.
Register invalidate to fire whenever ownTable or any deps entry is
written. Plain string entries are unconditional; object entries may carry
whenColumns to gate on specific UPDATE columns (INSERT/DELETE always fire).
Centralises the registration loop shared by cachedTable and cachedEntityTable.
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.
Render all 3 parts (subject, html, text) using custom templates with fallback to defaults
Render markdown to HTML (block-level: paragraphs, lists, etc.). Raw HTML is escaped and unsafe URLs are stripped.
Render a single Liquid template string with the given data
Read a required map entry, failing where a broken completeness invariant is first observed instead of passing an undefined value onward.
Get a required environment variable, throwing if not set.
Use this instead of getEnv(key) as string when the variable must exist.
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.
Result of session reservation attempt
Reset selected aggregate columns from trusted SQL expressions. Each expression must use the entity id as its only placeholder.
Clear every registered cache: each table-registered invalidator once (a cache registered against several tables still only clears once) plus every extra reset hook. Runs after operations that bypass the normal write path — a full reset or a restore — where any warm cache is stale. A lazily-loaded cache module that never ran is absent from the registry, which is correct: it has no cache to clear.
Reset the database by dropping all tables (reverse order for FK safety)
Reset cached demo mode value (for testing and cache invalidation)
Reset effective domain cache back to the default (for testing).
For testing: reset the engine (so filters can be re-registered after currency changes)
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.
For testing: reset host email config to read from env vars.
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.
Run a function within a CSRF-token scope (one container per request)
Run a function within an iframe-mode scope (one container per request)
Run a function within a pending-work scope. Whatever fn resolves to, the
queue is drained once more on the way out: an error logged after the
request's own flush (e.g. while the response is finalised) still queues
work, and work that outlived its request would complete during whatever
runs next — on Bunny that's a killed fetch, in tests a sanitizer failure
in an unrelated test.
Run a function with a request-scoped random ID for log correlation
Run a function within a session-memoization scope
Run fn with an isolated storage configuration (test-only).
Safely execute async operation, returning null on error. Re-throws PaymentUserError so user-facing messages propagate.
Strip a path's basename down with each [pattern, replacement] rule in
turn, falling back to "file" when nothing is left. Shared by every filename
sanitiser — each supplies its own character rules.
Ordered table names — matches FK dependency order (parents before children)
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.
Constant-time string comparison (over UTF-16 char codes) to prevent timing
attacks. Shares the one constant-time loop in constantTimeCodesEqual.
Max attendees per seeded listing
Seed the effective domain from the request's own hostname.
Send a bulk email via the configured provider. Every supported provider has a
batch endpoint, so this works for any EmailConfig. Chunks recipients to the
provider's batch limit and POSTs each chunk; logs (never throws) on a non-OK
batch, whose recipients then count as failed. Each batch's provider response
is captured so the caller can relay it to the sender.
Send a single email via the configured provider. Logs errors, never throws. Returns HTTP status or undefined on non-HTTP errors.
Send an error notification to the configured ntfy URL Returns a promise so callers can await delivery if needed. Delivery failures are logged locally (via logErrorLocal) but never throw.
Send registration confirmation + admin notification emails. Entries is an array because one registration can cover multiple listings. Silently skips if email is not configured. Attaches one SVG ticket per entry to the confirmation email.
Send consolidated webhook to all unique webhook URLs for the given entries
Send a test email to the business email address. Returns HTTP status or undefined on non-HTTP errors.
Send a webhook payload to a URL Fires and forgets - errors are logged but don't block registration
Servicing-event fields — name only, and a servicing reason rather than a person's name, so demo mode doesn't turn "Boiler Service" into "Bob Smith".
Metadata attached to a validated payment session.
Store the resolved session in the current request scope
Set database client (for testing)
Explicitly set demo mode on or off (for testing). Bypasses Deno.env to avoid races between parallel test workers.
Set effective domain directly (for testing).
Explicitly set or clear the encryption key for testing. Bypasses Deno.env to avoid races between parallel test workers. Automatically clears all crypto caches (encryption, HMAC, and any registered via onEncryptionKeyChange).
Explicitly enable/disable fast PBKDF2 for testing without env var races
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.
For testing: set host email config directly. Bypasses env vars to avoid races.
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).
Explicitly set RSA key size for testing without env var races
Test-only: set the suite-level storage config that describeWithEnv's storage
option applies. A directly-exported named function (not an export {} list,
which the test-hook scanner does not detect, nor a module-level alias) so it is
visible to and registered in ALLOWED_TEST_HOOKS
(test/lib/code-quality.test.ts), alongside runWithStorageConfig.
Set module-level request log suppression (avoids env race in parallel tests).
Full settings snapshot type.
Set up a webhook endpoint for a provider. Some providers (e.g. Stripe) support programmatic creation; recreating any existing endpoint returns a fresh signing secret. Shared by the provider interface and each provider's own implementation so the signature can't drift.
Apple requires SHA-1 for manifest entries; CMS signs the manifest with SHA-256.
Shift a YYYY-MM month string by delta months (negative goes backwards).
Crosses year boundaries: shiftMonth("2026-12", 1) → "2027-01".
Create a signed CSRF token: s1.{timestamp}.{nonce}.{hmac}
The payload/signature pair a test POSTs to a provider webhook route.
Build a test webhook delivery: JSON-encode the event, sign it with the
provider's own signing rule, and return the payload/signature pair a test
can POST to the webhook route. Each provider supplies only sign.
Convert single-listing answerIds to the per-listing format used in metadata
Admin role levels that may edit the public-facing site content (homepage,
contact, order intro). Site editing has always been owner-only; the editor
role is added to it, but manager stays excluded — so this is owner+editor,
NOT the broader CONTENT_ADMIN_LEVELS.
Site contact page fields
Site homepage fields
A user-created content page. Adds sort_order, which positions the page
among root-level pages.
One ordered membership edge: item (of item_type) sits inside page_id
at sort_order. Keyed on the composite (page_id, item_type, item_id).
The kind of thing a SitePageItem points at. Exhaustive union — a new
member is a compile error at every Record<SitePageItemType, …> dispatch.
Schema for the kind of thing a SitePageItem points at.
Turn arbitrary text into a URL slug: lowercase, every run of non
[a-z0-9] collapsed to a single hyphen, no leading/trailing hyphen. Shared
by the news permalink builder and the provider-resource slug.
Sanitize a site name into a valid provider resource slug: the shared
slugify, capped at maxLength with any hyphen the cut left trimmed.
Slug-with-index pair. Index is the blind-index type computeIndex
produces (a BlindIndex for the real tables).
Every config key that maps to a snapshot field, in load order.
Non-mutating sort with comparator
The listing values needed to place a listing in the shared sort order: which tier it belongs to (its type and date), its name for the within-tier sort, and — for a daily listing — the values that decide its next bookable date. A read that only lists or picks listings can select these columns alone and skip the whole listing record.
Sort listings in unified 3-tier order. Takes anything carrying the values the order is built from, so a narrow picker read sorts the same way a full listing record does.
Sort strings in ascending locale order without changing the input.
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.
Wall-clock milliseconds during which at least one query was in flight: the combined length of the query intervals with overlaps merged.
Admin role levels that are back-office staff (not delivery agents).
Threshold for abandoned payment reservations in ms (default: 300000 = 5 min)
Round a date down to the start of the current hour for cache-stable signatures
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.
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).
Sum an array of numbers (identity selector shorthand for sumOf). Replaces the common pattern: reduce((acc, n) => acc + n, 0)
Curried group-and-sum: accumulate valueOf(item) into a Map keyed by
keyOf(item). Replaces the common pattern:
const m = new Map(); for (const x of xs) m.set(k(x), (m.get(k(x)) ?? 0) + v(x))
The buyer's summed price (minor units) across an order's entries.
The bundle's summed booked quantity across an order's entries.
Curried sum-by-selector. Adds up the numbers produced by selector for each
item. Curried adapter over @std/collections.sumOf.
Replaces the common pattern: reduce((acc, x) => acc + selector(x), 0)
Non-PII ticket data for SVG rendering (extends shared wallet fields with display-formatted values)
Decrypt a prefixed AES-GCM payload with the given key.
Encrypt plaintext with an AES-GCM key, returning prefixed format: enc:1:$base64iv:$base64ciphertext
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
Data object passed to Liquid templates
Terms and conditions field
UI theme
Convert Uint8Array to base64 string
Convert Uint8Array to base64url string (no padding)
Convert registration line items to compact, edge-tagged booking items (v2).
Each package member line carries ITS OWN package edge (k:"p", r=its group
id) so the webhook can revalidate each line's nodeKey — an order can book
several packages, so the edge is per line, never order-wide; folded children
(in allocations) and standalone lines stay untagged. See signed-metadata.ts.
Convert snake_case to camelCase (e.g. max_attendees → maxAttendees).
Normalise a provider timestamp to the ledger's canonical ISO 8601 form
(YYYY-MM-DDTHH:mm:ss.sssZ), or undefined when it's absent or unparseable.
Convert a provider-specific checkout result to a CheckoutSessionResult. Returns null if session ID or URL is missing.
Get today's date as YYYY-MM-DD in the given timezone.
Convert minor units to major units string for form display. e.g. toMajorUnits(1050) → "10.50" (for GBP)
Convert major units (decimal) to minor units (integer). e.g. toMinorUnits(10.50) → 1050 (for GBP)
Compact the resolved modifier specs to id/quantity references for metadata.
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.
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.
Strip padding added by padAuthToken to recover the original serial number
Try to delete a file from storage, logging errors on failure
Create a TTL (Time-To-Live) cache. Entries expire after ttlMs milliseconds. Accepts an optional clock function for testing.
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.
Remove duplicate values (by reference/value equality), keeping first
occurrences in order. Curried adapter over @std/collections.distinct.
Remove duplicates by a key function, keeping first occurrences in order.
Curried adapter over @std/collections.distinctBy.
Make a deterministic base slug unique by appending -2, -3, … until one
is free. Unlike generateUniqueSlug (random 5-char slugs), this keeps
a human-readable base — the news permalink yyyy-MM-dd-post-name — and only
disambiguates on collision (two same-day posts with the same name).
Remove one registration from the registry (handed back by the register functions; production callers register for the process's lifetime and drop it, tests must call it so their entries never outlive the test).
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.
Unwrap a symmetric key Expects format: wk:1:$base64iv:$base64wrapped
Unwrap a key using a session token
Unwrap a session's DATA_KEY from its token. An authenticated session that reaches a data-key operation always carries a wrapped data key, so a missing one is a broken invariant — throw rather than invent a key.
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).
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)
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.
Updates the business email in the database. Pass empty string to clear the business email. Email is encrypted at rest.
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.
Upload an attachment to Bunny storage. Encrypts the file bytes before uploading. Uses the provided filename (caller generates via generateAttachmentFilename). Returns the filename on success.
Transcode an uploaded image to WebP and store one file per target.
Upload raw bytes to storage, routing to local or Bunny based on config
A link between an agent user and a logistics agent (van/crew) they drive. Many-to-many: a user may cover several agents and an agent may have several users.
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.
Convert a UTC ISO datetime string to a datetime-local input value (YYYY-MM-DDTHH:MM) in the given timezone. Used for pre-populating form inputs with timezone-adjusted values.
Parse a UTC ISO string into a ZonedDateTime in the given timezone
Valid provider names (the picklist options), derived from the PROVIDERS map
Validate an attachment file: check size only (any file type allowed).
Validate a custom domain (delegates to bunnyCdnApi for testability).
A validated payment session returned after checkout completion
Assemble the one ValidatedPaymentSession shape every provider adapter
returns. Owns the createdAt rule — the key is left out entirely when the
provider gave no usable timestamp — and normalizes the guarded wire metadata
into the canonical shape. metadata must already have passed
hasRequiredSessionMetadata (or come from our own staged checkout row).
Validate a comma-separated list of host patterns. Returns null if all valid, or the first error message.
Validate encryption key is present and valid Call this on startup to fail fast if key is missing
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).
Validate a single host pattern Returns null if valid, or an error message if invalid
Validate an image file: check size, the declared MIME type, and the magic
bytes. Both the declared type and the sniffed content must be an accepted
upload format; a mismatch or an unsupported format (e.g.
a GIF, or a file whose bytes don't match any decodable format) is rejected.
On success, detectedType is the sniffed format the transcoder will decode.
Validate a normalized slug. Returns error message or null.
Validate a Liquid template by parsing it (no rendering). Returns null if valid, or an error message string if invalid.
An email address that has passed validation (a non-empty host containing at least one dot) and been normalized (trimmed, lowercased).
Verify a password against a hash Uses constant-time comparison to prevent timing attacks
Verify a signed CSRF token's signature and expiry
Verify a user's password (decrypt stored hash, then verify) Returns the decrypted password hash if valid (needed for KEK derivation)
Decoded icon files for inclusion in .pkpass bundles
Shared wallet pass data common to both Apple and Google Wallet
Pretty-printed JSON for embedding in documentation
The example payload, matching what buildWebhookPayload would produce
Attendee data needed for webhook notifications
Consolidated payload sent to webhook endpoints
Listing data needed for webhook notifications
Result of webhook endpoint setup
Single ticket in the webhook payload
Result of webhook signature verification
Hands back the Web Crypto key the large-payload path needs. Nothing imports a key until a payload is actually big enough to want one.
The dated entry whose booked range ends last — the stay covering a whole package bundle — or null when every entry is date-less (a standard package). A dated entry with no stored end (a single-day booking, or a legacy row) sorts below any ranged stay. Shared by the collapsed email/SVG displays and the /t package card, so every surface picks the SAME representative stay.
Wrap a checkout operation, converting PaymentUserError to { error } result and swallowing unexpected errors as null. Used by both provider adapters.
Replace each markdown link whose target matches matcher with its plain
text. Used to strip links the viewer isn't allowed to open (e.g. owner-only
admin pages) before rendering — a rendered link is a promise that it works,
so a viewer who can't follow it gets the words without the link.
Ensure "email" is included in an listing fields setting
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.
Wrap a DATA_KEY under the password-bound (v2) KEK in one step. The single place new wrapped_data_keys are produced — setup, login migration, invite acceptance, password change, and superuser creation all go through here, so the derive-then-wrap pair lives in exactly one spot.
Wrap a symmetric key with another key using AES-GCM Returns format: wk:1:$base64iv:$base64wrapped
Wrap a key using a session token (derives a wrapping key from the token)
Wrap a named resource so create/update apply demo overrides to the form
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.
Verb of a mutating SQL statement
docs/config.ts
Configuration, environment, and session context.
Admin role levels
Schema for admin role levels.
Admin API listing shape — all listing fields except internal indices. Used by both admin JSON API and admin templates to ensure consistent field exposure. Snake_case keys match the DB schema.
Every admin role level — used to gate actions every authenticated user must reach (e.g. logout). Derived from AdminLevelSchema so adding a new role propagates automatically instead of being hand-listed here.
The day counts a customisable listing offers, ascending: the priced counts
that fall within [1, duration_days] (duration_days is the maximum when
customisable_days is on). Empty for non-customisable listings.
A single row in the attendee table: an attendee plus the listings the row covers, in display order. Roster/check-in tables render one row per booking line (a one-listing array); the browsing tables (attendees list, dashboard) group an attendee's lines into one row carrying every listing.
Build a DayPrices map from raw entries. checkEntry reads one raw
key/value pair and returns the day count and price to keep, null to skip
that entry, or a problem message — which stops the walk and is returned in
place of the map. Shared by the lenient stored-value reader
(parseDayPrices) and the fail-closed admin API body parser.
Clamp a valid whole-day count to the supported booking range.
All valid contact field names (runtime array matching the ContactField union)
Individual contact field name
Required name+email with optional phone/address/special_instructions from ContactInfo
Schema for an individual contact field name
Attendee contact details — the core PII fields collected at registration
Admin role levels that may create/edit listings & groups: the back-office
staff plus the content-only editor. Used to gate the listing/group content
routes editors are explicitly opted into; deliberately excludes agent.
The subset of listing fields needed to reason about day-count pricing.
The per-ticket price (minor units) for booking days on a customisable
listing, or null when the listing isn't customisable or that count has no
configured price (and therefore isn't offered).
Per-day-count ticket prices for "customisable days" listings, in minor
units, keyed by the number of days booked. e.g. { 1: 1000, 2: 1800 }
means a 1-day booking costs 1000 and a 2-day booking 1800. Only counts
present here are offered to the visitor.
Strict stored shape for per-day-count prices.
Admin role levels that may reach the delivery run sheet
(/admin/deliveries): staff plus delivery agents. This is the audience the
run sheet has always had; the content-only editor is excluded.
The attendee fields the shared attendee table and its column registry
actually read. A full decrypted Attendee satisfies it, and so does a
field-selected read that skipped the money subqueries — which is exactly why
the browsing tables can render rows that never computed price_paid or
remaining_balance (see src/shared/db/attendees/select.ts).
A single part of an email template
Schema for the parts of an email template: the subject line, the html body, and the plain-text body.
Persisted email template type
Schema for the persisted email template types: the attendee confirmation and the admin notification. The single source of truth for the template discriminator used by the renderer, settings store, and admin forms.
The fields shared by every named, slugged content record whose free-text
columns are stored encrypted: the body/meta blobs, the display name, and the
/slug permalink paired with its plaintext HMAC blind index (slug_index).
Both site pages and news posts build on this.
Visual level of a flash message: a positive result, a failure, or a neutral acknowledgement.
Get booking fee percentage from database. Returns 0 if not set.
Get the Botpoison public key from environment (safe to expose to browsers). Returns empty string when unset.
Get the Botpoison secret key from environment (server-side verification only). Returns empty string when unset.
Get the Bunny CDN API key from environment
Get the Bunny DNS subdomain suffix (e.g. ".tickets") from environment
Get the Bunny DNS zone ID from environment
Get the Bunny Edge Script ID from environment
Return the cached session if already resolved, or undefined if not yet resolved
Diagnostic key gating the verbose /health response. Empty when unset, in
which case /health only ever returns the plain liveness reply. Holding the
key reveals non-private build/runtime diagnostics (commit, build time) that
are useful for operators but needlessly helpful to an attacker.
Get the default database provider from DEFAULT_DB_HOST env var. Returns "turso" when set to "turso", "bunny" otherwise.
Get the Deno Deploy organization ID from environment.
Get the Deno Deploy organization slug used in managed production domains.
Get the Deno Deploy API token from environment.
Get the effective domain synchronously; DEFAULT_DOMAIN until a request resolves a real one.
Get allowed embed hosts from database (encrypted, parsed to array) Returns empty array if not configured (embedding allowed from anywhere)
Get an environment variable value Checks process.env first (Bunny Edge), falls back to Deno.env (local dev)
The shared secret authorizing the inter-instance site-credentials endpoint.
Get the READ_ONLY_FROM cutoff ISO string, or null if not set
Get the RENEWAL_URL, or null if not set
Get the Turso API token from environment.
Get the Turso database group from environment.
Get the Turso organization name from environment.
The group ids each listing belongs to (listing id → group ids). A listing absent from the map belongs to no group.
A membership of listing_id in group_id, hydrated for the package editor
and booking flow. A listing may belong to several groups. package_price
(minor units) is the per-listing override when the group is a package, read
from the group dimension of listing_prices (not a column on the join
table): null means no override (use the listing's own price), 0 means
explicitly free in the package, and a positive value overrides the price.
quantity (≥1, stored on the join row) is how many of this listing one unit of
the package includes. Both are ignored for non-package groups.
A group paired with the active member listings used to decide and render it.
True when an attendee/booking row is a real ticket (quantity ≥ 1) rather than
the no-quantity sentinel (quantity 0). The shared "is this a real ticket, not a
ghost" test for the readers, rosters, and exports that must skip sentinel rows —
one home for the rule instead of a bare quantity > 0 plus an explanatory
comment at each call site.
Schema for the kind of item an image can be attached to.
Type guard: check if a string is a valid AdminLevel
Check if Botpoison spam protection is configured. Requires both BOTPOISON_PUBLIC_KEY and BOTPOISON_SECRET_KEY to be set. This gates the public contact form feature.
Check if this instance can build other sites.
Check if Bunny CDN pull zone management is enabled Requires both BUNNY_API_KEY and BUNNY_SCRIPT_ID to be set
Check if the Bunny hosted database provider is enabled (requires BUNNY_API_KEY).
Check if Bunny DNS subdomain feature is enabled. Requires BUNNY_API_KEY and BUNNY_DNS_ZONE_ID to be set.
Type guard: check if an arbitrary string is a valid ContactField
True for roles that may create/edit listings & groups (owner/manager/editor).
True for roles that may reach the delivery run sheet (owner/manager/agent).
Check if Deno Deploy hosting has its token, organization ID, and domain slug.
Type guard: check if a string is a valid EmailTemplateFormat
Type guard: check if a string is a valid EmailTemplateType
Whether the inter-instance site-credentials endpoint is enabled. Off unless MAIN_INSTANCE_KEY is set, so a non-builder instance never exposes it. The key is a high-entropy shared secret the operator passes to the upgrade workflow at trigger time (it is never stored in GitHub).
Pure helper: is the current time within the warning window before cutoff?
Type guard: check if an arbitrary string is a valid ListingType
True only for the owner role.
Whether an listing can accept payments: a flat price, pay-what-you-want, or a customisable-days listing with at least one non-zero day-count price.
Type guard: check if a string is a valid PaymentProviderType
Type guard: check if a string is a valid PaymentProviderSetting
Check if payments are enabled (any provider configured with valid keys)
Check if the system is in read-only mode based on the READ_ONLY_FROM cutoff
Pure helper: is the site read-only based on a cutoff timestamp?
Check if the site should show a pre-expiry warning banner
Type guard: a non-null, non-array object (a Record shape).
Whether we are serving a real, resolved host rather than the default. Gates
HTTPS-only behaviour — Secure/__Host- cookies and the HSTS header — which
must stay off for local development on DEFAULT_DOMAIN.
Type guard: is this string a valid SitePageItemType?
True for roles that may edit public-site content (owner/editor).
True for back-office staff (owner/manager).
Check if Turso hosted database provider is enabled (requires TURSO_API_TOKEN, TURSO_ORGANIZATION, TURSO_GROUP).
- active
- assign_built_site
- attachment_name
- attachment_url
- bookable_alone
- bookable_days
- can_pay_more
- closes_at
- created
- customisable_days
- date
- day_prices
- description
- duration_days
- fields
- hidden
- id
- initial_site_months
- listing_type
- location
- max_attendees
- max_price
- max_quantity
- maximum_days_after
- minimum_days_before
- months_per_unit
- name
- non_transferable
- purchase_only
- slug
- slug_index
- thank_you_url
- unit_price
- use_defaults
- uses_logistics
- webhook_url
Contact fields setting for an listing (comma-separated ContactField names, or empty for name-only). Alias kept for documentation; runtime enforcement happens in parseListingFields.
Listing type: standard (one-time) or daily (date-based booking)
Schema for a listing type: standard (one-time) or daily (date-based booking)
Load the effective domain from DB, falling back to the request URL hostname.
Upper bound on multi-day booking duration. Each day in a booking range adds a per-day clause to the atomic capacity SQL, so the cap keeps that statement bounded regardless of which write path set the value.
An owner-defined price modifier (surcharge / discount / add-on). calc_value
is the positive magnitude the owner entered (a fixed amount in major currency
units, a percentage, or a multiplier); direction chooses charge vs discount.
Unique identifiers for settings nags that prompt the admin to complete required or recommended configuration.
A news post shown on the public /news page. All free-text columns are
stored encrypted; created stays plaintext (like listings) so the
newest-first ordering and the RSS pubDate never need a scan-and-decrypt.
slug is the /news/:slug permalink (auto-generated from the created date
and the name at creation, then immutable); slug_index is its blind index.
The public /news list projection: a summary plus the post's first image (the shared ItemImageProjection columns).
The narrow list projection — id, created, slug, name, snippet — for readers
that render no images (the RSS feed, the admin list). Never the large
content/meta_* blobs (cold-start efficiency, like SitePageNavRow).
Units of a shared capped group consumed by one parent+child order: the parent line plus its single required child line each take one spot in the group they share (invariants I1, I7). Used to convert a shared group's remaining spots into how many whole parent+child orders still fit.
Coerce an arbitrary stored/parsed value into a clean DayPrices map. Keeps only whole-number day counts in [1, MAX_DURATION_DAYS] mapped to finite, non-negative whole-number minor-unit prices; everything else is dropped. Used on both the DB read path and form parsing so the rest of the code can treat the map as already-valid.
Parse a flash cookie value into type, message, optional result, and the optional form re-fill stash token.
Parse a string into a positive integer for warning days. Returns defaultVal on bad input.
Schema for supported payment provider identifiers
Persisted payment-provider setting: an explicit provider, "none" (admin saved payments-disabled), or absent (never saved — drives the settings nag).
Supported payment provider identifiers
Pick the value for the active payment provider from a per-provider set, or
null when no known provider is active. The one place the stripe/square/sumup
dispatch lives, so every per-provider flag is read the same way.
Get a required environment variable, throwing if not set.
Use this instead of getEnv(key) as string when the variable must exist.
Reset effective domain cache back to the default (for testing).
Run a function within a session-memoization scope
Seed the effective domain from the request's own hostname.
Store the resolved session in the current request scope
Set effective domain directly (for testing).
Admin role levels that may edit the public-facing site content (homepage,
contact, order intro). Site editing has always been owner-only; the editor
role is added to it, but manager stays excluded — so this is owner+editor,
NOT the broader CONTENT_ADMIN_LEVELS.
A user-created content page. Adds sort_order, which positions the page
among root-level pages.
One ordered membership edge: item (of item_type) sits inside page_id
at sort_order. Keyed on the composite (page_id, item_type, item_id).
The kind of thing a SitePageItem points at. Exhaustive union — a new
member is a compile error at every Record<SitePageItemType, …> dispatch.
Schema for the kind of thing a SitePageItem points at.
Sanitize a site name into a valid provider resource slug: the shared
slugify, capped at maxLength with any hyphen the cut left trimmed.
The listing values needed to place a listing in the shared sort order: which tier it belongs to (its type and date), its name for the within-tier sort, and — for a daily listing — the values that decide its next bookable date. A read that only lists or picks listings can select these columns alone and skip the whole listing record.
Admin role levels that are back-office staff (not delivery agents).
UI theme
A link between an agent user and a logistics agent (van/crew) they drive. Many-to-many: a user may cover several agents and an agent may have several users.
docs/crypto.ts
Encryption, hashing, CSRF protection, and secure operations.
Key length AES-256 takes, in bytes.
AES-GCM decrypt with raw key bytes. The mirror of aesGcmEncryptBytes; note it measures the ciphertext, which carries the tag the plaintext does not.
AES-GCM decrypt raw data with an imported key
AES-GCM encrypt with raw key bytes, using whichever implementation is faster for this payload.
AES-GCM encrypt raw data with an imported key, returning IV and ciphertext
AES-GCM encrypt a text string with an imported key
Convert standard base64 to base64url (no padding). Works on both strings and Uint8Array (bytes are first encoded to base64).
Compute HMAC-SHA256 using Web Crypto API, returning raw ArrayBuffer
Concatenate byte arrays into one array.
Constant-time compare of two code sequences, given their lengths and a per-index code reader for each. Walks the longer sequence and folds every difference into one flag with XOR, so no early return leaks a length or the position of the first mismatch. Callers supply the code source (UTF-8 bytes, UTF-16 char codes, …), keeping this the single constant-time comparison loop.
Constant-time string comparison to prevent timing attacks Always iterates over the longer string and XORs the lengths so that different-length inputs don't leak via an early return.
Constant-time comparison for Uint8Arrays of equal length Caller must ensure arrays have the same length (validated by verifyPassword)
Default message for invalid/expired CSRF form submissions (request-scoped).
Decrypt a string value encrypted with encrypt() Expects format: enc:1:$base64iv:$base64ciphertext
Decrypt binary data encrypted with encryptBytes(). Expects ENCB binary format: magic + version + IV + ciphertext.
Decrypt data with a symmetric key
Decrypt a value encrypted with encryptWithOwnerKey, using the owner's private key (obtained from the session in admin views).
Legacy (v1) KEK derived from the stored password hash. Retained only to unwrap and migrate existing wrapped_data_keys — new wraps use deriveKEKFromPassword. Salt prefix is empty so this stays byte-compatible with keys wrapped before the v2 split.
Password-bound (v2) KEK derived from the raw password. Because the password is never stored, a database dump plus DB_ENCRYPTION_KEY cannot unwrap the DATA_KEY — this is what binds attendee PII at rest to the account password.
Encrypt a string value using AES-256-GCM via node:crypto (faster than Web Crypto for the small payloads this handles; output stays interoperable). Returns format: enc:1:$base64iv:$base64ciphertext Note: ciphertext includes the GCM auth tag appended.
Encrypt binary data with AES-256-GCM using compact binary format. Output: ENCB + version byte + 12-byte IV + ciphertext (with GCM auth tag). Overhead is only 33 bytes (vs ~76% bloat in the legacy text format).
Encryption format version prefix Format: enc:1:$base64iv:$base64ciphertext
Encrypt data with a symmetric key (for wrapping private key with DATA_KEY)
Encrypt a value with the site owner's public key (hybrid RSA+AES). Only the owner's password-derived private key can decrypt it. Used for attendee PII, email-preference blobs, and bulk-email drafts/templates. Can be called without authentication (e.g. from public ticket forms).
Format IV + ciphertext as a prefixed base64 string
Convert base64 string to Uint8Array
Convert a base64url string (no padding) back to a Uint8Array — the inverse of toBase64Url.
Generate a random 256-bit symmetric key for data encryption
Generate an RSA key pair for asymmetric encryption Returns { publicKey, privateKey } as exportable JWK strings
Generate a cryptographically secure random token Uses Web Crypto API getRandomValues
Generate a 5-byte uppercase hex ticket token for public ticket URLs
Get the most recently generated CSRF token (for synchronous JSX rendering)
Raw 256-bit encryption key bytes, decoded once from DB_ENCRYPTION_KEY
Get the encryption key bytes from environment variable (sync validation only) Expects DB_ENCRYPTION_KEY to be a base64-encoded 256-bit (32 byte) key
Derive the private key from session credentials Used to decrypt attendee PII in admin views Results are cached per session token for 10 seconds
Generate random bytes using Web Crypto API
Hash a password using PBKDF2 Returns format: pbkdf2:iterations:$base64salt:$base64hash
Hash a session token using SHA-256 Used to store session lookups without exposing the actual token
HMAC-SHA256 hash using DB_ENCRYPTION_KEY Used for blind indexes and hashing limited keyspace values Returns deterministic output for same input (unlike encrypt)
Hex-encoded HMAC-SHA256 of a UTF-8 message under the given secret.
Convert ArrayBuffer to base64 string
Convert ArrayBuffer to hex string
Prefix tagging a hybrid (RSA+AES) ciphertext, e.g. owner-key activity-log messages and attendee PII. Distinguishes them from env-key ENCRYPTION_PREFIX values so a decrypt path can route by format.
Decrypt data using hybrid encryption Expects format: hyb:1:$base64WrappedKey:$base64iv:$base64ciphertext Results are cached in a bounded LRU (ciphertext -> plaintext)
Encrypt data using hybrid encryption (RSA + AES)
Import a CryptoKey from DB_ENCRYPTION_KEY.
Import a private key from JWK string
Import a public key from JWK string
Check whether a token uses the signed format
Parse a prefixed encrypted payload into IV and ciphertext bytes. Validates the prefix and separator; throws on invalid format.
Run a function within a CSRF-token scope (one container per request)
Constant-time string comparison (over UTF-16 char codes) to prevent timing
attacks. Shares the one constant-time loop in constantTimeCodesEqual.
Explicitly set or clear the encryption key for testing. Bypasses Deno.env to avoid races between parallel test workers. Automatically clears all crypto caches (encryption, HMAC, and any registered via onEncryptionKeyChange).
Explicitly enable/disable fast PBKDF2 for testing without env var races
Explicitly set RSA key size for testing without env var races
Create a signed CSRF token: s1.{timestamp}.{nonce}.{hmac}
Decrypt a prefixed AES-GCM payload with the given key.
Encrypt plaintext with an AES-GCM key, returning prefixed format: enc:1:$base64iv:$base64ciphertext
Convert Uint8Array to base64 string
Convert Uint8Array to base64url string (no padding)
Unwrap a symmetric key Expects format: wk:1:$base64iv:$base64wrapped
Unwrap a key using a session token
Unwrap a session's DATA_KEY from its token. An authenticated session that reaches a data-key operation always carries a wrapped data key, so a missing one is a broken invariant — throw rather than invent a key.
Validate encryption key is present and valid Call this on startup to fail fast if key is missing
Verify a password against a hash Uses constant-time comparison to prevent timing attacks
Verify a signed CSRF token's signature and expiry
Hands back the Web Crypto key the large-payload path needs. Nothing imports a key until a payload is actually big enough to want one.
Wrap a DATA_KEY under the password-bound (v2) KEK in one step. The single place new wrapped_data_keys are produced — setup, login migration, invite acceptance, password change, and superuser creation all go through here, so the derive-then-wrap pair lives in exactly one spot.
Wrap a symmetric key with another key using AES-GCM Returns format: wk:1:$base64iv:$base64wrapped
Wrap a key using a session token (derives a wrapping key from the token)
docs/database.ts
Database client, ORM abstractions, and entity tables.
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.
Activity log entry as callers see it: the message decrypted to plaintext.
Activity log table definition.
Correct projected listing income to the requested amount.
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.
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.
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.
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.
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
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.
A browsing-table attendee row — every core column plus refunded, but none
of the expensive money projections.
Build an INSERT into listing_attendees, capacity-checked by default.
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).
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).
Helper to create column definitions
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
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.
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.
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.
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.
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.
One delete-rows-matching-a-field target: which table, matched on which field, for which value.
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).
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, 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.
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.
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 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.
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.
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.
Get all listings in a group with attendee counts (including inactive).
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.
Everything a caller declares to read listing records: which rows to keep and in what order.
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 listing ids in a group, and the reverse listing-to-groups side.
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.
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 generated id + plaintext created stamp columns. created stays
unencrypted so SQL can order and prune by time without decrypting.
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.
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).
Type guard: narrows an arbitrary string to an AttendeeSort.
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 ").
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.
Read and decrypt listing names without loading full records.
The shared narrow listing shape used by listing and attribute pickers.
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.
Listing CRUD with cache invalidation and listing-price synchronization.
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.
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.
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.
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.
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.
Input shared by ordered tables whose only required value is a name.
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.
Package-group display info for grouping a booking's lines under the package name on tickets/emails.
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.
Current PII blob schema version
Query all rows, returning a typed array.
Execute a SQL query and map result rows through an async transformer.
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.
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.
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.
Raw listings table. Records adds cache-aware CRUD and price syncing.
Embed a raw SQL expression (e.g. last_insert_rowid())
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.
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.
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.
Result of session reservation attempt
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).
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.
Wall-clock milliseconds during which at least one query was in flight: the combined length of the query intervals with overlaps merged.
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.
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).
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
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.
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.
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.
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.
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.
docs/demo.ts
Demo mode and seed data generation.
Replace form field values with demo data when demo mode is active. Only replaces fields that are present and non-empty in the form. Mutates and returns the same URLSearchParams for chaining.
Attendee PII fields
Create seed listings and attendees using efficient batch writes. Encrypts all data before inserting, matching production behavior. Assigns random ticket quantities (1-4) per attendee without overselling.
Demo addresses
Demo email addresses
Demo group descriptions
Demo group names
Demo holiday names
Demo listing descriptions — rock-themed gig blurbs assembled from word pools. Like the names above, the list is procedurally generated but seeded so it stays deterministic across runs.
Demo listing locations — pretend rock-venue / festival listings. Procedurally generated from the venue word pools using a seeded PRNG.
Demo listing names — pretend rock/heavy-metal band listings. Generated procedurally from a seeded PRNG so the list stays deterministic across runs (tests rely on this) but offers far more variety than a hand-curated list while staying on-theme.
Demo attendee names (full names for demo mode overrides)
Demo page text (homepage / contact)
Demo phone numbers (UK format)
Demo servicing-event names (a reason/job for a capacity hold, not a person)
Demo special instructions
Demo terms and conditions
Demo website titles
Maps form field names to arrays of possible demo values
Group name and description fields
Holiday name field
Check if demo mode is enabled
Listing metadata fields
Apply demo-mode overrides to the form, then run the loader — the shared prelude of the attendee submit handlers, which 404 when the loader finds nothing (a stale id racing a delete).
Attendee Logistics tab fields: the address is masked like the attendee form's, and the pinned latitude/longitude are cleared outright — an exact real-world location is PII even beside a masked address.
Pick a random element from an array
Reset cached demo mode value (for testing and cache invalidation)
Max attendees per seeded listing
Servicing-event fields — name only, and a servicing reason rather than a person's name, so demo mode doesn't turn "Boiler Service" into "Bob Smith".
Explicitly set demo mode on or off (for testing). Bypasses Deno.env to avoid races between parallel test workers.
Site contact page fields
Site homepage fields
Terms and conditions field
Wrap a named resource so create/update apply demo overrides to the form
docs/email.ts
Email sending, templates, and notifications.
Build SVG ticket data from an email entry (non-PII only)
Build the data object exposed to Liquid templates. Rows booked through a
package head the email by the package's name (listing_names); an order may
carry several bundles beside plain rows. hidePackageMembers (set for the
buyer's confirmation, not the admin notification) collapses each HIDDEN
package's member rows into one package row so members aren't revealed —
whatever else the order carries beside them.
Generate SVG ticket attachments for the buyer's row groups: each HIDDEN package collapses to a single package-level SVG so the attachments don't reveal the member listings the email body hides — whatever else the order carries beside the bundle. Callers without package rows pass one group per entry (buyerEntryGroups builds the real thing).
Placeholder in a bulk template marking where each recipient's unsubscribe URL goes.
What the provider returned for one batch: HTTP status, ok flag, and the raw response body. Providers reply with queued message IDs (or rejection reasons), so the body is kept to surface back to the sender and the log.
A bulk send: shared template (html/text may contain the placeholder) + recipients.
One bulk recipient: address plus its unsubscribe URL (marketing sends only).
Outcome of a bulk send: recipients attempted, batches sent, recipients in failed batches, and the raw per-batch provider responses.
One buyer-facing row group: a HIDDEN package's rows gather behind its name; every other row stands alone.
Group an order's entries for buyer-facing rendering (the confirmation body and its SVG tickets): each hidden package's rows collapse into one group sitting where its first row was, so a mixed order conceals every hidden bundle while its other rows render normally.
The single-row summary a hidden package collapses to for buyers: the bundle's summed price and quantity plus the widest member's dated stay (hiding members must not lose the date the buyer booked). Shared by the email body row and the SVG ticket, so the two can never disagree.
Display labels for email providers — keys must match EmailProvider
Format-only email schema: validates an address exactly as typed, without trimming or lowercasing. Used by field validators that check raw user input (see validateEmail in #templates/fields.ts).
Host (everything after the last @) of a validated address. The ValidEmail
type guarantees a host is present, so there is no empty-host case to handle
and the compiler forbids passing a raw, unvalidated string.
Listing data needed for registration pipeline (extends webhook listing with display + assignment fields)
Local part (everything before the last @) of a validated address.
Union of all supported email provider keys, derived from the PROVIDERS map
Picklist schema for the supported email providers. Its options are derived
from the PROVIDERS map so the two can never drift, and it mirrors the
string-union picklists in types.ts (ContactFieldSchema, PaymentProviderSchema
…) — EmailProviderSchema.options + v.is replace the previous hand-rolled
Set + .has() guard.
Canonical email schema used across the app: local@host.tld. valibot's
email action guarantees a non-empty local part and a host containing at
least one dot. The input is trimmed and lowercased before validation, and the
output is branded as ValidEmail so a value can only be produced by passing
validation. All email validation that needs a normalized, carry-onward value
goes through this (see isValidEmail / parseEmail).
The email config sending should use: the site's own settings when complete, otherwise the host-level config. Null when neither is configured — callers treat that as "email is off".
Read email config from DB settings. Falls back to business email for fromAddress. Returns null if not configured or the from address is invalid.
Get host-level email config. Uses test override if set, otherwise reads env vars.
Type guard: checks if a string is a valid EmailProvider
Whether a string is a valid email (trimmed) per EmailSchema.
Parse and normalize a candidate email, returning the branded ValidEmail when it is valid or null otherwise. Use this in preference to isValidEmail when the validated address needs to be carried onward in a type-safe way.
Render all 3 parts (subject, html, text) using custom templates with fallback to defaults
Render a single Liquid template string with the given data
For testing: reset the engine (so filters can be re-registered after currency changes)
For testing: reset host email config to read from env vars.
Send a bulk email via the configured provider. Every supported provider has a
batch endpoint, so this works for any EmailConfig. Chunks recipients to the
provider's batch limit and POSTs each chunk; logs (never throws) on a non-OK
batch, whose recipients then count as failed. Each batch's provider response
is captured so the caller can relay it to the sender.
Send a single email via the configured provider. Logs errors, never throws. Returns HTTP status or undefined on non-HTTP errors.
Send an error notification to the configured ntfy URL Returns a promise so callers can await delivery if needed. Delivery failures are logged locally (via logErrorLocal) but never throw.
Send registration confirmation + admin notification emails. Entries is an array because one registration can cover multiple listings. Silently skips if email is not configured. Attaches one SVG ticket per entry to the confirmation email.
Send a test email to the business email address. Returns HTTP status or undefined on non-HTTP errors.
For testing: set host email config directly. Bypasses env vars to avoid races.
The buyer's summed price (minor units) across an order's entries.
The bundle's summed booked quantity across an order's entries.
Data object passed to Liquid templates
Updates the business email in the database. Pass empty string to clear the business email. Email is encrypted at rest.
Valid provider names (the picklist options), derived from the PROVIDERS map
Validate a Liquid template by parsing it (no rendering). Returns null if valid, or an error message string if invalid.
An email address that has passed validation (a non-empty host containing at least one dot) and been normalized (trimmed, lowercased).
docs/embed.ts
Embeddable widget: iframe integration and CDN storage.
Append iframe=true query param to a URL when in iframe mode
User-facing messages for attachment validation errors
Attachment validation error
Attachment validation result
Build embed snippets (script and iframe variants) for a ticket URL
Build a frame-ancestors CSP value from allowed embed hosts. Returns null if the list is empty (allow embedding from anywhere).
Build the full subdomain record name (user choice + suffix). e.g. "mylisting" + ".tickets" → "mylisting.tickets"
Delete all first-class image files.
Delete all attachment files for a list of listings
Delete a file, routing to local or Bunny based on config.
Delete the full-size image and thumbnail files for a first-class image.
Delete an image's storage files, throwing if any file could not be removed (a file that is already gone counts as success, so retries are safe). Unlike deleteImageStorageFiles, this surfaces failures so the caller can keep the image's DB record for a later retry instead of orphaning the stored files under a deleted record.
Delete the attachment file for a single listing
Upload and publish new script code to a Bunny edge script (defaults to this
host's own script when scriptId is omitted).
Detect iframe mode from a request URL and store it for the current request
Detect the actual image type from magic bytes. Returns the MIME type if matched, null otherwise.
Matches a valid hostname like "example.com" or "sub.example.com"
Download and decrypt a file. Returns the decrypted bytes, or null if the file does not exist.
Download raw bytes from storage. Returns null if the file does not exist.
A secret as reported by the Bunny API (name + metadata only — never the value).
Generate a random CDN filename preserving the original name for readability
Generate a random .webp filename. Every uploaded image is transcoded to
WebP, so stored image variants always carry the .webp extension.
Extract the basename from a path (handles both forward and backslash separators)
Get CDN hostname (delegates to bunnyCdnApi for testability).
Get the current request's iframe mode
Images are encrypted at rest, so browsers load them through this route.
Get the MIME type for an image filename from its extension.
Returns which storage backend is active: "bunny", "local", or "none".
User-facing messages for image validation errors
Image validation error
Image validation result
Check if image storage is enabled (Bunny CDN or local filesystem).
List files in storage matching a prefix (names only), sorted by name.
List files (with size metadata) matching a path prefix, sorted by name. The
prefix may name a subfolder (see splitListingPrefix); returned names always
include that folder so callers can download/delete them directly. For Bunny
CDN the size comes from the Length field of the listing API.
Parse a Bunny API error response into a BunnyApiResult.
Parse a comma-separated list of hosts into trimmed, lowercased entries. Filters out empty strings from trailing commas etc.
Register a bunny subdomain (DNS + CDN).
Run a function within an iframe-mode scope (one container per request)
Run fn with an isolated storage configuration (test-only).
Strip a path's basename down with each [pattern, replacement] rule in
turn, falling back to "file" when nothing is left. Shared by every filename
sanitiser — each supplies its own character rules.
Test-only: set the suite-level storage config that describeWithEnv's storage
option applies. A directly-exported named function (not an export {} list,
which the test-hook scanner does not detect, nor a module-level alias) so it is
visible to and registered in ALLOWED_TEST_HOOKS
(test/lib/code-quality.test.ts), alongside runWithStorageConfig.
Try to delete a file from storage, logging errors on failure
Upload an attachment to Bunny storage. Encrypts the file bytes before uploading. Uses the provided filename (caller generates via generateAttachmentFilename). Returns the filename on success.
Transcode an uploaded image to WebP and store one file per target.
Upload raw bytes to storage, routing to local or Bunny based on config
Validate an attachment file: check size only (any file type allowed).
Validate a custom domain (delegates to bunnyCdnApi for testability).
Validate a comma-separated list of host patterns. Returns null if all valid, or the first error message.
Validate a single host pattern Returns null if valid, or an error message if invalid
Validate an image file: check size, the declared MIME type, and the magic
bytes. Both the declared type and the sniffed content must be an accepted
upload format; a mismatch or an unsupported format (e.g.
a GIF, or a file whose bytes don't match any decodable format) is rejected.
On success, detectedType is the sniffed format the transcoder will decode.
docs/listings.ts
Listing management: fields, sorting, and availability.
Add days to a YYYY-MM-DD date string
Add N months to an ISO timestamp, clamping to the last day of the target month. e.g. 2026-01-31 + 1mo → 2026-02-28 Preserves the time component (hour/minute/second/ms). Zero months returns the input with canonical ISO string formatting.
The human-readable label for one booking's actual span: the stored
[date, endDate) range when a multi-day range is stored, the listing's
fixed duration when only a start date is (legacy rows written before end
dates were stored), else the single booked day. "" when there is no date.
The ONE booked-range renderer the confirmation email, the /t ticket cards,
and the collapsed package displays share, so they can never disagree about
a booking's stay.
The whole day count of a stored [start_at, end_at) booking range — the
customisable day count the buyer chose. A missing or degenerate range is 1.
Build the calendar grid for a YYYY-MM month as a flat list of YYYY-MM-DD strings. The grid is whole Monday→Sunday weeks spanning the month plus one extra full week on each side, so adjacent-month context is always visible.
Generate a range of YYYY-MM-DD date strings from start to end (inclusive)
Compute how many days ago an listing started, relative to today in the configured timezone. Returns null if the listing date is today or in the future, or if the date is empty/invalid. For past listings, returns a positive integer (1 = yesterday).
Format a YYYY-MM-DD date for display. Returns "Monday 15 March 2026"
Format a UTC ISO datetime as a date-only label in the configured timezone, e.g. "Monday 15 June 2026" — no time. Returns "" for an empty/invalid input. Used where a stored timestamp should read as a plain published date (the public news post page).
Format a booking's stored [start_at, end_at) ISO range as a human label.
1-day bookings collapse to formatDateLabel; multi-day bookings use the
compact English range formatter (inclusive — subtracts 1 day from end_at,
which is the first midnight after the booked window).
Compact English date-range formatter. Uses an en dash (–) for ranges.
Format an ISO datetime string for display in the given timezone. Returns e.g. "Monday 15 June 2026 at 14:00 BST"
Compact ISO datetime formatter for table cells. Returns e.g. "07/04/2026 14:00" in the configured timezone.
Format a YYYY-MM month string for display, e.g. "July 2026".
Human "time ago" label for a past ISO timestamp, relative to nowMsValue
(epoch ms), via Intl.RelativeTimeFormat in the largest whole unit that
applies — e.g. "now", "5 minutes ago", "yesterday", "2 days ago". Returns
null for an unparseable or future timestamp.
Compute available booking dates for a daily listing.
Filters by bookable days of the week and excludes holidays.
For listings with duration_days > 1, excludes start dates whose full range
would hit a non-bookable day or extend past the booking window.
Available start dates for a daily listing's booking/date pickers. Customisable-days listings use single-day availability — the span is chosen separately and validated at submit time — so every individually-bookable start is offered; other listings use their fixed duration.
Get the next available booking date for a daily listing. More efficient than getAvailableDates()[0] — stops at first match. Returns null if no bookable dates are available.
Whether booking days consecutive days starting on date is valid for a
daily listing: every day must be a bookable weekday, fall outside all
holidays, and stay within the listing's booking window. Used to enforce the
visitor's chosen span on "customisable days" listings at submit time, where
the day count isn't known when the date list is rendered.
Convert a UTC ISO datetime to a YYYY-MM-DD calendar date in the given timezone. Returns null if the input is empty or invalid. Used by the calendar view to map standard listing dates to calendar days.
Load all listings with holidays and return them sorted. With a filter, only the listings it keeps are returned; without one, every listing is returned.
Determine which contact fields to collect for multiple listings. Returns the union of all field settings, sorted by canonical CONTACT_FIELDS order.
List every YYYY-MM month within yearsEitherSide years of the given month's
year, in ascending order. e.g. monthsAround("2026-03", 5) runs from
"2021-01" to "2031-12". Used to populate the calendar's month picker.
Normalize datetime-local "YYYY-MM-DDTHH:MM" to full UTC ISO string. The input is interpreted as local time in the given timezone and converted to UTC.
Parse a date/timestamp string to epoch milliseconds, or null when it
doesn't parse — the safe wrapper around Date.parse, whose own failure
mode (NaN) is easy to let leak into arithmetic by accident.
Parse a user-supplied YYYY-MM-DD value (a ?date= query param): the string
when well-formed and a real calendar date, else null. The UTC round-trip
rejects rolled-over impossibilities like 2025-02-30.
Parse a comma-separated fields string into individual ContactField names
Shift a YYYY-MM month string by delta months (negative goes backwards).
Crosses year boundaries: shiftMonth("2026-12", 1) → "2027-01".
Sort listings in unified 3-tier order. Takes anything carrying the values the order is built from, so a narrow picker read sorts the same way a full listing record does.
Round a date down to the start of the current hour for cache-stable signatures
The dated entry whose booked range ends last — the stay covering a whole package bundle — or null when every entry is date-less (a standard package). A dated entry with no stored end (a single-day booking, or a legacy row) sorts below any ranged stay. Shared by the collapsed email/SVG displays and the /t package card, so every surface picks the SAME representative stay.
Ensure "email" is included in an listing fields setting
docs/payments.ts
Payment processing with Stripe, Square, and SumUp.
Build a checkout's signed metadata the way the given provider needs it, with the caps read from the provider registry: build the logical shape within the per-value and entry caps, pack the small fields into one entry when the provider needs that to fit, and enforce the caps on the shape that reaches the wire. SumUp's caps are unbounded (its metadata is stored locally, never sent to the provider), which makes the enforcement a no-op for it.
Processed booking intent extracted from payment session metadata
Booking result — callers map this to their response format
Build checkout session metadata from booking data (items already compact).
Render a priced order into a provider's line-item array: each ticket line via
line, each extra (booking fee, …) via extra. Providers supply the two
shape callbacks; the ordering (tickets, then extras) matches what Stripe and
Square built by hand before.
Cache a provider API client keyed on its config. Reuses the cached client while the config is unchanged and recreates it when the config changes; returns null when the provider is unconfigured.
Registration intent for checkout (one or more listings)
Single item within a checkout — one bookable PATH. A listing booked through two overlapping packages (or a package plus its own standalone row) in one order is one item per path, each with its own quantity and price.
Build a standalone-line CheckoutItem for one listing — the shared
shape every single-listing checkout (direct-to-provider QR booking, the
plain public booking form) builds its one-item items array from.
Result of creating a checkout session.
Run an operation with the lazily-resolved client. Returns null when the
client is unconfigured or the operation fails (unless the error should
propagate). The named type keeps the contract visible to callers of the
widely-used stripeClientRuntime.run so a signature drift fails at the
definition instead of leaking to callers.
Create a withClient helper that runs an operation with a lazily-resolved client. Returns null if the client is not available or the operation fails.
Shared shape for a provider credential check in connection-test results.
Enforce a payment provider's metadata limits.
Normalize validated session metadata into the canonical SessionMetadata shape.
Per-listing answer references carried through a checkout, shared by the booking and checkout intents.
True when the listing still has spots for this quantity on this date (no date for a date-less listing, whose capacity is one running total).
Build a provider's createCheckoutSession: call the provider's own create
function, read the session id and URL off whatever shape it returns, and map
that to a shared CheckoutSessionResult — all inside the standard checkout
error guard. Each provider only supplies its create call, how to read the
id/url, and its display label.
Compact modifier reference stored in session metadata: the modifier id and the quantity taken. The webhook re-fetches the modifier by id and re-derives its amount from the current database — provider metadata amounts are never trusted.
A modifier resolved for a specific checkout — the input the pricing pipeline
applies. Eligibility (scope, stock, codes) is decided upstream; by the time a
spec reaches pricing it is known to apply. value is the signed calc value
(see modifierDelta); listingIds scopes which items it is charged on
(null = the whole order); quantity is how many the buyer took (1 for an
automatic or code modifier, more for an opt-in add-on).
Collapse the packable small fields into one JSON b entry, dropping them
from the top level. Falsy values are omitted (the "" = absent convention), so
the b entry only appears when at least one packed field is actually present.
Stubbable API for internal calls (testable via spyOn, like stripeApi/squareApi)
Valid payment status value
Schema for valid payment status values. "failed" is a terminal non-payment (declined or expired checkout) — distinct from "unpaid", which may still complete.
Error subclass for user-facing payment validation errors (e.g. invalid phone number). These propagate through safeAsync so the message can be shown to the user.
Process a single-listing booking.
Safely execute async operation, returning null on error. Re-throws PaymentUserError so user-facing messages propagate.
Metadata attached to a validated payment session.
Set up a webhook endpoint for a provider. Some providers (e.g. Stripe) support programmatic creation; recreating any existing endpoint returns a fresh signing secret. Shared by the provider interface and each provider's own implementation so the signature can't drift.
The payload/signature pair a test POSTs to a provider webhook route.
Build a test webhook delivery: JSON-encode the event, sign it with the
provider's own signing rule, and return the payload/signature pair a test
can POST to the webhook route. Each provider supplies only sign.
Convert single-listing answerIds to the per-listing format used in metadata
Convert registration line items to compact, edge-tagged booking items (v2).
Each package member line carries ITS OWN package edge (k:"p", r=its group
id) so the webhook can revalidate each line's nodeKey — an order can book
several packages, so the edge is per line, never order-wide; folded children
(in allocations) and standalone lines stay untagged. See signed-metadata.ts.
Normalise a provider timestamp to the ledger's canonical ISO 8601 form
(YYYY-MM-DDTHH:mm:ss.sssZ), or undefined when it's absent or unparseable.
Convert a provider-specific checkout result to a CheckoutSessionResult. Returns null if session ID or URL is missing.
Compact the resolved modifier specs to id/quantity references for metadata.
A validated payment session returned after checkout completion
Assemble the one ValidatedPaymentSession shape every provider adapter
returns. Owns the createdAt rule — the key is left out entirely when the
provider gave no usable timestamp — and normalizes the guarded wire metadata
into the canonical shape. metadata must already have passed
hasRequiredSessionMetadata (or come from our own staged checkout row).
Result of webhook endpoint setup
Result of webhook signature verification
Wrap a checkout operation, converting PaymentUserError to { error } result and swallowing unexpected errors as null. Used by both provider adapters.
docs/tickets.ts
Ticket generation: QR codes, SVG tickets, and Apple Wallet passes.
Build the check-in URL for a single ticket token
Build info lines from ticket data (non-PII listing and booking details)
Build a complete .pkpass file as a Uint8Array (ZIP archive)
Create manifest.json mapping filenames to SHA-1 hashes
Extract the inner content of an SVG element (strip the outer <svg> wrapper)
Extract the viewBox from an SVG element to compute its coordinate space
Build the pass.json content from pass data and signing credentials
Generate an SVG string for a QR code encoding the given text. Returns a complete <svg> element suitable for inline embedding.
Generate a standalone SVG ticket with QR code and listing/booking details. Returns a complete SVG document string.
Whether this listing can send QR code scanners directly to checkout. True when no extra contact fields or questions are required.
Pad a serial number to meet Apple's minimum authenticationToken length. Uses "-" (not in uppercase hex charset) so padding is cleanly reversible.
Data needed to generate a pass — maps to existing ticket/listing data
Apple requires SHA-1 for manifest entries; CMS signs the manifest with SHA-256.
Non-PII ticket data for SVG rendering (extends shared wallet fields with display-formatted values)
Strip padding added by padAuthToken to recover the original serial number
Decoded icon files for inclusion in .pkpass bundles
Shared wallet pass data common to both Apple and Google Wallet
docs/utilities.ts
Shared utilities: FP helpers, formatting, slugs, caching, and logging.
Queue a promise that must complete before the response is sent
Narrow an unknown value to string, defaulting to "" if not a string.
Replaces typeof x === "string" ? x : "" at type boundaries.
Resource management pattern (like Haskell's bracket or try-with-resources). Ensures cleanup happens even if the operation throws.
Why cached data was cleared. Only a committed write needs primary refills.
Split an array into chunks of a given size.
Curried adapter over @std/collections.chunk (which throws for size < 1).
Create an in-memory collection cache with TTL. Loads all items via fetchAll on first access or after invalidation/expiry, then serves from memory until the TTL expires or invalidate() is called. Accepts an optional clock function for testing.
Create a request timer for measuring duration
The epoch-ms instant of the START of a calendar day (00:00 local time) in the
given timezone. Used to turn a YYYY-MM-DD filter bound into the integer
occurred_at bound the ledger queries compare against, so a day range is
interpreted in the operator's own timezone rather than UTC.
Resolve after ms milliseconds — for retry backoff and similar waits.
A dependsOn entry accepted by cachedTable / cachedEntityTable.
The YYYY-MM-DD calendar day an epoch-ms instant falls on in tz. The
inverse direction of dayStartEpochMs, for labelling a stored
occurred_at as the local day it belongs to.
Error code strings for use in logError calls
Human-readable labels for error codes (shown in admin activity log)
Epoch seconds maxAgeSeconds from now — the expiry (e) that signed tokens
carry, kept in one place so every builder computes it the same way.
A copy of base with extra entries merged on top (an extra key wins over
the same key in base). Curried so a constant overlay (a fixed content type,
a default set) can extend whatever record it is given.
Index one field from each item by its id.
Curried filter
Run a valibot schema with abortPipeEarly and return the first error message or null.
Alternative combinator: try a sequence of producers in order and return the first that yields a defined value, or undefined if every one declines.
Check items in order and stop at the first reported problem.
Curried flatMap
Await all queued work. Call before returning the response. Loops until the queue stays empty: work already running can queue more (a background job that fails queues its error's activity-log write), and a single pass would discard those late arrivals unawaited.
Format an amount in minor units (pence/cents) as a currency string. e.g. formatCurrency(1050) → "£10.50" (when currency is GBP)
Format a UTC ISO datetime string for display in the given timezone. Returns e.g. "Monday 15 June 2026 at 14:00 BST"
Compact format for table cells: "yyyy-MM-dd HH:mm" in the given timezone.
Delegates to the browser-compatible formatIsoForPreview helper so the
same formatting runs on the server and in the admin JS bundle.
Format an error context into a human-readable activity log message
Format an error detail string with request context and error message
Format a signed change in minor units. Positive value is added, negative value is removed, and zero has no misleading sign.
Generate a random slug with at least 2 digits and 2 letters. Uses Fisher-Yates shuffle on the fixed positions to avoid bias.
Generate a unique slug by retrying random slugs until one is not taken.
Collect stats from all registered caches
Get the number of decimal places for a currency code
Get the current request ID, or empty string if outside request context
Group rows by a key, keeping only the chosen value from each row. Keys appear in first-occurrence order and each value list preserves input order.
True when running inside a runWithPendingWork scope (i.e. a request).
Return a value unchanged.
Fire every cache invalidator registered against table (no-op if none).
Treats the write as unconditional (INSERT semantics): always fires column-gated entries too.
Fire registered cache invalidators for table, respecting column gates.
Remove null and undefined values from array
ISO timestamp a fixed duration before the current time.
True when a link/image URL is safe to render. Relative URLs (no scheme) are
allowed; absolute URLs must use a scheme from SAFE_URL_SCHEMES.
Leading ASCII control characters and spaces — which browsers strip before
resolving a scheme — are removed first so java\tscript: can't sneak through.
True when text is markdown so simple it renders as nothing more than a
single <p> of plain text — no bold, italic, links, lists, headings, code,
blockquotes, tables, or multiple paragraphs. When this returns true the
question can safely be used as the clickable label of its control; when
false the question should be rendered as a prose block above the control.
Check if a naive datetime-local string is a parseable datetime. Does not interpret timezone — purely a format check.
Validate that a string is a valid IANA timezone identifier.
Join an array of strings into a single string (curried reduce shorthand). Replaces the common pattern: reduce((acc: string, s: string) => acc + s, "")
Resettable lazy reference - like once() but can be reset for testing. Returns [get, set] tuple where set(null) resets to uncomputed state.
Convert a naive datetime-local value (YYYY-MM-DDTHH:MM) to a UTC ISO string, interpreting the value as local time in the given timezone.
Log categories for debug logging
Log a failed database operation under the standard DB_QUERY code.
Log a debug message with category prefix For detailed debugging during development
Log a classified error to console.error and persist to the activity log. Console output uses error codes and safe metadata (never PII). Activity log entry is encrypted and visible to admins on the log pages.
Log a classified error to console.error only (no ntfy, no activity log). Use this where calling logError would cause infinite recursion (e.g. ntfy.ts).
Log a completed request to console.debug Path is automatically redacted for privacy
Curried map
Index items by one field and chosen value. Keys keep first-occurrence order, while later matching items replace the stored value.
Index items by id and a chosen value.
Curried map that drops null/undefined results in one pass.
Curried adapter over @std/collections.mapNotNullish.
Replaces the two-step pattern: compact(map(fn)(array))
Map over a promise-returning function in parallel (Promise.all)
Strip non-numeric characters from a phone number and normalize to +{prefix}{local}
Normalize a user-provided slug: trim, lowercase, replace spaces with hyphens
Current time as a Date
Full ISO-8601 timestamp for created/logged_at fields
Epoch milliseconds for numeric comparisons
Current time in whole epoch seconds — the unit signed-token expiry uses.
Lazy evaluation - compute once on first call, cache forever.
Use instead of let x = null; const getX = () => x ??= compute();
Split an array into [matching, rest] by a predicate, keeping order.
Curried adapter over @std/collections.partition.
Build tel: and wa.me hrefs for a phone number, or null when the number
has no digits. The prefix is the country dialling code (e.g. "44"); a
leading "+" is tolerated so a settings value of either "44" or "+44" works.
WhatsApp's wa.me wants the international number with no leading "+".
Compose functions left-to-right (pipe).
Redact dynamic segments from paths for privacy-safe logging Replaces:
Curried reduce
Register a cache stat provider (called at module load time)
Register an extra full-clear to run with a write cause when every cache is
reset. Only needed by caches without a table registration;
resetAllCaches already fires every table-registered invalidator.
Register invalidate to fire whenever ownTable or any deps entry is
written. Plain string entries are unconditional; object entries may carry
whenColumns to gate on specific UPDATE columns (INSERT/DELETE always fire).
Centralises the registration loop shared by cachedTable and cachedEntityTable.
Register invalidate to run whenever any of tables is written.
Render markdown to HTML (block-level: paragraphs, lists, etc.). Raw HTML is escaped and unsafe URLs are stripped.
Read a required map entry, failing where a broken completeness invariant is first observed instead of passing an undefined value onward.
Clear every registered cache: each table-registered invalidator once (a cache registered against several tables still only clears once) plus every extra reset hook. Runs after operations that bypass the normal write path — a full reset or a restore — where any warm cache is stale. A lazily-loaded cache module that never ran is absent from the registry, which is correct: it has no cache to clear.
Run a function within a pending-work scope. Whatever fn resolves to, the
queue is drained once more on the way out: an error logged after the
request's own flush (e.g. while the response is finalised) still queues
work, and work that outlived its request would complete during whatever
runs next — on Bunny that's a killed fetch, in tests a sanitizer failure
in an unrelated test.
Run a function with a request-scoped random ID for log correlation
Set module-level request log suppression (avoids env race in parallel tests).
Turn arbitrary text into a URL slug: lowercase, every run of non
[a-z0-9] collapsed to a single hyphen, no leading/trailing hyphen. Shared
by the news permalink builder and the provider-resource slug.
Slug-with-index pair. Index is the blind-index type computeIndex
produces (a BlindIndex for the real tables).
Non-mutating sort with comparator
Sort strings in ascending locale order without changing the input.
Sum an array of numbers (identity selector shorthand for sumOf). Replaces the common pattern: reduce((acc, n) => acc + n, 0)
Curried group-and-sum: accumulate valueOf(item) into a Map keyed by
keyOf(item). Replaces the common pattern:
const m = new Map(); for (const x of xs) m.set(k(x), (m.get(k(x)) ?? 0) + v(x))
Curried sum-by-selector. Adds up the numbers produced by selector for each
item. Curried adapter over @std/collections.sumOf.
Replaces the common pattern: reduce((acc, x) => acc + selector(x), 0)
Get today's date as YYYY-MM-DD in the given timezone.
Convert minor units to major units string for form display. e.g. toMajorUnits(1050) → "10.50" (for GBP)
Convert major units (decimal) to minor units (integer). e.g. toMinorUnits(10.50) → 1050 (for GBP)
Create a TTL (Time-To-Live) cache. Entries expire after ttlMs milliseconds. Accepts an optional clock function for testing.
Remove duplicate values (by reference/value equality), keeping first
occurrences in order. Curried adapter over @std/collections.distinct.
Remove duplicates by a key function, keeping first occurrences in order.
Curried adapter over @std/collections.distinctBy.
Make a deterministic base slug unique by appending -2, -3, … until one
is free. Unlike generateUniqueSlug (random 5-char slugs), this keeps
a human-readable base — the news permalink yyyy-MM-dd-post-name — and only
disambiguates on collision (two same-day posts with the same name).
Remove one registration from the registry (handed back by the register functions; production callers register for the process's lifetime and drop it, tests must call it so their entries never outlive the test).
Convert a UTC ISO datetime string to a datetime-local input value (YYYY-MM-DDTHH:MM) in the given timezone. Used for pre-populating form inputs with timezone-adjusted values.
Parse a UTC ISO string into a ZonedDateTime in the given timezone
Validate a normalized slug. Returns error message or null.
Replace each markdown link whose target matches matcher with its plain
text. Used to strip links the viewer isn't allowed to open (e.g. owner-only
admin pages) before rendering — a rendered link is a promise that it works,
so a viewer who can't follow it gets the words without the link.
Verb of a mutating SQL statement
docs/webhooks.ts
Webhook delivery and API examples.
Example availability response JSON
Example free booking response JSON
Example paid booking response JSON
Example booking request body
Example listing matching the webhook example data
The bundle both package examples describe: browsing lists it, and the package endpoint documents the same one in full.
The example PublicListing, produced by toPublicListing
Example list response JSON
Example single-listing response JSON
Pretty-printed JSON for embedding in documentation
The example payload, matching what buildWebhookPayload would produce