Zum Inhalt

Virtual Tables

Eine Virtual Table ist eine typisierte Tabelle mit Datensätzen, die eine Organisation für ihre Agents, Workflows und Integrationen führt: abzugleichende Bestellungen, zu verarbeitende Dateien, nachzufassende Leads.

Tabellen sind Metadaten plus JSONB. Nichts legt eine physische SQL-Tabelle an: Eine Tabelle zu erstellen kostet eine Zeile, das Umbenennen einer Spalte ändert keinen Datensatz, und kein Tenant kann den Datenbankkatalog wachsen lassen. Jeder Lese- und Schreibzugriff läuft über einen einzigen Service, VirtualTableService, sodass die Konsole, Agent-Tools, Workflow-Knoten und die öffentliche API dieselben Regeln teilen. Diese Seite beschreibt den Service und seine HTTP-Routen unter /api/v1/tables; das OpenAPI-Dokument ist ihr Vertrag.

Der Tabellenbereich füllt den verfügbaren Platz automatisch, ohne Vergrößerungsknopf. Das Datensatzpanel hat einen eigenen Full screen-Knopf, der alle Felder ohne Verlust laufender Änderungen vergrößert; Exit full screen stellt das Seitenpanel wieder her. Zeilen liegen direkt unter der Leiste, ohne zweiten Rahmen oder leeres gepolstertes Panel. Der Katalog füllt den verfügbaren Seitenbereich, bietet einen eigenen Vollbildknopf und zeigt Seitennavigation nur bei mehreren Seiten.

Jede Tabelle zeigt die gesperrten Datensatzfelder created_at, updated_at, created_by und updated_by. Es sind dieselben Eigenschaften wie über Markdown, keine bearbeitbaren Kopien in Zellen. Vor der ersten Aktualisierung zeigt updated_at das Erstellungsdatum. Die Felder bleiben sichtbar, wenn eigene Spalten ausgeblendet werden. Ihre Namen sind beim Hinzufügen und Umbenennen von Spalten reserviert; bestehende ältere Namen bleiben lesbar.

Lege einen typisierten Standardwert in den Spalteneinstellungen oder beim Hinzufügen einer Spalte fest. Neue Datensätze verwenden ihn nur, wenn das Feld fehlt; ein expliziter Wert oder null hat Vorrang. Auswahl-Standardwerte dürfen beim Einreichen eines Schemas IDs oder Bezeichnungen nennen; der Server speichert IDs. Wähle die Farbe jeder Option in ihrer Liste; Automatisch behält die bisherige Palette. Farbänderungen erhalten Options-IDs und die Bedeutungsrevision der Spalte.

Die Schnelleingabe steht direkt hinter den geladenen Zeilen in deren Scrollbereich. Der CSV-Export der Konsole enthält die vier Datensatzeigenschaften. API-Exporte behalten ihr bisheriges Format, sofern nicht include_metadata: true angefordert wird. Beim Erstellen einer Tabelle aus einem exportierten CSV werden Systemeigenschaften übersprungen; importierte Datensätze erhalten neue Systemmetadaten.

Geben Sie in einem Markdown-Dokument / am Zeilenanfang ein, um einen Block zu suchen: Aufgabe, Überschrift, Liste, Zitat, Code, Trennlinie, Tabelle, Bild, Video oder Link. Pfeiltasten und Eingabe wählen einen Block; Escape schließt das Menü. Kopierte Bilder lassen sich direkt einfügen. PNG-, JPEG-, GIF- und WebP-Bilder bis 5 MiB werden mit der Tabelle gespeichert, zählen zu ihrem Speicherlimit und unterliegen ihren aktuellen Zugriffsrechten. Sie bleiben nach der Archivierung verfügbar, auch über Verweise im Datensatzverlauf.

Die Spaltentypen user und agent verweisen auf ein Organisationsmitglied oder einen zugänglichen KI-Agenten; multi_user und multi_agent auf bis zu 100 verschiedene Identitäten. Wählen Sie nach Namen aus Vorschlägen mit Avataren. Gespeichert werden stabile UUIDs; Mitgliedschaft und Agentenzugriff werden beim Schreiben und bei Standardwerten geprüft. Einzelne Referenzen unterstützen eq, ne, in und is_null, mehrere Referenzen contains und is_null. Identitätsspalten sind nicht sortierbar.

Dokumentvorlagen im Markdown-Feld öffnen die Bibliothek dieser Spalte. Speichern Sie das aktuelle Dokument, passen Sie eine Startvorlage an, bearbeiten oder löschen Sie benannte Vorlagen und wählen Sie einen Standard. Dieser füllt neue Datensätze automatisch; Änderungen überschreiben keine vorhandenen Dokumente. Pro Spalte sind bis zu 10 Vorlagen mit insgesamt 100.000 Zeichen möglich. Normale Datensatzeigenschaften stehen im Datensatzpanel über den Dokumentinhalten.

Die Datensatzauswahl zeigt eine schwebende Aktionsleiste, ohne das Raster zu verschieben. Der Öffnen-Button bleibt neben dem Auswahlkästchen angeheftet, auch beim horizontalen Scrollen. Jedes Datensatzpanel zeigt Erstellungs- und Änderungszeiten sowie Autoren mit Avataren. Seitenpanels starten in Preview, ganzseitige Dokumente in Write.

Externe Clients

Die Herkunft eines Schreibvorgangs wird getrennt vom autorisierenden Mitglied gespeichert. Neue Schreibvorgänge setzen created_source und updated_source mit Art, ID und Name: Person, Agent, API-Schlüssel, Workflow oder System. Die Konsole zeigt den Avatar einer Person, das Porträt eines Agenten oder ein API-/Workflow-Symbol neben dem Namen in Systemspalten und Dokumenteigenschaften. Beim Erstellen werden beide Quellen gesetzt; spätere Änderungen erhalten den Ersteller. Alte Datensätze behalten ihr gespeichertes Mitglied, ohne eine historische Herkunft zu erfinden. Diese Felder lassen sich weder durch Eingabewerte noch Markdown setzen.

Organisations-API-Keys werden auf /api/v1/tables akzeptiert. Senden Sie Authorization: Bearer aos_…; die Organisation kommt vom Key. Lesen erfordert tables:view, Datensatz- und Schemaänderungen tables:edit, das Anlegen einer Tabelle tables:create. Sowohl der aktuelle Zugriff des Ausstellers als auch die Scopes des Keys gelten. Historie und Quittungen nennen den Aussteller; der Key erweitert dessen Zugriff nicht. Eine unzugängliche Tabelle antwortet 404, eine fehlende Sammlungsberechtigung 403, eine veraltete Revision 409, eine ungültige Abfrage oder ein ungültiger Datensatz 422. Eine Wiederholung mit demselben Idempotency-Key und Inhalt liefert das ursprüngliche Ergebnis ohne weiteres Created-Ereignis.

POST /api/v1/tables/{id}/records/query akzeptiert columns (bis zu 100 IDs aktiver Spalten) neben Filtern, Sortierung, skip und limit. Nur diese Zellen und Link-Labels erreichen die Antwort. null behält den ganzen Datensatz; [] liefert nur Metadaten. Berechnete Spalten werden vor der Projektion ausgewertet; ihre Abhängigkeiten müssen nicht ausgewählt sein. Dieselben Datensätze erscheinen sofort in der Konsole; die asynchrone Ausführung des Tabellen-Triggers verzögert die CRUD-Antwort nicht.

Wie eine Tabelle aufgebaut ist

Teil Was es ist Identität
Table Ein Name, ein Besitzer, eine Sichtbarkeit und Grants, wie bei einer Context-Datei id, stabil
Schema version Ein unveränderlicher Schnappschuss der Spalten. Eine Änderung hängt Version N+1 an version
Column Eine Bezeichnung, ein Typ, ob sie leer sein darf, ein optionaler Standardwert id, stabil
Option Eine Auswahl einer Select-Spalte id, stabil
Record Zellwerte, nach Spalten-id geordnet, eine Revision und eine optionale external_id id, stabil

Werte werden über die id der Spalte adressiert, nie über ihre Bezeichnung. Ein Umbenennen schreibt daher eine Schema-Version um und keinen Datensatz. Ein Datensatz merkt sich die Schema-Version, unter der er zuletzt geschrieben wurde.

Ein Datensatz speichert nur die Zellen, die einen Wert enthalten. Eine Zelle, die als null gesendet wird, wird geleert, und ein Lesezugriff zeigt für sie nichts an. Bei einem Create füllt der Standardwert einer Spalte nur die Zellen, die Sie weglassen; eine Zelle, die Sie als null senden, bleibt leer.

Spaltentypen

Typ Gespeichert als Filter
text Text bis 1.000 Zeichen eq ne contains starts_with in is_null
long_text Text bis 100.000 Zeichen wie text
rich_text Markdown-Quelltext mit bis zu 100.000 Zeichen wie text
email Eine Adresse mit einem @ und einem Punkt in der Domain, bis 254 Zeichen wie text
url Eine http- oder https-Adresse mit Host, bis 2.048 Zeichen wie text
phone Ziffern, Leerzeichen und + ( ) - . /, mindestens fünf Ziffern wie text
number Eine endliche Zahl eq ne lt lte gt gte in is_null
integer Eine ganze Zahl, höchstens 2^53 - 1 wie number
rating Eine ganze Zahl von 1 bis 5 wie number
boolean true oder false eq ne is_null
date YYYY-MM-DD wie number
datetime ISO 8601 mit Zeitzone, als UTC gespeichert wie number
single_select Die id einer Option eq ne in is_null
multi_select Eine Liste von Options-ids contains is_null
link Eine Liste von bis zu 100 Datensatz-ids der verknüpften Tabelle contains (eine Datensatz-id) is_null
lookup, rollup Nichts: beim Lesen eines Datensatzes ermittelt keine
formula Nichts: beim Lesen aus den übrigen Spalten des Datensatzes ermittelt keine

Text wird genau so gespeichert, wie er gesendet wurde. Führende und nachgestellte Leerzeichen, Zeilenumbrüche und Werte, die nur aus Leerzeichen bestehen, sind Daten des Benutzers und werden deshalb nicht beschnitten. Es gilt nur eine Längenbegrenzung, und das NUL-Zeichen wird abgelehnt, in Zellen ebenso wie in Namen, Bezeichnungen, Beschreibungen und external ids, weil PostgreSQL es nicht speichern kann.

Vergleiche treffen nur Zellen, die einen Wert enthalten. Mit is_null finden Sie die leeren. contains und starts_with nehmen ein Stück eines Werts, sodass @example.com in einer email-Spalte die Adressen einer Domain findet.

Die Tabellen-Tools eines Agenten und die Tabellenschritte eines Workflows benennen eine Auswahl stattdessen über das Label ihrer Option, ohne Beachtung der Groß- und Kleinschreibung: Sie schreiben und filtern per Label und lesen die Auswahl eines Datensatzes als Labels zurück, auch die einer archivierten Option. Ein Wert, der keine aktive Option nennt, wird mit den Optionen der Spalte abgelehnt.

Formatierter Text und Markdown

Wähle Rich text (Markdown) für Notizen mit Überschriften, Listen, Aufgaben, Tabellen, Links und Code. Ein Zellklick öffnet den Datensatz. Write bietet ein Dokumentfeld mit Werkzeugen für Fett, Kursiv, Links, Bilder und Video. Preview nutzt den Chat-Renderer für Links und Bilder; direkte HTTP(S)-Links auf MP4, WebM oder Ogg werden als Video mit Steuerelementen ohne Autoplay angezeigt. Rohes HTML wird nicht ausgeführt. Expand editor öffnet mehr Platz. Gespeicherte Dokumente starten in der Vorschau; Write wechselt zur Bearbeitung.

Kanban zeigt eine Karte unter dem Zeiger und markiert die Zielspalte; beim Speichern steht die Karte bereits dort. Bei abgelehntem Schreiben kehrt sie zurück und die Konfliktaktionen bleiben erhalten. Das Menü Move to ist per Tastatur erreichbar.

Dokumenteigenschaften stehen über dem Markdown-Editor. Erstellt, Zuletzt geändert und Erstellt von stammen aus dem Datensatz und lassen sich nicht über Markdown ändern. Das Änderungsdatum folgt Schreibvorgängen am gesamten Datensatz, auch an anderen Zellen; vor der ersten Änderung gilt das Erstellungsdatum.

Dokumenteigenschaften gehören in typisierte Tabellenspalten. Der Markdown-Editor erstellt oder bearbeitet keine YAML-Eigenschaften mehr. Neue Notizbuchvorlagen enthalten nur Markdown. Bereits importierte Header bleiben bei Inhaltsänderungen unverändert erhalten, damit historische Daten nicht verloren gehen. Automatische Datumsangaben und Schreibquellen stammen weiterhin aus dem Datensatz.

Wähle die Standardvorlage beim Erstellen einer Rich-Text-Spalte oder unter Columns. Leeres Dokument, Notiz, Besprechungsnotizen und Projektbeschreibung sind anpassbare Startpunkte. Die Vorlage nutzt den vorhandenen Spaltenwert default: Konsole, API und Workflow übernehmen ihn beim Erstellen, wenn die Zelle fehlt. Ein ausdrücklich angegebener Wert, auch eine leere Zeichenfolge, hat Vorrang. Eine Vorlagenänderung betrifft nur neue Datensätze. Spaltenvorlage verwenden ersetzt nach Bestätigung den Inhalt eines bestehenden Dokuments und behält seine Eigenschaften. Textabfragen und Konvertierungen behalten ihre bisherigen Verträge.

Eine link-Spalte hält Datensätze einer anderen Tabelle oder derselben, benannt durch link_table_id. Beim Schreiben wird geprüft, dass jeder Datensatz in dieser Tabelle existiert und der Schreibende sie sehen kann, sodass eine Verknüpfung nicht verrät, welche ids eine private Tabelle hat. Ein Datensatz liefert seine Verknüpfungen benannt zurück, in links, nach dem ersten Textwert jedes verknüpften Datensatzes, sonst nach seiner externen id. Ein inzwischen gelöschter verknüpfter Datensatz fällt einfach weg.

Ein lookup liest eine Spalte der verknüpften Datensätze über eine Verknüpfungsspalte derselben Tabelle (link_column_id, target_column_id): die Werte, eine Auswahl nach ihrem Label. Ein rollup ermittelt daraus etwas: count der Verknüpfungen oder sum, avg, min oder max einer Zahlenspalte - min und max auch von Datumswerten. Beide werden beim Lesen ermittelt und nie gespeichert, geschrieben, gefiltert oder sortiert.

Was ein Nachschlagen zeigt, reicht nicht weiter als der Leser darf: Eine verknüpfte Tabelle, die der Leser nicht sieht, lässt die Verknüpfungen unbenannt und jedes Nachschlagen und jede Zusammenfassung darüber leer. Eine Verknüpfung behält ihre Tabelle für immer. Ein Nachschlagen liest eine Spalte mit Werten, keine andere Verknüpfung und kein Nachschlagen, und das Archivieren einer Verknüpfungsspalte wird abgelehnt, solange etwas über sie liest. Ein Export schreibt eine Verknüpfung mit den Namen ihrer Datensätze und ein Nachschlagen mit dem Gelesenen.

Formeln

Eine formula-Spalte ermittelt einen Wert aus den übrigen Spalten des Datensatzes: {Price} * {Quantity}, IF({Total} > 100, "large", "small"), ROUND({Total} / {Count}, 2), DAYS({Due}, TODAY()). Eine Spalte wird mit ihrem Label in geschweiften Klammern genannt, und eine Umbenennung schreibt jede Formel um, die sie nennt. Eine Formel rechnet mit + - * / %, vergleicht mit = <> < <= > >=, verbindet Bedingungen mit and, or und not und ruft IF, ROUND, ABS, MIN, MAX, CONCAT, LEN, UPPER, LOWER, DAYS und TODAY auf. Eine Auswahl liest sich als ihr Label.

Sie darf jede lebende Spalte mit Werten lesen, auch das Ergebnis eines Nachschlagens oder einer Zusammenfassung, aber keine Verknüpfung und keine andere Formel, und eine Spalte, die eine Formel liest, kann nicht archiviert werden. Beim Speichern des Schemas wird die Formel geprüft und gesagt, was nicht stimmt. Ein Wert, der sich nicht ermitteln lässt - eine Division durch null, Text in einer Summe, eine leere Zelle - lässt die Zelle dieses Datensatzes leer. Eine Formel wird beim Lesen ermittelt und nie gespeichert, geschrieben, gefiltert oder sortiert.

Ein Schema ändern

PUT /tables/{id}/schema nimmt die vollständige Liste der Spalten entgegen, die die Tabelle haben soll, und die expected_version, die der Aufrufer zuletzt gelesen hat. Der Service gleicht sie mit den aktuellen Spalten ab:

  • Eine Spalte mit id ist diese Spalte. Eine Spalte ohne id ist neu.
  • Der Typ einer Spalte ändert sich nur in einen, als den sich ihre Werte lesen lassen, und die Änderung schreibt den Wert jedes Datensatzes neu. Jeder Wert wird zu Text; Text wird zu einer Zahl, einem Ja/Nein, einem Datum oder einer Einfachauswahl, wenn er eines sagt - eine Auswahl erhält je unterschiedlichem Wert eine Option; eine ganze Zahl wird zur Zahl und zurück, wenn sie ganz ist; ein Datum wird zu Datum und Uhrzeit; eine Auswahl wird zu mehreren und zurück, wenn es eine ist. Jede andere Änderung wird abgelehnt, ebenso eine, die ein Wert nicht übersteht - mit Anzahl und Beispiel, und nichts wird geschrieben. Was die Spalte verwendet, lehnt sie wie eine Archivierung ab. Die Revision eines Datensatzes rückt weiter, sodass eine Bearbeitung gegen den alten Wert ein Konflikt ist.
  • Text wird zu einer email-, url-, phone- oder rating-Spalte, wenn jeder Wert sich so liest, und eine Zahl wird zu einer Bewertung, wenn sie eine ganze Zahl von 1 bis 5 ist. Jede davon wird wieder zu Text, eine Bewertung auch zu einer Zahl.
  • Nichts wird gelöscht. Eine weggelassene Spalte oder Option wird archiviert: Ihre Werte bleiben les- und filterbar, und ein Schreibzugriff darauf wird mit ARCHIVED_COLUMN abgelehnt.
  • Archivierte zählen zu den Grenzen. Eine Tabelle hat höchstens 100 Spalten und eine Select-Spalte höchstens 100 Optionen, Archivierte eingeschlossen. Eine Änderung, die eine davon überschreiten würde, wird mit INVALID_SCHEMA abgelehnt und hängt keine Version an; eine volle Liste von Optionen lässt sich also nicht ersetzen. Fügen Sie stattdessen eine neue Spalte hinzu.
  • Eine neue Pflichtspalte braucht einen Standardwert, weil bestehende Datensätze nichts für sie enthalten. Eine bestehende Spalte kann nicht zur Pflichtspalte werden, solange ein Datensatz keinen Wert für sie hat, und eine Pflichtspalte kann ohne Standardwert nicht aus dem Archiv zurückkehren, weil Datensätze, die während der Archivierung geschrieben wurden, keinen halten konnten.
  • Eine veraltete expected_version ergibt SCHEMA_VERSION_CONFLICT.
  • Eine Übermittlung, die den aktuellen Spalten entspricht, mit denselben ids, derselben Reihenfolge, denselben Bezeichnungen und Optionen, ändert nichts: Es wird keine Version angehängt, und die aktuelle Tabelle wird zurückgegeben. Eine neue Reihenfolge oder eine geänderte Bezeichnung ist eine Änderung.

Datensätze werden nicht umgeschrieben. Ein unter Version 1 geschriebener Datensatz bleibt unter Version 4 lesbar und bearbeitbar; eine Pflichtspalte, die er nie hatte, erhält beim nächsten Bearbeiten des Datensatzes ihren Standardwert.

Jede Spalte trägt eine schreibgeschützte revision, die für eine neue Spalte und für jede Spalte, die vor ihrem Hinzufügen existierte, 0 ist. Sie zählt die Änderungen daran, was die Werte der Spalte bedeuten, sodass eine veröffentlichte Seite erkennen kann, dass die Spalte, gegen die sie veröffentlicht wurde, nicht mehr dieselbe ist. Sie steigt um eins bei:

  • einer Typkonvertierung;
  • einer Änderung an einer Formel, verglichen, nachdem eine Umbenennung durch sie hindurchgeschrieben wurde; eine Spalte umzubenennen, die eine Formel liest, zählt also nicht, wohl aber, wenn eine andere Spalte das Label übernimmt;
  • einem anderen Link-, Lookup- oder Rollup-Ziel oder einem anderen Aggregat;
  • dem Archivieren oder Wiederherstellen der Spalte oder dem Archivieren oder Wiederherstellen einer ihrer Select-Optionen.

Ein neues Label, ein neuer Standardwert oder eine neue Pflichtigkeit, eine umbenannte Select-Option und eine neue Option lassen sie, wie sie war. Ein Filter hält Options-IDs, daher ändert nichts davon, was ein Filter trifft. Eine TableRead-Spalte kann in einer Schemaänderung so zurückgeschickt werden, wie sie kam: Das Feld wird akzeptiert und ignoriert, und nur die Änderung selbst setzt es.

Ein Schreibzugriff auf einen Datensatz und eine Schemaänderung oder Archivierung derselben Tabelle kommen nacheinander dran: Der Schreibzugriff wartet auf eine laufende und wird dann an dem gemessen, was sie committet hat, sodass ein Datensatz nie in einer Tabelle landet, die einen Moment zuvor archiviert wurde.

Beim Archivieren einer Spalte oder der ganzen Tabelle wird zuerst jeder registrierte Dependency-Checker gefragt, ob etwas, das der Aufrufer sowohl sehen als auch ändern kann, sie noch verwendet. Gespeicherte Ansichten (siehe Gespeicherte Ansichten) und die Workflows, die die Tabelle lesen oder schreiben, registrieren ihre in app/services/virtual_tables/dependencies.py, ebenso Tabellen-Trigger, die jeden blockieren, der archiviert: Ein Trigger, der nach einer nicht mehr vorhandenen Spalte filtert, käme kaputt zurück.

Eine Ablehnung führt jeden Abhängigen in SCHEMA_DEPENDENCY mit kind, id und name auf, und name ist null für einen Trigger in einem Workflow, den der Aufrufer nicht öffnen kann. Die Konsole zeigt sie im Dialog, der gefragt hat, einen Workflow als Link darauf. Jeder andere Abhängige wird nie genannt und blockiert den Aufrufer nie: sein Feature kommt stattdessen selbst mit der Änderung zurecht.

Eine Tabelle archivieren und wiederherstellen

Eine Tabelle wird nie gelöscht, sondern archiviert. Eine archivierte Tabelle behält ihre Datensätze, Spalten, Ansichten und ihren Verlauf, und jedes Lesen antwortet weiterhin. Jeder Schreibzugriff wird mit TABLE_ARCHIVED abgelehnt, egal wer fragt: Kein Datensatz wird angelegt, geändert oder gelöscht, und weder ihr Name noch ihre Spalten ändern sich. Ihr Name ist für eine neue Tabelle frei, und sie zählt weiterhin zum Tabellenlimit der Organisation.

In der Konsole findet ein Mitglied, das die Tabelle bearbeiten darf, Archive table im Menü More actions auf ihrer Seite. Der Dialog sagt, was das Archivieren bewirkt, bevor sich etwas ändert. Ein veröffentlichter Workflow, der die Tabelle noch liest oder schreibt und den der Aufrufer bearbeiten darf, lehnt die Archivierung mit SCHEMA_DEPENDENCY ab, und der Dialog führt ihn als Link auf. Gespeicherte Ansichten und Trigger blockieren nur das Archivieren einer Spalte.

Die Seite einer archivierten Tabelle sagt, wann sie archiviert wurde. Für ein Mitglied, das die Tabelle bearbeiten darf, trägt der Hinweis Restore, das sie unter ihrem Namen zurückholt; alle anderen lesen ihre Datensätze und erfahren, wer sie wiederherstellen kann. Der Katalog listet aktive Tabellen, bis darüber Archived included gewählt wird, und die Karte einer archivierten Tabelle sagt, dass sie archiviert ist.

Aktion Route Abgelehnt mit
Archivieren POST /tables/{id}/archive SCHEMA_DEPENDENCY (409), solange etwas die Tabelle verwendet. Zweimal archivieren ist kein Fehler
Wiederherstellen POST /tables/{id}/unarchive BAD_REQUEST (400), wenn die Tabelle nicht archiviert ist; ALREADY_EXISTS (409), wenn eine aktive Tabelle inzwischen ihren Namen übernommen hat

Beide brauchen das Recht, diese Tabelle zu bearbeiten, per Rolle oder per Grant auf ihr, und alle anderen erhalten ein 404. can_edit ist auf einer archivierten Tabelle false, daher sagt TableRead.can_restore, ob dieser Aufrufer sie wiederherstellen darf. Beides wird auditiert, als table.archived und table.unarchived, und zählt zum Schreib-Rate-Limit für Tabellen. Ein inzwischen vergebener Name wird zuerst an der aktiven Tabelle geändert, weil eine archivierte nicht umbenannt werden kann.

Datensätze und Revisions

Jeder Datensatz hat eine revision, die bei 1 beginnt und mit jeder Änderung steigt.

Operation Route Braucht expected_revision
Erstellen POST /tables/{id}/records Nein
Genannte Zellen ändern PATCH /tables/{id}/records/{record_id} Ja
Löschen DELETE /tables/{id}/records/{record_id}?expected_revision= Ja
Upsert PUT /tables/{id}/records/by-external-id/{external_id} Nur wenn der Datensatz existiert
Lesen, exists GET .../records/{record_id}, .../by-external-id/{external_id}, .../exists Nein

Ein Update oder Delete mit einer alten Revision wird mit REVISION_CONFLICT (409) und details.current_revision abgelehnt; nichts wird überschrieben. Lesen Sie den Datensatz erneut und versuchen Sie es noch einmal. Ein Upsert, der einen bestehenden Datensatz findet und kein expected_revision erhält, bekommt REVISION_REQUIRED (428), wieder mit der zu sendenden Revision.

Eine external id hat 1 bis 255 Zeichen und darf / enthalten, wie in 2026/ORD-1. Sie darf weder NUL noch einen Zeilenumbruch enthalten. Der Service prüft das ebenso wie die Routen. Über HTTP lehnt die Route es zuerst ab, mit VALIDATION_ERROR; ein Aufrufer, der den Service direkt nutzt, erhält INVALID_RECORD.

Gleichzeitige Upserts derselben external id erzeugen einen Datensatz. Der Verlierer findet ihn und wird wie ein Update beantwortet: Er braucht die Revision oder erfährt, welche er senden muss.

Ein Update, das jede Zelle unverändert ließe, ändert nichts. Die Revision bleibt, es wird keine Historienzeile und kein Receipt geschrieben, und der aktuelle Datensatz wird zurückgegeben. Eine veraltete expected_revision ist weiterhin ein Konflikt, weil sie zuerst geprüft wird. Ein Upsert, der den Datensatz findet, folgt derselben Regel.

Ein Delete ist ein hartes Löschen. Die Historie des Datensatzes bleibt, bis ihre Aufbewahrung sie entfernt.

Datensätze in der Konsole bearbeiten

Katalog und die Ansichten Table, Kanban und List verwenden ein gemeinsames Panel mit Suche und gespeicherten Ansichten. Add record nutzt zwei Spalten auf großen Bildschirmen und eine auf dem Telefon; Langtext und Verknüpfungen behalten die volle Breite. Das Datensatz-Panel nutzt die gemeinsame Tastatur- und Fokussteuerung der Konsole. Columns klappt vorhandene Auswahloptionen hinter ihrer Anzahl ein. Ladefehler bei Datensätzen bieten Retry.

Die Karten des Katalogs sagen, wie viele Datensätze und aktive Spalten jede Tabelle hat, wer sie erreichen darf und wann sie sich zuletzt geändert hat. Auf der Seite einer Tabelle zeigt jede Kopfzeile den Typ ihrer Spalte als Symbol, Zahlen stehen rechtsbündig, und Daten lesen sich so, wie Ihre Sprache sie schreibt. Das Datensatz-Panel trägt die erste Spalte des Datensatzes als Titel und sagt, wann er hinzugefügt und zuletzt geändert wurde.

Ein Mitglied, das die Tabelle bearbeiten darf, fügt Datensätze auf ihrer Seite hinzu, ändert und löscht sie. Add record fragt nach einem Wert je aktiver Spalte, im Typ der Spalte. Eine Pflichtspalte ohne Standardwert ist mit * markiert und muss ausgefüllt sein, bevor der Datensatz geschrieben wird; jede andere leere Spalte übernimmt ihren Standardwert. Ein Klick auf eine Zelle bearbeitet sie direkt: Enter oder ein Klick daneben speichert, Escape lässt sie unverändert, und ein Ja/Nein, das nicht leer sein darf, wechselt mit einem Klick. Die Pfeiltasten wechseln zwischen Zellen, und die Schaltfläche zum Aufklappen am Zeilenende öffnet den ganzen Datensatz.

Die Zeile unter dem Raster fügt Datensätze beim Tippen hinzu: Was du tippst, landet in der ersten Textspalte, und Enter legt den Datensatz an und lässt die Zeile für den nächsten bereit. Eine Tabelle mit einer weiteren Pflichtspalte öffnet stattdessen Add record mit dem getippten Wert, sodass nichts geschrieben wird, bevor alle Pflichtwerte da sind.

Jede Bearbeitung ist ein PATCH gegen die angezeigte Revision, daher wird eine Bearbeitung, die gegen eine neuere Änderung verliert, abgelehnt statt darüber geschrieben. Der Datensatz öffnet sich dann mit dem abgelehnten Wert neben Reload and reapply. Markierte Zeilen bieten Delete für alle an, jede gegen ihre eigene Revision: Ein zwischenzeitlich geänderter Datensatz bleibt erhalten, und die Konsole sagt, wie viele es waren. Das Datensatz-Panel löscht einen einzelnen Datensatz auf dieselbe Weise.

Ein Löschen wartet ein paar Sekunden mit Undo in seiner Meldung, bevor es gesendet wird; die Datensätze verschwinden sofort aus jeder Ansicht, und Undo bringt sie unverändert zurück. Wer die Tabelle nur ansehen darf, sieht dasselbe Raster schreibgeschützt, und ein Klick auf eine Zeile öffnet den Datensatz.

Für ein Mitglied, das die Tabelle bearbeiten darf, öffnet der Kopf einer Spalte ein Menü. Sort ascending und Sort descending sortieren das Raster nach ihr, und Hide in this view nimmt sie vom Bildschirm, bis die Schaltfläche für ausgeblendete Spalten sie wieder zeigt; Save view speichert beides. Rename und Archive column ändern die Tabelle für alle, jeweils als dieselbe neue Schemaversion, die der Dialog Columns schreiben würde, und eine Archivierung von etwas, das noch verwendet wird, wird abgelehnt, mit den Workflows, Ansichten und Triggern im Dialog. Das + nach der letzten Spalte fügt eine hinzu, anfangs optional. Der Typ einer Spalte ändert sich in Columns, in die Typen, in die sich ihre Werte umwandeln lassen.

In Columns macht Required eine Spalte zur Pflicht, und eine archivierte Spalte wandert unter Archived columns, wo Restore sie samt ihren Werten zurückbringt.

Import und Export

Exporte lesen höchstens 4 MiB des entpackten Datensatz-JSON pro Seite (oder einen Datensatz), mit maximal 500 Datensätzen pro Seite. Eine Organisation kann einen Export gleichzeitig übertragen und zehn pro Minute starten. Weitere Anfragen erhalten 429 mit Retry-After; danach erneut versuchen. Die Startbegrenzung bleibt bei ausgefallenem Redis pro Prozess erhalten; die Datenbank erzwingt weiterhin einen gleichzeitigen Export pro Organisation. Ein Servercursor hält den Datensatz-Snapshot des Exports stabil und lädt höchstens vier Datensätze vorab. Er schließt nach Abschluss oder Abbruch; spätere Seiten durchlaufen frühere Datensätze nicht erneut.

Export, unter … neben Share, speichert, was die Seite zeigt, als CSV-Datei: die Datensätze, die Filter und Suche treffen, in der Reihenfolge des Rasters, unter seinen sichtbaren Spalten. POST /tables/{id}/records/export tut dasselbe für einen Aufrufer, mit denselben Filtern, Suche, Sortierung und einer Liste columns. Eine Option wird als ihre Bezeichnung geschrieben, mehrere als a; b, und eine Textzelle, die eine Tabellenkalkulation als Formel lesen würde, beginnt mit '. Mehr als 100.000 passende Datensätze werden mit EXPORT_TOO_LARGE (413) abgelehnt.

Import, im selben Menü, liest eine komma- oder semikolongetrennte CSV-Datei, deren erste Zeile die Spalten benennt. Jede Spalte der Datei wird der gleichnamigen Tabellenspalte oder External id zugeordnet und kann umgelenkt oder übersprungen werden. Ein Wert, der sich nicht als Typ seiner Spalte lesen lässt, lässt seine Zeile scheitern, bevor etwas gesendet wird. Der Rest geht zu je 200 an POST /tables/{id}/records/batch, das jeden Datensatz einzeln schreibt und die abgelehnten mit ihren Codes auflistet, und die Konsole listet jede gescheiterte Zeile mit ihrer Zeilennummer. Jeder hinzugefügte Datensatz startet die Trigger der Tabelle wie jeder andere.

Mit zugeordneter External id importiert Update records already here eine Datei erneut, um die Tabelle zu aktualisieren: Eine Zeile, deren externe ID die Tabelle schon hat, ändert die genannten Zellen dieses Datensatzes, statt als vergeben abgelehnt zu werden (update_existing im Batch), und der Import zählt, was er hinzugefügt und was er aktualisiert hat.

New table beginnt mit einer Spalte Name. Geben Sie zuerst den Tabellennamen und die Spalten an; Beschreibung und Sichtbarkeit stehen unter More options, standardmäßig privat. Stattdessen kann sie mit einer Datei beginnen: Start from a CSV file macht aus der Kopfzeile Spalten und gibt jeder den engsten Typ, als den sich alle ihre Werte lesen lassen - Ganzzahl, Zahl, Ja/Nein, Datum, Datum und Uhrzeit in ISO, eine E-Mail-Adresse oder ein Link - und sonst Text, oder langen Text, sobald ein Wert einen Zeilenumbruch hat oder länger als 1.000 Zeichen ist. Der Name kommt aus der Datei, und beides lässt sich vor dem Erstellen ändern, was weniger kostet als eine spätere Typänderung. Sobald die Tabelle existiert, öffnet sich der Import mit jeder Spalte schon zugeordnet, danach die Tabelle.

Sichere Wiederholungen

Jeder Schreibzugriff auf Datensätze akzeptiert einen Idempotency-Key-Header (höchstens 128 Zeichen). Eine Wiederholung mit demselben Schlüssel und demselben Body liefert die erste Antwort mit Idempotent-Replayed: true und schreibt nichts, selbst wenn sich der Datensatz inzwischen geändert hat. Derselbe Schlüssel mit einem anderen Body wird mit IDEMPOTENCY_KEY_REUSED abgelehnt.

Der Header kennzeichnet ein wiederholtes Create, Update oder Upsert. Ein wiederholtes Delete antwortet wie beim ersten Mal mit 204 und wird nicht gekennzeichnet.

Ein Schlüssel gehört dem Aufrufer und der Art des Schreibzugriffs, sodass zwei Aufrufer dieselbe Zeichenfolge verwenden können und ein Aufrufer sie für ein Create und ein Upsert nutzen kann. Nur Erfolge werden gespeichert: Ein abgelehnter Schreibzugriff hinterlässt keine Quittung; Sie korrigieren ihn also und wiederholen ihn mit demselben Schlüssel.

Eine Wiederholung wird nur einem Aufrufer beantwortet, der die Tabelle noch bearbeiten darf. Nach dem Entzug des Zugriffs ist dieselbe Wiederholung ein 404.

Ein Receipt hält 24 Stunden. Danach ist der Schlüssel vergessen, und derselbe Schlüssel mit demselben Body ist ein neuer Schreibzugriff: Er wird erneut ausgeführt, statt die erste Antwort zurückzugeben. Wiederholen Sie innerhalb des Fensters und behandeln Sie eine längere Pause als neue Anfrage. Die Lebensdauer wird beim Verwenden des Schlüssels durchgesetzt und gilt daher auf die Stunde genau; der tägliche Sweep gibt nur den Platz von Receipts frei, die niemand wiederholt hat.

Auflisten und Filtern

GET /tables/{id}/records blättert durch eine Tabelle; POST /tables/{id}/records/query fügt typisierte Filter hinzu, die alle zutreffen müssen. Beide sind begrenzt: limit liegt zwischen 1 und 100, skip bei höchstens 10.000, und eine Abfrage hat höchstens 20 Filter.

search ist Text, den ein Datensatz enthalten muss, ohne Beachtung der Groß- und Kleinschreibung, in einer aktiven Text- oder Langtextspalte oder in der Bezeichnung einer Auswahloption, die er hält. Die Suche wird mit den Filtern kombiniert, und eine leere Suche sucht nichts. Eine gespeicherte Ansicht bewahrt sie neben ihren Filtern auf. In der Konsole sendet das Suchfeld sie, und Filter schreibt die Bedingungen: eine Spalte, einen Operator, den ihr Typ unterstützt, und einen Wert. Jede vollständige Bedingung grenzt die Datensätze sofort ein, und Save view speichert Bedingungen, Suche und Sortierung in der angezeigten Ansicht.

Die Reihenfolge ist total. Auf die gewünschte Sortierung (created_at, updated_at oder eine sortierbare Spalte) folgt die Datensatz-id, sodass eine Seite in einer unveränderten Tabelle nie einen Datensatz wiederholt oder überspringt. Datensätze ohne Wert in der sortierten Spalte kommen in beiden Richtungen zuletzt. Eine multi_select-Spalte lässt sich nicht sortieren. updated_at wird beim Erstellen eines Datensatzes gesetzt und rückt mit jeder Bearbeitung vor, sodass Datensätze, die niemand bearbeitet hat, nach ihrer Erstellungszeit sortieren.

Eine Auflistung hat kein total, weil das Zählen einer gefilterten Tabelle nicht billig ist: has_more sagt, ob eine weitere Seite folgt. POST /tables/{id}/records/count beantwortet diese Frage getrennt, für die Filter und die Suche einer Abfrage, und zählt höchstens bis 100.000; capped sagt, dass mehr passen. Die Konsole zeigt diese Zahl neben den Ansichts-Tabs, und ihr Raster lädt beim Scrollen jeweils hundert Datensätze und zeichnet nur die sichtbaren Zeilen. Jenseits der 10.000 Datensätze, die eine Abfrage überspringen darf, bittet es um einen Filter oder eine Suche.

Gespeicherte Ansichten

Sortieren neben Filter wählt eine sortierbare Spalte und aufsteigende oder absteigende Reihenfolge für Tabelle, Liste oder Karten innerhalb jeder Kanban-Spalte. Ausgeblendete sortierbare Spalten und Systemdaten bleiben verfügbar. Die Schaltfläche zeigt Spalte und Richtung. Der Server sortiert vor der Seiteneinteilung, auch noch nicht geladene Datensätze. Ansicht speichern behält die Reihenfolge zusammen mit Filtern und Darstellung; + erstellt eine neue Ansicht. Standardreihenfolge: älteste zuerst stellt die Sortierung nach Erstellungsdatum wieder her.

Ansichtseinstellungen steuert Reihenfolge und Sichtbarkeit aller Spalten einschließlich Systemfelder. Ziehe Spalten oder nutze die Pfeile; ausgeblendete Systemfelder behalten ihre automatischen Werte. Text umbrechen zeigt vollständigen Text mit Zeilenumbrüchen, ausgeschaltet bleiben Zeilen kompakt. Wähle automatische, ISO-, Tag-zuerst-, Monat-zuerst- oder lange Datumsangaben und automatische, 24-Stunden-, 12-Stunden- oder ausgeblendete Uhrzeiten. Reine Datumswerte behalten ihren Tag; Zeitstempel verwenden die Zeitzone der betrachtenden Person. Ansicht speichern speichert die Auswahl für diese Ansicht; + erstellt eine neue. Formatierung ändert weder Werte noch Filter.

Ein Kanban-Board ohne gespeicherte Gruppierung nutzt die einzige aktive Einfachauswahl-Spalte automatisch. Bei mehreren wählen Sie die Spalte für die Bahnen. Eine gespeicherte Ansicht behält diese Gruppierung. Listenzeilen benennen zusätzliche Felder und zeigen Unnamed record bei leerem Titel. Triggers bietet Open workflows, wenn noch kein Workflow von der Tabelle startet.

Eine Ansicht ist ein gespeicherter Filter, eine Sortierung und eine Gruppierung über die Datensätze einer Tabelle - was die Tabellen-/Kanban-/Listenansichten der Konsole speichern, damit niemand bei jedem Besuch dasselbe Board neu aufbaut. Sie ist eine Unterressource der Tabelle, keine eigene teilbare Ressource: Eine Ansicht hat keinen eigenen Besitzer und keine eigenen Grants ihrer Art, und shared bedeutet nur "sichtbar für jeden, der bereits tables:view auf der übergeordneten Tabelle hält" - sie erweitert den Zugriff nie über das hinaus, was die Tabelle selbst erlaubt.

In der Konsole heißt der Bildschirm ohne gewählte Ansicht All records, und + daneben speichert, was er zeigt, als neue Ansicht. Ein Kanban-Board ohne gespeicherte Gruppierung nutzt die einzige aktive Einfachauswahl-Spalte automatisch. Bei mehreren wählen Sie die Spalte für die Bahnen. Eine gespeicherte Ansicht behält diese Gruppierung. Eine Karte zeigt unter ihrem Titel bis zu vier Spalten des Bildschirms und lässt leere weg. Eine Tabelle ohne Einfachauswahl-Spalte bietet stattdessen Add a column an.

Der Eigentümer einer Ansicht schaltet sie mit der Schaltfläche neben Rename zwischen Private und Shared um. Wer die Ansicht eines anderen Mitglieds verwaltet, etwa ein Admin, bekommt diesen Schalter nicht: Ob jemandes eigene Filter geteilt werden, entscheidet diese Person selbst.

GET/POST /tables/{id}/views und GET/PATCH/DELETE /tables/{id}/views/{view_id} listen, erstellen, lesen, aktualisieren und löschen sie. Die Liste ist mit skip und limit (höchstens 100) seitenweise: zuerst die eigenen Ansichten des Aufrufers, dann die geteilten, jeweils nach Name; total zählt alle. config ist {filters, search, sort, visible_columns, group_by, display} - eine RecordQuery plus Darstellungsfelder, die nur die Darstellung der Konsole braucht: visible_columns (null bedeutet jede lebende Spalte) und group_by (eine lebende single_select-Spalte, für die Spalten eines Kanban-Boards).

Das Objekt display enthält column_order, hidden_system_columns, wrap_text, date_format und time_format. Ältere Ansichten behalten kompakte Zeilen und sprachabhängige Formate. Ein ausdrückliches visible_columns: [] blendet alle benutzerdefinierten Spalten aus.

Feld Bedeutung
kind table, kanban oder list - eine Ansicht wird für eine Art gespeichert
visibility private (nur ihr Besitzer) oder shared (jeder, der die Tabelle sehen kann)
can_manage Ob dieser Aufrufer sie umbenennen, umkonfigurieren oder teilen darf
can_delete Ob dieser Aufrufer sie löschen darf

Auflisten, Lesen und Löschen lösen sich gegen die Tabelle auf (tables:view); eine Ansicht zu erstellen oder zu ändern braucht tables:edit auf der Tabelle, sodass ein Besitzer, dem das Bearbeitungsrecht entzogen wurde, seine Ansichten noch löschen, aber nicht mehr umbauen oder teilen kann.

Eine Ansicht zu ändern oder zu löschen ist enger: nur ihr Besitzer, oder ein Aufrufer, dessen Scope für tables:edit ALL ist - nicht "jeder, der die Tabelle bearbeiten darf" - sodass ein geteilter Bearbeiter nicht stillschweigend den gespeicherten Filter eines anderen Mitglieds umbiegen kann. Die Ablehnung funktioniert wie bei jedem anderen Schreibzugriff auf eine einzelne Ressource hier: NOT_FOUND (404), nie ein 403, der einem abgelehnten Aufrufer verraten würde, dass die Ansicht existiert. can_manage und can_delete sagen, welches von beidem dieser Aufrufer darf.

Das Archivieren einer Spalte wird mit SCHEMA_DEPENDENCY abgelehnt und nennt die Ansicht, wenn eine Ansicht, die der Aufrufer sowohl sehen als auch ändern kann, sie noch zum Filtern, Sortieren oder Gruppieren nutzt: eine seiner eigenen oder - für einen Aufrufer, dessen Scope für tables:edit ALL ist - eine geteilte. Jede andere Ansicht blockiert das Archivieren nicht und wird nicht genannt, weil der Aufrufer sie nicht aus dem Weg räumen könnte; das gilt für jede private Ansicht eines anderen Mitglieds, die selbst einem Aufrufer mit Scope ALL nicht offengelegt wird. Eine Spalte nur in visible_columns anzuzeigen blockiert ebenfalls nicht.

Was eine Ansicht noch von einer Spalte nennt, die nicht mehr lebt, wird beim Lesen der Ansicht weggelassen: ein Filter darauf entfällt, eine Sortierung danach fällt auf created_at zurück, eine Gruppierung danach wird geleert, und sie verlässt visible_columns - eine Ansicht, die keine ihrer gewählten Spalten mehr zeigt, zeigt alle lebenden. Die gespeicherte Konfiguration wird nicht umgeschrieben.

Seiten und Ansichten

Ein veröffentlichtes Artefakt liest nicht über eine Ansicht. Seine Bindings sind Seitenabfragen: eine Tabelle, Filter, eine Sortierung, ein Limit und die Felder, die die Seite liest, die die Plattform beim Veröffentlichen auf der Version der Seite speichert. Ein Binding kann von einer gespeicherten Ansicht ausgehen - ein Agent findet eine mit list_views, das für die Tabellen, die er lesen darf, die ID jeder Ansicht, ihre Spalten und ob sie geteilt ist liefert -, aber die Filter und die Sortierung der Ansicht werden dann in die Seite kopiert.

Die Ansicht danach zu bearbeiten, zu teilen, umzubenennen oder zu löschen, ändert nichts an der Seite, und visible_columns und group_by einer Ansicht werden ignoriert: Die fields des Bindings bestimmen die Spalten.

Eine Ansicht mit Freitextsuche lässt sich nicht binden, weil eine Suche jede aktive Textspalte trifft und mit jeder hinzugefügten Spalte breiter würde; das Veröffentlichen wird abgelehnt und nennt die Ansicht. Ebenso eine Ansicht, deren Filter eine nicht mehr vorhandene Spalte nennt.

Wer die Datensätze der Seite lesen darf, entscheidet nicht die Ansicht. Sie werden mit dem Zugriff des Besitzers der Seite gelesen, sobald der Besitzer eingewilligt hat, und der Zugriff auf eine Tabelle gilt ganz oder gar nicht: read gibt jede Zeile und jede aktive Spalte. Eine Seitenabfrage ist der Weg, auf dem die Zeilen einer Tabelle Personen erreichen, die sie nicht öffnen könnten, und der Besitzer wird zuvor gefragt. Siehe Artefakte.

Eine Seitenabfrage hält die revision jeder Spalte fest, die sie erreicht: ihre Felder, ihre Filter- und Sortierspalten, die Eingaben einer Formel, den Link und das Ziel eines Lookups oder Rollups und die Spalte, nach der jede verknüpfte Tabelle ihre Datensätze beschriftet. Ein Binding wird unavailable (source_changed), wenn eine davon sich weiterentwickelt hat oder archiviert wurde oder wenn eine Tabelle, die es erreicht, archiviert wurde; die anderen Bindings arbeiten weiter. Nichts hindert dafür eine Tabellenänderung: Eine Seite ist keine Abhängigkeit, die ein Archivieren blockiert. share_view gibt es nicht mehr; es gibt keine private Ansicht, die für eine Seite zu teilen wäre.

Trigger

Kanban-Spalten laden jeweils 25 Karten. Weitere laden behält vorhandene Karten; nach einem Seitenfehler ist ein erneuter Versuch möglich. Eine geänderte Gruppierung kann in der aktiven Ansicht gespeichert werden. Werden Spaltentypen durch ein anderes Mitglied geändert, entfernt das Lesen einer privaten Ansicht inkompatible Filter, Sortierung und Gruppierung aus der Antwort, ohne die gespeicherte Ansicht ihres Eigentümers zu überschreiben. Eine Seitenabfrage, die einen Filter verliert, erfordert erneute Zustimmung.

Tabellenereignisse verwenden beim Einfügen nach der Änderungssperre die Echtzeituhr, passend zur Trigger-Aktivierung. Eine vor der Aktivierung begonnene Transaktion kann daher ein späteres berechtigtes Ereignis erzeugen. Die Migration 0229_table_event_wall_clock.py ändert künftige Zeitstempel; sie spielt zuvor übersprungene Ereignisse nicht erneut ab, um doppelte Auswirkungen zu vermeiden.

Ein Workflow, dessen Trigger-Knoten Table record ist, läuft nach der Veröffentlichung für das, was mit den Datensätzen seiner Tabelle geschieht - hinzufügen, ändern oder löschen, je nachdem, was events nennt; standardmäßig nur hinzufügen -, egal auf welchem Weg: in der Konsole, über die API, durch das Tabellen-Tool eines Agents oder den Tabellenschritt eines anderen Workflows. Ein Upsert, der einen Datensatz anlegt, ist ein Hinzufügen, einer, der ihn aktualisiert, eine Änderung. Das Veröffentlichen braucht Lesezugriff auf die Tabelle und das Recht, den Workflow auszuführen, denn er läuft als das Mitglied, das ihn veröffentlicht hat. Dessen Zugriff wird bei jedem Ereignis erneut geprüft.

Der Trigger führt die Version aus, die ihn eingeschaltet hat, und die nächste Veröffentlichung verschiebt ihn auf die neue Version. Seine Filter nutzen die Operatoren aus Auflisten und Filtern und werden am Datensatz geprüft, wie er hinzugefügt oder geändert wurde, oder wie er vor dem Löschen war. Eine Änderung startet ihn nur, wenn sich eine der für die Tabelle gewählten Spalten geändert hat, sofern Spalten gewählt sind.

Er gibt dem Run den Datensatz: record_id, event, values nach Spalten-ID, dieselben Werte als fields nach Beschriftung und author_id, bei einer Änderung oder Löschung auch before und die changed Spalten nach Beschriftung. Triggers auf der Seite der Tabelle listet die Workflows, pausiert und setzt jeden fort und öffnet seinen Verlauf.

Ein Trigger startet nur für Datensätze, die hinzukommen, während er eingeschaltet ist. Das Einschalten - eine Veröffentlichung oder das Fortsetzen - nimmt die Schema-Sperre der Tabelle, auf die jeder Schreibzugriff wartet, sodass kein vorher committeter Datensatz ihn je startet und nichts nachgeholt wird, was hinzukam, während er aus war. Der Job workflow-table-triggers, der alle zehn Sekunden läuft, liest das Outbox-Ereignis jedes neuen Datensatzes, sodass ein Trigger nach etwa zehn Sekunden startet. Er entscheidet einmal pro Trigger und hält die Entscheidung fest; ein zweiter Durchlauf oder ein zweiter Worker findet sie und startet nichts.

Verlauf listet jede Entscheidung, die neueste zuerst, ohne die Werte des Datensatzes:

Angezeigt als Warum
Lauf gestartet Jeder Filter galt; der Run ist verlinkt
Übersprungen Der Datensatz passte nicht oder kam hinzu, bevor der Trigger eingeschaltet war
Blockiert Er hätte sich erneut gestartet, die Kette ging mehr als fünf Trigger tief oder über 50 Runs, oder die Zulassungsquote lehnte den Run ab
Konnte nicht starten Das Mitglied, als das er läuft, darf die Tabelle nicht mehr lesen oder den Workflow nicht mehr ausführen, oder das Anlegen des Datensatzes war nicht lesbar

Ein Workflow, der in eine Tabelle schreibt, kann deren Trigger starten, und so weiter über weitere Tabellen. Jeder Run trägt die Kette, zu der er gehört, und ein Trigger, den die Kette schon durchlaufen hat, wird blockiert statt erneut gestartet - so laufen zwei Workflows, die Datensätze in die Tabellen des jeweils anderen schreiben, nicht im Kreis. Eine Spalte, auf die ein Trigger filtert, kann nicht archiviert werden, bis der Trigger seines Workflows nicht mehr auf sie filtert und veröffentlicht ist, auch wenn er pausiert ist, und eine Tabelle, mit der ein veröffentlichter Workflow startet, kann nicht archiviert werden, bis dieser Workflow anders startet.

Was gemeinsam committet

Ein Schreibzugriff auf einen Datensatz, seine Historienzeile, seine Idempotenz-Quittung und bei einem Create eine Outbox-Zeile table.record.created werden in einer Transaktion geschrieben und gemeinsam committet oder zurückgerollt. Ein Fehler in irgendeinem Schritt hinterlässt keines davon. Änderungen an Tabelle und Schema werden im Audit-Log festgehalten; Änderungen an Datensätzen in der Historie pro Datensatz, die die von jeder Änderung berührten Zellen aufbewahrt.

Zwei dieser Speicher halten Kopien dessen, was geschrieben wurde. Ein Receipt enthält den ganzen Datensatz, wie ihn der Schreibzugriff zurückgab, und die Historie enthält, was sich geändert hat. Ein Löschen des Datensatzes entfernt also die aktuelle Zeile und lässt beide zurück, bis ihre Aufbewahrung sie entfernt. Outbox-Zeilen enthalten ids. Behandeln Sie alle drei als personenbezogene Daten, wenn es die Zellen sind; siehe Datenschutz und Limits und Aufbewahrung.

Die Outbox-Zeile ist die Übergabe an alles, was auf einen neuen Datensatz reagiert: heute die Trigger. Ihr Heartbeat holt sich nicht zugestellte Zeilen in einer eigenen Session, prüft jede gegen die Trigger der Tabelle und markiert sie in derselben Transaktion als versendet. Eine nicht zugestellte Zeile wird nur durch ihr eigenes, viel längeres Aufbewahrungsfenster entfernt (unten) - eine Dead-Letter-Frist für einen Worker, der so lange ausgefallen ist, keine Behauptung, das Ereignis sei je abgeholt worden.

Limits und Aufbewahrung

Ein Tenant kann die gemeinsame Datenbank nur so weit wachsen lassen, wie das Deployment es zulässt. Jedes Limit ist eine Einstellung des Deployments, gilt je Organisation, sodass die Nutzung eines Tenants nie auf einen anderen angerechnet wird, und wird mit QUOTA_EXCEEDED (402) abgelehnt, wenn ein Schreibzugriff es überschreiten würde.

Einstellung Standard Begrenzt
TABLES_MAX_PER_ORGANIZATION 200 Tabellen einer Organisation. Archivierte zählen mit, weil eine Tabelle nie gelöscht wird
TABLES_MAX_RECORDS_PER_TABLE 100.000 Datensätze in einer Tabelle. Ein Update eines Datensatzes in einer vollen Tabelle ist erlaubt
TABLES_MAX_RECORD_BYTES 1.000.000 Die serialisierten Werte eines Datensatzes in Bytes
TABLES_MAX_STORAGE_BYTES_PER_ORGANIZATION 1,073,741,824 Gespeicherte PostgreSQL-JSONB-Nutzdaten je Organisation über Datensätze, Historie, Quittungen und Outbox; Tabellen- und Index-Overhead zählt separat

Die Gesamtgrenze wird im Savepoint der Änderung unter einer Organisationssperre vor dem Commit geprüft. Eine Überschreitung rollt den Datensatz und alle Kopien zurück, auch eine Importzeile; übrige Zeilen werden als abgelehnt gemeldet. Quittungswiederholungen, unveränderte Schreibvorgänge, Lesen und Löschen bleiben nach einer Absenkung möglich. Die Aufbewahrungsbereinigung gibt entfernte Bytes frei.

Die Ablehnung nennt das Limit und seine Obergrenze in details ({"quota": "records", "limit": 100000}), nie den Inhalt, und schreibt einen Eintrag table.quota_refused in das Audit-Log mit denselben zwei Feldern. Die abgelehnte Anfrage schreibt nichts. Schreibzugriffe sind außerdem auf RATE_LIMIT_TABLE_WRITES_PER_MINUTE (300) je Mitglied und Organisation begrenzt, in der Konsole ebenso wie über die API; ein Mitglied darüber erhält ein 429 mit Retry-After. Siehe Konfiguration.

Was die Historie aufbewahrt. Ein Create hält den ganzen Datensatz in after, ein Delete den ganzen Datensatz in before; das Datensatzlimit begrenzt beides. Ein Datensatz, der älter als das Limit ist oder geschrieben wurde, bevor TABLES_MAX_RECORD_BYTES gesenkt wurde, kann immer noch darüber liegen - sein Delete hält before dann als {"omitted": {"bytes": <seine Größe>, "limit": <das Limit>}} statt der Werte, und gelingt trotzdem. Ein Update hält nur die Zellen, die sich geändert haben: before enthält ihre früheren Werte und after die neuen, und eine Spalte, die auf einer Seite fehlt, war dort leer. Das Bearbeiten einer Zelle eines großen Datensatzes kostet daher eine Zelle, wie oft es auch wiederholt wird.

Aufbewahrung. Der tägliche Aufbewahrungs-Sweep entfernt auch Tabellendaten, hart und in Batches, für jede Organisation:

Was Entfernt, wenn Einstellung
Receipts Älter als 24 Stunden TABLES_RECEIPT_TTL_HOURS
Outbox-Zeilen Vor mehr als 3 Tagen zugestellt TABLES_OUTBOX_RETENTION_DAYS
Nicht zugestellte Outbox-Zeilen Nie zugestellt und 30 Tage alt: Der Trigger-Heartbeat lief so lange nicht, und für diese Datensätze startet kein Trigger mehr TABLES_OUTBOX_UNDISPATCHED_RETENTION_DAYS
Historie Älter als 365 Tage, für einen gelöschten Datensatz ebenso wie für einen lebenden TABLES_HISTORY_RETENTION_DAYS

Der Sweep schreibt einen Audit-Eintrag je Organisation, der die Klasse (table_receipts, table_outbox, table_history) und die Anzahl nennt. Es sind Einstellungen des Deployments, keine je Organisation. Die Datensätze selbst und die Tabellen entfernt er nie.

RATE_LIMIT_TABLE_WRITES_PER_MINUTE ist ein Kontingent je Mitglied, daher skaliert das Budget eines Durchlaufs für jede der drei Klassen sowohl damit als auch mit der Anzahl der aktiven Mitglieder der Organisation, mit Spielraum, damit ein bestehender Rückstand schrumpft statt nur gehalten zu werden - jedes Mitglied, das gleichzeitig ununterbrochen schreibt, läuft dem Sweep nie davon, bis zu einer großzügigen Grenze, für wie viele Mitglieder sich der Durchlauf einer Organisation bemisst. Die Zahlen stehen in der Konfiguration. Ein Rückstand darüber hinaus wird wie bei jeder anderen Klasse über mehrere Durchläufe abgearbeitet.

Wer was darf

Permission Wer sie hält
tables:view Owner, Admin, Builder und Operator sehen alle Tabellen; ein Member oder Viewer sieht die eigenen, die organisationsweit sichtbaren und die geteilten
tables:edit Owner und Admin bearbeiten alle; ein Builder bearbeitet die eigenen und geteilten; ein Member die eigenen
tables:create Owner, Admin, Builder, Member

tables:view und tables:edit sind Ressourcen-Permissions, daher erweitert ein Grant auf eine Tabelle eine Rolle nur für diese Tabelle: Ein Viewer mit edit auf einer Tabelle bearbeitet diese Tabelle und sonst nichts. Das Teilen nutzt dieselben /tables/{id}/sharing-Routen wie die anderen geteilten Ressourcen. Datensätze erben den Zugriff ihrer Tabelle, und das Schema erzwingt es: Eine Zeile in Records, History oder Outbox verweist auch über die Organisation auf ihre Tabelle und kann keine Tabelle eines anderen Tenants nennen.

Die Tabelle einer anderen Organisation und eine, die der Aufrufer nicht erreichen darf, sind beide ein 404. Ein Kontext ohne angemeldetes Subjekt erreicht nichts.

Ob ein bestimmter Aufrufer eine bestimmte Tabelle bearbeiten darf, steht auch direkt auf dem Wire: TableSummary.can_edit und TableRead.can_edit werden serverseitig aufgelöst (Rollen-Scope oder ein expliziter Grant) und bei jedem Lesen mitgeschickt, genau wie Agent.can_run - sodass eine Katalogzeile oder eine Detailseite nie raten muss, ob ihre Bearbeitungskontrollen abgelehnt würden. Das eigene can_manage und can_delete einer gespeicherten Ansicht sind dieselbe Idee, eine Ebene tiefer (siehe Gespeicherte Ansichten).

Fehler

Jede Ablehnung antwortet mit {"error": {"code", "message", "details"}}, und der code ist das, wonach ein Client verzweigt.

Code Status Bedeutung
REVISION_CONFLICT 409 Der Datensatz hat sich seit dem Lesen geändert
REVISION_REQUIRED 428 Ein Upsert eines bestehenden Datensatzes braucht expected_revision
SCHEMA_VERSION_CONFLICT 409 Das Schema hat sich seit dem Lesen geändert
SCHEMA_DEPENDENCY 409 Etwas hängt von dem ab, was die Änderung entfernt
TABLE_ARCHIVED 409 Die Tabelle lehnt Schreibzugriffe ab
BAD_REQUEST 400 Eine Wiederherstellung nannte eine Tabelle, die nicht archiviert ist
ALREADY_EXISTS 409 Der Tabellenname, ein Ansichtsname oder die external id ist vergeben
INVALID_RECORD 422 Ein Wert passt nicht zu seiner Spalte; details.fields nennt jeden
ARCHIVED_COLUMN 422 Ein Wert nennt eine archivierte Spalte
INVALID_QUERY 422 Ein Filter oder eine Sortierung, die die Tabelle nicht beantworten kann
INVALID_SCHEMA 422 Eine widersprüchliche Schemaänderung
IDEMPOTENCY_KEY_REUSED 422 Der Schlüssel wurde für eine andere Anfrage verwendet
QUOTA_EXCEEDED 402 Der Schreibzugriff würde ein Speicherlimit überschreiten; details nennt das Limit (tables, records, record_bytes, storage_bytes) und seine Obergrenze
RATE_LIMIT_EXCEEDED 429 Zu viele Tabellenschreibzugriffe in der letzten Minute; siehe Retry-After
VALIDATION_ERROR 422 Die Anfrage selbst ist fehlerhaft: ein falscher Typ, ein unbekanntes Feld, eine Grenze oder NUL, ein Zeilenumbruch oder ein einzelnes Surrogat in einer id, einem Schlüssel oder Namen. Die Route lehnt sie ab, bevor der Service läuft
AUTHORIZATION_ERROR 403 Dem Aufrufer fehlt die Permission, die eine Collection-Route verlangt (tables:view, tables:create)
CONCURRENT_CHANGE 409 Ein Upsert hat ein Rennen mit dem Löschen desselben Datensatzes verloren. Wiederholen Sie ihn
NOT_FOUND 404 Keine solche Tabelle oder kein solcher Datensatz, oder nicht erreichbar für den Aufrufer

Den Service aus Python aufrufen

service = VirtualTableService(db)
table = await service.create_table(
    ctx,
    TableCreate(
        name="Orders",
        columns=[
            ColumnInput(label="Customer", type="text"),
        ],
    ),
)
customer = str(table.columns[0].id)

written = await service.upsert_record(
    ctx,
    table.id,
    "ORD-1042",
    RecordUpsert(values={customer: "Acme"}),
    operation_key="import-2026-09-21-row-17",
)

# Send the revision back to change it; a stale one raises RevisionConflictError.
await service.upsert_record(
    ctx,
    table.id,
    "ORD-1042",
    RecordUpsert(values={customer: "Acme Ltd"}, expected_revision=written.record.revision),
)

Die Organisation kommt immer aus ctx, nie aus einem Argument. Der Service committet nie: Das tut die Session der Anfrage, und ein Worker besitzt seinen eigenen Session-Scope.

Einen Spaltentyp hinzufügen

Ein Spaltentyp ist ein Eintrag in COLUMN_TYPES in backend/app/services/virtual_tables/types.py, mit seinem Namen in ColumnTypeName in backend/app/schemas/virtual_table.py. Der Eintrag legt fest, wie die Zelle zum Vergleichen und Sortieren aus dem Datensatz gelesen wird (ein SqlKind), welche Filteroperatoren sie nimmt, ob sie sortierbar ist, und welchen Validator jeder Schreibzugriff und jeder Filteroperand durchläuft. Der Validator gibt den Wert so zurück, wie er gespeichert wird, oder wirft CellProblem mit einer Meldung für den, der ihn geschrieben hat. Jede Oberfläche ruft denselben Service, also nehmen Konsole, API, Agents und Workflows den neuen Typ sofort an, und ein Trigger filtert mit denselben Regeln darauf.

Die Konsole braucht den Typ in ColumnTypeName in frontend/src/types/tables.ts, einen Editor in record-cell-editor.tsx, eine Auswahl in den Dialogen für Schema und neue Tabelle und seine Beschriftung in den drei Meldungskatalogen. Tests gehören für den Validator in backend/tests/test_virtual_table_types.py und in einen Integrationstest, der einen Datensatz des neuen Typs schreibt, filtert und sortiert.

Beim Erstellen einer leeren Tabelle benötigen Pflichtspalten keine Vorgaben: Jeder neue Datensatz muss ihre Werte liefern. Neue Pflichtspalten in einem bestehenden Schema benötigen weiterhin einen Standardwert zum Schutz vorhandener Datensätze.