Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]

### Added
- **Database transactions** (#664): `in transaction on db: ... end transaction`
runs a group of statements on a single pooled connection, committing when the
block finishes and rolling back if anything inside it fails or if `exit` stops
the program mid-block. Transaction blocks cannot be nested on one database, and
a database cannot be closed inside its own transaction; both are reported
rather than silently tolerated. Under `main loop concurrently:` a transaction
belongs to the handler that opened it, so a concurrent handler using the same
database handle is never enrolled in it. `transaction` is a positional marker
word, not a reserved keyword, so programs already using it as a variable name
are unaffected.
- **Authenticated encryption** (#665): `seal of <text> and <key>` and
`unseal of <sealed> and <key>`, backed by XChaCha20-Poly1305. Keys are the
64-hex-character values `secure_random_bytes of 32` already produced; nonces
are generated internally per call and never exposed. An optional third
argument supplies associated data that binds a ciphertext to its context.
`unseal` fails closed and reports every failure identically.
- **File permissions** (#666): `file_mode of <path>` returns a file's mode as
four octal digits, and `set_file_mode of <path> and "0600"` sets it, so a
program can both restrict a file holding a secret and verify that it is
restricted. Unix implements real POSIX semantics; on Windows, reading returns
a documented approximation and setting raises an explicit unsupported error
rather than silently doing nothing.
- **TOML support** (#667): `parse_toml`, `stringify_toml` and
`stringify_toml_pretty`, mirroring the existing JSON functions. A TOML
document must be a table, and `nothing`-valued keys are omitted when writing
(TOML has no null); `nothing` inside a list is an error rather than a silent
drop.

### Fixed
- **Transaction control SQL sent through `query`/`execute` is now rejected**
(#664). `BEGIN`, `COMMIT`, `ROLLBACK`, `START TRANSACTION`, `SAVEPOINT` and
`RELEASE` previously ran on arbitrary pooled connections, so a hand-written
`BEGIN`/`ROLLBACK` sequence silently failed to cover the statements between
them: the rollback undid nothing, the writes survived, and no error was
raised. These statements now raise an error naming the transaction block.
Leading SQL comments are skipped before the keyword is read, so
`-- go\nBEGIN` is caught too; only the first real token is inspected, so
ordinary SQL that merely contains those words (a `begin_at` column, a
`'rollback plan'` value) still runs.

### Removed
- **Hand-written transaction control through `query`/`execute` no longer runs,
including on `sqlite::memory:`** (#664). On pooled backends the pattern never
worked, but an in-memory SQLite database is capped at a single connection, so
there `BEGIN`/`COMMIT`/`ROLLBACK` through `execute` did genuinely take effect.
Programs relying on that — most likely tests and examples, which commonly use
`sqlite::memory:` — now raise an error and must use `in transaction on db:`
instead. This is deliberate rather than a deprecation: the same code silently
corrupts data the moment it is pointed at a file-backed or networked database,
so "works in development, loses writes in production" is the behaviour being
removed. The error names the replacement construct, and the fix is mechanical.

- **Binding Repository Hygiene and Layout Policy** (`REPOSITORY_HYGIENE.md`,
governance §3.8) with a machine-readable profile (`.repo-hygiene.toml`) and a
dependency-free checker (`scripts/check_repo_hygiene.py`) enforced as a
Expand Down
112 changes: 105 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ encoding_rs = "0.8.35"
# and TLS backend; `tls-rustls` aliases the ring-backed rustls stack we used before.
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "mysql", "postgres", "chrono"] }
serde_json = "1.0.150"
# Backs the `parse_toml` / `stringify_toml` builtins, mirroring the serde_json-backed
# JSON surface. Ships TOML 1.0 support (and reads the 1.1 spec additions).
toml = "1.1.4"
# Keep Warp 0.3.x for its stable routing/filter API, but do not enable its legacy
# TLS adapter: that adapter pins rustls 0.22. WFL serves the same filters through
# Tokio-Rustls 0.26 below, preserving secured-listener behavior on rustls 0.23.
Expand Down Expand Up @@ -88,6 +91,11 @@ argon2 = "0.5"
scrypt = { version = "0.11", features = ["simple"] }
pbkdf2 = { version = "0.12", features = ["simple"] }
bcrypt = "0.19"
# Authenticated encryption for the `seal`/`unseal` builtins. XChaCha20-Poly1305
# specifically: its 192-bit nonce is wide enough that the implementation can mint
# a fresh random nonce per seal without a counter, so callers never touch nonces
# and cannot reuse one. `zeroize` wipes the expanded key on drop.
chacha20poly1305 = { version = "0.11.0", features = ["zeroize"] }
# Force newer version to fix future incompatibility warning
num-bigint-dig = "0.8.6"
# Direct dep so the lib can expose `init_rustls_crypto_provider()` (called by
Expand Down
102 changes: 102 additions & 0 deletions Docs/04-advanced-features/databases.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,108 @@ store row as rows[0]
store new_id as row["id"]
```

## Transactions

Some changes only make sense together. Moving money between two accounts is two
`UPDATE`s, and stopping halfway is worse than never starting. A transaction
block makes a group of statements all-or-nothing:

```wfl
in transaction on db:
store debited as execute db with "UPDATE accounts SET balance = balance - 100 WHERE id = 1"
store credited as execute db with "UPDATE accounts SET balance = balance + 100 WHERE id = 2"
end transaction
```

As everywhere else, `execute` is written as `store <name> as execute ...` — the
result object is always bound, even when you do not need it.

If both statements succeed, the block commits and the changes become permanent
when it reaches `end transaction`. If anything inside fails, everything the
block did is undone — including the statements that had already succeeded — and
the error is reported as usual, so you can catch it:

```wfl
try:
in transaction on db:
store claimed as execute db with "INSERT INTO jobs (slug, status) VALUES (?, 'running')" and parameters [slug]
store counted as execute db with "UPDATE counters SET running = running + 1"
end transaction
display "Job claimed."
when error:
display "Could not claim the job; nothing was changed."
end try
```

### What the block guarantees

- **One connection for the whole block.** `open database` maintains a pool of
connections, and outside a transaction each statement takes whichever one is
free. Inside the block, every statement runs on the same connection — that is
what makes the group atomic.
- **Reads see the block's own writes.** A `query` inside the block sees rows the
block has inserted but not yet committed. Other connections do not see them
until the block commits.
- **`break`, `continue` and `return` commit.** They are ordinary ways to leave a
block, so the work inside finished and is kept.
- **Errors and `exit` roll back.** A failed statement rolls the block back, and
so does `exit`, which stops the program where it stands rather than finishing
the block. A transaction still open when the program ends rolls back for the
same reason — an abrupt stop discards the partial work rather than
half-committing it.

### Restrictions

**Transaction blocks cannot be nested on the same database.** Starting a second
block on a database that already has one open is an error rather than a silent
flattening of one into the other. Nested transactions require savepoints, which
WFL does not currently expose.

**A database cannot be closed inside its own transaction.** `close database`
during an open block is an error; let the block finish first.

### Do not send BEGIN or COMMIT through `query` or `execute`

Writing transaction control as SQL does not work, and WFL now says so. Both
statements are checked, not just `execute`:

```wfl
store t as execute db with "BEGIN" // Error, with a pointer to the block syntax
store r as query db with "COMMIT" // Same error — `query` is checked too
```

The reason is the connection pool. `BEGIN`, the statements after it, and
`COMMIT` would each take a different pooled connection, so the transaction would
not cover the statements it appeared to wrap — a `ROLLBACK` would quietly undo
nothing while the writes it was meant to discard survived. `BEGIN`, `COMMIT`,
`ROLLBACK`, `START TRANSACTION`, `SAVEPOINT` and `RELEASE` are therefore
rejected with an error naming the block syntax above.

Only the first real token of a statement is checked, so ordinary SQL that merely
contains those words — a column named `begin_at`, a value of `'rollback plan'` —
runs normally. Leading comments are skipped before that token is read, so
`-- set up\nBEGIN` is refused rather than slipping past.

> **If this used to work for you, it worked by accident.** An in-memory SQLite
> database (`sqlite::memory:`) only ever has one connection, so hand-written
> transaction control did take effect there — and nowhere else. The same program
> pointed at a file-backed or networked database silently lost the writes it
> meant to roll back. That is why this is now an error everywhere rather than a
> warning: the pattern's failure mode was to pass in development and corrupt data
> in production. Replace it with the block above.

### Transactions and concurrent handlers

Under `main loop concurrently:` a transaction belongs to the handler that opened
it. Two requests can hold their own transactions on the same database handle at
the same time, and a handler that has no transaction of its own keeps taking a
pooled connection as usual — it is never enrolled in someone else's transaction,
and cannot have its writes committed or rolled back by another request.

> `transaction` is not a reserved word. It is recognized only in
> `in transaction on ...` and `end transaction`, so existing programs that use
> `transaction` as a variable name keep working.

## Returning Results from Actions

`query` and `execute` — with or without `and parameters [...]` — can be
Expand Down
Loading
Loading