fs keeps every file on this device and, once an account is connected, a second copy in the account. This page covers what that second copy asks of your app: the write and read rules, file states, adoption, conflicts, deletes, and picking up another device’s change.
The account is the FKN identity a person carries between sites, and that second copy is the account copy. The queue, adoption and conflicts belong to fs alone: opfs has no account half and cloud.fs has no local one, see storage.
fs writes the account copy behind the OPFS copy and never waits for it, so the synchronous calls stay synchronous. OPFS is the browser’s own store for this origin, and the broker is the connection your app holds into FKN. When the in-memory layer flushes a write (see storage), fs writes OPFS, waits for it, then asks the broker whether an account is connected. The answer has three values:
Answer
What happens to the write
connected
fs writes the account copy in the background while the last account listing succeeded, and queues the path otherwise or when that write fails.
disconnected
Nothing is owed: no account was ever linked, the person signed out, or the token was refused.
unknown
Nobody could be asked, so fs queues the path and arms a retry.
Only disconnected drops work owed to the account, because treating unknown the same way would lose a write. A queued path waits for the drain, the background process that replicates queued writes and deletes to the account. Watch the rule run at the flush:
flush() // resolves once every dirty path was attempted, even when OPFS refused
(alias) namespaceshell
import shell
shell.
shell_d_exports.busyReasons(): string[]
export shell_d_exports.busyReasons
What a shell reload would sever right now, as a list of human-readable reasons, empty when
nothing is bound to the broker connection.
This is the evidence for deciding whether to call applyUpdate yet: open sockets and listening
servers, a streaming proxy response, a mounted package, a live frame attachment, unflushed
write-behind. An empty list is not a promise that a reload is free, only that this realm holds
nothing the lib knows about, so an app with state of its own should weigh that too.
It is per REALM, and that distinction has already caused a wrong reading once. A worker that
imports @fkn/lib/net keeps its own tally, which this function cannot see from the window. An
app whose transfers live in a worker should ask the worker, not the page.
busyReasons() // without 'unsaved files' once OPFS took the bytes
pending() // [], or ['library/catalog.json'] once a replicate failed or nobody could be asked
flush() resolved either way, so a refused OPFS write leaves the path dirty and only shell.busyReasons() shows it, see storage. A refused replicate is queued: fs warns with fs: cloud replicate failed, queued for adopt() and lists the path in pending().
The probe behind the answer waits 8 seconds for the broker, and 1 second while the last probe timed out. A timeout answers unknown, so a page whose broker never answers waits up to 8 seconds at its first mount() or flush.
Without OPFS there is nowhere to queue from. A write refuses with fs: cloud unreachable on unknown and with fs: no storage backend available on disconnected. The flush swallows that refusal and leaves the file dirty.
fs reads OPFS first, asks the account only when OPFS has no such file, and caches an account hit into OPFS in the background. A local copy always wins, even when the account holds a newer one. Another device’s write stays invisible here until you ask for it:
readFileSync('library/catalog.json', 'utf8')) // the account's bytes
The first read answered this device’s bytes and the last one the account’s. pull() reads the account, writes what it got into OPFS and resolves with it as a Blob, going around the in-memory layer, so the remount() follows. The flush() comes first because a hydrate leaves a dirty path alone (see storage), so over an unflushed write the pair would seem to do nothing.
pull() rejects with StorageNotFoundError when the account holds nothing at the path, which isNotFound(error) recognises. It rejects with StorageLockedError after a dismissed unlock card, see encryption. Guard the call: a new device often meets the first case.
A stamp is the updatedAt the account reported for a path. A file fs holds is in one of five states, and the state decides what the drain does with it next:
State
What a read sees
What a listing shows
What the drain does next
local only
this device’s bytes
the OPFS entry alone
nothing, until adopt() or the adopt card moves it
queued
this device’s bytes
one entry, the newer stamp
uploads it while the account stamp still matches
replicated
this device’s bytes
one entry, the newer stamp
nothing
conflicted
this device’s bytes
one entry, the newer stamp
asks your resolver or the conflict card
pending delete
ENOENT, until a remount() lists the account’s copy
the account’s entry, while it exists
retries it while the stamp still matches and the file was not recreated here, else abandons it
Under a connected account mount() lists both halves and merges them by path, and the newer updatedAt wins. Signed out or on unknown, the listing is the OPFS half alone. The stamps it saw are what conflict detection compares against. The merge decides an entry’s mtime and content type, never which bytes are loaded, so statSync can report the account’s newer time over this device’s older bytes.
replicating() answers whether the last listing was complete. It is true while signed out, when the account half was never asked, and false only after an account listing failed while connected, which suspends replication until a listing succeeds. pending() and pendingDeletes() list the paths still owed, so a status line reads the queue and not the flag:
status())) // '1 change(s) waiting to reach the account', the drain starts too
(alias) namespaceshell
import shell
shell.
shell_d_exports.busyReasons(): string[]
export shell_d_exports.busyReasons
What a shell reload would sever right now, as a list of human-readable reasons, empty when
nothing is bound to the broker connection.
This is the evidence for deciding whether to call applyUpdate yet: open sockets and listening
servers, a streaming proxy response, a mounted package, a live frame attachment, unflushed
write-behind. An empty list is not a promise that a reload is free, only that this realm holds
nothing the lib knows about, so an app with state of its own should weigh that too.
It is per REALM, and that distinction has already caused a wrong reading once. A worker that
imports @fkn/lib/net keeps its own tally, which this function cannot see from the window. An
app whose transfers live in a worker should ask the worker, not the page.
busyReasons() // includes 'storage replication' while a drain or an adopt offer is in flight
nothing waiting says the queue is empty and nothing about the account, since a signed-out app reads it too. For the account, ask account.info(). Queued work drains on its own: on online, on an account change and after every complete listing under an account.
A drain that threw or left per-file failures arms a retry, 60 seconds doubling up to 10 minutes. One that left a conflict unresolved arms nothing, so a timer never re-raises a dismissed card.
Files written before sign-in owe the account nothing and stay local. After a complete listing under an account with nothing queued, the broker offers the local-only files it has not been told to skip. The card is Add your files to your account?, with Add to my account and Keep local only.
fs remembers a decline per path in this origin’s localStorage and never asks about that path again. An account change runs the offer for the rest. The broker’s account menu still counts every local-only file, declined ones included, and can add them from there. Your app can ask the same question:
adoptable() counted one local-only file. The person agreed, and adopt() uploaded it. adopt() runs the drain’s own push with local-only files included, serialised so the two never race. Its summary has six lists: uploaded, kept (paths the account already held), resolved (each with its choice, 'local', 'cloud' or 'merged'), unresolved, deleted and failed (each with its error message).
A conflict is a queued path whose account stamp moved since this device last saw it. Both sides changed, so pushing would overwrite the other device’s work. By default the broker shows its conflict card, Two versions of a file, with This device and Your account as the answers and a close that keeps both. Register a resolver with onConflict to decide in code instead:
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
endPosition – length(this). Otherwise returns false.
endsWith('.json')) {
const
constlocalNewer:boolean
localNewer=
var Date:DateConstructor
Enables basic storage and retrieval of dates and times.
Date.
DateConstructor.parse(s: string): number
Parses a string containing a date, and returns the number of milliseconds between that date and midnight, January 1, 1970.
@param ― s A date string
parse(
conflict: FileConflict
conflict.
local: ConflictSide
local.
updatedAt: string |null
updatedAt??'') >=
var Date:DateConstructor
Enables basic storage and retrieval of dates and times.
Date.
DateConstructor.parse(s: string): number
Parses a string containing a date, and returns the number of milliseconds between that date and midnight, January 1, 1970.
Creates a Promise that is resolved with an array of results when all of the provided Promises
resolve, or rejected when any Promise is rejected.
@param ― values An array of Promises.
@returns ― A new Promise.
all([
conflict: FileConflict
conflict.
readLocal: () =>Promise<Blob>
readLocal(),
conflict: FileConflict
conflict.
readCloud: () =>Promise<Blob>
readCloud()])
try {
const
constmerged:any
merged= { ...
varJSON:JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
JSON.
JSON.parse(text: string, reviver?: (this:any, key:string, value:any) => any): any
Converts a JavaScript Object Notation (JSON) string into an object.
@param ― text A valid JSON string.
@param ― reviver A function that transforms the results. This function is called for each member of the object.
If a member contains nested objects, the nested objects are transformed before the parent object is.
@throws ― {SyntaxError} If text is not valid JSON.
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
JSON.
JSON.parse(text: string, reviver?: (this:any, key:string, value:any) => any): any
Converts a JavaScript Object Notation (JSON) string into an object.
@param ― text A valid JSON string.
@param ― reviver A function that transforms the results. This function is called for each member of the object.
If a member contains nested objects, the nested objects are transformed before the parent object is.
@throws ― {SyntaxError} If text is not valid JSON.
parse(await
constlocal:Blob
local.
Blob.text(): Promise<string>
The text() method of the string containing the contents of the blob, interpreted as UTF-8.
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
@param ― value A JavaScript value, usually an object or array, to be converted.
@param ― replacer A function that transforms the results.
@param ― space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
@throws ― {TypeError} If a circular reference or a BigInt value is found.
stringify(
constmerged:any
merged) // written to both sides, resolved as 'merged'
} catch {
returnnull// both copies stay, reported under unresolved
}
})
constoff: () =>void
off() // back to the conflict card
The resolver merges a JSON file, keeps the newer side of any other file, and leaves a side that does not parse for later. It receives one FileConflict, whose readLocal() and readCloud() answer each side’s bytes as a Blob, read once and remembered. It returns a ConflictResolution from @fkn/lib/fs: the write data that wins, or null, see TypeScript.
fs recognises the very Blob from readLocal() or readCloud() by identity, so picking a side costs one write. Any other value is a merge that fs writes to both sides, a rebuilt Blob with equal bytes included. null leaves both copies and reports the path under unresolved. A resolver that throws counts as null.
Comparing updatedAt stamps, as the example does, is a heuristic and never proof of which copy is newest, see reading with the seal time.
A delete removes the OPFS copy and, while connected, the account copy. fs queues a refused account delete and warns with fs: cloud delete failed, queued for retry. On unknown, fs queues the delete only for a path the account was seen to hold. A delete always drops a queued upload of the same path, so a file deleted locally owes the account nothing:
pendingDeletes() // [], or ['library/catalog.json'] once the account delete failed or could not be attempted
Under a connected account both deletes ran inside the flush, so the queue holds the path only after a refusal or an unknown. The drain retries it under the rule in the state table.
Deletes do not travel between devices: another device that still holds the file lists it again, and there are no tombstones, see limitations.
readFileSealed(path) reads a file and reports when the copy was sealed, as { data, sealedAt }. On fs it follows the read rule. A local hit answers sealedAt: null, because an OPFS file carries no envelope, so the stamp arrives only for a path this device does not hold. On cloud.fs it always reads the account, which is the form a high-water mark wants:
A cloud read that also reports when the copy was sealed.
sealedAt is an ISO string, or null when the envelope carries no such field (one written before
the seal time existed) or when the data plane is older than this call. The value is
AUTHENTICATED, so the server can neither forge nor alter it, and it is the WRITER's own claim
about when it sealed, so on its own it does not prove this copy is the newest one: an older but
perfectly authentic copy of the same path is still authentic. An app that needs that answer keeps
a high-water mark per path and compares. Do not confuse it with the updatedAt on a listing,
which is the server's hint and is fine for display and nothing else.
The bytes and the stamp come from ONE read of ONE envelope, which is what makes the stamp
describe the bytes returned beside it. readFile is unchanged and unaffected.
readFileSealed('backup/library.bin') // the account's copy and its seal time
The getItem() method of the Storage interface, when passed a key name, will return that key's value, or null if the key does not exist, in the given Storage object.
The setItem() method of the Storage interface, when passed a key name and value, will add that key to the given Storage object, or update that key's value if it already exists.
apply ran only when the stamp beat the newest this device had applied. sealedAt is the writer’s own claim about when it sealed, not proof of being newest. An older but authentic copy is still authentic, and the mark is what stops it from replacing a newer one.
The stamp is an ISO string authenticated with the bytes, so it describes exactly those bytes. It is null for an object sealed before the seal time existed, a broker too old to report it, or a stamp no Date can express.
Both reads reject the way pull() does. On fs the call goes around the in-memory layer, so it does not see an unflushed synchronous write, and it never caches what it fetched into OPFS.
A file follows the account to every device that signs in. A delete does not.
The first listing after a sign-out wipes the stamps this device remembered, so one account’s stamps never vouch for the next account’s files. Signing out leaves OPFS untouched, so the next account on the same browser sees the previous account’s local files and may be offered them. It also leaves the pending queue in place, because only a local delete drops a queued upload. A write queued under one account therefore drains into the next account, or conflicts with it.
Another writer moved the object between the presign and the commit, or between the read and the delete, so the service, the FKN server keeping account files, refused. The path is queued, and adopt() lists it under failed.