[{"data":1,"prerenderedAt":1398},["ShallowReactive",2],{"search":3,"content-developer\u002Fdecisions\u002F010-listing-engine":442,"surround-\u002Fdeveloper\u002Fdecisions\u002F010-listing-engine":1393},[4,8,12,16,20,24,28,32,36,40,44,48,52,56,60,64,68,72,76,80,84,88,92,96,100,104,108,112,116,120,124,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,260,264,268,272,276,280,284,288,292,296,300,304,308,312,316,320,323,327,331,335,339,343,347,351,355,359,363,367,371,375,379,383,387,391,395,399,403,407,411,415,419,423,426,430,434,438],{"path":5,"title":6,"description":7},"\u002Fguide","Guide","Product guides for Owlat — a modular, self-hosted email platform. Learn how to send campaigns, run a personal mailbox, manage a team inbox, and more.",{"path":9,"title":10,"description":11},"\u002Fguide\u002Fgetting-started","Welcome to Owlat","Set up your Owlat workspace and send your first email — from deploying the stack to verifying a domain, building your audience, and launching a campaign.",{"path":13,"title":14,"description":15},"\u002Fguide\u002Fcontact-properties","Contact Properties","Custom fields that extend built-in contact data with your own values for segmentation.",{"path":17,"title":18,"description":19},"\u002Fguide\u002Ftopics","Topics","Topics are explicit audience groups you manage by hand — ideal for opt-in subscribers, imported cohorts, and organized contact buckets you target with campaigns.",{"path":21,"title":22,"description":23},"\u002Fguide\u002Fsegments","Segments","Build dynamic, rule-based contact groups from properties, email activity, and topic membership, re-evaluated from current data each time they're used.",{"path":25,"title":26,"description":27},"\u002Fguide\u002Fforms","Forms","Form Endpoints collect new contacts from your website or landing pages by exposing a public endpoint that accepts submissions and feeds them into a topic.",{"path":29,"title":30,"description":31},"\u002Fguide\u002Fcampaigns","Campaigns & Reporting","Build and send marketing campaigns to a topic or segment with the five-step wizard, optional A\u002FB testing, and full delivery reporting.",{"path":33,"title":34,"description":35},"\u002Fguide\u002Fab-testing","A\u002FB Testing","Compare two variants of a campaign on a test group, then automatically or manually send the winning version to the rest of your audience.",{"path":37,"title":38,"description":39},"\u002Fguide\u002Fautomations","Automations","Send emails automatically based on triggers, delays, and conditions — build welcome series, trial flows, and follow-ups once and let Owlat run them.",{"path":41,"title":42,"description":43},"\u002Fguide\u002Ftransactional","Transactional Emails","One-to-one emails your application triggers in response to a user action — password resets, order confirmations, welcome emails, and similar notifications.",{"path":45,"title":46,"description":47},"\u002Fguide\u002Fcreate-campaign","Create a Campaign","Walk through Owlat's five-step campaign wizard: Basics, Audience, Content, A\u002FB Test, and Review & Send.",{"path":49,"title":50,"description":51},"\u002Fguide\u002Fsend-campaign","Send & Monitor a Campaign","How to send your campaign and track its performance with real-time metrics.",{"path":53,"title":54,"description":55},"\u002Fguide\u002Fquick-start","Quick Start","The fastest path from a blank Owlat workspace to a live email campaign, from your first template through sending and reviewing results.",{"path":57,"title":58,"description":59},"\u002Fguide\u002Ftransactional-setup","Transactional Email Setup","Set up and send transactional emails like password resets and order confirmations via the Owlat API and SDKs.",{"path":61,"title":62,"description":63},"\u002Fguide\u002Fdeliverability","Deliverability","Verify sending domains, manage your blocklist, monitor sending reputation, and stay compliant so your emails reach the inbox.",{"path":65,"title":66,"description":67},"\u002Fguide\u002Fapi-keys-webhooks","API Keys & Webhooks","Create API keys for programmatic access and set up outbound webhooks to receive real-time notifications for email and contact events.",{"path":69,"title":70,"description":71},"\u002Fguide\u002Ffeature-flags","Feature flags","Owlat is modular — every feature listed in this guide can be turned on or off. This page is the user-facing overview of how to do it.",{"path":73,"title":74,"description":75},"\u002Fguide\u002Fteam-permissions","Team & Permissions","Use role-based access to control what each member of your organization can do, with Owner, Admin, and Editor roles.",{"path":77,"title":78,"description":79},"\u002Fguide\u002Faudit-logs","Audit Logs","A chronological record of significant actions in your Owlat organization, so you can see who did what and when.",{"path":81,"title":82,"description":83},"\u002Fguide\u002Fshare-links","Share Links","Create temporary preview links to share email designs with stakeholders who don't have dashboard access.",{"path":85,"title":86,"description":87},"\u002Fguide\u002Fpostbox","Postbox — Personal Email","Per-user mailboxes with a webmail interface and native IMAP\u002FSMTP support. Run your own Gmail-equivalent personal mailbox on your Owlat instance.",{"path":89,"title":90,"description":91},"\u002Fguide\u002Fmigrate-from-google","Migrate from Google","Import your full Gmail history into Owlat over IMAP, and let your AI assistant learn from every imported conversation.",{"path":93,"title":94,"description":95},"\u002Fguide\u002Fteam-inbox","Team Inbox","Triage inbound email as a team: read AI-classified threads, approve, edit or reject agent drafts, work the review queue, and manage quarantine.",{"path":97,"title":98,"description":99},"\u002Fguide\u002Femail-editor","Email Editor","A block-based visual editor for building responsive emails that render consistently across desktop, mobile, Outlook, Gmail, and Apple Mail.",{"path":101,"title":102,"description":103},"\u002Fguide\u002Fai-agent","AI Agent & Autonomy","Configure the AI agent that classifies and drafts replies to inbound mail: auto-reply settings, the health dashboard, circuit breakers, autonomy rules, and the knowledge backfill.",{"path":105,"title":106,"description":107},"\u002Fguide\u002Fknowledge-graph","Knowledge Graph","Browse, search, and manage Owlat's typed organizational knowledge — the 7 entry types, source attribution, confidence decay, relations, and how entries are extracted from mail.",{"path":109,"title":110,"description":111},"\u002Fguide\u002Ffiles","Files","Upload, browse, search, tag, and version documents in the file library.",{"path":113,"title":114,"description":115},"\u002Fguide\u002Fchat","Team Chat","Use Owlat's built-in team chat: public and private channels, direct messages, mentions, attachments, and channels linked to an inbox conversation.",{"path":117,"title":118,"description":119},"\u002Fguide\u002Fcode-tasks","Code Tasks","Queue coding-agent tasks, watch them move from queued through review, and run the code-worker sidecar that opens the pull requests.",{"path":121,"title":122,"description":123},"\u002Fguide\u002Faudience-data","Audience Data: Identities, Relationships & Timeline","Unify a contact across email, phone, and messaging channels, merge duplicates, map relationships, and read the cross-channel interaction timeline.",{"path":125,"title":126,"description":127},"\u002Fguide\u002Fimporting-contacts","Importing & Exporting Contacts","Bring contacts into Owlat from a CSV or from Mailchimp and Stripe, export them back out, and run bulk operations on your audience.",{"path":129,"title":130,"description":131},"\u002Fguide\u002Faccount","Your Account & Data","Export your data as JSON or CSV, request account deletion with a 30-day grace period, and use the onboarding checklist and the public preference center.",{"path":133,"title":134,"description":135},"\u002Fguide\u002Fchannels","Communication Channels","Configure SMS, WhatsApp, and generic-webhook channels, monitor channel health, and understand which channels are fully live today.",{"path":137,"title":138,"description":139},"\u002Fguide\u002Fdesktop-app","Desktop App","Install the Owlat desktop app, connect one or more workspaces, switch between them, and use native notifications, tray badges, shortcuts, and deep links.",{"path":141,"title":142,"description":143},"\u002Fguide\u002Femail-templates","Email Templates","Reusable email designs that define the structure, content, and personalization of every campaign and transactional message you send in Owlat.",{"path":145,"title":146,"description":147},"\u002Fguide\u002Fai-assistant","AI Assistant","Owlat's multi-turn, streaming, tool-calling AI assistant — a private chat surface that can search your workspace and draft copy, plus @assistant replies inside team chat.",{"path":149,"title":150,"description":151},"\u002Fguide\u002Fsecurity-scanning","Sending Security & Scanning","Owlat's security scanning: a content check for spam and phishing, an attachment scan for malware, and a Google Safe Browsing URL check. Suspicious content goes to a review queue.",{"path":153,"title":154,"description":155},"\u002Fguide\u002Fsystem-updates","System & Updates","The owner-only System & Updates screen: your current Owlat version, container health, LLM spend, and the in-app one-click updater with history.",{"path":157,"title":158,"description":159},"\u002Fguide\u002Foperating-modes","Operating Modes","The different ways to run Owlat at a company — read external mailboxes over IMAP, send transactional or marketing email through a delivery provider, host your own mail server, or run a team inbox with AI — and the rules that keep each combination coherent.",{"path":161,"title":162,"description":163},"\u002Fguide\u002Fsaved-blocks","Saved Blocks","Create reusable, linked content blocks you can drop into any email — edit one and every email that uses it updates automatically.",{"path":165,"title":166,"description":167},"\u002Fguide\u002Fmedia-library","Media Library","Manage, organize, search, and reuse images and files across your emails from one centralized hub.",{"path":169,"title":170,"description":171},"\u002Fguide\u002Femail-theme","Email Theme","Set your organization's default colors, font, and email width so every new template starts from a consistent baseline.",{"path":173,"title":174,"description":175},"\u002Fguide\u002Ftranslations","Translations","Send one email in multiple languages: add per-language translations to a single template and Owlat picks the right version for each recipient.",{"path":177,"title":178,"description":179},"\u002Fguide\u002Fcontacts","Contacts","How to add, view, organize, and manage contacts in Owlat, including sources, the contact detail tabs, and subscription compliance.",{"path":181,"title":182,"description":183},"\u002Fapi","API Overview","Owlat exposes authenticated API endpoints under your Convex site URL.",{"path":185,"title":186,"description":187},"\u002Fapi\u002Fwebhooks","Webhooks","Owlat supports both outbound customer webhooks and inbound provider webhooks.",{"path":189,"title":190,"description":191},"\u002Fapi\u002Fpublic-endpoints","Public Endpoints","These routes are public-facing and usually accessed from email links or embedded forms.",{"path":193,"title":194,"description":195},"\u002Fapi\u002Fwebhook-payloads","Webhook Payloads","The authoritative wire contract for outbound webhooks: envelope, signature headers, per-event data shapes, and payload versioning.",{"path":197,"title":198,"description":199},"\u002Fapi\u002Finbound-channels","Inbound Channel Webhooks","Provider webhook reference for inbound SMS, WhatsApp, and generic-channel messages, plus the MTA mailbox and credential callbacks.",{"path":201,"title":202,"description":203},"\u002Fapi\u002Fauthentication","Authentication","Secure API access with organization-scoped API keys.",{"path":205,"title":206,"description":207},"\u002Fapi\u002Fsdk","TypeScript SDK","Typed client for the Owlat API, usable from Node.js, Bun, Deno, or any server-side JavaScript runtime.",{"path":209,"title":210,"description":211},"\u002Fapi\u002Fsdk-java","Java SDK","The official `owlat-sdk` package provides a typed client for interacting with the Owlat API from any JVM application. Requires Java 11+.",{"path":213,"title":214,"description":215},"\u002Fapi\u002Fcontacts","Contacts API","Manage contacts for your organization.",{"path":217,"title":218,"description":219},"\u002Fapi\u002Ftopics","Topics API","Manage topic membership through authenticated endpoints.",{"path":221,"title":222,"description":223},"\u002Fapi\u002Fevents","Events API","Send contact events to drive segmentation and automation triggers.",{"path":225,"title":226,"description":227},"\u002Fapi\u002Ftransactional","Transactional API","Send published transactional templates to a recipient.",{"path":229,"title":230,"description":231},"\u002Fapi\u002Fforms","Forms API","Capture subscribers through public form endpoints.",{"path":233,"title":234,"description":235},"\u002Fdeveloper","Developer Guide","Technical architecture, feature-flag model, and provider abstractions used by Owlat.",{"path":237,"title":238,"description":239},"\u002Fdeveloper\u002Fmta-system","MTA System","Owlat's custom Mail Transfer Agent for direct SMTP delivery with intelligent rate limiting, bounce processing, and IP warming.",{"path":241,"title":242,"description":243},"\u002Fdeveloper\u002Ffeature-flags","Feature flags — developer reference","How the Owlat feature flag system works: single source of truth, dependency resolution, docker profile mapping, and how to add a new flag.",{"path":245,"title":246,"description":247},"\u002Fdeveloper\u002Fhow-email-works","How Email Works","A technical deep-dive into how email actually works — from SMTP and DNS to authentication, deliverability, and the differences between marketing and private email.",{"path":249,"title":250,"description":251},"\u002Fdeveloper\u002Femail-security","Email Security","Content scanning, attachment validation, URL reputation checking, and malware detection for outbound emails.",{"path":253,"title":254,"description":255},"\u002Fdeveloper\u002Fpostbox-architecture","Postbox Architecture","How the Postbox personal-mail feature is wired — schema, IMAP server, app-password auth, outbound relay, inbound delivery, and external mailboxes.",{"path":257,"title":258,"description":259},"\u002Fdeveloper\u002Fproviders","Providers","Pluggable provider abstractions for LLM, email sending, notifications, vector stores, and analytics, selected per-deployment so self-hosters can swap implementations without code changes.",{"path":261,"title":262,"description":263},"\u002Fdeveloper\u002Fcampaign-internals","Campaign Internals","How the campaign backend works: two status machines, send pre-flight, the send orchestrator, emailSends records, and the priority workpools.",{"path":265,"title":266,"description":267},"\u002Fdeveloper\u002Faudience-internals","Audience Internals","Backend reference for contact resolution, the double opt-in lifecycle, topic subscription, the conditions registry, and segment evaluation.",{"path":269,"title":270,"description":271},"\u002Fdeveloper\u002Fautomation-internals","Automation Internals","How the automation run engine works: the step walker, the lifecycle state machine, trigger fanout, the three step types, and the resilience cron.",{"path":273,"title":274,"description":275},"\u002Fdeveloper\u002Fdeliverability-infrastructure","Deliverability Infrastructure","The Convex-side deliverability backend: provider routing, health-aware failover, sending reputation with auto-enforcement, IP warming cache, the blocklist, and the content-scan gate.",{"path":277,"title":278,"description":279},"\u002Fdeveloper\u002Farchitecture","Architecture Overview","Owlat follows a modern serverless architecture with real-time capabilities.",{"path":281,"title":282,"description":283},"\u002Fdeveloper\u002Fplatform-operations","Platform Operations","Operator reference for abuse status and the sending gate, the platform-admin roster, content review, org deletion, in-app self-update, dev endpoints, crons, and migrations.",{"path":285,"title":286,"description":287},"\u002Fdeveloper\u002Fscopes","Scopes","What each app and package in the Owlat monorepo is responsible for.",{"path":289,"title":290,"description":291},"\u002Fdeveloper\u002Fself-hosting","Self-Hosting","Deploy Owlat on your own infrastructure with Docker Compose. Complete guide from first boot to production.",{"path":293,"title":294,"description":295},"\u002Fdeveloper\u002Fself-hosting-config","Self-Hosting Configuration","Complete reference for Docker environment variables, Convex backend variables, service topology, and volume persistence.",{"path":297,"title":298,"description":299},"\u002Fdeveloper\u002Fself-hosting-dns-email","DNS & Email Setup","Configure DNS records, DKIM signing, SPF, DMARC, and bounce handling for reliable email delivery.",{"path":301,"title":302,"description":303},"\u002Fdeveloper\u002Fself-hosting-production","Production Deployment","Secure your self-hosted Owlat instance with TLS, firewall rules, backups, and monitoring.",{"path":305,"title":306,"description":307},"\u002Fdeveloper\u002Fself-hosting-maintenance","Maintenance & Updates","Keep your self-hosted Owlat instance up to date, manage backups, scale performance, and troubleshoot common issues.",{"path":309,"title":310,"description":311},"\u002Fdeveloper\u002Fself-hosting-desktop","Desktop Installer","Install Owlat on a bare Linux VPS straight from the desktop app over SSH — no terminal — with a live, animated provisioning timeline.",{"path":313,"title":314,"description":315},"\u002Fdeveloper\u002Fsetup-cli","Setup CLI & Installer","Operator reference for the Owlat self-host tooling: the install.sh one-liner, the owlat-setup CLI, the convex-deploy flow, and admin bootstrap.",{"path":317,"title":318,"description":319},"\u002Fdeveloper\u002Fconvex","Convex Backend","Owlat uses Convex as its serverless backend, providing real-time subscriptions, ACID transactions, and TypeScript-first development.",{"path":321,"title":202,"description":322},"\u002Fdeveloper\u002Fauthentication","Owlat uses BetterAuth with the Convex adapter for authentication and organization (team) management.",{"path":324,"title":325,"description":326},"\u002Fdeveloper\u002Femail-system","Email System","Owlat's email system consists of a visual editor, template management, and multi-provider sending infrastructure.",{"path":328,"title":329,"description":330},"\u002Fdeveloper\u002Femail-renderer","Email Renderer","The @owlat\u002Femail-renderer package converts editor JSON blocks into production-ready HTML emails with cross-client compatibility, CSS inlining, dark mode, and Outlook VML fallbacks.",{"path":332,"title":333,"description":334},"\u002Fdeveloper\u002Fenvironment-variables","Environment Variables","Reference for every environment variable Owlat reads across the Convex backend, web app, MTA, IMAP server, and mail-sync worker.",{"path":336,"title":337,"description":338},"\u002Fdeveloper\u002Fcomponents","Component Library","Reference for the reusable, auto-imported Vue UI components shipped in the packages\u002Fui layer.",{"path":340,"title":341,"description":342},"\u002Fdeveloper\u002Fdecisions","Architectural Decision Records","The architectural decision records for the Owlat project, each capturing the context, the decision, and the trade-offs involved.",{"path":344,"title":345,"description":346},"\u002Fdeveloper\u002Fdecisions\u002F009-model-routing","ADR-009: Task-Based Model Routing","Why Owlat supports per-task LLM model selection instead of using a single model for all pipeline steps.",{"path":348,"title":349,"description":350},"\u002Fdeveloper\u002Fdecisions\u002F010-listing-engine","ADR-010: Listing Engine","Why Owlat replaced four incompatible list-query contracts with one generic listing engine driven by per-entity descriptors.",{"path":352,"title":353,"description":354},"\u002Fdeveloper\u002Fdecisions\u002F001-custom-email-renderer","ADR-001: Custom Email Renderer Over MJML","Why Owlat built a custom table-based HTML email renderer instead of using MJML, gaining full control over VML, dark mode, and per-client rendering.",{"path":356,"title":357,"description":358},"\u002Fdeveloper\u002Fdecisions\u002F002-convex-backend","ADR-002: Convex as Backend","Why Owlat chose Convex over PostgreSQL and Firebase for real-time reactivity, co-located TypeScript logic, and zero-config scaling.",{"path":360,"title":361,"description":362},"\u002Fdeveloper\u002Fdecisions\u002F003-notion-like-builder","ADR-003: Notion-like Email Builder","Why Owlat replaced the traditional 3-panel email editor with a Notion-like single-column canvas for inline WYSIWYG editing.",{"path":364,"title":365,"description":366},"\u002Fdeveloper\u002Fdecisions\u002F004-monorepo-bun-workspaces","ADR-004: Monorepo with Bun Workspaces","Why Owlat uses a monorepo with Bun workspaces and Turborepo for fast installs, atomic cross-package changes, and cached CI.",{"path":368,"title":369,"description":370},"\u002Fdeveloper\u002Fdecisions\u002F005-custom-mta","ADR-005: Custom MTA","Why Owlat built a custom Mail Transfer Agent instead of relying solely on third-party email providers.",{"path":372,"title":373,"description":374},"\u002Fdeveloper\u002Fdecisions\u002F006-self-hosted-convex","ADR-006: Self-Hosted Convex","Why Owlat uses the open-source Convex backend for self-hosting instead of migrating to a different database.",{"path":376,"title":377,"description":378},"\u002Fdeveloper\u002Fdecisions\u002F007-pluggable-llm","ADR-007: Pluggable LLM Provider","Why Owlat uses the Vercel AI SDK with a provider abstraction layer instead of hardcoding a single LLM vendor.",{"path":380,"title":381,"description":382},"\u002Fdeveloper\u002Fdecisions\u002F008-process-architecture","ADR-008: Agent Process Architecture","Why Owlat processes inbound messages with a self-scheduling step walker plus a lifecycle coordinator instead of one sequential function.",{"path":384,"title":385,"description":386},"\u002Fexamples","Examples","Copy-pasteable integration patterns for common Owlat use cases.",{"path":388,"title":389,"description":390},"\u002Fexamples\u002Fwelcome-email","Welcome Email","Send a personalized welcome email when a new user signs up.",{"path":392,"title":393,"description":394},"\u002Fexamples\u002Fbilling-email","Billing Email","Send a billing receipt with an invoice PDF attached after a successful payment.",{"path":396,"title":397,"description":398},"\u002Fexamples\u002Fevent-automation","Event Automation","Trigger automations with custom events for trial lifecycle, feature adoption, and more.",{"path":400,"title":401,"description":402},"\u002Fexamples\u002Fcontact-sync","Contact Sync","Sync contacts from your database to Owlat using upsert patterns and bulk operations.",{"path":404,"title":405,"description":406},"\u002Fexamples\u002Fwebhook-handler","Webhook Handler","Handle Owlat delivery webhooks with signature verification and event routing.",{"path":408,"title":409,"description":410},"\u002Fexamples\u002Fmultilingual-email","Multilingual Email","Send emails in the recipient's preferred language using template translations.",{"path":412,"title":413,"description":414},"\u002Fvision","Vision","Where Owlat is heading — from email platform to unified communication intelligence powered by AI agents.",{"path":416,"title":417,"description":418},"\u002Fvision\u002Fself-hosting","Self-Hosting Architecture","How Owlat runs as a fully self-hosted stack using Docker Compose — open-source Convex backend, custom MTA, and a pluggable LLM provider.",{"path":420,"title":421,"description":422},"\u002Fvision\u002Fagent-pipeline","Agent Pipeline","Technical architecture for the inbound email agent pipeline — step modules, the walker, security scanning, threading, and human review.",{"path":424,"title":106,"description":425},"\u002Fvision\u002Fknowledge-graph","Technical architecture for Owlat's typed knowledge storage — how organizational knowledge is stored, searched, decayed, and maintained.",{"path":427,"title":428,"description":429},"\u002Fvision\u002Fmulti-channel","Multi-Channel & CRM","Technical architecture for channel adapters, unified messaging, contact identity unification, and the CRM hub.",{"path":431,"title":432,"description":433},"\u002Fvision\u002Ffile-system","Semantic File System","Technical architecture for Owlat's semantic file storage — version tracking with provenance today, plus the planned embedding-based retrieval and auto-tagging layer.",{"path":435,"title":436,"description":437},"\u002Fvision\u002Fdesktop-app","Desktop App & Advanced Agents","Architecture of the Owlat desktop shell, visualization agent, adaptive dashboard, agent health, graduated autonomy, and coding agents.",{"path":439,"title":440,"description":441},"\u002Fvision\u002Froadmap","Roadmap","What's planned next for Owlat — the documented-but-unbuilt pieces still being wired, and the enhancements on our radar.",{"id":443,"title":349,"body":444,"description":350,"extension":1387,"meta":1388,"navigation":1389,"path":348,"seo":1390,"stem":1391,"__hash__":1392},"content\u002F3.developer\u002Fdecisions\u002F11.010-listing-engine.md",{"type":445,"value":446,"toc":1377},"minimark",[447,464,469,487,490,602,605,678,681,685,704,707,856,867,922,927,1035,1039,1046,1122,1147,1151,1210,1214,1236,1258,1267,1271,1276,1311,1316,1358,1373],[448,449,450,458],"ul",{},[451,452,453,457],"li",{},[454,455,456],"strong",{},"Status:"," Accepted",[451,459,460,463],{},[454,461,462],{},"Date:"," 2026-05-26",[465,466,468],"h2",{"id":467},"context","Context",[470,471,472,473,477,478,482,483,486],"p",{},"The Convex backend already had deep write-side modules — lifecycles, intake, dispatch, find-or-create — each owning how one entity ",[474,475,476],"em",{},"changes",". The read side never got the same treatment. \"List a page of ",[479,480,481],"code",{},"\u003Centity>","\" was open-coded across ~80 ",[479,484,485],{},"list*"," query endpoints, and the duplication was not the worst of it: no two of them agreed on their own contract.",[470,488,489],{},"There were four mutually incompatible \"give me a filtered, paginated page\" shapes:",[491,492,493,512],"table",{},[494,495,496],"thead",{},[497,498,499,503,506,509],"tr",{},[500,501,502],"th",{},"Entity",[500,504,505],{},"Return shape",[500,507,508],{},"Cursor",[500,510,511],{},"Access path",[513,514,515,538,563,580],"tbody",{},[497,516,517,520,525,532],{},[518,519,178],"td",{},[518,521,522],{},[479,523,524],{},"{ page, isDone, continueCursor }",[518,526,527,528,531],{},"Real Convex cursor on browse; the literal string ",[479,529,530],{},"'search'"," on search",[518,533,534,537],{},[479,535,536],{},"search_contacts"," index + creation index",[497,539,540,543,547,550],{},[518,541,542],{},"Campaigns",[518,544,545],{},[479,546,524],{},[518,548,549],{},"Stringified integer offset",[518,551,552,555,556,559,560],{},[479,553,554],{},"by_status","\u002F",[479,557,558],{},"by_updated_at",", then in-memory ",[479,561,562],{},".filter()",[497,564,565,568,571,574],{},[518,566,567],{},"Email templates",[518,569,570],{},"Bare array, no pagination at all",[518,572,573],{},"—",[518,575,576,579],{},[479,577,578],{},".collect()"," the whole table, then in-memory filter + sort",[497,581,582,584,590,593],{},[518,583,18],{},[518,585,586,589],{},[479,587,588],{},"{ page, … }"," + per-row enrichment",[518,591,592],{},"Real Convex cursor",[518,594,595,598,599],{},[479,596,597],{},".paginate()",", no filter, N+1 ",[479,600,601],{},"contactCount",[470,603,604],{},"Four list queries, four return contracts, none canonical. A single piece of pagination UI physically could not consume all of them. Four concrete problems compounded the divergence:",[606,607,608,622,639,661],"ol",{},[451,609,610,613,614,617,618,621],{},[454,611,612],{},"The cursor was a lie on the search path."," Contact search returned ",[479,615,616],{},"continueCursor: 'search'"," (cast ",[479,619,620],{},"as unknown as string",") and always re-read from the top, so asking for page 2 of a search re-served page 1. Search results were silently single-page.",[451,623,624,627,628,631,632,634,635,638],{},[454,625,626],{},"The index-vs-collect decision was made per file, often wrongly."," Contacts used a real ",[479,629,630],{},"searchIndex","; campaigns collected a whole status bucket and filtered search in memory; email templates ",[479,633,578],{},"-ed the ",[474,636,637],{},"entire"," table for every list, filter, and sort — a scaling cliff with no index in sight.",[451,640,641,644,645,648,649,652,653,656,657,660],{},[454,642,643],{},"Counts were their own zoo."," ",[479,646,647],{},"countByStatus",", ",[479,650,651],{},"countByType",", and ",[479,654,655],{},"count"," each used a different strategy (collect-and-group, collect-and-group again, a denormalized cached counter), with no shared surface — even though the dashboards that render a list need their facet counts ",[474,658,659],{},"alongside"," the page.",[451,662,663,666,667,669,670,673,674,677],{},[454,664,665],{},"Enrichment was duplicated and N+1."," The topic ",[479,668,601],{}," enrichment was inlined separately in both ",[479,671,672],{},"list"," and ",[479,675,676],{},"get",", and the per-row count was a full pagination scan whenever the cached field was absent.",[470,679,680],{},"This is the read-side counterpart to the write-side lifecycle modules, and it follows the codebase's own resolved precedent: a thin generic dispatcher (the Walker) over per-type data (the Block modules).",[465,682,684],{"id":683},"decision","Decision",[470,686,687,688,691,692,695,696,699,700,703],{},"Make resource listing one seam: a generic ",[454,689,690],{},"Listing engine"," dispatching over per-entity ",[454,693,694],{},"Listing descriptors",", returning one Convex-native contract. The engine lives in ",[479,697,698],{},"apps\u002Fapi\u002Fconvex\u002Flib\u002Flisting.ts","; each entity owns a descriptor at ",[479,701,702],{},"apps\u002Fapi\u002Fconvex\u002F\u003Centity>\u002Flisting.ts",".",[470,705,706],{},"A descriptor declares the entity's read surface as data — its search index, browse index, legal sorts and filters, soft-delete policy, per-row enrichment, and named facet counts:",[708,709,714],"pre",{"className":710,"code":711,"language":712,"meta":713,"style":713},"language-ts shiki shiki-themes github-light github-dark-dimmed","\u002F\u002F apps\u002Fapi\u002Fconvex\u002Fcontacts\u002Flisting.ts — the cleanest case\nexport const contactListing: ListingDescriptor\u003C'contacts'> = {\n  table: 'contacts',\n  search: { index: 'search_contacts', field: 'searchableText', filterFields: ['deletedAt'] },\n  browse: { index: 'by_deleted_at_and_created_at', order: 'desc' },\n  softDelete: true,\n  facets: { total: { kind: 'cachedCounter', table: 'instanceSettings', field: 'contactCount' } },\n};\n","ts","",[479,715,716,725,763,774,798,816,827,850],{"__ignoreMap":713},[717,718,721],"span",{"class":719,"line":720},"line",1,[717,722,724],{"class":723},"sDN9O","\u002F\u002F apps\u002Fapi\u002Fconvex\u002Fcontacts\u002Flisting.ts — the cleanest case\n",[717,726,728,732,735,739,742,746,750,754,757,760],{"class":719,"line":727},2,[717,729,731],{"class":730},"s7YZ4","export",[717,733,734],{"class":730}," const",[717,736,738],{"class":737},"sviXB"," contactListing",[717,740,741],{"class":730},":",[717,743,745],{"class":744},"sOLd2"," ListingDescriptor",[717,747,749],{"class":748},"sYgZi","\u003C",[717,751,753],{"class":752},"s-HuK","'contacts'",[717,755,756],{"class":748},"> ",[717,758,759],{"class":730},"=",[717,761,762],{"class":748}," {\n",[717,764,766,769,771],{"class":719,"line":765},3,[717,767,768],{"class":748},"  table: ",[717,770,753],{"class":752},[717,772,773],{"class":748},",\n",[717,775,777,780,783,786,789,792,795],{"class":719,"line":776},4,[717,778,779],{"class":748},"  search: { index: ",[717,781,782],{"class":752},"'search_contacts'",[717,784,785],{"class":748},", field: ",[717,787,788],{"class":752},"'searchableText'",[717,790,791],{"class":748},", filterFields: [",[717,793,794],{"class":752},"'deletedAt'",[717,796,797],{"class":748},"] },\n",[717,799,801,804,807,810,813],{"class":719,"line":800},5,[717,802,803],{"class":748},"  browse: { index: ",[717,805,806],{"class":752},"'by_deleted_at_and_created_at'",[717,808,809],{"class":748},", order: ",[717,811,812],{"class":752},"'desc'",[717,814,815],{"class":748}," },\n",[717,817,819,822,825],{"class":719,"line":818},6,[717,820,821],{"class":748},"  softDelete: ",[717,823,824],{"class":737},"true",[717,826,773],{"class":748},[717,828,830,833,836,839,842,844,847],{"class":719,"line":829},7,[717,831,832],{"class":748},"  facets: { total: { kind: ",[717,834,835],{"class":752},"'cachedCounter'",[717,837,838],{"class":748},", table: ",[717,840,841],{"class":752},"'instanceSettings'",[717,843,785],{"class":748},[717,845,846],{"class":752},"'contactCount'",[717,848,849],{"class":748}," } },\n",[717,851,853],{"class":719,"line":852},8,[717,854,855],{"class":748},"};\n",[470,857,858,859,862,863,866],{},"The session-auth shell (",[479,860,861],{},"contacts\u002Fcontacts.ts:list",") and the API-key shell (",[479,864,865],{},"\u003Centity>\u002Forganization.ts",") keep their own auth posture and call into the engine:",[708,868,870],{"className":710,"code":869,"language":712,"meta":713,"style":713},"\u002F\u002F apps\u002Fapi\u002Fconvex\u002Fcontacts\u002Fcontacts.ts (shape)\nconst page  = await listResources(ctx.db, contactListing, args);\nconst total = await countFacet(ctx.db, contactListing, 'total');\n",[479,871,872,877,898],{"__ignoreMap":713},[717,873,874],{"class":719,"line":720},[717,875,876],{"class":723},"\u002F\u002F apps\u002Fapi\u002Fconvex\u002Fcontacts\u002Fcontacts.ts (shape)\n",[717,878,879,882,885,888,891,895],{"class":719,"line":727},[717,880,881],{"class":730},"const",[717,883,884],{"class":737}," page",[717,886,887],{"class":730},"  =",[717,889,890],{"class":730}," await",[717,892,894],{"class":893},"sPO5f"," listResources",[717,896,897],{"class":748},"(ctx.db, contactListing, args);\n",[717,899,900,902,905,908,910,913,916,919],{"class":719,"line":765},[717,901,881],{"class":730},[717,903,904],{"class":737}," total",[717,906,907],{"class":730}," =",[717,909,890],{"class":730},[717,911,912],{"class":893}," countFacet",[717,914,915],{"class":748},"(ctx.db, contactListing, ",[717,917,918],{"class":752},"'total'",[717,920,921],{"class":748},");\n",[923,924,926],"h3",{"id":925},"what-the-engine-guarantees","What the engine guarantees",[448,928,929,946,974,999,1015],{},[451,930,931,934,935,938,939,941,942,945],{},[454,932,933],{},"A real Convex cursor on both paths."," Search uses ",[479,936,937],{},".withSearchIndex(...).paginate()"," (a real, opaque cursor — the ",[479,940,530],{}," sentinel is gone); browse uses ",[479,943,944],{},".withIndex(...).order().paginate()",". Search is genuinely multi-page the moment a query routes through the engine.",[451,947,948,951,952,955,956,959,960,963,964,967,968,971,972,703],{},[454,949,950],{},"Search means relevance order."," Search results are relevance-ordered, so ",[479,953,954],{},"sortKeys"," apply to the ",[454,957,958],{},"browse path only",". Passing ",[479,961,962],{},"search"," ignores ",[479,965,966],{},"sort",". This is part of the interface, not a hidden detail — see ",[479,969,970],{},"listResources"," in ",[479,973,698],{},[451,975,976,979,980,983,984,987,988,991,992,995,996,998],{},[454,977,978],{},"Soft-delete rides the index, never thins the page."," When ",[479,981,982],{},"softDelete"," is true, ",[479,985,986],{},"deletedAt === undefined"," is fixed inside the index range on both paths. The browse index must therefore lead with ",[479,989,990],{},"deletedAt"," (contacts use ",[479,993,994],{},"by_deleted_at_and_created_at","). Ordinary equality filters may thin a page; only ",[479,997,990],{}," is barred from that fate.",[451,1000,1001,1004,1005,1008,1009,1012,1013,703],{},[454,1002,1003],{},"Index-native filters where a compound index exists."," A single equality filter with a dedicated compound index (e.g. ",[479,1006,1007],{},"status"," → ",[479,1010,1011],{},"by_status_and_updated_at",") is served index-natively and ordered. A filter with no such index falls back to a post-index ",[479,1014,562],{},[451,1016,1017,1020,1021,1024,1025,1027,1028,1030,1031,1034],{},[454,1018,1019],{},"The descriptor owns enrichment cost."," The engine runs ",[479,1022,1023],{},"enrich"," over the page (and the entity's ",[479,1026,676],{}," reuses it), and does not hide whether each call is O(1) cached or a scan — the cost is stated in the descriptor. Topics enrich ",[479,1029,601],{}," from the denormalized ",[479,1032,1033],{},"cachedMemberCount"," (with a bounded membership scan fallback); the automation descriptor declares no per-row enrichment.",[923,1036,1038],{"id":1037},"facets-are-closed-to-three-strategies","Facets are closed to three strategies",[470,1040,1041,1042,1045],{},"The count zoo collapses into exactly three ",[479,1043,1044],{},"Facet"," kinds — anything richer is rejected at the interface, and a one-off count is written as a plain query outside the seam:",[491,1047,1048,1061],{},[494,1049,1050],{},[497,1051,1052,1055,1058],{},[500,1053,1054],{},"Facet kind",[500,1056,1057],{},"Counts via",[500,1059,1060],{},"Example",[513,1062,1063,1082,1103],{},[497,1064,1065,1070,1073],{},[518,1066,1067],{},[479,1068,1069],{},"indexCount",[518,1071,1072],{},"A bounded paginated count over one index",[518,1074,1075,1076,1079,1080],{},"Campaign ",[479,1077,1078],{},"total"," over ",[479,1081,558],{},[497,1083,1084,1089,1094],{},[518,1085,1086],{},[479,1087,1088],{},"groupBy",[518,1090,1091,1092],{},"One bounded index count per bucket, summed to ",[479,1093,1078],{},[518,1095,1075,1096,1099,1100],{},[479,1097,1098],{},"byStatus",", template ",[479,1101,1102],{},"byType",[497,1104,1105,1110,1113],{},[518,1106,1107],{},[479,1108,1109],{},"cachedCounter",[518,1111,1112],{},"A denormalized counter on a singleton row (with a bounded-scan fallback)",[518,1114,1115,1116,1118,1119],{},"Contact ",[479,1117,1078],{}," from ",[479,1120,1121],{},"instanceSettings.contactCount",[470,1123,1124,1127,1128,1130,1131,1133,1134,971,1137,1139,1140,1143,1144,703],{},[479,1125,1126],{},"countFacet(db, descriptor, name)"," resolves the strategy; ",[479,1129,1088],{}," returns per-bucket counts whose ",[479,1132,1078],{}," is their sum. The implementation lives in ",[479,1135,1136],{},"countFacet",[479,1138,698],{}," and reuses the ",[479,1141,1142],{},"countWithPagination"," primitive in ",[479,1145,1146],{},"apps\u002Fapi\u002Fconvex\u002Flib\u002Fpagination.ts",[923,1148,1150],{"id":1149},"decisions-resolved-during-design","Decisions resolved during design",[606,1152,1153,1159,1183,1189,1195,1200],{},[451,1154,1155,1158],{},[454,1156,1157],{},"Walker + descriptors hybrid",", not a generic config-bag and not a per-entity module family — a single generic engine over thin per-type data, the codebase's already-accepted dispatcher-over-data shape.",[451,1160,1161,1164,1165,1168,1169,1172,1173,652,1176,1178,1179,1182],{},[454,1162,1163],{},"Convex-native cursor only",", accepting the schema bill: campaigns and email templates gained ",[479,1166,1167],{},"search_campaigns"," \u002F ",[479,1170,1171],{},"search_templates"," search indexes with ",[479,1174,1175],{},"filterFields",[479,1177,990],{}," joined the relevant browse index. The in-memory ",[479,1180,1181],{},"paginateArray"," offset fallback was rejected as the source of both the offset cursor and the table-scan-on-search.",[451,1184,1185,1188],{},[454,1186,1187],{},"Scope is page + enrichment + facet counts."," The descriptor owns the entity's whole read surface, so the count queries and the topic enrichment all collapse in. A page-only seam would have left the count zoo and the N+1 standing.",[451,1190,1191,1194],{},[454,1192,1193],{},"The descriptor owns enrichment cost"," rather than the engine refusing non-O(1) enrichment, which would have forced premature denormalization.",[451,1196,1197,1199],{},[454,1198,1038],{},", exactly what existed in the wild.",[451,1201,1202,1205,1206,1209],{},[454,1203,1204],{},"Auth stays in the shells."," The engine takes a ",[479,1207,1208],{},"DatabaseReader",", never a session — it reads, it does not authenticate or scope.",[923,1211,1213],{"id":1212},"enforcement","Enforcement",[470,1215,1216,1217,1220,1221,1224,1225,1228,1229,673,1232,1235],{},"A ",[479,1218,1219],{},"lint:listing"," guard (",[479,1222,1223],{},"apps\u002Fapi\u002Fscripts\u002Fcheck-listing.sh",", run as part of ",[479,1226,1227],{},"bun run lint",", sibling to ",[479,1230,1231],{},"lint:env",[479,1233,1234],{},"lint:errors",") keeps the seam from eroding. It bans, in query files outside the engine and the descriptors:",[448,1237,1238,1244],{},[451,1239,1240,1243],{},[479,1241,1242],{},"paginateArray("," — the removed stringified-integer offset helper.",[451,1245,1246,1247,1250,1251,1254,1255,1257],{},"Manual ",[479,1248,1249],{},"numItems + 1"," \"take n+1 to compute ",[479,1252,1253],{},"hasMore","\" pagination — the hand-rolled pattern that produced the ",[479,1256,530],{},"-sentinel fake cursor.",[470,1259,1260,1261,1263,1264,1266],{},"The third open-coded shape (",[479,1262,578],{},"-then-filter-then-paginate) is held down by the existing ",[479,1265,578],{}," baseline check; porting a list query to the engine only ever lowers it.",[465,1268,1270],{"id":1269},"consequences","Consequences",[470,1272,1273],{},[454,1274,1275],{},"Enables:",[448,1277,1278,1284,1287,1293],{},[451,1279,1280,1281,1283],{},"One read contract — every list endpoint returns the same ",[479,1282,524],{}," with a real Convex cursor plus its facets, so one pagination UI works against all of them.",[451,1285,1286],{},"The contact-search cursor bug dies on cutover; search becomes genuinely multi-page.",[451,1288,1289,1290,1292],{},"The index-vs-collect decision is made once, in one tested place. The full-table ",[479,1291,578],{}," that email-template listing got wrong is gone; a new listable entity is a ~6-line descriptor instead of 60 lines of re-derived query. Six entities now use the engine — contacts, campaigns, topics, segments, automations, and email templates.",[451,1294,1295,1296,1168,1298,1168,1300,1302,1303,1306,1307,673,1309,703],{},"The count zoo and the N+1 collapse: ",[479,1297,647],{},[479,1299,651],{},[479,1301,655],{}," become declared ",[479,1304,1305],{},"facets",", and per-row enrichment is declared once and shared by ",[479,1308,672],{},[479,1310,676],{},[470,1312,1313],{},[454,1314,1315],{},"Trade-offs:",[448,1317,1318,1335,1341],{},[451,1319,1320,1321,1168,1323,1325,1326,1168,1328,1331,1332,1334],{},"An additive schema bill — ",[479,1322,1167],{},[479,1324,1171],{}," search indexes, ",[479,1327,1011],{},[479,1329,1330],{},"by_type_and_updated_at"," compound indexes, and ",[479,1333,990],{}," folded into the soft-deletable browse indexes.",[451,1336,1337,1338,1340],{},"An intentional, atomic break: the email-template HTTP list changed from a bare array to a paginated ",[479,1339,588],{},", in the same clean-break spirit as the SDK changes (no two-phase migration).",[451,1342,1343,1344,1168,1347,1350,1351,1354,1355,1357],{},"Convex's ",[479,1345,1346],{},"withIndex",[479,1348,1349],{},"withSearchIndex"," want per-table string-literal index names that a function generic over ",[479,1352,1353],{},"TableNames"," cannot satisfy, so the engine casts its query builders internally — the same trade ",[479,1356,1142],{}," already makes, confined to one module.",[470,1359,1360,1361,1364,1365,1369,1370,1372],{},"This decision is recorded as ADR-0037 in the engineering ADR set (",[479,1362,1363],{},"docs\u002Fadr\u002F0037-resource-listing-engine.md","). It builds on ",[1366,1367,1368],"a",{"href":356},"ADR-002: Convex as backend"," and mirrors the dispatcher-over-data pattern; for the broader backend layout see the ",[1366,1371,318],{"href":317}," reference.",[1374,1375,1376],"style",{},"html pre.shiki code .sDN9O, html code.shiki .sDN9O{--shiki-default:#6A737D;--shiki-dark:#768390}html pre.shiki code .s7YZ4, html code.shiki .s7YZ4{--shiki-default:#D73A49;--shiki-dark:#F47067}html pre.shiki code .sviXB, html code.shiki .sviXB{--shiki-default:#005CC5;--shiki-dark:#6CB6FF}html pre.shiki code .sOLd2, html code.shiki .sOLd2{--shiki-default:#6F42C1;--shiki-dark:#F69D50}html pre.shiki code .sYgZi, html code.shiki .sYgZi{--shiki-default:#24292E;--shiki-dark:#ADBAC7}html pre.shiki code .s-HuK, html code.shiki .s-HuK{--shiki-default:#032F62;--shiki-dark:#96D0FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sPO5f, html code.shiki .sPO5f{--shiki-default:#6F42C1;--shiki-dark:#DCBDFB}",{"title":713,"searchDepth":727,"depth":727,"links":1378},[1379,1380,1386],{"id":467,"depth":727,"text":468},{"id":683,"depth":727,"text":684,"children":1381},[1382,1383,1384,1385],{"id":925,"depth":765,"text":926},{"id":1037,"depth":765,"text":1038},{"id":1149,"depth":765,"text":1150},{"id":1212,"depth":765,"text":1213},{"id":1269,"depth":727,"text":1270},"md",{},true,{"title":349,"description":350},"3.developer\u002Fdecisions\u002F11.010-listing-engine","9pnKUh0PY9uqNslMOTixB122OFEoqN-A1LMAS_7YkWw",[1394,1396],{"title":345,"path":344,"stem":1395,"children":-1},"3.developer\u002Fdecisions\u002F10.009-model-routing",{"title":353,"path":352,"stem":1397,"children":-1},"3.developer\u002Fdecisions\u002F2.001-custom-email-renderer",1782846429168]