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::{AcceptedFieldAbsencePolicy, AcceptedRowLayoutRuntimeContract},
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_generated_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 accepted schema absence policy.
67    // Future database defaults should extend `AcceptedFieldAbsencePolicy`; this
68    // check must not inspect `Default` impls or generated 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 matches!(field.absence_policy(), AcceptedFieldAbsencePolicy::Required) {
76            return Err(
77                InternalError::mutation_structural_patch_required_field_missing(
78                    E::PATH,
79                    field.name(),
80                ),
81            );
82        }
83    }
84
85    Ok(())
86}
87
88// Preserve generated-field ownership diagnostics ahead of sparse-patch
89// required-field diagnostics. Public structural writes must not author fields
90// whose values are owned by accepted schema write policy, except for the
91// redundant primary-key slot because the structural API already carries the
92// authoritative key separately.
93fn reject_explicit_generated_fields_from_accepted_patch<E>(
94    descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
95    patch: &AuthoredStructuralPatch,
96) -> Result<(), InternalError>
97where
98    E: PersistedRow,
99{
100    for entry in patch.entries() {
101        let slot = entry.slot().index();
102        let Some(accepted_field) = descriptor.field_for_slot_index(slot) else {
103            continue;
104        };
105        let write_policy = accepted_field.write_policy();
106
107        if write_policy.insert_generation().is_some()
108            && !descriptor.is_primary_key_field_name(accepted_field.name())
109        {
110            return Err(InternalError::mutation_generated_field_explicit(
111                E::PATH,
112                accepted_field.name(),
113            ));
114        }
115    }
116
117    Ok(())
118}
119
120impl<C: CanisterKind> DbSession<C> {
121    /// Insert one entity row.
122    pub fn insert<E>(&self, entity: E) -> Result<E, InternalError>
123    where
124        E: PersistedRow<Canister = C>,
125    {
126        self.execute_save_entity(|save| save.insert(entity))
127    }
128
129    /// Insert one authored typed input.
130    pub fn create<I>(&self, input: I) -> Result<I::Entity, InternalError>
131    where
132        I: EntityCreateInput,
133        I::Entity: PersistedRow<Canister = C>,
134    {
135        self.execute_save_entity(|save| save.create(input))
136    }
137
138    /// Insert a single-entity-type batch atomically in one commit window.
139    ///
140    /// If any item fails pre-commit validation, no row in the batch is persisted.
141    /// Prefer this helper when the caller needs all-or-nothing behavior for a
142    /// same-entity batch.
143    ///
144    /// This API is not a multi-entity transaction surface.
145    pub fn insert_many_atomic<E>(
146        &self,
147        entities: impl IntoIterator<Item = E>,
148    ) -> Result<WriteBatchResponse<E>, InternalError>
149    where
150        E: PersistedRow<Canister = C>,
151    {
152        self.execute_save_batch(|save| save.insert_many_atomic(entities))
153    }
154
155    /// Insert a batch with explicitly non-atomic semantics.
156    ///
157    /// WARNING: fail-fast and non-atomic. Earlier inserts may commit before an
158    /// error, and returning that error from the surrounding canister update does
159    /// not roll back the committed prefix. Use [`Self::insert_many_atomic`] when
160    /// partial batch persistence is not acceptable.
161    pub fn insert_many_non_atomic<E>(
162        &self,
163        entities: impl IntoIterator<Item = E>,
164    ) -> Result<WriteBatchResponse<E>, InternalError>
165    where
166        E: PersistedRow<Canister = C>,
167    {
168        self.execute_save_batch(|save| save.insert_many_non_atomic(entities))
169    }
170
171    /// Replace one existing entity row.
172    pub fn replace<E>(&self, entity: E) -> Result<E, InternalError>
173    where
174        E: PersistedRow<Canister = C>,
175    {
176        self.execute_save_entity(|save| save.replace(entity))
177    }
178
179    /// Apply one structural mutation under one explicit write-mode contract.
180    ///
181    /// This is the public core session boundary for structural writes:
182    /// callers provide the key, field patch, and intended mutation mode, and
183    /// the session routes that through the shared structural mutation pipeline.
184    pub fn mutate_structural<E>(
185        &self,
186        key: E::Key,
187        patch: AuthoredStructuralPatch,
188        mode: MutationMode,
189    ) -> Result<E, InternalError>
190    where
191        E: PersistedRow<Canister = C>,
192    {
193        let context = self.accepted_schema_catalog_context_for_query::<E>()?;
194        let (descriptor, _) = AcceptedRowLayoutRuntimeContract::from_generated_compatible_schema(
195            context.snapshot(),
196            E::MODEL,
197        )?;
198        validate_structural_patch_schema_policy::<E>(&descriptor, &patch, mode)?;
199        let (
200            row_decode_contract,
201            mutation_row_decode_contract,
202            accepted_schema_info,
203            accepted_schema_fingerprint,
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            |save| save.apply_structural_mutation(mode, key, patch, mutation_row_decode_contract),
211            std::convert::identity,
212        )
213    }
214
215    /// Build one structural patch through the accepted schema row layout.
216    ///
217    /// This is the session-owned patch construction boundary for callers that
218    /// can provide all dynamic field updates at once. It resolves field names
219    /// through the accepted row-layout descriptor before the patch reaches the
220    /// generated-compatible write codec bridge.
221    pub fn structural_patch<E, I, S, V>(
222        &self,
223        fields: I,
224    ) -> Result<AuthoredStructuralPatch, InternalError>
225    where
226        E: PersistedRow<Canister = C>,
227        I: IntoIterator<Item = (S, V)>,
228        S: AsRef<str>,
229        V: Into<InputValue>,
230    {
231        let accepted_schema = self.ensure_accepted_schema_snapshot::<E>()?;
232        let (descriptor, _) = AcceptedRowLayoutRuntimeContract::from_generated_compatible_schema(
233            &accepted_schema,
234            E::MODEL,
235        )?;
236        let mut patch = AuthoredStructuralPatch::new();
237
238        // Phase 1: resolve every caller-provided field name against the
239        // accepted descriptor so public structural patch construction no
240        // longer has to choose slots from generated model field order.
241        for (field_name, value) in fields {
242            let field_name = field_name.as_ref();
243            patch = append_accepted_structural_patch_field(
244                E::PATH,
245                &descriptor,
246                patch,
247                field_name,
248                value.into(),
249            )?;
250        }
251
252        Ok(patch)
253    }
254
255    /// Apply one structural replacement, inserting if missing.
256    ///
257    /// Replace semantics still do not inherit omitted fields from the old row.
258    /// Missing fields must materialize through explicit defaults or managed
259    /// field preflight, or the write fails closed.
260    #[cfg(test)]
261    pub(in crate::db) fn replace_structural<E>(
262        &self,
263        key: E::Key,
264        patch: AuthoredStructuralPatch,
265    ) -> Result<E, InternalError>
266    where
267        E: PersistedRow<Canister = C>,
268    {
269        self.mutate_structural(key, patch, MutationMode::Replace)
270    }
271
272    /// Replace a single-entity-type batch atomically in one commit window.
273    ///
274    /// If any item fails pre-commit validation, no row in the batch is persisted.
275    /// Prefer this helper when the caller needs all-or-nothing behavior for a
276    /// same-entity batch.
277    ///
278    /// This API is not a multi-entity transaction surface.
279    pub fn replace_many_atomic<E>(
280        &self,
281        entities: impl IntoIterator<Item = E>,
282    ) -> Result<WriteBatchResponse<E>, InternalError>
283    where
284        E: PersistedRow<Canister = C>,
285    {
286        self.execute_save_batch(|save| save.replace_many_atomic(entities))
287    }
288
289    /// Replace a batch with explicitly non-atomic semantics.
290    ///
291    /// WARNING: fail-fast and non-atomic. Earlier replaces may commit before an
292    /// error, and returning that error from the surrounding canister update does
293    /// not roll back the committed prefix. Use [`Self::replace_many_atomic`] when
294    /// partial batch persistence is not acceptable.
295    pub fn replace_many_non_atomic<E>(
296        &self,
297        entities: impl IntoIterator<Item = E>,
298    ) -> Result<WriteBatchResponse<E>, InternalError>
299    where
300        E: PersistedRow<Canister = C>,
301    {
302        self.execute_save_batch(|save| save.replace_many_non_atomic(entities))
303    }
304
305    /// Update one existing entity row.
306    pub fn update<E>(&self, entity: E) -> Result<E, InternalError>
307    where
308        E: PersistedRow<Canister = C>,
309    {
310        self.execute_save_entity(|save| save.update(entity))
311    }
312
313    /// Apply one structural insert from a patch-defined after-image.
314    ///
315    /// Insert semantics no longer require a pre-built full row image.
316    /// Missing fields still fail closed unless derive-owned materialization can
317    /// supply them through explicit defaults or managed-field preflight.
318    #[cfg(test)]
319    pub(in crate::db) fn insert_structural<E>(
320        &self,
321        key: E::Key,
322        patch: AuthoredStructuralPatch,
323    ) -> Result<E, InternalError>
324    where
325        E: PersistedRow<Canister = C>,
326    {
327        self.mutate_structural(key, patch, MutationMode::Insert)
328    }
329
330    /// Apply one structural field patch to an existing entity row.
331    ///
332    /// This session-owned boundary keeps structural mutation out of the raw
333    /// executor surface while still routing through the same typed save
334    /// preflight before commit staging.
335    #[cfg(test)]
336    pub(in crate::db) fn update_structural<E>(
337        &self,
338        key: E::Key,
339        patch: AuthoredStructuralPatch,
340    ) -> Result<E, InternalError>
341    where
342        E: PersistedRow<Canister = C>,
343    {
344        self.mutate_structural(key, patch, MutationMode::Update)
345    }
346
347    /// Update a single-entity-type batch atomically in one commit window.
348    ///
349    /// If any item fails pre-commit validation, no row in the batch is persisted.
350    /// Prefer this helper when the caller needs all-or-nothing behavior for a
351    /// same-entity batch.
352    ///
353    /// This API is not a multi-entity transaction surface.
354    pub fn update_many_atomic<E>(
355        &self,
356        entities: impl IntoIterator<Item = E>,
357    ) -> Result<WriteBatchResponse<E>, InternalError>
358    where
359        E: PersistedRow<Canister = C>,
360    {
361        self.execute_save_batch(|save| save.update_many_atomic(entities))
362    }
363
364    /// Update a batch with explicitly non-atomic semantics.
365    ///
366    /// WARNING: fail-fast and non-atomic. Earlier updates may commit before an
367    /// error, and returning that error from the surrounding canister update does
368    /// not roll back the committed prefix. Use [`Self::update_many_atomic`] when
369    /// partial batch persistence is not acceptable.
370    pub fn update_many_non_atomic<E>(
371        &self,
372        entities: impl IntoIterator<Item = E>,
373    ) -> Result<WriteBatchResponse<E>, InternalError>
374    where
375        E: PersistedRow<Canister = C>,
376    {
377        self.execute_save_batch(|save| save.update_many_non_atomic(entities))
378    }
379}