Chobble Tickets
A self-hosted ticket reservation system built on Deno with libsql.
Features
- Free and paid listings (Stripe, Square, and SumUp integration)
- Standard listings (fixed capacity) and daily listings (date-based booking)
- Multi-listing bookings in one checkout
- Hybrid RSA-OAEP + AES-256-GCM encryption for PII at rest
- Check-in with QR codes (built-in scanner)
- Apple Wallet pass support
- Admin dashboard with multi-user management
- Email notifications (Resend, Postmark, SendGrid, Mailgun)
- Public JSON API (optional)
- Webhooks on registration
- ICS/RSS calendar feeds
- Embeddable widget via iframe
Modules
| Module | Description |
|---|---|
| Database | ORM, table abstractions, and entity CRUD |
| Crypto | Encryption, hashing, and CSRF |
| Payments | Stripe, Square, and SumUp integration |
| Email sending and templates | |
| Tickets | QR codes, SVG tickets, Apple Wallet |
| Listings | Listing fields, sorting, availability |
| Config | Settings, environment, sessions |
| Utilities | FP helpers, formatting, caching |
| Embed | Widget embedding and CDN |
| Webhooks | Webhook delivery and API examples |
| Demo | Demo mode and seed data |
Deployment Options
- Bunny Edge Scripting (edge-deployed)
- Docker containers
- Any Deno-compatible environment
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.
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.
A keyless invite leaves wrapped_data_key NULL. An editor holds no
DATA_KEY, so unlike acceptInvite there is no handoff to unwrap or
re-wrap. The password only authenticates, and protects no key.
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.
Advance a stored terminal outcome from its conservative shape to its final one, once the follow-up money work has finished. Reads the row fresh, so ANY later process — not just the one that stored it — can finish the move. The write is fenced on the exact bytes just read; a racing advance makes this one converge instead of clobbering. A missing or re-reserved row means the idempotency record was pruned mid-move: the durable money records are already correct, so there is nothing left to advance.
One fenced attempt to move a session's stored outcome to to: the write
lands only while the row still holds the exact fence bytes. When the
fence misses, the row must have converged to to (a racing advance won) or
vanished (pruned) — anything else is a real conflict and throws. Exported
for that race arm: a test can hand it a stale fence deterministically,
where a live race cannot be scripted.
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
Both repairs for one table's aggregate columns, given the SQL that recounts each one. Every aggregate family declares its table and its per-column recount here rather than writing the same two wrappers again.
Whether any of the given listings is a member of a package group. Empty input → false (no query). Used to keep a package member from being turned into another listing's required child (a package page can't render child edges).
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.
Narrow an unknown value to string, defaulting to "" if not a string.
Replaces typeof x === "string" ? x : "" at type boundaries.
Remove or detach every dependent row for one or many attendee ids.
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.
Resource management pattern (like Haskell's bracket or try-with-resources). Ensures cleanup happens even if the operation throws.
Build an INSERT into listing_attendees, capacity-checked by default. A zero-quantity booking carries no capacity or active condition: it demands no places, so it can land on a full or inactive listing too — but by default it still names a listing that must exist, because listing_attendees has no foreign key. An overbook caller (the payment ghost store) explicitly asks for the row whatever the listing state. An order-level extra condition still applies.
Build the check-in URL for a single ticket token
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 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.
Build a consolidated webhook payload from registration entries
Key items by their own id, so a caller can look one up. Hand-rolled because
@std/collections.associateBy keys by a string and gives back an object,
while every caller here has number ids and wants a Map.
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.
A write to the table, or to a dependsOn table whose triggers feed it,
clears the cache automatically at the db-client layer.
A write to the table, or to a dependsOn table whose triggers feed it,
clears the cache at the db-client layer, so no write path calls it.
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.
Cut a provider's reply to that length, marking the cut.
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.
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.
Clear the one booking-stage relationship a merge must replace early.
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)
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).
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 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. Arrays of different lengths are never equal — the length difference seeds the mismatch flags, so no branch on it can leak timing.
Extract ContactInfo fields from an object
Copy the source's package overrides onto the duplicate's membership rows in
the SAME transaction that inserted them (the create write's afterWrite), so a
failure rolls the whole duplicate back rather than leaving a live member at the
default price. The flat group and per-day group_day price rows are copied
only for package groups the NEW listing actually joined (the duplicate form may
untick some of the source's groups) — scoping each source row's encoded group
to the clone's group_listings, exactly as the quantity copy does. Otherwise a
copied override for a non-joined group would lurk invisibly and resurrect the
source's price if the clone were later added to that package.
enforceBudget: false exempts the call from the budget allowance only — the
reserve/allowance cap the migration runner sets below the real limit. The hard
round-trip limit below always applies: it mirrors Bunny's platform cap, which
no call, cleanup included, can exceed. So a rollback is let past our own
stricter reserve to run, but is still blocked if it would be a genuine
over-the-platform-limit subrequest — exactly as Bunny would reject it.
Count all rows in a table. table must be a trusted constant, not input.
Every YYYY-MM-DD day a stored [date, endDate) booking covers. The end is
exclusive, and a booking with no end recorded covers only the day it starts.
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 withClient helper that runs an operation with a lazily-resolved client. Returns null if the client is not available or the operation fails.
Default message for invalid/expired CSRF form submissions (request-scoped).
Generate a range of YYYY-MM-DD date strings from start to end (inclusive). A start past the end is an empty range: Array.from reads the negative length as zero.
Convert a nullable date to the stored half-open range.
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).
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.
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
Resolve after ms milliseconds — for retry backoff and similar waits.
Delete all sessions (used when password is changed)
Delete all stale reservations (unfinalized, outcome-less, and older than STALE_RESERVATION_MS). Called from admin listing views to clean up abandoned checkouts. Rows carrying a recorded terminal failure are kept so a late redirect/webhook replays the handled outcome rather than re-refunding.
Delete an attendee and all its listing links, payments, and answers.
Delete rows matching a field value
Delete rows from multiple tables in a single batch transaction
Build the DELETE statement for one DeleteByFieldTarget — for batches that mix these deletes with other statements.
Delete 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
Delete one row and the rows that point at it, children first, in one batch. Each child names the column that holds the parent's id.
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.
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.
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.
Local part (everything before the last @) of a validated address.
A Map holding an empty list for each key, ready to be filled.
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).
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.
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).
Only the fields that grow with what the buyer selected can realistically exceed the per-value limit. Form validation already holds every other field well below the smallest provider limit of 255.
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.
Execute multiple write statements, discarding results.
Write without firing cache invalidation. Reserved for plaintext bookkeeping rows (script-version markers) no cache ever holds — written concurrently with requests, the normal path would wipe the settings snapshot the request just loaded.
Run a write that ends in RETURNING and read back the row it wrote. A
write that returns no row means nothing was written — fail there, loudly.
A single statement with no table-scoped invalidation, for a caller keeping its own cache state through a write. Query tracking still happens. Every other write wants execute.
Expand a daily-listing range into individual day strings.
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)
The lower-cased column names an UPDATE's SET clause assigns, or null when none
can be read. Commas inside parentheses are skipped, so a coalesce(x, 0) does
not split one assignment into two. Null means the caller invalidates
unconditionally — safe over stale.
Extract the viewBox from an SVG element to compute its coordinate space
Pull the reason out of a provider's error reply: the parsed message or the raw body, on one line, capped, and with every email-shaped value blanked. Empty when the reply body says nothing.
Index one field from each item by its id.
Curried filter
Heals a reservation ONLY while it is unresolved, so it never overwrites the
attendee_id or blanks the ticket_tokens a racing delivery just finalized.
The guard is UNRESOLVED_RESERVATION, so the first outcome wins.
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. Repeats 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 the site currency is GBP). A stored provider amount passes its own currency so the symbol and minor-unit divisor describe that charge rather than today's site setting.
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 the safe Sentry message and activity-log base.
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.
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 whose booking on one listing covers a given day. A booking spanning several days covers each of them, so a stay from Friday to Sunday answers to Saturday as well as to its own first day. Uses the same half-open overlap predicate as the capacity checks, so the people a day's message reaches are the people that day counts.
Get the encrypted PII blobs for attendees booked onto any of the given listings (one row per attendee, even if booked onto several of them). Returns an empty array when no listing IDs are supplied.
Get an attendee by ID without decrypting PII Used for payment callbacks and webhooks where decryption is not needed Returns the attendee with encrypted fields (id, listing_id, quantity are plaintext)
Get attendees by ID without decrypting PII, one row per (attendee, booking). Used by the agent run sheet, which already knows the attendee ids it needs and only reads each attendee's contact fields. Returns an empty array for no ids. Decrypt with decryptAttendees before display.
Read raw attendees attached to any requested listing.
Look up attendees by plaintext tokens, returning full booking data. Two queries: attendees by token index, then all listing_attendees for those attendees. Returns results in the same order as input tokens. Bookings sorted by start_at then listing_id for deterministic ordering.
Pagination counts ATTENDEES, not booking lines, so a grouped attendee row
carries their complete listings list and never splits across a page boundary.
listingIds decides WHICH attendees match, and the returned rows still cover
all of a matched attendee's listings.
A duration_days > 1 listing excludes a start date whose full range hits a
non-bookable day or runs 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
DEFAULT_DB_HOST's provider: "turso" when set to it, "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)
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)
The first real booking, or a no-quantity placeholder when no real one remains. The returned row itself proves whether the action has a live booking; callers do not need a second existence query.
One stored group, through the shared many-group read. Null when no group has that id.
Get a single group by slug_index (from cache)
Every membership row for a group, carrying its package_price override and
per-package quantity. A null package_price means "no override — use the
listing's own price", 0 means explicitly free in this package, and a
positive value overrides the price; quantity defaults to 1. The override is
read from the group dimension of listing_prices; quantity from the
membership row.
The membership rows for several groups in one query, keyed by group id, so a list endpoint can hydrate every group's package members without a per-group round-trip. Groups with no membership rows are absent from the map.
Remaining group capacity for one listing, or undefined when no cap applies or the listing does not exist.
Every group keyed by id, from the request-cached set — the batched alternative to one read per id when resolving or validating many groups without tripping the N+1 read guard.
Groups by id in one query, read straight from the table: a group about to be shown, edited or acted on must be the stored row, not a cached copy that another edge may already have changed. An id with no row is simply absent from the map.
Static maximum capacity for each capped group.
Get 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.
Members of SEVERAL groups at once, keyed by group id — the batched form of the
single-group loaders for a multi-group surface. A page with many group leaves
would otherwise run one member query per group; this loads the join once and
the member listings once, then assembles each group's list in memory. Every
requested group id maps to an entry (empty when it has no matching member).
activeOnly keeps just active members (the site-page nav's liveness gate); the
default includes inactive members (the validators' group-compatibility read
for a listing that joins many groups, kept batched to stay under the N+1
guard).
Read every listing keyed by id.
Read listings by slug in input order, retaining nulls for missing rows.
Read listings in input order, retaining nulls for expected missing rows.
Get listing and its activity log in a single database round-trip. Uses batch API to reduce latency for remote databases.
Read one listing and one attendee in one round-trip.
Read one listing and all its attendee rows in one round-trip.
Read one listing when absence is expected.
Read one listing by its plaintext slug when absence is expected.
Read a just-written listing from the primary, or null if it was deleted.
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 checking spans at the 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.
Resolve the provider for callbacks and completion of payments that already exist. New sales use getActivePaymentProvider.
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
Read a session and the user it belongs to together.
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.
The merged contact-fields setting for a page's listings: every listing's own setting, folded into one. Takes anything with a listing that names its contact fields, so this module stays free of booking-model imports.
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.
Does a group row exist? The add-item revalidation's single-row check — no name decryption, never the whole table.
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 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.
Transaction-local recheck: whether any sold booking still holds this group's package id. Used inside the group write transaction so a checkout that commits between a request-level sold-hidden check and the write rolls the un-packaging back rather than revealing concealed member names.
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
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.
Return a value unchanged.
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. "?, ?, ?"
The key of the row an INSERT … RETURNING wrote, read from the row itself
rather than from the driver's optional lastInsertRowid. Every generated key
is a positive integer, so anything else means nothing downstream can be keyed
on this row and the write must fail here.
Fire every cache invalidator registered against table (no-op if none).
The write narrows nothing, so column-gated entries fire 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).
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 this instance can build other sites.
Check if demo mode is enabled
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)
Pure helper: is the current time within the warning window before cutoff?
Remove null and undefined values from array
ISO timestamp a fixed duration after the current time — for a column that says when to do something next, rather than when something happened.
ISO timestamp a fixed duration before the current time.
Ask whether a value is one of a fixed list: isOneOf(["a", "b"])("a") is
true. Curried, so a list of words becomes one named check.
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.
Whether an active provider has the credentials a sale needs.
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).
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 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.
Check whether a slug is already used, optionally excluding one listing.
Check if image storage is enabled (Bunny CDN or local filesystem).
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.
Keep the items a test accepts, then take one thing from each.
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.
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.
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.
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).
Whether this listing can send QR code scanners directly to checkout. True when no extra contact fields or questions are required.
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.
The full pricing state of SEVERAL package groups, keyed by group id, in two reads however many groups are asked for — an order that books many packages would otherwise spend two round-trips per package and eat the request's subrequest budget. Every requested group is present; one with no membership rows reads as empty maps.
Load one explicitly named provider implementation.
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 several activities as ONE write. A booking with many lines records a line apiece, and one insert per line would eat the edge request's subrequest budget; a batch costs the same single round-trip however many there are, and either they all land or none does. Returns the stored rows in the same order, with their plaintext messages. A caller may pass its open write transaction so the log and the action it records commit together.
Log one activity, through the shared many-activity write. Optionally associate it with a listing and/or attendee so admin views can filter the log by either.
Record a registration and queue its external notifications.
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).
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 and how to read the
id/url. A null create answer means the provider is not configured; a non-null
answer must contain both documented fields.
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.
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.
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.
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.
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.
Rewrite a built INSERT as INSERT OR IGNORE, dropping a row whose unique key
is already stored instead of raising a constraint error. This is the once-only
latch resumable flows lean on: a replayed write re-derives the same key and
lands nowhere. It silences every conflict on the statement, so use it only
where the unique key IS the idempotency rule.
Whether a stored booking overlaps one day. String comparison mirrors the SQLite overlap check byte-for-byte.
True when a refused gate admits only the owner account: either the guard names the role exactly, or it names one role and that role is owner.
The package displays behind a set of booked rows — each row's attendee names
its persisted package_group_id (0 on a plain row, matching no package).
Shared by the ticket view, the wallet lookup, and the email renderer, which
all carry { attendee, listing } row shapes.
A package group's member rows projected into the two maps every consumer
needs (the booking flow, the webhook revalidation, the bookability gate, and
the test harness): prices keeps only members with a real override — a
positive price OR an explicit free 0, dropping a null "no override" — while
quantities covers every member (default 1). Owning both here keeps the "what
counts as an override" rule in one place; callers destructure what they use.
The member-naming package error for the first listing in listings that
can't be a package member (pay-what-you-want, an add-on of another listing,
or — on a hidden package — a member gating its own children), or null when
every listing is a valid member. Judged against ONE batched edge load (two
queries for the whole member list, never one per member); the rules and
their messages live in the shared packageMemberError. The one place
every package save (group form, add-listings, listing form/API, catalog
import) turns an unpackageable member into its user-facing message.
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.
Parse a Bunny API error response into a BunnyApiResult.
The epoch milliseconds a written date names, or null when it names none —
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.
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. A non-empty value is an app-written durable outcome, so decryption or schema failure is corruption and must surface here instead of being mistaken for a handled booking result.
Parse a string into a positive integer for warning days. Returns defaultVal on bad input.
The one door every webhook body comes through. A body that is not JSON is refused the same way for every provider. What the body then means differs — a signed provider posts the event itself, SumUp posts two fields we build one from — so each provider passes its own reading, run outside the catch so a bug in it never reads as bad JSON. A reading that answers nothing means the body is JSON this provider cannot read, which earns the same refusal.
Split an array into [matching, rest] by a predicate, keeping order.
Curried adapter over @std/collections.partition.
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 "+".
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 provider is active. The set is keyed by the provider union,
so a caller cannot forget a provider: leaving one out is a compile error at
the call site rather than a silent null on a live site.
Query all rows as a typed array. A read whose cache refill requires read-your-writes goes to the primary instead.
queryAll for a caller that must read its own writes. The same rows, pinned to the primary, because a replica can lag behind the write and miss them. See queryBatchPrimary. It takes the statement whole, which is the shape a batch runs, so a caller that holds one hands it over as it is.
Execute a SQL query and map result rows through an async transformer.
Run a single-column SELECT and collect that column's values into a Set of strings — the shared shape of the "which hashes/names already exist" reads (e.g. the live table names, the unsubscribed contact hashes).
Run a query whose single selected column is aliased id and return the ids.
Query one row, or null when the query returns none.
Query an optional row on the primary — the singular of
queryAllPrimary, as queryOne is of queryAll. args
is required here: every read-back keys on the row the write just made.
Pick a random element from an array
Whole numbers counting up from start, stopping before end ([] when end
is not past start). Hand-rolled because @std/collections has no numeric
range helper. Iterating this bounded array replaces hand-counted loops,
which a single slip in their step can make endless.
Embed a raw SQL expression (e.g. last_insert_rowid())
Read several groups' members together with one more thing about the SAME
groups — their prices, their full membership, whatever the caller needs — in
one round of reads. A page hydrating many packages would otherwise pay a pair
of reads per package and eat the request's subrequest budget. activeOnly
has the meaning getListingsByGroupIds gives it, and is spelled out at
every call site — whether inactive members count is the caller's decision,
never a default.
Rebuild the full schema after resetDatabase(), WITHOUT reading the database to decide what to create.
Recompute end_at on all existing listing_attendees rows for an listing
based on a new duration_days value. Leaves NULL-start rows alone.
The .000Z suffix matches the format fresh inserts produce via
toISOString() so raw-row dumps stay consistent.
Curried reduce
Register a bunny subdomain (DNS + CDN).
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.
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.
Renders block markdown, escaping raw HTML and stripping unsafe URLs.
Move live attendee assignments while keeping their records.
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). Missing and stale unresolved rows are claimed atomically. Existing fresh, finalized, and failed rows are returned without changing them.
Reset selected aggregate columns from trusted SQL expressions. Each expression must use the entity id as its only placeholder.
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 engines (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.
Cast libsql ResultSet rows to a typed array (single centralized assertion)
True when the query returns a row. sql should be an existence probe such as
SELECT 1 ... LIMIT 1; which columns it selects is ignored.
Build an existence check for "one leading id, matched against a list of ids".
The checker binds leadingId to the first ? and expands ids into the
IN (...) that buildSql embeds through the placeholder string it is handed.
Empty ids runs an empty IN (), which matches nothing.
Run an id-keyed SELECT, short-circuiting to [] (no query) when ids is
empty. buildSql receives the bound ?-placeholder list for ids, so ids
are the only query args. The base skeleton for the id-map helpers below and
for any read that loads rows for a caller-supplied id list.
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 session-memoization scope
Run fn with an isolated storage configuration (test-only).
Whether two records hold the same value in every named field.
Whether two sequences hold the same values, in the same order. It stops at the first difference, so how long it takes says how much matched: never compare secrets with it.
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.
Constant-time string comparison (over UTF-16 char codes) to prevent timing
attacks. Shares the one constant-time loop in constantTimeCodesEqual.
Seed the effective domain from the request's own hostname.
signal cancels the request while it is in flight. null is the
everyday case: a send that nothing cancels.
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 one direct webhook request without blocking registration on failure.
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.
Clears every crypto cache, including any registered by
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.
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).
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}
Convert single-listing answerIds to the per-listing format used in metadata
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.
Non-mutating sort with comparator
The numbers, each once, smallest first.
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.
Wall-clock milliseconds during which at least one query was in flight: the combined length of the query intervals with overlaps merged.
Round a date down to the start of the current hour for cache-stable signatures
Name of the configured Bunny storage zone, or null when Bunny is not the active backend. Shown to operators so they can tell which zone a page reads.
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).
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)
Decrypt a prefixed AES-GCM payload with the given key.
AES-GCM encrypts to enc:1:<base64 iv>:<base64 ciphertext>.
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.
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)
currency defaults to the site's currency; a caller converting a charge
taken in another currency passes that currency so the divisor matches it.
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.
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. Give maxEntries a value to bound the cache: storing a new key at the cap drops the oldest-stored entry first. Bound any cache whose keys can be chosen from outside (e.g. unknown session cookies), so a flood of unique keys cannot grow it without limit. Leave it unset for caches whose keyspace our own data already bounds — the keyed entity caches rely on holding every row of their table.
The listings whose lines do not fit right now, in one query. The attendee-edit preflight names its culprit with this.
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).
Unwrap a symmetric key Expects format: wk:1:$base64iv:$base64wrapped
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.
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).
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.
Upload raw bytes to storage, routing to local or Bunny based on config
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
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 encryption key is present and valid Call this on startup to fail fast if key is missing
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.
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)
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 } and letting unexpected failures propagate. 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
Use this rather than a plain batch only when a multi-step write needs logic between steps, such as create → check capacity → finalize, where a zero-row guard must abort and undo everything.
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 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. On update, an optional readState runs before
the statement and its result reaches persist; creates skip it. Returns the
row id — existingId on update, or the key the INSERT returned on create.
Execute one table-built INSERT/UPDATE on an open transaction and return the affected row. A conditional write returns null when its condition is false.
Activity log entry as callers see it: the message decrypted to plaintext.
What one batch's reply said about the messages inside it.
A table built by defineTable: the transactional statement builders and the primary-pinned read are always present. They stay optional on Table only for hand-written façade tables that never take the transactional path.
A secret as reported by the Bunny API (name + metadata only — never the value).
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.
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.
- 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
A listing's identity, capacity, and current booked quantity.
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.
Input shared by ordered tables whose only required value is a name.
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.
A package group's full pricing state: what it charges, plus the membership rows those charges were read from (which listings are in the bundle).
What a package charges for its members: the flat override + quantity maps (packageMemberMaps) and each customisable member's per-day overrides. The shape the booking page, the webhook payload, and the payment revalidation all price from.
Operations every configured payment provider supplies.
One terminal-failure transition that can join an existing transaction.
statement establishes the conservative outcome; once follow-up money work
finishes, advanceSessionFailure moves it to its final shape.
Render all 3 parts (subject, html, text) using custom templates with fallback to defaults
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).
Table definition with CRUD operations
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.
One thing to record in the log: what happened, and which listing/attendee it happened to.
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.
Per-column comparison of each aggregate F's stored value against its
rebuilt-from-source value — what the "recalculate aggregates" tools return.
The two owner-facing repairs every trigger-maintained aggregate column set needs: write the numbers the operator typed, and rebuild chosen columns from the rows they count.
Stored values of the trigger-maintained aggregate columns F, keyed by column.
A desired final-state line for the atomic update path. Re-exported from the shared types module so callers can keep importing it from here.
Attachment validation error
Attachment validation result
Input for creating an attendee atomically (one or more listings)
One page of attendee booking rows, plus whether a further page exists.
Carries the full field set because the same page query feeds both the
browsing table (which shows no money) and the CSV export (which sums
price_paid); the table simply ignores the columns it doesn't render.
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)
Runs several statements as one batch and answers each in turn: the shape
queryBatch and executeBatchWithResults have, and the one
TxScope's batch fits. A caller that reads either from the client
or through an open transaction takes one of these instead of naming both.
Booking result — callers map this to their response format
A browsing-table attendee row — every core column plus refunded, but none
of the expensive money projections.
One buyer-facing row group: a HIDDEN package's rows gather behind its name; every other row stands alone.
Why cached data was cleared. Only a committed write needs primary refills.
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.
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 provider runners so a signature drift fails at the definition instead of leaking to callers.
Individual contact field name
Required name+email with optional phone/address/special_instructions from ContactInfo
Attendee contact details — the core PII fields collected at registration
Result of atomic attendee creation. A failure carries the listings whose lines did not fit when the refusal was checked — empty when the failure was not one listing's capacity shortfall (a duplicate slot, a replayed ledger event, or a race that freed the room again before the check).
Shared shape for a provider credential check in connection-test results.
The subset of listing fields needed to reason about day-count pricing.
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.
A decrypted attendee row: the raw row with its PII overlaid and its
booleans/price coerced, keeping exactly whichever optional money fields the
read selected. DecryptedAttendeeRow<Attendee> is the full Attendee.
One delete-rows-matching-a-field target: which table, matched on which field, for which value.
Maps form field names to arrays of possible demo values
A dependsOn entry accepted by cachedTable / cachedEntityTable.
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: the subject line, the html body, or the plain-text body. A plain union for the same reason.
The persisted email template types: the attendee confirmation and the admin notification. The discriminator the renderer, the settings store, and the admin forms all key on. A plain union rather than a picklist schema, because nothing validates a string against it — every value comes from a typed call, never from a form or a stored row.
Error facts routed to the applicable sinks.
Provider implementation for work on an existing payment.
Visual level of a flash message: a positive result, a failure, or a neutral acknowledgement.
Everything a caller declares to read listing records: which rows to keep and in what order.
The group ids each listing belongs to (listing id → group ids). A listing absent from the map belongs to no group.
Image validation error
Image validation result
Row from listing_attendees — per-listing booking data
A single listing booking within a multi-listing attendee creation
A listing's contact fields, as comma-separated ContactField names, or empty
for name only. parseListingFields is what enforces it at runtime.
How the rows come back. A named order so callers can't hand-roll a stray
ORDER BY. Exported because a narrow listing read — one that selects its own
columns rather than the whole record — still wants to come back in the same
order as the full reads.
The raw shape a listing read returns: the stored columns plus the worked-out values, before decryption and before any inherited defaults are overlaid.
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.
Log categories for debug logging
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).
Unique identifiers for settings nags that prompt the admin to complete required or recommended configuration.
The public /news list projection: a summary plus the post's first image (the shared ItemImageColumns 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).
Package-group display info for grouping a booking's lines under the package name on tickets/emails.
Data needed to generate a pass — maps to existing ticket/listing data
Supported payment provider identifiers
Valid payment status value
Processed payment record
The raw attendee columns the decrypt step reads and coerces. price_paid
and refunded are optional because a field-selected read may leave them out
(see file://./select.ts); the decrypt then leaves them out too rather
than coercing an absent column into "undefined" / false.
Run one column's declared read transform (e.g. decrypt) on a stored value —
identity when the column declares none or the value is null. For reading a
single column back without building a whole row. rowId (when known) lets
the transform name the record in error reports.
Result of session reservation attempt
Everything retrieving a checkout session by id can come back as.
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.
Validated logical metadata. Missing wire values are represented by "";
provider-specific packing is removed at extraction.
A session and the user it belongs to. user is null when the session
outlived its user, which the caller reports and clears — never mistake it for
a token nobody ever had.
Full settings snapshot type.
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 kind of thing a SitePageItem points at. Exhaustive union — a new
member is a compile error at every Record<SitePageItemType, …> dispatch.
Slug-with-index pair. Index is the blind-index type computeIndex
produces (a BlindIndex for the real tables).
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.
A single SQL statement plus its bound arguments — the object form libsql's
batch API accepts. This is the one shared shape for a { sql, args } pair;
callers that build statements to hand to executeBatch and friends
import this rather than re-declaring the same object type locally.
A stored log message: owner-key ciphertext for rows written since the keypair existed, env-key ciphertext for legacy rows the backfill hasn't re-encrypted yet. The format prefix routes decryption at runtime.
Non-PII ticket data for the SVG, with its values already formatted.
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
UI theme
Reads narrow pre-update state through an open write transaction.
The slice of an open write transaction handed to a withTransaction callback: run statements singly or as one batch; commit/rollback are managed for you.
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).
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)
A validated payment session returned after checkout completion
An email address that has passed validation (a non-empty host containing at least one dot) and been normalized (trimmed, lowercased).
Shared wallet pass data common to both Apple and Google Wallet
Attendee data needed for webhook notifications
Consolidated payload sent to webhook endpoints
Listing data needed for webhook notifications
Everything resolving a webhook's session can come back as: the session; "skip" to acknowledge without processing; "retry" to answer with the fixed retryable refusal; a rejection carrying a paid charge the boundary could not read; or null for an event that is provably not ours.
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.
Activity log table definition.
Schema for admin role levels.
Key length AES-256 takes, in bytes.
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.
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
User-facing messages for attachment validation errors
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.
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
Convert standard base64 to base64url (no padding). Works on both strings and Uint8Array (bytes are first encoded to base64).
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).
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.
Helper to create column definitions
All valid contact field names (runtime array matching the ContactField union)
Schema for an individual contact field name
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.
Create a new (already-activated) user with encrypted fields. Activated users are created at the password-bound KEK scheme (v2); the caller computes the matching wrapped_data_key via wrapDataKeyForPassword.
Most physical database attempts made for one retryable operation on the remote database — the number the edge subrequest budgets are sized from. A file database retries longer, where no subrequest budget binds.
Milliseconds in one day — for whole-day arithmetic on epoch times.
Strict stored shape for per-day-count prices.
Delete all first-class image files.
Delete all attachment files for a list of listings
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
Deno Deploy app names: 3–32 chars, lowercase letters, numbers, hyphens.
Matches a valid hostname like "example.com" or "sub.example.com"
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).
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).
Encryption format version prefix Format: enc:1:$base64iv:$base64ciphertext
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.
Write statements in order in one transaction, returning every ResultSet — suited to cascading deletes and multi-step writes.
Per-day remaining for several capped groups, loaded in two queries.
Remaining capacity for each capped group.
Tightest remaining capped-group capacity for each listing.
Get all listings in a group with attendee counts (including inactive).
Group name and description fields
The listing ids in a group, and the reverse listing-to-groups side.
Execute a query and decrypt the resulting group rows
True when any of the listings has a paid line for this attendee — a gross
sale leg in the row's ledger_event_group (a sale leg's amount is always > 0,
so its existence is exactly a non-zero projected price_paid; a refund keeps the
gross leg, so a refunded line still reads as paid). One query over all the IDs,
read from the live ledger rather than the edit form's submitted key (a
stale/missing key can leave it null), so a recorded payment is never dropped
onto a fresh quantity-0 row. Callers pass a non-empty list.
Holiday name field
Cached holidays table — name is encrypted, dates are plaintext; writes auto-invalidate the cache.
The host machine's own email credentials, read from its environment, with the hook a test uses to stand a different set in front of them.
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.
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.
User-facing messages for image validation errors
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 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.
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).
Type guard: check if an arbitrary string is a valid ListingType
True only for the owner role.
Type guard: check if a string is a valid PaymentProviderType
Type guard: check if a string is a valid PaymentProviderSetting
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).
Whether Turso hosting has its API token, organization, and group set.
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.
Listing metadata fields
Write the operator's typed listing aggregates, or rebuild chosen ones from the booking rows they count.
Read and decrypt listing names without loading full records.
The shared narrow listing shape used by listing and attribute pickers.
The listing reader. Reading by group starts from the membership rows and folds a listing's several memberships back into one row, naming which groups it matched; every other read starts from the listings themselves.
Listing CRUD with cache invalidation and listing-price synchronization.
Schema for a listing type: standard (one-time) or daily (date-based booking)
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.
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.
Max listings that can be created in a single seed operation
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.
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.
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).
Stubbable API for internal calls (testable via spyOn, like stripeApi/squareApi)
Schema for valid payment status values. "failed" is a terminal non-payment (declined or expired checkout) — distinct from "unpaid", which may still complete.
Current PII blob schema version
Execute multiple read queries in a single round-trip using Turso batch API.
HTTP primary reads use a separate BEGIN and COMMIT in one pipeline. A complete SELECT-only batch can run on a replica despite its write mode.
Raw listings table. Records adds cache-aware CRUD and price syncing.
Register a cache stat provider (called at module load time)
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.
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
Safely execute async operation, returning null on error. Re-throws PaymentUserError so user-facing messages propagate.
Ordered table names — matches FK dependency order (parents before children)
Max attendees per seeded listing
Send one consolidated payload to each distinct registration webhook.
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".
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
Schema for the kind of thing a SitePageItem points at.
Every config key that maps to a snapshot field, in load order.
Admin role levels that are back-office staff (not delivery agents).
Threshold for abandoned payment reservations in ms (default: 300000 = 5 min)
Sum an array of numbers (identity selector shorthand for sumOf). Replaces the common pattern: reduce((acc, n) => acc + n, 0)
The buyer's summed price (minor units) across an order's entries.
The bundle's summed booked quantity across an order's entries.
Terms and conditions field
Every statement inside a withTransaction holds the single primary write
connection open for another edge→primary round-trip. A chatty interactive
transaction is what the primary aborts as "Transaction timed-out".
Turso database names: non-empty, at most 63 chars.
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 key using a session token
Transcode an uploaded image to WebP, one stored file per target, returned in
the order of targets.
Decoded icon files for inclusion in .pkpass bundles
Pretty-printed JSON for embedding in documentation
The example payload, matching what buildWebhookPayload would produce
Keep non-critical error notifications behind a command's critical work. Nested callers join the outer queue so only the outermost boundary flushes.
Run read-only work against one database snapshot.
Wrap a key using a session token (derives a wrapping key from the token)
Usage
import * as mod from "doc.ts";