Orientation for maintainers and AI coding agents. Read this before changing code. It records what
isn't obvious from the code: the architecture, the invariants, the traps, and the reasons behind
decisions. User-facing documentation lives in docs/.
fss is a set of small F# libraries, published to NuGet:
| Package | Source | Role | Status |
|---|---|---|---|
fss_duckdb |
src/fss_duckdb/db_duckdb.fs |
DuckDB dialect | Primary use case |
fss_postgres |
src/fss_postgres/db_pg.fs |
Postgres dialect (Npgsql) | Maintained |
fss_sqlite |
src/fss_sqlite/db_sqlite.fs |
SQLite dialect (Microsoft.Data.Sqlite) | Maintained |
fss_mysql |
src/fss_mysql/ + src/fss/db_common_mysql.fs |
Old fork of the core for MySQL | Retired: builds, not packed or published |
fss |
src/fss/*.fs except db_common*.fs |
HTTP server, Jinja-like templates, JSON parser, multipart | Legacy: maintenance and security fixes only |
The database packages are a "micro-ORM": SQL in, F# records out (Query<'T>), and F# records in
(InsertOne/InsertMany), with no mapping code. They are independent of the fss web package.
Directory.Build.props shared MSBuild settings, *the release version* (<VersionPrefix>), SourceLink, NuGetAudit
Directory.Packages.props NuGet Central Package Management: every package version lives here
global.json .NET SDK 10 (rollForward latestFeature)
fss.slnx solution (XML .slnx format)
src/Directory.Build.props packaging metadata for the libraries (license, icon, readme, symbols)
src/fss/ pool.fs + db_common.fs (THE SHARED DB CORE) and the legacy web/template code
src/fss_<db>/ one dialect file each + README.md (the NuGet package readme)
tests/fss.Tests/ NUnit 5. One file per area; test_db_duckdb.fs is the best model for new tests
samples/ runnable console apps; CI runs them as smoke tests
docs/ user docs (markdown); docs/releasing.md is the release runbook
.github/workflows/ ci.yml (build/test/samples/pack), release.yml (tag -> NuGet via Trusted Publishing)
The shared core is compiled into each package; it isn't a package of its own. Each dialect
.fsproj compiles ../fss/pool.fs and ../fss/db_common.fs as linked source files. So fss_duckdb.dll,
fss_postgres.dll and fss_sqlite.dll each contain their own copy of the Fss.Pool and
Fss.Data.Common types. Consequences:
- A change to
db_common.fsships in every dialect. Test it against all of them (DuckDB and SQLite always run; Postgres needsFSS_TEST_POSTGRES). - Referencing two dialects in one project works, as
samples/SqliteToDuckDbshows, because users reach the types through the module abbreviations (Fss.Data.DuckDB.DynamicSqlConnection). If a useropensFss.Data.Commondirectly with two dialects referenced, the types are ambiguous. Afss_data_corepackage would fix this, but it means one more package to version and publish, so it hasn't been done.
Flow:
DynamicSqlConnection<'Conn,'Parameter,'Customizer>(connStr, poolSize)(indb_common.fs):- Construction creates a
Pool<DbConnection>. The pool callscustomizer.openConnection(connStr, first)poolSizetimes, eagerly. Then it loads metadata:loadColDetailgivesMap<SchematizedTable, ColDetail[]>, andgetSearchPathgives the schemas used for bare table names. Query<'T>(sql, ?parameters, ?transProvided)is a lazyseq { }. Inside it, it borrows a command (and so a pooled connection), executes, and usesmakeRowBuilderto decide how to build each row: tuple by position, record/class by constructor parameter name, or scalar from column 0. Each value goes throughvalueConverter(NULL to option/Nullable handling) and thencoerce(type conversion). The command is disposed, releasing the connection, when enumeration ends.InsertManyresolves the table (findTable, which refreshes metadata once on a miss and acceptsschema.table), checks that record fields match columns and that option-ness matches nullability, buildsinsert into schema.table(cols) values(<prefix>p1..) [returning pk], and runs one prepared command per row inside a transaction. It uses its own transaction, or the caller'stransProvided. If it created the transaction, it disposes it infinally, which rolls back on error.
- Construction creates a
DynamicSqlTransactionholds one pooled connection from construction untilDispose. Its methods delegate to the connection's methods, passingtransProvided = this.ISqlConnectionis implemented by both, so user code can run inside or outside a transaction.Customization<'P,'C>is the dialect interface:openConnection,loadColDetail,getSearchPath,sequenceMechanism(RETURNS_CLAUSEorSQL_STMT sql),parameterPrefix,needsKeepAlive,reopenConnection,reloadTypes.
Dialect specifics:
| DuckDB | Postgres | SQLite | |
|---|---|---|---|
| Metadata | duckdb_columns(), duckdb_constraints() |
pg_class/pg_attribute/pg_constraint |
sqlite_master + pragma_table_info |
| Keys | RETURNING |
RETURNING |
select last_insert_rowid() |
| Param prefix | $ |
@ |
@ |
| Pool connections | first opened; the rest Duplicate() (in-memory) or reopen (file) |
via a registered NpgsqlDataSource if any (registerDataSource), else new NpgsqlConnection |
new SqliteConnection |
| Keep-alive thread | no | yes | no |
| Name case | schema/table lower-cased in metadata | as stored (Postgres folds unquoted names to lower) | lower-cased |
- Every
Pool.Takemust be matched by exactly oneRelease.Releaseof an item that isn't checked out throwsInvalidOperationException. Useuse cmd = db.Command ..., ortry/finally. A leaked connection eventually deadlocks callers. Querymust stay lazy, and must acquire its connection inside theseq { }. Acquiring it outside leaks a connection whenever the sequence isn't enumerated. Regression test:UnenumeratedQueryDoesNotHoldConnection.- DuckDB
:memory:needsDuplicate(). Opening the connection string again gives a separate empty database.Duplicate()returns an unopened connection and throwsNotSupportedExceptionfor file databases. Regression test:InMemoryDatabaseIsSharedAcrossPooledConnections. - DuckDB rejects
:nameparameters. It needs$name. Postgres and SQLite take@name. Generated SQL usescustomizer.parameterPrefix().DynamicSqlCommand.AddParameterstrips any marker from the name, because DuckDB.NET won't match$xtox. - Microsoft.Data.Sqlite does not implement
GetSchema("Columns"). The fss_sqlite breakage of 2025 came from assuming it did. Don't reintroduceGetSchema. - Record mapping uses the constructor with the most parameters, matched by parameter name. F# records and anonymous records name constructor parameters after their fields. Classes work if their constructor parameter names match the columns.
coerceis the single place for type conversion. Add new driver/F# type pairs there, with a test intest_db_duckdb.fs.Noneis represented asnull, both inobj[]constructor arguments and invalueConverterresults.- Metadata is cached.
findTablerefreshes it once when a table isn't found.Reload()refreshes it explicitly, and Postgres types too. Column type changes (as opposed to new tables) still needReload(). - The keep-alive thread must stay a background thread. It holds
disposeLockwhile pinging. - Npgsql 7+ has no global type mapping. Enum and plugin mappings must go through
Postgres.registerDataSourcebefore the connection is constructed. fss(web) security debt is documented, not fixed. Templates don't HTML-escape, a missing template variable is truthy,Cookie.checkCookieis forgeable, and there are no request size or time limits. Fixing escaping changes rendered output for existing users. That needs a major version, or an opt-in. See "Backlog".- Line endings are mixed (some files CRLF, some LF). Preserve each file's existing style when you edit it. Scripted edits that write LF into a CRLF file produce noisy diffs.
dotnet build fss.slnx
dotnet test fss.slnx # DuckDB + SQLite + pool/template/json/server tests
dotnet run --project samples/DuckDbQuickStart
dotnet pack fss.slnx -c Release -o artifacts # fss, fss_duckdb, fss_postgres, fss_sqlite (+ .snupkg)Server-backed tests are skipped (NUnit Ignore) unless configured. tests/fss.Tests/shared.fs
reads, in order: the environment variable, then a legacy file (connection_*.txt) in the test
directory or up to four parents.
| Database | Env var | Legacy file |
|---|---|---|
| Postgres | FSS_TEST_POSTGRES |
connection_postgres.txt |
| MySQL | FSS_TEST_MYSQL |
connection_mysql.txt |
| SQLite (optional) | FSS_TEST_SQLITE |
connection_sqlite.txt (default: a temp file) |
A throwaway local Postgres, without Docker or touching any installed server, using the PostgreSQL binaries:
initdb -D /tmp/fsspg -U fss -A trust -E UTF8 --no-locale
pg_ctl -D /tmp/fsspg -o "-p 55432 -c listen_addresses=localhost" -l /tmp/fsspg.log start
psql -h localhost -p 55432 -U fss -d postgres -c "create database testfss"
FSS_TEST_POSTGRES="Host=localhost;Port=55432;Username=fss;Database=testfss" dotnet test fss.slnx
pg_ctl -D /tmp/fsspg stop # then delete /tmp/fsspgOr with Docker: docker run -d -p 5432:5432 -e POSTGRES_USER=fss -e POSTGRES_PASSWORD=fss -e POSTGRES_DB=fsstest postgres:17.
Expected state: 0 warnings and 0 failed tests. With Postgres configured, only the MySQL tests are skipped (about 20).
- F# style: match the surrounding code. The core uses explicit type annotations on public members
and
///XML docs on anything public. The legacy files have their own older style; don't reformat them wholesale. - Commit and release-note prefixes (from project history):
FEAfeature,BUGfix,SECsecurity,MNTmaintenance,DOC,TST,CHKcheckpoint,MINminor. - Every bug fix gets a regression test, named for the behaviour (see
test_db_duckdb.fs). - Branches: the default branch is
master(a stalemainbranch also exists). Topic branches look likeyy/mm/PREFIX-description(e.g.26/10/MNT-update_to_cpm_better_docs). - Package versions live only in
Directory.Packages.props. Never putVersion=on aPackageReference. - FSharp.Core is pinned low (8.0.x) on purpose, so library consumers aren't forced to upgrade. It's
referenced explicitly from
Directory.Build.props(DisableImplicitFSharpCoreReference), and Dependabot ignores it. NuGetAuditruns on restore (all transitive packages, every severity). A new advisory shows up as a restore warning. Fix it by bumping the package, or by pinning the transitive package inDirectory.Packages.props(transitive pinning is enabled).
Summary: bump <VersionPrefix>, date the RELEASE_NOTES.md heading, merge, then
git tag vX.Y.Z && git push origin vX.Y.Z. GitHub Actions publishes with NuGet Trusted Publishing
(OIDC, no stored API key). Full runbook, including the one-time nuget.org and GitHub setup:
docs/releasing.md.
| Decision | Why |
|---|---|
| .NET 10 only (no netstandard) | DuckDB.NET and Npgsql 10 target modern .NET. One TFM keeps CI and the package simple. |
| Paket and FAKE removed (2026-10) | Central Package Management covers what Paket did. The FAKE script was a stale template (wrong description, Paket pack). Plain dotnet CLI + GitHub Actions is enough. |
| One version for all packages | Easier to reason about. The packages share source files. |
| fss_mysql retired, not deleted | Existing users keep 2.0.0. The code still builds, so a future port is cheap. |
Customization grew openConnection and parameterPrefix (2.1) |
Needed to fix DuckDB pooling and parameters. Only dialect implementers are affected. |
| Web package kept, not split out | Existing users. Security notes in docs instead of behaviour changes. |
NUnit 5 with Assert.That only |
The classic Assert.AreEqual API was removed. The constraint model works across NUnit versions. |
- Web: template auto-escaping (opt-in flag, then default in 3.0); missing template variable should
be false in
{% if %}; request and header size limits and socket timeouts; anchored route matching inSecUD; an HMAC-signed cookie helper to replacecheckCookie. - DB:
InsertManyis one statement per row. Add a multi-rowVALUESbatch path, or the DuckDB appender, for large loads. - DB: composite primary keys return nothing from inserts. Could return a tuple.
- DB: consider a
fss_data_corepackage so the shared types exist once (see Architecture). - DB:
Customizationcould gainquoteIdentifier. Generated SQL doesn't quote names today, so mixed-case or reserved-word table and column names may fail on insert. - JSON (
Fss.Json): doesn't handle escapes, mis-parses fractions with leading zeros (1.05→ 1.5), and has no objects, true/false/null, or whitespace. Recommend System.Text.Json, and consider removingFss.Jsonin 3.0. - MySQL: port to the shared core on MySqlConnector, or delete in 3.0.
- Consider
EnablePackageValidationwith a 2.1.0 baseline once 2.1.0 ships, to catch accidental API breaks.