with name = manager { body } runs body inside a resource. The manager's With behavior wraps the body: a transaction begins, runs the body, then commits or rolls back; a file opens, runs the body, then closes. The region is an expression. It yields what the body yields, and a failure leaving the body reaches the caller unchanged once cleanup is done.
Managers
A manager implements With.execute. Ship hands it the body as a callback; calling body runs the region and returns how it ended, a ScopeExit: Success, Failure with the cause, or Ended with the cause when the failure ended the resource itself (Terminal failures). execute decides the cleanup from that outcome and returns Unit, so it cannot replace the body's value or hide its failure. @ required means every path through execute calls body exactly once.
type Log = {
mut entries: Vector(String);
}
let notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit = fn(loglog: Book.ResourceScopes.Log: Log, entryentry: String: String) {
log.entries := Vector.pushpush: pure fn!('a)(items: Vector(String), item: String) -> Vector(String)(loglog: Book.ResourceScopes.Log.entriesentries: Vector(String), entryentry: String);
};
type Transaction = {
log: Log @ global;
}
let transactiontransaction: fn(log: Book.ResourceScopes.Log @ global) -> Book.ResourceScopes.Transaction = (loglog: Book.ResourceScopes.Log: Log) =>
Transaction { log = loglog: Book.ResourceScopes.Log; };
module behavior With(Transaction) {
executeexecute: fn(tx: Book.ResourceScopes.Transaction, body: (fn(Book.ResourceScopes.Transaction) -> ScopeExit('a)) @ required) -> Unit = fn(txtx: Book.ResourceScopes.Transaction: Transaction, bodybody: fn(Book.ResourceScopes.Transaction) -> ScopeExit('a): (fn(Transaction) -> ScopeExit('error)) @ required) -> Unit {
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(txtx: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log, "begin""begin": String);
match bodybody: fn(Book.ResourceScopes.Transaction) -> ScopeExit('a)(txtx: Book.ResourceScopes.Transaction) {
| Success -> notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(txtx: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log, "commit""commit": String)
| Failure(_) | Ended(_) -> notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(txtx: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log, "rollback""rollback": String)
}
};
}
let open_accountopen_account: fn(log: Book.ResourceScopes.Log @ global, owner: String @ global) -> String = fn(loglog: Book.ResourceScopes.Log, ownerowner: String) {
with txtx: Book.ResourceScopes.Transaction = transactiontransaction: fn(log: Book.ResourceScopes.Log @ global) -> Book.ResourceScopes.Transaction(loglog: Book.ResourceScopes.Log) {
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(txtx: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log, "insert ""insert ": String ++ ownerowner: String);
ownerowner: String
}
};
Regions Produce Values
let eventsevents: Book.ResourceScopes.Log = Log { entries = []; };
expect ✓open_accountopen_account: fn(log: Book.ResourceScopes.Log @ global, owner: String @ global) -> String(eventsevents: Book.ResourceScopes.Log, "ada""ada": String) == "ada""ada": String;
expect ✓eventsevents: Book.ResourceScopes.Log.entriesentries: Vector(String) == ["begin""begin": String, "insert ada""insert ada": String, "commit""commit": String];Failures reach the caller
A failure leaving the body is the body's Failure. The manager sees it, cleans up, and the same failure continues to the caller once execute returns. Nothing after the region runs, and nothing inside the manager can turn the failure into a value.
let abandonabandon: fn(log: Book.ResourceScopes.Log @ global) -> 'a, exn [ :abandoned ] = fn(loglog: Book.ResourceScopes.Log) {
with txtx: Book.ResourceScopes.Transaction = transactiontransaction: fn(log: Book.ResourceScopes.Log @ global) -> Book.ResourceScopes.Transaction(loglog: Book.ResourceScopes.Log) {
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(txtx: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log, "insert ada""insert ada": String);
error :abandoned;
};
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(loglog: Book.ResourceScopes.Log, "after the region""after the region": String);
};
Failures Reach The Caller
let eventsevents: Book.ResourceScopes.Log = Log { entries = []; };
let outcomeoutcome: String = try abandonabandon: fn(log: Book.ResourceScopes.Log @ global) -> String, exn [ :abandoned ](eventsevents: Book.ResourceScopes.Log) {
| :abandoned -> "abandoned""abandoned": String
};
expect ✓outcomeoutcome: String == "abandoned""abandoned": String;
expect ✓eventsevents: Book.ResourceScopes.Log.entriesentries: Vector(String) == ["begin""begin": String, "insert ada""insert ada": String, "rollback""rollback": String];Causes
A Failure carries a Cause: every reason the scope failed, in order. A reason is an expected error, a defect such as an out-of-bounds index, or an interruption. Cleanup is generic over the error type, so it can tell these apart and read a defect's message, while only the caller handles the error itself. A cleanup that fails adds its reason to the same cause.
let describedescribe: fn(cause: Cause('a)) -> String = fn(causecause: Cause('a): Cause('error)) {
match causecause: Cause('a).reasonsreasons: Vector(Cause.Reason('a))[00: Int] {
| Cause.Fail(_) -> "an error""an error": String
| Cause.Die(defectdefect: Cause.Defect) -> "a defect: ""a defect: ": String ++ defectdefect: Cause.Defect.messagemessage: String
| Cause.Interrupt(interruptioninterruption: Cause.Interruption) -> "an interruption: ""an interruption: ": String ++ interruptioninterruption: Cause.Interruption.reasonreason: String
}
};
Providers
A provider's drop sees the ScopeExit of its provide body in the same way. drop runs outside its own binding, so a provider that wraps a service can still reach the provider it wraps. This journal buffers writes and hands them to the outer journal only when its body succeeds.
effect service Journal {
write: fn(entry: String) -> Unit;
}
let journaljournal: fn(log: Book.ResourceScopes.Log) -> Service.Provider(Journal) = (loglog: Book.ResourceScopes.Log: Log) =>
module: Service.Provider(Journal) #{
let service Provider: Journal = {
type t = Unit
init = () => ()(): Unit;
drop = fn(selfself: t: t, exitexit: ScopeExit('a): ScopeExit('error)) -> Unit {};
write = fn(selfself: t: t, entryentry: String: String) -> Unit {
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(loglog: Book.ResourceScopes.Log, entryentry: String);
};
};
};
let service BufferedBuffered: service Journal: Journal = {
type t = {
mut pending: Vector(String);
}
init = () -> t =>
{ pending = []; };
drop = fn(selfself: t: t, exitexit: ScopeExit('a): ScopeExit('error)) -> Unit {
match exitexit: ScopeExit('a) {
| Success -> {
let mut indexindex: Int = 00: Int;
while indexindex: Int < Vector.lengthlength: pure fn!('a)(items: Vector(String)) -> Int(selfself: Book.ResourceScopes.Buffered.pendingpending: Vector(String)) {
Journal.writewrite: fn(entry: String) -> Unit, eff Journal(selfself: Book.ResourceScopes.Buffered.pendingpending: Vector(String)[indexindex: Int]);
index := indexindex: Int + 11: Int;
}
}
| Failure(causecause: Cause('a)) | Ended(causecause: Cause('a)) -> Journal.writewrite: fn(entry: String) -> Unit, eff Journal("discarded after ""discarded after ": String ++ describedescribe: fn(cause: Cause('a)) -> String(causecause: Cause('a)))
}
};
write = fn(selfself: t: t, entryentry: String: String) -> Unit {
self.pending := Vector.pushpush: pure fn!('a)(items: Vector(String), item: String) -> Vector(String)(selfself: Book.ResourceScopes.Buffered.pendingpending: Vector(String), entryentry: String);
};
};
let sign_upsign_up: fn(log: Book.ResourceScopes.Log, email: String) -> String, exn [ :blank ] = fn(loglog: Book.ResourceScopes.Log, emailemail: String) {
provide { Journal = journaljournal: fn(log: Book.ResourceScopes.Log) -> Service.Provider(Journal)(loglog: Book.ResourceScopes.Log); } {
provide { Journal = BufferedBuffered: service Journal; } {
Journal.writewrite: fn(entry: String) -> Unit, eff Journal("user ""user ": String ++ emailemail: String);
if emailemail: String == """": String {
error :blank;
}
Journal.writewrite: fn(entry: String) -> Unit, eff Journal("welcome ""welcome ": String ++ emailemail: String);
emailemail: String
}
}
};
let attemptattempt: fn(log: Book.ResourceScopes.Log, email: String) -> String = fn(loglog: Book.ResourceScopes.Log, emailemail: String) {
try sign_upsign_up: fn(log: Book.ResourceScopes.Log, email: String) -> String, exn [ :blank ](loglog: Book.ResourceScopes.Log, emailemail: String) {
| :blank -> "blank""blank": String
}
};
Providers See How Their Scope Ended
let eventsevents: Book.ResourceScopes.Log = Log { entries = []; };
expect ✓(attemptattempt: fn(log: Book.ResourceScopes.Log, email: String) -> String(eventsevents: Book.ResourceScopes.Log, "ada@ship.dev""ada@ship.dev": String), attemptattempt: fn(log: Book.ResourceScopes.Log, email: String) -> String(eventsevents: Book.ResourceScopes.Log, """": String)) == ("ada@ship.dev""ada@ship.dev": String, "blank""blank": String);
expect ✓eventsevents: Book.ResourceScopes.Log.entriesentries: Vector(String) == [
"user ada@ship.dev""user ada@ship.dev": String,
"welcome ada@ship.dev""welcome ada@ship.dev": String,
"discarded after an error""discarded after an error": String,
];Managers with effects
execute is an ordinary function, so it can use services. A region performs what its manager's execute performs as well as what its body does, so the enclosing function lists both. This audited gate writes to the journal when it opens and when it closes.
type Gate = {
name: String;
}
module behavior With(Gate) {
executeexecute: fn(gate: Book.ResourceScopes.Gate, body: (fn(Book.ResourceScopes.Gate) -> ScopeExit('a)) @ required) -> Unit, eff Journal = fn(gategate: Book.ResourceScopes.Gate: Gate, bodybody: fn(Book.ResourceScopes.Gate) -> ScopeExit('a): (fn(Gate) -> ScopeExit('error)) @ required) -> Unit {
Journal.writewrite: fn(entry: String) -> Unit, eff Journal("open ""open ": String ++ gategate: Book.ResourceScopes.Gate.namename: String);
let _exit_exit: ScopeExit('a) = bodybody: fn(Book.ResourceScopes.Gate) -> ScopeExit('a)(gategate: Book.ResourceScopes.Gate);
Journal.writewrite: fn(entry: String) -> Unit, eff Journal("close ""close ": String ++ gategate: Book.ResourceScopes.Gate.namename: String);
};
}
let open_gateopen_gate: fn(name: String) -> Book.ResourceScopes.Gate = (namename: String: String) =>
Gate { name = namename: String; };
let passpass: fn(name: String) -> String, eff Journal = fn(namename: String) {
with gategate: Book.ResourceScopes.Gate = open_gateopen_gate: fn(name: String) -> Book.ResourceScopes.Gate(namename: String) {
gategate: Book.ResourceScopes.Gate.namename: String
}
};
Managers Add Their Effects
✓passpass: fn(name: String) -> String, eff Journal: fn(name: String) -> String, eff Journal;
let eventsevents: Book.ResourceScopes.Log = Log { entries = []; };
let passedpassed: String = provide { Journal = journaljournal: fn(log: Book.ResourceScopes.Log) -> Service.Provider(Journal)(eventsevents: Book.ResourceScopes.Log); } {
passpass: fn(name: String) -> String, eff Journal("front""front": String)
};
expect ✓passedpassed: String == "front""front": String;
expect ✓eventsevents: Book.ResourceScopes.Log.entriesentries: Vector(String) == ["open front""open front": String, "close front""close front": String];Recovering where the scope ends
try name = manager { body } { arms } is try around the region with name = manager { body }. The body is the scope of name; the arms sit outside it and run after cleanup, so they handle what ended the scope and never see name. A savepoint recovers a failed insert this way while its transaction keeps going.
let savepointsavepoint: fn(tx: Book.ResourceScopes.Transaction) -> Book.ResourceScopes.Transaction = (txtx: Book.ResourceScopes.Transaction: Transaction) =>
Transaction { log = txtx: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log; };
let open_pairopen_pair: fn(log: Book.ResourceScopes.Log @ global) -> String = fn(loglog: Book.ResourceScopes.Log) {
with txtx: Book.ResourceScopes.Transaction = transactiontransaction: fn(log: Book.ResourceScopes.Log @ global) -> Book.ResourceScopes.Transaction(loglog: Book.ResourceScopes.Log) {
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(txtx: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log, "insert ada""insert ada": String);
let secondsecond: String = try spsp: Book.ResourceScopes.Transaction = savepointsavepoint: fn(tx: Book.ResourceScopes.Transaction) -> Book.ResourceScopes.Transaction(txtx: Book.ResourceScopes.Transaction) {
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(spsp: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log, "insert ada again""insert ada again": String);
error :duplicate("ada""ada": String);
} {
| :duplicate(ownerowner: String) -> "skipped ""skipped ": String ++ ownerowner: String
};
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(txtx: Book.ResourceScopes.Transaction.loglog: Book.ResourceScopes.Log, secondsecond: String);
secondsecond: String
}
};
Savepoints Recover Inside A Transaction
let eventsevents: Book.ResourceScopes.Log = Log { entries = []; };
expect ✓open_pairopen_pair: fn(log: Book.ResourceScopes.Log @ global) -> String(eventsevents: Book.ResourceScopes.Log) == "skipped ada""skipped ada": String;
expect ✓eventsevents: Book.ResourceScopes.Log.entriesentries: Vector(String) == [
"begin""begin": String,
"insert ada""insert ada": String,
"begin""begin": String,
"insert ada again""insert ada again": String,
"rollback""rollback": String,
"skipped ada""skipped ada": String,
"commit""commit": String,
];Any manager works. A line reader opens, hands its lines to the body, and closes after the body returns, whichever way it ended. execute needs no defer for that: calling body reports the failure as a value instead of raising it.
type Lines = {
log: Log @ global;
items: Vector(String);
}
let open_linesopen_lines: fn(log: Book.ResourceScopes.Log @ global, items: Vector(String)) -> Book.ResourceScopes.Lines = (loglog: Book.ResourceScopes.Log: Log, itemsitems: Vector(String): Vector(String)) =>
Lines { log = loglog: Book.ResourceScopes.Log; items = itemsitems: Vector(String); };
module behavior With(Lines) {
executeexecute: fn(lines: Book.ResourceScopes.Lines, body: (fn(Book.ResourceScopes.Lines) -> ScopeExit('a)) @ required) -> Unit = fn(lineslines: Book.ResourceScopes.Lines: Lines, bodybody: fn(Book.ResourceScopes.Lines) -> ScopeExit('a): (fn(Lines) -> ScopeExit('error)) @ required) -> Unit {
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(lineslines: Book.ResourceScopes.Lines.loglog: Book.ResourceScopes.Log, "open""open": String);
let _exit_exit: ScopeExit('a) = bodybody: fn(Book.ResourceScopes.Lines) -> ScopeExit('a)(lineslines: Book.ResourceScopes.Lines);
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(lineslines: Book.ResourceScopes.Lines.loglog: Book.ResourceScopes.Log, "close""close": String);
};
}
let titletitle: fn(log: Book.ResourceScopes.Log @ global, items: Vector(String)) -> String = fn(loglog: Book.ResourceScopes.Log, itemsitems: Vector(String)) {
try lineslines: Book.ResourceScopes.Lines = open_linesopen_lines: fn(log: Book.ResourceScopes.Log @ global, items: Vector(String)) -> Book.ResourceScopes.Lines(loglog: Book.ResourceScopes.Log, itemsitems: Vector(String)) {
if Vector.is_emptyis_empty: pure fn!('a)(items: Vector(String)) -> Bool(lineslines: Book.ResourceScopes.Lines.itemsitems: Vector(String)) {
error :empty;
}
lineslines: Book.ResourceScopes.Lines.itemsitems: Vector(String)[00: Int]
} {
| :empty -> {
notenote: fn(log: Book.ResourceScopes.Log @ global, entry: String @ global) -> Unit(loglog: Book.ResourceScopes.Log, "no title""no title": String);
"untitled""untitled": String
}
}
};
Recovering After A Reader Closes
let eventsevents: Book.ResourceScopes.Log = Log { entries = []; };
expect ✓titletitle: fn(log: Book.ResourceScopes.Log @ global, items: Vector(String)) -> String(eventsevents: Book.ResourceScopes.Log, ["Ship""Ship": String, "a language""a language": String]) == "Ship""Ship": String;
expect ✓titletitle: fn(log: Book.ResourceScopes.Log @ global, items: Vector(String)) -> String(eventsevents: Book.ResourceScopes.Log, []) == "untitled""untitled": String;
expect ✓eventsevents: Book.ResourceScopes.Log.entriesentries: Vector(String) == ["open""open": String, "close""close": String, "open""open": String, "close""close": String, "no title""no title": String];Leaving a region
A with body ends only with its value or its failure: return and break cannot leave it. Make the body's value the region's result and leave after the region. Rejected programs shows these and the other programs Ship rejects here.