Forget cached entries when sharing changes

Nothing invalidated the SDK entry cache on share or unshare — the socket
handlers only fire on item mutations — so is_shared, which now rides in
the cached entry, stayed stale for every consistency: 'eventual' read.
That includes the GUI's own listing refresh, which defaults to it, so a
badge would not have appeared until an unrelated write flushed the cache.
This commit is contained in:
Juan Castro
2026-08-25 18:15:05 -04:00
parent 1600dc38a5
commit bac3dde945
4 changed files with 63 additions and 7 deletions
@@ -9,9 +9,11 @@ import readdir from './readdir.js';
import readdirSubdomains from './readdirSubdomains.js';
import rename from './rename.js';
import revokeReadURL from './revokeReadUrl.js';
import share from './share.js';
import sign from './sign.js';
import space from './space.js';
import stat from './stat.js';
import unshare from './unshare.js';
import write from './write.js';
/**
@@ -82,7 +84,8 @@ const makeFS = () => ({
// write delegates to upload, which has its own tests.
upload: vi.fn(async () => ({ uid: 'written' })),
copy, delete: deleteFSEntry, getReadURL, mkdir, move, read, readdir,
readdirSubdomains, rename, revokeReadURL, sign, space, stat, write,
readdirSubdomains, rename, revokeReadURL, share, sign, space, stat,
unshare, write,
});
const makeCache = () => {
@@ -411,6 +414,35 @@ describe('stat', () => {
});
});
it('re-reads a cached item after its sharing changes', async () => {
FakeXHR.respondWith = () => ({ uid: 'u1', is_dir: false, is_shared: false });
await fs.stat('/a/file.txt');
FakeXHR.respondWith = () => ({ status: 'success', results: [{ status: 'success', uid: 's1', mode: 'read' }] });
await fs.share('/a/file.txt', 'friend', 'read');
// Without invalidation the stale is_shared: false would be served here.
FakeXHR.requests = [];
FakeXHR.respondWith = () => ({ uid: 'u1', is_dir: false, is_shared: true });
const item = await fs.stat({ path: '/a/file.txt', consistency: 'eventual' });
expect(FakeXHR.requests).toHaveLength(1);
expect(item.is_shared).toBe(true);
});
it('re-reads a cached item after a share is withdrawn', async () => {
FakeXHR.respondWith = () => ({ uid: 'u1', is_dir: false, is_shared: true });
await fs.stat('/a/file.txt');
FakeXHR.respondWith = () => ({ revoked: 1 });
await fs.unshare('/a/file.txt', 'friend');
FakeXHR.requests = [];
FakeXHR.respondWith = () => ({ uid: 'u1', is_dir: false, is_shared: false });
const item = await fs.stat({ path: '/a/file.txt', consistency: 'eventual' });
expect(FakeXHR.requests).toHaveLength(1);
expect(item.is_shared).toBe(false);
});
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,6 +1,6 @@
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
import { defineOperation, firstDefined } from './scaffold.js';
import { toShare, toShareItems, toShareRecipients } from './shareUtil.js';
import { invalidateShareCache, toShare, toShareItems, toShareRecipients } from './shareUtil.js';
/** @typedef {import('../types.js').ShareOptions} ShareOptions */
/** @typedef {import('../types.js').ShareMode} ShareMode */
@@ -53,6 +53,7 @@ const share = defineOperation({
code: String(first.code ?? 'share_failed'),
};
}
invalidateShareCache(items);
return ok.map(toShare);
},
};
@@ -1,5 +1,6 @@
// Shared helpers for the sharing operations.
import path from 'path-browserify';
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
/** @typedef {import('../types.js').Share} Share */
@@ -84,3 +85,22 @@ export const toShare = (row) => ({
modified: /** @type {number} */ (row.modified ?? 0),
size: /** @type {number | null} */ (row.size ?? null),
});
/**
* Forget cached entries for items whose sharing just changed — `is_shared`
* rides in the cached entry, and nothing else invalidates it. Items addressed
* by uid have no key to drop, so those flush the cache instead.
*
* @param {Array<{ path?: string, uid?: string }>} items
*/
export const invalidateShareCache = (items) => {
if ( ! puter?._cache ) return;
for ( const item of items ) {
if ( ! item.path ) {
puter._cache.flushall();
return;
}
puter._cache.del(`item:${ item.path}`);
puter._cache.del(`readdir:${ path.dirname(item.path)}`);
}
};
@@ -1,6 +1,6 @@
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
import { defineOperation, firstDefined } from './scaffold.js';
import { toShareItems, toShareRecipients } from './shareUtil.js';
import { invalidateShareCache, toShareItems, toShareRecipients } from './shareUtil.js';
/** @typedef {import('../types.js').UnshareOptions} UnshareOptions */
/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */
@@ -28,17 +28,20 @@ import { toShareItems, toShareRecipients } from './shareUtil.js';
const unshare = defineOperation({
positional: ['path', 'recipient'],
request (options) {
const items = toShareItems(options, (path) => getAbsolutePathForApp(path));
return {
endpoint: '/share/revoke',
body: {
recipients: toShareRecipients(
firstDefined(options, 'recipient', 'recipients'),
),
items: toShareItems(options, (path) => getAbsolutePathForApp(path)),
items,
},
transform: (/** @type {{ revoked?: number }} */ response) => {
const revoked = Number(response.revoked ?? 0);
if ( revoked > 0 ) invalidateShareCache(items);
return { revoked };
},
transform: (/** @type {{ revoked?: number }} */ response) => ({
revoked: Number(response.revoked ?? 0),
}),
};
},
});