LCOV - code coverage report
Current view: top level - antares-sql/src/store/pg - doc.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.3 % 688 683
Test Date: 2026-09-21 10:31:06 Functions: 70.7 % 276 195

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! PgStore slice two: the doc-table kinds —
       3              : //! `subscriptions`, `csource_registrations`, `csource_subscriptions` — plus
       4              : //! cross-tenant `jsonld_contexts`. Same sync-facade shape as `pg_entity`.
       5              : //!
       6              : //! The v0 interchange form stores ONE doc per resource; the bookkeeping
       7              : //! columns (`expires_at`, `is_active`, `times_sent`, `last_*`) are EXTRACTED
       8              : //! from the doc on every write, so the row stays the truth while the
       9              : //! API layer keeps its doc-shaped view until the cutover completes.
      10              : 
      11              : use antares_model::operations::{group_members, DEFAULT_OPERATION_GROUP, OPERATION_NAMES};
      12              : use antares_model::TenantId;
      13              : use serde_json::Value;
      14              : use sqlx::postgres::PgPool;
      15              : use sqlx::Row;
      16              : 
      17              : use super::entity::{check_ceiling, MAX_UNDECIDED_ROWS};
      18              : 
      19              : /// Which doc table a resource kind lives in.
      20              : #[derive(Clone, Copy, Debug)]
      21              : pub enum DocKind {
      22              :     Subscription,
      23              :     Registration,
      24              :     CSourceSubscription,
      25              :     Snapshot,
      26              :     EntityMap,
      27              :     DistSub,
      28              :     DeadLetter,
      29              : }
      30              : 
      31              : impl DocKind {
      32        62139 :     fn table(self) -> &'static str {
      33        62139 :         match self {
      34          496 :             DocKind::Subscription => "subscriptions",
      35        20983 :             DocKind::Registration => "csource_registrations",
      36         1674 :             DocKind::CSourceSubscription => "csource_subscriptions",
      37        18803 :             DocKind::Snapshot => "snapshots",
      38        19055 :             DocKind::EntityMap => "entity_map_docs",
      39         1124 :             DocKind::DistSub => "dist_subs",
      40            4 :             DocKind::DeadLetter => "dead_letters",
      41              :         }
      42        62139 :     }
      43        61620 :     fn doc_column(self) -> &'static str {
      44        61620 :         match self {
      45         2013 :             DocKind::Subscription | DocKind::CSourceSubscription => "subscription",
      46        20741 :             DocKind::Registration => "registration",
      47              :             DocKind::Snapshot | DocKind::EntityMap | DocKind::DistSub | DocKind::DeadLetter => {
      48        38866 :                 "doc"
      49              :             }
      50              :         }
      51        61620 :     }
      52         2584 :     fn has_bookkeeping(self) -> bool {
      53         2584 :         matches!(self, DocKind::Subscription | DocKind::CSourceSubscription)
      54         2584 :     }
      55              : }
      56              : 
      57              : /// Bookkeeping columns, derived from the doc (5.2.14.2 output members).
      58          163 : fn bookkeeping(
      59          163 :     doc: &Value,
      60          163 : ) -> (
      61          163 :     Option<String>,
      62          163 :     bool,
      63          163 :     i64,
      64          163 :     Option<String>,
      65          163 :     Option<String>,
      66          163 :     Option<String>,
      67          163 : ) {
      68              :     // 4.6.3: a comma seconds-fraction is legal in a request; every one of
      69              :     // these becomes a `::timestamptz` bind.
      70          652 :     let s = |v: Option<&Value>| {
      71          652 :         v.and_then(Value::as_str)
      72          652 :             .map(|t| antares_store::filter::canonical_datetime(t).into_owned())
      73          652 :     };
      74          163 :     let n = doc.get("notification");
      75              :     (
      76          163 :         s(doc.get("expiresAt")),
      77          163 :         doc.get("isActive") != Some(&Value::Bool(false)),
      78          163 :         n.and_then(|n| n.get("timesSent"))
      79          163 :             .and_then(Value::as_i64)
      80          163 :             .unwrap_or(0),
      81          163 :         s(n.and_then(|n| n.get("lastNotification"))),
      82          163 :         s(n.and_then(|n| n.get("lastSuccess"))),
      83          163 :         s(n.and_then(|n| n.get("lastFailure"))),
      84              :     )
      85          163 : }
      86              : 
      87              : // ---- csource_index maintenance ---------------------------------------------
      88              : // The flattened federation match table, rebuilt in Rust inside the same
      89              : // transaction as every registration write (no triggers). Deleting a
      90              : // registration cleans its rows via the FK ON DELETE CASCADE.
      91              : 
      92              : // A registration's operations are stored as a bitmask over Table 4.20-1
      93              : // (`antares_model::operations::OPERATION_NAMES`), bit position = index in
      94              : // that list. The list's order is append-only for exactly this reason: a
      95              : // bitmask is stored data, so renumbering it is a migration, not an edit.
      96              : //
      97              : // The bit position being stored data also means appending a 64th operation
      98              : // would overflow the shift below and write a mask no later migration could
      99              : // distinguish from a real one. Fail at compile time on the day it is
     100              : // appended, not on a corrupted row later.
     101              : const _: () = assert!(OPERATION_NAMES.len() < 64, "ops bitmask is i64");
     102              : 
     103              : /// Registration `operations` → bitmask; absent defaults to federationOps
     104              : /// (5.2.9).
     105        40986 : pub fn ops_mask(reg: &Value) -> i64 {
     106        40986 :     let names: Vec<&str> = reg
     107        40986 :         .get("operations")
     108        40986 :         .and_then(Value::as_array)
     109        40986 :         .map(|a| a.iter().filter_map(Value::as_str).collect())
     110        40986 :         .unwrap_or_else(|| vec![DEFAULT_OPERATION_GROUP]);
     111        40986 :     let mut mask = 0i64;
     112       778532 :     let mut set = |op: &str| {
     113     25486028 :         if let Some(bit) = OPERATION_NAMES.iter().position(|o| *o == op) {
     114       778530 :             mask |= 1 << bit;
     115       778530 :         }
     116       778532 :     };
     117        40988 :     for n in names {
     118        40988 :         match group_members(n) {
     119       778522 :             Some(members) => members.iter().for_each(|m| set(m)),
     120           10 :             None => set(n),
     121              :         }
     122              :     }
     123        40986 :     mask
     124        40986 : }
     125              : 
     126        40976 : fn mode_code(reg: &Value) -> i16 {
     127        40976 :     match reg.get("mode").and_then(Value::as_str) {
     128           10 :         Some("auxiliary") => 0,
     129           10 :         Some("redirect") => 2,
     130            8 :         Some("exclusive") => 3,
     131        40966 :         _ => 1, // inclusive is the default (5.2.9)
     132              :     }
     133        40976 : }
     134              : 
     135              : /// Hard ceiling on rows one registration may explode into. The API caps
     136              : /// cardinality at the validation boundary, but a document written
     137              : /// through any other path — a restored dump, a future importer — must not be
     138              : /// able to drive this quadratically. Truncating loses federation matches for
     139              : /// an absurd registration; OOM loses the process.
     140              : pub const MAX_INDEX_ROWS: usize = 10_000;
     141              : 
     142              : /// Explode one registration document into csource_index rows: each
     143              : /// RegistrationInfo element yields entities × (propertyNames ∪
     144              : /// relationshipNames) rows, with NULL placeholders when a dimension is
     145              : /// absent — the Scorpio csourceinformation shape minus the 46
     146              : /// boolean columns. Attribute/type names are stored as they appear in the
     147              : /// document; canonical-IRI storage lands with the SQL matching path.
     148              : /// NGSI-LD 2.0 readiness: when propertyNames/relationshipNames
     149              : /// merge into attributeNames, the migration is a coalesce of the two name
     150              : /// columns into one attribute_name column — no reshape.
     151          965 : pub fn index_rows(reg: &Value) -> Vec<Value> {
     152          965 :     let endpoint = reg
     153          965 :         .get("endpoint")
     154          965 :         .and_then(Value::as_str)
     155          965 :         .unwrap_or_default();
     156        40976 :     let common = |entity: Option<&Value>, prop: Option<&str>, rel: Option<&str>| {
     157        40976 :         serde_json::json!({
     158        40976 :             "entity_id": entity.and_then(|e| e.get("id")).and_then(Value::as_str),
     159        40976 :             "id_pattern": entity.and_then(|e| e.get("idPattern")).and_then(Value::as_str),
     160        40976 :             "entity_type": entity.and_then(|e| e.get("type")).and_then(Value::as_str),
     161        40976 :             "property_name": prop,
     162        40976 :             "relationship_name": rel,
     163              :             // A registration carries its geo scope as a RAW GeoJSON
     164              :             // geometry under `location` (not instance-wrapped like an entity
     165              :             // attribute) — see antares_api::csource::csr_matches_subscription,
     166              :             // which hands exactly this value to `matches_geometry`.
     167        40976 :             "location": reg.get("location").filter(|g| g.get("type").is_some())
     168        40976 :                            .map(|g| g.to_string()),
     169        40976 :             "scopes": reg.get("scope").map(|s| match s {
     170            0 :                 Value::String(one) => vec![one.clone()],
     171            0 :                 Value::Array(a) => a.iter().filter_map(Value::as_str).map(str::to_owned).collect(),
     172            0 :                 _ => vec![],
     173            0 :             }),
     174              :             // 4.6.3 comma fraction: this row's `expires_at` is cast with a
     175              :             // bare `::timestamptz` in the INSERT below.
     176        40976 :             "expires_at": reg.get("expiresAt").and_then(Value::as_str)
     177        40976 :                              .map(antares_store::filter::canonical_datetime),
     178        40976 :             "endpoint": endpoint,
     179        40976 :             "mode": mode_code(reg),
     180        40976 :             "ops": ops_mask(reg),
     181        40976 :             "tenant_at_peer": reg.get("tenant").and_then(Value::as_str),
     182        40976 :             "headers": reg.get("contextSourceInfo"),
     183              :             // Table 5.2.9-1 names this member `contextSourceAlias` — the
     184              :             // peer's tenant-specific loop pseudonym. (`hostAlias` is the
     185              :             // prose spelling 6.3.18 uses and the csource_index column name;
     186              :             // it is not a payload member and was never sent by any client.)
     187        40976 :             "host_alias": reg.get("contextSourceAlias").and_then(Value::as_str),
     188              :         })
     189        40976 :     };
     190          965 :     let mut rows = Vec::new();
     191          965 :     let infos = reg
     192          965 :         .get("information")
     193          965 :         .and_then(Value::as_array)
     194          965 :         .cloned()
     195          965 :         .unwrap_or_default();
     196          965 :     for info in &infos {
     197         1926 :         let names = |k: &str| -> Vec<String> {
     198         1926 :             info.get(k)
     199         1926 :                 .and_then(Value::as_array)
     200         1926 :                 .map(|a| {
     201           16 :                     a.iter()
     202           16 :                         .filter_map(Value::as_str)
     203           16 :                         .map(str::to_owned)
     204           16 :                         .collect()
     205           16 :                 })
     206         1926 :                 .unwrap_or_default()
     207         1926 :         };
     208          963 :         let props = names("propertyNames");
     209          963 :         let rels = names("relationshipNames");
     210          963 :         let entities: Vec<Option<&Value>> = match info.get("entities").and_then(Value::as_array) {
     211          962 :             Some(a) if !a.is_empty() => a.iter().map(Some).collect(),
     212            1 :             _ => vec![None],
     213              :         };
     214        20965 :         for ent in entities {
     215              :             // Checked per ROW, not per entity: one `information` element with
     216              :             // one entity and a million propertyNames never reaches a
     217              :             // per-entity check twice, so the vector grew unbounded — the OOM
     218              :             // this ceiling exists to prevent.
     219        40978 :             for (p, r) in std::iter::once((None, None))
     220        20965 :                 .filter(|_| props.is_empty() && rels.is_empty())
     221        20965 :                 .chain(props.iter().map(|p| (Some(p.as_str()), None)))
     222        20965 :                 .chain(rels.iter().map(|r| (None, Some(r.as_str()))))
     223              :             {
     224        40978 :                 if rows.len() >= MAX_INDEX_ROWS {
     225            4 :                     tracing::warn!(
     226              :                         "registration explodes past {MAX_INDEX_ROWS} index rows; truncating"
     227              :                     );
     228            4 :                     return rows;
     229        40974 :                 }
     230        40974 :                 rows.push(common(ent, p, r));
     231              :             }
     232              :         }
     233              :     }
     234          961 :     if infos.is_empty() {
     235            2 :         rows.push(common(None, None, None));
     236          959 :     }
     237          961 :     rows
     238          965 : }
     239              : 
     240              : /// Rebuild one registration's `csource_index` rows (delete + multi-row
     241              : /// insert) inside the CALLER's transaction, so the extracted match rows are
     242              : /// never a version behind the document. Shared by `upsert` and `mutate` —
     243              : /// the row lock lives in here, so no caller can do it atomically itself.
     244              : ///
     245              : /// The geometry goes through `try_geomfromgeojson` (0001_init.sql): a
     246              : /// location PostGIS cannot parse leaves the column NULL instead of aborting
     247              : /// the write.
     248          911 : async fn rebuild_csource_index(
     249          911 :     tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
     250          911 :     tenant: &TenantId,
     251          911 :     id: &str,
     252          911 :     doc: &Value,
     253          911 : ) -> Result<(), sqlx::Error> {
     254          911 :     sqlx::query("DELETE FROM csource_index WHERE tenant_id = $1 AND registration_id = $2")
     255          911 :         .bind(tenant.as_str())
     256          911 :         .bind(id)
     257          911 :         .execute(&mut **tx)
     258          911 :         .await?;
     259          911 :     sqlx::query(
     260          911 :         "INSERT INTO csource_index
     261          911 :            (tenant_id, registration_id, entity_id, id_pattern, entity_type,
     262          911 :             property_name, relationship_name, scopes, expires_at, endpoint,
     263          911 :             mode, ops, tenant_at_peer, headers, host_alias, location)
     264          911 :          SELECT $1, $2, e->>'entity_id', e->>'id_pattern', e->>'entity_type',
     265          911 :                 e->>'property_name', e->>'relationship_name',
     266          911 :                 CASE WHEN e->'scopes' = 'null'::jsonb THEN NULL
     267          911 :                      ELSE ARRAY(SELECT jsonb_array_elements_text(e->'scopes')) END,
     268          911 :                 (e->>'expires_at')::timestamptz, e->>'endpoint',
     269          911 :                 (e->>'mode')::smallint, (e->>'ops')::bigint,
     270          911 :                 e->>'tenant_at_peer', e->'headers', e->>'host_alias',
     271          911 :                 CASE WHEN ST_IsValid(try_geomfromgeojson(e->>'location'))
     272          911 :                      THEN try_geomfromgeojson(e->>'location') END
     273          911 :          FROM jsonb_array_elements($3::jsonb) AS e",
     274          911 :     )
     275          911 :     .bind(tenant.as_str())
     276          911 :     .bind(id)
     277          911 :     .bind(Value::Array(index_rows(doc)))
     278          911 :     .execute(&mut **tx)
     279          911 :     .await
     280          911 :     .map(|_| ())
     281          911 : }
     282              : 
     283              : /// The INSERT every doc write shares — same columns, same binds, differing
     284              : /// only in the caller's `ON CONFLICT …` tail. `None` = the tail took a
     285              : /// DO NOTHING path (the row was already there).
     286         1139 : async fn insert_doc(
     287         1139 :     tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
     288         1139 :     tenant: &TenantId,
     289         1139 :     kind: DocKind,
     290         1139 :     id: &str,
     291         1139 :     doc: &Value,
     292         1139 :     conflict: &str,
     293         1139 : ) -> Result<Option<bool>, sqlx::Error> {
     294         1139 :     let table = kind.table();
     295         1139 :     let col = kind.doc_column();
     296         1139 :     let head = if kind.has_bookkeeping() {
     297           89 :         format!(
     298              :             "INSERT INTO {table} (tenant_id, id, {col}, context, expires_at, is_active,
     299              :                times_sent, last_notification, last_success, last_failure)
     300              :              VALUES ($1, $2, $3, $4, $5::timestamptz, $6, $7,
     301              :                $8::timestamptz, $9::timestamptz, $10::timestamptz)"
     302              :         )
     303              :     } else {
     304         1050 :         format!("INSERT INTO {table} (tenant_id, id, {col}) VALUES ($1, $2, $3)")
     305              :     };
     306              :     // literals from `DocKind` plus the caller's literal tail, values bound
     307         1139 :     let mut q = sqlx::query(sqlx::AssertSqlSafe(format!("{head}{conflict}")))
     308         1139 :         .bind(tenant.as_str())
     309         1139 :         .bind(id)
     310         1139 :         .bind(doc);
     311              :     let bk;
     312         1139 :     if kind.has_bookkeeping() {
     313           89 :         let context = doc
     314           89 :             .get("@context")
     315           89 :             .cloned()
     316           89 :             .unwrap_or(Value::Object(Default::default()));
     317           89 :         bk = (bookkeeping(doc), context);
     318           89 :         let ((expires, active, sent, last_n, last_s, last_f), context) = &bk;
     319           89 :         q = q
     320           89 :             .bind(context)
     321           89 :             .bind(expires)
     322           89 :             .bind(*active)
     323           89 :             .bind(*sent)
     324           89 :             .bind(last_n)
     325           89 :             .bind(last_s)
     326           89 :             .bind(last_f);
     327         1050 :     }
     328         1139 :     Ok(q.fetch_optional(&mut **tx)
     329         1139 :         .await?
     330         1139 :         .map(|r| r.get::<bool, _>(0)))
     331         1139 : }
     332              : 
     333              : pub struct PgDocStore {
     334              :     pool: PgPool,
     335              : }
     336              : 
     337              : /// Arm the Row-Level Security policy on `jsonld_contexts` for one call.
     338              : ///
     339              : /// `antares.tenant` is transaction-scoped, so it is set inside the caller's
     340              : /// own transaction. `None` leaves it unset, and `current_setting` then
     341              : /// returns NULL: the policy's `tenant_id = current_setting(...)` arm is NULL
     342              : /// for every row and only `tenant_id IS NULL` — the Cached rows — remains.
     343              : /// That is what the boot warm and the Cached write-through want.
     344          659 : async fn set_context_tenant(
     345          659 :     tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
     346          659 :     tenant: Option<&TenantId>,
     347          659 : ) -> Result<(), sqlx::Error> {
     348          659 :     match tenant {
     349          640 :         Some(t) => super::set_tenant(tx, t).await,
     350           19 :         None => Ok(()),
     351              :     }
     352          659 : }
     353              : 
     354              : impl PgDocStore {
     355           59 :     pub fn new(pool: PgPool) -> Self {
     356           59 :         Self { pool }
     357           59 :     }
     358              : 
     359         1252 :     pub fn pool(&self) -> &PgPool {
     360         1252 :         &self.pool
     361         1252 :     }
     362              : 
     363              :     /// Create one doc: `Ok(false)` when a document with that id already
     364              :     /// exists. 5.8.1.4 (and 5.9.2.4 for registrations): "If the NGSI-LD
     365              :     /// endpoint already knows about this Subscription, as there is an
     366              :     /// existing Subscription whose id (URI) is equivalent, an error of type
     367              :     /// AlreadyExists shall be raised."
     368              :     ///
     369              :     /// ONE statement, so the answer comes from the unique constraint itself:
     370              :     /// a read-then-write would let two concurrent creates of the same
     371              :     /// client-supplied id both report created, and the second would silently
     372              :     /// overwrite the first.
     373         1066 :     pub async fn create(
     374         1066 :         &self,
     375         1066 :         tenant: &TenantId,
     376         1066 :         kind: DocKind,
     377         1066 :         id: &str,
     378         1066 :         doc: &Value,
     379         1066 :     ) -> Result<bool, sqlx::Error> {
     380         1066 :         let conflict = " ON CONFLICT (tenant_id, id) DO NOTHING RETURNING true AS created";
     381         1066 :         let mut tx = super::begin(&self.pool).await?;
     382         1066 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     383         1066 :         crate::store::pg::claim_tenant(&mut tx, tenant).await?;
     384         1066 :         let created = insert_doc(&mut tx, tenant, kind, id, doc, conflict)
     385         1066 :             .await?
     386         1066 :             .is_some();
     387              :         // a losing INSERT must not rebuild the winner's index rows
     388         1066 :         if created && matches!(kind, DocKind::Registration) {
     389          896 :             rebuild_csource_index(&mut tx, tenant, id, doc).await?;
     390          170 :         }
     391         1066 :         tx.commit().await?;
     392         1066 :         Ok(created)
     393         1066 :     }
     394              : 
     395              :     /// Upsert one doc, refreshing the extracted columns. `Ok(true)` = it
     396              :     /// existed before.
     397           73 :     pub async fn upsert(
     398           73 :         &self,
     399           73 :         tenant: &TenantId,
     400           73 :         kind: DocKind,
     401           73 :         id: &str,
     402           73 :         doc: &Value,
     403           73 :     ) -> Result<bool, sqlx::Error> {
     404           73 :         let col = kind.doc_column();
     405           73 :         let conflict = if kind.has_bookkeeping() {
     406           59 :             format!(
     407              :                 " ON CONFLICT (tenant_id, id) DO UPDATE SET {col} = EXCLUDED.{col},
     408              :                    context = EXCLUDED.context, expires_at = EXCLUDED.expires_at,
     409              :                    is_active = EXCLUDED.is_active, times_sent = EXCLUDED.times_sent,
     410              :                    last_notification = EXCLUDED.last_notification,
     411              :                    last_success = EXCLUDED.last_success, last_failure = EXCLUDED.last_failure
     412              :                  RETURNING (xmax <> 0) AS existed"
     413              :             )
     414              :         } else {
     415           14 :             format!(
     416              :                 " ON CONFLICT (tenant_id, id) DO UPDATE SET {col} = EXCLUDED.{col}
     417              :                  RETURNING (xmax <> 0) AS existed"
     418              :             )
     419              :         };
     420           73 :         let mut tx = super::begin(&self.pool).await?;
     421           73 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     422           73 :         crate::store::pg::claim_tenant(&mut tx, tenant).await?;
     423              :         // INSERT … ON CONFLICT DO UPDATE … RETURNING always answers with
     424              :         // the row; no row means the statement stopped being an upsert.
     425           73 :         let existed = insert_doc(&mut tx, tenant, kind, id, doc, &conflict)
     426           73 :             .await?
     427           73 :             .ok_or(sqlx::Error::RowNotFound)?;
     428           73 :         if matches!(kind, DocKind::Registration) {
     429           14 :             rebuild_csource_index(&mut tx, tenant, id, doc).await?;
     430           59 :         }
     431           73 :         tx.commit().await?;
     432           73 :         Ok(existed)
     433           73 :     }
     434              : 
     435         1608 :     pub async fn get(
     436         1608 :         &self,
     437         1608 :         tenant: &TenantId,
     438         1608 :         kind: DocKind,
     439         1608 :         id: &str,
     440         1608 :     ) -> Result<Option<Value>, sqlx::Error> {
     441         1608 :         let sql = format!(
     442              :             "SELECT {} FROM {} WHERE tenant_id = $1 AND id = $2",
     443         1608 :             kind.doc_column(),
     444         1608 :             kind.table()
     445              :         );
     446         1608 :         let mut tx = super::begin(&self.pool).await?;
     447         1608 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     448         1608 :         let row = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
     449         1608 :             .bind(tenant.as_str())
     450         1608 :             .bind(id)
     451         1608 :             .fetch_optional(&mut *tx)
     452         1608 :             .await?;
     453         1608 :         tx.commit().await?;
     454         1608 :         Ok(row.map(|r| r.get::<Value, _>(0)))
     455         1608 :     }
     456              : 
     457              :     /// Read-modify-write in ONE transaction under the row lock (the entity
     458              :     /// pattern applied to doc kinds): `SELECT … FOR UPDATE` → apply → `UPDATE`.
     459              :     /// A missing row returns `None` and is NEVER inserted — a concurrent
     460              :     /// DELETE must win, not be resurrected by a bookkeeping writeback
     461              :     /// (the 047_06 leftover-subscription bug).
     462              :     /// 5.2.14.2 delivery bookkeeping as ONE statement.
     463              :     ///
     464              :     /// The generic `mutate` takes an arbitrary closure, so it has to hold the
     465              :     /// row under `FOR UPDATE` across a network round trip while Rust runs.
     466              :     /// This mutation is fixed, so it can be expressed in SQL and the lock
     467              :     /// lives only as long as the UPDATE. That is the point: at fan-out every
     468              :     /// delivery on one subscription contends for the same row, so the lock
     469              :     /// hold time is what serializes delivery, not the statement count.
     470              :     ///
     471              :     /// The pre-image comes back through a locking sub-select, not a plain
     472              :     /// CTE. Both are read once per statement, but a CTE is answered from the
     473              :     /// snapshot the statement started with: when another attempt on the same
     474              :     /// subscription commits while this one waits for the row, the CTE hands
     475              :     /// back the value from BEFORE that commit, and the rollback a failed
     476              :     /// attempt performs then rewinds `lastSuccess` past a delivery that
     477              :     /// succeeded. `FOR UPDATE` waits for the same row lock the UPDATE needs
     478              :     /// and re-reads the row it actually gets, so the pre-image is the value
     479              :     /// this statement overwrote.
     480              :     ///
     481              :     /// `notification` is a mandatory member (5.2.12) and `jsonb_set` on a
     482              :     /// path with no parent is a no-op, so a document that somehow lacks it is
     483              :     /// left alone rather than grown a synthetic one.
     484           13 :     pub async fn record_delivery(
     485           13 :         &self,
     486           13 :         tenant: &TenantId,
     487           13 :         kind: DocKind,
     488           13 :         id: &str,
     489           13 :         now: &str,
     490           13 :     ) -> Result<Option<(Value, Option<Value>)>, sqlx::Error> {
     491           13 :         let table = kind.table();
     492           13 :         let col = kind.doc_column();
     493              :         // literals from DocKind; every value is bound
     494           13 :         let sql = format!(
     495              :             "UPDATE {table} AS d SET
     496              :                {col} = jsonb_set(jsonb_set(jsonb_set(jsonb_set(
     497              :                    d.{col} - 'status',
     498              :                    '{{notification,timesSent}}',
     499              :                    to_jsonb(COALESCE((d.{col} #>> '{{notification,timesSent}}')::bigint, 0) + 1)),
     500              :                    '{{notification,lastNotification}}', to_jsonb($3::text)),
     501              :                    '{{notification,lastSuccess}}', to_jsonb($3::text)),
     502              :                    '{{notification,status}}', '\"ok\"'::jsonb),
     503              :                times_sent = COALESCE(d.times_sent, 0) + 1,
     504              :                last_notification = $3::timestamptz,
     505              :                last_success = $3::timestamptz
     506              :              FROM (SELECT id AS locked_id,
     507              :                           {col} #> '{{notification,lastSuccess}}' AS ls
     508              :                      FROM {table}
     509              :                     WHERE tenant_id = $1 AND id = $2
     510              :                       FOR UPDATE) AS prev
     511              :              WHERE d.tenant_id = $1 AND d.id = prev.locked_id
     512              :              RETURNING d.{col}, prev.ls"
     513              :         );
     514           13 :         async move {
     515           13 :             let mut tx = super::begin(&self.pool).await?;
     516           13 :             crate::store::pg::set_tenant(&mut tx, tenant).await?;
     517           13 :             let row = sqlx::query(sqlx::AssertSqlSafe(sql))
     518           13 :                 .bind(tenant.as_str())
     519           13 :                 .bind(id)
     520           13 :                 .bind(now)
     521           13 :                 .fetch_optional(&mut *tx)
     522           13 :                 .await?;
     523           13 :             let Some(row) = row else {
     524            2 :                 return Ok(None);
     525              :             };
     526           11 :             let doc: Value = row.get(0);
     527           11 :             let prev: Option<Value> = row.get(1);
     528           11 :             tx.commit().await?;
     529           11 :             Ok(Some((doc, prev)))
     530           13 :         }
     531           13 :         .await
     532           13 :     }
     533              : 
     534          159 :     pub async fn mutate<T, E>(
     535          159 :         &self,
     536          159 :         tenant: &TenantId,
     537          159 :         kind: DocKind,
     538          159 :         id: &str,
     539          159 :         f: impl FnOnce(&mut Value) -> Result<T, E>,
     540          159 :     ) -> Result<Option<Result<T, E>>, sqlx::Error> {
     541          159 :         let col = kind.doc_column();
     542          159 :         let table = kind.table();
     543          159 :         let select =
     544          159 :             format!("SELECT {col} FROM {table} WHERE tenant_id = $1 AND id = $2 FOR UPDATE");
     545          159 :         let update = if kind.has_bookkeeping() {
     546            4 :             format!(
     547              :                 "UPDATE {table} SET {col} = $3, expires_at = $4::timestamptz,
     548              :                    is_active = $5, times_sent = $6, last_notification = $7::timestamptz,
     549              :                    last_success = $8::timestamptz, last_failure = $9::timestamptz,
     550              :                    context = $10
     551              :                  WHERE tenant_id = $1 AND id = $2"
     552              :             )
     553              :         } else {
     554          155 :             format!("UPDATE {table} SET {col} = $3 WHERE tenant_id = $1 AND id = $2")
     555              :         };
     556          159 :         async move {
     557          159 :             let mut tx = super::begin(&self.pool).await?;
     558          159 :             crate::store::pg::set_tenant(&mut tx, tenant).await?;
     559          159 :             let row = sqlx::query(sqlx::AssertSqlSafe(select.clone()))
     560          159 :                 .bind(tenant.as_str())
     561          159 :                 .bind(id)
     562          159 :                 .fetch_optional(&mut *tx)
     563          159 :                 .await?;
     564          159 :             let Some(row) = row else {
     565           85 :                 return Ok(None);
     566              :             };
     567           74 :             let mut doc: Value = row.get(0);
     568              :             // The match rows are a pure function of the document, so the
     569              :             // rows this write would produce can be compared with the ones
     570              :             // already stored before deciding to touch them.
     571           74 :             let index_before = matches!(kind, DocKind::Registration).then(|| index_rows(&doc));
     572           74 :             match f(&mut doc) {
     573           74 :                 Ok(t) => {
     574           74 :                     let context = doc
     575           74 :                         .get("@context")
     576           74 :                         .cloned()
     577           74 :                         .unwrap_or(Value::Object(Default::default()));
     578           74 :                     let (expires, active, sent, last_n, last_s, last_f) = bookkeeping(&doc);
     579           74 :                     let mut q = sqlx::query(sqlx::AssertSqlSafe(update.clone()))
     580           74 :                         .bind(tenant.as_str())
     581           74 :                         .bind(id)
     582           74 :                         .bind(&doc);
     583           74 :                     if kind.has_bookkeeping() {
     584            1 :                         q = q
     585            1 :                             .bind(&expires)
     586            1 :                             .bind(active)
     587            1 :                             .bind(sent)
     588            1 :                             .bind(&last_n)
     589            1 :                             .bind(&last_s)
     590            1 :                             .bind(&last_f)
     591            1 :                             .bind(&context);
     592           73 :                     }
     593           74 :                     q.execute(&mut *tx).await?;
     594              :                     // 5.9.3 Update Registration: a patch may flip the mode,
     595              :                     // rewrite `information` or move the endpoint, so the
     596              :                     // extracted match rows have to be rebuilt with the doc —
     597              :                     // in this transaction, under the same row lock. A write
     598              :                     // that moves none of them rebuilds nothing: Table
     599              :                     // 5.2.9-2's forward counters are booked on every
     600              :                     // distributed operation, and the delete + re-insert
     601              :                     // those would drag along rewrites the whole index of a
     602              :                     // busy registration for a document the matcher reads
     603              :                     // identically.
     604           74 :                     if index_before.is_some_and(|before| before != index_rows(&doc)) {
     605            1 :                         rebuild_csource_index(&mut tx, tenant, id, &doc).await?;
     606           73 :                     }
     607           74 :                     tx.commit().await?;
     608           74 :                     Ok(Some(Ok(t)))
     609              :                 }
     610              :                 // closure rejected the change: nothing written, lock released
     611            0 :                 Err(e) => Ok(Some(Err(e))),
     612              :             }
     613          159 :         }
     614          159 :         .await
     615          159 :     }
     616              : 
     617          529 :     pub async fn delete(
     618          529 :         &self,
     619          529 :         tenant: &TenantId,
     620          529 :         kind: DocKind,
     621          529 :         id: &str,
     622          529 :     ) -> Result<bool, sqlx::Error> {
     623          529 :         let sql = format!(
     624              :             "DELETE FROM {} WHERE tenant_id = $1 AND id = $2",
     625          529 :             kind.table()
     626              :         );
     627          529 :         let mut tx = super::begin(&self.pool).await?;
     628          529 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     629          529 :         let done = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
     630          529 :             .bind(tenant.as_str())
     631          529 :             .bind(id)
     632          529 :             .execute(&mut *tx)
     633          529 :             .await?
     634          529 :             .rows_affected();
     635          529 :         tx.commit().await?;
     636          529 :         Ok(done == 1)
     637          529 :     }
     638              : 
     639              :     /// One id-ordered page of docs: ids strictly greater than `after`, at
     640              :     /// most `limit`.
     641              :     ///
     642              :     /// No ceiling, deliberately. `list`'s `MAX_UNDECIDED_ROWS` exists so a
     643              :     /// large tenant cannot be materialized into one `Vec`; a page bounds
     644              :     /// that by construction, so refusing here would only break the readers
     645              :     /// that must see every row — and one tenant's stored volume would
     646              :     /// decide whether another tenant's subscriptions are ever matched.
     647              :     /// Keyset, not OFFSET: the walk runs against a table being written to.
     648              :     /// `after = None` is bound as the empty string rather than as a second
     649              :     /// statement, which keeps the primary-key range scan; it is below every
     650              :     /// id because a document id is a URI and a URI is never empty. A row
     651              :     /// stored with an empty id would be returned by `list` and skipped by
     652              :     /// every page of this walk.
     653        55954 :     pub async fn list_page(
     654        55954 :         &self,
     655        55954 :         tenant: &TenantId,
     656        55954 :         kind: DocKind,
     657        55954 :         after: Option<&str>,
     658        55954 :         limit: i64,
     659        55954 :     ) -> Result<Vec<Value>, sqlx::Error> {
     660        55954 :         let sql = format!(
     661              :             "SELECT {} FROM {} WHERE tenant_id = $1 AND id > $2 ORDER BY id LIMIT $3",
     662        55954 :             kind.doc_column(),
     663        55954 :             kind.table()
     664              :         );
     665        55954 :         let mut tx = super::begin(&self.pool).await?;
     666        55954 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     667        55954 :         let rows = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
     668        55954 :             .bind(tenant.as_str())
     669        55954 :             .bind(after.unwrap_or(""))
     670        55954 :             .bind(limit)
     671        55954 :             .fetch_all(&mut *tx)
     672        55954 :             .await?;
     673        55954 :         tx.commit().await?;
     674       171287 :         Ok(rows.into_iter().map(|r| r.get::<Value, _>(0)).collect())
     675        55954 :     }
     676              : 
     677              :     /// One id-ordered window of docs and the size of the whole set:
     678              :     /// elements `offset..offset + limit`, plus the count 6.3.10 reports.
     679              :     ///
     680              :     /// No ceiling, and no `check_ceiling`, for the same reason `list_page`
     681              :     /// has none: the window bounds the result by construction, so refusing
     682              :     /// here would only refuse a page the client is entitled to. Both
     683              :     /// statements run in one transaction under the same `set_tenant`, so
     684              :     /// the count and the page describe the same set — a count taken outside
     685              :     /// it could report a total no page of this read ever adds up to.
     686           62 :     pub async fn list_slice(
     687           62 :         &self,
     688           62 :         tenant: &TenantId,
     689           62 :         kind: DocKind,
     690           62 :         offset: i64,
     691           62 :         limit: i64,
     692           62 :     ) -> Result<(Vec<Value>, i64), sqlx::Error> {
     693           62 :         let page_sql = format!(
     694              :             "SELECT {} FROM {} WHERE tenant_id = $1 ORDER BY id LIMIT $2 OFFSET $3",
     695           62 :             kind.doc_column(),
     696           62 :             kind.table()
     697              :         );
     698           62 :         let count_sql = format!("SELECT count(*) FROM {} WHERE tenant_id = $1", kind.table());
     699           62 :         let mut tx = super::begin(&self.pool).await?;
     700           62 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     701           62 :         let total: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(count_sql))
     702           62 :             .bind(tenant.as_str())
     703           62 :             .fetch_one(&mut *tx)
     704           62 :             .await?;
     705           62 :         let rows = sqlx::query(sqlx::AssertSqlSafe(page_sql))
     706           62 :             .bind(tenant.as_str())
     707           62 :             .bind(limit)
     708           62 :             .bind(offset)
     709           62 :             .fetch_all(&mut *tx)
     710           62 :             .await?;
     711           62 :         tx.commit().await?;
     712              :         Ok((
     713           62 :             rows.into_iter().map(|r| r.get::<Value, _>(0)).collect(),
     714           62 :             total,
     715              :         ))
     716           62 :     }
     717              : 
     718              :     /// Every doc of one kind for one tenant, id-ordered.
     719              :     ///
     720              :     /// Bounded: without a LIMIT this statement materializes a whole tenant's
     721              :     /// subscriptions/registrations into one `Vec`, which at the 100 000-per-
     722              :     /// broker target is the broker's memory, not the database's. A tenant that
     723              :     /// reaches the ceiling is refused with TooManyResults (5.5.6) rather than
     724              :     /// served a silent prefix.
     725         2612 :     pub async fn list(&self, tenant: &TenantId, kind: DocKind) -> Result<Vec<Value>, sqlx::Error> {
     726         2612 :         let sql = format!(
     727              :             "SELECT {} FROM {} WHERE tenant_id = $1 ORDER BY id LIMIT $2",
     728         2612 :             kind.doc_column(),
     729         2612 :             kind.table()
     730              :         );
     731         2612 :         let mut tx = super::begin(&self.pool).await?;
     732         2612 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     733         2612 :         let rows = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
     734         2612 :             .bind(tenant.as_str())
     735         2612 :             .bind(MAX_UNDECIDED_ROWS)
     736         2612 :             .fetch_all(&mut *tx)
     737         2612 :             .await?;
     738         2612 :         tx.commit().await?;
     739         2612 :         check_ceiling(false, rows.len(), MAX_UNDECIDED_ROWS)?;
     740         2612 :         Ok(rows.into_iter().map(|r| r.get::<Value, _>(0)).collect())
     741         2612 :     }
     742              : 
     743              :     /// 5.12: the registrations that may take part in an operation on these
     744              :     /// entity ids / types, read through the `csource_index` rows every
     745              :     /// registration write maintains — the alternative is listing every
     746              :     /// registration document of the tenant and scanning it in Rust, which at
     747              :     /// the 100 000-registrations target is a full table read per federated
     748              :     /// request.
     749              :     ///
     750              :     /// The narrowing is one-directional, exactly like the entity pushdown: SQL
     751              :     /// may only REMOVE rows the caller's matcher would reject anyway, and the
     752              :     /// matcher stays the arbiter of every other 5.12 condition (csf, geo,
     753              :     /// intervals, datasetId, the Via chain, the idPattern regex). Hence an
     754              :     /// index dimension left NULL is unconstrained and always survives, and an
     755              :     /// `idPattern` row survives every id query. `None` means "do not narrow on
     756              :     /// that dimension". A registration whose explosion hit `MAX_INDEX_ROWS`
     757              :     /// carries only the rows that fit — the same truncation the index write
     758              :     /// already accepts.
     759              :     ///
     760              :     /// `types` must be EXPANDED plain type IRIs, because that is what the
     761              :     /// registration write stored (each EntityInfo `type` goes through
     762              :     /// `expand_key` before the index row is built). A term, or a 4.17 Entity
     763              :     /// Type Selection expression, matches no stored value and would narrow away
     764              :     /// registrations that do match — a caller holding either passes `None`.
     765              :     ///
     766              :     /// Bounded like every other read (5.5.6): a tenant whose candidate set
     767              :     /// reaches the ceiling is refused rather than served a silent prefix.
     768         3467 :     pub async fn matching_registrations(
     769         3467 :         &self,
     770         3467 :         tenant: &TenantId,
     771         3467 :         ids: Option<&[String]>,
     772         3467 :         types: Option<&[String]>,
     773         3467 :     ) -> Result<Vec<Value>, sqlx::Error> {
     774              :         // An absent dimension is OMITTED from the statement rather than bound
     775              :         // as NULL and escaped in SQL: `$2 IS NULL OR …` is unfoldable in a
     776              :         // GENERIC plan, and Postgres switches a repeatedly executed prepared
     777              :         // statement to one — measured as a sequential scan of csource_index,
     778              :         // exactly the read this function exists to avoid.
     779         3467 :         let mut wheres = String::new();
     780         3467 :         let mut n = 1; // $1 = tenant_id
     781         3467 :         if types.is_some() {
     782          925 :             n += 1;
     783          925 :             wheres.push_str(&format!(
     784          925 :                 " AND (x.entity_type IS NULL OR x.entity_type = ANY(${n}))"
     785          925 :             ));
     786         2542 :         }
     787         3467 :         if ids.is_some() {
     788         3084 :             n += 1;
     789         3084 :             wheres.push_str(&format!(
     790         3084 :                 " AND (x.entity_id IS NULL OR x.id_pattern IS NOT NULL \
     791         3084 :                    OR x.entity_id = ANY(${n}))"
     792         3084 :             ));
     793         3084 :         }
     794              :         // literals from this function plus `$n` placeholders — no caller text
     795         3467 :         let sql = format!(
     796              :             "SELECT DISTINCT r.id, r.registration
     797              :                FROM csource_registrations r
     798              :                JOIN csource_index x
     799              :                  ON x.tenant_id = r.tenant_id AND x.registration_id = r.id
     800              :               WHERE r.tenant_id = $1{wheres}
     801              :               ORDER BY r.id LIMIT ${}",
     802         3467 :             n + 1
     803              :         );
     804         3467 :         let mut tx = super::begin(&self.pool).await?;
     805         3467 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     806         3467 :         let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.clone())).bind(tenant.as_str());
     807         3467 :         if let Some(types) = types {
     808          925 :             q = q.bind(types);
     809         2542 :         }
     810         3467 :         if let Some(ids) = ids {
     811         3084 :             q = q.bind(ids);
     812         3084 :         }
     813         3467 :         let rows = q.bind(MAX_UNDECIDED_ROWS).fetch_all(&mut *tx).await?;
     814         3467 :         tx.commit().await?;
     815         3467 :         check_ceiling(false, rows.len(), MAX_UNDECIDED_ROWS)?;
     816        30751 :         Ok(rows.into_iter().map(|r| r.get::<Value, _>(1)).collect())
     817         3467 :     }
     818              : 
     819              :     /// Bookkeeping columns straight from the row (test hook: rows are truth).
     820              :     #[cfg(any(test, feature = "test-kit"))]
     821            1 :     pub async fn status_row(
     822            1 :         &self,
     823            1 :         tenant: &TenantId,
     824            1 :         kind: DocKind,
     825            1 :         id: &str,
     826            1 :     ) -> Result<Option<(bool, i64)>, sqlx::Error> {
     827            1 :         let sql = format!(
     828              :             "SELECT is_active, times_sent FROM {} WHERE tenant_id = $1 AND id = $2",
     829            1 :             kind.table()
     830              :         );
     831            1 :         let mut tx = super::begin(&self.pool).await?;
     832            1 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     833            1 :         let row = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
     834            1 :             .bind(tenant.as_str())
     835            1 :             .bind(id)
     836            1 :             .fetch_optional(&mut *tx)
     837            1 :             .await?;
     838            1 :         tx.commit().await?;
     839            1 :         Ok(row.map(|r| (r.get(0), r.get(1))))
     840            1 :     }
     841              : 
     842              :     // jsonldContexts — a `tenant_id` GENERATED from the row's kind and its
     843              :     // "owner" member, under the Row-Level Security policy 0006 installs
     844              :     // (ADR-0021). A Cached row's tenant is NULL and every Tenant reaches it;
     845              :     // every other kind is reachable only by the Tenant that stored it.
     846              :     //
     847              :     // Both halves of the belt, as everywhere else in this store: the explicit
     848              :     // `tenant_id IS NULL OR tenant_id = $n` predicate on every statement, and
     849              :     // the policy behind it. The predicate is what holds under the roles that
     850              :     // bypass RLS (a superuser, a BYPASSRLS role); the policy is what holds
     851              :     // when a future statement forgets the predicate. The GUC is
     852              :     // transaction-scoped, so each call below opens its own transaction and
     853              :     // sets it — and leaves it unset for `tenant: None`, which is the
     854              :     // policy's "rows belonging to no Tenant".
     855              :     ///
     856              :     /// 5.13.1: "Implementations shall periodically invalidate the 'Cached'
     857              :     /// @contexts." A Cached row is written per distinct external URL a request
     858              :     /// references — client-controlled input — and the broker warms every
     859              :     /// stored row at startup, so an insert that pushes the cache past its
     860              :     /// ceiling evicts the oldest Cached rows. `Hosted` and
     861              :     /// `ImplicitlyCreated` rows are resources the broker serves on demand
     862              :     /// (5.13.2, 5.13.4), not cache, and are never evicted.
     863          162 :     pub async fn context_put(
     864          162 :         &self,
     865          162 :         tenant: Option<&TenantId>,
     866          162 :         id: &str,
     867          162 :         doc: &Value,
     868          162 :         kind: &str,
     869          162 :     ) -> Result<(), sqlx::Error> {
     870          162 :         let mut tx = self.pool.begin().await?;
     871          162 :         set_context_tenant(&mut tx, tenant).await?;
     872              :         // `xmax` is zero on a fresh row and the locking transaction's id
     873              :         // on the conflict path: the eviction then runs only when the table
     874              :         // actually grew, never on a usage bump rewriting a row in place.
     875              :         // The `WHERE` on the conflict path is the write half of the rule:
     876              :         // a row another Tenant owns is not replaced, the statement returns
     877              :         // no row, and `fetch_one` raises rather than silently doing
     878              :         // nothing. Nothing in the broker reaches it — every id is minted
     879              :         // by the caller — so it is a backstop, and it is the one the
     880              :         // driver contract probes.
     881          162 :         let inserted: bool = sqlx::query_scalar(
     882          162 :             "INSERT INTO jsonld_contexts (id, body, kind) VALUES ($1, $2, $3)
     883          162 :              ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body, kind = EXCLUDED.kind
     884          162 :              WHERE jsonld_contexts.tenant_id IS NOT DISTINCT FROM $4
     885          162 :                 OR jsonld_contexts.tenant_id IS NULL
     886          162 :              RETURNING xmax::text = '0'",
     887          162 :         )
     888          162 :         .bind(id)
     889          162 :         .bind(doc)
     890          162 :         .bind(kind)
     891          162 :         .bind(tenant.map(TenantId::as_str))
     892          162 :         .fetch_one(&mut *tx)
     893          162 :         .await?;
     894          162 :         if inserted && kind == "Cached" {
     895           22 :             sqlx::query(
     896           22 :                 "DELETE FROM jsonld_contexts WHERE id IN (
     897           22 :                    SELECT id FROM jsonld_contexts WHERE kind = 'Cached'
     898           22 :                     ORDER BY created_at DESC, id DESC OFFSET $1)",
     899           22 :             )
     900           22 :             .bind(crate::store::MAX_CACHED_CONTEXTS as i64)
     901           22 :             .execute(&mut *tx)
     902           22 :             .await?;
     903          140 :         }
     904          162 :         tx.commit().await?;
     905          162 :         Ok(())
     906          162 :     }
     907              : 
     908          329 :     pub async fn context_get(
     909          329 :         &self,
     910          329 :         tenant: Option<&TenantId>,
     911          329 :         id: &str,
     912          329 :     ) -> Result<Option<Value>, sqlx::Error> {
     913          329 :         let mut tx = self.pool.begin().await?;
     914          329 :         set_context_tenant(&mut tx, tenant).await?;
     915          329 :         let row = sqlx::query(
     916          329 :             "SELECT body FROM jsonld_contexts
     917          329 :               WHERE id = $1 AND (tenant_id IS NULL OR tenant_id = $2)",
     918          329 :         )
     919          329 :         .bind(id)
     920          329 :         .bind(tenant.map(TenantId::as_str))
     921          329 :         .fetch_optional(&mut *tx)
     922          329 :         .await?;
     923          329 :         tx.commit().await?;
     924          329 :         Ok(row.map(|r| r.get::<Value, _>(0)))
     925          329 :     }
     926              : 
     927          101 :     pub async fn context_delete(
     928          101 :         &self,
     929          101 :         tenant: Option<&TenantId>,
     930          101 :         id: &str,
     931          101 :     ) -> Result<bool, sqlx::Error> {
     932          101 :         let mut tx = self.pool.begin().await?;
     933          101 :         set_context_tenant(&mut tx, tenant).await?;
     934          101 :         let gone = sqlx::query(
     935          101 :             "DELETE FROM jsonld_contexts
     936          101 :               WHERE id = $1 AND (tenant_id IS NULL OR tenant_id = $2)",
     937          101 :         )
     938          101 :         .bind(id)
     939          101 :         .bind(tenant.map(TenantId::as_str))
     940          101 :         .execute(&mut *tx)
     941          101 :         .await?
     942          101 :         .rows_affected()
     943              :             == 1;
     944          101 :         tx.commit().await?;
     945          101 :         Ok(gone)
     946          101 :     }
     947              : 
     948              :     /// Every row without its `@context` document: `- 'body'` drops that
     949              :     /// member in the database, so the bodies are never on the wire, never
     950              :     /// decoded and never resident. A row's body may be 5 MiB and only the
     951              :     /// `Cached` rows are capped in number, so selecting whole rows here was
     952              :     /// a multi-gigabyte read on the boot path.
     953           67 :     pub async fn context_list_meta(
     954           67 :         &self,
     955           67 :         tenant: Option<&TenantId>,
     956           67 :     ) -> Result<Vec<Value>, sqlx::Error> {
     957           67 :         let mut tx = self.pool.begin().await?;
     958           67 :         set_context_tenant(&mut tx, tenant).await?;
     959           67 :         let rows = sqlx::query(
     960           67 :             "SELECT body - 'body' FROM jsonld_contexts
     961           67 :               WHERE tenant_id IS NULL OR tenant_id = $1 ORDER BY id",
     962           67 :         )
     963           67 :         .bind(tenant.map(TenantId::as_str))
     964           67 :         .fetch_all(&mut *tx)
     965           67 :         .await?;
     966           67 :         tx.commit().await?;
     967          176 :         Ok(rows.into_iter().map(|r| r.get::<Value, _>(0)).collect())
     968           67 :     }
     969              : }
     970              : 
     971              : #[cfg(test)]
     972              : mod tests {
     973              :     use super::*;
     974              :     use serde_json::json;
     975              : 
     976              :     #[test]
     977            2 :     fn ops_mask_expands_groups_and_defaults() {
     978              :         // absent operations = federationOps (5.2.9)
     979            2 :         let default_mask = ops_mask(&json!({}));
     980            2 :         let fed_mask = ops_mask(&json!({"operations": ["federationOps"]}));
     981            2 :         assert_eq!(default_mask, fed_mask);
     982         1306 :         let bit = |op: &str| 1i64 << OPERATION_NAMES.iter().position(|o| *o == op).expect(op);
     983              :         // every member of the group, not a sample: the index and the
     984              :         // registration validator read one Table 4.20-2, so a member the
     985              :         // vocabulary gains or loses has to move this mask with it
     986           38 :         for op in group_members("federationOps").expect("federationOps") {
     987           38 :             assert_ne!(fed_mask & bit(op), 0, "{op} is in federationOps");
     988              :         }
     989            2 :         assert_eq!(
     990            2 :             fed_mask & bit("createEntity"),
     991              :             0,
     992              :             "provision op not in federationOps"
     993              :         );
     994            2 :         let m = ops_mask(&json!({"operations": ["createEntity", "retrieveOps"]}));
     995            2 :         assert_ne!(m & bit("createEntity"), 0);
     996            2 :         assert_ne!(m & bit("queryEntity"), 0, "retrieveOps expands");
     997            2 :         assert_eq!(m & bit("deleteEntity"), 0);
     998            2 :         assert_eq!(ops_mask(&json!({"operations": ["notARealOp"]})), 0);
     999            2 :     }
    1000              : 
    1001              :     /// Table 4.20-2 defines `redirectionOps` as 23 operations — the provision
    1002              :     /// and retrieve set PLUS type/attribute introspection, the EntityMap
    1003              :     /// operations and `retrieveContextSourceIdentity`. A short group writes a
    1004              :     /// mask that stops those requests being redirected at all.
    1005              :     #[test]
    1006            2 :     fn redirection_ops_expands_to_the_whole_table_4_20_2_group() {
    1007            2 :         let m = ops_mask(&json!({"operations": ["redirectionOps"]}));
    1008         1164 :         let bit = |op: &str| 1i64 << OPERATION_NAMES.iter().position(|o| *o == op).expect(op);
    1009           32 :         for op in [
    1010            2 :             "createEntity",
    1011            2 :             "deleteEntity",
    1012            2 :             "purgeEntity",
    1013            2 :             "retrieveEntity",
    1014            2 :             "queryEntity",
    1015            2 :             "retrieveEntityTypes",
    1016            2 :             "retrieveEntityTypeDetails",
    1017            2 :             "retrieveEntityTypeInfo",
    1018            2 :             "retrieveAttrTypes",
    1019            2 :             "retrieveAttrTypeDetails",
    1020            2 :             "retrieveAttrTypeInfo",
    1021            2 :             "retrieveEntityMap",
    1022            2 :             "updateEntityMap",
    1023            2 :             "deleteEntityMap",
    1024            2 :             "createEntityMapQueryEntity",
    1025            2 :             "retrieveContextSourceIdentity",
    1026            2 :         ] {
    1027           32 :             assert_ne!(m & bit(op), 0, "redirectionOps is missing {op}");
    1028              :         }
    1029            2 :         assert_eq!(m.count_ones(), 23, "Table 4.20-2 lists 23 operations");
    1030              :         // and NOT the members the table leaves out
    1031           10 :         for op in [
    1032            2 :             "createSubscription",
    1033            2 :             "queryBatch",
    1034            2 :             "createBatch",
    1035            2 :             "retrieveTemporal",
    1036            2 :             "createEntityMapQueryTemporal",
    1037            2 :         ] {
    1038           10 :             assert_eq!(m & bit(op), 0, "{op} is not a redirectionOp");
    1039              :         }
    1040            2 :     }
    1041              : 
    1042              :     /// The bit position IS stored data (`csource_index.ops` is a `bigint`), so
    1043              :     /// the list can only grow to 63 entries; at 64 the shift silently writes a
    1044              :     /// wrong mask no migration could tell from a real one.
    1045              :     #[test]
    1046            2 :     fn the_operation_list_still_fits_the_bitmask() {
    1047            2 :         assert!(OPERATION_NAMES.len() < 64, "ops bitmask is i64");
    1048              :         // every name is unique: a duplicate would give one operation two bits
    1049            2 :         let mut seen = OPERATION_NAMES.to_vec();
    1050            2 :         seen.sort_unstable();
    1051            2 :         seen.dedup();
    1052            2 :         assert_eq!(
    1053            2 :             seen.len(),
    1054            2 :             OPERATION_NAMES.len(),
    1055              :             "duplicate operation name"
    1056              :         );
    1057            2 :     }
    1058              : 
    1059              :     /// The explosion ceiling has to bound the shape its own doc comment names
    1060              :     /// — a document written through a path with no cardinality validation (a
    1061              :     /// restored dump, an importer). ONE entity with a huge `propertyNames` is
    1062              :     /// exactly that shape, and a per-entity check never sees it twice.
    1063              :     #[test]
    1064            2 :     fn the_index_ceiling_bounds_one_entity_too() {
    1065        21000 :         let names: Vec<String> = (0..MAX_INDEX_ROWS + 500).map(|i| format!("p{i}")).collect();
    1066            2 :         let reg = json!({
    1067            2 :             "endpoint": "http://cs.example:9090",
    1068            2 :             "information": [{"entities": [{"type": "T"}], "propertyNames": names}]
    1069              :         });
    1070            2 :         assert_eq!(index_rows(&reg).len(), MAX_INDEX_ROWS);
    1071              :         // and across many entities, where it already held
    1072            2 :         let many: Vec<Value> = (0..MAX_INDEX_ROWS + 500)
    1073        21000 :             .map(|i| json!({"id": format!("urn:e:{i}")}))
    1074            2 :             .collect();
    1075            2 :         let reg = json!({"endpoint": "e", "information": [{"entities": many}]});
    1076            2 :         assert_eq!(index_rows(&reg).len(), MAX_INDEX_ROWS);
    1077            2 :     }
    1078              : 
    1079              :     #[test]
    1080            2 :     fn index_rows_explode_information() {
    1081              :         // 2 entities × (1 property + 1 relationship) = 4 rows
    1082            2 :         let reg = json!({
    1083            2 :             "endpoint": "http://cs.example:9090",
    1084            2 :             "mode": "exclusive",
    1085            2 :             "information": [{
    1086            2 :                 "entities": [
    1087            2 :                     {"id": "urn:a", "type": "T1"},
    1088            2 :                     {"idPattern": "urn:.*", "type": "T2"}
    1089              :                 ],
    1090            2 :                 "propertyNames": ["speed"],
    1091            2 :                 "relationshipNames": ["isParked"]
    1092              :             }],
    1093            2 :             "expiresAt": "2030-01-01T00:00:00Z"
    1094              :         });
    1095            2 :         let rows = index_rows(&reg);
    1096            2 :         assert_eq!(rows.len(), 4);
    1097            2 :         assert!(rows
    1098            2 :             .iter()
    1099            8 :             .all(|r| r["endpoint"] == "http://cs.example:9090"
    1100            8 :                 && r["mode"] == 3
    1101            8 :                 && r["expires_at"] == "2030-01-01T00:00:00Z"));
    1102            2 :         assert!(rows
    1103            2 :             .iter()
    1104            2 :             .any(|r| r["entity_id"] == "urn:a" && r["property_name"] == "speed"));
    1105            2 :         assert!(rows
    1106            2 :             .iter()
    1107            8 :             .any(|r| r["id_pattern"] == "urn:.*" && r["relationship_name"] == "isParked"));
    1108              :         // attribute-less info: one row per entity, both attr columns NULL
    1109            2 :         let bare = json!({"endpoint": "e", "information": [{"entities": [{"type": "T"}]}]});
    1110            2 :         let rows = index_rows(&bare);
    1111            2 :         assert_eq!(rows.len(), 1);
    1112            2 :         assert!(rows[0]["property_name"].is_null() && rows[0]["relationship_name"].is_null());
    1113            2 :     }
    1114              : }
        

Generated by: LCOV version 2.0-1