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}