Datenbankschema des Adapters

Zweck

Der Adapter hält eine eigene PostgreSQL‑Datenbank mit sechs Tabellen. Sie enthält keine Messwerte — die stehen ausschließlich im FROST‑Server jedes Mandanten. Was hier liegt, ist die Mandanten‑Registry und die Stammdaten, die eine ThingsBoard‑Telemetrie in einen SensorThings‑Begriff übersetzen.

Diese Seite beschreibt die Tabellen und ihre Beziehungen. Welche Regeln beim Schreiben und Löschen gelten, steht in Geschäftsregeln; wie diese Zeilen auf SensorThings‑Entitäten abgebildet werden, in Datenmodell. Alle Modelle stehen in model/sql/models.py; das Schema wird beim Start von bootstrap() angelegt, es gibt keine Migrationswerkzeuge.

Überblick

                       tenant
                         │ 1
                         │            (tenant_id, observed_property_id) = PK
                         │            UNIQUE (tenant_id, frost_id)
                         ▼ n
              tenantobservedproperty ──────┐
                                           │ n
                                           ▼ 1
   unitofmeasurementkey            observedproperty
             │ n                           ▲ 1
             │                             │
             ▼ 1                           │ n
     unitofmeasurement ◄──── n ───── telemetrykey
                            default_uom_id  │
                                            └── observed_property_id (RESTRICT)

Zwei Gruppen: tenant und tenantobservedproperty bilden die Registry, die übrigen vier die mandantenübergreifenden Stammdaten. Die einzige Verbindung zwischen beiden Gruppen ist tenantobservedproperty — die Tabelle, die festhält, welche FROST‑Entität in welchem Mandanten die Kopie einer ObservedProperty ist.

Die Registry

tenant

Ein Mandant ist die Zusammenfassung eines ThingsBoard‑Mandanten mit genau einem FROST‑Server.

Spalte Constraint Bedeutung

id

PK

Fortlaufende Ganzzahl.

slug

UNIQUE

Der Bezeichner in der Ingest‑URL. Unveränderlich — er steht in der Rule‑Chain‑URL eines fremden Systems, weshalb TenantUpdate das Feld nicht kennt und mit extra="forbid" einen Änderungsversuch zurückweist statt ihn still zu verwerfen.

name

Anzeigename. Darf von slug abweichen und tut das nach einer Umbenennung in ThingsBoard auch.

frost_server_url

UNIQUE

Die Service‑Root des FROST‑Servers. Die Eindeutigkeit ist es, die die strikte 1:1‑Topologie erzwingt — zwei Mandanten können nicht in denselben Server schreiben.

keycloak_client_id

Der Keycloak‑Client dieses FROST‑Servers. Der Benutzer dahinter ist deployment‑weit, nur der Client ist mandantenspezifisch.

thingsboard_api_key

nullable

Nur bei THINGSBOARD_EDITION=pe belegt. Fernet‑Chiffrat, niemals Klartext.

thingsboard_username

nullable

Nur bei THINGSBOARD_EDITION=ce belegt. Kein Geheimnis — der ThingsBoard‑Benutzer, als der die Geräte dieses Mandanten gelesen werden, und deshalb das einzige Zugangsdatenfeld, das TenantPublic mitliefert.

thingsboard_password

nullable

Nur bei THINGSBOARD_EDITION=ce belegt. Fernet‑Chiffrat, niemals Klartext.

Zwei der drei Zugangsdatenspalten sind immer NULL, und welche zwei entscheidet nicht die Zeile, sondern die Konfiguration: Es gibt genau eine THINGSBOARD_URL, also genau ein Deployment, also genau eine Edition. Alle Zeilen eines Deployments haben damit zu jedem Zeitpunkt dieselbe Form. Eine Aufteilung in zwei Tabellen oder zwei Modelle würde eine Variation abbilden, die nicht vorkommen kann — und dafür eine Diskriminatorspalte, zwei CRUD‑Pfade und Polymorphie auf einer Tabelle kosten, in die tenantobservedproperty einen Fremdschlüssel hält. Erzwungen wird die Form in services/tenant_service.py, nicht im Schema.

Die Geheimnisse sind über die API nur schreibbar: TenantPublic erbt sie nicht und trägt stattdessen has_thingsboard_credentials.

Drei abgeleitete Eigenschaften stehen am Modell und nicht in der Tabelle:

decrypted_api_key / decrypted_password

entschlüsselt mit TENANT_SECRET_KEY. Schlägt fehl, sobald der Schlüssel gewechselt hat — pro Mandant, beim Bau seiner Clients. Ist die Spalte leer, nennt der Fehler die fehlende Spalte, statt in der Chiffre aufzulaufen.

has_thingsboard_credentials

ob die Zeile sich überhaupt anmelden kann. Ein Benutzername ohne Passwort ist keine halbe Anmeldung, sondern keine.

fingerprint

ein SHA‑256 über genau die Felder, aus denen die Clients eines Mandanten gebaut werden — alle drei Zugangsdatenspalten inbegriffen, nicht nur die der konfigurierten Edition. Der Client‑Cache ist danach geschlüsselt, damit ein PATCH in allen Workern greift und nicht nur in dem, der ihn bedient hat; ein rotiertes Passwort, das den Fingerabdruck nicht bewegt, ließe jeden Worker unbegrenzt mit dem alten anmelden.

Ein leeres Geheimnis ist unrepräsentierbar: TenantCreate und TenantUpdate verlangen min_length=1, und in TenantUpdate bedeutet ein fehlendes Feld „Chiffrat behalten“, während der Leerstring zurückgewiesen wird. Es gibt keinen Weg, Zugangsdaten zu entfernen — ein Mandant ohne sie wäre unbrauchbar.

tenantobservedproperty

Die Zuordnungstabelle. Sie beantwortet: Welche FROST‑Entität in welchem Mandanten ist diese kanonische Observed Property?

Spalte Constraint Bedeutung

tenant_id

PK, FK → tenant.id, ON DELETE CASCADE

Der Mandant. Das Löschen einer Mandantenzeile räumt seine Zuordnungen mit ab.

observed_property_id

PK, FK → observedproperty.id, ON DELETE CASCADE

Die kanonische Zeile.

frost_id

UNIQUE (tenant_id, frost_id)

Die ID der Kopie im FROST dieses Mandanten. Die Eindeutigkeit verhindert, dass zwei kanonische Zeilen auf dieselbe FROST‑Entität zeigen.

Identität wird hier festgehalten, niemals aus dem Namen erschlossen. Eine fehlende Zeile bedeutet: die Übertragung ist nie angekommen — Reparatur ist Anlegen. Eine vorhandene Zeile bedeutet: die Kopie existiert und darf nie ein zweites Mal entstehen — Reparatur ist das Überschreiben der Attribute. Eine namensbasierte Auflösung kann diese beiden Fälle nicht unterscheiden und erzeugt im zweiten Fall ein Duplikat, das eine Messgröße auf zwei Entitäten aufteilt.

Die Stammdaten

observedproperty

Die kanonische Zeile: name (eindeutig), definition, description. Jeder Mandant hält in seinem FROST eine abgeleitete Kopie, adressiert über tenantobservedproperty. Lesende Zugriffe sind reine Postgres‑Selects und sprechen keinen FROST‑Server an.

Es gibt bewusst keine Beziehung observed_property.telemetry_keys. Eine solche Sammlung wäre ein Lazy Load, der auf jede abgelöste kanonische Zeile wartet, die die Schreibpfade zurückgeben.

telemetrykey

Die Übersetzung eines ThingsBoard‑Telemetrie‑Schlüssels in einen SensorThings‑Begriff.

Spalte Constraint Bedeutung

key

UNIQUE

Der Telemetrie‑Schlüssel aus ThingsBoard. Er ist zugleich der Name des zugehörigen Datastreams in FROST.

observed_property_id

NOT NULL, FK → observedproperty.id, ON DELETE RESTRICT

Die gemessene Größe.

default_uom_id

FK → unitofmeasurement.id, nullable

Die Standard‑Maßeinheit.

Die Spalte trägt die Id und nicht mehr den Namen der Observed Property. Damit ist eine Umbenennung ein Schreibvorgang auf eine Zeile, und es bleibt kein Übertragungsschritt übrig, der halb greifen könnte. RESTRICT macht „zeigt auf eine Observed Property, die es nicht mehr gibt“ unrepräsentierbar — der Wächter darüber ist das, was daraus eine Meldung mit den betroffenen Schlüsseln macht statt eines nackten IntegrityError.

unitofmeasurement und unitofmeasurementkey

unitofmeasurement trägt name (eindeutig), symbol und definition — genau die drei Felder, die in FROST als eingebettetes unitOfMeasurement‑Objekt eines Datastreams landen.

unitofmeasurementkey bildet ein Suffix eines Telemetrie‑Schlüssels auf eine Maßeinheit ab: key (eindeutig) und ein nullables uom_id. Auf diese Tabelle verweist nichts zurück, weshalb sie als einzige keine Löschsperre hat.

Eine Maßeinheit hat keine eigene Identität in FROST. Sie steht als eingebetteter Wert in jedem Datastream, der sie benutzt — nicht als verknüpfte Entität. Dasselbe gilt für einen Telemetry Key, der nur als Name eines Datastreams existiert. Genau das ist der Grund, warum es für diese beiden keinen Wiederholungslauf gibt, für ObservedProperty mit ihrer Zuordnungszeile aber schon.

Ladeverhalten

Ein Repository gibt ein vollständig geladenes Objekt zurück, weil die Zeile nach dem Session‑Block abgelöst ist. Drei Beziehungen sind deshalb auf lazy=False gesetzt:

TelemetryKey.default_uom
TelemetryKey.observed_property
UnitOfMeasurementKey.uom

Die beiden Sammlungen auf UnitOfMeasurement (telemetry_keys, uom_keys) bleiben lazy und werden dort, wo sie gebraucht werden, innerhalb der Session berührt. Der Vertrag dazu steht in Architektur im Detail.

Die Antwortmodelle

Im selben Modul liegen die Modelle, die die REST‑API nach außen gibt. Zwei Formen fallen auf, weil sie eine Eigenschaft des Systems tragen und nicht nur eine Zeile:

Modell Trägt zusätzlich

TelemetryKeyWriteResult
UnitOfMeasurementWriteResult
ObservedPropertyWriteResult

failed_tenants — die Mandanten, die ein fächernder Schreibvorgang nicht erreicht hat. Postgres ist kanonisch und in jedem Fall committet; ein fehlgeschlagener Push ist eine Verzögerung und kein Defekt — aber nur, wenn jemand davon erfährt. Diese Liste ist dieses Erfahren.

TenantCreateResponse
TenantUpdateResponse

{tenant, sync} statt einer flachen Zeile, weil die beiden verschiedenen Regeln folgen: Der Mandant ist die committete Tatsache — ohne bestandene Prüfung gäbe es die Zeile nicht —, während sync best‑effort ist und fehlen darf, ohne dass der Schreibvorgang weniger erledigt wäre.

ObservedPropertySyncReport

unmanaged_in_use — Kopien, die keine Zuordnungszeile beansprucht und an denen Datastreams hängen. Leer ist die normale Antwort. Zählwerte von Angelegtem und Aktualisiertem fehlen bewusst: Niemand handelt danach, sie gehören in die Logzeile.