diff --git a/backend/src/BuiltinCli/Builtin.fs b/backend/src/BuiltinCli/Builtin.fs
index 629d9c26e3..b2cc79b381 100644
--- a/backend/src/BuiltinCli/Builtin.fs
+++ b/backend/src/BuiltinCli/Builtin.fs
@@ -15,6 +15,7 @@ let builtins =
Libs.Execution.builtins
Libs.Output.builtins
Libs.Process.builtins
+ Libs.Sqlite.builtins
Libs.Stdin.builtins
Libs.Time.builtins
Libs.Terminal.builtins ]
diff --git a/backend/src/BuiltinCli/BuiltinCli.fsproj b/backend/src/BuiltinCli/BuiltinCli.fsproj
index 254904ec91..463a4d95af 100644
--- a/backend/src/BuiltinCli/BuiltinCli.fsproj
+++ b/backend/src/BuiltinCli/BuiltinCli.fsproj
@@ -15,6 +15,7 @@
+
diff --git a/backend/src/BuiltinCli/Libs/Sqlite.fs b/backend/src/BuiltinCli/Libs/Sqlite.fs
new file mode 100644
index 0000000000..4a9200c5ea
--- /dev/null
+++ b/backend/src/BuiltinCli/Libs/Sqlite.fs
@@ -0,0 +1,216 @@
+/// Standard libraries for SQLite
+module BuiltinCli.Libs.Sqlite
+
+open System.Threading.Tasks
+open FSharp.Control.Tasks
+open Microsoft.Data.Sqlite
+open Fumble
+
+open Prelude
+open LibExecution.RuntimeTypes
+module Dval = LibExecution.Dval
+module VT = LibExecution.ValueType
+module TypeChecker = LibExecution.TypeChecker
+module Builtin = LibExecution.Builtin
+open Builtin.Shortcuts
+
+
+// Helper function to convert SQLite values to Dvals
+let sqliteValueToDval (threadID : ThreadID) (value : obj) : Dval =
+ match value with
+ | null -> TypeChecker.DvalCreator.option threadID VT.unknownTODO None
+ | :? int64 as i -> DInt64 i
+ | :? double as d -> DFloat d
+ | :? string as s -> DString s
+ | :? System.Boolean as b -> DBool b
+ | :? (byte array) as bytes ->
+ DList(
+ ValueType.Known KTUInt8,
+ bytes |> Array.map (fun b -> DUInt8 b) |> Array.toList
+ )
+ | _ -> DString(value.ToString())
+
+
+// Helper function to convert a row to a Dict
+let rowToDict (threadID : ThreadID) (reader : SqliteDataReader) : Dval =
+ let mutable fields = Map.empty
+
+ for i in 0 .. reader.FieldCount - 1 do
+ let columnName = reader.GetName(i)
+ let value = reader.GetValue(i)
+ let dval = sqliteValueToDval threadID value
+ fields <- Map.add columnName dval fields
+
+ DDict(ValueType.Unknown, fields)
+
+
+let fns : List =
+ [ { name = fn "sqliteQuery" 0
+ typeParams = []
+ parameters =
+ [ Param.make "path" TString ""; Param.make "sql" TString "" ]
+ returnType = TypeReference.result (TList(TDict TString)) TString
+ description =
+ "Executes a SELECT query against the SQLite database at and returns the results as a list of dictionaries. Each dictionary represents a row with column names as keys."
+ fn =
+ (function
+ | _, vmState, _, [ DString path; DString sql ] ->
+ uply {
+ try
+ let path =
+ path.Replace(
+ "$HOME",
+ System.Environment.GetEnvironmentVariable "HOME"
+ )
+
+ let connString = $"Data Source={path};Mode=ReadWrite"
+ use connection = new SqliteConnection(connString)
+ do! connection.OpenAsync()
+
+ use command = connection.CreateCommand()
+ command.CommandText <- sql
+
+ use! reader = command.ExecuteReaderAsync()
+ let mutable results = []
+
+ let rec readRows () =
+ uply {
+ let! hasRow = reader.ReadAsync()
+
+ if hasRow then
+ let row = rowToDict vmState.threadID reader
+ results <- row :: results
+ return! readRows ()
+ else
+ return ()
+ }
+
+ do! readRows ()
+
+ let listResult =
+ DList(ValueType.Known(KTDict(ValueType.Unknown)), List.rev results)
+
+ return
+ Dval.resultOk
+ (KTList(ValueType.Known(KTDict(ValueType.Unknown))))
+ KTString
+ listResult
+ with e ->
+ return
+ Dval.resultError
+ (KTList(ValueType.Known(KTDict(ValueType.Unknown))))
+ KTString
+ (DString($"SQLite query error: {e.Message}"))
+ }
+ | _ -> incorrectArgs ())
+ sqlSpec = NotQueryable
+ previewable = Impure
+ deprecated = NotDeprecated }
+
+
+ { name = fn "sqliteQueryOne" 0
+ typeParams = []
+ parameters =
+ [ Param.make "path" TString ""; Param.make "sql" TString "" ]
+ returnType = TypeReference.result (TypeReference.option (TDict TString)) TString
+ description =
+ "Executes a SELECT query against the SQLite database at and returns the first row as an Option of dictionary. Returns None if no rows match."
+ fn =
+ (function
+ | _, vmState, _, [ DString path; DString sql ] ->
+ uply {
+ try
+ let path =
+ path.Replace(
+ "$HOME",
+ System.Environment.GetEnvironmentVariable "HOME"
+ )
+
+ let connString = $"Data Source={path};Mode=ReadWrite"
+ use connection = new SqliteConnection(connString)
+ do! connection.OpenAsync()
+
+ use command = connection.CreateCommand()
+ command.CommandText <- sql
+
+ use! reader = command.ExecuteReaderAsync()
+
+ let! hasRow = reader.ReadAsync()
+
+ let dictType = VT.dict VT.unknownTODO
+
+ let optResult =
+ if hasRow then
+ let row = rowToDict vmState.threadID reader
+ TypeChecker.DvalCreator.optionSome vmState.threadID dictType row
+ else
+ TypeChecker.DvalCreator.optionNone dictType
+
+ // Manually construct Result.Ok enum containing the option
+ return
+ DEnum(
+ Dval.resultType,
+ Dval.resultType,
+ [ VT.unknownTODO; VT.string ],
+ "Ok",
+ [ optResult ]
+ )
+ with e ->
+ // Manually construct Result.Error enum
+ return
+ DEnum(
+ Dval.resultType,
+ Dval.resultType,
+ [ VT.unknownTODO; VT.string ],
+ "Error",
+ [ DString($"SQLite query error: {e.Message}") ]
+ )
+ }
+ | _ -> incorrectArgs ())
+ sqlSpec = NotQueryable
+ previewable = Impure
+ deprecated = NotDeprecated }
+
+
+ { name = fn "sqliteExecute" 0
+ typeParams = []
+ parameters =
+ [ Param.make "path" TString ""; Param.make "sql" TString "" ]
+ returnType = TypeReference.result TInt64 TString
+ description =
+ "Executes a SQL statement (INSERT, UPDATE, DELETE, CREATE, etc.) against the SQLite database at . Returns the number of rows affected."
+ fn =
+ (function
+ | _, _, _, [ DString path; DString sql ] ->
+ uply {
+ try
+ let path =
+ path.Replace(
+ "$HOME",
+ System.Environment.GetEnvironmentVariable "HOME"
+ )
+
+ let connString = $"Data Source={path};Mode=ReadWriteCreate"
+ use connection = new SqliteConnection(connString)
+ do! connection.OpenAsync()
+
+ use command = connection.CreateCommand()
+ command.CommandText <- sql
+
+ let! affectedRows = command.ExecuteNonQueryAsync()
+ let result = DInt64(int64 affectedRows)
+ return Dval.resultOk KTInt64 KTString result
+ with e ->
+ return
+ Dval.resultError
+ KTInt64
+ KTString
+ (DString($"SQLite execute error: {e.Message}"))
+ }
+ | _ -> incorrectArgs ())
+ sqlSpec = NotQueryable
+ previewable = Impure
+ deprecated = NotDeprecated } ]
+
+
+let builtins : Builtins = Builtin.make [] fns
diff --git a/backend/src/BuiltinCli/paket.references b/backend/src/BuiltinCli/paket.references
index 4b5bc5d60f..d8c5d9e883 100644
--- a/backend/src/BuiltinCli/paket.references
+++ b/backend/src/BuiltinCli/paket.references
@@ -1,3 +1,5 @@
Ply
FSharp.Core
-FSharpPlus
\ No newline at end of file
+FSharpPlus
+Microsoft.Data.Sqlite
+Fumble
\ No newline at end of file
diff --git a/packages/darklang/cli/tests/sqlite.dark b/packages/darklang/cli/tests/sqlite.dark
new file mode 100644
index 0000000000..0f794d55bf
--- /dev/null
+++ b/packages/darklang/cli/tests/sqlite.dark
@@ -0,0 +1,288 @@
+module Darklang.Cli.Tests.Sqlite
+
+type TestResult =
+ | Pass
+ | Fail of message: String
+
+
+let testCreateTableAndInsert (): TestResult =
+ // Use a temporary file for testing
+ let tempResult = Builtin.fileCreateTemp ()
+
+ match tempResult with
+ | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}"
+ | Ok tempPath ->
+ // Create a table
+ let createResult =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "CREATE TABLE test_users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"
+
+ match createResult with
+ | Error msg -> TestResult.Fail $"Failed to create table: {msg}"
+ | Ok rowCount ->
+ // Insert some data
+ let insert1 =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "INSERT INTO test_users (id, name, age) VALUES (1, 'Alice', 30)"
+
+ let insert2 =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "INSERT INTO test_users (id, name, age) VALUES (2, 'Bob', 25)"
+
+ match (insert1, insert2) with
+ | (Ok count1, Ok count2) ->
+ if count1 == 1L && count2 == 1L then
+ // Query the data
+ let queryResult =
+ Stdlib.Cli.Sqlite.query tempPath "SELECT * FROM test_users ORDER BY id"
+
+ match queryResult with
+ | Error msg -> TestResult.Fail $"Failed to query: {msg}"
+ | Ok rows ->
+ let rowCount = Stdlib.List.length rows
+
+ if rowCount == 2L then
+ TestResult.Pass
+ else
+ TestResult.Fail $"Expected 2 rows, got {rowCount}"
+ else
+ TestResult.Fail $"Expected 1 row affected per insert, got {count1} and {count2}"
+ | (Error msg, _) -> TestResult.Fail $"Insert 1 failed: {msg}"
+ | (_, Error msg) -> TestResult.Fail $"Insert 2 failed: {msg}"
+
+
+let testQueryOne (): TestResult =
+ // Use a temporary file for testing
+ let tempResult = Builtin.fileCreateTemp ()
+
+ match tempResult with
+ | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}"
+ | Ok tempPath ->
+ // Create table and insert data
+ let _ =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "CREATE TABLE test_data (id INTEGER, value TEXT)"
+
+ let _ =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "INSERT INTO test_data VALUES (1, 'first')"
+
+ // Query for existing row
+ let queryResult =
+ Stdlib.Cli.Sqlite.queryOne
+ tempPath
+ "SELECT * FROM test_data WHERE id = 1"
+
+ match queryResult with
+ | Error msg -> TestResult.Fail $"Query failed: {msg}"
+ | Ok optRow ->
+ match optRow with
+ | None -> TestResult.Fail "Expected Some row, got None"
+ | Some row ->
+ // Query for non-existing row
+ let queryResult2 =
+ Stdlib.Cli.Sqlite.queryOne
+ tempPath
+ "SELECT * FROM test_data WHERE id = 999"
+
+ match queryResult2 with
+ | Error msg -> TestResult.Fail $"Query 2 failed: {msg}"
+ | Ok optRow2 ->
+ match optRow2 with
+ | Some _ -> TestResult.Fail "Expected None, got Some row"
+ | None -> TestResult.Pass
+
+
+let testUpdate (): TestResult =
+ // Use a temporary file for testing
+ let tempResult = Builtin.fileCreateTemp ()
+
+ match tempResult with
+ | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}"
+ | Ok tempPath ->
+ // Create table and insert data
+ let _ =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "CREATE TABLE test_update (id INTEGER, name TEXT)"
+
+ let _ =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "INSERT INTO test_update VALUES (1, 'original')"
+
+ // Update the row
+ let updateResult =
+ Stdlib.Cli.Sqlite.update tempPath "test_update" "name = 'updated'" "id = 1"
+
+ match updateResult with
+ | Error msg -> TestResult.Fail $"Update failed: {msg}"
+ | Ok count ->
+ if count == 1L then
+ // Verify the update
+ let queryResult =
+ Stdlib.Cli.Sqlite.queryOne
+ tempPath
+ "SELECT name FROM test_update WHERE id = 1"
+
+ match queryResult with
+ | Error msg -> TestResult.Fail $"Query failed: {msg}"
+ | Ok optRow ->
+ match optRow with
+ | None -> TestResult.Fail "Expected row after update"
+ | Some row ->
+ let name = Stdlib.Dict.get row "name"
+
+ match name with
+ | Some nameVal ->
+ if nameVal == "updated" then
+ TestResult.Pass
+ else
+ TestResult.Fail $"Expected 'updated', got {nameVal}"
+ | None -> TestResult.Fail "Name column not found"
+ else
+ TestResult.Fail $"Expected 1 row updated, got {count}"
+
+
+let testDelete (): TestResult =
+ // Use a temporary file for testing
+ let tempResult = Builtin.fileCreateTemp ()
+
+ match tempResult with
+ | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}"
+ | Ok tempPath ->
+ // Create table and insert data
+ let _ =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "CREATE TABLE test_delete (id INTEGER, name TEXT)"
+
+ let _ =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "INSERT INTO test_delete VALUES (1, 'to_delete')"
+
+ let _ =
+ Stdlib.Cli.Sqlite.execute
+ tempPath
+ "INSERT INTO test_delete VALUES (2, 'to_keep')"
+
+ // Delete one row
+ let deleteResult =
+ Stdlib.Cli.Sqlite.delete tempPath "test_delete" "id = 1"
+
+ match deleteResult with
+ | Error msg -> TestResult.Fail $"Delete failed: {msg}"
+ | Ok count ->
+ if count == 1L then
+ // Verify only one row remains
+ let queryResult =
+ Stdlib.Cli.Sqlite.query tempPath "SELECT * FROM test_delete"
+
+ match queryResult with
+ | Error msg -> TestResult.Fail $"Query failed: {msg}"
+ | Ok rows ->
+ let rowCount = Stdlib.List.length rows
+
+ if rowCount == 1L then
+ TestResult.Pass
+ else
+ TestResult.Fail $"Expected 1 row remaining, got {rowCount}"
+ else
+ TestResult.Fail $"Expected 1 row deleted, got {count}"
+
+
+let testHelperFunctions (): TestResult =
+ // Use a temporary file for testing
+ let tempResult = Builtin.fileCreateTemp ()
+
+ match tempResult with
+ | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}"
+ | Ok tempPath ->
+ // Test createTable helper
+ let createResult =
+ Stdlib.Cli.Sqlite.createTable
+ tempPath
+ "helpers_test"
+ "id INTEGER PRIMARY KEY, name TEXT"
+
+ match createResult with
+ | Error msg -> TestResult.Fail $"createTable failed: {msg}"
+ | Ok _ ->
+ // Test insert helper
+ let insertResult =
+ Stdlib.Cli.Sqlite.insert tempPath "helpers_test" "id, name" "1, 'test'"
+
+ match insertResult with
+ | Error msg -> TestResult.Fail $"insert failed: {msg}"
+ | Ok count ->
+ if count == 1L then
+ TestResult.Pass
+ else
+ TestResult.Fail $"Expected 1 row inserted, got {count}"
+
+
+let testInvalidSql (): TestResult =
+ // Use a temporary file for testing
+ let tempResult = Builtin.fileCreateTemp ()
+
+ match tempResult with
+ | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}"
+ | Ok tempPath ->
+ // Try to execute invalid SQL
+ let result =
+ Stdlib.Cli.Sqlite.execute tempPath "THIS IS NOT VALID SQL"
+
+ match result with
+ | Error msg ->
+ // We expect an error - check that it contains "SQLite" or "error"
+ if Stdlib.String.contains msg "SQLite" ||
+ Stdlib.String.contains msg "error" ||
+ Stdlib.String.contains msg "syntax" then
+ TestResult.Pass
+ else
+ TestResult.Fail $"Expected SQLite error message, got: {msg}"
+ | Ok _ -> TestResult.Fail "Expected error for invalid SQL, but got success"
+
+
+let testFileNotFound (): TestResult =
+ // Try to query a non-existent database file
+ let result =
+ Stdlib.Cli.Sqlite.query "/tmp/this-file-definitely-does-not-exist-12345.db" "SELECT 1"
+
+ match result with
+ | Error msg ->
+ // We expect an error - check that it mentions the issue
+ if Stdlib.String.contains msg "SQLite" || Stdlib.String.contains msg "error" then
+ TestResult.Pass
+ else
+ TestResult.Fail $"Expected SQLite error message, got: {msg}"
+ | Ok _ -> TestResult.Fail "Expected error for non-existent file, but got success"
+
+
+let testInvalidTable (): TestResult =
+ // Use a temporary file for testing
+ let tempResult = Builtin.fileCreateTemp ()
+
+ match tempResult with
+ | Error msg -> TestResult.Fail $"Failed to create temp file: {msg}"
+ | Ok tempPath ->
+ // Try to query a table that doesn't exist
+ let result =
+ Stdlib.Cli.Sqlite.query tempPath "SELECT * FROM nonexistent_table"
+
+ match result with
+ | Error msg ->
+ // We expect an error about the table not existing
+ if Stdlib.String.contains msg "no such table" ||
+ Stdlib.String.contains msg "nonexistent" ||
+ Stdlib.String.contains msg "error" then
+ TestResult.Pass
+ else
+ TestResult.Fail $"Expected 'no such table' error, got: {msg}"
+ | Ok _ -> TestResult.Fail "Expected error for non-existent table, but got success"
diff --git a/packages/darklang/stdlib/cli/sqlite.dark b/packages/darklang/stdlib/cli/sqlite.dark
new file mode 100644
index 0000000000..f1deaf3688
--- /dev/null
+++ b/packages/darklang/stdlib/cli/sqlite.dark
@@ -0,0 +1,118 @@
+module Darklang.Stdlib.Cli.Sqlite
+
+/// Executes a SQL statement (INSERT, UPDATE, DELETE, CREATE, etc.) against
+/// the SQLite database at the specified path. Returns the number of rows affected
+/// wrapped in a Result.
+///
+/// Example:
+/// Stdlib.Cli.Sqlite.execute "test.db" "CREATE TABLE users (id INTEGER, name TEXT)"
+/// // Result.Ok 0L
+///
+/// Stdlib.Cli.Sqlite.execute "test.db" "INSERT INTO users VALUES (1, 'Alice')"
+/// // Result.Ok 1L
+let execute (path: String) (sql: String) : Stdlib.Result.Result =
+ Builtin.sqliteExecute path sql
+
+
+/// Executes a SELECT query against the SQLite database at the specified path
+/// and returns all matching rows as a list of dictionaries. Each dictionary
+/// represents a row with column names as keys.
+///
+/// Example:
+/// Stdlib.Cli.Sqlite.query "test.db" "SELECT * FROM users"
+/// // Result.Ok [ { "id": 1L, "name": "Alice" }, { "id": 2L, "name": "Bob" } ]
+let query
+ (path: String)
+ (sql: String)
+ : Stdlib.Result.Result>, String> =
+ Builtin.sqliteQuery path sql
+
+
+/// Executes a SELECT query against the SQLite database at the specified path
+/// and returns the first matching row as an Option of dictionary. Returns None
+/// if no rows match.
+///
+/// Example:
+/// Stdlib.Cli.Sqlite.queryOne "test.db" "SELECT * FROM users WHERE id = 1"
+/// // Result.Ok (Some { "id": 1L, "name": "Alice" })
+///
+/// Stdlib.Cli.Sqlite.queryOne "test.db" "SELECT * FROM users WHERE id = 999"
+/// // Result.Ok None
+let queryOne
+ (path: String)
+ (sql: String)
+ : Stdlib.Result.Result>, String> =
+ Builtin.sqliteQueryOne path sql
+
+
+/// Creates a new table with the specified name and columns.
+/// This is a helper function that wraps the execute function.
+///
+/// Example:
+/// Stdlib.Cli.Sqlite.createTable
+/// "test.db"
+/// "users"
+/// "id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT"
+/// // Result.Ok 0L
+let createTable
+ (path: String)
+ (tableName: String)
+ (columns: String)
+ : Stdlib.Result.Result =
+ let sql = $"CREATE TABLE IF NOT EXISTS {tableName} ({columns})"
+ execute path sql
+
+
+/// Inserts a row into the specified table.
+/// This is a helper function that wraps the execute function.
+///
+/// Example:
+/// Stdlib.Cli.Sqlite.insert
+/// "test.db"
+/// "users"
+/// "name, email"
+/// "'Alice', 'alice@example.com'"
+/// // Result.Ok 1L
+let insert
+ (path: String)
+ (tableName: String)
+ (columns: String)
+ (values: String)
+ : Stdlib.Result.Result =
+ let sql = $"INSERT INTO {tableName} ({columns}) VALUES ({values})"
+ execute path sql
+
+
+/// Updates rows in the specified table that match the given condition.
+/// This is a helper function that wraps the execute function.
+///
+/// Example:
+/// Stdlib.Cli.Sqlite.update
+/// "test.db"
+/// "users"
+/// "email = 'newemail@example.com'"
+/// "id = 1"
+/// // Result.Ok 1L
+let update
+ (path: String)
+ (tableName: String)
+ (setClause: String)
+ (whereClause: String)
+ : Stdlib.Result.Result =
+ let sql = $"UPDATE {tableName} SET {setClause} WHERE {whereClause}"
+ execute path sql
+
+
+/// Deletes rows from the specified table that match the given condition.
+/// This is a helper function that wraps the execute function.
+///
+/// Example:
+/// Stdlib.Cli.Sqlite.delete "test.db" "users" "id = 1"
+/// // Result.Ok 1L
+let delete
+ (path: String)
+ (tableName: String)
+ (whereClause: String)
+ : Stdlib.Result.Result =
+ let sql = $"DELETE FROM {tableName} WHERE {whereClause}"
+ execute path sql