@lacrous/omnicloud 0.2.26 → 0.2.28

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.26",
3
+ "version": "0.2.28",
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.26",
52
- "@omnicloud/shared": "0.2.26",
53
- "@omnicloud/telegram": "0.2.26",
51
+ "@omnicloud/core": "0.2.28",
52
+ "@omnicloud/shared": "0.2.28",
53
+ "@omnicloud/telegram": "0.2.28",
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(caption, size) {
621
+ if (!this.provider.findByCaption) return null;
622
+ try {
623
+ return await this.provider.findByCaption(caption, 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);
@@ -1093,6 +1102,15 @@ 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 uploadCaption(sha256, operationId) {
1109
+ return `${sha256}:${operationId}`;
1110
+ }
1111
+ function contentSize(input) {
1112
+ return input.spooled?.size ?? input.data?.byteLength ?? 0;
1113
+ }
1096
1114
  function connectionCausedFailure(error) {
1097
1115
  if (error instanceof TelegramConnectionError) return true;
1098
1116
  if (error instanceof UploadFailedError) {
@@ -1144,6 +1162,9 @@ var FileService = class {
1144
1162
  if (op?.status === "UPLOADING" && op.telegramMessageId !== null) {
1145
1163
  return this.commitStoredObject(userId, input, operations, op);
1146
1164
  }
1165
+ if (op?.status === "UNKNOWN") {
1166
+ return this.resolveUnknown(userId, input, operations, op);
1167
+ }
1147
1168
  if (!op) {
1148
1169
  try {
1149
1170
  op = await operations.create({ userId, operationId, requestFingerprint: fingerprint });
@@ -1162,6 +1183,7 @@ var FileService = class {
1162
1183
  }
1163
1184
  try {
1164
1185
  const record = await this.performUpload(userId, input, {
1186
+ caption: uploadCaption(contentSha256(input), op.operationId),
1165
1187
  onStored: async (stored) => {
1166
1188
  await operations.update(op.id, { telegramMessageId: Number(stored.messageId) });
1167
1189
  await operations.claim(op.id, "UPLOADING", "COMMITTING");
@@ -1173,11 +1195,54 @@ var FileService = class {
1173
1195
  const status = connectionCausedFailure(error) ? "UNKNOWN" : "FAILED";
1174
1196
  await operations.update(op.id, {
1175
1197
  status,
1198
+ sha256: contentSha256(input),
1199
+ size: contentSize(input),
1176
1200
  error: error instanceof Error ? error.message.slice(0, 200) : "upload failed"
1177
1201
  });
1178
1202
  throw error;
1179
1203
  }
1180
1204
  }
1205
+ /**
1206
+ * Resolves an operation whose Telegram outcome is unknown. The channel is searched
1207
+ * for the object by its content hash and size. Exactly one match is adopted, zero
1208
+ * matches means nothing was stored so the upload is sent again, and anything
1209
+ * ambiguous stays UNKNOWN so that no duplicate is written.
1210
+ */
1211
+ async resolveUnknown(userId, input, operations, op) {
1212
+ if (op.sha256 === null || op.size === null) {
1213
+ throw new ConflictError(
1214
+ "This upload's outcome is unknown and cannot be checked automatically. Ask an administrator to reconcile it."
1215
+ );
1216
+ }
1217
+ const engine = await this.engineFor(userId);
1218
+ const matches = await engine.findByCaption(uploadCaption(op.sha256, op.operationId), op.size);
1219
+ if (matches === null) {
1220
+ throw new ConflictError(
1221
+ "This upload's outcome is unknown and the storage backend cannot search for it."
1222
+ );
1223
+ }
1224
+ if (matches.length > 1) {
1225
+ throw new ConflictError(
1226
+ "This upload's outcome is unknown and more than one matching object exists. Nothing was written."
1227
+ );
1228
+ }
1229
+ if (matches.length === 1) {
1230
+ const stored = matches[0];
1231
+ await operations.update(op.id, { telegramMessageId: Number(stored.messageId) });
1232
+ await operations.claim(op.id, "UNKNOWN", "COMMITTING");
1233
+ const record = await this.commitRecord(userId, input, {
1234
+ name: sanitizeFileName(input.name),
1235
+ mimeType: mimeFromFilename(sanitizeFileName(input.name)),
1236
+ size: op.size,
1237
+ sha256: op.sha256,
1238
+ telegramMessageId: Number(stored.messageId)
1239
+ });
1240
+ await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
1241
+ return record;
1242
+ }
1243
+ await operations.claim(op.id, "UNKNOWN", "FAILED");
1244
+ return this.uploadOnce(userId, input, op.operationId, operations);
1245
+ }
1181
1246
  /**
1182
1247
  * Follows an operation another request is driving, for a bounded time. Returns
1183
1248
  * the finished file if the winner completes, or the stored object's commit if
@@ -1255,8 +1320,9 @@ var FileService = class {
1255
1320
  mimeType,
1256
1321
  path: input.spooled.path,
1257
1322
  size: input.spooled.size,
1258
- sha256: input.spooled.sha256
1259
- } : { name, mimeType, data: input.data },
1323
+ sha256: input.spooled.sha256,
1324
+ caption: hooks.caption
1325
+ } : { name, mimeType, data: input.data, caption: hooks.caption },
1260
1326
  control
1261
1327
  );
1262
1328
  await hooks.onStored?.(stored);
@@ -2785,6 +2851,7 @@ var TelegramStorageProvider = class {
2785
2851
  file,
2786
2852
  forceDocument: true,
2787
2853
  workers: 1,
2854
+ caption: input.caption,
2788
2855
  // Surface transport-level progress to the caller. GramJS reports a
2789
2856
  // 0..1 fraction. `isCanceled` lets us abort an in-flight upload.
2790
2857
  progressCallback: Object.assign(
@@ -2942,6 +3009,41 @@ var TelegramStorageProvider = class {
2942
3009
  }
2943
3010
  return objects;
2944
3011
  }
3012
+ /**
3013
+ * Finds stored documents whose caption equals the given caption and whose size
3014
+ * matches. Used to recover an upload whose outcome was unknown.
3015
+ */
3016
+ async findByCaption(caption, size) {
3017
+ const matches = [];
3018
+ try {
3019
+ for await (const message of this.client.iterMessages(this.peer, {
3020
+ search: caption,
3021
+ limit: 20
3022
+ })) {
3023
+ if (!(message instanceof Api2.Message)) continue;
3024
+ if (message.message !== caption) continue;
3025
+ const document = message.document;
3026
+ if (!(document instanceof Api2.Document)) continue;
3027
+ if (Number(document.size.toString()) !== size) continue;
3028
+ let fileName;
3029
+ for (const attr of document.attributes) {
3030
+ if (attr instanceof Api2.DocumentAttributeFilename) {
3031
+ fileName = attr.fileName;
3032
+ break;
3033
+ }
3034
+ }
3035
+ matches.push({
3036
+ messageId: message.id.toString(),
3037
+ name: fileName ?? "file",
3038
+ size,
3039
+ mimeType: document.mimeType
3040
+ });
3041
+ }
3042
+ } catch (error) {
3043
+ throw mapTelegramError(error, "Telegram lookup failed");
3044
+ }
3045
+ return matches;
3046
+ }
2945
3047
  async stat(ref) {
2946
3048
  const message = await this.getMessage(ref.messageId);
2947
3049
  if (!message?.media) return null;
@@ -5095,7 +5197,7 @@ function encryptionCheck(configured) {
5095
5197
  }
5096
5198
 
5097
5199
  // ../../apps/api/src/routes/storage.ts
5098
- var VERSION = "0.2.26";
5200
+ var VERSION = "0.2.28";
5099
5201
  function registerStorageRoutes(app2, container2) {
5100
5202
  app2.get("/api/health", async () => {
5101
5203
  const database = await probeDatabase(container2);