Skip to content

Sync and conflicts

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:

AnswerWhat happens to the write
connectedfs writes the account copy in the background while the last account listing succeeded, and queues the path otherwise or when that write fails.
disconnectedNothing is owed: no account was ever linked, the person signed out, or the token was refused.
unknownNobody 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:

app.ts
import
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
from '@fkn/lib/fs'
import {
(alias) namespace shell
import shell
shell
} from '@fkn/lib'
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
mount: () => Promise<void>
mount
()
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void
writeFileSync
('library/catalog.json',
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.

@paramreplacer A function that transforms the results.

@paramspace 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
({
items: never[]
items
: [] }))
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
flush: () => Promise<void>
flush
() // resolves once every dirty path was attempted, even when OPFS refused
(alias) namespace shell
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
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
pending: () => string[]
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:

app.ts
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
mount: () => Promise<void>
mount
()
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string
readFileSync
('library/catalog.json', 'utf8')) // this device's copy, whatever the account holds
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
flush: () => Promise<void>
flush
() // nothing dirty, so the hydrate below may replace the entry
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
pull: (path: string) => Promise<Blob>
pull
('library/catalog.json') // the account's copy, into OPFS and not into memory
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
remount: () => Promise<void>
remount
() // hydrates again, OPFS first
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string
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:

StateWhat a read seesWhat a listing showsWhat the drain does next
local onlythis device’s bytesthe OPFS entry alonenothing, until adopt() or the adopt card moves it
queuedthis device’s bytesone entry, the newer stampuploads it while the account stamp still matches
replicatedthis device’s bytesone entry, the newer stampnothing
conflictedthis device’s bytesone entry, the newer stampasks your resolver or the conflict card
pending deleteENOENT, until a remount() lists the account’s copythe account’s entry, while it existsretries 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:

app.ts
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
mount: () => Promise<void>
mount
()
const
const status: () => string
status
= () => {
const
const queued: number
queued
=
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
pending: () => string[]
pending
().
Array<string>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
+
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
pendingDeletes: () => string[]
pendingDeletes
().
Array<string>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
if (!
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
replicating: () => boolean
replicating
()) return 'the account could not be listed, changes wait on this device'
return
const queued: number
queued
> 0 ? `${
const queued: number
queued
} change(s) waiting to reach the account` : 'nothing waiting'
}
function addEventListener<"online">(type: "online", listener: (this: Window, ev: Event) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)
addEventListener
('online', () =>
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
const status: () => string
status
())) // '1 change(s) waiting to reach the account', the drain starts too
(alias) namespace shell
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:

app.ts
const {
const paths: string[]
paths
,
const bytes: number
bytes
} = await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
adoptable: () => Promise<{
paths: string[];
bytes: number;
}>
adoptable
() // ['library/catalog.json'] and its size, or [] and 0 without both halves
if (
const paths: string[]
paths
.
Array<string>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
> 0 &&
function confirm(message?: string): boolean

window.confirm() instructs the browser to display a dialog with an optional message, and to wait until the user either confirms or cancels the dialog.

MDN Reference

confirm
(`Add ${
const paths: string[]
paths
.
Array<string>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
} file(s), ${
const bytes: number
bytes
} bytes, to your account?`)) {
const
const summary: AdoptSummary
summary
= await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
adopt: () => Promise<AdoptSummary>
adopt
()
const summary: AdoptSummary
summary
.
uploaded: string[]
uploaded
// ['library/catalog.json']
const summary: AdoptSummary
summary
.
unresolved: string[]
unresolved
// [], once every conflict was settled
for (const {
const path: string
path
,
const error: string
error
} of
const summary: AdoptSummary
summary
.
failed: {
path: string;
error: string;
}[]
failed
)
var console: Console
console
.
Console.warn(...data: any[]): void

The console.warn() static method outputs a warning message to the console at the 'warning' log level.

MDN Reference

warn
(
const path: string
path
,
const error: string
error
)
}

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).

adopt() rejects with fs: this needs a signed-in account on disconnected and fs: cloud unreachable on unknown, so connect the account first, see account and quota. A failed account listing aborts the run rather than guessing the account is empty. So does a locked account, which keeps the work queued, see encryption.

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:

app.ts
const
const off: () => void
off
=
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
onConflict: (next: ConflictResolver) => (() => void)
onConflict
(async
conflict: FileConflict
conflict
=> {
if (!
conflict: FileConflict
conflict
.
path: string
path
.
String.endsWith(searchString: string, endPosition?: number): boolean

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
const localNewer: 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.

@params 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.

@params A date string

parse
(
conflict: FileConflict
conflict
.
cloud: ConflictSide
cloud
.
updatedAt: string | null
updatedAt
?? '')
return
const localNewer: boolean
localNewer
?
conflict: FileConflict
conflict
.
readLocal: () => Promise<Blob>
readLocal
() :
conflict: FileConflict
conflict
.
readCloud: () => Promise<Blob>
readCloud
() // one write, to the other side
}
const [
const local: Blob
local
,
const cloud: Blob
cloud
] = await
var Promise: PromiseConstructor

Represents the completion of an asynchronous operation

Promise
.
PromiseConstructor.all<[Promise<Blob>, Promise<Blob>]>(values: [Promise<Blob>, Promise<Blob>]): Promise<[Blob, Blob]> (+1 overload)

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.

@paramvalues An array of Promises.

@returnsA new Promise.

all
([
conflict: FileConflict
conflict
.
readLocal: () => Promise<Blob>
readLocal
(),
conflict: FileConflict
conflict
.
readCloud: () => Promise<Blob>
readCloud
()])
try {
const
const merged: any
merged
= { ...
var JSON: 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.

@paramtext A valid JSON string.

@paramreviver 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
const cloud: Blob
cloud
.
Blob.text(): Promise<string>

The text() method of the string containing the contents of the blob, interpreted as UTF-8.

MDN Reference

text
()), ...
var JSON: 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.

@paramtext A valid JSON string.

@paramreviver 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
const local: Blob
local
.
Blob.text(): Promise<string>

The text() method of the string containing the contents of the blob, interpreted as UTF-8.

MDN Reference

text
()) }
return
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.

@paramreplacer A function that transforms the results.

@paramspace 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
(
const merged: any
merged
) // written to both sides, resolved as 'merged'
} catch {
return null // both copies stay, reported under unresolved
}
})
const off: () => 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:

app.ts
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
mount: () => Promise<void>
mount
()
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
unlinkSync: (path: import("node:fs").PathLike) => void
unlinkSync
('library/catalog.json')
await
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
flush: () => Promise<void>
flush
() // the OPFS delete now, and the account delete too while the account is connected
const fs: {
available: () => Promise<boolean>;
readFileSync: (path: import("node:fs").PathLike, options?: ReadOptions) => Buffer | string;
writeFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
appendFileSync: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => void;
existsSync: (path: import("node:fs").PathLike) => boolean;
statSync: (path: import("node:fs").PathLike) => Stats;
... 28 more ...;
onConflict: (next: ConflictResolver) => (() => void);
}
fs
.
pendingDeletes: () => string[]
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:

app.ts
const {
const data: Uint8Array<ArrayBufferLike>
data
,
const sealedAt: string | null
sealedAt
} = await
(alias) namespace cloud
import cloud
cloud
.
namespace cloud_d_exports.fs
export cloud_d_exports.fs
fs
.
fs_d_exports.readFileSealed(path: string): Promise<cloud.fs.SealedRead>
export fs_d_exports.readFileSealed

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
const
const newestSeen: string | null
newestSeen
=
var localStorage: Storage
localStorage
.
Storage.getItem(key: string): string | null

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.

MDN Reference

getItem
('backup:sealedAt')
if (
const sealedAt: string | null
sealedAt
!== null && (
const newestSeen: string | null
newestSeen
=== null ||
const sealedAt: string
sealedAt
>
const newestSeen: string
newestSeen
)) {
var localStorage: Storage
localStorage
.
Storage.setItem(key: string, value: string): void

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.

MDN Reference

setItem
('backup:sealedAt',
const sealedAt: string
sealedAt
)
const apply: (bytes: Uint8Array) => void
apply
(
const data: Uint8Array<ArrayBufferLike>
data
) // newer than any copy this device applied
}

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.

The four rows these calls meet most, each linked to the catalogue:

MessageWhat happened
Concurrent update, retryAnother 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.
fs: cloud unreachableThe probe answered unknown to adopt(), or to a write or delete with no OPFS to queue from. Retry once the broker answers.
fs: no storage backend availableA write or delete with no OPFS met a definite sign-out.
fs: this needs a signed-in accountadopt() ran while signed out. Connect the account first, see account and quota.

Every other message has its row on every error. The exact shapes of AdoptSummary, FileConflict, ConflictResolution and SealedRead are in the generated reference for fs.