7.4k

Files and streaming

Raw file effects are the escape hatch for files the user owns: imports, exports, recordings, images, CSV, and other blobs. App state belongs in Model Persistence, the Record Store, or Relational SQLite; secrets belong in credentials.

Every operation is still an effect. update returns a Cmd, the host performs I/O after the model commits, and results return as later Msg values. Session recording journals those results; streamed chunks live in the content-addressed blobs/ directory beside the journal so replay never opens the original file.

The blob directory is deduplicated but currently has no quota or automatic GC; see Automation: Session recording and blob growth.

Small whole-file operations

Cmd.readFile and Cmd.writeFile remain convenient for payloads up to 1 MiB. An over-bound write is rejected. An over-bound read returns truncated rather than passing cut bytes as a successful file. Cmd.appendFile appends one payload up to 1 MiB, and Cmd.statFile reports { exists, size, mtimeMs } without reading the content.

return [model, Cmd.statFile(path, {
  ok: "file_stat",
  err: "file_failed",
})];

File outcomes are closed and machine-readable: ok, not_found, io_failed, truncated, rejected, cancelled, sink_missing, out_of_order, and disk_full.

Streaming reads

Cmd.readFileStream delivers zero or more 256-KiB chunks, followed by exactly one done message carrying the total byte count, or one err message.

Read-stream keys follow ordinary file semantics: reissuing a live key silently replaces the old read, and Cmd.cancel(key) silently drops it. Stale chunks from the retired generation cannot reach the replacement route.

return [model, Cmd.readFileStream(path, {
  key: "import",
  chunk: "import_chunk",
  done: "import_done",
  err: "import_failed",
})];

Streaming has its own four-slot budget and no total-size ceiling. Each individual chunk remains bounded, so one large import cannot consume the sixteen general spawn/fetch/whole-file slots.

Atomic streaming writes

Open a sink, send one acknowledged chunk at a time, then close it. Close syncs the temporary file and atomically replaces the destination; before a successful close, the old destination stays visible and teardown removes the temporary file.

Cmd.writeFileStream("export", path, { ok: "sink_open", err: "export_failed" });
Cmd.writeFileChunk("export", bytes, { ok: "chunk_written", err: "export_failed" });
Cmd.writeFileClose("export", { ok: "export_done", err: "export_failed" });

A duplicate live sink rejects. A chunk or close issued before the previous acknowledgment returns out_of_order; a chunk or close with no open sink returns sink_missing. Chunks may be at most 1 MiB.

Write sinks use the loud stream discipline: Cmd.cancel(key) ends the sink through err: cancelled and removes its temporary file. A half-written export is never silently replaced or installed.

Filesystem permission

Raw paths inside this app's resolved data, config, cache, state, logs, and temp directories need no permission. Any path outside those roots requires:

.permissions = .{ "filesystem" },

The runtime is authoritative. Before checking, it resolves the target when it exists, or resolves the deepest existing parent and normalizes the missing suffix. Existing symlinks are followed, so a symlink inside an app directory that points outside is external and requires the permission. .. cannot escape an allowed root.

native check also reports NS1074 for certainly-external literal paths, but dynamic paths are decided only by the runtime. File pickers therefore require the filesystem permission because the selected user file is normally outside app-owned directories.

Directory enumeration, file watching, chmod/permissions, and per-path grant prompts are not part of this API.