Ship

Book · Persistence

Persistence

Draft

Put task storage behind one service, then back it with files or SQLite.

On this page · 3 sections

One API

The tracker saves tasks without knowing where they go. An effect service names the operations, and code that calls them only says it needs a TaskStore. Saving a task whose id already exists replaces it.

type TaskId = new String

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

type Task = {
  id: TaskId;
  title: Title;
}

effect service TaskStore {
  save: fn(task: Task) -> Unit, exn [ :store(String) ];
  find: fn(id: TaskId) -> Option(Task), exn [ :store(String) ];
  all: fn() -> Vector(Task), exn [ :store(String) ];
}

let trackertracker: Book.YouTube.Persistence.Task = Task {
  id = TaskId("task-tracker""task-tracker": String);
  title = Title("Build the task tracker""Build the task tracker": String);
};

let walkthroughwalkthrough: Book.YouTube.Persistence.Task = Task {
  id = TaskId("youtube-walkthrough""youtube-walkthrough": String);
  title = Title("Write the YouTube walkthrough""Write the YouTube walkthrough": String);
};

let renamedrenamed: Book.YouTube.Persistence.Task = { trackertracker: Book.YouTube.Persistence.Task with title = Title("Ship the task tracker""Ship the task tracker": String); };

plan is the whole app as far as storage goes. Every test below runs it unchanged; only the provide block around it differs.

let planplan: fn() -> (Option(Book.YouTube.Persistence.Task), Int), exn [ :store(String) ], eff TaskStore = fn() {
  TaskStore.savesave: fn(task: Book.YouTube.Persistence.Task) -> Unit, exn [ :store(String) ], eff TaskStore(trackertracker: Book.YouTube.Persistence.Task);
  TaskStore.savesave: fn(task: Book.YouTube.Persistence.Task) -> Unit, exn [ :store(String) ], eff TaskStore(walkthroughwalkthrough: Book.YouTube.Persistence.Task);
  TaskStore.savesave: fn(task: Book.YouTube.Persistence.Task) -> Unit, exn [ :store(String) ], eff TaskStore(renamedrenamed: Book.YouTube.Persistence.Task);
  (TaskStore.findfind: fn(id: Book.YouTube.Persistence.TaskId) -> Option(Book.YouTube.Persistence.Task), exn [ :store(String) ], eff TaskStore(trackertracker: Book.YouTube.Persistence.Task.idid: Book.YouTube.Persistence.TaskId), Vector.lengthlength: pure fn!('a)(items: Vector(Book.YouTube.Persistence.Task)) -> Int(TaskStore.allall: fn() -> Vector(Book.YouTube.Persistence.Task), exn [ :store(String) ], eff TaskStore()))
};

Files

The file store writes each task as <id>.json under root. It is a function returning a provider, so each setup picks its own directory. It talks to disk only through the FileSystem service.

Callers only see store errors. fs_to_store turns any disk failure into one, and each function attaches it with >>. A file that fails to decode also names itself, so read_task adds a second stage that can still see name.

let fs_to_storefs_to_store: Unit = function {
  | error :fs(failure) -> error :store(failure.message)
};

let read_taskread_task: fn(root: String, name: String) -> Book.YouTube.Persistence.Task, exn [ :store(String) ], eff FileSystem = fn(rootroot: String: String, namename: String: String) {
  FileSystem.read_stringread_string: fn(root: String, path: String) -> String, exn [ :fs(FileSystem.Error) ], eff FileSystem(rootroot: String, namename: String)
  |> Bytes.of_stringof_string: pure fn!(string: String) -> Bytes
  |> Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.YouTube.Persistence.Task), ~max_depth: Int = 128) -> Book.YouTube.Persistence.Task, exn [ :decode(Wire.DecodeError) ](Task)
} >> fs_to_store
  >> | error :decode(failurefailure: Wire.DecodeError) -> error :store("${namename: String}: ${failurefailure: Wire.DecodeError.messagemessage: String}");

let read_allread_all: fn(root: String) -> Vector(Book.YouTube.Persistence.Task), exn [ :store(String) ], eff FileSystem = fn(rootroot: String: String) {
  FileSystem.list_dirlist_dir: fn(root: String, path: String) -> Vector(FileSystem.Entry), exn [ :fs(FileSystem.Error) ], eff FileSystem(rootroot: String, "."".": String)
  |> Vector.mapmap: pure fn!('a, 'b)(items: Vector(FileSystem.Entry) @ 'm, ~f: fn(value: FileSystem.Entry @ 'm) -> Book.YouTube.Persistence.Task) -> Vector(Book.YouTube.Persistence.Task)(f = (entryentry: FileSystem.Entry) => read_taskread_task: fn(root: String, name: String) -> Book.YouTube.Persistence.Task, exn [ :store(String) ], eff FileSystem(rootroot: String, entryentry: FileSystem.Entry.namename: String))
} >> fs_to_store;

let file_storefile_store: fn(root: String) -> Service.Provider(TaskStore) = (rootroot: String: String) =>
  module: Service.Provider(TaskStore) #{
    let service Provider: TaskStore = {
      type t = Unit

      init = () => ()(): Unit;

      drop = fn(selfself: t: t) {};

      save = fn(selfself: t: t, tasktask: Book.YouTube.Persistence.Task: Task) {
        let bodybody: String = must String.of_bytesof_bytes: pure fn(bytes: Bytes) -> String, exn [ :utf8(Int) ](must Json.encodeencode: pure fn('a: Encode)(value: Book.YouTube.Persistence.Task) -> Bytes, exn [ :encode(Wire.EncodeError) ](tasktask: Book.YouTube.Persistence.Task));
        FileSystem.write_stringwrite_string: fn(root: String, path: String, body: String) -> Unit, exn [ :fs(FileSystem.Error) ], eff FileSystem(rootroot: String, "${tasktask: Book.YouTube.Persistence.Task.idid: Book.YouTube.Persistence.TaskId}.json", bodybody: String)
      } >> fs_to_store;

      find = fn(selfself: t: t, idid: Book.YouTube.Persistence.TaskId: TaskId) {
        read_allread_all: fn(root: String) -> Vector(Book.YouTube.Persistence.Task), exn [ :store(String) ], eff FileSystem(rootroot: String) |> Vector.findfind: pure fn!('a)(items: Vector(Book.YouTube.Persistence.Task) @ 'm, ~f: fn(value: Book.YouTube.Persistence.Task @ 'm) -> Bool) -> Option(Book.YouTube.Persistence.Task)(f = (tasktask: Book.YouTube.Persistence.Task) => tasktask: Book.YouTube.Persistence.Task.idid: Book.YouTube.Persistence.TaskId == idid: Book.YouTube.Persistence.TaskId)
      };

      all = fn(selfself: t: t) { read_allread_all: fn(root: String) -> Vector(Book.YouTube.Persistence.Task), exn [ :store(String) ], eff FileSystem(rootroot: String) };
    };
  };

The app hands the file store a real directory through LocalFileSystem:

let on_diskon_disk: fn(root: String) -> (Option(Book.YouTube.Persistence.Task), Int), exn [ :store(String) ] = fn(rootroot: String: String) {
  provide { FileSystem = FileSystem.LocalFileSystemLocalFileSystem: service FileSystem; TaskStore = file_storefile_store: fn(root: String) -> Service.Provider(TaskStore)(rootroot: String); } {
    planplan: fn() -> (Option(Book.YouTube.Persistence.Task), Int), exn [ :store(String) ], eff TaskStore()
  }
};

Tests swap the layer underneath instead. MemoryFileSystem keeps files in a vector, so the file store runs exactly as written without touching disk. The store only reads, writes, and lists, so the rest are stubs.

type MemoryFile = {
  path: String;
  body: String;
}

let not_foundnot_found: fn(path: String) -> 'a, exn [ :fs(FileSystem.Error) ] = (pathpath: String: String) =>
  error :fs(
    FileSystem.Error {
      operation = "read""read": String;
      path = pathpath: String;
      message = "not found""not found": String;
    },
  );

let service MemoryFileSystemMemoryFileSystem: service FileSystem: FileSystem = {
  type t = {
    mut files: Vector(MemoryFile);
  }

  init = () -> t =>
    { files = []; };

  drop = fn(selfself: t: t) {};

  read_string = fn(selfself: t: t, rootroot: String: String, pathpath: String: String) {
    match Vector.findfind: pure fn!('a)(items: Vector(Book.YouTube.Persistence.MemoryFile) @ 'm, ~f: fn(value: Book.YouTube.Persistence.MemoryFile @ 'm) -> Bool) -> Option(Book.YouTube.Persistence.MemoryFile)(selfself: Book.YouTube.Persistence.MemoryFileSystem.filesfiles: Vector(Book.YouTube.Persistence.MemoryFile), f = (filefile: Book.YouTube.Persistence.MemoryFile) => filefile: Book.YouTube.Persistence.MemoryFile.pathpath: String == pathpath: String) {
      | Some(filefile: Book.YouTube.Persistence.MemoryFile) -> filefile: Book.YouTube.Persistence.MemoryFile.bodybody: String
      | None -> not_foundnot_found: fn(path: String) -> String, exn [ :fs(FileSystem.Error) ](pathpath: String)
    }
  };

  write_string = fn(selfself: t: t, rootroot: String: String, pathpath: String: String, bodybody: String: String) {
    let othersothers: Vector(Book.YouTube.Persistence.MemoryFile) = Vector.filterfilter: pure fn!('a)(items: Vector(Book.YouTube.Persistence.MemoryFile) @ 'm, ~f: fn(value: Book.YouTube.Persistence.MemoryFile @ 'm) -> Bool) -> Vector(Book.YouTube.Persistence.MemoryFile)(selfself: Book.YouTube.Persistence.MemoryFileSystem.filesfiles: Vector(Book.YouTube.Persistence.MemoryFile), f = (filefile: Book.YouTube.Persistence.MemoryFile) => filefile: Book.YouTube.Persistence.MemoryFile.pathpath: String != pathpath: String);
    self.files := Vector.pushpush: pure fn!('a)(items: Vector(Book.YouTube.Persistence.MemoryFile), item: Book.YouTube.Persistence.MemoryFile) -> Vector(Book.YouTube.Persistence.MemoryFile)(
      othersothers: Vector(Book.YouTube.Persistence.MemoryFile),
      MemoryFile { path = pathpath: String; body = bodybody: String; },
    );
  };

  list_dir = fn(selfself: t: t, rootroot: String: String, pathpath: String: String) {
    Vector.mapmap: pure fn!('a, 'b)(items: Vector(Book.YouTube.Persistence.MemoryFile) @ 'm, ~f: fn(value: Book.YouTube.Persistence.MemoryFile @ 'm) -> FileSystem.Entry) -> Vector(FileSystem.Entry)(
      selfself: Book.YouTube.Persistence.MemoryFileSystem.filesfiles: Vector(Book.YouTube.Persistence.MemoryFile),
      f = (filefile: Book.YouTube.Persistence.MemoryFile) =>
        FileSystem.Entry {
          name = filefile: Book.YouTube.Persistence.MemoryFile.pathpath: String;
          kind = FileSystem.FileFile: FileSystem.EntryKind;
        },
    )
  };

  metadata = fn(selfself: t: t, rootroot: String: String, pathpath: String: String) { not_foundnot_found: fn(path: String) -> 'a, exn [ :fs(FileSystem.Error) ](pathpath: String) };

  make_dir = fn(selfself: t: t, rootroot: String: String, pathpath: String: String) {};

  remove_file = fn(selfself: t: t, rootroot: String: String, pathpath: String: String) {};
};

File Store

test✓
  let (foundfound: Option(Book.YouTube.Persistence.Task), countcount: Int) = must provide { FileSystem = MemoryFileSystemMemoryFileSystem: service FileSystem; TaskStore = file_storefile_store: fn(root: String) -> Service.Provider(TaskStore)("tasks""tasks": String); } {
    planplan: fn() -> (Option(Book.YouTube.Persistence.Task), Int), exn [ :store(String) ], eff TaskStore()
  };
  expect ✓foundfound: Option(Book.YouTube.Persistence.Task) == SomeSome: fn(Book.YouTube.Persistence.Task) -> Option(Book.YouTube.Persistence.Task)(renamedrenamed: Book.YouTube.Persistence.Task);
  expect ✓countcount: Int == 22: Int;

A file anyone can edit is untrusted input. Decoding a Title runs its ensure, so a blank title on disk surfaces as a store error instead of an invalid Task:

Ensure Guards The Disk

test✓
  let outcomeoutcome: String = must provide { FileSystem = MemoryFileSystemMemoryFileSystem: service FileSystem; TaskStore = file_storefile_store: fn(root: String) -> Service.Provider(TaskStore)("tasks""tasks": String); } {
    must FileSystem.write_stringwrite_string: fn(root: String, path: String, body: String) -> Unit, exn [ :fs(FileSystem.Error) ], eff FileSystem(
      "tasks""tasks": String,
      "blank.json""blank.json": String,
      #"{"id":"blank","title":""}"##"{"id":"blank","title":""}"#: String,
    );
    match TaskStore.findfind: fn(id: Book.YouTube.Persistence.TaskId) -> Option(Book.YouTube.Persistence.Task), exn [ :store(String) ], eff TaskStore(TaskId("blank""blank": String)) {
      | _ -> "loaded""loaded": String
      | error :store(messagemessage: String) -> messagemessage: String
    }
  };
  expect ✓outcomeoutcome: String == "blank.json: `Title` failed validation: :empty_title""blank.json: `Title` failed validation: :empty_title": String;

SQLite

The SQLite store keeps the same promises with a database. The branded TaskId and Title types are the columns, so rows come back as checked values and the generated find only accepts a TaskId.

database TaskDb(Sqlite) {
  table Tasks {
    id: TaskId #{ primary = True; };
    title: Title;
  }
}

let as_taskas_task: fn(row: TaskDb.Tasks.Row) -> Book.YouTube.Persistence.Task = (rowrow: TaskDb.Tasks.Row: TaskDb.Tasks.Row) =>
  Task { id = rowrow: TaskDb.Tasks.Row.idid: Book.YouTube.Persistence.TaskId; title = rowrow: TaskDb.Tasks.Row.titletitle: Book.YouTube.Persistence.Title; };

Every operation runs one transaction. db_to_store plays the part fs_to_store did for files. save also records a trace span, so the test can see which saves succeeded.

let db_to_storedb_to_store: Unit = function {
  | error :db(_) -> error :store("database")
};

let service SqliteStoreSqliteStore: service TaskStore: TaskStore = {
  type t = Unit

  init = () => ()(): Unit;

  drop = fn(selfself: t: t) {};

  save = fn(selfself: t: t, tasktask: Book.YouTube.Persistence.Task: Task) {
    with txtx: Database.Transaction(TaskDb) = TaskDb.transactiontransaction: fn!() -> Database.Transaction(TaskDb), eff TaskDb() {
      TaskDb.Tasks.upsertupsert: fn(tx: Database.Transaction(TaskDb), input: TaskDb.Tasks.Insert) -> TaskDb.Tasks.Row, exn! tx [> :db([ :rejected(String) | :unique(String) ]) ](txtx: Database.Transaction(TaskDb), tasktask: Book.YouTube.Persistence.Task) |> ignoreignore: pure fn!('a)(value: TaskDb.Tasks.Row) -> Unit
    }
  } >> db_to_store
    >> Trace.spanspan: pure fn!(name: String) -> Trace.Span("TaskStore.save""TaskStore.save": String);

  find = fn(selfself: t: t, idid: Book.YouTube.Persistence.TaskId: TaskId) {
    with txtx: Database.Transaction(TaskDb) = TaskDb.transactiontransaction: fn!() -> Database.Transaction(TaskDb), eff TaskDb() {
      TaskDb.Tasks.findfind: fn(tx: Database.Transaction(TaskDb), id: Book.YouTube.Persistence.TaskId) -> Option(TaskDb.Tasks.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(TaskDb), idid: Book.YouTube.Persistence.TaskId) |> Option.mapmap: pure fn!('a, 'b)(option: Option(TaskDb.Tasks.Row) @ 'm, ~f: fn(value: TaskDb.Tasks.Row @ 'm) -> Book.YouTube.Persistence.Task) -> Option(Book.YouTube.Persistence.Task)(f = as_taskas_task: fn(row: TaskDb.Tasks.Row) -> Book.YouTube.Persistence.Task)
    }
  } >> db_to_store;

  all = fn(selfself: t: t) {
    with txtx: Database.Transaction(TaskDb) = TaskDb.transactiontransaction: fn!() -> Database.Transaction(TaskDb), eff TaskDb() {
      TaskDb.Tasks.allall: fn(tx: Database.Transaction(TaskDb)) -> Vector(TaskDb.Tasks.Row), exn! tx [> :db.rejected(String) ](txtx: Database.Transaction(TaskDb)) |> Vector.mapmap: pure fn!('a, 'b)(items: Vector(TaskDb.Tasks.Row) @ 'm, ~f: fn(value: TaskDb.Tasks.Row @ 'm) -> Book.YouTube.Persistence.Task) -> Vector(Book.YouTube.Persistence.Task)(f = as_taskas_task: fn(row: TaskDb.Tasks.Row) -> Book.YouTube.Persistence.Task)
    }
  } >> db_to_store;
};

Same plan, same answers. The setup is the only difference: SQLite needs a database where the file store needed a file system, and Trace.collect() keeps the spans save finishes.

Sqlite Store

test✓
  let (foundfound: Option(Book.YouTube.Persistence.Task), countcount: Int, spansspans: Vector(Trace.Finished)) = must provide { TaskDb = TaskDb.test_databasetest_database: pure fn!() -> Service.Provider(TaskDb)(); Trace = Trace.collectcollect: pure fn!() -> Service.Provider(Trace)(); TaskStore = SqliteStoreSqliteStore: service TaskStore; } {
    let (foundfound: Option(Book.YouTube.Persistence.Task), countcount: Int) = planplan: fn() -> (Option(Book.YouTube.Persistence.Task), Int), exn [ :store(String) ], eff TaskStore();
    (foundfound: Option(Book.YouTube.Persistence.Task), countcount: Int, Trace.finishedfinished: fn!() -> Vector(Trace.Finished), eff Trace())
  };
  expect ✓foundfound: Option(Book.YouTube.Persistence.Task) == SomeSome: fn(Book.YouTube.Persistence.Task) -> Option(Book.YouTube.Persistence.Task)(renamedrenamed: Book.YouTube.Persistence.Task);
  expect ✓countcount: Int == 22: Int;
  expect ✓Vector.lengthlength: pure fn!('a)(items: Vector(Trace.Finished)) -> Int(spansspans: Vector(Trace.Finished)) == 33: Int;
  expect ✓Vector.for_allfor_all: pure fn!('a)(items: Vector(Trace.Finished) @ 'm, ~f: fn(value: Trace.Finished @ 'm) -> Bool) -> Bool(
    spansspans: Vector(Trace.Finished),
    f = (spanspan: Trace.Finished) => spanspan: Trace.Finished.namename: String == "TaskStore.save""TaskStore.save": String && spanspan: Trace.Finished.okok: Bool,
  );