Better FS Tools
Write tools

Read before write

Refuse changes to files the model has not read or that changed after the read, with the state of the read tool.

The read tool stores a record of each read in its state. The write tools look up that record before they change a file that exists. The default state is null, on the single factories and on the bundles. Then the tools do not check. They change a file that the model has not read, and add no note. The tool descriptions still tell the model to read a file before it changes it.

Share the store with the read tool

Give the read tool and the write tools the same state and the same digest. A record that another digest made does not count. nodeDigest() and the default sha256Digest() have the same id, so their records count for each other:

import { nodeDigest, nodeFileSystem } from "@better-fs-tools/node";
import { createReadTool, memoryStore } from "@better-fs-tools/read";
import { createEditTool, createWriteTool, memoryLocks } from "@better-fs-tools/write";

const cwd = process.cwd();
const fs = nodeFileSystem({ cwd, allowedRoots: [cwd] });
const shared = { fs, digest: nodeDigest(), state: memoryStore() };
const read = createReadTool(shared);
const locks = memoryLocks();
const edit = createEditTool({ ...shared, locks });
const write = createWriteTool({ ...shared, locks });

A change to a file with no record returns status error with code NOT_READ:

[edit:not-read] Read src/greet.ts with the read tool before changing it.

state needs a digest, as on the read tool. The default digest is sha256Digest(). With digest: null and a state, the options do not type-check, and the factory throws TypeError.

Give the write tools one locks too. The lock stops two calls from changing one file at the same time. Each factory makes its own lock manager by default, so edit and write would not wait for each other. A lock works in one process only.

What counts as a read

Caseedit and apply_patchwrite
The file is newNo read neededNo read needed
No recordNOT_READNOT_READ
The model read part of the fileGoes onNOT_READ
Ranged reads show every lineGoes onGoes on
The file changed after the readSTALESTALE

write replaces the whole file, so it needs a read of the whole file. Reads in ranges of the same content count when together they show every line:

[write:not-read] Read all of src/greet.ts before replacing it. Reads in ranges count when together they show every line.

When the file changed after the read, the call fails with STALE:

[edit:stale] src/greet.ts changed since it was last read. Read it again, then retry.

After a change, the tool stores a new record, so the model can change the file again with no new read. When the model has not seen the new text, the next write needs a read. This is the case when the user changed the content in askBeforeWrite(), when a hook rewrote the file, or after a stale edit with onStale: "rematch". A no-change result stores nothing.

memoryStore() keeps 1,000 records for 30 minutes. After that, the model must read the file again. A failed store call counts as no record.

Change the rules

Set preconditions with the rules you want to change. The other rules keep their defaults:

const edit = createEditTool({ ...shared, locks, preconditions: { onStale: "rematch" } });

With onStale: "rematch", edit and apply_patch check the old text against the changed file. When every old text still matches exactly once, the change goes on with a note:

[edit:stale-rematched] src/greet.ts changed since it was last read, but every old text still matched exactly once, so the edit was applied.
RuleDefaultValues
requireRead"existing""existing" needs a record for a file that exists. "off" turns the check off
partialRead"edit-only"Which tools accept a read of part of the file: "edit-only" for edit and apply_patch, "always", or "never"
onStale"reject""reject" returns STALE. "rematch" lets edit and apply_patch go on when the old text still matches. write always returns STALE

Forget a read

Other tools can change a file and leave the record in place. For example, a bash command can run sed -i. The write tools still find that the file changed, and return STALE. To make the next change need a new read, delete the record with createInvalidator():

import { createInvalidator } from "@better-fs-tools/write";

const invalidate = createInvalidator({ fs, state: shared.state });

const outcome = await invalidate("src/greet.ts");
outcome.ok; // true

On success, outcome.recorded tells you if there was a record. invalidate never throws. When the stat or the store fails, it returns ok: false, and the record can still be there.

Use one bundle

createFsTools() creates read, edit, and write with one state, one digest, one locks, and one clock. The default state is null, and the default digest is sha256Digest(). Set state: memoryStore() to turn on read before write. Set applyPatch: true to also create apply_patch:

import { createFsTools } from "@better-fs-tools/write";

const { read, edit, write, applyPatch, invalidate } = createFsTools({
  fs,
  state: memoryStore(),
  applyPatch: true,
});

To use one store for each session, set state to a function of the call, as on the read tool. Each tool of the bundle calls it at most once for each call. Then pass the call context to invalidate(path, call). See Choose an integration.

On this page