fix(fs): SDK uid stat cache, readdir page types, move lookup errors, UID-shaped paths (#4109)

- stat: only path-addressed results are cached; uid-addressed stats no
  longer share one cache key.
- readdir: overloads declare the page result for recursive + limit/offset
  and for the positional page/stream forms.
- move: only a not-found destination lookup means "new path"; other
  lookup failures reject. A uid destination skips the lookup.
- getAbsolutePathForApp always resolves paths. read/copy/move/delete use
  getAbsolutePathOrUidForApp, which keeps sending UID-shaped strings as uids.
- Docs for read, copy, move, delete and readdir.
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-07 11:46:22 -07:00
1 parent 27eebc7300
commit 4a54beac6b
15 files changed
+260 -47

No files matched your search

+4 -2
View File
@@ -18,11 +18,13 @@ puter.fs.copy(options)
#### `source` (String) (Required)
The path to the file or directory to copy.
The path or UID of the file or directory to copy.
#### `destination` (String) (Required)
The path to the destination directory. If destination is a directory then the file or directory will be copied into that directory using the same name as the source file or directory. If the destination is a file, we overwrite if overwrite is `true`, otherwise we error.
The path or UID of the destination directory. If destination is a directory then the file or directory will be copied into that directory using the same name as the source file or directory. If the destination is a file, we overwrite if overwrite is `true`, otherwise we error.
Relative paths resolve against the app's root directory. A relative name that looks like a UID is read as one; prefix it with `./` to address a file by that name.
#### `options` (Object) (Optional)
+2 -2
View File
@@ -18,8 +18,8 @@ puter.fs.delete(options)
#### `paths` (String | String[]) (required)
A single path or array of paths of the file(s) or directory(ies) to delete.
If a path is not absolute, it will be resolved relative to the app's root directory.
A single path or array of paths of the file(s) or directory(ies) to delete. A UID can be given in place of a path.
If a path is not absolute, it will be resolved relative to the app's root directory. A relative name that looks like a UID is read as one; prefix it with `./` to address a file by that name.
#### `options` (Object) (optional)
+6 -2
View File
@@ -18,11 +18,15 @@ puter.fs.move(options)
#### `source` (String) (Required)
The path to the file or directory to move.
The path or UID of the file or directory to move.
#### `destination` (String) (Required)
The path to the destination directory. If destination is a directory then the file or directory will be moved into that directory using the same name as the source file or directory. If the destination is a file, we overwrite if overwrite is `true`, otherwise we error.
The path to the destination directory, or the item's new path. If destination is a directory then the file or directory will be moved into that directory using the same name as the source file or directory. Otherwise the item is moved into the destination's parent directory under its last path component; if a file is already there, we overwrite if overwrite is `true`, otherwise we error. A UID can be given in place of a path, and must name a directory.
Relative paths resolve against the app's root directory. A relative name that looks like a UID is read as one; prefix it with `./` to address a file by that name.
Without `newName`, the destination is looked up first to tell the two cases apart. If that lookup fails for any reason other than the destination not existing (for example, access is denied), the promise rejects with that error.
#### `options` (Object) (Optional)
+2 -2
View File
@@ -18,8 +18,8 @@ puter.fs.read(options)
#### `path` (String) (required)
Path of the file to read.
If `path` is not absolute, it will be resolved relative to the app's root directory.
Path of the file to read, or its UID.
If `path` is not absolute, it will be resolved relative to the app's root directory. A relative name that looks like a UID is read as one; prefix it with `./` to address a file by that name.
#### `options` (Object) (optional)
+1 -1
View File
@@ -43,7 +43,7 @@ A `Promise` that resolves to an array of [`FSItem`](/Objects/fsitem/) objects (f
Each item carries `is_shared`: `true` when it has been shared with someone, `false` when it has not, and `null` for items that are not yours. Only shares on the item itself count — the children of a folder you shared report `false`, since the share lives on the folder. Use [`getShares()`](/FS/getShares/) on an item to see who can reach it, including access inherited from a parent.
When the request includes `cursor` (even `null`) or `includeTotal`, the promise instead resolves to a page object:
When the request includes `cursor` (even `null`) or `includeTotal`, or is `recursive` with a `limit` or `offset`, the promise instead resolves to a page object:
- `items` (Array): The [`FSItem`](/Objects/fsitem/) objects on this page.
- `cursor` (String) (optional): Present while more pages exist; pass it to the next call.
@@ -1,4 +1,4 @@
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
import { getAbsolutePathOrUidForApp } from '../utils/getAbsolutePathForApp.js';
import { defineOperation, firstDefined } from './scaffold.js';
/** @typedef {import('../types.js').CopyOptions} CopyOptions */
@@ -6,8 +6,8 @@ import { defineOperation, firstDefined } from './scaffold.js';
/**
* Copies a file or directory to another location. Relative paths resolve
* against the app's root directory. When `destination` is a directory the item
* is copied into it under the same name.
* against the app's root directory; a UID-shaped string is read as a uid. When
* `destination` is a directory the item is copied into it under the same name.
*
* @type {{
* (options: CopyOptions): Promise<FSItem>,
@@ -29,8 +29,8 @@ const copy = defineOperation({
// No socket at all for a client that opted out (`puter.socketEnabled`).
original_client_socket_id: this.socket?.id,
socket_id: this.socket?.id,
source: getAbsolutePathForApp(options.source),
destination: getAbsolutePathForApp(options.destination),
source: getAbsolutePathOrUidForApp(options.source),
destination: getAbsolutePathOrUidForApp(options.destination),
overwrite: options.overwrite,
new_name: firstDefined(options, 'newName', 'new_name'),
// if user is copying an item to where its source is, change the name so there is no conflict
@@ -1,11 +1,11 @@
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
import { getAbsolutePathOrUidForApp } from '../utils/getAbsolutePathForApp.js';
import { defineOperation, firstDefined } from './scaffold.js';
/** @typedef {import('../types.js').DeleteOptions} DeleteOptions */
/**
* Deletes one or more files or directories. Relative paths resolve against the
* app's root directory.
* app's root directory; a UID-shaped string is read as a uid.
*
* Named `deleteFSEntry` rather than `delete` because `delete` is a reserved
* keyword; it is exposed as `puter.fs.delete`.
@@ -29,7 +29,7 @@ const deleteFSEntry = defineOperation({
return {
endpoint: '/delete',
body: {
paths: paths.map((path) => getAbsolutePathForApp(path)),
paths: paths.map((path) => getAbsolutePathOrUidForApp(path)),
descendants_only: firstDefined(options, 'descendantsOnly', 'descendants_only') ?? false,
recursive: options.recursive ?? true,
},
@@ -1,10 +1,9 @@
import { looksLikeUid } from '../utils/getAbsolutePathForApp.js';
import { parseOperationArgs } from './scaffold.js';
import stat from './stat.js';
/** @typedef {import('../types.js').GetShareLinkOptions} GetShareLinkOptions */
const UUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
/**
* @typedef {{
* (options: GetShareLinkOptions): Promise<string>,
@@ -44,7 +43,7 @@ const getShareLinkImpl = async function (...args) {
const item = typeof options.item === 'string' ? options.item : undefined;
const uid = options.uid !== undefined
? String(options.uid)
: (item !== undefined && UUID.test(item) ? item : undefined);
: (looksLikeUid(item) ? item : undefined);
const path = uid === undefined ? (options.path ?? item) : undefined;
if ( uid === undefined && (typeof path !== 'string' || path === '') ) {
return fail({ message: 'getShareLink() needs a path or a uid.', code: 'field_missing' });
@@ -1,5 +1,5 @@
import path from 'path-browserify';
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
import { getAbsolutePathOrUidForApp, looksLikeUid } from '../utils/getAbsolutePathForApp.js';
import { defineOperation, firstDefined } from './scaffold.js';
import stat from './stat.js';
@@ -8,9 +8,9 @@ import stat from './stat.js';
/**
* Moves a file or directory to another location. Relative paths resolve
* against the app's root directory. When `destination` is a directory the item
* is moved into it under the same name; otherwise the last path component is
* used as the new name.
* against the app's root directory; a UID-shaped string is read as a uid. When
* `destination` is a directory the item is moved into it under the same name;
* otherwise the last path component is used as the new name.
*
* @type {{
* (options: MoveOptions): Promise<FSItem>,
@@ -26,19 +26,24 @@ import stat from './stat.js';
const move = defineOperation({
positional: ['source', 'destination'],
async request (options) {
const source = getAbsolutePathForApp(options.source);
let destination = getAbsolutePathForApp(options.destination);
const source = getAbsolutePathOrUidForApp(options.source);
let destination = getAbsolutePathOrUidForApp(options.destination);
let newName = firstDefined(options, 'newName', 'new_name');
if ( ! newName ) {
// Whether the destination names a directory to move into, or the
// new path of the item itself.
// A path destination is either a directory to move into or the item's
// new path; a uid can only be a directory, so it needs no lookup.
if ( ! newName && ! looksLikeUid(destination) ) {
let destinationIsDir = false;
try {
const destStats = await stat.call(this, destination);
destinationIsDir = Boolean(destStats.is_dir);
} catch (e) {
// Destination doesn't exist — treat it as the new path.
// Only a missing destination is a new path. A denied or failed
// lookup must not turn the move into a rename.
if ( e?.code !== 'subject_does_not_exist' ) {
if ( typeof options.error === 'function' ) options.error(e);
throw e;
}
}
if ( ! destinationIsDir ) {
newName = path.basename(destination);
@@ -205,6 +205,44 @@ describe('move', () => {
create_missing_parents: true,
});
});
it('treats a missing destination as the new path', async () => {
FakeXHR.respondWith = (body, xhr) => {
if ( ! xhr.url.endsWith('/stat') ) return { success: true };
xhr.status = 404;
return { message: 'Subject does not exist', code: 'subject_does_not_exist' };
};
await fs.move('/a/file.txt', '/b/renamed.txt');
expect(lastRequest().url).toBe('https://api.test/move');
expect(lastBody()).toMatchObject({ destination: '/b', new_name: 'renamed.txt' });
});
it('rejects without moving when the destination lookup is denied', async () => {
const denied = { message: 'Access denied', code: 'access_denied' };
FakeXHR.respondWith = (body, xhr) => {
xhr.status = 403;
return denied;
};
const error = vi.fn();
await expect(fs.move('/a/file.txt', '/b/hidden', undefined, undefined, error))
.rejects.toEqual(denied);
expect(error).toHaveBeenCalledWith(denied);
expect(FakeXHR.requests.map(xhr => xhr.url)).toEqual(['https://api.test/stat']);
});
it('moves into a uid destination without a lookup', async () => {
const dirUid = '0f8fad5b-d9cb-469f-a165-70867728950e';
await fs.move('/a/file.txt', dirUid);
expect(FakeXHR.requests).toHaveLength(1);
expect(lastBody()).toMatchObject({ source: '/a/file.txt', destination: dirUid });
expect(lastBody().new_name).toBeUndefined();
});
it('sends a uid source through unchanged', async () => {
const uid = '0f8fad5b-d9cb-469f-a165-70867728950e';
await fs.move({ source: uid, destination: '/b', newName: 'x.txt' });
expect(lastBody()).toMatchObject({ source: uid, destination: '/b' });
});
});
describe('mkdir', () => {
@@ -221,6 +259,12 @@ describe('mkdir', () => {
});
});
it('resolves a UID-shaped relative name as a path', async () => {
const name = '0f8fad5b-d9cb-469f-a165-70867728950e';
await fs.mkdir(name);
expect(lastBody()).toMatchObject({ parent: '~', path: name });
});
it('mkdir(path, options) applies the options', async () => {
await fs.mkdir('/a/b', { dedupeName: true, createMissingParents: true });
expect(lastBody()).toMatchObject({ dedupe_name: true, create_missing_parents: true });
@@ -284,6 +328,14 @@ describe('read', () => {
expect(lastRequest().url).toBe('https://api.test/read?file=%2Fa%2Ffile.txt&offset=4&byte_count=8');
});
it('reads by uid, and by path when a UID-shaped name has a ./ prefix', async () => {
const uid = '0f8fad5b-d9cb-469f-a165-70867728950e';
await fs.read(uid);
expect(lastRequest().url).toBe(`https://api.test/read?file=${uid}`);
await fs.read(`./${uid}`);
expect(lastRequest().url).toBe(`https://api.test/read?file=%7E%2F${uid}`);
});
it('opts back into HTTP caching with { cache: true }', async () => {
await fs.read('/a/file.txt', { cache: true });
expect(lastRequest().requestHeaders?.['Cache-Control']).toBeUndefined();
@@ -495,6 +547,22 @@ describe('stat', () => {
expect(item.is_shared).toBe(false);
});
it('does not serve one uid\'s cached entry for another uid', async () => {
FakeXHR.respondWith = body => ({ uid: body.uid, name: `${body.uid}.txt`, is_dir: false });
await fs.stat({ uid: 'uid-a' });
FakeXHR.requests = [];
const item = await fs.stat({ uid: 'uid-b', consistency: 'eventual' });
expect(FakeXHR.requests).toHaveLength(1);
expect(item.uid).toBe('uid-b');
});
it('resolves a UID-shaped relative path as a path', async () => {
const name = '0f8fad5b-d9cb-469f-a165-70867728950e';
await fs.stat(name);
expect(lastBody().path).toBe(`~/${name}`);
expect(lastBody().uid).toBeUndefined();
});
it('keeps a share-carrying result out of the cache', async () => {
FakeXHR.respondWith = () => ({ uid: 'u1', is_dir: false, shares: [] });
await fs.stat('/a/cached.txt', { returnShares: true });
@@ -1,11 +1,12 @@
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
import { getAbsolutePathOrUidForApp } from '../utils/getAbsolutePathForApp.js';
import { defineOperation } from './scaffold.js';
/** @typedef {import('../types.js').ReadOptions} ReadOptions */
/**
* Reads a file and resolves with its contents as a `Blob`. Relative paths
* resolve against the app's root directory.
* resolve against the app's root directory; a UID-shaped string is read as a
* uid.
*
* @type {{
* (options: ReadOptions): Promise<Blob>,
@@ -25,7 +26,7 @@ import { defineOperation } from './scaffold.js';
const read = defineOperation({
positional: ['path'],
request (options) {
const query = new URLSearchParams({ file: getAbsolutePathForApp(options.path) });
const query = new URLSearchParams({ file: getAbsolutePathOrUidForApp(options.path) });
if ( options.offset ) query.set('offset', String(options.offset));
if ( options.byte_count ) query.set('byte_count', String(options.byte_count));
@@ -5,8 +5,10 @@ import mapV2EntryToV1 from '../utils/mapV2EntryToV1.js';
import { fsRequest, parseOperationArgs } from './scaffold.js';
/** @typedef {import('../types.js').ReaddirOptions} ReaddirOptions */
/** @typedef {import('../types.js').ReaddirOptionsOwn} ReaddirOptionsOwn */
/** @typedef {import('../types.js').FSItemRead} FSItemRead */
/** @typedef {import('../../../lib/types.js').ListPage<FSItemRead>} FSItemPage */
/** @typedef {import('../../../lib/types.js').RequestCallbacks<FSItemPage>} FSItemPageCallbacks */
// Listings larger than this are served but never cached.
const MAX_CACHE_SIZE = 100 * 1024 * 1024;
@@ -162,11 +164,31 @@ const readdirPaged = async function (options) {
return await requestPromise;
};
/**
* Options that make readdir resolve with one `{items, cursor?, total?}` page:
* `cursor` or `includeTotal`, or a `recursive` listing bounded by `limit` or
* `offset` (recursive listings are always paged).
*
* @typedef {ReaddirOptionsOwn & FSItemPageCallbacks & (
* | { cursor: string | null }
* | { includeTotal: true }
* | { recursive: true, limit: number }
* | { recursive: true, offset: number }
* )} ReaddirPageOptions
*/
/**
* @typedef {{
* (options: ReaddirOptions & { stream: true }): AsyncIterableIterator<FSItemPage>,
* (options: ReaddirOptions & ({ cursor: string | null } | { includeTotal: true })): Promise<FSItemPage>,
* (options: ReaddirPageOptions): Promise<FSItemPage>,
* (options: ReaddirOptions): Promise<FSItemRead[]>,
* (path: string, options: ReaddirOptions & { stream: true }): AsyncIterableIterator<FSItemPage>,
* (
* path: string,
* options: ReaddirPageOptions,
* success?: (value: FSItemPage) => void,
* error?: (reason: unknown) => void,
* ): Promise<FSItemPage>,
* (
* path: string,
* options?: ReaddirOptions,
@@ -186,8 +208,9 @@ const readdirPaged = async function (options) {
* resolve against the app's root directory) or by `uid`.
*
* By default the whole listing is returned as an array. Passing `cursor` or
* `includeTotal` returns one `{items, cursor?, total?}` page instead, and
* `stream: true` returns an async iterator over those pages.
* `includeTotal`, or `limit`/`offset` with `recursive`, returns one
* `{items, cursor?, total?}` page instead, and `stream: true` returns an async
* iterator over those pages.
*
* @type {ReaddirOperation}
*/
@@ -53,13 +53,14 @@ const statImpl = async function (...args) {
options.consistency = 'strong';
}
// Generate cache key based on path or uid
// Only path-addressed entries are cached: path-based invalidation (socket
// events, share changes) can't reach an entry keyed by uid.
let cacheKey;
if ( options.path ) {
if ( options.uid === undefined && options.path ) {
cacheKey = `item:${ options.path}`;
}
if ( options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize && !options.returnShares ) {
if ( cacheKey && options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize && !options.returnShares ) {
const cachedResult = await puter._cache.get(cacheKey);
if ( cachedResult ) {
return cachedResult;
@@ -105,7 +106,7 @@ const statImpl = async function (...args) {
result.shares = result.shares.map(toShare);
}
// Not cached — a later plain stat must not serve share data.
if ( ! options.returnShares && JSON.stringify(result).length <= MAX_CACHE_SIZE ) {
if ( cacheKey && ! options.returnShares && JSON.stringify(result).length <= MAX_CACHE_SIZE ) {
puter._cache.set(cacheKey, result);
}
return result;
@@ -1,5 +1,14 @@
import path from 'path-browserify';
const reLooksLikeUUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
/**
* @param {unknown} value
* @returns {value is string}
*/
export const looksLikeUid = (value) =>
typeof value === 'string' && reLooksLikeUUID.test(value);
const getAbsolutePathForApp = (relativePath, puter = globalThis.puter) => {
// preserve previous behavior for falsy values when env is gui
if ( puter.env === 'gui' && !relativePath )
@@ -7,12 +16,6 @@ const getAbsolutePathForApp = (relativePath, puter = globalThis.puter) => {
return relativePath;
}
const reLooksLikeUUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
const isUUID = reLooksLikeUUID.test(relativePath);
if ( isUUID ) {
return relativePath;
}
// if no relative path is provided, use the current working directory
if ( ! relativePath )
{
@@ -32,4 +35,16 @@ const getAbsolutePathForApp = (relativePath, puter = globalThis.puter) => {
return relativePath;
};
export default getAbsolutePathForApp;
/**
* For request fields the backend reads as either a path or a uid: a
* UID-shaped string is sent as-is (a uid), anything else is resolved as a
* path. A relative name that looks like a UID needs a `./` prefix.
*
* @param {string} pathOrUid
* @param {unknown} [puter]
* @returns {string}
*/
export const getAbsolutePathOrUidForApp = (pathOrUid, puter = globalThis.puter) =>
looksLikeUid(pathOrUid) ? pathOrUid : getAbsolutePathForApp(pathOrUid, puter);
export default getAbsolutePathForApp;
+95
View File
@@ -348,6 +348,22 @@ export default suite('fs', {
t.assert.deepEqual(rel, ['a', 'a/b', 'a/b/deep.txt', 'top.txt']);
},
'readdir recursive with limit resolves to a page': async (t) => {
const base = `${home(t)}/fs-suite-recursive-limit`;
await t.puter.fs.write(`${base}/a/one.txt`, '1', {
createMissingParents: true,
});
await t.puter.fs.write(`${base}/two.txt`, '2');
const page = await t.puter.fs.readdir({
path: base,
recursive: true,
limit: 1,
});
t.assert.equal(Array.isArray(page), false);
t.assert.equal(page.items.length, 1);
t.assert.equal(typeof page.cursor, 'string');
},
'copy duplicates a file': async (t) => {
const src = `${home(t)}/fs-suite-copy-src.txt`;
const dstDir = `${home(t)}/fs-suite-copy-dst`;
@@ -426,6 +442,67 @@ export default suite('fs', {
t.assert.equal(await blob.text(), 'move+rename');
},
'move into a directory addressed by uid': async (t) => {
const src = `${home(t)}/fs-suite-move-to-uid-src.txt`;
const dir = await t.puter.fs.mkdir(
`${home(t)}/fs-suite-move-to-uid-dst`,
);
await t.puter.fs.write(src, 'into uid dir');
await t.puter.fs.move(src, dir.uid);
const blob = await t.puter.fs.read(
`${home(t)}/fs-suite-move-to-uid-dst/fs-suite-move-to-uid-src.txt`,
);
t.assert.equal(await blob.text(), 'into uid dir');
},
'read, copy, move and delete take a uid in place of a path': async (t) => {
const file = await t.puter.fs.write(
`${home(t)}/fs-suite-uid-selector.txt`,
'by uid',
);
const dir = await t.puter.fs.mkdir(
`${home(t)}/fs-suite-uid-selector-dir`,
);
t.assert.equal(
await (await t.puter.fs.read(file.uid)).text(),
'by uid',
);
await t.puter.fs.copy(file.uid, dir.uid, { newName: 'copied.txt' });
await t.puter.fs.move(file.uid, dir.uid, { newName: 'moved.txt' });
const names = (
await t.puter.fs.readdir(`${home(t)}/fs-suite-uid-selector-dir`)
)
.map((e) => e.name)
.sort();
t.assert.deepEqual(names, ['copied.txt', 'moved.txt']);
await t.puter.fs.delete(file.uid);
await t.assert.rejects(
() => t.puter.fs.stat({ uid: file.uid }),
'deleted by uid',
);
},
'a UID-shaped relative name resolves as a path': async (t) => {
const dirName = '6a1f3c2e-8b4d-4e5f-9a6b-7c8d9e0f1a2b';
const fileName = '7b2e4d3f-9c5e-4f60-8b7c-8d9e0f1a2b3c';
const dir = await t.puter.fs.mkdir(dirName);
t.assert.equal(dir.path, `${home(t)}/${dirName}`);
await t.puter.fs.write(`${dirName}/inner.txt`, 'inner');
const entries = await t.puter.fs.readdir(dirName);
t.assert.deepEqual(
entries.map((e) => e.name),
['inner.txt'],
);
t.assert.equal((await t.puter.fs.stat(dirName)).uid, dir.uid);
await t.puter.fs.write(`${home(t)}/${fileName}`, 'uid-shaped name');
t.assert.equal((await t.puter.fs.stat(fileName)).name, fileName);
// Methods that also take a uid read a bare UID-shaped string as one,
// so the relative name needs `./`.
const blob = await t.puter.fs.read(`./${fileName}`);
t.assert.equal(await blob.text(), 'uid-shaped name');
},
'rename addresses the item by uid': async (t) => {
const path = `${home(t)}/fs-suite-rename-uid.txt`;
const written = await t.puter.fs.write(path, 'rename by uid');
@@ -1391,6 +1468,24 @@ export default suite('fs', {
t.assert.equal(byUid.uid, written.uid);
},
'eventual stat by uid returns the entry for that uid': async (t) => {
const a = await t.puter.fs.write(
`${home(t)}/fs-suite-stat-uid-a.txt`,
'a',
);
const b = await t.puter.fs.write(
`${home(t)}/fs-suite-stat-uid-b.txt`,
'b',
);
await t.puter.fs.stat({ uid: a.uid });
const info = await t.puter.fs.stat({
uid: b.uid,
consistency: 'eventual',
});
t.assert.equal(info.uid, b.uid);
t.assert.equal(info.name, 'fs-suite-stat-uid-b.txt');
},
'stat with eventual consistency serves the cached entry': async (t) => {
const path = `${home(t)}/fs-suite-stat-cached.txt`;
await t.puter.fs.write(path, 'cache me');