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::{EntityAuthority, 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>(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>(patch: &StructuralPatch) -> bool
93where
94    E: PersistedRow + EntityValue,
95{
96    patch.entries().iter().any(|entry| {
97        E::MODEL
98            .fields()
99            .get(entry.slot().index())
100            .is_some_and(|field| {
101                field.insert_generation().is_some() && field.name() != E::MODEL.primary_key.name()
102            })
103    })
104}
105
106impl<C: CanisterKind> DbSession<C> {
107    /// Insert one entity row.
108    pub fn insert<E>(&self, entity: E) -> Result<E, InternalError>
109    where
110        E: PersistedRow<Canister = C> + EntityValue,
111    {
112        self.execute_save_entity(|save| save.insert(entity))
113    }
114
115    /// Insert one authored typed input.
116    pub fn create<I>(&self, input: I) -> Result<I::Entity, InternalError>
117    where
118        I: EntityCreateInput,
119        I::Entity: PersistedRow<Canister = C> + EntityValue,
120    {
121        self.execute_save_entity(|save| save.create(input))
122    }
123
124    /// Insert a single-entity-type batch atomically in one commit window.
125    ///
126    /// If any item fails pre-commit validation, no row in the batch is persisted.
127    ///
128    /// This API is not a multi-entity transaction surface.
129    pub fn insert_many_atomic<E>(
130        &self,
131        entities: impl IntoIterator<Item = E>,
132    ) -> Result<WriteBatchResponse<E>, InternalError>
133    where
134        E: PersistedRow<Canister = C> + EntityValue,
135    {
136        self.execute_save_batch(|save| save.insert_many_atomic(entities))
137    }
138
139    /// Insert a batch with explicitly non-atomic semantics.
140    ///
141    /// WARNING: fail-fast and non-atomic. Earlier inserts may commit before an error.
142    pub fn insert_many_non_atomic<E>(
143        &self,
144        entities: impl IntoIterator<Item = E>,
145    ) -> Result<WriteBatchResponse<E>, InternalError>
146    where
147        E: PersistedRow<Canister = C> + EntityValue,
148    {
149        self.execute_save_batch(|save| save.insert_many_non_atomic(entities))
150    }
151
152    /// Replace one existing entity row.
153    pub fn replace<E>(&self, entity: E) -> Result<E, InternalError>
154    where
155        E: PersistedRow<Canister = C> + EntityValue,
156    {
157        self.execute_save_entity(|save| save.replace(entity))
158    }
159
160    /// Apply one structural mutation under one explicit write-mode contract.
161    ///
162    /// This is the public core session boundary for structural writes:
163    /// callers provide the key, field patch, and intended mutation mode, and
164    /// the session routes that through the shared structural mutation pipeline.
165    pub fn mutate_structural<E>(
166        &self,
167        key: E::Key,
168        patch: StructuralPatch,
169        mode: MutationMode,
170    ) -> Result<E, InternalError>
171    where
172        E: PersistedRow<Canister = C> + EntityValue,
173    {
174        let (accepted_schema, _) =
175            self.ensure_accepted_schema_snapshot_and_authority(EntityAuthority::for_type::<E>())?;
176        let descriptor =
177            AcceptedRowLayoutRuntimeDescriptor::from_accepted_schema(&accepted_schema)?;
178        validate_structural_patch_absence_policy::<E>(&descriptor, &patch, mode)?;
179
180        self.execute_save_entity(|save| save.apply_structural_mutation(mode, key, patch))
181    }
182
183    /// Build one structural patch through the accepted schema row layout.
184    ///
185    /// This is the session-owned patch construction boundary for callers that
186    /// can provide all dynamic field updates at once. It resolves field names
187    /// through the accepted row-layout descriptor before the patch reaches the
188    /// generated-compatible write codec bridge.
189    pub fn structural_patch<E, I, S>(&self, fields: I) -> Result<StructuralPatch, InternalError>
190    where
191        E: PersistedRow<Canister = C> + EntityValue,
192        I: IntoIterator<Item = (S, Value)>,
193        S: AsRef<str>,
194    {
195        let (accepted_schema, _) =
196            self.ensure_accepted_schema_snapshot_and_authority(EntityAuthority::for_type::<E>())?;
197        let descriptor =
198            AcceptedRowLayoutRuntimeDescriptor::from_accepted_schema(&accepted_schema)?;
199        let mut patch = StructuralPatch::new();
200
201        // Phase 1: resolve every caller-provided field name against the
202        // accepted descriptor so public structural patch construction no
203        // longer has to choose slots from generated model field order.
204        for (field_name, value) in fields {
205            let field_name = field_name.as_ref();
206            patch = append_accepted_structural_patch_field(
207                E::PATH,
208                &descriptor,
209                patch,
210                field_name,
211                value,
212            )?;
213        }
214
215        Ok(patch)
216    }
217
218    /// Apply one structural replacement, inserting if missing.
219    ///
220    /// Replace semantics still do not inherit omitted fields from the old row.
221    /// Missing fields must materialize through explicit defaults or managed
222    /// field preflight, or the write fails closed.
223    #[cfg(test)]
224    pub(in crate::db) fn replace_structural<E>(
225        &self,
226        key: E::Key,
227        patch: StructuralPatch,
228    ) -> Result<E, InternalError>
229    where
230        E: PersistedRow<Canister = C> + EntityValue,
231    {
232        self.mutate_structural(key, patch, MutationMode::Replace)
233    }
234
235    /// Replace a single-entity-type batch atomically in one commit window.
236    ///
237    /// If any item fails pre-commit validation, no row in the batch is persisted.
238    ///
239    /// This API is not a multi-entity transaction surface.
240    pub fn replace_many_atomic<E>(
241        &self,
242        entities: impl IntoIterator<Item = E>,
243    ) -> Result<WriteBatchResponse<E>, InternalError>
244    where
245        E: PersistedRow<Canister = C> + EntityValue,
246    {
247        self.execute_save_batch(|save| save.replace_many_atomic(entities))
248    }
249
250    /// Replace a batch with explicitly non-atomic semantics.
251    ///
252    /// WARNING: fail-fast and non-atomic. Earlier replaces may commit before an error.
253    pub fn replace_many_non_atomic<E>(
254        &self,
255        entities: impl IntoIterator<Item = E>,
256    ) -> Result<WriteBatchResponse<E>, InternalError>
257    where
258        E: PersistedRow<Canister = C> + EntityValue,
259    {
260        self.execute_save_batch(|save| save.replace_many_non_atomic(entities))
261    }
262
263    /// Update one existing entity row.
264    pub fn update<E>(&self, entity: E) -> Result<E, InternalError>
265    where
266        E: PersistedRow<Canister = C> + EntityValue,
267    {
268        self.execute_save_entity(|save| save.update(entity))
269    }
270
271    /// Apply one structural insert from a patch-defined after-image.
272    ///
273    /// Insert semantics no longer require a pre-built full row image.
274    /// Missing fields still fail closed unless derive-owned materialization can
275    /// supply them through explicit defaults or managed-field preflight.
276    #[cfg(test)]
277    pub(in crate::db) fn insert_structural<E>(
278        &self,
279        key: E::Key,
280        patch: StructuralPatch,
281    ) -> Result<E, InternalError>
282    where
283        E: PersistedRow<Canister = C> + EntityValue,
284    {
285        self.mutate_structural(key, patch, MutationMode::Insert)
286    }
287
288    /// Apply one structural field patch to an existing entity row.
289    ///
290    /// This session-owned boundary keeps structural mutation out of the raw
291    /// executor surface while still routing through the same typed save
292    /// preflight before commit staging.
293    #[cfg(test)]
294    pub(in crate::db) fn update_structural<E>(
295        &self,
296        key: E::Key,
297        patch: StructuralPatch,
298    ) -> Result<E, InternalError>
299    where
300        E: PersistedRow<Canister = C> + EntityValue,
301    {
302        self.mutate_structural(key, patch, MutationMode::Update)
303    }
304
305    /// Update a single-entity-type batch atomically in one commit window.
306    ///
307    /// If any item fails pre-commit validation, no row in the batch is persisted.
308    ///
309    /// This API is not a multi-entity transaction surface.
310    pub fn update_many_atomic<E>(
311        &self,
312        entities: impl IntoIterator<Item = E>,
313    ) -> Result<WriteBatchResponse<E>, InternalError>
314    where
315        E: PersistedRow<Canister = C> + EntityValue,
316    {
317        self.execute_save_batch(|save| save.update_many_atomic(entities))
318    }
319
320    /// Update a batch with explicitly non-atomic semantics.
321    ///
322    /// WARNING: fail-fast and non-atomic. Earlier updates may commit before an error.
323    pub fn update_many_non_atomic<E>(
324        &self,
325        entities: impl IntoIterator<Item = E>,
326    ) -> Result<WriteBatchResponse<E>, InternalError>
327    where
328        E: PersistedRow<Canister = C> + EntityValue,
329    {
330        self.execute_save_batch(|save| save.update_many_non_atomic(entities))
331    }
332}