Tutorial 6 — porting to a new database
What it takes to add a seventh engine, in the order that works, with the steps that get skipped called out — because two of the six existing ports skipped them and the results are in the audit register.
Normative reference:
spec/15-portability-and-dialects.md.
What you are actually porting#
Very little, which is the good news:
| Changes? | |
|---|---|
map/src/{model,shred,reconstruct,value,fold,canon,error}.rs |
no |
gen/src/** (the whole generator) |
no |
map/src/ddl.rs |
yes — the real work |
store/** |
yes |
Those first two rows are X15.1, and they are not aspirational: measured across
the six existing ports, every one of those files is byte-identical modulo the
crate name. They operate on Rust types and never emit SQL, so there is nothing
in them for a dialect to change.
The SQLite annex says it well: PG_MAX_IDENT = 63 and SPLIT_WIDTH = 150 look
like PostgreSQL constants but are correct for SQLite too, because both are
tighter than SQLite's limits. Identifier fitting and table splitting carry over
unmodified.
Step 1 — copy the closest port#
fhir-mysql for a client/server SQL engine, fhir-sqlite for an embedded one.
Rename directories and crates to fhir-<engine>-{map,gen,store} (W16.2).
Fix every crate description while you are there (W16.3). All six current
ports say "PostgreSQL storage layer for fhir-<engine>" — a string published to
crates.io and rendered on docs.rs, read by exactly the person who has not looked
at the code yet (F-02).
Step 2 — write the annex, before the code#
This is the step to do out of order — before implementing, not after — and it is the one both scaffold ports skipped.
Write spec/14-<engine>-dialect.md against the twelve-item X15.6 checklist:
- Engine floor, and the dialect fact that sets it (
S1.4) - Namespace mechanism (
S1.2) ColTybinding, justified againstM3.6a–M3.6c(M3.6)ordsbinding, and how the three value-domain properties survive (M3.4a)- Install atomicity (
G2.5) - Snapshot isolation level, and any database setting it needs (
R4.5) - Write serialization for
version_idand the chain append (H5.4) - Append-only enforcement, or its absence (
M3.17) - Index limits and what is done instead (
P6.4a) - Paging and placeholder syntax
- Transport security, or why there is no connection (
O10.7) - Every core requirement this port does not satisfy, as a numbered departure
"Not applicable" is an acceptable answer to any of these. Silence is not: silence and having-not-considered-it look identical on the page.
A departure must cite what it amends (X15.7) and must not restate
unchanged core requirements (X15.8):
- **M14.6** `ords` MUST be a `TEXT` column, amending **M3.4**'s array type.
The value-domain properties of M3.4a survive: negative ordinals appear
verbatim, the empty path is the two-character string `{}`, and depth is
unbounded because the encoding is variable-length.
What skipping this looks like.
fhir-mssql/spec/14-mssql-dialect.mdandfhir-oracle/spec/14-oracle-dialect.mdare the MySQL annex with three lines changed. Both are titled "14. MySQL dialect" and declare the target as "MySQL 8.0 or later, InnoDB,utf8mb4". The MSSQL one is worse than useless: the port'sddl.rsis genuine, deliberate T-SQL, so its own specification contradicts its own working code, and a reader following the annex would reject correct code. EveryM14.xid in both files is wrong, permanently, because ids are never reused (C0.5). Tracked as F-16.
Step 3 — ddl.rs#
Start at col_sql. Three bindings are always wrong the obvious way:
Numeric is not a decimal type (M3.6a). M3.6 requires a decimal's
original textual precision to survive round-trip. DECIMAL(65,30) returns
1.50 as 1.500000000000000000000000000000; REAL cannot hold 1.50
distinctly from 1.5 at all. Bind to text, and serve range search from a
derived sort column.
TextC must be binary and NO PAD (M3.6b). It backs :exact matching and
key identity. SQL Server's default collation is case- and accent-insensitive,
so a column left at the default silently acquires fuzzy equality. Under a PAD
SPACE collation 'Smith' = 'Smith ' is true, which widens :exact and weakens
primary keys.
Jsonb must not be a JSON type (M3.6c). The hash chain commits to bytes
canonicalized in Rust; a JSON column re-normalizes what it is given, so the
bytes read back would differ from the bytes signed and every chain would fail
verification.
Then the rest: IF NOT EXISTS support (Oracle has none, so idempotence means a
PL/SQL block swallowing ORA-00955), trigger syntax for append-only, index key
limits, and reserved words.
What skipping this looks like.
fhir-oracle'sddl.rsis still the MySQL emitter —TEXT,TINYINT(1),DATETIME(6),utf8mb4_0900_bin, none of which exist in Oracle. Its eleven MySQL-asserting tests are#[ignore]d, which is the right call and the reason the code is honest even though the README is not (F-08).
Step 4 — point CI at the real engine#
Before writing the store, make scripts/db.sh and both CI configs provision
your engine at the version your annex declares (O10.12).
What skipping this looks like.
fhir-mssqlandfhir-oracleboth startmysql:8.4, inscripts/db.sh, in.github/workflows/ci.yml, and in.woodpecker/database.yaml. Compounding it,fhir-mssql's only live test needsFHIR_MSSQL_TEST_DSNand skips silently without it, which that pipeline never sets. So the T-SQL DDL has never run in CI, the build is green, and the summary reports a passing database gate (F-06).A test that skips is indistinguishable from a test that passes, in a summary (
T11.12). That is a rule about tests, and here it reappeared one level up, about pipelines.
Step 5 — the DDL test#
Before the store: does the generated schema actually execute?
Unit assertions on statement shape catch a stray backquote. They do not catch a reserved word, an unindexable column, or a trigger the parser rejects. Running the generated schema through the real engine is what found the SQLite and MySQL ports' real bugs.
Make it fail rather than skip when its DSN is absent in CI.
Step 6 — the store#
Follow the parallel-module pattern: <engine>.rs and <engine>_search.rs
alongside the shared lib.rs and chain.rs. Order of implementation, roughly
the order the existing ports used:
open/connect,init,drop_schema,installed_checksumput,get— round-trip a resource through the live enginehistory,vread,deletesearch,search_full,search_pagelog_access,verify_audit,purgetransact_audited, conditional operations,emit_checkpointupgradeandbackfill_norm— needed before any fold change ships (O10.4a)
Two transaction requirements are easy to miss and hard to debug:
- Every multi-statement read is one snapshot (
R4.5). A read touches one base table and many child tables; issued independently, a concurrent write between them reconstructs a resource that never existed. version_idis assigned under a lock that serializes writers per resource id (H5.4), because the chain digest of version n commits to n−1 — a race produces two rows claiming the same predecessor and a chain that verifies for neither.
Step 7 — tests#
Port concurrency.rs, redaction.rs, and audit.rs from fhir-postgresql.
These are the tests behind T11.6, T11.7, and T11.8 — the concurrency and
audit guarantees, which are the ones §13 maps to HIPAA §164.312(b) and (c).
Only fhir-postgresql has them today, which is why four ports carry ? rather
than • in the matrix. Sharing a correct implementation is not evidence that
this port runs it.
And verify each test by mutation (T11.10): break what it guards, watch it
go red. This matters most for tamper evidence, where a control that cannot fail
is indistinguishable from one that works.
Step 8 — write the documentation#
Not substitute it (W16.8).
What skipping this looks like. Every port's README carries the PostgreSQL reference's paragraph — "all 7,399 official FHIR example resources round-trip losslessly", "94.8% of R5 search parameters compile", "
fhir-<engine> servemounts every installed version" — with the engine name swapped. Two of those ports have no store. None has a CLI crate. That is a claim about clinical software, in a product's own name, that nothing substantiates (F-01), and it is the most serious finding in the register.
State the port's conformance level (C0.8) and do not claim above it
(C0.11). A Scaffold port's README says it is a scaffold.
Step 9 — the matrix#
Add a column to spec/conformance-matrix.md.
Use ?, honestly, for everything your test suite does not demonstrate. ? is
not a soft • — it means "plausibly satisfied by shared code, and nothing here
proves it".
The checklist#
[ ] crates renamed, descriptions fixed W16.2, W16.3
[ ] annex written against the X15.6 checklist X15.6
[ ] ddl.rs, starting at col_sql M3.6a-c
[ ] CI and scripts/db.sh provision the real engine O10.12
[ ] DDL execution test — fails, not skips, in CI T11.12
[ ] store, in the order above R4.5, H5.4
[ ] concurrency, redaction, audit tests ported T11.6-8
[ ] every test verified by mutation T11.10
[ ] documentation written, not substituted W16.8
[ ] conformance matrix column added, honestly C0.9
[ ] git remote set to the new repository W16.15
The two lines that decide whether the port is trustworthy are the annex and the documentation. They are also the two with no compiler behind them, which is why they are the two that get skipped.