Skip to main content

dynoxide/storage_backend/
mod.rs

1//! Storage backend abstraction.
2//!
3//! Defines the [`StorageBackend`] trait that decouples Dynoxide's data layer
4//! from a specific SQLite binding. The native [`rusqlite`]-backed
5//! [`Storage`](crate::storage::Storage) implements the trait, and the
6//! `wasm-sqlite` build adds `wasm_backend::WasmBridgeBackend`, which runs the
7//! same SQL against a JS SQLite database over a wasm-bindgen bridge. Both
8//! backends issue identical SQL because they share the builders in
9//! [`sql_builders`].
10//!
11//! The native build consumes the trait monomorphically through `Storage`; the
12//! wasm build consumes it through `WasmBridgeBackend`. The escape hatches
13//! `Storage::conn()` and `Storage::conn_mut()` are not exposed by the trait
14//! and remain native-only.
15//!
16//! # No `Send + Sync` super-trait
17//!
18//! [`Storage`](crate::storage::Storage) carries a `RefCell<HashMap<...>>` for
19//! its metadata cache, so `Storage: !Sync`. A `Send + Sync` super-trait would
20//! refuse the impl on `Storage`. With no dynamic dispatch site in scope,
21//! auto-trait propagation across `.await` is decided per-callsite anyway, so
22//! adding `Send` to the super-trait would not earn any compile-time
23//! guarantee on the futures returned by trait methods.
24
25pub mod clock;
26pub mod error;
27// Internal: the shared SQL contract between the rusqlite and wasm backends.
28// `pub` only because both backend modules consume it across the cfg split; it
29// is not a stable API, hence `#[doc(hidden)]`.
30#[doc(hidden)]
31pub mod sql_builders;
32// Schema versions and the migrations a recorded version calls for on open.
33pub(crate) mod schema;
34// The native rusqlite-backed `Storage` exists whenever either SQLite backend
35// feature is on (the crate refuses to build with neither), and the handlers
36// now consume `StorageBackend` through it, so the impl must track the same
37// condition rather than `native-sqlite` alone.
38#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
39pub mod rusqlite_impl;
40#[cfg(feature = "wasm-sqlite")]
41pub mod wasm_backend;
42// Compile-only proof that the trait is satisfiable by a fresh impl. In-crate
43// rather than under `tests/`, because a sealed trait cannot be implemented
44// from outside.
45#[cfg(test)]
46mod trait_compile;
47
48use crate::storage::{
49    CreateTableMetadata, DatabaseInfo, QueryParams, ScanParams, StreamRecord, TableMetadata,
50    TableStats,
51};
52use crate::types::Tag;
53
54pub use clock::{Clock, ManualClock, SystemClock};
55pub use error::BackendError;
56#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
57pub use error::from_rusqlite;
58#[doc(hidden)]
59pub use sql_builders::SqlParam;
60#[cfg(feature = "wasm-sqlite")]
61pub use wasm_backend::WasmBridgeBackend;
62
63/// One base-table row for a bulk insert via [`StorageBackend::put_base_items`].
64///
65/// Unlike [`StorageBackend::put_item_with_hash`], which preserves any existing
66/// `cached_at` value, the bulk path writes `cached_at` verbatim: this mirrors
67/// the import flow, which sets the timestamp explicitly (or clears it) for
68/// every row it loads.
69#[derive(Debug, Clone)]
70pub struct BaseItemRow {
71    /// Partition key string.
72    pub pk: String,
73    /// Sort key string (empty for tables without a sort key).
74    pub sk: String,
75    /// Serialised item JSON.
76    pub item_json: String,
77    /// Item size in bytes.
78    pub item_size: usize,
79    /// Cache timestamp written verbatim; `None` clears the column.
80    pub cached_at: Option<f64>,
81    /// Hash prefix used for parallel-scan ordering.
82    pub hash_prefix: String,
83}
84
85/// One GSI-table row for a bulk insert via [`StorageBackend::insert_gsi_items`].
86///
87/// The fields mirror the argument order of the single-row
88/// [`StorageBackend::insert_gsi_item`].
89#[derive(Debug, Clone)]
90pub struct GsiItemRow {
91    /// GSI partition key string.
92    pub gsi_pk: String,
93    /// GSI sort key string (empty when the index has no sort key).
94    pub gsi_sk: String,
95    /// Base-table partition key string.
96    pub table_pk: String,
97    /// Base-table sort key string.
98    pub table_sk: String,
99    /// Projected item JSON.
100    pub item_json: String,
101}
102
103/// The DynamoDB size of one stored index entry, read from its projected JSON.
104/// Both backends size a non-ALL index this way, so they agree on it.
105pub(crate) fn projected_entry_size(item_json: &str) -> Result<i64, BackendError> {
106    let item: crate::types::Item = serde_json::from_str(item_json)
107        .map_err(|e| BackendError::Other(format!("unreadable index entry: {e}")))?;
108    Ok(crate::types::item_size(&item) as i64)
109}
110
111/// One vector shadow-table row for a bulk insert via
112/// [`StorageBackend::insert_vector_items`].
113///
114/// The fields mirror the shadow table's column order.
115#[derive(Debug, Clone)]
116pub struct VectorItemRow {
117    /// Base-table partition key string.
118    pub table_pk: String,
119    /// Base-table sort key string.
120    pub table_sk: String,
121    /// SearchSchema HASH attribute key string (empty when the schema declares
122    /// no HASH element).
123    pub hash_value: String,
124    /// The f32-truncated vector as a JSON array.
125    pub vector_json: String,
126    /// INLINE_FILTER attribute values as a JSON object.
127    pub filter_json: String,
128    /// Projected item JSON.
129    pub item_json: String,
130    /// The entry's billable size in bytes, by the captured write formula.
131    /// Stored so a search sums a column instead of rebuilding every entry it
132    /// scanned.
133    pub entry_bytes: i64,
134}
135
136/// One candidate row loaded from a vector shadow table for scoring by
137/// SearchVectors, via [`StorageBackend::query_vector_candidates`].
138///
139/// Carries no `hash_value`: scoping by HASH value happens in the query
140/// itself, so every returned row already matches the scope.
141#[derive(Debug, Clone)]
142pub struct VectorCandidateRow {
143    /// Base-table partition key string.
144    pub table_pk: String,
145    /// Base-table sort key string.
146    pub table_sk: String,
147    /// The stored f32-truncated vector as a JSON array.
148    pub vector_json: String,
149    /// INLINE_FILTER attribute values as a wire-shaped JSON object.
150    pub filter_json: String,
151    /// The entry's billable size, read back rather than recomputed. The
152    /// projected item is deliberately absent: a search scores from the vector
153    /// and the filter, and only the rows it returns need the item, so carrying
154    /// it here made a scan of a large index materialise every entry to throw
155    /// almost all of them away.
156    pub entry_bytes: i64,
157}
158
159/// One index-table write operation, backend-neutral.
160///
161/// The per-write and per-delete GSI/LSI fan-out builds an ordered list of these
162/// and hands it to [`StorageBackend::apply_index_writes`] in a single call. The
163/// default impl replays each op through the matching per-item method, identical
164/// to the per-op loop it replaces; the wasm backend overrides it to collapse the
165/// list into one bridge crossing. Each variant's fields mirror the argument
166/// order of the per-item method it stands in for.
167#[derive(Debug, Clone)]
168pub enum IndexWriteOp {
169    /// Remove this base key's entry from a GSI table.
170    DeleteGsi {
171        table_name: String,
172        index_name: String,
173        table_pk: String,
174        table_sk: String,
175    },
176    /// Insert (or replace) this item's projected entry into a GSI table.
177    InsertGsi {
178        table_name: String,
179        index_name: String,
180        gsi_pk: String,
181        gsi_sk: String,
182        table_pk: String,
183        table_sk: String,
184        item_json: String,
185    },
186    /// Remove this base key's entry from an LSI table.
187    DeleteLsi {
188        table_name: String,
189        index_name: String,
190        base_pk: String,
191        base_sk: String,
192    },
193    /// Insert (or replace) this item's projected entry into an LSI table.
194    InsertLsi {
195        table_name: String,
196        index_name: String,
197        pk: String,
198        sk: String,
199        base_pk: String,
200        base_sk: String,
201        item_json: String,
202    },
203    /// Remove this base key's row from a vector index shadow table.
204    DeleteVector {
205        table_name: String,
206        index_name: String,
207        table_pk: String,
208        table_sk: String,
209    },
210    /// Insert (or replace) this item's derived row into a vector index shadow
211    /// table. Carries the whole [`VectorItemRow`] rather than flattened fields
212    /// because the row struct already mirrors the shadow table's column order.
213    /// Boxed so the six-string row does not widen the whole enum: every op in
214    /// a fan-out pays for the largest variant, and the size should stay
215    /// pinned by `InsertGsi` (see the size test below).
216    InsertVector {
217        table_name: String,
218        index_name: String,
219        row: Box<VectorItemRow>,
220    },
221}
222
223/// Restricts [`StorageBackend`] to this crate's own backends.
224///
225/// The trait's method set tracks engine features rather than a stable
226/// interface: vector index support added seven methods to it in a single
227/// release. Sealing keeps that growth out of the versioning contract, because
228/// a trait nothing downstream can implement cannot be broken by a new method.
229/// The trait stays nameable, so [`Database`](crate::Database) and the wasm
230/// dispatch bounds are unaffected.
231///
232/// Unsealing later is a minor, if third-party backends ever earn their place.
233/// Sealing after 1.0.0 would not be, which is why it happens before.
234mod private {
235    pub trait Sealed {}
236
237    #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
238    impl Sealed for crate::storage::Storage {}
239
240    #[cfg(feature = "wasm-sqlite")]
241    impl Sealed for super::wasm_backend::WasmBridgeBackend {}
242}
243
244/// Backend-neutral storage interface.
245///
246/// Sealed: implemented by this crate's own backends only. It is public because
247/// [`Database`](crate::Database) is generic over it and the wasm dispatch
248/// functions take it as a bound, so it can be named and used as a bound from
249/// anywhere. Its method set tracks engine features rather than a stable
250/// interface, so closing it to outside impls keeps adding one out of the
251/// versioning contract. The [versioning policy] carries the reasoning.
252///
253/// [versioning policy]: https://github.com/nubo-db/dynoxide/blob/main/docs/versioning.md
254///
255/// Method signatures mirror [`Storage`](crate::storage::Storage)'s public
256/// surface, with three mechanical transformations:
257///
258/// 1. `Result<T, DynoxideError>` becomes `Result<T, BackendError>`.
259/// 2. `fn` becomes `async fn`.
260/// 3. Filesystem-typed and rusqlite-typed methods are excluded; they remain
261///    on the native [`Storage`](crate::storage::Storage) only.
262///
263/// Methods marked `#[doc(hidden)]` serve this crate's own action handlers,
264/// have no public `Storage` counterpart and are not part of the stable API.
265///
266/// The trait is not consumed dynamically today. The native
267/// [`Storage`](crate::storage::Storage) and the wasm
268/// `WasmBridgeBackend` each implement it
269/// monomorphically.
270///
271/// The `#[allow(async_fn_in_trait)]` reflects the monomorphic-only consumption
272/// model. The lint can be revisited if and when `dyn StorageBackend` becomes
273/// a real callsite.
274#[allow(async_fn_in_trait)]
275pub trait StorageBackend: private::Sealed {
276    // -----------------------------------------------------------------------
277    // Capabilities
278    // -----------------------------------------------------------------------
279
280    /// Wall-clock access for the stream and TTL paths.
281    ///
282    /// Sync because reading the clock is not I/O. The native backend returns
283    /// its injected [`Clock`]; the wasm SQLite backend supplies its own.
284    fn clock(&self) -> &dyn Clock;
285
286    /// Whether this backend can record and serve DynamoDB Streams.
287    ///
288    /// A backend that answers `false` still gets its stream methods called
289    /// nowhere on the happy path: the actions consult this before mutating, so
290    /// a request that needs streams is refused before anything is created,
291    /// rather than half-applied and then failed. Defaults to `true`; the wasm
292    /// backend answers `false` until a delivery mechanism exists.
293    fn supports_streams(&self) -> bool {
294        true
295    }
296
297    /// Whether this backend stores resource tags. Same contract as
298    /// [`supports_streams`](Self::supports_streams): consulted before a
299    /// mutation that would need `set_tags`, so a tagged `CreateTable` on a
300    /// backend without tags is refused whole rather than creating the table
301    /// and then failing.
302    fn supports_tags(&self) -> bool {
303        true
304    }
305
306    // -----------------------------------------------------------------------
307    // Table metadata
308    // -----------------------------------------------------------------------
309
310    async fn insert_table_metadata(&self, m: &CreateTableMetadata<'_>) -> Result<(), BackendError>;
311
312    async fn get_table_metadata(
313        &self,
314        table_name: &str,
315    ) -> Result<Option<TableMetadata>, BackendError>;
316
317    async fn delete_table_metadata(&self, table_name: &str) -> Result<bool, BackendError>;
318
319    async fn update_table_metadata(
320        &self,
321        table_name: &str,
322        attribute_definitions: &str,
323        gsi_definitions: Option<&str>,
324    ) -> Result<(), BackendError>;
325
326    /// Update a table's vector index definitions. The write the
327    /// add/delete-vector path of `UpdateTable` issues; `None` clears the
328    /// column when no vector indexes remain.
329    async fn update_vector_index_definitions(
330        &self,
331        table_name: &str,
332        vector_index_definitions: Option<&str>,
333    ) -> Result<(), BackendError>;
334
335    async fn update_provisioned_throughput(
336        &self,
337        table_name: &str,
338        provisioned_throughput: &str,
339    ) -> Result<(), BackendError>;
340
341    async fn clear_provisioned_throughput(&self, table_name: &str) -> Result<(), BackendError>;
342
343    async fn update_billing_mode(
344        &self,
345        table_name: &str,
346        billing_mode: &str,
347    ) -> Result<(), BackendError>;
348
349    async fn update_table_class(
350        &self,
351        table_name: &str,
352        table_class: &str,
353    ) -> Result<(), BackendError>;
354
355    /// Store the serialised on-demand throughput ceilings. Implementations
356    /// must store the payload opaquely: the `clear_on_demand_throughput`
357    /// default passes a JSON `null` through this method, which every reader
358    /// treats as absent, so parsing or rejecting the payload here would break
359    /// that default.
360    async fn update_on_demand_throughput(
361        &self,
362        table_name: &str,
363        on_demand_throughput: &str,
364    ) -> Result<(), BackendError>;
365
366    /// Remove any stored on-demand throughput ceilings. The default stores a
367    /// JSON `null`, which every reader treats as absent, so existing backend
368    /// implementations keep working without changes; the in-tree backends
369    /// override this to clear the underlying value properly.
370    async fn clear_on_demand_throughput(&self, table_name: &str) -> Result<(), BackendError> {
371        self.update_on_demand_throughput(table_name, "null").await
372    }
373
374    async fn get_tags(&self, table_name: &str) -> Result<Vec<Tag>, BackendError>;
375
376    async fn set_tags(&self, table_name: &str, new_tags: &[Tag]) -> Result<(), BackendError>;
377
378    async fn update_deletion_protection(
379        &self,
380        table_name: &str,
381        enabled: bool,
382    ) -> Result<(), BackendError>;
383
384    async fn remove_tags(&self, table_name: &str, keys: &[String]) -> Result<(), BackendError>;
385
386    async fn list_table_names(&self) -> Result<Vec<String>, BackendError>;
387
388    async fn table_exists(&self, table_name: &str) -> Result<bool, BackendError>;
389
390    // -----------------------------------------------------------------------
391    // Dynamic data tables (DDL)
392    // -----------------------------------------------------------------------
393
394    async fn create_data_table(&self, table_name: &str) -> Result<(), BackendError>;
395
396    async fn drop_data_table(&self, table_name: &str) -> Result<(), BackendError>;
397
398    async fn create_gsi_table(
399        &self,
400        table_name: &str,
401        index_name: &str,
402    ) -> Result<(), BackendError>;
403
404    async fn drop_gsi_table(&self, table_name: &str, index_name: &str) -> Result<(), BackendError>;
405
406    async fn create_lsi_table(
407        &self,
408        table_name: &str,
409        index_name: &str,
410    ) -> Result<(), BackendError>;
411
412    async fn drop_lsi_table(&self, table_name: &str, index_name: &str) -> Result<(), BackendError>;
413
414    /// Create the physical shadow table for a vector index.
415    async fn create_vector_table(
416        &self,
417        table_name: &str,
418        index_name: &str,
419    ) -> Result<(), BackendError>;
420
421    /// Drop the physical shadow table for a vector index, if it exists.
422    async fn drop_vector_table(
423        &self,
424        table_name: &str,
425        index_name: &str,
426    ) -> Result<(), BackendError>;
427
428    /// Bulk-insert many rows into one vector shadow table.
429    ///
430    /// Batch-shaped so a backend can amortise per-row round-trips, mirroring
431    /// [`insert_gsi_items`](Self::insert_gsi_items). Used by the vector index
432    /// backfill path, and by the default
433    /// [`apply_index_writes`](Self::apply_index_writes) with a one-row slice
434    /// for live-write maintenance.
435    async fn insert_vector_items(
436        &self,
437        table_name: &str,
438        index_name: &str,
439        rows: &[VectorItemRow],
440    ) -> Result<(), BackendError>;
441
442    /// Delete a vector shadow-table row by base-table primary key. Used by the
443    /// live-write maintenance fan-out, mirroring
444    /// [`delete_gsi_item`](Self::delete_gsi_item).
445    async fn delete_vector_item(
446        &self,
447        table_name: &str,
448        index_name: &str,
449        table_pk: &str,
450        table_sk: &str,
451    ) -> Result<(), BackendError>;
452
453    /// Load the rows SearchVectors scores, optionally scoped to one
454    /// SearchSchema HASH value (key-string encoded, matching the write
455    /// path's `hash_value` column). Rows come back ordered by base-table
456    /// primary key so equal scores tie-break deterministically.
457    async fn query_vector_candidates(
458        &self,
459        table_name: &str,
460        index_name: &str,
461        hash_value: Option<&str>,
462    ) -> Result<Vec<VectorCandidateRow>, BackendError>;
463
464    /// Load the projected entries for the base-table keys a search returns.
465    ///
466    /// Paired with [`query_vector_candidates`](Self::query_vector_candidates),
467    /// which leaves the projected item behind so a scan of a large index does
468    /// not materialise every entry only to score it and drop it. `keys` are the
469    /// post-`TopK` survivors, so this reads at most `TopK` rows. Returns
470    /// `(table_pk, table_sk, item_json)` per row found; a key with no row is
471    /// omitted rather than erroring.
472    async fn vector_items_for_keys(
473        &self,
474        table_name: &str,
475        index_name: &str,
476        keys: &[(String, String)],
477    ) -> Result<Vec<(String, String, String)>, BackendError>;
478
479    // -----------------------------------------------------------------------
480    // GSI item operations
481    // -----------------------------------------------------------------------
482
483    #[allow(clippy::too_many_arguments)]
484    async fn insert_gsi_item(
485        &self,
486        table_name: &str,
487        index_name: &str,
488        gsi_pk: &str,
489        gsi_sk: &str,
490        table_pk: &str,
491        table_sk: &str,
492        item_json: &str,
493    ) -> Result<(), BackendError>;
494
495    /// Bulk-insert many rows into one GSI table.
496    ///
497    /// Batch-shaped so a backend can amortise per-row round-trips (the native
498    /// backend reuses a single cached prepared statement). Used by the GSI
499    /// backfill path; the per-row [`insert_gsi_item`](Self::insert_gsi_item)
500    /// covers single writes during normal fan-out.
501    async fn insert_gsi_items(
502        &self,
503        table_name: &str,
504        index_name: &str,
505        rows: &[GsiItemRow],
506    ) -> Result<(), BackendError>;
507
508    async fn delete_gsi_item(
509        &self,
510        table_name: &str,
511        index_name: &str,
512        table_pk: &str,
513        table_sk: &str,
514    ) -> Result<(), BackendError>;
515
516    async fn query_gsi_items(
517        &self,
518        table_name: &str,
519        index_name: &str,
520        gsi_pk: &str,
521        params: &QueryParams<'_>,
522    ) -> Result<Vec<(String, String, String)>, BackendError>;
523
524    async fn scan_gsi_items(
525        &self,
526        table_name: &str,
527        index_name: &str,
528        params: &ScanParams<'_>,
529    ) -> Result<Vec<(String, String, String)>, BackendError>;
530
531    // -----------------------------------------------------------------------
532    // LSI item operations
533    // -----------------------------------------------------------------------
534
535    #[allow(clippy::too_many_arguments)]
536    async fn insert_lsi_item(
537        &self,
538        table_name: &str,
539        index_name: &str,
540        pk: &str,
541        sk: &str,
542        base_pk: &str,
543        base_sk: &str,
544        item_json: &str,
545    ) -> Result<(), BackendError>;
546
547    async fn delete_lsi_item(
548        &self,
549        table_name: &str,
550        index_name: &str,
551        base_pk: &str,
552        base_sk: &str,
553    ) -> Result<(), BackendError>;
554
555    async fn query_lsi_items(
556        &self,
557        table_name: &str,
558        index_name: &str,
559        pk: &str,
560        params: &QueryParams<'_>,
561    ) -> Result<Vec<(String, String, String)>, BackendError>;
562
563    async fn scan_lsi_items(
564        &self,
565        table_name: &str,
566        index_name: &str,
567        params: &ScanParams<'_>,
568    ) -> Result<Vec<(String, String, String)>, BackendError>;
569
570    // -----------------------------------------------------------------------
571    // Index write fan-out
572    // -----------------------------------------------------------------------
573
574    /// Apply an ordered batch of GSI/LSI write operations.
575    ///
576    /// The GSI/LSI maintenance helpers build the list and call this once per
577    /// fan-out instead of invoking the per-item methods one at a time. The
578    /// default impl replays each op through the matching per-item method in
579    /// order, so a backend that does not override it behaves exactly as the
580    /// per-op loop did. The wasm backend overrides this to issue the whole list
581    /// in a single bridge crossing.
582    ///
583    /// Owns no transaction: the caller's open transaction supplies atomicity, so
584    /// a mid-batch failure is rolled back by that caller. An empty list does no
585    /// work.
586    async fn apply_index_writes(&self, ops: &[IndexWriteOp]) -> Result<(), BackendError> {
587        for op in ops {
588            match op {
589                IndexWriteOp::DeleteGsi {
590                    table_name,
591                    index_name,
592                    table_pk,
593                    table_sk,
594                } => {
595                    self.delete_gsi_item(table_name, index_name, table_pk, table_sk)
596                        .await?;
597                }
598                IndexWriteOp::InsertGsi {
599                    table_name,
600                    index_name,
601                    gsi_pk,
602                    gsi_sk,
603                    table_pk,
604                    table_sk,
605                    item_json,
606                } => {
607                    self.insert_gsi_item(
608                        table_name, index_name, gsi_pk, gsi_sk, table_pk, table_sk, item_json,
609                    )
610                    .await?;
611                }
612                IndexWriteOp::DeleteLsi {
613                    table_name,
614                    index_name,
615                    base_pk,
616                    base_sk,
617                } => {
618                    self.delete_lsi_item(table_name, index_name, base_pk, base_sk)
619                        .await?;
620                }
621                IndexWriteOp::InsertLsi {
622                    table_name,
623                    index_name,
624                    pk,
625                    sk,
626                    base_pk,
627                    base_sk,
628                    item_json,
629                } => {
630                    self.insert_lsi_item(
631                        table_name, index_name, pk, sk, base_pk, base_sk, item_json,
632                    )
633                    .await?;
634                }
635                IndexWriteOp::DeleteVector {
636                    table_name,
637                    index_name,
638                    table_pk,
639                    table_sk,
640                } => {
641                    self.delete_vector_item(table_name, index_name, table_pk, table_sk)
642                        .await?;
643                }
644                IndexWriteOp::InsertVector {
645                    table_name,
646                    index_name,
647                    row,
648                } => {
649                    // The batch-shaped insert with a one-row slice: no separate
650                    // per-item method exists because the row struct already
651                    // carries the full column set.
652                    self.insert_vector_items(
653                        table_name,
654                        index_name,
655                        std::slice::from_ref(row.as_ref()),
656                    )
657                    .await?;
658                }
659            }
660        }
661        Ok(())
662    }
663
664    // -----------------------------------------------------------------------
665    // Transactions
666    // -----------------------------------------------------------------------
667
668    async fn begin_transaction(&self) -> Result<(), BackendError>;
669    async fn commit(&self) -> Result<(), BackendError>;
670    async fn rollback(&self) -> Result<(), BackendError>;
671
672    // -----------------------------------------------------------------------
673    // Schema version
674    // -----------------------------------------------------------------------
675
676    /// The schema version the database records, or `None` when it records
677    /// none, or one that isn't a number.
678    async fn recorded_schema_version(&self) -> Result<Option<u32>, BackendError>;
679
680    /// Record `version` as the database's schema version, replacing any
681    /// recorded one.
682    async fn record_schema_version(&self, version: u32) -> Result<(), BackendError>;
683
684    // -----------------------------------------------------------------------
685    // Bulk-loading PRAGMAs
686    // -----------------------------------------------------------------------
687
688    async fn enable_bulk_loading(&self) -> Result<(), BackendError>;
689    async fn disable_bulk_loading(&self) -> Result<(), BackendError>;
690
691    // -----------------------------------------------------------------------
692    // Item CRUD
693    // -----------------------------------------------------------------------
694
695    async fn put_item(
696        &self,
697        table_name: &str,
698        pk: &str,
699        sk: &str,
700        item_json: &str,
701        item_size: usize,
702    ) -> Result<Option<String>, BackendError>;
703
704    #[allow(clippy::too_many_arguments)]
705    async fn put_item_with_hash(
706        &self,
707        table_name: &str,
708        pk: &str,
709        sk: &str,
710        item_json: &str,
711        item_size: usize,
712        hash_prefix: &str,
713    ) -> Result<Option<String>, BackendError>;
714
715    /// Bulk-insert many base-table rows in one call (`INSERT OR REPLACE`).
716    ///
717    /// Batch-shaped so a backend can amortise per-row round-trips (the native
718    /// backend reuses a single cached prepared statement). Used by the import
719    /// path. Writes `cached_at` verbatim from each [`BaseItemRow`]; see the
720    /// note there for how this differs from
721    /// [`put_item_with_hash`](Self::put_item_with_hash).
722    async fn put_base_items(
723        &self,
724        table_name: &str,
725        rows: &[BaseItemRow],
726    ) -> Result<(), BackendError>;
727
728    async fn get_item(
729        &self,
730        table_name: &str,
731        pk: &str,
732        sk: &str,
733    ) -> Result<Option<String>, BackendError>;
734
735    async fn get_partition_size(&self, table_name: &str, pk: &str) -> Result<i64, BackendError>;
736
737    async fn get_lsi_partition_size(
738        &self,
739        table_name: &str,
740        index_name: &str,
741        pk: &str,
742    ) -> Result<i64, BackendError>;
743
744    async fn delete_item(
745        &self,
746        table_name: &str,
747        pk: &str,
748        sk: &str,
749    ) -> Result<Option<String>, BackendError>;
750
751    async fn query_items(
752        &self,
753        table_name: &str,
754        pk: &str,
755        params: &QueryParams<'_>,
756    ) -> Result<Vec<(String, String, String)>, BackendError>;
757
758    async fn scan_items(
759        &self,
760        table_name: &str,
761        params: &ScanParams<'_>,
762    ) -> Result<Vec<(String, String, String)>, BackendError>;
763
764    async fn count_items(&self, table_name: &str) -> Result<i64, BackendError>;
765
766    /// The number of items in a data table and the sum of their stored
767    /// `item_size`, which is the DynamoDB item size each write computed.
768    async fn item_count_and_size(&self, table_name: &str) -> Result<(i64, i64), BackendError>;
769
770    /// The number of entries in a GSI and the total of their projected item
771    /// sizes: its `ItemCount` and `IndexSizeBytes`. `whole_items` is true for
772    /// an ALL projection, where each entry is the base item and the base
773    /// table's stored size is its size; any other projection sizes each
774    /// entry's stored JSON.
775    async fn gsi_item_count_and_size(
776        &self,
777        table_name: &str,
778        index_name: &str,
779        whole_items: bool,
780    ) -> Result<(i64, i64), BackendError>;
781
782    /// [`gsi_item_count_and_size`](Self::gsi_item_count_and_size) for an LSI.
783    async fn lsi_item_count_and_size(
784        &self,
785        table_name: &str,
786        index_name: &str,
787        whole_items: bool,
788    ) -> Result<(i64, i64), BackendError>;
789
790    /// The number of entries in a vector index and the total of their stored
791    /// billable sizes: its `ItemCount` and `IndexSizeBytes`.
792    async fn vector_item_count_and_size(
793        &self,
794        table_name: &str,
795        index_name: &str,
796    ) -> Result<(i64, i64), BackendError>;
797
798    /// Rewrite the stored `item_size` of the given rows, each a
799    /// `(pk, sk, item_size)`, leaving the rows otherwise unchanged.
800    async fn set_item_sizes(
801        &self,
802        table_name: &str,
803        sizes: &[(String, String, usize)],
804    ) -> Result<(), BackendError>;
805
806    // -----------------------------------------------------------------------
807    // Introspection
808    // -----------------------------------------------------------------------
809
810    async fn db_size_bytes(&self) -> Result<u64, BackendError>;
811    async fn table_count(&self) -> Result<usize, BackendError>;
812    async fn table_stats(&self) -> Result<Vec<TableStats>, BackendError>;
813    async fn database_info(&self) -> Result<DatabaseInfo, BackendError>;
814    async fn vacuum(&self) -> Result<(), BackendError>;
815
816    // -----------------------------------------------------------------------
817    // Streams
818    // -----------------------------------------------------------------------
819
820    async fn enable_stream(
821        &self,
822        table_name: &str,
823        view_type: &str,
824        label: &str,
825    ) -> Result<(), BackendError>;
826
827    async fn disable_stream(&self, table_name: &str) -> Result<(), BackendError>;
828
829    #[allow(clippy::too_many_arguments)]
830    async fn insert_stream_record(
831        &self,
832        table_name: &str,
833        event_name: &str,
834        keys_json: &str,
835        new_image: Option<&str>,
836        old_image: Option<&str>,
837        sequence_number: &str,
838        shard_id: &str,
839        created_at: i64,
840    ) -> Result<(), BackendError>;
841
842    #[allow(clippy::too_many_arguments)]
843    async fn insert_stream_record_with_identity(
844        &self,
845        table_name: &str,
846        event_name: &str,
847        keys_json: &str,
848        new_image: Option<&str>,
849        old_image: Option<&str>,
850        sequence_number: &str,
851        shard_id: &str,
852        created_at: i64,
853        user_identity: Option<&str>,
854    ) -> Result<(), BackendError>;
855
856    async fn next_stream_sequence_number(&self, table_name: &str) -> Result<i64, BackendError>;
857
858    async fn get_stream_records(
859        &self,
860        table_name: &str,
861        shard_id: &str,
862        after_sequence: i64,
863        limit: usize,
864    ) -> Result<Vec<StreamRecord>, BackendError>;
865
866    /// As `get_stream_records`, with each record's stored event id. The id is
867    /// `None` only on a row an older build wrote since this one opened the file.
868    #[doc(hidden)]
869    async fn get_stream_records_with_event_ids(
870        &self,
871        table_name: &str,
872        shard_id: &str,
873        after_sequence: i64,
874        limit: usize,
875    ) -> Result<Vec<(StreamRecord, Option<String>)>, BackendError>;
876
877    async fn list_stream_enabled_tables(&self) -> Result<Vec<TableMetadata>, BackendError>;
878
879    // -----------------------------------------------------------------------
880    // TTL operations
881    // -----------------------------------------------------------------------
882
883    async fn update_ttl_config(
884        &self,
885        table_name: &str,
886        attribute_name: Option<&str>,
887        enabled: bool,
888    ) -> Result<(), BackendError>;
889
890    async fn list_ttl_enabled_tables(&self) -> Result<Vec<TableMetadata>, BackendError>;
891
892    async fn get_shard_sequence_range(
893        &self,
894        table_name: &str,
895        shard_id: &str,
896    ) -> Result<(Option<String>, Option<String>), BackendError>;
897
898    // -----------------------------------------------------------------------
899    // Cache tracking
900    // -----------------------------------------------------------------------
901
902    async fn touch_cached_at(
903        &self,
904        table_name: &str,
905        pk: &str,
906        sk: &str,
907        timestamp: f64,
908    ) -> Result<(), BackendError>;
909
910    async fn get_lru_items(
911        &self,
912        table_name: &str,
913        limit: usize,
914    ) -> Result<Vec<(String, String, i64)>, BackendError>;
915}
916
917#[cfg(test)]
918mod tests {
919    use super::IndexWriteOp;
920
921    /// `InsertVector` carries its row boxed so the vector family does not
922    /// widen the whole enum: every op in a fan-out list pays for the largest
923    /// variant, and before the vector variants arrived that was `InsertGsi`
924    /// at seven `String`s plus the discriminant word. Pin the enum to that
925    /// size so a future field on the vector row cannot regrow it unnoticed.
926    #[test]
927    fn index_write_op_stays_at_the_insert_gsi_driven_size() {
928        let insert_gsi_driven = 7 * std::mem::size_of::<String>() + std::mem::size_of::<usize>();
929        assert!(
930            std::mem::size_of::<IndexWriteOp>() <= insert_gsi_driven,
931            "IndexWriteOp grew past the InsertGsi-driven size: {} > {}",
932            std::mem::size_of::<IndexWriteOp>(),
933            insert_gsi_driven
934        );
935    }
936}