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