@lacrous/omnicloud 0.1.1 → 0.2.1

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
@@ -30,21 +30,32 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ActivityService: () => ActivityService,
33
34
  ERROR_CODES: () => ERROR_CODES,
34
35
  FileService: () => FileService,
35
36
  FolderService: () => FolderService,
37
+ IntegrityService: () => IntegrityService,
36
38
  OmniCloudClient: () => OmniCloudClient,
37
39
  OmniCloudError: () => OmniCloudError,
40
+ RecentService: () => RecentService,
38
41
  SearchService: () => SearchService,
42
+ StatsService: () => StatsService,
39
43
  StorageEngine: () => StorageEngine,
40
44
  TELEGRAM_PROVIDER: () => TELEGRAM_PROVIDER,
41
45
  TelegramClientManager: () => TelegramClientManager,
42
46
  TelegramConnectionService: () => TelegramConnectionService,
43
47
  TelegramStorageProvider: () => TelegramStorageProvider,
48
+ TrashService: () => TrashService,
44
49
  fileCategory: () => fileCategory,
50
+ isFileCategory: () => isFileCategory,
45
51
  mapProviderError: () => mapProviderError,
46
52
  mapTelegramError: () => mapTelegramError,
47
53
  mimeFromFilename: () => mimeFromFilename,
54
+ mimeMatchersForType: () => mimeMatchersForType,
55
+ noopActivityRecorder: () => noopActivityRecorder,
56
+ paginationMeta: () => paginationMeta,
57
+ parseSearchQuery: () => parseSearchQuery,
58
+ parseSize: () => parseSize,
48
59
  sanitizeFileName: () => sanitizeFileName,
49
60
  sha256Hex: () => sha256Hex
50
61
  });
@@ -52,176 +63,521 @@ module.exports = __toCommonJS(index_exports);
52
63
 
53
64
  // src/client.ts
54
65
  var OmniCloudError = class extends Error {
55
- constructor(status, code, message) {
66
+ constructor(status, code, message, requestId = void 0, details = void 0) {
56
67
  super(message);
57
68
  this.status = status;
58
69
  this.code = code;
70
+ this.requestId = requestId;
71
+ this.details = details;
59
72
  this.name = "OmniCloudError";
60
73
  }
61
74
  status;
62
75
  code;
76
+ requestId;
77
+ details;
63
78
  };
79
+ var DEFAULT_RETRY = { attempts: 3, baseDelayMs: 300 };
80
+ function sleep(ms, signal) {
81
+ return new Promise((resolve, reject) => {
82
+ const timer = setTimeout(resolve, ms);
83
+ signal?.addEventListener(
84
+ "abort",
85
+ () => {
86
+ clearTimeout(timer);
87
+ reject(new OmniCloudError(0, "ABORTED", "Request aborted"));
88
+ },
89
+ { once: true }
90
+ );
91
+ });
92
+ }
93
+ function isRetryableStatus(status) {
94
+ return status === 408 || status === 429 || status >= 500;
95
+ }
96
+ function buildQuery(query) {
97
+ const params = new URLSearchParams();
98
+ if (query) {
99
+ for (const [key, value] of Object.entries(query)) {
100
+ if (value === void 0 || value === null || value === "") continue;
101
+ params.set(key, String(value));
102
+ }
103
+ }
104
+ const encoded = params.toString();
105
+ return encoded ? `?${encoded}` : "";
106
+ }
107
+ function safeJsonParse(text) {
108
+ try {
109
+ return JSON.parse(text);
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
64
114
  var OmniCloudClient = class {
115
+ files;
116
+ folders;
117
+ trash;
118
+ storage;
119
+ search;
120
+ recent;
121
+ activity;
122
+ auth;
123
+ /** @internal */
65
124
  baseUrl;
125
+ /** @internal */
66
126
  fetchImpl;
127
+ /** @internal */
128
+ retry;
129
+ /** @internal */
130
+ defaultHeaders;
67
131
  constructor(options = {}) {
68
132
  this.baseUrl = (options.baseUrl ?? "").replace(/\/$/, "");
69
133
  this.fetchImpl = options.fetchImpl ?? fetch.bind(globalThis);
134
+ this.retry = { ...DEFAULT_RETRY, ...options.retry };
135
+ this.defaultHeaders = {
136
+ ...options.token ? { Authorization: `Bearer ${options.token}` } : {},
137
+ ...options.headers
138
+ };
139
+ this.files = new FilesApi(this);
140
+ this.folders = new FoldersApi(this);
141
+ this.trash = new TrashApi(this);
142
+ this.storage = new StorageApi(this);
143
+ this.search = new SearchApi(this);
144
+ this.recent = new RecentApi(this);
145
+ this.activity = new ActivityApi(this);
146
+ this.auth = new AuthApi(this);
147
+ }
148
+ /** Low-level request helper (retries transient failures). */
149
+ async request(method, path, body) {
150
+ return this.withRetry(async () => this.rawRequest(method, path, body));
70
151
  }
71
- // ── Authentication ───────────────────────────────────────────────────────
72
- async me() {
73
- return this.request("GET", "/api/auth/me");
74
- }
75
- async startTelegramLogin(phone) {
76
- await this.request("POST", "/api/auth/telegram/start", { phone });
77
- }
78
- async verifyTelegramCode(phone, code) {
79
- return this.request("POST", "/api/auth/telegram/verify", { phone, code });
80
- }
81
- async submitTelegramPassword(phone, password) {
82
- return this.request("POST", "/api/auth/telegram/password", { phone, password });
83
- }
84
- async ensureStorage() {
85
- const { storage } = await this.request("POST", "/api/storage/ensure");
86
- return storage;
87
- }
88
- async logout() {
89
- await this.request("POST", "/api/auth/logout");
90
- }
91
- // ── Folders ──────────────────────────────────────────────────────────────
92
- async listFolders(parentId) {
93
- const { folders } = await this.request(
94
- "GET",
95
- `/api/folders${parentId ? `?parentId=${encodeURIComponent(parentId)}` : ""}`
96
- );
97
- return folders;
98
- }
99
- async folderTree() {
100
- const { folders } = await this.request("GET", "/api/folders/tree");
101
- return folders;
102
- }
103
- async createFolder(name, parentId = null) {
104
- const { folder } = await this.request("POST", "/api/folders", {
105
- name,
106
- parentId
152
+ /** @internal */
153
+ async rawRequest(method, path, body) {
154
+ const isForm = typeof FormData !== "undefined" && body instanceof FormData;
155
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
156
+ method,
157
+ headers: {
158
+ ...this.defaultHeaders,
159
+ ...body !== void 0 && !isForm ? { "Content-Type": "application/json" } : {}
160
+ },
161
+ body: body === void 0 ? void 0 : isForm ? body : JSON.stringify(body),
162
+ credentials: "include"
107
163
  });
108
- return folder;
164
+ if (response.status === 204) return void 0;
165
+ const text = await response.text();
166
+ const data = text ? safeJsonParse(text) : null;
167
+ if (!response.ok) {
168
+ const error = data?.error;
169
+ throw new OmniCloudError(
170
+ response.status,
171
+ error?.code ?? "INTERNAL_ERROR",
172
+ error?.message ?? `Request failed (${response.status})`,
173
+ error?.requestId,
174
+ error?.details
175
+ );
176
+ }
177
+ return data;
109
178
  }
110
- async renameFolder(id, name) {
111
- const { folder } = await this.request("PATCH", `/api/folders/${id}`, {
112
- name
113
- });
114
- return folder;
179
+ /** @internal */
180
+ async withRetry(operation) {
181
+ const { attempts, baseDelayMs } = this.retry;
182
+ let lastError;
183
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
184
+ try {
185
+ return await operation();
186
+ } catch (error) {
187
+ lastError = error;
188
+ const retryable = error instanceof OmniCloudError && isRetryableStatus(error.status);
189
+ if (!retryable || attempt >= attempts) break;
190
+ await sleep(baseDelayMs * 2 ** (attempt - 1));
191
+ }
192
+ }
193
+ throw lastError;
115
194
  }
116
- async moveFolder(id, parentId) {
117
- const { folder } = await this.request(
118
- "POST",
119
- `/api/folders/${id}/move`,
120
- { parentId }
121
- );
122
- return folder;
195
+ /** @internal */
196
+ async requestBinary(path, onProgress) {
197
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
198
+ headers: this.defaultHeaders,
199
+ credentials: "include"
200
+ });
201
+ if (!response.ok) {
202
+ const text = await response.text();
203
+ const error = safeJsonParse(text)?.error;
204
+ throw new OmniCloudError(
205
+ response.status,
206
+ error?.code ?? "DOWNLOAD_FAILED",
207
+ error?.message ?? `Download failed (${response.status})`
208
+ );
209
+ }
210
+ const total = Number(response.headers.get("content-length") ?? 0) || null;
211
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
212
+ const sha256 = response.headers.get("x-content-sha256");
213
+ const integrityHeader = response.headers.get("x-integrity-verified");
214
+ const integrityVerified = integrityHeader === null ? null : integrityHeader === "true";
215
+ const data = await readBody(response, total, onProgress);
216
+ return { data, contentType, size: data.byteLength, sha256, integrityVerified };
123
217
  }
124
- async deleteFolder(id) {
125
- return this.request("DELETE", `/api/folders/${id}`);
218
+ };
219
+ async function readBody(response, total, onProgress) {
220
+ const body = response.body;
221
+ if (!body) {
222
+ const buffer = new Uint8Array(await response.arrayBuffer());
223
+ onProgress?.({ loaded: buffer.byteLength, total: buffer.byteLength, percent: 100 });
224
+ return buffer;
225
+ }
226
+ const chunks = [];
227
+ let loaded = 0;
228
+ const reader = body.getReader();
229
+ for (; ; ) {
230
+ const { done, value } = await reader.read();
231
+ if (done) break;
232
+ if (value) {
233
+ chunks.push(value);
234
+ loaded += value.byteLength;
235
+ onProgress?.({
236
+ loaded,
237
+ total,
238
+ percent: total ? Math.round(loaded / total * 100) : null
239
+ });
240
+ }
126
241
  }
127
- // ── Files ────────────────────────────────────────────────────────────────
128
- async listFiles(folderId) {
129
- const { files } = await this.request(
130
- "GET",
131
- `/api/files${folderId ? `?folderId=${encodeURIComponent(folderId)}` : ""}`
132
- );
133
- return files;
242
+ const merged = new Uint8Array(loaded);
243
+ let offset = 0;
244
+ for (const chunk of chunks) {
245
+ merged.set(chunk, offset);
246
+ offset += chunk.byteLength;
134
247
  }
135
- async getFile(id) {
136
- const { file } = await this.request("GET", `/api/files/${id}`);
137
- return file;
248
+ const BufferCtor = globalThis.Buffer;
249
+ return BufferCtor ? BufferCtor.from(merged) : merged;
250
+ }
251
+ var FilesApi = class {
252
+ constructor(client) {
253
+ this.client = client;
138
254
  }
139
- async renameFile(id, name) {
140
- const { file } = await this.request("PATCH", `/api/files/${id}`, { name });
141
- return file;
255
+ client;
256
+ /** `folderId: null` = root; omit it to list every active file. */
257
+ async list(query = {}) {
258
+ return this.client.request("GET", `/api/files${buildQuery(query)}`);
142
259
  }
143
- async moveFile(id, folderId) {
144
- const { file } = await this.request("POST", `/api/files/${id}/move`, {
145
- folderId
146
- });
260
+ async get(id) {
261
+ const { file } = await this.client.request("GET", `/api/files/${id}`);
147
262
  return file;
148
263
  }
149
- async deleteFile(id) {
150
- await this.request("DELETE", `/api/files/${id}`);
151
- }
152
- /** URL of the download endpoint (usable as href for direct downloads). */
153
- fileDownloadUrl(id) {
154
- return `${this.baseUrl}/api/files/${id}/download`;
264
+ async versions(id) {
265
+ const { versions } = await this.client.request(
266
+ "GET",
267
+ `/api/files/${id}/versions`
268
+ );
269
+ return versions;
155
270
  }
156
271
  /**
157
- * Uploads a file. Uses XMLHttpRequest when available so `onProgress`
158
- * works (browsers); falls back to fetch without progress in Node.
272
+ * Uploads a file with progress and retry support. Uses XMLHttpRequest when
273
+ * available (browsers) so progress and cancellation work; falls back to
274
+ * fetch elsewhere.
159
275
  */
160
- async uploadFile(input, options = {}) {
276
+ async upload(input, options = {}) {
277
+ const attempts = Math.max(1, (options.retry ?? 2) + 1);
278
+ let lastError;
279
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
280
+ try {
281
+ return await this.uploadOnce(input, options);
282
+ } catch (error) {
283
+ lastError = error;
284
+ if (options.signal?.aborted) throw error;
285
+ if (error instanceof OmniCloudError && !isRetryableStatus(error.status)) throw error;
286
+ if (attempt < attempts) await sleep(300 * 2 ** (attempt - 1), options.signal);
287
+ }
288
+ }
289
+ throw lastError;
290
+ }
291
+ uploadOnce(input, options) {
161
292
  const form = new FormData();
162
- const blob = input.data instanceof Blob ? input.data : new Blob([new Uint8Array(input.data)]);
293
+ const blob = typeof Blob !== "undefined" && input.data instanceof Blob ? input.data : new Blob([new Uint8Array(input.data)]);
163
294
  form.append("file", blob, input.name);
164
295
  if (options.folderId) form.append("folderId", options.folderId);
165
296
  if (typeof XMLHttpRequest === "function") {
166
- return this.uploadViaXhr(form, options.onProgress);
297
+ return this.uploadViaXhr(form, options);
167
298
  }
168
- return this.request("POST", "/api/files", form);
169
- }
170
- async search(query) {
171
- return this.request("GET", `/api/search?q=${encodeURIComponent(query)}`);
299
+ return this.client.request("POST", "/api/files", form).then((response) => response.file);
172
300
  }
173
- // ── internals ────────────────────────────────────────────────────────────
174
- uploadViaXhr(form, onProgress) {
301
+ uploadViaXhr(form, options) {
175
302
  return new Promise((resolve, reject) => {
176
303
  const xhr = new XMLHttpRequest();
177
- xhr.open("POST", `${this.baseUrl}/api/files`);
304
+ xhr.open("POST", `${this.client.baseUrl}/api/files`);
178
305
  xhr.responseType = "json";
179
306
  xhr.withCredentials = true;
307
+ const onAbort = () => xhr.abort();
308
+ options.signal?.addEventListener("abort", onAbort, { once: true });
180
309
  xhr.upload.onprogress = (event) => {
181
- if (!onProgress) return;
182
- onProgress({
310
+ options.onProgress?.({
183
311
  loaded: event.loaded,
184
312
  total: event.total,
185
313
  percent: event.total > 0 ? Math.round(event.loaded / event.total * 100) : 0
186
314
  });
187
315
  };
188
316
  xhr.onload = () => {
317
+ options.signal?.removeEventListener("abort", onAbort);
189
318
  const body = xhr.response;
190
319
  if (xhr.status >= 200 && xhr.status < 300 && body?.file) {
320
+ options.onProgress?.({ loaded: 1, total: 1, percent: 100 });
191
321
  resolve(body.file);
192
322
  return;
193
323
  }
194
324
  reject(
195
325
  new OmniCloudError(
196
326
  xhr.status,
197
- body?.error?.code ?? "INTERNAL_ERROR",
198
- body?.error?.message ?? `Upload failed (${xhr.status})`
327
+ body?.error?.code ?? "UPLOAD_FAILED",
328
+ body?.error?.message ?? `Upload failed (${xhr.status})`,
329
+ body?.error?.requestId
199
330
  )
200
331
  );
201
332
  };
202
333
  xhr.onerror = () => reject(new OmniCloudError(0, "NETWORK_ERROR", "Network error during upload"));
334
+ xhr.onabort = () => reject(new OmniCloudError(0, "ABORTED", "Upload aborted"));
203
335
  xhr.send(form);
204
336
  });
205
337
  }
206
- async request(method, path, body) {
207
- const isJsonBody = body !== void 0 && !(body instanceof FormData);
208
- const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
209
- method,
210
- headers: isJsonBody ? { "Content-Type": "application/json" } : void 0,
211
- body: body === void 0 ? void 0 : isJsonBody ? JSON.stringify(body) : body,
212
- credentials: "include"
338
+ /** Uploads a new version of an existing file. */
339
+ async replace(id, input) {
340
+ const form = new FormData();
341
+ const blob = typeof Blob !== "undefined" && input.data instanceof Blob ? input.data : new Blob([new Uint8Array(input.data)]);
342
+ form.append("file", blob, input.name);
343
+ const { file } = await this.client.request(
344
+ "POST",
345
+ `/api/files/${id}/replace`,
346
+ form
347
+ );
348
+ return file;
349
+ }
350
+ async download(id, onProgress) {
351
+ return this.client.requestBinary(`/api/files/${id}/download`, onProgress);
352
+ }
353
+ /** URL suitable for direct download (anchor href). */
354
+ downloadUrl(id) {
355
+ return `${this.client.baseUrl}/api/files/${id}/download`;
356
+ }
357
+ async rename(id, name) {
358
+ const { file } = await this.client.request("PATCH", `/api/files/${id}`, {
359
+ name
360
+ });
361
+ return file;
362
+ }
363
+ async star(id, starred = true) {
364
+ const { file } = await this.client.request("PATCH", `/api/files/${id}`, {
365
+ starred
366
+ });
367
+ return file;
368
+ }
369
+ async move(id, folderId) {
370
+ const { file } = await this.client.request("POST", `/api/files/${id}/move`, {
371
+ folderId
372
+ });
373
+ return file;
374
+ }
375
+ async trash(id) {
376
+ const { file } = await this.client.request("POST", `/api/files/${id}/trash`);
377
+ return file;
378
+ }
379
+ async restore(id) {
380
+ const { file } = await this.client.request(
381
+ "POST",
382
+ `/api/files/${id}/restore`
383
+ );
384
+ return file;
385
+ }
386
+ /** Permanently deletes a file and its Telegram objects. */
387
+ async delete(id) {
388
+ await this.client.request("DELETE", `/api/files/${id}`);
389
+ }
390
+ /** Batch operation: trash | restore | delete | star | unstar | move. */
391
+ async batch(operation, ids, options = {}) {
392
+ return this.client.request("POST", "/api/files/batch", {
393
+ operation,
394
+ ids,
395
+ ...operation === "move" ? { folderId: options.folderId ?? null } : {}
396
+ });
397
+ }
398
+ };
399
+ var FoldersApi = class {
400
+ constructor(client) {
401
+ this.client = client;
402
+ }
403
+ client;
404
+ async list(query = {}) {
405
+ const { parentId, ...rest } = query;
406
+ const path = parentId !== void 0 ? `/api/folders?parentId=${encodeURIComponent(parentId ?? "")}` : `/api/folders${buildQuery(rest)}`;
407
+ return this.client.request("GET", path);
408
+ }
409
+ /** The full active folder tree (for move dialogs). */
410
+ async tree() {
411
+ const { folders } = await this.client.request(
412
+ "GET",
413
+ "/api/folders/tree"
414
+ );
415
+ return folders;
416
+ }
417
+ async create(name, parentId = null) {
418
+ const { folder } = await this.client.request("POST", "/api/folders", {
419
+ name,
420
+ parentId
421
+ });
422
+ return folder;
423
+ }
424
+ async rename(id, name) {
425
+ const { folder } = await this.client.request(
426
+ "PATCH",
427
+ `/api/folders/${id}`,
428
+ { name }
429
+ );
430
+ return folder;
431
+ }
432
+ async star(id, starred = true) {
433
+ const { folder } = await this.client.request(
434
+ "PATCH",
435
+ `/api/folders/${id}`,
436
+ { starred }
437
+ );
438
+ return folder;
439
+ }
440
+ async move(id, parentId) {
441
+ const { folder } = await this.client.request(
442
+ "POST",
443
+ `/api/folders/${id}/move`,
444
+ { parentId }
445
+ );
446
+ return folder;
447
+ }
448
+ async trash(id) {
449
+ return this.client.request("POST", `/api/folders/${id}/trash`);
450
+ }
451
+ async restore(id) {
452
+ return this.client.request("POST", `/api/folders/${id}/restore`);
453
+ }
454
+ /** Permanently deletes the folder subtree and its Telegram objects. */
455
+ async delete(id) {
456
+ return this.client.request("DELETE", `/api/folders/${id}`);
457
+ }
458
+ async batch(operation, ids, options = {}) {
459
+ return this.client.request("POST", "/api/folders/batch", {
460
+ operation,
461
+ ids,
462
+ ...operation === "move" ? { parentId: options.parentId ?? null } : {}
213
463
  });
214
- if (response.status === 204) return void 0;
215
- const data = await response.json().catch(() => null);
216
- if (!response.ok) {
217
- const error = data?.error;
218
- throw new OmniCloudError(
219
- response.status,
220
- error?.code ?? "INTERNAL_ERROR",
221
- error?.message ?? `Request failed (${response.status})`
222
- );
223
- }
224
- return data;
464
+ }
465
+ };
466
+ var TrashApi = class {
467
+ constructor(client) {
468
+ this.client = client;
469
+ }
470
+ client;
471
+ async list(query = {}) {
472
+ return this.client.request("GET", `/api/trash${buildQuery(query)}`);
473
+ }
474
+ /** Permanently removes everything in the Trash. */
475
+ async empty() {
476
+ return this.client.request("POST", "/api/trash/empty");
477
+ }
478
+ };
479
+ var StorageApi = class {
480
+ constructor(client) {
481
+ this.client = client;
482
+ }
483
+ client;
484
+ /** Creates the private storage channel if it does not exist yet. */
485
+ async ensure() {
486
+ const { storage } = await this.client.request(
487
+ "POST",
488
+ "/api/storage/ensure"
489
+ );
490
+ return storage;
491
+ }
492
+ async stats() {
493
+ const { stats } = await this.client.request(
494
+ "GET",
495
+ "/api/storage/stats"
496
+ );
497
+ return stats;
498
+ }
499
+ async health() {
500
+ return this.client.request("GET", "/api/storage/health");
501
+ }
502
+ /** Probes the storage backend; `deep` performs a real Telegram round-trip. */
503
+ async check(deep = false) {
504
+ const { health } = await this.client.request(
505
+ "POST",
506
+ "/api/storage/health/check",
507
+ { deep }
508
+ );
509
+ return health;
510
+ }
511
+ /** Read-only integrity scan; nothing is repaired or deleted. */
512
+ async integrityCheck(options = {}) {
513
+ const { report } = await this.client.request(
514
+ "POST",
515
+ "/api/storage/integrity/check",
516
+ { deep: options.deep ?? false }
517
+ );
518
+ return report;
519
+ }
520
+ /** Public server health (no authentication required). */
521
+ async serverHealth() {
522
+ return this.client.request("GET", "/api/health");
523
+ }
524
+ };
525
+ var SearchApi = class {
526
+ constructor(client) {
527
+ this.client = client;
528
+ }
529
+ client;
530
+ /**
531
+ * Searches with the v0.2 query language:
532
+ * `type:pdf`, `size:>100MB`, `folder:Projects`, `starred:true`, `after:2026-01-01`
533
+ */
534
+ async query(q, page = {}) {
535
+ return this.client.request(
536
+ "GET",
537
+ `/api/search${buildQuery({ q, ...page })}`
538
+ );
539
+ }
540
+ };
541
+ var RecentApi = class {
542
+ constructor(client) {
543
+ this.client = client;
544
+ }
545
+ client;
546
+ async list(page = {}) {
547
+ return this.client.request("GET", `/api/recent${buildQuery(page)}`);
548
+ }
549
+ };
550
+ var ActivityApi = class {
551
+ constructor(client) {
552
+ this.client = client;
553
+ }
554
+ client;
555
+ async list(page = {}) {
556
+ return this.client.request("GET", `/api/activity${buildQuery(page)}`);
557
+ }
558
+ };
559
+ var AuthApi = class {
560
+ constructor(client) {
561
+ this.client = client;
562
+ }
563
+ client;
564
+ async me() {
565
+ return this.client.request("GET", "/api/auth/me");
566
+ }
567
+ async startTelegramLogin(phone) {
568
+ await this.client.request("POST", "/api/auth/telegram/start", { phone });
569
+ }
570
+ async verifyTelegramCode(phone, code) {
571
+ return this.client.request("POST", "/api/auth/telegram/verify", {
572
+ phone,
573
+ code
574
+ });
575
+ }
576
+ async submitTelegramPassword(phone, password) {
577
+ return this.client.request("POST", "/api/auth/telegram/password", { phone, password });
578
+ }
579
+ async logout() {
580
+ await this.client.request("POST", "/api/auth/logout");
225
581
  }
226
582
  };
227
583
 
@@ -341,18 +697,205 @@ function fileCategory(mimeType) {
341
697
  if (OFFICE_MIMES.has(mimeType)) return "document";
342
698
  return "other";
343
699
  }
700
+ var FILE_CATEGORIES = [
701
+ "image",
702
+ "video",
703
+ "audio",
704
+ "pdf",
705
+ "archive",
706
+ "document",
707
+ "text",
708
+ "other"
709
+ ];
710
+ var CATEGORY_PREFIXES = {
711
+ image: ["image/"],
712
+ video: ["video/"],
713
+ audio: ["audio/"],
714
+ pdf: ["application/pdf"],
715
+ archive: [
716
+ "application/zip",
717
+ "application/x-tar",
718
+ "application/gzip",
719
+ "application/x-7z-compressed",
720
+ "application/vnd.rar",
721
+ "application/x-bzip2",
722
+ "application/x-xz"
723
+ ],
724
+ text: [
725
+ "text/",
726
+ "application/json",
727
+ "application/xml",
728
+ "application/yaml",
729
+ "application/javascript"
730
+ ],
731
+ document: [...OFFICE_MIMES],
732
+ other: []
733
+ };
734
+ function isFileCategory(value) {
735
+ return FILE_CATEGORIES.includes(value);
736
+ }
737
+ function mimeMatchersForType(value) {
738
+ const normalized = value.toLowerCase().trim();
739
+ if (!normalized) return null;
740
+ if (isFileCategory(normalized)) {
741
+ const prefixes = CATEGORY_PREFIXES[normalized];
742
+ return prefixes.length > 0 ? prefixes : null;
743
+ }
744
+ return [normalized];
745
+ }
746
+
747
+ // ../shared/src/query.ts
748
+ var SIZE_UNITS = {
749
+ b: 1,
750
+ kb: 1024,
751
+ mb: 1024 ** 2,
752
+ gb: 1024 ** 3,
753
+ tb: 1024 ** 4
754
+ };
755
+ function parseSize(value) {
756
+ const match = /^\s*([0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]*)\s*$/.exec(value);
757
+ if (!match) return null;
758
+ const amount = Number(match[1]);
759
+ if (!Number.isFinite(amount) || amount < 0) return null;
760
+ const unit = (match[2] ?? "").toLowerCase() || "b";
761
+ const factor = SIZE_UNITS[unit];
762
+ if (factor === void 0) return null;
763
+ return Math.round(amount * factor);
764
+ }
765
+ function parseSizeComparison(value) {
766
+ const match = /^\s*(>=|<=|>|<|=)?\s*(.+)$/.exec(value);
767
+ if (!match) return null;
768
+ const operator = match[1] ?? "=";
769
+ const bytes = parseSize(match[2]);
770
+ if (bytes === null) return null;
771
+ switch (operator) {
772
+ case ">":
773
+ return { min: bytes + 1 };
774
+ case ">=":
775
+ return { min: bytes };
776
+ case "<":
777
+ return { max: bytes - 1 };
778
+ case "<=":
779
+ return { max: bytes };
780
+ default:
781
+ return { min: bytes, max: bytes };
782
+ }
783
+ }
784
+ function parseBoolean(value) {
785
+ const normalized = value.toLowerCase();
786
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
787
+ if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
788
+ return null;
789
+ }
790
+ function parseDate(value, endOfDay) {
791
+ const trimmed = value.trim();
792
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
793
+ const date2 = /* @__PURE__ */ new Date(`${trimmed}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z`);
794
+ return Number.isNaN(date2.getTime()) ? null : date2;
795
+ }
796
+ const date = new Date(trimmed);
797
+ return Number.isNaN(date.getTime()) ? null : date;
798
+ }
799
+ function parseSearchQuery(input, base = {}) {
800
+ const query = { ...base };
801
+ const terms = [];
802
+ for (const rawToken of input.trim().split(/\s+/)) {
803
+ if (!rawToken) continue;
804
+ const separator = rawToken.indexOf(":");
805
+ if (separator <= 0) {
806
+ terms.push(rawToken);
807
+ continue;
808
+ }
809
+ const field = rawToken.slice(0, separator).toLowerCase();
810
+ const value = rawToken.slice(separator + 1);
811
+ if (!value) {
812
+ terms.push(rawToken);
813
+ continue;
814
+ }
815
+ switch (field) {
816
+ case "name":
817
+ case "filename":
818
+ terms.push(value);
819
+ break;
820
+ case "type":
821
+ query.type = value.toLowerCase();
822
+ break;
823
+ case "ext":
824
+ case "extension":
825
+ query.ext = value.replace(/^\./, "").toLowerCase();
826
+ break;
827
+ case "size": {
828
+ const comparison = parseSizeComparison(value);
829
+ if (!comparison) {
830
+ terms.push(rawToken);
831
+ break;
832
+ }
833
+ if (comparison.min !== void 0) query.minSize = comparison.min;
834
+ if (comparison.max !== void 0) query.maxSize = comparison.max;
835
+ break;
836
+ }
837
+ case "folder":
838
+ query.folderName = value;
839
+ break;
840
+ case "starred": {
841
+ const parsed = parseBoolean(value);
842
+ if (parsed === null) terms.push(rawToken);
843
+ else query.starred = parsed;
844
+ break;
845
+ }
846
+ case "trashed":
847
+ case "deleted": {
848
+ const parsed = parseBoolean(value);
849
+ if (parsed === null) terms.push(rawToken);
850
+ else query.status = parsed ? "trashed" : "active";
851
+ break;
852
+ }
853
+ case "after":
854
+ case "since": {
855
+ const date = parseDate(value, false);
856
+ if (!date) terms.push(rawToken);
857
+ else query.from = date.toISOString();
858
+ break;
859
+ }
860
+ case "before": {
861
+ const date = parseDate(value, true);
862
+ if (!date) terms.push(rawToken);
863
+ else query.to = date.toISOString();
864
+ break;
865
+ }
866
+ default:
867
+ terms.push(rawToken);
868
+ break;
869
+ }
870
+ }
871
+ const freeText = terms.join(" ").trim();
872
+ if (freeText) query.q = freeText;
873
+ return { query, freeText };
874
+ }
875
+ function paginationMeta(page, limit, total) {
876
+ return { page, limit, total, hasMore: page * limit < total };
877
+ }
344
878
 
345
879
  // ../shared/src/index.ts
346
880
  var ERROR_CODES = {
347
- VALIDATION: "VALIDATION_ERROR",
348
- UNAUTHENTICATED: "UNAUTHENTICATED",
349
- FORBIDDEN: "FORBIDDEN",
881
+ AUTH_REQUIRED: "AUTH_REQUIRED",
882
+ AUTH_INVALID: "AUTH_INVALID",
883
+ PERMISSION_DENIED: "PERMISSION_DENIED",
884
+ INVALID_REQUEST: "INVALID_REQUEST",
350
885
  NOT_FOUND: "NOT_FOUND",
886
+ FILE_NOT_FOUND: "FILE_NOT_FOUND",
887
+ FOLDER_NOT_FOUND: "FOLDER_NOT_FOUND",
351
888
  CONFLICT: "CONFLICT",
352
889
  PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
353
890
  RATE_LIMITED: "RATE_LIMITED",
354
891
  STORAGE_NOT_INITIALIZED: "STORAGE_NOT_INITIALIZED",
355
- TELEGRAM_ERROR: "TELEGRAM_ERROR",
892
+ STORAGE_UNAVAILABLE: "STORAGE_UNAVAILABLE",
893
+ TELEGRAM_AUTH_REQUIRED: "TELEGRAM_AUTH_REQUIRED",
894
+ TELEGRAM_CONNECTION_FAILED: "TELEGRAM_CONNECTION_FAILED",
895
+ TELEGRAM_FILE_NOT_FOUND: "TELEGRAM_FILE_NOT_FOUND",
896
+ UPLOAD_FAILED: "UPLOAD_FAILED",
897
+ DOWNLOAD_FAILED: "DOWNLOAD_FAILED",
898
+ INTEGRITY_CHECK_FAILED: "INTEGRITY_CHECK_FAILED",
356
899
  INTERNAL: "INTERNAL_ERROR"
357
900
  };
358
901
 
@@ -371,22 +914,31 @@ var DomainError = class extends Error {
371
914
  };
372
915
  var ValidationError = class extends DomainError {
373
916
  constructor(message, details) {
374
- super(ERROR_CODES.VALIDATION, 400, message, details);
917
+ super(ERROR_CODES.INVALID_REQUEST, 400, message, details);
375
918
  }
376
919
  };
377
920
  var UnauthorizedError = class extends DomainError {
378
921
  constructor(message = "Authentication required") {
379
- super(ERROR_CODES.UNAUTHENTICATED, 401, message);
922
+ super(ERROR_CODES.AUTH_REQUIRED, 401, message);
380
923
  }
381
924
  };
382
925
  var ForbiddenError = class extends DomainError {
383
926
  constructor(message = "You do not have access to this resource") {
384
- super(ERROR_CODES.FORBIDDEN, 403, message);
927
+ super(ERROR_CODES.PERMISSION_DENIED, 403, message);
385
928
  }
386
929
  };
387
930
  var NotFoundError = class extends DomainError {
388
- constructor(message = "Resource not found") {
389
- super(ERROR_CODES.NOT_FOUND, 404, message);
931
+ constructor(message = "Resource not found", resource) {
932
+ super(
933
+ resource === "file" ? ERROR_CODES.FILE_NOT_FOUND : resource === "folder" ? ERROR_CODES.FOLDER_NOT_FOUND : ERROR_CODES.NOT_FOUND,
934
+ 404,
935
+ message
936
+ );
937
+ }
938
+ };
939
+ var ConflictError = class extends DomainError {
940
+ constructor(message) {
941
+ super(ERROR_CODES.CONFLICT, 409, message);
390
942
  }
391
943
  };
392
944
  var RateLimitedError = class extends DomainError {
@@ -399,11 +951,39 @@ var StorageNotInitializedError = class extends DomainError {
399
951
  super(ERROR_CODES.STORAGE_NOT_INITIALIZED, 409, "Storage is not initialized for this account");
400
952
  }
401
953
  };
402
- var StorageProviderError = class extends DomainError {
403
- constructor(message, details) {
404
- super(ERROR_CODES.TELEGRAM_ERROR, 502, message, details);
954
+ var TelegramAuthRequiredError = class extends DomainError {
955
+ constructor(message = "Your Telegram session has expired \u2014 please reconnect") {
956
+ super(ERROR_CODES.TELEGRAM_AUTH_REQUIRED, 401, message);
957
+ }
958
+ };
959
+ var TelegramConnectionError = class extends DomainError {
960
+ constructor(message = "Could not reach Telegram", details) {
961
+ super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details);
405
962
  }
406
963
  };
964
+ var TelegramFileNotFoundError = class extends DomainError {
965
+ constructor(message = "The stored file is missing from Telegram") {
966
+ super(ERROR_CODES.TELEGRAM_FILE_NOT_FOUND, 404, message);
967
+ }
968
+ };
969
+ var UploadFailedError = class extends DomainError {
970
+ constructor(message = "Upload failed", details) {
971
+ super(ERROR_CODES.UPLOAD_FAILED, 502, message, details);
972
+ }
973
+ };
974
+ var OperationCancelledError = class extends DomainError {
975
+ constructor(message = "Operation cancelled") {
976
+ super(ERROR_CODES.INVALID_REQUEST, 499, message);
977
+ }
978
+ };
979
+ function mapProviderError(message, error) {
980
+ if (error instanceof DomainError) return error;
981
+ const detail = error instanceof Error ? error.message : String(error);
982
+ return new UploadFailedError(detail ? `${message}: ${detail}` : message, error);
983
+ }
984
+
985
+ // ../core/src/storage/engine.ts
986
+ var import_node_stream = require("stream");
407
987
 
408
988
  // ../core/src/utils/hash.ts
409
989
  var import_node_crypto = require("crypto");
@@ -412,49 +992,237 @@ function sha256Hex(data) {
412
992
  }
413
993
 
414
994
  // ../core/src/storage/engine.ts
995
+ 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
+ function delay(ms, signal) {
1006
+ return new Promise((resolve, reject) => {
1007
+ if (signal?.aborted) {
1008
+ reject(new OperationCancelledError());
1009
+ return;
1010
+ }
1011
+ const timer = setTimeout(() => {
1012
+ signal?.removeEventListener("abort", onAbort);
1013
+ resolve();
1014
+ }, ms);
1015
+ function onAbort() {
1016
+ clearTimeout(timer);
1017
+ reject(new OperationCancelledError());
1018
+ }
1019
+ signal?.addEventListener("abort", onAbort, { once: true });
1020
+ });
1021
+ }
1022
+ function throwIfAborted(signal) {
1023
+ if (signal?.aborted) throw new OperationCancelledError();
1024
+ }
415
1025
  var StorageEngine = class {
416
- constructor(provider) {
1026
+ constructor(provider, retryPolicy = DEFAULT_RETRY_POLICY) {
417
1027
  this.provider = provider;
1028
+ this.retryPolicy = retryPolicy;
418
1029
  }
419
1030
  provider;
1031
+ retryPolicy;
420
1032
  get providerName() {
421
1033
  return this.provider.name;
422
1034
  }
423
- /** Uploads an object and computes its checksum in one step. */
424
- async upload(input) {
425
- const sha256 = sha256Hex(input.data);
426
- let stored;
1035
+ /**
1036
+ * Uploads an object, computing its SHA-256 as it goes and reporting
1037
+ * progress. Retries transient failures with exponential backoff.
1038
+ */
1039
+ 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 });
1043
+ const stored = await this.withRetry(
1044
+ () => this.provider.put({ ...input, data }, control),
1045
+ control,
1046
+ (error) => new UploadFailedError("Upload failed", error)
1047
+ );
1048
+ control.onProgress?.({ transferred: data.byteLength, total: data.byteLength, percent: 100 });
1049
+ return { stored, sha256, size: data.byteLength };
1050
+ }
1051
+ async download(ref, control = {}) {
1052
+ return this.withRetry(
1053
+ () => this.provider.get(ref, control),
1054
+ control,
1055
+ (error) => mapProviderError("Download failed", error)
1056
+ );
1057
+ }
1058
+ /** Streams the object, computing the checksum on the fly when requested. */
1059
+ async downloadStream(ref, control = {}) {
1060
+ const stream = await this.withRetry(
1061
+ () => this.provider.getStream(ref, control),
1062
+ control,
1063
+ (error) => mapProviderError("Download failed", error)
1064
+ );
1065
+ return { stream, hash: () => null };
1066
+ }
1067
+ async remove(ref) {
1068
+ await this.withRetry(
1069
+ () => this.provider.delete(ref),
1070
+ {},
1071
+ (error) => mapProviderError("Delete failed", error)
1072
+ );
1073
+ }
1074
+ async exists(ref) {
427
1075
  try {
428
- stored = await this.provider.put(input);
1076
+ return await this.provider.exists(ref);
429
1077
  } catch (error) {
430
- throw mapProviderError("Upload failed", error);
1078
+ throw mapProviderError("Storage lookup failed", error);
431
1079
  }
432
- return { stored, sha256, size: input.data.byteLength };
433
1080
  }
434
- async download(ref) {
1081
+ async stat(ref) {
435
1082
  try {
436
- return await this.provider.get(ref);
1083
+ return await this.provider.stat(ref);
437
1084
  } catch (error) {
438
- throw mapProviderError("Download failed", error);
1085
+ throw mapProviderError("Storage lookup failed", error);
439
1086
  }
440
1087
  }
441
- async remove(ref) {
1088
+ async health() {
442
1089
  try {
443
- await this.provider.delete(ref);
1090
+ return await this.provider.healthCheck();
444
1091
  } catch (error) {
445
- throw mapProviderError("Delete failed", error);
1092
+ return {
1093
+ healthy: false,
1094
+ latencyMs: null,
1095
+ message: error instanceof Error ? error.message : "Storage health check failed",
1096
+ targetTitle: null
1097
+ };
446
1098
  }
447
1099
  }
448
1100
  async verifyIntegrity(data, expectedSha256) {
449
1101
  return sha256Hex(data) === expectedSha256;
450
1102
  }
1103
+ // ── 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
+ async withRetry(operation, control, wrapError) {
1117
+ const { attempts, baseDelayMs } = this.retryPolicy;
1118
+ let lastError;
1119
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
1120
+ throwIfAborted(control.signal);
1121
+ try {
1122
+ return await operation();
1123
+ } catch (error) {
1124
+ lastError = error;
1125
+ if (attempt >= attempts || !isRetryable(error)) break;
1126
+ await delay(baseDelayMs * 2 ** (attempt - 1), control.signal);
1127
+ }
1128
+ }
1129
+ throw wrapError(lastError);
1130
+ }
451
1131
  };
452
- function mapProviderError(message, error) {
453
- if (error instanceof StorageProviderError) return error;
454
- const detail = error instanceof Error ? error.message : String(error);
455
- return new StorageProviderError(detail ? `${message}: ${detail}` : message, error);
1132
+
1133
+ // ../core/src/services/query-resolver.ts
1134
+ async function resolveItemQuery(folders, userId, input) {
1135
+ const query = {};
1136
+ if (input.folderId !== void 0) query.folderId = input.folderId;
1137
+ if (input.q && input.q.trim()) query.nameContains = input.q.trim();
1138
+ if (input.ext) query.ext = input.ext.replace(/^\./, "").toLowerCase();
1139
+ if (input.starred !== void 0) query.starred = input.starred;
1140
+ if (input.minSize !== void 0) query.minSize = input.minSize;
1141
+ if (input.maxSize !== void 0) query.maxSize = input.maxSize;
1142
+ if (input.sort) query.sort = input.sort;
1143
+ if (input.order) query.order = input.order;
1144
+ switch (input.status) {
1145
+ case "active":
1146
+ query.trashed = false;
1147
+ break;
1148
+ case "trashed":
1149
+ query.trashed = true;
1150
+ break;
1151
+ default:
1152
+ query.trashed = void 0;
1153
+ break;
1154
+ }
1155
+ if (input.type) {
1156
+ const matchers = mimeMatchersForType(input.type);
1157
+ if (matchers) query.mimeMatchers = matchers;
1158
+ }
1159
+ if (input.from) {
1160
+ const from = new Date(input.from);
1161
+ if (!Number.isNaN(from.getTime())) query.from = from;
1162
+ }
1163
+ if (input.to) {
1164
+ const to = new Date(input.to);
1165
+ if (!Number.isNaN(to.getTime())) query.to = to;
1166
+ }
1167
+ if (input.folderName) {
1168
+ query.folderIds = await folders.findIdsByName(userId, input.folderName);
1169
+ }
1170
+ return query;
456
1171
  }
457
1172
 
1173
+ // ../core/src/services/activity-service.ts
1174
+ var ActivityService = class {
1175
+ constructor(activity, retentionDays = 90) {
1176
+ this.activity = activity;
1177
+ this.retentionDays = retentionDays;
1178
+ }
1179
+ activity;
1180
+ retentionDays;
1181
+ /**
1182
+ * Records an event. Never throws — a logging failure must not fail the
1183
+ * user-facing operation that produced it.
1184
+ */
1185
+ async record(input) {
1186
+ try {
1187
+ await this.activity.record({
1188
+ userId: input.userId,
1189
+ action: input.action,
1190
+ resourceType: input.resourceType,
1191
+ resourceId: input.resourceId,
1192
+ resourceName: input.resourceName ?? null,
1193
+ metadata: sanitizeMetadata(input.metadata)
1194
+ });
1195
+ } catch {
1196
+ }
1197
+ }
1198
+ async list(userId, page) {
1199
+ return this.activity.list(userId, page);
1200
+ }
1201
+ /** Most recent distinct files for the given actions, for the Recent view. */
1202
+ async recentFiles(userId, actions, limit) {
1203
+ return this.activity.recentFiles(userId, actions, limit);
1204
+ }
1205
+ async prune() {
1206
+ const cutoff = new Date(Date.now() - this.retentionDays * 24 * 60 * 60 * 1e3);
1207
+ return this.activity.pruneOlderThan(cutoff);
1208
+ }
1209
+ };
1210
+ var SENSITIVE_KEY = /(password|secret|token|session|hash|credential)/i;
1211
+ function sanitizeMetadata(metadata) {
1212
+ if (!metadata) return null;
1213
+ const clean = {};
1214
+ for (const [key, value] of Object.entries(metadata)) {
1215
+ if (SENSITIVE_KEY.test(key)) continue;
1216
+ if (value === void 0) continue;
1217
+ clean[key] = value;
1218
+ }
1219
+ return Object.keys(clean).length > 0 ? clean : null;
1220
+ }
1221
+ var noopActivityRecorder = {
1222
+ async record() {
1223
+ }
1224
+ };
1225
+
458
1226
  // ../core/src/utils/filename.ts
459
1227
  function sanitizeFileName(name) {
460
1228
  const base = name.split(/[/\\]/).pop() ?? name;
@@ -465,170 +1233,588 @@ function sanitizeFileName(name) {
465
1233
 
466
1234
  // ../core/src/services/file-service.ts
467
1235
  var FileService = class {
468
- constructor(files, folders, engineFor) {
1236
+ constructor(files, folders, engineFor, activity = noopActivityRecorder) {
469
1237
  this.files = files;
470
1238
  this.folders = folders;
471
1239
  this.engineFor = engineFor;
1240
+ this.activity = activity;
472
1241
  }
473
1242
  files;
474
1243
  folders;
475
1244
  engineFor;
1245
+ activity;
1246
+ // ── Upload ───────────────────────────────────────────────────────────────
476
1247
  async upload(userId, input) {
477
1248
  const name = sanitizeFileName(input.name);
478
1249
  if (!name) throw new ValidationError("Invalid file name");
479
- if (input.folderId !== null) {
480
- const folder = await this.folders.findById(input.folderId);
481
- if (!folder || folder.userId !== userId) throw new NotFoundError("Folder not found");
1250
+ let folderId = input.folderId;
1251
+ if (input.replaceFileId) {
1252
+ const existing = await this.get(userId, input.replaceFileId);
1253
+ folderId = existing.folderId;
1254
+ } else if (folderId !== null) {
1255
+ await this.assertFolder(userId, folderId);
482
1256
  }
483
1257
  const mimeType = mimeFromFilename(name);
484
1258
  const engine = await this.engineFor(userId);
485
- const { stored, sha256, size } = await engine.upload({ name, mimeType, data: input.data });
486
- return this.files.create({
1259
+ const control = {
1260
+ onProgress: input.onProgress,
1261
+ signal: input.signal
1262
+ };
1263
+ const { stored, sha256, size } = await engine.upload(
1264
+ { name, mimeType, data: input.data },
1265
+ control
1266
+ );
1267
+ const telegramMessageId = Number(stored.messageId);
1268
+ if (input.replaceFileId) {
1269
+ return this.replaceVersion(userId, input.replaceFileId, {
1270
+ name,
1271
+ size,
1272
+ mimeType,
1273
+ sha256,
1274
+ telegramMessageId
1275
+ });
1276
+ }
1277
+ const record = await this.files.create({
487
1278
  userId,
488
- folderId: input.folderId,
1279
+ folderId,
489
1280
  name,
490
1281
  size,
491
1282
  mimeType,
492
1283
  sha256,
493
- telegramMessageId: Number(stored.messageId)
1284
+ telegramMessageId
494
1285
  });
1286
+ const version = await this.files.createVersion({
1287
+ fileId: record.id,
1288
+ userId,
1289
+ size,
1290
+ mimeType,
1291
+ sha256,
1292
+ telegramMessageId
1293
+ });
1294
+ const withVersion = await this.files.update(record.id, {
1295
+ currentVersionId: version.id,
1296
+ versionCount: 1
1297
+ });
1298
+ await this.activity.record({
1299
+ userId,
1300
+ action: "upload",
1301
+ resourceType: "file",
1302
+ resourceId: withVersion.id,
1303
+ resourceName: withVersion.name,
1304
+ metadata: { size, mimeType }
1305
+ });
1306
+ return withVersion;
1307
+ }
1308
+ /** Uploads a new version of an existing file and makes it current. */
1309
+ async replaceVersion(userId, fileId, next) {
1310
+ const existing = await this.get(userId, fileId);
1311
+ const version = await this.files.createVersion({
1312
+ fileId,
1313
+ userId,
1314
+ size: next.size,
1315
+ mimeType: next.mimeType,
1316
+ sha256: next.sha256,
1317
+ telegramMessageId: next.telegramMessageId
1318
+ });
1319
+ const count = await this.files.countVersions(fileId);
1320
+ const updated = await this.files.update(fileId, {
1321
+ name: next.name,
1322
+ size: next.size,
1323
+ mimeType: next.mimeType,
1324
+ sha256: next.sha256,
1325
+ telegramMessageId: next.telegramMessageId,
1326
+ currentVersionId: version.id,
1327
+ versionCount: count
1328
+ });
1329
+ await this.activity.record({
1330
+ userId,
1331
+ action: "replace",
1332
+ resourceType: "file",
1333
+ resourceId: fileId,
1334
+ resourceName: updated.name,
1335
+ metadata: { previousSha256: existing.sha256, size: next.size }
1336
+ });
1337
+ return updated;
495
1338
  }
1339
+ // ── Reads ────────────────────────────────────────────────────────────────
496
1340
  async list(userId, folderId) {
497
- if (folderId !== null) {
498
- const folder = await this.folders.findById(folderId);
499
- if (!folder || folder.userId !== userId) throw new NotFoundError("Folder not found");
500
- }
1341
+ if (folderId !== null) await this.assertFolder(userId, folderId);
501
1342
  return this.files.listByFolder(userId, folderId);
502
1343
  }
1344
+ async query(userId, input, page) {
1345
+ if (input.folderId != null) await this.assertFolder(userId, input.folderId);
1346
+ const query = await resolveItemQuery(this.folders, userId, input);
1347
+ return this.files.query(userId, query, page);
1348
+ }
503
1349
  async get(userId, id) {
504
1350
  const record = await this.files.findById(id);
505
- if (!record || record.userId !== userId) throw new NotFoundError("File not found");
1351
+ if (!record || record.userId !== userId) throw new NotFoundError("File not found", "file");
506
1352
  return record;
507
1353
  }
508
- async rename(userId, id, name) {
1354
+ /** Like get(), but rejects trashed files (for download/open). */
1355
+ async getActive(userId, id) {
1356
+ const record = await this.get(userId, id);
1357
+ if (record.deletedAt) throw new NotFoundError("File not found", "file");
1358
+ return record;
1359
+ }
1360
+ async listVersions(userId, id) {
509
1361
  await this.get(userId, id);
1362
+ return this.files.listVersions(id);
1363
+ }
1364
+ // ── Mutations ────────────────────────────────────────────────────────────
1365
+ async rename(userId, id, name) {
1366
+ const existing = await this.get(userId, id);
510
1367
  const clean = sanitizeFileName(name);
511
1368
  if (!clean) throw new ValidationError("Invalid file name");
512
- return this.files.update(id, { name: clean });
1369
+ const updated = await this.files.update(id, { name: clean });
1370
+ await this.activity.record({
1371
+ userId,
1372
+ action: "rename",
1373
+ resourceType: "file",
1374
+ resourceId: id,
1375
+ resourceName: clean,
1376
+ metadata: { previousName: existing.name }
1377
+ });
1378
+ return updated;
513
1379
  }
514
1380
  async move(userId, id, folderId) {
1381
+ const existing = await this.get(userId, id);
1382
+ if (folderId !== null) await this.assertFolder(userId, folderId);
1383
+ const updated = await this.files.update(id, { folderId });
1384
+ await this.activity.record({
1385
+ userId,
1386
+ action: "move",
1387
+ resourceType: "file",
1388
+ resourceId: id,
1389
+ resourceName: updated.name,
1390
+ metadata: { from: existing.folderId, to: folderId }
1391
+ });
1392
+ return updated;
1393
+ }
1394
+ async setStarred(userId, id, starred) {
515
1395
  await this.get(userId, id);
1396
+ const updated = await this.files.update(id, { starred });
1397
+ await this.activity.record({
1398
+ userId,
1399
+ action: starred ? "star" : "unstar",
1400
+ resourceType: "file",
1401
+ resourceId: id,
1402
+ resourceName: updated.name
1403
+ });
1404
+ return updated;
1405
+ }
1406
+ /**
1407
+ * Moves a file to Trash (soft delete). The remote object is untouched, so
1408
+ * restore is lossless.
1409
+ */
1410
+ async trash(userId, id) {
1411
+ const record = await this.get(userId, id);
1412
+ if (record.deletedAt) return record;
1413
+ const updated = await this.files.update(id, {
1414
+ deletedAt: /* @__PURE__ */ new Date(),
1415
+ trashBatchId: `solo-${id}`
1416
+ });
1417
+ await this.activity.record({
1418
+ userId,
1419
+ action: "trash",
1420
+ resourceType: "file",
1421
+ resourceId: id,
1422
+ resourceName: updated.name
1423
+ });
1424
+ return updated;
1425
+ }
1426
+ /** Restores a trashed file to its previous folder (or root). */
1427
+ async restore(userId, id) {
1428
+ const record = await this.get(userId, id);
1429
+ let folderId = record.folderId;
516
1430
  if (folderId !== null) {
517
1431
  const folder = await this.folders.findById(folderId);
518
- if (!folder || folder.userId !== userId) throw new NotFoundError("Folder not found");
1432
+ if (!folder || folder.userId !== userId || folder.deletedAt) folderId = null;
519
1433
  }
520
- return this.files.update(id, { folderId });
1434
+ const updated = await this.files.update(id, { deletedAt: null, trashBatchId: null, folderId });
1435
+ await this.activity.record({
1436
+ userId,
1437
+ action: "restore",
1438
+ resourceType: "file",
1439
+ resourceId: id,
1440
+ resourceName: updated.name
1441
+ });
1442
+ return updated;
521
1443
  }
522
- async delete(userId, id) {
1444
+ /**
1445
+ * Permanently deletes a file: the Telegram object is removed first, then the
1446
+ * metadata. If the remote delete fails we keep the metadata so the user can
1447
+ * retry, rather than silently orphaning the storage object.
1448
+ */
1449
+ async deletePermanently(userId, id) {
523
1450
  const record = await this.get(userId, id);
524
1451
  const engine = await this.engineFor(userId);
525
- try {
526
- await engine.remove({ messageId: String(record.telegramMessageId) });
527
- } catch {
1452
+ await engine.remove({ messageId: String(record.telegramMessageId) });
1453
+ 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
+ }
1460
+ }
1461
+ await this.files.deleteVersionsByFileIds([id]);
1462
+ await this.files.deleteMany([id]);
1463
+ await this.activity.record({
1464
+ userId,
1465
+ action: "delete",
1466
+ resourceType: "file",
1467
+ resourceId: id,
1468
+ resourceName: record.name
1469
+ });
1470
+ }
1471
+ /**
1472
+ * v0.1-compatible hard delete used by the current API: permanently removes
1473
+ * the file. Retained so existing clients keep working.
1474
+ */
1475
+ async delete(userId, id) {
1476
+ await this.deletePermanently(userId, id);
1477
+ }
1478
+ // ── Download ─────────────────────────────────────────────────────────────
1479
+ async download(userId, id, control = {}) {
1480
+ const record = await this.getActive(userId, id);
1481
+ const engine = await this.engineFor(userId);
1482
+ const data = await engine.download({ messageId: String(record.telegramMessageId) }, control);
1483
+ const integrityVerified = await engine.verifyIntegrity(data, record.sha256);
1484
+ await this.activity.record({
1485
+ userId,
1486
+ action: "download",
1487
+ resourceType: "file",
1488
+ resourceId: id,
1489
+ resourceName: record.name,
1490
+ metadata: { size: record.size, integrityVerified }
1491
+ });
1492
+ return { record, data, integrityVerified };
1493
+ }
1494
+ /** Records an "open" event (the UI opening the details panel, say). */
1495
+ async touch(userId, id, action = "open") {
1496
+ const record = await this.get(userId, id);
1497
+ await this.activity.record({
1498
+ userId,
1499
+ action,
1500
+ resourceType: "file",
1501
+ resourceId: id,
1502
+ resourceName: record.name
1503
+ });
1504
+ }
1505
+ // ── Batch operations ─────────────────────────────────────────────────────
1506
+ /**
1507
+ * Applies an operation to many files, collecting per-item failures instead
1508
+ * of aborting the whole batch.
1509
+ */
1510
+ async batch(userId, ids, operation, options = {}) {
1511
+ const errors = [];
1512
+ let succeeded = 0;
1513
+ if (ids.length === 0) throw new ValidationError("No items selected");
1514
+ if (operation === "move") {
1515
+ const target = options.folderId ?? null;
1516
+ if (target !== null) await this.assertFolder(userId, target);
1517
+ }
1518
+ for (const id of ids) {
1519
+ try {
1520
+ switch (operation) {
1521
+ case "trash":
1522
+ await this.trash(userId, id);
1523
+ break;
1524
+ case "restore":
1525
+ await this.restore(userId, id);
1526
+ break;
1527
+ case "delete":
1528
+ await this.deletePermanently(userId, id);
1529
+ break;
1530
+ case "star":
1531
+ await this.setStarred(userId, id, true);
1532
+ break;
1533
+ case "unstar":
1534
+ await this.setStarred(userId, id, false);
1535
+ break;
1536
+ case "move":
1537
+ await this.move(userId, id, options.folderId ?? null);
1538
+ break;
1539
+ }
1540
+ succeeded += 1;
1541
+ } catch (error) {
1542
+ errors.push({ id, message: error instanceof Error ? error.message : "Operation failed" });
1543
+ }
528
1544
  }
529
- await this.files.deleteMany([record.id]);
1545
+ return { requested: ids.length, succeeded, failed: errors.length, errors };
530
1546
  }
531
- async download(userId, id) {
532
- const record = await this.get(userId, id);
533
- const engine = await this.engineFor(userId);
534
- const data = await engine.download({ messageId: String(record.telegramMessageId) });
535
- const integrityVerified = await engine.verifyIntegrity(data, record.sha256);
536
- return { record, data, integrityVerified };
1547
+ // ── internals ────────────────────────────────────────────────────────────
1548
+ async assertFolder(userId, folderId) {
1549
+ const folder = await this.folders.findById(folderId);
1550
+ if (!folder || folder.userId !== userId) {
1551
+ throw new NotFoundError("Folder not found", "folder");
1552
+ }
1553
+ if (folder.deletedAt) throw new ConflictError("The target folder is in the Trash");
537
1554
  }
538
1555
  };
539
1556
 
540
1557
  // ../core/src/services/folder-service.ts
541
1558
  var FolderService = class {
542
- constructor(folders, files, engineFor) {
1559
+ constructor(folders, files, engineFor, activity = noopActivityRecorder) {
543
1560
  this.folders = folders;
544
1561
  this.files = files;
545
1562
  this.engineFor = engineFor;
1563
+ this.activity = activity;
546
1564
  }
547
1565
  folders;
548
1566
  files;
549
1567
  engineFor;
1568
+ activity;
1569
+ // ── Create / read ────────────────────────────────────────────────────────
550
1570
  async create(userId, input) {
551
1571
  const name = sanitizeFileName(input.name);
552
1572
  if (!name) throw new ValidationError("Invalid folder name");
553
1573
  if (input.parentId !== null) {
554
- const parent = await this.folders.findById(input.parentId);
555
- if (!parent || parent.userId !== userId) throw new NotFoundError("Folder not found");
1574
+ const parent = await this.get(userId, input.parentId);
1575
+ if (parent.deletedAt) throw new ConflictError("The parent folder is in the Trash");
556
1576
  }
557
- return this.folders.create({ userId, parentId: input.parentId, name });
1577
+ const folder = await this.folders.create({ userId, parentId: input.parentId, name });
1578
+ await this.activity.record({
1579
+ userId,
1580
+ action: "create_folder",
1581
+ resourceType: "folder",
1582
+ resourceId: folder.id,
1583
+ resourceName: folder.name
1584
+ });
1585
+ return folder;
558
1586
  }
1587
+ /** Active folders directly inside a folder (null = root). */
559
1588
  async listChildren(userId, parentId) {
560
- if (parentId !== null) {
561
- const parent = await this.folders.findById(parentId);
562
- if (!parent || parent.userId !== userId) throw new NotFoundError("Folder not found");
563
- }
1589
+ if (parentId !== null) await this.get(userId, parentId);
564
1590
  return this.folders.listChildren(userId, parentId);
565
1591
  }
1592
+ async query(userId, input, page) {
1593
+ const query = await resolveItemQuery(this.folders, userId, input);
1594
+ return this.folders.query(userId, query, page);
1595
+ }
1596
+ /** Every active folder for the user (used to build trees). */
566
1597
  async tree(userId) {
567
- return this.folders.listByUser(userId);
1598
+ const all = await this.folders.listByUser(userId);
1599
+ return all.filter((folder) => folder.deletedAt === null);
568
1600
  }
569
1601
  async get(userId, id) {
570
1602
  const record = await this.folders.findById(id);
571
- if (!record || record.userId !== userId) throw new NotFoundError("Folder not found");
1603
+ if (!record || record.userId !== userId) throw new NotFoundError("Folder not found", "folder");
572
1604
  return record;
573
1605
  }
1606
+ // ── Mutations ────────────────────────────────────────────────────────────
574
1607
  async rename(userId, id, name) {
575
- await this.get(userId, id);
1608
+ const existing = await this.get(userId, id);
576
1609
  const clean = sanitizeFileName(name);
577
1610
  if (!clean) throw new ValidationError("Invalid folder name");
578
- return this.folders.update(id, { name: clean });
1611
+ const updated = await this.folders.update(id, { name: clean });
1612
+ await this.activity.record({
1613
+ userId,
1614
+ action: "rename",
1615
+ resourceType: "folder",
1616
+ resourceId: id,
1617
+ resourceName: clean,
1618
+ metadata: { previousName: existing.name }
1619
+ });
1620
+ return updated;
579
1621
  }
580
1622
  async move(userId, id, newParentId) {
581
- await this.get(userId, id);
1623
+ const existing = await this.get(userId, id);
582
1624
  if (newParentId === id) throw new ValidationError("Cannot move a folder into itself");
583
1625
  if (newParentId !== null) {
1626
+ const target = await this.get(userId, newParentId);
1627
+ if (target.deletedAt) throw new ConflictError("The target folder is in the Trash");
584
1628
  let cursor = newParentId;
585
1629
  while (cursor !== null) {
586
1630
  if (cursor === id) {
587
1631
  throw new ValidationError("Cannot move a folder into one of its subfolders");
588
1632
  }
589
1633
  const parent = await this.folders.findById(cursor);
590
- if (!parent || parent.userId !== userId) throw new NotFoundError("Folder not found");
1634
+ if (!parent || parent.userId !== userId)
1635
+ throw new NotFoundError("Folder not found", "folder");
591
1636
  cursor = parent.parentId;
592
1637
  }
593
1638
  }
594
- return this.folders.update(id, { parentId: newParentId });
1639
+ const updated = await this.folders.update(id, { parentId: newParentId });
1640
+ await this.activity.record({
1641
+ userId,
1642
+ action: "move",
1643
+ resourceType: "folder",
1644
+ resourceId: id,
1645
+ resourceName: updated.name,
1646
+ metadata: { from: existing.parentId, to: newParentId }
1647
+ });
1648
+ return updated;
595
1649
  }
596
- async delete(userId, id) {
1650
+ async setStarred(userId, id, starred) {
1651
+ await this.get(userId, id);
1652
+ const updated = await this.folders.update(id, { starred });
1653
+ await this.activity.record({
1654
+ userId,
1655
+ action: starred ? "star" : "unstar",
1656
+ resourceType: "folder",
1657
+ resourceId: id,
1658
+ resourceName: updated.name
1659
+ });
1660
+ return updated;
1661
+ }
1662
+ // ── Trash lifecycle ──────────────────────────────────────────────────────
1663
+ /**
1664
+ * Moves a folder and its entire subtree to the Trash. Nothing is deleted
1665
+ * from Telegram, so restore is lossless.
1666
+ */
1667
+ async trash(userId, id) {
1668
+ const record = await this.get(userId, id);
1669
+ if (record.deletedAt) {
1670
+ return { affectedFolders: 1, affectedFiles: 0 };
1671
+ }
1672
+ const subtree = await this.collectSubtree(userId, id);
1673
+ const fileIds = await this.collectFileIds(userId, subtree);
1674
+ const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1675
+ const stampedAt = /* @__PURE__ */ new Date();
1676
+ await this.folders.updateMany(
1677
+ subtree.map((folder) => folder.id),
1678
+ { deletedAt: stampedAt, trashBatchId: batchId }
1679
+ );
1680
+ await this.files.updateMany(fileIds, { deletedAt: stampedAt, trashBatchId: batchId });
1681
+ await this.activity.record({
1682
+ userId,
1683
+ action: "trash",
1684
+ resourceType: "folder",
1685
+ resourceId: id,
1686
+ resourceName: record.name,
1687
+ metadata: { folders: subtree.length, files: fileIds.length }
1688
+ });
1689
+ return { affectedFolders: subtree.length, affectedFiles: fileIds.length };
1690
+ }
1691
+ /**
1692
+ * Restores a folder subtree. Only nodes trashed at the same instant as the
1693
+ * folder itself are restored, so items the user trashed separately stay put.
1694
+ */
1695
+ async restore(userId, id) {
1696
+ const record = await this.get(userId, id);
1697
+ let parentId = record.parentId;
1698
+ if (parentId !== null) {
1699
+ const parent = await this.folders.findById(parentId);
1700
+ if (!parent || parent.deletedAt) parentId = null;
1701
+ }
1702
+ const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
1703
+ const batchId = record.trashBatchId;
1704
+ const belongsToBatch = (deletedAt, marker) => deletedAt !== null && (batchId !== null ? marker === batchId : record.deletedAt !== null && deletedAt.getTime() === record.deletedAt.getTime());
1705
+ const restorableFolders = subtree.filter(
1706
+ (folder) => belongsToBatch(folder.deletedAt, folder.trashBatchId)
1707
+ );
1708
+ const restorableFolderIds = new Set(restorableFolders.map((folder) => folder.id));
1709
+ const allFiles = await this.files.listByUser(userId);
1710
+ const restorableFiles = allFiles.filter(
1711
+ (file) => file.folderId !== null && restorableFolderIds.has(file.folderId) && belongsToBatch(file.deletedAt, file.trashBatchId)
1712
+ );
1713
+ const restorableFileIds = restorableFiles.map((file) => file.id);
1714
+ if (restorableFolders.length > 0) {
1715
+ await this.folders.updateMany(
1716
+ restorableFolders.map((folder) => folder.id),
1717
+ { deletedAt: null, trashBatchId: null }
1718
+ );
1719
+ await this.folders.update(id, { parentId });
1720
+ }
1721
+ if (restorableFileIds.length > 0) {
1722
+ await this.files.updateMany(restorableFileIds, { deletedAt: null, trashBatchId: null });
1723
+ }
1724
+ await this.activity.record({
1725
+ userId,
1726
+ action: "restore",
1727
+ resourceType: "folder",
1728
+ resourceId: id,
1729
+ resourceName: record.name,
1730
+ metadata: { folders: restorableFolders.length, files: restorableFiles.length }
1731
+ });
1732
+ return { affectedFolders: restorableFolders.length, affectedFiles: restorableFiles.length };
1733
+ }
1734
+ /**
1735
+ * Permanently deletes a folder subtree: remote objects first, then
1736
+ * metadata. Files whose remote delete fails are left in the Trash so the
1737
+ * user can retry rather than losing data silently.
1738
+ */
1739
+ async deletePermanently(userId, id) {
597
1740
  await this.get(userId, id);
1741
+ const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
1742
+ const folderIds = subtree.map((folder) => folder.id);
1743
+ const allFiles = await this.files.listByUser(userId);
1744
+ const affected = allFiles.filter(
1745
+ (file) => file.folderId !== null && folderIds.includes(file.folderId)
1746
+ );
1747
+ const engine = await this.engineFor(userId);
1748
+ const removedFileIds = [];
1749
+ for (const file of affected) {
1750
+ try {
1751
+ await engine.remove({ messageId: String(file.telegramMessageId) });
1752
+ removedFileIds.push(file.id);
1753
+ } catch {
1754
+ }
1755
+ }
1756
+ if (removedFileIds.length > 0) {
1757
+ await this.files.deleteVersionsByFileIds(removedFileIds);
1758
+ await this.files.deleteMany(removedFileIds);
1759
+ }
1760
+ const remainingFolderIds = new Set(
1761
+ affected.filter((file) => !removedFileIds.includes(file.id)).map((file) => file.folderId)
1762
+ );
1763
+ const deletable = folderIds.filter((folderId) => !remainingFolderIds.has(folderId));
1764
+ if (deletable.length > 0) {
1765
+ await this.folders.deleteMany(deletable);
1766
+ }
1767
+ await this.activity.record({
1768
+ userId,
1769
+ action: "delete",
1770
+ resourceType: "folder",
1771
+ resourceId: id,
1772
+ resourceName: subtree[0]?.name ?? "folder",
1773
+ metadata: { folders: deletable.length, files: removedFileIds.length }
1774
+ });
1775
+ return { affectedFolders: deletable.length, affectedFiles: removedFileIds.length };
1776
+ }
1777
+ /**
1778
+ * v0.1-compatible recursive delete. Now routes through the v0.2 permanent
1779
+ * delete so remote cleanup and orphan prevention are consistent.
1780
+ */
1781
+ async delete(userId, id) {
1782
+ return this.deletePermanently(userId, id);
1783
+ }
1784
+ // ── internals ────────────────────────────────────────────────────────────
1785
+ /** The folder plus every descendant (BFS), optionally including trashed. */
1786
+ async collectSubtree(userId, rootId, options = {}) {
598
1787
  const all = await this.folders.listByUser(userId);
599
1788
  const childrenOf = /* @__PURE__ */ new Map();
600
1789
  for (const folder of all) {
601
1790
  const list = childrenOf.get(folder.parentId) ?? [];
602
- list.push(folder.id);
1791
+ list.push(folder);
603
1792
  childrenOf.set(folder.parentId, list);
604
1793
  }
605
- const doomed = [id];
606
- const queue = [id];
1794
+ const root = all.find((folder) => folder.id === rootId);
1795
+ if (!root) throw new NotFoundError("Folder not found", "folder");
1796
+ const collected = [root];
1797
+ const queue = [rootId];
607
1798
  while (queue.length > 0) {
608
1799
  const current = queue.shift();
609
1800
  for (const child of childrenOf.get(current) ?? []) {
610
- doomed.push(child);
611
- queue.push(child);
612
- }
613
- }
614
- const doomedSet = new Set(doomed);
615
- const userFiles = await this.files.listByUser(userId);
616
- const affectedFiles = userFiles.filter((f) => f.folderId !== null && doomedSet.has(f.folderId));
617
- const engine = await this.engineFor(userId);
618
- for (const file of affectedFiles) {
619
- try {
620
- await engine.remove({ messageId: String(file.telegramMessageId) });
621
- } catch {
1801
+ if (!options.includeTrashed && child.deletedAt) continue;
1802
+ collected.push(child);
1803
+ queue.push(child.id);
622
1804
  }
623
1805
  }
624
- await this.files.deleteMany(affectedFiles.map((f) => f.id));
625
- await this.folders.deleteMany(doomed);
626
- return { deletedFolders: doomed.length, deletedFiles: affectedFiles.length };
1806
+ return collected;
1807
+ }
1808
+ /** Ids of files inside the given folders that are not already trashed. */
1809
+ async collectFileIds(userId, folders) {
1810
+ const ids = new Set(folders.map((folder) => folder.id));
1811
+ const allFiles = await this.files.listByUser(userId);
1812
+ return allFiles.filter((file) => file.deletedAt === null && file.folderId !== null && ids.has(file.folderId)).map((file) => file.id);
627
1813
  }
628
1814
  };
629
1815
 
630
1816
  // ../core/src/services/search-service.ts
631
- var MAX_QUERY_LENGTH = 100;
1817
+ var MAX_QUERY_LENGTH = 200;
632
1818
  var SearchService = class {
633
1819
  constructor(files, folders) {
634
1820
  this.files = files;
@@ -636,17 +1822,310 @@ var SearchService = class {
636
1822
  }
637
1823
  files;
638
1824
  folders;
639
- async search(userId, query, limit = 25) {
640
- const q = query.trim();
641
- if (!q) throw new ValidationError("Search query must not be empty");
642
- if (q.length > MAX_QUERY_LENGTH) {
1825
+ async search(userId, rawQuery, page, overrides = {}) {
1826
+ const trimmed = rawQuery.trim();
1827
+ if (!trimmed) throw new ValidationError("Search query must not be empty");
1828
+ if (trimmed.length > MAX_QUERY_LENGTH) {
643
1829
  throw new ValidationError(`Search query must be at most ${MAX_QUERY_LENGTH} characters`);
644
1830
  }
645
- const [fileResults, folderResults] = await Promise.all([
646
- this.files.searchByName(userId, q, limit),
647
- this.folders.searchByName(userId, q, limit)
1831
+ const { query } = parseSearchQuery(trimmed, overrides);
1832
+ if (query.status === void 0) query.status = "all";
1833
+ const itemQuery = await resolveItemQuery(this.folders, userId, query);
1834
+ const [filePage, folderPage] = await Promise.all([
1835
+ this.files.query(userId, itemQuery, page),
1836
+ this.folders.query(userId, itemQuery, page)
1837
+ ]);
1838
+ const total = filePage.total + folderPage.total;
1839
+ return {
1840
+ files: filePage.items,
1841
+ folders: folderPage.items,
1842
+ pagination: paginationMeta(page.page, page.limit, total)
1843
+ };
1844
+ }
1845
+ /** Convenience wrapper returning the shared DTO shape. */
1846
+ async searchDTO(userId, rawQuery, page) {
1847
+ return this.search(userId, rawQuery, page);
1848
+ }
1849
+ };
1850
+
1851
+ // ../core/src/services/stats-service.ts
1852
+ var LARGEST_FILES = 10;
1853
+ var StatsService = class {
1854
+ constructor(files, folders) {
1855
+ this.files = files;
1856
+ this.folders = folders;
1857
+ }
1858
+ files;
1859
+ folders;
1860
+ async stats(userId, quotaBytes = null) {
1861
+ const [counters, allFiles] = await Promise.all([
1862
+ this.files.statsByUser(userId),
1863
+ // Metadata-only aggregation; v0.2 datasets fit comfortably in memory.
1864
+ this.files.listByUser(userId)
1865
+ ]);
1866
+ const active = allFiles.filter((file) => file.deletedAt === null);
1867
+ const categoryBytes = /* @__PURE__ */ new Map();
1868
+ for (const file of active) {
1869
+ const category = fileCategory(file.mimeType);
1870
+ const bucket = categoryBytes.get(category) ?? { bytes: 0, count: 0 };
1871
+ bucket.bytes += file.size;
1872
+ bucket.count += 1;
1873
+ categoryBytes.set(category, bucket);
1874
+ }
1875
+ const byType = [...categoryBytes.entries()].map(([category, bucket]) => ({ category, bytes: bucket.bytes, count: bucket.count })).sort((a, b) => b.bytes - a.bytes);
1876
+ const largestFiles = [...active].sort((a, b) => b.size - a.size).slice(0, LARGEST_FILES);
1877
+ return {
1878
+ fileCount: counters.fileCount,
1879
+ folderCount: counters.folderCount,
1880
+ totalBytes: counters.totalBytes,
1881
+ trashBytes: counters.trashBytes,
1882
+ trashFileCount: counters.trashFileCount,
1883
+ starredCount: counters.starredCount,
1884
+ byType,
1885
+ largestFiles,
1886
+ quotaBytes
1887
+ };
1888
+ }
1889
+ };
1890
+
1891
+ // ../core/src/services/trash-service.ts
1892
+ var TrashService = class {
1893
+ constructor(files, folders, engineFor) {
1894
+ this.files = files;
1895
+ this.folders = folders;
1896
+ this.engineFor = engineFor;
1897
+ }
1898
+ files;
1899
+ folders;
1900
+ engineFor;
1901
+ async list(userId, page, extra = {}) {
1902
+ const itemQuery = await resolveItemQuery(this.folders, userId, {
1903
+ ...extra,
1904
+ status: "trashed",
1905
+ folderId: void 0
1906
+ });
1907
+ const [filePage, folderPage] = await Promise.all([
1908
+ this.files.query(userId, itemQuery, page),
1909
+ this.folders.query(userId, itemQuery, page)
648
1910
  ]);
649
- return { files: fileResults, folders: folderResults };
1911
+ const total = filePage.total + folderPage.total;
1912
+ return {
1913
+ files: filePage.items,
1914
+ folders: folderPage.items,
1915
+ pagination: paginationMeta(page.page, page.limit, total)
1916
+ };
1917
+ }
1918
+ /**
1919
+ * Permanently removes everything in the Trash for a user. Folders are
1920
+ * processed root-first so removing a parent also removes its contents.
1921
+ */
1922
+ async empty(userId) {
1923
+ const engine = await this.engineFor(userId);
1924
+ const allFiles = await this.files.listByUser(userId);
1925
+ const trashedFiles = allFiles.filter((file) => file.deletedAt !== null);
1926
+ let deletedFiles = 0;
1927
+ let failedFiles = 0;
1928
+ const deletedFileIds = [];
1929
+ for (const file of trashedFiles) {
1930
+ try {
1931
+ await engine.remove({ messageId: String(file.telegramMessageId) });
1932
+ deletedFileIds.push(file.id);
1933
+ deletedFiles += 1;
1934
+ } catch {
1935
+ failedFiles += 1;
1936
+ }
1937
+ }
1938
+ if (deletedFileIds.length > 0) {
1939
+ await this.files.deleteVersionsByFileIds(deletedFileIds);
1940
+ await this.files.deleteMany(deletedFileIds);
1941
+ }
1942
+ const allFolders = await this.folders.listByUser(userId);
1943
+ const trashedFolders = allFolders.filter((folder) => folder.deletedAt !== null);
1944
+ const depth = (folder) => {
1945
+ let level = 0;
1946
+ let cursor = folder.parentId;
1947
+ const seen = /* @__PURE__ */ new Set();
1948
+ while (cursor !== null && !seen.has(cursor)) {
1949
+ seen.add(cursor);
1950
+ const parent = allFolders.find((candidate) => candidate.id === cursor);
1951
+ if (!parent) break;
1952
+ level += 1;
1953
+ cursor = parent.parentId;
1954
+ }
1955
+ return level;
1956
+ };
1957
+ trashedFolders.sort((a, b) => depth(a) - depth(b));
1958
+ let deletedFolders = 0;
1959
+ for (const folder of trashedFolders) {
1960
+ const stillHasFiles = await this.files.query(
1961
+ userId,
1962
+ { folderId: folder.id, trashed: true },
1963
+ { page: 1, limit: 1 }
1964
+ );
1965
+ if (stillHasFiles.total > 0) continue;
1966
+ await this.folders.deleteMany([folder.id]);
1967
+ deletedFolders += 1;
1968
+ }
1969
+ return { deletedFiles, deletedFolders, failedFiles };
1970
+ }
1971
+ /** Restores a single trashed item (used by the batch restore endpoint). */
1972
+ async verifyTrashedFile(userId, id) {
1973
+ const file = await this.files.findById(id);
1974
+ if (!file || file.userId !== userId || file.deletedAt === null) {
1975
+ throw new NotFoundError("File is not in the Trash", "file");
1976
+ }
1977
+ return file;
1978
+ }
1979
+ };
1980
+
1981
+ // ../core/src/services/integrity-service.ts
1982
+ var IntegrityService = class {
1983
+ constructor(files, engineFor) {
1984
+ this.files = files;
1985
+ this.engineFor = engineFor;
1986
+ }
1987
+ files;
1988
+ engineFor;
1989
+ async check(userId, options = {}) {
1990
+ const startedAt = Date.now();
1991
+ const all = await this.files.listByUser(userId);
1992
+ const active = all.filter((file) => file.deletedAt === null);
1993
+ const targets = options.limit ? active.slice(0, options.limit) : active;
1994
+ const engine = await this.engineFor(userId);
1995
+ const issues = [];
1996
+ let healthy = 0;
1997
+ for (const file of targets) {
1998
+ const issue = await this.inspect(file, options.deep ?? false, engine);
1999
+ if (issue) issues.push(issue);
2000
+ else healthy += 1;
2001
+ }
2002
+ const missing = issues.filter((issue) => issue.kind === "missing").length;
2003
+ const inconsistent = issues.filter(
2004
+ (issue) => issue.kind === "size_mismatch" || issue.kind === "hash_mismatch"
2005
+ ).length;
2006
+ const unreadable = issues.filter((issue) => issue.kind === "unreadable").length;
2007
+ return {
2008
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2009
+ filesChecked: targets.length,
2010
+ healthy,
2011
+ missing,
2012
+ inconsistent,
2013
+ unreadable,
2014
+ issues,
2015
+ durationMs: Date.now() - startedAt
2016
+ };
2017
+ }
2018
+ async inspect(file, deep, engine) {
2019
+ const ref = { messageId: String(file.telegramMessageId) };
2020
+ let stat;
2021
+ try {
2022
+ stat = await engine.stat(ref);
2023
+ } catch (error) {
2024
+ return {
2025
+ fileId: file.id,
2026
+ name: file.name,
2027
+ kind: "unreadable",
2028
+ detail: error instanceof Error ? error.message : "Storage lookup failed"
2029
+ };
2030
+ }
2031
+ if (!stat) {
2032
+ return {
2033
+ fileId: file.id,
2034
+ name: file.name,
2035
+ kind: "missing",
2036
+ detail: "No message with this id exists in the storage channel"
2037
+ };
2038
+ }
2039
+ if (stat.size !== file.size) {
2040
+ return {
2041
+ fileId: file.id,
2042
+ name: file.name,
2043
+ kind: "size_mismatch",
2044
+ detail: `metadata=${file.size} storage=${stat.size}`
2045
+ };
2046
+ }
2047
+ if (deep) {
2048
+ 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,
2055
+ kind: "hash_mismatch",
2056
+ detail: "Downloaded bytes do not match the stored SHA-256"
2057
+ };
2058
+ }
2059
+ } catch (error) {
2060
+ return {
2061
+ fileId: file.id,
2062
+ name: file.name,
2063
+ kind: "unreadable",
2064
+ detail: error instanceof Error ? error.message : "Download failed"
2065
+ };
2066
+ }
2067
+ }
2068
+ return null;
2069
+ }
2070
+ };
2071
+
2072
+ // ../core/src/services/recent-service.ts
2073
+ var RECENT_ACTIONS = [
2074
+ "upload",
2075
+ "download",
2076
+ "open",
2077
+ "rename",
2078
+ "move",
2079
+ "replace"
2080
+ ];
2081
+ var RecentService = class {
2082
+ constructor(activity, files) {
2083
+ this.activity = activity;
2084
+ this.files = files;
2085
+ }
2086
+ activity;
2087
+ files;
2088
+ async list(userId, page) {
2089
+ const window = Math.min(page.page * page.limit * 3, 500);
2090
+ const events = await this.activity.recentFiles(userId, RECENT_ACTIONS, window);
2091
+ const seen = /* @__PURE__ */ new Set();
2092
+ for (const event of events) {
2093
+ seen.add(event.resourceId);
2094
+ }
2095
+ const fileIds = [...seen];
2096
+ const records = fileIds.length > 0 ? await this.files.listByIds(userId, fileIds) : [];
2097
+ const byId = new Map(records.map((record) => [record.id, record]));
2098
+ const items = [];
2099
+ for (const fileId of fileIds) {
2100
+ const record = byId.get(fileId);
2101
+ if (!record || record.deletedAt !== null) continue;
2102
+ const event = events.find((candidate) => candidate.resourceId === fileId);
2103
+ items.push({
2104
+ file: {
2105
+ id: record.id,
2106
+ name: record.name,
2107
+ size: record.size,
2108
+ mimeType: record.mimeType,
2109
+ sha256: record.sha256,
2110
+ folderId: record.folderId,
2111
+ starred: record.starred,
2112
+ trashed: false,
2113
+ deletedAt: null,
2114
+ versionCount: record.versionCount,
2115
+ currentVersionId: record.currentVersionId,
2116
+ createdAt: record.createdAt.toISOString(),
2117
+ updatedAt: record.updatedAt.toISOString()
2118
+ },
2119
+ lastAction: event.action,
2120
+ lastActionAt: event.createdAt.toISOString()
2121
+ });
2122
+ }
2123
+ const start = (page.page - 1) * page.limit;
2124
+ const pageItems = items.slice(start, start + page.limit);
2125
+ return {
2126
+ items: pageItems,
2127
+ pagination: paginationMeta(page.page, page.limit, items.length)
2128
+ };
650
2129
  }
651
2130
  };
652
2131
 
@@ -655,9 +2134,9 @@ var import_telegram = require("telegram");
655
2134
  var import_sessions = require("telegram/sessions/index.js");
656
2135
 
657
2136
  // ../telegram/src/errors.ts
658
- var import_errors5 = require("telegram/errors/index.js");
2137
+ var import_errors6 = require("telegram/errors/index.js");
659
2138
  function isTelegramRpcError(error) {
660
- return error instanceof import_errors5.RPCError;
2139
+ return error instanceof import_errors6.RPCError;
661
2140
  }
662
2141
  function isPasswordRequiredError(error) {
663
2142
  return isTelegramRpcError(error) && error.errorMessage === "SESSION_PASSWORD_NEEDED";
@@ -688,26 +2167,44 @@ function mapTelegramError(error, fallbackMessage) {
688
2167
  case "PHONE_NUMBER_BANNED":
689
2168
  return new ForbiddenError("This phone number is banned from Telegram");
690
2169
  case "AUTH_KEY_UNREGISTERED":
2170
+ case "AUTH_KEY_DUPLICATED":
691
2171
  case "SESSION_REVOKED":
2172
+ case "SESSION_EXPIRED":
692
2173
  case "USER_DEACTIVATED":
693
- return new UnauthorizedError("The Telegram session is no longer valid \u2014 please reconnect");
2174
+ case "USER_DEACTIVATED_BAN":
2175
+ return new TelegramAuthRequiredError();
694
2176
  case "CHANNEL_PRIVATE":
2177
+ case "CHANNEL_INVALID":
695
2178
  case "CHAT_ID_INVALID":
696
2179
  return new StorageNotInitializedError();
2180
+ case "MSG_ID_INVALID":
2181
+ case "MESSAGE_ID_INVALID":
2182
+ return new TelegramFileNotFoundError();
2183
+ case "FILE_REFERENCE_EXPIRED":
2184
+ case "FILE_REFERENCE_INVALID":
2185
+ return new TelegramFileNotFoundError(
2186
+ "The Telegram file reference expired \u2014 re-upload the file to refresh it"
2187
+ );
697
2188
  default:
698
- return new StorageProviderError(fallbackMessage, {
2189
+ return new TelegramConnectionError(fallbackMessage, {
699
2190
  code: error.code,
700
2191
  telegramError: msg
701
2192
  });
702
2193
  }
703
2194
  }
704
- return new StorageProviderError(
705
- error instanceof Error ? `${fallbackMessage}: ${error.message}` : fallbackMessage,
2195
+ const detail = error instanceof Error ? error.message : String(error);
2196
+ if (/timeout|ECONNRESET|ENOTFOUND|socket|network|connect/i.test(detail)) {
2197
+ return new TelegramConnectionError(`${fallbackMessage} (network error)`, error);
2198
+ }
2199
+ return new TelegramConnectionError(
2200
+ detail ? `${fallbackMessage}: ${detail}` : fallbackMessage,
706
2201
  error
707
2202
  );
708
2203
  }
709
2204
 
710
2205
  // ../telegram/src/client.ts
2206
+ var HEALTH_TTL_MS = 3e4;
2207
+ var RECONNECT_COOLDOWN_MS = 5e3;
711
2208
  var TelegramClientManager = class {
712
2209
  constructor(credentials, sessions) {
713
2210
  this.credentials = credentials;
@@ -715,52 +2212,208 @@ var TelegramClientManager = class {
715
2212
  }
716
2213
  credentials;
717
2214
  sessions;
718
- clients = /* @__PURE__ */ new Map();
2215
+ connections = /* @__PURE__ */ new Map();
2216
+ lastReconnectAt = /* @__PURE__ */ new Map();
2217
+ /** Returns a connected client, (re)connecting when needed. */
719
2218
  async getForUser(userId) {
720
- const cached = this.clients.get(userId);
721
- if (cached) {
2219
+ const existing = this.connections.get(userId);
2220
+ if (existing) {
2221
+ if (existing.state === "CONNECTED" && this.isConnected(existing.client)) {
2222
+ return existing.client;
2223
+ }
2224
+ if (existing.pending) return existing.pending;
2225
+ if (existing.state === "AUTH_REQUIRED") {
2226
+ throw new TelegramAuthRequiredError();
2227
+ }
2228
+ return this.reconnect(userId, existing);
2229
+ }
2230
+ return this.connectUser(userId);
2231
+ }
2232
+ /** Connection status for health reporting and the storage dashboard. */
2233
+ statusForUser(userId) {
2234
+ const connection = this.connections.get(userId);
2235
+ if (!connection) {
2236
+ return { state: "DISCONNECTED", lastError: null, lastCheckAt: null, healthy: null };
2237
+ }
2238
+ return {
2239
+ state: connection.state,
2240
+ lastError: connection.lastError,
2241
+ lastCheckAt: connection.lastCheckAt ? new Date(connection.lastCheckAt) : null,
2242
+ healthy: connection.lastCheckOk
2243
+ };
2244
+ }
2245
+ /**
2246
+ * Probes a user's connection. Cheap by default (connection state only);
2247
+ * `deep` performs a real Telegram round-trip to validate the session.
2248
+ */
2249
+ async healthCheck(userId, deep = false) {
2250
+ const connection = this.connections.get(userId);
2251
+ if (!connection) {
722
2252
  try {
723
- return await cached;
724
- } catch {
725
- this.clients.delete(userId);
2253
+ await this.getForUser(userId);
2254
+ } catch (error) {
2255
+ const state = error instanceof TelegramAuthRequiredError ? "AUTH_REQUIRED" : "ERROR";
2256
+ const current = this.connections.get(userId);
2257
+ if (current) current.state = state;
2258
+ return {
2259
+ healthy: false,
2260
+ latencyMs: null,
2261
+ message: error instanceof Error ? error.message : "Telegram is not connected",
2262
+ targetTitle: null
2263
+ };
726
2264
  }
727
2265
  }
728
- const session = await this.sessions.get(userId);
729
- if (!session) {
730
- throw new UnauthorizedError("Telegram account is not connected");
2266
+ const active = this.connections.get(userId);
2267
+ if (!deep && active.lastCheckAt && Date.now() - active.lastCheckAt < HEALTH_TTL_MS) {
2268
+ return {
2269
+ healthy: active.lastCheckOk ?? false,
2270
+ latencyMs: null,
2271
+ message: active.lastError,
2272
+ targetTitle: null
2273
+ };
731
2274
  }
732
- const promise = this.connect(session.stringSession);
733
- this.clients.set(userId, promise);
2275
+ const startedAt = Date.now();
734
2276
  try {
735
- return await promise;
2277
+ await active.client.getMe();
2278
+ active.lastCheckAt = Date.now();
2279
+ active.lastCheckOk = true;
2280
+ active.lastError = null;
2281
+ active.state = "CONNECTED";
2282
+ return {
2283
+ healthy: true,
2284
+ latencyMs: Date.now() - startedAt,
2285
+ message: null,
2286
+ targetTitle: null
2287
+ };
736
2288
  } catch (error) {
737
- this.clients.delete(userId);
738
- throw mapTelegramError(error, "Could not connect to Telegram");
2289
+ const mapped = mapTelegramError(error, "Telegram health check failed");
2290
+ active.lastCheckAt = Date.now();
2291
+ active.lastCheckOk = false;
2292
+ active.lastError = mapped.message;
2293
+ active.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
2294
+ return {
2295
+ healthy: false,
2296
+ latencyMs: Date.now() - startedAt,
2297
+ message: mapped.message,
2298
+ targetTitle: null
2299
+ };
739
2300
  }
740
2301
  }
741
- /** Registers an already-authenticated client (e.g. from the login flow). */
2302
+ /** Registers an already-authenticated client (from the login flow). */
742
2303
  cacheClient(userId, client) {
743
- this.clients.set(userId, Promise.resolve(client));
2304
+ this.connections.set(userId, {
2305
+ client,
2306
+ state: "CONNECTED",
2307
+ pending: null,
2308
+ lastError: null,
2309
+ lastCheckAt: Date.now(),
2310
+ lastCheckOk: true
2311
+ });
2312
+ }
2313
+ /** Drops a user's connection (e.g. after logout) without touching storage. */
2314
+ async disconnectUser(userId) {
2315
+ const connection = this.connections.get(userId);
2316
+ if (!connection) return;
2317
+ this.connections.delete(userId);
2318
+ try {
2319
+ await connection.client.disconnect();
2320
+ } catch {
2321
+ }
744
2322
  }
745
2323
  async disconnectAll() {
746
- for (const [userId, promise] of this.clients) {
747
- this.clients.delete(userId);
2324
+ const ids = [...this.connections.keys()];
2325
+ await Promise.all(ids.map((userId) => this.disconnectUser(userId)));
2326
+ }
2327
+ // ── internals ────────────────────────────────────────────────────────────
2328
+ isConnected(client) {
2329
+ try {
2330
+ return client.connected === true;
2331
+ } catch {
2332
+ return false;
2333
+ }
2334
+ }
2335
+ async connectUser(userId) {
2336
+ const session = await this.sessions.get(userId);
2337
+ if (!session) {
2338
+ throw new TelegramAuthRequiredError("Telegram account is not connected");
2339
+ }
2340
+ const connection = {
2341
+ client: null,
2342
+ state: "CONNECTING",
2343
+ pending: null,
2344
+ lastError: null,
2345
+ lastCheckAt: null,
2346
+ lastCheckOk: null
2347
+ };
2348
+ const attempt = this.establish(session.stringSession).then((client) => {
2349
+ connection.client = client;
2350
+ connection.state = "CONNECTED";
2351
+ connection.pending = null;
2352
+ connection.lastError = null;
2353
+ return client;
2354
+ });
2355
+ connection.pending = attempt;
2356
+ this.connections.set(userId, connection);
2357
+ try {
2358
+ return await attempt;
2359
+ } catch (error) {
2360
+ connection.pending = null;
2361
+ const mapped = mapTelegramError(error, "Could not connect to Telegram");
2362
+ connection.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
2363
+ connection.lastError = mapped.message;
2364
+ throw mapped;
2365
+ }
2366
+ }
2367
+ async reconnect(userId, connection) {
2368
+ const lastAttempt = this.lastReconnectAt.get(userId) ?? 0;
2369
+ if (Date.now() - lastAttempt < RECONNECT_COOLDOWN_MS && connection.pending) {
2370
+ return connection.pending;
2371
+ }
2372
+ this.lastReconnectAt.set(userId, Date.now());
2373
+ connection.state = "RECONNECTING";
2374
+ const session = await this.sessions.get(userId);
2375
+ if (!session) {
2376
+ connection.state = "AUTH_REQUIRED";
2377
+ throw new TelegramAuthRequiredError("Telegram account is not connected");
2378
+ }
2379
+ const attempt = (async () => {
748
2380
  try {
749
- const client = await promise;
750
- await client.disconnect();
2381
+ await connection.client.disconnect().catch(() => void 0);
751
2382
  } catch {
752
2383
  }
753
- }
2384
+ return this.establish(session.stringSession);
2385
+ })().then((client) => {
2386
+ connection.client = client;
2387
+ connection.state = "CONNECTED";
2388
+ connection.pending = null;
2389
+ connection.lastError = null;
2390
+ return client;
2391
+ }).catch((error) => {
2392
+ connection.pending = null;
2393
+ const mapped = mapTelegramError(error, "Could not reconnect to Telegram");
2394
+ connection.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
2395
+ connection.lastError = mapped.message;
2396
+ throw mapped;
2397
+ });
2398
+ connection.pending = attempt;
2399
+ return attempt;
754
2400
  }
755
- async connect(sessionString) {
2401
+ async establish(sessionString) {
756
2402
  const client = new import_telegram.TelegramClient(
757
2403
  new import_sessions.StringSession(sessionString),
758
2404
  this.credentials.apiId,
759
2405
  this.credentials.apiHash,
760
2406
  { connectionRetries: 3 }
761
2407
  );
762
- await client.connect();
763
- return client;
2408
+ try {
2409
+ await client.connect();
2410
+ if (!client.connected) {
2411
+ throw new TelegramConnectionError("Telegram client did not connect");
2412
+ }
2413
+ return client;
2414
+ } catch (error) {
2415
+ throw mapTelegramError(error, "Could not connect to Telegram");
2416
+ }
764
2417
  }
765
2418
  };
766
2419
  function newTelegramClient(credentials) {
@@ -773,13 +2426,15 @@ function extractCreatedChannel(result) {
773
2426
  for (const chat of chats) {
774
2427
  if (chat instanceof import_telegram.Api.Channel) return chat;
775
2428
  }
776
- throw new StorageProviderError("Telegram did not return the created storage channel");
2429
+ throw new TelegramConnectionError("Telegram did not return the created storage channel");
777
2430
  }
778
2431
 
779
2432
  // ../telegram/src/provider.ts
2433
+ var import_node_stream2 = require("stream");
780
2434
  var import_telegram2 = require("telegram");
781
2435
  var import_big_integer = __toESM(require("big-integer"), 1);
782
2436
  var import_uploads = require("telegram/client/uploads.js");
2437
+ var MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 * 1024;
783
2438
  var TelegramStorageProvider = class {
784
2439
  constructor(client, options) {
785
2440
  this.client = client;
@@ -787,48 +2442,100 @@ var TelegramStorageProvider = class {
787
2442
  channelId: (0, import_big_integer.default)(options.chatId),
788
2443
  accessHash: (0, import_big_integer.default)(options.accessHash)
789
2444
  });
2445
+ this.title = options.title ?? null;
790
2446
  }
791
2447
  client;
792
2448
  name = "telegram";
793
2449
  peer;
794
- async put(input) {
2450
+ title;
2451
+ // ── Upload ───────────────────────────────────────────────────────────────
2452
+ async put(input, control = {}) {
2453
+ const data = await this.materialize(input);
2454
+ if (data.byteLength > MAX_DOCUMENT_BYTES) {
2455
+ throw new UploadFailedError(
2456
+ `Telegram does not accept documents larger than ${MAX_DOCUMENT_BYTES / (1024 * 1024 * 1024)} GB`
2457
+ );
2458
+ }
2459
+ this.throwIfAborted(control.signal);
2460
+ const total = data.byteLength;
2461
+ control.onProgress?.({ transferred: 0, total, percent: 0 });
795
2462
  try {
796
- const file = new import_uploads.CustomFile(input.name, input.data.byteLength, "", input.data);
2463
+ const file = new import_uploads.CustomFile(input.name, total, "", data);
797
2464
  const message = await this.client.sendFile(this.peer, {
798
2465
  file,
799
2466
  forceDocument: true,
800
- workers: 1
2467
+ workers: 1,
2468
+ // Surface transport-level progress to the caller. GramJS reports a
2469
+ // 0..1 fraction. `isCanceled` lets us abort an in-flight upload.
2470
+ progressCallback: Object.assign(
2471
+ (fraction) => {
2472
+ const clamped = Math.max(0, Math.min(1, fraction));
2473
+ control.onProgress?.({
2474
+ transferred: Math.round(clamped * total),
2475
+ total,
2476
+ percent: Math.round(clamped * 100)
2477
+ });
2478
+ },
2479
+ {
2480
+ get isCanceled() {
2481
+ return control.signal?.aborted === true;
2482
+ }
2483
+ }
2484
+ )
801
2485
  });
802
2486
  if (!(message instanceof import_telegram2.Api.Message)) {
803
- throw new StorageProviderError("Telegram returned an unexpected upload response");
2487
+ throw new UploadFailedError("Telegram returned an unexpected upload response");
804
2488
  }
2489
+ control.onProgress?.({ transferred: total, total, percent: 100 });
805
2490
  return {
806
2491
  messageId: message.id.toString(),
807
2492
  name: input.name,
808
- size: input.data.byteLength,
2493
+ size: total,
809
2494
  mimeType: input.mimeType
810
2495
  };
811
2496
  } catch (error) {
2497
+ if (error instanceof OperationCancelledError) throw error;
812
2498
  throw mapTelegramError(error, "Telegram upload failed");
813
2499
  }
814
2500
  }
815
- async get(ref) {
2501
+ // ── Download ─────────────────────────────────────────────────────────────
2502
+ async get(ref, control = {}) {
2503
+ const message = await this.requireMessage(ref);
2504
+ this.throwIfAborted(control.signal);
2505
+ const size = this.documentSize(message);
2506
+ control.onProgress?.({ transferred: 0, total: size, percent: 0 });
816
2507
  let data;
817
2508
  try {
818
- const message = await this.getMessage(ref.messageId);
819
- if (!message?.media) {
820
- throw new StorageProviderError("The stored file is missing from Telegram");
821
- }
822
- data = await this.client.downloadMedia(message, {});
2509
+ data = await this.client.downloadMedia(message, {
2510
+ progressCallback: (downloaded, total) => {
2511
+ const transferred = Number(downloaded.toString());
2512
+ const totalBytes = Number(total.toString());
2513
+ control.onProgress?.({
2514
+ transferred,
2515
+ total: totalBytes,
2516
+ percent: totalBytes > 0 ? Math.round(transferred / totalBytes * 100) : null
2517
+ });
2518
+ }
2519
+ });
823
2520
  } catch (error) {
824
- if (error instanceof StorageProviderError) throw error;
825
2521
  throw mapTelegramError(error, "Telegram download failed");
826
2522
  }
827
2523
  if (!data || typeof data === "string") {
828
- throw new StorageProviderError("Telegram did not return file content");
2524
+ throw new TelegramFileNotFoundError("Telegram did not return file content");
829
2525
  }
830
- return Buffer.from(data);
2526
+ const buffer = Buffer.from(data);
2527
+ control.onProgress?.({
2528
+ transferred: buffer.byteLength,
2529
+ total: buffer.byteLength,
2530
+ percent: 100
2531
+ });
2532
+ return buffer;
2533
+ }
2534
+ async getStream(ref, control = {}) {
2535
+ const data = await this.get(ref, control);
2536
+ return import_node_stream2.Readable.from([data]);
831
2537
  }
2538
+ // ── Lifecycle ────────────────────────────────────────────────────────────
832
2539
  async delete(ref) {
833
2540
  try {
834
2541
  await this.client.deleteMessages(this.peer, [Number(ref.messageId)], { revoke: true });
@@ -844,12 +2551,7 @@ var TelegramStorageProvider = class {
844
2551
  }
845
2552
  }
846
2553
  async stat(ref) {
847
- let message;
848
- try {
849
- message = await this.getMessage(ref.messageId);
850
- } catch (error) {
851
- throw mapTelegramError(error, "Telegram lookup failed");
852
- }
2554
+ const message = await this.getMessage(ref.messageId);
853
2555
  if (!message?.media) return null;
854
2556
  const document = message.document;
855
2557
  if (!(document instanceof import_telegram2.Api.Document)) return null;
@@ -867,10 +2569,81 @@ var TelegramStorageProvider = class {
867
2569
  mimeType: document.mimeType
868
2570
  };
869
2571
  }
2572
+ // ── Health ───────────────────────────────────────────────────────────────
2573
+ /**
2574
+ * Validates that the client is connected and the storage channel is
2575
+ * reachable (a real Telegram round-trip on the channel peer).
2576
+ */
2577
+ async healthCheck() {
2578
+ const startedAt = Date.now();
2579
+ try {
2580
+ if (!this.client.connected) {
2581
+ return {
2582
+ healthy: false,
2583
+ latencyMs: null,
2584
+ message: "Telegram client is not connected",
2585
+ targetTitle: this.title
2586
+ };
2587
+ }
2588
+ const result = await this.client.invoke(
2589
+ new import_telegram2.Api.channels.GetFullChannel({ channel: this.peer })
2590
+ );
2591
+ const title = result instanceof import_telegram2.Api.messages.ChatFull && result.chats[0] instanceof import_telegram2.Api.Channel ? result.chats[0].title : this.title;
2592
+ return {
2593
+ healthy: true,
2594
+ latencyMs: Date.now() - startedAt,
2595
+ message: null,
2596
+ targetTitle: title ?? null
2597
+ };
2598
+ } catch (error) {
2599
+ const mapped = mapTelegramError(error, "Telegram storage health check failed");
2600
+ return {
2601
+ healthy: false,
2602
+ latencyMs: Date.now() - startedAt,
2603
+ message: mapped.message,
2604
+ targetTitle: this.title
2605
+ };
2606
+ }
2607
+ }
2608
+ // ── internals ────────────────────────────────────────────────────────────
2609
+ throwIfAborted(signal) {
2610
+ if (signal?.aborted) throw new OperationCancelledError();
2611
+ if (!this.client.connected) {
2612
+ throw new TelegramConnectionError("Telegram client is not connected");
2613
+ }
2614
+ }
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
+ documentSize(message) {
2627
+ const document = message.document;
2628
+ if (document instanceof import_telegram2.Api.Document) return Number(document.size.toString());
2629
+ return null;
2630
+ }
2631
+ async requireMessage(ref) {
2632
+ const message = await this.getMessage(ref.messageId);
2633
+ if (!message?.media) {
2634
+ throw new TelegramFileNotFoundError();
2635
+ }
2636
+ return message;
2637
+ }
870
2638
  async getMessage(id) {
871
- const messages = await this.client.getMessages(this.peer, { ids: [Number(id)] });
872
- const first = messages[0];
873
- return first instanceof import_telegram2.Api.Message ? first : void 0;
2639
+ try {
2640
+ const messages = await this.client.getMessages(this.peer, { ids: [Number(id)] });
2641
+ const first = messages[0];
2642
+ return first instanceof import_telegram2.Api.Message ? first : void 0;
2643
+ } catch (error) {
2644
+ if (error instanceof TelegramAuthRequiredError) throw error;
2645
+ throw mapTelegramError(error, "Telegram lookup failed");
2646
+ }
874
2647
  }
875
2648
  };
876
2649
 
@@ -973,7 +2746,7 @@ var TelegramConnectionService = class {
973
2746
  }
974
2747
  const accessHash = channel.accessHash?.toString();
975
2748
  if (!accessHash) {
976
- throw new StorageProviderError("Telegram did not provide the channel access hash");
2749
+ throw new TelegramConnectionError("Telegram did not provide the channel access hash");
977
2750
  }
978
2751
  return this.repos.storages.create({
979
2752
  userId,
@@ -1040,21 +2813,32 @@ var TelegramConnectionService = class {
1040
2813
  };
1041
2814
  // Annotate the CommonJS export names for ESM import in node:
1042
2815
  0 && (module.exports = {
2816
+ ActivityService,
1043
2817
  ERROR_CODES,
1044
2818
  FileService,
1045
2819
  FolderService,
2820
+ IntegrityService,
1046
2821
  OmniCloudClient,
1047
2822
  OmniCloudError,
2823
+ RecentService,
1048
2824
  SearchService,
2825
+ StatsService,
1049
2826
  StorageEngine,
1050
2827
  TELEGRAM_PROVIDER,
1051
2828
  TelegramClientManager,
1052
2829
  TelegramConnectionService,
1053
2830
  TelegramStorageProvider,
2831
+ TrashService,
1054
2832
  fileCategory,
2833
+ isFileCategory,
1055
2834
  mapProviderError,
1056
2835
  mapTelegramError,
1057
2836
  mimeFromFilename,
2837
+ mimeMatchersForType,
2838
+ noopActivityRecorder,
2839
+ paginationMeta,
2840
+ parseSearchQuery,
2841
+ parseSize,
1058
2842
  sanitizeFileName,
1059
2843
  sha256Hex
1060
2844
  });