A database service hands out transactions; it does not run statements on its own. Every generated operation, reads included, takes the transaction as its first argument, so code that touches the database always names the transaction it goes through. A with region owns the outcome: Ledger.transaction() begins when the region opens, commits when its body finishes, and rolls back when any failure leaves it. The region's value is the body's value, and a failure reaches the caller after the rollback.
The region is the only way to begin or end a transaction. Database keeps its begin, commit, and rollback builtins private, so no code can open a transaction without closing it.
A transaction begins, commits, and rolls back only through its with region. Database.shp declares those builtins privately, so code outside it cannot name them.
database Ledger(Sqlite) {
table Accounts {
id: Int #{ primary = True; };
owner: String;
}
}
let commit_by_hand = fn(tx: Database.Transaction(Ledger)) {
Database.begin(tx);
Database.commit(tx);
};
$ship-types-test lifecycle-outside-region.shp
error: `Database` has no member `begin`
--> lifecycle-outside-region.shp:9:3
|
9 | Database.begin(tx);
| ^^^^^^^^^^^^^^ not found in `Database`
error: `Database` has no member `commit`
--> lifecycle-outside-region.shp:10:3
|
10 | Database.commit(tx);
| ^^^^^^^^^^^^^^^ not found in `Database`Only the standard library declares builtins, in its contracts or privately in its sources.
let zero: pure fn!() -> Int = #builtin(
id = "int_zero",
runtime = %{ Native },
comptime = Effectful,
);
$ship-types-test app-builtin.shp
error: `#builtin` is only valid in the standard library
--> app-builtin.shp:1:32
|
1 | let zero: pure fn!() -> Int = #builtin(
| ^^^^^^^ app and package sources cannot create compiler-owned builtinsdatabase Ledger(Sqlite) {
table Accounts {
id: Int #{
primary = True;
rowid = True;
autoincrement = True;
};
owner: String #{ unique = True; };
balance: Int;
}
table Audits {
id: Int #{
primary = True;
rowid = True;
autoincrement = True;
};
account_id: Int;
note: String;
}
}
Writing through a transaction
Helpers take the transaction like any other argument. A read goes through it too, so a read inside a region sees the region's own writes.
let open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ] = fn(txtx: Database.Transaction(Ledger), ownerowner: String) {
Ledger.Accounts.insertinsert: fn(tx: Database.Transaction(Ledger), input: Ledger.Accounts.Insert) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](
txtx: Database.Transaction(Ledger),
{ owner = ownerowner: String; balance = 100100: Int; },
)
};
let ownersowners: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger = fn() {
with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
Vector.mapmap: pure fn!('a, 'b)(items: Vector(Ledger.Accounts.Row) @ 'm, ~f: fn(value: Ledger.Accounts.Row @ 'm) -> String) -> Vector(String)(
Ledger.Accounts.allall: fn(tx: Database.Transaction(Ledger)) -> Vector(Ledger.Accounts.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(Ledger)),
f = (accountaccount: Ledger.Accounts.Row) => accountaccount: Ledger.Accounts.Row.ownerowner: String,
)
}
};
let accounts_owned_byaccounts_owned_by: fn(tx: Database.Transaction(Ledger), owner: String) -> Vector(Ledger.Accounts.Row), exn! tx [> :db.rejected(String) ] = query(ownerowner: String: String) {
from account in Ledger.Accounts,
where account.owner == owner,
select account;
};
Opening a transaction needs the Ledger service. An operation needs only the transaction: holding tx is the capability to use it. A query takes the transaction before its own parameters.
A statement the database refuses ends the transaction, so every operation lists its refusals as exn! tx. Updating or deleting a key that does not exist is different: it is an ordinary :db.not_found(key) failure, and the transaction keeps going.
Transaction API
✓Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger: fn!() -> Database.Transaction(Ledger), eff Ledger;
✓Ledger.Accounts.allall: fn(tx: Database.Transaction(Ledger)) -> Vector(Ledger.Accounts.Row), exn! tx [> :db.rejected(String) ]: fn(tx: Database.Transaction(Ledger)) -> Vector(Ledger.Accounts.Row),
exn! tx [> :db.rejected(String) ];
✓Ledger.Accounts.insertinsert: fn(tx: Database.Transaction(Ledger), input: Ledger.Accounts.Insert) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ]: fn(
tx: Database.Transaction(Ledger),
input: Ledger.Accounts.Insert,
) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ];
✓Ledger.Accounts.updateupdate: fn(tx: Database.Transaction(Ledger), id: Int, patch: Ledger.Accounts.Update) -> Ledger.Accounts.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ]: fn(
tx: Database.Transaction(Ledger),
id: Int,
patch: Ledger.Accounts.Update,
) -> Ledger.Accounts.Row,
exn [> :db.not_found(Int) ],
exn! tx [> :db([ :rejected(String) | :unique(String) ]) ];
✓Ledger.Accounts.deletedelete: fn(tx: Database.Transaction(Ledger), id: Int) -> Ledger.Accounts.Row, exn [> :db.not_found(Int) ], exn! tx [> :db.rejected(String) ]: fn(
tx: Database.Transaction(Ledger),
id: Int,
) -> Ledger.Accounts.Row, exn [> :db.not_found(Int) ], exn! tx [> :db.rejected(String) ];
✓accounts_owned_byaccounts_owned_by: fn(tx: Database.Transaction(Ledger), owner: String) -> Vector(Ledger.Accounts.Row), exn! tx [> :db.rejected(String) ]: fn(
tx: Database.Transaction(Ledger),
owner: String,
) -> Vector(Ledger.Accounts.Row), exn! tx [> :db.rejected(String) ];
✓open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ]: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row,
exn! tx [> :db([ :rejected(String) | :unique(String) ]) ];
✓ownersowners: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger;owners shows the region boundary. Inside the region a refused read ends tx; after the region it is an ordinary exn its caller handles.
Refused statements
The database refuses a statement with one of two tags:
:db.unique(detail): an insert or update broke a unique constraint. SQLite reports the column astable.column; Postgres reports the constraint's name.:db.rejected(message): anything else, with the driver's message.Only inserts and updates can break a unique constraint, so reads and deletes list only
:rejected.
let open_pairopen_pair: fn(first: String, second: String) -> Ledger.Accounts.Row, exn [> :db([ :rejected(String) | :unique(String) ]) ], eff Ledger = fn(firstfirst: String, secondsecond: String) {
with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
let openedopened: Ledger.Accounts.Row = open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(Ledger), firstfirst: String);
ignoreignore: pure fn!('a)(value: Ledger.Accounts.Row) -> Unit(open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(Ledger), secondsecond: String));
openedopened: Ledger.Accounts.Row
}
};
let open_then_abandonopen_then_abandon: fn() -> 'a, exn [> :abandoned | :db([ :rejected(String) | :unique(String) ]) ], eff Ledger = fn() {
with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
ignoreignore: pure fn!('a)(value: Ledger.Accounts.Row) -> Unit(open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(Ledger), "ada""ada": String));
error :abandoned;
}
};
Savepoints
try sp = Ledger.savepoint(tx) { ... } { ... } runs its body in a savepoint of tx. The body is the savepoint's scope and the arms sit outside it, so a failure that ends sp rolls back only the savepoint's work, the arms handle it, and tx keeps going.
let open_secondopen_second: fn(first: String, second: String) -> String, exn [> :db([ :rejected(String) | :unique(String) ]) ], eff Ledger = fn(firstfirst: String, secondsecond: String) {
with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
let openedopened: Ledger.Accounts.Row = open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(Ledger), firstfirst: String);
let notenote: String = try spsp: Database.Transaction(Ledger) = Ledger.savepointsavepoint: fn!(tx: Database.Transaction(Ledger)) -> Database.Transaction(Ledger)(txtx: Database.Transaction(Ledger)) {
let accountaccount: Ledger.Accounts.Row = open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](spsp: Database.Transaction(Ledger), secondsecond: String);
"opened ""opened ": String ++ accountaccount: Ledger.Accounts.Row.ownerowner: String
} {
| :db.unique(_) -> "duplicate skipped""duplicate skipped": String
| :db.rejected(reasonreason: String) -> "rejected: ""rejected: ": String ++ reasonreason: String
};
Ledger.Audits.insertinsert: fn(tx: Database.Transaction(Ledger), input: Ledger.Audits.Insert) -> Ledger.Audits.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](
txtx: Database.Transaction(Ledger),
{ account_id = openedopened: Ledger.Accounts.Row.idid: Int; note = notenote: String; },
).notenote: String
}
};
While the savepoint is open, sp borrows tx. A statement through tx inside the body is a defect: the runtime refuses it before it reaches the database. Ship does not check this statically yet.
While a savepoint is open it borrows its transaction: statements run through the savepoint. A statement through the borrowed transaction is a defect the runtime reports before it reaches the database. Ship does not reject it statically yet.
database Ledger(Sqlite) {
table Accounts {
id: Int #{
primary = True;
rowid = True;
autoincrement = True;
};
owner: String;
}
}
let open_through_parent = fn() {
with tx = Ledger.transaction() {
with sp = Ledger.savepoint(tx) {
Ledger.Accounts.insert(tx, { owner = "ada"; }).owner
}
}
};
let open_through_savepoint = fn() {
with tx = Ledger.transaction() {
with sp = Ledger.savepoint(tx) {
Ledger.Accounts.insert(sp, { owner = "ada"; }).owner
}
}
};
suite "Savepoints" {
providing Ledger = Test.Service.per_test((_test) => Ledger.test_database());
test "Statements Go Through The Savepoint" {
expect must open_through_savepoint() == "ada";
}
test "A Savepoint Borrows Its Transaction" {
expect must open_through_parent() == "ada";
}
}
$ship test input.shp
input.shp: 1 passed, 1 failed
passed: 1 failed: 1
error: runtime call failed
--> <generated>
| the transaction is borrowed by an open savepoint; run statements through the savepoint
error: ship defect: `must` failedEnding a transaction on purpose
error! tx, :tag ends the transaction with a domain failure. Nothing inside the region can catch it and commit half the work.
let withdrawwithdraw: fn(tx: Database.Transaction(Ledger), account: Ledger.Accounts.Row, amount: Int) -> Ledger.Accounts.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) | :insufficient_funds(Int) ] = fn(txtx: Database.Transaction(Ledger), accountaccount: Ledger.Accounts.Row: Ledger.Accounts.Row, amountamount: Int) {
if accountaccount: Ledger.Accounts.Row.balancebalance: Int < amountamount: Int {
error! tx, :insufficient_funds(accountaccount: Ledger.Accounts.Row.idid: Int);
}
Ledger.Accounts.updateupdate: fn(tx: Database.Transaction(Ledger), id: Int, patch: Ledger.Accounts.Update) -> Ledger.Accounts.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](
txtx: Database.Transaction(Ledger),
accountaccount: Ledger.Accounts.Row.idid: Int,
{ balance = accountaccount: Ledger.Accounts.Row.balancebalance: Int - amountamount: Int; },
)
};
let audited_withdrawaudited_withdraw: fn(account: Ledger.Accounts.Row, amount: Int) -> Int, exn [> :db([ :not_found(Int) | :rejected(String) | :unique(String) ]) | :insufficient_funds(Int) ], eff Ledger = fn(accountaccount: Ledger.Accounts.Row: Ledger.Accounts.Row, amountamount: Int) {
with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
ignoreignore: pure fn!('a)(value: Ledger.Audits.Row) -> Unit(
Ledger.Audits.insertinsert: fn(tx: Database.Transaction(Ledger), input: Ledger.Audits.Insert) -> Ledger.Audits.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](
txtx: Database.Transaction(Ledger),
{ account_id = accountaccount: Ledger.Accounts.Row.idid: Int; note = "withdraw""withdraw": String; },
),
);
withdrawwithdraw: fn(tx: Database.Transaction(Ledger), account: Ledger.Accounts.Row, amount: Int) -> Ledger.Accounts.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) | :insufficient_funds(Int) ](txtx: Database.Transaction(Ledger), accountaccount: Ledger.Accounts.Row, amountamount: Int).balancebalance: Int
}
};
Failure Types
✓open_secondopen_second: fn(first: String, second: String) -> String, exn [> :db([ :rejected(String) | :unique(String) ]) ], eff Ledger: fn(first: String, second: String) -> String,
exn [> :db([ :rejected(String) | :unique(String) ]) ],
eff Ledger;
✓withdrawwithdraw: fn(tx: Database.Transaction(Ledger), account: Ledger.Accounts.Row, amount: Int) -> Ledger.Accounts.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) | :insufficient_funds(Int) ]: fn(
tx: Database.Transaction(Ledger),
account: Ledger.Accounts.Row,
amount: Int,
) -> Ledger.Accounts.Row,
exn [> :db.not_found(Int) ],
exn! tx [> :db([ :rejected(String) | :unique(String) ]) | :insufficient_funds(Int) ];Running transactions
Test bodies handle every failure, so they open regions with must or handle :db alongside the failures they expect.
suite · Transactions
providing Ledger = Test.Service.per_testper_test: pure fn!('service)(factory: pure fn!(case: Test.Case) -> Service.Provider(Ledger)) -> pure fn!(case: Test.Case) -> Service.Provider(Ledger)((_test_test: Test.Case) => Ledger.test_databasetest_database: pure fn!() -> Service.Provider(Ledger)());Writes Through A Transaction
suite · TransactionsLedger · per test let accountaccount: Ledger.Accounts.Row = must with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(Ledger), "ada""ada": String)
};
expect ✓accountaccount: Ledger.Accounts.Row.ownerowner: String == "ada""ada": String;
expect ✓must ownersowners: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger() == ["ada""ada": String];
let foundfound: Vector(Ledger.Accounts.Row) = must with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
accounts_owned_byaccounts_owned_by: fn(tx: Database.Transaction(Ledger), owner: String) -> Vector(Ledger.Accounts.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(Ledger), "ada""ada": String)
};
expect ✓foundfound: Vector(Ledger.Accounts.Row) == [accountaccount: Ledger.Accounts.Row];
Any failure leaving the region rolls it back, then reaches the caller unchanged.
Failures Roll Back
suite · TransactionsLedger · per test let outcomeoutcome: String = try open_then_abandonopen_then_abandon: fn() -> String, exn [> :abandoned | :db([ :rejected(String) | :unique(String) ]) ], eff Ledger() {
| :abandoned -> "abandoned""abandoned": String
| :db(_) -> "database failed""database failed": String
};
expect ✓outcomeoutcome: String == "abandoned""abandoned": String;
expect ✓must ownersowners: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger() == [];
A refused statement cannot be handled inside the region: the region rolls back, including the statements that succeeded before it, and the caller handles an ordinary failure.
Failed Statements End The Transaction
suite · TransactionsLedger · per test let adaada: Ledger.Accounts.Row = must open_pairopen_pair: fn(first: String, second: String) -> Ledger.Accounts.Row, exn [> :db([ :rejected(String) | :unique(String) ]) ], eff Ledger("ada""ada": String, "grace""grace": String);
expect ✓adaada: Ledger.Accounts.Row.ownerowner: String == "ada""ada": String;
let outcomeoutcome: String = match open_pairopen_pair: fn(first: String, second: String) -> Ledger.Accounts.Row, exn [> :db([ :rejected(String) | :unique(String) ]) ], eff Ledger("linus""linus": String, "ada""ada": String) {
| openedopened: Ledger.Accounts.Row -> openedopened: Ledger.Accounts.Row.ownerowner: String
| error :db.unique(_) -> "owner taken""owner taken": String
| error :db(_) -> "database failed""database failed": String
};
expect ✓outcomeoutcome: String == "owner taken""owner taken": String;
expect ✓must ownersowners: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger() == ["ada""ada": String, "grace""grace": String];
The refused savepoint leaves linus and the audit row in place. A savepoint that succeeds keeps its work.
Savepoints Recover Inside A Transaction
suite · TransactionsLedger · per test must open_pairopen_pair: fn(first: String, second: String) -> Ledger.Accounts.Row, exn [> :db([ :rejected(String) | :unique(String) ]) ], eff Ledger("ada""ada": String, "grace""grace": String);
expect ✓must open_secondopen_second: fn(first: String, second: String) -> String, exn [> :db([ :rejected(String) | :unique(String) ]) ], eff Ledger("linus""linus": String, "ada""ada": String) == "duplicate skipped""duplicate skipped": String;
expect ✓must ownersowners: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger() == ["ada""ada": String, "grace""grace": String, "linus""linus": String];
expect ✓must open_secondopen_second: fn(first: String, second: String) -> String, exn [> :db([ :rejected(String) | :unique(String) ]) ], eff Ledger("alan""alan": String, "barbara""barbara": String) == "opened barbara""opened barbara": String;
expect ✓must ownersowners: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger() == ["ada""ada": String, "grace""grace": String, "linus""linus": String, "alan""alan": String, "barbara""barbara": String];
Domain Failures End The Transaction
suite · TransactionsLedger · per test let adaada: Ledger.Accounts.Row = must with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
Ledger.Accounts.insertinsert: fn(tx: Database.Transaction(Ledger), input: Ledger.Accounts.Insert) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(Ledger), { owner = "ada""ada": String; balance = 1010: Int; })
};
let outcomeoutcome: String = match audited_withdrawaudited_withdraw: fn(account: Ledger.Accounts.Row, amount: Int) -> Int, exn [> :db([ :not_found(Int) | :rejected(String) | :unique(String) ]) | :insufficient_funds(Int) ], eff Ledger(adaada: Ledger.Accounts.Row, 5050: Int) {
| _ -> "withdrew""withdrew": String
| error :insufficient_funds(_) -> "declined""declined": String
| error :db(_) -> "database failed""database failed": String
};
expect ✓outcomeoutcome: String == "declined""declined": String;
let auditsaudits: Vector(Ledger.Audits.Row) = must with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
Ledger.Audits.allall: fn(tx: Database.Transaction(Ledger)) -> Vector(Ledger.Audits.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(Ledger))
};
expect ✓auditsaudits: Vector(Ledger.Audits.Row) == [];
One connection, one region at a time
A region leases its connection for as long as it is open. A second region on the same connection waits for the first to commit or roll back, then sees its committed work. The lease goes back only when the connection is outside any transaction.
Regions Take Turns On A Connection
suite · TransactionsLedger · per test let (~signal: writtenwritten: Signal, ~listener: writewrite: Signal.Listener) = Signal.makemake: pure fn!() -> (signal: Signal, listener: Signal.Listener)();
let (~signal: waitingwaiting: Signal, ~listener: waitwait: Signal.Listener) = Signal.makemake: pure fn!() -> (signal: Signal, listener: Signal.Listener)();
let readerreader: Async.Task(Vector(String)) = spawn {
await writewrite: Signal.Listener;
Signal.firefire: pure fn!(signal: Signal) -> Unit(waitingwaiting: Signal);
must ownersowners: fn() -> Vector(String), exn [> :db.rejected(String) ], eff Ledger()
};
let adaada: Ledger.Accounts.Row = must with txtx: Database.Transaction(Ledger) = Ledger.transactiontransaction: fn!() -> Database.Transaction(Ledger), eff Ledger() {
let adaada: Ledger.Accounts.Row = open_accountopen_account: fn(tx: Database.Transaction(Ledger), owner: String) -> Ledger.Accounts.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(Ledger), "ada""ada": String);
Signal.firefire: pure fn!(signal: Signal) -> Unit(writtenwritten: Signal);
await waitwait: Signal.Listener;
adaada: Ledger.Accounts.Row
};
expect ✓adaada: Ledger.Accounts.Row.ownerowner: String == "ada""ada": String;
expect ✓await readerreader: Async.Task(Vector(String)) == ["ada""ada": String];