@lacrous/omnicloud 0.2.9 → 0.2.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lacrous/omnicloud",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "SDK and core building blocks for OmniCloud — self-hosted cloud storage powered by your Telegram account.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,9 +48,9 @@
48
48
  "prisma": "^6.2.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@omnicloud/core": "0.2.9",
52
- "@omnicloud/shared": "0.2.9",
53
- "@omnicloud/telegram": "0.2.9",
51
+ "@omnicloud/core": "0.2.11",
52
+ "@omnicloud/shared": "0.2.11",
53
+ "@omnicloud/telegram": "0.2.11",
54
54
  "@types/node": "^22.10.0",
55
55
  "esbuild": "0.25.12",
56
56
  "tsup": "^8.3.0",
package/server/index.js CHANGED
@@ -761,6 +761,23 @@ function truncate(value, max) {
761
761
  return value === null ? null : value.slice(0, max);
762
762
  }
763
763
 
764
+ // ../core/src/services/version-retention.ts
765
+ function versionsToPrune(versions, currentVersionId, policy, now) {
766
+ const historical = versions.filter((version) => version.id !== currentVersionId).sort((a, b) => b.versionNumber - a.versionNumber);
767
+ switch (policy.kind) {
768
+ case "KEEP_ALL":
769
+ return [];
770
+ case "KEEP_LATEST_N": {
771
+ const keep = Math.max(0, policy.count);
772
+ return historical.slice(keep);
773
+ }
774
+ case "KEEP_FOR_DAYS": {
775
+ const cutoff = now.getTime() - policy.days * 24 * 60 * 60 * 1e3;
776
+ return historical.filter((version) => version.createdAt.getTime() < cutoff);
777
+ }
778
+ }
779
+ }
780
+
764
781
  // ../core/src/services/upload-operation.ts
765
782
  function isValidOperationId(id) {
766
783
  return /^[A-Za-z0-9_-]{8,64}$/.test(id);
@@ -918,7 +935,9 @@ var ReconciliationApplier = class {
918
935
  const file = (await this.repos.files.listByUser(this.userId)).find(
919
936
  (candidate) => candidate.telegramMessageId === action.messageId
920
937
  );
921
- if (!file) return;
938
+ if (!file) {
939
+ throw new Error("No file of yours references this message, so there is nothing to detach");
940
+ }
922
941
  await this.repos.files.update(file.id, { deletedAt: /* @__PURE__ */ new Date(), trashBatchId: null });
923
942
  }
924
943
  async adopt(action) {
@@ -1538,6 +1557,33 @@ var FileService = class {
1538
1557
  };
1539
1558
  }
1540
1559
  // ── internals ────────────────────────────────────────────────────────────
1560
+ /**
1561
+ * Removes historical versions the policy no longer keeps. For each version the
1562
+ * Telegram message is deleted first, and its row only after that succeeds, the
1563
+ * same order as a permanent delete. A failed remote delete leaves that version
1564
+ * in place. The current version is never removed. Returns how many were removed.
1565
+ */
1566
+ async pruneVersions(userId, fileId, policy, now = /* @__PURE__ */ new Date()) {
1567
+ const record = await this.get(userId, fileId);
1568
+ const versions = await this.files.listVersions(fileId);
1569
+ const doomed = versionsToPrune(versions, record.currentVersionId ?? null, policy, now);
1570
+ if (doomed.length === 0) return 0;
1571
+ const engine = await this.engineFor(userId);
1572
+ let removed = 0;
1573
+ for (const version of doomed) {
1574
+ await engine.remove({ messageId: String(version.telegramMessageId) });
1575
+ await this.files.deleteVersion(version.id);
1576
+ removed += 1;
1577
+ }
1578
+ await this.activity.record({
1579
+ userId,
1580
+ action: "delete",
1581
+ resourceType: "file",
1582
+ resourceId: fileId,
1583
+ resourceName: `${record.name} (${removed} old version${removed === 1 ? "" : "s"})`
1584
+ });
1585
+ return removed;
1586
+ }
1541
1587
  async assertFolder(userId, folderId) {
1542
1588
  const folder = await this.folders.findById(folderId);
1543
1589
  if (!folder || folder.userId !== userId) {
@@ -3870,6 +3916,9 @@ function createFileRepo(prisma) {
3870
3916
  async deleteVersionsByFileIds(fileIds) {
3871
3917
  if (fileIds.length === 0) return;
3872
3918
  await prisma.fileVersion.deleteMany({ where: { fileId: { in: fileIds } } });
3919
+ },
3920
+ async deleteVersion(versionId) {
3921
+ await prisma.fileVersion.deleteMany({ where: { id: versionId } });
3873
3922
  }
3874
3923
  };
3875
3924
  }
@@ -4317,6 +4366,27 @@ function parseListQuery(raw) {
4317
4366
  }
4318
4367
  return { query, page: query.page, limit: query.limit };
4319
4368
  }
4369
+ function parseRetentionPolicy(body) {
4370
+ const kind = requireString(body, "policy");
4371
+ if (kind === "KEEP_ALL") return { kind: "KEEP_ALL" };
4372
+ if (kind === "KEEP_LATEST_N") {
4373
+ const count = requireWholeNumber(body, "count");
4374
+ return { kind: "KEEP_LATEST_N", count };
4375
+ }
4376
+ if (kind === "KEEP_FOR_DAYS") {
4377
+ const days = requireWholeNumber(body, "days");
4378
+ if (days < 1) throw new ValidationError("days must be at least 1");
4379
+ return { kind: "KEEP_FOR_DAYS", days };
4380
+ }
4381
+ throw new ValidationError("policy must be KEEP_ALL, KEEP_LATEST_N or KEEP_FOR_DAYS");
4382
+ }
4383
+ function requireWholeNumber(body, field) {
4384
+ const value = body?.[field];
4385
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
4386
+ throw new ValidationError(`${field} must be a whole number of zero or more`);
4387
+ }
4388
+ return value;
4389
+ }
4320
4390
 
4321
4391
  // ../../apps/api/src/mappers.ts
4322
4392
  function toUserDTO(user) {
@@ -4637,6 +4707,13 @@ function registerFileRoutes(app2, container2) {
4637
4707
  const versions = await container2.files.listVersions(request.user.id, id);
4638
4708
  return { versions: versions.map((v) => toVersionDTO(v, record.currentVersionId)) };
4639
4709
  });
4710
+ app2.post("/api/files/:id/versions/prune", async (request, reply) => {
4711
+ const { id } = request.params;
4712
+ const body = requireBody(request);
4713
+ const policy = parseRetentionPolicy(body);
4714
+ const removed = await container2.files.pruneVersions(request.user.id, id, policy);
4715
+ return reply.send({ removed });
4716
+ });
4640
4717
  app2.post("/api/files/:id/replace", async (request, reply) => {
4641
4718
  const { id } = request.params;
4642
4719
  const part = await request.file();
@@ -4895,7 +4972,7 @@ function encryptionCheck(configured) {
4895
4972
  }
4896
4973
 
4897
4974
  // ../../apps/api/src/routes/storage.ts
4898
- var VERSION = "0.2.9";
4975
+ var VERSION = "0.2.11";
4899
4976
  function registerStorageRoutes(app2, container2) {
4900
4977
  app2.get("/api/health", async () => {
4901
4978
  const database = await probeDatabase(container2);