ADR-010: Listing-Engine
Warum Owlat vier inkompatible Vertragsformen für Listenabfragen durch eine generische Listing-Engine ersetzt hat, die von Deskriptoren pro Entität gesteuert wird.
- Status: Angenommen
- Datum: 2026-05-26
Kontext
Das Convex-Backend hatte bereits tiefgehende Schreibmodule — Lifecycles, Intake, Dispatch, Find-or-Create —, von denen jedes besitzt, wie sich eine Entität ändert. Die Leseseite hat diese Behandlung nie erfahren. „Eine Seite <entity> auflisten“ war über rund 80 list*-Query-Endpunkte hinweg ausgeschrieben, und die Duplikation war noch nicht einmal das Schlimmste: Keine zwei von ihnen waren sich über ihren eigenen Vertrag einig.
Es existierten vier zueinander inkompatible Formen von „gib mir eine gefilterte, paginierte Seite“:
| Entität | Rückgabeform | Cursor | Zugriffspfad |
|---|---|---|---|
| Kontakte | { page, isDone, continueCursor } | Echter Convex-Cursor beim Browsen; der Literal-String 'search' bei der Suche | Index search_contacts + Erstellungsindex |
| Kampagnen | { page, isDone, continueCursor } | Als String serialisierter ganzzahliger Offset | by_status/by_updated_at, danach .filter() im Speicher |
| E-Mail-Vorlagen | Reines Array, überhaupt keine Paginierung | — | .collect() über die gesamte Tabelle, danach Filtern und Sortieren im Speicher |
| Themen | { page, … } + Anreicherung pro Zeile | Echter Convex-Cursor | .paginate(), kein Filter, N+1 bei contactCount |
Vier Listenabfragen, vier Rückgabeverträge, keiner davon kanonisch. Eine einzelne Paginierungs-UI konnte sie schlicht physisch nicht alle bedienen. Vier konkrete Probleme verstärkten die Divergenz:
- Der Cursor war auf dem Suchpfad eine Lüge. Die Kontaktsuche gab
continueCursor: 'search'zurück (gecastet alsas unknown as string) und las stets von vorn, sodass die Anfrage nach Seite 2 einer Suche erneut Seite 1 auslieferte. Suchergebnisse waren stillschweigend einseitig. - Die Entscheidung zwischen Index und Collect wurde pro Datei getroffen — oft falsch. Kontakte nutzten einen echten
searchIndex; Kampagnen sammelten einen ganzen Status-Bucket ein und filterten die Suche im Speicher; E-Mail-Vorlagen führten.collect()über die gesamte Tabelle aus, für jede Auflistung, jeden Filter und jede Sortierung — eine Skalierungsklippe ohne einen Index weit und breit. - Zählungen waren ein eigener Zoo.
countByStatus,countByTypeundcountverwendeten jeweils eine andere Strategie (einsammeln und gruppieren, erneut einsammeln und gruppieren, ein denormalisierter zwischengespeicherter Zähler), ohne gemeinsame Oberfläche — obwohl die Dashboards, die eine Liste rendern, ihre Facettenzählungen zusammen mit der Seite benötigen. - Anreicherung war dupliziert und N+1. Die
contactCount-Anreicherung der Themen war inlistundgetjeweils separat inline ausgeschrieben, und die Zählung pro Zeile war ein vollständiger Paginierungs-Scan, sobald das zwischengespeicherte Feld fehlte.
Dies ist das leseseitige Gegenstück zu den schreibseitigen Lifecycle-Modulen und folgt dem bereits in der Codebase etablierten Präzedenzfall: ein dünner generischer Dispatcher (der Walker) über Daten pro Typ (die Block-Module).
Entscheidung
Das Auflisten von Ressourcen zu einer einzigen Naht machen: eine generische Listing-Engine, die über Listing-Deskriptoren pro Entität dispatcht und einen einzigen Convex-nativen Vertrag zurückgibt. Die Engine liegt in apps/api/convex/lib/listing.ts; jede Entität besitzt einen Deskriptor unter apps/api/convex/<entity>/listing.ts.
Ein Deskriptor deklariert die Leseoberfläche der Entität als Daten — ihren Suchindex, ihren Browse-Index, zulässige Sortierungen und Filter, die Soft-Delete-Policy, die Anreicherung pro Zeile und benannte Facettenzählungen:
// apps/api/convex/contacts/listing.ts — the cleanest case
export const contactListing: ListingDescriptor<'contacts'> = {
table: 'contacts',
search: { index: 'search_contacts', field: 'searchableText', filterFields: ['deletedAt'] },
browse: { index: 'by_deleted_at_and_created_at', order: 'desc' },
softDelete: true,
facets: { total: { kind: 'cachedCounter', table: 'instanceSettings', field: 'contactCount' } },
};
Die Session-Auth-Hülle (contacts/contacts.ts:list) und die API-Key-Hülle (<entity>/organization.ts) behalten ihre eigene Auth-Haltung und rufen in die Engine hinein:
// apps/api/convex/contacts/contacts.ts (shape)
const page = await listResources(ctx.db, contactListing, args);
const total = await countFacet(ctx.db, contactListing, 'total');
Was die Engine garantiert
- Einen echten Convex-Cursor auf beiden Pfaden. Die Suche verwendet
.withSearchIndex(...).paginate()(einen echten, opaken Cursor — das'search'-Sentinel ist verschwunden); das Browsen verwendet.withIndex(...).order().paginate(). Die Suche ist echt mehrseitig, sobald eine Abfrage durch die Engine geleitet wird. - Suche bedeutet Relevanzreihenfolge. Suchergebnisse sind nach Relevanz sortiert, daher gelten
sortKeysnur für den Browse-Pfad. Wirdsearchübergeben, wirdsortignoriert. Das ist Teil der Schnittstelle, kein verstecktes Detail — siehelistResourcesinapps/api/convex/lib/listing.ts. - Soft-Delete reitet auf dem Index und dünnt nie die Seite aus. Ist
softDeletegesetzt, wirddeletedAt === undefinedauf beiden Pfaden innerhalb des Indexbereichs fixiert. Der Browse-Index muss deshalb mitdeletedAtbeginnen (Kontakte verwendenby_deleted_at_and_created_at). Gewöhnliche Gleichheitsfilter dürfen eine Seite ausdünnen; nurdeletedAtist von diesem Schicksal ausgenommen. - Index-native Filter, wo ein zusammengesetzter Index existiert. Ein einzelner Gleichheitsfilter mit einem dedizierten zusammengesetzten Index (z. B.
status→by_status_and_updated_at) wird index-nativ und geordnet bedient. Ein Filter ohne einen solchen Index fällt auf ein.filter()nach dem Index zurück. - Der Deskriptor besitzt die Anreicherungskosten. Die Engine führt
enrichüber die Seite aus (und dasgetder Entität verwendet es wieder) und verbirgt nicht, ob jeder Aufruf O(1)-zwischengespeichert oder ein Scan ist — die Kosten stehen im Deskriptor. Themen reicherncontactCountaus dem denormalisiertencachedMemberCountan (mit einem begrenzten Mitgliedschafts-Scan als Fallback); der Automations-Deskriptor deklariert keine Anreicherung pro Zeile.
Facetten sind auf drei Strategien beschränkt
Der Zählungs-Zoo kollabiert auf exakt drei Facet-Arten — alles Reichhaltigere wird an der Schnittstelle abgelehnt, und eine einmalige Zählung wird als schlichte Query außerhalb der Naht geschrieben:
| Facettenart | Zählt über | Beispiel |
|---|---|---|
indexCount | Eine begrenzte paginierte Zählung über einen Index | Kampagnen-total über by_updated_at |
groupBy | Eine begrenzte Indexzählung pro Bucket, summiert zu total | Kampagnen-byStatus, Vorlagen-byType |
cachedCounter | Ein denormalisierter Zähler auf einer Singleton-Zeile (mit begrenztem Scan als Fallback) | Kontakt-total aus instanceSettings.contactCount |
countFacet(db, descriptor, name) löst die Strategie auf; groupBy liefert Zählungen pro Bucket, deren total deren Summe ist. Die Implementierung liegt in countFacet in apps/api/convex/lib/listing.ts und verwendet das Primitiv countWithPagination in apps/api/convex/lib/pagination.ts wieder.
Während des Entwurfs getroffene Entscheidungen
- Hybrid aus Walker + Deskriptoren, weder ein generischer Config-Bag noch eine Modulfamilie pro Entität — eine einzige generische Engine über dünnen Daten pro Typ, also die in der Codebase bereits akzeptierte Form „Dispatcher über Daten“.
- Ausschließlich Convex-nativer Cursor, unter Inkaufnahme der Schema-Rechnung: Kampagnen und E-Mail-Vorlagen erhielten die Suchindizes
search_campaigns/search_templatesmitfilterFields, unddeletedAtkam in den betreffenden Browse-Index hinzu. Der In-Memory-Offset-FallbackpaginateArraywurde verworfen, da er sowohl den Offset-Cursor als auch den Table-Scan bei der Suche verursacht hat. - Der Umfang ist Seite + Anreicherung + Facettenzählungen. Der Deskriptor besitzt die gesamte Leseoberfläche der Entität, sodass die Zähl-Queries und die Themen-Anreicherung ebenfalls hineinfallen. Eine Naht nur für Seiten hätte den Zählungs-Zoo und das N+1 stehen lassen.
- Der Deskriptor besitzt die Anreicherungskosten, statt dass die Engine nicht-O(1)-Anreicherung verweigert, was zu vorzeitiger Denormalisierung gezwungen hätte.
- Facetten sind auf drei Strategien beschränkt — genau auf das, was in freier Wildbahn existierte.
- Auth bleibt in den Hüllen. Die Engine nimmt einen
DatabaseReaderentgegen, nie eine Session — sie liest, sie authentifiziert oder scopet nicht.
Durchsetzung
Ein lint:listing-Guard (apps/api/scripts/check-listing.sh, ausgeführt als Teil von bun run lint, Geschwister von lint:env und lint:errors) verhindert, dass die Naht erodiert. Er verbietet in Query-Dateien außerhalb der Engine und der Deskriptoren:
paginateArray(— den entfernten Helfer für den als String serialisierten ganzzahligen Offset.- Manuelle
numItems + 1-Paginierung nach dem Muster „nimm n+1, umhasMorezu berechnen“ — das handgestrickte Muster, das den gefälschten'search'-Sentinel-Cursor hervorgebracht hat.
Die dritte ausgeschriebene Form (.collect(), dann filtern, dann paginieren) wird durch die bestehende .collect()-Baseline-Prüfung in Schach gehalten; die Portierung einer Listenabfrage auf die Engine senkt sie immer nur.
Konsequenzen
Ermöglicht:
- Einen einzigen Lesevertrag — jeder Listenendpunkt liefert dasselbe
{ page, isDone, continueCursor }mit einem echten Convex-Cursor samt seinen Facetten, sodass eine einzige Paginierungs-UI gegen alle funktioniert. - Der Cursor-Bug der Kontaktsuche stirbt beim Umschalten; die Suche wird echt mehrseitig.
- Die Entscheidung zwischen Index und Collect wird einmal getroffen, an einer getesteten Stelle. Das vollständige
.collect(), das die E-Mail-Vorlagen-Auflistung falsch gemacht hat, ist verschwunden; eine neue listbare Entität ist ein Deskriptor von rund 6 Zeilen statt 60 Zeilen neu hergeleiteter Query. Sechs Entitäten nutzen die Engine inzwischen — Kontakte, Kampagnen, Themen, Segmente, Automationen und E-Mail-Vorlagen. - Der Zählungs-Zoo und das N+1 kollabieren:
countByStatus/countByType/countwerden zu deklariertenfacets, und die Anreicherung pro Zeile wird einmal deklariert und vonlistundgetgemeinsam genutzt.
Trade-offs:
- Eine additive Schema-Rechnung — die Suchindizes
search_campaigns/search_templates, die zusammengesetzten Indizesby_status_and_updated_at/by_type_and_updated_atunddeletedAt, eingefaltet in die soft-delete-fähigen Browse-Indizes. - Ein bewusster, atomarer Bruch: Die HTTP-Liste der E-Mail-Vorlagen wechselte vom reinen Array zu einem paginierten
{ page, … }, im selben Geist des sauberen Schnitts wie die SDK-Änderungen (keine zweiphasige Migration). - Convex'
withIndex/withSearchIndexverlangen pro Tabelle String-Literal-Indexnamen, die eine überTableNamesgenerische Funktion nicht liefern kann, weshalb die Engine ihre Query-Builder intern castet — derselbe Handel, dencountWithPaginationbereits eingeht, beschränkt auf ein Modul.
Diese Entscheidung ist als ADR-0037 im Engineering-ADR-Satz festgehalten (docs/adr/0037-resource-listing-engine.md). Sie baut auf ADR-002: Convex als Backend auf und spiegelt das Muster „Dispatcher über Daten“ wider; zum umfassenderen Backend-Aufbau siehe die Referenz Convex Backend.