@nightowne/tas-cli 2.4.0 → 3.0.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/src/db/index.js CHANGED
@@ -20,13 +20,18 @@ export class FileIndex {
20
20
 
21
21
  // Enable WAL mode for better concurrent access
22
22
  this.db.pragma('journal_mode = WAL');
23
+ // Enforce foreign keys so ON DELETE CASCADE actually works
24
+ // (SQLite disables FK enforcement by default per connection)
25
+ this.db.pragma('foreign_keys = ON');
26
+ // Don't fail instantly when sync workers write concurrently
27
+ this.db.pragma('busy_timeout = 5000');
23
28
 
24
29
  // Create files table
25
30
  this.db.exec(`
26
31
  CREATE TABLE IF NOT EXISTS files (
27
32
  id INTEGER PRIMARY KEY AUTOINCREMENT,
28
33
  filename TEXT NOT NULL,
29
- hash TEXT UNIQUE NOT NULL,
34
+ hash TEXT NOT NULL,
30
35
  original_size INTEGER NOT NULL,
31
36
  stored_size INTEGER NOT NULL,
32
37
  chunks INTEGER NOT NULL DEFAULT 1,
@@ -39,6 +44,8 @@ export class FileIndex {
39
44
  CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash);
40
45
  `);
41
46
 
47
+ this._removeLegacyUniqueHashConstraint();
48
+
42
49
  // Create chunks table (for multi-part files)
43
50
  this.db.exec(`
44
51
  CREATE TABLE IF NOT EXISTS chunks (
@@ -47,11 +54,13 @@ export class FileIndex {
47
54
  chunk_index INTEGER NOT NULL,
48
55
  message_id TEXT NOT NULL,
49
56
  file_telegram_id TEXT,
57
+ bot_id TEXT,
50
58
  size INTEGER NOT NULL,
51
59
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
52
60
  FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
53
61
  UNIQUE(file_id, chunk_index)
54
62
  );
63
+
55
64
  `);
56
65
 
57
66
  // Create tags table for file organization
@@ -116,11 +125,12 @@ export class FileIndex {
116
125
  file_path TEXT NOT NULL,
117
126
  hash TEXT NOT NULL,
118
127
  original_size INTEGER NOT NULL,
128
+ stored_size INTEGER NOT NULL DEFAULT 0,
129
+ compressed INTEGER NOT NULL DEFAULT 0,
119
130
  total_chunks INTEGER NOT NULL,
120
131
  uploaded_chunks INTEGER NOT NULL DEFAULT 0,
121
132
  temp_dir TEXT,
122
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
123
- UNIQUE(hash)
133
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
124
134
  );
125
135
 
126
136
  CREATE TABLE IF NOT EXISTS pending_chunks (
@@ -131,10 +141,126 @@ export class FileIndex {
131
141
  uploaded INTEGER NOT NULL DEFAULT 0,
132
142
  message_id TEXT,
133
143
  file_telegram_id TEXT,
144
+ bot_id TEXT,
145
+ size INTEGER NOT NULL DEFAULT 0,
134
146
  FOREIGN KEY (pending_id) REFERENCES pending_uploads(id) ON DELETE CASCADE,
135
147
  UNIQUE(pending_id, chunk_index)
136
148
  );
137
149
  `);
150
+
151
+ // Online migration for databases created before multi-bot support.
152
+ // NULL means the legacy `primary` bot and is intentionally not rewritten.
153
+ const chunkColumns = this.db.pragma('table_info(chunks)').map(column => column.name);
154
+ if (!chunkColumns.includes('bot_id')) {
155
+ this.db.exec('ALTER TABLE chunks ADD COLUMN bot_id TEXT');
156
+ }
157
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_chunks_bot_id ON chunks(bot_id)');
158
+ const pendingChunkColumns = this.db.pragma('table_info(pending_chunks)').map(column => column.name);
159
+ if (!pendingChunkColumns.includes('bot_id')) {
160
+ this.db.exec('ALTER TABLE pending_chunks ADD COLUMN bot_id TEXT');
161
+ }
162
+ if (!pendingChunkColumns.includes('size')) {
163
+ this.db.exec('ALTER TABLE pending_chunks ADD COLUMN size INTEGER NOT NULL DEFAULT 0');
164
+ }
165
+ const pendingUploadColumns = this.db.pragma('table_info(pending_uploads)').map(column => column.name);
166
+ if (!pendingUploadColumns.includes('stored_size')) {
167
+ this.db.exec('ALTER TABLE pending_uploads ADD COLUMN stored_size INTEGER NOT NULL DEFAULT 0');
168
+ }
169
+ if (!pendingUploadColumns.includes('compressed')) {
170
+ this.db.exec('ALTER TABLE pending_uploads ADD COLUMN compressed INTEGER NOT NULL DEFAULT 0');
171
+ }
172
+ this._removeLegacyPendingHashConstraint();
173
+ }
174
+
175
+ /**
176
+ * v1-v2.5 made content hashes UNIQUE, which prevented the same bytes from
177
+ * being stored at two logical paths and could orphan FUSE uploads. Rebuild
178
+ * only legacy tables that still carry that constraint.
179
+ */
180
+ _removeLegacyUniqueHashConstraint() {
181
+ const hasUniqueHash = this.db.pragma('index_list(files)').some(index => {
182
+ if (!index.unique) return false;
183
+ const columns = this.db.pragma(`index_info('${index.name.replaceAll("'", "''")}')`);
184
+ return columns.length === 1 && columns[0].name === 'hash';
185
+ });
186
+ if (!hasUniqueHash) return;
187
+
188
+ this.db.pragma('foreign_keys = OFF');
189
+ this.db.pragma('legacy_alter_table = ON');
190
+ try {
191
+ this.db.exec(`
192
+ BEGIN;
193
+ ALTER TABLE files RENAME TO files_legacy_unique_hash;
194
+ CREATE TABLE files (
195
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
196
+ filename TEXT NOT NULL,
197
+ hash TEXT NOT NULL,
198
+ original_size INTEGER NOT NULL,
199
+ stored_size INTEGER NOT NULL,
200
+ chunks INTEGER NOT NULL DEFAULT 1,
201
+ compressed INTEGER NOT NULL DEFAULT 0,
202
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
203
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
204
+ );
205
+ INSERT INTO files
206
+ (id, filename, hash, original_size, stored_size, chunks, compressed, created_at, updated_at)
207
+ SELECT id, filename, hash, original_size, stored_size, chunks, compressed, created_at, updated_at
208
+ FROM files_legacy_unique_hash;
209
+ DROP TABLE files_legacy_unique_hash;
210
+ CREATE INDEX IF NOT EXISTS idx_files_filename ON files(filename);
211
+ CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash);
212
+ COMMIT;
213
+ `);
214
+ } catch (error) {
215
+ try { this.db.exec('ROLLBACK'); } catch { }
216
+ throw error;
217
+ } finally {
218
+ this.db.pragma('legacy_alter_table = OFF');
219
+ this.db.pragma('foreign_keys = ON');
220
+ }
221
+ }
222
+
223
+ _removeLegacyPendingHashConstraint() {
224
+ const hasUniqueHash = this.db.pragma('index_list(pending_uploads)').some(index => {
225
+ if (!index.unique) return false;
226
+ const columns = this.db.pragma(`index_info('${index.name.replaceAll("'", "''")}')`);
227
+ return columns.length === 1 && columns[0].name === 'hash';
228
+ });
229
+ if (!hasUniqueHash) return;
230
+
231
+ this.db.pragma('foreign_keys = OFF');
232
+ this.db.pragma('legacy_alter_table = ON');
233
+ try {
234
+ this.db.exec(`
235
+ BEGIN;
236
+ ALTER TABLE pending_uploads RENAME TO pending_uploads_legacy_unique_hash;
237
+ CREATE TABLE pending_uploads (
238
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
239
+ filename TEXT NOT NULL,
240
+ file_path TEXT NOT NULL,
241
+ hash TEXT NOT NULL,
242
+ original_size INTEGER NOT NULL,
243
+ stored_size INTEGER NOT NULL DEFAULT 0,
244
+ compressed INTEGER NOT NULL DEFAULT 0,
245
+ total_chunks INTEGER NOT NULL,
246
+ uploaded_chunks INTEGER NOT NULL DEFAULT 0,
247
+ temp_dir TEXT,
248
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
249
+ );
250
+ INSERT INTO pending_uploads
251
+ (id, filename, file_path, hash, original_size, stored_size, compressed, total_chunks, uploaded_chunks, temp_dir, created_at)
252
+ SELECT id, filename, file_path, hash, original_size, stored_size, compressed, total_chunks, uploaded_chunks, temp_dir, created_at
253
+ FROM pending_uploads_legacy_unique_hash;
254
+ DROP TABLE pending_uploads_legacy_unique_hash;
255
+ COMMIT;
256
+ `);
257
+ } catch (error) {
258
+ try { this.db.exec('ROLLBACK'); } catch { }
259
+ throw error;
260
+ } finally {
261
+ this.db.pragma('legacy_alter_table = OFF');
262
+ this.db.pragma('foreign_keys = ON');
263
+ }
138
264
  }
139
265
 
140
266
  /**
@@ -161,13 +287,35 @@ export class FileIndex {
161
287
  /**
162
288
  * Add chunk metadata
163
289
  */
164
- addChunk(fileId, chunkIndex, messageId, size) {
290
+ addChunk(fileId, chunkIndex, messageId, size, fileTelegramId = null, botId = null) {
165
291
  const stmt = this.db.prepare(`
166
- INSERT INTO chunks (file_id, chunk_index, message_id, size)
167
- VALUES (?, ?, ?, ?)
292
+ INSERT INTO chunks (file_id, chunk_index, message_id, size, file_telegram_id, bot_id)
293
+ VALUES (?, ?, ?, ?, ?, ?)
168
294
  `);
169
295
 
170
- stmt.run(fileId, chunkIndex, messageId, size);
296
+ stmt.run(fileId, chunkIndex, messageId, size, fileTelegramId, botId);
297
+ }
298
+
299
+ /** Number of chunks that depend on a configured bot. */
300
+ countChunksByBot(botId) {
301
+ if (botId === 'primary') {
302
+ return this.db.prepare(`
303
+ SELECT COUNT(*) AS count FROM chunks WHERE bot_id = ? OR bot_id IS NULL
304
+ `).get(botId).count;
305
+ }
306
+ return this.db.prepare('SELECT COUNT(*) AS count FROM chunks WHERE bot_id = ?').get(botId).count;
307
+ }
308
+
309
+ countPendingChunksByBot(botId) {
310
+ if (botId === 'primary') {
311
+ return this.db.prepare(`
312
+ SELECT COUNT(*) AS count FROM pending_chunks
313
+ WHERE uploaded = 1 AND (bot_id = ? OR bot_id IS NULL)
314
+ `).get(botId).count;
315
+ }
316
+ return this.db.prepare(`
317
+ SELECT COUNT(*) AS count FROM pending_chunks WHERE uploaded = 1 AND bot_id = ?
318
+ `).get(botId).count;
171
319
  }
172
320
 
173
321
  /**
@@ -178,25 +326,70 @@ export class FileIndex {
178
326
  }
179
327
 
180
328
  /**
181
- * Find file by hash (exact match or prefix match)
329
+ * Find file by hash (exact match preferred, then prefix match)
182
330
  */
183
331
  findByHash(hash) {
332
+ const exact = this.db.prepare('SELECT * FROM files WHERE hash = ?').get(hash);
333
+ if (exact) return exact;
334
+
184
335
  const stmt = this.db.prepare(`
185
- SELECT * FROM files WHERE hash = ? OR hash LIKE ? ESCAPE '\\'
336
+ SELECT * FROM files WHERE hash LIKE ? ESCAPE '\\'
186
337
  `);
187
338
 
188
- return stmt.get(hash, this._escapeLike(hash) + '%');
339
+ return stmt.get(this._escapeLike(hash) + '%');
189
340
  }
190
341
 
191
342
  /**
192
- * Find file by filename (exact match or substring match)
343
+ * Find file by filename (exact match preferred, then substring match)
344
+ * Preferring exact matches avoids returning an arbitrary row when
345
+ * duplicate filenames exist in the index.
193
346
  */
194
347
  findByName(filename) {
348
+ const exact = this.db.prepare('SELECT * FROM files WHERE filename = ?').get(filename);
349
+ if (exact) return exact;
350
+
351
+ const stmt = this.db.prepare(`
352
+ SELECT * FROM files WHERE filename LIKE ? ESCAPE '\\'
353
+ `);
354
+
355
+ return stmt.get('%' + this._escapeLike(filename) + '%');
356
+ }
357
+
358
+ /** Exact logical-path lookup. Required by FUSE; never falls back to LIKE. */
359
+ findByExactName(filename) {
360
+ return this.db.prepare('SELECT * FROM files WHERE filename = ? ORDER BY id DESC LIMIT 1').get(filename);
361
+ }
362
+
363
+ /**
364
+ * Find files whose chunk rows don't match the expected chunk count.
365
+ * These are leftovers from interrupted uploads (see processFile cleanup).
366
+ * Used by `tas resume` to offer cleanup/retry.
367
+ */
368
+ getIncompleteUploads() {
195
369
  const stmt = this.db.prepare(`
196
- SELECT * FROM files WHERE filename = ? OR filename LIKE ? ESCAPE '\\'
370
+ SELECT f.*, COUNT(c.id) as actual_chunks
371
+ FROM files f
372
+ LEFT JOIN chunks c ON c.file_id = f.id
373
+ GROUP BY f.id
374
+ HAVING actual_chunks != f.chunks OR f.stored_size = 0
197
375
  `);
376
+ return stmt.all();
377
+ }
198
378
 
199
- return stmt.get(filename, '%' + this._escapeLike(filename) + '%');
379
+ /**
380
+ * Delete a file and all its chunk rows explicitly.
381
+ * Explicit deletes keep things correct even on connections
382
+ * where FK enforcement was not enabled (older DBs).
383
+ */
384
+ deleteFileCascade(fileId) {
385
+ const delChunks = this.db.prepare('DELETE FROM chunks WHERE file_id = ?');
386
+ delChunks.run(fileId);
387
+ const delTags = this.db.prepare('DELETE FROM tags WHERE file_id = ?');
388
+ try { delTags.run(fileId); } catch { /* tags table may not exist on very old DBs */ }
389
+ const delShares = this.db.prepare('DELETE FROM shares WHERE file_id = ?');
390
+ try { delShares.run(fileId); } catch { /* ignore */ }
391
+ const stmt = this.db.prepare('DELETE FROM files WHERE id = ?');
392
+ stmt.run(fileId);
200
393
  }
201
394
 
202
395
  /**
@@ -253,6 +446,94 @@ export class FileIndex {
253
446
  return stmt.get();
254
447
  }
255
448
 
449
+ /** Export only durable storage metadata required to rebuild the local index. */
450
+ exportManifest({ includeShares = false } = {}) {
451
+ const files = this.db.prepare('SELECT * FROM files ORDER BY id').all();
452
+ const chunks = this.db.prepare('SELECT * FROM chunks ORDER BY file_id, chunk_index').all();
453
+ const tags = this.db.prepare('SELECT file_id, tag, created_at FROM tags ORDER BY file_id, tag').all();
454
+ const manifest = {
455
+ schemaVersion: 1,
456
+ createdAt: new Date().toISOString(),
457
+ files,
458
+ chunks,
459
+ tags
460
+ };
461
+ if (includeShares) manifest.shares = this.db.prepare('SELECT * FROM shares ORDER BY id').all();
462
+ return manifest;
463
+ }
464
+
465
+ /** Replace storage metadata from a validated decrypted remote manifest. */
466
+ importManifest(manifest) {
467
+ if (!manifest || manifest.schemaVersion !== 1 || !Array.isArray(manifest.files) || !Array.isArray(manifest.chunks)) {
468
+ throw new Error('Unsupported or malformed TAS manifest');
469
+ }
470
+
471
+ const insertFile = this.db.prepare(`
472
+ INSERT INTO files
473
+ (id, filename, hash, original_size, stored_size, chunks, compressed, created_at, updated_at)
474
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
475
+ `);
476
+ const insertChunk = this.db.prepare(`
477
+ INSERT INTO chunks
478
+ (id, file_id, chunk_index, message_id, file_telegram_id, bot_id, size, created_at)
479
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
480
+ `);
481
+ const insertTag = this.db.prepare(`
482
+ INSERT OR IGNORE INTO tags (file_id, tag, created_at) VALUES (?, ?, ?)
483
+ `);
484
+ const insertShare = this.db.prepare(`
485
+ INSERT INTO shares
486
+ (id, file_id, token, expires_at, max_downloads, download_count, created_at)
487
+ VALUES (?, ?, ?, ?, ?, ?, ?)
488
+ `);
489
+
490
+ this.db.transaction(() => {
491
+ if (Array.isArray(manifest.shares)) this.db.prepare('DELETE FROM shares').run();
492
+ this.db.prepare('DELETE FROM tags').run();
493
+ this.db.prepare('DELETE FROM chunks').run();
494
+ this.db.prepare('DELETE FROM files').run();
495
+ for (const file of manifest.files) {
496
+ insertFile.run(
497
+ file.id,
498
+ file.filename,
499
+ file.hash,
500
+ file.original_size,
501
+ file.stored_size,
502
+ file.chunks,
503
+ file.compressed,
504
+ file.created_at,
505
+ file.updated_at
506
+ );
507
+ }
508
+ for (const chunk of manifest.chunks) {
509
+ insertChunk.run(
510
+ chunk.id,
511
+ chunk.file_id,
512
+ chunk.chunk_index,
513
+ chunk.message_id,
514
+ chunk.file_telegram_id,
515
+ chunk.bot_id || null,
516
+ chunk.size,
517
+ chunk.created_at
518
+ );
519
+ }
520
+ for (const tag of manifest.tags || []) {
521
+ insertTag.run(tag.file_id, tag.tag, tag.created_at);
522
+ }
523
+ for (const share of manifest.shares || []) {
524
+ insertShare.run(
525
+ share.id,
526
+ share.file_id,
527
+ share.token,
528
+ share.expires_at,
529
+ share.max_downloads,
530
+ share.download_count,
531
+ share.created_at
532
+ );
533
+ }
534
+ })();
535
+ }
536
+
256
537
  // ============== TAG METHODS ==============
257
538
 
258
539
  /**
@@ -436,14 +717,16 @@ export class FileIndex {
436
717
  addPendingUpload(data) {
437
718
  const stmt = this.db.prepare(`
438
719
  INSERT OR REPLACE INTO pending_uploads
439
- (filename, file_path, hash, original_size, total_chunks, uploaded_chunks, temp_dir)
440
- VALUES (?, ?, ?, ?, ?, ?, ?)
720
+ (filename, file_path, hash, original_size, stored_size, compressed, total_chunks, uploaded_chunks, temp_dir)
721
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
441
722
  `);
442
723
  const result = stmt.run(
443
724
  data.filename,
444
725
  data.filePath,
445
726
  data.hash,
446
727
  data.originalSize,
728
+ data.storedSize || 0,
729
+ data.compressed ? 1 : 0,
447
730
  data.totalChunks,
448
731
  data.uploadedChunks || 0,
449
732
  data.tempDir
@@ -454,27 +737,27 @@ export class FileIndex {
454
737
  /**
455
738
  * Add a pending chunk
456
739
  */
457
- addPendingChunk(pendingId, chunkIndex, chunkPath) {
740
+ addPendingChunk(pendingId, chunkIndex, chunkPath, size = 0) {
458
741
  const stmt = this.db.prepare(`
459
- INSERT OR REPLACE INTO pending_chunks (pending_id, chunk_index, chunk_path, uploaded)
460
- VALUES (?, ?, ?, 0)
742
+ INSERT OR REPLACE INTO pending_chunks (pending_id, chunk_index, chunk_path, size, uploaded)
743
+ VALUES (?, ?, ?, ?, 0)
461
744
  `);
462
- stmt.run(pendingId, chunkIndex, chunkPath);
745
+ stmt.run(pendingId, chunkIndex, chunkPath, size);
463
746
  }
464
747
 
465
748
  /**
466
749
  * Mark chunk as uploaded
467
750
  */
468
- markChunkUploaded(pendingId, chunkIndex, messageId, fileTelegramId) {
751
+ markChunkUploaded(pendingId, chunkIndex, messageId, fileTelegramId, botId = null) {
469
752
  const stmt = this.db.prepare(`
470
753
  UPDATE pending_chunks
471
- SET uploaded = 1, message_id = ?, file_telegram_id = ?
472
- WHERE pending_id = ? AND chunk_index = ?
754
+ SET uploaded = 1, message_id = ?, file_telegram_id = ?, bot_id = ?
755
+ WHERE pending_id = ? AND chunk_index = ? AND uploaded = 0
473
756
  `);
474
- stmt.run(messageId, fileTelegramId, pendingId, chunkIndex);
757
+ const result = stmt.run(messageId, fileTelegramId, botId, pendingId, chunkIndex);
475
758
 
476
759
  // Update uploaded count
477
- this.db.prepare(`
760
+ if (result.changes > 0) this.db.prepare(`
478
761
  UPDATE pending_uploads SET uploaded_chunks = uploaded_chunks + 1 WHERE id = ?
479
762
  `).run(pendingId);
480
763
  }