lix 0.18.0

Embeddable version control for apps and AI agents.
Documentation
//! Defaults added by an accepted schema amendment are ordinary row
//! changes, materialized once in the schema transaction rather than on reads.
use super::*;

pub(super) fn schemas_with_defaults(
    write: &TransactionWrite,
) -> Result<Vec<(String, JsonValue)>, LixError> {
    let rows = match write {
        TransactionWrite::Rows { rows, .. }
        | TransactionWrite::RowsWithFileContent { rows, .. } => rows,
    };
    // Certified SQL batches contain one homogeneous non-control schema; keep
    // the ordinary insert/update path constant-time at this optional hook.
    if rows
        .certified_preparation()
        .is_some_and(|proof| proof.fileless_typed_sql_rows)
        && rows
            .get(0)
            .is_some_and(|row| row.schema_key != REGISTERED_SCHEMA_KEY)
    {
        return Ok(Vec::new());
    }
    let mut schemas = Vec::new();
    for row in rows
        .iter()
        .filter(|row| row.schema_key == REGISTERED_SCHEMA_KEY)
    {
        let snapshot = if let Some(snapshot) = row.snapshot_json() {
            snapshot.value().clone()
        } else if let Some(typed) = row.decoded_snapshot() {
            typed.to_json_value()?
        } else {
            continue;
        };
        push_schema_with_defaults(&mut schemas, &snapshot);
    }
    Ok(schemas)
}

// Plugin archive reconciliation injects registered-schema writes after the
// public write was dispatched. Read those definitions from the prepared batch.
pub(super) fn prepared_schemas_with_defaults(
    write: &PreparedTransactionWrite,
) -> Result<Vec<(String, JsonValue)>, LixError> {
    let mut schemas = Vec::new();
    for row in prepared_transaction_write_rows(write)
        .iter()
        .filter(|row| row.schema_key == REGISTERED_SCHEMA_KEY)
    {
        if let Some(snapshot) = row.materialize_decoded_snapshot()? {
            push_schema_with_defaults(&mut schemas, &snapshot.to_json_value()?);
        }
    }
    Ok(schemas)
}

fn push_schema_with_defaults(schemas: &mut Vec<(String, JsonValue)>, snapshot: &JsonValue) {
    let Some(schema) = snapshot.get("value") else {
        return;
    };
    let Some(key) = schema.get("key").and_then(JsonValue::as_str) else {
        return;
    };
    if schema
        .get("columns")
        .and_then(JsonValue::as_array)
        .is_some_and(|columns| {
            columns.iter().any(|column| {
                column.get("default_expression").is_some() || column.get("default_value").is_some()
            })
        })
    {
        schemas.push((key.to_owned(), schema.clone()));
    }
}

impl<StorageImpl> Transaction<StorageImpl>
where
    StorageImpl: Storage + Clone + Send + Sync + 'static,
{
    pub(super) fn materialize_schema_defaults<'a>(
        &'a mut self,
        schemas: &'a [(String, JsonValue)],
    ) -> futures_util::future::BoxFuture<'a, Result<(), LixError>> {
        // Construct the storage-heavy future only when this optional hook is
        // invoked. Boxing at an async call site still leaves its construction
        // temporary in every ordinary staging poll frame.
        Box::pin(async move {
            // A global schema can affect many branch/durability catalogs. Resolve
            // each row against the staged schema state, retaining local overrides.
            self.schema_resolver.clear_cached_catalogs();
            let mut updates = RawWriteBatch::default();
            for (schema_key, amended_schema) in schemas {
                let rows = self
                    .scan_visible_hot_state_batch(&HotStateScanRequest {
                        filter: HotStateFilter {
                            schema_keys: vec![schema_key.clone()],
                            ..Default::default()
                        },
                        projection: HotStateProjection {
                            columns: vec!["snapshot_content".into(), "metadata".into()],
                        },
                        ..Default::default()
                    })
                    .await?;
                let staged = self.staged_writes.staging_overlay()?;
                let read = self.opening_read();
                let hot_state = self
                    .hot_state
                    .transaction_reader(read, Arc::clone(&self.branch_head_control_cache));
                for row in rows.iter() {
                    let domain = Domain::for_live_row_ref(row).schema_catalog_domain();
                    let catalog = self
                        .schema_resolver
                        .catalog_for_row_normalization(&hot_state, &staged, &domain)
                        .await?;
                    let Some((_, plan)) = catalog.snapshot().plan_for_key(schema_key) else {
                        continue;
                    };
                    if plan.schema.as_ref() != amended_schema {
                        continue;
                    }
                    let Some(typed) = row.materialize_decoded_snapshot()? else {
                        continue;
                    };
                    if !plan.compiled_schema.defaults_would_apply(&typed.row) {
                        continue;
                    }
                    let mut value = typed.row.clone();
                    let functions = self.functions.clone();
                    let timestamp_functions = functions.clone();
                    plan.compiled_schema
                        .apply_defaults(
                            &mut value,
                            || functions.call_uuid_v7(),
                            || {
                                let timestamp = *self
                                    .current_timestamp
                                    .get_or_insert_with(|| timestamp_functions.call_timestamp());
                                i64::try_from(timestamp.milliseconds_since_unix_epoch())
                                    .expect("Lix timestamp fits i64")
                                    * 1_000
                            },
                        )
                        .map_err(|error| {
                            LixError::new(LixError::CODE_SCHEMA_VALIDATION, error.to_string())
                        })?;
                    plan.compiled_schema
                        .materialize_missing_nullable_columns(&mut value);
                    let typed = Arc::new(WasmTypedRow::from_row(plan, value)?);
                    let metadata = row
                        .metadata()
                        .map(|metadata| {
                            TransactionJson::from_value(
                                serde_json::from_str(metadata.as_str()).map_err(|error| {
                                    LixError::new(
                                        LixError::CODE_SCHEMA_VALIDATION,
                                        error.to_string(),
                                    )
                                })?,
                                "schema amendment row metadata",
                            )
                        })
                        .transpose()?;
                    updates.push_typed_parts(
                        Some(row.row_pk().clone()),
                        schema_key.as_str().into(),
                        row.file_id().map(Into::into),
                        Some(typed),
                        metadata,
                        None,
                        Some(row.created_at().to_string().into()),
                        None,
                        row.global(),
                        None,
                        None,
                        row.untracked(),
                        row.branch_id().into(),
                    );
                }
            }
            if !updates.is_empty() {
                // Retain normal file reconciliation, row identity/constraint checks,
                // change history and commit publication for the generated values.
                Box::pin(self.stage_write_without_schema_default_backfill(
                    TransactionWrite::Rows {
                        mode: TransactionWriteMode::Replace,
                        rows: updates,
                    },
                ))
                .await?;
            }
            Ok(())
        })
    }
}