Skip to content

Commit 7fd7060

Browse files
committed
fix: Mark freeze_database as not supported yet
1 parent 9380bf6 commit 7fd7060

11 files changed

Lines changed: 665 additions & 77 deletions

AGENTS.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Welcome to ecto_libsql! This guide provides comprehensive documentation, API ref
4040
- [Transactions](#transactions)
4141
- [Phoenix Integration](#phoenix-integration)
4242
- [Production Deployment](#production-deployment-with-turso)
43+
- [Limitations and Known Issues](#limitations-and-known-issues)
4344
- [API Reference](#api-reference)
4445
- [Real-World Examples](#real-world-examples)
4546
- [Performance Guide](#performance-guide)
@@ -1458,6 +1459,48 @@ export TURSO_AUTH_TOKEN="eyJ..."
14581459
- 🌍 **Global distribution** via Turso edge
14591460
- 💪 **Offline capability** - works without network
14601461

1462+
### Limitations and Known Issues
1463+
1464+
#### freeze_replica/1 - NOT SUPPORTED
1465+
1466+
The `EctoLibSql.Native.freeze_replica/1` function is **not implemented**. This function was intended to convert a remote replica into a standalone local database (useful for disaster recovery or field deployments).
1467+
1468+
**Status**: ⛔ Not supported - returns `{:error, :unsupported}`
1469+
1470+
**Why**: Converting a replica to primary requires taking ownership of the database connection, which is held in a shared `Arc<Mutex<>>` within the connection pool. This requires deep refactoring of the connection pool architecture that hasn't been completed.
1471+
1472+
**Workarounds** for disaster recovery scenarios:
1473+
1474+
1. **Backup and restore**: Copy the replica database file and use it independently
1475+
```bash
1476+
cp replica.db standalone.db
1477+
# Configure your app to use standalone.db directly
1478+
```
1479+
1480+
2. **Data replication**: Replicate all data to a new local database
1481+
```elixir
1482+
# In your application, read from replica and write to new local database
1483+
source_state = EctoLibSql.connect(database: "replica.db")
1484+
target_state = EctoLibSql.connect(database: "new_primary.db")
1485+
1486+
{:ok, _, result, _} = EctoLibSql.handle_execute(
1487+
"SELECT * FROM table_name", [], [], source_state
1488+
)
1489+
# ... transfer rows to target_state
1490+
```
1491+
1492+
3. **Application-level failover**: Keep the replica and manage failover at the application level
1493+
```elixir
1494+
defmodule MyApp.DatabaseFailover do
1495+
def connect_with_fallback(replica_opts, backup_opts) do
1496+
case EctoLibSql.connect(replica_opts) do
1497+
{:ok, state} -> {:ok, state}
1498+
{:error, _} -> EctoLibSql.connect(backup_opts) # Fall back to backup DB
1499+
end
1500+
end
1501+
end
1502+
```
1503+
14611504
### Type Mappings
14621505

14631506
Ecto types map to SQLite types as follows:

CLAUDE.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,104 @@ IO.inspect(result, label: "Result")
835835
4. **Check Rust output**: `cd native/ecto_libsql && cargo test -- --nocapture`
836836
5. **Verify NIF loading**: `File.exists?("priv/native/ecto_libsql.so")`
837837
838+
### Task 7: Marking Functions as Explicitly Unsupported
839+
840+
**Pattern**: When a function promised in the public API cannot be implemented due to architectural constraints, explicitly mark it as unsupported rather than hiding it or returning vague errors.
841+
842+
**Example**: The `freeze_database` NIF (promoting a replica to primary) cannot be implemented without deep refactoring of the connection pool architecture.
843+
844+
**Steps**:
845+
846+
1. **Update Rust NIF** to return a clear `:unsupported` atom error:
847+
```rust
848+
#[rustler::nif(schedule = "DirtyIo")]
849+
fn freeze_database(conn_id: &str) -> NifResult<Atom> {
850+
// Verify connection exists (basic validation)
851+
let conn_map = safe_lock(&CONNECTION_REGISTRY, "freeze_database")?;
852+
let _exists = conn_map
853+
.get(conn_id)
854+
.ok_or_else(|| rustler::Error::Term(Box::new("Connection not found")))?;
855+
drop(conn_map);
856+
857+
// Return typed error: :unsupported atom
858+
Err(rustler::Error::Atom("unsupported"))
859+
}
860+
```
861+
862+
2. **Update Elixir wrapper** to document unsupported status clearly:
863+
```elixir
864+
@doc """
865+
Freeze a remote replica, converting it to a standalone local database.
866+
867+
⚠️ **NOT SUPPORTED** - This function is currently not implemented.
868+
869+
Freeze is intended to ... However, this operation requires deep refactoring of the
870+
connection pool architecture and remains unimplemented. Instead, you can:
871+
872+
- **Option 1**: Backup the replica database file and use it independently
873+
- **Option 2**: Replicate all data to a new local database
874+
- **Option 3**: Keep the replica and manage failover at the application level
875+
876+
Always returns `{:error, :unsupported}`.
877+
878+
## Implementation Status
879+
880+
- **Blocker**: Requires taking ownership of the `Database` instance
881+
- **Work Required**: Refactoring connection pool architecture
882+
- **Timeline**: Uncertain - marked for future refactoring
883+
884+
See CLAUDE.md for technical details on why this is not currently supported.
885+
"""
886+
def freeze_replica(%EctoLibSql.State{conn_id: conn_id} = _state) when is_binary(conn_id) do
887+
{:error, :unsupported}
888+
end
889+
```
890+
891+
3. **Add comprehensive tests** asserting unsupported behavior:
892+
```elixir
893+
describe "freeze_replica - NOT SUPPORTED" do
894+
test "returns :unsupported atom for any valid connection" do
895+
{:ok, state} = EctoLibSql.connect(database: ":memory:")
896+
result = EctoLibSql.Native.freeze_replica(state)
897+
assert result == {:error, :unsupported}
898+
EctoLibSql.disconnect([], state)
899+
end
900+
901+
test "freeze does not modify database" do
902+
{:ok, state} = EctoLibSql.connect(database: ":memory:")
903+
904+
# Create and populate table
905+
{:ok, _, _, state} = EctoLibSql.handle_execute(
906+
"CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)",
907+
[], [], state
908+
)
909+
{:ok, _, _, state} = EctoLibSql.handle_execute(
910+
"INSERT INTO test (data) VALUES (?)", ["value"], [], state
911+
)
912+
913+
# Call freeze - should fail gracefully
914+
assert EctoLibSql.Native.freeze_replica(state) == {:error, :unsupported}
915+
916+
# Verify data is still accessible
917+
{:ok, _, result, _state} = EctoLibSql.handle_execute(
918+
"SELECT data FROM test WHERE id = 1", [], [], state
919+
)
920+
assert result.rows == [["value"]]
921+
922+
EctoLibSql.disconnect([], state)
923+
end
924+
end
925+
```
926+
927+
4. **Verify tests pass**: `mix test test/file_test.exs`
928+
929+
**Why This Pattern?**:
930+
- **Honest API**: Users know the operation is unsupported rather than failing mysteriously
931+
- **Clear error codes**: `:unsupported` atom is unambiguous (not a generic string error)
932+
- **Future-proof docs**: Documentation explains why and what workarounds exist
933+
- **No hidden behavior**: Function is a no-op that doesn't corrupt state
934+
- **Comprehensive tests**: Prevent accidental "fixes" that break in production
935+
838936
---
839937

840938
## Deployment & CI/CD

lib/ecto_libsql.ex

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,15 @@ defmodule EctoLibSql do
252252
into memory at once. Automatically deallocates the cursor when no more rows
253253
are available.
254254
"""
255-
def handle_fetch(%EctoLibSql.Query{} = _query, cursor, opts, %EctoLibSql.State{} = state) do
255+
def handle_fetch(
256+
%EctoLibSql.Query{} = _query,
257+
cursor,
258+
opts,
259+
%EctoLibSql.State{conn_id: conn_id} = state
260+
) do
256261
max_rows = Keyword.get(opts, :max_rows, 500)
257262

258-
case EctoLibSql.Native.fetch_cursor(cursor.ref, max_rows) do
263+
case EctoLibSql.Native.fetch_cursor(conn_id, cursor.ref, max_rows) do
259264
{columns, rows, _count} when is_list(rows) ->
260265
result = %EctoLibSql.Result{
261266
command: :select,

lib/ecto_libsql/native.ex

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ defmodule EctoLibSql.Native do
6262
def begin_transaction_with_behavior(_conn, _behavior), do: :erlang.nif_error(:nif_not_loaded)
6363

6464
@doc false
65-
def execute_with_transaction(_trx_id, _query, _args), do: :erlang.nif_error(:nif_not_loaded)
65+
def execute_with_transaction(_trx_id, _conn_id, _query, _args),
66+
do: :erlang.nif_error(:nif_not_loaded)
6667

6768
@doc false
68-
def query_with_trx_args(_trx_id, _query, _args), do: :erlang.nif_error(:nif_not_loaded)
69+
def query_with_trx_args(_trx_id, _conn_id, _query, _args),
70+
do: :erlang.nif_error(:nif_not_loaded)
6971

7072
@doc false
7173
def handle_status_transaction(_trx_id), do: :erlang.nif_error(:nif_not_loaded)
@@ -117,7 +119,7 @@ defmodule EctoLibSql.Native do
117119
def declare_cursor(_conn, _sql, _args), do: :erlang.nif_error(:nif_not_loaded)
118120

119121
@doc false
120-
def fetch_cursor(_cursor_id, _max_rows), do: :erlang.nif_error(:nif_not_loaded)
122+
def fetch_cursor(_conn_id, _cursor_id, _max_rows), do: :erlang.nif_error(:nif_not_loaded)
121123

122124
# Phase 1: Critical Production Features (v0.7.0)
123125
@doc false
@@ -168,6 +170,8 @@ defmodule EctoLibSql.Native do
168170
@doc false
169171
def flush_replicator(_conn_id), do: :erlang.nif_error(:nif_not_loaded)
170172

173+
# Internal NIF function - not supported, marked for deprecation
174+
# Always returns :unsupported atom rather than implementing the operation
171175
@doc false
172176
def freeze_database(_conn_id), do: :erlang.nif_error(:nif_not_loaded)
173177

@@ -288,7 +292,7 @@ defmodule EctoLibSql.Native do
288292

289293
@doc false
290294
def execute_with_trx(
291-
%EctoLibSql.State{conn_id: _conn_id, trx_id: trx_id} = state,
295+
%EctoLibSql.State{conn_id: conn_id, trx_id: trx_id} = state,
292296
%EctoLibSql.Query{statement: statement} = query,
293297
args
294298
) do
@@ -297,7 +301,7 @@ defmodule EctoLibSql.Native do
297301

298302
if has_returning do
299303
# Use query_with_trx_args for statements with RETURNING
300-
case query_with_trx_args(trx_id, statement, args) do
304+
case query_with_trx_args(trx_id, conn_id, statement, args) do
301305
%{
302306
"columns" => columns,
303307
"rows" => rows,
@@ -317,7 +321,7 @@ defmodule EctoLibSql.Native do
317321
end
318322
else
319323
# Use execute for statements without RETURNING
320-
case execute_with_transaction(trx_id, statement, args) do
324+
case execute_with_transaction(trx_id, conn_id, statement, args) do
321325
num_rows when is_integer(num_rows) ->
322326
result = %EctoLibSql.Result{
323327
command: detect_command(statement),
@@ -1167,46 +1171,53 @@ defmodule EctoLibSql.Native do
11671171
@doc """
11681172
Freeze a remote replica, converting it to a standalone local database.
11691173
1170-
This is useful for disaster recovery, promoting a replica to a primary,
1171-
or taking a snapshot for offline use. After freezing, the database
1172-
can no longer sync with the remote.
1174+
⚠️ **NOT SUPPORTED** - This function is currently not implemented.
1175+
1176+
Freeze is intended to convert a remote replica to a standalone local database
1177+
for disaster recovery. However, this operation requires deep refactoring of the
1178+
connection pool architecture and remains unimplemented. Instead, you can:
1179+
1180+
- **Option 1**: Backup the replica database file and use it independently
1181+
- **Option 2**: Replicate all data to a new local database
1182+
- **Option 3**: Keep the replica and manage failover at the application level
1183+
1184+
Always returns `{:error, :unsupported}`.
11731185
11741186
## Parameters
1175-
- state: The connection state (must be a remote replica)
1187+
- state: The connection state
11761188
11771189
## Returns
1178-
- `{:ok, state}` - Freeze succeeded, connection is now standalone
1179-
- `{:error, reason}` - If freeze failed or not a replica
1190+
- `{:error, :unsupported}` - Always (not implemented)
11801191
11811192
## Example
11821193
1183-
# Disaster recovery: primary is down
11841194
case EctoLibSql.Native.freeze_replica(replica_state) do
1185-
{:ok, frozen_state} ->
1186-
# Replica is now an independent local database
1187-
# Can write to it independently
1188-
Logger.info("Replica promoted to standalone")
1189-
{:ok, frozen_state}
1190-
{:error, reason} ->
1191-
Logger.error("Freeze failed: " <> to_string(reason))
1192-
{:error, reason}
1195+
{:ok, _frozen_state} ->
1196+
# This will never succeed
1197+
:unreachable
1198+
1199+
{:error, :unsupported} ->
1200+
Logger.error("Freeze is not supported. Use manual backup strategy instead.")
1201+
{:error, :unsupported}
11931202
end
11941203
1195-
## Notes
1196-
- Only works for remote replica connections
1197-
- After freezing, cannot sync with remote anymore
1198-
- All local data is preserved
1199-
- Useful for field deployment scenarios
1204+
## Implementation Status
1205+
1206+
- **Blocker**: Requires taking ownership of the `Database` instance, which is
1207+
held in `Arc<Mutex<LibSQLConn>>` within connection pool state
1208+
- **Work Required**: Refactoring connection pool architecture to support
1209+
consuming connections
1210+
- **Timeline**: Uncertain - marked for future refactoring
1211+
1212+
See CLAUDE.md for technical details on why this is not currently supported.
12001213
12011214
"""
1202-
def freeze_replica(%EctoLibSql.State{conn_id: conn_id} = state) when is_binary(conn_id) do
1203-
case freeze_database(conn_id) do
1204-
:ok -> {:ok, state}
1205-
error -> {:error, error}
1206-
end
1215+
def freeze_replica(%EctoLibSql.State{conn_id: conn_id} = _state) when is_binary(conn_id) do
1216+
# Always return unsupported - this feature is not implemented
1217+
{:error, :unsupported}
12071218
end
12081219

12091220
def freeze_replica(_state) do
1210-
{:error, "Invalid state - cannot freeze"}
1221+
{:error, :unsupported}
12111222
end
12121223
end

0 commit comments

Comments
 (0)