Skip to content
This repository was archived by the owner on Jul 25, 2026. It is now read-only.

Commit 8e44916

Browse files
Merge pull request #342 from opencomponents/feat/paginate-s3-gs-list-remove
feat(storage): paginate listSubDirectories and removeDir for S3 and GS
2 parents 7c9cc1f + 1e185ab commit 8e44916

6 files changed

Lines changed: 287 additions & 68 deletions

File tree

packages/oc-gs-storage-adapter/__mocks__/@google-cloud/storage.js

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,61 @@ const googleStorage = jest.genMockFromModule('@google-cloud/storage');
1212
const _Storage = class {
1313
constructor() {
1414
this.bucket = jest.fn(bucket => ({
15-
getFiles: () => {
16-
const files =
17-
bucket === 'my-empty-bucket'
18-
? []
19-
: [
20-
[
21-
{
22-
name: 'components/image/1.0.0/app.js'
23-
},
24-
{
25-
name: 'components/image/1.0.0/server.js'
26-
},
27-
{
28-
name: 'components/image/1.0.0/.env'
29-
},
30-
{
31-
name: 'components/image/1.0.1/new-server.js'
32-
},
33-
{
34-
name: 'components/image/1.0.1/new-.env'
35-
},
36-
{
37-
name: 'components/image/1.0.1/new-app.js'
38-
}
39-
]
40-
];
15+
getFiles: (options = {}) => {
16+
if (bucket === 'my-empty-bucket') {
17+
return Promise.resolve([]);
18+
}
19+
20+
if (bucket === 'paginated-bucket') {
21+
const makeFile = name => ({
22+
name,
23+
delete: jest.fn(() => Promise.resolve())
24+
});
25+
26+
if (!options.pageToken) {
27+
return Promise.resolve([
28+
[
29+
makeFile('components/a/1.0.0/app.js'),
30+
makeFile('components/a/1.0.0/server.js')
31+
],
32+
{ pageToken: 'page-2' }
33+
]);
34+
}
35+
36+
return Promise.resolve([
37+
[makeFile('components/a/2.0.0/app.js')],
38+
null
39+
]);
40+
}
41+
42+
const files = [
43+
[
44+
{
45+
name: 'components/image/1.0.0/app.js',
46+
delete: jest.fn(() => Promise.resolve())
47+
},
48+
{
49+
name: 'components/image/1.0.0/server.js',
50+
delete: jest.fn(() => Promise.resolve())
51+
},
52+
{
53+
name: 'components/image/1.0.0/.env',
54+
delete: jest.fn(() => Promise.resolve())
55+
},
56+
{
57+
name: 'components/image/1.0.1/new-server.js',
58+
delete: jest.fn(() => Promise.resolve())
59+
},
60+
{
61+
name: 'components/image/1.0.1/new-.env',
62+
delete: jest.fn(() => Promise.resolve())
63+
},
64+
{
65+
name: 'components/image/1.0.1/new-app.js',
66+
delete: jest.fn(() => Promise.resolve())
67+
}
68+
]
69+
];
4170
return Promise.resolve(files);
4271
},
4372
upload: (filePath, { destination }) => {

packages/oc-gs-storage-adapter/__test__/gs.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,26 @@ test('test getJson force mode', async () => {
185185
});
186186
});
187187

188+
test('listSubDirectories follows pageToken across pages', async () => {
189+
const client = gs({ ...validOptions, bucket: 'paginated-bucket' });
190+
191+
const data = await client.listSubDirectories('components/a');
192+
193+
// Page 1 yields 1.0.0, page 2 yields 2.0.0. Both are only returned if the
194+
// adapter follows the nextQuery pageToken across pages.
195+
expect(data).toEqual(['1.0.0', '2.0.0']);
196+
});
197+
198+
test('removeDir follows pageToken across pages and deletes all files', async () => {
199+
const client = gs({ ...validOptions, bucket: 'paginated-bucket' });
200+
201+
const result = (await client.removeDir('components/a')) as unknown[];
202+
203+
// Page 1 lists two files, page 2 lists one file. Three deletes only happen
204+
// if the adapter paginates using the nextQuery pageToken.
205+
expect(result).toHaveLength(3);
206+
});
207+
188208
test('test getUrl ', () => {
189209
const client = gs(validOptions);
190210
expect(client.getUrl('test', '1.0.0', 'test.js')).toBe('/test/1.0.0/test.js');

packages/oc-gs-storage-adapter/src/index.ts

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,48 @@ export default function gsAdapter(conf: GsConfig): StorageAdapter {
118118
? dir
119119
: dir + '/';
120120

121-
const options = {
122-
prefix: normalisedPath
123-
};
124-
125121
try {
126-
const results = await getClient().bucket(bucketName).getFiles(options);
122+
const collected: { name: string }[] = [];
123+
let pageToken: string | undefined;
124+
125+
do {
126+
const requestPageToken = pageToken;
127+
const options: {
128+
prefix: string;
129+
autoPaginate: false;
130+
pageToken?: string;
131+
} = {
132+
prefix: normalisedPath,
133+
autoPaginate: false
134+
};
135+
if (requestPageToken) {
136+
options.pageToken = requestPageToken;
137+
}
138+
139+
const results = await getClient()
140+
.bucket(bucketName)
141+
.getFiles(options);
142+
const files = results[0] ?? [];
143+
const nextQuery = results[1] as { pageToken?: string } | undefined;
144+
145+
for (const file of files) {
146+
collected.push(file);
147+
}
148+
149+
const nextPageToken = nextQuery?.pageToken;
150+
if (nextPageToken && nextPageToken === requestPageToken) {
151+
throw new Error(
152+
'GCS getFiles returned an unchanged pageToken; aborting to avoid an infinite loop'
153+
);
154+
}
155+
pageToken = nextPageToken;
156+
} while (pageToken);
127157

128-
const files = results[0];
129-
if (files.length === 0) {
158+
if (collected.length === 0) {
130159
throw 'no files';
131160
}
132161

133-
const result = files
162+
const result = collected
134163
//remove prefix
135164
.map(file => file.name.replace(normalisedPath, ''))
136165
// only get files that aren't in root directory
@@ -258,11 +287,41 @@ export default function gsAdapter(conf: GsConfig): StorageAdapter {
258287
? dir
259288
: `${dir}/`;
260289

261-
const [files] = await getClient()
262-
.bucket(bucketName)
263-
.getFiles({ prefix: normalisedPath });
290+
const collected: { delete: () => Promise<unknown> }[] = [];
291+
let pageToken: string | undefined;
292+
293+
do {
294+
const requestPageToken = pageToken;
295+
const options: {
296+
prefix: string;
297+
autoPaginate: false;
298+
pageToken?: string;
299+
} = {
300+
prefix: normalisedPath,
301+
autoPaginate: false
302+
};
303+
if (requestPageToken) {
304+
options.pageToken = requestPageToken;
305+
}
306+
307+
const results = await getClient()
308+
.bucket(bucketName)
309+
.getFiles(options);
310+
const files = results[0] ?? [];
311+
const nextQuery = results[1] as { pageToken?: string } | undefined;
312+
313+
for (const file of files) {
314+
collected.push(file as { delete: () => Promise<unknown> });
315+
}
316+
317+
const nextPageToken = nextQuery?.pageToken;
318+
if (nextPageToken && nextPageToken === requestPageToken) {
319+
throw new Error('GCS getFiles returned an unchanged pageToken; aborting to avoid an infinite loop');
320+
}
321+
pageToken = nextPageToken;
322+
} while (pageToken);
264323

265-
return Promise.all(files.map(file => file.delete()));
324+
return Promise.all(collected.map(file => file.delete()));
266325
};
267326

268327
return removeFromContainer();

packages/oc-s3-storage-adapter/__mocks__/@aws-sdk/client-s3.js

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,67 @@ const _S3 = class {
5656
});
5757

5858
this.listObjects = jest.fn(val => {
59-
const CommonPrefixes =
60-
val.Bucket === 'my-empty-bucket'
61-
? []
62-
: [
63-
{
64-
Prefix: 'components/image/1.0.0/'
65-
},
66-
{
67-
Prefix: 'components/image/1.0.1/'
68-
}
69-
];
59+
if (val.Bucket === 'my-empty-bucket') {
60+
return Promise.resolve({ CommonPrefixes: [], Contents: [] });
61+
}
62+
63+
if (val.Bucket === 'paginated-bucket') {
64+
const isDelimited = val.Delimiter === '/';
65+
66+
if (isDelimited) {
67+
// listSubDirectories path: S3 returns NextMarker when Delimiter is set.
68+
if (!val.Marker) {
69+
return Promise.resolve({
70+
CommonPrefixes: [
71+
{ Prefix: 'components/a/1.0.0/' },
72+
{ Prefix: 'components/a/1.0.1/' }
73+
],
74+
IsTruncated: true,
75+
NextMarker: 'marker-2'
76+
});
77+
}
78+
79+
return Promise.resolve({
80+
CommonPrefixes: [{ Prefix: 'components/a/2.0.0/' }],
81+
IsTruncated: false
82+
});
83+
}
84+
85+
// removeDir path: no Delimiter, so S3 v1 does NOT return NextMarker;
86+
// the client must use the last Key in Contents as the next Marker.
87+
if (!val.Marker) {
88+
return Promise.resolve({
89+
Contents: [
90+
{ Key: 'components/a/1.0.0/file.js' },
91+
{ Key: 'components/a/1.0.0/package.json' }
92+
],
93+
IsTruncated: true
94+
});
95+
}
96+
97+
return Promise.resolve({
98+
Contents: [
99+
{ Key: 'components/a/2.0.0/file.js' },
100+
{ Key: 'components/a/2.0.0/package.json' }
101+
],
102+
IsTruncated: false
103+
});
104+
}
105+
106+
const CommonPrefixes = [
107+
{
108+
Prefix: 'components/image/1.0.0/'
109+
},
110+
{
111+
Prefix: 'components/image/1.0.1/'
112+
}
113+
];
70114

71115
return Promise.resolve({ CommonPrefixes });
72116
});
73117

118+
this.deleteObject = jest.fn(() => Promise.resolve({}));
119+
74120
this.putObject = jest.fn(data => {
75121
if (data && data.Key && data.Key.indexOf('error') >= 0) {
76122
if (data.Key.indexOf('throw') >= 0) {

packages/oc-s3-storage-adapter/__test__/s3.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,26 @@ test('test getJson force mode', async () => {
200200
});
201201
});
202202

203+
test('listSubDirectories follows continuation tokens across pages', async () => {
204+
const client = s3({ ...validOptions, bucket: 'paginated-bucket' });
205+
206+
const data = await client.listSubDirectories('components/a');
207+
208+
// Page 1 returns 1.0.0 and 1.0.1, page 2 returns 2.0.0. All three are
209+
// only returned if the adapter follows NextMarker across pages.
210+
expect(data).toEqual(['1.0.0', '1.0.1', '2.0.0']);
211+
});
212+
213+
test('removeDir follows continuation tokens across pages and deletes all keys', async () => {
214+
const client = s3({ ...validOptions, bucket: 'paginated-bucket' });
215+
216+
const result = (await client.removeDir('components/a')) as unknown[];
217+
218+
// Page 1 lists two keys, page 2 lists two keys. Four deletes only happen
219+
// if the adapter paginates using the last Key as the next Marker.
220+
expect(result).toHaveLength(4);
221+
});
222+
203223
test('test getUrl ', () => {
204224
const client = s3(validOptions);
205225
expect(client.getUrl('test', '1.0.0', 'test.js')).toBe('/test/1.0.0/test.js');

0 commit comments

Comments
 (0)