git.iko.soy/rowtype-yoga/purescript-yoga-sqlite
on main help sign in
Add typed SQLite BLOB support
commitbfc4a62258d3d9c14273515fce3d04be0f3d8fed authorMark <mark.eibes@gmail.com> dateSat 8 Aug 2026, 16:19 (2 months ago) parentdf52983
8 files changed+82−1
spago.lock+9 spago.yaml+1 src/Yoga/SQLite/SQLite.js+9 src/Yoga/SQLite/SQLite.purs+11 src/Yoga/SQLite/Schema.purs+24 test/Main.purs+20−1 test/TempDb.js+3 test/TempDb.purs+5
spago.lock+9
@@ -8,6 +8,9 @@
88 {
99 "aff": ">=8.0.0 <9.0.0"
1010 },
11+ {
12+ "arraybuffer-types": ">=3.0.2 <4.0.0"
13+ },
1114 {
1215 "arrays": ">=7.3.0 <8.0.0"
1316 },
@@ -140,6 +143,12 @@
140143 "prelude"
141144 ]
142145 },
146+ "arraybuffer-types": {
147+ "type": "registry",
148+ "version": "3.0.2",
149+ "integrity": "sha256-p05cJnSkyeoB7VHzMyc2Eb1RwUaB7Nl0sQSqEGNEUD8=",
150+ "dependencies": []
151+ },
143152 "arrays": {
144153 "type": "registry",
145154 "version": "7.3.0",
spago.yaml+1
@@ -10,6 +10,7 @@ package:
1010 githubRepo: purescript-yoga-sqlite
1111 dependencies:
1212 - aff: ">=8.0.0 <9.0.0"
13+ - arraybuffer-types: ">=3.0.2 <4.0.0"
1314 - arrays: ">=7.3.0 <8.0.0"
1415 - datetime: ">=6.1.0 <7.0.0"
1516 - effect: ">=4.0.0 <5.0.0"
src/Yoga/SQLite/SQLite.js+9
@@ -160,6 +160,15 @@ export const pingImpl = async (client) => {
160160// Convert DateTime (JSDate) to ISO string for SQLite TEXT storage
161161export const dateTimeToStringImpl = (jsDate) => jsDate.toISOString();
162162
163+// General SQLite BLOB conversions. Copy a sliced view so unrelated bytes from
164+// its backing buffer are never persisted.
165+export const uint8ArrayToArrayBufferImpl = (bytes) =>
166+ bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
167+ ? bytes.buffer
168+ : bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
169+
170+export const arrayBufferToUint8ArrayImpl = (buffer) => new Uint8Array(buffer);
171+
163172// F32 vector <-> Array conversion for Turso vector columns
164173export const f32VectorFromArrayImpl = (arr) => new Float32Array(arr).buffer;
165174export const f32VectorToArrayImpl = (buf) => Array.from(new Float32Array(buf));
src/Yoga/SQLite/SQLite.purs+11
@@ -2,6 +2,7 @@ module Yoga.SQLite.SQLite where
22
33import Prelude
44
5+import Data.ArrayBuffer.Types (ArrayBuffer, Uint8Array)
56import Data.DateTime (DateTime)
67import Data.Maybe (Maybe(..))
78import Data.Newtype (class Newtype)
@@ -18,6 +19,16 @@ import Promise (Promise)
1819import Promise.Aff (toAffE) as Promise
1920import Unsafe.Coerce (unsafeCoerce)
2021
22+foreign import uint8ArrayToArrayBufferImpl :: Uint8Array -> ArrayBuffer
23+
24+uint8ArrayToArrayBuffer :: Uint8Array -> ArrayBuffer
25+uint8ArrayToArrayBuffer = uint8ArrayToArrayBufferImpl
26+
27+foreign import arrayBufferToUint8ArrayImpl :: ArrayBuffer -> Uint8Array
28+
29+arrayBufferToUint8Array :: ArrayBuffer -> Uint8Array
30+arrayBufferToUint8Array = arrayBufferToUint8ArrayImpl
31+
2132-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2233-- Opaque Foreign Types
2334-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
src/Yoga/SQLite/Schema.purs+24
@@ -4,6 +4,7 @@ import Prelude
44
55import Data.Array as Array
66import Data.Array (intercalate, mapWithIndex, foldl)
7+import Data.ArrayBuffer.Types (ArrayBuffer, Uint8Array)
78import Data.Date (Date, exactDate)
89import Data.Enum (toEnum, fromEnum)
910import Data.DateTime (DateTime(..), date, time)
@@ -98,6 +99,26 @@ instance ReadForeign Json where
9899 s :: String <- readImpl f
99100 Json <$> parseJSON s
100101
102+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
103+-- Blob: general-purpose SQLite BLOB backed by an ArrayBuffer
104+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
105+
106+newtype Blob = Blob ArrayBuffer
107+
108+derive instance Newtype Blob _
109+
110+blobFromUint8Array :: Uint8Array -> Blob
111+blobFromUint8Array = Blob <<< SQLite.uint8ArrayToArrayBuffer
112+
113+blobToUint8Array :: Blob -> Uint8Array
114+blobToUint8Array (Blob buffer) = SQLite.arrayBufferToUint8Array buffer
115+
116+instance ReadForeign Blob where
117+ readImpl = pure <<< Blob <<< unsafeCoerce
118+
119+instance SQLite.ToSQLiteValue Blob where
120+ toSQLiteValue (Blob buffer) = unsafeCoerce buffer
121+
101122-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
102123-- SQLUUID: newtype over Data.UUID.UUID (stored as TEXT in SQLite)
103124-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -317,6 +338,9 @@ instance SQLiteTypeName SQLTime where
317338instance SQLiteTypeName Json where
318339 sqliteTypeName _ = "TEXT"
319340
341+instance SQLiteTypeName Blob where
342+ sqliteTypeName _ = "BLOB"
343+
320344instance IsSymbol dim => SQLiteTypeName (F32Vector dim) where
321345 sqliteTypeName _ = "F32_BLOB(" <> reflectSymbol (Proxy :: Proxy dim) <> ")"
322346
test/Main.purs+20−1
@@ -25,7 +25,7 @@ import Test.Spec.Reporter.Console (consoleReporter)
2525import Test.Spec.Runner (runSpec)
2626import Type.Function (type (#))
2727import Type.Proxy (Proxy(..))
28−import Test.Sqlite.TempDb (mkTempDbUrl)
28+import Test.Sqlite.TempDb (mkTempDbUrl, testBytes, uint8ArrayValues)
2929import Yoga.JSON (class ReadForeign, readImpl, unsafeStringify)
3030import Yoga.SQLite.SQLite as SQLite
3131import Yoga.SQLite.Schema
@@ -108,6 +108,14 @@ type F64DocsTable = Table "f64docs"
108108f64DocsTable :: Proxy F64DocsTable
109109f64DocsTable = Proxy
110110
111+type BlobsTable = Table "blobs"
112+ ( id :: Int # PrimaryKey # AutoIncrement
113+ , body :: Blob
114+ )
115+
116+blobsTable :: Proxy BlobsTable
117+blobsTable = Proxy
118+
111119type RandomRowIdTable = Table "things"
112120 ( id :: Int # PrimaryKey # RandomRowId
113121 , name :: String
@@ -479,6 +487,10 @@ spec = before setupConn do
479487 let result = createTableDDL @LinkedCodesTable
480488 result `shouldEqual` "CREATE TABLE linked_codes (code TEXT NOT NULL REFERENCES codes(code), id INTEGER PRIMARY KEY AUTOINCREMENT)"
481489
490+ it "derives BLOB columns from Blob" \_ -> do
491+ let result = createTableDDL @BlobsTable
492+ result `shouldEqual` "CREATE TABLE blobs (body BLOB NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT)"
493+
482494 describe "SQL builder output" do
483495 it "SELECT *" \_ -> do
484496 toSQL typedSelectAll `shouldEqual` "SELECT * FROM users"
@@ -700,6 +712,13 @@ spec = before setupConn do
700712 let result = map (\r -> unF64Vector r.embedding) (rows :: Array { id :: Int, title :: String, embedding :: F64Vector "3" })
701713 result `shouldEqual` [[1.0, 2.0, 3.0]]
702714
715+ it "round-trips a sliced Uint8Array as one BLOB" \conn -> do
716+ SQLite.executeSimple (SQLite.SQL (createTableDDL @BlobsTable)) conn # void
717+ runExecute conn {} (from blobsTable # insert { body: blobFromUint8Array testBytes }) # void
718+ rows <- runQuery conn {} (from blobsTable # selectAll)
719+ let values = map (uint8ArrayValues <<< blobToUint8Array <<< _.body) (rows :: Array { id :: Int, body :: Blob })
720+ values `shouldEqual` [ [ 0, 1, 2, 127, 128, 255 ] ]
721+
703722 it "columnTypes populated after query" \conn -> do
704723 SQLite.executeSimple (SQLite.SQL (createTableDDL @UsersTable)) conn # void
705724 runExecute conn {} (from usersTable # insert { name: "Alice", email: "a@b.com", age: Just 30 }) # void
test/TempDb.js+3
@@ -2,6 +2,9 @@ import { mkdtempSync } from "fs";
22import { tmpdir } from "os";
33import { join } from "path";
44
5+const testBytesBacking = new Uint8Array([99, 0, 1, 2, 127, 128, 255, 99]);
6+export const testBytes = testBytesBacking.subarray(1, 7);
7+export const uint8ArrayValues = (bytes) => Array.from(bytes);
58let counter = 0;
69
710export const mkTempDbUrl = () => {
test/TempDb.purs+5
@@ -1,5 +1,10 @@
11module Test.Sqlite.TempDb where
22
3+import Data.ArrayBuffer.Types (Uint8Array)
34import Effect (Effect)
45
56foreign import mkTempDbUrl :: Effect String
7+
8+foreign import testBytes :: Uint8Array
9+
10+foreign import uint8ArrayValues :: Uint8Array -> Array Int