show.mjs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import commandLineArgs from 'command-line-args'
  2. import {
  3. loadAtVersion,
  4. getChunkMetadataForVersion,
  5. getProjectChunksFromVersion,
  6. } from '../lib/chunk_store/index.js'
  7. import { client } from '../lib/mongodb.js'
  8. import knex from '../lib/knex.js'
  9. import {
  10. loadGlobalBlobs,
  11. BlobStore,
  12. makeProjectKey,
  13. } from '../lib/blob_store/index.js'
  14. import { TextDecoder } from 'node:util'
  15. import {
  16. backupPersistor,
  17. chunksBucket,
  18. projectBlobsBucket,
  19. } from '../lib/backupPersistor.mjs'
  20. import fs from 'node:fs'
  21. import { pipeline } from 'node:stream/promises'
  22. import os from 'node:os'
  23. import path from 'node:path'
  24. import { createHash } from 'node:crypto'
  25. import projectKey from '../lib/project_key.js'
  26. import { createGunzip } from 'node:zlib'
  27. import { text } from 'node:stream/consumers'
  28. const optionDefinitions = [
  29. { name: 'historyId', alias: 'p', type: String },
  30. { name: 'version', alias: 'v', type: Number },
  31. { name: 'blob', alias: 'b', type: String },
  32. { name: 'remote', alias: 'r', type: Boolean },
  33. { name: 'keep', alias: 'k', type: Boolean },
  34. ]
  35. function makeChunkKey(projectId, startVersion) {
  36. return path.join(projectKey.format(projectId), projectKey.pad(startVersion))
  37. }
  38. async function listChunks(historyId) {
  39. for await (const chunkRecord of getProjectChunksFromVersion(historyId, 0)) {
  40. console.log('Chunk record:', chunkRecord)
  41. }
  42. }
  43. async function fetchChunkLocal(historyId, version) {
  44. const chunkRecord = await getChunkMetadataForVersion(historyId, version)
  45. const chunk = await loadAtVersion(historyId, version)
  46. return { key: version, chunk, metadata: chunkRecord, source: 'local storage' }
  47. }
  48. async function fetchChunkRemote(historyId, version) {
  49. const chunkRecord = await getChunkMetadataForVersion(historyId, version)
  50. const startVersion = chunkRecord.startVersion
  51. const key = makeChunkKey(historyId, startVersion)
  52. const backupPersistorForProject = await backupPersistor.forProject(
  53. chunksBucket,
  54. key
  55. )
  56. const backupChunkStream = await backupPersistorForProject.getObjectStream(
  57. chunksBucket,
  58. key
  59. )
  60. const backupStr = await text(backupChunkStream.pipe(createGunzip()))
  61. return {
  62. key,
  63. chunk: JSON.parse(backupStr),
  64. metadata: chunkRecord,
  65. source: 'remote backup',
  66. }
  67. }
  68. async function displayChunk(historyId, version, options) {
  69. const { key, chunk, metadata, source } = await (options.remote
  70. ? fetchChunkRemote(historyId, version)
  71. : fetchChunkLocal(historyId, version))
  72. console.log('Source:', source)
  73. console.log('Chunk record', metadata)
  74. console.log('Key', key)
  75. // console.log('Number of changes', chunk.getChanges().length)
  76. console.log(JSON.stringify(chunk))
  77. }
  78. async function fetchBlobRemote(historyId, blobHash) {
  79. const backupPersistorForProject = await backupPersistor.forProject(
  80. projectBlobsBucket,
  81. makeProjectKey(historyId, '')
  82. )
  83. const blobKey = makeProjectKey(historyId, blobHash)
  84. return {
  85. stream: await backupPersistorForProject.getObjectStream(
  86. projectBlobsBucket,
  87. blobKey,
  88. { autoGunzip: true }
  89. ),
  90. metadata: { hash: blobHash },
  91. source: 'remote backup',
  92. }
  93. }
  94. async function fetchBlobLocal(historyId, blobHash) {
  95. const blobStore = new BlobStore(historyId)
  96. const blob = await blobStore.getBlob(blobHash)
  97. if (!blob) throw new Error(`Blob ${blobHash} not found`)
  98. return {
  99. stream: await blobStore.getStream(blobHash),
  100. metadata: blob,
  101. source: 'local storage',
  102. }
  103. }
  104. async function displayBlobContent(filepath, metadata, source, blobHash) {
  105. console.log('Source:', source)
  106. console.log('Blob metadata:', metadata)
  107. // Compute git hash using streaming
  108. const stat = fs.statSync(filepath)
  109. const header = `blob ${stat.size}\0`
  110. const hash = createHash('sha1')
  111. hash.update(header)
  112. const hashStream = fs.createReadStream(filepath)
  113. for await (const chunk of hashStream) {
  114. hash.update(chunk)
  115. }
  116. const gitHash = hash.digest('hex')
  117. // Check content type and display preview
  118. const fd = fs.openSync(filepath, 'r')
  119. try {
  120. const headBuf = Buffer.alloc(16)
  121. const tailBuf = Buffer.alloc(16)
  122. try {
  123. // Stream through TextDecoderStream to check for valid UTF-8
  124. const textStream = fs.createReadStream(filepath)
  125. const decoder = new TextDecoder('utf-8', { fatal: true })
  126. for await (const chunk of textStream) {
  127. decoder.decode(chunk, { stream: true })
  128. }
  129. decoder.decode()
  130. // If we get here, it's valid UTF-8
  131. if (stat.size <= 1024) {
  132. console.log('Content (text):', await fs.readFileSync(filepath, 'utf8'))
  133. } else {
  134. console.log('Content (text, truncated):')
  135. console.log(` Length: ${stat.size} bytes`)
  136. fs.readSync(fd, headBuf, 0, 16, 0)
  137. fs.readSync(fd, tailBuf, 0, 16, stat.size - 16)
  138. console.log(
  139. ' Content:',
  140. headBuf.toString('utf8') +
  141. ' ...(truncated)... ' +
  142. tailBuf.toString('utf8')
  143. )
  144. }
  145. } catch (e) {
  146. // Binary content - show head and tail
  147. console.log('Content (binary):')
  148. console.log(` Length: ${stat.size} bytes`)
  149. if (stat.size <= 32) {
  150. // Small file - read it all
  151. const buf = Buffer.alloc(stat.size)
  152. fs.readSync(fd, buf, 0, stat.size, 0)
  153. const hexBytes = buf.toString('hex').match(/../g).join(' ')
  154. console.log(' Bytes:', hexBytes)
  155. } else {
  156. // Read tail for large files
  157. fs.readSync(fd, headBuf, 0, 16, 0)
  158. fs.readSync(fd, tailBuf, 0, 16, stat.size - 16)
  159. const headHex = headBuf.toString('hex').match(/../g).join(' ')
  160. const tailHex = tailBuf.toString('hex').match(/../g).join(' ')
  161. console.log(' Bytes:', headHex + ' ... ' + tailHex)
  162. }
  163. console.log(' Git-style SHA1:', gitHash)
  164. if (gitHash !== blobHash) {
  165. console.log(' Warning: Git hash differs from blob hash!\x1b[0m')
  166. console.log(' Blob hash:', blobHash)
  167. }
  168. }
  169. } finally {
  170. fs.closeSync(fd)
  171. }
  172. }
  173. async function withTempDir(prefix, fn, options = {}) {
  174. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
  175. try {
  176. return await Promise.resolve(fn(tmpDir))
  177. } finally {
  178. if (!options.keep) {
  179. fs.rmSync(tmpDir, { recursive: true, force: true })
  180. } else {
  181. console.log('Keeping temporary file:', path.join(tmpDir, 'blob'))
  182. }
  183. }
  184. }
  185. async function displayBlob(historyId, blobHash, options) {
  186. try {
  187. const { stream, metadata, source } = await (options.remote
  188. ? fetchBlobRemote(historyId, blobHash)
  189. : fetchBlobLocal(historyId, blobHash))
  190. await withTempDir(
  191. 'blob-show-',
  192. async tmpDir => {
  193. const tmpPath = path.join(tmpDir, 'blob')
  194. await pipeline(stream, fs.createWriteStream(tmpPath))
  195. await displayBlobContent(tmpPath, metadata, source, blobHash)
  196. },
  197. { keep: options.keep }
  198. )
  199. } catch (err) {
  200. if (err.code === 'NoSuchKey') {
  201. throw new Error(`Blob ${blobHash} not found in backup`)
  202. }
  203. throw err
  204. }
  205. }
  206. async function main() {
  207. const { historyId, version, blob, remote, keep } =
  208. commandLineArgs(optionDefinitions)
  209. if (!historyId) {
  210. console.error('Error: --historyId is required.')
  211. process.exit(1)
  212. }
  213. await loadGlobalBlobs()
  214. if (version != null) {
  215. await displayChunk(historyId, version, { remote })
  216. } else if (blob != null) {
  217. await displayBlob(historyId, blob, { remote, keep })
  218. } else {
  219. await listChunks(historyId)
  220. }
  221. }
  222. main()
  223. .then(() => console.log('Done.'))
  224. .catch(err => {
  225. console.error('Error:', err)
  226. process.exit(1)
  227. })
  228. .finally(() => {
  229. knex.destroy().catch(err => console.error('Error closing Postgres:', err))
  230. client.close().catch(err => console.error('Error closing MongoDB:', err))
  231. })