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