@lacrous/omnicloud 0.2.1 → 0.2.3

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/index.cjs CHANGED
@@ -193,6 +193,65 @@ var OmniCloudClient = class {
193
193
  throw lastError;
194
194
  }
195
195
  /** @internal */
196
+ /**
197
+ * Opens a binary response as a stream. Bytes are delivered as they arrive, so
198
+ * memory does not grow with the file. The stream errors with DOWNLOAD_INCOMPLETE
199
+ * if the body ends before Content-Length.
200
+ */
201
+ async requestStream(path, onProgress) {
202
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
203
+ headers: this.defaultHeaders,
204
+ credentials: "include"
205
+ });
206
+ if (!response.ok) {
207
+ const text = await response.text();
208
+ const error = safeJsonParse(text)?.error;
209
+ throw new OmniCloudError(
210
+ response.status,
211
+ error?.code ?? "DOWNLOAD_FAILED",
212
+ error?.message ?? `Download failed (${response.status})`
213
+ );
214
+ }
215
+ const total = Number(response.headers.get("content-length") ?? 0) || null;
216
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
217
+ const sha256 = response.headers.get("x-content-sha256");
218
+ const source = response.body;
219
+ if (!source) {
220
+ throw new OmniCloudError(0, "DOWNLOAD_FAILED", "The response has no body to stream");
221
+ }
222
+ let loaded = 0;
223
+ const reader = source.getReader();
224
+ const stream = new ReadableStream({
225
+ async pull(controller) {
226
+ const { done, value } = await reader.read();
227
+ if (done) {
228
+ if (total !== null && loaded !== total) {
229
+ controller.error(
230
+ new OmniCloudError(
231
+ 0,
232
+ "DOWNLOAD_INCOMPLETE",
233
+ `Download ended after ${loaded} of ${total} bytes`
234
+ )
235
+ );
236
+ return;
237
+ }
238
+ controller.close();
239
+ return;
240
+ }
241
+ loaded += value.byteLength;
242
+ onProgress?.({
243
+ loaded,
244
+ total,
245
+ percent: total ? Math.round(loaded / total * 100) : null
246
+ });
247
+ controller.enqueue(value);
248
+ },
249
+ cancel(reason) {
250
+ return reader.cancel(reason);
251
+ }
252
+ });
253
+ return { stream, contentType, size: total, sha256 };
254
+ }
196
255
  async requestBinary(path, onProgress) {
197
256
  const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
198
257
  headers: this.defaultHeaders,
@@ -216,6 +275,28 @@ var OmniCloudClient = class {
216
275
  return { data, contentType, size: data.byteLength, sha256, integrityVerified };
217
276
  }
218
277
  };
278
+ async function toBlob(input) {
279
+ if (input.path !== void 0) {
280
+ const { readFile } = await import("fs/promises");
281
+ return new Blob([new Uint8Array(await readFile(input.path))]);
282
+ }
283
+ const data = input.data;
284
+ if (data === void 0) throw new OmniCloudError(0, "INVALID_REQUEST", "No upload content given");
285
+ if (typeof Blob !== "undefined" && data instanceof Blob) return data;
286
+ if (data instanceof Uint8Array) return new Blob([new Uint8Array(data)]);
287
+ const parts = [];
288
+ if (typeof data.getReader === "function") {
289
+ const reader = data.getReader();
290
+ for (; ; ) {
291
+ const { done, value } = await reader.read();
292
+ if (done) break;
293
+ parts.push(value);
294
+ }
295
+ } else {
296
+ for await (const chunk of data) parts.push(chunk);
297
+ }
298
+ return new Blob(parts);
299
+ }
219
300
  async function readBody(response, total, onProgress) {
220
301
  const body = response.body;
221
302
  if (!body) {
@@ -239,6 +320,13 @@ async function readBody(response, total, onProgress) {
239
320
  });
240
321
  }
241
322
  }
323
+ if (total !== null && loaded !== total) {
324
+ throw new OmniCloudError(
325
+ 0,
326
+ "DOWNLOAD_INCOMPLETE",
327
+ `Download ended after ${loaded} of ${total} bytes`
328
+ );
329
+ }
242
330
  const merged = new Uint8Array(loaded);
243
331
  let offset = 0;
244
332
  for (const chunk of chunks) {
@@ -288,9 +376,9 @@ var FilesApi = class {
288
376
  }
289
377
  throw lastError;
290
378
  }
291
- uploadOnce(input, options) {
379
+ async uploadOnce(input, options) {
292
380
  const form = new FormData();
293
- const blob = typeof Blob !== "undefined" && input.data instanceof Blob ? input.data : new Blob([new Uint8Array(input.data)]);
381
+ const blob = await toBlob(input);
294
382
  form.append("file", blob, input.name);
295
383
  if (options.folderId) form.append("folderId", options.folderId);
296
384
  if (typeof XMLHttpRequest === "function") {
@@ -350,6 +438,45 @@ var FilesApi = class {
350
438
  async download(id, onProgress) {
351
439
  return this.client.requestBinary(`/api/files/${id}/download`, onProgress);
352
440
  }
441
+ /**
442
+ * Streams a file's bytes without holding the whole file in memory. Use this for
443
+ * large files. Read `stream` to the end: a truncated transfer errors instead of
444
+ * ending quietly.
445
+ */
446
+ async downloadStream(id, onProgress) {
447
+ return this.client.requestStream(`/api/files/${id}/download`, onProgress);
448
+ }
449
+ /**
450
+ * Node only. Streams a file to `targetPath` through a `.part` file that is
451
+ * renamed into place only after the full length is verified. A truncated or
452
+ * failed transfer leaves nothing at `targetPath`.
453
+ */
454
+ async downloadToFile(id, targetPath, onProgress) {
455
+ const { createWriteStream: createWriteStream2 } = await import("fs");
456
+ const { rename, rm: rm2 } = await import("fs/promises");
457
+ const { Readable: Readable3 } = await import("stream");
458
+ const { pipeline: pipeline2 } = await import("stream/promises");
459
+ const download = await this.downloadStream(id, onProgress);
460
+ const partPath = `${targetPath}.part`;
461
+ let bytes = 0;
462
+ try {
463
+ await pipeline2(
464
+ Readable3.fromWeb(download.stream),
465
+ async function* (source) {
466
+ for await (const chunk of source) {
467
+ bytes += chunk.byteLength;
468
+ yield chunk;
469
+ }
470
+ },
471
+ createWriteStream2(partPath)
472
+ );
473
+ await rename(partPath, targetPath);
474
+ } catch (error) {
475
+ await rm2(partPath, { force: true });
476
+ throw error;
477
+ }
478
+ return { path: targetPath, bytes, sha256: download.sha256 };
479
+ }
353
480
  /** URL suitable for direct download (anchor href). */
354
481
  downloadUrl(id) {
355
482
  return `${this.client.baseUrl}/api/files/${id}/download`;
@@ -904,12 +1031,18 @@ var DomainError = class extends Error {
904
1031
  code;
905
1032
  status;
906
1033
  details;
907
- constructor(code, status, message, details) {
1034
+ /** Whether repeating the same operation may succeed. Defaults to false. */
1035
+ retryable = false;
1036
+ /** Server-requested delay before a retry, in seconds, when the provider gave one. */
1037
+ retryAfterSeconds;
1038
+ constructor(code, status, message, details, retry) {
908
1039
  super(message);
909
1040
  this.name = new.target.name;
910
1041
  this.code = code;
911
1042
  this.status = status;
912
1043
  this.details = details;
1044
+ this.retryable = retry?.retryable ?? false;
1045
+ this.retryAfterSeconds = retry?.retryAfterSeconds;
913
1046
  }
914
1047
  };
915
1048
  var ValidationError = class extends DomainError {
@@ -942,8 +1075,11 @@ var ConflictError = class extends DomainError {
942
1075
  }
943
1076
  };
944
1077
  var RateLimitedError = class extends DomainError {
945
- constructor(message = "Too many requests, please retry later") {
946
- super(ERROR_CODES.RATE_LIMITED, 429, message);
1078
+ constructor(message = "Too many requests, please retry later", options = {}) {
1079
+ super(ERROR_CODES.RATE_LIMITED, 429, message, void 0, {
1080
+ retryable: true,
1081
+ retryAfterSeconds: options.retryAfterSeconds
1082
+ });
947
1083
  }
948
1084
  };
949
1085
  var StorageNotInitializedError = class extends DomainError {
@@ -958,7 +1094,7 @@ var TelegramAuthRequiredError = class extends DomainError {
958
1094
  };
959
1095
  var TelegramConnectionError = class extends DomainError {
960
1096
  constructor(message = "Could not reach Telegram", details) {
961
- super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details);
1097
+ super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details, { retryable: true });
962
1098
  }
963
1099
  };
964
1100
  var TelegramFileNotFoundError = class extends DomainError {
@@ -971,6 +1107,11 @@ var UploadFailedError = class extends DomainError {
971
1107
  super(ERROR_CODES.UPLOAD_FAILED, 502, message, details);
972
1108
  }
973
1109
  };
1110
+ var IntegrityCheckError = class extends DomainError {
1111
+ constructor(message = "Integrity check failed", details) {
1112
+ super(ERROR_CODES.INTEGRITY_CHECK_FAILED, 502, message, details);
1113
+ }
1114
+ };
974
1115
  var OperationCancelledError = class extends DomainError {
975
1116
  constructor(message = "Operation cancelled") {
976
1117
  super(ERROR_CODES.INVALID_REQUEST, 499, message);
@@ -983,8 +1124,23 @@ function mapProviderError(message, error) {
983
1124
  }
984
1125
 
985
1126
  // ../core/src/storage/engine.ts
1127
+ var import_node_crypto2 = require("crypto");
986
1128
  var import_node_stream = require("stream");
987
1129
 
1130
+ // ../core/src/storage/retry.ts
1131
+ function isRetryableError(error) {
1132
+ if (error instanceof OperationCancelledError) return false;
1133
+ if (error instanceof DomainError) return error.retryable;
1134
+ return false;
1135
+ }
1136
+ function retryDelayMs(error, attempt, baseDelayMs, random = Math.random) {
1137
+ if (error instanceof DomainError && error.retryAfterSeconds !== void 0) {
1138
+ return error.retryAfterSeconds * 1e3;
1139
+ }
1140
+ const ceiling = baseDelayMs * 2 ** (attempt - 1);
1141
+ return Math.floor(random() * ceiling);
1142
+ }
1143
+
988
1144
  // ../core/src/utils/hash.ts
989
1145
  var import_node_crypto = require("crypto");
990
1146
  function sha256Hex(data) {
@@ -993,15 +1149,6 @@ function sha256Hex(data) {
993
1149
 
994
1150
  // ../core/src/storage/engine.ts
995
1151
  var DEFAULT_RETRY_POLICY = { attempts: 3, baseDelayMs: 400 };
996
- function isRetryable(error) {
997
- if (error instanceof OperationCancelledError) return false;
998
- const name = error instanceof Error ? error.name : "";
999
- const message = error instanceof Error ? error.message : String(error);
1000
- const code = error.code;
1001
- if (name === "AbortError" || code === "ABORT_ERR") return false;
1002
- if (/AUTH|PASSWORD|PHONE|FLOOD_WAIT|not found|invalid/i.test(message)) return false;
1003
- return true;
1004
- }
1005
1152
  function delay(ms, signal) {
1006
1153
  return new Promise((resolve, reject) => {
1007
1154
  if (signal?.aborted) {
@@ -1037,16 +1184,30 @@ var StorageEngine = class {
1037
1184
  * progress. Retries transient failures with exponential backoff.
1038
1185
  */
1039
1186
  async upload(input, control = {}) {
1040
- const data = await this.readInput(input);
1041
- const sha256 = sha256Hex(data);
1042
- control.onProgress?.({ transferred: 0, total: data.byteLength, percent: 0 });
1187
+ if (input.data) {
1188
+ const sha2562 = sha256Hex(input.data);
1189
+ const size2 = input.data.byteLength;
1190
+ control.onProgress?.({ transferred: 0, total: size2, percent: 0 });
1191
+ const stored2 = await this.withRetry(
1192
+ () => this.provider.put(input, control),
1193
+ control,
1194
+ (error) => new UploadFailedError("Upload failed", error)
1195
+ );
1196
+ control.onProgress?.({ transferred: size2, total: size2, percent: 100 });
1197
+ return { stored: stored2, sha256: sha2562, size: size2 };
1198
+ }
1199
+ const { path, size, sha256 } = input;
1200
+ if (!path || size === void 0 || !sha256) {
1201
+ throw new UploadFailedError("Upload input has neither data nor a complete spooled file");
1202
+ }
1203
+ control.onProgress?.({ transferred: 0, total: size, percent: 0 });
1043
1204
  const stored = await this.withRetry(
1044
- () => this.provider.put({ ...input, data }, control),
1205
+ () => this.provider.put(input, control),
1045
1206
  control,
1046
1207
  (error) => new UploadFailedError("Upload failed", error)
1047
1208
  );
1048
- control.onProgress?.({ transferred: data.byteLength, total: data.byteLength, percent: 100 });
1049
- return { stored, sha256, size: data.byteLength };
1209
+ control.onProgress?.({ transferred: size, total: size, percent: 100 });
1210
+ return { stored, sha256, size };
1050
1211
  }
1051
1212
  async download(ref, control = {}) {
1052
1213
  return this.withRetry(
@@ -1055,14 +1216,41 @@ var StorageEngine = class {
1055
1216
  (error) => mapProviderError("Download failed", error)
1056
1217
  );
1057
1218
  }
1058
- /** Streams the object, computing the checksum on the fly when requested. */
1059
- async downloadStream(ref, control = {}) {
1060
- const stream = await this.withRetry(
1219
+ /**
1220
+ * Streams the object and verifies its SHA-256 against `expectedSha256`.
1221
+ *
1222
+ * The last chunk is held back until the digest of everything before it has
1223
+ * been checked, so a corrupt object errors the stream before its final bytes
1224
+ * are released. A consumer therefore never sees a complete-looking body that
1225
+ * fails the checksum. Memory stays bounded to about two provider chunks.
1226
+ */
1227
+ async downloadStream(ref, expectedSha256, control = {}) {
1228
+ const source = await this.withRetry(
1061
1229
  () => this.provider.getStream(ref, control),
1062
1230
  control,
1063
1231
  (error) => mapProviderError("Download failed", error)
1064
1232
  );
1065
- return { stream, hash: () => null };
1233
+ const hash = (0, import_node_crypto2.createHash)("sha256");
1234
+ let held = null;
1235
+ const gate = new import_node_stream.Transform({
1236
+ transform(chunk, _encoding, callback) {
1237
+ hash.update(chunk);
1238
+ if (held) this.push(held);
1239
+ held = chunk;
1240
+ callback();
1241
+ },
1242
+ flush(callback) {
1243
+ if (hash.digest("hex") !== expectedSha256) {
1244
+ callback(new IntegrityCheckError("Downloaded content does not match its checksum"));
1245
+ return;
1246
+ }
1247
+ if (held) this.push(held);
1248
+ callback();
1249
+ }
1250
+ });
1251
+ source.once("error", (error) => gate.destroy(error));
1252
+ source.pipe(gate);
1253
+ return gate;
1066
1254
  }
1067
1255
  async remove(ref) {
1068
1256
  await this.withRetry(
@@ -1078,6 +1266,14 @@ var StorageEngine = class {
1078
1266
  throw mapProviderError("Storage lookup failed", error);
1079
1267
  }
1080
1268
  }
1269
+ /** Read-only listing of stored objects, for reconciliation. Never modifies anything. */
1270
+ async list(limit) {
1271
+ try {
1272
+ return await this.provider.listObjects(limit);
1273
+ } catch (error) {
1274
+ throw mapProviderError("Storage listing failed", error);
1275
+ }
1276
+ }
1081
1277
  async stat(ref) {
1082
1278
  try {
1083
1279
  return await this.provider.stat(ref);
@@ -1101,18 +1297,6 @@ var StorageEngine = class {
1101
1297
  return sha256Hex(data) === expectedSha256;
1102
1298
  }
1103
1299
  // ── internals ────────────────────────────────────────────────────────────
1104
- /** Materializes an upload input so the checksum can be computed. */
1105
- async readInput(input) {
1106
- if (input.data) return input.data;
1107
- if (!input.stream) {
1108
- throw new UploadFailedError("Upload input has neither data nor a stream");
1109
- }
1110
- const chunks = [];
1111
- for await (const chunk of input.stream) {
1112
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1113
- }
1114
- return Buffer.concat(chunks);
1115
- }
1116
1300
  async withRetry(operation, control, wrapError) {
1117
1301
  const { attempts, baseDelayMs } = this.retryPolicy;
1118
1302
  let lastError;
@@ -1122,8 +1306,8 @@ var StorageEngine = class {
1122
1306
  return await operation();
1123
1307
  } catch (error) {
1124
1308
  lastError = error;
1125
- if (attempt >= attempts || !isRetryable(error)) break;
1126
- await delay(baseDelayMs * 2 ** (attempt - 1), control.signal);
1309
+ if (attempt >= attempts || !isRetryableError(error)) break;
1310
+ await delay(retryDelayMs(error, attempt, baseDelayMs), control.signal);
1127
1311
  }
1128
1312
  }
1129
1313
  throw wrapError(lastError);
@@ -1170,6 +1354,51 @@ async function resolveItemQuery(folders, userId, input) {
1170
1354
  return query;
1171
1355
  }
1172
1356
 
1357
+ // ../core/src/services/auth-session-service.ts
1358
+ var import_node_crypto3 = require("crypto");
1359
+
1360
+ // ../core/src/services/upload-operation.ts
1361
+ function isValidOperationId(id) {
1362
+ return /^[A-Za-z0-9_-]{8,64}$/.test(id);
1363
+ }
1364
+ var OperationAlreadyExistsError = class extends Error {
1365
+ constructor() {
1366
+ super("An upload with this operation id already exists");
1367
+ this.name = "OperationAlreadyExistsError";
1368
+ }
1369
+ };
1370
+
1371
+ // ../core/src/utils/operation-log.ts
1372
+ async function timedOperation(sink, base, run, now = Date.now) {
1373
+ const started = now();
1374
+ const emit = (status, errorCode) => {
1375
+ if (!sink) return;
1376
+ const record = {
1377
+ operation: base.operation,
1378
+ userId: base.userId,
1379
+ resourceId: base.resourceId ?? null,
1380
+ sizeBytes: base.sizeBytes ?? null,
1381
+ durationMs: now() - started,
1382
+ status,
1383
+ errorCode
1384
+ };
1385
+ try {
1386
+ if (status === "ok") sink.info({ ...record }, `${base.operation}.completed`);
1387
+ else sink.warn({ ...record }, `${base.operation}.failed`);
1388
+ } catch {
1389
+ }
1390
+ };
1391
+ try {
1392
+ const result = await run();
1393
+ emit("ok", null);
1394
+ return result;
1395
+ } catch (error) {
1396
+ const code = error.code ?? "INTERNAL_ERROR";
1397
+ emit("error", code);
1398
+ throw error;
1399
+ }
1400
+ }
1401
+
1173
1402
  // ../core/src/services/activity-service.ts
1174
1403
  var ActivityService = class {
1175
1404
  constructor(activity, retentionDays = 90) {
@@ -1233,18 +1462,120 @@ function sanitizeFileName(name) {
1233
1462
 
1234
1463
  // ../core/src/services/file-service.ts
1235
1464
  var FileService = class {
1236
- constructor(files, folders, engineFor, activity = noopActivityRecorder) {
1465
+ constructor(files, folders, engineFor, activity = noopActivityRecorder, operations = null, log = null) {
1237
1466
  this.files = files;
1238
1467
  this.folders = folders;
1239
1468
  this.engineFor = engineFor;
1240
1469
  this.activity = activity;
1470
+ this.operations = operations;
1471
+ this.log = log;
1241
1472
  }
1242
1473
  files;
1243
1474
  folders;
1244
1475
  engineFor;
1245
1476
  activity;
1477
+ operations;
1478
+ log;
1246
1479
  // ── Upload ───────────────────────────────────────────────────────────────
1247
1480
  async upload(userId, input) {
1481
+ return timedOperation(
1482
+ this.log,
1483
+ {
1484
+ operation: "upload",
1485
+ userId,
1486
+ resourceId: input.replaceFileId ?? null,
1487
+ sizeBytes: input.spooled?.size ?? input.data?.byteLength ?? null
1488
+ },
1489
+ () => input.operationId && this.operations ? this.uploadOnce(userId, input, input.operationId, this.operations) : this.performUpload(userId, input)
1490
+ );
1491
+ }
1492
+ /**
1493
+ * Idempotent upload keyed by a client-supplied operation id. A retry never
1494
+ * writes a second Telegram object: a completed operation returns its file, and
1495
+ * an operation whose object was already stored is committed without uploading.
1496
+ */
1497
+ async uploadOnce(userId, input, operationId, operations) {
1498
+ if (!isValidOperationId(operationId)) {
1499
+ throw new ValidationError("Invalid upload operation id");
1500
+ }
1501
+ let op = await operations.findByOperationId(userId, operationId);
1502
+ if (op?.status === "COMPLETED" && op.fileId) {
1503
+ return this.get(userId, op.fileId);
1504
+ }
1505
+ if (op?.status === "UPLOADING" && op.telegramMessageId !== null) {
1506
+ return this.commitStoredObject(userId, input, operations, op);
1507
+ }
1508
+ if (!op) {
1509
+ try {
1510
+ op = await operations.create({ userId, operationId });
1511
+ } catch (error) {
1512
+ if (!(error instanceof OperationAlreadyExistsError)) throw error;
1513
+ const outcome = await this.followWinner(userId, input, operationId, operations);
1514
+ if (outcome) return outcome;
1515
+ throw new ConflictError("A concurrent upload with this operation id is still in progress");
1516
+ }
1517
+ }
1518
+ const claimed = await operations.claim(op.id, "PENDING", "UPLOADING") || await operations.claim(op.id, "FAILED", "UPLOADING");
1519
+ if (!claimed) {
1520
+ const outcome = await this.followWinner(userId, input, operationId, operations);
1521
+ if (outcome) return outcome;
1522
+ throw new ConflictError("A concurrent upload with this operation id is still in progress");
1523
+ }
1524
+ try {
1525
+ const record = await this.performUpload(userId, input, {
1526
+ onStored: async (stored) => {
1527
+ await operations.update(op.id, { telegramMessageId: Number(stored.messageId) });
1528
+ }
1529
+ });
1530
+ await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
1531
+ return record;
1532
+ } catch (error) {
1533
+ await operations.update(op.id, {
1534
+ status: "FAILED",
1535
+ error: error instanceof Error ? error.message.slice(0, 200) : "upload failed"
1536
+ });
1537
+ throw error;
1538
+ }
1539
+ }
1540
+ /**
1541
+ * Follows an operation another request is driving, for a bounded time. Returns
1542
+ * the finished file if the winner completes, or the stored object's commit if
1543
+ * it has stored one. Returns null when the winner is still in progress after the
1544
+ * budget, so the caller can report a conflict instead of waiting forever.
1545
+ */
1546
+ async followWinner(userId, input, operationId, operations) {
1547
+ for (let attempt = 0; attempt < 50; attempt += 1) {
1548
+ const current = await operations.findByOperationId(userId, operationId);
1549
+ if (current?.status === "COMPLETED" && current.fileId) {
1550
+ return this.get(userId, current.fileId);
1551
+ }
1552
+ if (current?.status === "FAILED") {
1553
+ return null;
1554
+ }
1555
+ if (current?.status === "UPLOADING" && current.telegramMessageId !== null) {
1556
+ return this.commitStoredObject(userId, input, operations, current);
1557
+ }
1558
+ await new Promise((resolve) => setTimeout(resolve, 10));
1559
+ }
1560
+ return null;
1561
+ }
1562
+ /** Commits metadata for an object that is already in Telegram. */
1563
+ async commitStoredObject(userId, input, operations, op) {
1564
+ const name = sanitizeFileName(input.name);
1565
+ const mimeType = mimeFromFilename(name);
1566
+ const size = op.size ?? input.spooled?.size ?? input.data?.byteLength ?? 0;
1567
+ const sha256 = op.sha256 ?? input.spooled?.sha256 ?? sha256Hex(input.data ?? Buffer.alloc(0));
1568
+ const record = await this.commitRecord(userId, input, {
1569
+ name,
1570
+ mimeType,
1571
+ size,
1572
+ sha256,
1573
+ telegramMessageId: op.telegramMessageId
1574
+ });
1575
+ await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
1576
+ return record;
1577
+ }
1578
+ async performUpload(userId, input, hooks = {}) {
1248
1579
  const name = sanitizeFileName(input.name);
1249
1580
  if (!name) throw new ValidationError("Invalid file name");
1250
1581
  let folderId = input.folderId;
@@ -1260,11 +1591,36 @@ var FileService = class {
1260
1591
  onProgress: input.onProgress,
1261
1592
  signal: input.signal
1262
1593
  };
1594
+ if (!input.data && !input.spooled) {
1595
+ throw new ValidationError("An upload needs content");
1596
+ }
1263
1597
  const { stored, sha256, size } = await engine.upload(
1264
- { name, mimeType, data: input.data },
1598
+ input.spooled ? {
1599
+ name,
1600
+ mimeType,
1601
+ path: input.spooled.path,
1602
+ size: input.spooled.size,
1603
+ sha256: input.spooled.sha256
1604
+ } : { name, mimeType, data: input.data },
1265
1605
  control
1266
1606
  );
1267
- const telegramMessageId = Number(stored.messageId);
1607
+ await hooks.onStored?.(stored);
1608
+ try {
1609
+ return await this.commitRecord(userId, input, {
1610
+ name,
1611
+ mimeType,
1612
+ size,
1613
+ sha256,
1614
+ telegramMessageId: Number(stored.messageId)
1615
+ });
1616
+ } catch (error) {
1617
+ await engine.remove({ messageId: String(stored.messageId) }).catch(() => void 0);
1618
+ throw error;
1619
+ }
1620
+ }
1621
+ /** Persists the file (or the new version of a file) for an object already stored. */
1622
+ async commitRecord(userId, input, next) {
1623
+ const { name, mimeType, size, sha256, telegramMessageId } = next;
1268
1624
  if (input.replaceFileId) {
1269
1625
  return this.replaceVersion(userId, input.replaceFileId, {
1270
1626
  name,
@@ -1274,6 +1630,10 @@ var FileService = class {
1274
1630
  telegramMessageId
1275
1631
  });
1276
1632
  }
1633
+ const folderId = input.folderId;
1634
+ if (folderId !== null) {
1635
+ await this.assertFolder(userId, folderId);
1636
+ }
1277
1637
  const record = await this.files.create({
1278
1638
  userId,
1279
1639
  folderId,
@@ -1447,16 +1807,20 @@ var FileService = class {
1447
1807
  * retry, rather than silently orphaning the storage object.
1448
1808
  */
1449
1809
  async deletePermanently(userId, id) {
1810
+ return timedOperation(
1811
+ this.log,
1812
+ { operation: "delete", userId, resourceId: id },
1813
+ () => this.performPermanentDelete(userId, id)
1814
+ );
1815
+ }
1816
+ async performPermanentDelete(userId, id) {
1450
1817
  const record = await this.get(userId, id);
1451
1818
  const engine = await this.engineFor(userId);
1452
- await engine.remove({ messageId: String(record.telegramMessageId) });
1453
1819
  const versions = await this.files.listVersions(id);
1454
- for (const version of versions) {
1455
- if (version.telegramMessageId === record.telegramMessageId) continue;
1456
- try {
1457
- await engine.remove({ messageId: String(version.telegramMessageId) });
1458
- } catch {
1459
- }
1820
+ const messageIds = /* @__PURE__ */ new Set([String(record.telegramMessageId)]);
1821
+ for (const version of versions) messageIds.add(String(version.telegramMessageId));
1822
+ for (const messageId of messageIds) {
1823
+ await engine.remove({ messageId });
1460
1824
  }
1461
1825
  await this.files.deleteVersionsByFileIds([id]);
1462
1826
  await this.files.deleteMany([id]);
@@ -1491,6 +1855,36 @@ var FileService = class {
1491
1855
  });
1492
1856
  return { record, data, integrityVerified };
1493
1857
  }
1858
+ /**
1859
+ * Streaming download for large files. The caller must consume `stream` to
1860
+ * the end and then await `verified` to learn whether the bytes match the
1861
+ * stored checksum. The download activity is recorded when the stream opens.
1862
+ */
1863
+ async downloadStreamed(userId, id, control = {}) {
1864
+ return timedOperation(
1865
+ this.log,
1866
+ { operation: "download", userId, resourceId: id },
1867
+ () => this.performStreamedDownload(userId, id, control)
1868
+ );
1869
+ }
1870
+ async performStreamedDownload(userId, id, control) {
1871
+ const record = await this.getActive(userId, id);
1872
+ const engine = await this.engineFor(userId);
1873
+ const stream = await engine.downloadStream(
1874
+ { messageId: String(record.telegramMessageId) },
1875
+ record.sha256,
1876
+ control
1877
+ );
1878
+ await this.activity.record({
1879
+ userId,
1880
+ action: "download",
1881
+ resourceType: "file",
1882
+ resourceId: id,
1883
+ resourceName: record.name,
1884
+ metadata: { size: record.size, streamed: true }
1885
+ });
1886
+ return { record, stream };
1887
+ }
1494
1888
  /** Records an "open" event (the UI opening the details panel, say). */
1495
1889
  async touch(userId, id, action = "open") {
1496
1890
  const record = await this.get(userId, id);
@@ -1542,7 +1936,13 @@ var FileService = class {
1542
1936
  errors.push({ id, message: error instanceof Error ? error.message : "Operation failed" });
1543
1937
  }
1544
1938
  }
1545
- return { requested: ids.length, succeeded, failed: errors.length, errors };
1939
+ return {
1940
+ ...options.operationId ? { operationId: options.operationId } : {},
1941
+ requested: ids.length,
1942
+ succeeded,
1943
+ failed: errors.length,
1944
+ errors
1945
+ };
1546
1946
  }
1547
1947
  // ── internals ────────────────────────────────────────────────────────────
1548
1948
  async assertFolder(userId, folderId) {
@@ -1636,7 +2036,7 @@ var FolderService = class {
1636
2036
  cursor = parent.parentId;
1637
2037
  }
1638
2038
  }
1639
- const updated = await this.folders.update(id, { parentId: newParentId });
2039
+ const updated = await this.folders.moveSafely(id, newParentId);
1640
2040
  await this.activity.record({
1641
2041
  userId,
1642
2042
  action: "move",
@@ -1697,7 +2097,7 @@ var FolderService = class {
1697
2097
  let parentId = record.parentId;
1698
2098
  if (parentId !== null) {
1699
2099
  const parent = await this.folders.findById(parentId);
1700
- if (!parent || parent.deletedAt) parentId = null;
2100
+ if (!parent || parent.userId !== userId || parent.deletedAt) parentId = null;
1701
2101
  }
1702
2102
  const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
1703
2103
  const batchId = record.trashBatchId;
@@ -1994,10 +2394,28 @@ var IntegrityService = class {
1994
2394
  const engine = await this.engineFor(userId);
1995
2395
  const issues = [];
1996
2396
  let healthy = 0;
2397
+ const versionsByFile = await this.files.listVersionsForFiles(targets.map((file) => file.id));
1997
2398
  for (const file of targets) {
1998
2399
  const issue = await this.inspect(file, options.deep ?? false, engine);
1999
2400
  if (issue) issues.push(issue);
2000
2401
  else healthy += 1;
2402
+ for (const version of versionsByFile.get(file.id) ?? []) {
2403
+ if (version.telegramMessageId === file.telegramMessageId) continue;
2404
+ const versionIssue = await this.inspectObject(
2405
+ {
2406
+ id: file.id,
2407
+ name: file.name,
2408
+ telegramMessageId: version.telegramMessageId,
2409
+ size: version.size,
2410
+ sha256: version.sha256
2411
+ },
2412
+ options.deep ?? false,
2413
+ engine,
2414
+ version.id
2415
+ );
2416
+ if (versionIssue) issues.push(versionIssue);
2417
+ else healthy += 1;
2418
+ }
2001
2419
  }
2002
2420
  const missing = issues.filter((issue) => issue.kind === "missing").length;
2003
2421
  const inconsistent = issues.filter(
@@ -2016,53 +2434,69 @@ var IntegrityService = class {
2016
2434
  };
2017
2435
  }
2018
2436
  async inspect(file, deep, engine) {
2019
- const ref = { messageId: String(file.telegramMessageId) };
2437
+ return this.inspectObject(
2438
+ {
2439
+ id: file.id,
2440
+ name: file.name,
2441
+ telegramMessageId: file.telegramMessageId,
2442
+ size: file.size,
2443
+ sha256: file.sha256
2444
+ },
2445
+ deep,
2446
+ engine,
2447
+ void 0
2448
+ );
2449
+ }
2450
+ async inspectObject(target, deep, engine, versionId) {
2451
+ const ref = { messageId: String(target.telegramMessageId) };
2452
+ const withVersion = (issue) => versionId ? { ...issue, versionId } : issue;
2020
2453
  let stat;
2021
2454
  try {
2022
2455
  stat = await engine.stat(ref);
2023
2456
  } catch (error) {
2024
- return {
2025
- fileId: file.id,
2026
- name: file.name,
2457
+ return withVersion({
2458
+ fileId: target.id,
2459
+ name: target.name,
2027
2460
  kind: "unreadable",
2028
2461
  detail: error instanceof Error ? error.message : "Storage lookup failed"
2029
- };
2462
+ });
2030
2463
  }
2031
2464
  if (!stat) {
2032
- return {
2033
- fileId: file.id,
2034
- name: file.name,
2465
+ return withVersion({
2466
+ fileId: target.id,
2467
+ name: target.name,
2035
2468
  kind: "missing",
2036
2469
  detail: "No message with this id exists in the storage channel"
2037
- };
2470
+ });
2038
2471
  }
2039
- if (stat.size !== file.size) {
2040
- return {
2041
- fileId: file.id,
2042
- name: file.name,
2472
+ if (stat.size !== target.size) {
2473
+ return withVersion({
2474
+ fileId: target.id,
2475
+ name: target.name,
2043
2476
  kind: "size_mismatch",
2044
- detail: `metadata=${file.size} storage=${stat.size}`
2045
- };
2477
+ detail: `metadata=${target.size} storage=${stat.size}`
2478
+ });
2046
2479
  }
2047
2480
  if (deep) {
2048
2481
  try {
2049
- const data = await engine.download(ref);
2050
- const ok = await engine.verifyIntegrity(data, file.sha256);
2051
- if (!ok) {
2052
- return {
2053
- fileId: file.id,
2054
- name: file.name,
2482
+ const stream = await engine.downloadStream(ref, target.sha256);
2483
+ for await (const _chunk of stream) {
2484
+ }
2485
+ } catch (error) {
2486
+ if (error instanceof IntegrityCheckError) {
2487
+ return withVersion({
2488
+ fileId: target.id,
2489
+ name: target.name,
2055
2490
  kind: "hash_mismatch",
2056
2491
  detail: "Downloaded bytes do not match the stored SHA-256"
2057
- };
2492
+ });
2058
2493
  }
2059
- } catch (error) {
2060
- return {
2061
- fileId: file.id,
2062
- name: file.name,
2494
+ return withVersion({
2495
+ fileId: target.id,
2496
+ name: target.name,
2063
2497
  kind: "unreadable",
2064
2498
  detail: error instanceof Error ? error.message : "Download failed"
2065
- };
2499
+ });
2066
2500
  }
2067
2501
  }
2068
2502
  return null;
@@ -2129,14 +2563,26 @@ var RecentService = class {
2129
2563
  }
2130
2564
  };
2131
2565
 
2566
+ // ../core/src/utils/spool.ts
2567
+ var import_node_crypto4 = require("crypto");
2568
+ var import_node_fs = require("fs");
2569
+ var import_promises = require("fs/promises");
2570
+ var import_node_os = require("os");
2571
+ var import_node_path = require("path");
2572
+ var import_node_stream2 = require("stream");
2573
+ var import_promises2 = require("stream/promises");
2574
+
2575
+ // ../core/src/utils/secret-box.ts
2576
+ var import_node_crypto5 = require("crypto");
2577
+
2132
2578
  // ../telegram/src/client.ts
2133
2579
  var import_telegram = require("telegram");
2134
2580
  var import_sessions = require("telegram/sessions/index.js");
2135
2581
 
2136
2582
  // ../telegram/src/errors.ts
2137
- var import_errors6 = require("telegram/errors/index.js");
2583
+ var import_errors12 = require("telegram/errors/index.js");
2138
2584
  function isTelegramRpcError(error) {
2139
- return error instanceof import_errors6.RPCError;
2585
+ return error instanceof import_errors12.RPCError;
2140
2586
  }
2141
2587
  function isPasswordRequiredError(error) {
2142
2588
  return isTelegramRpcError(error) && error.errorMessage === "SESSION_PASSWORD_NEEDED";
@@ -2147,7 +2593,9 @@ function mapTelegramError(error, fallbackMessage) {
2147
2593
  const msg = error.errorMessage ?? "";
2148
2594
  if (msg.startsWith("FLOOD_WAIT_")) {
2149
2595
  const seconds = Number(msg.slice("FLOOD_WAIT_".length)) || 60;
2150
- return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds`);
2596
+ return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds`, {
2597
+ retryAfterSeconds: seconds
2598
+ });
2151
2599
  }
2152
2600
  switch (msg) {
2153
2601
  case "PHONE_NUMBER_INVALID":
@@ -2430,11 +2878,12 @@ function extractCreatedChannel(result) {
2430
2878
  }
2431
2879
 
2432
2880
  // ../telegram/src/provider.ts
2433
- var import_node_stream2 = require("stream");
2881
+ var import_node_stream3 = require("stream");
2434
2882
  var import_telegram2 = require("telegram");
2435
2883
  var import_big_integer = __toESM(require("big-integer"), 1);
2436
2884
  var import_uploads = require("telegram/client/uploads.js");
2437
2885
  var MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 * 1024;
2886
+ var DOWNLOAD_CHUNK_BYTES = 512 * 1024;
2438
2887
  var TelegramStorageProvider = class {
2439
2888
  constructor(client, options) {
2440
2889
  this.client = client;
@@ -2450,17 +2899,19 @@ var TelegramStorageProvider = class {
2450
2899
  title;
2451
2900
  // ── Upload ───────────────────────────────────────────────────────────────
2452
2901
  async put(input, control = {}) {
2453
- const data = await this.materialize(input);
2454
- if (data.byteLength > MAX_DOCUMENT_BYTES) {
2902
+ const total = input.data ? input.data.byteLength : input.size;
2903
+ if (total === void 0 || !input.data && !input.path) {
2904
+ throw new UploadFailedError("Upload input has neither data nor a spooled file");
2905
+ }
2906
+ if (total > MAX_DOCUMENT_BYTES) {
2455
2907
  throw new UploadFailedError(
2456
2908
  `Telegram does not accept documents larger than ${MAX_DOCUMENT_BYTES / (1024 * 1024 * 1024)} GB`
2457
2909
  );
2458
2910
  }
2459
2911
  this.throwIfAborted(control.signal);
2460
- const total = data.byteLength;
2461
2912
  control.onProgress?.({ transferred: 0, total, percent: 0 });
2462
2913
  try {
2463
- const file = new import_uploads.CustomFile(input.name, total, "", data);
2914
+ const file = input.data ? new import_uploads.CustomFile(input.name, total, "", input.data) : new import_uploads.CustomFile(input.name, total, input.path);
2464
2915
  const message = await this.client.sendFile(this.peer, {
2465
2916
  file,
2466
2917
  forceDocument: true,
@@ -2531,9 +2982,47 @@ var TelegramStorageProvider = class {
2531
2982
  });
2532
2983
  return buffer;
2533
2984
  }
2985
+ /**
2986
+ * Streams the object in Telegram-sized chunks, so memory use stays bounded
2987
+ * by one chunk regardless of file size. The first request happens here so
2988
+ * that a missing message fails before the caller starts consuming bytes.
2989
+ */
2534
2990
  async getStream(ref, control = {}) {
2535
- const data = await this.get(ref, control);
2536
- return import_node_stream2.Readable.from([data]);
2991
+ const message = await this.requireMessage(ref);
2992
+ this.throwIfAborted(control.signal);
2993
+ const size = this.documentSize(message);
2994
+ const media = message.media;
2995
+ if (size === null || !media) {
2996
+ throw new TelegramFileNotFoundError("Telegram did not return file content");
2997
+ }
2998
+ const iterator = this.client.iterDownload({
2999
+ file: media,
3000
+ fileSize: (0, import_big_integer.default)(size),
3001
+ requestSize: DOWNLOAD_CHUNK_BYTES
3002
+ });
3003
+ let transferred = 0;
3004
+ control.onProgress?.({ transferred: 0, total: size, percent: 0 });
3005
+ const signal = control.signal;
3006
+ return import_node_stream3.Readable.from(
3007
+ (async function* () {
3008
+ try {
3009
+ for await (const chunk of iterator) {
3010
+ if (signal?.aborted) throw new OperationCancelledError();
3011
+ const buffer = Buffer.from(chunk);
3012
+ transferred += buffer.byteLength;
3013
+ control.onProgress?.({
3014
+ transferred,
3015
+ total: size,
3016
+ percent: size > 0 ? Math.round(transferred / size * 100) : null
3017
+ });
3018
+ yield buffer;
3019
+ }
3020
+ } catch (error) {
3021
+ if (error instanceof OperationCancelledError) throw error;
3022
+ throw mapTelegramError(error, "Telegram download failed");
3023
+ }
3024
+ })()
3025
+ );
2537
3026
  }
2538
3027
  // ── Lifecycle ────────────────────────────────────────────────────────────
2539
3028
  async delete(ref) {
@@ -2550,6 +3039,38 @@ var TelegramStorageProvider = class {
2550
3039
  throw mapTelegramError(error, "Telegram lookup failed");
2551
3040
  }
2552
3041
  }
3042
+ /**
3043
+ * Read-only listing of stored documents in the channel, newest first, up to
3044
+ * `limit`. Only messages that carry a document are returned; text messages and
3045
+ * service messages are skipped. Nothing is modified.
3046
+ */
3047
+ async listObjects(limit) {
3048
+ const objects = [];
3049
+ try {
3050
+ for await (const message of this.client.iterMessages(this.peer, { limit: limit * 2 })) {
3051
+ if (objects.length >= limit) break;
3052
+ if (!(message instanceof import_telegram2.Api.Message)) continue;
3053
+ const document = message.document;
3054
+ if (!(document instanceof import_telegram2.Api.Document)) continue;
3055
+ let fileName;
3056
+ for (const attr of document.attributes) {
3057
+ if (attr instanceof import_telegram2.Api.DocumentAttributeFilename) {
3058
+ fileName = attr.fileName;
3059
+ break;
3060
+ }
3061
+ }
3062
+ objects.push({
3063
+ messageId: message.id.toString(),
3064
+ name: fileName ?? "file",
3065
+ size: Number(document.size.toString()),
3066
+ mimeType: document.mimeType
3067
+ });
3068
+ }
3069
+ } catch (error) {
3070
+ throw mapTelegramError(error, "Telegram listing failed");
3071
+ }
3072
+ return objects;
3073
+ }
2553
3074
  async stat(ref) {
2554
3075
  const message = await this.getMessage(ref.messageId);
2555
3076
  if (!message?.media) return null;
@@ -2612,17 +3133,6 @@ var TelegramStorageProvider = class {
2612
3133
  throw new TelegramConnectionError("Telegram client is not connected");
2613
3134
  }
2614
3135
  }
2615
- async materialize(input) {
2616
- if (input.data) return input.data;
2617
- if (!input.stream) {
2618
- throw new UploadFailedError("Upload input has neither data nor a stream");
2619
- }
2620
- const chunks = [];
2621
- for await (const chunk of input.stream) {
2622
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2623
- }
2624
- return Buffer.concat(chunks);
2625
- }
2626
3136
  documentSize(message) {
2627
3137
  const document = message.document;
2628
3138
  if (document instanceof import_telegram2.Api.Document) return Number(document.size.toString());