@lacrous/omnicloud 0.2.1 → 0.2.2

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.js CHANGED
@@ -130,6 +130,65 @@ var OmniCloudClient = class {
130
130
  throw lastError;
131
131
  }
132
132
  /** @internal */
133
+ /**
134
+ * Opens a binary response as a stream. Bytes are delivered as they arrive, so
135
+ * memory does not grow with the file. The stream errors with DOWNLOAD_INCOMPLETE
136
+ * if the body ends before Content-Length.
137
+ */
138
+ async requestStream(path, onProgress) {
139
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
140
+ headers: this.defaultHeaders,
141
+ credentials: "include"
142
+ });
143
+ if (!response.ok) {
144
+ const text = await response.text();
145
+ const error = safeJsonParse(text)?.error;
146
+ throw new OmniCloudError(
147
+ response.status,
148
+ error?.code ?? "DOWNLOAD_FAILED",
149
+ error?.message ?? `Download failed (${response.status})`
150
+ );
151
+ }
152
+ const total = Number(response.headers.get("content-length") ?? 0) || null;
153
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
154
+ const sha256 = response.headers.get("x-content-sha256");
155
+ const source = response.body;
156
+ if (!source) {
157
+ throw new OmniCloudError(0, "DOWNLOAD_FAILED", "The response has no body to stream");
158
+ }
159
+ let loaded = 0;
160
+ const reader = source.getReader();
161
+ const stream = new ReadableStream({
162
+ async pull(controller) {
163
+ const { done, value } = await reader.read();
164
+ if (done) {
165
+ if (total !== null && loaded !== total) {
166
+ controller.error(
167
+ new OmniCloudError(
168
+ 0,
169
+ "DOWNLOAD_INCOMPLETE",
170
+ `Download ended after ${loaded} of ${total} bytes`
171
+ )
172
+ );
173
+ return;
174
+ }
175
+ controller.close();
176
+ return;
177
+ }
178
+ loaded += value.byteLength;
179
+ onProgress?.({
180
+ loaded,
181
+ total,
182
+ percent: total ? Math.round(loaded / total * 100) : null
183
+ });
184
+ controller.enqueue(value);
185
+ },
186
+ cancel(reason) {
187
+ return reader.cancel(reason);
188
+ }
189
+ });
190
+ return { stream, contentType, size: total, sha256 };
191
+ }
133
192
  async requestBinary(path, onProgress) {
134
193
  const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
135
194
  headers: this.defaultHeaders,
@@ -153,6 +212,28 @@ var OmniCloudClient = class {
153
212
  return { data, contentType, size: data.byteLength, sha256, integrityVerified };
154
213
  }
155
214
  };
215
+ async function toBlob(input) {
216
+ if (input.path !== void 0) {
217
+ const { readFile } = await import("fs/promises");
218
+ return new Blob([new Uint8Array(await readFile(input.path))]);
219
+ }
220
+ const data = input.data;
221
+ if (data === void 0) throw new OmniCloudError(0, "INVALID_REQUEST", "No upload content given");
222
+ if (typeof Blob !== "undefined" && data instanceof Blob) return data;
223
+ if (data instanceof Uint8Array) return new Blob([new Uint8Array(data)]);
224
+ const parts = [];
225
+ if (typeof data.getReader === "function") {
226
+ const reader = data.getReader();
227
+ for (; ; ) {
228
+ const { done, value } = await reader.read();
229
+ if (done) break;
230
+ parts.push(value);
231
+ }
232
+ } else {
233
+ for await (const chunk of data) parts.push(chunk);
234
+ }
235
+ return new Blob(parts);
236
+ }
156
237
  async function readBody(response, total, onProgress) {
157
238
  const body = response.body;
158
239
  if (!body) {
@@ -176,6 +257,13 @@ async function readBody(response, total, onProgress) {
176
257
  });
177
258
  }
178
259
  }
260
+ if (total !== null && loaded !== total) {
261
+ throw new OmniCloudError(
262
+ 0,
263
+ "DOWNLOAD_INCOMPLETE",
264
+ `Download ended after ${loaded} of ${total} bytes`
265
+ );
266
+ }
179
267
  const merged = new Uint8Array(loaded);
180
268
  let offset = 0;
181
269
  for (const chunk of chunks) {
@@ -225,9 +313,9 @@ var FilesApi = class {
225
313
  }
226
314
  throw lastError;
227
315
  }
228
- uploadOnce(input, options) {
316
+ async uploadOnce(input, options) {
229
317
  const form = new FormData();
230
- const blob = typeof Blob !== "undefined" && input.data instanceof Blob ? input.data : new Blob([new Uint8Array(input.data)]);
318
+ const blob = await toBlob(input);
231
319
  form.append("file", blob, input.name);
232
320
  if (options.folderId) form.append("folderId", options.folderId);
233
321
  if (typeof XMLHttpRequest === "function") {
@@ -287,6 +375,45 @@ var FilesApi = class {
287
375
  async download(id, onProgress) {
288
376
  return this.client.requestBinary(`/api/files/${id}/download`, onProgress);
289
377
  }
378
+ /**
379
+ * Streams a file's bytes without holding the whole file in memory. Use this for
380
+ * large files. Read `stream` to the end: a truncated transfer errors instead of
381
+ * ending quietly.
382
+ */
383
+ async downloadStream(id, onProgress) {
384
+ return this.client.requestStream(`/api/files/${id}/download`, onProgress);
385
+ }
386
+ /**
387
+ * Node only. Streams a file to `targetPath` through a `.part` file that is
388
+ * renamed into place only after the full length is verified. A truncated or
389
+ * failed transfer leaves nothing at `targetPath`.
390
+ */
391
+ async downloadToFile(id, targetPath, onProgress) {
392
+ const { createWriteStream: createWriteStream2 } = await import("fs");
393
+ const { rename, rm: rm2 } = await import("fs/promises");
394
+ const { Readable: Readable3 } = await import("stream");
395
+ const { pipeline: pipeline2 } = await import("stream/promises");
396
+ const download = await this.downloadStream(id, onProgress);
397
+ const partPath = `${targetPath}.part`;
398
+ let bytes = 0;
399
+ try {
400
+ await pipeline2(
401
+ Readable3.fromWeb(download.stream),
402
+ async function* (source) {
403
+ for await (const chunk of source) {
404
+ bytes += chunk.byteLength;
405
+ yield chunk;
406
+ }
407
+ },
408
+ createWriteStream2(partPath)
409
+ );
410
+ await rename(partPath, targetPath);
411
+ } catch (error) {
412
+ await rm2(partPath, { force: true });
413
+ throw error;
414
+ }
415
+ return { path: targetPath, bytes, sha256: download.sha256 };
416
+ }
290
417
  /** URL suitable for direct download (anchor href). */
291
418
  downloadUrl(id) {
292
419
  return `${this.client.baseUrl}/api/files/${id}/download`;
@@ -841,12 +968,18 @@ var DomainError = class extends Error {
841
968
  code;
842
969
  status;
843
970
  details;
844
- constructor(code, status, message, details) {
971
+ /** Whether repeating the same operation may succeed. Defaults to false. */
972
+ retryable = false;
973
+ /** Server-requested delay before a retry, in seconds, when the provider gave one. */
974
+ retryAfterSeconds;
975
+ constructor(code, status, message, details, retry) {
845
976
  super(message);
846
977
  this.name = new.target.name;
847
978
  this.code = code;
848
979
  this.status = status;
849
980
  this.details = details;
981
+ this.retryable = retry?.retryable ?? false;
982
+ this.retryAfterSeconds = retry?.retryAfterSeconds;
850
983
  }
851
984
  };
852
985
  var ValidationError = class extends DomainError {
@@ -879,8 +1012,11 @@ var ConflictError = class extends DomainError {
879
1012
  }
880
1013
  };
881
1014
  var RateLimitedError = class extends DomainError {
882
- constructor(message = "Too many requests, please retry later") {
883
- super(ERROR_CODES.RATE_LIMITED, 429, message);
1015
+ constructor(message = "Too many requests, please retry later", options = {}) {
1016
+ super(ERROR_CODES.RATE_LIMITED, 429, message, void 0, {
1017
+ retryable: true,
1018
+ retryAfterSeconds: options.retryAfterSeconds
1019
+ });
884
1020
  }
885
1021
  };
886
1022
  var StorageNotInitializedError = class extends DomainError {
@@ -895,7 +1031,7 @@ var TelegramAuthRequiredError = class extends DomainError {
895
1031
  };
896
1032
  var TelegramConnectionError = class extends DomainError {
897
1033
  constructor(message = "Could not reach Telegram", details) {
898
- super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details);
1034
+ super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details, { retryable: true });
899
1035
  }
900
1036
  };
901
1037
  var TelegramFileNotFoundError = class extends DomainError {
@@ -908,6 +1044,11 @@ var UploadFailedError = class extends DomainError {
908
1044
  super(ERROR_CODES.UPLOAD_FAILED, 502, message, details);
909
1045
  }
910
1046
  };
1047
+ var IntegrityCheckError = class extends DomainError {
1048
+ constructor(message = "Integrity check failed", details) {
1049
+ super(ERROR_CODES.INTEGRITY_CHECK_FAILED, 502, message, details);
1050
+ }
1051
+ };
911
1052
  var OperationCancelledError = class extends DomainError {
912
1053
  constructor(message = "Operation cancelled") {
913
1054
  super(ERROR_CODES.INVALID_REQUEST, 499, message);
@@ -920,7 +1061,22 @@ function mapProviderError(message, error) {
920
1061
  }
921
1062
 
922
1063
  // ../core/src/storage/engine.ts
923
- import { Readable } from "stream";
1064
+ import { createHash as createHash2 } from "crypto";
1065
+ import { Readable, Transform } from "stream";
1066
+
1067
+ // ../core/src/storage/retry.ts
1068
+ function isRetryableError(error) {
1069
+ if (error instanceof OperationCancelledError) return false;
1070
+ if (error instanceof DomainError) return error.retryable;
1071
+ return false;
1072
+ }
1073
+ function retryDelayMs(error, attempt, baseDelayMs, random = Math.random) {
1074
+ if (error instanceof DomainError && error.retryAfterSeconds !== void 0) {
1075
+ return error.retryAfterSeconds * 1e3;
1076
+ }
1077
+ const ceiling = baseDelayMs * 2 ** (attempt - 1);
1078
+ return Math.floor(random() * ceiling);
1079
+ }
924
1080
 
925
1081
  // ../core/src/utils/hash.ts
926
1082
  import { createHash } from "crypto";
@@ -930,15 +1086,6 @@ function sha256Hex(data) {
930
1086
 
931
1087
  // ../core/src/storage/engine.ts
932
1088
  var DEFAULT_RETRY_POLICY = { attempts: 3, baseDelayMs: 400 };
933
- function isRetryable(error) {
934
- if (error instanceof OperationCancelledError) return false;
935
- const name = error instanceof Error ? error.name : "";
936
- const message = error instanceof Error ? error.message : String(error);
937
- const code = error.code;
938
- if (name === "AbortError" || code === "ABORT_ERR") return false;
939
- if (/AUTH|PASSWORD|PHONE|FLOOD_WAIT|not found|invalid/i.test(message)) return false;
940
- return true;
941
- }
942
1089
  function delay(ms, signal) {
943
1090
  return new Promise((resolve, reject) => {
944
1091
  if (signal?.aborted) {
@@ -974,16 +1121,30 @@ var StorageEngine = class {
974
1121
  * progress. Retries transient failures with exponential backoff.
975
1122
  */
976
1123
  async upload(input, control = {}) {
977
- const data = await this.readInput(input);
978
- const sha256 = sha256Hex(data);
979
- control.onProgress?.({ transferred: 0, total: data.byteLength, percent: 0 });
1124
+ if (input.data) {
1125
+ const sha2562 = sha256Hex(input.data);
1126
+ const size2 = input.data.byteLength;
1127
+ control.onProgress?.({ transferred: 0, total: size2, percent: 0 });
1128
+ const stored2 = await this.withRetry(
1129
+ () => this.provider.put(input, control),
1130
+ control,
1131
+ (error) => new UploadFailedError("Upload failed", error)
1132
+ );
1133
+ control.onProgress?.({ transferred: size2, total: size2, percent: 100 });
1134
+ return { stored: stored2, sha256: sha2562, size: size2 };
1135
+ }
1136
+ const { path, size, sha256 } = input;
1137
+ if (!path || size === void 0 || !sha256) {
1138
+ throw new UploadFailedError("Upload input has neither data nor a complete spooled file");
1139
+ }
1140
+ control.onProgress?.({ transferred: 0, total: size, percent: 0 });
980
1141
  const stored = await this.withRetry(
981
- () => this.provider.put({ ...input, data }, control),
1142
+ () => this.provider.put(input, control),
982
1143
  control,
983
1144
  (error) => new UploadFailedError("Upload failed", error)
984
1145
  );
985
- control.onProgress?.({ transferred: data.byteLength, total: data.byteLength, percent: 100 });
986
- return { stored, sha256, size: data.byteLength };
1146
+ control.onProgress?.({ transferred: size, total: size, percent: 100 });
1147
+ return { stored, sha256, size };
987
1148
  }
988
1149
  async download(ref, control = {}) {
989
1150
  return this.withRetry(
@@ -992,14 +1153,41 @@ var StorageEngine = class {
992
1153
  (error) => mapProviderError("Download failed", error)
993
1154
  );
994
1155
  }
995
- /** Streams the object, computing the checksum on the fly when requested. */
996
- async downloadStream(ref, control = {}) {
997
- const stream = await this.withRetry(
1156
+ /**
1157
+ * Streams the object and verifies its SHA-256 against `expectedSha256`.
1158
+ *
1159
+ * The last chunk is held back until the digest of everything before it has
1160
+ * been checked, so a corrupt object errors the stream before its final bytes
1161
+ * are released. A consumer therefore never sees a complete-looking body that
1162
+ * fails the checksum. Memory stays bounded to about two provider chunks.
1163
+ */
1164
+ async downloadStream(ref, expectedSha256, control = {}) {
1165
+ const source = await this.withRetry(
998
1166
  () => this.provider.getStream(ref, control),
999
1167
  control,
1000
1168
  (error) => mapProviderError("Download failed", error)
1001
1169
  );
1002
- return { stream, hash: () => null };
1170
+ const hash = createHash2("sha256");
1171
+ let held = null;
1172
+ const gate = new Transform({
1173
+ transform(chunk, _encoding, callback) {
1174
+ hash.update(chunk);
1175
+ if (held) this.push(held);
1176
+ held = chunk;
1177
+ callback();
1178
+ },
1179
+ flush(callback) {
1180
+ if (hash.digest("hex") !== expectedSha256) {
1181
+ callback(new IntegrityCheckError("Downloaded content does not match its checksum"));
1182
+ return;
1183
+ }
1184
+ if (held) this.push(held);
1185
+ callback();
1186
+ }
1187
+ });
1188
+ source.once("error", (error) => gate.destroy(error));
1189
+ source.pipe(gate);
1190
+ return gate;
1003
1191
  }
1004
1192
  async remove(ref) {
1005
1193
  await this.withRetry(
@@ -1015,6 +1203,14 @@ var StorageEngine = class {
1015
1203
  throw mapProviderError("Storage lookup failed", error);
1016
1204
  }
1017
1205
  }
1206
+ /** Read-only listing of stored objects, for reconciliation. Never modifies anything. */
1207
+ async list(limit) {
1208
+ try {
1209
+ return await this.provider.listObjects(limit);
1210
+ } catch (error) {
1211
+ throw mapProviderError("Storage listing failed", error);
1212
+ }
1213
+ }
1018
1214
  async stat(ref) {
1019
1215
  try {
1020
1216
  return await this.provider.stat(ref);
@@ -1038,18 +1234,6 @@ var StorageEngine = class {
1038
1234
  return sha256Hex(data) === expectedSha256;
1039
1235
  }
1040
1236
  // ── internals ────────────────────────────────────────────────────────────
1041
- /** Materializes an upload input so the checksum can be computed. */
1042
- async readInput(input) {
1043
- if (input.data) return input.data;
1044
- if (!input.stream) {
1045
- throw new UploadFailedError("Upload input has neither data nor a stream");
1046
- }
1047
- const chunks = [];
1048
- for await (const chunk of input.stream) {
1049
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1050
- }
1051
- return Buffer.concat(chunks);
1052
- }
1053
1237
  async withRetry(operation, control, wrapError) {
1054
1238
  const { attempts, baseDelayMs } = this.retryPolicy;
1055
1239
  let lastError;
@@ -1059,8 +1243,8 @@ var StorageEngine = class {
1059
1243
  return await operation();
1060
1244
  } catch (error) {
1061
1245
  lastError = error;
1062
- if (attempt >= attempts || !isRetryable(error)) break;
1063
- await delay(baseDelayMs * 2 ** (attempt - 1), control.signal);
1246
+ if (attempt >= attempts || !isRetryableError(error)) break;
1247
+ await delay(retryDelayMs(error, attempt, baseDelayMs), control.signal);
1064
1248
  }
1065
1249
  }
1066
1250
  throw wrapError(lastError);
@@ -1107,6 +1291,51 @@ async function resolveItemQuery(folders, userId, input) {
1107
1291
  return query;
1108
1292
  }
1109
1293
 
1294
+ // ../core/src/services/auth-session-service.ts
1295
+ import { createHash as createHash3, randomBytes } from "crypto";
1296
+
1297
+ // ../core/src/services/upload-operation.ts
1298
+ function isValidOperationId(id) {
1299
+ return /^[A-Za-z0-9_-]{8,64}$/.test(id);
1300
+ }
1301
+ var OperationAlreadyExistsError = class extends Error {
1302
+ constructor() {
1303
+ super("An upload with this operation id already exists");
1304
+ this.name = "OperationAlreadyExistsError";
1305
+ }
1306
+ };
1307
+
1308
+ // ../core/src/utils/operation-log.ts
1309
+ async function timedOperation(sink, base, run, now = Date.now) {
1310
+ const started = now();
1311
+ const emit = (status, errorCode) => {
1312
+ if (!sink) return;
1313
+ const record = {
1314
+ operation: base.operation,
1315
+ userId: base.userId,
1316
+ resourceId: base.resourceId ?? null,
1317
+ sizeBytes: base.sizeBytes ?? null,
1318
+ durationMs: now() - started,
1319
+ status,
1320
+ errorCode
1321
+ };
1322
+ try {
1323
+ if (status === "ok") sink.info({ ...record }, `${base.operation}.completed`);
1324
+ else sink.warn({ ...record }, `${base.operation}.failed`);
1325
+ } catch {
1326
+ }
1327
+ };
1328
+ try {
1329
+ const result = await run();
1330
+ emit("ok", null);
1331
+ return result;
1332
+ } catch (error) {
1333
+ const code = error.code ?? "INTERNAL_ERROR";
1334
+ emit("error", code);
1335
+ throw error;
1336
+ }
1337
+ }
1338
+
1110
1339
  // ../core/src/services/activity-service.ts
1111
1340
  var ActivityService = class {
1112
1341
  constructor(activity, retentionDays = 90) {
@@ -1170,18 +1399,120 @@ function sanitizeFileName(name) {
1170
1399
 
1171
1400
  // ../core/src/services/file-service.ts
1172
1401
  var FileService = class {
1173
- constructor(files, folders, engineFor, activity = noopActivityRecorder) {
1402
+ constructor(files, folders, engineFor, activity = noopActivityRecorder, operations = null, log = null) {
1174
1403
  this.files = files;
1175
1404
  this.folders = folders;
1176
1405
  this.engineFor = engineFor;
1177
1406
  this.activity = activity;
1407
+ this.operations = operations;
1408
+ this.log = log;
1178
1409
  }
1179
1410
  files;
1180
1411
  folders;
1181
1412
  engineFor;
1182
1413
  activity;
1414
+ operations;
1415
+ log;
1183
1416
  // ── Upload ───────────────────────────────────────────────────────────────
1184
1417
  async upload(userId, input) {
1418
+ return timedOperation(
1419
+ this.log,
1420
+ {
1421
+ operation: "upload",
1422
+ userId,
1423
+ resourceId: input.replaceFileId ?? null,
1424
+ sizeBytes: input.spooled?.size ?? input.data?.byteLength ?? null
1425
+ },
1426
+ () => input.operationId && this.operations ? this.uploadOnce(userId, input, input.operationId, this.operations) : this.performUpload(userId, input)
1427
+ );
1428
+ }
1429
+ /**
1430
+ * Idempotent upload keyed by a client-supplied operation id. A retry never
1431
+ * writes a second Telegram object: a completed operation returns its file, and
1432
+ * an operation whose object was already stored is committed without uploading.
1433
+ */
1434
+ async uploadOnce(userId, input, operationId, operations) {
1435
+ if (!isValidOperationId(operationId)) {
1436
+ throw new ValidationError("Invalid upload operation id");
1437
+ }
1438
+ let op = await operations.findByOperationId(userId, operationId);
1439
+ if (op?.status === "COMPLETED" && op.fileId) {
1440
+ return this.get(userId, op.fileId);
1441
+ }
1442
+ if (op?.status === "UPLOADING" && op.telegramMessageId !== null) {
1443
+ return this.commitStoredObject(userId, input, operations, op);
1444
+ }
1445
+ if (!op) {
1446
+ try {
1447
+ op = await operations.create({ userId, operationId });
1448
+ } catch (error) {
1449
+ if (!(error instanceof OperationAlreadyExistsError)) throw error;
1450
+ const outcome = await this.followWinner(userId, input, operationId, operations);
1451
+ if (outcome) return outcome;
1452
+ throw new ConflictError("A concurrent upload with this operation id is still in progress");
1453
+ }
1454
+ }
1455
+ const claimed = await operations.claim(op.id, "PENDING", "UPLOADING") || await operations.claim(op.id, "FAILED", "UPLOADING");
1456
+ if (!claimed) {
1457
+ const outcome = await this.followWinner(userId, input, operationId, operations);
1458
+ if (outcome) return outcome;
1459
+ throw new ConflictError("A concurrent upload with this operation id is still in progress");
1460
+ }
1461
+ try {
1462
+ const record = await this.performUpload(userId, input, {
1463
+ onStored: async (stored) => {
1464
+ await operations.update(op.id, { telegramMessageId: Number(stored.messageId) });
1465
+ }
1466
+ });
1467
+ await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
1468
+ return record;
1469
+ } catch (error) {
1470
+ await operations.update(op.id, {
1471
+ status: "FAILED",
1472
+ error: error instanceof Error ? error.message.slice(0, 200) : "upload failed"
1473
+ });
1474
+ throw error;
1475
+ }
1476
+ }
1477
+ /**
1478
+ * Follows an operation another request is driving, for a bounded time. Returns
1479
+ * the finished file if the winner completes, or the stored object's commit if
1480
+ * it has stored one. Returns null when the winner is still in progress after the
1481
+ * budget, so the caller can report a conflict instead of waiting forever.
1482
+ */
1483
+ async followWinner(userId, input, operationId, operations) {
1484
+ for (let attempt = 0; attempt < 50; attempt += 1) {
1485
+ const current = await operations.findByOperationId(userId, operationId);
1486
+ if (current?.status === "COMPLETED" && current.fileId) {
1487
+ return this.get(userId, current.fileId);
1488
+ }
1489
+ if (current?.status === "FAILED") {
1490
+ return null;
1491
+ }
1492
+ if (current?.status === "UPLOADING" && current.telegramMessageId !== null) {
1493
+ return this.commitStoredObject(userId, input, operations, current);
1494
+ }
1495
+ await new Promise((resolve) => setTimeout(resolve, 10));
1496
+ }
1497
+ return null;
1498
+ }
1499
+ /** Commits metadata for an object that is already in Telegram. */
1500
+ async commitStoredObject(userId, input, operations, op) {
1501
+ const name = sanitizeFileName(input.name);
1502
+ const mimeType = mimeFromFilename(name);
1503
+ const size = op.size ?? input.spooled?.size ?? input.data?.byteLength ?? 0;
1504
+ const sha256 = op.sha256 ?? input.spooled?.sha256 ?? sha256Hex(input.data ?? Buffer.alloc(0));
1505
+ const record = await this.commitRecord(userId, input, {
1506
+ name,
1507
+ mimeType,
1508
+ size,
1509
+ sha256,
1510
+ telegramMessageId: op.telegramMessageId
1511
+ });
1512
+ await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
1513
+ return record;
1514
+ }
1515
+ async performUpload(userId, input, hooks = {}) {
1185
1516
  const name = sanitizeFileName(input.name);
1186
1517
  if (!name) throw new ValidationError("Invalid file name");
1187
1518
  let folderId = input.folderId;
@@ -1197,11 +1528,36 @@ var FileService = class {
1197
1528
  onProgress: input.onProgress,
1198
1529
  signal: input.signal
1199
1530
  };
1531
+ if (!input.data && !input.spooled) {
1532
+ throw new ValidationError("An upload needs content");
1533
+ }
1200
1534
  const { stored, sha256, size } = await engine.upload(
1201
- { name, mimeType, data: input.data },
1535
+ input.spooled ? {
1536
+ name,
1537
+ mimeType,
1538
+ path: input.spooled.path,
1539
+ size: input.spooled.size,
1540
+ sha256: input.spooled.sha256
1541
+ } : { name, mimeType, data: input.data },
1202
1542
  control
1203
1543
  );
1204
- const telegramMessageId = Number(stored.messageId);
1544
+ await hooks.onStored?.(stored);
1545
+ try {
1546
+ return await this.commitRecord(userId, input, {
1547
+ name,
1548
+ mimeType,
1549
+ size,
1550
+ sha256,
1551
+ telegramMessageId: Number(stored.messageId)
1552
+ });
1553
+ } catch (error) {
1554
+ await engine.remove({ messageId: String(stored.messageId) }).catch(() => void 0);
1555
+ throw error;
1556
+ }
1557
+ }
1558
+ /** Persists the file (or the new version of a file) for an object already stored. */
1559
+ async commitRecord(userId, input, next) {
1560
+ const { name, mimeType, size, sha256, telegramMessageId } = next;
1205
1561
  if (input.replaceFileId) {
1206
1562
  return this.replaceVersion(userId, input.replaceFileId, {
1207
1563
  name,
@@ -1211,6 +1567,10 @@ var FileService = class {
1211
1567
  telegramMessageId
1212
1568
  });
1213
1569
  }
1570
+ const folderId = input.folderId;
1571
+ if (folderId !== null) {
1572
+ await this.assertFolder(userId, folderId);
1573
+ }
1214
1574
  const record = await this.files.create({
1215
1575
  userId,
1216
1576
  folderId,
@@ -1384,16 +1744,20 @@ var FileService = class {
1384
1744
  * retry, rather than silently orphaning the storage object.
1385
1745
  */
1386
1746
  async deletePermanently(userId, id) {
1747
+ return timedOperation(
1748
+ this.log,
1749
+ { operation: "delete", userId, resourceId: id },
1750
+ () => this.performPermanentDelete(userId, id)
1751
+ );
1752
+ }
1753
+ async performPermanentDelete(userId, id) {
1387
1754
  const record = await this.get(userId, id);
1388
1755
  const engine = await this.engineFor(userId);
1389
- await engine.remove({ messageId: String(record.telegramMessageId) });
1390
1756
  const versions = await this.files.listVersions(id);
1391
- for (const version of versions) {
1392
- if (version.telegramMessageId === record.telegramMessageId) continue;
1393
- try {
1394
- await engine.remove({ messageId: String(version.telegramMessageId) });
1395
- } catch {
1396
- }
1757
+ const messageIds = /* @__PURE__ */ new Set([String(record.telegramMessageId)]);
1758
+ for (const version of versions) messageIds.add(String(version.telegramMessageId));
1759
+ for (const messageId of messageIds) {
1760
+ await engine.remove({ messageId });
1397
1761
  }
1398
1762
  await this.files.deleteVersionsByFileIds([id]);
1399
1763
  await this.files.deleteMany([id]);
@@ -1428,6 +1792,36 @@ var FileService = class {
1428
1792
  });
1429
1793
  return { record, data, integrityVerified };
1430
1794
  }
1795
+ /**
1796
+ * Streaming download for large files. The caller must consume `stream` to
1797
+ * the end and then await `verified` to learn whether the bytes match the
1798
+ * stored checksum. The download activity is recorded when the stream opens.
1799
+ */
1800
+ async downloadStreamed(userId, id, control = {}) {
1801
+ return timedOperation(
1802
+ this.log,
1803
+ { operation: "download", userId, resourceId: id },
1804
+ () => this.performStreamedDownload(userId, id, control)
1805
+ );
1806
+ }
1807
+ async performStreamedDownload(userId, id, control) {
1808
+ const record = await this.getActive(userId, id);
1809
+ const engine = await this.engineFor(userId);
1810
+ const stream = await engine.downloadStream(
1811
+ { messageId: String(record.telegramMessageId) },
1812
+ record.sha256,
1813
+ control
1814
+ );
1815
+ await this.activity.record({
1816
+ userId,
1817
+ action: "download",
1818
+ resourceType: "file",
1819
+ resourceId: id,
1820
+ resourceName: record.name,
1821
+ metadata: { size: record.size, streamed: true }
1822
+ });
1823
+ return { record, stream };
1824
+ }
1431
1825
  /** Records an "open" event (the UI opening the details panel, say). */
1432
1826
  async touch(userId, id, action = "open") {
1433
1827
  const record = await this.get(userId, id);
@@ -1479,7 +1873,13 @@ var FileService = class {
1479
1873
  errors.push({ id, message: error instanceof Error ? error.message : "Operation failed" });
1480
1874
  }
1481
1875
  }
1482
- return { requested: ids.length, succeeded, failed: errors.length, errors };
1876
+ return {
1877
+ ...options.operationId ? { operationId: options.operationId } : {},
1878
+ requested: ids.length,
1879
+ succeeded,
1880
+ failed: errors.length,
1881
+ errors
1882
+ };
1483
1883
  }
1484
1884
  // ── internals ────────────────────────────────────────────────────────────
1485
1885
  async assertFolder(userId, folderId) {
@@ -1573,7 +1973,7 @@ var FolderService = class {
1573
1973
  cursor = parent.parentId;
1574
1974
  }
1575
1975
  }
1576
- const updated = await this.folders.update(id, { parentId: newParentId });
1976
+ const updated = await this.folders.moveSafely(id, newParentId);
1577
1977
  await this.activity.record({
1578
1978
  userId,
1579
1979
  action: "move",
@@ -1634,7 +2034,7 @@ var FolderService = class {
1634
2034
  let parentId = record.parentId;
1635
2035
  if (parentId !== null) {
1636
2036
  const parent = await this.folders.findById(parentId);
1637
- if (!parent || parent.deletedAt) parentId = null;
2037
+ if (!parent || parent.userId !== userId || parent.deletedAt) parentId = null;
1638
2038
  }
1639
2039
  const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
1640
2040
  const batchId = record.trashBatchId;
@@ -1931,10 +2331,28 @@ var IntegrityService = class {
1931
2331
  const engine = await this.engineFor(userId);
1932
2332
  const issues = [];
1933
2333
  let healthy = 0;
2334
+ const versionsByFile = await this.files.listVersionsForFiles(targets.map((file) => file.id));
1934
2335
  for (const file of targets) {
1935
2336
  const issue = await this.inspect(file, options.deep ?? false, engine);
1936
2337
  if (issue) issues.push(issue);
1937
2338
  else healthy += 1;
2339
+ for (const version of versionsByFile.get(file.id) ?? []) {
2340
+ if (version.telegramMessageId === file.telegramMessageId) continue;
2341
+ const versionIssue = await this.inspectObject(
2342
+ {
2343
+ id: file.id,
2344
+ name: file.name,
2345
+ telegramMessageId: version.telegramMessageId,
2346
+ size: version.size,
2347
+ sha256: version.sha256
2348
+ },
2349
+ options.deep ?? false,
2350
+ engine,
2351
+ version.id
2352
+ );
2353
+ if (versionIssue) issues.push(versionIssue);
2354
+ else healthy += 1;
2355
+ }
1938
2356
  }
1939
2357
  const missing = issues.filter((issue) => issue.kind === "missing").length;
1940
2358
  const inconsistent = issues.filter(
@@ -1953,53 +2371,69 @@ var IntegrityService = class {
1953
2371
  };
1954
2372
  }
1955
2373
  async inspect(file, deep, engine) {
1956
- const ref = { messageId: String(file.telegramMessageId) };
2374
+ return this.inspectObject(
2375
+ {
2376
+ id: file.id,
2377
+ name: file.name,
2378
+ telegramMessageId: file.telegramMessageId,
2379
+ size: file.size,
2380
+ sha256: file.sha256
2381
+ },
2382
+ deep,
2383
+ engine,
2384
+ void 0
2385
+ );
2386
+ }
2387
+ async inspectObject(target, deep, engine, versionId) {
2388
+ const ref = { messageId: String(target.telegramMessageId) };
2389
+ const withVersion = (issue) => versionId ? { ...issue, versionId } : issue;
1957
2390
  let stat;
1958
2391
  try {
1959
2392
  stat = await engine.stat(ref);
1960
2393
  } catch (error) {
1961
- return {
1962
- fileId: file.id,
1963
- name: file.name,
2394
+ return withVersion({
2395
+ fileId: target.id,
2396
+ name: target.name,
1964
2397
  kind: "unreadable",
1965
2398
  detail: error instanceof Error ? error.message : "Storage lookup failed"
1966
- };
2399
+ });
1967
2400
  }
1968
2401
  if (!stat) {
1969
- return {
1970
- fileId: file.id,
1971
- name: file.name,
2402
+ return withVersion({
2403
+ fileId: target.id,
2404
+ name: target.name,
1972
2405
  kind: "missing",
1973
2406
  detail: "No message with this id exists in the storage channel"
1974
- };
2407
+ });
1975
2408
  }
1976
- if (stat.size !== file.size) {
1977
- return {
1978
- fileId: file.id,
1979
- name: file.name,
2409
+ if (stat.size !== target.size) {
2410
+ return withVersion({
2411
+ fileId: target.id,
2412
+ name: target.name,
1980
2413
  kind: "size_mismatch",
1981
- detail: `metadata=${file.size} storage=${stat.size}`
1982
- };
2414
+ detail: `metadata=${target.size} storage=${stat.size}`
2415
+ });
1983
2416
  }
1984
2417
  if (deep) {
1985
2418
  try {
1986
- const data = await engine.download(ref);
1987
- const ok = await engine.verifyIntegrity(data, file.sha256);
1988
- if (!ok) {
1989
- return {
1990
- fileId: file.id,
1991
- name: file.name,
2419
+ const stream = await engine.downloadStream(ref, target.sha256);
2420
+ for await (const _chunk of stream) {
2421
+ }
2422
+ } catch (error) {
2423
+ if (error instanceof IntegrityCheckError) {
2424
+ return withVersion({
2425
+ fileId: target.id,
2426
+ name: target.name,
1992
2427
  kind: "hash_mismatch",
1993
2428
  detail: "Downloaded bytes do not match the stored SHA-256"
1994
- };
2429
+ });
1995
2430
  }
1996
- } catch (error) {
1997
- return {
1998
- fileId: file.id,
1999
- name: file.name,
2431
+ return withVersion({
2432
+ fileId: target.id,
2433
+ name: target.name,
2000
2434
  kind: "unreadable",
2001
2435
  detail: error instanceof Error ? error.message : "Download failed"
2002
- };
2436
+ });
2003
2437
  }
2004
2438
  }
2005
2439
  return null;
@@ -2066,6 +2500,18 @@ var RecentService = class {
2066
2500
  }
2067
2501
  };
2068
2502
 
2503
+ // ../core/src/utils/spool.ts
2504
+ import { createHash as createHash4 } from "crypto";
2505
+ import { createWriteStream } from "fs";
2506
+ import { mkdtemp, rm } from "fs/promises";
2507
+ import { tmpdir } from "os";
2508
+ import { join } from "path";
2509
+ import { Transform as Transform2 } from "stream";
2510
+ import { pipeline } from "stream/promises";
2511
+
2512
+ // ../core/src/utils/secret-box.ts
2513
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2, scryptSync } from "crypto";
2514
+
2069
2515
  // ../telegram/src/client.ts
2070
2516
  import { Api, TelegramClient } from "telegram";
2071
2517
  import { StringSession } from "telegram/sessions/index.js";
@@ -2084,7 +2530,9 @@ function mapTelegramError(error, fallbackMessage) {
2084
2530
  const msg = error.errorMessage ?? "";
2085
2531
  if (msg.startsWith("FLOOD_WAIT_")) {
2086
2532
  const seconds = Number(msg.slice("FLOOD_WAIT_".length)) || 60;
2087
- return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds`);
2533
+ return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds`, {
2534
+ retryAfterSeconds: seconds
2535
+ });
2088
2536
  }
2089
2537
  switch (msg) {
2090
2538
  case "PHONE_NUMBER_INVALID":
@@ -2372,6 +2820,7 @@ import { Api as Api2 } from "telegram";
2372
2820
  import bigInt from "big-integer";
2373
2821
  import { CustomFile } from "telegram/client/uploads.js";
2374
2822
  var MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 * 1024;
2823
+ var DOWNLOAD_CHUNK_BYTES = 512 * 1024;
2375
2824
  var TelegramStorageProvider = class {
2376
2825
  constructor(client, options) {
2377
2826
  this.client = client;
@@ -2387,17 +2836,19 @@ var TelegramStorageProvider = class {
2387
2836
  title;
2388
2837
  // ── Upload ───────────────────────────────────────────────────────────────
2389
2838
  async put(input, control = {}) {
2390
- const data = await this.materialize(input);
2391
- if (data.byteLength > MAX_DOCUMENT_BYTES) {
2839
+ const total = input.data ? input.data.byteLength : input.size;
2840
+ if (total === void 0 || !input.data && !input.path) {
2841
+ throw new UploadFailedError("Upload input has neither data nor a spooled file");
2842
+ }
2843
+ if (total > MAX_DOCUMENT_BYTES) {
2392
2844
  throw new UploadFailedError(
2393
2845
  `Telegram does not accept documents larger than ${MAX_DOCUMENT_BYTES / (1024 * 1024 * 1024)} GB`
2394
2846
  );
2395
2847
  }
2396
2848
  this.throwIfAborted(control.signal);
2397
- const total = data.byteLength;
2398
2849
  control.onProgress?.({ transferred: 0, total, percent: 0 });
2399
2850
  try {
2400
- const file = new CustomFile(input.name, total, "", data);
2851
+ const file = input.data ? new CustomFile(input.name, total, "", input.data) : new CustomFile(input.name, total, input.path);
2401
2852
  const message = await this.client.sendFile(this.peer, {
2402
2853
  file,
2403
2854
  forceDocument: true,
@@ -2468,9 +2919,47 @@ var TelegramStorageProvider = class {
2468
2919
  });
2469
2920
  return buffer;
2470
2921
  }
2922
+ /**
2923
+ * Streams the object in Telegram-sized chunks, so memory use stays bounded
2924
+ * by one chunk regardless of file size. The first request happens here so
2925
+ * that a missing message fails before the caller starts consuming bytes.
2926
+ */
2471
2927
  async getStream(ref, control = {}) {
2472
- const data = await this.get(ref, control);
2473
- return Readable2.from([data]);
2928
+ const message = await this.requireMessage(ref);
2929
+ this.throwIfAborted(control.signal);
2930
+ const size = this.documentSize(message);
2931
+ const media = message.media;
2932
+ if (size === null || !media) {
2933
+ throw new TelegramFileNotFoundError("Telegram did not return file content");
2934
+ }
2935
+ const iterator = this.client.iterDownload({
2936
+ file: media,
2937
+ fileSize: bigInt(size),
2938
+ requestSize: DOWNLOAD_CHUNK_BYTES
2939
+ });
2940
+ let transferred = 0;
2941
+ control.onProgress?.({ transferred: 0, total: size, percent: 0 });
2942
+ const signal = control.signal;
2943
+ return Readable2.from(
2944
+ (async function* () {
2945
+ try {
2946
+ for await (const chunk of iterator) {
2947
+ if (signal?.aborted) throw new OperationCancelledError();
2948
+ const buffer = Buffer.from(chunk);
2949
+ transferred += buffer.byteLength;
2950
+ control.onProgress?.({
2951
+ transferred,
2952
+ total: size,
2953
+ percent: size > 0 ? Math.round(transferred / size * 100) : null
2954
+ });
2955
+ yield buffer;
2956
+ }
2957
+ } catch (error) {
2958
+ if (error instanceof OperationCancelledError) throw error;
2959
+ throw mapTelegramError(error, "Telegram download failed");
2960
+ }
2961
+ })()
2962
+ );
2474
2963
  }
2475
2964
  // ── Lifecycle ────────────────────────────────────────────────────────────
2476
2965
  async delete(ref) {
@@ -2487,6 +2976,38 @@ var TelegramStorageProvider = class {
2487
2976
  throw mapTelegramError(error, "Telegram lookup failed");
2488
2977
  }
2489
2978
  }
2979
+ /**
2980
+ * Read-only listing of stored documents in the channel, newest first, up to
2981
+ * `limit`. Only messages that carry a document are returned; text messages and
2982
+ * service messages are skipped. Nothing is modified.
2983
+ */
2984
+ async listObjects(limit) {
2985
+ const objects = [];
2986
+ try {
2987
+ for await (const message of this.client.iterMessages(this.peer, { limit: limit * 2 })) {
2988
+ if (objects.length >= limit) break;
2989
+ if (!(message instanceof Api2.Message)) continue;
2990
+ const document = message.document;
2991
+ if (!(document instanceof Api2.Document)) continue;
2992
+ let fileName;
2993
+ for (const attr of document.attributes) {
2994
+ if (attr instanceof Api2.DocumentAttributeFilename) {
2995
+ fileName = attr.fileName;
2996
+ break;
2997
+ }
2998
+ }
2999
+ objects.push({
3000
+ messageId: message.id.toString(),
3001
+ name: fileName ?? "file",
3002
+ size: Number(document.size.toString()),
3003
+ mimeType: document.mimeType
3004
+ });
3005
+ }
3006
+ } catch (error) {
3007
+ throw mapTelegramError(error, "Telegram listing failed");
3008
+ }
3009
+ return objects;
3010
+ }
2490
3011
  async stat(ref) {
2491
3012
  const message = await this.getMessage(ref.messageId);
2492
3013
  if (!message?.media) return null;
@@ -2549,17 +3070,6 @@ var TelegramStorageProvider = class {
2549
3070
  throw new TelegramConnectionError("Telegram client is not connected");
2550
3071
  }
2551
3072
  }
2552
- async materialize(input) {
2553
- if (input.data) return input.data;
2554
- if (!input.stream) {
2555
- throw new UploadFailedError("Upload input has neither data nor a stream");
2556
- }
2557
- const chunks = [];
2558
- for await (const chunk of input.stream) {
2559
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2560
- }
2561
- return Buffer.concat(chunks);
2562
- }
2563
3073
  documentSize(message) {
2564
3074
  const document = message.document;
2565
3075
  if (document instanceof Api2.Document) return Number(document.size.toString());