Recipe: A File-Backed To-Do Store
The problem
You want to persist a list of items and update any one of them without
rewriting the whole file. The trick is to make every record the same size. Then
record N lives at a fixed byte offset, so reading or updating it is one
positional operation, not a scan. This is the idea under every fixed-page
database file.
The plan
- Define the record as an
extern struct, so its byte layout is fixed and identical on every write. - Add by appending at the current end of file.
- Read or update record
Natoffset = (N - 1) * @sizeOf(Record). - Update in place: read the slot, change a field, write it back to the same offset.
const std = @import("std");
// An extern struct has a fixed, predictable layout, so every record is the same
// number of bytes on disk. That is what makes record N reachable directly at
// offset N * @sizeOf(Record), with no index and no scan.
const Record = extern struct {
id: u32,
done: bool,
len: u8,
text: [64]u8,
};
fn addTask(io: std.Io, file: std.Io.File, text: []const u8) !u32 {
const end = try file.length(io);
const id: u32 = @intCast(end / @sizeOf(Record) + 1);
var rec: Record = .{ .id = id, .done = false, .len = @intCast(@min(text.len, 64)), .text = @splat(0) };
@memcpy(rec.text[0..rec.len], text[0..rec.len]);
// Append by writing at the current end of file.
try file.writePositionalAll(io, std.mem.asBytes(&rec), end);
return id;
}
fn toggle(io: std.Io, file: std.Io.File, id: u32) !void {
const offset = (id - 1) * @sizeOf(Record);
// Read one record straight from its slot, flip the flag, write it back to
// the same slot. No rewrite of the whole file.
var rec: Record = undefined;
_ = try file.readPositionalAll(io, std.mem.asBytes(&rec), offset);
rec.done = !rec.done;
try file.writePositionalAll(io, std.mem.asBytes(&rec), offset);
}
fn list(io: std.Io, file: std.Io.File, out: *std.Io.Writer) !void {
const end = try file.length(io);
var offset: u64 = 0;
while (offset < end) : (offset += @sizeOf(Record)) {
var rec: Record = undefined;
_ = try file.readPositionalAll(io, std.mem.asBytes(&rec), offset);
try out.print("{d} [{s}] {s}\n", .{
rec.id,
if (rec.done) "x" else " ",
rec.text[0..rec.len],
});
}
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
var buf: [512]u8 = undefined;
var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
const out = &file_writer.interface;
// A fresh database each run (createFile truncates by default), removed at
// the end so the example leaves nothing behind.
var dir = std.Io.Dir.cwd();
const file = try dir.createFile(io, "todo-demo.db", .{ .read = true });
defer dir.deleteFile(io, "todo-demo.db") catch {};
defer file.close(io);
_ = try addTask(io, file, "write the recipe");
const verify = try addTask(io, file, "verify it");
_ = try addTask(io, file, "ship it");
try toggle(io, file, verify); // mark item 2 done
try out.print("record size: {d} bytes\n\n", .{@sizeOf(Record)});
try list(io, file, out);
try out.flush();
}Fixed size is the whole trick
The record is an extern struct, which pins its layout: an id, a done
flag, a length byte, and 64 bytes of text, the same 72 bytes every time (the
extra two are alignment padding, and they are harmless because every offset is
computed from @sizeOf(Record)). Because the size is constant, the file is an
array on disk. Item 3 is at offset 2 * 72, always, so toggle jumps straight
there. A variable-length format, JSON lines for instance, loses this: you would
have to read from the start to find where record 3 begins.
Positional I/O, not seek
Since Zig 0.16 the filesystem lives behind the Io interface, and random
access is done with readPositionalAll and writePositionalAll, each taking
an explicit offset. There is no separate seek call to forget: the offset is an
argument to the read or write itself. The older seekTo plus getPos dance is
gone. file.length(io) gives the end of file, which is both the append point
and the loop bound for listing.
std.mem.asBytes(&rec) is what bridges the struct and the file: it views the
record as the [N]u8 the I/O calls want, with no copy. Reading does the
reverse, filling a Record straight from the bytes on disk. That only works
because the layout is fixed, which is why the struct is extern.
Update in place
toggle is the payoff. It reads the one record at its offset, flips done, and
writes those same bytes back to the same offset. Nothing else in the file moves.
A real store would add a free list for deletions and an index for lookups by
something other than id, but the core, a fixed record addressed by offset, does
not change.
Variations
- Delete: mark a record with a tombstone flag rather than removing it, so later offsets stay valid; compact later.
- Larger text: raise the
textarray, or store long values in a second file and keep an offset and length in the record. - Endianness:
externlayout is host byte order. To move the file between machines, write fields explicitly like the binary round-trip recipe does. - A real database engine: SQLite from Zig gives you the same on-disk durability with SQL on top.