Ship

Book · Databases

Databases

Draft

Declare a SQLite schema and use its generated, typed API.

On this page · 10 sections

A Ship database declaration is both a schema and an application API. The dialect is explicit; this page uses SQLite for a small local todo database. Postgres has its own broader surface rather than pretending both dialects are interchangeable.

Continue with PostgreSQL schemas and the checked-in, snapshot-addressed ship db migration workflow. Parallel changes to different tables, changes to one table, and migration conflicts walk through PR integration case by case. Transactions shows how writes go through an explicit transaction.

A schema

Fields describe stored values. Metadata describes facts the compiler, SQL generator, migrations, and generated operations all share.

database AppDb(Sqlite) {
  table Todos {
    id: Int #{
      primary = True;
      rowid = True;
      autoincrement = True;
    };

    title: String;
    note: Option(String);
    completed: Bool #{ default = False; };
  }
}

Generated table operations

The declaration creates Row, Insert, and Update types under AppDb.Todos. It also creates operations from the schema facts. insert accepts no id because SQLite generates it, and callers may omit completed because the field has a default. A table with a primary key also gets find, which returns the one row with that key or None. Every operation takes the transaction it runs in as its first argument.

let add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ] = (txtx: Database.Transaction(AppDb), titletitle: String) =>
  AppDb.Todos.insertinsert: fn(tx: Database.Transaction(AppDb), input: AppDb.Todos.Insert) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), { title = titletitle: String; note = None; });

let complete_todocomplete_todo: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ] = (txtx: Database.Transaction(AppDb), idid: Int) =>
  AppDb.Todos.updateupdate: fn(tx: Database.Transaction(AppDb), id: Int, patch: AppDb.Todos.Update) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), idid: Int, { completed = TrueTrue: Bool; });

Generated Table API

test✓
  ✓add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ]: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row,
    exn! tx [> :db([ :rejected(String) | :unique(String) ]) ];
  ✓complete_todocomplete_todo: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ]: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row,
    exn [> :db.not_found(Int) ],
    exn! tx [> :db([ :rejected(String) | :unique(String) ]) ];
  ✓AppDb.Todos.allall: fn(tx: Database.Transaction(AppDb)) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ]: fn(tx: Database.Transaction(AppDb)) -> Vector(AppDb.Todos.Row),
    exn! tx [> :db.rejected(String) ];
  ✓AppDb.Todos.findfind: fn(tx: Database.Transaction(AppDb), id: Int) -> Option(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ]: fn(tx: Database.Transaction(AppDb), id: Int) -> Option(AppDb.Todos.Row),
    exn! tx [> :db.rejected(String) ];
  ✓AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb: fn!() -> Database.Transaction(AppDb), eff AppDb;
  ✓AppDb.test_databasetest_database: pure fn!() -> Service.Provider(AppDb): pure fn!() -> Service.Provider(AppDb);

eff AppDb says opening a transaction needs this particular database service. It does not collapse every SQLite database into one global capability. An operation needs only its transaction: holding tx is the capability to use it.

Queries and updates

A SQLite declaration also creates test_database. The provider opens a new in-memory database, applies this declaration's generated schema before each test, and closes the connection afterward. Tests use the exact typed API the application uses; no test-only query layer or handwritten setup SQL exists. A with region commits when its body finishes, and must states that the test expects no failure.

suite · SQLite Database
  providing AppDb = Test.Service.per_testper_test: pure fn!('service)(factory: pure fn!(case: Test.Case) -> Service.Provider(AppDb)) -> pure fn!(case: Test.Case) -> Service.Provider(AppDb)((_test_test: Test.Case) => AppDb.test_databasetest_database: pure fn!() -> Service.Provider(AppDb)());

An omitted completed field takes the schema's False default. SQLite supplies the autoincrementing id.

Inserts With Defaults

suite · SQLite DatabaseAppDb · per test
test✓
    let insertedinserted: AppDb.Todos.Row = must with txtx: Database.Transaction(AppDb) = AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb() {
      add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Write an executable chapter""Write an executable chapter": String)
    };
    expect ✓insertedinserted: AppDb.Todos.Row == {
      id = 11: Int;
      title = "Write an executable chapter""Write an executable chapter": String;
      note = None;
      completed = FalseFalse: Bool;
    };
  

find looks a row up by its primary key.

Finds By Primary Key

suite · SQLite DatabaseAppDb · per test
test✓
    let (insertedinserted: AppDb.Todos.Row, foundfound: Option(AppDb.Todos.Row), missingmissing: Option(AppDb.Todos.Row)) = must with txtx: Database.Transaction(AppDb) = AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb() {
      let insertedinserted: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Find me""Find me": String);
      (
        insertedinserted: AppDb.Todos.Row,
        AppDb.Todos.findfind: fn(tx: Database.Transaction(AppDb), id: Int) -> Option(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(AppDb), insertedinserted: AppDb.Todos.Row.idid: Int),
        AppDb.Todos.findfind: fn(tx: Database.Transaction(AppDb), id: Int) -> Option(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(AppDb), insertedinserted: AppDb.Todos.Row.idid: Int + 11: Int),
      )
    };
    expect ✓foundfound: Option(AppDb.Todos.Row) == SomeSome: fn(AppDb.Todos.Row) -> Option(AppDb.Todos.Row)(insertedinserted: AppDb.Todos.Row);
    expect ✓missingmissing: Option(AppDb.Todos.Row) == NoneNone: Option(AppDb.Todos.Row);
  

Filtering rows

Inside a query, item is a table binding and its fields are SQL columns. Outside the query block, item.completed would be ordinary Ship field access; it never silently constructs SQL. A query takes the transaction before its own parameters, so a query without parameters is called with just tx.

Filters Rows

suite · SQLite DatabaseAppDb · per test
test✓
    let remaining_todosremaining_todos: fn(tx: Database.Transaction(AppDb)) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ] = query() {
      from item in AppDb.Todos,
      where item.completed == False,
      select item;
    };
    ✓remaining_todosremaining_todos: fn(tx: Database.Transaction(AppDb)) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ]: fn(tx: Database.Transaction(AppDb)) -> Vector(AppDb.Todos.Row),
      exn! tx [> :db.rejected(String) ];

    let (remainingremaining: AppDb.Todos.Row, remaining_rowsremaining_rows: Vector(AppDb.Todos.Row)) = must with txtx: Database.Transaction(AppDb) = AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb() {
      let remainingremaining: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Keep this todo""Keep this todo": String);
      let completedcompleted: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Finish this todo""Finish this todo": String);
      ignoreignore: pure fn!('a)(value: AppDb.Todos.Row) -> Unit(complete_todocomplete_todo: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), completedcompleted: AppDb.Todos.Row.idid: Int));
      (remainingremaining: AppDb.Todos.Row, remaining_todosremaining_todos: fn(tx: Database.Transaction(AppDb)) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(AppDb)))
      vector [
        AppDb.Todos.Row {
          id = 1;
          title = "Keep this todo";
          note = None;
          completed = False
        }
      ]    };
    expect ✓[remainingremaining: AppDb.Todos.Row] == remaining_rowsremaining_rows: Vector(AppDb.Todos.Row);
  

Query parameters

Query arguments are database binds. Even punctuation that would be meaningful in handwritten SQL remains ordinary string data. An annotation states the parameter type directly.

Binds Parameters

suite · SQLite DatabaseAppDb · per test
test✓
    let todos_with_titletodos_with_title: fn(tx: Database.Transaction(AppDb), title: String) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ] = query(titletitle: String: String) {
      from item in AppDb.Todos,
      where item.title == title,
      select item;
    };
    ✓todos_with_titletodos_with_title: fn(tx: Database.Transaction(AppDb), title: String) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ]: fn(
      tx: Database.Transaction(AppDb),
      title: String,
    ) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ];

    let adversarial_titleadversarial_title: String = "quote '; -- /* snowman ☃""quote '; -- /* snowman ☃": String;
    let (adversarialadversarial: AppDb.Todos.Row, title_matchestitle_matches: Vector(AppDb.Todos.Row)) = must with txtx: Database.Transaction(AppDb) = AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb() {
      let adversarialadversarial: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), adversarial_titleadversarial_title: String);
      (
        adversarialadversarial: AppDb.Todos.Row,
        todos_with_titletodos_with_title: fn(tx: Database.Transaction(AppDb), title: String) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(AppDb), adversarial_titleadversarial_title: String),
        vector [
          AppDb.Todos.Row {
            id = 1;
            title = "quote '; -- /* snowman ☃";
            note = None;
            completed = False
          }
        ]      )
    };
    expect ✓[adversarialadversarial: AppDb.Todos.Row] == title_matchestitle_matches: Vector(AppDb.Todos.Row);
  

Ship can also infer a parameter type from the column it is compared with. Here completed becomes Bool from item.completed.

Infers Parameters

suite · SQLite DatabaseAppDb · per test
test✓
    let todos_with_completiontodos_with_completion: fn(tx: Database.Transaction(AppDb), completed: Bool) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ] = query(completedcompleted: Bool) {
      from item in AppDb.Todos,
      where item.completed == completed,
      select item;
    };
    ✓todos_with_completiontodos_with_completion: fn(tx: Database.Transaction(AppDb), completed: Bool) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ]: fn(
      tx: Database.Transaction(AppDb),
      completed: Bool,
    ) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ];

    let (remainingremaining: AppDb.Todos.Row, incompleteincomplete: Vector(AppDb.Todos.Row)) = must with txtx: Database.Transaction(AppDb) = AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb() {
      let remainingremaining: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Keep this todo""Keep this todo": String);
      let completedcompleted: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Finish this todo""Finish this todo": String);
      ignoreignore: pure fn!('a)(value: AppDb.Todos.Row) -> Unit(complete_todocomplete_todo: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), completedcompleted: AppDb.Todos.Row.idid: Int));
      (remainingremaining: AppDb.Todos.Row, todos_with_completiontodos_with_completion: fn(tx: Database.Transaction(AppDb), completed: Bool) -> Vector(AppDb.Todos.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(AppDb), FalseFalse: Bool))
      vector [
        AppDb.Todos.Row {
          id = 1;
          title = "Keep this todo";
          note = None;
          completed = False
        }
      ]    };
    expect ✓[remainingremaining: AppDb.Todos.Row] == incompleteincomplete: Vector(AppDb.Todos.Row);
  

Projecting fields

Projection results are structural records containing only the selected fields. This query never materializes each row's id or note.

Projects Fields

suite · SQLite DatabaseAppDb · per test
test✓
    let todo_summariestodo_summaries: fn(tx: Database.Transaction(AppDb)) -> Vector(named fields (title: String; completed: Bool)), exn! tx [> :db.rejected(String) ] = query() {
      from item in AppDb.Todos,
      where item.completed == False,
      select {
        title: item.title;
        completed: item.completed;
      };
    };
    let (remainingremaining: AppDb.Todos.Row, summariessummaries: Vector(named fields (title: String; completed: Bool))) = must with txtx: Database.Transaction(AppDb) = AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb() {
      let remainingremaining: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Keep this todo""Keep this todo": String);
      let completedcompleted: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Finish this todo""Finish this todo": String);
      ignoreignore: pure fn!('a)(value: AppDb.Todos.Row) -> Unit(complete_todocomplete_todo: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), completedcompleted: AppDb.Todos.Row.idid: Int));
      (remainingremaining: AppDb.Todos.Row, todo_summariestodo_summaries: fn(tx: Database.Transaction(AppDb)) -> Vector(named fields (title: String; completed: Bool)), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(AppDb)))
      vector [{title = "Keep this todo"; completed = False}]    };
    expect ✓Vector.lengthlength: pure fn!('a)(items: Vector(named fields (title: String; completed: Bool))) -> Int(summariessummaries: Vector(named fields (title: String; completed: Bool))) == 11: Int;

    let summarysummary: named fields (title: String; completed: Bool) = Vector.getget: pure fn!('a)(items: Vector(named fields (title: String; completed: Bool)), index: Int) -> named fields (title: String; completed: Bool)(summariessummaries: Vector(named fields (title: String; completed: Bool)), 00: Int);
    expect ✓summarysummary: named fields (title: String; completed: Bool).titletitle: String == remainingremaining: AppDb.Todos.Row.titletitle: String;
    expect ✓summarysummary: named fields (title: String; completed: Bool).completedcompleted: Bool == FalseFalse: Bool;
  

Updates

Sparse updates distinguish setting an optional field from omitting it. Some writes a value, while None writes SQL NULL.

Updates Nullable Fields

suite · SQLite DatabaseAppDb · per test
test✓
    let set_noteset_note: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ] = (txtx: Database.Transaction(AppDb), idid: Int) =>
      AppDb.Todos.updateupdate: fn(tx: Database.Transaction(AppDb), id: Int, patch: AppDb.Todos.Update) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), idid: Int, { note = Some("typed""typed": String); });
    let clear_noteclear_note: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ] = (txtx: Database.Transaction(AppDb), idid: Int) => AppDb.Todos.updateupdate: fn(tx: Database.Transaction(AppDb), id: Int, patch: AppDb.Todos.Update) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), idid: Int, { note = None; });
    let (insertedinserted: AppDb.Todos.Row, notednoted: AppDb.Todos.Row, clearedcleared: AppDb.Todos.Row) = must with txtx: Database.Transaction(AppDb) = AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb() {
      let insertedinserted: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Document nullable updates""Document nullable updates": String);
      (
        insertedinserted: AppDb.Todos.Row,
        set_noteset_note: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), insertedinserted: AppDb.Todos.Row.idid: Int),
        clear_noteclear_note: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), insertedinserted: AppDb.Todos.Row.idid: Int),
      )
    };
    expect ✓insertedinserted: AppDb.Todos.Row.idid: Int == 11: Int;
    expect ✓notednoted: AppDb.Todos.Row.notenote: Option(String) == SomeSome: fn(String) -> Option(String)("typed""typed": String);
    expect ✓clearedcleared: AppDb.Todos.Row.notenote: Option(String) == NoneNone: Option(String);
  

Updating an unknown key raises the expected :db.not_found(id) failure. The database refused nothing, so the failure does not end the transaction: a handler inside the region can recover and keep writing.

Updates An Unknown Key

suite · SQLite DatabaseAppDb · per test
test✓
    let outcomeoutcome: String = must with txtx: Database.Transaction(AppDb) = AppDb.transactiontransaction: fn!() -> Database.Transaction(AppDb), eff AppDb() {
      let missingmissing: String = match complete_todocomplete_todo: fn(tx: Database.Transaction(AppDb), id: Int) -> AppDb.Todos.Row, exn [> :db.not_found(Int) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), 404404: Int) {
        | completedcompleted: AppDb.Todos.Row -> completedcompleted: AppDb.Todos.Row.titletitle: String
        | error :db.not_found(idid: Int) -> "no todo ${idid: Int}"
      };
      let addedadded: AppDb.Todos.Row = add_todoadd_todo: fn(tx: Database.Transaction(AppDb), title: String) -> AppDb.Todos.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(AppDb), "Written after the miss""Written after the miss": String);
      missingmissing: String ++ ", then "", then ": String ++ addedadded: AppDb.Todos.Row.titletitle: String
    };
    expect ✓outcomeoutcome: String == "no todo 404, then Written after the miss""no todo 404, then Written after the miss": String;
  

Branded columns

A column can use a newtype. The table stores the newtype's target, so Points is an SQLite INTEGER and CardTitle is TEXT. Reading a row checks ensure again, so a decoded CardTitle is as trustworthy as one built in code. Keys and query parameters keep the brand.

type CardId = new String

type CardTitle = new String #{
  ensure = fn(valuevalue: String) {
    if valuevalue: String == """": String {
      error :empty_title;
    }
  };
}

type Points = new Int

database BoardDb(Sqlite) {
  table Cards {
    id: CardId #{ primary = True; };
    title: CardTitle;
    points: Points;
  }
}

Branded Columns

test✓
  let with_titlewith_title: fn(tx: Database.Transaction(BoardDb), title: Book.Databases.CardTitle) -> Vector(BoardDb.Cards.Row), exn! tx [> :db.rejected(String) ] = query(titletitle: Book.Databases.CardTitle) {
    from item in BoardDb.Cards,
    where item.title == title,
    select item;
  };
  ✓with_titlewith_title: fn(tx: Database.Transaction(BoardDb), title: Book.Databases.CardTitle) -> Vector(BoardDb.Cards.Row), exn! tx [> :db.rejected(String) ]: fn(
    tx: Database.Transaction(BoardDb),
    title: CardTitle,
  ) -> Vector(BoardDb.Cards.Row), exn! tx [> :db.rejected(String) ];

  let cardscards: Vector(BoardDb.Cards.Row) = must provide { BoardDb = BoardDb.test_databasetest_database: pure fn!() -> Service.Provider(BoardDb)(); } {
    with txtx: Database.Transaction(BoardDb) = BoardDb.transactiontransaction: fn!() -> Database.Transaction(BoardDb), eff BoardDb() {
      ignoreignore: pure fn!('a)(value: BoardDb.Cards.Row) -> Unit(
        BoardDb.Cards.insertinsert: fn(tx: Database.Transaction(BoardDb), input: BoardDb.Cards.Insert) -> BoardDb.Cards.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](
          txtx: Database.Transaction(BoardDb),
          {
            id = CardId("cli""cli": String);
            title = CardTitle("Build the CLI""Build the CLI": String);
            points = Points(33: Int);
          },
        ),
      );
      ignoreignore: pure fn!('a)(value: BoardDb.Cards.Row) -> Unit(
        BoardDb.Cards.updateupdate: fn(tx: Database.Transaction(BoardDb), id: Book.Databases.CardId, patch: BoardDb.Cards.Update) -> BoardDb.Cards.Row, exn [> :db.not_found(Book.Databases.CardId) ], exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](
          txtx: Database.Transaction(BoardDb),
          CardId("cli""cli": String),
          { points = Points(55: Int); },
        ),
      );
      with_titlewith_title: fn(tx: Database.Transaction(BoardDb), title: Book.Databases.CardTitle) -> Vector(BoardDb.Cards.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(BoardDb), CardTitle("Build the CLI""Build the CLI": String))
    }
  };
  expect ✓Vector.lengthlength: pure fn!('a)(items: Vector(BoardDb.Cards.Row)) -> Int(cardscards: Vector(BoardDb.Cards.Row)) == 11: Int;

  let cardcard: BoardDb.Cards.Row = Vector.getget: pure fn!('a)(items: Vector(BoardDb.Cards.Row), index: Int) -> BoardDb.Cards.Row(cardscards: Vector(BoardDb.Cards.Row), 00: Int);
  expect ✓cardcard: BoardDb.Cards.Row.idid: Book.Databases.CardId == CardId("cli""cli": String);
  expect ✓cardcard: BoardDb.Cards.Row.pointspoints: Book.Databases.Points == Points(55: Int);

Upserts

An insert can name a key with on conflict (key) do update. When a row already holds that key, the same statement overwrites the inserted updatable columns instead of failing, so saving never reads first.

let save_cardsave_card: fn(tx: Database.Transaction(BoardDb), input: BoardDb.Cards.Insert) -> BoardDb.Cards.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ] = query(inputinput: BoardDb.Cards.Insert: BoardDb.Cards.Insert) {
  insert into BoardDb.Cards {
    id = input.id;
    title = input.title;
    points = input.points;
  }
  on conflict (id) do update
  returning row;
};

A table whose primary key callers supply also gets this query as upsert. AppDb.Todos has none, since SQLite generates its key.

Upserts By Key

test✓
  ✓BoardDb.Cards.upsertupsert: fn(tx: Database.Transaction(BoardDb), input: BoardDb.Cards.Insert) -> BoardDb.Cards.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ]: fn(
    tx: Database.Transaction(BoardDb),
    input: BoardDb.Cards.Insert,
  ) -> BoardDb.Cards.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ];

  let cardscards: Vector(BoardDb.Cards.Row) = must provide { BoardDb = BoardDb.test_databasetest_database: pure fn!() -> Service.Provider(BoardDb)(); } {
    with txtx: Database.Transaction(BoardDb) = BoardDb.transactiontransaction: fn!() -> Database.Transaction(BoardDb), eff BoardDb() {
      let cardcard: BoardDb.Cards.Insert: BoardDb.Cards.Insert = {
        id = CardId("cli""cli": String);
        title = CardTitle("Build the CLI""Build the CLI": String);
        points = Points(33: Int);
      };
      ignoreignore: pure fn!('a)(value: BoardDb.Cards.Row) -> Unit(save_cardsave_card: fn(tx: Database.Transaction(BoardDb), input: BoardDb.Cards.Insert) -> BoardDb.Cards.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(BoardDb), cardcard: BoardDb.Cards.Insert));
      ignoreignore: pure fn!('a)(value: BoardDb.Cards.Row) -> Unit(
        BoardDb.Cards.upsertupsert: fn(tx: Database.Transaction(BoardDb), input: BoardDb.Cards.Insert) -> BoardDb.Cards.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(BoardDb), { cardcard: BoardDb.Cards.Insert with points = Points(55: Int); }),
      );
      BoardDb.Cards.allall: fn(tx: Database.Transaction(BoardDb)) -> Vector(BoardDb.Cards.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(BoardDb))
    }
  };
  expect ✓Vector.lengthlength: pure fn!('a)(items: Vector(BoardDb.Cards.Row)) -> Int(cardscards: Vector(BoardDb.Cards.Row)) == 11: Int;
  expect ✓Vector.getget: pure fn!('a)(items: Vector(BoardDb.Cards.Row), index: Int) -> BoardDb.Cards.Row(cardscards: Vector(BoardDb.Cards.Row), 00: Int).pointspoints: Book.Databases.Points == Points(55: Int);

Where a schema lives

A database is a top-level src declaration. A nested module or an entry file cannot own one.

crates/ship-cli/tests/golden/check-database-location/run.t

Database declarations are only valid as top-level src declarations.

✓
$ship check .
error: database declarations must be top-level `src` declarations
  --> src/Nested.shp:1:8
    |
  1 | module Bad {
    |        ^^^ nested modules cannot declare databases
help: move the `database` block out to the containing `src` file or another top-level `src` module

error: database declarations are only valid in `src` modules
  --> bin/main.shp:1:10
    |
  1 | database BinDb(Sqlite) {
    |          ^^^^^ move this schema to a top-level `.shp` file under `src/`
help: `bin`, `client`, and `server` roots are entry points; keep application schema and business logic in `src`