From d5d76def09d0e87e32d1b1615954340f94228218 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Thu, 30 Apr 2026 06:08:48 -0400 Subject: [PATCH 1/2] replace ws4sql with new node sqlite module --- Makefile | 5 - devbox.json | 5 +- devbox.lock | 76 ++++++----- gren.json | 5 +- gren_packages/blaix_gren_ws4sql__4_0_0.pkg.gz | Bin 6357 -> 0 bytes gren_packages/gren_lang_node__6_0_0.pkg.gz | Bin 33899 -> 0 bytes process-compose.yml | 6 - src/Main.gren | 47 ++++--- src/PublishingIdentity.gren | 32 ++--- src/Registry/Db.gren | 128 ++++++++++-------- src/Route/PublishingIdentity.gren | 4 +- src/Route/Session.gren | 18 +-- src/Session.gren | 44 +++--- src/Test/E2E.gren | 17 ++- src/Test/E2E/Helper.gren | 22 ++- src/Test/E2E/PublishingIdentity.gren | 36 ++--- src/Test/E2E/Route/PublishingIdentity.gren | 28 ++-- src/Test/E2E/Route/Session.gren | 30 ++-- src/Test/E2E/Session.gren | 14 +- src/Test/E2E/User.gren | 23 ++-- src/Test/Main.gren | 19 +-- src/User.gren | 28 ++-- 22 files changed, 306 insertions(+), 281 deletions(-) delete mode 100644 gren_packages/blaix_gren_ws4sql__4_0_0.pkg.gz delete mode 100644 gren_packages/gren_lang_node__6_0_0.pkg.gz diff --git a/Makefile b/Makefile index 088fd4d..c0a4272 100644 --- a/Makefile +++ b/Makefile @@ -7,11 +7,6 @@ dist/test: dist/app gren_packages: gren.json gren package install -.PHONY: db -db: - # TODO: get db url from env - ws4sql --quick-db=db/local.db - .PHONY: server server: dist/app node dist/app diff --git a/devbox.json b/devbox.json index 7b5b001..be4d95c 100644 --- a/devbox.json +++ b/devbox.json @@ -1,11 +1,10 @@ { "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.13.6/.schema/devbox.schema.json", "packages": [ - "nodejs@20", - "gren@0.6.3", + "nodejs@24", + "gren@0.6.5", "curl@latest", "jq@latest", - "github:blaix/ws4sql-nix/0.17dev7", "sqlite@latest" ], "shell": { diff --git a/devbox.lock b/devbox.lock index ed46df5..0f8f499 100644 --- a/devbox.lock +++ b/devbox.lock @@ -128,55 +128,51 @@ "github:NixOS/nixpkgs/nixpkgs-unstable": { "resolved": "github:NixOS/nixpkgs/3549532663732bfd89993204d40543e9edaec4f2?lastModified=1742272065&narHash=sha256-ud8vcSzJsZ%2FCK%2Br8%2Fv0lyf4yUntVmDq6Z0A41ODfWbE%3D" }, - "github:blaix/ws4sql-nix/0.17dev7": { - "last_modified": "2026-03-11T20:58:00Z", - "resolved": "github:blaix/ws4sql-nix/4837de181c857cdb9fa015f7fb9a018cceec8532?lastModified=1773262680&narHash=sha256-mOBI%2BWUDHjd6xf3JhlC%2BMkt2Nk8BzN20J0dZfQetHbc%3D" - }, - "gren@0.6.3": { - "last_modified": "2025-09-18T16:33:27Z", - "resolved": "github:NixOS/nixpkgs/f4b140d5b253f5e2a1ff4e5506edbf8267724bde#gren", + "gren@0.6.5": { + "last_modified": "2026-04-16T08:46:55Z", + "resolved": "github:NixOS/nixpkgs/b86751bc4085f48661017fa226dee99fab6c651b#gren", "source": "devbox-search", - "version": "0.6.3", + "version": "0.6.5", "systems": { "aarch64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/clr249d9d9rlqs3fsgj2lb4m97ph2hms-gren-0.6.3", + "path": "/nix/store/kijfkwvkxkfp8vpzj630q99k5cymdgx2-gren-0.6.5", "default": true } ], - "store_path": "/nix/store/clr249d9d9rlqs3fsgj2lb4m97ph2hms-gren-0.6.3" + "store_path": "/nix/store/kijfkwvkxkfp8vpzj630q99k5cymdgx2-gren-0.6.5" }, "aarch64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/zgx86f6s63l0ckj78psmkdkwqfxr3770-gren-0.6.3", + "path": "/nix/store/a2za3hav09hg1amcj225b913dyfjs2wp-gren-0.6.5", "default": true } ], - "store_path": "/nix/store/zgx86f6s63l0ckj78psmkdkwqfxr3770-gren-0.6.3" + "store_path": "/nix/store/a2za3hav09hg1amcj225b913dyfjs2wp-gren-0.6.5" }, "x86_64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/1gdj8kkrb5d945rklcxwqzl8s55kjpyx-gren-0.6.3", + "path": "/nix/store/0mrp2wghqzmlcqc6nrfpv9l7s1p3kmcg-gren-0.6.5", "default": true } ], - "store_path": "/nix/store/1gdj8kkrb5d945rklcxwqzl8s55kjpyx-gren-0.6.3" + "store_path": "/nix/store/0mrp2wghqzmlcqc6nrfpv9l7s1p3kmcg-gren-0.6.5" }, "x86_64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/xhc5c5vl8jxxm0jrr8vap34agz3f6yfi-gren-0.6.3", + "path": "/nix/store/3bc06xrds99f1cdhymrfyv8nwi43q8bp-gren-0.6.5", "default": true } ], - "store_path": "/nix/store/xhc5c5vl8jxxm0jrr8vap34agz3f6yfi-gren-0.6.3" + "store_path": "/nix/store/3bc06xrds99f1cdhymrfyv8nwi43q8bp-gren-0.6.5" } } }, @@ -296,68 +292,84 @@ } } }, - "nodejs@20": { - "last_modified": "2025-02-12T00:10:52Z", + "nodejs@24": { + "last_modified": "2025-12-29T16:45:58Z", "plugin_version": "0.0.2", - "resolved": "github:NixOS/nixpkgs/83a2581c81ff5b06f7c1a4e7cc736a455dfcf7b4#nodejs_20", + "resolved": "github:NixOS/nixpkgs/346dd96ad74dc4457a9db9de4f4f57dab2e5731d#nodejs_24", "source": "devbox-search", - "version": "20.18.3", + "version": "24.12.0", "systems": { "aarch64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/pba611q472k53hjk8kai3mqg5nqa0q4v-nodejs-20.18.3", + "path": "/nix/store/b1h0af3yb3z9jz048phclcqw6ihiww67-nodejs-24.12.0", "default": true }, + { + "name": "dev", + "path": "/nix/store/ksn4nj0nbp0ikmh4scy9x4biqqdswwhy-nodejs-24.12.0-dev" + }, { "name": "libv8", - "path": "/nix/store/giz92jzgk5j5x4rr87qfhh7h23vxw02s-nodejs-20.18.3-libv8" + "path": "/nix/store/3x72bm7l30ib7l7cximrp0sklcq8jqwy-nodejs-24.12.0-libv8" } ], - "store_path": "/nix/store/pba611q472k53hjk8kai3mqg5nqa0q4v-nodejs-20.18.3" + "store_path": "/nix/store/b1h0af3yb3z9jz048phclcqw6ihiww67-nodejs-24.12.0" }, "aarch64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/958yayymdnm342x6vldlfbjah51dy0d4-nodejs-20.18.3", + "path": "/nix/store/9jfsyhcr9c6rpm94lrkibv75jkggvwss-nodejs-24.12.0", "default": true }, + { + "name": "dev", + "path": "/nix/store/7izhv9x1yvsmyrhmmyps6bxjjclajm80-nodejs-24.12.0-dev" + }, { "name": "libv8", - "path": "/nix/store/wxl92b7ld4dfhskvmgvf9r7q9h86q2s6-nodejs-20.18.3-libv8" + "path": "/nix/store/8hl74pnc1qyz68rg10ixw7fz32hcxqkk-nodejs-24.12.0-libv8" } ], - "store_path": "/nix/store/958yayymdnm342x6vldlfbjah51dy0d4-nodejs-20.18.3" + "store_path": "/nix/store/9jfsyhcr9c6rpm94lrkibv75jkggvwss-nodejs-24.12.0" }, "x86_64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/3wrrj4ddsc8mrim3xnqwws8w2b3an4jv-nodejs-20.18.3", + "path": "/nix/store/cj1243xswxvnwgifyiyg8axhn0r2vl48-nodejs-24.12.0", "default": true }, + { + "name": "dev", + "path": "/nix/store/kzia3rkaf9vwpn3py23q9v49jpjm6zx7-nodejs-24.12.0-dev" + }, { "name": "libv8", - "path": "/nix/store/bw62bzsr6rgrygpk5g4vjpbbqwffn8sg-nodejs-20.18.3-libv8" + "path": "/nix/store/xszji81xj7ghjhz7jk0jkhb84nfwyd8f-nodejs-24.12.0-libv8" } ], - "store_path": "/nix/store/3wrrj4ddsc8mrim3xnqwws8w2b3an4jv-nodejs-20.18.3" + "store_path": "/nix/store/cj1243xswxvnwgifyiyg8axhn0r2vl48-nodejs-24.12.0" }, "x86_64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/1bmfcixbhs5fxv2zzyc45grx3xgc90j9-nodejs-20.18.3", + "path": "/nix/store/9z1v3wyrxp6fpyzw21lcakd5w7aknzyc-nodejs-24.12.0", "default": true }, { "name": "libv8", - "path": "/nix/store/03gwhdryzihhv729133pfmri9qjhnbr5-nodejs-20.18.3-libv8" + "path": "/nix/store/5yvx909wlis13qd2y1ahwl7ij21ma69a-nodejs-24.12.0-libv8" + }, + { + "name": "dev", + "path": "/nix/store/7ll99p08gmpg0n048669p80zjibl5akr-nodejs-24.12.0-dev" } ], - "store_path": "/nix/store/1bmfcixbhs5fxv2zzyc45grx3xgc90j9-nodejs-20.18.3" + "store_path": "/nix/store/9z1v3wyrxp6fpyzw21lcakd5w7aknzyc-nodejs-24.12.0" } } }, diff --git a/gren.json b/gren.json index 2a64fa2..582e016 100644 --- a/gren.json +++ b/gren.json @@ -4,13 +4,12 @@ "source-directories": [ "src" ], - "gren-version": "0.6.3", + "gren-version": "0.6.5", "dependencies": { "direct": { "blaix/gren-effectful-tests": "6.0.0", - "blaix/gren-ws4sql": "4.0.0", "gren-lang/core": "7.0.0", - "gren-lang/node": "6.0.0", + "gren-lang/node": "local:../node", "gren-lang/test": "5.0.0", "gren-lang/url": "6.0.0" }, diff --git a/gren_packages/blaix_gren_ws4sql__4_0_0.pkg.gz b/gren_packages/blaix_gren_ws4sql__4_0_0.pkg.gz deleted file mode 100644 index 8e67c51ec57371b3dade988258622d2ced200faa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6357 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 X!Ik}3_7%7Zx3~WXKSTm@zh?jd=%{LA diff --git a/gren_packages/gren_lang_node__6_0_0.pkg.gz b/gren_packages/gren_lang_node__6_0_0.pkg.gz deleted file mode 100644 index b043fe801faa05ac11a2163022d0cdddd373fa1f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33899 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 Py?_6I{{FhjgmnS{m4N#* diff --git a/process-compose.yml b/process-compose.yml index 6577b82..bf658ca 100644 --- a/process-compose.yml +++ b/process-compose.yml @@ -2,11 +2,5 @@ version: "0.5" processes: - db: - command: make db - ready_log_line: "Web Service listening" server: command: make server - depends_on: - db: - condition: process_log_ready diff --git a/src/Main.gren b/src/Main.gren index fcbbe2f..f518976 100644 --- a/src/Main.gren +++ b/src/Main.gren @@ -2,9 +2,9 @@ module Main exposing (main) import Bytes exposing (Bytes) import Crypto -import Db +import Sqlite import Dict -import Db.Encode +import FileSystem import HttpClient import HttpServer exposing (Request, ServerError(..), Method(..)) import HttpServer.Response as Response exposing (Response) @@ -18,8 +18,9 @@ import Route.Error import Route.PublishingIdentity import Route.Session import Stream +import Sqlite +import Sqlite.Encode import Task exposing (Task) -import Test.E2E.Helper as Helper import Time import Url import User exposing (User) @@ -48,7 +49,7 @@ type alias Model = { stdout : Stream.Writable Bytes , stderr : Stream.Writable Bytes , server : Maybe HttpServer.Server - , db : Db.Connection + , db : Maybe Sqlite.Database , postmark : Maybe Postmark.Configuration , secureContext : Maybe Crypto.SecureContext } @@ -58,11 +59,9 @@ init : Environment -> Init.Task { model : Model, command : Cmd Msg } init env = Init.await HttpServer.initialize <| \serverPermission -> Init.await HttpClient.initialize <| \httpPerm -> + Init.await FileSystem.initialize <| \fsPerm -> Init.awaitTask Node.getEnvironmentVariables <| \envVars -> let - db = - Helper.initDb httpPerm - postmark = envVars |> Dict.get "POSTMARK_SERVER_TOKEN" @@ -78,14 +77,23 @@ init env = { stdout = env.stdout , stderr = env.stderr , server = Nothing - , db = db + , db = Nothing , secureContext = Nothing , postmark = postmark } , command = + let + initDb = + Registry.Db.open fsPerm + |> Task.andThen + (\db -> + Registry.Db.migrate db + |> Task.map (\_ -> db) + ) + |> Task.attempt DbMigrationResult + in Cmd.batch - [ Registry.Db.migrate db - |> Task.attempt DbMigrationResult + [ initDb , HttpServer.createServer serverPermission serverConfig |> Task.attempt CreateServerResult , Crypto.getSecureContext @@ -99,7 +107,7 @@ init env = type Msg = CreateServerResult (Result HttpServer.ServerError HttpServer.Server) - | DbMigrationResult (Result Db.Error (Array Int)) + | DbMigrationResult (Result Sqlite.Error Sqlite.Database) | SecureContextResult (Result {} Crypto.SecureContext) | GotRequest { request : HttpServer.Request, response : Response } | ResponseReady Route.Response @@ -128,8 +136,8 @@ update msg model = DbMigrationResult result -> when result is - Ok numChanges -> - { model = model + Ok db -> + { model = { model | db = Just db } , command = Cmd.none } @@ -214,10 +222,12 @@ route model request response = config : { secureContext : Maybe Crypto.SecureContext , postmark : Maybe Postmark.Configuration + , db : Maybe Sqlite.Database } config = { secureContext = model.secureContext , postmark = model.postmark + , db = model.db } in when config is @@ -232,7 +242,10 @@ route model request response = { postmark = Nothing } -> Route.Error.serverError response "Missing postmark config." - { postmark = Just postmark, secureContext = Just secureContext } -> + { db = Nothing } -> + Route.Error.serverError response "DB failed to initialize." + + { postmark = Just postmark, secureContext = Just secureContext, db = Just db } -> when { method = request.method, path = path } is @@ -241,7 +254,7 @@ route model request response = { method = POST, path = [ "session" ] } -> Route.Session.create - { db = model.db + { db = db , postmark = postmark , secureContext = secureContext , body = request.body @@ -250,7 +263,7 @@ route model request response = { method = POST, path = [ "session", "fetch" ] } -> Route.Session.fetch - { db = model.db + { db = db , secureContext = secureContext , body = request.body , response = response @@ -262,7 +275,7 @@ route model request response = { method = POST, path = [ "publishing-identity" ] } -> Route.PublishingIdentity.create - { db = model.db + { db = db , body = request.body , response = response } diff --git a/src/PublishingIdentity.gren b/src/PublishingIdentity.gren index 224aff2..e68fc43 100644 --- a/src/PublishingIdentity.gren +++ b/src/PublishingIdentity.gren @@ -5,9 +5,9 @@ module PublishingIdentity exposing ) -import Db -import Db.Decode as Decode -import Db.Encode as Encode +import Sqlite +import Sqlite.Decode as Decode +import Sqlite.Encode as Encode import Task exposing (Task) import User exposing (User) @@ -20,13 +20,13 @@ type alias PublishingIdentity = create : - Db.Connection + Sqlite.Database -> { user : User, name : String } - -> Task Db.Error PublishingIdentity + -> Task Sqlite.Error PublishingIdentity create db { user, name } = let insertIdentity = - Db.execute db + Sqlite.execute db { statement = """ INSERT INTO publishing_identity (name) @@ -38,7 +38,7 @@ create db { user, name } = } getIdentityId = - Db.getOne db + Sqlite.getOne db { query = """ SELECT id FROM publishing_identity @@ -47,11 +47,11 @@ create db { user, name } = , parameters = [ Encode.string "name" name ] - , decoder = Decode.int "id" + , rowDecoder = Decode.int "id" Decode.succeed } insertUserLink identityId = - Db.execute db + Sqlite.execute db { statement = """ INSERT INTO publishing_identity_users (user_id, publishing_identity_id) @@ -77,11 +77,11 @@ create db { user, name } = get : - Db.Connection + Sqlite.Database -> { userId : Int, name : String } - -> Task Db.Error PublishingIdentity + -> Task Sqlite.Error PublishingIdentity get db { userId, name } = - Db.getOne db + Sqlite.getOne db { query = """ SELECT pi.id, pi.name, piu.user_id @@ -95,10 +95,10 @@ get db { userId, name } = [ Encode.int "user_id" userId , Encode.string "name" name ] - , decoder = - Decode.field Decode.int "id" <| \id -> - Decode.field Decode.int "user_id" <| \uid -> - Decode.field Decode.string "name" <| \n -> + , rowDecoder = + Decode.int "id" <| \id -> + Decode.int "user_id" <| \uid -> + Decode.string "name" <| \n -> Decode.succeed { id = id , userId = uid diff --git a/src/Registry/Db.gren b/src/Registry/Db.gren index bd97447..1a7889f 100644 --- a/src/Registry/Db.gren +++ b/src/Registry/Db.gren @@ -1,74 +1,88 @@ module Registry.Db exposing ( migrate + , open ) -import Db +import FileSystem +import FileSystem.Path as Path +import Sqlite import Task exposing (Task) -migrate : Db.Connection -> Task Db.Error (Array Int) +open : FileSystem.Permission -> Task Sqlite.Error Sqlite.Database +open fsPerm = + let + -- TODO: get db path from environment + dbPath = + "./db/registry.db" + in + Sqlite.open fsPerm Sqlite.defaultOptions (Sqlite.File (Path.fromPosixString dbPath)) + + +migrate : Sqlite.Database -> Task Sqlite.Error (Array Sqlite.ExecutionSummary) migrate db = - Db.transaction db - [ { statement = "PRAGMA foreign_keys = ON" - , parameters = [] - } + [ { statement = "PRAGMA foreign_keys = ON" + , parameters = [] + } - , { statement = "PRAGMA busy_timeout = 5000" - , parameters = [] - } + , { statement = "PRAGMA busy_timeout = 5000" + , parameters = [] + } - , { statement = - """ - CREATE TABLE IF NOT EXISTS user ( - id INTEGER PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - created INTEGER NOT NULL -- timestamp - ) STRICT - """ - , parameters = [] - } + , { statement = + """ + CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + created INTEGER NOT NULL -- timestamp + ) STRICT + """ + , parameters = [] + } - , { statement = - """ - CREATE TABLE IF NOT EXISTS session ( - id INTEGER PRIMARY KEY, - created INTEGER NOT NULL, - user_id INTEGER NOT NULL REFERENCES user(id), + , { statement = + """ + CREATE TABLE IF NOT EXISTS session ( + id INTEGER PRIMARY KEY, + created INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE, - -- Code emailed to user for email confirmation. - -- User will provide this to the CLI. - email_confirmation_code TEXT NOT NULL UNIQUE, + -- Code emailed to user for email confirmation. + -- User will provide this to the CLI. + email_confirmation_code TEXT NOT NULL UNIQUE, - -- Token used by the CLI to fetch session token. - fetch_session_token TEXT NOT NULL UNIQUE, + -- Token used by the CLI to fetch session token. + fetch_session_token TEXT NOT NULL UNIQUE, - -- Create and return this when CLI posts with email_confirmation_code and fetch_session_token - token TEXT UNIQUE - ) STRICT - """ - , parameters = [] - } + -- Create and return this when CLI posts with email_confirmation_code and fetch_session_token + token TEXT UNIQUE + ) STRICT + """ + , parameters = [] + } - , { statement = - """ - CREATE TABLE IF NOT EXISTS publishing_identity ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE - ) STRICT - """ - , parameters = [] - } + , { statement = + """ + CREATE TABLE IF NOT EXISTS publishing_identity ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE + ) STRICT + """ + , parameters = [] + } - , { statement = - """ - CREATE TABLE IF NOT EXISTS publishing_identity_users ( - user_id INTEGER NOT NULL REFERENCES user(id), - publishing_identity_id INTEGER NOT NULL REFERENCES publishing_identity(id), - role TEXT, - UNIQUE(user_id, publishing_identity_id) - ) STRICT - """ - , parameters = [] - } - ] + , { statement = + """ + CREATE TABLE IF NOT EXISTS publishing_identity_users ( + user_id INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE, + publishing_identity_id INTEGER NOT NULL REFERENCES publishing_identity(id) ON DELETE CASCADE, + role TEXT, + UNIQUE(user_id, publishing_identity_id) + ) STRICT + """ + , parameters = [] + } + ] + |> Sqlite.executeAll db + |> Sqlite.inTransaction db diff --git a/src/Route/PublishingIdentity.gren b/src/Route/PublishingIdentity.gren index dbd2389..3ab9e0b 100644 --- a/src/Route/PublishingIdentity.gren +++ b/src/Route/PublishingIdentity.gren @@ -4,7 +4,7 @@ module Route.PublishingIdentity exposing import Bytes exposing (Bytes) -import Db +import Sqlite import Json.Decode import PublishingIdentity import Route exposing (Response) @@ -17,7 +17,7 @@ import User exposing (User) create : - { db : Db.Connection + { db : Sqlite.Database , body : Bytes , response : Response } diff --git a/src/Route/Session.gren b/src/Route/Session.gren index 36469be..10b6c88 100644 --- a/src/Route/Session.gren +++ b/src/Route/Session.gren @@ -6,7 +6,7 @@ module Route.Session exposing import Bytes exposing (Bytes) import Crypto -import Db +import Sqlite import Email exposing (Email) import HttpClient import HttpServer exposing (Request) @@ -22,7 +22,7 @@ import User exposing (User) type Error - = DbError Db.Error + = DbError Sqlite.Error | SendEmailFailed HttpClient.Error @@ -35,7 +35,7 @@ This will email a confirmation code and return a fetch session token. Both must be POSTED to `fetch` to get a long lived session token. -} create : - { db : Db.Connection + { db : Sqlite.Database , postmark : Postmark.Configuration , secureContext : Crypto.SecureContext , body : Bytes @@ -62,7 +62,7 @@ POST emailConfirmationCode and fetchSessionToken from the `create` call to get a long-lived session token you can store to make authenticated API requests. -} fetch : - { db : Db.Connection + { db : Sqlite.Database , secureContext : Crypto.SecureContext , body : Bytes , response : Response @@ -140,13 +140,13 @@ fetchRequestDecoder = -- ACTIONS -findOrCreateUser : Db.Connection -> Email -> Task Error User +findOrCreateUser : Sqlite.Database -> Email -> Task Error User findOrCreateUser db email = User.findOrCreate db email |> Task.mapError DbError -createSession : Db.Connection -> Crypto.SecureContext -> User -> Task Error Session +createSession : Sqlite.Database -> Crypto.SecureContext -> User -> Task Error Session createSession db secureContext user = Session.create { db = db, secureContext = secureContext, user = user } |> Task.mapError DbError @@ -189,7 +189,7 @@ createFailed response error = let errorMsg = "Database error creating session: " ++ - Db.errorToString e + Sqlite.errorToString e in response |> Route.log (Log.Error errorMsg) @@ -229,7 +229,7 @@ fetchSuccess response sessionToken = fetchFailed : Response -> Error -> Task x Response fetchFailed response error = when error is - DbError Db.NoResultError -> + DbError Sqlite.NoResultsError -> response |> Route.setStatus 400 |> setErrorMessage "Invalid token or confirmation code" @@ -239,7 +239,7 @@ fetchFailed response error = let errorMsg = "Error fetching session from DB: " ++ - Db.errorToString e + Sqlite.errorToString e in response |> Route.log (Log.Error errorMsg) diff --git a/src/Session.gren b/src/Session.gren index 543f821..2a39efb 100644 --- a/src/Session.gren +++ b/src/Session.gren @@ -7,9 +7,9 @@ module Session exposing import Bytes.Decode import Crypto -import Db -import Db.Encode -import Db.Decode +import Sqlite +import Sqlite.Encode as Encode +import Sqlite.Decode as Decode import Email import Math import Task exposing (Task) @@ -32,11 +32,11 @@ type alias Session = create : - { db : Db.Connection + { db : Sqlite.Database , secureContext : Crypto.SecureContext , user : User } - -> Task Db.Error Session + -> Task Sqlite.Error Session create { db, user, secureContext } = Task.await (generateEmailConfirmationCode secureContext) <| \code -> Task.await (Crypto.randomUuidV4 secureContext) <| \uuid2 -> @@ -75,16 +75,16 @@ step. Creates and returns the long-lived session token. To keep fetchSessionToken single-use, this will error if session token already exists. -} fetch : - { db : Db.Connection + { db : Sqlite.Database , secureContext : Crypto.SecureContext , fetchSessionToken : String , emailConfirmationCode : String } - -> Task Db.Error String + -> Task Sqlite.Error String fetch { db, secureContext, fetchSessionToken, emailConfirmationCode } = let getSession cutoff = - Db.getOne db + Sqlite.getOne db { query = """ select id from session @@ -94,16 +94,16 @@ fetch { db, secureContext, fetchSessionToken, emailConfirmationCode } = and token is null """ , parameters = - [ Db.Encode.string "fetch_session_token" fetchSessionToken - , Db.Encode.string "email_confirmation_code" emailConfirmationCode - , Db.Encode.posix "cutoff" cutoff + [ Encode.string "fetch_session_token" fetchSessionToken + , Encode.string "email_confirmation_code" emailConfirmationCode + , Encode.time "cutoff" cutoff ] - , decoder = - Db.Decode.int "id" + , rowDecoder = + Decode.int "id" Decode.succeed } setSessionToken id sessionToken = - Db.execute db + Sqlite.execute db { statement = """ update session @@ -111,8 +111,8 @@ fetch { db, secureContext, fetchSessionToken, emailConfirmationCode } = where id = :id """ , parameters = - [ Db.Encode.string "token" sessionToken - , Db.Encode.int "id" id + [ Encode.string "token" sessionToken + , Encode.int "id" id ] } in @@ -131,19 +131,19 @@ fetch { db, secureContext, fetchSessionToken, emailConfirmationCode } = -- DB HELPERS -dbInsert : Db.Connection -> Session -> Task Db.Error Session +dbInsert : Sqlite.Database -> Session -> Task Sqlite.Error Session dbInsert db session = Task.map (\_ -> session) <| - Db.execute db + Sqlite.execute db { statement = """ insert into session (created, user_id, email_confirmation_code, fetch_session_token) values (:created, :user_id, :email_confirmation_code, :fetch_session_token) """ , parameters = - [ Db.Encode.posix "created" session.created - , Db.Encode.int "user_id" session.user.id - , Db.Encode.string "email_confirmation_code" session.emailConfirmationCode - , Db.Encode.string "fetch_session_token" session.fetchSessionToken + [ Encode.time "created" session.created + , Encode.int "user_id" session.user.id + , Encode.string "email_confirmation_code" session.emailConfirmationCode + , Encode.string "fetch_session_token" session.fetchSessionToken ] } diff --git a/src/Test/E2E.gren b/src/Test/E2E.gren index 2d3ca05..fbe384b 100644 --- a/src/Test/E2E.gren +++ b/src/Test/E2E.gren @@ -1,14 +1,13 @@ module Test.E2E exposing (tests) import Crypto -import Db +import Sqlite import Dict import Expect import HttpClient -import Node import Task import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe, test) -import Test.E2E.Helper exposing (get, expectBadStatus, initDb) +import Test.E2E.Helper exposing (get, expectBadStatus) import Test.E2E.Route.Session import Test.E2E.Route.PublishingIdentity import Test.E2E.Postmark @@ -17,13 +16,13 @@ import Test.E2E.Session import Test.E2E.User -tests : HttpClient.Permission -> Array Test -tests httpPerm = +tests : + { httpPerm : HttpClient.Permission + , db : Sqlite.Database + } + -> Array Test +tests { httpPerm, db } = let - db : Db.Connection - db = - initDb httpPerm - postmark = -- https://postmarkapp.com/support/article/1213-best-practices-for-testing-your-emails-through-postmark { apiToken = "POSTMARK_API_TEST" diff --git a/src/Test/E2E/Helper.gren b/src/Test/E2E/Helper.gren index 68f8819..d40c1bd 100644 --- a/src/Test/E2E/Helper.gren +++ b/src/Test/E2E/Helper.gren @@ -3,7 +3,6 @@ module Test.E2E.Helper exposing , expireFetchToken , expectBadStatus , expectJson - , initDb , get , post , postWithJson @@ -12,10 +11,11 @@ module Test.E2E.Helper exposing import Bytes exposing (Bytes) import Crypto -import Db -import Db.Encode +import Sqlite +import Sqlite.Encode import Email import Expect exposing (Expectation) +import FileSystem import HttpClient exposing (Response) import Json.Decode import Json.Encode @@ -30,12 +30,6 @@ url path = "http://localhost:3000" ++ path -initDb : HttpClient.Permission -> Db.Connection -initDb httpPerm = - -- TODO: get db url from env - Db.init httpPerm "http://localhost:12321/local" - - get : HttpClient.Permission -> String -> Task HttpClient.Error (HttpClient.Response {}) get httpPerm path = url path @@ -87,7 +81,7 @@ expectJson decoder expected bytes = Expect.fail (Json.Decode.errorToString error) -createSession : Db.Connection -> Crypto.SecureContext -> Task Db.Error Session +createSession : Sqlite.Database -> Crypto.SecureContext -> Task Sqlite.Error Session createSession db secureContext = User.findOrCreate db Email.example |> Task.andThen @@ -100,7 +94,7 @@ createSession db secureContext = ) -expireFetchToken : Db.Connection -> Session -> Task Db.Error {} +expireFetchToken : Sqlite.Database -> Session -> Task Sqlite.Error {} expireFetchToken db session = Task.await Time.now <| \now -> let @@ -108,7 +102,7 @@ expireFetchToken db session = (Time.posixToMillis now) - (16 * 60 * 1000) |> Time.millisToPosix in - Db.execute db + Sqlite.execute db { statement = """ update session @@ -116,8 +110,8 @@ expireFetchToken db session = where fetch_session_token = :fetch_session_token """ , parameters = - [ Db.Encode.posix "created" sixteenMinutesAgo - , Db.Encode.string "fetch_session_token" session.fetchSessionToken + [ Sqlite.Encode.time "created" sixteenMinutesAgo + , Sqlite.Encode.string "fetch_session_token" session.fetchSessionToken ] } |> Task.map (\_ -> {}) diff --git a/src/Test/E2E/PublishingIdentity.gren b/src/Test/E2E/PublishingIdentity.gren index 714272e..b5326c6 100644 --- a/src/Test/E2E/PublishingIdentity.gren +++ b/src/Test/E2E/PublishingIdentity.gren @@ -1,7 +1,7 @@ module Test.E2E.PublishingIdentity exposing (tests) -import Db +import Sqlite import Email import Expect import PublishingIdentity @@ -10,19 +10,19 @@ import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe import User exposing (User) -tests : Db.Connection -> Array Test +tests : Sqlite.Database -> Array Test tests db = let identityName = "duplicate-name-test" - cleanPublishingIdentity : Task Db.Error Int + cleanPublishingIdentity : Task Sqlite.Error (Array Sqlite.ExecutionSummary) cleanPublishingIdentity = - Db.transaction db - [ { statement = "DELETE FROM publishing_identity_users", parameters = [] } - , { statement = "DELETE FROM publishing_identity", parameters = [] } - ] - |> Task.map (\_ -> 0) + [ { statement = "DELETE FROM publishing_identity_users", parameters = [] } + , { statement = "DELETE FROM publishing_identity", parameters = [] } + ] + |> Sqlite.executeAll db + |> Sqlite.inTransaction db in [ describe "PublishingIdentity.create" [ await "clean publishing identity tables" cleanPublishingIdentity <| \_ -> @@ -33,25 +33,27 @@ tests db = [ awaitError "create identity with same name and same user" (PublishingIdentity.create db { user = user1, name = identityName }) <| \error -> test "fails when name already exists for same user" <| \_ -> - expectUniqueConstraintError error + expectUniqueConstraintErrorFor "name" error , awaitError "create identity with same name and different user" (PublishingIdentity.create db { user = user2, name = identityName }) <| \error -> test "fails when name already exists for different user" <| \_ -> - expectUniqueConstraintError error + expectUniqueConstraintErrorFor "name" error ] ] ] -expectUniqueConstraintError : Db.Error -> Expect.Expectation -expectUniqueConstraintError error = +expectUniqueConstraintErrorFor : String -> Sqlite.Error -> Expect.Expectation +expectUniqueConstraintErrorFor field error = when error is - Db.Error message -> - if String.contains "unique" (String.toLower message) then + Sqlite.UniqueConstraintError message -> + if String.contains field (String.toLower message) then Expect.pass else - Expect.fail ("Expected unique constraint error, got: " ++ message) + Expect.fail <| + "Expected unique constraint error for " ++ + field ++ ", got: " ++ message - _ -> - Expect.fail ("Expected Db.Error with unique constraint message, got: " ++ Db.errorToString error) + e -> + Expect.fail ("Expected unique constraint error, got: " ++ Sqlite.errorToString error) diff --git a/src/Test/E2E/Route/PublishingIdentity.gren b/src/Test/E2E/Route/PublishingIdentity.gren index ebe8e14..21fe5d1 100644 --- a/src/Test/E2E/Route/PublishingIdentity.gren +++ b/src/Test/E2E/Route/PublishingIdentity.gren @@ -3,9 +3,9 @@ module Test.E2E.Route.PublishingIdentity exposing (tests) import Crypto import Bytes exposing (Bytes) -import Db -import Db.Encode -import Db.Decode +import Sqlite +import Sqlite.Encode +import Sqlite.Decode import Expect import HttpClient import Json.Decode @@ -14,22 +14,22 @@ import PublishingIdentity exposing (PublishingIdentity) import Session exposing (Session) import Task exposing (Task) import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe, test, todo) -import Test.E2E.Helper exposing (createSession, initDb, get, post, postWithJson, expectBadStatus, expectJson) +import Test.E2E.Helper exposing (createSession, get, post, postWithJson, expectBadStatus, expectJson) import User exposing (User) -tests : Db.Connection -> HttpClient.Permission -> Crypto.SecureContext -> Array Test +tests : Sqlite.Database -> HttpClient.Permission -> Crypto.SecureContext -> Array Test tests db httpPerm secureContext = let - cleanPublishingIdentity : Task Db.Error Int + cleanPublishingIdentity : Task Sqlite.Error (Array Sqlite.ExecutionSummary) cleanPublishingIdentity = - Db.transaction db - [ { statement = "DELETE FROM publishing_identity_users", parameters = [] } - , { statement = "DELETE FROM publishing_identity", parameters = [] } - ] - |> Task.map (\_ -> 0) + [ { statement = "DELETE FROM publishing_identity_users", parameters = [] } + , { statement = "DELETE FROM publishing_identity", parameters = [] } + ] + |> Sqlite.executeAll db + |> Sqlite.inTransaction db - fetchSessionToken : Session -> Task Db.Error String + fetchSessionToken : Session -> Task Sqlite.Error String fetchSessionToken session = Session.fetch { db = db @@ -66,7 +66,7 @@ tests db httpPerm secureContext = -- GET /publishing-identity (404) - [ awaitError "GET /publishing-identity" + [ awaitError "GET /publishing-identity" (get httpPerm "/publishing-identity") <| \response -> test "404s" <| \_ -> expectBadStatus 404 response @@ -75,7 +75,7 @@ tests db httpPerm secureContext = -- POST /publishing-identity (create) - , awaitError "POST /publishing-identity with no session token" + , awaitError "POST /publishing-identity with no session token" (doPost { sessionToken = Nothing, name = "my-publishing-identity" }) <| \response -> test "Responds with 400 error" <| \_ -> expectBadStatus 400 response diff --git a/src/Test/E2E/Route/Session.gren b/src/Test/E2E/Route/Session.gren index 5ab6043..875af26 100644 --- a/src/Test/E2E/Route/Session.gren +++ b/src/Test/E2E/Route/Session.gren @@ -3,9 +3,9 @@ module Test.E2E.Route.Session exposing (tests) import Bytes import Crypto -import Db -import Db.Encode -import Db.Decode +import Sqlite +import Sqlite.Encode +import Sqlite.Decode import Email import Expect import HttpClient @@ -13,11 +13,11 @@ import Json.Decode import Json.Encode import Task exposing (Task) import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe, test, todo) -import Test.E2E.Helper exposing (expireFetchToken, createSession, initDb, get, post, postWithJson, expectBadStatus, expectJson) +import Test.E2E.Helper exposing (expireFetchToken, createSession, get, post, postWithJson, expectBadStatus, expectJson) import User exposing (User) -tests : Db.Connection -> HttpClient.Permission -> Crypto.SecureContext -> Array Test +tests : Sqlite.Database -> HttpClient.Permission -> Crypto.SecureContext -> Array Test tests db httpPerm secureContext = @@ -62,7 +62,7 @@ tests db httpPerm secureContext = Expect.equal 200 response.statusCode , await "Getting session info from db" - (Db.getOne db + (Sqlite.getOne db { query = """ select fetch_session_token from session @@ -72,9 +72,11 @@ tests db httpPerm secureContext = limit 1 """ , parameters = - [ Db.Encode.string "email" goodEmail ] - , decoder = - Db.Decode.string "fetch_session_token" + [ Sqlite.Encode.string "email" goodEmail ] + , rowDecoder = + Sqlite.Decode.string + "fetch_session_token" + Sqlite.Decode.succeed } ) (\expectedToken -> @@ -108,7 +110,7 @@ tests db httpPerm secureContext = ] in [ awaitError "with bad token and code" - (doPost { token = "", code = "" }) <| \response -> + (doPost { token = "", code = "" }) <| \response -> test "Responds with 400 error" <| \_ -> expectBadStatus 400 response @@ -129,16 +131,16 @@ tests db httpPerm secureContext = Expect.equal 200 response.statusCode , await "Getting session token from db" - (Db.getOne db + (Sqlite.getOne db { query = """ select token from session where fetch_session_token = :fetch_session_token """ , parameters = - [ Db.Encode.string "fetch_session_token" session.fetchSessionToken ] - , decoder = - Db.Decode.string "token" + [ Sqlite.Encode.string "fetch_session_token" session.fetchSessionToken ] + , rowDecoder = + Sqlite.Decode.string "token" Sqlite.Decode.succeed } ) (\expectedToken -> diff --git a/src/Test/E2E/Session.gren b/src/Test/E2E/Session.gren index 103dc9f..e21329f 100644 --- a/src/Test/E2E/Session.gren +++ b/src/Test/E2E/Session.gren @@ -2,9 +2,9 @@ module Test.E2E.Session exposing (tests) import Crypto -import Db -import Db.Encode -import Db.Decode +import Sqlite +import Sqlite.Encode as Encode +import Sqlite.Decode as Decode import Email import Expect import Fuzz @@ -14,7 +14,7 @@ import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe import User exposing (User) -tests : Db.Connection -> Crypto.SecureContext -> Array Test +tests : Sqlite.Database -> Crypto.SecureContext -> Array Test tests db secureContext = let sessionSampleSize = @@ -30,10 +30,10 @@ tests db secureContext = } countSessions user = - Db.getOne db + Sqlite.getOne db { query = "select count(*) as count from session where user_id = :user_id" - , parameters = [ Db.Encode.int "user_id" user.id ] - , decoder = Db.Decode.int "count" + , parameters = [ Encode.int "user_id" user.id ] + , rowDecoder = Decode.int "count" Decode.succeed } ambiguousChars = diff --git a/src/Test/E2E/User.gren b/src/Test/E2E/User.gren index 364350b..c074203 100644 --- a/src/Test/E2E/User.gren +++ b/src/Test/E2E/User.gren @@ -1,36 +1,35 @@ module Test.E2E.User exposing (tests) -import Db -import Db.Encode -import Db.Decode +import Sqlite +import Sqlite.Encode as Encode +import Sqlite.Decode as Decode import Email import Expect import Task exposing (Task) import Test.Runner.Effectful exposing (Test, await, concat, describe, test) -import Test.E2E.Helper exposing (initDb) import User exposing (User) -tests : Db.Connection -> Array Test +tests : Sqlite.Database -> Array Test tests db = let - dropUsers : Task Db.Error Int + dropUsers : Task Sqlite.Error Sqlite.ExecutionSummary dropUsers = - Db.execute db + Sqlite.execute db { statement = "delete from user" , parameters = [] } - countUsers : Task Db.Error Int + countUsers : Task Sqlite.Error Int countUsers = - Db.getOne db + Sqlite.getOne db { query = "select count(*) as userCount from user where email = :email" - , parameters = [ Db.Encode.string "email" (Email.toString Email.example) ] - , decoder = Db.Decode.int "userCount" + , parameters = [ Encode.string "email" (Email.toString Email.example) ] + , rowDecoder = Decode.int "userCount" Decode.succeed } - findOrCreateUser : Task Db.Error User + findOrCreateUser : Task Sqlite.Error User findOrCreateUser = User.findOrCreate db Email.example in diff --git a/src/Test/Main.gren b/src/Test/Main.gren index 1504fbc..a76ed70 100644 --- a/src/Test/Main.gren +++ b/src/Test/Main.gren @@ -1,13 +1,14 @@ module Test.Main exposing (main) import Expect +import FileSystem import HttpClient import Init import Node -import Registry.Db +import Registry.Db as Db +import Task import Test import Test.E2E -import Test.E2E.Helper as Helper import Test.Runner.Effectful as Effectful exposing (await) import Test.Unit @@ -15,13 +16,15 @@ main : Effectful.Program a main = Node.defineSimpleProgram <| \env -> Init.await HttpClient.initialize <| \httpPerm -> + Init.await FileSystem.initialize <| \fsPerm -> Effectful.run env <| - let - migrate = - Registry.Db.migrate (Helper.initDb httpPerm) - in Effectful.concat [ Effectful.wrap <| Test.describe "Unit tests" Test.Unit.tests - , await "Run migrations for E2E tests" migrate <| \_ -> - Effectful.describe "E2E tests" (Test.E2E.tests httpPerm) + , await "Init DB for E2E tests" (Db.open fsPerm) <| \db -> + await "Run migrations for E2E tests" (Db.migrate db) <| \_ -> + Effectful.describe "E2E tests" <| + Test.E2E.tests + { httpPerm = httpPerm + , db = db + } ] diff --git a/src/User.gren b/src/User.gren index d411297..41c5aa3 100644 --- a/src/User.gren +++ b/src/User.gren @@ -6,9 +6,9 @@ module User exposing ) -import Db -import Db.Decode as Decode exposing (Decoder) -import Db.Encode as Encode +import Sqlite +import Sqlite.Decode as Decode exposing (Decoder) +import Sqlite.Encode as Encode import Email exposing (Email) import Task exposing (Task) import Time @@ -23,14 +23,14 @@ type alias User = decoder : Decoder User decoder = - Decode.field Decode.string "email" <| \email -> + Decode.string "email" <| \email -> when (Email.fromString email) is Nothing -> Decode.fail ("Invalid email: " ++ email) Just e -> - Decode.field Decode.int "id" <| \id -> - Decode.field Decode.posix "created" <| \created -> + Decode.int "id" <| \id -> + Decode.time "created" <| \created -> Decode.succeed { id = id , created = created @@ -38,9 +38,9 @@ decoder = } -findBySessionToken : Db.Connection -> String -> Task Db.Error User +findBySessionToken : Sqlite.Database -> String -> Task Sqlite.Error User findBySessionToken db sessionToken = - Db.getOne db + Sqlite.getOne db { query = """ SELECT user.id, user.email, user.created @@ -49,11 +49,11 @@ findBySessionToken db sessionToken = WHERE session.token = :token """ , parameters = [ Encode.string "token" sessionToken ] - , decoder = decoder + , rowDecoder = decoder } -findOrCreate : Db.Connection -> Email -> Task Db.Error User +findOrCreate : Sqlite.Database -> Email -> Task Sqlite.Error User findOrCreate db email = let encodedEmail = @@ -61,19 +61,19 @@ findOrCreate db email = createUser = Task.await Time.now <| \now -> - Db.execute db + Sqlite.execute db { statement = "INSERT OR IGNORE INTO user (created, email) VALUES (:created, :email)" , parameters = - [ Encode.posix "created" now + [ Encode.time "created" now , encodedEmail ] } getUser = - Db.getOne db + Sqlite.getOne db { query = "SELECT * FROM USER WHERE email = :email" , parameters = [ encodedEmail ] - , decoder = decoder + , rowDecoder = decoder } in createUser |> Task.andThen (\_ -> getUser) From c8b6ffd1c6c3f2274c806f8549da5ea2aaa157b7 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Sat, 16 May 2026 06:53:09 -0400 Subject: [PATCH 2/2] remove redundant sql from migrations these are already set by Sqlite.defaultOptions --- src/Registry/Db.gren | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Registry/Db.gren b/src/Registry/Db.gren index 1a7889f..4890f0d 100644 --- a/src/Registry/Db.gren +++ b/src/Registry/Db.gren @@ -22,15 +22,7 @@ open fsPerm = migrate : Sqlite.Database -> Task Sqlite.Error (Array Sqlite.ExecutionSummary) migrate db = - [ { statement = "PRAGMA foreign_keys = ON" - , parameters = [] - } - - , { statement = "PRAGMA busy_timeout = 5000" - , parameters = [] - } - - , { statement = + [ { statement = """ CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY,