Skip to main content

improv_storage_pg/
lib.rs

1//! Persist an Improv `Model` in Postgres, in the `improv.*` catalog that the
2//! `pg_improv` extension computes over (design:
3//! `docs/reviews/2026-10-14-pg-improv-design.md` §2, §5).
4//!
5//! [`PgModelStore`] mirrors `improv_storage_mentat::ModelStore` method for
6//! method. The model is stored normalized: one row per category, item,
7//! measure and view, input cells either in their *bound* fact table (an
8//! `improv.binding` row maps a measure to `SELECT dims…, value FROM rel`) or,
9//! when unbound, in `improv.cell`. `improv.model_version` is the optimistic
10//! concurrency token.
11//!
12//! Encoding (v1; the extension must read the same):
13//! - `measure.formula`: written as serde JSON of the `Formula` AST (the DSL
14//!   printer lives in the GUI crate); read as JSON or, failing that, as DSL
15//!   text (`Target = expr`, what the extension's `improv.define` writes);
16//! - `measure.value_type`: `number | boolean | text | datetime | enum`;
17//! - `cell.coord`: `{"<category id>": <item id>, …}`;
18//! - cell values: plain JSON scalars (number, bool, string; datetimes as
19//!   RFC 3339 strings);
20//! - `lambda.definition`: written as serde JSON of the `NamedLambda`; read as
21//!   JSON or as `Name = LAMBDA(…)` text (what `improv.define` writes);
22//! - `meta.blob`: `{external_fns, external_calls, scenarios, sql_sources}`.
23//!   Before `improv.lambda` it also held `lambdas`, still read for a model
24//!   with no `improv.lambda` row.
25#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
26#![cfg_attr(coverage_nightly, allow(unused_features))]
27
28use improv_core_model::{
29    parse_definition, parse_expr, CategoryId, Coordinate, Definition, ExternalCall, ExternalFn,
30    Formula, ItemId, Measure, MeasureId, MeasureKind, Model, Name, Scenario, ScenarioId, SqlSource,
31    Value, ValueType,
32};
33use postgres::config::{Host, SslMode};
34use postgres::{Client, Config, GenericClient, IsolationLevel, NoTls};
35use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
36
37#[cfg(test)]
38mod tests;
39
40/// The catalog this client depends on (design §2), created when the
41/// `pg_improv` extension is not installed so a plain Postgres works too.
42///
43/// Kept in this ONE place so it can be reconciled with the extension's own
44/// catalog SQL; nothing else in this crate issues DDL against `improv.*`.
45pub const CATALOG_SQL: &str = r#"
46CREATE SCHEMA IF NOT EXISTS improv;
47CREATE TABLE IF NOT EXISTS improv.model (
48    id        bigserial PRIMARY KEY,
49    name      text NOT NULL UNIQUE,
50    sync_mode text NOT NULL DEFAULT 'logical'
51              CHECK (sync_mode IN ('logical', 'trigger', 'commit', 'manual')),
52    slot_name text,
53    created   timestamptz NOT NULL DEFAULT now()
54);
55CREATE TABLE IF NOT EXISTS improv.category (
56    model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
57    id       bigint NOT NULL,
58    name     text NOT NULL,
59    PRIMARY KEY (model_id, id)
60);
61CREATE TABLE IF NOT EXISTS improv.item (
62    model_id    bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
63    id          bigint NOT NULL,
64    category_id bigint NOT NULL,
65    name        text NOT NULL,
66    PRIMARY KEY (model_id, id)
67);
68CREATE TABLE IF NOT EXISTS improv.measure (
69    model_id    bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
70    id          bigint NOT NULL,
71    name        text NOT NULL,
72    value_type  text NOT NULL,
73    kind        text NOT NULL CHECK (kind IN ('input', 'derived')),
74    formula     text,
75    description text,
76    categories  bigint[] NOT NULL DEFAULT '{}',
77    PRIMARY KEY (model_id, id)
78);
79CREATE TABLE IF NOT EXISTS improv.binding (
80    model_id   bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
81    measure_id bigint NOT NULL,
82    rel        regclass NOT NULL,
83    value_col  text NOT NULL,
84    dim_cols   jsonb NOT NULL,
85    PRIMARY KEY (model_id, measure_id)
86);
87CREATE TABLE IF NOT EXISTS improv.cell (
88    model_id   bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
89    measure_id bigint NOT NULL,
90    coord      jsonb NOT NULL,
91    value      jsonb NOT NULL,
92    PRIMARY KEY (model_id, measure_id, coord)
93);
94CREATE TABLE IF NOT EXISTS improv.view (
95    model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
96    id       bigint NOT NULL,
97    name     text NOT NULL,
98    layout   jsonb NOT NULL,
99    PRIMARY KEY (model_id, id)
100);
101CREATE TABLE IF NOT EXISTS improv.meta (
102    model_id bigint PRIMARY KEY REFERENCES improv.model(id) ON DELETE CASCADE,
103    blob     jsonb NOT NULL
104);
105CREATE TABLE IF NOT EXISTS improv.model_version (
106    model_id bigint PRIMARY KEY REFERENCES improv.model(id) ON DELETE CASCADE,
107    tx       bigint NOT NULL DEFAULT 0
108);
109CREATE TABLE IF NOT EXISTS improv.lambda (
110    model_id   bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
111    id         bigint NOT NULL,
112    name       text NOT NULL,
113    definition text NOT NULL,
114    PRIMARY KEY (model_id, id)
115);
116-- Shared with the pg_improv extension (design §9.2): the same DDL in
117-- idempotent form (this runs against an existing database); the extension
118-- uses plain CREATE. Both pin mint_items' search_path.
119ALTER TABLE improv.binding ADD COLUMN IF NOT EXISTS agg text
120  CHECK (agg IN ('sum', 'avg', 'min', 'max', 'count', 'count_distinct'));
121CREATE UNIQUE INDEX IF NOT EXISTS item_name_uniq ON improv.item (model_id, category_id, name);
122CREATE OR REPLACE FUNCTION improv.mint_items(p_model bigint, p_cats bigint[], p_names text[])
123RETURNS TABLE (category_id bigint, name text, id bigint) LANGUAGE plpgsql
124SET search_path = pg_catalog, pg_temp AS $$
125BEGIN
126  PERFORM 1 FROM improv.model m WHERE m.id = p_model FOR UPDATE;
127  INSERT INTO improv.item (model_id, id, category_id, name)
128  SELECT p_model,
129         (SELECT coalesce(max(i.id), 0) FROM improv.item i WHERE i.model_id = p_model)
130           + row_number() OVER (ORDER BY s.c, s.n),
131         s.c, s.n
132  FROM (SELECT DISTINCT u.c, u.n FROM unnest(p_cats, p_names) AS u(c, n)
133        WHERE NOT EXISTS (SELECT 1 FROM improv.item i
134                          WHERE i.model_id = p_model AND i.category_id = u.c AND i.name = u.n)) s
135  ON CONFLICT DO NOTHING;
136  RETURN QUERY
137    SELECT i.category_id, i.name, i.id FROM improv.item i
138    JOIN (SELECT DISTINCT u.c, u.n FROM unnest(p_cats, p_names) AS u(c, n)) w
139      ON i.model_id = p_model AND i.category_id = w.c AND i.name = w.n;
140END $$;
141"#;
142
143#[derive(Debug, thiserror::Error)]
144pub enum StoreError {
145    /// Never produced by this backend; present so the variants match
146    /// `improv_storage_mentat::StoreError`.
147    #[error("mentat error: {0}")]
148    Mentat(String),
149    #[error("serialization error: {0}")]
150    Serde(#[from] serde_json::Error),
151    #[error("data integrity: {0}")]
152    Integrity(String),
153    /// Another writer bumped `improv.model_version` since this handle loaded.
154    #[error("conflict: {0}")]
155    Conflict(String),
156    #[error("postgres error: {0}")]
157    Pg(String),
158}
159
160pub type Result<T> = std::result::Result<T, StoreError>;
161
162impl From<postgres::Error> for StoreError {
163    fn from(e: postgres::Error) -> Self {
164        StoreError::Pg(match e.as_db_error() {
165            Some(db) => db.message().to_string(),
166            None => e.to_string(),
167        })
168    }
169}
170
171const CONFLICT_MSG: &str =
172    "the model was changed by another writer since it was loaded; reload and retry";
173
174/// Split `postgres://…?model=<name>[&…]` into the connection string without
175/// the `model` parameter and the model name (required).
176pub fn split_model_url(url: &str) -> Result<(String, String)> {
177    let (base, query) = url.split_once('?').unwrap_or((url, ""));
178    let mut model = None;
179    let mut rest = Vec::new();
180    for p in query.split('&').filter(|p| !p.is_empty()) {
181        match p.strip_prefix("model=") {
182            Some(m) => model = Some(m.to_string()),
183            None => rest.push(p),
184        }
185    }
186    let model = model.filter(|m| !m.is_empty()).ok_or_else(|| {
187        StoreError::Pg(
188            "a postgres model URL needs ?model=<name> (e.g. postgres://me@db/sales?model=plan)"
189                .into(),
190        )
191    })?;
192    let conn = if rest.is_empty() {
193        base.to_string()
194    } else {
195        format!("{base}?{}", rest.join("&"))
196    };
197    Ok((conn, model))
198}
199
200/// Refuse to send credentials in cleartext off-host, or to ask for TLS this
201/// build lacks (same policy as `improv_storage_sql::connect_postgres`).
202fn check_transport(config: &Config) -> Result<()> {
203    let no_tls = |why: &str| {
204        Err(StoreError::Pg(format!(
205            "TLS isn't built into this build of Improv; {why}"
206        )))
207    };
208    let mode = config.get_ssl_mode();
209    if mode == SslMode::Require {
210        return no_tls("sslmode=require cannot be honoured (use an SSH tunnel to a loopback port)");
211    }
212    let local = config.get_hosts().iter().all(|h| match h {
213        Host::Tcp(name) => {
214            name.eq_ignore_ascii_case("localhost")
215                || name
216                    .parse::<std::net::IpAddr>()
217                    .is_ok_and(|ip| ip.is_loopback())
218        }
219        #[cfg(unix)]
220        Host::Unix(_) => true,
221    }) && config.get_hostaddrs().iter().all(|ip| ip.is_loopback());
222    if !local && mode != SslMode::Disable {
223        return no_tls(
224            "refusing a non-loopback host without an explicit sslmode=disable \
225             (the password would cross the network in cleartext)",
226        );
227    }
228    Ok(())
229}
230
231/// Quote an identifier the way `quote_ident` does (always quoted).
232fn ident(s: &str) -> String {
233    format!("\"{}\"", s.replace('"', "\"\""))
234}
235
236fn id32(v: i64) -> Result<u32> {
237    u32::try_from(v).map_err(|_| StoreError::Integrity(format!("id {v} is out of range")))
238}
239
240fn vt_name(vt: ValueType) -> &'static str {
241    match vt {
242        ValueType::Number => "number",
243        ValueType::Boolean => "boolean",
244        ValueType::Text => "text",
245        ValueType::DateTime => "datetime",
246        ValueType::Enum => "enum",
247    }
248}
249
250fn vt_parse(s: &str) -> Result<ValueType> {
251    Ok(match s {
252        "number" => ValueType::Number,
253        "boolean" => ValueType::Boolean,
254        "text" => ValueType::Text,
255        "datetime" => ValueType::DateTime,
256        "enum" => ValueType::Enum,
257        other => {
258            return Err(StoreError::Integrity(format!(
259                "unknown value type {other:?}"
260            )))
261        }
262    })
263}
264
265/// A cell value as the JSON scalar stored in `improv.cell.value` and fed to
266/// a bound table through `jsonb_populate_recordset`.
267fn value_to_json(v: &Value) -> Result<serde_json::Value> {
268    Ok(match v {
269        // Integral numbers as JSON integers, so an `int` fact column accepts them.
270        Value::Number(n) if n.fract() == 0.0 && n.abs() < 9.0e15 => (*n as i64).into(),
271        Value::Number(n) => serde_json::json!(n),
272        Value::Boolean(b) => (*b).into(),
273        Value::Text(s) => s.as_str().into(),
274        Value::DateTime(d) => d.to_rfc3339().into(),
275        Value::Enum(e) => (*e).into(),
276        Value::Error(e) => {
277            return Err(StoreError::Integrity(format!(
278                "an error value cannot be stored as an input ({e:?})"
279            )))
280        }
281    })
282}
283
284fn json_to_value(vt: ValueType, j: &serde_json::Value) -> Result<Value> {
285    use serde_json::Value as J;
286    Ok(match (vt, j) {
287        (ValueType::Number, J::Number(n)) => Value::Number(n.as_f64().unwrap_or(f64::NAN)),
288        (ValueType::Enum, J::Number(n)) if n.as_u64().is_some_and(|n| n <= u32::MAX as u64) => {
289            Value::Enum(n.as_u64().unwrap_or_default() as u32)
290        }
291        (ValueType::Boolean, J::Bool(b)) => Value::Boolean(*b),
292        (ValueType::DateTime, J::String(s)) => match chrono::DateTime::parse_from_rfc3339(s) {
293            Ok(dt) => Value::DateTime(dt.with_timezone(&chrono::Utc)),
294            Err(_) => Value::Text(s.clone()),
295        },
296        (ValueType::Text, J::String(s)) => Value::Text(s.clone()),
297        (ValueType::Text, other) => Value::Text(other.to_string()),
298        _ => {
299            return Err(StoreError::Integrity(format!(
300                "{j} is not a {} value",
301                vt_name(vt)
302            )))
303        }
304    })
305}
306
307/// The SQL aggregate call a `binding.agg` names, over value expression `v`.
308/// The SQL aggregate call of a binding's `agg` over `v`. Postgres has no
309/// `min(bool)` or `max(bool)`: they are `bool_and` and `bool_or`.
310fn agg_call(agg: &str, v: &str, vt: ValueType) -> Result<String> {
311    match (agg, vt) {
312        ("min", ValueType::Boolean) => Ok(format!("bool_and({v})")),
313        ("max", ValueType::Boolean) => Ok(format!("bool_or({v})")),
314        ("sum" | "avg" | "min" | "max" | "count", _) => Ok(format!("{agg}({v})")),
315        ("count_distinct", _) => Ok(format!("count(DISTINCT {v})")),
316        _ => Err(StoreError::Integrity(format!(
317            "unknown binding agg {agg:?}"
318        ))),
319    }
320}
321
322fn value_type_of(model: &Model, m: MeasureId) -> Result<ValueType> {
323    model
324        .measures
325        .get(&m)
326        .map(|m| m.value_type)
327        .ok_or_else(|| StoreError::Integrity(format!("cell for unknown measure {}", m.0)))
328}
329
330/// A formula stored as DSL text: `Target = expr` (the extension's form) or a
331/// bare `expr`.
332fn parse_formula_text(
333    model: &Model,
334    text: &str,
335) -> std::result::Result<Formula, improv_core_model::ParseError> {
336    match parse_definition(model, text) {
337        Ok(Definition::Formula(ft)) => Ok(ft.formula),
338        _ => parse_expr(model, text),
339    }
340}
341
342/// The non-structural parts of a model, as `improv.meta.blob`.
343#[derive(serde::Deserialize, Default)]
344struct Meta {
345    #[serde(default)]
346    external_fns: HashMap<String, ExternalFn>,
347    #[serde(default)]
348    external_calls: HashMap<MeasureId, ExternalCall>,
349    #[serde(default)]
350    scenarios: HashMap<ScenarioId, Scenario>,
351    #[serde(default)]
352    sql_sources: HashMap<MeasureId, SqlSource>,
353    #[serde(default)]
354    lambdas: std::collections::BTreeMap<u32, improv_core_model::NamedLambda>,
355}
356
357/// One `improv.binding` row, resolved against the model.
358struct Binding {
359    measure: MeasureId,
360    /// `"schema"."table"`, quoted.
361    table: String,
362    value_col: String,
363    /// `(category, column)`, in `dim_cols` key order.
364    dims: Vec<(CategoryId, String)>,
365    /// `sum | avg | min | max | count | count_distinct`: rows per coordinate are aggregated
366    /// on load and the measure is read-only.
367    agg: Option<String>,
368}
369
370fn bindings(c: &mut impl GenericClient, model: &Model, mid: i64) -> Result<Vec<Binding>> {
371    let rows = c.query(
372        "SELECT b.measure_id, n.nspname::text, c.relname::text, b.value_col, b.dim_cols::text, \
373                b.agg \
374         FROM improv.binding b JOIN pg_class c ON c.oid = b.rel \
375         JOIN pg_namespace n ON n.oid = c.relnamespace \
376         WHERE b.model_id = $1 ORDER BY b.measure_id",
377        &[&mid],
378    )?;
379    rows.iter()
380        .map(|r| {
381            let cols: BTreeMap<String, String> = serde_json::from_str(r.get(4))?;
382            let dims = cols
383                .into_iter()
384                .map(|(cat, col)| {
385                    model
386                        .category_by_name(&cat)
387                        .map(|c| (c.id, col))
388                        .ok_or_else(|| {
389                            StoreError::Integrity(format!("binding names unknown category {cat:?}"))
390                        })
391                })
392                .collect::<Result<_>>()?;
393            Ok(Binding {
394                measure: MeasureId(id32(r.get(0))?),
395                table: format!("{}.{}", ident(r.get(1)), ident(r.get(2))),
396                value_col: r.get(3),
397                dims,
398                agg: r.get(5),
399            })
400        })
401        .collect()
402}
403
404fn version(c: &mut impl GenericClient, mid: i64, lock: bool) -> Result<i64> {
405    let sql = if lock {
406        "SELECT tx FROM improv.model_version WHERE model_id = $1 FOR UPDATE"
407    } else {
408        "SELECT tx FROM improv.model_version WHERE model_id = $1"
409    };
410    Ok(c.query_one(sql, &[&mid])?.get(0))
411}
412
413/// What a [`PgModelStore`] last loaded or saved: the base `save_model` diffs
414/// against, so a save writes only what this client changed. Carry it across
415/// handles with [`PgModelStore::set_snapshot`], as with `loaded_tx`.
416#[derive(Debug, Clone)]
417pub struct PgSnapshot {
418    /// Bound input cells, as read from their fact tables.
419    bound: HashMap<(MeasureId, Coordinate), Value>,
420    /// Unbound input cells, as read from `improv.cell`.
421    cells: HashMap<(MeasureId, Coordinate), Value>,
422    /// Row ids per catalog table (`category`, `item`, `measure`, `view`,
423    /// `lambda`).
424    ids: HashMap<&'static str, HashSet<i64>>,
425    /// The named LAMBDAs, to write only the changed ones.
426    lambdas: BTreeMap<u32, improv_core_model::NamedLambda>,
427    /// Item names, to address a removed bound cell whose item is gone.
428    names: HashMap<ItemId, String>,
429}
430
431impl PgSnapshot {
432    /// `model` as stored; `cells` is its unbound part.
433    fn new(model: &Model, cells: HashMap<(MeasureId, Coordinate), Value>) -> Self {
434        let bound = model
435            .inputs
436            .iter()
437            .filter(|(k, _)| !cells.contains_key(*k))
438            .map(|(k, v)| (k.clone(), v.clone()))
439            .collect();
440        let ids = HashMap::from([
441            (
442                "category",
443                model.categories.keys().map(|i| i64::from(i.0)).collect(),
444            ),
445            ("item", model.items.keys().map(|i| i64::from(i.0)).collect()),
446            (
447                "measure",
448                model.measures.keys().map(|i| i64::from(i.0)).collect(),
449            ),
450            ("view", model.views.keys().map(|i| i64::from(i.0)).collect()),
451            (
452                "lambda",
453                model.lambdas.keys().map(|i| i64::from(*i)).collect(),
454            ),
455        ]);
456        let names = model
457            .items
458            .values()
459            .map(|i| (i.id, i.name.0.clone()))
460            .collect();
461        PgSnapshot {
462            bound,
463            cells,
464            ids,
465            lambdas: model.lambdas.clone(),
466            names,
467        }
468    }
469}
470
471/// Delete the `improv.<table>` rows the model dropped: with a snapshot
472/// (`loaded`), only those it held, so rows another client added since
473/// survive; without one, every row the model lacks.
474fn delete_dropped(
475    tx: &mut impl GenericClient,
476    mid: i64,
477    table: &str,
478    keep: &[i64],
479    loaded: Option<&HashSet<i64>>,
480) -> Result<()> {
481    let Some(loaded) = loaded else {
482        tx.execute(
483            &format!("DELETE FROM improv.{table} WHERE model_id = $1 AND id <> ALL($2)"),
484            &[&mid, &keep],
485        )?;
486        return Ok(());
487    };
488    let keep: HashSet<&i64> = keep.iter().collect();
489    let gone: Vec<i64> = loaded
490        .iter()
491        .filter(|i| !keep.contains(i))
492        .copied()
493        .collect();
494    if !gone.is_empty() {
495        tx.execute(
496            &format!("DELETE FROM improv.{table} WHERE model_id = $1 AND id = ANY($2)"),
497            &[&mid, &gone],
498        )?;
499    }
500    Ok(())
501}
502
503/// Write binding `b`'s input cells to its fact table: values only, never DDL
504/// or a row delete (a row may hold other measures).
505///
506/// With a snapshot (`base`) only the cells added, changed or removed since it
507/// are written, each guarded by its loaded value: `UPDATE … SET value = <new>
508/// WHERE <dims> AND value IS NOT DISTINCT FROM <loaded>` (a removed cell's
509/// new value is NULL; an added cell's loaded one is NULL), and an added cell
510/// no row holds is `INSERT`ed. A guard matching no row (or several) means the
511/// cell changed in the database since the load: [`StoreError::Conflict`].
512/// Without a snapshot every cell is written, unchecked.
513/// `jsonb_populate_record` casts each JSON value to its column's type.
514fn write_bound(
515    tx: &mut impl GenericClient,
516    model: &Model,
517    b: &Binding,
518    base: Option<&PgSnapshot>,
519) -> Result<()> {
520    // Deleting a measure must not wipe its column.
521    let Some(measure) = model.measures.get(&b.measure) else {
522        return Ok(());
523    };
524    let snap = base.map(|s| &s.bound);
525    // An aggregated measure is never written; without a snapshot there is
526    // no telling what changed, so nothing is checked either.
527    if b.agg.is_some() && snap.is_none() {
528        return Ok(());
529    }
530    // (coordinate, value now, value as loaded); `None`: no cell.
531    let mut diff = Vec::new();
532    for (k, v) in &model.inputs {
533        if k.0 != b.measure {
534            continue;
535        }
536        let old = snap.and_then(|s| s.get(k));
537        if snap.is_none() || old != Some(v) {
538            diff.push((&k.1, Some(v), old));
539        }
540    }
541    for (k, v) in snap.into_iter().flatten() {
542        if k.0 == b.measure && !model.inputs.contains_key(k) {
543            diff.push((&k.1, None, Some(v)));
544        }
545    }
546    if diff.is_empty() {
547        return Ok(());
548    }
549    if b.agg.is_some() {
550        return Err(StoreError::Integrity(format!(
551            "aggregated measure {} is read-only",
552            measure.name.0
553        )));
554    }
555    let json = |v: Option<&Value>| v.map_or(Ok(serde_json::Value::Null), value_to_json);
556    let (mut xs, mut labels) = (Vec::new(), Vec::new());
557    for (coord, new, old) in &diff {
558        let mut n = serde_json::Map::new();
559        let mut names = Vec::new();
560        for (cat, col) in &b.dims {
561            let name = coord
562                .get(*cat)
563                .and_then(|i| {
564                    let gone = || base.and_then(|s| s.names.get(&i)).map(String::as_str);
565                    model.items.get(&i).map(|i| i.name.0.as_str()).or_else(gone)
566                })
567                .ok_or_else(|| {
568                    StoreError::Integrity(format!(
569                        "a cell of measure {} lacks a {} item",
570                        b.measure.0, col
571                    ))
572                })?;
573            n.insert(col.clone(), name.into());
574            names.push(name);
575        }
576        n.insert(b.value_col.clone(), json(*new)?);
577        let mut x = serde_json::Map::from_iter([("n".to_string(), n.into())]);
578        if base.is_some() {
579            let o = serde_json::Map::from_iter([(b.value_col.clone(), json(*old)?)]);
580            x.insert("o".into(), o.into());
581        }
582        xs.push(serde_json::Value::Object(x));
583        labels.push(names.join(", "));
584    }
585
586    let (t, v) = (&b.table, ident(&b.value_col));
587    let on = b
588        .dims
589        .iter()
590        .map(|(_, c)| format!("t.{0} = n.{0}", ident(c)))
591        .chain(std::iter::once("true".to_string()))
592        .collect::<Vec<_>>()
593        .join(" AND ");
594    let src = format!(
595        "jsonb_array_elements($1::text::jsonb) WITH ORDINALITY e(x, i), \
596         LATERAL jsonb_populate_record(NULL::{t}, e.x->'n') n"
597    );
598    let guard = match base {
599        Some(_) => {
600            format!("t.{v} IS NOT DISTINCT FROM (jsonb_populate_record(NULL::{t}, e.x->'o')).{v}")
601        }
602        None => format!("t.{v} IS DISTINCT FROM n.{v}"),
603    };
604    // Rows each cell's UPDATE matched, by its 1-based ordinal.
605    let mut hits = vec![0; diff.len()];
606    for r in tx.query(
607        &format!("UPDATE {t} t SET {v} = n.{v} FROM {src} WHERE {on} AND {guard} RETURNING e.i"),
608        &[&serde_json::to_string(&xs)?],
609    )? {
610        hits[r.get::<_, i64>(0) as usize - 1] += 1;
611    }
612
613    // Added cells the UPDATE missed: INSERT where no row has their
614    // dimensions. The data-modifying CTE runs once and the outer SELECT sees
615    // the table as before it: the added cells whose row already existed.
616    let added: Vec<usize> = (0..diff.len())
617        .filter(|&i| hits[i] == 0 && (base.is_none() || diff[i].2.is_none()))
618        .collect();
619    let mut taken = vec![false; diff.len()];
620    if !added.is_empty() {
621        let cols: Vec<String> = b
622            .dims
623            .iter()
624            .map(|(_, c)| ident(c))
625            .chain(std::iter::once(v.clone()))
626            .collect();
627        let ncols: Vec<String> = cols.iter().map(|c| format!("n.{c}")).collect();
628        let sub: Vec<&serde_json::Value> = added.iter().map(|&i| &xs[i]).collect();
629        for r in tx.query(
630            &format!(
631                "WITH ins AS (INSERT INTO {t} ({}) SELECT {} FROM {src} \
632                 WHERE NOT EXISTS (SELECT 1 FROM {t} t WHERE {on})) \
633                 SELECT e.i FROM {src} WHERE EXISTS (SELECT 1 FROM {t} t WHERE {on})",
634                cols.join(", "),
635                ncols.join(", ")
636            ),
637            &[&serde_json::to_string(&sub)?],
638        )? {
639            taken[added[r.get::<_, i64>(0) as usize - 1]] = true;
640        }
641    }
642    if base.is_none() {
643        return Ok(());
644    }
645    let lost = (0..diff.len())
646        .find(|&i| hits[i] > 1 || (hits[i] == 0 && (diff[i].2.is_some() || taken[i])));
647    match lost {
648        Some(i) => Err(StoreError::Conflict(format!(
649            "bound cell {}[{}] was changed in the database since it was loaded; \
650             reload and retry",
651            measure.name.0, labels[i]
652        ))),
653        None => Ok(()),
654    }
655}
656
657/// A model store over one named model in a Postgres database.
658pub struct PgModelStore {
659    client: Client,
660    model_id: i64,
661    /// `improv.model_version.tx` as of this handle's last load or save;
662    /// `save_model` refuses to commit if it moved. `None`: unchecked.
663    loaded_tx: Option<i64>,
664    /// What this handle last loaded or saved; `save_model` writes the diff
665    /// against it. `None`: write everything, unchecked.
666    loaded: Option<PgSnapshot>,
667}
668
669impl PgModelStore {
670    /// Connect to `postgres://…?model=<name>` and open (or create) that model.
671    /// Creates the `improv.*` catalog ([`CATALOG_SQL`]) if it is missing.
672    pub fn open(url: &str) -> Result<Self> {
673        let (conn, name) = split_model_url(url)?;
674        let config: Config = conn
675            .parse()
676            .map_err(|_| StoreError::Pg("invalid postgres connection string (redacted)".into()))?;
677        check_transport(&config)?;
678        // A non-database connect error may echo the DSN; keep only its kind.
679        let mut client = config.connect(NoTls).map_err(|e| {
680            StoreError::Pg(match e.as_db_error() {
681                Some(db) => format!("postgres connect failed: {}", db.message()),
682                None => "postgres connect failed (connection error)".into(),
683            })
684        })?;
685        // The newest catalog objects: an older catalog is upgraded in place
686        // (CATALOG_SQL is idempotent).
687        const INSTALLED: &str =
688            "SELECT to_regprocedure('improv.mint_items(bigint, bigint[], text[])') IS NOT NULL \
689             AND to_regclass('improv.lambda') IS NOT NULL";
690        if !client.query_one(INSTALLED, &[])?.get::<_, bool>(0) {
691            // A session lock, so the check below starts a new transaction
692            // and sees a catalog another client installed meanwhile: its
693            // DDL rerun against that client's writes would deadlock.
694            client.execute("SELECT pg_advisory_lock(hashtext('improv.catalog'))", &[])?;
695            if !client.query_one(INSTALLED, &[])?.get::<_, bool>(0) {
696                client.batch_execute(&format!("BEGIN; {CATALOG_SQL} COMMIT;"))?;
697            }
698            client.execute("SELECT pg_advisory_unlock(hashtext('improv.catalog'))", &[])?;
699        }
700        let model_id: i64 = client
701            .query_one(
702                "WITH ins AS (INSERT INTO improv.model(name) VALUES ($1) \
703                 ON CONFLICT (name) DO NOTHING RETURNING id) \
704                 SELECT id FROM ins UNION ALL SELECT id FROM improv.model WHERE name = $1",
705                &[&name],
706            )?
707            .get(0);
708        client.execute(
709            "INSERT INTO improv.model_version(model_id) VALUES ($1) ON CONFLICT DO NOTHING",
710            &[&model_id],
711        )?;
712        Ok(PgModelStore {
713            client,
714            model_id,
715            loaded_tx: None,
716            loaded: None,
717        })
718    }
719
720    /// The model's current `improv.model_version.tx`, read live.
721    pub fn tx_high_water(&mut self) -> Result<i64> {
722        version(&mut self.client, self.model_id, false)
723    }
724
725    /// The version this handle last loaded or saved at (`None` if neither).
726    pub fn loaded_tx(&self) -> Option<i64> {
727        self.loaded_tx
728    }
729
730    /// Adopt `tx` as the base `save_model` checks against; `None` saves
731    /// unconditionally.
732    pub fn set_loaded_tx(&mut self, tx: Option<i64>) {
733        self.loaded_tx = tx;
734    }
735
736    /// What this handle last loaded or saved: the base `save_model` diffs
737    /// against (`None` if neither).
738    pub fn snapshot(&self) -> Option<&PgSnapshot> {
739        self.loaded.as_ref()
740    }
741
742    /// Adopt `snapshot` (e.g. from the handle that loaded) as the save base;
743    /// `None` makes `save_model` write the whole model, unchecked.
744    pub fn set_snapshot(&mut self, snapshot: Option<PgSnapshot>) {
745        self.loaded = snapshot;
746    }
747
748    /// Re-read the model after a [`StoreError::Conflict`].
749    pub fn reload_model(&mut self) -> Result<Model> {
750        self.load_model()
751    }
752
753    /// Read the whole model (structure, unbound cells, bound fact tables) from
754    /// one consistent snapshot.
755    pub fn load_model(&mut self) -> Result<Model> {
756        self.load(None)
757    }
758
759    /// Like [`Self::load_model`], but only the cells of `measure_ids` and
760    /// their dependency closure. Saving the result through this handle
761    /// writes only what changed against what it loaded, so the cells it did
762    /// not load are left alone.
763    pub fn load_partial(&mut self, measure_ids: &[MeasureId]) -> Result<Model> {
764        self.load(Some(measure_ids))
765    }
766
767    fn load(&mut self, roots: Option<&[MeasureId]>) -> Result<Model> {
768        let mid = self.model_id;
769        let mut tx = self
770            .client
771            .build_transaction()
772            .isolation_level(IsolationLevel::RepeatableRead)
773            .read_only(true)
774            .start()?;
775        let tx_now = version(&mut tx, mid, false)?;
776        let mut model = Model::new();
777        for r in tx.query(
778            "SELECT id, name FROM improv.category WHERE model_id = $1 ORDER BY id",
779            &[&mid],
780        )? {
781            model.add_category(CategoryId(id32(r.get(0))?), r.get::<_, String>(1));
782        }
783        for r in tx.query(
784            "SELECT id, category_id, name FROM improv.item WHERE model_id = $1 ORDER BY id",
785            &[&mid],
786        )? {
787            let (id, cat) = (ItemId(id32(r.get(0))?), CategoryId(id32(r.get(1))?));
788            model.add_item(id, cat, r.get::<_, String>(2));
789        }
790        let mut dsl = Vec::new();
791        for r in tx.query(
792            "SELECT id, name, value_type, kind, formula, description, categories \
793             FROM improv.measure WHERE model_id = $1 ORDER BY id",
794            &[&mid],
795        )? {
796            let id = MeasureId(id32(r.get(0))?);
797            let kind = match r.get::<_, &str>(3) {
798                "derived" => {
799                    let f: Option<String> = r.get(4);
800                    let f = f.ok_or_else(|| {
801                        StoreError::Integrity(format!("derived measure {} has no formula", id.0))
802                    })?;
803                    match serde_json::from_str(&f) {
804                        Ok(f) => MeasureKind::Derived(f),
805                        // DSL text (what `improv.define` writes): parsed once
806                        // every measure name is known.
807                        Err(_) => {
808                            dsl.push((id, f));
809                            MeasureKind::Input
810                        }
811                    }
812                }
813                _ => MeasureKind::Input,
814            };
815            let categories = r
816                .get::<_, Vec<i64>>(6)
817                .into_iter()
818                .map(|c| id32(c).map(CategoryId))
819                .collect::<Result<_>>()?;
820            model.add_measure(Measure {
821                id,
822                name: Name(r.get(1)),
823                value_type: vt_parse(r.get(2))?,
824                categories,
825                kind,
826                description: r.get(5),
827            });
828        }
829        if let Some(r) = tx.query_opt(
830            "SELECT blob::text FROM improv.meta WHERE model_id = $1",
831            &[&mid],
832        )? {
833            let meta: Meta = serde_json::from_str(r.get(0))?;
834            model.external_fns = meta.external_fns;
835            model.external_calls = meta.external_calls;
836            model.scenarios = meta.scenarios;
837            model.sql_sources = meta.sql_sources;
838            // Saved before improv.lambda existed; the table's rows win.
839            model.lambdas = meta.lambdas;
840        }
841        let lambdas = tx
842            .query(
843                "SELECT id, definition FROM improv.lambda WHERE model_id = $1 ORDER BY id",
844                &[&mid],
845            )?
846            .iter()
847            .map(|r| Ok((id32(r.get(0))?, r.get::<_, String>(1))))
848            .collect::<Result<Vec<_>>>()?;
849        // The legacy blob's lambdas are not stored rows: the next save
850        // writes them to improv.lambda.
851        let legacy = lambdas.is_empty() && !model.lambdas.is_empty();
852        if !lambdas.is_empty() {
853            model.lambdas.clear();
854            model
855                .load_lambdas(&lambdas)
856                .map_err(StoreError::Integrity)?;
857        }
858        // Text formulas last: they may call any lambda.
859        for (id, text) in dsl {
860            let f = parse_formula_text(&model, &text).map_err(|e| {
861                StoreError::Integrity(format!("measure {}: bad formula {text:?}: {e}", id.0))
862            })?;
863            if let Some(m) = model.measures.get_mut(&id) {
864                m.kind = MeasureKind::Derived(f);
865            }
866        }
867        for r in tx.query(
868            "SELECT layout::text FROM improv.view WHERE model_id = $1",
869            &[&mid],
870        )? {
871            model.add_view(serde_json::from_str(r.get(0))?);
872        }
873
874        // Cells: only the closure's, filtered in the database.
875        let keep: Option<HashSet<MeasureId>> = roots.map(|r| model.measure_dependency_closure(r));
876        let filter: Option<Vec<i64>> = keep
877            .as_ref()
878            .map(|k| k.iter().map(|m| i64::from(m.0)).collect());
879        for r in tx.query(
880            "SELECT measure_id, coord::text, value::text FROM improv.cell \
881             WHERE model_id = $1 AND ($2::int8[] IS NULL OR measure_id = ANY($2))",
882            &[&mid, &filter],
883        )? {
884            let m = MeasureId(id32(r.get(0))?);
885            let vt = value_type_of(&model, m)?;
886            let dims: BTreeMap<CategoryId, ItemId> = serde_json::from_str(r.get(1))?;
887            let v = json_to_value(vt, &serde_json::from_str(r.get(2))?)?;
888            model.set_input(m, Coordinate { dims }, v);
889        }
890        // The unbound cells as read; the rest of `model.inputs` is bound.
891        let cells = model.inputs.clone();
892
893        // Bound cells: dimension values map to items by name. Values with no
894        // item are minted by `improv.mint_items` (the one minter, shared with
895        // the pg_improv extension) once this snapshot closes: it must see
896        // concurrent mints, so it runs read-committed, not in this snapshot.
897        let mut names: HashMap<(CategoryId, String), ItemId> = model
898            .items
899            .values()
900            .map(|i| ((i.category, i.name.0.clone()), i.id))
901            .collect();
902        let bound = bindings(&mut tx, &model, mid)?;
903        let mut rows = Vec::new();
904        for (bi, b) in bound.iter().enumerate() {
905            if keep.as_ref().is_some_and(|k| !k.contains(&b.measure)) {
906                continue;
907            }
908            let vt = value_type_of(&model, b.measure)?;
909            let mut cols: Vec<String> = b
910                .dims
911                .iter()
912                .map(|(_, c)| format!("{}::text", ident(c)))
913                .collect();
914            let mut v = ident(&b.value_col);
915            if let Some(a) = b.agg.as_deref() {
916                v = agg_call(a, &v, vt)?;
917            }
918            // A date/timestamp column as timestamptz: its JSON is RFC 3339.
919            if vt == ValueType::DateTime {
920                v = format!("({v})::timestamptz");
921            }
922            cols.push(format!("to_jsonb({v})::text"));
923            let sql = match b.agg {
924                None => format!("SELECT {} FROM {}", cols.join(", "), b.table),
925                Some(_) => {
926                    let by: Vec<String> = (1..cols.len()).map(|i| i.to_string()).collect();
927                    format!(
928                        "SELECT {} FROM {} GROUP BY {}",
929                        cols.join(", "),
930                        b.table,
931                        by.join(", ")
932                    )
933                }
934            };
935            'row: for r in tx.query(&sql, &[])? {
936                // NULL value: no cell. NULL dimension: the row is ignored.
937                let Some(v) = r.get::<_, Option<&str>>(b.dims.len()) else {
938                    continue;
939                };
940                let mut dims = Vec::with_capacity(b.dims.len());
941                for (i, (cat, _)) in b.dims.iter().enumerate() {
942                    let Some(name) = r.get::<_, Option<String>>(i) else {
943                        continue 'row;
944                    };
945                    dims.push((*cat, name));
946                }
947                rows.push((bi, dims, json_to_value(vt, &serde_json::from_str(v)?)?));
948            }
949        }
950        tx.commit()?;
951        let new: BTreeSet<&(CategoryId, String)> = rows
952            .iter()
953            .flat_map(|(_, dims, _)| dims)
954            .filter(|k| !names.contains_key(*k))
955            .collect();
956        if !new.is_empty() {
957            let (cats, new): (Vec<i64>, Vec<&str>) = new
958                .into_iter()
959                .map(|(c, n)| (i64::from(c.0), n.as_str()))
960                .unzip();
961            for r in self.client.query(
962                "SELECT category_id, name, id FROM improv.mint_items($1, $2::int8[], $3::text[])",
963                &[&mid, &cats, &new],
964            )? {
965                let (cat, id) = (CategoryId(id32(r.get(0))?), ItemId(id32(r.get(2))?));
966                let name: String = r.get(1);
967                model.add_item(id, cat, name.clone());
968                names.insert((cat, name), id);
969            }
970        }
971        for (bi, dims, v) in rows {
972            let b = &bound[bi];
973            let mut coord = Coordinate::new();
974            for k in &dims {
975                let item = names.get(k).ok_or_else(|| {
976                    StoreError::Integrity(format!("improv.mint_items returned no id for {:?}", k.1))
977                })?;
978                coord = coord.with(k.0, *item);
979            }
980            if model.inputs.insert((b.measure, coord), v).is_some() {
981                return Err(StoreError::Integrity(format!(
982                    "{} has two rows for one coordinate of measure {}",
983                    b.table, b.measure.0
984                )));
985            }
986        }
987        self.loaded_tx = Some(tx_now);
988        let mut snap = PgSnapshot::new(&model, cells);
989        if legacy {
990            snap.lambdas.clear();
991            snap.ids.insert("lambda", HashSet::new());
992        }
993        self.loaded = Some(snap);
994        Ok(model)
995    }
996
997    /// Persist the model in ONE transaction and bump `improv.model_version`.
998    ///
999    /// The save is a diff against this handle's snapshot (what it last loaded
1000    /// or saved): structure rows are upserted, and only rows the snapshot
1001    /// held but the model dropped are deleted (rows another client added
1002    /// survive); input cells added, changed or removed since are written,
1003    /// bound ones to their fact table under a per-cell optimistic guard
1004    /// (a removed one's value column is set to NULL, the row kept), unbound
1005    /// ones to `improv.cell`. Without a snapshot ([`Self::set_snapshot`]) the
1006    /// whole model is written unchecked and rows it lacks are deleted.
1007    ///
1008    /// Optimistic concurrency: fails with [`StoreError::Conflict`], writing
1009    /// nothing, if the version moved since this handle loaded or saved, or a
1010    /// bound cell it writes was changed in the database since.
1011    pub fn save_model(&mut self, model: &Model) -> Result<()> {
1012        let mid = self.model_id;
1013        let mut tx = self.client.transaction()?;
1014        let cur = version(&mut tx, mid, true)?;
1015        if self.loaded_tx.is_some_and(|l| l != cur) {
1016            return Err(StoreError::Conflict(CONFLICT_MSG.into()));
1017        }
1018
1019        let (ids, names): (Vec<i64>, Vec<&str>) = model
1020            .categories
1021            .values()
1022            .map(|c| (i64::from(c.id.0), c.name.0.as_str()))
1023            .unzip();
1024        tx.execute(
1025            "INSERT INTO improv.category(model_id, id, name) \
1026             SELECT $1, * FROM unnest($2::int8[], $3::text[]) \
1027             ON CONFLICT (model_id, id) DO UPDATE SET name = EXCLUDED.name",
1028            &[&mid, &ids, &names],
1029        )?;
1030        let cat_ids = ids;
1031
1032        let mut ids = Vec::new();
1033        let mut cats = Vec::new();
1034        let mut names = Vec::new();
1035        for i in model.items.values() {
1036            ids.push(i64::from(i.id.0));
1037            cats.push(i64::from(i.category.0));
1038            names.push(i.name.0.as_str());
1039        }
1040        tx.execute(
1041            "INSERT INTO improv.item(model_id, id, category_id, name) \
1042             SELECT $1, * FROM unnest($2::int8[], $3::int8[], $4::text[]) \
1043             ON CONFLICT (model_id, id) \
1044             DO UPDATE SET category_id = EXCLUDED.category_id, name = EXCLUDED.name",
1045            &[&mid, &ids, &cats, &names],
1046        )?;
1047        let item_ids = ids;
1048
1049        let upsert = tx.prepare(
1050            "INSERT INTO improv.measure \
1051             (model_id, id, name, value_type, kind, formula, description, categories) \
1052             VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
1053             ON CONFLICT (model_id, id) DO UPDATE SET name = EXCLUDED.name, \
1054             value_type = EXCLUDED.value_type, kind = EXCLUDED.kind, \
1055             formula = EXCLUDED.formula, description = EXCLUDED.description, \
1056             categories = EXCLUDED.categories",
1057        )?;
1058        let mut measure_ids = Vec::new();
1059        for m in model.measures.values() {
1060            let (kind, formula) = match &m.kind {
1061                MeasureKind::Input => ("input", None),
1062                MeasureKind::Derived(f) => ("derived", Some(serde_json::to_string(f)?)),
1063            };
1064            let cats: Vec<i64> = m.categories.iter().map(|c| i64::from(c.0)).collect();
1065            let id = i64::from(m.id.0);
1066            tx.execute(
1067                &upsert,
1068                &[
1069                    &mid,
1070                    &id,
1071                    &m.name.0,
1072                    &vt_name(m.value_type),
1073                    &kind,
1074                    &formula,
1075                    &m.description,
1076                    &cats,
1077                ],
1078            )?;
1079            measure_ids.push(id);
1080        }
1081        // Children before parents, should the catalog grow foreign keys.
1082        let base = self.loaded.as_ref();
1083        for (table, keep) in [
1084            ("measure", &measure_ids),
1085            ("item", &item_ids),
1086            ("category", &cat_ids),
1087        ] {
1088            delete_dropped(&mut tx, mid, table, keep, base.map(|s| &s.ids[table]))?;
1089        }
1090
1091        // Bound cells: values only, never DDL (see `write_bound`).
1092        let bound = bindings(&mut tx, model, mid)?;
1093        for b in &bound {
1094            write_bound(&mut tx, model, b, base)?;
1095        }
1096
1097        // Unbound cells: with a snapshot, upsert those added or changed since
1098        // and delete those removed; without, improv.cell ends up holding
1099        // exactly the model's.
1100        let bound: HashSet<MeasureId> = bound.iter().map(|b| b.measure).collect();
1101        let cells: HashMap<(MeasureId, Coordinate), Value> = model
1102            .inputs
1103            .iter()
1104            .filter(|((m, _), _)| !bound.contains(m))
1105            .map(|(k, v)| (k.clone(), v.clone()))
1106            .collect();
1107        let (mut ms, mut coords, mut vals) = (Vec::new(), Vec::new(), Vec::new());
1108        for (k @ (m, coord), v) in &cells {
1109            if base.is_some_and(|s| s.cells.get(k) == Some(v)) {
1110                continue;
1111            }
1112            ms.push(i64::from(m.0));
1113            coords.push(serde_json::to_string(&coord.dims)?);
1114            vals.push(value_to_json(v)?.to_string());
1115        }
1116        if let Some(s) = base {
1117            let (mut gm, mut gk) = (Vec::new(), Vec::new());
1118            for (m, coord) in s.cells.keys().filter(|k| !cells.contains_key(*k)) {
1119                gm.push(i64::from(m.0));
1120                gk.push(serde_json::to_string(&coord.dims)?);
1121            }
1122            if !gm.is_empty() {
1123                tx.execute(
1124                    "DELETE FROM improv.cell c USING unnest($2::int8[], $3::text[]) u(m, k) \
1125                     WHERE c.model_id = $1 AND c.measure_id = u.m AND c.coord = u.k::jsonb",
1126                    &[&mid, &gm, &gk],
1127                )?;
1128            }
1129        } else {
1130            tx.execute(
1131                "DELETE FROM improv.cell c WHERE c.model_id = $1 AND NOT EXISTS \
1132                 (SELECT 1 FROM unnest($2::int8[], $3::text[]) u(m, k) \
1133                  WHERE c.measure_id = u.m AND c.coord = u.k::jsonb)",
1134                &[&mid, &ms, &coords],
1135            )?;
1136        }
1137        if !ms.is_empty() {
1138            tx.execute(
1139                "INSERT INTO improv.cell(model_id, measure_id, coord, value) \
1140                 SELECT $1, m, k::jsonb, v::jsonb FROM unnest($2::int8[], $3::text[], $4::text[]) u(m, k, v) \
1141                 ON CONFLICT (model_id, measure_id, coord) DO UPDATE SET value = EXCLUDED.value \
1142                 WHERE improv.cell.value IS DISTINCT FROM EXCLUDED.value",
1143                &[&mid, &ms, &coords, &vals],
1144            )?;
1145        }
1146
1147        let (mut ids, mut names, mut layouts) = (Vec::new(), Vec::new(), Vec::new());
1148        for v in model.views.values() {
1149            ids.push(i64::from(v.id.0));
1150            names.push(v.name.0.as_str());
1151            layouts.push(serde_json::to_string(v)?);
1152        }
1153        tx.execute(
1154            "INSERT INTO improv.view(model_id, id, name, layout) \
1155             SELECT $1, i, n, l::jsonb FROM unnest($2::int8[], $3::text[], $4::text[]) u(i, n, l) \
1156             ON CONFLICT (model_id, id) DO UPDATE SET name = EXCLUDED.name, layout = EXCLUDED.layout",
1157            &[&mid, &ids, &names, &layouts],
1158        )?;
1159        delete_dropped(&mut tx, mid, "view", &ids, base.map(|s| &s.ids["view"]))?;
1160
1161        // Lambdas: one improv.lambda row each, as JSON. Only those this
1162        // client changed are written, so a row improv.define wrote as text
1163        // keeps its text.
1164        let stored = base.map(|s| &s.lambdas);
1165        let ids: Vec<i64> = model.lambdas.keys().map(|i| i64::from(*i)).collect();
1166        let (mut up, mut names, mut defs) = (Vec::new(), Vec::new(), Vec::new());
1167        for (id, l) in &model.lambdas {
1168            if stored.is_some_and(|s| s.get(id) == Some(l)) {
1169                continue;
1170            }
1171            up.push(i64::from(*id));
1172            names.push(l.name.0.as_str());
1173            defs.push(serde_json::to_string(l)?);
1174        }
1175        tx.execute(
1176            "INSERT INTO improv.lambda(model_id, id, name, definition) \
1177             SELECT $1, i, n, d FROM unnest($2::int8[], $3::text[], $4::text[]) u(i, n, d) \
1178             ON CONFLICT (model_id, id) DO UPDATE SET name = EXCLUDED.name, \
1179             definition = EXCLUDED.definition",
1180            &[&mid, &up, &names, &defs],
1181        )?;
1182        delete_dropped(&mut tx, mid, "lambda", &ids, base.map(|s| &s.ids["lambda"]))?;
1183
1184        let blob = serde_json::json!({
1185            "external_fns": model.external_fns,
1186            "external_calls": model.external_calls,
1187            "scenarios": model.scenarios,
1188            "sql_sources": model.sql_sources,
1189        })
1190        .to_string();
1191        tx.execute(
1192            "INSERT INTO improv.meta(model_id, blob) VALUES ($1, $2::text::jsonb) \
1193             ON CONFLICT (model_id) DO UPDATE SET blob = EXCLUDED.blob",
1194            &[&mid, &blob],
1195        )?;
1196
1197        let new_tx: i64 = tx
1198            .query_one(
1199                "UPDATE improv.model_version SET tx = tx + 1 WHERE model_id = $1 RETURNING tx",
1200                &[&mid],
1201            )?
1202            .get(0);
1203        tx.commit()?;
1204        self.loaded_tx = Some(new_tx);
1205        self.loaded = Some(PgSnapshot::new(model, cells));
1206        Ok(())
1207    }
1208}