@lacrous/omnicloud 0.1.0 → 0.2.0

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