diff --git a/src/puter-js/src/modules/FileSystem/operations/operations.test.js b/src/puter-js/src/modules/FileSystem/operations/operations.test.js index bc41d3d28..ce9802d44 100644 --- a/src/puter-js/src/modules/FileSystem/operations/operations.test.js +++ b/src/puter-js/src/modules/FileSystem/operations/operations.test.js @@ -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 }); diff --git a/src/puter-js/src/modules/FileSystem/operations/share.js b/src/puter-js/src/modules/FileSystem/operations/share.js index 99566e3f9..71ba4555c 100644 --- a/src/puter-js/src/modules/FileSystem/operations/share.js +++ b/src/puter-js/src/modules/FileSystem/operations/share.js @@ -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); }, }; diff --git a/src/puter-js/src/modules/FileSystem/operations/shareUtil.js b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js index 436b6bc86..1386cb5c8 100644 --- a/src/puter-js/src/modules/FileSystem/operations/shareUtil.js +++ b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js @@ -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)}`); + } +}; diff --git a/src/puter-js/src/modules/FileSystem/operations/unshare.js b/src/puter-js/src/modules/FileSystem/operations/unshare.js index 73db094b3..12ceb3651 100644 --- a/src/puter-js/src/modules/FileSystem/operations/unshare.js +++ b/src/puter-js/src/modules/FileSystem/operations/unshare.js @@ -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), - }), }; }, });