Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Storage Layer

Two files. One knows about the disk and nothing about tasks. The other knows about tasks and nothing about the disk.

The File

Filename: storage/_json_store.zu

import json
import os

/**
 * Reads every stored task dictionary.
 *
 * A missing file is an empty board, not an error: a fresh install has
 * nothing saved yet and should start cleanly. A file that exists but
 * cannot be parsed IS an error, because silently discarding someone's
 * data is worse than refusing to start.
 *
 * @param string path
 * @returns list[dict]
 * @throws Error if the file exists and does not contain a JSON list.
 */
def read_all(path: string) {
  var handle = file(path)

  if !handle.exists() {
    return []
  }

  var decoded

  catch {
    decoded = json.decode(handle.read())
  } as error {
    raise Error('${path} is not readable as JSON: ' + error.message)
  }

  if !is_list(decoded) {
    raise Error('${path} should contain a JSON list, found ' + typeof(decoded))
  }

  return decoded
}

/**
 * Replaces the stored board with `records`.
 *
 * Writes to a temporary file beside the target and renames it into
 * place, so a write interrupted halfway leaves the previous board
 * intact rather than a truncated file.
 *
 * @param string path
 * @param list records
 */
def write_all(path: string, records: list) {
  var directory = os.dir_name(path)

  if !os.dir_exists(directory) {
    os.create_dir(directory, nil, true)
  }

  var temporary = path + '.tmp'
  var handle = file(temporary, 'w')

  handle.open()
  handle.write(json.encode(records, false))
  handle.close()

  file(temporary).rename(path)
}

Four decisions in fifty lines.

A missing file is not an error. A first run has nothing saved, and the program should start. A file that exists but cannot be parsed is an error, because the alternative is overwriting whatever was in there with an empty board.

The error message names the path. The person reading it is looking at one line of a log.

The write is atomic. Writing into a temporary file and renaming it into place means a process killed mid-write leaves the previous board intact, because a rename within a directory either happens or does not.

The handle is opened explicitly. write() on a closed handle opens, writes and closes again, so two write() calls in w mode would each truncate the file. open() first, then write, then close(). That is the rule from Chapter 9, and this is exactly where it bites.

The Board

storage/index.zu is the layer above. It holds Task objects, answers questions about them, and persists through _json_store — and it is the only thing in the application that knows a file is involved at all.

Rather than read it as one hundred lines, here it is a piece at a time.

The Error It Raises

Filename: storage/index.zu

import os

import ..config
import ..models { Task, from_dict }
import ._json_store

/**
 * Raised when a task id does not name a task on the board.
 */
class NotFoundError < Error {

  @new(message) {
    parent(message)
    self.type = 'NotFoundError'
  }
}

A custom error class, four lines, and it earns them. Every layer above can say instance_of(error, NotFoundError) instead of matching on a message string, and the middleware in Middleware, Logging and Errors turns exactly this class into a 404. Had get() raised a plain Error('not found'), that mapping would be a substring search.

Note the two lines inside @new. parent(message) lets the base constructor set message and capture the stack trace; self.type is what makes the class name show up in logs and in the uncaught-error banner. Both are the pattern from Chapter 7.

The Fields and the Constructor

/**
 * A board backed by a JSON file.
 */
class Board {

  /** Every task, newest last. */
  var tasks = []

  /** The file this board reads from and writes to. */
  var path

  /**
   * @param ?string directory: defaults to `config.DATA_DIR`
   */
  @new(directory) {
    self.path = os.join_paths(directory or config.DATA_DIR, 'board.json')
    self.tasks = _json_store.read_all(self.path).map(@(record) => from_dict(record))
  }

Two fields, declared with var even though @new assigns both. The constructor could have declared them implicitly; writing them out means the class’s shape is visible at the top without reading the constructor, and it is required the moment any other method assigns to them.

The constructor does two things and no more. It works out where the file is, and it loads what is in it.

directory or config.DATA_DIR is the optional-parameter idiom from Chapter 5. It is safe here precisely because a directory is never legitimately '' or 0 — the falsy-default trap does not apply to paths.

The map on the second line is the boundary between two worlds. read_all() returns plain dictionaries, because that is what JSON is; from_dict() turns each into a real Task. Everything above this line deals in objects, everything below it deals in dictionaries, and this is the single place they meet.

Reading the Board

  all() {
    return self.tasks
  }

  in_column(column: string) {
    return self.tasks.filter(@(task) => task.column == column)
  }

all() is a one-liner, and it hands back the real list rather than a copy — deliberate, because every caller in this application only reads it. If that changed, this is the line that would need clone().

in_column() is filter() and nothing else. It is a method rather than a loop at each call site so that “which column is this task in” is decided in one place.

Shaping It for the Page

  /**
   * The board grouped for display: one entry per configured column,
   * in board order, each with its own tasks.
   *
   * @returns list[dict]: `{ name, tasks }`
   */
  columns() {
    return config.COLUMNS.map(@(name) {
      var tasks = self.in_column(name).map(@(task) => task.to_view())

      return { name, tasks }
    })
  }

This is the method the HTML template consumes, and three decisions inside it are worth naming.

It iterates config.COLUMNS, not the tasks. That means a column with no tasks still appears, as an empty column — which is what a board should look like. Grouping by walking the tasks instead would silently drop empty columns and produce them in whatever order the data happened to be in.

It returns to_view() results, not Task objects. A view carries the formatted date and the is_done flag already computed, so the template never has to. Server-Rendered Pages explains why that matters for templates specifically.

{ name, tasks } uses the shorthand. Both keys match the variables holding them, so writing { name: name, tasks: tasks } would be noise.

Finding One

  /**
   * @throws NotFoundError if no task has that id.
   */
  get(id: string) {
    var found = self.tasks.find(@(task) => task.id == id)

    if found == nil {
      raise NotFoundError('no task with id ${id}')
    }

    return found
  }

The important decision is that get() raises rather than returning nil.

That choice propagates. Every caller either has a real task or has an error, so no route handler contains if task == nil. The alternative — returning nil — would put that check in five places and guarantee one of them was eventually forgotten, producing a nil field access somewhere far away from the cause.

The message includes the id, because the person reading the log has the request and nothing else.

Changing It

  add(title, options) {
    var task = Task(title, options)

    self.tasks.append(task)
    self._save()

    return task
  }

  update(id: string, changes: dict) {
    var task = self.get(id).update(changes)

    self._save()

    return task
  }

  remove(id: string) {
    var task = self.get(id)

    self.tasks.remove_at(self.tasks.index_of(task))
    self._save()

    return task
  }

All three follow one shape: change the in-memory list, then save, then return what changed.

update() and remove() both start by calling get(), which means a bad id raises NotFoundError before anything is modified. There is no path that half-applies a change.

Each returns the affected task rather than nothing, which is what lets the JSON API answer with the created or updated record without a second lookup.

remove_at(index_of(task)) rather than remove(task) is deliberate: list remove() compares by value, and two tasks with identical fields would make that ambiguous. Removing by position removes exactly the object get() found.

Counting, and Saving

  summary() {
    var counts = {}

    for name in config.COLUMNS {
      counts.set(name, self.in_column(name).length())
    }

    return counts
  }

  _save() {
    _json_store.write_all(self.path, self.tasks.map(@(task) => task.to_dict()))
  }
}

summary() walks the configured columns for the same reason columns() does: an empty column should report 0, not be missing.

_save() is the other side of the constructor’s map. Objects go out as dictionaries via to_dict(), exactly as they came in through from_dict(). The two are a matched pair, and a field added to Task needs to appear in both or it will not survive a restart.

_save() is private, and that is the class’s most important property. There is no public method that changes the board without persisting, because add, update and remove each call it and nothing else can. A caller cannot forget to save, because a caller is never given the choice.

The Trade It Makes

The whole board is held in memory and rewritten in full after every change.

For a board a team can read on one screen, that is the right trade: the code is simple enough to hold in your head, and a full rewrite of a few kilobytes is immaterial. It is also the first assumption to revisit if this ever has to hold a hundred thousand tasks, at which point the answer is a real database rather than a cleverer file format.

Concurrency

Board holds the whole file in memory. Two processes writing the same board.json would each overwrite the other’s changes, and the atomic rename does not fix that: it guarantees the file is never half-written, not that two writers agree.

This application runs one server process, so the question does not arise. Running It for Real is where it does.