Skip to content

Commit 7da5a7d

Browse files
authored
feat: add ability to configure and utilize soft-delete and restore (#2425)
1 parent d5cd465 commit 7da5a7d

File tree

5 files changed

+198
-0
lines changed

5 files changed

+198
-0
lines changed

src/bucket.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export interface GetFilesOptions {
169169
maxApiCalls?: number;
170170
maxResults?: number;
171171
pageToken?: string;
172+
softDeleted?: boolean;
172173
startOffset?: string;
173174
userProject?: string;
174175
versions?: boolean;
@@ -342,6 +343,10 @@ export interface BucketMetadata extends BaseMetadata {
342343
retentionPeriod?: string | number;
343344
} | null;
344345
rpo?: string;
346+
softDeletePolicy?: {
347+
retentionDurationSeconds?: string | number;
348+
readonly effectiveTime?: string;
349+
};
345350
storageClass?: string;
346351
timeCreated?: string;
347352
updated?: string;
@@ -2629,6 +2634,9 @@ class Bucket extends ServiceObject<Bucket, BucketMetadata> {
26292634
* or 1 page of results will be returned per call.
26302635
* @property {string} [pageToken] A previously-returned page token
26312636
* representing part of the larger set of results to view.
2637+
* @property {boolean} [softDeleted] If true, only soft-deleted object versions will be
2638+
* listed as distinct results in order of generation number. Note `soft_deleted` and
2639+
* `versions` cannot be set to true simultaneously.
26322640
* @property {string} [startOffset] Filter results to objects whose names are
26332641
* lexicographically equal to or after startOffset. If endOffset is also set,
26342642
* the objects listed have names between startOffset (inclusive) and endOffset (exclusive).
@@ -2671,6 +2679,9 @@ class Bucket extends ServiceObject<Bucket, BucketMetadata> {
26712679
* or 1 page of results will be returned per call.
26722680
* @param {string} [query.pageToken] A previously-returned page token
26732681
* representing part of the larger set of results to view.
2682+
* @param {boolean} [query.softDeleted] If true, only soft-deleted object versions will be
2683+
* listed as distinct results in order of generation number. Note `soft_deleted` and
2684+
* `versions` cannot be set to true simultaneously.
26742685
* @param {string} [query.startOffset] Filter results to objects whose names are
26752686
* lexicographically equal to or after startOffset. If endOffset is also set,
26762687
* the objects listed have names between startOffset (inclusive) and endOffset (exclusive).

src/file.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ import {
7272
BaseMetadata,
7373
DeleteCallback,
7474
DeleteOptions,
75+
GetResponse,
76+
InstanceResponseCallback,
7577
RequestResponse,
7678
SetMetadataOptions,
7779
} from './nodejs-common/service-object.js';
@@ -172,6 +174,8 @@ export interface GetFileMetadataCallback {
172174

173175
export interface GetFileOptions extends GetConfig {
174176
userProject?: string;
177+
generation?: number;
178+
softDeleted?: boolean;
175179
}
176180

177181
export type GetFileResponse = [File, unknown];
@@ -418,6 +422,11 @@ export interface SetStorageClassCallback {
418422
(err?: Error | null, apiResponse?: unknown): void;
419423
}
420424

425+
export interface RestoreOptions extends PreconditionOptions {
426+
generation: number;
427+
projection?: 'full' | 'noAcl';
428+
}
429+
421430
export interface FileMetadata extends BaseMetadata {
422431
acl?: AclMetadata[] | null;
423432
bucket?: string;
@@ -436,6 +445,7 @@ export interface FileMetadata extends BaseMetadata {
436445
eventBasedHold?: boolean | null;
437446
readonly eventBasedHoldReleaseTime?: string;
438447
generation?: string | number;
448+
hardDeleteTime?: string;
439449
kmsKeyName?: string;
440450
md5Hash?: string;
441451
mediaLink?: string;
@@ -454,6 +464,7 @@ export interface FileMetadata extends BaseMetadata {
454464
} | null;
455465
retentionExpirationTime?: string;
456466
size?: string | number;
467+
softDeleteTime?: string;
457468
storageClass?: string;
458469
temporaryHold?: boolean | null;
459470
timeCreated?: string;
@@ -803,6 +814,9 @@ class File extends ServiceObject<File, FileMetadata> {
803814
* @param {options} [options] Configuration options.
804815
* @param {string} [options.userProject] The ID of the project which will be
805816
* billed for the request.
817+
* @param {number} [options.generation] The generation number to get
818+
* @param {boolean} [options.softDeleted] If true, returns the soft-deleted object.
819+
Object `generation` is required if `softDeleted` is set to True.
806820
* @param {GetFileCallback} [callback] Callback function.
807821
* @returns {Promise<GetFileResponse>}
808822
*
@@ -2344,6 +2358,27 @@ class File extends ServiceObject<File, FileMetadata> {
23442358
return this;
23452359
}
23462360

2361+
get(options?: GetFileOptions): Promise<GetResponse<File>>;
2362+
get(callback: InstanceResponseCallback<File>): void;
2363+
get(options: GetFileOptions, callback: InstanceResponseCallback<File>): void;
2364+
get(
2365+
optionsOrCallback?: GetFileOptions | InstanceResponseCallback<File>,
2366+
cb?: InstanceResponseCallback<File>
2367+
): Promise<GetResponse<File>> | void {
2368+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2369+
const options: any =
2370+
typeof optionsOrCallback === 'object' ? optionsOrCallback : {};
2371+
cb =
2372+
typeof optionsOrCallback === 'function'
2373+
? (optionsOrCallback as InstanceResponseCallback<File>)
2374+
: cb;
2375+
2376+
super
2377+
.get(options)
2378+
.then(resp => cb!(null, ...resp))
2379+
.catch(cb!);
2380+
}
2381+
23472382
getExpirationDate(): Promise<GetExpirationDateResponse>;
23482383
getExpirationDate(callback: GetExpirationDateCallback): void;
23492384
/**
@@ -3597,6 +3632,39 @@ class File extends ServiceObject<File, FileMetadata> {
35973632
this.move(destinationFile, options, callback);
35983633
}
35993634

3635+
/**
3636+
* @typedef {object} RestoreOptions Options for File#restore(). See an
3637+
* {@link https://cloud.google.com/storage/docs/json_api/v1/objects#resource| Object resource}.
3638+
* @param {string} [userProject] The ID of the project which will be
3639+
* billed for the request.
3640+
* @param {number} [generation] If present, selects a specific revision of this object.
3641+
* @param {string} [projection] Specifies the set of properties to return. If used, must be 'full' or 'noAcl'.
3642+
* @param {string | number} [ifGenerationMatch] Request proceeds if the generation of the target resource
3643+
* matches the value used in the precondition.
3644+
* If the values don't match, the request fails with a 412 Precondition Failed response.
3645+
* @param {string | number} [ifGenerationNotMatch] Request proceeds if the generation of the target resource does
3646+
* not match the value used in the precondition. If the values match, the request fails with a 304 Not Modified response.
3647+
* @param {string | number} [ifMetagenerationMatch] Request proceeds if the meta-generation of the target resource
3648+
* matches the value used in the precondition.
3649+
* If the values don't match, the request fails with a 412 Precondition Failed response.
3650+
* @param {string | number} [ifMetagenerationNotMatch] Request proceeds if the meta-generation of the target resource does
3651+
* not match the value used in the precondition. If the values match, the request fails with a 304 Not Modified response.
3652+
*/
3653+
/**
3654+
* Restores a soft-deleted file
3655+
* @param {RestoreOptions} options Restore options.
3656+
* @returns {Promise<File>}
3657+
*/
3658+
async restore(options: RestoreOptions): Promise<File> {
3659+
const [file] = await this.request({
3660+
method: 'POST',
3661+
uri: '/restore',
3662+
qs: options,
3663+
});
3664+
3665+
return file as File;
3666+
}
3667+
36003668
request(reqOpts: DecorateRequestOptions): Promise<RequestResponse>;
36013669
request(
36023670
reqOpts: DecorateRequestOptions,
@@ -4240,6 +4308,7 @@ promisifyAll(File, {
42404308
'setEncryptionKey',
42414309
'shouldRetryBasedOnPreconditionAndIdempotencyStrat',
42424310
'getBufferFromReadable',
4311+
'restore',
42434312
],
42444313
});
42454314

system-test/storage.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,10 @@ describe('storage', function () {
773773

774774
beforeEach(createBucket);
775775

776+
afterEach(async () => {
777+
await bucket.delete();
778+
});
779+
776780
it("sets bucket's RPO to ASYNC_TURBO", async () => {
777781
await setTurboReplication(bucket, RPO_ASYNC_TURBO);
778782
const [bucketMetadata] = await bucket.getMetadata();
@@ -786,6 +790,80 @@ describe('storage', function () {
786790
});
787791
});
788792

793+
describe('soft-delete', () => {
794+
let bucket: Bucket;
795+
const SOFT_DELETE_RETENTION_SECONDS = 7 * 24 * 60 * 60; //7 days in seconds;
796+
797+
beforeEach(async () => {
798+
bucket = storage.bucket(generateName());
799+
await bucket.create();
800+
await bucket.setMetadata({
801+
softDeletePolicy: {
802+
retentionDurationSeconds: SOFT_DELETE_RETENTION_SECONDS,
803+
},
804+
});
805+
});
806+
807+
afterEach(async () => {
808+
await bucket.deleteFiles({force: true, versions: true});
809+
await bucket.delete();
810+
});
811+
812+
it('should set softDeletePolicy correctly', async () => {
813+
const metadata = await bucket.getMetadata();
814+
assert(metadata[0].softDeletePolicy);
815+
assert(metadata[0].softDeletePolicy.effectiveTime);
816+
assert.deepStrictEqual(
817+
metadata[0].softDeletePolicy.retentionDurationSeconds,
818+
SOFT_DELETE_RETENTION_SECONDS.toString()
819+
);
820+
});
821+
822+
it('should LIST soft-deleted files', async () => {
823+
const f1 = bucket.file('file1');
824+
const f2 = bucket.file('file2');
825+
await f1.save('file1');
826+
await f2.save('file2');
827+
await f1.delete();
828+
await f2.delete();
829+
const [notSoftDeletedFiles] = await bucket.getFiles();
830+
assert.strictEqual(notSoftDeletedFiles.length, 0);
831+
const [softDeletedFiles] = await bucket.getFiles({softDeleted: true});
832+
assert.strictEqual(softDeletedFiles.length, 2);
833+
});
834+
835+
it('should GET a soft-deleted file', async () => {
836+
const f1 = bucket.file('file3');
837+
await f1.save('file3');
838+
const [metadata] = await f1.getMetadata();
839+
await f1.delete();
840+
const [softDeletedFile] = await f1.get({
841+
softDeleted: true,
842+
generation: parseInt(metadata.generation?.toString() || '0'),
843+
});
844+
assert(softDeletedFile);
845+
assert.strictEqual(
846+
softDeletedFile.metadata.generation,
847+
metadata.generation
848+
);
849+
});
850+
851+
it('should restore a soft-deleted file', async () => {
852+
const f1 = bucket.file('file4');
853+
await f1.save('file4');
854+
const [metadata] = await f1.getMetadata();
855+
await f1.delete();
856+
let [files] = await bucket.getFiles();
857+
assert.strictEqual(files.length, 0);
858+
const restoredFile = await f1.restore({
859+
generation: parseInt(metadata.generation?.toString() || '0'),
860+
});
861+
assert(restoredFile);
862+
[files] = await bucket.getFiles();
863+
assert.strictEqual(files.length, 1);
864+
});
865+
});
866+
789867
describe('dual-region', () => {
790868
let bucket: Bucket;
791869

test/bucket.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,6 +1863,25 @@ describe('Bucket', () => {
18631863
});
18641864
});
18651865

1866+
it('should return soft-deleted Files if queried for softDeleted', done => {
1867+
const softDeletedTime = new Date('1/1/2024').toISOString();
1868+
bucket.request = (
1869+
reqOpts: DecorateRequestOptions,
1870+
callback: Function
1871+
) => {
1872+
callback(null, {
1873+
items: [{name: 'fake-file-name', generation: 1, softDeletedTime}],
1874+
});
1875+
};
1876+
1877+
bucket.getFiles({softDeleted: true}, (err: Error, files: FakeFile[]) => {
1878+
assert.ifError(err);
1879+
assert(files[0] instanceof FakeFile);
1880+
assert.strictEqual(files[0].metadata.softDeletedTime, softDeletedTime);
1881+
done();
1882+
});
1883+
});
1884+
18661885
it('should set kmsKeyName on file', done => {
18671886
const kmsKeyName = 'kms-key-name';
18681887

test/file.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ const fakePromisify = {
104104
'setEncryptionKey',
105105
'shouldRetryBasedOnPreconditionAndIdempotencyStrat',
106106
'getBufferFromReadable',
107+
'restore',
107108
]);
108109
},
109110
};
@@ -4145,6 +4146,26 @@ describe('File', () => {
41454146
});
41464147
});
41474148

4149+
describe('restore', () => {
4150+
it('should pass options to underlying request call', async () => {
4151+
file.parent.request = function (
4152+
reqOpts: DecorateRequestOptions,
4153+
callback_: Function
4154+
) {
4155+
assert.strictEqual(this, file);
4156+
assert.deepStrictEqual(reqOpts, {
4157+
method: 'POST',
4158+
uri: '/restore',
4159+
qs: {generation: 123},
4160+
});
4161+
assert.strictEqual(callback_, undefined);
4162+
return [];
4163+
};
4164+
4165+
await file.restore({generation: 123});
4166+
});
4167+
});
4168+
41484169
describe('request', () => {
41494170
it('should call the parent request function', () => {
41504171
const options = {};

0 commit comments

Comments
 (0)