Skip to main content

icydb_core/db/session/
write.rs

1//! Module: db::session::write
2//! Responsibility: session-owned typed write APIs for insert, replace, update,
3//! and structural mutation entrypoints over the shared save pipeline.
4//! Does not own: commit staging, mutation execution, or persistence encoding.
5//! Boundary: keeps public session write semantics above the executor save surface.
6
7use crate::{
8    db::{
9        DbSession, PersistedRow, WriteBatchResponse,
10        data::{FieldSlot, StructuralPatch},
11        executor::MutationMode,
12        schema::{
13            AcceptedFieldAbsencePolicy, AcceptedRowLayoutRuntimeDescriptor, SchemaInfo,
14            accepted_commit_schema_fingerprint_for_model,
15        },
16    },
17    error::InternalError,
18    traits::{CanisterKind, EntityCreateInput, EntityValue},
19    value::Value,
20};
21
22// Append one session-resolved structural field update. The caller passes the
23// accepted runtime descriptor that already crossed schema reconciliation, so
24// field-name lookup follows persisted row-layout metadata rather than generated
25// declaration order.
26fn append_accepted_structural_patch_field(
27    entity_path: &'static str,
28    descriptor: &AcceptedRowLayoutRuntimeDescriptor<'_>,
29    patch: StructuralPatch,
30    field_name: &str,
31    value: Value,
32) -> Result<StructuralPatch, InternalError> {
33    let slot = descriptor
34        .field_slot_index_by_name(field_name)
35        .ok_or_else(|| InternalError::mutation_structural_field_unknown(entity_path, field_name))?;
36
37    Ok(patch.set(FieldSlot::from_validated_index(slot), value))
38}
39
40// Enforce public structural patch policy before the executor materializes an
41// entity through generated derive code. This keeps database write ownership and
42// absence/default policy owned by accepted schema metadata instead of
43// accidentally relying on executor-local generated field metadata, Rust
44// construction defaults, or derive-local missing slot behavior.
45fn validate_structural_patch_schema_policy<E>(
46    descriptor: &AcceptedRowLayoutRuntimeDescriptor<'_>,
47    patch: &StructuralPatch,
48    mode: MutationMode,
49) -> Result<(), InternalError>
50where
51    E: PersistedRow + EntityValue,
52{
53    reject_explicit_generated_fields_from_accepted_patch::<E>(descriptor, patch)?;
54
55    if matches!(mode, MutationMode::Update) {
56        return Ok(());
57    }
58
59    let mut provided_slots = vec![false; descriptor.required_slot_count()];
60    for entry in patch.entries() {
61        let slot = entry.slot().index();
62        if slot < provided_slots.len() {
63            provided_slots[slot] = true;
64        }
65    }
66
67    // Every omitted field must be allowed by accepted schema absence policy.
68    // Future database defaults should extend `AcceptedFieldAbsencePolicy`; this
69    // check must not inspect `Default` impls or generated construction values.
70    for field in descriptor.fields() {
71        let slot = usize::from(field.slot().get());
72        if provided_slots.get(slot).copied().unwrap_or(false) {
73            continue;
74        }
75
76        if matches!(field.absence_policy(), AcceptedFieldAbsencePolicy::Required) {
77            return Err(
78                InternalError::mutation_structural_patch_required_field_missing(
79                    E::PATH,
80                    field.name(),
81                ),
82            );
83        }
84    }
85
86    Ok(())
87}
88
89// Preserve generated-field ownership diagnostics ahead of sparse-patch
90// required-field diagnostics. Public structural writes must not author fields
91// whose values are owned by accepted schema write policy, except for the
92// redundant primary-key slot because the structural API already carries the
93// authoritative key separately.
94fn reject_explicit_generated_fields_from_accepted_patch<E>(
95    descriptor: &AcceptedRowLayoutRuntimeDescriptor<'_>,
96    patch: &StructuralPatch,
97) -> Result<(), InternalError>
98where
99    E: PersistedRow + EntityValue,
100{
101    for entry in patch.entries() {
102        let slot = entry.slot().index();
103        let Some(accepted_field) = descriptor.field_for_slot_index(slot) else {
104            continue;
105        };
106        let write_policy = accepted_field.write_policy();
107
108        if write_policy.insert_generation().is_some()
109            && accepted_field.name() != descriptor.primary_key_name()
110        {
111            return Err(InternalError::mutation_generated_field_explicit(
112                E::PATH,
113                accepted_field.name(),
114            ));
115        }
116    }
117
118    Ok(())
119}
120
121impl<C: CanisterKind> DbSession<C> {
122    /// Insert one entity row.
123    pub fn insert<E>(&self, entity: E) -> Result<E, InternalError>
124    where
125        E: PersistedRow<Canister = C> + EntityValue,
126    {
127        self.execute_save_entity(|save| save.insert(entity))
128    }
129
130    /// Insert one authored typed input.
131    pub fn create<I>(&self, input: I) -> Result<I::Entity, InternalError>
132    where
133        I: EntityCreateInput,
134        I::Entity: PersistedRow<Canister = C> + EntityValue,
135    {
136        self.execute_save_entity(|save| save.create(input))
137    }
138
139    /// Insert a single-entity-type batch atomically in one commit window.
140    ///
141    /// If any item fails pre-commit validation, no row in the batch is persisted.
142    ///
143    /// This API is not a multi-entity transaction surface.
144    pub fn insert_many_atomic<E>(
145        &self,
146        entities: impl IntoIterator<Item = E>,
147    ) -> Result<WriteBatchResponse<E>, InternalError>
148    where
149        E: PersistedRow<Canister = C> + EntityValue,
150    {
151        self.execute_save_batch(|save| save.insert_many_atomic(entities))
152    }
153
154    /// Insert a batch with explicitly non-atomic semantics.
155    ///
156    /// WARNING: fail-fast and non-atomic. Earlier inserts may commit before an error.
157    pub fn insert_many_non_atomic<E>(
158        &self,
159        entities: impl IntoIterator<Item = E>,
160    ) -> Result<WriteBatchResponse<E>, InternalError>
161    where
162        E: PersistedRow<Canister = C> + EntityValue,
163    {
164        self.execute_save_batch(|save| save.insert_many_non_atomic(entities))
165    }
166
167    /// Replace one existing entity row.
168    pub fn replace<E>(&self, entity: E) -> Result<E, InternalError>
169    where
170        E: PersistedRow<Canister = C> + EntityValue,
171    {
172        self.execute_save_entity(|save| save.replace(entity))
173    }
174
175    /// Apply one structural mutation under one explicit write-mode contract.
176    ///
177    /// This is the public core session boundary for structural writes:
178    /// callers provide the key, field patch, and intended mutation mode, and
179    /// the session routes that through the shared structural mutation pipeline.
180    pub fn mutate_structural<E>(
181        &self,
182        key: E::Key,
183        patch: StructuralPatch,
184        mode: MutationMode,
185    ) -> Result<E, InternalError>
186    where
187        E: PersistedRow<Canister = C> + EntityValue,
188    {
189        let accepted_schema = self.ensure_accepted_schema_snapshot::<E>()?;
190        let (descriptor, _) = AcceptedRowLayoutRuntimeDescriptor::from_generated_compatible_schema(
191            &accepted_schema,
192            E::MODEL,
193        )?;
194        validate_structural_patch_schema_policy::<E>(&descriptor, &patch, mode)?;
195        let accepted_schema_info =
196            SchemaInfo::from_accepted_snapshot_for_model(E::MODEL, &accepted_schema);
197        let accepted_schema_fingerprint =
198            accepted_commit_schema_fingerprint_for_model(E::MODEL, &accepted_schema)?;
199
200        let row_decode_contract = descriptor.row_decode_contract();
201        let mutation_row_decode_contract = row_decode_contract.clone();
202
203        self.execute_save_with_checked_accepted_row_contract(
204            row_decode_contract,
205            accepted_schema_info,
206            accepted_schema_fingerprint,
207            |save| save.apply_structural_mutation(mode, key, patch, mutation_row_decode_contract),
208            std::convert::identity,
209        )
210    }
211
212    /// Build one structural patch through the accepted schema row layout.
213    ///
214    /// This is the session-owned patch construction boundary for callers that
215    /// can provide all dynamic field updates at once. It resolves field names
216    /// through the accepted row-layout descriptor before the patch reaches the
217    /// generated-compatible write codec bridge.
218    pub fn structural_patch<E, I, S>(&self, fields: I) -> Result<StructuralPatch, InternalError>
219    where
220        E: PersistedRow<Canister = C> + EntityValue,
221        I: IntoIterator<Item = (S, Value)>,
222        S: AsRef<str>,
223    {
224        let accepted_schema = self.ensure_accepted_schema_snapshot::<E>()?;
225        let (descriptor, _) = AcceptedRowLayoutRuntimeDescriptor::from_generated_compatible_schema(
226            &accepted_schema,
227            E::MODEL,
228        )?;
229        let mut patch = StructuralPatch::new();
230
231        // Phase 1: resolve every caller-provided field name against the
232        // accepted descriptor so public structural patch construction no
233        // longer has to choose slots from generated model field order.
234        for (field_name, value) in fields {
235            let field_name = field_name.as_ref();
236            patch = append_accepted_structural_patch_field(
237                E::PATH,
238                &descriptor,
239                patch,
240                field_name,
241                value,
242            )?;
243        }
244
245        Ok(patch)
246    }
247
248    /// Apply one structural replacement, inserting if missing.
249    ///
250    /// Replace semantics still do not inherit omitted fields from the old row.
251    /// Missing fields must materialize through explicit defaults or managed
252    /// field preflight, or the write fails closed.
253    #[cfg(test)]
254    pub(in crate::db) fn replace_structural<E>(
255        &self,
256        key: E::Key,
257        patch: StructuralPatch,
258    ) -> Result<E, InternalError>
259    where
260        E: PersistedRow<Canister = C> + EntityValue,
261    {
262        self.mutate_structural(key, patch, MutationMode::Replace)
263    }
264
265    /// Replace a single-entity-type batch atomically in one commit window.
266    ///
267    /// If any item fails pre-commit validation, no row in the batch is persisted.
268    ///
269    /// This API is not a multi-entity transaction surface.
270    pub fn replace_many_atomic<E>(
271        &self,
272        entities: impl IntoIterator<Item = E>,
273    ) -> Result<WriteBatchResponse<E>, InternalError>
274    where
275        E: PersistedRow<Canister = C> + EntityValue,
276    {
277        self.execute_save_batch(|save| save.replace_many_atomic(entities))
278    }
279
280    /// Replace a batch with explicitly non-atomic semantics.
281    ///
282    /// WARNING: fail-fast and non-atomic. Earlier replaces may commit before an error.
283    pub fn replace_many_non_atomic<E>(
284        &self,
285        entities: impl IntoIterator<Item = E>,
286    ) -> Result<WriteBatchResponse<E>, InternalError>
287    where
288        E: PersistedRow<Canister = C> + EntityValue,
289    {
290        self.execute_save_batch(|save| save.replace_many_non_atomic(entities))
291    }
292
293    /// Update one existing entity row.
294    pub fn update<E>(&self, entity: E) -> Result<E, InternalError>
295    where
296        E: PersistedRow<Canister = C> + EntityValue,
297    {
298        self.execute_save_entity(|save| save.update(entity))
299    }
300
301    /// Apply one structural insert from a patch-defined after-image.
302    ///
303    /// Insert semantics no longer require a pre-built full row image.
304    /// Missing fields still fail closed unless derive-owned materialization can
305    /// supply them through explicit defaults or managed-field preflight.
306    #[cfg(test)]
307    pub(in crate::db) fn insert_structural<E>(
308        &self,
309        key: E::Key,
310        patch: StructuralPatch,
311    ) -> Result<E, InternalError>
312    where
313        E: PersistedRow<Canister = C> + EntityValue,
314    {
315        self.mutate_structural(key, patch, MutationMode::Insert)
316    }
317
318    /// Apply one structural field patch to an existing entity row.
319    ///
320    /// This session-owned boundary keeps structural mutation out of the raw
321    /// executor surface while still routing through the same typed save
322    /// preflight before commit staging.
323    #[cfg(test)]
324    pub(in crate::db) fn update_structural<E>(
325        &self,
326        key: E::Key,
327        patch: StructuralPatch,
328    ) -> Result<E, InternalError>
329    where
330        E: PersistedRow<Canister = C> + EntityValue,
331    {
332        self.mutate_structural(key, patch, MutationMode::Update)
333    }
334
335    /// Update a single-entity-type batch atomically in one commit window.
336    ///
337    /// If any item fails pre-commit validation, no row in the batch is persisted.
338    ///
339    /// This API is not a multi-entity transaction surface.
340    pub fn update_many_atomic<E>(
341        &self,
342        entities: impl IntoIterator<Item = E>,
343    ) -> Result<WriteBatchResponse<E>, InternalError>
344    where
345        E: PersistedRow<Canister = C> + EntityValue,
346    {
347        self.execute_save_batch(|save| save.update_many_atomic(entities))
348    }
349
350    /// Update a batch with explicitly non-atomic semantics.
351    ///
352    /// WARNING: fail-fast and non-atomic. Earlier updates may commit before an error.
353    pub fn update_many_non_atomic<E>(
354        &self,
355        entities: impl IntoIterator<Item = E>,
356    ) -> Result<WriteBatchResponse<E>, InternalError>
357    where
358        E: PersistedRow<Canister = C> + EntityValue,
359    {
360        self.execute_save_batch(|save| save.update_many_non_atomic(entities))
361    }
362}