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