@lacrous/omnicloud 0.2.25 → 0.2.27

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.25",
3
+ "version": "0.2.27",
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.25",
52
- "@omnicloud/shared": "0.2.25",
53
- "@omnicloud/telegram": "0.2.25",
51
+ "@omnicloud/core": "0.2.27",
52
+ "@omnicloud/shared": "0.2.27",
53
+ "@omnicloud/telegram": "0.2.27",
54
54
  "@types/node": "^22.10.0",
55
55
  "esbuild": "0.25.12",
56
56
  "tsup": "^8.3.0",
package/server/index.js CHANGED
@@ -616,6 +616,15 @@ var StorageEngine = class {
616
616
  throw mapProviderError("Storage listing failed", error);
617
617
  }
618
618
  }
619
+ /** Read-only search for stored objects with this content hash and size. */
620
+ async findByCaption(sha256, size) {
621
+ if (!this.provider.findByCaption) return null;
622
+ try {
623
+ return await this.provider.findByCaption(sha256, size);
624
+ } catch (error) {
625
+ throw mapProviderError("Storage lookup failed", error);
626
+ }
627
+ }
619
628
  async stat(ref) {
620
629
  try {
621
630
  return await this.provider.stat(ref);
@@ -852,7 +861,7 @@ var ReconciliationService = class {
852
861
  */
853
862
  async pendingUploadMessageIds(userId) {
854
863
  const ids = /* @__PURE__ */ new Set();
855
- for (const status of ["UPLOADING", "PENDING"]) {
864
+ for (const status of ["UPLOADING", "PENDING", "UNKNOWN"]) {
856
865
  for (const operation of await this.repos.uploadOperations.listByStatus(userId, status)) {
857
866
  if (operation.telegramMessageId !== null) ids.add(operation.telegramMessageId);
858
867
  }
@@ -1093,6 +1102,12 @@ function uploadRequestFingerprint(input) {
1093
1102
  }
1094
1103
 
1095
1104
  // ../core/src/services/file-service.ts
1105
+ function contentSha256(input) {
1106
+ return input.spooled?.sha256 ?? sha256Hex(input.data ?? Buffer.alloc(0));
1107
+ }
1108
+ function contentSize(input) {
1109
+ return input.spooled?.size ?? input.data?.byteLength ?? 0;
1110
+ }
1096
1111
  function connectionCausedFailure(error) {
1097
1112
  if (error instanceof TelegramConnectionError) return true;
1098
1113
  if (error instanceof UploadFailedError) {
@@ -1144,6 +1159,9 @@ var FileService = class {
1144
1159
  if (op?.status === "UPLOADING" && op.telegramMessageId !== null) {
1145
1160
  return this.commitStoredObject(userId, input, operations, op);
1146
1161
  }
1162
+ if (op?.status === "UNKNOWN") {
1163
+ return this.resolveUnknown(userId, input, operations, op);
1164
+ }
1147
1165
  if (!op) {
1148
1166
  try {
1149
1167
  op = await operations.create({ userId, operationId, requestFingerprint: fingerprint });
@@ -1173,11 +1191,54 @@ var FileService = class {
1173
1191
  const status = connectionCausedFailure(error) ? "UNKNOWN" : "FAILED";
1174
1192
  await operations.update(op.id, {
1175
1193
  status,
1194
+ sha256: contentSha256(input),
1195
+ size: contentSize(input),
1176
1196
  error: error instanceof Error ? error.message.slice(0, 200) : "upload failed"
1177
1197
  });
1178
1198
  throw error;
1179
1199
  }
1180
1200
  }
1201
+ /**
1202
+ * Resolves an operation whose Telegram outcome is unknown. The channel is searched
1203
+ * for the object by its content hash and size. Exactly one match is adopted, zero
1204
+ * matches means nothing was stored so the upload is sent again, and anything
1205
+ * ambiguous stays UNKNOWN so that no duplicate is written.
1206
+ */
1207
+ async resolveUnknown(userId, input, operations, op) {
1208
+ if (op.sha256 === null || op.size === null) {
1209
+ throw new ConflictError(
1210
+ "This upload's outcome is unknown and cannot be checked automatically. Ask an administrator to reconcile it."
1211
+ );
1212
+ }
1213
+ const engine = await this.engineFor(userId);
1214
+ const matches = await engine.findByCaption(op.sha256, op.size);
1215
+ if (matches === null) {
1216
+ throw new ConflictError(
1217
+ "This upload's outcome is unknown and the storage backend cannot search for it."
1218
+ );
1219
+ }
1220
+ if (matches.length > 1) {
1221
+ throw new ConflictError(
1222
+ "This upload's outcome is unknown and more than one matching object exists. Nothing was written."
1223
+ );
1224
+ }
1225
+ if (matches.length === 1) {
1226
+ const stored = matches[0];
1227
+ await operations.update(op.id, { telegramMessageId: Number(stored.messageId) });
1228
+ await operations.claim(op.id, "UNKNOWN", "COMMITTING");
1229
+ const record = await this.commitRecord(userId, input, {
1230
+ name: sanitizeFileName(input.name),
1231
+ mimeType: mimeFromFilename(sanitizeFileName(input.name)),
1232
+ size: op.size,
1233
+ sha256: op.sha256,
1234
+ telegramMessageId: Number(stored.messageId)
1235
+ });
1236
+ await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
1237
+ return record;
1238
+ }
1239
+ await operations.claim(op.id, "UNKNOWN", "FAILED");
1240
+ return this.uploadOnce(userId, input, op.operationId, operations);
1241
+ }
1181
1242
  /**
1182
1243
  * Follows an operation another request is driving, for a bounded time. Returns
1183
1244
  * the finished file if the winner completes, or the stored object's commit if
@@ -2785,6 +2846,7 @@ var TelegramStorageProvider = class {
2785
2846
  file,
2786
2847
  forceDocument: true,
2787
2848
  workers: 1,
2849
+ caption: input.sha256,
2788
2850
  // Surface transport-level progress to the caller. GramJS reports a
2789
2851
  // 0..1 fraction. `isCanceled` lets us abort an in-flight upload.
2790
2852
  progressCallback: Object.assign(
@@ -2942,6 +3004,41 @@ var TelegramStorageProvider = class {
2942
3004
  }
2943
3005
  return objects;
2944
3006
  }
3007
+ /**
3008
+ * Finds stored documents whose caption equals the content hash and whose size
3009
+ * matches. Used to recover an upload whose outcome was unknown.
3010
+ */
3011
+ async findByCaption(sha256, size) {
3012
+ const matches = [];
3013
+ try {
3014
+ for await (const message of this.client.iterMessages(this.peer, {
3015
+ search: sha256,
3016
+ limit: 20
3017
+ })) {
3018
+ if (!(message instanceof Api2.Message)) continue;
3019
+ if (message.message !== sha256) continue;
3020
+ const document = message.document;
3021
+ if (!(document instanceof Api2.Document)) continue;
3022
+ if (Number(document.size.toString()) !== size) continue;
3023
+ let fileName;
3024
+ for (const attr of document.attributes) {
3025
+ if (attr instanceof Api2.DocumentAttributeFilename) {
3026
+ fileName = attr.fileName;
3027
+ break;
3028
+ }
3029
+ }
3030
+ matches.push({
3031
+ messageId: message.id.toString(),
3032
+ name: fileName ?? "file",
3033
+ size,
3034
+ mimeType: document.mimeType
3035
+ });
3036
+ }
3037
+ } catch (error) {
3038
+ throw mapTelegramError(error, "Telegram lookup failed");
3039
+ }
3040
+ return matches;
3041
+ }
2945
3042
  async stat(ref) {
2946
3043
  const message = await this.getMessage(ref.messageId);
2947
3044
  if (!message?.media) return null;
@@ -5095,7 +5192,7 @@ function encryptionCheck(configured) {
5095
5192
  }
5096
5193
 
5097
5194
  // ../../apps/api/src/routes/storage.ts
5098
- var VERSION = "0.2.25";
5195
+ var VERSION = "0.2.27";
5099
5196
  function registerStorageRoutes(app2, container2) {
5100
5197
  app2.get("/api/health", async () => {
5101
5198
  const database = await probeDatabase(container2);