Skip to content

Commit 28af010

Browse files
committed
fix: Fix savepoint tests, update docs
1 parent acbab8c commit 28af010

7 files changed

Lines changed: 73 additions & 63 deletions

File tree

IMPLEMENTATION_ROADMAP_FOCUSED.md

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**Version**: 3.1.0 (Updated with Phase 1 & 2 Completion)
44
**Date**: 2025-12-04
55
**Current Version**: ecto_libsql v0.6.0 (v0.8.0-rc1 ready)
6-
**Target Version**: v1.0.0 (May 2026)
6+
**Target Version**: v1.0.0
77
**LibSQL Version**: 0.9.24
88

99
---
@@ -15,10 +15,10 @@ This roadmap is **laser-focused** on delivering **100% of production-critical li
1515
**Status as of Dec 4, 2025**:
1616
- Phase 1: ✅ 100% Complete (3/3 features)
1717
- Phase 2: ✅ 83% Complete (2.5/3 features)
18-
- Phase 3: 0% (scheduled for Q1 2026)
19-
- Phase 4: 0% (scheduled for Q2 2026)
18+
- Phase 3: 0%
19+
- Phase 4: 0%
2020

21-
**Estimated Final**: 95%+ feature coverage by v1.0.0 (May 2026)
21+
**Estimated Final**: 95%+ feature coverage by v1.0.0
2222

2323
### Focus Areas
2424

@@ -158,11 +158,8 @@ end)
158158

159159
**Total Effort**: 8-9 days (2-3 weeks with testing/docs)
160160
**Impact**: Fixes critical performance issue, enables complex operations, improves DX
161-
**Release**: v0.7.0 (January 2026)
162161

163162
**Completion Notes**:
164-
- All 3 Phase 1 features implemented and tested
165-
- 271 tests passing, 0 failures, 25 skipped
166163
- No `.unwrap()` panics - all errors handled gracefully
167164
- Ready to proceed with Phase 2
168165

@@ -172,7 +169,6 @@ end)
172169

173170
**Status**: ✅ **IMPLEMENTED**
174171

175-
**Target Date**: February 2026 (2-3 weeks)
176172
**Goal**: Full embedded replica monitoring and control
177173
**Impact**: **HIGH** - Enables production monitoring of replicas
178174

@@ -344,7 +340,6 @@ let rows = query_result.into_iter().collect::<Vec<_>>(); // ← Loads EVERYTHIN
344340

345341
## Phase 3: Enable Advanced Use Cases (v0.9.0)
346342

347-
**Target Date**: March-April 2026 (4-5 weeks)
348343
**Goal**: Hooks, extensions, custom functions
349344
**Impact**: **MEDIUM-HIGH** - Enables advanced patterns
350345

@@ -521,13 +516,11 @@ end)
521516

522517
**Total Effort**: 15-21 days (4-5 weeks with testing/docs)
523518
**Impact**: Enables advanced patterns (real-time, multi-tenant, extensions)
524-
**Release**: v0.9.0 (March-April 2026)
525519

526520
---
527521

528522
## Phase 4: Production Polish & v1.0.0
529523

530-
**Target Date**: May 2026 (2 weeks)
531524
**Goal**: Production-grade polish, comprehensive docs
532525
**Impact**: **MEDIUM** - Completes feature set
533526

@@ -650,15 +643,14 @@ end)
650643
- [ ] Large dataset processing example
651644

652645
**Estimated Effort**: 5 days
653-
**Priority**: **HIGH** - Essential for v1.0.0
646+
**Priority**: **HIGH**
654647

655648
---
656649

657650
### Phase 4 Summary
658651

659652
**Total Effort**: 15-19 days (2-3 weeks)
660653
**Impact**: Completes feature set, production-ready documentation
661-
**Release**: v1.0.0 (May 2026)
662654

663655
---
664656

@@ -830,7 +822,6 @@ This roadmap focuses on:
830822
**Document Version**: 3.1.0 (Updated with Phase 1 & 2 Results)
831823
**Date**: 2025-12-04
832824
**Last Updated**: 2025-12-04 (Phase 1 & 2 completion)
833-
**Next Review**: After v0.8.0 release (February 2026)
834825
**Based On**: LIBSQL_FEATURE_MATRIX_FINAL.md v4.0.0
835826

836827
---

LIBSQL_FEATURE_MATRIX_FINAL.md

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,17 @@ This analysis is based on **authoritative sources**:
1616
3. ✅ Current ecto_libsql implementation audit (29 NIFs, 1,509 lines)
1717
4. ✅ Development guide requirements (`ecto_libsql_development_guide.md`)
1818

19-
**Key Finding**: ecto_libsql implements **54% of libsql features** with **excellent coverage of production-critical features** (100% of P0) but **missing advanced features** needed for specific use cases.
19+
**Key Finding**: ecto_libsql implements **65% of libsql features** with **excellent coverage of production-critical features** (100% of P0) and **strong support for advanced features** including full transaction control, statement introspection, and replica monitoring.
2020

2121
### What's Implemented (Strong Foundation)
2222

2323
**All 3 Connection Modes**: Local, Remote, Embedded Replica
24-
**Full Transaction Support**: All 4 behaviours (Deferred, Immediate, Exclusive, Read-Only)
24+
**Full Transaction Support**: All 4 behaviours (Deferred, Immediate, Exclusive, Read-Only) + Savepoints
2525
**Comprehensive Metadata**: last_insert_rowid, changes, total_changes, is_autocommit
2626
**Production Configuration**: busy_timeout, reset, interrupt, PRAGMA helpers
2727
**Batch Operations**: Native and manual, transactional and non-transactional
28-
**Basic Replication**: Manual sync with timeout, auto-sync for writes
29-
**Prepared Statements**: Prepare, execute, query (with re-prepare workaround)
28+
**Advanced Replication**: Manual sync with timeout, auto-sync for writes, frame monitoring, sync_until, flush_replicator
29+
**Prepared Statements**: Prepare, execute, query (with re-prepare workaround) + introspection (column_count, column_name, parameter_count)
3030
**Vector Search**: Helper functions for vector operations
3131
**Encryption**: AES-256 at rest
3232

@@ -36,9 +36,6 @@ This analysis is based on **authoritative sources**:
3636
**No Custom Functions**: create_scalar_function, create_aggregate_function
3737
**No Extensions**: load_extension (FTS5, R-Tree, etc.)
3838
**Limited Streaming**: Cursors load all rows upfront (memory issue)
39-
**No Savepoints**: Cannot nest transactions
40-
**No Advanced Sync**: sync_until, flush_replicator, freeze, get_frame_no
41-
**Limited Introspection**: No statement column_count, column_name
4239

4340
---
4441

@@ -87,11 +84,11 @@ This analysis is based on **authoritative sources**:
8784
| READ_ONLY behaviour || Line 130 | `TransactionBehavior::ReadOnly` | P0 |
8885
| Commit transaction || `commit_or_rollback_transaction/5` (lib.rs:285) | `trx.commit()` | P0 |
8986
| Rollback transaction || `commit_or_rollback_transaction/5` (lib.rs:285) | `trx.rollback()` | P0 |
90-
| Savepoints | | Not implemented | `trx.savepoint()` | P1 |
91-
| Release savepoint | | Not implemented | `trx.release_savepoint()` | P1 |
92-
| Rollback to savepoint | | Not implemented | `trx.rollback_to_savepoint()` | P1 |
87+
| Savepoints | | `savepoint/2` (lib.rs) | `SAVEPOINT` SQL | P1 |
88+
| Release savepoint | | `release_savepoint/1` (lib.rs) | `RELEASE SAVEPOINT` SQL | P1 |
89+
| Rollback to savepoint | | `rollback_to_savepoint/1` (lib.rs) | `ROLLBACK TO SAVEPOINT` SQL | P1 |
9390

94-
**Assessment**: All basic transaction operations complete. Savepoints would enable nested transaction-like behaviour for complex operations.
91+
**Assessment**: All transaction operations complete, including savepoints for nested transaction-like behaviour. Savepoint support added in v0.6.0 (PR #27) enables complex error handling and partial rollbacks within transactions.
9592

9693
**Why Savepoints Matter**:
9794
```elixir
@@ -113,7 +110,7 @@ end)
113110

114111
---
115112

116-
### 4. Prepared Statements (44% Coverage) ⚠️
113+
### 4. Prepared Statements (78% Coverage)
117114

118115
| Feature | Status | Implementation | libsql API | Priority |
119116
|---------|--------|---------------|-----------|----------|
@@ -123,9 +120,9 @@ end)
123120
| Close statement || `close/2` (lib.rs:336) | Registry cleanup | P0 |
124121
| Statement reset || **Re-prepares!** | `stmt.reset()` | P0 |
125122
| Clear bindings || Not implemented | `stmt.clear_bindings()` | P2 |
126-
| Column count | | Not implemented | `stmt.column_count()` | P1 |
127-
| Column name | | Not implemented | `stmt.column_name()` | P1 |
128-
| Parameter count | | Not implemented | `stmt.parameter_count()` | P1 |
123+
| Column count | | `get_statement_column_count/1` (lib.rs) | `stmt.column_count()` | P1 |
124+
| Column name | | `get_statement_column_name/2` (lib.rs) | `stmt.column_name()` | P1 |
125+
| Parameter count | | `get_statement_parameter_count/1` (lib.rs) | `stmt.parameter_count()` | P1 |
129126

130127
**Critical Issue**: Lines 885-888 and 951-954 re-prepare statements on every execution, defeating the purpose of prepared statements.
131128

@@ -140,21 +137,21 @@ let stmt = conn_guard.prepare(&sql).await // ← Called EVERY time!
140137

141138
---
142139

143-
### 5. Replica Sync Features (33% Coverage)
140+
### 5. Replica Sync Features (67% Coverage)
144141

145142
| Feature | Status | Implementation | libsql API | Priority |
146143
|---------|--------|---------------|-----------|----------|
147144
| Manual sync || `do_sync/2` (lib.rs:263) | `db.sync()` | P0 |
148145
| Sync with timeout || `sync_with_timeout` (lib.rs:44) | Custom wrapper | P0 |
149146
| Auto-sync on writes || Built-in | libsql automatic | P0 |
150147
| Sync frames || Not implemented | `db.sync_frames()` | P2 |
151-
| Sync until frame | | Not implemented | `db.sync_until()` | P2 |
152-
| Get frame number | | Not implemented | `db.get_frame_no()` | P2 |
153-
| Flush replicator | | Not implemented | `db.flush_replicator()` | P2 |
148+
| Sync until frame | | `sync_until/2` (lib.rs) | `db.sync_until()` | P2 |
149+
| Get frame number | | `get_frame_number/1` (lib.rs) | `db.get_frame_no()` | P2 |
150+
| Flush replicator | | `flush_replicator/1` (lib.rs) | `db.flush_replicator()` | P2 |
154151
| Freeze database || Not implemented | `db.freeze()` | P2 |
155152
| Flush writes || Not implemented | `db.flush()` | P2 |
156153

157-
**Assessment**: Core sync functionality works. Advanced features needed for monitoring replication lag and fine-grained control.
154+
**Assessment**: Excellent replica sync support! Core sync functionality and advanced monitoring features are implemented (added in v0.6.0, PR #27). Can now monitor replication lag via frame numbers and fine-tune sync behaviour.
158155

159156
**Important Note** (from code comments lines 507-513, 737-738):
160157
> libsql automatically syncs writes to remote for embedded replicas. Manual sync is for pulling remote changes locally.
@@ -166,14 +163,17 @@ let stmt = conn_guard.prepare(&sql).await // ← Called EVERY time!
166163
- ✅ Monotonic reads guaranteed
167164
- ❌ No global ordering guarantees
168165

169-
**Use Cases for Missing Features**:
166+
**Example Usage of Advanced Sync Features**:
170167
```elixir
171-
# Monitor replication lag
172-
frame = EctoLibSql.get_frame_number(repo)
173-
:ok = EctoLibSql.sync_until(repo, frame + 100) # Wait for specific frame
168+
# Monitor replication lag (now available!)
169+
{:ok, frame} = EctoLibSql.Native.get_frame_number(state)
170+
{:ok, new_state} = EctoLibSql.Native.sync_until(state, frame + 100)
174171

175-
# Disaster recovery
176-
:ok = EctoLibSql.freeze(repo) # Convert replica to standalone DB
172+
# Flush pending writes (now available!)
173+
{:ok, new_state} = EctoLibSql.Native.flush_replicator(state)
174+
175+
# Still missing: Disaster recovery
176+
# :ok = EctoLibSql.freeze(repo) # Convert replica to standalone DB
177177
```
178178

179179
---
@@ -366,16 +366,16 @@ Repo.query("SELECT * FROM docs ORDER BY #{distance} LIMIT 10")
366366
|----------|------------|---------|----------|----------|
367367
| Connection Management | 6 | 2 | **75%** | P0 |
368368
| Query Execution | 4 | 1 | **80%** | P0 |
369-
| Transactions | 8 | 3 | **73%** | P0 |
370-
| Prepared Statements | 4 | 5 | **44%** ⚠️ | P0 |
371-
| Replica Sync | 3 | 6 | **33%** | P1 |
369+
| Transactions | 11 | 0 | **100%** | P0 |
370+
| Prepared Statements | 7 | 2 | **78%** | P0 |
371+
| Replica Sync | 6 | 3 | **67%** | P1 |
372372
| Metadata | 4 | 3 | **57%** | P1 |
373373
| Configuration | 4 | 4 | **50%** | P0 |
374374
| Batch Execution | 4 | 1 | **80%** | P1 |
375375
| Cursors/Streaming | 4 | 3 | **57%** | P1 |
376376
| Hooks/Extensions | 0 | 8 | **0%**| P2 |
377377
| Vector Search | 3 | 2 | **60%** | P2 |
378-
| **TOTAL** | **44** | **38** | **54%** | - |
378+
| **TOTAL** | **53** | **29** | **65%** | - |
379379

380380
### By Priority (Production Readiness)
381381

TESTING_PLAN_COMPREHENSIVE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -990,7 +990,7 @@ mix test --only turso_remote
990990

991991
-**Test Coverage Badge**: In README.md
992992
-**Performance Baselines**: Documented in PERFORMANCE.md
993-
-**Test Organization**: Clear file structure
993+
-**Test Organisation**: Clear file structure
994994
-**Example Usage**: Tests serve as examples
995995

996996
---

lib/ecto_libsql/native.ex

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,6 @@ defmodule EctoLibSql.Native do
168168
@doc false
169169
def flush_replicator(_conn_id), do: :erlang.nif_error(:nif_not_loaded)
170170

171-
172-
173171
@doc false
174172
def freeze_database(_conn_id), do: :erlang.nif_error(:nif_not_loaded)
175173

@@ -982,8 +980,9 @@ defmodule EctoLibSql.Native do
982980
def create_savepoint(%EctoLibSql.State{trx_id: trx_id} = _state, name)
983981
when is_binary(trx_id) and is_binary(name) do
984982
case savepoint(trx_id, name) do
985-
{} -> :ok
983+
:ok -> :ok
986984
{:error, reason} -> {:error, reason}
985+
other -> {:error, "Unexpected response: #{inspect(other)}"}
987986
end
988987
end
989988

@@ -1009,8 +1008,9 @@ defmodule EctoLibSql.Native do
10091008
def release_savepoint_by_name(%EctoLibSql.State{trx_id: trx_id} = _state, name)
10101009
when is_binary(trx_id) and is_binary(name) do
10111010
case release_savepoint(trx_id, name) do
1012-
{} -> :ok
1011+
:ok -> :ok
10131012
{:error, reason} -> {:error, reason}
1013+
other -> {:error, "Unexpected response: #{inspect(other)}"}
10141014
end
10151015
end
10161016

@@ -1046,8 +1046,9 @@ defmodule EctoLibSql.Native do
10461046
def rollback_to_savepoint_by_name(%EctoLibSql.State{trx_id: trx_id} = _state, name)
10471047
when is_binary(trx_id) and is_binary(name) do
10481048
case rollback_to_savepoint(trx_id, name) do
1049-
{} -> :ok
1049+
:ok -> :ok
10501050
{:error, reason} -> {:error, reason}
1051+
other -> {:error, "Unexpected response: #{inspect(other)}"}
10511052
end
10521053
end
10531054

@@ -1084,7 +1085,8 @@ defmodule EctoLibSql.Native do
10841085
def get_frame_number_for_replica(conn_id) when is_binary(conn_id) do
10851086
case get_frame_number(conn_id) do
10861087
frame_no when is_integer(frame_no) -> {:ok, frame_no}
1087-
error -> {:error, error}
1088+
{:error, reason} -> {:error, reason}
1089+
other -> {:error, "Unexpected response: #{inspect(other)}"}
10881090
end
10891091
end
10901092

@@ -1119,7 +1121,8 @@ defmodule EctoLibSql.Native do
11191121
when is_binary(conn_id) and is_integer(target_frame) do
11201122
case sync_until(conn_id, target_frame) do
11211123
:ok -> :ok
1122-
other -> {:error, other}
1124+
{:error, reason} -> {:error, reason}
1125+
other -> {:error, "Unexpected response: #{inspect(other)}"}
11231126
end
11241127
end
11251128

@@ -1150,7 +1153,8 @@ defmodule EctoLibSql.Native do
11501153
def flush_and_get_frame(conn_id) when is_binary(conn_id) do
11511154
case flush_replicator(conn_id) do
11521155
frame_no when is_integer(frame_no) -> {:ok, frame_no}
1153-
error -> {:error, error}
1156+
{:error, reason} -> {:error, reason}
1157+
other -> {:error, "Unexpected response: #{inspect(other)}"}
11541158
end
11551159
end
11561160

native/ecto_libsql/src/lib.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,25 +1635,35 @@ fn statement_parameter_count(conn_id: &str, stmt_id: &str) -> NifResult<usize> {
16351635
/// Create a savepoint within a transaction.
16361636
/// Savepoints allow partial rollback without aborting the entire transaction.
16371637
#[rustler::nif(schedule = "DirtyIo")]
1638-
fn savepoint(trx_id: &str, name: &str) -> NifResult<()> {
1638+
fn savepoint(trx_id: &str, name: &str) -> NifResult<Atom> {
16391639
let mut txn_registry = safe_lock(&TXN_REGISTRY, "savepoint")?;
16401640

16411641
let trx = txn_registry
16421642
.get_mut(trx_id)
16431643
.ok_or_else(|| rustler::Error::Term(Box::new("Transaction not found")))?;
16441644

1645+
// Validate savepoint name is a valid SQL identifier (alphanumeric + underscore, not starting with digit)
1646+
if name.is_empty()
1647+
|| !name.chars().all(|c| c.is_alphanumeric() || c == '_')
1648+
|| name.chars().next().map_or(true, |c| c.is_ascii_digit())
1649+
{
1650+
return Err(rustler::Error::Term(Box::new(
1651+
"Invalid savepoint name: must be a valid SQL identifier",
1652+
)));
1653+
}
1654+
16451655
let sql = format!("SAVEPOINT {}", name);
16461656

16471657
TOKIO_RUNTIME
16481658
.block_on(async { trx.execute(&sql, Vec::<Value>::new()).await })
16491659
.map_err(|e| rustler::Error::Term(Box::new(format!("Savepoint failed: {}", e))))?;
16501660

1651-
Ok(())
1661+
Ok(rustler::types::atom::ok())
16521662
}
16531663

16541664
/// Release (commit) a savepoint, making its changes permanent within the transaction.
16551665
#[rustler::nif(schedule = "DirtyIo")]
1656-
fn release_savepoint(trx_id: &str, name: &str) -> NifResult<()> {
1666+
fn release_savepoint(trx_id: &str, name: &str) -> NifResult<Atom> {
16571667
let mut txn_registry = safe_lock(&TXN_REGISTRY, "release_savepoint")?;
16581668

16591669
let trx = txn_registry
@@ -1666,13 +1676,13 @@ fn release_savepoint(trx_id: &str, name: &str) -> NifResult<()> {
16661676
.block_on(async { trx.execute(&sql, Vec::<Value>::new()).await })
16671677
.map_err(|e| rustler::Error::Term(Box::new(format!("Release savepoint failed: {}", e))))?;
16681678

1669-
Ok(())
1679+
Ok(rustler::types::atom::ok())
16701680
}
16711681

16721682
/// Rollback to a savepoint, undoing all changes made after the savepoint was created.
16731683
/// The savepoint remains active and can be released or rolled back to again.
16741684
#[rustler::nif(schedule = "DirtyIo")]
1675-
fn rollback_to_savepoint(trx_id: &str, name: &str) -> NifResult<()> {
1685+
fn rollback_to_savepoint(trx_id: &str, name: &str) -> NifResult<Atom> {
16761686
let mut txn_registry = safe_lock(&TXN_REGISTRY, "rollback_to_savepoint")?;
16771687

16781688
let trx = txn_registry
@@ -1687,7 +1697,7 @@ fn rollback_to_savepoint(trx_id: &str, name: &str) -> NifResult<()> {
16871697
rustler::Error::Term(Box::new(format!("Rollback to savepoint failed: {}", e)))
16881698
})?;
16891699

1690-
Ok(())
1700+
Ok(rustler::types::atom::ok())
16911701
}
16921702

16931703
/// Get the current frame number from a remote replica database.

test/prepared_statement_test.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ defmodule EctoLibSql.PreparedStatementTest do
145145
result = Native.query_stmt(state, stmt_id, [1])
146146

147147
case result do
148-
{:error, _reason} -> :ok
148+
{:error, _reason} ->
149+
:ok
150+
149151
{:ok, result} ->
150152
# If it succeeds, it should return empty results (no matches)
151153
assert is_list(result.rows)

0 commit comments

Comments
 (0)