@homecloud-platform/sdk 0.5.8 → 0.5.10

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/so.js +199 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homecloud-platform/sdk",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "HomeCloud Functions SDK for Node.js (ADR-033 / ADR-025e)",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
package/src/so.js CHANGED
@@ -213,6 +213,19 @@ class SoAPI {
213
213
  };
214
214
  }
215
215
 
216
+ async copy(bucketName, sourceKey, destinationKey, { sourceBucket = null } = {}) {
217
+ this._c.requireAccessKey();
218
+ const { signPath, urlPath } = soObjectPaths(this._c.accountId, bucketName, sourceKey);
219
+ return this._c.dataPlaneRequest("so", "POST", `${signPath}/copy`, {
220
+ urlPath: `${urlPath}/copy`,
221
+ signPath: `${signPath}/copy`,
222
+ json: {
223
+ destination_key: destinationKey,
224
+ source_bucket: sourceBucket || null,
225
+ },
226
+ });
227
+ }
228
+
216
229
  async deleteRecursive(bucketName, prefix = "") {
217
230
  const items = await this.listAllObjects(bucketName, { prefix, recursive: true });
218
231
  for (const item of items) {
@@ -221,7 +234,135 @@ class SoAPI {
221
234
  return items.length;
222
235
  }
223
236
 
224
- async syncLocalToBucket(localDir, bucketName, { prefix = "", deleteExtra = false } = {}) {
237
+ _isSoUri(target) {
238
+ const lowered = String(target || "").toLowerCase();
239
+ return lowered.startsWith("so://") || lowered.startsWith("s3://");
240
+ }
241
+
242
+ _parseSoUri(target) {
243
+ let text = String(target || "").trim();
244
+ const lowered = text.toLowerCase();
245
+ if (lowered.startsWith("so://")) text = text.slice(5);
246
+ else if (lowered.startsWith("s3://")) text = text.slice(5);
247
+ text = text.replace(/^\/+|\/+$/g, "");
248
+ if (!text) throw new HomeCloudError("URI must include a bucket name");
249
+ const slash = text.indexOf("/");
250
+ if (slash < 0) return { bucket: text, prefix: "" };
251
+ return { bucket: text.slice(0, slash), prefix: text.slice(slash + 1) };
252
+ }
253
+
254
+ _syncJoinPrefix(prefixClean, relative) {
255
+ const rel = String(relative || "").replace(/^\/+/, "");
256
+ if (!prefixClean) return rel;
257
+ if (!rel) return prefixClean;
258
+ return `${prefixClean}/${rel}`;
259
+ }
260
+
261
+ _syncRelativePath(key, prefixClean) {
262
+ if (!prefixClean) return key;
263
+ if (key === prefixClean) return key.split("/").pop();
264
+ if (key.startsWith(`${prefixClean}/`)) return key.slice(prefixClean.length + 1);
265
+ return key;
266
+ }
267
+
268
+ /**
269
+ * Unified sync: local↔bucket or bucket↔bucket.
270
+ * Prefer this over syncLocalToBucket / syncBucketToLocal.
271
+ */
272
+ async sync(source, destination, { deleteExtra = false, skip = false } = {}) {
273
+ const srcRemote = this._isSoUri(source);
274
+ const dstRemote = this._isSoUri(destination);
275
+ if (srcRemote && dstRemote) {
276
+ const src = this._parseSoUri(source);
277
+ const dst = this._parseSoUri(destination);
278
+ return this._syncBucketToBucket(src.bucket, dst.bucket, {
279
+ sourcePrefix: src.prefix,
280
+ destinationPrefix: dst.prefix,
281
+ deleteExtra,
282
+ skip,
283
+ });
284
+ }
285
+ if (srcRemote && !dstRemote) {
286
+ const src = this._parseSoUri(source);
287
+ return this.syncBucketToLocal(src.bucket, destination, {
288
+ prefix: src.prefix,
289
+ deleteExtra,
290
+ skip,
291
+ });
292
+ }
293
+ if (!srcRemote && dstRemote) {
294
+ const dst = this._parseSoUri(destination);
295
+ return this.syncLocalToBucket(source, dst.bucket, {
296
+ prefix: dst.prefix,
297
+ deleteExtra,
298
+ skip,
299
+ });
300
+ }
301
+ throw new HomeCloudError(
302
+ "One or both sides must be an so:// URI (local↔bucket or bucket↔bucket)"
303
+ );
304
+ }
305
+
306
+ async _syncBucketToBucket(
307
+ sourceBucket,
308
+ destinationBucket,
309
+ { sourcePrefix = "", destinationPrefix = "", deleteExtra = false, skip = false } = {}
310
+ ) {
311
+ this._c.requireAccessKey();
312
+ const srcPrefix = String(sourcePrefix || "").replace(/^\/+|\/+$/g, "");
313
+ const dstPrefix = String(destinationPrefix || "").replace(/^\/+|\/+$/g, "");
314
+ if (sourceBucket === destinationBucket && srcPrefix === dstPrefix) {
315
+ throw new HomeCloudError(`Source and destination are the same: so://${sourceBucket}/${srcPrefix}`);
316
+ }
317
+
318
+ const sourceItems = await this.listAllObjects(sourceBucket, {
319
+ prefix: srcPrefix,
320
+ recursive: true,
321
+ });
322
+ const destItems = await this.listAllObjects(destinationBucket, {
323
+ prefix: dstPrefix,
324
+ recursive: true,
325
+ });
326
+
327
+ const sourceRels = new Map();
328
+ for (const item of sourceItems) {
329
+ const rel = this._syncRelativePath(item.key, srcPrefix);
330
+ sourceRels.set(rel, { key: item.key, size: Number(item.size || 0) });
331
+ }
332
+ const destRels = new Map();
333
+ for (const item of destItems) {
334
+ const rel = this._syncRelativePath(item.key, dstPrefix);
335
+ destRels.set(rel, { key: item.key, size: Number(item.size || 0) });
336
+ }
337
+
338
+ let copied = 0;
339
+ let skipped = 0;
340
+ for (const [rel, src] of sourceRels) {
341
+ const dest = destRels.get(rel);
342
+ if (skip && dest && dest.size === src.size) {
343
+ skipped += 1;
344
+ continue;
345
+ }
346
+ const dstKey = this._syncJoinPrefix(dstPrefix, rel);
347
+ await this.copy(destinationBucket, src.key, dstKey, {
348
+ sourceBucket: sourceBucket !== destinationBucket ? sourceBucket : null,
349
+ });
350
+ copied += 1;
351
+ }
352
+
353
+ let deleted = 0;
354
+ if (deleteExtra) {
355
+ for (const [rel, dest] of destRels) {
356
+ if (sourceRels.has(rel)) continue;
357
+ await this.delete(destinationBucket, dest.key);
358
+ deleted += 1;
359
+ }
360
+ }
361
+ return { copied, skipped, deleted };
362
+ }
363
+
364
+ /** Prefer sync("./dir", "so://bucket/prefix"). */
365
+ async syncLocalToBucket(localDir, bucketName, { prefix = "", deleteExtra = false, skip = false } = {}) {
225
366
  this._c.requireAccessKey();
226
367
  const root = path.resolve(localDir);
227
368
  const prefixClean = String(prefix || "").replace(/^\/+|\/+$/g, "");
@@ -232,43 +373,83 @@ class SoAPI {
232
373
  const st = fs.statSync(full);
233
374
  const rel = path.relative(base, full).split(path.sep).join("/");
234
375
  if (st.isDirectory()) out.push(...walk(full, base));
235
- else out.push(rel);
376
+ else out.push({ rel, size: st.size });
236
377
  }
237
378
  return out;
238
379
  };
239
380
  const locals = walk(root, root);
240
- for (const rel of locals) {
241
- const key = prefixClean ? `${prefixClean}/${rel}` : rel;
381
+ const remote = await this.listAllObjects(bucketName, { prefix: prefixClean, recursive: true });
382
+ const remoteByKey = new Map(remote.map((item) => [item.key, item]));
383
+
384
+ let uploaded = 0;
385
+ let skipped = 0;
386
+ for (const { rel, size } of locals) {
387
+ const key = this._syncJoinPrefix(prefixClean, rel);
388
+ const existing = remoteByKey.get(key);
389
+ if (skip && existing && Number(existing.size || 0) === size) {
390
+ skipped += 1;
391
+ continue;
392
+ }
242
393
  await this.upload(bucketName, path.join(root, rel), { key });
394
+ uploaded += 1;
243
395
  }
396
+ let deleted = 0;
244
397
  if (deleteExtra) {
245
- const remote = await this.listAllObjects(bucketName, { prefix: prefixClean, recursive: true });
246
- const localKeys = new Set(
247
- locals.map((rel) => (prefixClean ? `${prefixClean}/${rel}` : rel))
248
- );
398
+ const localKeys = new Set(locals.map(({ rel }) => this._syncJoinPrefix(prefixClean, rel)));
249
399
  for (const item of remote) {
250
- if (!localKeys.has(item.key)) await this.delete(bucketName, item.key);
400
+ if (!localKeys.has(item.key)) {
401
+ await this.delete(bucketName, item.key);
402
+ deleted += 1;
403
+ }
251
404
  }
252
405
  }
253
- return { uploaded: locals.length };
406
+ return { uploaded, skipped, deleted };
254
407
  }
255
408
 
256
- async syncBucketToLocal(bucketName, localDir, { prefix = "" } = {}) {
409
+ /** Prefer sync("so://bucket/prefix", "./dir"). */
410
+ async syncBucketToLocal(bucketName, localDir, { prefix = "", deleteExtra = false, skip = false } = {}) {
257
411
  this._c.requireAccessKey();
258
412
  const root = path.resolve(localDir);
259
413
  const prefixClean = String(prefix || "").replace(/^\/+|\/+$/g, "");
260
414
  const items = await this.listAllObjects(bucketName, { prefix: prefixClean, recursive: true });
415
+ let downloaded = 0;
416
+ let skippedCount = 0;
417
+ const remoteRels = new Set();
261
418
  for (const item of items) {
262
- let rel = item.key;
263
- if (prefixClean && item.key.startsWith(`${prefixClean}/`)) {
264
- rel = item.key.slice(prefixClean.length + 1);
265
- } else if (prefixClean && item.key === prefixClean) {
266
- rel = path.basename(item.key);
267
- }
419
+ const rel = this._syncRelativePath(item.key, prefixClean);
420
+ remoteRels.add(rel);
268
421
  const dest = path.join(root, rel);
422
+ if (
423
+ skip &&
424
+ fs.existsSync(dest) &&
425
+ fs.statSync(dest).isFile() &&
426
+ fs.statSync(dest).size === Number(item.size || 0)
427
+ ) {
428
+ skippedCount += 1;
429
+ continue;
430
+ }
269
431
  await this.download(bucketName, item.key, { destPath: dest });
432
+ downloaded += 1;
433
+ }
434
+ let deleted = 0;
435
+ if (deleteExtra && fs.existsSync(root)) {
436
+ const walk = (dir, base) => {
437
+ const out = [];
438
+ for (const name of fs.readdirSync(dir)) {
439
+ const full = path.join(dir, name);
440
+ const st = fs.statSync(full);
441
+ if (st.isDirectory()) out.push(...walk(full, base));
442
+ else out.push(path.relative(base, full).split(path.sep).join("/"));
443
+ }
444
+ return out;
445
+ };
446
+ for (const rel of walk(root, root)) {
447
+ if (remoteRels.has(rel)) continue;
448
+ fs.unlinkSync(path.join(root, rel));
449
+ deleted += 1;
450
+ }
270
451
  }
271
- return { downloaded: items.length };
452
+ return { downloaded, skipped: skippedCount, deleted };
272
453
  }
273
454
  }
274
455