feat(nim): TodoMVC in Nim with asynchttpserver and static SQLite

Server-rendered TodoMVC that works without JavaScript, with a small ES
module that posts the same forms and swaps in server-rendered fragments.
Static linux-arm64 build through zig cc (musl).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev df803f7fd2e2f9c2d4947ed78f9650d6212d42f6 parent 1e00cbb
12 files changed, +1098 −0
  • apps/nim/AGENTS.md +35 −0
  • apps/nim/DEPS +2 −0
  • apps/nim/Makefile +70 −0
  • apps/nim/README.md +92 −0
  • apps/nim/src/app.nim +236 −0
  • apps/nim/src/page.nimf +75 −0
  • apps/nim/src/store.nim +208 −0
  • apps/nim/src/views.nim +30 −0
  • apps/nim/static/app.css +62 −0
  • apps/nim/static/app.js +94 −0
  • apps/nim/tests/test_app.nim +181 −0
  • apps/nim/todo.nimble +13 −0
diff --git a/apps/nim/AGENTS.md b/apps/nim/AGENTS.md
new file mode 100644
index 0000000..467cb44
--- /dev/null
+++ b/apps/nim/AGENTS.md
@@ -0,0 +1,35 @@
+# 🤖 AGENTS.md: todo-nim
+
+The bakeoff's TodoMVC, written in Nim, deployed to Carloku as `todo-nim`
+on account `bfj` (`https://todo-nim.bfj.oncarlos.com`).
+
+## The one rule
+
+**The page works with JavaScript off.** Every action is a plain HTML form
+that posts and gets a 303 back. JavaScript only makes the same requests
+without a page load and swaps in the HTML the server renders. If a change
+needs JavaScript to function, it's the wrong change.
+
+## Tenancy
+
+- **One instance for the whole app.** Every visitor shares one process
+ and one SQLite file.
+- **Each visitor's list is kept separately by an anonymous cookie.** The
+ first visit gets a random 128-bit value in an `HttpOnly; SameSite=Lax`
+ cookie (`Secure` on Carloku). Every query is scoped to that list, and an
+ id from someone else's list answers 404. A cookie the app doesn't
+ recognise gets a fresh, empty list.
+- **No sign-in.** Losing the cookie means losing the list.
+- **Trust class C.** Todo titles are stored as plain text in SQLite, not
+ encrypted. That's a deliberate choice: the data is low-value, has no
+ owner identity attached, and a todo list doesn't justify key handling.
+
+## Working here
+
+- Source is in `src/`. `make test` runs the unit tests, `make build` the
+ local binary; the README has the rest.
+- The CSS is `shared/base.css` and `shared/index.css`, embedded at compile
+ time and served byte for byte. Don't copy or edit them here.
+- Migrations only add. Append a new entry to `migrations` in
+ `src/store.nim`; never edit or remove one that has shipped.
+- Bind `127.0.0.1` only. Scratch goes in `$TMPDIR`.
diff --git a/apps/nim/DEPS b/apps/nim/DEPS
new file mode 100644
index 0000000..e221fdb
--- /dev/null
+++ b/apps/nim/DEPS
@@ -0,0 +1,2 @@
+db_connector
+sqlite
diff --git a/apps/nim/Makefile b/apps/nim/Makefile
new file mode 100644
index 0000000..00dca3a
--- /dev/null
+++ b/apps/nim/Makefile
@@ -0,0 +1,70 @@
+# TodoMVC in Nim. See README.md.
+SHELL := bash
+REPO := $(abspath ../..)
+ZIGCC := $(REPO)/scripts/zigcc-aarch64-musl
+CACHE := $(CURDIR)/.cache
+
+VERSION ?= $(shell git rev-parse --short HEAD)
+SQLITE := sqlite-amalgamation-3540100
+SQLITE_URL := https://sqlite.org/2026/$(SQLITE).zip
+SQLITE_SHA256 := 0a9c8e34352dad46006aa82836d5383bff9dc6bc9ce5a60553c05aa30e6ad1f9
+SQLITE_C := $(CACHE)/sqlite/sqlite3.c
+NIMBLE_DIR := $(CACHE)/nimble
+DEPS_STAMP := $(NIMBLE_DIR)/.installed
+
+# Every library is compiled from source into a nimcache under .cache/.
+NIMFLAGS := --hints:off -d:release --opt:speed \
+ --noNimblePath $$(for p in $(NIMBLE_DIR)/pkgs2/*/; do echo --path:$$p; done) \
+ --dynlibOverride:sqlite3 -d:sqliteSource=$(SQLITE_C) \
+ -d:appVersion=$(VERSION)
+ARM64FLAGS := --cpu:arm64 --os:linux --cc:clang \
+ --clang.exe=$(ZIGCC) --clang.linkerexe=$(ZIGCC) --passL:-static --passL:-s
+
+SOURCES := $(wildcard src/*.nim src/*.nimf static/*) $(REPO)/shared/base.css $(REPO)/shared/index.css
+
+.PHONY: build build-arm64 test clean clean-deps deps bump unbump deploy
+
+build: dist/app
+build-arm64: dist/app-linux-arm64
+deps: $(DEPS_STAMP) $(SQLITE_C)
+
+dist/app: $(SOURCES) deps .FORCE
+ nim c $(NIMFLAGS) --nimcache:$(CACHE)/nimcache-host -o:$@ src/app.nim
+
+dist/app-linux-arm64: $(SOURCES) deps .FORCE
+ nim c $(NIMFLAGS) $(ARM64FLAGS) --nimcache:$(CACHE)/nimcache-arm64 -o:$@ src/app.nim
+
+test: deps
+ nim c -r $(NIMFLAGS) -d:testing --nimcache:$(CACHE)/nimcache-test -o:$(CACHE)/test_app tests/test_app.nim
+
+$(DEPS_STAMP): todo.nimble
+ source $(REPO)/scripts/env.sh && nimble --nimbleDir:$(NIMBLE_DIR) install --depsOnly -y
+ touch $@
+
+$(SQLITE_C):
+ mkdir -p $(CACHE)/dl $(CACHE)/sqlite
+ [ -f $(CACHE)/dl/$(SQLITE).zip ] || curl -sSfL -o $(CACHE)/dl/$(SQLITE).zip $(SQLITE_URL)
+ echo "$(SQLITE_SHA256) $(CACHE)/dl/$(SQLITE).zip" | sha256sum -c --quiet
+ unzip -q -o -j $(CACHE)/dl/$(SQLITE).zip '$(SQLITE)/sqlite3.c' '$(SQLITE)/sqlite3.h' -d $(CACHE)/sqlite
+ touch $@
+
+clean:
+ rm -rf dist
+
+# Compiled output only: downloaded sources (.cache/nimble, .cache/dl,
+# .cache/sqlite) stay, so the next build compiles every library again.
+clean-deps: clean
+ rm -rf $(CACHE)/nimcache-host $(CACHE)/nimcache-arm64 $(CACHE)/nimcache-test $(CACHE)/test_app
+
+bump:
+ sed -i 's/serverName = "todo-nim"/serverName = "todo-nim bumped"/' src/app.nim
+
+unbump:
+ sed -i 's/serverName = "todo-nim bumped"/serverName = "todo-nim"/' src/app.nim
+
+deploy:
+ source $(REPO)/scripts/env.sh && carlos deploy --account bfj --app todo-nim \
+ --target linux-arm64 --no-prompt --message "$${MESSAGE:-Deploy todo-nim $(VERSION)}" \
+ dist/app-linux-arm64
+
+.FORCE:
diff --git a/apps/nim/README.md b/apps/nim/README.md
new file mode 100644
index 0000000..634a667
--- /dev/null
+++ b/apps/nim/README.md
@@ -0,0 +1,92 @@
+# 🤖 todo-nim
+
+TodoMVC in Nim for the CARLOS bakeoff: server-rendered, works with
+JavaScript off, and keeps each visitor's list in SQLite. It runs at
+[https://todo-nim.bfj.oncarlos.com](https://todo-nim.bfj.oncarlos.com).
+
+## The stack, and why
+
+- **Nim 2.2, compiled to C.** Nim builds a small native binary, and its C
+ backend makes the static linux-arm64 build easy: `zig cc` targeting
+ musl acts as the C compiler and linker, and the result is a 1.7 MB
+ static executable that starts in milliseconds.
+- **HTTP: the standard library's `asynchttpserver`**, on `asyncdispatch`.
+ A todo list doesn't need more than a single-threaded event loop, and the
+ standard library means no framework to pin. The popular alternatives
+ don't fit this platform: Mummy and httpbeast (Jester's server) only
+ listen on TCP, and Carloku hands the app a unix socket.
+ `asynchttpserver` doesn't take a unix socket either, but it's a small
+ gap: `unixServer` in `src/app.nim` binds the socket itself and gives it
+ to the server through `std/importutils.privateAccess`. The standard
+ library's docs call this server basic; it has no request timeouts or
+ keep-alive limits. Carloku's edge sits in front of it, and that's an
+ acceptable trade for an app this size.
+- **Routing: a `case` on the path.** Six actions and four pages don't need
+ a router.
+- **Templates: Nim's built-in `stdtmpl` source filter** (`src/page.nimf`).
+ It compiles the template into an ordinary Nim proc, so the markup is
+ type-checked and there's nothing to load at run time. Titles go through
+ `xmltree.escape`.
+- **SQLite: `db_connector`'s `sqlite3` binding** (the database module that
+ moved out of Nim's standard library) over the **SQLite 3.54.1
+ amalgamation, compiled into the binary.** The binding normally loads
+ `libsqlite3.so`; `--dynlibOverride:sqlite3` turns that off so it links
+ against the compiled-in copy. `src/store.nim` prepares every statement
+ once and binds parameters, so no user input is ever spliced into SQL.
+ WAL mode, `synchronous = NORMAL`, and numbered migrations that only add
+ (`PRAGMA user_version` records how far the file has got).
+- **JavaScript: one plain ES module, no framework** (`static/app.js`). It
+ sends the same form POSTs with an `X-Fragment: 1` header, and the server
+ answers with the re-rendered list section instead of a 303. Rendering
+ stays in one place, the server.
+- **The CSS** is `shared/base.css` and `shared/index.css`, embedded at
+ compile time with `staticRead` and served byte for byte.
+ `static/app.css` adds the no-JavaScript controls and hides them once
+ JavaScript is running.
+
+Lists are anonymous: the first visit gets a cookie holding 128 random bits
+from the OS (`std/sysrand`), and every query is scoped to it. See
+`AGENTS.md` for the tenancy declaration.
+
+## Build, run, test, deploy
+
+Put the toolchains on `PATH` first: `source scripts/env.sh` from the repo
+root. The first build fetches `db_connector` into `.cache/nimble` and the
+SQLite amalgamation into `.cache/dl`, checking it against a pinned SHA-256.
+
+```
+make build # dist/app for this machine
+make build-arm64 # dist/app-linux-arm64, static (zig cc, musl)
+make test # unit tests: the store, and the routes with fake requests
+make clean # removes dist/
+make clean-deps # also removes the compiled C under .cache/nimcache-*
+```
+
+Run it locally and drive the shared behaviour suite against it:
+
+```
+dist/app --listen 127.0.0.1:8080 --db $TMPDIR/nim.db
+scripts/test-app.sh http://127.0.0.1:8080 $TMPDIR/nim-suite.json
+```
+
+On Carloku it runs as `app --socket <path> --db <path>`, reads
+`CARLOS_HOST` for its origin, and sets `Secure` on its cookie. It exits
+cleanly on SIGTERM.
+
+`/api/version` reports the git short SHA the binary was built from, so
+commit before building what you deploy. Then:
+
+```
+scripts/boot-check.sh dist/app-linux-arm64
+make deploy MESSAGE="what changed"
+```
+
+## Layout
+
+| Path | What |
+|---|---|
+| `src/app.nim` | Flags, the server, routes and cookies |
+| `src/store.nim` | SQLite: schema, migrations, every query |
+| `src/views.nim`, `src/page.nimf` | The page and the list fragment |
+| `static/app.js`, `static/app.css` | The JavaScript layer and the no-JS controls |
+| `tests/test_app.nim` | Unit tests |
diff --git a/apps/nim/src/app.nim b/apps/nim/src/app.nim
new file mode 100644
index 0000000..b94bc8c
--- /dev/null
+++ b/apps/nim/src/app.nim
@@ -0,0 +1,236 @@
+## TodoMVC on CARLOS: the HTTP server and its routes.
+##
+## app --socket <path> --db <path> (on Carloku)
+## app --listen 127.0.0.1:<port> --db <path> (local runs and tests)
+
+import std/[asyncdispatch, asynchttpserver, asyncnet, cookies, importutils, options,
+ nativesockets, net, os, parseopt, posix, strutils, strtabs, uri]
+import store, views
+
+const
+ appVersion {.strdefine.} = "dev"
+ # `make bump` edits this string so the next build has to recompile this file.
+ serverName = "todo-nim"
+ cookieName = "todo_list"
+ baseCss = staticRead("../../../shared/base.css")
+ indexCss = staticRead("../../../shared/index.css")
+ appCss = staticRead("../static/app.css")
+ appJs = staticRead("../static/app.js")
+
+type
+ Config = object
+ socket, listen, db: string
+
+ App = ref object
+ store: Store
+ origin: string ## the only Origin a POST may carry
+ secure: bool ## served over https, so the cookie gets `Secure`
+
+ Reply = object
+ code: HttpCode
+ body: string
+ headers: seq[(string, string)]
+
+# Requests
+
+proc header(req: Request, name: string): string =
+ if req.headers.hasKey(name): $req.headers[name] else: ""
+
+proc parseFilter(s: string): Filter =
+ case s
+ of "active": fActive
+ of "completed": fCompleted
+ else: fAll
+
+proc formFields(req: Request): StringTableRef =
+ result = newStringTable(modeCaseSensitive)
+ for (k, v) in decodeQuery(req.body):
+ result[k] = v
+
+proc queryParam(req: Request, name: string): string =
+ for (k, v) in decodeQuery(req.url.query):
+ if k == name: return v
+
+# Responses
+
+proc reply(code: HttpCode, body = "", contentType = "text/plain; charset=utf-8"): Reply =
+ Reply(code: code, body: body, headers: @{"Content-Type": contentType})
+
+proc html(body: string): Reply =
+ result = reply(Http200, body, "text/html; charset=utf-8")
+ result.headers.add ("Cache-Control", "no-store")
+
+proc asset(body, contentType: string): Reply =
+ result = reply(Http200, body, contentType)
+ result.headers.add ("Cache-Control", "public, max-age=86400")
+
+proc seeOther(path: string): Reply =
+ result = reply(Http303)
+ result.headers.add ("Location", path)
+
+# The visitor's list
+
+proc listFor(app: App, req: Request, r: var Reply): string =
+ ## The list the visitor's cookie names, or a new one with a cookie that
+ ## says so. Garbled and unknown cookies get a new list too.
+ let jar = parseCookies(req.header("Cookie"))
+ result = jar.getOrDefault(cookieName)
+ if not app.store.listExists(result):
+ result = app.store.createList()
+ let cookie = setCookie(cookieName, result, path = "/", noName = true,
+ secure = app.secure, httpOnly = true, maxAge = some(400 * 24 * 3600),
+ sameSite = Lax)
+ r.headers.add ("Set-Cookie", cookie)
+
+proc view(app: App, list: string, filter: Filter, editing = 0'i64): View =
+ let (total, completed) = app.store.counts(list)
+ View(todos: app.store.todos(list, filter), total: total, completed: completed,
+ filter: filter, editing: editing, version: appVersion)
+
+# Routes
+
+proc page(app: App, req: Request, filter: Filter): Reply =
+ var r: Reply
+ let list = app.listFor(req, r)
+ let editing = try: parseBiggestInt(req.queryParam("edit")) except ValueError: 0
+ result = html(renderPage(app.view(list, filter, editing)))
+ result.headers.add r.headers
+
+proc action(app: App, req: Request, path: string): Reply =
+ ## A form POST: change the list, then 303 back to the filter, or render the
+ ## changed list as a fragment when the page's JavaScript asks for one.
+ let origin = req.header("Origin")
+ if origin != "" and origin != app.origin:
+ return reply(Http403, "Cross-origin request refused\n")
+ var r: Reply
+ let list = app.listFor(req, r)
+ let form = req.formFields()
+ let filter = parseFilter(form.getOrDefault("filter"))
+ let parts = path.split('/') # "", "todos", <id or verb>, [verb]
+ var found = true
+ if parts.len == 2:
+ let title = form.getOrDefault("title").strip()
+ if title.len > 0: app.store.add(list, title)
+ elif parts.len == 3 and parts[2] == "toggle-all":
+ app.store.toggleAll(list)
+ elif parts.len == 3 and parts[2] == "clear-completed":
+ app.store.clearCompleted(list)
+ elif parts.len == 4:
+ let id = try: parseBiggestInt(parts[2]) except ValueError: -1
+ case parts[3]
+ of "toggle": found = app.store.toggle(list, id)
+ of "destroy": found = app.store.destroy(list, id)
+ of "edit":
+ let title = form.getOrDefault("title").strip()
+ found = if title.len > 0: app.store.rename(list, id, title)
+ else: app.store.destroy(list, id)
+ else: return reply(Http404, "Not found\n")
+ else:
+ return reply(Http404, "Not found\n")
+ result =
+ if not found: reply(Http404, "No such item in your list\n")
+ elif req.header("X-Fragment") == "1":
+ html(renderMain(app.view(list, filter)))
+ else: seeOther(filterPath(filter))
+ result.headers.add r.headers
+
+proc route(app: App, req: Request): Reply =
+ let path = req.url.path
+ case req.reqMethod
+ of HttpGet:
+ case path
+ of "/": app.page(req, fAll)
+ of "/active": app.page(req, fActive)
+ of "/completed": app.page(req, fCompleted)
+ of "/healthz": reply(Http200, "ok\n")
+ of "/api/version": reply(Http200, appVersion)
+ of "/static/base.css": asset(baseCss, "text/css; charset=utf-8")
+ of "/static/index.css": asset(indexCss, "text/css; charset=utf-8")
+ of "/static/app.css": asset(appCss, "text/css; charset=utf-8")
+ of "/static/app.js": asset(appJs, "text/javascript; charset=utf-8")
+ else: reply(Http404, "Not found\n")
+ of HttpPost:
+ if path == "/todos" or path.startsWith("/todos/"): app.action(req, path)
+ else: reply(Http404, "Not found\n")
+ else:
+ reply(Http405, "Method not allowed\n")
+
+proc handle(app: App, req: Request) {.async.} =
+ var r: Reply
+ try:
+ r = app.route(req)
+ except CatchableError as e:
+ stderr.writeLine "error: ", req.reqMethod, " ", req.url.path, ": ", e.msg
+ r = reply(Http500, "Internal error\n")
+ r.headers.add ("Server", serverName)
+ # asynchttpserver sets Content-Length from the body, in bytes.
+ await req.respond(r.code, r.body, newHttpHeaders(r.headers))
+
+# Startup and shutdown
+
+const usage = "usage: app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>"
+
+proc parseArgs(): Config =
+ # Every flag takes a value; listing one with none makes `--flag value` work.
+ var p = initOptParser(commandLineParams(), longNoVal = @["help"])
+ for kind, key, val in p.getopt():
+ if kind != cmdLongOption:
+ quit "unexpected argument: " & key, 2
+ case key
+ of "socket": result.socket = val
+ of "listen": result.listen = val
+ of "db": result.db = val
+ of "help": quit usage, 0
+ else: quit "unknown flag --" & key, 2
+ if result.db == "" or (result.socket == "") == (result.listen == ""):
+ quit usage, 2
+
+proc unixServer(path: string): AsyncHttpServer =
+ ## asynchttpserver only listens on TCP; hand it a unix socket instead.
+ privateAccess(AsyncHttpServer)
+ result = newAsyncHttpServer()
+ if fileExists(path) or symlinkExists(path): removeFile(path)
+ let sock = newAsyncSocket(Domain.AF_UNIX, SockType.SOCK_STREAM, Protocol.IPPROTO_IP)
+ sock.bindUnix(path)
+ sock.listen()
+ result.socket = sock
+ result.maxFDs = try: maxDescriptors() except OSError: nimMaxDescriptorsFallback
+
+proc main() =
+ let cfg = parseArgs()
+ let app = App(store: store.open(cfg.db))
+ var server: AsyncHttpServer
+ if cfg.socket != "":
+ server = unixServer(cfg.socket)
+ app.origin = "https://" & getEnv("CARLOS_HOST")
+ app.secure = true
+ else:
+ let i = cfg.listen.rfind(':')
+ let host = if i < 0: "" else: cfg.listen[0 ..< i]
+ let port = try: parseInt(cfg.listen[i + 1 .. ^1]) except ValueError: -1
+ if host != "127.0.0.1" or port notin 1..65535:
+ quit "--listen must be 127.0.0.1:<port>", 2
+ server = newAsyncHttpServer()
+ server.listen(Port(port), host)
+ app.origin = "http://" & cfg.listen
+
+ proc stop(fd: AsyncFD): bool =
+ server.close()
+ if cfg.socket != "": discard tryRemoveFile(cfg.socket)
+ app.store.close()
+ quit 0
+ addSignal(SIGTERM.int, stop)
+ addSignal(SIGINT.int, stop)
+
+ stderr.writeLine "todo-nim ", appVersion, " listening on ",
+ (if cfg.socket != "": cfg.socket else: cfg.listen)
+ proc serve() {.async.} =
+ while true:
+ if server.shouldAcceptRequest():
+ await server.acceptRequest(proc (req: Request): Future[void] = app.handle(req))
+ else:
+ await sleepAsync(10)
+ waitFor serve()
+
+when isMainModule and not defined(testing):
+ main()
diff --git a/apps/nim/src/page.nimf b/apps/nim/src/page.nimf
new file mode 100644
index 0000000..4ed3f5a
--- /dev/null
+++ b/apps/nim/src/page.nimf
@@ -0,0 +1,75 @@
+#? stdtmpl(subsChar = '$', metaChar = '#')
+#proc renderMain*(v: View): string =
+# result = ""
+# let f = $v.filter
+# if v.total > 0:
+ <section class="main">
+ <input id="toggle-all" class="toggle-all" type="checkbox"${checkedIf(v.total == v.completed)}>
+ <label for="toggle-all">Mark all as complete</label>
+ <form class="inline" method="post" action="/todos/toggle-all"><input type="hidden" name="filter" value="$f"><button class="toggle-all-button" data-action="toggle-all">Mark all as complete</button></form>
+ <ul class="todo-list">
+# for t in v.todos:
+# let editing = t.id == v.editing
+# let title = h(t.title)
+ <li${classes(t.completed, editing)} data-id="${t.id}">
+ <div class="view">
+ <input class="toggle" type="checkbox"${checkedIf(t.completed)}>
+ <label>$title</label>
+ <form class="inline" method="post" action="/todos/${t.id}/toggle"><input type="hidden" name="filter" value="$f"><button class="toggle-button" data-action="toggle">${if t.completed: "Mark as active" else: "Mark as complete"}</button></form>
+ <a class="edit-link" data-action="edit" href="${filterPath(v.filter)}?edit=${t.id}">edit</a>
+ <form class="inline" method="post" action="/todos/${t.id}/destroy"><input type="hidden" name="filter" value="$f"><button class="destroy" aria-label="Delete"></button></form>
+ </div>
+# if editing:
+ <form class="inline" method="post" action="/todos/${t.id}/edit"><input type="hidden" name="filter" value="$f"><input class="edit" name="title" value="$title" autofocus></form>
+# else:
+ <input class="edit" value="$title">
+# end if
+ </li>
+# end for
+ </ul>
+ </section>
+ <footer class="footer">
+ <span class="todo-count"><strong>${v.total - v.completed}</strong> ${if v.total - v.completed == 1: "item" else: "items"} left</span>
+ <ul class="filters">
+# for (filter, name) in [(fAll, "All"), (fActive, "Active"), (fCompleted, "Completed")]:
+ <li>
+ <a${if filter == v.filter: " class=\"selected\"" else: ""} href="${filterPath(filter)}">$name</a>
+ </li>
+# end for
+ </ul>
+# if v.completed > 0:
+ <form class="inline" method="post" action="/todos/clear-completed"><input type="hidden" name="filter" value="$f"><button class="clear-completed">Clear completed</button></form>
+# end if
+ </footer>
+# end if
+#end proc
+#
+#proc renderPage*(v: View): string =
+# result = ""
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>TodoMVC</title>
+ <script>document.documentElement.classList.add("js")</script>
+ <link rel="stylesheet" href="/static/base.css">
+ <link rel="stylesheet" href="/static/index.css">
+ <link rel="stylesheet" href="/static/app.css?v=${h(v.version)}">
+ <script type="module" src="/static/app.js?v=${h(v.version)}"></script>
+ </head>
+ <body>
+ <section class="todoapp" data-filter="${v.filter}">
+ <header class="header">
+ <h1>todos</h1>
+ <form class="inline" method="post" action="/todos"><input type="hidden" name="filter" value="${v.filter}"><input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"${if v.editing == 0: " autofocus" else: ""}></form>
+ </header>
+${renderMain(v)} </section>
+ <footer class="info">
+ <p>Double-click to edit a todo</p>
+ <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Nim</p>
+ <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+ </footer>
+ </body>
+</html>
+#end proc
diff --git a/apps/nim/src/store.nim b/apps/nim/src/store.nim
new file mode 100644
index 0000000..e64d3ea
--- /dev/null
+++ b/apps/nim/src/store.nim
@@ -0,0 +1,208 @@
+## SQLite storage: one file, WAL mode, every query scoped to one list.
+##
+## The binding is `db_connector/sqlite3`; the SQLite amalgamation is compiled
+## into the binary here, and the build passes `--dynlibOverride:sqlite3` so
+## the binding links against it instead of loading `libsqlite3.so`.
+
+import std/[sysrand, strutils, times]
+import db_connector/sqlite3
+
+const
+ sqliteSource {.strdefine.} = "../.cache/sqlite/sqlite3.c"
+ sqliteFlags = "-O2 -DSQLITE_THREADSAFE=0 -DSQLITE_DQS=0 " &
+ "-DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1 " &
+ "-DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_OMIT_DEPRECATED " &
+ "-DSQLITE_OMIT_SHARED_CACHE -DSQLITE_LIKE_DOESNT_MATCH_BLOBS"
+
+{.compile(sqliteSource, sqliteFlags).}
+
+type
+ StoreError* = object of CatchableError
+
+ Todo* = object
+ id*: int64
+ title*: string
+ completed*: bool
+
+ Store* = ref object
+ db: PSqlite3
+ stmts: seq[PStmt]
+
+ Stmt = enum
+ sListExists, sCreateList, sTodos, sAdd, sToggle, sRename, sDestroy,
+ sCountAll, sCountCompleted, sSetAll, sClearCompleted
+
+const
+ # Migrations only ever add. Append; never edit or remove a shipped entry.
+ migrations = [
+ """
+ CREATE TABLE lists (
+ id TEXT PRIMARY KEY,
+ created_at INTEGER NOT NULL
+ ) WITHOUT ROWID;
+ CREATE TABLE todos (
+ id INTEGER PRIMARY KEY,
+ list_id TEXT NOT NULL REFERENCES lists(id),
+ title TEXT NOT NULL,
+ completed INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL
+ );
+ CREATE INDEX todos_by_list ON todos(list_id, id);
+ """,
+ ]
+
+ sql: array[Stmt, string] = [
+ sListExists: "SELECT 1 FROM lists WHERE id = ?1",
+ sCreateList: "INSERT INTO lists (id, created_at) VALUES (?1, ?2)",
+ sTodos: "SELECT id, title, completed FROM todos WHERE list_id = ?1 " &
+ "AND (?2 = 0 OR completed = ?3) ORDER BY id",
+ sAdd: "INSERT INTO todos (list_id, title, created_at) VALUES (?1, ?2, ?3)",
+ sToggle: "UPDATE todos SET completed = 1 - completed " &
+ "WHERE list_id = ?1 AND id = ?2",
+ sRename: "UPDATE todos SET title = ?3 WHERE list_id = ?1 AND id = ?2",
+ sDestroy: "DELETE FROM todos WHERE list_id = ?1 AND id = ?2",
+ sCountAll: "SELECT count(*) FROM todos WHERE list_id = ?1",
+ sCountCompleted: "SELECT count(*) FROM todos WHERE list_id = ?1 AND completed = 1",
+ sSetAll: "UPDATE todos SET completed = ?2 WHERE list_id = ?1",
+ sClearCompleted: "DELETE FROM todos WHERE list_id = ?1 AND completed = 1",
+ ]
+
+proc check(s: Store, rc: int32): int32 {.discardable.} =
+ if rc notin [SQLITE_OK, SQLITE_ROW, SQLITE_DONE]:
+ raise newException(StoreError, $errmsg(s.db))
+ rc
+
+proc execScript(s: Store, script: string) =
+ var err: cstring
+ if exec(s.db, script.cstring, nil, nil, err) != SQLITE_OK:
+ let msg = $err
+ free(err)
+ raise newException(StoreError, msg)
+
+proc scalar(s: Store, query: string): int64 =
+ var st: PStmt
+ s.check prepare_v2(s.db, query.cstring, -1, st, nil)
+ try:
+ if step(st) == SQLITE_ROW: result = column_int64(st, 0)
+ finally:
+ discard finalize(st)
+
+proc schemaVersion*(s: Store): int = s.scalar("PRAGMA user_version").int
+
+proc migrate(s: Store) =
+ for i in s.schemaVersion ..< migrations.len:
+ s.execScript("BEGIN;\n" & migrations[i] & "\nPRAGMA user_version = " &
+ $(i + 1) & ";\nCOMMIT;")
+
+proc open*(path: string): Store =
+ result = Store()
+ if sqlite3.open(path.cstring, result.db) != SQLITE_OK:
+ raise newException(StoreError, "cannot open " & path)
+ discard busy_timeout(result.db, 5000)
+ result.execScript("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; " &
+ "PRAGMA foreign_keys = ON;")
+ result.migrate()
+ result.stmts = newSeq[PStmt](Stmt.high.ord + 1)
+ for k in Stmt:
+ result.check prepare_v2(result.db, sql[k].cstring, -1, result.stmts[k.ord], nil)
+
+proc close*(s: Store) =
+ for st in s.stmts: discard finalize(st)
+ s.stmts.setLen(0)
+ discard sqlite3.close(s.db)
+
+# Statements are prepared once and reused: bind, step, then reset.
+
+type Arg = object
+ case isText: bool
+ of true: text: string
+ of false: num: int64
+
+proc toArg(v: string): Arg = Arg(isText: true, text: v)
+proc toArg(v: int64): Arg = Arg(isText: false, num: v)
+proc toArg(v: int): Arg = Arg(isText: false, num: v.int64)
+proc toArg(v: bool): Arg = Arg(isText: false, num: ord(v).int64)
+
+proc bound(s: Store, k: Stmt, args: openArray[Arg]): PStmt =
+ result = s.stmts[k.ord]
+ discard reset(result)
+ discard clear_bindings(result)
+ for i, a in args:
+ let n = int32(i + 1)
+ if a.isText:
+ s.check bind_text(result, n, a.text.cstring, a.text.len.int32, SQLITE_TRANSIENT)
+ else:
+ s.check bind_int64(result, n, a.num)
+
+proc exec(s: Store, k: Stmt, args: varargs[Arg, toArg]): int =
+ ## Runs a statement that returns no rows; answers the rows it changed.
+ let st = s.bound(k, args)
+ try:
+ s.check step(st)
+ result = changes(s.db).int
+ finally:
+ discard reset(st)
+
+proc count(s: Store, k: Stmt, args: varargs[Arg, toArg]): int =
+ let st = s.bound(k, args)
+ try:
+ if s.check(step(st)) == SQLITE_ROW: result = column_int64(st, 0).int
+ finally:
+ discard reset(st)
+
+proc now(): int64 = getTime().toUnix()
+
+# Lists
+
+proc newListId*(): string =
+ ## 128 random bits from the OS, as 32 hex characters.
+ for b in urandom(16): result.add b.toHex(2).toLowerAscii
+
+proc isWellFormedListId*(id: string): bool =
+ id.len == 32 and id.allCharsInSet({'0'..'9', 'a'..'f'})
+
+proc listExists*(s: Store, id: string): bool =
+ isWellFormedListId(id) and s.count(sListExists, id) == 1
+
+proc createList*(s: Store): string =
+ result = newListId()
+ discard s.exec(sCreateList, result, now())
+
+# Todos
+
+type Filter* = enum
+ fAll = "all", fActive = "active", fCompleted = "completed"
+
+proc todos*(s: Store, list: string, filter = fAll): seq[Todo] =
+ let st = s.bound(sTodos, [toArg(list), toArg(filter != fAll),
+ toArg(filter == fCompleted)])
+ try:
+ while s.check(step(st)) == SQLITE_ROW:
+ result.add Todo(id: column_int64(st, 0), title: $column_text(st, 1),
+ completed: column_int64(st, 2) != 0)
+ finally:
+ discard reset(st)
+
+proc counts*(s: Store, list: string): tuple[total, completed: int] =
+ (s.count(sCountAll, list), s.count(sCountCompleted, list))
+
+proc add*(s: Store, list, title: string) =
+ discard s.exec(sAdd, list, title, now())
+
+proc toggle*(s: Store, list: string, id: int64): bool =
+ ## False when `id` isn't in `list`.
+ s.exec(sToggle, list, id) == 1
+
+proc rename*(s: Store, list: string, id: int64, title: string): bool =
+ s.exec(sRename, list, id, title) == 1
+
+proc destroy*(s: Store, list: string, id: int64): bool =
+ s.exec(sDestroy, list, id) == 1
+
+proc toggleAll*(s: Store, list: string) =
+ ## Completes every item, or reopens them all if all were already done.
+ let (total, completed) = s.counts(list)
+ discard s.exec(sSetAll, list, total == 0 or completed < total)
+
+proc clearCompleted*(s: Store, list: string) =
+ discard s.exec(sClearCompleted, list)
diff --git a/apps/nim/src/views.nim b/apps/nim/src/views.nim
new file mode 100644
index 0000000..e84bc49
--- /dev/null
+++ b/apps/nim/src/views.nim
@@ -0,0 +1,30 @@
+## HTML rendering. The markup is TodoMVC's own template (shared/reference.html)
+## plus the forms and links that make it work without JavaScript.
+
+import std/xmltree
+import store
+
+type View* = object
+ todos*: seq[Todo] ## the items the filter shows
+ total*, completed*: int ## counts over the whole list
+ filter*: Filter
+ editing*: int64 ## the item being edited without JavaScript, or 0
+ version*: string
+
+proc h(s: string): string = xmltree.escape(s)
+
+proc filterPath*(f: Filter): string =
+ case f
+ of fAll: "/"
+ of fActive: "/active"
+ of fCompleted: "/completed"
+
+proc checkedIf(b: bool): string = (if b: " checked" else: "")
+
+proc classes(completed, editing: bool): string =
+ if completed and editing: " class=\"completed editing\""
+ elif completed: " class=\"completed\""
+ elif editing: " class=\"editing\""
+ else: ""
+
+include "page.nimf"
diff --git a/apps/nim/static/app.css b/apps/nim/static/app.css
new file mode 100644
index 0000000..5ca055a
--- /dev/null
+++ b/apps/nim/static/app.css
@@ -0,0 +1,62 @@
+/* The controls that work without JavaScript, laid over TodoMVC's own. With
+ JavaScript on (html.js) they're hidden and the page is the reference. */
+
+form.inline {
+ display: contents;
+}
+
+.toggle-button,
+.toggle-all-button {
+ position: absolute;
+ z-index: 1;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 0;
+ cursor: pointer;
+}
+
+.todo-list li .toggle-button {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+}
+
+.toggle-all-button {
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+}
+
+.todo-list li .edit-link {
+ position: absolute;
+ top: 0;
+ right: 54px;
+ bottom: 0;
+ height: 20px;
+ margin: auto 0;
+ font-size: 14px;
+ line-height: 20px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.todo-list li .edit-link:hover,
+.todo-list li .edit-link:focus {
+ color: #484848;
+ text-decoration: underline;
+}
+
+html:not(.js) .todo-list li label {
+ padding-right: 90px;
+}
+
+.js .toggle-button,
+.js .toggle-all-button,
+.js .edit-link {
+ display: none;
+}
diff --git a/apps/nim/static/app.js b/apps/nim/static/app.js
new file mode 100644
index 0000000..03e8fc1
--- /dev/null
+++ b/apps/nim/static/app.js
@@ -0,0 +1,94 @@
+// Progressive enhancement for the server-rendered TodoMVC. Every action is
+// the same form POST the page makes without JavaScript; with the
+// `X-Fragment: 1` header the server answers with the re-rendered
+// `section.main` and `footer.footer` instead of a 303, and we swap them in.
+
+const app = document.querySelector('.todoapp');
+const filter = app.dataset.filter;
+const newTodo = app.querySelector('.new-todo');
+
+// Requests run one at a time, so responses apply in the order they were made.
+let queue = Promise.resolve();
+
+function post(action, fields) {
+ const body = new URLSearchParams({ ...fields, filter });
+ const run = async () => {
+ const res = await fetch(action, {
+ method: 'POST',
+ body,
+ headers: { 'X-Fragment': '1' },
+ credentials: 'same-origin',
+ });
+ // The item is gone (another tab removed it): show the list as it is.
+ if (!res.ok) return location.reload();
+ render(await res.text());
+ };
+ queue = queue.then(run, run);
+ return queue;
+}
+
+function render(html) {
+ app.querySelector('.main')?.remove();
+ app.querySelector('.footer')?.remove();
+ app.querySelector('.header').insertAdjacentHTML('afterend', html);
+}
+
+function itemOf(el) {
+ return el.closest('.todo-list li');
+}
+
+// Forms: adding, deleting and clearing completed.
+app.addEventListener('submit', (e) => {
+ const form = e.target;
+ e.preventDefault();
+ const fields = Object.fromEntries(new FormData(form));
+ delete fields.filter;
+ post(form.getAttribute('action'), fields);
+ if (form.contains(newTodo)) newTodo.value = '';
+});
+
+app.addEventListener('change', (e) => {
+ if (e.target.matches('.toggle')) {
+ post(`/todos/${itemOf(e.target).dataset.id}/toggle`, {});
+ } else if (e.target.matches('.toggle-all')) {
+ post('/todos/toggle-all', {});
+ }
+});
+
+// Editing: double-click a label; Enter or blur saves, Escape cancels.
+app.addEventListener('dblclick', (e) => {
+ if (!e.target.matches('.todo-list label')) return;
+ const li = itemOf(e.target);
+ const input = li.querySelector('.edit');
+ li.classList.add('editing');
+ input.value = e.target.textContent;
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+});
+
+function finishEditing(input, save) {
+ const li = itemOf(input);
+ if (!li?.classList.contains('editing')) return;
+ li.classList.remove('editing');
+ // A page opened from an edit link (?edit=<id>) is done with it now.
+ if (location.search) history.replaceState(null, '', location.pathname);
+ if (save) {
+ post(`/todos/${li.dataset.id}/edit`, { title: input.value });
+ } else {
+ input.value = li.querySelector('label').textContent;
+ }
+}
+
+app.addEventListener('keydown', (e) => {
+ if (!e.target.matches('.todo-list .edit')) return;
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ finishEditing(e.target, true);
+ } else if (e.key === 'Escape') {
+ finishEditing(e.target, false);
+ }
+});
+
+app.addEventListener('focusout', (e) => {
+ if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true);
+});
diff --git a/apps/nim/tests/test_app.nim b/apps/nim/tests/test_app.nim
new file mode 100644
index 0000000..537e066
--- /dev/null
+++ b/apps/nim/tests/test_app.nim
@@ -0,0 +1,181 @@
+## Unit tests: the store on its own, then the routes with fake requests.
+## Run with `make test`.
+
+import std/[unittest, os, tempfiles, strutils, uri]
+include "../src/app.nim"
+
+let dir = createTempDir("todo-nim-test", "")
+var dbCount = 0
+
+proc freshStore(): Store =
+ inc dbCount
+ store.open(dir / ("t" & $dbCount & ".db"))
+
+suite "store":
+ test "keeps each list to itself":
+ let s = freshStore()
+ let a = s.createList()
+ let b = s.createList()
+ s.add(a, "mine")
+ let id = s.todos(a)[0].id
+ check s.todos(b).len == 0
+ check not s.toggle(b, id)
+ check not s.rename(b, id, "theirs")
+ check not s.destroy(b, id)
+ check s.todos(a) == @[Todo(id: id, title: "mine", completed: false)]
+
+ test "filters, toggles all and clears completed":
+ let s = freshStore()
+ let l = s.createList()
+ for t in ["a", "b", "c"]: s.add(l, t)
+ check s.toggle(l, s.todos(l)[1].id)
+ check s.todos(l, fActive).len == 2
+ check s.todos(l, fCompleted).len == 1
+ s.toggleAll(l)
+ check s.counts(l) == (total: 3, completed: 3)
+ s.toggleAll(l)
+ check s.counts(l) == (total: 3, completed: 0)
+ discard s.toggle(l, s.todos(l)[0].id)
+ s.clearCompleted(l)
+ check s.todos(l).len == 2
+
+ test "keeps any Unicode and long titles exactly":
+ let s = freshStore()
+ let l = s.createList()
+ let long = "x".repeat(10_000)
+ s.add(l, "naïve 🚀 日本語 <b>")
+ s.add(l, long)
+ check s.todos(l)[0].title == "naïve 🚀 日本語 <b>"
+ check s.todos(l)[1].title == long
+
+ test "reopening runs no migration twice and keeps data":
+ let path = dir / "reopen.db"
+ var s = store.open(path)
+ let l = s.createList()
+ s.add(l, "kept")
+ s.close()
+ s = store.open(path)
+ check s.todos(l)[0].title == "kept"
+ check s.schemaVersion == 1
+ check fileExists(path & "-wal")
+ s.close()
+
+ test "list ids are 128 random bits":
+ let a = newListId()
+ check a.len == 32 and isWellFormedListId(a)
+ check a != newListId()
+ check not isWellFormedListId("%%%")
+
+# Routes
+
+let app = App(store: freshStore(), origin: "http://127.0.0.1:9", secure: false)
+
+proc request(meth: HttpMethod, target: string, body = "",
+ headers: openArray[(string, string)] = []): Request =
+ Request(reqMethod: meth, url: parseUri(target), body: body,
+ headers: newHttpHeaders(headers))
+
+proc headerOf(r: Reply, name: string): string =
+ for (k, v) in r.headers:
+ if k.cmpIgnoreCase(name) == 0: return v
+
+proc cookieOf(r: Reply): string =
+ r.headerOf("Set-Cookie").split(';')[0]
+
+proc post(target, body: string, cookie: string,
+ extra: openArray[(string, string)] = []): Reply =
+ app.route(request(HttpPost, target, body, @{"Cookie": cookie} & @extra))
+
+suite "routes":
+ test "the first visit gets a cookie and an empty page":
+ let r = app.route(request(HttpGet, "/"))
+ check r.code == Http200
+ let c = r.headerOf("Set-Cookie")
+ check c.startsWith("todo_list=") and "HttpOnly" in c and "SameSite=Lax" in c
+ check "Secure" notin c
+ check "<section class=\"main\">" notin r.body
+ check "Created by <a href=\"https://bakeoff.bfj.oncarlos.com\">the CARLOS bakeoff</a> · Nim" in r.body
+
+ test "a garbled or unknown cookie gets a fresh list":
+ for bad in ["%%%", "0123456789abcdef0123456789abcdef"]:
+ let r = app.route(request(HttpGet, "/", headers = {"Cookie": "todo_list=" & bad}))
+ check r.code == Http200
+ check r.cookieOf() != "todo_list=" & bad
+
+ test "actions answer 303 back to the filter":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ for (filter, path) in [("all", "/"), ("active", "/active"),
+ ("completed", "/completed"), ("bogus", "/"), ("", "/")]:
+ let r = post("/todos", "title=x&filter=" & filter, cookie)
+ check r.code == Http303
+ check r.headerOf("Location") == path
+
+ test "titles are trimmed, blank ones ignored, and shown as text":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ discard post("/todos", "title=" & encodeUrl(" ") & "&filter=all", cookie)
+ discard post("/todos", "title=" & encodeUrl(" <img src=x> & \"q\" "), cookie)
+ let page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body
+ check page.count("<li data-id=") == 1
+ check "<label>&lt;img src=x&gt; &amp; &quot;q&quot;</label>" in page
+
+ test "another list's id answers 404 and changes nothing":
+ let mine = app.route(request(HttpGet, "/")).cookieOf()
+ let theirs = app.route(request(HttpGet, "/")).cookieOf()
+ discard post("/todos", "title=mine", mine)
+ let list = mine.split('=')[1]
+ let id = app.store.todos(list)[0].id
+ for verb in ["toggle", "destroy", "edit"]:
+ check post("/todos/" & $id & "/" & verb, "title=x", theirs).code == Http404
+ check post("/todos/nope/toggle", "", mine).code == Http404
+ check app.store.todos(list)[0] == Todo(id: id, title: "mine", completed: false)
+
+ test "a foreign Origin is refused; our own and none are allowed":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ check post("/todos", "title=a", cookie, {"Origin": "https://evil.example"}).code == Http403
+ check post("/todos", "title=a", cookie, {"Origin": "http://127.0.0.1:9"}).code == Http303
+ check post("/todos", "title=a", cookie).code == Http303
+
+ test "editing: the edit view, renaming, and blank deletes":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ discard post("/todos", "title=old", cookie)
+ let list = cookie.split('=')[1]
+ let id = $app.store.todos(list)[0].id
+ let page = app.route(request(HttpGet, "/active?edit=" & id,
+ headers = {"Cookie": cookie})).body
+ check "<li class=\"editing\" data-id=\"" & id & "\">" in page
+ check "action=\"/todos/" & id & "/edit\"" in page
+ discard post("/todos/" & id & "/edit", "title=" & encodeUrl(" new "), cookie)
+ check app.store.todos(list)[0].title == "new"
+ discard post("/todos/" & id & "/edit", "title=+++", cookie)
+ check app.store.todos(list).len == 0
+
+ test "the page's JavaScript gets a fragment instead of a redirect":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ let r = post("/todos", "title=one&filter=active", cookie, {"X-Fragment": "1"})
+ check r.code == Http200
+ check r.body.strip.startsWith("<section class=\"main\">")
+ check "<strong>1</strong> item left" in r.body
+ check "<a class=\"selected\" href=\"/active\">" in r.body
+
+ test "counter, toggle-all state and clear-completed":
+ let cookie = app.route(request(HttpGet, "/")).cookieOf()
+ discard post("/todos", "title=a", cookie)
+ discard post("/todos", "title=b", cookie)
+ var page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body
+ check "<strong>2</strong> items left" in page
+ check "clear-completed" notin page.split("<footer class=\"footer\">")[1].split("</footer>")[0]
+ discard post("/todos/toggle-all", "", cookie)
+ page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body
+ check "<strong>0</strong> items left" in page
+ check "class=\"toggle-all\" type=\"checkbox\" checked" in page
+ check "<button class=\"clear-completed\">Clear completed</button>" in page
+
+ test "static files, health and version":
+ check app.route(request(HttpGet, "/static/base.css")).body == readFile("../../shared/base.css")
+ check app.route(request(HttpGet, "/static/index.css")).body == readFile("../../shared/index.css")
+ check app.route(request(HttpGet, "/healthz")).code == Http200
+ check app.route(request(HttpGet, "/api/version")).body == appVersion
+ check app.route(request(HttpGet, "/nope")).code == Http404
+
+app.store.close()
+removeDir(dir)
diff --git a/apps/nim/todo.nimble b/apps/nim/todo.nimble
new file mode 100644
index 0000000..b3f9232
--- /dev/null
+++ b/apps/nim/todo.nimble
@@ -0,0 +1,13 @@
+# Package
+
+version = "0.1.0"
+author = "CARLOS bakeoff"
+description = "TodoMVC, server-rendered, on CARLOS"
+license = "MIT"
+srcDir = "src"
+bin = @["app"]
+
+# Dependencies
+
+requires "nim >= 2.2.0"
+requires "db_connector#29450a2063970712422e1ab857695c12d80112a6"