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ätRückgabeformCursorZugriffspfad
Kontakte{ page, isDone, continueCursor }Echter Convex-Cursor beim Browsen; der Literal-String 'search' bei der SucheIndex search_contacts + Erstellungsindex
Kampagnen{ page, isDone, continueCursor }Als String serialisierter ganzzahliger Offsetby_status/by_updated_at, danach .filter() im Speicher
E-Mail-VorlagenReines Array, überhaupt keine Paginierung.collect() über die gesamte Tabelle, danach Filtern und Sortieren im Speicher
Themen{ page, … } + Anreicherung pro ZeileEchter 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:

  1. Der Cursor war auf dem Suchpfad eine Lüge. Die Kontaktsuche gab continueCursor: 'search' zurück (gecastet als as unknown as string) und las stets von vorn, sodass die Anfrage nach Seite 2 einer Suche erneut Seite 1 auslieferte. Suchergebnisse waren stillschweigend einseitig.
  2. 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.
  3. Zählungen waren ein eigener Zoo. countByStatus, countByType und count verwendeten 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.
  4. Anreicherung war dupliziert und N+1. Die contactCount-Anreicherung der Themen war in list und get jeweils 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 sortKeys nur für den Browse-Pfad. Wird search übergeben, wird sort ignoriert. Das ist Teil der Schnittstelle, kein verstecktes Detail — siehe listResources in apps/api/convex/lib/listing.ts.
  • Soft-Delete reitet auf dem Index und dünnt nie die Seite aus. Ist softDelete gesetzt, wird deletedAt === undefined auf beiden Pfaden innerhalb des Indexbereichs fixiert. Der Browse-Index muss deshalb mit deletedAt beginnen (Kontakte verwenden by_deleted_at_and_created_at). Gewöhnliche Gleichheitsfilter dürfen eine Seite ausdünnen; nur deletedAt ist von diesem Schicksal ausgenommen.
  • Index-native Filter, wo ein zusammengesetzter Index existiert. Ein einzelner Gleichheitsfilter mit einem dedizierten zusammengesetzten Index (z. B. statusby_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 das get der Entität verwendet es wieder) und verbirgt nicht, ob jeder Aufruf O(1)-zwischengespeichert oder ein Scan ist — die Kosten stehen im Deskriptor. Themen reichern contactCount aus dem denormalisierten cachedMemberCount an (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:

FacettenartZählt überBeispiel
indexCountEine begrenzte paginierte Zählung über einen IndexKampagnen-total über by_updated_at
groupByEine begrenzte Indexzählung pro Bucket, summiert zu totalKampagnen-byStatus, Vorlagen-byType
cachedCounterEin 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

  1. 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“.
  2. Ausschließlich Convex-nativer Cursor, unter Inkaufnahme der Schema-Rechnung: Kampagnen und E-Mail-Vorlagen erhielten die Suchindizes search_campaigns / search_templates mit filterFields, und deletedAt kam in den betreffenden Browse-Index hinzu. Der In-Memory-Offset-Fallback paginateArray wurde verworfen, da er sowohl den Offset-Cursor als auch den Table-Scan bei der Suche verursacht hat.
  3. 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.
  4. Der Deskriptor besitzt die Anreicherungskosten, statt dass die Engine nicht-O(1)-Anreicherung verweigert, was zu vorzeitiger Denormalisierung gezwungen hätte.
  5. Facetten sind auf drei Strategien beschränkt — genau auf das, was in freier Wildbahn existierte.
  6. Auth bleibt in den Hüllen. Die Engine nimmt einen DatabaseReader entgegen, 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, um hasMore zu 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 / count werden zu deklarierten facets, und die Anreicherung pro Zeile wird einmal deklariert und von list und get gemeinsam genutzt.

Trade-offs:

  • Eine additive Schema-Rechnung — die Suchindizes search_campaigns / search_templates, die zusammengesetzten Indizes by_status_and_updated_at / by_type_and_updated_at und deletedAt, 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 / withSearchIndex verlangen pro Tabelle String-Literal-Indexnamen, die eine über TableNames generische Funktion nicht liefern kann, weshalb die Engine ihre Query-Builder intern castet — derselbe Handel, den countWithPagination bereits 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.