@tachybase/module-backup 0.23.22 → 0.23.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.js +1 -1
- package/dist/externalVersion.js +6 -6
- package/dist/node_modules/@hapi/topo/package.json +1 -1
- package/dist/node_modules/archiver/package.json +1 -1
- package/dist/node_modules/mkdirp/package.json +1 -1
- package/dist/node_modules/semver/package.json +1 -1
- package/dist/node_modules/yauzl/LICENSE +21 -0
- package/dist/node_modules/yauzl/fd-slicer.js +314 -0
- package/dist/node_modules/yauzl/index.js +1 -0
- package/dist/node_modules/yauzl/package.json +1 -0
- package/dist/server/dumper.js +34 -35
- package/dist/server/resourcers/backup-files.js +7 -6
- package/dist/server/restorer.d.ts +11 -2
- package/dist/server/restorer.js +105 -40
- package/dist/server/utils.d.ts +1 -0
- package/dist/server/utils.js +12 -0
- package/package.json +10 -10
- package/dist/node_modules/decompress/index.js +0 -16
- package/dist/node_modules/decompress/license +0 -9
- package/dist/node_modules/decompress/package.json +0 -1
package/dist/server/dumper.js
CHANGED
|
@@ -196,11 +196,16 @@ class Dumper extends import_app_migrator.AppMigrator {
|
|
|
196
196
|
});
|
|
197
197
|
Dumper.dumpTasks.set(backupFileName, promise);
|
|
198
198
|
} else {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
try {
|
|
200
|
+
await this.dump({
|
|
201
|
+
groups: options.groups,
|
|
202
|
+
fileName: backupFileName
|
|
203
|
+
});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw err;
|
|
206
|
+
} finally {
|
|
207
|
+
this.cleanLockFile(backupFileName);
|
|
208
|
+
}
|
|
204
209
|
}
|
|
205
210
|
return backupFileName;
|
|
206
211
|
}
|
|
@@ -281,14 +286,14 @@ class Dumper extends import_app_migrator.AppMigrator {
|
|
|
281
286
|
await import_promises.default.writeFile(metaPath, JSON.stringify(metaObj), "utf8");
|
|
282
287
|
}
|
|
283
288
|
async dumpCollection(options) {
|
|
284
|
-
var _a;
|
|
289
|
+
var _a, _b;
|
|
285
290
|
const app = this.app;
|
|
286
291
|
const dir = this.workDir;
|
|
287
292
|
const collectionName = options.name;
|
|
288
|
-
app.logger.info(`
|
|
293
|
+
app.logger.info(`Dumping collection ${collectionName}`);
|
|
289
294
|
const collection = app.db.getCollection(collectionName);
|
|
290
295
|
if (!collection) {
|
|
291
|
-
this.app.logger.warn(`
|
|
296
|
+
this.app.logger.warn(`Collection ${collectionName} not found`);
|
|
292
297
|
return;
|
|
293
298
|
}
|
|
294
299
|
const collectionOnDumpOption = (_a = this.app.db.collectionFactory.collectionTypes.get(
|
|
@@ -302,35 +307,29 @@ class Dumper extends import_app_migrator.AppMigrator {
|
|
|
302
307
|
const collectionDataDir = import_path.default.resolve(dir, "collections", collectionName);
|
|
303
308
|
await import_promises.default.mkdir(collectionDataDir, { recursive: true });
|
|
304
309
|
let count = 0;
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
310
|
+
const dataFilePath = import_path.default.resolve(collectionDataDir, "data");
|
|
311
|
+
const dataStream = import_fs.default.createWriteStream(dataFilePath);
|
|
312
|
+
const rows = await app.db.sequelize.query(
|
|
313
|
+
(0, import_utils.sqlAdapter)(app.db, `SELECT * FROM ${collection.isParent() ? "ONLY" : ""} ${collection.quotedTableName()}`),
|
|
314
|
+
{ type: "SELECT" }
|
|
315
|
+
);
|
|
316
|
+
for (const row of rows) {
|
|
317
|
+
const rowData = JSON.stringify(
|
|
318
|
+
columns.map((col) => {
|
|
319
|
+
const val = row[col];
|
|
320
|
+
const field = collection.getField(col);
|
|
321
|
+
return field ? import_field_value_writer.FieldValueWriter.toDumpedValue(field, val) : val;
|
|
322
|
+
})
|
|
317
323
|
);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
columns.map((col) => {
|
|
321
|
-
const val = row[col];
|
|
322
|
-
const field = collection.getField(col);
|
|
323
|
-
return field ? import_field_value_writer.FieldValueWriter.toDumpedValue(field, val) : val;
|
|
324
|
-
})
|
|
325
|
-
);
|
|
326
|
-
dataStream.write(rowData + "\r\n", "utf8");
|
|
324
|
+
if (!dataStream.write(rowData + "\r\n", "utf8")) {
|
|
325
|
+
await new Promise((resolve) => dataStream.once("drain", resolve));
|
|
327
326
|
}
|
|
328
|
-
|
|
329
|
-
await finished(dataStream);
|
|
330
|
-
count = rows.length;
|
|
327
|
+
count++;
|
|
331
328
|
}
|
|
329
|
+
dataStream.end();
|
|
330
|
+
await finished(dataStream);
|
|
332
331
|
const metaAttributes = import_lodash.default.mapValues(attributes, (attr, key) => {
|
|
333
|
-
var _a2,
|
|
332
|
+
var _a2, _b2, _c;
|
|
334
333
|
const collectionField = collection.getField(key);
|
|
335
334
|
const fieldOptionKeys = ["field", "primaryKey", "autoIncrement", "allowNull", "defaultValue", "unique"];
|
|
336
335
|
if (collectionField) {
|
|
@@ -340,7 +339,7 @@ class Dumper extends import_app_migrator.AppMigrator {
|
|
|
340
339
|
type: collectionField.type,
|
|
341
340
|
typeOptions: collectionField.options
|
|
342
341
|
};
|
|
343
|
-
if (((_c = (
|
|
342
|
+
if (((_c = (_b2 = (_a2 = fieldAttributes.typeOptions) == null ? void 0 : _a2.defaultValue) == null ? void 0 : _b2.constructor) == null ? void 0 : _c.name) === "UUIDV4") {
|
|
344
343
|
delete fieldAttributes.typeOptions.defaultValue;
|
|
345
344
|
}
|
|
346
345
|
return fieldAttributes;
|
|
@@ -363,7 +362,7 @@ class Dumper extends import_app_migrator.AppMigrator {
|
|
|
363
362
|
meta["inherits"] = import_lodash.default.uniq(collection.options.inherits);
|
|
364
363
|
}
|
|
365
364
|
const autoIncrAttr = collection.model.autoIncrementAttribute;
|
|
366
|
-
if (autoIncrAttr && collection.model.rawAttributes[autoIncrAttr]
|
|
365
|
+
if (autoIncrAttr && ((_b = collection.model.rawAttributes[autoIncrAttr]) == null ? void 0 : _b.autoIncrement)) {
|
|
367
366
|
const queryInterface = app.db.queryInterface;
|
|
368
367
|
const autoIncrInfo = await queryInterface.getAutoIncrementInfo({
|
|
369
368
|
tableInfo: {
|
|
@@ -112,14 +112,16 @@ var backup_files_default = {
|
|
|
112
112
|
* @param next
|
|
113
113
|
*/
|
|
114
114
|
async create(ctx, next) {
|
|
115
|
+
var _a, _b;
|
|
115
116
|
const data = ctx.request.body;
|
|
116
117
|
let taskId;
|
|
117
118
|
const app = ctx.app;
|
|
118
|
-
if (data.method === "worker") {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
if (data.method === "worker" && !((_a = app.worker) == null ? void 0 : _a.available)) {
|
|
120
|
+
ctx.throw(500, ctx.t("No worker thread", { ns: "worker-thread" }));
|
|
121
|
+
return next();
|
|
122
|
+
}
|
|
123
|
+
let useWorker = data.method === "worker" || data.method === "priority" && ((_b = app.worker) == null ? void 0 : _b.available);
|
|
124
|
+
if (useWorker) {
|
|
123
125
|
try {
|
|
124
126
|
taskId = await app.worker.callPluginMethod({
|
|
125
127
|
plugin: import_server2.default,
|
|
@@ -132,7 +134,6 @@ var backup_files_default = {
|
|
|
132
134
|
});
|
|
133
135
|
app.noticeManager.notify("backup", { level: "info", msg: ctx.t("Done") });
|
|
134
136
|
} catch (error) {
|
|
135
|
-
ctx.logger.warn(error);
|
|
136
137
|
ctx.throw(500, ctx.t(error.message, { ns: "worker-thread" }));
|
|
137
138
|
}
|
|
138
139
|
} else {
|
|
@@ -22,14 +22,23 @@ export declare class Restorer extends AppMigrator {
|
|
|
22
22
|
getImportMeta(): Promise<any>;
|
|
23
23
|
checkMeta(): Promise<void>;
|
|
24
24
|
importCollections(options: RestoreOptions): Promise<void>;
|
|
25
|
-
decompressBackup(backupFilePath: string): Promise<
|
|
25
|
+
decompressBackup(backupFilePath: string): Promise<unknown>;
|
|
26
26
|
readCollectionMeta(collectionName: string): Promise<any>;
|
|
27
27
|
importCollection(options: {
|
|
28
28
|
name: string;
|
|
29
29
|
insert?: boolean;
|
|
30
30
|
clear?: boolean;
|
|
31
31
|
rowCondition?: (row: any) => boolean;
|
|
32
|
-
}): Promise<
|
|
32
|
+
}): Promise<void>;
|
|
33
33
|
importDb(options: RestoreOptions): Promise<void>;
|
|
34
|
+
insertMetaRows({ rows, collectionName, columns, fieldAttributes, rawAttributes, addSchemaTableName, options }: {
|
|
35
|
+
rows: any;
|
|
36
|
+
collectionName: any;
|
|
37
|
+
columns: any;
|
|
38
|
+
fieldAttributes: any;
|
|
39
|
+
rawAttributes: any;
|
|
40
|
+
addSchemaTableName: any;
|
|
41
|
+
options: any;
|
|
42
|
+
}): Promise<any>;
|
|
34
43
|
}
|
|
35
44
|
export {};
|
package/dist/server/restorer.js
CHANGED
|
@@ -35,9 +35,9 @@ var import_promises = __toESM(require("fs/promises"));
|
|
|
35
35
|
var import_path = __toESM(require("path"));
|
|
36
36
|
var import_database = require("@tachybase/database");
|
|
37
37
|
var Topo = __toESM(require("@hapi/topo"));
|
|
38
|
-
var import_decompress = __toESM(require("decompress"));
|
|
39
38
|
var import_lodash = __toESM(require("lodash"));
|
|
40
39
|
var import_semver = __toESM(require("semver"));
|
|
40
|
+
var import_yauzl = __toESM(require("yauzl"));
|
|
41
41
|
var import_app_migrator = require("./app-migrator");
|
|
42
42
|
var import_restore_check_error = require("./errors/restore-check-error");
|
|
43
43
|
var import_field_value_writer = require("./field-value-writer");
|
|
@@ -154,7 +154,38 @@ class Restorer extends import_app_migrator.AppMigrator {
|
|
|
154
154
|
await this.emitAsync("restoreCollectionsFinished");
|
|
155
155
|
}
|
|
156
156
|
async decompressBackup(backupFilePath) {
|
|
157
|
-
if (!this.decompressed)
|
|
157
|
+
if (!this.decompressed) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
import_yauzl.default.open(backupFilePath, { lazyEntries: true }, (err, zipfile) => {
|
|
160
|
+
if (err) return reject(err);
|
|
161
|
+
zipfile.readEntry();
|
|
162
|
+
zipfile.on("entry", (entry) => {
|
|
163
|
+
const filePath = import_path.default.join(this.workDir, entry.fileName);
|
|
164
|
+
if (/\/$/.test(entry.fileName)) {
|
|
165
|
+
import_fs.default.mkdir(filePath, { recursive: true }, (err2) => {
|
|
166
|
+
if (err2) return reject(err2);
|
|
167
|
+
zipfile.readEntry();
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
zipfile.openReadStream(entry, (err2, readStream) => {
|
|
171
|
+
if (err2) return reject(err2);
|
|
172
|
+
const writeStream = import_fs.default.createWriteStream(filePath);
|
|
173
|
+
readStream.pipe(writeStream);
|
|
174
|
+
writeStream.on("close", () => {
|
|
175
|
+
zipfile.readEntry();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
zipfile.on("end", () => {
|
|
181
|
+
this.decompressed = true;
|
|
182
|
+
resolve(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
return Promise.resolve();
|
|
188
|
+
}
|
|
158
189
|
}
|
|
159
190
|
async readCollectionMeta(collectionName) {
|
|
160
191
|
const dir = this.workDir;
|
|
@@ -225,52 +256,45 @@ class Restorer extends import_app_migrator.AppMigrator {
|
|
|
225
256
|
if (meta.inherits) {
|
|
226
257
|
for (const inherit of import_lodash.default.uniq(meta.inherits)) {
|
|
227
258
|
const parentMeta = await this.readCollectionMeta(inherit);
|
|
228
|
-
const
|
|
259
|
+
const sql = `ALTER TABLE ${app.db.utils.quoteTable(addSchemaTableName)} INHERIT ${app.db.utils.quoteTable(
|
|
229
260
|
parentMeta.tableName
|
|
230
261
|
)};`;
|
|
231
|
-
await db.sequelize.query(
|
|
262
|
+
await db.sequelize.query(sql);
|
|
232
263
|
}
|
|
233
264
|
}
|
|
234
265
|
}
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
266
|
+
const batchSize = 1e3;
|
|
267
|
+
let batch = [];
|
|
268
|
+
let allLength = 0;
|
|
269
|
+
await (0, import_utils.readEveryLines)(collectionDataPath, async (line) => {
|
|
270
|
+
batch.push(line);
|
|
271
|
+
if (batch.length >= batchSize) {
|
|
272
|
+
await this.insertMetaRows({
|
|
273
|
+
rows: batch,
|
|
274
|
+
collectionName,
|
|
275
|
+
columns,
|
|
276
|
+
fieldAttributes,
|
|
277
|
+
rawAttributes,
|
|
278
|
+
addSchemaTableName,
|
|
279
|
+
options
|
|
280
|
+
});
|
|
281
|
+
allLength += batchSize;
|
|
282
|
+
batch = [];
|
|
250
283
|
}
|
|
251
|
-
return true;
|
|
252
284
|
});
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
{},
|
|
265
|
-
insertGeneratorAttributes
|
|
266
|
-
);
|
|
267
|
-
if (options.insert === false) {
|
|
268
|
-
return sql;
|
|
285
|
+
if (!this.importedCollections.includes(collectionName)) {
|
|
286
|
+
await this.insertMetaRows({
|
|
287
|
+
rows: batch,
|
|
288
|
+
collectionName,
|
|
289
|
+
columns,
|
|
290
|
+
fieldAttributes,
|
|
291
|
+
rawAttributes,
|
|
292
|
+
addSchemaTableName,
|
|
293
|
+
options
|
|
294
|
+
});
|
|
295
|
+
allLength += batch.length;
|
|
269
296
|
}
|
|
270
|
-
|
|
271
|
-
type: "INSERT"
|
|
272
|
-
});
|
|
273
|
-
app.logger.info(`${collectionName} imported with ${rowsWithMeta.length} rows`);
|
|
297
|
+
app.logger.info(`${collectionName} imported with ${allLength} rows`);
|
|
274
298
|
if (meta.autoIncrement) {
|
|
275
299
|
const queryInterface = app.db.queryInterface;
|
|
276
300
|
await queryInterface.setAutoIncrementVal({
|
|
@@ -313,6 +337,47 @@ class Restorer extends import_app_migrator.AppMigrator {
|
|
|
313
337
|
}
|
|
314
338
|
}
|
|
315
339
|
}
|
|
340
|
+
async insertMetaRows({ rows, collectionName, columns, fieldAttributes, rawAttributes, addSchemaTableName, options }) {
|
|
341
|
+
const app = this.app;
|
|
342
|
+
const db = app.db;
|
|
343
|
+
if (rows.length === 0) {
|
|
344
|
+
app.logger.info(`${collectionName} has no data to import`);
|
|
345
|
+
this.importedCollections.push(collectionName);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const rowsWithMeta = rows.map(
|
|
349
|
+
(row) => JSON.parse(row).map((val, index) => [columns[index], val]).reduce((carry, [column, val]) => {
|
|
350
|
+
const field = fieldAttributes[column];
|
|
351
|
+
carry[column] = field ? import_field_value_writer.FieldValueWriter.write(field, val) : val;
|
|
352
|
+
return carry;
|
|
353
|
+
}, {})
|
|
354
|
+
).filter((row) => {
|
|
355
|
+
if (options.rowCondition) {
|
|
356
|
+
return options.rowCondition(row);
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
});
|
|
360
|
+
if (rowsWithMeta.length === 0) {
|
|
361
|
+
app.logger.info(`${collectionName} has no data to import`);
|
|
362
|
+
this.importedCollections.push(collectionName);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const insertGeneratorAttributes = import_lodash.default.mapKeys(rawAttributes, (value, key) => {
|
|
366
|
+
return value.field;
|
|
367
|
+
});
|
|
368
|
+
const sql = db.sequelize.queryInterface.queryGenerator.bulkInsertQuery(
|
|
369
|
+
addSchemaTableName,
|
|
370
|
+
rowsWithMeta,
|
|
371
|
+
{},
|
|
372
|
+
insertGeneratorAttributes
|
|
373
|
+
);
|
|
374
|
+
if (options.insert === false) {
|
|
375
|
+
return sql;
|
|
376
|
+
}
|
|
377
|
+
await app.db.sequelize.query(sql, {
|
|
378
|
+
type: "INSERT"
|
|
379
|
+
});
|
|
380
|
+
}
|
|
316
381
|
}
|
|
317
382
|
// Annotate the CommonJS export names for ESM import in node:
|
|
318
383
|
0 && (module.exports = {
|
package/dist/server/utils.d.ts
CHANGED
|
@@ -2,4 +2,5 @@ import { Database } from '@tachybase/database';
|
|
|
2
2
|
export declare const DUMPED_EXTENSION = "tbdump";
|
|
3
3
|
export declare function sqlAdapter(database: Database, sql: string): string;
|
|
4
4
|
export declare function readLines(filePath: string): Promise<any[]>;
|
|
5
|
+
export declare function readEveryLines(filePath: string, processLine: (line: string) => void): Promise<void>;
|
|
5
6
|
export declare function humanFileSize(bytes: any, si?: boolean, dp?: number): string;
|
package/dist/server/utils.js
CHANGED
|
@@ -29,6 +29,7 @@ var utils_exports = {};
|
|
|
29
29
|
__export(utils_exports, {
|
|
30
30
|
DUMPED_EXTENSION: () => DUMPED_EXTENSION,
|
|
31
31
|
humanFileSize: () => humanFileSize,
|
|
32
|
+
readEveryLines: () => readEveryLines,
|
|
32
33
|
readLines: () => readLines,
|
|
33
34
|
sqlAdapter: () => sqlAdapter
|
|
34
35
|
});
|
|
@@ -55,6 +56,16 @@ async function readLines(filePath) {
|
|
|
55
56
|
}
|
|
56
57
|
return results;
|
|
57
58
|
}
|
|
59
|
+
async function readEveryLines(filePath, processLine) {
|
|
60
|
+
const fileStream = import_fs.default.createReadStream(filePath);
|
|
61
|
+
const rl = import_readline.default.createInterface({
|
|
62
|
+
input: fileStream,
|
|
63
|
+
crlfDelay: Infinity
|
|
64
|
+
});
|
|
65
|
+
for await (const line of rl) {
|
|
66
|
+
await processLine(line);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
58
69
|
function humanFileSize(bytes, si = false, dp = 1) {
|
|
59
70
|
const thresh = si ? 1e3 : 1024;
|
|
60
71
|
if (Math.abs(bytes) < thresh) {
|
|
@@ -73,6 +84,7 @@ function humanFileSize(bytes, si = false, dp = 1) {
|
|
|
73
84
|
0 && (module.exports = {
|
|
74
85
|
DUMPED_EXTENSION,
|
|
75
86
|
humanFileSize,
|
|
87
|
+
readEveryLines,
|
|
76
88
|
readLines,
|
|
77
89
|
sqlAdapter
|
|
78
90
|
});
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tachybase/module-backup",
|
|
3
3
|
"displayName": "App backup & restore",
|
|
4
|
-
"version": "0.23.
|
|
4
|
+
"version": "0.23.40",
|
|
5
5
|
"description": "Backup and restore applications for scenarios such as application replication, migration, and upgrades.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"System management"
|
|
8
8
|
],
|
|
9
9
|
"license": "Apache-2.0",
|
|
10
10
|
"main": "./dist/server/index.js",
|
|
11
|
-
"dependencies": {},
|
|
12
11
|
"devDependencies": {
|
|
13
12
|
"@ant-design/icons": "^5.5.2",
|
|
14
13
|
"@hapi/topo": "^6.0.2",
|
|
@@ -30,16 +29,17 @@
|
|
|
30
29
|
"react-i18next": "^15.2.0",
|
|
31
30
|
"semver": "^7.6.3",
|
|
32
31
|
"tar": "^6.2.1",
|
|
33
|
-
"
|
|
34
|
-
"@tachybase/
|
|
32
|
+
"yauzl": "^3.2.0",
|
|
33
|
+
"@tachybase/components": "0.23.40",
|
|
34
|
+
"@tachybase/module-worker-thread": "0.23.40"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@tachybase/actions": "0.23.
|
|
38
|
-
"@tachybase/client": "0.23.
|
|
39
|
-
"@tachybase/database": "0.23.
|
|
40
|
-
"@tachybase/
|
|
41
|
-
"@tachybase/
|
|
42
|
-
"@tachybase/utils": "0.23.
|
|
37
|
+
"@tachybase/actions": "0.23.40",
|
|
38
|
+
"@tachybase/client": "0.23.40",
|
|
39
|
+
"@tachybase/database": "0.23.40",
|
|
40
|
+
"@tachybase/test": "0.23.40",
|
|
41
|
+
"@tachybase/server": "0.23.40",
|
|
42
|
+
"@tachybase/utils": "0.23.40"
|
|
43
43
|
},
|
|
44
44
|
"description.zh-CN": "备份和还原应用,可用于应用的复制、迁移、升级等场景。",
|
|
45
45
|
"displayName.zh-CN": "应用的备份与还原",
|