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}