Retained-directory staging
stageFileInDirectory() from @openclaw/fs-safe/advanced prepares one file in an existing directory and keeps that directory open until cleanup or disposal. It requires the native binding on Linux or macOS, even in native mode auto. Native off, an unavailable binding, or a binding without the required mechanism fails before file creation. Windows is explicitly unsupported for this API. Existing root, output, temp, lock, and durability APIs retain their documented platform and fallback contracts.
import type { DirectoryReceipt } from "@openclaw/fs-safe/durability";
import {
stageFileInDirectory,
type PublishedFileReceipt,
} from "@openclaw/fs-safe/advanced";
// Call while holding the application's cooperative writer lock. The caller
// supplies its authorization, manager, snapshot, and fingerprint checks.
export async function publishReviewedUpdate(options: {
directory: DirectoryReceipt;
basename: string;
bytes: Uint8Array;
assertAuthorizedAndCurrent(): Promise<void>;
}): Promise<PublishedFileReceipt> {
await options.assertAuthorizedAndCurrent();
await using staged = await stageFileInDirectory({
directory: options.directory,
content: options.bytes,
});
await options.assertAuthorizedAndCurrent();
await staged.assertCurrent();
return await staged.publish(options.basename, { overwrite: true });
}
await using always disposes, including on exceptions. If both the body and disposal fail, JavaScript preserves both errors in a SuppressedError. A successful return means publication was observed and verified, not that a separate application transaction committed. Application rollback and recovery remain with the caller.
#API
function stageFileInDirectory(options: {
directory: string | DirectoryReceipt<Stats | BigIntStats>;
content: string | Uint8Array;
mode?: number;
}): Promise<StagedFile>;
interface StagedFile extends AsyncDisposable {
readonly receipt: StagedFileReceipt;
assertCurrent(): Promise<void>;
publish(basename: string, options: { overwrite: boolean }): Promise<PublishedFileReceipt>;
cleanup(): Promise<StagedFileCleanupReceipt>;
}
Strings are UTF-8. mode is the requested published mode and defaults to 0600; exact final modes, including 000, are supported. The unpublished file stays at 0600 throughout preparation and any awaited application checks. The retained descriptor's actual mode is checked before writing payload bytes, by assertCurrent(), and before publication; a successful but ineffective chmod fails with insecure-permissions. Final mode verification after publication can fail with a published receipt while preserving the completed file. These are POSIX mode checks, not ACL or ownership admission. After rename succeeds and the published entry passes identity validation, the owner applies the requested mode through its retained file descriptor. Content was synchronized during preparation; publication always synchronizes the parent. Modes retaining owner read/write skip the extra mode-only file sync, so a crash may leave the tighter staged 0600 instead of the wider requested mode. Modes removing owner read or write, and corrections of an observed wider staged mode, retain the post-chmod file sync. No parent is created or chmodded. Creation uses an exclusive, no-follow, close-on-exec open of a generated direct child name. Writes use that descriptor. Inspection uses non-following metadata operations, never a potentially blocking reopen of the leaf.
A supplied directory receipt must still match at admission. Caller receipts can carry numeric Stats or exact BigIntStats; untracked numeric identities must be exactly representable. Ambiguous identity fails closed. Returned receipts are frozen descriptive snapshots, not mutable authority. Changing a supplied receipt after admission cannot retarget the lifecycle.
Use fs.lstat(path, { bigint: true }) for caller-created directory receipts: bigint stats retain exact Windows file IDs. Copied or reconstructed receipts with unsafe Number identities reject with FsSafeError("path-mismatch"). Original library receipts retain their private exact identity even when their public numeric metadata cannot represent every bit.
StagedFileReceipt contains directory (path, realPath, and exact bigint identity.dev/identity.ino), temporaryBasename, and the prepared file's identity (dev, ino, size, nlink, mtimeNs, ctimeNs as bigint; mode, uid, gid as numbers). This is a preparation-time snapshot of the private stage: its mode is 0600. Publication does not refresh this snapshot; neither its mode nor its timestamps are a final-file fingerprint. No raw descriptors are exposed.
assertCurrent() verifies the original pathname's directory identity and the staged name against the retained file. A failed check does not disable cleanup. After successful publication there is no staged name to check, so further checks or publication reject. Cleanup closes the lifecycle; later checks and publication reject before descriptor use. Rejected publication still carries its phase, cause, and recorded publication outcome after closure. Concurrent calls are serialized in invocation order, including cleanup and disposal. Repeated cleanup returns the recorded outcome, or repeats the recorded error, without touching descriptors.
#Publication and failure evidence
publish() requires an explicit boolean overwrite and one direct-child basename. Empty, dot, dotdot, separators, absolute paths, NUL, control characters, drive-relative spellings, and the stage's own name are rejected.
With overwrite: false, publication is genuine kernel no-replace rename. A native collision raises FsSafeError("already-exists"), but ordinary errno does not prove that a remote rename never committed. Native rename failures without explicit non-publication provenance therefore report indeterminate: cleanup preserves names and closes descriptors, and further publication rejects. Rejection before rename dispatch, or an unsupported no-replace capability, leaves the stage eligible for identity-checked cleanup. Linux EINVAL for the fixed RENAME_NOREPLACE flag on a regular file renamed to a distinct sibling, ENOSYS, and unsupported-operation errno prove that nothing was published (rename(2), VFS rename contract). This native-only API reports helper-unavailable with the capability name; it does not retry through JavaScript. Root.create() in native auto mode owns that separate compatibility retry. With overwrite: true, publication is plain atomic replacement. Neither route copies. Both source and destination resolve through the retained original parent, with checks immediately before rename and after publication.
PublishedFileReceipt has status: "published", staged, basename, and overwrite. Its staged field retains the private preparation snapshot, not final metadata. Errors from publication carry typed StagedFileFailureDetails in FsSafeError.details, including phase and publication. Publication is not-published, published (with its receipt), or indeterminate (with the attempted basename and overwrite policy) if a rename error cannot establish whether it committed. The underlying error remains in cause.
A parent move after the final pre-check cannot divert the rename to a replacement parent. It can publish inside the moved original and then fail post-validation. Successful rename is recorded before those checks; cleanup never deletes or rolls back a published final name. Indeterminate publication also preserves names for caller-directed recovery.
The same rule applies if applying the published mode, synchronizing, or a later check fails: the error reports published, and cleanup preserves the final name. The file may still have mode 0600 or may already have the requested mode, depending on which operation failed. A published failure receipt records rename success, not successful permission finalization.
#Cleanup guarantee and limits
If the temporary basename still names the object created by this lifecycle and filesystem removal remains permitted, moving or replacing the actual parent (or an ancestor) does not strand the unpublished temp. Cleanup resolves through the retained original directory, even after assertCurrent() rejects drift. Same-name sentinels in the replacement parent are not touched.
StagedFileCleanupReceipt records temporaryBasename, publication, resources (closed or close-failed), and status:
| Status | Meaning |
|---|---|
removed | The owned recorded name was unlinked through the retained directory. |
name-absent | That name was absent in the original directory; this does not prove the inode has no other names. |
preserved | An observed substitute or an indeterminate publication was left alone. |
failed | Inspection or removal failed. |
not-needed | Publication was recorded, or preparation failed before creating a file. |
Explicit cleanup returns preservation outcomes. Removal or close failures throw an FsSafeError with the receipt in details.cleanup and underlying errors in cause. Disposal also throws for preservation, so ignoring its return value cannot hide incomplete cleanup. Setup failures preserve the original error; when cleanup also fails, an aggregate cause retains both failures. Descriptors are closed on every cleanup outcome, with no retry through recycled numbers.
This is directory-relative targeting, not expected-destination-inode/CAS publication. Checking an identity before rename does not make rename CAS. Likewise, the identity check followed by unlinkat is not an atomic conditional unlink: an adversary can replace the leaf in that final syscall gap. Observed substitutions are preserved, but this API does not guarantee recovery after arbitrary child renames, permission revocation, I/O failure, or process death. Keep application authorization, manager checks, snapshots, fingerprints, cooperative locks, and conditional rollback.
Namespace cleanup is not crash durability. Staging retains the native writer's file synchronization behavior, and publication synchronizes its directory (with the existing EPERM exception); cleanup does not promise a durable unlink. No successful receipt promises survival across a crash. See Directory durability when an application needs a separate durability proof. pinDirectory().assertCurrent() and .sync() still require a current pathname; they do not gain this cleanup authority.
#Related pages
#Round-tripping identities
StagedFileReceipt.identity and directory.identity already contain exact bigint dev/ino values. Retain those values directly; do not convert them to Number. PublishedFileReceipt.staged.identity is the same staged file's identity, not a new pathname observation. File metadata such as size and timestamps is a preparation-time snapshot and can change during writing. Publication callbacks using PublishedWriteIdentity likewise already return exact bigint identities.
For the standalone publishFileExclusive() API, retain its exactIdentity companion instead of its legacy identity: Stats; see directory durability.