oriel

Settings store

A thread-safe JSON key-value file for the app's settings, written atomically, in the app's config directory on every platform.

oriel.store keeps small settings (a theme, a zoom level, the last folder opened, a launch counter) in a JSON file the app’s Zig code reads and writes by key. Every change is saved at once, atomically, so a crash or a power cut leaves either the old file or the new one, never half of each. Oriel itself uses it to remember window sizes (WindowOptions.remember_geometry).

Use it for settings: a few kilobytes of values the app reads at start. Use SQL for records that grow (notes, history, anything you query), and plain files under oriel.paths.appData for blobs (images, downloaded models). A setting only the page needs can stay in the page’s localStorage; the store is for what Zig needs too.

Availability

PlatformSettings fileAtomic writeLock
Linux$XDG_CONFIG_HOME/<app id>/<name>.json (~/.config/...)GLib g_file_set_contentsGMutex
Windows%APPDATA%\<app id>\<name>.json (Roaming)temp file, FlushFileBuffers, MoveFileExW (replace existing, write through)SRWLOCK
macOS~/Library/Application Support/<app id>/<name>.jsontemp file (O_EXCL, 0600), fsync, rename, directory fsyncpthread mutex
iOS<container>/Library/Application Support/<app id>/<name>.jsonas macOSpthread mutex
Android<filesDir>/<app id>/<name>.json (/data/user/0/<package>/files/...)as macOSpthread mutex

The module is on by default on every platform (store flag); turn it off with .store = false on the Oriel dependency. It needs no permission. oriel.store.available is false when it is off, and then any other oriel.store declaration is a build error unless the use is guarded by if (oriel.store.available).

Quick start

// build.zig: the store is on by default; nothing to add.
const dep = b.dependency("oriel", .{ .target = target, .optimize = optimize });
// src/main.zig
const std = @import("std");
const oriel = @import("oriel");
const app = @import("oriel_app");

pub const Commands = struct {
    /// invoke("launches"): counts the app's launches across restarts.
    pub fn launches(_: std.mem.Allocator) !i64 {
        var store = try oriel.store.Store.open(std.heap.smp_allocator, app.build.id, "settings");
        defer store.deinit();
        const n = (store.getInt("launches", i64) orelse 0) + 1;
        try store.set("launches", n); // saved to settings.json now
        return n;
    }
};

pub fn main(init: std.process.Init) !u8 {
    return oriel.main(init, .{ .commands = Commands, .events = struct {} }, .{
        .app = app.build,
        .window = .{ .title = "My App", .width = 800, .height = 600 },
    });
}
const n = await window.oriel.invoke("launches");
document.title = `Launched ${n} times`;

The page has no store API of its own: it reaches the store through the app’s commands, which decide what the page may read and change.

How it works

flowchart LR
  Page["Page"] -->|"invoke(command)"| Cmd["Your command<br/>(any thread)"]
  Zig["Your Zig code"] --> Store
  Cmd --> Store["Store<br/>(mutex + JSON map<br/>in an arena)"]
  Store -->|"open: read + parse"| File[("config dir/name.json")]
  Store -->|"set / delete / clear / save:<br/>whole file, temp + rename"| File
  Store -.->|"unparseable file:<br/>copy kept"| Corrupt[("name.json.corrupt")]

Store.open(gpa, app_id, name) resolves the app’s config directory (oriel.paths.appConfig, created if missing; on Android its <app id> folder), reads <name>.json and parses it into a std.json.ObjectMap whose keys and values live in the store’s arena. A missing or empty file is an empty store. A file that doesn’t parse is copied to <name>.json.corrupt before anything else, a warning is logged, and the store opens empty: the next save replaces the original, the copy keeps the user’s data.

Every method takes the store’s mutex, so one *Store can be shared between threads (commands run on the main thread, or on workers when listed in async_commands). Each set turns the value into JSON and parses it back into the arena, so the store never borrows the caller’s memory. With auto_save on (the default), set, delete and clear rewrite the whole file before returning; deinit saves once more and frees everything.

The file is written atomically: a sibling temp file (<name>.json.tmp.<pid>.<n>), flushed to disk, renamed over the old one (on macOS, iOS and Android the directory is fsynced too, so the rename itself survives a power cut). The JSON is indented with two spaces, so it can be read and edited by hand while the app is closed.

The store is a cache of the file taken at open: two Stores on the same file don’t see each other’s changes, and the last save wins. Open one per file and keep it, or open, change and close it in one go as the quick start does.

The code is src/modules/store.zig (the facade), src/modules/store/common.zig (GenericStore, the JSON logic, and the store’s folder from oriel.paths) and one backend per platform for the file and lock: linux.zig (GLib), windows.zig (Win32 files), macos.zig (macOS and iOS) and android.zig, with posix.zig holding the file and mutex code macOS, iOS and Android share.

Examples

Keep one store for the app’s lifetime

const std = @import("std");
const oriel = @import("oriel");
const app = @import("oriel_app");

var settings: *oriel.store.Store = undefined;

fn setup() !void {
    settings = try oriel.store.Store.open(std.heap.smp_allocator, app.build.id, "settings");
}

pub const Commands = struct {
    pub fn get_theme(_: std.mem.Allocator) []const u8 {
        return settings.getString("theme") orelse "system";
    }

    pub fn set_theme(_: std.mem.Allocator, args: struct { theme: []const u8 }) !void {
        if (!std.mem.eql(u8, args.theme, "light") and !std.mem.eql(u8, args.theme, "dark") and
            !std.mem.eql(u8, args.theme, "system")) return error.InvalidTheme;
        try settings.set("theme", args.theme);
    }
};

Pass .setup = setup in oriel.main‘s config. The returned string is borrowed from the store and stays valid until deinit; the command’s result is serialized before the next call.

Batch several changes into one write

var store = try oriel.store.Store.open(gpa, app.build.id, "settings");
defer store.deinit(); // saves (auto_save is back on)

store.auto_save = false;
try store.set("width", 1280);
try store.set("height", 800);
try store.set("recent", .{ "/home/me/a.txt", "/home/me/b.txt" });
store.auto_save = true;
try store.save(); // one atomic write

auto_save is a plain field, not guarded by the mutex: change it only while no other thread uses that store.

Structured values

set takes anything std.json.Stringify can write (structs, arrays, optionals, enums); get returns the std.json.Value, which std.json.parseFromValue turns back into a struct:

const Window = struct { x: i32, y: i32, docked: bool = false };

try store.set("palette", Window{ .x = 40, .y = 60 });

if (store.get("palette")) |v| {
    const parsed = try std.json.parseFromValue(Window, gpa, v, .{});
    defer parsed.deinit();
    std.log.info("palette at {d},{d}", .{ parsed.value.x, parsed.value.y });
}

A file outside the config directory

const dir = try oriel.paths.appData(gpa, .{ .name = app.build.id });
defer gpa.free(dir);
const path = try std.fs.path.join(gpa, &.{ dir, "library.json" });
defer gpa.free(path);
var lib = try oriel.store.Store.openPath(gpa, path); // the directory must exist
defer lib.deinit();

The showcase counts its launches with the store (examples/showcase/src/main.zig, the hello store).

Reference

oriel.store

pub const available: bool;

pub const Store; // the JSON store on this platform's file and lock code

pub fn check(gpa: std.mem.Allocator, ctx: oriel.CheckContext) !oriel.Check;

Store

DeclarationMeaning
open(gpa, app_id: []const u8, name: []const u8) !*StoreOpens (or starts) <name>.json in the app’s config directory (the Availability table); .json is added unless name already ends with it. Creates the directory.
openPath(gpa, path: []const u8) !*StoreOpens a store at any path. The directory must exist; the file needn’t.
deinit(self) voidSaves (when auto_save is on, errors ignored) and frees the store. Every value get returned becomes invalid.
get(self, key) ?std.json.ValueThe raw value, or null.
getString(self, key) ?[]const u8The string, or null when missing or not a string.
getInt(self, key, comptime T: type) ?TThe integer cast to T, or null when missing, not an integer, or out of T’s range.
getFloat(self, key, comptime T: type) ?TThe number as T (integers convert), or null.
getBool(self, key) ?boolThe boolean, or null.
has(self, key) boolWhether the key exists.
set(self, key, value: anytype) !voidStores a copy of value (anything std.json.Stringify accepts, std.json.Value included) and, with auto_save, saves.
delete(self, key) boolRemoves the key; true if it was there. With auto_save, saves (a failed save is ignored).
clear(self) !voidRemoves every key; with auto_save, saves.
save(self) !voidWrites the file now, atomically.
auto_save: boolDefault true. Not locked: set it while no other thread uses the store.

Values. Strings, arrays and objects returned by the getters are borrowed from the store’s arena: valid until deinit, even after the key is overwritten or deleted (the arena only grows; a store that is set millions of times should be reopened now and then). Keys are compared exactly; the order in the file is insertion order, with deletes moving the last key into the hole.

Errors. open and openPath: allocation failures; the directory’s errors (error.CreateDirectoryFailed; Windows error.KnownFolderNotFound; macOS and iOS error.HomeNotSet; Android error.AppNotRunning before the runtime started); error.InvalidAppId / error.EmptyAppId from the app id check (the oriel.paths name rules); on macOS, iOS and Android error.OpenFailed, error.ReadFailed and error.StoreTooLarge (over 16 MiB). Saving: error.SaveFailed (Linux, Windows; Linux logs GLib’s message), or on macOS, iOS and Android error.CreateTempFailed, error.WriteFailed, error.SyncFailed, error.RenameFailed.

Threads. Every method locks the store; a *Store may be used from any thread. Opening the same file twice gives two independent stores.

Directory

The store’s files live in oriel.paths.appConfig(.{ .name = app_id }) (the Availability table); on Android in an <app id> folder inside it, <filesDir>/<app id>, where apps’ settings have always been. Every other folder the app needs comes from oriel.paths.

App id rules: those of oriel.paths’ name: not empty (error.EmptyAppId); no /, \, :, .., NUL or control characters (error.InvalidAppId). The id becomes a directory name.

Window geometry

A window with .remember_geometry = true (in WindowOptions) saves its size and maximized state to the window_geometry store (window_geometry.json in the config directory, keys <label>_width, <label>_height, <label>_maximized) and restores them when it is created. With the store off, remember_geometry does nothing.

Smoke check

check writes, rereads and deletes a temporary store (in /tmp, the temp directory, or the Android cache directory). oriel.checkAll runs it with every enabled module’s check.

Migrating from 0.11

  • oriel.store.configDir, dataDir and cacheDir are gone: use oriel.paths.appConfig, appData and appCache. They are the same folders except on Android, where the store’s were an <app id> subfolder (<filesDir>/<app id>): the store still keeps its own files there, but other files move to appData (the files dir itself).
  • oriel.store.impl.externalDataDir is gone: use oriel.paths.appExternal (the external files dir itself, no <app id> folder).
  • oriel.store.impl and oriel.store.common are no longer public.

Troubleshooting

  • Settings were reset and a .corrupt file appeared. The JSON didn’t parse (edited by hand, or written by something else). The log has failed to parse store file <path> (<error>); kept a copy at <path>.corrupt. Fix the copy and move it back while the app is closed.
  • A value written in one place isn’t seen in another. Two Stores were opened on the same file; each has its own copy. Share one.
  • oriel.store.Store is disabled at build time: the dependency has .store = false; turn it on or guard the use with if (oriel.store.available).
  • AppNotRunning on Android. The store was opened before the runtime handed over its directories; open it in setup or a command, not at comptime or from a global initializer.

Related

  • App directories: where the app’s data and cache go.
  • SQL: SQLite for data that grows or is queried.
  • Commands: how the page reaches the store.
  • Windows: remember_geometry.

Search documentation

Type to search the documentation.