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