LCOV - code coverage report
Current view: top level - antares-sql/src/store/pg - temporal.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.2 % 1699 1652
Test Date: 2026-11-02 13:21:27 Functions: 64.5 % 493 318

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! PgStore slice three: the temporal store over
       3              : //! `attr_instances` ROWS. The 0002
       4              : //! bridge doc is gone — `temporal_entities` holds only the small `meta`
       5              : //! document; every instance lives as a row, reads RECONSTRUCT the doc shape
       6              : //! the API layer consumes (so window()/aggregation/presentation are
       7              : //! untouched), and writes are deltas, never a full-history rewrite.
       8              : //!
       9              : //! What this buys: the hypertable/partition
      10              : //! machinery acts on the data queries actually read; retention shortens
      11              : //! query results; instance pruning and entity paging run in SQL with the
      12              : //! `(tenant_id, entity_id, attr_id, observed_at, instance_id)` index (0017).
      13              : 
      14              : use antares_model::TenantId;
      15              : use serde_json::Value;
      16              : use sqlx::postgres::PgPool;
      17              : use sqlx::Row;
      18              : 
      19              : pub struct PgTemporalStore {
      20              :     pool: PgPool,
      21              :     /// Set when this instance serves only the temporal seam: it never holds
      22              :     /// the entities, so the append guard must not look for them here.
      23              :     pub temporal_only: bool,
      24              :     keys: Keys,
      25              : }
      26              : 
      27              : /// The integer history keys' process state (docs/src/storage.md
      28              : /// "Integer history keys").
      29              : #[derive(Default)]
      30              : struct Keys {
      31              :     /// `history_cutover`, cached once set: only a migration sets it, but a
      32              :     /// pod that started before a rolling deploy's migration sees it unset, so
      33              :     /// an unset value is read again on every write until it is set.
      34              :     /// A test store pins it with `PgTemporalStore::with_cutover`.
      35              :     cutover: tokio::sync::OnceCell<Option<chrono::DateTime<chrono::Utc>>>,
      36              :     /// (Tenant, IRI) pairs a committed `iri_ids` row holds. A write sends the
      37              :     /// dictionary insert only for an IRI missing here, so a warm process
      38              :     /// sends no extra statement. An IRI this process inserted is added only
      39              :     /// when a later write finds it stored: the inserting transaction may still
      40              :     /// roll back. The ids themselves come from `iri_no` inside each
      41              :     /// statement, so a stale entry fails a write (23502) instead of keying a
      42              :     /// row by an id that names nothing.
      43              :     known: std::sync::Mutex<Known>,
      44              :     /// Which writes keep short history: their rows go to the
      45              :     /// unlogged child of `attr_rows`, whatever the cut-over.
      46              :     short: std::sync::RwLock<std::sync::Arc<antares_store::ShortHistory>>,
      47              : }
      48              : 
      49              : /// Pairs one generation of [`Known`] holds before it ages. Two generations
      50              : /// hold at most twice this: about 150 B per pair (two `String`s with a
      51              : /// 60-byte IRI, the table slot), so ~30 MB per process at the bound.
      52              : const KNOWN_GEN: usize = 100_000;
      53              : 
      54              : /// The known (Tenant, IRI) set, bounded as a two-generation LRU: a pair
      55              : /// found in `old` moves to `new`; when `new` is full it becomes `old` and
      56              : /// the previous `old` is dropped. A working set under [`KNOWN_GEN`] pairs
      57              : /// never misses; a pair aged out costs its next write one
      58              : /// `INSERT ... ON CONFLICT DO NOTHING` round trip, after which it is known
      59              : /// again.
      60              : #[derive(Default)]
      61              : struct Known {
      62              :     new: std::collections::HashSet<(String, String)>,
      63              :     old: std::collections::HashSet<(String, String)>,
      64              : }
      65              : 
      66              : impl Known {
      67          184 :     fn contains(&mut self, k: &(String, String)) -> bool {
      68          184 :         if self.new.contains(k) {
      69           52 :             return true;
      70          132 :         }
      71          132 :         match self.old.take(k) {
      72            4 :             Some(k) => {
      73            4 :                 self.insert(k);
      74            4 :                 true
      75              :             }
      76          128 :             None => false,
      77              :         }
      78          184 :     }
      79              : 
      80       400018 :     fn insert(&mut self, k: (String, String)) {
      81       400018 :         if self.new.len() >= KNOWN_GEN {
      82            4 :             self.old = std::mem::take(&mut self.new);
      83       400014 :         }
      84       400018 :         self.new.insert(k);
      85       400018 :     }
      86              : 
      87           28 :     fn retain(&mut self, mut keep: impl FnMut(&(String, String)) -> bool) {
      88           28 :         self.new.retain(&mut keep);
      89           28 :         self.old.retain(keep);
      90           28 :     }
      91              : }
      92              : 
      93              : impl Keys {
      94           83 :     fn known(&self) -> std::sync::MutexGuard<'_, Known> {
      95           83 :         self.known
      96           83 :             .lock()
      97           83 :             .unwrap_or_else(std::sync::PoisonError::into_inner)
      98           83 :     }
      99              : 
     100         4814 :     async fn cutover(
     101         4814 :         &self,
     102         4814 :         tx: &mut sqlx::PgConnection,
     103         4814 :     ) -> Result<Option<chrono::DateTime<chrono::Utc>>, sqlx::Error> {
     104         4814 :         if let Some(at) = self.cutover.get() {
     105           28 :             return Ok(*at);
     106         4786 :         }
     107         4786 :         let at = sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
     108         4786 :             "SELECT at FROM history_cutover",
     109         4786 :         )
     110         4786 :         .fetch_optional(&mut *tx)
     111         4786 :         .await
     112         4786 :         .map(Option::flatten)?;
     113         4786 :         if at.is_some() {
     114            1 :             // a racing writer may have set it first: both read the same row
     115            1 :             let _ = self.cutover.set(at);
     116         4785 :         }
     117         4786 :         Ok(at)
     118         4814 :     }
     119              : 
     120              :     /// Writes the `iri_ids` rows of `iris` the dictionary lacks, inside the
     121              :     /// caller's transaction (RLS checks its Tenant), in IRI order so two
     122              :     /// writers wait on each other's first sights in one order. `ensured`
     123              :     /// holds what this transaction already wrote or found.
     124           41 :     async fn ensure(
     125           41 :         &self,
     126           41 :         tx: &mut sqlx::PgConnection,
     127           41 :         tenant: &TenantId,
     128           41 :         mut iris: Vec<String>,
     129           41 :         ensured: &mut std::collections::HashSet<(String, String)>,
     130           41 :     ) -> Result<(), sqlx::Error> {
     131              :         {
     132           41 :             let mut known = self.known();
     133          176 :             iris.retain(|i| {
     134          176 :                 let k = (tenant.as_str().to_owned(), i.clone());
     135          176 :                 !known.contains(&k) && !ensured.contains(&k)
     136          176 :             });
     137              :         }
     138           41 :         if iris.is_empty() {
     139           25 :             return Ok(());
     140           16 :         }
     141           16 :         iris.sort_unstable();
     142           16 :         iris.dedup();
     143           16 :         let fresh: std::collections::HashSet<String> = sqlx::query_scalar(
     144           16 :             "INSERT INTO iri_ids (tenant_id, iri) SELECT $1, unnest($2::text[])
     145           16 :              ON CONFLICT DO NOTHING RETURNING iri",
     146           16 :         )
     147           16 :         .bind(tenant.as_str())
     148           16 :         .bind(&iris)
     149           16 :         .fetch_all(&mut *tx)
     150           16 :         .await?
     151           16 :         .into_iter()
     152           16 :         .collect();
     153           16 :         let mut known = self.known();
     154           32 :         for i in iris {
     155           32 :             let k = (tenant.as_str().to_owned(), i);
     156           32 :             if !fresh.contains(&k.1) {
     157           12 :                 known.insert(k.clone());
     158           20 :             }
     159           32 :             ensured.insert(k);
     160              :         }
     161           16 :         Ok(())
     162           41 :     }
     163              : 
     164              :     /// Drops what this process knows of `tenant`'s dictionary: its purge, or
     165              :     /// a keyed write that found an IRI gone (another pod's purge).
     166           26 :     fn forget(&self, tenant: &TenantId) {
     167           26 :         self.known().retain(|(t, _)| t != tenant.as_str());
     168           26 :     }
     169              : 
     170              :     /// Whether a write of an Entity of `types` whose current state is
     171              :     /// `shell` keeps short history.
     172         5013 :     fn is_short(&self, tenant: &TenantId, types: &[String], shell: &Value) -> bool {
     173         5013 :         self.short
     174         5013 :             .read()
     175         5013 :             .unwrap_or_else(std::sync::PoisonError::into_inner)
     176         5013 :             .is_short(
     177         5013 :                 tenant.as_str(),
     178         5013 :                 types,
     179         5013 :                 shell.get(antares_store::HISTORY_RETENTION).is_some(),
     180         5013 :             )
     181         5013 :     }
     182              : }
     183              : 
     184              : /// The dictionary IRIs a write of `doc` for Entity `id` keys its rows by:
     185              : /// the Entity id and its Attribute names.
     186            2 : fn iris_of(id: &str, doc: &Value) -> Vec<String> {
     187            2 :     let mut out = vec![id.to_owned()];
     188            2 :     if let Some(o) = doc.as_object() {
     189            2 :         out.extend(
     190            2 :             o.iter()
     191           10 :                 .filter(|(k, v)| v.is_array() && !DOC_META.contains(&k.as_str()))
     192            2 :                 .map(|(k, _)| k.clone()),
     193              :         );
     194            0 :     }
     195            2 :     out
     196            2 : }
     197              : 
     198              : /// 4.22: "expiresAt is defined as the system temporal Property at which a
     199              : /// certain Entity, Property or Relationship shall become invalid" — an
     200              : /// expired temporal entity is absent from every read, ahead of the retention
     201              : /// sweep. One shared literal so `query`, its count fallback and `get_range`
     202              : /// can never disagree.
     203              : ///
     204              : /// The stamp is jsonb TEXT, so it goes through `try_timestamptz`
     205              : /// (0001_init.sql): a bare cast RAISES on anything it cannot parse, and
     206              : /// these reads are
     207              : /// tenant-wide — one bad stamp would take down the whole tenant's temporal
     208              : /// API rather than hide one entity. An unusable stamp reads as no expiry, the
     209              : /// same direction the memory arm takes in `filter::expired_at`.
     210              : const NOT_EXPIRED: &str =
     211              :     "(try_timestamptz(m.meta->>'expiresAt') IS NULL OR try_timestamptz(m.meta->>'expiresAt') > now())";
     212              : 
     213              : /// The temporal tables carry the same four extracted columns as `entities`,
     214              : /// so they are read by the same function (`super::entity::types_scopes_stamps`)
     215              : /// rather than a second copy of it.
     216              : use super::entity::types_scopes_stamps as extract;
     217              : 
     218              : /// Entity-doc members that are NOT temporal attributes. `scope` may itself be
     219              : /// instance-shaped (deletion instances, 020_19/20) — it lives in `meta`
     220              : /// verbatim either way, exactly as the bridge kept it unpruned.
     221              : const DOC_META: &[&str] = antares_model::ENTITY_META_KEYS;
     222              : 
     223              : /// The meta an append leaves on a `temporal_entities` row that exists: the
     224              : /// shell's (`EXCLUDED.meta`), but a stored `scope` that holds instances —
     225              : /// its recorded history, which `push_scope` keeps in `meta` — stays.
     226              : const KEPT_META: &str = "CASE WHEN jsonb_typeof(temporal_entities.meta->'scope'->0) = 'object'
     227              :            THEN EXCLUDED.meta || jsonb_build_object('scope', temporal_entities.meta->'scope')
     228              :            ELSE EXCLUDED.meta END";
     229              : 
     230              : /// The meta-only document stored in `temporal_entities.meta`.
     231         3040 : fn meta_of(doc: &Value) -> Value {
     232         3040 :     let mut m = serde_json::Map::new();
     233         3040 :     if let Some(obj) = doc.as_object() {
     234        24320 :         for k in DOC_META {
     235        24320 :             if let Some(v) = obj.get(*k) {
     236        11617 :                 m.insert((*k).to_owned(), v.clone());
     237        12703 :             }
     238              :         }
     239            0 :     }
     240         3040 :     Value::Object(m)
     241         3040 : }
     242              : 
     243              : /// Decompose a doc's attribute arrays into row JSON for the multi-row
     244              : /// INSERT. The `observed_at` string is derived ONLY from the instance
     245              : /// document, so decomposing a reconstructed doc reproduces byte-identical
     246              : /// keys (what the mutate diff relies on).
     247         3332 : fn decompose(doc: &Value) -> Vec<Value> {
     248         3332 :     let mut rows: Vec<Value> = Vec::new();
     249         3332 :     if let Some(obj) = doc.as_object() {
     250        11766 :         for (attr, instances) in obj {
     251        11766 :             if DOC_META.contains(&attr.as_str()) {
     252         4808 :                 continue;
     253         6958 :             }
     254         6958 :             let Some(arr) = instances.as_array() else {
     255           38 :                 continue;
     256              :             };
     257        17151 :             for i in arr {
     258       126271 :                 let s = |k: &str| i.get(k).and_then(Value::as_str);
     259              :                 // 4.6.3: a comma seconds-fraction is legal in a request and
     260              :                 // these stamps go straight into `::timestamptz` casts, which
     261              :                 // refuse it — and the raise lands in the temporal drain,
     262              :                 // which absorbs it, so the whole request's history would be
     263              :                 // lost with a 2xx already returned. Timestamps only:
     264              :                 // `datasetId` is a URI, where a comma is an ordinary
     265              :                 // character and rewriting it would corrupt the id.
     266        91969 :                 let ts = |k: &str| s(k).map(antares_store::filter::canonical_datetime);
     267        17151 :                 let Some(instance_id) = s("instanceId") else {
     268            0 :                     continue; // stamped by the API layer; belt only
     269              :                 };
     270              :                 // observed_at falls back through the instance's own
     271              :                 // timestamps — deletion instances carry only deletedAt and
     272              :                 // must NOT collapse onto the epoch (retention would reap them)
     273        17151 :                 let observed = ts("observedAt")
     274        17151 :                     .or_else(|| ts("modifiedAt"))
     275        17151 :                     .or_else(|| ts("deletedAt"))
     276        17151 :                     .or_else(|| ts("createdAt"))
     277        17151 :                     .unwrap_or(std::borrow::Cow::Borrowed("1970-01-01T00:00:00Z"));
     278              :                 // Provenance is a column, never data.
     279        17151 :                 let mut data = i.clone();
     280        17151 :                 let provenance = antares_store::take_provenance(&mut data);
     281              :                 // the `instance_id` column holds it: ~70 B a row
     282        17151 :                 if let Some(o) = data.as_object_mut() {
     283        17151 :                     o.remove("instanceId");
     284        17151 :                 }
     285        17151 :                 rows.push(serde_json::json!({
     286        17151 :                     "attr_id": attr,
     287        17151 :                     "instance_id": instance_id,
     288        17151 :                     "dataset_id": s("datasetId"),
     289        17151 :                     "observed_at": observed,
     290        17151 :                     "created_at": ts("createdAt").unwrap_or_else(|| observed.clone()),
     291        17151 :                     "modified_at": ts("modifiedAt").unwrap_or(observed),
     292        17151 :                     "deleted_at": ts("deletedAt"),
     293              :                     // 4.22: the reap's index key (migration 0030); a stamp
     294              :                     // chrono cannot read stays NULL there, and the daily
     295              :                     // scan of `data` reaps it
     296        17151 :                     "expires_at": ts("expiresAt")
     297        17151 :                         .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t).ok())
     298        17151 :                         .map(|t| t.to_rfc3339()),
     299        17151 :                     "data": data,
     300        17151 :                     "provenance": provenance,
     301              :                 }));
     302              :             }
     303              :         }
     304            0 :     }
     305         3332 :     rows
     306         3332 : }
     307              : 
     308              : /// One attribute's instances of history rows `alias`, as the reads group
     309              : /// them: a row stores its instanceId in the `instance_id` column
     310              : /// only, so an attribute comes back as `[instances, instance ids]` in one
     311              : /// order, which [`unpack`] zips into the instances. A row written before
     312              : /// the compact layout still holds `instanceId` in `data`, equal to the column. Building
     313              : /// each row in SQL instead (`jsonb_build_array` or `||` per row) cost
     314              : /// Postgres 10-35 % more than reading the old documents (240k rows).
     315         1666 : fn insts_and_ids(alias: &str) -> String {
     316         1666 :     let key = format!("{alias}.created_at, {alias}.observed_at, {alias}.instance_id");
     317         1666 :     format!(
     318              :         "jsonb_build_array(jsonb_agg({alias}.data ORDER BY {key}), \
     319              :          to_jsonb(array_agg({alias}.instance_id ORDER BY {key})))"
     320              :     )
     321         1666 : }
     322              : 
     323              : /// The whole instance document of row `alias`, built in SQL, where a row is
     324              : /// copied (attr_versions keeps whole documents) or read rarely. The column
     325              : /// equals `data`'s own instanceId on a row written.
     326         2945 : fn restamp(alias: &str) -> String {
     327         2945 :     format!("({alias}.data || jsonb_build_object('instanceId', {alias}.instance_id))")
     328         2945 : }
     329              : 
     330              : /// The history rows of one Entity over both parents (docs/src/storage.md
     331              : /// "Integer history keys"): `attr_instances` and, in its row type, `attr_rows`
     332              : /// (`attr_rows_of`, migration 0020), which stays empty until the cut-over. The
     333              : /// caller's predicates reach each arm's btree; an ordered read with a LIMIT
     334              : /// writes its arms out instead, each limited itself.
     335         1640 : fn history(tenant: &str, entity: &str) -> String {
     336         1640 :     format!(
     337              :         "(SELECT * FROM attr_instances WHERE tenant_id = {tenant} AND entity_id = {entity} \
     338              :          UNION ALL SELECT * FROM attr_rows_of({tenant}, {entity}))"
     339              :     )
     340         1640 : }
     341              : 
     342              : /// The Entity `m` has a history row passing `preds` (on alias `ai`): the
     343              : /// page qualification. Each parent is probed on its own along its btree,
     344              : /// `dir` from the end the window opens towards, so the first row read is
     345              : /// most likely in the window. An EXISTS over `history` read each Entity's
     346              : /// rows oldest first from the widened column bound and fetched every row the
     347              : /// text predicate then refused: ~115 rows an Entity on the bike-share
     348              : /// history, past the 30 s statement timeout for 577 Entities.
     349              : /// `attrs`, the bind of the Attribute IRIs a policy shows, keeps the probe to
     350              : /// their rows.
     351          251 : pub fn has_history(preds: &str, dir: &str, attrs: Option<usize>) -> String {
     352          251 :     let (x, r) = attrs.map_or_else(Default::default, |n| {
     353            9 :         (
     354            9 :             format!(" AND ai.attr_id = ANY(${n})"),
     355            9 :             format!(
     356            9 :                 " AND ai.attr_no IN (SELECT i.id FROM iri_ids i \
     357            9 :                  WHERE i.tenant_id = m.tenant_id AND i.iri = ANY(${n}))"
     358            9 :             ),
     359            9 :         )
     360            9 :     });
     361          251 :     format!(
     362              :         "((SELECT true FROM attr_instances ai \
     363              :            WHERE ai.tenant_id = m.tenant_id AND ai.entity_id = m.id{x}{preds} \
     364              :            ORDER BY ai.attr_id {dir}, ai.observed_at {dir} LIMIT 1) IS NOT NULL \
     365              :          OR (SELECT true FROM attr_rows ai \
     366              :            WHERE ai.tenant_id = m.tenant_id AND ai.entity_no = iri_no(m.tenant_id, m.id){r}{preds} \
     367              :            ORDER BY ai.attr_no {dir}, ai.observed_at {dir} LIMIT 1) IS NOT NULL)"
     368              :     )
     369          251 : }
     370              : 
     371              : /// Turns each attribute's `[instances, instance ids]` that
     372              : /// [`insts_and_ids`] returned back into its instances.
     373         1809 : fn unpack(doc: &mut Value) {
     374         1809 :     let Some(o) = doc.as_object_mut() else {
     375            2 :         return;
     376              :     };
     377         8742 :     for attr in o.values_mut() {
     378         8742 :         let Some([insts, ids]) = attr.as_array_mut().map(Vec::as_mut_slice) else {
     379         5660 :             continue;
     380              :         };
     381         3082 :         let (Some(list), Some(ids)) = (insts.as_array_mut(), ids.as_array_mut()) else {
     382           29 :             continue;
     383              :         };
     384         3053 :         if list.len() != ids.len() || !ids.iter().all(Value::is_string) {
     385            4 :             continue;
     386         3049 :         }
     387        26022 :         for (inst, id) in list.iter_mut().zip(ids.iter_mut()) {
     388        26022 :             if let Some(i) = inst.as_object_mut() {
     389        26022 :                 i.insert("instanceId".to_owned(), id.take());
     390        26022 :             }
     391              :         }
     392         3049 :         let list = insts.take();
     393         3049 :         *attr = list;
     394              :     }
     395         1809 : }
     396              : 
     397              : /// `ANTARES_TEMPORAL_DEDUP=off` records a row per write again; anything else,
     398              : /// or unset, records a new instance only (see [`unseen`]).
     399         2118 : fn temporal_dedup() -> bool {
     400              :     static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
     401         2118 :     *ON.get_or_init(|| std::env::var("ANTARES_TEMPORAL_DEDUP").as_deref() != Ok("off"))
     402         2118 : }
     403              : 
     404              : /// An instance's stamps: two instances that differ only in these are the same
     405              : /// observation (CIM 009 4.5: a static Property is one instance).
     406              : const STAMPS: &str = "ARRAY['instanceId','createdAt','modifiedAt']";
     407              : 
     408              : /// The provenance of a row `e` that corrects the stored one.
     409              : const CORRECTED: &str = "jsonb_set(COALESCE(NULLIF(e->'provenance', 'null'::jsonb), '{}'::jsonb), '{operation}', '\"correct\"')";
     410              : 
     411              : /// The geometry column of an instance row `e` (see `insert_rows`).
     412              : const GEO_VALUE: &str = "CASE WHEN jsonb_typeof(e->'data'->'value') = 'object'
     413              :                       AND e->'data'->'value'->>'type' IN
     414              :                           ('Point','MultiPoint','LineString','MultiLineString',
     415              :                            'Polygon','MultiPolygon')
     416              :                      THEN try_geomfromgeojson((e->'data'->'value')::text) END";
     417              : 
     418              : /// Whether the newest stored instance of row `e`'s (attribute, datasetId) is
     419              : /// another instance with the same value: one row read by the index
     420              : /// (tenant_id, entity_id, attr_id, observed_at, …) read backwards, false when none is stored.
     421              : /// The current and the last week first: unbounded, the newest row is
     422              : /// a merge over every weekly partition, one cold index descent each. A row
     423              : /// found there is the newest of all; only an attribute silent for longer
     424              : /// reads the whole history, and COALESCE runs that probe for it alone.
     425              : /// Each parent is its own ordered arm with its own LIMIT: Postgres
     426              : /// merges UNION ALL arms in index order only when each arm is limited itself.
     427              : const NEWEST_REPEATS: &str = "COALESCE((
     428              :              SELECT x.instance_id <> e->>'instance_id'
     429              :                     AND x.data - ARRAY['instanceId','createdAt','modifiedAt']
     430              :                         = (e->'data') - ARRAY['instanceId','createdAt','modifiedAt']
     431              :              FROM ((SELECT x.instance_id, x.data, x.observed_at FROM attr_instances x
     432              :                     WHERE x.tenant_id = $1 AND x.entity_id = $2 AND x.attr_id = e->>'attr_id'
     433              :                       AND x.dataset_id IS NOT DISTINCT FROM e->>'dataset_id'
     434              :                       AND x.observed_at >= date_trunc('week', now()) - interval '1 week'
     435              :                     ORDER BY x.observed_at DESC LIMIT 1)
     436              :                    UNION ALL
     437              :                    (SELECT x.instance_id, x.data, x.observed_at FROM attr_rows_at($1, $2, e->>'attr_id') x
     438              :                     WHERE x.dataset_id IS NOT DISTINCT FROM e->>'dataset_id'
     439              :                       AND x.observed_at >= date_trunc('week', now()) - interval '1 week'
     440              :                     ORDER BY x.observed_at DESC LIMIT 1)) x
     441              :              ORDER BY x.observed_at DESC LIMIT 1), (
     442              :              SELECT x.instance_id <> e->>'instance_id'
     443              :                     AND x.data - ARRAY['instanceId','createdAt','modifiedAt']
     444              :                         = (e->'data') - ARRAY['instanceId','createdAt','modifiedAt']
     445              :              FROM ((SELECT x.instance_id, x.data, x.observed_at FROM attr_instances x
     446              :                     WHERE x.tenant_id = $1 AND x.entity_id = $2 AND x.attr_id = e->>'attr_id'
     447              :                       AND x.dataset_id IS NOT DISTINCT FROM e->>'dataset_id'
     448              :                     ORDER BY x.observed_at DESC LIMIT 1)
     449              :                    UNION ALL
     450              :                    (SELECT x.instance_id, x.data, x.observed_at FROM attr_rows_at($1, $2, e->>'attr_id') x
     451              :                     WHERE x.dataset_id IS NOT DISTINCT FROM e->>'dataset_id'
     452              :                     ORDER BY x.observed_at DESC LIMIT 1)) x
     453              :              ORDER BY x.observed_at DESC LIMIT 1), false)";
     454              : 
     455              : /// The earliest and latest `observed_at` of `rows`, bound beside every probe
     456              : /// that matches a row by its `observed_at`. The equality reaches
     457              : /// the partition key only through a join, so a generic plan reads every
     458              : /// weekly partition of the Entity; a bound parameter prunes them to the weeks
     459              : /// the batch writes before the statement runs. A stamp chrono cannot read
     460              : /// leaves the range open, as the cast in the statement judges it.
     461         2925 : fn observed_range(rows: &[Value]) -> (String, String) {
     462         2925 :     let mut range: Option<(
     463         2925 :         chrono::DateTime<chrono::FixedOffset>,
     464         2925 :         chrono::DateTime<chrono::FixedOffset>,
     465         2925 :     )> = None;
     466        14227 :     for r in rows {
     467        14227 :         let Some(t) = r["observed_at"]
     468        14227 :             .as_str()
     469        14227 :             .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
     470              :         else {
     471            0 :             return ("-infinity".into(), "infinity".into());
     472              :         };
     473        14227 :         range = Some(match range {
     474         2925 :             None => (t, t),
     475        11302 :             Some((lo, hi)) => (lo.min(t), hi.max(t)),
     476              :         });
     477              :     }
     478         2925 :     match range {
     479         2925 :         Some((lo, hi)) => (lo.to_rfc3339(), hi.to_rfc3339()),
     480            0 :         None => ("-infinity".into(), "infinity".into()),
     481              :     }
     482         2925 : }
     483              : 
     484              : /// The rows of one append that are new observations: of rows with an
     485              : /// `observedAt`, one per (attribute, datasetId, observedAt), and of rows
     486              : /// without, each one that differs but for its stamps from the row kept
     487              : /// before it in its (attribute, datasetId) — the rule NEWEST_REPEATS applies
     488              : /// against the table, so a value that comes back after another is recorded.
     489              : /// A row kept after another of its series is marked `follows`: its
     490              : /// predecessor is that row, never the table's newest, and `insert_rows`
     491              : /// holds only a series' first row against what the table already holds.
     492         2122 : fn unseen(rows: Vec<Value>) -> Vec<Value> {
     493         2122 :     let mut observed = std::collections::HashSet::new();
     494         2122 :     let mut last: std::collections::HashMap<(String, String), Value> =
     495         2122 :         std::collections::HashMap::new();
     496         2122 :     rows.into_iter()
     497         4759 :         .filter_map(|mut r| {
     498        19036 :             let field = |k: &str| r.get(k).cloned().unwrap_or(Value::Null);
     499         4759 :             let series = (
     500         4759 :                 field("attr_id").to_string(),
     501         4759 :                 field("dataset_id").to_string(),
     502         4759 :             );
     503         4759 :             let at = field("observed_at").to_string();
     504         4759 :             let mut d = field("data");
     505         4759 :             let has_observed_at = d.get("observedAt").is_some();
     506         4759 :             if let Some(o) = d.as_object_mut() {
     507        14277 :                 for k in ["instanceId", "createdAt", "modifiedAt"] {
     508        14277 :                     o.remove(k);
     509        14277 :                 }
     510            0 :             }
     511         4759 :             if has_observed_at {
     512          963 :                 if !observed.insert((series.0.clone(), series.1.clone(), at)) {
     513            2 :                     return None;
     514          961 :                 }
     515         3796 :             } else if last.get(&series) == Some(&d) {
     516            2 :                 return None;
     517         3794 :             }
     518         4755 :             if last.insert(series, d).is_some() {
     519           19 :                 if let Some(o) = r.as_object_mut() {
     520           19 :                     o.insert("follows".into(), Value::Bool(true));
     521           19 :                 }
     522         4736 :             }
     523         4755 :             Some(r)
     524         4759 :         })
     525         2122 :         .collect()
     526         2122 : }
     527              : 
     528              : /// How [`insert_rows`] writes one batch: `dedup` records new observations
     529              : /// only, `short` writes every row to the unlogged child.
     530              : #[derive(Clone, Copy)]
     531              : struct Route {
     532              :     dedup: bool,
     533              :     short: bool,
     534              : }
     535              : 
     536              : /// Multi-row instance upsert inside the caller's transaction. With `dedup`
     537              : /// (the auto-recording append) a row is written only when it is a new
     538              : /// observation: an instance with an `observedAt` already stored for
     539              : /// its (attribute, datasetId) is updated in place, keeping its instanceId,
     540              : /// and one without is skipped when the newest stored instance holds the same
     541              : /// value. tenant_id is part of every comparison.
     542              : ///
     543              : /// A row observed from the cut-over on goes to `attr_rows`, any
     544              : /// earlier one to `attr_instances`, a late write included: one observation
     545              : /// always meets its stored instance in the same parent. Every row of a
     546              : /// `short` write goes to `attr_rows` as `short`, its unlogged child.
     547         3028 : async fn insert_rows(
     548         3028 :     tx: &mut sqlx::PgConnection,
     549         3028 :     keys: &Keys,
     550         3028 :     ensured: &mut std::collections::HashSet<(String, String)>,
     551         3028 :     tenant: &TenantId,
     552         3028 :     entity_id: &str,
     553         3028 :     rows: Vec<Value>,
     554         3028 :     Route { dedup, short }: Route,
     555         3028 : ) -> Result<(), sqlx::Error> {
     556         3028 :     let dedup = dedup && temporal_dedup();
     557         3028 :     let rows = if dedup { unseen(rows) } else { rows };
     558         3028 :     if rows.is_empty() {
     559          104 :         return Ok(());
     560         2924 :     }
     561              :     // The Attributes given an instance in a dataset (migration 0009): lastN
     562              :     // keeps every row of these, so the read needs to know them.
     563         2924 :     let mut in_dataset: Vec<String> = rows
     564         2924 :         .iter()
     565        14227 :         .filter(|r| r["dataset_id"].is_string())
     566         2924 :         .filter_map(|r| r["attr_id"].as_str().map(str::to_owned))
     567         2924 :         .collect();
     568         2924 :     in_dataset.sort_unstable();
     569         2924 :     in_dataset.dedup();
     570              :     // The newest instance whose observedAt the rank may order unlike
     571              :     // observed_at (migration 0028): lastN reads past its N newest only there.
     572         2924 :     let offset_until = rows
     573         2924 :         .iter()
     574        14227 :         .filter(|r| !utc_observed(&r["data"]))
     575         4020 :         .filter_map(|r| r["observed_at"].as_str())
     576         4020 :         .filter_map(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
     577         2924 :         .max();
     578         2924 :     let (keyed, text): (Vec<Value>, Vec<Value>) = if short {
     579           27 :         (rows, Vec::new())
     580              :     } else {
     581         2897 :         let cutover = keys.cutover(tx).await?;
     582        14140 :         rows.into_iter().partition(|r| is_keyed(r, cutover))
     583              :     };
     584         2924 :     if !text.is_empty() {
     585         2885 :         insert_text(tx, tenant, entity_id, text, dedup).await?;
     586           39 :     }
     587         2924 :     if !keyed.is_empty() {
     588           40 :         let mut iris = vec![entity_id.to_owned()];
     589           40 :         iris.extend(
     590           40 :             keyed
     591           40 :                 .iter()
     592          136 :                 .filter_map(|r| r["attr_id"].as_str().map(str::to_owned)),
     593              :         );
     594           40 :         keys.ensure(tx, tenant, iris, ensured).await?;
     595           40 :         if let Err(e) = insert_keyed(tx, tenant, entity_id, keyed, dedup, short).await {
     596              :             // an IRI the known set holds is gone: another pod purged the Tenant
     597            0 :             if matches!(&e, sqlx::Error::Database(d) if d.code().as_deref() == Some("23502")) {
     598            0 :                 keys.forget(tenant);
     599            0 :             }
     600            0 :             return Err(e);
     601           40 :         }
     602         2884 :     }
     603         2924 :     if let Some(t) = offset_until {
     604              :         // Every caller writes the Entity's temporal row before its instances.
     605         2107 :         sqlx::query(
     606         2107 :             "UPDATE temporal_entities SET offset_until = $3
     607         2107 :              WHERE tenant_id = $1 AND id = $2 AND offset_until < $3",
     608         2107 :         )
     609         2107 :         .bind(tenant.as_str())
     610         2107 :         .bind(entity_id)
     611         2107 :         .bind(t)
     612         2107 :         .execute(&mut *tx)
     613         2107 :         .await?;
     614          817 :     }
     615         2924 :     if !in_dataset.is_empty() {
     616              :         // Every caller writes the Entity's temporal row before its instances.
     617          394 :         sqlx::query(
     618          394 :             "UPDATE temporal_entities
     619          394 :              SET dataset_attrs = ARRAY(SELECT DISTINCT x FROM unnest(dataset_attrs || $3) AS x ORDER BY x)
     620          394 :              WHERE tenant_id = $1 AND id = $2 AND NOT dataset_attrs @> $3",
     621          394 :         )
     622          394 :         .bind(tenant.as_str())
     623          394 :         .bind(entity_id)
     624          394 :         .bind(&in_dataset)
     625          394 :         .execute(&mut *tx)
     626          394 :         .await?;
     627         2530 :     }
     628         2924 :     Ok(())
     629         3028 : }
     630              : 
     631              : /// Whether an instance's `observedAt` is a UTC RFC 3339 stamp, whose text
     632              : /// key (`dt_key`) orders exactly like its instant. Any other instance, one
     633              : /// without observedAt included, may rank unlike `observed_at` in lastN.
     634        14245 : fn utc_observed(data: &Value) -> bool {
     635        14245 :     data.get("observedAt")
     636        14245 :         .and_then(Value::as_str)
     637              :         // `dt_key` reads the first 19 bytes as the wall time: only the `T`
     638              :         // spelling of the separator orders with the rest
     639        14245 :         .filter(|t| t.ends_with('Z') && t.as_bytes().get(10) == Some(&b'T'))
     640        14245 :         .is_some_and(|t| {
     641        10213 :             chrono::DateTime::parse_from_rfc3339(&antares_store::filter::canonical_datetime(t))
     642        10213 :                 .is_ok()
     643        10213 :         })
     644        14245 : }
     645              : 
     646              : /// Whether row `r` is observed at or after `cutover`. A stamp chrono cannot
     647              : /// read stays in the text shape, where its cast judges it as before.
     648        14150 : fn is_keyed(r: &Value, cutover: Option<chrono::DateTime<chrono::Utc>>) -> bool {
     649        14150 :     let Some(cut) = cutover else {
     650        14062 :         return false;
     651              :     };
     652           88 :     r["observed_at"]
     653           88 :         .as_str()
     654           88 :         .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
     655           88 :         .is_some_and(|t| t >= cut)
     656        14150 : }
     657              : 
     658              : /// The `attr_rows` key of an instance id and whether it is the broker's own:
     659              : /// the uuid of `urn:ngsi-ld:Instance:<uuid>` in its canonical lowercase form,
     660              : /// which the read side prints back as is; any other id keys by its uuid v5
     661              : /// and stays in `data` as `instanceId` (docs/src/storage.md).
     662          152 : fn instance_no(id: &str) -> (uuid::Uuid, bool) {
     663          152 :     let own = id.strip_prefix("urn:ngsi-ld:Instance:").and_then(|s| {
     664          142 :         uuid::Uuid::try_parse(s)
     665          142 :             .ok()
     666          142 :             .filter(|u| u.hyphenated().to_string() == s)
     667          142 :     });
     668          152 :     match own {
     669          122 :         Some(u) => (u, true),
     670           30 :         None => (
     671           30 :             uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, id.as_bytes()),
     672           30 :             false,
     673           30 :         ),
     674              :     }
     675          152 : }
     676              : 
     677              : /// [`insert_rows`] into `attr_instances`, the text shape.
     678         2885 : async fn insert_text(
     679         2885 :     tx: &mut sqlx::PgConnection,
     680         2885 :     tenant: &TenantId,
     681         2885 :     entity_id: &str,
     682         2885 :     rows: Vec<Value>,
     683         2885 :     dedup: bool,
     684         2885 : ) -> Result<(), sqlx::Error> {
     685         2885 :     let (lo, hi) = observed_range(&rows);
     686              :     // geo_value: extracted per instance when the value LOOKS like a GeoJSON
     687              :     // geometry; try_geomfromgeojson (0001_init.sql) maps anything PostGIS rejects to
     688              :     // NULL, which the S3 prefilter treats as "reaches the evaluator".
     689         2885 :     let rows = Value::Array(rows);
     690              :     // The stored instance of the same (attribute, datasetId) a row of `e` repeats.
     691              :     // Its tenant and entity predicates stay written in each statement, where the tenant check
     692              :     // (tests/attr_instances_tenant_predicate.rs) reads them.
     693         2885 :     let same = "x.attr_id = e->>'attr_id'
     694         2885 :                 AND x.dataset_id IS NOT DISTINCT FROM e->>'dataset_id'
     695         2885 :                 AND x.instance_id <> e->>'instance_id'";
     696              :     // A row a write replaces with another value is a correction: the stored
     697              :     // version is kept in attr_versions before the row below takes the next
     698              :     // version number in place. Its two targets
     699              :     // are the two below: the same instanceId (the upsert's conflict), and with
     700              :     // `dedup` the same (attribute, datasetId, observedAt).
     701         2885 :     sqlx::query(sqlx::AssertSqlSafe(format!(
     702         2885 :         "INSERT INTO attr_versions
     703         2885 :            (tenant_id, entity_id, attr_id, instance_id, dataset_id, observed_at, version, data, provenance,
     704         2885 :             expires_at)
     705         2885 :          SELECT x.tenant_id, x.entity_id, x.attr_id, x.instance_id, x.dataset_id, x.observed_at,
     706         2885 :                 x.version, {x_data}, x.provenance, x.expires_at
     707         2885 :          FROM jsonb_array_elements($3::jsonb) AS e
     708         2885 :          JOIN attr_instances x ON x.tenant_id = $1 AND x.entity_id = $2
     709         2885 :            AND x.attr_id = e->>'attr_id' AND x.observed_at = (e->>'observed_at')::timestamptz
     710         2885 :            AND x.observed_at BETWEEN $5::timestamptz AND $6::timestamptz
     711         2885 :          WHERE (x.instance_id = e->>'instance_id'
     712         2885 :                 OR ($4 AND e->'data' ? 'observedAt'
     713         2885 :                     AND x.dataset_id IS NOT DISTINCT FROM e->>'dataset_id'))
     714         2885 :            AND x.data - {STAMPS} IS DISTINCT FROM (e->'data') - {STAMPS}
     715         2885 :          ON CONFLICT DO NOTHING",
     716         2885 :         x_data = restamp("x")
     717         2885 :     )))
     718         2885 :     .bind(tenant.as_str())
     719         2885 :     .bind(entity_id)
     720         2885 :     .bind(&rows)
     721         2885 :     .bind(dedup)
     722         2885 :     .bind(&lo)
     723         2885 :     .bind(&hi)
     724         2885 :     .execute(&mut *tx)
     725         2885 :     .await?;
     726         2885 :     if dedup {
     727              :         // Same observedAt, another value: that observation was corrected.
     728         2074 :         sqlx::query(sqlx::AssertSqlSafe(format!(
     729         2074 :             "UPDATE attr_instances x
     730         2074 :              SET data = (e->'data') - 'instanceId',
     731         2074 :                  version = x.version + 1, provenance = {CORRECTED},
     732         2074 :                  modified_at = (e->>'modified_at')::timestamptz,
     733         2074 :                  deleted_at = (e->>'deleted_at')::timestamptz, geo_value = {GEO_VALUE},
     734         2074 :                  expires_at = (e->>'expires_at')::timestamptz
     735         2074 :              FROM jsonb_array_elements($3::jsonb) AS e
     736         2074 :              WHERE x.tenant_id = $1 AND x.entity_id = $2 AND e->'data' ? 'observedAt' AND {same}
     737         2074 :                AND x.observed_at = (e->>'observed_at')::timestamptz
     738         2074 :                AND x.observed_at BETWEEN $4::timestamptz AND $5::timestamptz
     739         2074 :                AND x.data - {STAMPS} IS DISTINCT FROM (e->'data') - {STAMPS}"
     740         2074 :         )))
     741         2074 :         .bind(tenant.as_str())
     742         2074 :         .bind(entity_id)
     743         2074 :         .bind(&rows)
     744         2074 :         .bind(&lo)
     745         2074 :         .bind(&hi)
     746         2074 :         .execute(&mut *tx)
     747         2074 :         .await?;
     748          811 :     }
     749              :     // `$4` off: today's row per write. On: a row with an observedAt already
     750              :     // stored, or without one equal to the newest stored instance, is no new
     751              :     // observation; a row that `follows` another of the batch was compared
     752              :     // with that one by `unseen`. Both probes ride
     753              :     // (tenant_id, entity_id, attr_id, observed_at, …)
     754              :     // and read one row: the second is NEWEST_REPEATS, never a scan of the
     755              :     // attribute's whole history (that scan cost ~90 ms a station at a
     756              :     // month of history, and a minute's batch of 2,063 stations recorded nothing).
     757         2885 :     sqlx::query(sqlx::AssertSqlSafe(format!(
     758         2885 :         "INSERT INTO attr_instances
     759         2885 :            (tenant_id, entity_id, attr_id, instance_id, dataset_id, observed_at,
     760         2885 :             created_at, modified_at, deleted_at, data, geo_value, provenance, expires_at)
     761         2885 :          SELECT $1, $2, e->>'attr_id', e->>'instance_id', e->>'dataset_id',
     762         2885 :                 (e->>'observed_at')::timestamptz, (e->>'created_at')::timestamptz,
     763         2885 :                 (e->>'modified_at')::timestamptz, (e->>'deleted_at')::timestamptz,
     764         2885 :                 e->'data', {GEO_VALUE}, NULLIF(e->'provenance', 'null'::jsonb),
     765         2885 :                 (e->>'expires_at')::timestamptz
     766         2885 :          FROM jsonb_array_elements($3::jsonb) AS e
     767         2885 :          WHERE NOT $4 OR NOT CASE
     768         2885 :            WHEN e->'data' ? 'observedAt' THEN EXISTS (
     769         2885 :              SELECT 1 FROM attr_instances x
     770         2885 :              WHERE x.tenant_id = $1 AND x.entity_id = $2 AND {same}
     771         2885 :                AND x.observed_at = (e->>'observed_at')::timestamptz
     772         2885 :                AND x.observed_at BETWEEN $5::timestamptz AND $6::timestamptz)
     773         2885 :            ELSE NOT (e ? 'follows') AND {NEWEST_REPEATS}
     774         2885 :          END
     775         2885 :          ON CONFLICT (tenant_id, entity_id, attr_id, instance_id, observed_at)
     776         2885 :            DO UPDATE SET data = EXCLUDED.data, modified_at = EXCLUDED.modified_at,
     777         2885 :                          dataset_id = EXCLUDED.dataset_id,
     778         2885 :                          deleted_at = EXCLUDED.deleted_at,
     779         2885 :                          geo_value = EXCLUDED.geo_value,
     780         2885 :                          expires_at = EXCLUDED.expires_at,
     781         2885 :                          version = attr_instances.version + 1,
     782         2885 :                          provenance = jsonb_set(COALESCE(EXCLUDED.provenance, '{{}}'::jsonb),
     783         2885 :                                                 '{{operation}}', '\"correct\"')
     784         2885 :            WHERE attr_instances.data - {STAMPS} IS DISTINCT FROM EXCLUDED.data - {STAMPS}"
     785         2885 :     )))
     786         2885 :     .bind(tenant.as_str())
     787         2885 :     .bind(entity_id)
     788         2885 :     .bind(&rows)
     789         2885 :     .bind(dedup)
     790         2885 :     .bind(&lo)
     791         2885 :     .bind(&hi)
     792         2885 :     .execute(&mut *tx)
     793         2885 :     .await?;
     794         2885 :     Ok(())
     795         2885 : }
     796              : 
     797              : /// [`insert_rows`] into `attr_rows`: [`insert_text`]'s three statements over
     798              : /// the integer keys. `iri_no` binds the dictionary ids [`Keys::ensure`] wrote;
     799              : /// `instance_no` keys the instance, and a client's own id rides in `data`.
     800           40 : async fn insert_keyed(
     801           40 :     tx: &mut sqlx::PgConnection,
     802           40 :     tenant: &TenantId,
     803           40 :     entity_id: &str,
     804           40 :     mut rows: Vec<Value>,
     805           40 :     dedup: bool,
     806           40 :     short: bool,
     807           40 : ) -> Result<(), sqlx::Error> {
     808          136 :     for r in &mut rows {
     809          136 :         let id = r["instance_id"].as_str().unwrap_or_default().to_owned();
     810          136 :         let (no, own) = instance_no(&id);
     811          136 :         r["instance_no"] = Value::String(no.to_string());
     812          136 :         if !own {
     813           16 :             if let Some(d) = r["data"].as_object_mut() {
     814           16 :                 d.insert("instanceId".to_owned(), Value::String(id));
     815           16 :             }
     816          120 :         }
     817              :     }
     818           40 :     let (lo, hi) = observed_range(&rows);
     819           40 :     let rows = Value::Array(rows);
     820              :     // the stored instance of the same (attribute, datasetId) a row of `e` repeats
     821           40 :     let same = "x.attr_no = iri_no($1, e->>'attr_id')
     822           40 :                 AND x.dataset_id IS NOT DISTINCT FROM e->>'dataset_id'
     823           40 :                 AND x.instance_no <> (e->>'instance_no')::uuid";
     824              :     // attr_versions keeps whole documents in the text shape
     825           40 :     sqlx::query(sqlx::AssertSqlSafe(format!(
     826           40 :         "INSERT INTO attr_versions
     827           40 :            (tenant_id, entity_id, attr_id, instance_id, dataset_id, observed_at, version, data, provenance,
     828           40 :             expires_at)
     829           40 :          SELECT x.tenant_id, x.entity_id, x.attr_id, x.instance_id, x.dataset_id, x.observed_at,
     830           40 :                 x.version, {x_data}, x.provenance, x.expires_at
     831           40 :          FROM jsonb_array_elements($3::jsonb) AS e
     832           40 :          CROSS JOIN LATERAL attr_rows_at($1, $2, e->>'attr_id') x
     833           40 :          WHERE x.observed_at = (e->>'observed_at')::timestamptz
     834           40 :            AND x.observed_at BETWEEN $5::timestamptz AND $6::timestamptz
     835           40 :            AND (x.instance_id = e->>'instance_id'
     836           40 :                 OR ($4 AND e->'data' ? 'observedAt'
     837           40 :                     AND x.dataset_id IS NOT DISTINCT FROM e->>'dataset_id'))
     838           40 :            AND x.data - {STAMPS} IS DISTINCT FROM (e->'data') - {STAMPS}
     839           40 :          ON CONFLICT DO NOTHING",
     840           40 :         x_data = restamp("x")
     841           40 :     )))
     842           40 :     .bind(tenant.as_str())
     843           40 :     .bind(entity_id)
     844           40 :     .bind(&rows)
     845           40 :     .bind(dedup)
     846           40 :     .bind(&lo)
     847           40 :     .bind(&hi)
     848           40 :     .execute(&mut *tx)
     849           40 :     .await?;
     850           40 :     if dedup {
     851              :         // Same observedAt, another value: corrected in place, keeping the
     852              :         // stored instance's id (a client's one in `data`).
     853           32 :         sqlx::query(sqlx::AssertSqlSafe(format!(
     854           32 :             "UPDATE attr_rows x
     855           32 :              SET data = ((e->'data') - 'instanceId')
     856           32 :                         || CASE WHEN x.data ? 'instanceId'
     857           32 :                                 THEN jsonb_build_object('instanceId', x.data->'instanceId')
     858           32 :                                 ELSE '{{}}'::jsonb END,
     859           32 :                  version = x.version + 1, provenance = {CORRECTED},
     860           32 :                  modified_at = (e->>'modified_at')::timestamptz,
     861           32 :                  deleted_at = (e->>'deleted_at')::timestamptz, geo_value = {GEO_VALUE},
     862           32 :                  expires_at = (e->>'expires_at')::timestamptz
     863           32 :              FROM jsonb_array_elements($3::jsonb) AS e
     864           32 :              WHERE x.tenant_id = $1 AND x.entity_no = iri_no($1, $2)
     865           32 :                AND e->'data' ? 'observedAt' AND {same}
     866           32 :                AND x.observed_at = (e->>'observed_at')::timestamptz
     867           32 :                AND x.observed_at BETWEEN $4::timestamptz AND $5::timestamptz
     868           32 :                AND x.data - {STAMPS} IS DISTINCT FROM (e->'data') - {STAMPS}"
     869           32 :         )))
     870           32 :         .bind(tenant.as_str())
     871           32 :         .bind(entity_id)
     872           32 :         .bind(&rows)
     873           32 :         .bind(&lo)
     874           32 :         .bind(&hi)
     875           32 :         .execute(&mut *tx)
     876           32 :         .await?;
     877            8 :     }
     878           40 :     sqlx::query(sqlx::AssertSqlSafe(format!(
     879           40 :         "INSERT INTO attr_rows
     880           40 :            (tenant_id, entity_no, attr_no, instance_no, dataset_id, observed_at,
     881           40 :             created_at, modified_at, deleted_at, data, geo_value, provenance, short, expires_at)
     882           40 :          SELECT $1, iri_no($1, $2), iri_no($1, e->>'attr_id'), (e->>'instance_no')::uuid,
     883           40 :                 e->>'dataset_id',
     884           40 :                 (e->>'observed_at')::timestamptz, (e->>'created_at')::timestamptz,
     885           40 :                 (e->>'modified_at')::timestamptz, (e->>'deleted_at')::timestamptz,
     886           40 :                 e->'data', {GEO_VALUE}, NULLIF(e->'provenance', 'null'::jsonb), $7,
     887           40 :                 (e->>'expires_at')::timestamptz
     888           40 :          FROM jsonb_array_elements($3::jsonb) AS e
     889           40 :          WHERE NOT $4 OR NOT CASE
     890           40 :            WHEN e->'data' ? 'observedAt' THEN EXISTS (
     891           40 :              SELECT 1 FROM attr_rows x
     892           40 :              WHERE x.tenant_id = $1 AND x.entity_no = iri_no($1, $2) AND {same}
     893           40 :                AND x.observed_at = (e->>'observed_at')::timestamptz
     894           40 :                AND x.observed_at BETWEEN $5::timestamptz AND $6::timestamptz)
     895           40 :            ELSE NOT (e ? 'follows') AND {NEWEST_REPEATS}
     896           40 :          END
     897           40 :          ON CONFLICT (tenant_id, entity_no, attr_no, observed_at, instance_no, short)
     898           40 :            DO UPDATE SET data = EXCLUDED.data, modified_at = EXCLUDED.modified_at,
     899           40 :                          dataset_id = EXCLUDED.dataset_id,
     900           40 :                          deleted_at = EXCLUDED.deleted_at,
     901           40 :                          geo_value = EXCLUDED.geo_value,
     902           40 :                          expires_at = EXCLUDED.expires_at,
     903           40 :                          version = attr_rows.version + 1,
     904           40 :                          provenance = jsonb_set(COALESCE(EXCLUDED.provenance, '{{}}'::jsonb),
     905           40 :                                                 '{{operation}}', '\"correct\"')
     906           40 :            WHERE attr_rows.data - {STAMPS} IS DISTINCT FROM EXCLUDED.data - {STAMPS}"
     907           40 :     )))
     908           40 :     .bind(tenant.as_str())
     909           40 :     .bind(entity_id)
     910           40 :     .bind(&rows)
     911           40 :     .bind(dedup)
     912           40 :     .bind(&lo)
     913           40 :     .bind(&hi)
     914           40 :     .bind(short)
     915           40 :     .execute(&mut *tx)
     916           40 :     .await?;
     917           40 :     Ok(())
     918           40 : }
     919              : 
     920              : /// Removes every instance and every kept version of one Entity: what 5.6.16
     921              : /// and an expired Entity's reap take, versions included.
     922         1891 : async fn wipe_instances(
     923         1891 :     tx: &mut sqlx::PgConnection,
     924         1891 :     tenant: &TenantId,
     925         1891 :     id: &str,
     926         1891 : ) -> Result<(), sqlx::Error> {
     927         1891 :     sqlx::query("DELETE FROM attr_instances WHERE tenant_id = $1 AND entity_id = $2")
     928         1891 :         .bind(tenant.as_str())
     929         1891 :         .bind(id)
     930         1891 :         .execute(&mut *tx)
     931         1891 :         .await?;
     932         1891 :     sqlx::query("DELETE FROM attr_rows WHERE tenant_id = $1 AND entity_no = iri_no($1, $2)")
     933         1891 :         .bind(tenant.as_str())
     934         1891 :         .bind(id)
     935         1891 :         .execute(&mut *tx)
     936         1891 :         .await?;
     937         1891 :     sqlx::query("DELETE FROM attr_versions WHERE tenant_id = $1 AND entity_id = $2")
     938         1891 :         .bind(tenant.as_str())
     939         1891 :         .bind(id)
     940         1891 :         .execute(&mut *tx)
     941         1891 :         .await?;
     942         1891 :     Ok(())
     943         1891 : }
     944              : 
     945              : // TemporalFilter lives in `store::filter`; re-exported for path compat.
     946              : pub use crate::store::filter::{TemporalFilter, TemporalOutcome};
     947              : 
     948              : /// The correlated subquery reconstructing the attribute object for the meta
     949              : /// row aliased `m`, with the 4.11 range and the lastN RANK() cap applied over
     950              : /// the rows (byte-exact against the API window: predicates and ordering run
     951              : /// on the instance JSON with COLLATE "C", never on the partition column).
     952              : /// Returns the SQL fragment + its text binds, numbered from `first_bind`;
     953              : /// `None` when a range is present but outside the compiler's exact subset —
     954              : /// the caller then reconstructs unpruned and the window stays the arbiter.
     955         1378 : fn attr_object_expr(f: &TemporalFilter<'_>, first_bind: usize) -> Option<(String, Vec<String>)> {
     956         1378 :     let (range_and, mut binds) = window_sql(f, first_bind)?;
     957         1376 :     let tp = first_bind;
     958         1376 :     let expr = match f.last_n {
     959          491 :         Some(n) => {
     960          491 :             let n_bind = first_bind.saturating_add(binds.len());
     961          491 :             binds.push(n.to_string());
     962              :             // 4.11 lastN keeps the N most recent INSTANTS, so the rank orders
     963              :             // on the same canonical key the window compares on — raw bytes
     964              :             // put "…00.000Z" after "…00Z" and kept the wrong N.
     965          491 :             let order_key = crate::compile::temporal::dt_key_sql(&format!("(ai.data ->> ${tp})"));
     966              :             // firstN keeps the N oldest (TS 104 176 6.4.7.3), the exact
     967              :             // reverse: the API window sorts an instance without the
     968              :             // timeproperty before every instant, so it ranks first ascending
     969          491 :             let dir = if f.oldest_first {
     970          378 :                 "ASC NULLS FIRST"
     971              :             } else {
     972          113 :                 "DESC NULLS LAST"
     973              :             };
     974          491 :             let rows = newest_rows(f, first_bind, n_bind)
     975          491 :                 .unwrap_or_else(|| format!("{} ai WHERE true", history("m.tenant_id", "m.id")));
     976          491 :             format!(
     977              :                 "COALESCE((SELECT jsonb_object_agg(g.attr_id, g.insts) FROM (\
     978              :                    SELECT s.attr_id, {s_insts} AS insts \
     979              :                    FROM (SELECT ai.*, rank() OVER (PARTITION BY ai.attr_id, ai.data ->> 'datasetId' \
     980              :                              ORDER BY {order_key} {dir}) AS rk \
     981              :                          FROM {rows}{range_and}) s \
     982              :                    WHERE s.rk <= ${n_bind}::bigint GROUP BY s.attr_id) g), '{{}}'::jsonb)",
     983          491 :                 s_insts = insts_and_ids("s")
     984              :             )
     985              :         }
     986              :         None => {
     987              :             // No range and no lastN: nothing names the timeproperty, so the
     988              :             // bind `window_sql` reserved for it is not a parameter of the
     989              :             // statement the caller assembles. Handing it back anyway would
     990              :             // make every plain Retrieve Temporal Evolution bind one argument
     991              :             // more than its SQL declares.
     992          885 :             if f.range.is_none() {
     993          625 :                 binds.clear();
     994          625 :             }
     995          885 :             format!(
     996              :                 "COALESCE((SELECT jsonb_object_agg(g.attr_id, g.insts) FROM (\
     997              :                    SELECT ai.attr_id, {ai_insts} AS insts \
     998              :                    FROM {rows} ai \
     999              :                    WHERE true{range_and} \
    1000              :                    GROUP BY ai.attr_id) g), '{{}}'::jsonb)",
    1001          885 :                 ai_insts = insts_and_ids("ai"),
    1002          885 :                 rows = history("m.tenant_id", "m.id")
    1003              :             )
    1004              :         }
    1005              :     };
    1006         1376 :     Some((expr, binds))
    1007         1378 : }
    1008              : 
    1009              : /// [`newest_rows`]' statement: `{window}` is its range bound on `t`
    1010              : /// (`{xwindow}`, `{rwindow}` the same on `x` and `r`), `${n}` the bind of N.
    1011              : ///
    1012              : /// Distinct Attributes by a skip scan of the same btree, in each parent,
    1013              : /// inside the window's column bound: an Attribute with no row
    1014              : /// there contributes no row anyway, and the bound keeps the scan to the
    1015              : /// partitions the window reaches instead of every week of the retention;
    1016              : /// `OFFSET 0` keeps the instance read a parameterised index
    1017              : /// range per Attribute, which a hash join over the whole Entity would
    1018              : /// otherwise replace. The N-th newest takes the newest N of each parent,
    1019              : /// each arm limited itself so both read their btree backwards, over the rows
    1020              : /// that carry observedAt only: the rank puts a row without it after every
    1021              : /// one with it (`NULLS LAST`), whatever its `observed_at` column says.
    1022              : const NEWEST_ROWS: &str = "(WITH RECURSIVE a(attr) AS (\
    1023              :             SELECT min(x.attr_id) FROM attr_instances x \
    1024              :             WHERE x.tenant_id = m.tenant_id AND x.entity_id = m.id{xwindow} \
    1025              :           UNION ALL \
    1026              :             SELECT (SELECT min(x.attr_id) FROM attr_instances x \
    1027              :                     WHERE x.tenant_id = m.tenant_id AND x.entity_id = m.id AND x.attr_id > a.attr{xwindow}) \
    1028              :             FROM a WHERE a.attr IS NOT NULL), \
    1029              :           b(no) AS (\
    1030              :             SELECT min(r.attr_no) FROM attr_rows r \
    1031              :             WHERE r.tenant_id = m.tenant_id AND r.entity_no = iri_no(m.tenant_id, m.id){rwindow} \
    1032              :           UNION ALL \
    1033              :             SELECT (SELECT min(r.attr_no) FROM attr_rows r \
    1034              :                     WHERE r.tenant_id = m.tenant_id AND r.entity_no = iri_no(m.tenant_id, m.id) \
    1035              :                       AND r.attr_no > b.no{rwindow}) \
    1036              :             FROM b WHERE b.no IS NOT NULL), \
    1037              :           n(attr) AS (\
    1038              :             SELECT a.attr FROM a WHERE a.attr IS NOT NULL \
    1039              :           UNION \
    1040              :             SELECT i.iri FROM b JOIN iri_ids i ON i.tenant_id = m.tenant_id AND i.id = b.no) \
    1041              :           SELECT ai.* FROM n a \
    1042              :           CROSS JOIN LATERAL (SELECT CASE WHEN a.attr = ANY(m.dataset_attrs) THEN '-infinity'::timestamptz \
    1043              :               ELSE (SELECT CASE WHEN k.nth - interval '28 hours' > m.offset_until THEN k.nth \
    1044              :                 ELSE k.nth - interval '28 hours' END FROM (SELECT COALESCE((SELECT t.observed_at FROM (\
    1045              :                 (SELECT t.observed_at FROM attr_instances t \
    1046              :                   WHERE t.tenant_id = m.tenant_id AND t.entity_id = m.id AND t.attr_id = a.attr{window} \
    1047              :                     AND t.data ->> 'observedAt' IS NOT NULL \
    1048              :                   ORDER BY t.observed_at DESC LIMIT greatest(${n}::bigint, 1)) \
    1049              :                 UNION ALL \
    1050              :                 (SELECT t.observed_at FROM attr_rows_at(m.tenant_id, m.id, a.attr) t \
    1051              :                   WHERE t.data ->> 'observedAt' IS NOT NULL{window} \
    1052              :                   ORDER BY t.observed_at DESC LIMIT greatest(${n}::bigint, 1))) t \
    1053              :                 ORDER BY t.observed_at DESC OFFSET greatest(${n}::bigint - 1, 0) LIMIT 1), \
    1054              :                 '-infinity'::timestamptz) AS nth) k) END AS th) c \
    1055              :           CROSS JOIN LATERAL (SELECT * FROM attr_instances ai \
    1056              :             WHERE ai.tenant_id = m.tenant_id AND ai.entity_id = m.id AND ai.attr_id = a.attr \
    1057              :             AND ai.observed_at >= c.th \
    1058              :             UNION ALL SELECT * FROM attr_rows_at(m.tenant_id, m.id, a.attr) ai \
    1059              :             WHERE ai.observed_at >= c.th OFFSET 0) ai) ai WHERE true";
    1060              : 
    1061              : /// lastN's candidate rows, read through the (tenant, entity, attr,
    1062              : /// observed_at DESC) btree instead of ranking every instance of the window:
    1063              : /// per Attribute, the rows at most 28 hours older than its N-th newest
    1064              : /// `observed_at`. A SUPERSET of what the rank keeps: the parsed column and
    1065              : /// the text key the rank orders on diverge by at most the two RFC 3339
    1066              : /// offsets (±14 h each), so a row more than 28 h older than the N-th newest
    1067              : /// has N rows strictly above it under either order. KPI histories of 22k
    1068              : /// instances per Entity passed the gateway's 30 s ranking all of them;
    1069              : /// this reads about N plus 28 hours of rows per Attribute.
    1070              : /// Where the Entity's `offset_until` (migration 0028) lies more than 28 h
    1071              : /// before the N-th newest, every row within those 28 h is a UTC stamp,
    1072              : /// whose text key orders like `observed_at`: the N newest and their ties are
    1073              : /// then exactly what the rank keeps, and the read stops there (the
    1074              : /// bike-share stations write ~1200 rows a day each).
    1075              : ///
    1076              : /// `None` (rank the whole window, as before) where the superset argument
    1077              : /// does not hold or no index serves it:
    1078              : /// - a timeproperty other than observedAt (no btree on its column);
    1079              : /// - firstN, and a window closed towards now (`before`, `between`): rows the
    1080              : ///   window drops could raise the N-th newest above what it keeps;
    1081              : /// - an Attribute with any instance in a dataset: the rank partitions by
    1082              : ///   datasetId, and a sparse dataset's newest may sit below the Attribute's
    1083              : ///   N-th; such an Attribute keeps every row (`-infinity`), found through the
    1084              : ///   Entity's `dataset_attrs` (migration 0009), which the write path keeps.
    1085          497 : fn newest_rows(f: &TemporalFilter<'_>, first_bind: usize, n_bind: usize) -> Option<String> {
    1086          497 :     if f.oldest_first || f.timeproperty != "observedAt" {
    1087          379 :         return None;
    1088          118 :     }
    1089          121 :     let bound = |alias| match &f.range {
    1090          168 :         None => Some(String::new()),
    1091          118 :         Some(r) if r.timerel == "any" => Some(String::new()),
    1092          118 :         Some(r) if r.timerel == "after" => {
    1093           84 :             crate::compile::temporal::column_range_bound(r, alias, first_bind.saturating_add(1))
    1094           84 :                 .map(|b| format!(" AND {b}"))
    1095              :         }
    1096           34 :         Some(_) => None,
    1097          286 :     };
    1098              :     Some(
    1099          118 :         NEWEST_ROWS
    1100          118 :             .replace("{window}", &bound("t")?)
    1101           84 :             .replace("{xwindow}", &bound("x")?)
    1102           84 :             .replace("{rwindow}", &bound("r")?)
    1103           84 :             .replace("{n}", &n_bind.to_string()),
    1104              :     )
    1105          497 : }
    1106              : 
    1107              : /// The 4.11 window over the instance rows `ai` of the meta row `m`: the
    1108              : /// ` AND …` fragment plus its text binds — $first_bind is always the
    1109              : /// timeproperty (predicate member / order key), the range binds follow.
    1110              : /// `None` when the range is outside the compiler's exact subset.
    1111         1410 : fn window_sql(f: &TemporalFilter<'_>, first_bind: usize) -> Option<(String, Vec<String>)> {
    1112         1410 :     let mut binds = vec![f.timeproperty.to_owned()];
    1113         1410 :     let mut range_and = String::new();
    1114         1410 :     if let Some(r) = &f.range {
    1115          575 :         let c = crate::compile::temporal::compile_instance_range(r, "ai.data", first_bind)?;
    1116          573 :         range_and = format!(" AND {}", c.sql);
    1117          573 :         debug_assert_eq!(c.binds.first().map(String::as_str), Some(f.timeproperty));
    1118          573 :         binds.extend(c.binds.into_iter().skip(1));
    1119              :         // widened COLUMN bound on the SAME binds ($first_bind+1 = timeAt):
    1120              :         // lets the (tenant, entity, attr, observed_at) btree serve the range;
    1121              :         // the byte-exact text predicate above still decides membership
    1122          557 :         if let Some(cb) =
    1123          573 :             crate::compile::temporal::column_range_bound(r, "ai", first_bind.saturating_add(1))
    1124          557 :         {
    1125          557 :             range_and.push_str(&format!(" AND {cb}"));
    1126          557 :         }
    1127          835 :     }
    1128         1408 :     Some((range_and, binds))
    1129         1410 : }
    1130              : 
    1131              : /// Timestamp text in the shape the API's aggregated rows carry.
    1132              : const BUCKET_TS: &str = r#"to_char({} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')"#;
    1133              : 
    1134              : /// 4.5.19 / 5.7.4.4 aggregated temporal representation computed in SQL for
    1135              : /// the meta row `m`: per attribute the bucket matrix of every requested
    1136              : /// method as `[value, start, end]` rows, in one object
    1137              : /// `{"bad": <any windowed value non-numeric>, "attrs": {iri: {"type":
    1138              : /// "Property", method: rows…}}}`. Only the numeric class (numbers and
    1139              : /// booleans, Table 4.5.19.1-1) is computed here; `bad` tells the caller to
    1140              : /// reconstruct instead so the API keeps every other class and the
    1141              : /// eligibility errors. Buckets: `period_secs` wide from the anchor (the
    1142              : /// request's timeAt, else the attribute's first instant); no period = one
    1143              : /// bucket spanning the query's whole time range (4.5.19.1 PT0S), with the
    1144              : /// edge 4.11 leaves open closed by the attribute's own first or last
    1145              : /// instant — the API's own rule.
    1146           32 : fn aggregate_expr(
    1147           32 :     f: &TemporalFilter<'_>,
    1148           32 :     agg: &crate::store::filter::Aggregate<'_>,
    1149           32 :     first_bind: usize,
    1150           32 : ) -> Option<(String, Vec<String>)> {
    1151           32 :     let (range_and, mut binds) = window_sql(f, first_bind)?;
    1152           32 :     let tp = first_bind;
    1153           32 :     let col = match f.timeproperty {
    1154           32 :         "observedAt" => "observed_at",
    1155            0 :         "createdAt" => "created_at",
    1156            0 :         "modifiedAt" => "modified_at",
    1157            0 :         _ => return None,
    1158              :     };
    1159           32 :     let anchor = match agg.anchor {
    1160           31 :         Some(a) => {
    1161           31 :             binds.push(antares_store::filter::canonical_datetime(a).into_owned());
    1162           31 :             format!(
    1163              :                 "${}::timestamptz",
    1164           31 :                 first_bind.saturating_add(binds.len()).saturating_sub(1)
    1165              :             )
    1166              :         }
    1167            1 :         None => format!("min(ai.{col}) OVER (PARTITION BY ai.attr_id)"),
    1168              :     };
    1169           32 :     let (bs, be) = match agg.period_secs {
    1170              :         None => {
    1171              :             // 4.5.19.1: a zero duration "is interpreted as a duration
    1172              :             // spanning the whole time range specified by the temporal
    1173              :             // query". `before` names only the range's end and `after` only
    1174              :             // its start (4.11), so the data closes the other edge.
    1175           15 :             let (qs, qe) = match &f.range {
    1176           14 :                 Some(r) if r.timerel == "before" => (None, Some(r.time_at)),
    1177           13 :                 Some(r) if r.timerel == "between" => (Some(r.time_at), r.end_time_at),
    1178           12 :                 Some(r) if r.timerel == "after" => (Some(r.time_at), None),
    1179            1 :                 _ => (None, None),
    1180              :             };
    1181           15 :             let start = match qs {
    1182           13 :                 Some(v) => {
    1183           13 :                     binds.push(antares_store::filter::canonical_datetime(v).into_owned());
    1184           13 :                     format!(
    1185              :                         "${}::timestamptz",
    1186           13 :                         first_bind.saturating_add(binds.len()).saturating_sub(1)
    1187              :                     )
    1188              :                 }
    1189            2 :                 None => "s0.first_ts".to_owned(),
    1190              :             };
    1191           15 :             let end = match qe {
    1192            2 :                 Some(v) => {
    1193            2 :                     binds.push(antares_store::filter::canonical_datetime(v).into_owned());
    1194            2 :                     format!(
    1195              :                         "${}::timestamptz",
    1196            2 :                         first_bind.saturating_add(binds.len()).saturating_sub(1)
    1197              :                     )
    1198              :                 }
    1199           13 :                 None => "s0.last + interval '1 second'".to_owned(),
    1200              :             };
    1201           15 :             (start, end)
    1202              :         }
    1203           17 :         Some(sc) => {
    1204           17 :             binds.push(sc.to_string());
    1205           17 :             let n = first_bind.saturating_add(binds.len()).saturating_sub(1);
    1206           17 :             let start = format!("date_bin(make_interval(secs => ${n}::bigint), s0.ts, s0.anchor)");
    1207           17 :             (
    1208           17 :                 start.clone(),
    1209           17 :                 format!("{start} + make_interval(secs => ${n}::bigint)"),
    1210           17 :             )
    1211              :         }
    1212              :     };
    1213           32 :     let mut aggs = String::new();
    1214           32 :     let mut rows = String::new();
    1215           32 :     let mut pairs = String::new();
    1216           67 :     for (i, m) in agg.methods.iter().enumerate() {
    1217              :         // The SQL and the member name both come out of this table, so the
    1218              :         // request's own string never reaches the statement.
    1219           67 :         let (name, sql) = match m.as_str() {
    1220           67 :             "totalCount" => ("totalCount", "count(*)"),
    1221           53 :             "distinctCount" => ("distinctCount", "count(DISTINCT s.v)"),
    1222           53 :             "min" => ("min", "min(s.v)"),
    1223           49 :             "max" => ("max", "max(s.v)"),
    1224           30 :             "sum" => ("sum", "sum(s.v)"),
    1225           30 :             "avg" => ("avg", "avg(s.v)"),
    1226            0 :             "stddev" => ("stddev", "stddev_pop(s.v)"),
    1227            0 :             "sumsq" => ("sumsq", "sum(s.v * s.v)"),
    1228            0 :             _ => return None,
    1229              :         };
    1230           67 :         aggs.push_str(&format!(", {sql} AS m{i}"));
    1231           67 :         rows.push_str(&format!(
    1232           67 :             ", jsonb_agg(jsonb_build_array(g.m{i}, {}, {}) ORDER BY g.bs) AS r{i}",
    1233           67 :             BUCKET_TS.replace("{}", "g.bs"),
    1234           67 :             BUCKET_TS.replace("{}", "g.be")
    1235           67 :         ));
    1236           67 :         pairs.push_str(&format!(", '{name}', b.r{i}"));
    1237              :     }
    1238           32 :     let hist = history("m.tenant_id", "m.id");
    1239           32 :     let expr = format!(
    1240              :         "(SELECT jsonb_build_object('bad', bool_or(b.bad), 'attrs', \
    1241              :             jsonb_object_agg(b.attr_id, jsonb_build_object('type', 'Property'{pairs}))) \
    1242              :           FROM (SELECT g.attr_id, bool_or(g.bad) AS bad{rows} \
    1243              :                 FROM (SELECT s.attr_id, s.bs, s.be, bool_or(s.bad) AS bad{aggs} \
    1244              :                       FROM (SELECT s0.attr_id, s0.v, s0.bad, {bs} AS bs, {be} AS be \
    1245              :                             FROM (SELECT ai.attr_id, \
    1246              :                                    CASE WHEN jsonb_typeof(ai.data -> 'value') = 'boolean' \
    1247              :                                           THEN (CASE WHEN (ai.data ->> 'value')::boolean THEN 1 ELSE 0 END)::float8 \
    1248              :                                         WHEN jsonb_typeof(ai.data -> 'value') = 'number' \
    1249              :                                           THEN (ai.data ->> 'value')::float8 END AS v, \
    1250              :                                    jsonb_typeof(ai.data -> 'value') IS DISTINCT FROM 'number' \
    1251              :                                      AND jsonb_typeof(ai.data -> 'value') IS DISTINCT FROM 'boolean' AS bad, \
    1252              :                                    ai.{col} AS ts, {anchor} AS anchor, \
    1253              :                                    min(ai.{col}) OVER (PARTITION BY ai.attr_id) AS first_ts, \
    1254              :                                    max(ai.{col}) OVER (PARTITION BY ai.attr_id) AS last \
    1255              :                                   FROM {hist} ai \
    1256              :                                   WHERE jsonb_typeof(ai.data -> ${tp}) = 'string'{range_and}) s0) s \
    1257              :                       GROUP BY s.attr_id, s.bs, s.be) g \
    1258              :                 GROUP BY g.attr_id) b)"
    1259              :     );
    1260           32 :     Some((expr, binds))
    1261           32 : }
    1262              : 
    1263              : impl PgTemporalStore {
    1264          153 :     pub fn new(pool: PgPool) -> Self {
    1265          153 :         Self {
    1266          153 :             pool,
    1267          153 :             temporal_only: false,
    1268          153 :             keys: Keys::default(),
    1269          153 :         }
    1270          153 :     }
    1271              : 
    1272              :     /// A store that routes by `cutover` instead of `history_cutover`, so a
    1273              :     /// test sets its own cut-over without moving every other test's rows.
    1274              :     #[cfg(any(test, feature = "test-kit"))]
    1275           10 :     pub fn with_cutover(pool: PgPool, cutover: Option<chrono::DateTime<chrono::Utc>>) -> Self {
    1276           10 :         let mut s = Self::new(pool);
    1277           10 :         s.keys.cutover = tokio::sync::OnceCell::new_with(Some(cutover));
    1278           10 :         s
    1279           10 :     }
    1280              : 
    1281              :     /// Replaces which writes keep short history with `short`.
    1282            4 :     pub fn set_short_history(&self, short: antares_store::ShortHistory) {
    1283            4 :         *self
    1284            4 :             .keys
    1285            4 :             .short
    1286            4 :             .write()
    1287            4 :             .unwrap_or_else(std::sync::PoisonError::into_inner) = std::sync::Arc::new(short);
    1288            4 :     }
    1289              : 
    1290              :     /// Forgets the dictionary entries of `tenant` after its purge.
    1291           26 :     pub fn forget_tenant(&self, tenant: &TenantId) {
    1292           26 :         self.keys.forget(tenant);
    1293           26 :     }
    1294              : 
    1295              :     /// `false` when the id already exists (create semantics, like the memory
    1296              :     /// store's `create`).
    1297              :     ///
    1298              :     /// 4.22: an expired one does NOT exist, so it is dropped here — history
    1299              :     /// included — and the insert below is then an ordinary create. Leaving it
    1300              :     /// in place would make `create` report a conflict for an id every read
    1301              :     /// calls absent, and `AnyStore::upsert` reads that `false` as "already
    1302              :     /// there" and falls through to `mutate`, which refuses an expired entity
    1303              :     /// too: the upsert would write nothing and report success.
    1304          629 :     pub async fn create(
    1305          629 :         &self,
    1306          629 :         tenant: &TenantId,
    1307          629 :         id: &str,
    1308          629 :         doc: &Value,
    1309          629 :     ) -> Result<bool, sqlx::Error> {
    1310          629 :         let (types, scopes, created, modified) = extract(doc);
    1311          629 :         let meta = meta_of(doc);
    1312          629 :         let rows = decompose(doc);
    1313          629 :         let mut tx = super::begin(&self.pool).await?;
    1314          629 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1315          629 :         crate::store::pg::claim_tenant(&mut tx, tenant).await?;
    1316          629 :         let reaped = sqlx::query(sqlx::AssertSqlSafe(format!(
    1317          629 :             "DELETE FROM temporal_entities m \
    1318          629 :              WHERE m.tenant_id = $1 AND m.id = $2 AND NOT {NOT_EXPIRED}"
    1319          629 :         )))
    1320          629 :         .bind(tenant.as_str())
    1321          629 :         .bind(id)
    1322          629 :         .execute(&mut *tx)
    1323          629 :         .await?
    1324          629 :         .rows_affected();
    1325          629 :         if reaped == 1 {
    1326            1 :             wipe_instances(&mut tx, tenant, id).await?;
    1327          628 :         }
    1328          629 :         let n = sqlx::query(
    1329          629 :             "INSERT INTO temporal_entities
    1330          629 :                (tenant_id, id, types, scopes, meta, created_at, modified_at)
    1331          629 :              VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz)
    1332          629 :              ON CONFLICT (tenant_id, id) DO NOTHING",
    1333          629 :         )
    1334          629 :         .bind(tenant.as_str())
    1335          629 :         .bind(id)
    1336          629 :         .bind(&types)
    1337          629 :         .bind(&scopes)
    1338          629 :         .bind(&meta)
    1339          629 :         .bind(&created)
    1340          629 :         .bind(&modified)
    1341          629 :         .execute(&mut *tx)
    1342          629 :         .await?
    1343          629 :         .rows_affected();
    1344          629 :         if n == 1 {
    1345          622 :             let mut ensured = std::collections::HashSet::new();
    1346          622 :             let short = self.keys.is_short(tenant, &types, doc);
    1347          622 :             insert_rows(
    1348          622 :                 &mut tx,
    1349          622 :                 &self.keys,
    1350          622 :                 &mut ensured,
    1351          622 :                 tenant,
    1352          622 :                 id,
    1353          622 :                 rows,
    1354          622 :                 Route {
    1355          622 :                     dedup: false,
    1356          622 :                     short,
    1357          622 :                 },
    1358          622 :             )
    1359          622 :             .await?;
    1360            7 :         }
    1361          629 :         tx.commit().await?;
    1362          629 :         Ok(n == 1)
    1363          629 :     }
    1364              : 
    1365              :     /// Append-only fast path (auto-recording, 5.6.12 adds): NO reconstruction,
    1366              :     /// no history read — a shell meta insert (first touch) plus one multi-row
    1367              :     /// instance upsert. This is the write the old full-resync made O(history).
    1368              :     ///
    1369              :     /// Conditional on the entity still existing. 5.6.6 Delete Entity removes
    1370              :     /// the entity and the temporal evolution recorded for it, in that order and
    1371              :     /// in two transactions; an auto-recording append that overlaps the delete
    1372              :     /// would otherwise commit history for an entity that is gone, and no later
    1373              :     /// delete would ever clean it. The `FOR KEY SHARE` lock is what makes the
    1374              :     /// check hold: it lets concurrent updates of the same entity through and
    1375              :     /// makes a concurrent DELETE wait until this append has committed.
    1376          136 :     pub async fn append(
    1377          136 :         &self,
    1378          136 :         tenant: &TenantId,
    1379          136 :         id: &str,
    1380          136 :         shell: &Value,
    1381          136 :         additions: &Value,
    1382          136 :     ) -> Result<(), sqlx::Error> {
    1383          136 :         let mut tx = super::begin(&self.pool).await?;
    1384          136 :         let mut ensured = std::collections::HashSet::new();
    1385          136 :         self.append_in(&mut tx, &mut ensured, tenant, id, shell, additions)
    1386          136 :             .await?;
    1387          136 :         tx.commit().await?;
    1388          136 :         Ok(())
    1389          136 :     }
    1390              : 
    1391              :     /// [`Self::append`] of a whole drained request in one transaction:
    1392              :     /// a batch of N Entities costs one BEGIN, not N. Each append
    1393              :     /// keeps its own liveness check and tenant.
    1394         1917 :     pub async fn append_many(
    1395         1917 :         &self,
    1396         1917 :         appends: &[(&TenantId, &str, &Value, Value)],
    1397         1917 :     ) -> Result<(), sqlx::Error> {
    1398         1917 :         if appends.is_empty() {
    1399            0 :             return Ok(());
    1400         1917 :         }
    1401         1917 :         let mut tx = super::begin(&self.pool).await?;
    1402         1917 :         let mut ensured = std::collections::HashSet::new();
    1403              :         // After the cut-over or for a short write, the batch's first sights go
    1404              :         // in first, in one order: an Entity at a time, two batches could each
    1405              :         // wait on an IRI the other wrote first. Only live Entities count:
    1406              :         // append_in skips the others, and their IRIs would stay in iri_ids
    1407              :         // naming nothing. The FOR KEY SHARE taken here holds until commit, so
    1408              :         // append_in's own check finds the same Entities.
    1409         1917 :         let short = appends
    1410         1917 :             .iter()
    1411         1985 :             .any(|(tenant, _, shell, _)| self.keys.is_short(tenant, &extract(shell).0, shell));
    1412         1917 :         if short || self.keys.cutover(&mut tx).await?.is_some() {
    1413              :             // the batch's Entities per Tenant, as (id, additions)
    1414              :             type Docs<'a> = Vec<(&'a str, &'a Value)>;
    1415            1 :             let mut by_tenant: std::collections::BTreeMap<&str, (&TenantId, Docs)> =
    1416            1 :                 std::collections::BTreeMap::new();
    1417            1 :             for (tenant, id, _, additions) in appends {
    1418            1 :                 by_tenant
    1419            1 :                     .entry(tenant.as_str())
    1420            1 :                     .or_insert_with(|| (tenant, Vec::new()))
    1421              :                     .1
    1422            1 :                     .push((id, additions));
    1423              :             }
    1424            1 :             for (tenant, docs) in by_tenant.into_values() {
    1425            1 :                 crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1426            1 :                 let live = self.live_of(&mut tx, tenant, &docs).await?;
    1427            1 :                 let iris = docs
    1428            1 :                     .into_iter()
    1429            1 :                     .filter(|(id, _)| live.contains(*id))
    1430            1 :                     .flat_map(|(id, additions)| iris_of(id, additions))
    1431            1 :                     .collect();
    1432            1 :                 self.keys
    1433            1 :                     .ensure(&mut tx, tenant, iris, &mut ensured)
    1434            1 :                     .await?;
    1435              :             }
    1436         1916 :         }
    1437         1985 :         for (tenant, id, shell, additions) in appends {
    1438         1985 :             self.append_in(&mut tx, &mut ensured, tenant, id, shell, additions)
    1439         1985 :                 .await?;
    1440              :         }
    1441         1917 :         tx.commit().await?;
    1442         1917 :         Ok(())
    1443         1917 :     }
    1444              : 
    1445              :     /// The ids of `docs` stored in `entities` (all of them when this store
    1446              :     /// serves only the temporal seam), each locked FOR KEY SHARE until the
    1447              :     /// caller's commit.
    1448            1 :     async fn live_of(
    1449            1 :         &self,
    1450            1 :         tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
    1451            1 :         tenant: &TenantId,
    1452            1 :         docs: &[(&str, &Value)],
    1453            1 :     ) -> Result<std::collections::HashSet<String>, sqlx::Error> {
    1454            1 :         let mut ids: Vec<&str> = docs.iter().map(|(id, _)| *id).collect();
    1455            1 :         if self.temporal_only {
    1456            0 :             return Ok(ids.into_iter().map(str::to_owned).collect());
    1457            1 :         }
    1458            1 :         ids.sort_unstable();
    1459            1 :         ids.dedup();
    1460            1 :         let live = sqlx::query_scalar(
    1461            1 :             "SELECT id FROM entities WHERE tenant_id = $1 AND id = ANY($2)
    1462            1 :              ORDER BY id FOR KEY SHARE",
    1463            1 :         )
    1464            1 :         .bind(tenant.as_str())
    1465            1 :         .bind(&ids)
    1466            1 :         .fetch_all(&mut **tx)
    1467            1 :         .await?;
    1468            1 :         Ok(live.into_iter().collect())
    1469            1 :     }
    1470              : 
    1471         2121 :     async fn append_in(
    1472         2121 :         &self,
    1473         2121 :         tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
    1474         2121 :         ensured: &mut std::collections::HashSet<(String, String)>,
    1475         2121 :         tenant: &TenantId,
    1476         2121 :         id: &str,
    1477         2121 :         shell: &Value,
    1478         2121 :         additions: &Value,
    1479         2121 :     ) -> Result<(), sqlx::Error> {
    1480         2121 :         let (types, scopes, created, modified) = extract(shell);
    1481         2121 :         let meta = meta_of(shell);
    1482         2121 :         let rows = decompose(additions);
    1483         2121 :         crate::store::pg::set_tenant(tx, tenant).await?;
    1484         2121 :         if !self.temporal_only {
    1485         2083 :             let live = sqlx::query(
    1486         2083 :                 "SELECT 1 FROM entities WHERE tenant_id = $1 AND id = $2 FOR KEY SHARE",
    1487         2083 :             )
    1488         2083 :             .bind(tenant.as_str())
    1489         2083 :             .bind(id)
    1490         2083 :             .fetch_optional(&mut **tx)
    1491         2083 :             .await?;
    1492         2083 :             if live.is_none() {
    1493            3 :                 return Ok(());
    1494         2080 :             }
    1495           38 :         }
    1496              :         // DO NOTHING froze types/scopes at first touch — an
    1497              :         // entity gaining a type stayed invisible to type-filtered
    1498              :         // temporal queries forever. The shell carries the CURRENT
    1499              :         // entity, so refresh on change; the IS DISTINCT FROM guard keeps
    1500              :         // the common no-change append from churning the meta row.
    1501              :         // The one meta member the shell cannot refresh is a `scope` that
    1502              :         // holds instances (4.5.6 scope history, its deletion instances):
    1503              :         // the shell's is the current string array, and writing it over the
    1504              :         // history erased every scope change recorded so far.
    1505         2118 :         sqlx::query(sqlx::AssertSqlSafe(format!(
    1506         2118 :             "INSERT INTO temporal_entities
    1507         2118 :                (tenant_id, id, types, scopes, meta, created_at, modified_at)
    1508         2118 :              VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz)
    1509         2118 :              ON CONFLICT (tenant_id, id) DO UPDATE SET
    1510         2118 :                types = EXCLUDED.types,
    1511         2118 :                scopes = EXCLUDED.scopes,
    1512         2118 :                meta = {KEPT_META},
    1513         2118 :                modified_at = EXCLUDED.modified_at
    1514         2118 :              WHERE temporal_entities.types IS DISTINCT FROM EXCLUDED.types
    1515         2118 :                 OR temporal_entities.scopes IS DISTINCT FROM EXCLUDED.scopes
    1516         2118 :                 OR temporal_entities.meta IS DISTINCT FROM {KEPT_META}"
    1517         2118 :         )))
    1518         2118 :         .bind(tenant.as_str())
    1519         2118 :         .bind(id)
    1520         2118 :         .bind(&types)
    1521         2118 :         .bind(&scopes)
    1522         2118 :         .bind(&meta)
    1523         2118 :         .bind(&created)
    1524         2118 :         .bind(&modified)
    1525         2118 :         .execute(&mut **tx)
    1526         2118 :         .await?;
    1527         2118 :         let short = self.keys.is_short(tenant, &types, shell);
    1528         2118 :         let route = Route { dedup: true, short };
    1529         2118 :         insert_rows(tx, &self.keys, ensured, tenant, id, rows, route).await
    1530         2121 :     }
    1531              : 
    1532          563 :     pub async fn get(&self, tenant: &TenantId, id: &str) -> Result<Option<Value>, sqlx::Error> {
    1533          563 :         self.get_range(tenant, id, &TemporalFilter::default()).await
    1534          563 :     }
    1535              : 
    1536              :     /// 5.6.16 Delete Temporal Evolution: `true` when it was there, `false`
    1537              :     /// for "no existing Entity whose id (URI) is equivalent held locally",
    1538              :     /// which the caller answers as ResourceNotFound. 4.22 decides what "held
    1539              :     /// locally" means, the same way `get_range` and `query` decide it.
    1540         4805 :     pub async fn delete(&self, tenant: &TenantId, id: &str) -> Result<bool, sqlx::Error> {
    1541         4805 :         let mut tx = super::begin(&self.pool).await?;
    1542         4805 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1543         4805 :         let n = sqlx::query(sqlx::AssertSqlSafe(format!(
    1544         4805 :             "DELETE FROM temporal_entities m \
    1545         4805 :              WHERE m.tenant_id = $1 AND m.id = $2 AND {NOT_EXPIRED}"
    1546         4805 :         )))
    1547         4805 :         .bind(tenant.as_str())
    1548         4805 :         .bind(id)
    1549         4805 :         .execute(&mut *tx)
    1550         4805 :         .await?
    1551         4805 :         .rows_affected();
    1552              :         // no FK: a partitioned table cannot be the referencing side of a
    1553              :         // cascade from temporal_entities — clean the instances explicitly,
    1554              :         // and only for the row this call actually removed. An expired
    1555              :         // entity is refused above and keeps its history until the 4.22
    1556              :         // reap or a create replaces it; wiping it here would destroy the
    1557              :         // history behind a 404.
    1558         4805 :         if n == 1 {
    1559         1890 :             wipe_instances(&mut tx, tenant, id).await?;
    1560         2915 :         }
    1561         4805 :         tx.commit().await?;
    1562         4805 :         Ok(n == 1)
    1563         4805 :     }
    1564              : 
    1565              :     /// Erasure: the rows of `attrs` of one Entity,
    1566              :     /// current and superseded, observed at or before `before` (`None`: all);
    1567              :     /// `attrs` `None` takes every Attribute, and the Entity's history itself
    1568              :     /// once it holds no row. How many rows went.
    1569            1 :     pub async fn erase(
    1570            1 :         &self,
    1571            1 :         tenant: &TenantId,
    1572            1 :         id: &str,
    1573            1 :         attrs: Option<&[String]>,
    1574            1 :         before: Option<&str>,
    1575            1 :     ) -> Result<u64, sqlx::Error> {
    1576            1 :         let mut tx = super::begin(&self.pool).await?;
    1577            1 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1578            1 :         let names = attrs.map(<[String]>::to_vec);
    1579            1 :         let mut n = 0u64;
    1580            3 :         for sql in [
    1581            1 :             "DELETE FROM attr_instances WHERE tenant_id = $1 AND entity_id = $2 \
    1582            1 :              AND ($3::text[] IS NULL OR attr_id = ANY($3)) \
    1583            1 :              AND ($4::timestamptz IS NULL OR observed_at <= $4::timestamptz)",
    1584            1 :             "DELETE FROM attr_rows WHERE tenant_id = $1 AND entity_no = iri_no($1, $2) \
    1585            1 :              AND ($3::text[] IS NULL OR attr_no IN \
    1586            1 :                   (SELECT i.id FROM iri_ids i WHERE i.tenant_id = $1 AND i.iri = ANY($3))) \
    1587            1 :              AND ($4::timestamptz IS NULL OR observed_at <= $4::timestamptz)",
    1588            1 :             "DELETE FROM attr_versions WHERE tenant_id = $1 AND entity_id = $2 \
    1589            1 :              AND ($3::text[] IS NULL OR attr_id = ANY($3)) \
    1590            1 :              AND ($4::timestamptz IS NULL OR observed_at <= $4::timestamptz)",
    1591            1 :         ] {
    1592            3 :             let gone = sqlx::query(sql)
    1593            3 :                 .bind(tenant.as_str())
    1594            3 :                 .bind(id)
    1595            3 :                 .bind(&names)
    1596            3 :                 .bind(before)
    1597            3 :                 .execute(&mut *tx)
    1598            3 :                 .await?
    1599            3 :                 .rows_affected();
    1600            3 :             n = n.saturating_add(gone);
    1601              :         }
    1602            1 :         if attrs.is_none() {
    1603            0 :             sqlx::query(
    1604            0 :                 "DELETE FROM temporal_entities t WHERE t.tenant_id = $1 AND t.id = $2 \
    1605            0 :                  AND NOT EXISTS (SELECT 1 FROM attr_instances a \
    1606            0 :                                  WHERE a.tenant_id = t.tenant_id AND a.entity_id = t.id) \
    1607            0 :                  AND NOT EXISTS (SELECT 1 FROM attr_rows r WHERE r.tenant_id = t.tenant_id \
    1608            0 :                                  AND r.entity_no = iri_no(t.tenant_id, t.id))",
    1609            0 :             )
    1610            0 :             .bind(tenant.as_str())
    1611            0 :             .bind(id)
    1612            0 :             .execute(&mut *tx)
    1613            0 :             .await?;
    1614            1 :         }
    1615            1 :         tx.commit().await?;
    1616            1 :         Ok(n)
    1617            1 :     }
    1618              : 
    1619              :     /// Every version of the rows of one Entity's history by Attribute: each instance with its `version` and `provenance`,
    1620              :     /// a superseded one with `supersededAt`, oldest first per measurement.
    1621            7 :     pub async fn versions(
    1622            7 :         &self,
    1623            7 :         tenant: &TenantId,
    1624            7 :         id: &str,
    1625            7 :     ) -> Result<serde_json::Map<String, Value>, sqlx::Error> {
    1626            7 :         let mut tx = super::begin(&self.pool).await?;
    1627            7 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1628            7 :         let rows: Vec<(String, Value)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
    1629            7 :             "SELECT attr_id, data || jsonb_build_object('version', version) || jsonb_strip_nulls( \
    1630            7 :                  jsonb_build_object('provenance', provenance, 'supersededAt', \
    1631            7 :                    to_char(superseded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'))) \
    1632            7 :              FROM (SELECT attr_id, observed_at, dataset_id, version, data, provenance, superseded_at \
    1633            7 :                      FROM attr_versions WHERE tenant_id = $1 AND entity_id = $2 \
    1634            7 :                    UNION ALL \
    1635            7 :                    SELECT ai.attr_id, ai.observed_at, ai.dataset_id, ai.version, {}, ai.provenance, NULL \
    1636            7 :                      FROM {} ai) v \
    1637            7 :              ORDER BY attr_id, observed_at, dataset_id NULLS FIRST, version",
    1638            7 :             restamp("ai"),
    1639            7 :             history("$1", "$2")
    1640            7 :         )))
    1641            7 :         .bind(tenant.as_str())
    1642            7 :         .bind(id)
    1643            7 :         .fetch_all(&mut *tx)
    1644            7 :         .await?;
    1645            7 :         tx.commit().await?;
    1646            7 :         let mut out = serde_json::Map::new();
    1647          387 :         for (attr, instance) in rows {
    1648          387 :             if let Some(list) = out
    1649          387 :                 .entry(attr)
    1650          387 :                 .or_insert_with(|| Value::Array(Vec::new()))
    1651          387 :                 .as_array_mut()
    1652          387 :             {
    1653          387 :                 list.push(instance);
    1654          387 :             }
    1655              :         }
    1656            7 :         Ok(out)
    1657            7 :     }
    1658              : 
    1659              :     /// Temporal query: entity narrowing (ids/types/attrs) in the WHERE,
    1660              :     /// instance pruning (range + lastN cap) in the reconstruction, and —
    1661              :     /// when the caller passes a page — entity qualification + LIMIT/OFFSET
    1662              :     /// in SQL, so a temporal query no longer materializes the whole tenant.
    1663          504 :     pub async fn query(
    1664          504 :         &self,
    1665          504 :         tenant: &TenantId,
    1666          504 :         f: &TemporalFilter<'_>,
    1667          504 :     ) -> Result<TemporalOutcome, sqlx::Error> {
    1668          504 :         self.query_inner(tenant, f, f.aggregate.is_some()).await
    1669          504 :     }
    1670              : 
    1671              :     /// `push_agg`: compute the filter's 4.5.19 aggregation in SQL; a row
    1672              :     /// whose windowed values are not all numeric makes the whole query fall
    1673              :     /// back to instance reconstruction (one extra round trip, only then).
    1674          505 :     async fn query_inner(
    1675          505 :         &self,
    1676          505 :         tenant: &TenantId,
    1677          505 :         f: &TemporalFilter<'_>,
    1678          505 :         push_agg: bool,
    1679          505 :     ) -> Result<TemporalOutcome, sqlx::Error> {
    1680              :         enum B {
    1681              :             Text(String),
    1682              :             Arr(Vec<String>),
    1683              :             Num(i64),
    1684              :             Float(f64),
    1685              :         }
    1686          505 :         let mut binds: Vec<B> = vec![B::Text(tenant.as_str().to_owned())];
    1687              :         // 4.22: an expired ENTITY is invalid on temporal reads too — filter it
    1688              :         // in SQL (no bind) so paging/totals stay exact. Expired instances are
    1689              :         // stripped at the read boundary (any.rs). Literal, applies to the
    1690              :         // fallback count query too.
    1691          505 :         let mut wheres = vec!["m.tenant_id = $1".to_owned(), NOT_EXPIRED.to_owned()];
    1692          505 :         if let Some(ids) = f.ids {
    1693          127 :             binds.push(B::Arr(super::storable(ids)));
    1694          127 :             wheres.push(format!("m.id = ANY(${})", binds.len()));
    1695          378 :         }
    1696          505 :         if let Some(types) = f.types {
    1697          318 :             // overlap: entity has ANY of the wanted types (flat OR list).
    1698          318 :             // `&&` alone is no index condition under RLS (not leakproof):
    1699          318 :             // the first-type arm and the multi-type arm are (migration 0015),
    1700          318 :             // and they BitmapOr instead of reading the Tenant.
    1701          318 :             binds.push(B::Arr(super::storable(types)));
    1702          318 :             let n = binds.len();
    1703          318 :             wheres.push(format!(
    1704          318 :                 "(m.types[1] = ANY(${n}) OR (cardinality(m.types) > 1 AND m.types && ${n}))"
    1705          318 :             ));
    1706          318 :         }
    1707          505 :         if let Some(attrs) = f.attrs {
    1708           32 :             binds.push(B::Arr(super::storable(attrs)));
    1709           32 :             wheres.push(format!(
    1710           32 :                 "(EXISTS (SELECT 1 FROM attr_instances x WHERE x.tenant_id = m.tenant_id \
    1711           32 :                  AND x.entity_id = m.id AND x.attr_id = ANY(${n})) \
    1712           32 :                  OR EXISTS (SELECT 1 FROM attr_rows r WHERE r.tenant_id = m.tenant_id \
    1713           32 :                  AND r.entity_no = iri_no(m.tenant_id, m.id) AND r.attr_no IN \
    1714           32 :                    (SELECT i.id FROM iri_ids i WHERE i.tenant_id = m.tenant_id AND i.iri = ANY(${n}))))",
    1715           32 :                 n = binds.len()
    1716           32 :             ));
    1717          473 :         }
    1718              :         // page pushdown: only when the caller passed one AND the range (if
    1719              :         // any) compiles — SQL then also applies the evaluator's entity-
    1720              :         // qualification rule (≥1 instance, in-window when ranged). WHERE
    1721              :         // binds come FIRST so the fallback count query (offset past the end)
    1722              :         // can reuse them with identical numbering.
    1723              :         // None: no range to compile. Some(None): a range that does not
    1724              :         // compile, which is the case page pushdown must not take — the SQL
    1725              :         // would qualify entities on a window it cannot express.
    1726          505 :         let compiled_range = f.range.as_ref().map(|r| {
    1727          446 :             crate::compile::temporal::compile_instance_range(
    1728          446 :                 r,
    1729              :                 "ai.data",
    1730          446 :                 binds.len().saturating_add(1),
    1731              :             )
    1732          446 :         });
    1733          505 :         let mut paged = false;
    1734          505 :         if f.page.is_some() && !matches!(compiled_range, Some(None)) {
    1735          246 :             let mut preds = String::new();
    1736          246 :             if let (Some(r), Some(Some(c))) = (&f.range, compiled_range) {
    1737          244 :                 let n = binds.len().saturating_add(1);
    1738          566 :                 for b in c.binds {
    1739          566 :                     binds.push(B::Text(b));
    1740          566 :                 }
    1741          244 :                 preds.push_str(&format!(" AND {}", c.sql));
    1742              :                 // index-serving widened bound on the same binds ($n+1 = timeAt)
    1743          244 :                 if let Some(cb) =
    1744          244 :                     crate::compile::temporal::column_range_bound(r, "ai", n.saturating_add(1))
    1745          244 :                 {
    1746          244 :                     preds.push_str(&format!(" AND {cb}"));
    1747          244 :                 }
    1748            2 :             }
    1749          246 :             let dir = match &f.range {
    1750          244 :                 Some(r) if r.timerel == "before" => "ASC",
    1751          208 :                 _ => "DESC",
    1752              :             };
    1753          246 :             let shown = f.page_attrs.map(|attrs| {
    1754            7 :                 binds.push(B::Arr(super::storable(attrs)));
    1755            7 :                 binds.len()
    1756            7 :             });
    1757          246 :             wheres.push(has_history(&preds, dir, shown));
    1758          246 :             paged = true;
    1759          259 :         }
    1760              :         // 5.7.4.4 S2 superset prefilter: entities with no windowed instance
    1761              :         // satisfying the compilable part of q= are never reconstructed. The
    1762              :         // API arbiter re-evaluates q on every row that comes back, so this
    1763              :         // can only narrow (compile::qprefilter invariant), never decide.
    1764          505 :         if let Some(qn) = f.q {
    1765          146 :             if let Some(c) = crate::compile::qprefilter::compile_prefilter(
    1766          146 :                 qn,
    1767          146 :                 f.range.as_ref(),
    1768          146 :                 "m",
    1769          146 :                 binds.len().saturating_add(1),
    1770          146 :                 f.expand,
    1771          146 :             ) {
    1772         1754 :                 for b in c.binds {
    1773         1754 :                     binds.push(B::Text(b));
    1774         1754 :                 }
    1775          124 :                 wheres.push(c.sql);
    1776           22 :             }
    1777          359 :         }
    1778              :         // 5.7.4.4 S3 superset prefilter: entities with no windowed instance
    1779              :         // of the geoproperty possibly satisfying the geoquery are never
    1780              :         // reconstructed. NULL geo_value (rows with no extracted geometry) always
    1781              :         // survives; GeoQuery::matches stays the arbiter.
    1782          505 :         for (spec, iri) in f.geo {
    1783           29 :             let attr_bind = binds.len().saturating_add(1);
    1784           29 :             let mut win_binds: Vec<String> = Vec::new();
    1785           29 :             let mut window = String::new();
    1786           29 :             if let Some(r) = &f.range {
    1787           29 :                 if let Some(cb) = crate::compile::temporal::column_range_bound(
    1788           29 :                     r,
    1789           29 :                     "gi",
    1790           29 :                     attr_bind.saturating_add(1),
    1791           29 :                 ) {
    1792              :                     // 4.6.3 lets a request spell the seconds fraction with a
    1793              :                     // comma; the bound lands in a `::timestamptz` cast, which
    1794              :                     // takes only the point form. Every other range bind is
    1795              :                     // canonicalized for exactly this reason.
    1796           29 :                     win_binds
    1797           29 :                         .push(antares_store::filter::canonical_datetime(r.time_at).into_owned());
    1798           29 :                     if r.timerel == "between" {
    1799           22 :                         if let Some(e) = r.end_time_at {
    1800           22 :                             win_binds
    1801           22 :                                 .push(antares_store::filter::canonical_datetime(e).into_owned());
    1802           22 :                         }
    1803            7 :                     }
    1804           29 :                     window = format!(" AND {cb}");
    1805            0 :                 }
    1806            0 :             }
    1807           29 :             if let Some(c) = crate::compile::geo::compile_geo_instance(
    1808           29 :                 spec,
    1809           29 :                 "gi.geo_value",
    1810           29 :                 attr_bind.saturating_add(1).saturating_add(win_binds.len()),
    1811           29 :             ) {
    1812           29 :                 binds.push(B::Text(iri.to_owned()));
    1813           51 :                 for b in win_binds {
    1814           51 :                     binds.push(B::Text(b));
    1815           51 :                 }
    1816           29 :                 for b in c.geo_binds {
    1817           29 :                     binds.push(B::Text(b));
    1818           29 :                 }
    1819           29 :                 for n in c.num_binds {
    1820           17 :                     binds.push(B::Float(n));
    1821           17 :                 }
    1822           29 :                 wheres.push(format!(
    1823              :                     "EXISTS (SELECT 1 FROM (SELECT * FROM attr_instances \
    1824              :                      WHERE tenant_id = m.tenant_id AND entity_id = m.id AND attr_id = ${attr_bind} \
    1825              :                      UNION ALL SELECT * FROM attr_rows_at(m.tenant_id, m.id, ${attr_bind})) gi \
    1826              :                      WHERE true{window} AND {})",
    1827              :                     c.sql
    1828              :                 ));
    1829            0 :             }
    1830              :         }
    1831          505 :         let n_where = binds.len();
    1832          505 :         let where_sql = wheres.join(" AND ");
    1833          505 :         let agg = if push_agg {
    1834           32 :             f.aggregate
    1835           32 :                 .as_ref()
    1836           32 :                 .and_then(|a| aggregate_expr(f, a, n_where.saturating_add(1)))
    1837              :         } else {
    1838          473 :             None
    1839              :         };
    1840          505 :         let aggregated = agg.is_some();
    1841          505 :         let (attr_expr, extra) = match agg {
    1842           32 :             Some((e, b)) => (format!("jsonb_build_object('$agg', {e})"), b),
    1843          473 :             None => match attr_object_expr(f, n_where.saturating_add(1)) {
    1844          473 :                 Some(v) => v,
    1845              :                 // refused range shape: reconstruct unpruned, window arbitrates.
    1846              :                 // The default filter has neither range nor lastN, the two
    1847              :                 // shapes the compiler can refuse: pinned by
    1848              :                 // `the_unpruned_fallback_always_compiles`. A request reaches
    1849              :                 // this line: a refusal is an error, never a panic.
    1850            0 :                 None => attr_object_expr(&TemporalFilter::default(), n_where.saturating_add(1))
    1851            0 :                     .ok_or_else(|| {
    1852            0 :                         sqlx::Error::Protocol("the unpruned temporal shape did not compile".into())
    1853            0 :                     })?,
    1854              :             },
    1855              :         };
    1856          505 :         binds.extend(extra.into_iter().map(B::Text));
    1857          505 :         let mut select_total = String::new();
    1858          505 :         let mut tail = " ORDER BY m.id".to_owned();
    1859              :         // `paged` is set only inside `if f.page.is_some()`, so binding the
    1860              :         // two together says that in the type system instead of in a comment.
    1861          505 :         if let (true, Some(page)) = (paged, f.page.as_ref()) {
    1862          246 :             select_total = ", count(*) OVER () AS total".into();
    1863          246 :             binds.push(B::Num(page.limit));
    1864          246 :             tail.push_str(&format!(" LIMIT ${}", binds.len()));
    1865          246 :             binds.push(B::Num(page.offset));
    1866          246 :             tail.push_str(&format!(" OFFSET ${}", binds.len()));
    1867          289 :         } else {
    1868          259 :             // No page pushed down: the caller still has to filter, so the only
    1869          259 :             // bound on this statement is the safety ceiling — without it a
    1870          259 :             // bare `?timerel=…` reconstructs every temporal entity of the
    1871          259 :             // tenant into one Vec.
    1872          259 :             binds.push(B::Num(super::entity::MAX_UNDECIDED_ROWS));
    1873          259 :             tail.push_str(&format!(" LIMIT ${}", binds.len()));
    1874          259 :         }
    1875          505 :         let sql = format!(
    1876              :             "SELECT m.meta || {attr_expr}{select_total} FROM temporal_entities m \
    1877              :              WHERE {where_sql}{tail}"
    1878              :         );
    1879          505 :         let first: Result<Option<TemporalOutcome>, sqlx::Error> = async {
    1880          505 :             let mut tx = super::begin(&self.pool).await?;
    1881          505 :             crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1882              :             // `sql` is compiler literals + $n placeholders only.
    1883          505 :             let mut qy = sqlx::query(sqlx::AssertSqlSafe(sql.clone()));
    1884         5603 :             for b in &binds {
    1885         5603 :                 qy = match b {
    1886         4351 :                     B::Text(s) => qy.bind(s),
    1887          484 :                     B::Arr(v) => qy.bind(v),
    1888          751 :                     B::Num(n) => qy.bind(n),
    1889           17 :                     B::Float(x) => qy.bind(x),
    1890              :                 };
    1891              :             }
    1892          505 :             let rows = qy.fetch_all(&mut *tx).await?;
    1893          505 :             let mut total = if paged {
    1894          246 :                 rows.first().map(|r| r.get::<i64, _>(1))
    1895              :             } else {
    1896          259 :                 None
    1897              :             };
    1898          505 :             if paged && total.is_none() {
    1899              :                 // offset past the end: the window function came back with the
    1900              :                 // page, which is empty — count separately with the WHERE binds
    1901           32 :                 let count_sql =
    1902           32 :                     format!("SELECT count(*) FROM temporal_entities m WHERE {where_sql}");
    1903           32 :                 let mut cq = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(count_sql));
    1904          270 :                 for b in binds.iter().take(n_where) {
    1905          270 :                     cq = match b {
    1906          256 :                         B::Text(s) => cq.bind(s),
    1907           14 :                         B::Arr(v) => cq.bind(v),
    1908            0 :                         B::Num(n) => cq.bind(n),
    1909            0 :                         B::Float(x) => cq.bind(x),
    1910              :                     };
    1911              :                 }
    1912           32 :                 total = Some(cq.fetch_one(&mut *tx).await?);
    1913          473 :             }
    1914          505 :             tx.commit().await?;
    1915          505 :             super::entity::check_ceiling(paged, rows.len(), super::entity::MAX_UNDECIDED_ROWS)?;
    1916         1102 :             let mut docs: Vec<Value> = rows.into_iter().map(|r| r.get::<Value, _>(0)).collect();
    1917          505 :             docs.iter_mut().for_each(unpack);
    1918          505 :             if aggregated {
    1919           34 :                 for d in &mut docs {
    1920           34 :                     let Some(o) = d.as_object_mut() else { continue };
    1921           34 :                     let Some(agg) = o.remove("$agg") else {
    1922            0 :                         continue;
    1923              :                     };
    1924           34 :                     if agg.get("bad").and_then(Value::as_bool) == Some(true) {
    1925            1 :                         return Ok(None);
    1926           33 :                     }
    1927           33 :                     if let Some(attrs) = agg.get("attrs").and_then(Value::as_object) {
    1928           61 :                         for (k, v) in attrs {
    1929           61 :                             o.insert(k.clone(), v.clone());
    1930           61 :                         }
    1931            0 :                     }
    1932              :                 }
    1933          473 :             }
    1934          504 :             Ok(Some(TemporalOutcome {
    1935          504 :                 rows: docs,
    1936          504 :                 paged,
    1937          504 :                 total,
    1938          504 :                 aggregated,
    1939          504 :             }))
    1940          505 :         }
    1941          505 :         .await;
    1942          505 :         match first? {
    1943          504 :             Some(out) => Ok(out),
    1944              :             // the fallback re-runs the same statement without the pushed
    1945              :             // aggregation; boxed because an async fn cannot recurse inline
    1946            1 :             None => Box::pin(self.query_inner(tenant, f, false)).await,
    1947              :         }
    1948          505 :     }
    1949              : 
    1950              :     /// Single-entity fetch with the same instance pruning (Retrieve
    1951              :     /// Temporal Evolution, 5.7.3). `None` = entity absent.
    1952          889 :     pub async fn get_range(
    1953          889 :         &self,
    1954          889 :         tenant: &TenantId,
    1955          889 :         id: &str,
    1956          889 :         f: &TemporalFilter<'_>,
    1957          889 :     ) -> Result<Option<Value>, sqlx::Error> {
    1958              :         // Same unpruned fallback as `query_inner`, and compiling for the
    1959              :         // same reason: no range and no lastN is the shape that never refuses.
    1960              :         // A request reaches this line: a refusal here is an error, never a
    1961              :         // panic.
    1962          889 :         let (attr_expr, binds) = match attr_object_expr(f, 3) {
    1963          889 :             Some(v) => v,
    1964            0 :             None => attr_object_expr(&TemporalFilter::default(), 3).ok_or_else(|| {
    1965            0 :                 sqlx::Error::Protocol("the unpruned temporal shape did not compile".into())
    1966            0 :             })?,
    1967              :         };
    1968              :         // 4.22: an expired entity is invalid → None (404), same as get().
    1969          889 :         let sql = format!(
    1970              :             "SELECT m.meta || {attr_expr} FROM temporal_entities m \
    1971              :              WHERE m.tenant_id = $1 AND m.id = $2 AND {NOT_EXPIRED}"
    1972              :         );
    1973          889 :         let mut tx = super::begin(&self.pool).await?;
    1974          889 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1975          889 :         let mut qy = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
    1976          889 :             .bind(tenant.as_str())
    1977          889 :             .bind(id);
    1978          889 :         for b in &binds {
    1979          694 :             qy = qy.bind(b);
    1980          694 :         }
    1981          889 :         let row = qy.fetch_optional(&mut *tx).await?;
    1982          889 :         tx.commit().await?;
    1983          889 :         Ok(row.map(|r| {
    1984          413 :             let mut doc = r.get::<Value, _>(0);
    1985          413 :             unpack(&mut doc);
    1986          413 :             doc
    1987          413 :         }))
    1988          889 :     }
    1989              : 
    1990           14 :     pub async fn list(&self, tenant: &TenantId) -> Result<Vec<Value>, sqlx::Error> {
    1991           14 :         Ok(self.query(tenant, &TemporalFilter::default()).await?.rows)
    1992           14 :     }
    1993              : 
    1994              :     /// Row-locked read-modify-write over the RECONSTRUCTED doc, written back
    1995              :     /// as a DELTA: rows are diffed by (attr, instanceId) and only moved,
    1996              :     /// changed or removed instances touch the table — never a full-history
    1997              :     /// rewrite (the old resync's O(history) write amplification, and the
    1998              :     /// thing that fought hypertable compression).
    1999          317 :     pub async fn mutate<T, E>(
    2000          317 :         &self,
    2001          317 :         tenant: &TenantId,
    2002          317 :         id: &str,
    2003          317 :         f: impl FnOnce(&mut Value) -> Result<T, E>,
    2004          317 :     ) -> Result<Option<Result<T, E>>, sqlx::Error> {
    2005          317 :         let mut tx = super::begin(&self.pool).await?;
    2006          317 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    2007              :         // the meta row is the serialization point (FOR UPDATE). 4.22
    2008              :         // qualifies it: 5.6.12 to 5.6.15 reach the history through here,
    2009              :         // and none of them may modify an entity every read calls absent.
    2010          317 :         let row = sqlx::query(sqlx::AssertSqlSafe(format!(
    2011          317 :             "SELECT m.meta FROM temporal_entities m \
    2012          317 :              WHERE m.tenant_id = $1 AND m.id = $2 AND {NOT_EXPIRED} FOR UPDATE"
    2013          317 :         )))
    2014          317 :         .bind(tenant.as_str())
    2015          317 :         .bind(id)
    2016          317 :         .fetch_optional(&mut *tx)
    2017          317 :         .await?;
    2018          317 :         let Some(row) = row else {
    2019           27 :             tx.commit().await?;
    2020           27 :             return Ok(None);
    2021              :         };
    2022          290 :         let meta: Value = row.get(0);
    2023              :         // reconstruct the full doc inside the same transaction
    2024          290 :         let attrs_row = sqlx::query(sqlx::AssertSqlSafe(format!(
    2025          290 :             "SELECT COALESCE((SELECT jsonb_object_agg(g.attr_id, g.insts) FROM (
    2026          290 :                 SELECT ai.attr_id,
    2027          290 :                        {} AS insts
    2028          290 :                 FROM {} ai
    2029          290 :                 GROUP BY ai.attr_id) g), '{{}}'::jsonb)",
    2030          290 :             insts_and_ids("ai"),
    2031          290 :             history("$1", "$2")
    2032          290 :         )))
    2033          290 :         .bind(tenant.as_str())
    2034          290 :         .bind(id)
    2035          290 :         .fetch_one(&mut *tx)
    2036          290 :         .await?;
    2037          290 :         let mut attrs: Value = attrs_row.get(0);
    2038          290 :         unpack(&mut attrs);
    2039          290 :         let mut doc = meta;
    2040          290 :         if let (Some(d), Some(a)) = (doc.as_object_mut(), attrs.as_object()) {
    2041          770 :             for (k, v) in a {
    2042          770 :                 d.insert(k.clone(), v.clone());
    2043          770 :             }
    2044            0 :         }
    2045          290 :         let before_rows = decompose(&doc);
    2046          290 :         match f(&mut doc) {
    2047          288 :             Ok(t) => {
    2048          288 :                 let (types, scopes, _created, modified) = extract(&doc);
    2049          288 :                 let after_rows = decompose(&doc);
    2050              :                 // diff by logical identity (attr, instanceId); a changed
    2051              :                 // observed_at moves the physical row → delete + insert
    2052         4972 :                 let key = |r: &Value| -> (String, String) {
    2053         4972 :                     (
    2054         4972 :                         r["attr_id"].as_str().unwrap_or("").to_owned(),
    2055         4972 :                         r["instance_id"].as_str().unwrap_or("").to_owned(),
    2056         4972 :                     )
    2057         4972 :                 };
    2058          288 :                 let old: std::collections::HashMap<(String, String), &Value> =
    2059         1470 :                     before_rows.iter().map(|r| (key(r), r)).collect();
    2060          288 :                 let new: std::collections::HashMap<(String, String), &Value> =
    2061         1751 :                     after_rows.iter().map(|r| (key(r), r)).collect();
    2062          288 :                 let mut deletes: Vec<Value> = Vec::new();
    2063         1470 :                 for (k, o) in &old {
    2064         1470 :                     match new.get(k) {
    2065           19 :                         None => deletes.push(serde_json::json!({"a": k.0, "i": k.1})),
    2066         1451 :                         Some(n) if n["observed_at"] != o["observed_at"] => {
    2067            0 :                             deletes.push(serde_json::json!({"a": k.0, "i": k.1}))
    2068              :                         }
    2069         1451 :                         _ => {}
    2070              :                     }
    2071              :                 }
    2072          288 :                 let upserts: Vec<Value> = after_rows
    2073          288 :                     .iter()
    2074         1751 :                     .filter(|r| old.get(&key(r)).is_none_or(|o| *o != *r))
    2075          288 :                     .cloned()
    2076          288 :                     .collect();
    2077          288 :                 if !deletes.is_empty() {
    2078              :                     // 5.6.15 deletes an instance: its last version is kept,
    2079              :                     // so a deletion is never without a record.
    2080           13 :                     sqlx::query(sqlx::AssertSqlSafe(format!(
    2081           13 :                         "INSERT INTO attr_versions
    2082           13 :                            (tenant_id, entity_id, attr_id, instance_id, dataset_id, observed_at,
    2083           13 :                             version, data, provenance, expires_at)
    2084           13 :                          SELECT t.tenant_id, t.entity_id, t.attr_id, t.instance_id, t.dataset_id,
    2085           13 :                                 t.observed_at, t.version, {}, t.provenance, t.expires_at
    2086           13 :                          FROM {} t JOIN jsonb_array_elements($3::jsonb) AS e
    2087           13 :                            ON t.attr_id = e->>'a' AND t.instance_id = e->>'i'
    2088           13 :                          ON CONFLICT DO NOTHING",
    2089           13 :                         restamp("t"),
    2090           13 :                         history("$1", "$2")
    2091           13 :                     )))
    2092           13 :                     .bind(tenant.as_str())
    2093           13 :                     .bind(id)
    2094           13 :                     .bind(Value::Array(deletes.clone()))
    2095           13 :                     .execute(&mut *tx)
    2096           13 :                     .await?;
    2097           26 :                     for sql in [
    2098           13 :                         "DELETE FROM attr_instances t
    2099           13 :                          USING jsonb_array_elements($3::jsonb) AS e
    2100           13 :                          WHERE t.tenant_id = $1 AND t.entity_id = $2
    2101           13 :                            AND t.attr_id = e->>'a' AND t.instance_id = e->>'i'",
    2102           13 :                         "DELETE FROM attr_rows t
    2103           13 :                          USING jsonb_array_elements($3::jsonb) AS e
    2104           13 :                          WHERE t.tenant_id = $1 AND t.entity_no = iri_no($1, $2)
    2105           13 :                            AND t.attr_no = iri_no($1, e->>'a')
    2106           13 :                            AND COALESCE(t.data->>'instanceId',
    2107           13 :                                         'urn:ngsi-ld:Instance:' || t.instance_no::text) = e->>'i'",
    2108           13 :                     ] {
    2109           26 :                         sqlx::query(sql)
    2110           26 :                             .bind(tenant.as_str())
    2111           26 :                             .bind(id)
    2112           26 :                             .bind(Value::Array(deletes.clone()))
    2113           26 :                             .execute(&mut *tx)
    2114           26 :                             .await?;
    2115              :                     }
    2116          275 :                 }
    2117          288 :                 let mut ensured = std::collections::HashSet::new();
    2118          288 :                 insert_rows(
    2119          288 :                     &mut tx,
    2120          288 :                     &self.keys,
    2121          288 :                     &mut ensured,
    2122          288 :                     tenant,
    2123          288 :                     id,
    2124          288 :                     upserts,
    2125          288 :                     Route {
    2126          288 :                         dedup: false,
    2127          288 :                         short: self.keys.is_short(tenant, &types, &doc),
    2128          288 :                     },
    2129          288 :                 )
    2130          288 :                 .await?;
    2131          288 :                 sqlx::query(
    2132          288 :                     "UPDATE temporal_entities SET meta = $3, types = $4, scopes = $5,
    2133          288 :                        modified_at = $6::timestamptz
    2134          288 :                      WHERE tenant_id = $1 AND id = $2",
    2135          288 :                 )
    2136          288 :                 .bind(tenant.as_str())
    2137          288 :                 .bind(id)
    2138          288 :                 .bind(meta_of(&doc))
    2139          288 :                 .bind(&types)
    2140          288 :                 .bind(&scopes)
    2141          288 :                 .bind(&modified)
    2142          288 :                 .execute(&mut *tx)
    2143          288 :                 .await?;
    2144          288 :                 tx.commit().await?;
    2145          288 :                 Ok(Some(Ok(t)))
    2146              :             }
    2147            2 :             Err(e) => {
    2148            2 :                 tx.rollback().await?;
    2149            2 :                 Ok(Some(Err(e)))
    2150              :             }
    2151              :         }
    2152          317 :     }
    2153              : }
    2154              : 
    2155              : #[cfg(test)]
    2156              : mod tests {
    2157              :     use super::*;
    2158              :     use crate::compile::temporal::InstanceRange;
    2159              : 
    2160       400012 :     fn pair(n: usize) -> (String, String) {
    2161       400012 :         ("t".to_owned(), format!("urn:ngsi-ld:E:{n}"))
    2162       400012 :     }
    2163              : 
    2164              :     /// the known set holds at most two generations; a pair read
    2165              :     /// while old survives the next ageing, one never read again is dropped.
    2166              :     #[test]
    2167            2 :     fn known_ages_by_generation_and_keeps_what_it_reads() {
    2168            2 :         let mut k = Known::default();
    2169       200000 :         for n in 0..KNOWN_GEN {
    2170       200000 :             k.insert(pair(n));
    2171       200000 :         }
    2172            2 :         k.insert(pair(KNOWN_GEN));
    2173            2 :         assert_eq!(k.old.len(), KNOWN_GEN);
    2174            2 :         assert_eq!(k.new.len(), 1);
    2175            2 :         assert!(k.contains(&pair(0)));
    2176            2 :         assert!(!k.old.contains(&pair(0)));
    2177       199998 :         for n in 1..KNOWN_GEN {
    2178       199998 :             k.insert(pair(KNOWN_GEN.saturating_add(n)));
    2179       199998 :         }
    2180            2 :         k.insert(pair(KNOWN_GEN.saturating_mul(3)));
    2181            2 :         assert!(k.contains(&pair(0)));
    2182            2 :         assert!(!k.contains(&pair(1)));
    2183            2 :         assert!(!k.contains(&pair(KNOWN_GEN.saturating_mul(4))));
    2184       200004 :         k.retain(|(t, _)| t != "t");
    2185            2 :         assert!(k.new.is_empty());
    2186            2 :         assert!(k.old.is_empty());
    2187            2 :     }
    2188              : 
    2189              :     /// the broker's own canonical id keys by its uuid; any other id,
    2190              :     /// an upper-case uuid included, keys by its v5 and stays in data.
    2191              :     #[test]
    2192            2 :     fn instance_no_keys_own_ids_by_their_uuid_and_others_by_v5() {
    2193            2 :         let own = "urn:ngsi-ld:Instance:0f8fad5b-d9cb-469f-a165-70867728950e";
    2194            2 :         let (no, is_own) = instance_no(own);
    2195            2 :         assert_eq!(
    2196            2 :             (no.to_string(), is_own),
    2197            2 :             ("0f8fad5b-d9cb-469f-a165-70867728950e".to_owned(), true)
    2198              :         );
    2199            2 :         let upper = "urn:ngsi-ld:Instance:0F8FAD5B-D9CB-469F-A165-70867728950E";
    2200            6 :         for other in [upper, "urn:client:speed:thirty", "urn:ngsi-ld:Instance:x"] {
    2201            6 :             let (no, is_own) = instance_no(other);
    2202            6 :             assert_eq!((no.get_version_num(), is_own), (5, false), "{other}");
    2203            6 :             assert_eq!(no, instance_no(other).0, "{other}: stable");
    2204              :         }
    2205            2 :         assert_ne!(
    2206            2 :             instance_no(upper).0,
    2207              :             no,
    2208              :             "a v5 never equals the uuid it spells"
    2209              :         );
    2210            2 :     }
    2211              : 
    2212              :     /// a row from the cut-over on is keyed; an earlier one, an
    2213              :     /// unreadable stamp and no cut-over stay in the text shape.
    2214              :     #[test]
    2215            2 :     fn is_keyed_routes_by_observed_at_against_the_cut_over() {
    2216            2 :         let cut = chrono::DateTime::parse_from_rfc3339("2026-10-26T00:00:00Z")
    2217            2 :             .ok()
    2218            2 :             .map(|t| t.to_utc());
    2219           10 :         let row = |at: &str| serde_json::json!({"observed_at": at});
    2220            2 :         let got = [
    2221            2 :             is_keyed(&row("2026-10-26T00:00:00Z"), cut),
    2222            2 :             is_keyed(&row("2026-10-26T01:00:00+02:00"), cut),
    2223            2 :             is_keyed(&row("2026-10-25T23:59:59Z"), cut),
    2224            2 :             is_keyed(&row("not a stamp"), cut),
    2225            2 :             is_keyed(&row("2027-01-01T00:00:00Z"), None),
    2226            2 :         ];
    2227            2 :         assert_eq!(got, [true, false, false, false, false]);
    2228            2 :     }
    2229              : 
    2230              :     /// the IRIs a batch append writes first are the Entity id and its
    2231              :     /// Attribute names, never a meta member.
    2232              :     #[test]
    2233            2 :     fn iris_of_names_the_entity_and_its_attributes() {
    2234            2 :         let doc = serde_json::json!({"id": "x", "type": "T", "createdAt": "2026-01-01T00:00:00Z",
    2235            2 :                                      "https://a": [{}], "https://b": {"not": "instances"}});
    2236            2 :         assert_eq!(
    2237            2 :             iris_of("urn:e", &doc),
    2238            2 :             vec!["urn:e".to_owned(), "https://a".to_owned()]
    2239              :         );
    2240            2 :     }
    2241              : 
    2242              :     /// a row's instanceId lives in its column; `unpack` puts it back
    2243              :     /// and leaves stored instances and meta members alone.
    2244              :     #[test]
    2245            2 :     fn instance_id_leaves_data_and_unpack_puts_it_back() {
    2246            2 :         let speed = "https://uri.etsi.org/ngsi-ld/default-context/speed";
    2247            2 :         let inst = serde_json::json!({"type": "Property", "value": 1,
    2248            2 :                                       "instanceId": "urn:ngsi-ld:Instance:1"});
    2249            2 :         let rows = decompose(&serde_json::json!({"id": "urn:ngsi-ld:V:1", speed: [inst.clone()]}));
    2250            2 :         assert_eq!(rows.len(), 1);
    2251            2 :         assert_eq!(
    2252            2 :             rows[0]["data"],
    2253            2 :             serde_json::json!({"type": "Property", "value": 1})
    2254              :         );
    2255            2 :         assert_eq!(rows[0]["instance_id"], "urn:ngsi-ld:Instance:1");
    2256              :         // a row written keeps its instanceId in data
    2257            2 :         let stored = serde_json::json!({"type": "Property", "value": 2, "instanceId": "urn:ngsi-ld:Instance:2"});
    2258            2 :         let mut doc = serde_json::json!({
    2259            2 :             "id": "urn:ngsi-ld:V:1", "type": "V", "scope": ["/a", "/b"],
    2260            2 :             speed: [[rows[0]["data"].clone(), stored.clone()],
    2261              :                     ["urn:ngsi-ld:Instance:1", "urn:ngsi-ld:Instance:2"]],
    2262            2 :             "short": [[{"value": 1}], ["x", "y"]],
    2263            2 :             "numbers": [[{"value": 1}], [1]]
    2264              :         });
    2265            2 :         unpack(&mut doc);
    2266            2 :         assert_eq!(
    2267              :             doc,
    2268            2 :             serde_json::json!({"id": "urn:ngsi-ld:V:1", "type": "V", "scope": ["/a", "/b"],
    2269            2 :                                speed: [inst, stored],
    2270            2 :                                "short": [[{"value": 1}], ["x", "y"]],
    2271            2 :                                "numbers": [[{"value": 1}], [1]]})
    2272              :         );
    2273            2 :         let mut scalar = serde_json::json!(1);
    2274            2 :         unpack(&mut scalar);
    2275            2 :         assert_eq!(scalar, serde_json::json!(1));
    2276            2 :     }
    2277              : 
    2278              :     /// the dedup of an instance without observedAt reads the newest
    2279              :     /// stored row through the index and nothing older. A correlated max()
    2280              :     /// compared row by row read the attribute's whole history per write
    2281              :     /// (~90 ms a station at a month deep).
    2282              :     #[test]
    2283            2 :     fn the_newest_row_probe_reads_one_row() {
    2284            2 :         assert!(NEWEST_REPEATS.contains("ORDER BY x.observed_at DESC LIMIT 1"));
    2285            2 :         assert!(!NEWEST_REPEATS.to_lowercase().contains("max("));
    2286              :         // Its stamps are the ones every other comparison here ignores.
    2287            2 :         assert!(NEWEST_REPEATS.contains(&STAMPS["ARRAY".len()..]));
    2288            2 :         assert!(NEWEST_REPEATS.contains("x.tenant_id = $1 AND x.entity_id = $2"));
    2289            2 :     }
    2290              : 
    2291              :     /// Two callers fall back to `attr_object_expr(&TemporalFilter::default())`
    2292              :     /// when a range is outside the compiler's exact subset, and unwrap it:
    2293              :     /// the default filter has no range and no `lastN`, and `None` is returned
    2294              :     /// only for a range the compiler refuses. That is the reconstruct-unpruned
    2295              :     /// path, so if it ever stopped compiling the broker would panic on
    2296              :     /// precisely the queries the fallback exists to serve. Pinned here rather
    2297              :     /// than argued in a comment.
    2298              :     #[test]
    2299            2 :     fn the_unpruned_fallback_always_compiles() {
    2300            2 :         let f = TemporalFilter::default();
    2301            2 :         assert!(f.range.is_none(), "the default filter carries no range");
    2302            2 :         assert!(f.last_n.is_none(), "the default filter carries no lastN");
    2303            8 :         for first_bind in [1, 3, 4, 17] {
    2304            8 :             let built = attr_object_expr(&f, first_bind);
    2305            8 :             let (sql, binds) = built.unwrap_or_else(|| {
    2306            0 :                 panic!("the unpruned fallback failed to compile at bind {first_bind}")
    2307              :             });
    2308            8 :             assert!(!sql.is_empty(), "empty SQL at bind {first_bind}");
    2309            8 :             assert!(
    2310            8 :                 !sql.contains('$'),
    2311              :                 "the unpruned fragment names no placeholder: {sql}"
    2312              :             );
    2313            8 :             assert!(
    2314            8 :                 binds.is_empty(),
    2315              :                 "a fragment that names no placeholder must hand back no bind: {binds:?}"
    2316              :             );
    2317              :         }
    2318            2 :     }
    2319              : 
    2320            2 :     fn between<'a>() -> TemporalFilter<'a> {
    2321            2 :         TemporalFilter {
    2322            2 :             range: Some(InstanceRange {
    2323            2 :                 timerel: "between",
    2324            2 :                 time_at: "2026-01-01T00:00:00Z",
    2325            2 :                 end_time_at: Some("2026-02-01T00:00:00Z"),
    2326            2 :                 timeproperty: "observedAt",
    2327            2 :             }),
    2328            2 :             ..Default::default()
    2329            2 :         }
    2330            2 :     }
    2331              : 
    2332              :     /// The temporal tables extract their four columns with the same function
    2333              :     /// the entity table uses, so what holds there holds here: `type` and
    2334              :     /// `scope` read as arrays whichever shape the document spells them in,
    2335              :     /// and 4.6.3's comma seconds-fraction is canonicalised. A stamp that
    2336              :     /// reaches a `::timestamptz` bind with its comma intact is rejected by
    2337              :     /// Postgres, and the append fails on a document the API accepted.
    2338              :     #[test]
    2339            2 :     fn extract_canonicalises_the_stamps_and_takes_type_in_either_shape() {
    2340            2 :         let (types, scopes, created, modified) = extract(&serde_json::json!({
    2341            2 :             "id": "urn:x", "type": "T", "scope": ["/a", "/b"],
    2342            2 :             "createdAt": "2026-01-01T00:00:00,500Z",
    2343            2 :             "modifiedAt": "2026-01-01T00:00:01.500Z"}));
    2344            2 :         assert_eq!(types, ["T"]);
    2345            2 :         assert_eq!(scopes.expect("scopes"), ["/a", "/b"]);
    2346            2 :         assert_eq!(
    2347              :             created, "2026-01-01T00:00:00.500Z",
    2348              :             "the comma is the fraction"
    2349              :         );
    2350            2 :         assert_eq!(modified, "2026-01-01T00:00:01.500Z");
    2351            2 :         let (types, scopes, created, _) = extract(&serde_json::json!({"id": "urn:x"}));
    2352            2 :         assert!(types.is_empty(), "no type is no types, never a panic");
    2353            2 :         assert!(
    2354            2 :             scopes.is_none(),
    2355              :             "an absent scope stays absent, not an empty list"
    2356              :         );
    2357            2 :         assert_eq!(
    2358              :             created, "1970-01-01T00:00:00Z",
    2359              :             "the belt stamp is usable as a bind"
    2360              :         );
    2361            2 :     }
    2362              : 
    2363              :     #[test]
    2364            2 :     fn meta_of_keeps_only_meta_members() {
    2365            2 :         let doc = serde_json::json!({
    2366            2 :             "id": "urn:x", "type": ["T"], "createdAt": "c", "modifiedAt": "m",
    2367            2 :             "https://a/attr": [{"instanceId": "i1"}]
    2368              :         });
    2369            2 :         let m = meta_of(&doc);
    2370            2 :         assert!(m.get("id").is_some() && m.get("https://a/attr").is_none());
    2371            2 :     }
    2372              : 
    2373              :     #[test]
    2374            2 :     fn range_binds_are_numbered_from_first_bind() {
    2375            2 :         let (expr, binds) = attr_object_expr(&between(), 2).expect("compiles");
    2376            2 :         assert_eq!(
    2377              :             binds,
    2378            2 :             vec!["observedAt", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"]
    2379              :         );
    2380            2 :         assert!(
    2381            2 :             expr.contains("$2") && expr.contains("$3") && expr.contains("$4"),
    2382              :             "{expr}"
    2383              :         );
    2384              :         // predicates run on the instance JSON, never the partition column
    2385            2 :         assert!(expr.contains("ai.data ->>"), "{expr}");
    2386            2 :     }
    2387              : 
    2388              :     #[test]
    2389            2 :     fn last_n_caps_with_rank_per_attr_and_dataset_never_row_number() {
    2390            2 :         let f = TemporalFilter {
    2391            2 :             last_n: Some(5),
    2392            2 :             ..Default::default()
    2393            2 :         };
    2394            2 :         let (expr, binds) = attr_object_expr(&f, 1).expect("compiles");
    2395              :         // RANK keeps timestamp ties; ROW_NUMBER would cut an instance the
    2396              :         // API-side per-attr lastN still wants (the tie-break divergence bug)
    2397            2 :         assert!(expr.contains("rank() OVER"), "{expr}");
    2398            2 :         assert!(!expr.contains("row_number"), "{expr}");
    2399            2 :         assert!(
    2400            2 :             expr.contains("PARTITION BY ai.attr_id, ai.data ->> 'datasetId'"),
    2401              :             "{expr}"
    2402              :         );
    2403            2 :         assert!(expr.contains("COLLATE \"C\" DESC NULLS LAST"), "{expr}");
    2404            2 :         assert_eq!(binds, vec!["observedAt", "5"]);
    2405            2 :     }
    2406              : 
    2407              :     /// lastN's distinct-Attribute skip scan keeps to the window's
    2408              :     /// column bound in both parents, so it reads the weeks the window
    2409              :     /// reaches and not every week of the retention; no window, no bound.
    2410              :     #[test]
    2411            2 :     fn the_attribute_skip_scan_keeps_to_the_window() {
    2412            2 :         let after = TemporalFilter {
    2413            2 :             last_n: Some(10),
    2414            2 :             range: Some(InstanceRange {
    2415            2 :                 timerel: "after",
    2416            2 :                 time_at: "2026-10-24T00:00:00Z",
    2417            2 :                 end_time_at: None,
    2418            2 :                 timeproperty: "observedAt",
    2419            2 :             }),
    2420            2 :             ..Default::default()
    2421            2 :         };
    2422            2 :         let sql = newest_rows(&after, 3, 7).expect("after reads the newest rows");
    2423            2 :         assert!(!sql.contains("window}"), "{sql}");
    2424            6 :         for alias in ["x", "r", "t"] {
    2425            6 :             let bound = format!("{alias}.observed_at >= $4::timestamptz - interval '48 hours'");
    2426            6 :             assert!(sql.contains(&bound), "{bound} missing: {sql}");
    2427              :         }
    2428            2 :         assert_eq!(sql.matches("x.observed_at >= $4").count(), 2, "{sql}");
    2429            2 :         assert_eq!(sql.matches("r.observed_at >= $4").count(), 2, "{sql}");
    2430            2 :         let unbounded = TemporalFilter {
    2431            2 :             last_n: Some(10),
    2432            2 :             ..Default::default()
    2433            2 :         };
    2434            2 :         let sql = newest_rows(&unbounded, 3, 4).expect("no range reads the newest rows");
    2435            2 :         assert!(!sql.contains("window}"), "{sql}");
    2436            2 :         assert!(!sql.contains("observed_at >= $"), "{sql}");
    2437            2 :     }
    2438              : 
    2439              :     /// the page qualification probes each parent newest first (oldest
    2440              :     /// first for `before`), one row along its btree, never an EXISTS over the
    2441              :     /// union that reads an Entity's rows in the btree's own order.
    2442              :     #[test]
    2443            2 :     fn the_page_qualification_probes_one_row_per_parent() {
    2444            2 :         let sql = has_history(" AND ai.x", "DESC", None);
    2445            2 :         assert!(
    2446            2 :             sql.contains("ORDER BY ai.attr_id DESC, ai.observed_at DESC LIMIT 1"),
    2447              :             "{sql}"
    2448              :         );
    2449            2 :         assert!(
    2450            2 :             sql.contains("ORDER BY ai.attr_no DESC, ai.observed_at DESC LIMIT 1"),
    2451              :             "{sql}"
    2452              :         );
    2453            2 :         assert_eq!(sql.matches(" AND ai.x").count(), 2, "{sql}");
    2454            2 :         assert!(!sql.contains("EXISTS"), "{sql}");
    2455            2 :         assert!(!sql.contains("ANY("), "{sql}");
    2456            2 :     }
    2457              : 
    2458              :     /// under a policy the page qualifies an Entity only through the
    2459              :     /// Attributes its rules show, in both parents, on the one bind.
    2460              :     #[test]
    2461            2 :     fn the_page_qualification_keeps_to_the_shown_attributes() {
    2462            2 :         let sql = has_history(" AND ai.x", "DESC", Some(9));
    2463            2 :         assert!(sql.contains("ai.attr_id = ANY($9)"), "{sql}");
    2464            2 :         assert!(
    2465            2 :             sql.contains("ai.attr_no IN (SELECT i.id FROM iri_ids i WHERE i.tenant_id = m.tenant_id AND i.iri = ANY($9))"),
    2466              :             "{sql}"
    2467              :         );
    2468            2 :     }
    2469              : 
    2470              :     /// the read takes the N newest alone only where the Entity's
    2471              :     /// last non-UTC stamp lies more than 28 h before the N-th newest.
    2472              :     #[test]
    2473            2 :     fn the_newest_rows_widen_only_near_an_offset_stamp() {
    2474            2 :         let f = TemporalFilter {
    2475            2 :             last_n: Some(10),
    2476            2 :             ..Default::default()
    2477            2 :         };
    2478            2 :         let sql = newest_rows(&f, 3, 4).expect("reads the newest rows");
    2479            2 :         assert!(
    2480            2 :             sql.contains(
    2481            2 :                 "CASE WHEN k.nth - interval '28 hours' > m.offset_until THEN k.nth \
    2482            2 :                  ELSE k.nth - interval '28 hours' END"
    2483            2 :             ),
    2484              :             "{sql}"
    2485              :         );
    2486            2 :         assert!(sql.contains("ai.observed_at >= c.th"), "{sql}");
    2487            2 :     }
    2488              : 
    2489              :     /// a UTC RFC 3339 stamp keys like its instant; an offset, a space
    2490              :     /// separator, a non-stamp or no observedAt does not.
    2491              :     #[test]
    2492            2 :     fn only_a_utc_t_stamp_orders_like_its_instant() {
    2493           18 :         for (data, utc) in [
    2494            2 :             (
    2495            2 :                 serde_json::json!({"observedAt": "2026-10-26T12:00:00Z"}),
    2496            2 :                 true,
    2497            2 :             ),
    2498            2 :             (
    2499            2 :                 serde_json::json!({"observedAt": "2026-10-26T12:00:00,5Z"}),
    2500            2 :                 true,
    2501            2 :             ),
    2502            2 :             (
    2503            2 :                 serde_json::json!({"observedAt": "2026-10-26T12:00:00.123456789Z"}),
    2504            2 :                 true,
    2505            2 :             ),
    2506            2 :             (
    2507            2 :                 serde_json::json!({"observedAt": "2026-10-26T12:00:00+00:00"}),
    2508            2 :                 false,
    2509            2 :             ),
    2510            2 :             (
    2511            2 :                 serde_json::json!({"observedAt": "2026-10-26T14:00:00+02:00"}),
    2512            2 :                 false,
    2513            2 :             ),
    2514            2 :             (
    2515            2 :                 serde_json::json!({"observedAt": "2026-10-26 12:00:00Z"}),
    2516            2 :                 false,
    2517            2 :             ),
    2518            2 :             (serde_json::json!({"observedAt": "yesterdayZ"}), false),
    2519            2 :             (serde_json::json!({"observedAt": 5}), false),
    2520            2 :             (serde_json::json!({"value": 1}), false),
    2521            2 :         ] {
    2522           18 :             assert_eq!(utc_observed(&data), utc, "{data}");
    2523              :         }
    2524            2 :     }
    2525              : 
    2526              :     #[test]
    2527            2 :     fn first_n_ranks_the_oldest_first() {
    2528            2 :         let f = TemporalFilter {
    2529            2 :             last_n: Some(5),
    2530            2 :             oldest_first: true,
    2531            2 :             ..Default::default()
    2532            2 :         };
    2533            2 :         let (expr, binds) = attr_object_expr(&f, 1).expect("compiles");
    2534            2 :         assert!(expr.contains("COLLATE \"C\" ASC NULLS FIRST"), "{expr}");
    2535            2 :         assert!(!expr.contains("DESC"), "{expr}");
    2536            2 :         assert_eq!(binds, vec!["observedAt", "5"]);
    2537            2 :     }
    2538              : 
    2539              :     #[test]
    2540            2 :     fn refused_range_shape_refuses_the_pruning() {
    2541            2 :         let f = TemporalFilter {
    2542            2 :             range: Some(InstanceRange {
    2543            2 :                 timerel: "since", // not a 4.11 relation
    2544            2 :                 time_at: "t",
    2545            2 :                 end_time_at: None,
    2546            2 :                 timeproperty: "observedAt",
    2547            2 :             }),
    2548            2 :             last_n: Some(3),
    2549            2 :             ..Default::default()
    2550            2 :         };
    2551              :         // half-pruning would silently skip the range: refuse instead
    2552            2 :         assert!(attr_object_expr(&f, 1).is_none());
    2553            2 :     }
    2554              : 
    2555              :     /// S3-06: without observedAt a row is dropped only when it repeats the
    2556              :     /// row kept before it in its (attribute, datasetId), as NEWEST_REPEATS
    2557              :     /// judges against the table: a value that comes back after another is
    2558              :     /// a new observation, and only a batch's first row of a series is held
    2559              :     /// against the table.
    2560              :     #[test]
    2561            2 :     fn unseen_keeps_a_value_that_returns_after_another() {
    2562           12 :         let row = |v: i64, i: &str| {
    2563           12 :             serde_json::json!({"attr_id": "a", "dataset_id": null,
    2564           12 :             "instance_id": i, "observed_at": "2026-01-01T00:00:00Z",
    2565           12 :             "data": {"type": "Property", "value": v}})
    2566           12 :         };
    2567            2 :         let kept = unseen(vec![row(1, "i1"), row(2, "i2"), row(1, "i3")]);
    2568            2 :         assert_eq!(kept.len(), 3, "{kept:?}");
    2569            2 :         let kept = unseen(vec![row(1, "i1"), row(1, "i2"), row(2, "i3")]);
    2570            4 :         let ids: Vec<_> = kept.iter().map(|r| r["instance_id"].clone()).collect();
    2571            2 :         assert_eq!(ids, ["i1", "i3"], "a repeat in a row is still one");
    2572            2 :     }
    2573              : 
    2574              :     #[test]
    2575            2 :     fn decompose_never_parks_deletion_instances_on_the_epoch() {
    2576            2 :         let doc = serde_json::json!({
    2577            2 :             "id": "urn:x",
    2578            2 :             "https://a/attr": [{
    2579            2 :                 "type": "Property", "value": "urn:ngsi-ld:null",
    2580            2 :                 "instanceId": "urn:ngsi-ld:Instance:d1",
    2581            2 :                 "deletedAt": "2026-08-08T10:00:00Z"
    2582              :             }]
    2583              :         });
    2584            2 :         let rows = decompose(&doc);
    2585            2 :         assert_eq!(rows.len(), 1);
    2586            2 :         assert_eq!(rows[0]["observed_at"], "2026-08-08T10:00:00Z");
    2587            2 :     }
    2588              : }
        

Generated by: LCOV version 2.0-1